deepseek-harness / patch-dsh.mjs
cjovs's picture
Fix dsh-local-project plugin boot crash (Dynamic require of events) + re-patch every start
6a72828
Raw
History Blame Contribute Delete
12.8 kB
#!/usr/bin/env node
/**
* 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().");
}