@1e0zj/dsh-plugin-mall 0.1.18 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -5
- package/package.json +6 -2
- package/src/cli.js +1638 -0
- package/src/client.js +213 -57
- package/src/github.js +71 -3
- package/src/guard.js +2413 -0
- package/src/index.js +1650 -88
- package/src/installer.js +1585 -77
package/src/cli.js
ADDED
|
@@ -0,0 +1,1638 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Standalone, host-independent CLI for the dsh plugin conflict guard.
|
|
3
|
+
//
|
|
4
|
+
// This file deliberately imports nothing from the host framework
|
|
5
|
+
// (@deepseek-ai/*). It only talks to guard.js — which is equally
|
|
6
|
+
// host-independent — so it keeps working even when dsh itself cannot boot far
|
|
7
|
+
// enough to reach the marketplace plugin's `apply`. That makes it the recovery
|
|
8
|
+
// path for a bad plugin install: the same checks that would have refused the
|
|
9
|
+
// install can roll the profile back from a plain terminal.
|
|
10
|
+
//
|
|
11
|
+
// It ships as the package bin `dsh-plugin-guard` and can also be run by path:
|
|
12
|
+
//
|
|
13
|
+
// dsh-plugin-guard guard launch --profile web -- dsh web
|
|
14
|
+
// node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard recover
|
|
15
|
+
//
|
|
16
|
+
// Commands:
|
|
17
|
+
// guard validate <profileDir> validate a profile as it sits on disk
|
|
18
|
+
// guard recover [profileDir] consume pending state: validate, commit or roll back
|
|
19
|
+
// guard list [--home <dir>] list pending install markers
|
|
20
|
+
// guard add <spec> --profile <name> guarded `dsh plugin add` wrapper
|
|
21
|
+
// guard remove <package> --profile <name> transactional guarded removal
|
|
22
|
+
// guard launch --profile <name> -- <cmd...> start a command under startup probation
|
|
23
|
+
// guard self-test offline fixtures (no network, no pnpm/dsh)
|
|
24
|
+
//
|
|
25
|
+
// A leading `guard` token is optional (`node cli.js recover` works too).
|
|
26
|
+
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import {
|
|
29
|
+
existsSync,
|
|
30
|
+
lstatSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
mkdtempSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
realpathSync,
|
|
35
|
+
rmSync,
|
|
36
|
+
symlinkSync,
|
|
37
|
+
writeFileSync,
|
|
38
|
+
} from "node:fs";
|
|
39
|
+
import { tmpdir } from "node:os";
|
|
40
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
import {
|
|
43
|
+
commitPendingSnapshot,
|
|
44
|
+
createProfileSnapshot,
|
|
45
|
+
listPendingSnapshots,
|
|
46
|
+
markPendingSnapshot,
|
|
47
|
+
pnpmGuardEnv,
|
|
48
|
+
preflightInstall,
|
|
49
|
+
readPendingSnapshot,
|
|
50
|
+
recoverAll,
|
|
51
|
+
recoverProfile,
|
|
52
|
+
resolveDshHome,
|
|
53
|
+
rollbackPendingSnapshot,
|
|
54
|
+
validateInstalledProfile,
|
|
55
|
+
validateRemoveCompletion,
|
|
56
|
+
} from "./guard.js";
|
|
57
|
+
// github.js imports node builtins only — the host-independence of this CLI is
|
|
58
|
+
// preserved (it must keep working when the dsh host itself is broken).
|
|
59
|
+
import { npmPackageInfo } from "./github.js";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pin a bare package name to name@latest: pnpm's minimumReleaseAge policy
|
|
63
|
+
* otherwise silently falls back to an older "installable" release, so
|
|
64
|
+
* `add somepkg` can install yesterday's version. Anything already carrying a
|
|
65
|
+
* version/tag, or a github:/file:/link: spec, passes through untouched; a
|
|
66
|
+
* registry failure keeps the bare spec rather than blocking the install.
|
|
67
|
+
*/
|
|
68
|
+
export async function pinSpecToLatest(spec, npmInfo = npmPackageInfo) {
|
|
69
|
+
if (!/^(@[^@/\s]+\/)?[^@/\s]+$/.test(spec)) return spec;
|
|
70
|
+
try {
|
|
71
|
+
const info = await npmInfo(spec);
|
|
72
|
+
if (info?.latest) return `${spec}@${info.latest}`;
|
|
73
|
+
} catch { /* offline or registry failure — keep the bare spec */ }
|
|
74
|
+
return spec;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── small helpers ────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
// Same shell-metacharacter blocklist as installer.assertSafeSpec. The install
|
|
80
|
+
// outer process uses shell:false (node.exe + official CLI entry + plain argv),
|
|
81
|
+
// but current official DSH forwards plugin argv through cmd.exe on Windows.
|
|
82
|
+
// Keep cmd expansion/separator characters out at this boundary too.
|
|
83
|
+
const UNSAFE_SPEC_RE = /[;&|`$()<>^%!"*\n\r]/;
|
|
84
|
+
const NPM_PACKAGE_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
|
|
85
|
+
|
|
86
|
+
function assertSafeSpec(spec) {
|
|
87
|
+
const value = String(spec ?? "");
|
|
88
|
+
if (UNSAFE_SPEC_RE.test(value)) {
|
|
89
|
+
throw new Error(`spec contains characters that are not allowed in an install spec: ${JSON.stringify(value)}`);
|
|
90
|
+
}
|
|
91
|
+
if (process.platform === "win32" && /\s/.test(value)) {
|
|
92
|
+
throw new Error(`install specs cannot contain whitespace on Windows — official DSH currently forwards pnpm argv through cmd, which would split one spec into multiple arguments: ${JSON.stringify(value)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function assertSafePackageName(packageName) {
|
|
97
|
+
const value = String(packageName ?? "");
|
|
98
|
+
if (value.startsWith("-") || !NPM_PACKAGE_NAME_RE.test(value)) {
|
|
99
|
+
throw new Error(`invalid package name ${JSON.stringify(value)} — remove accepts one exact npm package name`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Strict profile name rule for the install path. The name travels as plain
|
|
106
|
+
* argv (shell:false), so metacharacters can no longer execute — but they are
|
|
107
|
+
* still refused outright so a hostile or mistyped name fails loudly instead of
|
|
108
|
+
* silently addressing an unexpected profile directory.
|
|
109
|
+
*/
|
|
110
|
+
const SAFE_PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
111
|
+
// Windows reserves these device basenames even when followed by an extension
|
|
112
|
+
// (CON.txt still addresses the CON device).
|
|
113
|
+
const WINDOWS_DEVICE_BASENAME_RE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
114
|
+
|
|
115
|
+
function assertSafeProfileName(profile) {
|
|
116
|
+
const value = String(profile ?? "");
|
|
117
|
+
if (!SAFE_PROFILE_NAME_RE.test(value)) {
|
|
118
|
+
throw new Error(`invalid profile name ${JSON.stringify(value)} — only letters, digits, '.', '_' and '-' are allowed, starting with a letter or digit`);
|
|
119
|
+
}
|
|
120
|
+
if (process.platform === "win32") {
|
|
121
|
+
// A trailing dot/space aliases the trimmed name in the Windows filesystem
|
|
122
|
+
// but not in the pending-marker filename (pending-web..json vs
|
|
123
|
+
// pending-web.json), which would split profile state across two names.
|
|
124
|
+
if (/[. ]$/.test(value)) {
|
|
125
|
+
throw new Error(`invalid profile name ${JSON.stringify(value)} — Windows profile names must not end in a dot or space (on disk it would alias ${JSON.stringify(value.replace(/[. ]+$/, ""))} while using a different pending filename)`);
|
|
126
|
+
}
|
|
127
|
+
const deviceBase = value.replace(/\..*$/, "");
|
|
128
|
+
if (WINDOWS_DEVICE_BASENAME_RE.test(deviceBase)) {
|
|
129
|
+
throw new Error(`invalid profile name ${JSON.stringify(value)} — ${JSON.stringify(deviceBase.toUpperCase())} is a reserved Windows device name (even with an extension)`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `<home>/profiles/<name>`, without importing the host's profile resolver. */
|
|
135
|
+
function profileDirOf(home, profile) {
|
|
136
|
+
const name = String(profile ?? "").trim();
|
|
137
|
+
if (name.length === 0) throw new Error("a profile name is required");
|
|
138
|
+
assertSafeProfileName(name);
|
|
139
|
+
return join(home, "profiles", name);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderIssue(entry) {
|
|
143
|
+
const badge = entry.severity === "block" ? "BLOCK" : "WARN";
|
|
144
|
+
return ` [${badge}] ${entry.title}${entry.detail ? `: ${entry.detail}` : ""}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A bad invocation (wrong/missing argument) — reported as exit code 2. */
|
|
148
|
+
class UsageError extends Error {}
|
|
149
|
+
function usageError(message) {
|
|
150
|
+
return new UsageError(message);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Spawn a command, forwarding stdout/stderr live while capturing them. Always
|
|
155
|
+
* shell:false — callers pass a resolved executable (process.execPath) plus
|
|
156
|
+
* plain argv, so no token is ever shell-interpreted, on any platform.
|
|
157
|
+
* `env`, when given, replaces the child environment wholesale (callers pass a
|
|
158
|
+
* merged object such as pnpmGuardEnv()); it defaults to process.env.
|
|
159
|
+
*/
|
|
160
|
+
function runCapture(command, args, env) {
|
|
161
|
+
return new Promise((resolvePromise) => {
|
|
162
|
+
const chunks = [];
|
|
163
|
+
const capture = (stream, data) => {
|
|
164
|
+
const text = data.toString();
|
|
165
|
+
chunks.push(text);
|
|
166
|
+
stream.write(text);
|
|
167
|
+
};
|
|
168
|
+
let child;
|
|
169
|
+
try {
|
|
170
|
+
child = spawn(command, args, {
|
|
171
|
+
env: env === undefined ? process.env : env,
|
|
172
|
+
shell: false,
|
|
173
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
174
|
+
windowsHide: true,
|
|
175
|
+
});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
resolvePromise({ exitCode: 1, output: "", error });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
child.stdout?.on("data", (data) => capture(process.stdout, data));
|
|
181
|
+
child.stderr?.on("data", (data) => capture(process.stderr, data));
|
|
182
|
+
child.on("error", (error) => resolvePromise({ exitCode: 1, output: chunks.join(""), error }));
|
|
183
|
+
child.on("close", (exitCode) => resolvePromise({ exitCode: exitCode ?? 1, output: chunks.join("") }));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const IGNORED_BUILDS_RE = /(?:Ignored build scripts|onlyBuiltDependencies)\s*:/i;
|
|
188
|
+
|
|
189
|
+
// ── official dsh CLI resolution (no shell, no shims) ────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Locate the `dsh` shim on PATH without a shell. Returns undefined when there
|
|
193
|
+
* is no `dsh` command; never returns the bare name (unresolvable names fail
|
|
194
|
+
* closed in resolveDshCliEntry instead of reaching spawn).
|
|
195
|
+
*/
|
|
196
|
+
function resolveDshShim() {
|
|
197
|
+
if (process.platform === "win32") {
|
|
198
|
+
const resolved = resolveWindowsCommand("dsh");
|
|
199
|
+
// resolveWindowsCommand returns the name unchanged when nothing matches.
|
|
200
|
+
return /[\\/]/.test(resolved) || /^[a-zA-Z]:/.test(resolved) ? resolved : undefined;
|
|
201
|
+
}
|
|
202
|
+
for (const dir of (envValue("PATH") ?? "").split(":")) {
|
|
203
|
+
if (dir.length === 0) continue;
|
|
204
|
+
const full = join(dir, "dsh");
|
|
205
|
+
if (existsSync(full)) return full;
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Extract the `.../node_modules/@deepseek-ai/dsh/...` package root out of an
|
|
212
|
+
* entry path (any separator style), so the package identity can be verified
|
|
213
|
+
* against its manifest instead of trusting the path text.
|
|
214
|
+
*/
|
|
215
|
+
function packageRootFromEntry(entryPath) {
|
|
216
|
+
const parts = String(entryPath).split(/[\\/]+/);
|
|
217
|
+
for (let index = parts.length - 3; index >= 0; index--) {
|
|
218
|
+
if (
|
|
219
|
+
parts[index].toLowerCase() === "node_modules" &&
|
|
220
|
+
parts[index + 1].toLowerCase() === "@deepseek-ai" &&
|
|
221
|
+
parts[index + 2].toLowerCase() === "dsh"
|
|
222
|
+
) {
|
|
223
|
+
return parts.slice(0, index + 3).join(sep);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Verify that pkgRoot is the official @deepseek-ai/dsh package and return the
|
|
231
|
+
* absolute path of its `dsh` bin entry — an existing .js file strictly inside
|
|
232
|
+
* the package. Anything else (wrong name, missing manifest, bin escaping the
|
|
233
|
+
* package, non-JS entry) returns undefined so the caller fails closed.
|
|
234
|
+
*/
|
|
235
|
+
function officialDshEntryFromRoot(pkgRoot) {
|
|
236
|
+
const manifestPath = join(pkgRoot, "package.json");
|
|
237
|
+
if (!existsSync(manifestPath)) return undefined;
|
|
238
|
+
let manifest;
|
|
239
|
+
try {
|
|
240
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
241
|
+
} catch {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
if (manifest?.name !== "@deepseek-ai/dsh") return undefined;
|
|
245
|
+
const binRel = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.dsh;
|
|
246
|
+
if (typeof binRel !== "string" || binRel.length === 0) return undefined;
|
|
247
|
+
const root = resolve(pkgRoot);
|
|
248
|
+
const entry = resolve(root, binRel);
|
|
249
|
+
const rel = relative(root, entry);
|
|
250
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return undefined; // bin escapes the package
|
|
251
|
+
if (!/\.js$/i.test(entry)) return undefined;
|
|
252
|
+
if (!existsSync(entry)) return undefined;
|
|
253
|
+
const realRoot = realpathSync(root);
|
|
254
|
+
const realEntry = realpathSync(entry);
|
|
255
|
+
const realRel = relative(realRoot, realEntry);
|
|
256
|
+
if (realRel === ".." || realRel.startsWith(`..${sep}`) || isAbsolute(realRel)) return undefined;
|
|
257
|
+
if (!lstatSync(realEntry).isFile()) return undefined;
|
|
258
|
+
return realEntry;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Read the shim script and pull out the official entry path it references
|
|
263
|
+
* (`"%dp0%\node_modules\@deepseek-ai\dsh\lib\bin.js"` in the npm .cmd shim,
|
|
264
|
+
* `"$basedir/node_modules/..."` in the POSIX one, pnpm-style absolute paths).
|
|
265
|
+
* Covers layouts the fixed probes below do not.
|
|
266
|
+
*/
|
|
267
|
+
function entryFromShimText(shim, binDir) {
|
|
268
|
+
let text;
|
|
269
|
+
try {
|
|
270
|
+
text = readFileSync(shim, "utf8").slice(0, 65536);
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
const match = /["']([^"'\r\n]*@deepseek-ai[\\/]dsh[\\/][^"'\r\n]*?\.js)["']/.exec(text);
|
|
275
|
+
if (match === null) return undefined;
|
|
276
|
+
let entry = match[1];
|
|
277
|
+
if (entry.startsWith("%dp0%")) entry = binDir + entry.slice("%dp0%".length);
|
|
278
|
+
else if (entry.startsWith("$basedir")) entry = binDir + entry.slice("$basedir".length);
|
|
279
|
+
return entry;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** True when candidate resolves to this very cli.js — never "the dsh CLI". */
|
|
283
|
+
function entryIsSelf(entry) {
|
|
284
|
+
try {
|
|
285
|
+
const a = realpathSync(entry);
|
|
286
|
+
const b = realpathSync(fileURLToPath(import.meta.url));
|
|
287
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
288
|
+
} catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Resolve the official @deepseek-ai/dsh CLI entry to an absolute .js file, so
|
|
295
|
+
* the guarded install can run as `process.execPath <entry> ...` with
|
|
296
|
+
* shell:false — no cmd.exe, and no dsh.cmd/ds.cmd (or this guard's own) shim
|
|
297
|
+
* in the middle. Resolution never executes anything: it finds the `dsh`
|
|
298
|
+
* command on PATH, then verifies the @deepseek-ai/dsh package behind it
|
|
299
|
+
* (manifest name + a bin entry confined to the package). Throws — failing
|
|
300
|
+
* closed, with no shell:true or unguarded fallback — when no verified entry
|
|
301
|
+
* can be found.
|
|
302
|
+
*/
|
|
303
|
+
function resolveDshCliEntry() {
|
|
304
|
+
const shim = resolveDshShim();
|
|
305
|
+
if (shim === undefined) {
|
|
306
|
+
throw new Error("cannot resolve the official dsh CLI: no `dsh` command on PATH — install @deepseek-ai/dsh first. Refusing to fall back to a shell or an unguarded install.");
|
|
307
|
+
}
|
|
308
|
+
const binDir = dirname(shim);
|
|
309
|
+
const roots = [];
|
|
310
|
+
try {
|
|
311
|
+
// POSIX npm links <prefix>/bin/dsh as a symlink straight to the entry.
|
|
312
|
+
const real = realpathSync(shim);
|
|
313
|
+
if (/\.js$/i.test(real)) {
|
|
314
|
+
const root = packageRootFromEntry(real);
|
|
315
|
+
if (root !== undefined) roots.push(root);
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// Unreadable shim — fall through to the other probes.
|
|
319
|
+
}
|
|
320
|
+
const fromText = entryFromShimText(shim, binDir);
|
|
321
|
+
if (fromText !== undefined) {
|
|
322
|
+
const root = packageRootFromEntry(fromText);
|
|
323
|
+
if (root !== undefined) roots.push(root);
|
|
324
|
+
}
|
|
325
|
+
// npm global layouts: modules sit next to the bin dir (Windows) or under
|
|
326
|
+
// <prefix>/lib (POSIX).
|
|
327
|
+
roots.push(join(binDir, "node_modules", "@deepseek-ai", "dsh"));
|
|
328
|
+
roots.push(resolve(binDir, "..", "lib", "node_modules", "@deepseek-ai", "dsh"));
|
|
329
|
+
for (const root of roots) {
|
|
330
|
+
const entry = officialDshEntryFromRoot(root);
|
|
331
|
+
if (entry !== undefined && !entryIsSelf(entry)) return entry;
|
|
332
|
+
}
|
|
333
|
+
throw new Error(`cannot verify the official @deepseek-ai/dsh CLI entry behind the dsh command at ${shim} — refusing to run the install through a shell or skip the guard`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Plain argv for the wrapped official command — every token travels verbatim. */
|
|
337
|
+
function dshPluginAddArgv(dshEntry, profile, spec) {
|
|
338
|
+
return [dshEntry, "plugin", "--profile", profile, "add", spec];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Fixed official remove argv; lifecycle scripts stay disabled during escape. */
|
|
342
|
+
function dshPluginRemoveArgv(dshEntry, profile, packageName) {
|
|
343
|
+
return [dshEntry, "plugin", "--profile", profile, "remove", packageName, "--config.ignore-scripts=true"];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Keep the official child on the exact home whose profile was snapshotted. */
|
|
347
|
+
function officialDshEnv(home, { ignoreScripts = false } = {}) {
|
|
348
|
+
const base = {};
|
|
349
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
350
|
+
if (key.toUpperCase() !== "DSH_HOME") base[key] = value;
|
|
351
|
+
}
|
|
352
|
+
base.DSH_HOME = resolve(home);
|
|
353
|
+
const env = pnpmGuardEnv(base);
|
|
354
|
+
if (ignoreScripts) {
|
|
355
|
+
env.npm_config_ignore_scripts = "true";
|
|
356
|
+
env.NPM_CONFIG_IGNORE_SCRIPTS = "true";
|
|
357
|
+
}
|
|
358
|
+
return env;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── command handlers ─────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
function cmdValidate(profileDir) {
|
|
364
|
+
const result = validateInstalledProfile(profileDir);
|
|
365
|
+
console.log(`profile: ${profileDir}`);
|
|
366
|
+
console.log(`verdict: ${result.verdict}`);
|
|
367
|
+
for (const entry of result.issues) console.log(renderIssue(entry));
|
|
368
|
+
console.log(result.summary);
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function cmdRecover({ home, profileDir }) {
|
|
373
|
+
const results = profileDir !== undefined
|
|
374
|
+
? [recoverProfile(profileDir)]
|
|
375
|
+
: recoverAll(home);
|
|
376
|
+
for (const entry of results) {
|
|
377
|
+
const scope = entry.profileDir ?? profileDir ?? home;
|
|
378
|
+
if (entry.action === "committed") {
|
|
379
|
+
console.log(`committed ${scope}`);
|
|
380
|
+
} else if (entry.action === "rolled-back") {
|
|
381
|
+
console.log(`ROLLED BACK ${scope}: ${(entry.issues ?? []).map((issueEntry) => issueEntry.title).join("; ") || "profile would not load"}`);
|
|
382
|
+
if (entry.removed?.length) console.log(` removed from node_modules: ${entry.removed.join(", ")}`);
|
|
383
|
+
} else if (entry.action === "none") {
|
|
384
|
+
console.log(`no pending ${scope}`);
|
|
385
|
+
} else {
|
|
386
|
+
console.log(`error ${scope}: ${entry.error ?? "unknown error"}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return results;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function cmdList(home) {
|
|
393
|
+
const entries = listPendingSnapshots(home);
|
|
394
|
+
if (entries.length === 0) {
|
|
395
|
+
console.log("no pending install markers");
|
|
396
|
+
return entries;
|
|
397
|
+
}
|
|
398
|
+
for (const entry of entries) {
|
|
399
|
+
if (entry.error !== undefined) {
|
|
400
|
+
// listPendingSnapshots reports a corrupt marker as {error, markerPath}
|
|
401
|
+
// (no profileDir/spec/pendingAt) and leaves the file on disk.
|
|
402
|
+
console.log(`${entry.markerPath} CORRUPT MARKER (left untouched for manual inspection): ${entry.error}`);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const spec = entry.spec ?? entry.preflight?.candidate?.name ?? entry.candidate?.name ?? "(unknown)";
|
|
406
|
+
console.log(`${entry.profileDir} spec=${spec} pendingAt=${new Date(entry.pendingAt ?? entry.createdAt ?? 0).toISOString()}`);
|
|
407
|
+
}
|
|
408
|
+
return entries;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function cmdAdd({ spec, profile, home, acceptWarnings }) {
|
|
412
|
+
assertSafeProfileName(profile);
|
|
413
|
+
const profileDir = profileDirOf(home, profile);
|
|
414
|
+
if (!existsSync(join(profileDir, "package.json"))) {
|
|
415
|
+
throw new Error(`profile "${profile}" has no package.json (${profileDir}) — create it first with \`dsh plugin --profile ${profile} add <spec>\`, or pick an existing profile`);
|
|
416
|
+
}
|
|
417
|
+
if (readPendingSnapshot(profileDir) !== undefined) {
|
|
418
|
+
throw new Error(`profile "${profile}" already has a pending install awaiting recovery — run \`node src/cli.js guard recover\` first`);
|
|
419
|
+
}
|
|
420
|
+
assertSafeSpec(spec);
|
|
421
|
+
const pinned = await pinSpecToLatest(spec);
|
|
422
|
+
if (pinned !== spec) {
|
|
423
|
+
console.log(`[guard] resolved ${spec} → ${pinned} (pinning latest so pnpm's minimumReleaseAge cannot pick an older release)`);
|
|
424
|
+
spec = pinned;
|
|
425
|
+
}
|
|
426
|
+
// Resolve the official CLI entry up front (fail closed when it cannot be
|
|
427
|
+
// verified): the install below runs `node <entry> plugin --profile <name>
|
|
428
|
+
// add <spec>` with shell:false — no dsh.cmd/ds.cmd shim, no cmd.exe, and no
|
|
429
|
+
// token ever reaches a command line.
|
|
430
|
+
const dshEntry = resolveDshCliEntry();
|
|
431
|
+
|
|
432
|
+
// 1. Isolated preflight: install the candidate with scripts disabled into a
|
|
433
|
+
// throwaway directory and scan it against the live profile. The profile is
|
|
434
|
+
// still untouched at this point.
|
|
435
|
+
console.log(`[guard] preflight: probing ${spec} in an isolated directory (scripts disabled)`);
|
|
436
|
+
const preflight = await preflightInstall({ profileDir, spec });
|
|
437
|
+
console.log(`[guard] preflight verdict: ${preflight.verdict} — ${preflight.summary}`);
|
|
438
|
+
for (const entry of preflight.issues) console.log(renderIssue(entry));
|
|
439
|
+
if (preflight.verdict === "blocked") {
|
|
440
|
+
throw new Error("preflight blocked the install; the live profile was not touched");
|
|
441
|
+
}
|
|
442
|
+
if (preflight.verdict === "warning" && acceptWarnings !== true) {
|
|
443
|
+
throw new Error("preflight found warnings — re-run with --accept-warnings only after you have read and accepted them");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// 2. Snapshot the four profile files and register the pending marker BEFORE
|
|
447
|
+
// the install runs, so an interruption mid-install still leaves a
|
|
448
|
+
// recoverable marker rather than a half-written profile.
|
|
449
|
+
const snapshot = createProfileSnapshot(profileDir, { spec });
|
|
450
|
+
markPendingSnapshot(snapshot, { spec, preflight });
|
|
451
|
+
|
|
452
|
+
// 3. Run the official command this wraps: node.exe + the verified official
|
|
453
|
+
// CLI entry + plain argv (shell:false). It performs pnpm add + the bundle
|
|
454
|
+
// layer / client-row reconcile that dsh would do anyway. Peer auto-install
|
|
455
|
+
// is disabled through the environment so the nested pnpm never pulls the
|
|
456
|
+
// @deepseek-ai host peer stack into the profile.
|
|
457
|
+
console.log(`[guard] running: dsh plugin --profile ${profile} add ${spec}`);
|
|
458
|
+
const result = await runCapture(process.execPath, dshPluginAddArgv(dshEntry, profile, spec), officialDshEnv(home));
|
|
459
|
+
|
|
460
|
+
// 4. Validate what is now on disk. A clear compose-blocking problem is
|
|
461
|
+
// rolled back immediately; otherwise the marker stays pending and the next
|
|
462
|
+
// dsh startup (or `guard recover`) commits it once the plugin actually
|
|
463
|
+
// loads.
|
|
464
|
+
const validation = validateInstalledProfile(profileDir);
|
|
465
|
+
if (result.exitCode === 0 && validation.ok) {
|
|
466
|
+
console.log(`[guard] installed ${spec} into profile "${profile}".`);
|
|
467
|
+
console.log("Restart dsh to load it. On the next startup — or via `node src/cli.js guard recover` — the pending snapshot is committed once the profile proves loadable; if dsh fails to boot, the same command rolls it back.");
|
|
468
|
+
return { ok: true };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
rollbackPendingSnapshot(profileDir);
|
|
472
|
+
if (result.exitCode !== 0) {
|
|
473
|
+
const tail = String(result.output ?? "").replace(/\s+/g, " ").trim().slice(-800);
|
|
474
|
+
if (IGNORED_BUILDS_RE.test(result.output ?? "")) {
|
|
475
|
+
throw new Error(`dsh plugin add failed (exit ${result.exitCode}) because pnpm blocked install scripts. Approve them yourself with \`pnpm approve-builds\` in ${profileDir}, then re-run this command. The profile was restored to its pre-install state.`);
|
|
476
|
+
}
|
|
477
|
+
throw new Error(`dsh plugin add failed (exit ${result.exitCode}); the profile was restored to its pre-install state.${tail ? ` Output: ${tail}` : ""}${result.error?.message ? ` (${result.error.message})` : ""}`);
|
|
478
|
+
}
|
|
479
|
+
// exit code 0 but the profile still would not compose — a patch/loader
|
|
480
|
+
// collision dsh's own add does not check for.
|
|
481
|
+
throw new Error(`install completed but the profile would not load — rolled back. ${validation.summary}\n${validation.issues.map(renderIssue).join("\n")}`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Transactional plugin removal. Unlike add, a successful static validation is
|
|
486
|
+
* committed immediately: removal cannot introduce new plugin code that needs a
|
|
487
|
+
* startup probation window. A pending install always wins and must be resolved
|
|
488
|
+
* first, so remove can never overwrite its recovery evidence.
|
|
489
|
+
*/
|
|
490
|
+
async function cmdRemove({
|
|
491
|
+
packageName,
|
|
492
|
+
profile,
|
|
493
|
+
home,
|
|
494
|
+
_resolveDsh = resolveDshCliEntry,
|
|
495
|
+
_run = runCapture,
|
|
496
|
+
_rollback = rollbackPendingSnapshot,
|
|
497
|
+
_commit = commitPendingSnapshot,
|
|
498
|
+
}) {
|
|
499
|
+
assertSafeProfileName(profile);
|
|
500
|
+
const target = assertSafePackageName(packageName);
|
|
501
|
+
const profileDir = profileDirOf(home, profile);
|
|
502
|
+
const manifestPath = join(profileDir, "package.json");
|
|
503
|
+
if (!existsSync(manifestPath)) {
|
|
504
|
+
throw new Error(`profile "${profile}" has no package.json (${profileDir})`);
|
|
505
|
+
}
|
|
506
|
+
const markerPath = join(home, "guard", `pending-${profile}.json`);
|
|
507
|
+
if (existsSync(markerPath)) {
|
|
508
|
+
throw new Error(`profile "${profile}" already has a pending transaction awaiting recovery — run \`guard recover\` before removing anything`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let manifest;
|
|
512
|
+
try {
|
|
513
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
514
|
+
} catch (error) {
|
|
515
|
+
throw new Error(`profile manifest cannot be read before remove: ${error.message}`);
|
|
516
|
+
}
|
|
517
|
+
const dependencies = manifest?.dependencies;
|
|
518
|
+
if (dependencies === null || typeof dependencies !== "object" || Array.isArray(dependencies)
|
|
519
|
+
|| !Object.prototype.hasOwnProperty.call(dependencies, target)) {
|
|
520
|
+
throw new Error(`${target} is not a direct dependency of profile "${profile}"; refusing to remove a transitive or unknown package`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Resolve before creating recovery state: a missing/unverified official CLI
|
|
524
|
+
// must leave the profile and guard directory untouched.
|
|
525
|
+
const dshEntry = _resolveDsh();
|
|
526
|
+
const snapshot = createProfileSnapshot(profileDir, { operation: "remove", packageName: target });
|
|
527
|
+
try {
|
|
528
|
+
markPendingSnapshot(snapshot, { operation: "remove", candidate: { name: target } });
|
|
529
|
+
} catch (error) {
|
|
530
|
+
rmSync(snapshot.dir, { recursive: true, force: true });
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const rollbackAndThrow = (message) => {
|
|
535
|
+
try {
|
|
536
|
+
_rollback(profileDir);
|
|
537
|
+
} catch (rollbackError) {
|
|
538
|
+
throw new Error(`${message}; rollback also failed and the pending marker was kept: ${rollbackError.message}`);
|
|
539
|
+
}
|
|
540
|
+
throw new Error(`${message}; the profile was restored to its pre-remove state`);
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
console.log(`[guard] running: dsh plugin --profile ${profile} remove ${target}`);
|
|
544
|
+
const removeEnv = officialDshEnv(home, { ignoreScripts: true });
|
|
545
|
+
let result;
|
|
546
|
+
try {
|
|
547
|
+
result = await _run(process.execPath, dshPluginRemoveArgv(dshEntry, profile, target), removeEnv);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
rollbackAndThrow(`dsh plugin remove could not be started: ${error.message}`);
|
|
550
|
+
}
|
|
551
|
+
if (result.exitCode !== 0) {
|
|
552
|
+
const tail = String(result.output ?? "").replace(/\s+/g, " ").trim().slice(-800);
|
|
553
|
+
rollbackAndThrow(`dsh plugin remove failed (exit ${result.exitCode})${tail ? `. Output: ${tail}` : ""}${result.error?.message ? ` (${result.error.message})` : ""}`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
let validation;
|
|
557
|
+
try {
|
|
558
|
+
validation = validateInstalledProfile(profileDir);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
rollbackAndThrow(`remove completed but static profile validation threw: ${error.message}`);
|
|
561
|
+
}
|
|
562
|
+
const removeValidation = validateRemoveCompletion(profileDir, target);
|
|
563
|
+
if (!validation.ok || !removeValidation.ok) {
|
|
564
|
+
const issues = [...validation.issues, ...removeValidation.issues];
|
|
565
|
+
rollbackAndThrow(`remove completed but left an unloadable or partially-reconciled profile: ${validation.summary}\n${issues.map(renderIssue).join("\n")}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
_commit(profileDir);
|
|
569
|
+
console.log(`[guard] removed ${target} from profile "${profile}"; the transaction snapshot was committed.`);
|
|
570
|
+
return { ok: true };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── launch: process spawning & startup probation ─────────────────────────────
|
|
574
|
+
|
|
575
|
+
const DEFAULT_GRACE_MS = 10000;
|
|
576
|
+
|
|
577
|
+
// cmd.exe metacharacters. Rather than "escaping" these for a cmd round trip
|
|
578
|
+
// (cmd's quoting rules are famously inconsistent), the launch wrapper refuses
|
|
579
|
+
// them outright — a dsh invocation never needs them.
|
|
580
|
+
const CMD_METACHAR_RE = /[&|<>^%!\r\n]/;
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Quote one token for a %ComSpec% /d /s /c command line. Follows the MSVCRT /
|
|
584
|
+
* CommandLineToArgvW rules (backslashes before a quote or the closing quote are
|
|
585
|
+
* doubled, quotes become \") and rejects cmd metacharacters instead of trying
|
|
586
|
+
* to escape them. The command after `--` is never concatenated unquoted.
|
|
587
|
+
*/
|
|
588
|
+
function quoteCmdArg(token) {
|
|
589
|
+
const value = String(token ?? "");
|
|
590
|
+
if (value.length === 0) return '""';
|
|
591
|
+
if (CMD_METACHAR_RE.test(value)) {
|
|
592
|
+
throw new Error(`cannot quote safely for cmd.exe (shell metacharacter present): ${JSON.stringify(value)}`);
|
|
593
|
+
}
|
|
594
|
+
const escaped = value.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1");
|
|
595
|
+
return `"${escaped}"`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Case-insensitive env lookup (Windows env keys are case-insensitive). */
|
|
599
|
+
function envValue(name) {
|
|
600
|
+
const wanted = name.toUpperCase();
|
|
601
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
602
|
+
if (key.toUpperCase() === wanted && typeof value === "string") return value;
|
|
603
|
+
}
|
|
604
|
+
return undefined;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Resolve a bare command name (no path separators, no drive prefix) to the
|
|
609
|
+
* actual executable on PATH, honoring PATHEXT — the same rule cmd.exe applies,
|
|
610
|
+
* but implemented as plain filesystem probes so no shell is involved. Explicit
|
|
611
|
+
* paths are returned untouched, and an unresolvable name is returned unchanged
|
|
612
|
+
* so spawn reports the same ENOENT it would have before.
|
|
613
|
+
*/
|
|
614
|
+
function resolveWindowsCommand(command) {
|
|
615
|
+
if (process.platform !== "win32") return command;
|
|
616
|
+
if (/[\\/]/.test(command) || /^[a-zA-Z]:/.test(command)) return command; // explicit path
|
|
617
|
+
const pathExt = (envValue("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((ext) => ext.length > 0);
|
|
618
|
+
const hasExtension = /\.[a-z0-9]+$/i.test(command);
|
|
619
|
+
// Extensionless names only resolve via PATHEXT — an extensionless file on
|
|
620
|
+
// PATH (e.g. the POSIX shim npm installs next to the .cmd) is not
|
|
621
|
+
// executable by CreateProcess, so it must not win the probe.
|
|
622
|
+
const candidates = hasExtension ? [command] : pathExt.map((ext) => command + ext);
|
|
623
|
+
const dirs = (envValue("PATH") ?? "").split(";").filter((dir) => dir.length > 0);
|
|
624
|
+
for (const dir of dirs) {
|
|
625
|
+
for (const candidate of candidates) {
|
|
626
|
+
const full = join(dir, candidate);
|
|
627
|
+
if (existsSync(full)) return full;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return command;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Spawn the command after `--` with inherited stdio. POSIX uses shell:false so
|
|
635
|
+
* the argv reaches execvp untouched. On Windows a bare name is first resolved
|
|
636
|
+
* through PATH/PATHEXT (no shell): .cmd/.bat shims cannot be exec'd directly,
|
|
637
|
+
* so they go through %ComSpec% with every token strictly quoted; .exe/.com and
|
|
638
|
+
* explicit paths spawn directly with shell:false.
|
|
639
|
+
*/
|
|
640
|
+
function spawnCommand(command, args) {
|
|
641
|
+
const resolved = process.platform === "win32" ? resolveWindowsCommand(command) : command;
|
|
642
|
+
if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(resolved)) {
|
|
643
|
+
const comspec = process.env.ComSpec ?? "cmd.exe";
|
|
644
|
+
const line = [resolved, ...args].map(quoteCmdArg).join(" ");
|
|
645
|
+
// With /s, cmd strips exactly one outer pair of quotes from the /c payload;
|
|
646
|
+
// wrap the whole line so the per-token quoting above survives intact.
|
|
647
|
+
// windowsVerbatimArguments passes the line to CreateProcess exactly as
|
|
648
|
+
// built — otherwise libuv would re-quote it for CommandLineToArgvW and the
|
|
649
|
+
// escaped quotes would break cmd's /s stripping.
|
|
650
|
+
return spawn(comspec, ["/d", "/s", "/c", `"${line}"`], { shell: false, stdio: "inherit", env: process.env, windowsVerbatimArguments: true });
|
|
651
|
+
}
|
|
652
|
+
return spawn(resolved, args, { shell: false, stdio: "inherit", env: process.env });
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Forward SIGINT/SIGTERM to the child where the platform delivers them to us. */
|
|
656
|
+
function forwardSignals(child) {
|
|
657
|
+
if (process.platform === "win32") return () => {};
|
|
658
|
+
const onSigint = () => { try { child.kill("SIGINT"); } catch { /* already gone */ } };
|
|
659
|
+
const onSigterm = () => { try { child.kill("SIGTERM"); } catch { /* already gone */ } };
|
|
660
|
+
process.on("SIGINT", onSigint);
|
|
661
|
+
process.on("SIGTERM", onSigterm);
|
|
662
|
+
return () => {
|
|
663
|
+
process.removeListener("SIGINT", onSigint);
|
|
664
|
+
process.removeListener("SIGTERM", onSigterm);
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function waitForExit(child) {
|
|
669
|
+
return new Promise((resolvePromise) => {
|
|
670
|
+
child.on("error", (error) => resolvePromise({ error }));
|
|
671
|
+
child.on("exit", (code, signal) => resolvePromise({ code, signal }));
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Convert a raw child exit into the wrapper's exit code. */
|
|
676
|
+
function exitCodeOf(result) {
|
|
677
|
+
if (typeof result.code === "number") return result.code;
|
|
678
|
+
if (result.signal === "SIGINT") return 130;
|
|
679
|
+
if (result.signal === "SIGTERM") return 143;
|
|
680
|
+
return 1;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Run the command once with no probation; resolve with its exit result. */
|
|
684
|
+
async function runPlain(command, args) {
|
|
685
|
+
const child = spawnCommand(command, args);
|
|
686
|
+
const unforward = forwardSignals(child);
|
|
687
|
+
const result = await waitForExit(child);
|
|
688
|
+
unforward();
|
|
689
|
+
return result;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Commit the pending snapshot once startup probation passes. A commit failure
|
|
694
|
+
* is a warning, not a launch failure — the process is already running and
|
|
695
|
+
* healthy, and the marker simply stays pending for the next launch.
|
|
696
|
+
*/
|
|
697
|
+
function commitLaunchSnapshot(profileDir) {
|
|
698
|
+
try {
|
|
699
|
+
commitPendingSnapshot(profileDir);
|
|
700
|
+
console.log(`[guard] startup probation passed — pending snapshot committed for ${profileDir}`);
|
|
701
|
+
} catch (error) {
|
|
702
|
+
console.error(`[guard] warning: could not commit the pending snapshot for ${profileDir}: ${error.message} — the marker stays pending`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Run the command under startup probation for a profile with a pending marker.
|
|
708
|
+
* "before-grace" means it exited/errored inside the grace window; "after-grace"
|
|
709
|
+
* means it stayed alive through it (the snapshot is committed at that point)
|
|
710
|
+
* and the wrapper kept waiting for it.
|
|
711
|
+
*/
|
|
712
|
+
async function runProbation({ profileDir, command, args, graceMs }) {
|
|
713
|
+
const child = spawnCommand(command, args);
|
|
714
|
+
const unforward = forwardSignals(child);
|
|
715
|
+
const exited = waitForExit(child);
|
|
716
|
+
let timer;
|
|
717
|
+
const grace = new Promise((resolvePromise) => { timer = setTimeout(() => resolvePromise("grace"), graceMs); });
|
|
718
|
+
const first = await Promise.race([
|
|
719
|
+
exited.then((result) => ({ phase: "before-grace", ...result })),
|
|
720
|
+
grace.then(() => ({ phase: "after-grace" })),
|
|
721
|
+
]);
|
|
722
|
+
clearTimeout(timer);
|
|
723
|
+
if (first.phase === "before-grace") {
|
|
724
|
+
unforward();
|
|
725
|
+
return first;
|
|
726
|
+
}
|
|
727
|
+
commitLaunchSnapshot(profileDir);
|
|
728
|
+
const result = await exited;
|
|
729
|
+
unforward();
|
|
730
|
+
return { phase: "after-grace", ...result };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// The four load-bearing profile files, mirroring PROFILE_FILES in guard.js.
|
|
734
|
+
const SNAPSHOT_FILES = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "cordis.patch.yml"];
|
|
735
|
+
// Pending-marker schema v2, mirroring guard.js (SNAPSHOT_VERSION,
|
|
736
|
+
// SNAPSHOT_ID_RE, NPM_PACKAGE_NAME_RE). guard.js deliberately rejects v1
|
|
737
|
+
// markers (missing dependencies / candidate identity) and leaves them on disk;
|
|
738
|
+
// this pre-launch check must match that schema exactly — never weaker.
|
|
739
|
+
const SNAPSHOT_VERSION = 2;
|
|
740
|
+
const SNAPSHOT_ID_RE = /^[0-9]+-[a-z0-9]+$/;
|
|
741
|
+
const PENDING_OPERATIONS = new Set(["install", "remove"]);
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Read-only sanity check for a pending marker before launch, mirroring guard.js
|
|
745
|
+
* v2 sanitizeSnapshot + readValidatedPendingSnapshot exactly: schema version 2,
|
|
746
|
+
* a strict snapshot id, full file metadata, the profile's original
|
|
747
|
+
* `dependencies` as a list of valid package names, and a candidate identity
|
|
748
|
+
* (`preflight.candidate.name` or `candidate.name`) that is a valid package
|
|
749
|
+
* name — plus a profileDir confined to <home>/profiles and matching the
|
|
750
|
+
* profile being launched. A marker that fails this (corrupt or legacy v1) is
|
|
751
|
+
* refused: launch fails closed and nothing on disk is touched.
|
|
752
|
+
*/
|
|
753
|
+
function markerLooksValid(marker, profileDir, home) {
|
|
754
|
+
if (marker === null || typeof marker !== "object") return false;
|
|
755
|
+
if (marker.version !== SNAPSHOT_VERSION) return false;
|
|
756
|
+
if (typeof marker.id !== "string" || !SNAPSHOT_ID_RE.test(marker.id)) return false;
|
|
757
|
+
if (marker.files === null || typeof marker.files !== "object") return false;
|
|
758
|
+
for (const name of SNAPSHOT_FILES) {
|
|
759
|
+
if (typeof marker.files[name]?.present !== "boolean") return false;
|
|
760
|
+
}
|
|
761
|
+
if (!Array.isArray(marker.dependencies) || marker.dependencies.some((name) => typeof name !== "string" || !NPM_PACKAGE_NAME_RE.test(name))) return false;
|
|
762
|
+
if (marker.metadata === null || typeof marker.metadata !== "object" || Array.isArray(marker.metadata)) return false;
|
|
763
|
+
if (!PENDING_OPERATIONS.has(marker.operation) || !PENDING_OPERATIONS.has(marker.metadata.operation)) return false;
|
|
764
|
+
if (marker.operation !== marker.metadata.operation) return false;
|
|
765
|
+
const identities = [marker.preflight?.candidate?.name, marker.candidate?.name].filter((value) => value !== undefined);
|
|
766
|
+
if (identities.length === 0 || identities.some((name) => typeof name !== "string" || !NPM_PACKAGE_NAME_RE.test(name))) return false;
|
|
767
|
+
if (identities.some((name) => name !== identities[0])) return false;
|
|
768
|
+
if (marker.operation === "remove") {
|
|
769
|
+
if (typeof marker.metadata.packageName !== "string" || !NPM_PACKAGE_NAME_RE.test(marker.metadata.packageName)) return false;
|
|
770
|
+
if (identities[0] !== marker.metadata.packageName) return false;
|
|
771
|
+
} else if (marker.metadata.packageName !== undefined) {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
if (typeof marker.profileDir !== "string") return false;
|
|
775
|
+
const samePath = (a, b) => (process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
776
|
+
const fromMarker = resolve(marker.profileDir);
|
|
777
|
+
if (!samePath(dirname(fromMarker), resolve(join(home, "profiles")))) return false;
|
|
778
|
+
if (!samePath(fromMarker, resolve(profileDir))) return false;
|
|
779
|
+
|
|
780
|
+
// Cross-check the attacker-editable marker against the transaction identity
|
|
781
|
+
// captured in snapshot.json before any profile mutation began.
|
|
782
|
+
let stored;
|
|
783
|
+
try {
|
|
784
|
+
stored = JSON.parse(readFileSync(join(home, "guard", "snapshots", marker.id, "snapshot.json"), "utf8"));
|
|
785
|
+
} catch {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
if (stored?.version !== marker.version || stored?.id !== marker.id) return false;
|
|
789
|
+
if (typeof stored?.profileDir !== "string" || !samePath(resolve(stored.profileDir), fromMarker)) return false;
|
|
790
|
+
if (stored?.metadata === null || typeof stored?.metadata !== "object" || Array.isArray(stored.metadata)) return false;
|
|
791
|
+
if (!PENDING_OPERATIONS.has(stored.operation) || stored.operation !== stored.metadata.operation) return false;
|
|
792
|
+
if (stored.operation !== marker.operation) return false;
|
|
793
|
+
if (marker.operation === "remove" && stored.metadata.packageName !== marker.metadata.packageName) return false;
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* `guard launch`: start dsh (or any command) wrapped in startup probation for
|
|
799
|
+
* the profile's pending install, if one exists.
|
|
800
|
+
*
|
|
801
|
+
* - no pending marker → run the command, preserve its exit code
|
|
802
|
+
* - pending + static block → roll back BEFORE launch, then run plainly
|
|
803
|
+
* - pending + alive through the grace window (default 10s) → commit
|
|
804
|
+
* - pending + exit 0 within grace (one-shot command) → commit
|
|
805
|
+
* - pending + nonzero/error within grace → roll back and restart the exact
|
|
806
|
+
* command once with the restored state (never loops)
|
|
807
|
+
*
|
|
808
|
+
* A corrupt or legacy (pre-v2) marker, or a failed static-recovery step, fails
|
|
809
|
+
* closed: the command is NOT launched and no unvalidated path is deleted.
|
|
810
|
+
*/
|
|
811
|
+
async function cmdLaunch({ profile, home, graceMs, commandArgv }) {
|
|
812
|
+
const profileDir = profileDirOf(home, profile);
|
|
813
|
+
const grace = graceMs ?? DEFAULT_GRACE_MS;
|
|
814
|
+
const [command, ...args] = commandArgv;
|
|
815
|
+
// Mirrors guard.js pendingPath(): <home>/guard/pending-<profile>.json.
|
|
816
|
+
const markerPath = join(home, "guard", `pending-${profile}.json`);
|
|
817
|
+
|
|
818
|
+
let pending = false;
|
|
819
|
+
if (existsSync(markerPath)) {
|
|
820
|
+
const marker = readPendingSnapshot(profileDir);
|
|
821
|
+
if (marker === undefined || !markerLooksValid(marker, profileDir, home)) {
|
|
822
|
+
throw new Error(`pending marker ${markerPath} is corrupt or from an older guard schema (v2 with dependencies and candidate identity required) — refusing to launch (left untouched for manual inspection; fix or remove it, then run \`guard recover\`)`);
|
|
823
|
+
}
|
|
824
|
+
let validation;
|
|
825
|
+
try {
|
|
826
|
+
validation = validateInstalledProfile(profileDir);
|
|
827
|
+
} catch (error) {
|
|
828
|
+
throw new Error(`static validation of profile "${profile}" failed — refusing to launch: ${error.message}`);
|
|
829
|
+
}
|
|
830
|
+
const isRemove = marker.operation === "remove";
|
|
831
|
+
const candidateName = marker.preflight?.candidate?.name ?? marker.candidate?.name;
|
|
832
|
+
const removeValidation = isRemove
|
|
833
|
+
? validateRemoveCompletion(profileDir, candidateName)
|
|
834
|
+
: { ok: true, issues: [] };
|
|
835
|
+
if (!validation.ok || !removeValidation.ok) {
|
|
836
|
+
try {
|
|
837
|
+
rollbackPendingSnapshot(profileDir);
|
|
838
|
+
} catch (error) {
|
|
839
|
+
throw new Error(`profile "${profile}" failed static validation and rollback failed — refusing to launch: ${error.message}`);
|
|
840
|
+
}
|
|
841
|
+
console.error(`[guard] profile "${profile}" failed static validation — rolled back before launch:`);
|
|
842
|
+
for (const entry of [...validation.issues, ...removeValidation.issues]) console.error(renderIssue(entry));
|
|
843
|
+
} else {
|
|
844
|
+
pending = true;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (!pending) {
|
|
849
|
+
const result = await runPlain(command, args);
|
|
850
|
+
if (result.error !== undefined) console.error(`[guard] failed to start ${command}: ${result.error.message}`);
|
|
851
|
+
return exitCodeOf(result);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const outcome = await runProbation({ profileDir, command, args, graceMs: grace });
|
|
855
|
+
if (outcome.phase === "after-grace") return exitCodeOf(outcome);
|
|
856
|
+
if (outcome.signal === "SIGINT" || outcome.signal === "SIGTERM") {
|
|
857
|
+
// Interrupted from outside (Ctrl+C / service stop): not an install failure —
|
|
858
|
+
// leave the marker pending for the next launch, propagate the convention.
|
|
859
|
+
return exitCodeOf(outcome);
|
|
860
|
+
}
|
|
861
|
+
if (outcome.error === undefined && outcome.code === 0) {
|
|
862
|
+
// One-shot command that finished successfully inside the grace window.
|
|
863
|
+
commitLaunchSnapshot(profileDir);
|
|
864
|
+
return 0;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Crashed (or failed to start) inside the grace window: the pending install
|
|
868
|
+
// is the prime suspect. Roll back and restart the exact command ONCE with
|
|
869
|
+
// the restored state; the restarted process's exit code is preserved.
|
|
870
|
+
const why = outcome.error !== undefined
|
|
871
|
+
? `failed to start (${outcome.error.message})`
|
|
872
|
+
: `exited with code ${outcome.code ?? exitCodeOf(outcome)}`;
|
|
873
|
+
try {
|
|
874
|
+
rollbackPendingSnapshot(profileDir);
|
|
875
|
+
} catch (error) {
|
|
876
|
+
throw new Error(`the command ${why} within the grace period, but rollback failed — refusing to restart: ${error.message}`);
|
|
877
|
+
}
|
|
878
|
+
console.error(`[guard] the command ${why} within the ${grace}ms grace period — profile "${profile}" rolled back, restarting once with the restored state`);
|
|
879
|
+
const retry = await runPlain(command, args);
|
|
880
|
+
if (retry.error !== undefined) console.error(`[guard] failed to restart ${command}: ${retry.error.message}`);
|
|
881
|
+
return exitCodeOf(retry);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ── argument parsing ─────────────────────────────────────────────────────────
|
|
885
|
+
|
|
886
|
+
function usage() {
|
|
887
|
+
return `dsh plugin conflict guard (host-independent CLI)
|
|
888
|
+
|
|
889
|
+
Usage:
|
|
890
|
+
node src/cli.js guard validate <profileDir>
|
|
891
|
+
node src/cli.js guard recover [profileDir] [--home <dir>]
|
|
892
|
+
node src/cli.js guard list [--home <dir>]
|
|
893
|
+
node src/cli.js guard add <spec> --profile <name> [--home <dir>] [--accept-warnings]
|
|
894
|
+
node src/cli.js guard remove <package> --profile <name> [--home <dir>]
|
|
895
|
+
node src/cli.js guard launch --profile <name> [--home <dir>] [--grace-ms <ms>] -- <command> [args...]
|
|
896
|
+
node src/cli.js guard self-test
|
|
897
|
+
|
|
898
|
+
Commands:
|
|
899
|
+
validate validate a profile as it sits on disk (no changes). Exit 0 when
|
|
900
|
+
safe or warning-only, 1 when a blocker is found.
|
|
901
|
+
recover consume pending install state: validate, then commit or roll back.
|
|
902
|
+
With no profileDir it recovers every pending profile under --home
|
|
903
|
+
(default $DSH_HOME or ~/.dsh). Exit 1 if anything was rolled back
|
|
904
|
+
or could not be processed.
|
|
905
|
+
list list the pending install markers on disk.
|
|
906
|
+
add guarded install: isolated preflight -> snapshot -> \`dsh plugin
|
|
907
|
+
--profile <name> add <spec>\` -> validate. On success the profile
|
|
908
|
+
keeps a pending marker that a \`guard launch\`-wrapped startup (or
|
|
909
|
+
\`guard recover\`) commits once the plugin actually loads.
|
|
910
|
+
remove guarded escape removal: refuse while another transaction is
|
|
911
|
+
pending, snapshot, run official dsh remove with scripts disabled,
|
|
912
|
+
statically validate, then commit; failures roll back.
|
|
913
|
+
launch start the command after \`--\` under startup probation. With no
|
|
914
|
+
pending marker the command simply runs and its exit code is
|
|
915
|
+
preserved. With a pending marker: a profile that clearly fails
|
|
916
|
+
static validation is rolled back before launch; a process that
|
|
917
|
+
stays alive through the grace period (default 10000 ms) commits
|
|
918
|
+
the pending snapshot; exit 0 inside the grace period (one-shot
|
|
919
|
+
command) also commits; a crash or nonzero exit inside the grace
|
|
920
|
+
period rolls the profile back and restarts the exact command once
|
|
921
|
+
with the restored state (never loops). A corrupt or legacy (pre-v2)
|
|
922
|
+
marker fails closed: the command is not launched and nothing is
|
|
923
|
+
deleted.
|
|
924
|
+
self-test run offline fixtures (no network, no pnpm/dsh).
|
|
925
|
+
|
|
926
|
+
Exit codes: 0 ok, 1 blocked/rolled back/failed, 2 usage error; launch preserves
|
|
927
|
+
the wrapped command's exit code.
|
|
928
|
+
`;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function parseArgs(argv) {
|
|
932
|
+
const args = [...argv];
|
|
933
|
+
if (args[0] === "guard") args.shift(); // `node cli.js guard recover` / `node cli.js recover`
|
|
934
|
+
let command = args.shift() ?? "help";
|
|
935
|
+
if (command === "--help" || command === "-h") command = "help"; // `cli.js --help`
|
|
936
|
+
const opts = { home: undefined, profile: undefined, graceMs: undefined, acceptWarnings: false, positionals: [], commandArgv: [] };
|
|
937
|
+
for (let index = 0; index < args.length; index++) {
|
|
938
|
+
const arg = args[index];
|
|
939
|
+
if (arg === "--") { opts.commandArgv = args.slice(index + 1); break; } // launch: the wrapped command, verbatim
|
|
940
|
+
if (arg === "--help" || arg === "-h") { opts.help = true; continue; }
|
|
941
|
+
if (arg === "--accept-warnings" || arg === "--acceptWarnings") { opts.acceptWarnings = true; continue; }
|
|
942
|
+
if (arg === "--home") { opts.home = args[++index]; continue; }
|
|
943
|
+
if (arg.startsWith("--home=")) { opts.home = arg.slice("--home=".length); continue; }
|
|
944
|
+
if (arg === "--profile") { opts.profile = args[++index]; continue; }
|
|
945
|
+
if (arg.startsWith("--profile=")) { opts.profile = arg.slice("--profile=".length); continue; }
|
|
946
|
+
if (arg === "--grace-ms") { opts.graceMs = args[++index]; continue; }
|
|
947
|
+
if (arg.startsWith("--grace-ms=")) { opts.graceMs = arg.slice("--grace-ms=".length); continue; }
|
|
948
|
+
if (arg === "--all") { opts.all = true; continue; }
|
|
949
|
+
if (arg.startsWith("-")) throw new Error(`unknown option ${JSON.stringify(arg)}`);
|
|
950
|
+
opts.positionals.push(arg);
|
|
951
|
+
}
|
|
952
|
+
return { command, ...opts };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// ── entry point ──────────────────────────────────────────────────────────────
|
|
956
|
+
|
|
957
|
+
async function main(argv) {
|
|
958
|
+
let parsed;
|
|
959
|
+
try {
|
|
960
|
+
parsed = parseArgs(argv);
|
|
961
|
+
} catch (error) {
|
|
962
|
+
console.error(`error: ${error.message}`);
|
|
963
|
+
console.error(usage());
|
|
964
|
+
process.exitCode = 2;
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (parsed.help || parsed.command === "help") {
|
|
968
|
+
console.log(usage());
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const home = parsed.home !== undefined ? resolve(parsed.home) : resolveDshHome();
|
|
972
|
+
try {
|
|
973
|
+
switch (parsed.command) {
|
|
974
|
+
case "validate": {
|
|
975
|
+
const profileDir = parsed.positionals[0];
|
|
976
|
+
if (profileDir === undefined) throw usageError("validate needs a <profileDir> argument (see --help)");
|
|
977
|
+
const result = cmdValidate(resolve(profileDir));
|
|
978
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
case "recover": {
|
|
982
|
+
const raw = parsed.positionals[0];
|
|
983
|
+
const profileDir = raw === undefined ? undefined : resolve(raw);
|
|
984
|
+
const results = cmdRecover({ home, profileDir });
|
|
985
|
+
const needsAttention = results.some((entry) => entry.action === "rolled-back" || entry.action === "error");
|
|
986
|
+
process.exitCode = needsAttention ? 1 : 0;
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
case "list": {
|
|
990
|
+
cmdList(home);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
case "add": {
|
|
994
|
+
const spec = parsed.positionals[0];
|
|
995
|
+
if (spec === undefined) throw usageError("add needs a <spec> argument (see --help)");
|
|
996
|
+
if (parsed.positionals.length !== 1) throw usageError("add accepts exactly one <spec>");
|
|
997
|
+
if (parsed.profile === undefined) throw usageError("add needs --profile <name> (see --help)");
|
|
998
|
+
await cmdAdd({ spec, profile: parsed.profile, home, acceptWarnings: parsed.acceptWarnings });
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
case "remove": {
|
|
1002
|
+
const packageName = parsed.positionals[0];
|
|
1003
|
+
if (packageName === undefined) throw usageError("remove needs a <package> argument (see --help)");
|
|
1004
|
+
if (parsed.positionals.length !== 1) throw usageError("remove accepts exactly one <package>");
|
|
1005
|
+
if (parsed.profile === undefined) throw usageError("remove needs --profile <name> (see --help)");
|
|
1006
|
+
if (parsed.acceptWarnings) throw usageError("remove does not accept --accept-warnings");
|
|
1007
|
+
await cmdRemove({ packageName, profile: parsed.profile, home });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
case "launch": {
|
|
1011
|
+
if (parsed.profile === undefined) throw usageError("launch needs --profile <name> (see --help)");
|
|
1012
|
+
if (parsed.commandArgv.length === 0) throw usageError("launch needs a command after `--` (see --help)");
|
|
1013
|
+
let graceMs;
|
|
1014
|
+
if (parsed.graceMs !== undefined) {
|
|
1015
|
+
graceMs = Number(parsed.graceMs);
|
|
1016
|
+
if (!Number.isFinite(graceMs) || graceMs < 0) throw usageError(`--grace-ms must be a non-negative number, got ${JSON.stringify(parsed.graceMs)}`);
|
|
1017
|
+
}
|
|
1018
|
+
process.exitCode = await cmdLaunch({ profile: parsed.profile, home, graceMs, commandArgv: parsed.commandArgv });
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
case "self-test": {
|
|
1022
|
+
await selfTest();
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
default:
|
|
1026
|
+
throw usageError(`unknown command ${JSON.stringify(parsed.command)} (see --help)`);
|
|
1027
|
+
}
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
console.error(`error: ${error.message}`);
|
|
1030
|
+
process.exitCode = error instanceof UsageError ? 2 : 1;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ── offline fixtures ─────────────────────────────────────────────────────────
|
|
1035
|
+
|
|
1036
|
+
async function selfTest() {
|
|
1037
|
+
const root = mkdtempSync(join(tmpdir(), "dsh-guard-cli-"));
|
|
1038
|
+
try {
|
|
1039
|
+
// profileDirOf resolves <home>/profiles/<name> and rejects traversal. It
|
|
1040
|
+
// applies the same strict name validation as cmdAdd/cmdLaunch.
|
|
1041
|
+
{
|
|
1042
|
+
const home = join(root, "home");
|
|
1043
|
+
if (profileDirOf(home, "web") !== join(home, "profiles", "web")) throw new Error("profileDirOf fixture failed");
|
|
1044
|
+
let threw = false;
|
|
1045
|
+
try { profileDirOf(home, "../etc"); } catch { threw = true; }
|
|
1046
|
+
if (!threw) throw new Error("profileDirOf should reject path traversal");
|
|
1047
|
+
threw = false;
|
|
1048
|
+
try { profileDirOf(home, "a&b"); } catch { threw = true; }
|
|
1049
|
+
if (!threw) throw new Error("profileDirOf should reject an unsafe profile name");
|
|
1050
|
+
if (process.platform === "win32") {
|
|
1051
|
+
threw = false;
|
|
1052
|
+
try { profileDirOf(home, "web."); } catch { threw = true; }
|
|
1053
|
+
if (!threw) throw new Error("profileDirOf should reject a trailing-dot name on Windows");
|
|
1054
|
+
threw = false;
|
|
1055
|
+
try { profileDirOf(home, "con.txt"); } catch { threw = true; }
|
|
1056
|
+
if (!threw) throw new Error("profileDirOf should reject a reserved device basename on Windows");
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// parseArgs strips a leading "guard" and reads --home/--profile/positionals.
|
|
1061
|
+
{
|
|
1062
|
+
const p1 = parseArgs(["guard", "recover", "--home", "C:\\x"]);
|
|
1063
|
+
if (p1.command !== "recover" || p1.home !== "C:\\x") throw new Error("parseArgs guard-prefix fixture failed");
|
|
1064
|
+
const p2 = parseArgs(["validate", "C:\\p"]);
|
|
1065
|
+
if (p2.command !== "validate" || p2.positionals[0] !== "C:\\p") throw new Error("parseArgs validate fixture failed");
|
|
1066
|
+
const p3 = parseArgs(["add", "@scope/pkg@1.0.0", "--profile", "web", "--accept-warnings"]);
|
|
1067
|
+
if (p3.command !== "add" || p3.profile !== "web" || p3.acceptWarnings !== true || p3.positionals[0] !== "@scope/pkg@1.0.0") throw new Error("parseArgs add fixture failed");
|
|
1068
|
+
const p4 = parseArgs(["remove", "@scope/pkg", "--profile=web"]);
|
|
1069
|
+
if (p4.command !== "remove" || p4.profile !== "web" || p4.positionals[0] !== "@scope/pkg") throw new Error("parseArgs remove fixture failed");
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// parseArgs: launch splits the wrapped command at `--` and reads --grace-ms.
|
|
1073
|
+
{
|
|
1074
|
+
const p1 = parseArgs(["guard", "launch", "--profile", "web", "--grace-ms", "500", "--", "dsh", "--profile", "web"]);
|
|
1075
|
+
if (p1.command !== "launch" || p1.profile !== "web" || p1.graceMs !== "500") throw new Error("parseArgs launch fixture failed");
|
|
1076
|
+
if (p1.commandArgv.join(" ") !== "dsh --profile web") throw new Error("parseArgs `--` split fixture failed");
|
|
1077
|
+
const p2 = parseArgs(["launch", "--grace-ms=0", "--profile=web", "--", "dsh.cmd", "web"]);
|
|
1078
|
+
if (p2.graceMs !== "0" || p2.commandArgv[0] !== "dsh.cmd" || p2.commandArgv.length !== 2) throw new Error("parseArgs launch =fixture failed");
|
|
1079
|
+
const p3 = parseArgs(["launch", "--profile", "web", "--", "--weird-but-verbatim"]);
|
|
1080
|
+
if (p3.commandArgv[0] !== "--weird-but-verbatim") throw new Error("parseArgs should pass post-`--` args through verbatim");
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// quoteCmdArg: strict MSVCRT/CommandLineToArgvW quoting; cmd metacharacters
|
|
1084
|
+
// are refused rather than escaped.
|
|
1085
|
+
{
|
|
1086
|
+
if (quoteCmdArg("dsh") !== '"dsh"') throw new Error("quoteCmdArg plain fixture failed");
|
|
1087
|
+
if (quoteCmdArg("a b") !== '"a b"') throw new Error("quoteCmdArg space fixture failed");
|
|
1088
|
+
if (quoteCmdArg('say "hi"') !== '"say \\"hi\\""') throw new Error("quoteCmdArg quote fixture failed");
|
|
1089
|
+
if (quoteCmdArg("C:\\x\\") !== '"C:\\x\\\\"') throw new Error("quoteCmdArg trailing-backslash fixture failed");
|
|
1090
|
+
if (quoteCmdArg("") !== '""') throw new Error("quoteCmdArg empty fixture failed");
|
|
1091
|
+
for (const bad of ["a&b", "a|b", "a%b", "a^b", "a<b", "a!b", "a\nb"]) {
|
|
1092
|
+
let threw = false;
|
|
1093
|
+
try { quoteCmdArg(bad); } catch { threw = true; }
|
|
1094
|
+
if (!threw) throw new Error(`quoteCmdArg should reject ${JSON.stringify(bad)}`);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// resolveWindowsCommand: a bare name is resolved through PATH + PATHEXT to
|
|
1099
|
+
// the real shim (never executed here — filesystem probes only), explicit
|
|
1100
|
+
// paths pass through untouched, and unresolvable names stay unchanged so
|
|
1101
|
+
// spawn reports its usual ENOENT. Windows-only; POSIX never rewrites.
|
|
1102
|
+
if (process.platform === "win32") {
|
|
1103
|
+
const binDir = join(root, "fake-bin");
|
|
1104
|
+
mkdirSync(binDir, { recursive: true });
|
|
1105
|
+
writeFileSync(join(binDir, "tool.cmd"), "@echo off\r\nrem fake shim — must never be executed by self-test\r\n");
|
|
1106
|
+
writeFileSync(join(binDir, "tool"), "#!/bin/sh\n# fake POSIX shim — must never be executed or resolved by self-test\n");
|
|
1107
|
+
writeFileSync(join(binDir, "dual.cmd"), "@echo off\r\n");
|
|
1108
|
+
writeFileSync(join(binDir, "dual.exe"), "MZ fake — must never be executed by self-test\r\n");
|
|
1109
|
+
const savedPath = process.env.PATH;
|
|
1110
|
+
const savedPathExt = process.env.PATHEXT;
|
|
1111
|
+
const restore = (name, saved) => { if (saved === undefined) delete process.env[name]; else process.env[name] = saved; };
|
|
1112
|
+
process.env.PATH = binDir;
|
|
1113
|
+
process.env.PATHEXT = ".COM;.EXE;.BAT;.CMD";
|
|
1114
|
+
try {
|
|
1115
|
+
const tool = resolveWindowsCommand("tool");
|
|
1116
|
+
if (resolve(tool).toLowerCase() !== join(binDir, "tool.cmd").toLowerCase()) {
|
|
1117
|
+
throw new Error(`resolveWindowsCommand should find tool.cmd on PATH, got ${JSON.stringify(tool)}`);
|
|
1118
|
+
}
|
|
1119
|
+
// PATHEXT order wins: .exe beats .cmd in the same directory.
|
|
1120
|
+
const dual = resolveWindowsCommand("dual");
|
|
1121
|
+
if (resolve(dual).toLowerCase() !== join(binDir, "dual.exe").toLowerCase()) {
|
|
1122
|
+
throw new Error(`resolveWindowsCommand should honor PATHEXT order (.exe before .cmd), got ${JSON.stringify(dual)}`);
|
|
1123
|
+
}
|
|
1124
|
+
// Explicit paths (relative or absolute) are passed through untouched.
|
|
1125
|
+
if (resolveWindowsCommand(".\\tool.cmd") !== ".\\tool.cmd") throw new Error("resolveWindowsCommand should not rewrite explicit relative paths");
|
|
1126
|
+
if (resolveWindowsCommand(join(binDir, "tool.cmd")) !== join(binDir, "tool.cmd")) throw new Error("resolveWindowsCommand should not rewrite absolute paths");
|
|
1127
|
+
if (resolveWindowsCommand("C:\\Windows\\System32\\cmd.exe") !== "C:\\Windows\\System32\\cmd.exe") throw new Error("resolveWindowsCommand should not rewrite drive-prefixed paths");
|
|
1128
|
+
// Unknown names come back unchanged (spawn then reports ENOENT as before).
|
|
1129
|
+
if (resolveWindowsCommand("definitely-not-a-real-tool-9f3b") !== "definitely-not-a-real-tool-9f3b") throw new Error("resolveWindowsCommand should leave unresolvable names unchanged");
|
|
1130
|
+
// Names that already carry an executable extension resolve as-is.
|
|
1131
|
+
const withExt = resolveWindowsCommand("tool.cmd");
|
|
1132
|
+
if (resolve(withExt).toLowerCase() !== join(binDir, "tool.cmd").toLowerCase()) {
|
|
1133
|
+
throw new Error(`resolveWindowsCommand should resolve an explicit .cmd name via PATH, got ${JSON.stringify(withExt)}`);
|
|
1134
|
+
}
|
|
1135
|
+
} finally {
|
|
1136
|
+
restore("PATH", savedPath);
|
|
1137
|
+
restore("PATHEXT", savedPathExt);
|
|
1138
|
+
}
|
|
1139
|
+
} else if (resolveWindowsCommand("tool") !== "tool") {
|
|
1140
|
+
throw new Error("resolveWindowsCommand must be a no-op on POSIX");
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// validate: a healthy profile validates clean.
|
|
1144
|
+
const home = join(root, "dsh");
|
|
1145
|
+
const profileDir = join(home, "profiles", "web");
|
|
1146
|
+
mkdirSync(join(profileDir, "node_modules", "good"), { recursive: true });
|
|
1147
|
+
writeFileSync(join(profileDir, "package.json"), JSON.stringify({
|
|
1148
|
+
dependencies: { good: "1.0.0" },
|
|
1149
|
+
dsh: { profile: { bundles: ["good"] } },
|
|
1150
|
+
}));
|
|
1151
|
+
writeFileSync(join(profileDir, "cordis.patch.yml"), "[]\n");
|
|
1152
|
+
writeFileSync(join(profileDir, "node_modules", "good", "package.json"), JSON.stringify({
|
|
1153
|
+
name: "good", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } },
|
|
1154
|
+
}));
|
|
1155
|
+
writeFileSync(join(profileDir, "node_modules", "good", "cordis.patch.yml"), "- insert:\n - id: good\n name: good\n");
|
|
1156
|
+
const validated = validateInstalledProfile(profileDir);
|
|
1157
|
+
if (validated.ok !== true) throw new Error("healthy profile should validate clean (cli)");
|
|
1158
|
+
|
|
1159
|
+
// guarded remove: exact official argv + shell:false runner seam, snapshot
|
|
1160
|
+
// before mutation, immediate commit after a statically safe removal.
|
|
1161
|
+
{
|
|
1162
|
+
const removeHome = join(root, "remove-home");
|
|
1163
|
+
const removeProfile = join(removeHome, "profiles", "web");
|
|
1164
|
+
const removableDir = join(removeProfile, "node_modules", "remove-me");
|
|
1165
|
+
mkdirSync(removableDir, { recursive: true });
|
|
1166
|
+
writeFileSync(join(removeProfile, "package.json"), JSON.stringify({
|
|
1167
|
+
dependencies: { "remove-me": "1.0.0" },
|
|
1168
|
+
dsh: { profile: { bundles: ["remove-me"] } },
|
|
1169
|
+
}));
|
|
1170
|
+
writeFileSync(join(removeProfile, "cordis.patch.yml"), "[]\n");
|
|
1171
|
+
writeFileSync(join(removableDir, "package.json"), JSON.stringify({
|
|
1172
|
+
name: "remove-me", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } },
|
|
1173
|
+
}));
|
|
1174
|
+
writeFileSync(join(removableDir, "cordis.patch.yml"), "- insert:\n - id: remove-me\n name: remove-me\n");
|
|
1175
|
+
|
|
1176
|
+
let sawRun = false;
|
|
1177
|
+
await cmdRemove({
|
|
1178
|
+
packageName: "remove-me",
|
|
1179
|
+
profile: "web",
|
|
1180
|
+
home: removeHome,
|
|
1181
|
+
_resolveDsh: () => join(root, "verified-dsh.js"),
|
|
1182
|
+
_run: async (command, argv, env) => {
|
|
1183
|
+
sawRun = command === process.execPath
|
|
1184
|
+
&& argv.join("\0") === dshPluginRemoveArgv(join(root, "verified-dsh.js"), "web", "remove-me").join("\0")
|
|
1185
|
+
&& env.npm_config_ignore_scripts === "true"
|
|
1186
|
+
&& env.DSH_HOME === resolve(removeHome)
|
|
1187
|
+
&& existsSync(join(removeHome, "guard", "pending-web.json"));
|
|
1188
|
+
writeFileSync(join(removeProfile, "package.json"), JSON.stringify({ dependencies: {}, dsh: { profile: { bundles: [] } } }));
|
|
1189
|
+
rmSync(removableDir, { recursive: true, force: true });
|
|
1190
|
+
return { exitCode: 0, output: "" };
|
|
1191
|
+
},
|
|
1192
|
+
});
|
|
1193
|
+
if (!sawRun) throw new Error("cmdRemove must mark pending before fixed shell:false official argv runs");
|
|
1194
|
+
if (readPendingSnapshot(removeProfile) !== undefined || listPendingSnapshots(removeHome).length !== 0) {
|
|
1195
|
+
throw new Error("successful cmdRemove must commit and clear its marker/snapshot");
|
|
1196
|
+
}
|
|
1197
|
+
const removedManifest = JSON.parse(readFileSync(join(removeProfile, "package.json"), "utf8"));
|
|
1198
|
+
if (Object.prototype.hasOwnProperty.call(removedManifest.dependencies ?? {}, "remove-me")) {
|
|
1199
|
+
throw new Error("cmdRemove success fixture did not remove the direct dependency");
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// An existing marker is refused before entry resolution or process spawn,
|
|
1203
|
+
// including the corrupt-marker case because the check is existence-only.
|
|
1204
|
+
writeFileSync(join(removeProfile, "package.json"), JSON.stringify({ dependencies: { keep: "1.0.0" }, dsh: { profile: { bundles: [] } } }));
|
|
1205
|
+
const keepDir = join(removeProfile, "node_modules", "keep");
|
|
1206
|
+
mkdirSync(keepDir, { recursive: true });
|
|
1207
|
+
writeFileSync(join(keepDir, "package.json"), JSON.stringify({ name: "keep", version: "1.0.0" }));
|
|
1208
|
+
const pending = createProfileSnapshot(removeProfile, { fixture: true });
|
|
1209
|
+
markPendingSnapshot(pending, { candidate: { name: "keep" } });
|
|
1210
|
+
let resolved = false;
|
|
1211
|
+
let refused = false;
|
|
1212
|
+
try {
|
|
1213
|
+
await cmdRemove({ packageName: "keep", profile: "web", home: removeHome, _resolveDsh: () => { resolved = true; } });
|
|
1214
|
+
} catch { refused = true; }
|
|
1215
|
+
if (!refused || resolved) throw new Error("cmdRemove must refuse pending state before resolving/spawning dsh");
|
|
1216
|
+
commitPendingSnapshot(removeProfile);
|
|
1217
|
+
|
|
1218
|
+
// A failed official command invokes rollback. The seam avoids real pnpm;
|
|
1219
|
+
// guard.js separately fixtures the byte restore/reconcile implementation.
|
|
1220
|
+
let rolledBack = false;
|
|
1221
|
+
refused = false;
|
|
1222
|
+
try {
|
|
1223
|
+
await cmdRemove({
|
|
1224
|
+
packageName: "keep",
|
|
1225
|
+
profile: "web",
|
|
1226
|
+
home: removeHome,
|
|
1227
|
+
_resolveDsh: () => join(root, "verified-dsh.js"),
|
|
1228
|
+
_run: async () => ({ exitCode: 9, output: "simulated remove failure" }),
|
|
1229
|
+
_rollback: () => { rolledBack = true; commitPendingSnapshot(removeProfile); },
|
|
1230
|
+
});
|
|
1231
|
+
} catch { refused = true; }
|
|
1232
|
+
if (!refused || !rolledBack) throw new Error("cmdRemove failure must roll back and report failure");
|
|
1233
|
+
|
|
1234
|
+
// An exit-zero command is still rolled back if the resulting profile is
|
|
1235
|
+
// statically unsafe. This is the safety check dsh's own remove lacks.
|
|
1236
|
+
rolledBack = false;
|
|
1237
|
+
refused = false;
|
|
1238
|
+
try {
|
|
1239
|
+
await cmdRemove({
|
|
1240
|
+
packageName: "keep",
|
|
1241
|
+
profile: "web",
|
|
1242
|
+
home: removeHome,
|
|
1243
|
+
_resolveDsh: () => join(root, "verified-dsh.js"),
|
|
1244
|
+
_run: async () => {
|
|
1245
|
+
writeFileSync(join(removeProfile, "package.json"), JSON.stringify({
|
|
1246
|
+
dependencies: { missing: "1.0.0" },
|
|
1247
|
+
dsh: { profile: { bundles: ["missing"] } },
|
|
1248
|
+
}));
|
|
1249
|
+
return { exitCode: 0, output: "" };
|
|
1250
|
+
},
|
|
1251
|
+
_rollback: () => { rolledBack = true; commitPendingSnapshot(removeProfile); },
|
|
1252
|
+
});
|
|
1253
|
+
} catch { refused = true; }
|
|
1254
|
+
if (!refused || !rolledBack) throw new Error("cmdRemove unsafe post-state must roll back and report failure");
|
|
1255
|
+
|
|
1256
|
+
// Generic validation alone considers an unresolved bundle name a
|
|
1257
|
+
// template and can miss dsh's partial reconcile. Exit zero must still
|
|
1258
|
+
// roll back when the removed package remains in bundles/profile rows.
|
|
1259
|
+
writeFileSync(join(removeProfile, "package.json"), JSON.stringify({
|
|
1260
|
+
dependencies: { keep: "1.0.0" },
|
|
1261
|
+
dsh: { profile: { bundles: ["keep"] } },
|
|
1262
|
+
}));
|
|
1263
|
+
writeFileSync(join(removeProfile, "cordis.patch.yml"), "- insert:\n - id: keep-row\n name: keep\n");
|
|
1264
|
+
mkdirSync(keepDir, { recursive: true });
|
|
1265
|
+
writeFileSync(join(keepDir, "package.json"), JSON.stringify({ name: "keep", version: "1.0.0" }));
|
|
1266
|
+
rolledBack = false;
|
|
1267
|
+
refused = false;
|
|
1268
|
+
try {
|
|
1269
|
+
await cmdRemove({
|
|
1270
|
+
packageName: "keep",
|
|
1271
|
+
profile: "web",
|
|
1272
|
+
home: removeHome,
|
|
1273
|
+
_resolveDsh: () => join(root, "verified-dsh.js"),
|
|
1274
|
+
_run: async () => {
|
|
1275
|
+
writeFileSync(join(removeProfile, "package.json"), JSON.stringify({
|
|
1276
|
+
dependencies: {},
|
|
1277
|
+
dsh: { profile: { bundles: ["keep"] } },
|
|
1278
|
+
}));
|
|
1279
|
+
rmSync(keepDir, { recursive: true, force: true });
|
|
1280
|
+
return { exitCode: 0, output: "" };
|
|
1281
|
+
},
|
|
1282
|
+
_rollback: () => { rolledBack = true; commitPendingSnapshot(removeProfile); },
|
|
1283
|
+
});
|
|
1284
|
+
} catch { refused = true; }
|
|
1285
|
+
if (!refused || !rolledBack) throw new Error("cmdRemove partial dsh reconcile must roll back despite generic validation passing");
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Startup must apply the same remove-completion check before probation;
|
|
1289
|
+
// otherwise a long-running app would commit a crash-partial remove after
|
|
1290
|
+
// the grace period merely because generic validation says "loadable".
|
|
1291
|
+
{
|
|
1292
|
+
const launchHome = join(root, "remove-launch-home");
|
|
1293
|
+
const launchProfile = join(launchHome, "profiles", "web");
|
|
1294
|
+
const victimDir = join(launchProfile, "node_modules", "victim");
|
|
1295
|
+
mkdirSync(victimDir, { recursive: true });
|
|
1296
|
+
writeFileSync(join(launchProfile, "package.json"), JSON.stringify({
|
|
1297
|
+
dependencies: { victim: "1.0.0" },
|
|
1298
|
+
dsh: { profile: { bundles: ["victim"] } },
|
|
1299
|
+
}));
|
|
1300
|
+
writeFileSync(join(launchProfile, "cordis.patch.yml"), "- insert:\n - id: victim-row\n name: victim\n");
|
|
1301
|
+
writeFileSync(join(victimDir, "package.json"), JSON.stringify({ name: "victim", version: "1.0.0" }));
|
|
1302
|
+
const pending = createProfileSnapshot(launchProfile, { operation: "remove", packageName: "victim" });
|
|
1303
|
+
markPendingSnapshot(pending, { operation: "remove", candidate: { name: "victim" } });
|
|
1304
|
+
writeFileSync(join(launchProfile, "package.json"), JSON.stringify({
|
|
1305
|
+
dependencies: {},
|
|
1306
|
+
dsh: { profile: { bundles: ["victim"] } },
|
|
1307
|
+
}));
|
|
1308
|
+
if (!validateInstalledProfile(launchProfile).ok) throw new Error("launch partial-remove fixture must reproduce generic false-safe validation");
|
|
1309
|
+
const exitCode = await cmdLaunch({
|
|
1310
|
+
profile: "web",
|
|
1311
|
+
home: launchHome,
|
|
1312
|
+
graceMs: 0,
|
|
1313
|
+
commandArgv: [process.execPath, "-e", "process.exit(0)"],
|
|
1314
|
+
});
|
|
1315
|
+
if (exitCode !== 0) throw new Error("launch after pre-start remove rollback should preserve child exit 0");
|
|
1316
|
+
if (readPendingSnapshot(launchProfile) !== undefined || existsSync(pending.dir)) {
|
|
1317
|
+
throw new Error("launch must consume recovery state after rolling back an incomplete remove");
|
|
1318
|
+
}
|
|
1319
|
+
if (JSON.parse(readFileSync(join(launchProfile, "package.json"), "utf8")).dependencies?.victim !== "1.0.0") {
|
|
1320
|
+
throw new Error("launch must restore the original manifest before starting the child");
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Even an internally-consistent marker rewrite cannot turn a remove into
|
|
1325
|
+
// an install: snapshot.json keeps the pre-mutation operation identity.
|
|
1326
|
+
// Launch must fail before starting the wrapped command and retain evidence.
|
|
1327
|
+
{
|
|
1328
|
+
const launchHome = join(root, "tampered-remove-launch-home");
|
|
1329
|
+
const launchProfile = join(launchHome, "profiles", "web");
|
|
1330
|
+
const victimDir = join(launchProfile, "node_modules", "victim");
|
|
1331
|
+
mkdirSync(victimDir, { recursive: true });
|
|
1332
|
+
writeFileSync(join(launchProfile, "package.json"), JSON.stringify({ dependencies: { victim: "1.0.0" } }));
|
|
1333
|
+
writeFileSync(join(launchProfile, "cordis.patch.yml"), "[]\n");
|
|
1334
|
+
writeFileSync(join(victimDir, "package.json"), JSON.stringify({ name: "victim", version: "1.0.0" }));
|
|
1335
|
+
const pending = createProfileSnapshot(launchProfile, { operation: "remove", packageName: "victim" });
|
|
1336
|
+
markPendingSnapshot(pending, { operation: "remove", candidate: { name: "victim" } });
|
|
1337
|
+
const markerPath = join(launchHome, "guard", "pending-web.json");
|
|
1338
|
+
const marker = JSON.parse(readFileSync(markerPath, "utf8"));
|
|
1339
|
+
marker.operation = "install";
|
|
1340
|
+
marker.metadata.operation = "install";
|
|
1341
|
+
delete marker.metadata.packageName;
|
|
1342
|
+
const tamperedBytes = JSON.stringify(marker, undefined, 2) + "\n";
|
|
1343
|
+
writeFileSync(markerPath, tamperedBytes);
|
|
1344
|
+
const launchedSentinel = join(launchHome, "WRAPPED_COMMAND_RAN");
|
|
1345
|
+
let refused = false;
|
|
1346
|
+
try {
|
|
1347
|
+
await cmdLaunch({
|
|
1348
|
+
profile: "web",
|
|
1349
|
+
home: launchHome,
|
|
1350
|
+
graceMs: 0,
|
|
1351
|
+
commandArgv: [process.execPath, "-e", `require('node:fs').writeFileSync(${JSON.stringify(launchedSentinel)}, 'ran')`],
|
|
1352
|
+
});
|
|
1353
|
+
} catch { refused = true; }
|
|
1354
|
+
if (!refused || existsSync(launchedSentinel)) throw new Error("launch must refuse a relabelled remove marker before spawning");
|
|
1355
|
+
if (readFileSync(markerPath, "utf8") !== tamperedBytes || !existsSync(pending.dir)) {
|
|
1356
|
+
throw new Error("launch must retain tampered remove marker/snapshot evidence");
|
|
1357
|
+
}
|
|
1358
|
+
if (!existsSync(join(victimDir, "package.json"))) throw new Error("launch refusal must not mutate the remove target");
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// recover: a pending colliding install is rolled back and its marker consumed.
|
|
1362
|
+
const snapshot = createProfileSnapshot(profileDir, { spec: "bad" });
|
|
1363
|
+
writeFileSync(join(profileDir, "package.json"), JSON.stringify({
|
|
1364
|
+
dependencies: { good: "1.0.0", bad: "1.0.0" },
|
|
1365
|
+
dsh: { profile: { bundles: ["good", "bad"] } },
|
|
1366
|
+
}));
|
|
1367
|
+
mkdirSync(join(profileDir, "node_modules", "bad"), { recursive: true });
|
|
1368
|
+
writeFileSync(join(profileDir, "node_modules", "bad", "package.json"), JSON.stringify({
|
|
1369
|
+
name: "bad", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } },
|
|
1370
|
+
}));
|
|
1371
|
+
writeFileSync(join(profileDir, "node_modules", "bad", "cordis.patch.yml"), "- insert:\n - id: good\n name: bad\n");
|
|
1372
|
+
markPendingSnapshot(snapshot, { spec: "bad", preflight: { candidate: { name: "bad", version: "1.0.0", kind: "bundle", rows: [{ id: "good", name: "bad" }] }, verdict: "safe", issues: [] } });
|
|
1373
|
+
const recovered = recoverProfile(profileDir);
|
|
1374
|
+
if (recovered.action !== "rolled-back") throw new Error(`recover should roll back a colliding install, got ${recovered.action}`);
|
|
1375
|
+
const restored = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
|
|
1376
|
+
if (restored.dependencies?.bad !== undefined) throw new Error("recover should remove the bad dependency entry");
|
|
1377
|
+
if (existsSync(join(profileDir, "node_modules", "bad"))) throw new Error("recover should reconcile node_modules (remove 'bad')");
|
|
1378
|
+
if (readPendingSnapshot(profileDir) !== undefined) throw new Error("recover should clear the pending marker");
|
|
1379
|
+
if (listPendingSnapshots(home).length !== 0) throw new Error("list should be empty after recovery");
|
|
1380
|
+
|
|
1381
|
+
// markerLooksValid: a well-formed v2 marker passes; corrupt shapes and
|
|
1382
|
+
// legacy v1 markers (no dependencies / candidate identity) fail closed,
|
|
1383
|
+
// mirroring guard.js sanitizeSnapshot exactly.
|
|
1384
|
+
{
|
|
1385
|
+
const good = {
|
|
1386
|
+
version: 2,
|
|
1387
|
+
id: "1700000000000-abc123",
|
|
1388
|
+
files: Object.fromEntries(SNAPSHOT_FILES.map((name) => [name, { present: true }])),
|
|
1389
|
+
dependencies: ["good"],
|
|
1390
|
+
operation: "install",
|
|
1391
|
+
metadata: { operation: "install" },
|
|
1392
|
+
preflight: { candidate: { name: "bad", version: "1.0.0" } },
|
|
1393
|
+
profileDir,
|
|
1394
|
+
};
|
|
1395
|
+
const markerFixtureDir = join(home, "guard", "snapshots", good.id);
|
|
1396
|
+
mkdirSync(markerFixtureDir, { recursive: true });
|
|
1397
|
+
writeFileSync(join(markerFixtureDir, "snapshot.json"), JSON.stringify({
|
|
1398
|
+
version: good.version,
|
|
1399
|
+
id: good.id,
|
|
1400
|
+
profileDir: good.profileDir,
|
|
1401
|
+
operation: good.operation,
|
|
1402
|
+
metadata: good.metadata,
|
|
1403
|
+
}));
|
|
1404
|
+
if (!markerLooksValid(good, profileDir, home)) throw new Error("markerLooksValid should accept a well-formed v2 marker");
|
|
1405
|
+
// The candidate identity may also come from a top-level `candidate`.
|
|
1406
|
+
const { preflight: _omit, ...noPreflight } = good;
|
|
1407
|
+
if (!markerLooksValid({ ...noPreflight, candidate: { name: "bad" } }, profileDir, home)) throw new Error("markerLooksValid should accept a top-level candidate identity");
|
|
1408
|
+
if (markerLooksValid({ ...good, version: 1 }, profileDir, home)) throw new Error("markerLooksValid must reject a legacy v1 marker");
|
|
1409
|
+
if (markerLooksValid({ ...good, id: "../evil" }, profileDir, home)) throw new Error("markerLooksValid should reject a bad snapshot id");
|
|
1410
|
+
if (markerLooksValid({ ...good, files: { "package.json": { present: true } } }, profileDir, home)) throw new Error("markerLooksValid should reject missing file metadata");
|
|
1411
|
+
if (markerLooksValid({ ...good, dependencies: undefined }, profileDir, home)) throw new Error("markerLooksValid should reject missing dependencies");
|
|
1412
|
+
if (markerLooksValid({ ...good, dependencies: ["good", "../evil"] }, profileDir, home)) throw new Error("markerLooksValid should reject a corrupt dependency name");
|
|
1413
|
+
if (markerLooksValid(noPreflight, profileDir, home)) throw new Error("markerLooksValid should reject a marker without a candidate identity");
|
|
1414
|
+
if (markerLooksValid({ ...good, preflight: { candidate: { name: "../evil" } } }, profileDir, home)) throw new Error("markerLooksValid should reject a corrupt candidate name");
|
|
1415
|
+
if (markerLooksValid({ ...good, profileDir: join(home, "profiles", "other") }, profileDir, home)) throw new Error("markerLooksValid should reject a profileDir mismatch");
|
|
1416
|
+
if (markerLooksValid({ ...good, profileDir: join(home, "elsewhere", "web") }, profileDir, home)) throw new Error("markerLooksValid should reject a profileDir outside <home>/profiles");
|
|
1417
|
+
|
|
1418
|
+
const removeGood = {
|
|
1419
|
+
...good,
|
|
1420
|
+
id: "1700000000001-remove1",
|
|
1421
|
+
operation: "remove",
|
|
1422
|
+
metadata: { operation: "remove", packageName: "bad" },
|
|
1423
|
+
preflight: undefined,
|
|
1424
|
+
candidate: { name: "bad" },
|
|
1425
|
+
};
|
|
1426
|
+
const removeFixtureDir = join(home, "guard", "snapshots", removeGood.id);
|
|
1427
|
+
mkdirSync(removeFixtureDir, { recursive: true });
|
|
1428
|
+
writeFileSync(join(removeFixtureDir, "snapshot.json"), JSON.stringify({
|
|
1429
|
+
version: removeGood.version,
|
|
1430
|
+
id: removeGood.id,
|
|
1431
|
+
profileDir: removeGood.profileDir,
|
|
1432
|
+
operation: removeGood.operation,
|
|
1433
|
+
metadata: removeGood.metadata,
|
|
1434
|
+
}));
|
|
1435
|
+
if (!markerLooksValid(removeGood, profileDir, home)) throw new Error("markerLooksValid should accept a consistent remove transaction");
|
|
1436
|
+
if (markerLooksValid({ ...removeGood, operation: "unknown" }, profileDir, home)) throw new Error("markerLooksValid must reject an unsupported operation");
|
|
1437
|
+
if (markerLooksValid({ ...removeGood, operation: "install" }, profileDir, home)) throw new Error("markerLooksValid must reject top-level/metadata operation disagreement");
|
|
1438
|
+
if (markerLooksValid({ ...removeGood, metadata: { ...removeGood.metadata, operation: "install" } }, profileDir, home)) throw new Error("markerLooksValid must reject metadata/top-level operation disagreement");
|
|
1439
|
+
if (markerLooksValid({ ...removeGood, candidate: { name: "other" } }, profileDir, home)) throw new Error("markerLooksValid must reject remove candidate/packageName disagreement");
|
|
1440
|
+
if (markerLooksValid({ ...removeGood, metadata: { ...removeGood.metadata, packageName: "other" } }, profileDir, home)) throw new Error("markerLooksValid must reject tampered remove packageName");
|
|
1441
|
+
if (markerLooksValid({ ...removeGood, operation: "install", metadata: { operation: "install" } }, profileDir, home)) throw new Error("markerLooksValid must cross-check operation against snapshot.json");
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// cmdAdd hands pnpmGuardEnv() to the spawned `dsh plugin add` so the nested
|
|
1445
|
+
// pnpm honors disabled peer auto-install. Pin the exact env shape (pure).
|
|
1446
|
+
{
|
|
1447
|
+
const env = pnpmGuardEnv({ KEEP_ME: "1" });
|
|
1448
|
+
if (env.KEEP_ME !== "1") throw new Error("pnpmGuardEnv must preserve the base env");
|
|
1449
|
+
if (env.npm_config_auto_install_peers !== "false" || env.NPM_CONFIG_AUTO_INSTALL_PEERS !== "false") {
|
|
1450
|
+
throw new Error("pnpmGuardEnv must disable peer auto-install for the nested pnpm");
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// assertSafeProfileName: hostile profile text is rejected outright — it can
|
|
1455
|
+
// never become argv, let alone a shell fragment.
|
|
1456
|
+
{
|
|
1457
|
+
for (const good of ["web", "headless", "a.b-c_d", "A9", "x"]) assertSafeProfileName(good);
|
|
1458
|
+
for (const bad of ["", "../x", "a&b", "a b", "a|b", "a$b", "a`b`", "-lead", ".lead", 'a"b', "a\nb", "a;b"]) {
|
|
1459
|
+
let threw = false;
|
|
1460
|
+
try { assertSafeProfileName(bad); } catch { threw = true; }
|
|
1461
|
+
if (!threw) throw new Error(`assertSafeProfileName should reject ${JSON.stringify(bad)}`);
|
|
1462
|
+
}
|
|
1463
|
+
// Windows-only: a trailing dot/space aliases the trimmed name on disk
|
|
1464
|
+
// (`web.` → `web`) while keeping a distinct pending filename, and device
|
|
1465
|
+
// basenames are reserved even when followed by an extension.
|
|
1466
|
+
if (process.platform === "win32") {
|
|
1467
|
+
for (const bad of ["web.", "web..", "con", "CON", "con.txt", "prn", "aux", "nul", "com1", "COM9", "lpt1", "LPT9.md"]) {
|
|
1468
|
+
let threw = false;
|
|
1469
|
+
try { assertSafeProfileName(bad); } catch { threw = true; }
|
|
1470
|
+
if (!threw) throw new Error(`assertSafeProfileName should reject ${JSON.stringify(bad)} on Windows`);
|
|
1471
|
+
}
|
|
1472
|
+
// Lookalikes that are not exactly a device basename stay allowed.
|
|
1473
|
+
for (const good of ["com", "com0", "com10", "lpt", "console", "nul2", "web.a"]) assertSafeProfileName(good);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// assertSafeSpec: shell metacharacters in the spec are rejected, not quoted.
|
|
1478
|
+
{
|
|
1479
|
+
assertSafeSpec("@scope/pkg@1.0.0");
|
|
1480
|
+
const badSpecs = ["pkg&whoami", "pkg|calc", "pkg$(x)", "pkg`x`", "pkg>x", "pkg;x", "%DSH_SECRET%"];
|
|
1481
|
+
if (process.platform === "win32") badSpecs.push("pkg name");
|
|
1482
|
+
for (const bad of badSpecs) {
|
|
1483
|
+
let threw = false;
|
|
1484
|
+
try { assertSafeSpec(bad); } catch { threw = true; }
|
|
1485
|
+
if (!threw) throw new Error(`assertSafeSpec should reject ${JSON.stringify(bad)}`);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// assertSafePackageName: remove accepts a direct npm name, never a spec,
|
|
1490
|
+
// path, option or shell fragment.
|
|
1491
|
+
{
|
|
1492
|
+
for (const good of ["pkg", "pkg-name", "@scope/pkg"]) assertSafePackageName(good);
|
|
1493
|
+
for (const bad of ["", "pkg@1", "../pkg", "file:../pkg", "--global", "pkg&whoami", "@scope/"]) {
|
|
1494
|
+
let threw = false;
|
|
1495
|
+
try { assertSafePackageName(bad); } catch { threw = true; }
|
|
1496
|
+
if (!threw) throw new Error(`assertSafePackageName should reject ${JSON.stringify(bad)}`);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// dshPluginAddArgv: the wrapped command is plain argv (spawned with
|
|
1501
|
+
// shell:false), so even hostile text would travel as ONE literal element —
|
|
1502
|
+
// never joined into a command line.
|
|
1503
|
+
{
|
|
1504
|
+
const argv = dshPluginAddArgv(join(root, "bin.js"), "web", "pkg & whoami");
|
|
1505
|
+
if (argv.length !== 6) throw new Error("dshPluginAddArgv must produce exactly 6 argv elements");
|
|
1506
|
+
if (argv[5] !== "pkg & whoami") throw new Error("dshPluginAddArgv must keep the spec verbatim as a single element");
|
|
1507
|
+
if (argv[3] !== "web" || argv[2] !== "--profile") throw new Error("dshPluginAddArgv must keep the profile verbatim");
|
|
1508
|
+
const removeArgv = dshPluginRemoveArgv(join(root, "bin.js"), "web", "@scope/pkg");
|
|
1509
|
+
if (removeArgv.length !== 7 || removeArgv[5] !== "@scope/pkg" || removeArgv[6] !== "--config.ignore-scripts=true") {
|
|
1510
|
+
throw new Error("dshPluginRemoveArgv must be fixed plain argv with scripts disabled");
|
|
1511
|
+
}
|
|
1512
|
+
const addEnv = officialDshEnv(join(root, "explicit-home"));
|
|
1513
|
+
if (addEnv.DSH_HOME !== resolve(root, "explicit-home")
|
|
1514
|
+
|| Object.keys(addEnv).filter((key) => key.toUpperCase() === "DSH_HOME").length !== 1
|
|
1515
|
+
|| addEnv.npm_config_auto_install_peers !== "false") {
|
|
1516
|
+
throw new Error("guarded add child env must pin DSH_HOME to the snapshotted home");
|
|
1517
|
+
}
|
|
1518
|
+
const removeEnv = officialDshEnv(join(root, "explicit-remove-home"), { ignoreScripts: true });
|
|
1519
|
+
if (removeEnv.DSH_HOME !== resolve(root, "explicit-remove-home") || removeEnv.npm_config_ignore_scripts !== "true") {
|
|
1520
|
+
throw new Error("guarded remove child env must pin DSH_HOME and disable scripts");
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// resolveDshCliEntry: resolves the official entry from a fake global
|
|
1525
|
+
// install layout (never executed — filesystem probes only), and fails
|
|
1526
|
+
// closed when the package behind `dsh` is not @deepseek-ai/dsh, when its
|
|
1527
|
+
// bin escapes the package, or when no dsh command exists at all.
|
|
1528
|
+
{
|
|
1529
|
+
const binDir = join(root, "dsh-bin");
|
|
1530
|
+
const pkgRoot = join(binDir, "node_modules", "@deepseek-ai", "dsh");
|
|
1531
|
+
const entryFile = join(pkgRoot, "lib", "bin.js");
|
|
1532
|
+
mkdirSync(join(pkgRoot, "lib"), { recursive: true });
|
|
1533
|
+
const manifest = { name: "@deepseek-ai/dsh", version: "0.0.0", bin: { dsh: "lib/bin.js" } };
|
|
1534
|
+
writeFileSync(join(pkgRoot, "package.json"), JSON.stringify(manifest));
|
|
1535
|
+
writeFileSync(entryFile, "// fake dsh entry — must never be executed by self-test\n");
|
|
1536
|
+
if (process.platform === "win32") {
|
|
1537
|
+
writeFileSync(join(binDir, "dsh.cmd"), '@ECHO off\r\n"%_prog%" "%dp0%\\node_modules\\@deepseek-ai\\dsh\\lib\\bin.js" %*\r\n');
|
|
1538
|
+
} else {
|
|
1539
|
+
writeFileSync(join(binDir, "dsh"), '#!/bin/sh\nexec node "$basedir/node_modules/@deepseek-ai/dsh/lib/bin.js" "$@"\n');
|
|
1540
|
+
}
|
|
1541
|
+
const savedPath = process.env.PATH;
|
|
1542
|
+
const savedPathExt = process.env.PATHEXT;
|
|
1543
|
+
const restore = (name, saved) => { if (saved === undefined) delete process.env[name]; else process.env[name] = saved; };
|
|
1544
|
+
process.env.PATH = binDir;
|
|
1545
|
+
if (process.platform === "win32") process.env.PATHEXT = ".COM;.EXE;.BAT;.CMD";
|
|
1546
|
+
const sameFile = (a, b) => (process.platform === "win32" ? resolve(a).toLowerCase() === resolve(b).toLowerCase() : resolve(a) === resolve(b));
|
|
1547
|
+
try {
|
|
1548
|
+
const entry = resolveDshCliEntry();
|
|
1549
|
+
if (!sameFile(entry, entryFile)) throw new Error(`resolveDshCliEntry should resolve the official entry, got ${JSON.stringify(entry)}`);
|
|
1550
|
+
if (entryIsSelf(entry)) throw new Error("resolveDshCliEntry must never resolve to this guard CLI");
|
|
1551
|
+
|
|
1552
|
+
// A package that is not @deepseek-ai/dsh must fail closed.
|
|
1553
|
+
writeFileSync(join(pkgRoot, "package.json"), JSON.stringify({ name: "@evil/not-dsh", bin: { dsh: "lib/bin.js" } }));
|
|
1554
|
+
let threw = false;
|
|
1555
|
+
try { resolveDshCliEntry(); } catch { threw = true; }
|
|
1556
|
+
if (!threw) throw new Error("resolveDshCliEntry must fail closed when the package is not @deepseek-ai/dsh");
|
|
1557
|
+
|
|
1558
|
+
// A bin escaping the package must fail closed.
|
|
1559
|
+
writeFileSync(join(pkgRoot, "package.json"), JSON.stringify({ name: "@deepseek-ai/dsh", bin: { dsh: "../../escape.js" } }));
|
|
1560
|
+
threw = false;
|
|
1561
|
+
try { resolveDshCliEntry(); } catch { threw = true; }
|
|
1562
|
+
if (!threw) throw new Error("resolveDshCliEntry must fail closed when the bin escapes the package");
|
|
1563
|
+
writeFileSync(join(pkgRoot, "package.json"), JSON.stringify(manifest));
|
|
1564
|
+
|
|
1565
|
+
// Realpath containment: a lexically in-package bin symlink/junction
|
|
1566
|
+
// must not escape to executable code outside the verified package.
|
|
1567
|
+
const outsideEntry = join(root, "outside-dsh.js");
|
|
1568
|
+
const linkedEntry = join(pkgRoot, "lib", "linked-bin.js");
|
|
1569
|
+
writeFileSync(outsideEntry, "// outside fake entry\n");
|
|
1570
|
+
let linked = false;
|
|
1571
|
+
try {
|
|
1572
|
+
symlinkSync(outsideEntry, linkedEntry, "file");
|
|
1573
|
+
linked = true;
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
if (error?.code !== "EPERM" && error?.code !== "EACCES" && error?.code !== "ENOTSUP") throw error;
|
|
1576
|
+
}
|
|
1577
|
+
if (linked) {
|
|
1578
|
+
writeFileSync(join(pkgRoot, "package.json"), JSON.stringify({ name: "@deepseek-ai/dsh", bin: { dsh: "lib/linked-bin.js" } }));
|
|
1579
|
+
threw = false;
|
|
1580
|
+
try { resolveDshCliEntry(); } catch { threw = true; }
|
|
1581
|
+
if (!threw) throw new Error("resolveDshCliEntry must reject a realpath bin escape");
|
|
1582
|
+
writeFileSync(join(pkgRoot, "package.json"), JSON.stringify(manifest));
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// No dsh command on PATH at all must fail closed.
|
|
1586
|
+
const emptyDir = join(root, "empty-bin");
|
|
1587
|
+
mkdirSync(emptyDir, { recursive: true });
|
|
1588
|
+
process.env.PATH = emptyDir;
|
|
1589
|
+
threw = false;
|
|
1590
|
+
try { resolveDshCliEntry(); } catch { threw = true; }
|
|
1591
|
+
if (!threw) throw new Error("resolveDshCliEntry must fail closed when no dsh command is on PATH");
|
|
1592
|
+
} finally {
|
|
1593
|
+
restore("PATH", savedPath);
|
|
1594
|
+
restore("PATHEXT", savedPathExt);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// pinSpecToLatest: bare names get name@latest (minimumReleaseAge cannot
|
|
1599
|
+
// silently downgrade); pinned/scoped-pinned/git/file specs pass through;
|
|
1600
|
+
// registry failure keeps the bare spec.
|
|
1601
|
+
{
|
|
1602
|
+
const fakeInfo = async (name) => name === "somepkg" || name === "@scope/pkg" ? { latest: "9.9.9" } : null;
|
|
1603
|
+
if (await pinSpecToLatest("somepkg", fakeInfo) !== "somepkg@9.9.9") throw new Error("bare name must be pinned to latest");
|
|
1604
|
+
if (await pinSpecToLatest("@scope/pkg", fakeInfo) !== "@scope/pkg@9.9.9") throw new Error("bare scoped name must be pinned to latest");
|
|
1605
|
+
if (await pinSpecToLatest("somepkg@1.2.3", fakeInfo) !== "somepkg@1.2.3") throw new Error("already-pinned spec must pass through");
|
|
1606
|
+
if (await pinSpecToLatest("github:owner/repo", fakeInfo) !== "github:owner/repo") throw new Error("github spec must pass through");
|
|
1607
|
+
if (await pinSpecToLatest("unknown-pkg", fakeInfo) !== "unknown-pkg") throw new Error("unknown package must keep the bare spec");
|
|
1608
|
+
const offline = async () => { throw new Error("network down"); };
|
|
1609
|
+
if (await pinSpecToLatest("somepkg", offline) !== "somepkg") throw new Error("registry failure must keep the bare spec");
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
console.log("PASS cli argument/validate/recover fixtures");
|
|
1613
|
+
} finally {
|
|
1614
|
+
rmSync(root, { recursive: true, force: true });
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// Run main() when invoked directly (`node src/cli.js …`) or through the
|
|
1619
|
+
// package bin: npm links `dsh-plugin-guard` as a symlink on POSIX (argv[1] is
|
|
1620
|
+
// the link path) and as a shim that invokes cli.js on Windows.
|
|
1621
|
+
function isMainModule() {
|
|
1622
|
+
const invoked = process.argv[1];
|
|
1623
|
+
if (typeof invoked !== "string" || invoked.length === 0) return false;
|
|
1624
|
+
const same = (a, b) => (process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
1625
|
+
const self = fileURLToPath(import.meta.url);
|
|
1626
|
+
try {
|
|
1627
|
+
return same(realpathSync(invoked), realpathSync(self));
|
|
1628
|
+
} catch {
|
|
1629
|
+
return same(resolve(invoked), resolve(self));
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
if (isMainModule()) {
|
|
1634
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
1635
|
+
console.error(`error: ${error?.stack ?? error?.message ?? error}`);
|
|
1636
|
+
process.exitCode = 1;
|
|
1637
|
+
});
|
|
1638
|
+
}
|