Spaces:
Running
Running
| /** | |
| * patch-dsh.mjs | |
| * | |
| * HF Space persistent storage (/data) does NOT support hard links: dsh's | |
| * JSONL session backend and attachment backend publish files with `link()` | |
| * (a hard link), which fails with `ENOTSUP` on that filesystem and breaks | |
| * every new session ("link ... ENOTSUP: operation not supported on socket"). | |
| * | |
| * dsh's own storage-json backend already uses `rename()` for atomic publish | |
| * and works fine on /data, so we replace the two `link()` publishes with | |
| * `rename()`. Both backends write the temp file in the SAME directory as the | |
| * target, so rename() is an atomic same-filesystem replace -- safe here. | |
| * | |
| * It also relaxes the credentials-file owner-only assertion in | |
| * `dsh-credentials-local` (dsh >= rc.6 hard-fails when /data/.credentials.yaml | |
| * carries group/other bits -- which the bucket filesystem cannot clear), so | |
| * boot no longer dies on that check. | |
| * | |
| * This runs AFTER `npm install -g @deepseek-ai/dsh@latest` (see entrypoint.sh), | |
| * so it patches whatever version is actually installed on this start. | |
| */ | |
| import { execFileSync } from "node:child_process"; | |
| import { existsSync } from "node:fs"; | |
| import { readFile, writeFile } from "node:fs/promises"; | |
| import { join } from "node:path"; | |
| const npmBin = process.platform === "win32" ? "npm.cmd" : "npm"; | |
| const globalRoot = execFileSync(npmBin, ["root", "-g"], { encoding: "utf8", shell: true }).trim(); | |
| // dsh ships its sub-packages nested inside its own node_modules, but a | |
| // hoisted layout is possible too. Resolve the real path to each lib/index.js. | |
| function resolvePkgIndex(pkg) { | |
| const candidates = [ | |
| join(globalRoot, "@deepseek-ai", pkg, "lib", "index.js"), | |
| join(globalRoot, "@deepseek-ai", "dsh", "node_modules", "@deepseek-ai", pkg, "lib", "index.js"), | |
| ]; | |
| return candidates.find(existsSync) ?? null; | |
| } | |
| // A user-installed profile plugin (installed into $DSH_HOME/profiles/web/...) | |
| // that ships a CJS-style esbuild bundle loaded as ESM and needs its __require | |
| // shim fixed. Returns null when the plugin is not installed. | |
| function resolveProfilePluginIndex(pkg) { | |
| const home = process.env.DSH_HOME || "/data"; | |
| const p = join(home, "profiles", "web", "node_modules", pkg, "lib", "index.js"); | |
| return existsSync(p) ? p : null; | |
| } | |
| const fixes = [ | |
| { | |
| label: "dsh-session-persistence-jsonl", | |
| file: () => resolvePkgIndex("dsh-session-persistence-jsonl"), | |
| importFrom: | |
| 'import { link, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, stat, truncate } from "node:fs/promises";', | |
| importTo: | |
| 'import { link, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, truncate } from "node:fs/promises";', | |
| callFrom: "await link(tmp, finalPath);", | |
| callTo: "await rename(tmp, finalPath);", | |
| }, | |
| { | |
| label: "dsh-attachment-local", | |
| file: () => resolvePkgIndex("dsh-attachment-local"), | |
| importFrom: 'import { chmod, link, mkdir, open, readFile, unlink } from "node:fs/promises";', | |
| importTo: 'import { chmod, link, mkdir, open, readFile, rename, unlink } from "node:fs/promises";', | |
| callFrom: "await link(temporary, target);", | |
| callTo: "await rename(temporary, target);", | |
| }, | |
| { | |
| // dsh >= rc.6 hard-fails at boot if the credentials file has any | |
| // group/other mode bits (assertOwnerOnly). HF bucket storage (/data) | |
| // reports 644 for files it cannot truly chmod, so boot always died with | |
| // 'credentials-local: ... readable beyond its owner'. This keeps the | |
| // security intent (try chmod 600, re-check) but degrades to a warning | |
| // when the filesystem cannot enforce POSIX modes -- correct for a | |
| // single-user Space behind Basic Auth on a private bucket. | |
| label: "dsh-credentials-local", | |
| file: () => resolvePkgIndex("dsh-credentials-local"), | |
| importFrom: 'import { mkdir, readFile, stat } from "node:fs/promises";', | |
| importTo: 'import { chmod, mkdir, readFile, stat } from "node:fs/promises";', | |
| callFrom: | |
| 'throw new Error(`credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 511).toString(8)}); run "chmod 600 ${filename}" before starting again`);', | |
| callTo: | |
| '\ttry {\n' + | |
| '\t\tawait chmod(filename, 384);\n' + | |
| '\t\tif (((await stat(filename)).mode & GROUP_OTHER_BITS) === 0) return;\n' + | |
| '\t} catch {}\n' + | |
| '\tconsole.warn(`[dsh-hf-patch] ${filename} has group/other-readable mode ${(mode & 511).toString(8)}; proceeding (HF bucket storage cannot always enforce POSIX modes)`);', | |
| }, | |
| { | |
| // HF bucket storage does not populate dirent types (d_type) in readdir, | |
| // so dirent.isDirectory() returns false for real directories and dsh's | |
| // session scan (listProjectDirs/listSessionDirs) returns an empty list -- | |
| // the sidebar shows no sessions after a restart. Fall back to stat() for | |
| // entries whose dirent type is UNKNOWN. stat is already imported. | |
| label: "dsh-session-persistence-jsonl:listDirs", | |
| file: () => resolvePkgIndex("dsh-session-persistence-jsonl"), | |
| callFrom: | |
| '\t\t\treturn entries.filter((e) => e.isDirectory()).map((e) => join(this.root, e.name));\n' + | |
| '\t\t} catch (error) {\n' + | |
| '\t\t\tif (isENOENT(error)) return [];\n' + | |
| '\t\t\tthrow error;\n' + | |
| '\t\t}\n' + | |
| '\t}\n' + | |
| '\t/** List session-owned directories and reject the obsolete flat-file layout. */\n' + | |
| '\tasync listSessionDirs(project, signal) {\n' + | |
| '\t\tsignal?.throwIfAborted();\n' + | |
| '\t\tconst entries = await readdir(project, { withFileTypes: true });\n' + | |
| '\t\tsignal?.throwIfAborted();\n' + | |
| '\t\tconst legacy = entries.find((entry) => entry.isFile() && (entry.name.endsWith(".jsonl") || entry.name.endsWith(".jsonl.zstd")));\n' + | |
| '\t\tif (legacy !== void 0) throw this.legacyLayout(join(project, legacy.name));\n' + | |
| '\t\treturn entries.filter((entry) => entry.isDirectory()).map((entry) => join(project, entry.name));\n' + | |
| '\t}', | |
| callTo: | |
| '\t\t\tconst dirs = [];\n' + | |
| '\t\t\tfor (const e of entries) {\n' + | |
| '\t\t\t\tsignal?.throwIfAborted();\n' + | |
| '\t\t\t\tconst p = join(this.root, e.name);\n' + | |
| '\t\t\t\tif (e.isDirectory()) { dirs.push(p); continue; }\n' + | |
| '\t\t\t\tif (e.type !== 0 || e.isFile()) continue;\n' + | |
| '\t\t\t\ttry { if ((await stat(p)).isDirectory()) dirs.push(p); } catch {}\n' + | |
| '\t\t\t}\n' + | |
| '\t\t\treturn dirs;\n' + | |
| '\t\t} catch (error) {\n' + | |
| '\t\t\tif (isENOENT(error)) return [];\n' + | |
| '\t\t\tthrow error;\n' + | |
| '\t\t}\n' + | |
| '\t}\n' + | |
| '\t/** List session-owned directories and reject the obsolete flat-file layout. */\n' + | |
| '\tasync listSessionDirs(project, signal) {\n' + | |
| '\t\tsignal?.throwIfAborted();\n' + | |
| '\t\tconst entries = await readdir(project, { withFileTypes: true });\n' + | |
| '\t\tsignal?.throwIfAborted();\n' + | |
| '\t\tconst dirs = [];\n' + | |
| '\t\tfor (const entry of entries) {\n' + | |
| '\t\t\tsignal?.throwIfAborted();\n' + | |
| '\t\t\tconst p = join(project, entry.name);\n' + | |
| '\t\t\tif (entry.isFile()) {\n' + | |
| '\t\t\t\tif (entry.name.endsWith(".jsonl") || entry.name.endsWith(".jsonl.zstd")) throw this.legacyLayout(p);\n' + | |
| '\t\t\t\tcontinue;\n' + | |
| '\t\t\t}\n' + | |
| '\t\t\tif (entry.isDirectory()) { dirs.push(p); continue; }\n' + | |
| '\t\t\tif (entry.type !== 0) continue;\n' + | |
| '\t\t\tconst s = await stat(p).catch(() => void 0);\n' + | |
| '\t\t\tif (s === void 0) continue;\n' + | |
| '\t\t\tif (s.isFile()) {\n' + | |
| '\t\t\t\tif (entry.name.endsWith(".jsonl") || entry.name.endsWith(".jsonl.zstd")) throw this.legacyLayout(p);\n' + | |
| '\t\t\t\tcontinue;\n' + | |
| '\t\t\t}\n' + | |
| '\t\t\tif (s.isDirectory()) dirs.push(p);\n' + | |
| '\t\t}\n' + | |
| '\t\treturn dirs;\n' + | |
| '\t}', | |
| }, | |
| { | |
| // exists() used open(path,"r") which on the HF bucket mount both returns | |
| // TRUE for absent paths AND materializes placeholder DIRECTORIES at the | |
| // probed name (so probing session.jsonl created a session.jsonl dir that | |
| // then tripped encodingMismatch). Switch to stat() and accept only real | |
| // files -- all dsh exists() call sites are artifact-file paths. | |
| label: "dsh-session-persistence-jsonl:exists-stat", | |
| file: () => resolvePkgIndex("dsh-session-persistence-jsonl"), | |
| callFrom: | |
| '\t\ttry {\n' + | |
| '\t\t\tawait (await open(path, "r")).close();\n' + | |
| '\t\t\treturn true;\n' + | |
| '\t\t} catch (error) {', | |
| callTo: | |
| '\t\ttry {\n' + | |
| '\t\t\tconst st = await stat(path);\n' + | |
| '\t\t\treturn st.isFile();\n' + | |
| '\t\t} catch (error) {', | |
| }, | |
| { | |
| // A stray opposite-compression artifact (e.g. an orphaned session.jsonl | |
| // next to session.jsonl.zstd) used to abort EVERY session op with | |
| // encodingMismatch. Degrade to a warning and keep using the real file. | |
| label: "dsh-session-persistence-jsonl:lenient-list", | |
| file: () => resolvePkgIndex("dsh-session-persistence-jsonl"), | |
| callFrom: | |
| '\t\t\t\tconst oppositeExists = await this.exists(opposite);\n' + | |
| '\t\t\t\tsignal?.throwIfAborted();\n' + | |
| '\t\t\t\tif (oppositeExists) throw this.encodingMismatch(opposite);', | |
| callTo: | |
| '\t\t\t\tconst oppositeExists = await this.exists(opposite);\n' + | |
| '\t\t\t\tsignal?.throwIfAborted();\n' + | |
| '\t\t\t\tif (oppositeExists) console.warn(`[dsh-hf-patch] ignoring stray opposite artifact ${opposite}`);', | |
| }, | |
| { | |
| label: "dsh-session-persistence-jsonl:lenient-findlog", | |
| file: () => resolvePkgIndex("dsh-session-persistence-jsonl"), | |
| callFrom: | |
| '\t\t\tconst oppositeExists = await this.exists(opposite);\n' + | |
| '\t\t\tsignal?.throwIfAborted();\n' + | |
| '\t\t\tif (oppositeExists) throw this.encodingMismatch(opposite);', | |
| callTo: | |
| '\t\t\tconst oppositeExists = await this.exists(opposite);\n' + | |
| '\t\t\tsignal?.throwIfAborted();\n' + | |
| '\t\t\tif (oppositeExists) console.warn(`[dsh-hf-patch] ignoring stray opposite artifact ${opposite}`);', | |
| }, | |
| { | |
| label: "dsh-session-persistence-jsonl:lenient-rootcheck", | |
| file: () => resolvePkgIndex("dsh-session-persistence-jsonl"), | |
| callFrom: | |
| '\t\t\tconst incompatible = join(dir, `session${logSuffix(this.oppositeCompression())}`);\n' + | |
| '\t\t\tif (await this.exists(incompatible)) throw this.encodingMismatch(incompatible);', | |
| callTo: | |
| '\t\t\tconst incompatible = join(dir, `session${logSuffix(this.oppositeCompression())}`);\n' + | |
| '\t\t\tif (await this.exists(incompatible)) console.warn(`[dsh-hf-patch] ignoring stray opposite artifact ${incompatible}`);', | |
| }, | |
| { | |
| // User plugin dsh-local-project (github:w769721503/dsh-local-project) is an | |
| // esbuild CJS-style bundle loaded as ESM; its __require shim throws for node | |
| // built-ins like "events", crashing the whole plugin tree at boot (502). | |
| // Fall back to createRequire via process.getBuiltinModule (Node >=22.3) so | |
| // built-ins resolve; optional ws natives (utf-8-validate/bufferutil) still | |
| // throw inside their own try/catch and are ignored. | |
| label: "dsh-local-project:esm-require", | |
| file: () => resolveProfilePluginIndex("dsh-local-project"), | |
| callFrom: | |
| " throw Error('Dynamic require of \"' + x + '\" is not supported');\n});", | |
| callTo: | |
| ' var __dshReq = process && typeof process.getBuiltinModule === "function" ? process.getBuiltinModule("node:module").createRequire(import.meta.url) : null;\n' + | |
| ' if (__dshReq) return __dshReq(x);\n' + | |
| " throw Error('Dynamic require of \"' + x + '\" is not supported');\n});", | |
| }, | |
| ]; | |
| let failed = false; | |
| for (const fix of fixes) { | |
| const filePath = fix.file(); | |
| if (!filePath) { | |
| console.warn(`[patch-dsh] SKIP (package not found): ${fix.label}`); | |
| failed = true; | |
| continue; | |
| } | |
| let source; | |
| try { | |
| source = await readFile(filePath, "utf8"); | |
| } catch (error) { | |
| console.warn(`[patch-dsh] SKIP (cannot read) ${filePath}: ${error.message}`); | |
| failed = true; | |
| continue; | |
| } | |
| const hadImport = fix.importFrom === void 0 || source.includes(fix.importFrom); | |
| const hadCall = source.includes(fix.callFrom); | |
| if (fix.importFrom !== void 0 && hadImport) source = source.replace(fix.importFrom, fix.importTo); | |
| if (hadCall) source = source.replace(fix.callFrom, fix.callTo); | |
| await writeFile(filePath, source); | |
| if (hadImport && hadCall) { | |
| console.log(`[patch-dsh] OK link() -> rename() patched: ${filePath}`); | |
| } else { | |
| console.warn( | |
| `[patch-dsh] PARTIAL (import=${hadImport} call=${hadCall}) -- upstream layout may have changed: ${filePath}`, | |
| ); | |
| failed = true; | |
| } | |
| } | |
| if (failed) { | |
| console.error("[patch-dsh] One or more patches did not fully apply; inspect logs."); | |
| process.exitCode = 1; | |
| } else { | |
| console.log("[patch-dsh] All hard-link publishes replaced with rename()."); | |
| } | |