@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/src/index.js CHANGED
@@ -15,12 +15,17 @@
15
15
 
16
16
  import z from "@deepseek-ai/schemastery";
17
17
  import { defineTool } from "@deepseek-ai/dsh-tools";
18
- import { existsSync, readFileSync } from "node:fs";
19
- import { join } from "node:path";
18
+ import { existsSync, readFileSync, realpathSync, mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
19
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
20
20
  import { spawn } from "node:child_process";
21
+ import { createHash, randomBytes } from "node:crypto";
22
+ import { fileURLToPath } from "node:url";
23
+ import { createRequire } from "node:module";
24
+ import { tmpdir } from "node:os";
21
25
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
22
- import { repoInfo, searchPlugins, verifyPlugins, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
23
- import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker, assertSafeSpec, resolveRegistry } from "./installer.js";
26
+ import { repoInfo, searchPlugins, verifyPlugins, cachedRepoManifest, fetchRawFile, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
27
+ import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof } from "./installer.js";
28
+ import { preflightInstall, inspectRemoteCandidate } from "./guard.js";
24
29
 
25
30
  export const name = "@1e0zj/dsh-plugin-mall";
26
31
  export const inject = ["tools", "jobs", "systemPrompt"];
@@ -45,6 +50,890 @@ async function registryFor(profile, npmRegistry) {
45
50
  return explicit.length > 0 ? explicit.replace(/\/+$/, "") : await resolveRegistry(profile);
46
51
  }
47
52
 
53
+ // ── profile-state fingerprinting & preflight cache (guard.js) ───────────────
54
+ //
55
+ // Every install path — the /market RPC channel AND the market_install agent
56
+ // tool — runs the same preflight before a job starts: the candidate is
57
+ // installed into a disposable directory with scripts disabled and compared
58
+ // against the profile (manifest + patch conflict scan). A blocker refuses the
59
+ // install; a warning requires explicit user confirmation.
60
+ //
61
+ // Preflight cache/pin reuse is strictly bound to a fingerprint of protected
62
+ // profile files and installed direct dependency state. Before reuse, the
63
+ // fingerprint is recomputed; any profile change invalidates and reruns.
64
+
65
+ const PREFLIGHT_TTL = 30000; // 30s — short TTL for preflight-then-install round trip
66
+ const PIN_TTL = 600000; // 10 min — the approval-retry window
67
+ const NPM_PACKAGE_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
68
+
69
+ function directPackageJsonPath(packageName, profileDir) {
70
+ if (typeof packageName !== "string" || !NPM_PACKAGE_NAME_RE.test(packageName)) return undefined;
71
+ const modulesRoot = resolve(profileDir, "node_modules");
72
+ const direct = resolve(modulesRoot, ...packageName.split("/"), "package.json");
73
+ const rel = relative(modulesRoot, direct);
74
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined;
75
+ if (existsSync(direct)) return direct;
76
+ return undefined;
77
+ }
78
+
79
+ /**
80
+ * Deterministic fingerprint of protected profile files and installed direct
81
+ * dependency state. Inspects ONLY <profile>/node_modules/<dep>/package.json,
82
+ * requires exact manifest.name, and never falls back to ancestor Node resolution.
83
+ */
84
+ export function computeProfileFingerprint(profileDir) {
85
+ const hash = createHash("sha256");
86
+ const protectedFiles = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "cordis.patch.yml"];
87
+ for (const filename of protectedFiles) {
88
+ const fullPath = join(profileDir, filename);
89
+ if (existsSync(fullPath)) {
90
+ try {
91
+ const content = readFileSync(fullPath);
92
+ hash.update(`${filename}:present:${content.length}\0`);
93
+ hash.update(content);
94
+ } catch (err) {
95
+ hash.update(`${filename}:error:${err?.message ?? String(err)}\0`);
96
+ }
97
+ } else {
98
+ hash.update(`${filename}:missing\0`);
99
+ }
100
+ }
101
+
102
+ const manifestPath = join(profileDir, "package.json");
103
+ if (existsSync(manifestPath)) {
104
+ try {
105
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
106
+ const deps = Object.keys(manifest.dependencies ?? {}).sort();
107
+ for (const dep of deps) {
108
+ const declaredVersion = manifest.dependencies[dep];
109
+ hash.update(`dep:${dep}=${declaredVersion}\0`);
110
+ const depPkgPath = directPackageJsonPath(dep, profileDir);
111
+ if (depPkgPath && existsSync(depPkgPath)) {
112
+ try {
113
+ const depManifest = JSON.parse(readFileSync(depPkgPath, "utf8"));
114
+ if (depManifest && typeof depManifest === "object" && depManifest.name === dep) {
115
+ hash.update(`installed:${dep}@${depManifest.version ?? "unknown"}\0`);
116
+ } else {
117
+ hash.update(`installed:${dep}:corrupt\0`);
118
+ }
119
+ } catch {
120
+ hash.update(`installed:${dep}:corrupt\0`);
121
+ }
122
+ } else {
123
+ hash.update(`installed:${dep}:missing\0`);
124
+ }
125
+ }
126
+ } catch {
127
+ hash.update("manifest:corrupt\0");
128
+ }
129
+ }
130
+
131
+ return hash.digest("hex");
132
+ }
133
+
134
+ // `${profileDir}\u0000${spec}` -> { report, fingerprint, at, pinnedAt }
135
+ // ── browsing-time compat badge cache ────────────────────────────────────────
136
+ // `${fingerprint}::${repo}` -> { entry, at }. Keyed by the profile fingerprint
137
+ // so any install/uninstall/profile edit invalidates every badge at once; the
138
+ // TTL only bounds how long a badge survives the plugin repo itself changing.
139
+ const compatCache = new Map();
140
+ const COMPAT_TTL = 600000;
141
+
142
+ function compatCacheGet(key) {
143
+ const cached = compatCache.get(key);
144
+ if (cached === undefined) return undefined;
145
+ if (Date.now() - cached.at > COMPAT_TTL) {
146
+ compatCache.delete(key);
147
+ return undefined;
148
+ }
149
+ return cached.entry;
150
+ }
151
+
152
+ function compatCacheSet(key, entry) {
153
+ if (compatCache.size > 500) compatCache.clear();
154
+ compatCache.set(key, { entry, at: Date.now() });
155
+ }
156
+
157
+ const preflightCache = new Map();
158
+
159
+ function preflightCacheKey(profileDir, spec) {
160
+ return `${profileDir}\u0000${spec}`;
161
+ }
162
+
163
+ /** Whether a cache entry is pinned and still inside the approval-retry window. */
164
+ function isPinned(cached) {
165
+ return cached?.pinnedAt !== undefined && Date.now() - cached.pinnedAt < PIN_TTL;
166
+ }
167
+
168
+ /** Drop cached preflights for one profile. */
169
+ function invalidatePreflightFor(profileDir) {
170
+ const prefix = `${profileDir}\u0000`;
171
+ for (const key of [...preflightCache.keys()]) {
172
+ if (key.startsWith(prefix)) preflightCache.delete(key);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Keep a spec's preflight report alive past the TTL for the approval-retry
178
+ * window. Re-checks the profile fingerprint before pinning; report-only,
179
+ * never caches warning consent.
180
+ */
181
+ function pinPreflight(profileDir, spec) {
182
+ const key = preflightCacheKey(profileDir, spec);
183
+ const cached = preflightCache.get(key);
184
+ if (cached === undefined) return;
185
+ const currentFingerprint = computeProfileFingerprint(profileDir);
186
+ if (cached.fingerprint !== currentFingerprint) {
187
+ preflightCache.delete(key);
188
+ return;
189
+ }
190
+ cached.pinnedAt = Date.now();
191
+ }
192
+
193
+ /**
194
+ * Run the isolated preflight for a resolved install spec, reusing a fresh
195
+ * cache entry ONLY if the profile fingerprint matches.
196
+ */
197
+ async function runPreflight({ profile, spec, force = false }) {
198
+ let profileDir;
199
+ try {
200
+ profileDir = resolveProfileDir(profile);
201
+ } catch (error) {
202
+ throw new Error(`invalid profile: ${error.message}`);
203
+ }
204
+ if (!existsSync(join(profileDir, "package.json"))) ensureProfile(profile);
205
+
206
+ const currentFingerprint = computeProfileFingerprint(profileDir);
207
+ const key = preflightCacheKey(profileDir, spec);
208
+ const cached = preflightCache.get(key);
209
+
210
+ if (cached !== undefined && cached.fingerprint !== currentFingerprint) {
211
+ preflightCache.delete(key);
212
+ }
213
+
214
+ const validCached = preflightCache.get(key);
215
+ const fresh = validCached !== undefined
216
+ && validCached.fingerprint === currentFingerprint
217
+ && (isPinned(validCached) || Date.now() - validCached.at < PREFLIGHT_TTL);
218
+
219
+ if (!force && fresh) {
220
+ return { report: validCached.report, profileDir, fingerprint: currentFingerprint };
221
+ }
222
+
223
+ const report = await preflightInstall({ profileDir, spec });
224
+ preflightCache.set(key, {
225
+ report,
226
+ fingerprint: currentFingerprint,
227
+ at: Date.now(),
228
+ pinnedAt: undefined,
229
+ });
230
+ return { report, profileDir, fingerprint: currentFingerprint };
231
+ }
232
+
233
+ // ── opaque one-shot approval tokens ─────────────────────────────────────────
234
+ //
235
+ // Warning consent must NOT be cached before a real needsApproval result.
236
+ // When an install started with explicit warning consent pauses for install-script
237
+ // approval, backend issues an opaque one-shot approval token bound to:
238
+ // - profile (and resolved profileDir)
239
+ // - canonical spec & preflight report digest
240
+ // - exact requested build package set
241
+ // - current profile-state fingerprint
242
+ // - surface/session (browser vs agent/owner)
243
+ //
244
+ // The build-approval retry atomically consumes this token. Any failure, cancel,
245
+ // or profile mutation invalidates consent. No cross-surface/agent reuse.
246
+
247
+ function canonicalizeForDigest(value) {
248
+ if (Array.isArray(value)) return value.map(canonicalizeForDigest);
249
+ if (value && typeof value === "object") {
250
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalizeForDigest(value[key])]));
251
+ }
252
+ return value;
253
+ }
254
+
255
+ function sha256Canonical(value) {
256
+ const hash = createHash("sha256");
257
+ hash.update(JSON.stringify(canonicalizeForDigest(value ?? null)));
258
+ return hash.digest("hex");
259
+ }
260
+
261
+ export function digestPreflightReport(report) {
262
+ return sha256Canonical(report);
263
+ }
264
+
265
+ const approvalTokens = new Map();
266
+
267
+ export function issueApprovalToken({
268
+ profile,
269
+ profileDir,
270
+ spec,
271
+ preflightReport,
272
+ needsApproval,
273
+ proof,
274
+ surface,
275
+ owner,
276
+ warningConsentActive = false,
277
+ acceptWarningsActive = false,
278
+ }) {
279
+ if (!profileDir || !existsSync(profileDir)) throw new Error("cannot issue approval token without an existing profile directory");
280
+ const token = `mkt-appr-${randomBytes(24).toString("hex")}`;
281
+ const reportDigest = digestPreflightReport(preflightReport);
282
+ const packageNames = new Set();
283
+ for (const item of Array.isArray(needsApproval) ? needsApproval : []) {
284
+ const name = typeof item === "string" ? item.trim() : (typeof item?.name === "string" ? item.name.trim() : "");
285
+ if (name.length > 0 && NPM_PACKAGE_NAME_RE.test(name)) {
286
+ packageNames.add(name);
287
+ }
288
+ }
289
+ const requestedPackages = [...packageNames].sort();
290
+ if (requestedPackages.length === 0) {
291
+ throw new Error("cannot issue approval token without a valid install-script package name");
292
+ }
293
+ const proofSerialized = serializeCanonicalProof(proof);
294
+ let canonicalProof;
295
+ try {
296
+ canonicalProof = JSON.parse(proofSerialized);
297
+ } catch {
298
+ throw new Error("cannot issue approval token without a canonical materialized artifact proof");
299
+ }
300
+ const validHash = (value) => typeof value === "string" && /^[0-9a-f]{64}$/i.test(value);
301
+ if (
302
+ !NPM_PACKAGE_NAME_RE.test(canonicalProof?.candidate?.name ?? "")
303
+ || !validHash(canonicalProof?.candidate?.contentHash)
304
+ || !Array.isArray(canonicalProof?.blockedPackages)
305
+ || canonicalProof.blockedPackages.length === 0
306
+ || canonicalProof.blockedPackages.some((entry) => (
307
+ !NPM_PACKAGE_NAME_RE.test(entry?.name ?? "")
308
+ || typeof entry?.selector !== "string"
309
+ || entry.selector.length === 0
310
+ || !validHash(entry?.contentHash)
311
+ ))
312
+ ) {
313
+ throw new Error("cannot issue approval token with an invalid or empty materialized artifact proof");
314
+ }
315
+ const proofNames = [...new Set(canonicalProof.blockedPackages.map((entry) => entry.name))].sort();
316
+ if (JSON.stringify(proofNames) !== JSON.stringify(requestedPackages)) {
317
+ throw new Error("approval disclosure package names do not match the materialized artifact proof");
318
+ }
319
+ const normalizeSecurityDisclosure = (entry) => ({
320
+ name: String(entry?.name ?? ""),
321
+ version: String(entry?.version ?? ""),
322
+ selector: String(entry?.selector ?? ""),
323
+ direct: Boolean(entry?.direct),
324
+ scripts: Object.fromEntries(["preinstall", "install", "postinstall"]
325
+ .filter((key) => typeof entry?.scripts?.[key] === "string")
326
+ .map((key) => [key, entry.scripts[key]])),
327
+ contentHash: String(entry?.contentHash ?? ""),
328
+ });
329
+ const disclosedSecurity = (Array.isArray(needsApproval) ? needsApproval : [])
330
+ .map(normalizeSecurityDisclosure)
331
+ .sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version) || a.selector.localeCompare(b.selector));
332
+ const provedSecurity = canonicalProof.blockedPackages
333
+ .map(normalizeSecurityDisclosure)
334
+ .sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version) || a.selector.localeCompare(b.selector));
335
+ if (JSON.stringify(disclosedSecurity) !== JSON.stringify(provedSecurity)) {
336
+ throw new Error("approval disclosure identity, scripts, or content hash does not match the materialized artifact proof");
337
+ }
338
+ const disclosureSerialized = JSON.stringify(canonicalizeForDigest(needsApproval));
339
+ const profileFingerprint = computeProfileFingerprint(profileDir);
340
+ const warningConsent = Boolean(warningConsentActive || acceptWarningsActive);
341
+
342
+ const realProfileDir = realpathSync(profileDir);
343
+
344
+ const record = {
345
+ token,
346
+ profile,
347
+ profileDir,
348
+ realProfileDir,
349
+ spec,
350
+ reportDigest,
351
+ requestedPackages,
352
+ disclosureSerialized,
353
+ disclosureDigest: createHash("sha256").update(disclosureSerialized).digest("hex"),
354
+ proofSerialized,
355
+ proofDigest: createHash("sha256").update(proofSerialized).digest("hex"),
356
+ profileFingerprint,
357
+ surface, // "browser" | "agent"
358
+ owner: owner ?? "",
359
+ warningConsent,
360
+ issuedAt: Date.now(),
361
+ expiresAt: Date.now() + PIN_TTL,
362
+ };
363
+
364
+ approvalTokens.set(token, record);
365
+ return token;
366
+ }
367
+
368
+ export function assertValidApprovalInvocation(allowBuildScripts, approvalToken) {
369
+ const hasAllow = Array.isArray(allowBuildScripts) && allowBuildScripts.length > 0;
370
+ const hasToken = typeof approvalToken === "string" && approvalToken.trim().length > 0;
371
+ if (hasAllow && !hasToken) {
372
+ throw new Error("allowBuildScripts cannot be specified on initial install — it can only be used on retry with a valid approval token issued after install-script approval is required");
373
+ }
374
+ }
375
+
376
+ export function consumeApprovalToken({
377
+ token,
378
+ profile,
379
+ profileDir,
380
+ spec,
381
+ preflightReport,
382
+ allowBuildScripts,
383
+ surface,
384
+ owner,
385
+ }) {
386
+ if (typeof token !== "string" || token.trim().length === 0) {
387
+ return { valid: false, reason: "missing approval token" };
388
+ }
389
+ const cleanToken = token.trim();
390
+ const record = approvalTokens.get(cleanToken);
391
+ if (record === undefined) {
392
+ return { valid: false, reason: "invalid or already consumed approval token" };
393
+ }
394
+
395
+ // Atomically delete token on validation to guarantee one-shot
396
+ approvalTokens.delete(cleanToken);
397
+
398
+ if (Date.now() > record.expiresAt) {
399
+ return { valid: false, reason: "approval token expired" };
400
+ }
401
+ if (record.profile !== profile) {
402
+ return { valid: false, reason: "approval token profile mismatch" };
403
+ }
404
+ if (record.spec !== spec) {
405
+ return { valid: false, reason: "approval token spec mismatch" };
406
+ }
407
+ if (record.realProfileDir && profileDir && existsSync(profileDir)) {
408
+ try {
409
+ const currentReal = realpathSync(profileDir);
410
+ if (record.realProfileDir !== currentReal) {
411
+ return { valid: false, reason: "approval token profile directory mismatch" };
412
+ }
413
+ } catch {
414
+ return { valid: false, reason: "approval token profile directory mismatch" };
415
+ }
416
+ }
417
+ if (record.surface !== surface) {
418
+ return { valid: false, reason: "approval token surface mismatch (cannot reuse between browser and agent)" };
419
+ }
420
+ if (record.owner !== (owner ?? "")) {
421
+ return { valid: false, reason: `approval token ${surface === "browser" ? "session" : "owner"} mismatch` };
422
+ }
423
+ if (
424
+ createHash("sha256").update(record.disclosureSerialized).digest("hex") !== record.disclosureDigest
425
+ || createHash("sha256").update(record.proofSerialized).digest("hex") !== record.proofDigest
426
+ ) {
427
+ return { valid: false, reason: "approval token disclosure or artifact proof integrity mismatch" };
428
+ }
429
+
430
+ const currentFingerprint = computeProfileFingerprint(profileDir);
431
+ if (record.profileFingerprint !== currentFingerprint) {
432
+ return { valid: false, reason: "profile state changed since approval token was issued" };
433
+ }
434
+
435
+ const currentReportDigest = digestPreflightReport(preflightReport);
436
+ if (record.reportDigest !== currentReportDigest) {
437
+ return { valid: false, reason: "preflight report changed since approval token was issued" };
438
+ }
439
+
440
+ // Exact package-set equality check:
441
+ // Require exact equality between sorted unique allowBuildScripts and the exact needsApproval package set (reject missing, extra, duplicate/invalid names).
442
+ if (!Array.isArray(allowBuildScripts)) {
443
+ return { valid: false, reason: "allowBuildScripts must be an array of package names" };
444
+ }
445
+ if (allowBuildScripts.length === 0) {
446
+ return { valid: false, reason: "allowBuildScripts cannot be empty when consuming approval token" };
447
+ }
448
+
449
+ const seen = new Set();
450
+ const normalizedAllow = [];
451
+ for (const raw of allowBuildScripts) {
452
+ if (typeof raw !== "string") {
453
+ return { valid: false, reason: `invalid package name in allowBuildScripts: ${JSON.stringify(raw)}` };
454
+ }
455
+ const name = raw.trim();
456
+ if (name.length === 0 || !NPM_PACKAGE_NAME_RE.test(name)) {
457
+ return { valid: false, reason: `invalid package name in allowBuildScripts: ${JSON.stringify(raw)}` };
458
+ }
459
+ if (seen.has(name)) {
460
+ return { valid: false, reason: `duplicate package name in allowBuildScripts: ${JSON.stringify(name)}` };
461
+ }
462
+ seen.add(name);
463
+ normalizedAllow.push(name);
464
+ }
465
+
466
+ normalizedAllow.sort();
467
+
468
+ if (normalizedAllow.length !== record.requestedPackages.length) {
469
+ return {
470
+ valid: false,
471
+ reason: `allowBuildScripts package count (${normalizedAllow.length}) does not match required package count (${record.requestedPackages.length})`,
472
+ };
473
+ }
474
+
475
+ for (let i = 0; i < record.requestedPackages.length; i++) {
476
+ if (normalizedAllow[i] !== record.requestedPackages[i]) {
477
+ return {
478
+ valid: false,
479
+ reason: `allowBuildScripts mismatch: expected "${record.requestedPackages[i]}", got "${normalizedAllow[i]}"`,
480
+ };
481
+ }
482
+ }
483
+
484
+ return { valid: true, warningConsent: record.warningConsent, proof: JSON.parse(record.proofSerialized) };
485
+ }
486
+
487
+ export function invalidateApprovalToken(token, owner, surface) {
488
+ if (typeof token !== "string") return false;
489
+ const clean = token.trim();
490
+ const record = approvalTokens.get(clean);
491
+ if (!record) return false;
492
+ if (owner !== undefined && record.owner !== owner) return false;
493
+ if (surface !== undefined && record.surface !== surface) return false;
494
+ approvalTokens.delete(clean);
495
+ return true;
496
+ }
497
+
498
+ export function clearApprovalTokensFor(profile, spec, { surface, owner } = {}) {
499
+ for (const [token, record] of approvalTokens) {
500
+ if (
501
+ record.profile === profile
502
+ && (spec === undefined || record.spec === spec)
503
+ && (surface === undefined || record.surface === surface)
504
+ && (owner === undefined || record.owner === owner)
505
+ ) {
506
+ approvalTokens.delete(token);
507
+ }
508
+ }
509
+ }
510
+
511
+ const BROWSER_SESSION_RE = /^sess_(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i;
512
+
513
+ function requireBrowserSession(value) {
514
+ const session = typeof value === "string" ? value.trim() : "";
515
+ if (!BROWSER_SESSION_RE.test(session)) {
516
+ throw new Error("a valid browser session nonce is required");
517
+ }
518
+ return session;
519
+ }
520
+
521
+ /** Approval ownership on the agent surface must be a stable scalar identity,
522
+ * never the transient exec.agent object reference (a fresh facade may be
523
+ * supplied on every tool call). DSH's Agent.id is the live session identity;
524
+ * older hosts may expose the same value as session.id. */
525
+ function requireAgentApprovalOwner(exec) {
526
+ const raw = exec?.agent?.id ?? exec?.agent?.session?.id;
527
+ const owner = raw === undefined || raw === null ? "" : String(raw).trim();
528
+ if (owner.length === 0 || owner.length > 256 || /[\u0000-\u001f\u007f]/.test(owner)) {
529
+ throw new Error("market_install approval requires a stable calling agent identity");
530
+ }
531
+ return owner;
532
+ }
533
+
534
+ // ── safe restart launch resolution (cli.js guard launch) ────────────────────
535
+ //
536
+ // Marketplace restart routes through this package's absolute src/cli.js:
537
+ // node cli.js guard launch --profile <profile> -- node <absolute official DSH entry> <original args>
538
+ // using shell:false and ordinary argv. Fails closed if safe entry cannot be determined.
539
+
540
+ export const SAFE_PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
541
+ export const WINDOWS_DEVICE_BASENAME_RE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
542
+
543
+ export function assertSafeProfileName(profile, isWindows = process.platform === "win32") {
544
+ const value = String(profile ?? "");
545
+ if (!SAFE_PROFILE_NAME_RE.test(value)) {
546
+ throw new Error(`invalid profile name ${JSON.stringify(value)} — only letters, digits, '.', '_' and '-' are allowed, starting with a letter or digit`);
547
+ }
548
+ if (isWindows) {
549
+ if (/[. ]$/.test(value)) {
550
+ 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)`);
551
+ }
552
+ const deviceBase = value.replace(/\..*$/, "");
553
+ if (WINDOWS_DEVICE_BASENAME_RE.test(deviceBase)) {
554
+ throw new Error(`invalid profile name ${JSON.stringify(value)} — ${JSON.stringify(deviceBase.toUpperCase())} is a reserved Windows device name (even with an extension)`);
555
+ }
556
+ }
557
+ }
558
+
559
+ export function isSafeProfileName(profile, isWindows = process.platform === "win32") {
560
+ const value = String(profile ?? "");
561
+ if (!SAFE_PROFILE_NAME_RE.test(value)) return false;
562
+ if (isWindows) {
563
+ if (/[. ]$/.test(value)) return false;
564
+ const deviceBase = value.replace(/\..*$/, "");
565
+ if (WINDOWS_DEVICE_BASENAME_RE.test(deviceBase)) return false;
566
+ }
567
+ return true;
568
+ }
569
+
570
+ function packageRootFromEntry(entryPath) {
571
+ const parts = String(entryPath).split(/[\\/]+/);
572
+ for (let index = parts.length - 3; index >= 0; index--) {
573
+ if (
574
+ parts[index].toLowerCase() === "node_modules" &&
575
+ parts[index + 1].toLowerCase() === "@deepseek-ai" &&
576
+ parts[index + 2].toLowerCase() === "dsh"
577
+ ) {
578
+ return parts.slice(0, index + 3).join(sep);
579
+ }
580
+ }
581
+ return undefined;
582
+ }
583
+
584
+ function officialDshEntryFromRoot(pkgRoot) {
585
+ const manifestPath = join(pkgRoot, "package.json");
586
+ if (!existsSync(manifestPath)) return undefined;
587
+ let manifest;
588
+ try {
589
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
590
+ } catch {
591
+ return undefined;
592
+ }
593
+ if (manifest?.name !== "@deepseek-ai/dsh") return undefined;
594
+ const binRel = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.dsh;
595
+ if (typeof binRel !== "string" || binRel.length === 0) return undefined;
596
+ const root = resolve(pkgRoot);
597
+ const entry = resolve(root, binRel);
598
+ const rel = relative(root, entry);
599
+ if (rel.startsWith("..") || isAbsolute(rel)) return undefined;
600
+ if (!/\.js$/i.test(entry)) return undefined;
601
+ if (!existsSync(entry)) return undefined;
602
+ return entry;
603
+ }
604
+
605
+ function entryIsSelf(entry) {
606
+ try {
607
+ const a = realpathSync(entry);
608
+ const b = realpathSync(fileURLToPath(import.meta.url));
609
+ const cliB = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), "cli.js"));
610
+ const same = (x, y) => (process.platform === "win32" ? x.toLowerCase() === y.toLowerCase() : x === y);
611
+ return same(a, b) || same(a, cliB);
612
+ } catch {
613
+ return false;
614
+ }
615
+ }
616
+
617
+ function entryFromShimText(shim, binDir) {
618
+ let text;
619
+ try {
620
+ text = readFileSync(shim, "utf8").slice(0, 65536);
621
+ } catch {
622
+ return undefined;
623
+ }
624
+ const match = /["']([^"'\r\n]*@deepseek-ai[\\/]dsh[\\/][^"'\r\n]*?\.js)["']/.exec(text);
625
+ if (match === null) return undefined;
626
+ let entry = match[1];
627
+ if (entry.startsWith("%dp0%")) entry = binDir + entry.slice("%dp0%".length);
628
+ else if (entry.startsWith("$basedir")) entry = binDir + entry.slice("$basedir".length);
629
+ return entry;
630
+ }
631
+
632
+ function resolveDshShim() {
633
+ if (process.platform === "win32") {
634
+ const pathEnv = process.env.PATH ?? "";
635
+ const pathext = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD;.PS1").split(";");
636
+ for (const dir of pathEnv.split(";")) {
637
+ if (dir.length === 0) continue;
638
+ for (const ext of pathext) {
639
+ const full = join(dir, `dsh${ext}`);
640
+ if (existsSync(full)) return full;
641
+ }
642
+ }
643
+ return undefined;
644
+ }
645
+ for (const dir of (process.env.PATH ?? "").split(":")) {
646
+ if (dir.length === 0) continue;
647
+ const full = join(dir, "dsh");
648
+ if (existsSync(full)) return full;
649
+ }
650
+ return undefined;
651
+ }
652
+
653
+ export function resolveCurrentDshEntry() {
654
+ const invoked = process.argv[1];
655
+ if (typeof invoked === "string" && invoked.length > 0 && existsSync(invoked)) {
656
+ try {
657
+ const real = realpathSync(invoked);
658
+ if (/\.js$/i.test(real) && !entryIsSelf(real)) {
659
+ const root = packageRootFromEntry(real);
660
+ if (root !== undefined) {
661
+ const entry = officialDshEntryFromRoot(root);
662
+ if (entry !== undefined && !entryIsSelf(entry)) return entry;
663
+ }
664
+ }
665
+ const fromText = entryFromShimText(invoked, dirname(invoked));
666
+ if (fromText !== undefined) {
667
+ const root = packageRootFromEntry(fromText);
668
+ if (root !== undefined) {
669
+ const entry = officialDshEntryFromRoot(root);
670
+ if (entry !== undefined && !entryIsSelf(entry)) return entry;
671
+ }
672
+ }
673
+ } catch {
674
+ /* ignore */
675
+ }
676
+ }
677
+
678
+ const shim = resolveDshShim();
679
+ if (shim !== undefined) {
680
+ const binDir = dirname(shim);
681
+ const roots = [];
682
+ try {
683
+ const real = realpathSync(shim);
684
+ if (/\.js$/i.test(real) && !entryIsSelf(real)) {
685
+ const root = packageRootFromEntry(real);
686
+ if (root !== undefined) roots.push(root);
687
+ }
688
+ } catch {
689
+ /* ignore */
690
+ }
691
+ const fromText = entryFromShimText(shim, binDir);
692
+ if (fromText !== undefined) {
693
+ const root = packageRootFromEntry(fromText);
694
+ if (root !== undefined) roots.push(root);
695
+ }
696
+ roots.push(join(binDir, "node_modules", "@deepseek-ai", "dsh"));
697
+ roots.push(resolve(binDir, "..", "lib", "node_modules", "@deepseek-ai", "dsh"));
698
+ for (const root of roots) {
699
+ const entry = officialDshEntryFromRoot(root);
700
+ if (entry !== undefined && !entryIsSelf(entry)) return entry;
701
+ }
702
+ }
703
+
704
+ return undefined;
705
+ }
706
+
707
+ export function resolveRestartLaunchPlan({ profile, config = {}, isWindows }) {
708
+ if (config.allowRestart === false) {
709
+ return { ok: false, error: "restart disabled by config (allowRestart: false)" };
710
+ }
711
+
712
+ const name = String(profile ?? "").trim();
713
+ if (name.length === 0) {
714
+ return { ok: false, error: "restart requires a target profile name" };
715
+ }
716
+ try {
717
+ // isWindows 显式传入时覆盖平台默认(离线 fixture 用它跨平台钉死 Windows 语义)。
718
+ assertSafeProfileName(name, isWindows === undefined ? undefined : isWindows === true);
719
+ } catch (error) {
720
+ return { ok: false, error: error.message };
721
+ }
722
+
723
+ const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "cli.js");
724
+ if (!existsSync(cliPath)) {
725
+ return { ok: false, error: `cannot locate guard CLI at ${cliPath}` };
726
+ }
727
+
728
+ const dshEntry = resolveCurrentDshEntry();
729
+ if (dshEntry === undefined) {
730
+ return { ok: false, error: "cannot resolve verified official @deepseek-ai/dsh CLI entry for automatic restart under guard probation — please restart manually" };
731
+ }
732
+
733
+ const nodePath = process.execPath;
734
+ const originalDshArgs = process.argv.slice(2);
735
+ const args = [cliPath, "guard", "launch", "--profile", name, "--", nodePath, dshEntry, ...originalDshArgs];
736
+
737
+ return {
738
+ ok: true,
739
+ nodePath,
740
+ args,
741
+ cliPath,
742
+ dshEntry,
743
+ profile: name,
744
+ };
745
+ }
746
+
747
+ // ── in-process job tracker for browser RPC ───────────────────────────────────
748
+
749
+ let trackerCounter = 0;
750
+
751
+ export function createJobTracker({ producerFactory } = {}) {
752
+ const records = new Map();
753
+ const prune = () => {
754
+ const now = Date.now();
755
+ for (const [id, record] of records) {
756
+ const terminal = record.status !== "running";
757
+ if (terminal && record.finishedAt !== undefined && now - record.finishedAt > 3600000) records.delete(id);
758
+ }
759
+ if (records.size > 20) {
760
+ const ordered = [...records.entries()].sort((a, b) => a[1].startedAt - b[1].startedAt);
761
+ for (const [id, record] of ordered) {
762
+ if (records.size <= 20) break;
763
+ if (record.status !== "running") records.delete(id);
764
+ }
765
+ }
766
+ };
767
+
768
+ return {
769
+ start({
770
+ profile,
771
+ spec,
772
+ verb = "add",
773
+ allowBuildScripts,
774
+ approvedProof,
775
+ preflight,
776
+ profileDir,
777
+ acceptWarningsActive = false,
778
+ surface = "browser",
779
+ session,
780
+ onSettled,
781
+ producerFactory: startProducerFactory,
782
+ }) {
783
+ const id = `market-${++trackerCounter}`;
784
+ const kind = verb === "remove" ? "dsh-plugin-uninstall" : "dsh-plugin-install";
785
+ const factory = startProducerFactory ?? producerFactory;
786
+ const producer = typeof factory === "function"
787
+ ? factory({ profile, spec, verb, allowBuildScripts, approvedProof, preflight, profileDir })
788
+ : verb === "remove"
789
+ ? runRemove({ profile, packageName: spec })
790
+ : runInstall({ profile, spec, allowBuildScripts, approvedProof, preflight });
791
+
792
+ const record = {
793
+ id,
794
+ kind,
795
+ label: `dsh plugin --profile ${profile} ${verb} ${spec}`,
796
+ profile,
797
+ spec,
798
+ surface,
799
+ session: session ?? "",
800
+ status: "running",
801
+ detail: undefined,
802
+ needsApproval: undefined,
803
+ approvalToken: undefined,
804
+ startedAt: Date.now(),
805
+ finishedAt: undefined,
806
+ producer,
807
+ };
808
+
809
+ // Ensure producer.done rejection is caught and always ends in terminal failed state
810
+ Promise.resolve(producer.done)
811
+ .catch((error) => ({
812
+ status: "failed",
813
+ detail: `${verb === "remove" ? "remove" : "install"} of ${spec} hit an unexpected error: ${error?.message ?? String(error)}`,
814
+ }))
815
+ .then((outcome) => {
816
+ const status = outcome?.status ?? "failed";
817
+ record.status = status;
818
+ record.detail = outcome?.detail;
819
+ record.needsApproval = outcome?.needsApproval;
820
+ record.finishedAt = Date.now();
821
+
822
+ if (status === "completed") {
823
+ if (profileDir) invalidatePreflightFor(profileDir);
824
+ clearApprovalTokensFor(profile, spec);
825
+ } else if (outcome?.needsApproval && outcome.needsApproval.length > 0) {
826
+ clearApprovalTokensFor(profile, spec, { surface: record.surface, owner: record.session });
827
+ const token = issueApprovalToken({
828
+ profile,
829
+ profileDir,
830
+ spec,
831
+ preflightReport: preflight,
832
+ needsApproval: outcome.needsApproval,
833
+ proof: outcome.proof,
834
+ surface: record.surface,
835
+ owner: record.session,
836
+ acceptWarningsActive,
837
+ });
838
+ record.approvalToken = token;
839
+ } else {
840
+ clearApprovalTokensFor(profile, spec, { surface: record.surface, owner: record.session });
841
+ }
842
+
843
+ try {
844
+ onSettled?.(outcome);
845
+ } catch (e) {
846
+ console.error("[dsh-plugin-mall] onSettled error:", e);
847
+ }
848
+ })
849
+ .catch((fatalError) => {
850
+ record.status = "failed";
851
+ record.detail = `internal error: ${fatalError?.message ?? String(fatalError)}`;
852
+ record.finishedAt = Date.now();
853
+ clearApprovalTokensFor(profile, spec, { surface: record.surface, owner: record.session });
854
+ });
855
+
856
+ records.set(id, record);
857
+ prune();
858
+ return id;
859
+ },
860
+
861
+ get(jobId, session) {
862
+ const record = records.get(String(jobId));
863
+ if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
864
+ const isSameSession = record.surface !== "browser" || (record.session !== "" && record.session === session);
865
+ return {
866
+ snapshot: {
867
+ id: record.id,
868
+ kind: record.kind,
869
+ label: record.label,
870
+ status: record.status,
871
+ detail: record.detail,
872
+ needsApproval: record.needsApproval,
873
+ approvalToken: isSameSession ? record.approvalToken : undefined,
874
+ spec: record.spec,
875
+ startedAt: record.startedAt,
876
+ finishedAt: record.finishedAt,
877
+ },
878
+ output: typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "",
879
+ };
880
+ },
881
+
882
+ cancel(jobId, session) {
883
+ const record = records.get(String(jobId));
884
+ if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
885
+ if (record.surface === "browser" && (record.session === "" || record.session !== session)) {
886
+ throw new Error("unauthorized to cancel job from another session");
887
+ }
888
+ if (typeof record.producer?.cancel === "function") {
889
+ record.producer.cancel();
890
+ }
891
+ if (record.approvalToken) {
892
+ invalidateApprovalToken(record.approvalToken, record.session || undefined, record.surface);
893
+ record.approvalToken = undefined;
894
+ }
895
+ clearApprovalTokensFor(record.profile, record.spec, { surface: record.surface, owner: record.session });
896
+ return "requested";
897
+ },
898
+
899
+ dismiss(jobId, session) {
900
+ const record = records.get(String(jobId));
901
+ if (record === undefined) return false;
902
+ if (record.surface === "browser" && (record.session === "" || record.session !== session)) {
903
+ return false;
904
+ }
905
+ if (record.approvalToken) {
906
+ invalidateApprovalToken(record.approvalToken, record.session || undefined, record.surface);
907
+ record.approvalToken = undefined;
908
+ }
909
+ return true;
910
+ },
911
+ };
912
+ }
913
+
914
+ /** Render one preflight issue as a compact line for model/error output. */
915
+ function renderPreflightIssue(entry) {
916
+ const badge = entry.severity === "block" ? "BLOCK" : "WARN";
917
+ return ` [${badge}] ${entry.title}: ${entry.detail}`;
918
+ }
919
+
920
+ /**
921
+ * Enforce a preflight verdict for an install. Throws when a blocker exists, or
922
+ * when there are only warnings and acceptWarnings is not true.
923
+ */
924
+ function enforcePreflight(report, acceptWarnings, label) {
925
+ if (report.verdict === "blocked") {
926
+ const error = new Error(`${label}: ${report.summary}\n${report.issues.filter((entry) => entry.severity === "block").map(renderPreflightIssue).join("\n")}`);
927
+ error.preflight = report;
928
+ throw error;
929
+ }
930
+ if (report.verdict === "warning" && acceptWarnings !== true) {
931
+ const error = new Error(`${label}: ${report.summary}\n${report.issues.filter((entry) => entry.severity === "warn").map(renderPreflightIssue).join("\n")}\n\nTo continue, show these warnings to the user and, after their explicit confirmation, call again with acceptWarnings: true.`);
932
+ error.preflight = report;
933
+ throw error;
934
+ }
935
+ }
936
+
48
937
  /** Clip long strings for compact model-facing output. */
49
938
  function clip(text, max) {
50
939
  const trimmed = String(text ?? "").replace(/\s+/g, " ").trim();
@@ -131,39 +1020,20 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
131
1020
  };
132
1021
 
133
1022
  // ── browser RPC channel (/market) ───────────────────────────────────────────
134
- //
135
- // The web UI half (src/client.js) talks to this node half through the
136
- // Connection service's generic RPC channels (`connection.rpc.handle`). The
137
- // shared /api channel belongs to the api-gateway, so the marketplace owns its
138
- // own loopback-only channel. Every endpoint answers `{ok:true,value}` or
139
- // `{ok:false,error}` — the client unwraps this envelope itself.
140
1023
 
141
1024
  function rpcOk(value) {
142
1025
  return { ok: true, value };
143
1026
  }
144
1027
 
145
1028
  function rpcFail(error) {
146
- // dsh connection RPC 的响应信封校验(dsh-client-connection rpcResultSchema)
147
- // 要求 error 为 discriminated object:{code, message, details}。code 取
148
- // 通用 "internal",否则整条错误会被 zod 以 invalid_union 吞掉。
149
1029
  return { ok: false, error: { code: "internal", message: error?.message ?? String(error), details: {} } };
150
1030
  }
151
1031
 
152
1032
  /**
153
- * Dispatch one /market RPC endpoint. Runs inside the plugin fiber, so it
154
- * shares the tools' GitHub helpers and the install tracker. The agent-plane
155
- * tools keep using ctx.jobs; the browser surface uses `tracker` because the
156
- * web host plane has no job controller for ctx.jobs to serve.
157
- * @param ctx - plugin context.
158
- * @param endpoint - "search" | "info" | "installed" | "install" | "uninstall" | "job" | "jobCancel".
159
- * @param payload - endpoint arguments from the browser.
160
- * @param config - the row config (defaultProfile, apiBase, perPageMax).
161
- * @param token - GitHub token from the environment.
162
- * @param tracker - the in-process install tracker.
163
- * @returns the {ok, value|error} envelope.
1033
+ * Dispatch one /market RPC endpoint.
164
1034
  */
165
1035
  async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
166
- const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, allowRestart = true, npmRegistry = "", rawSources = [] } = config;
1036
+ const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, npmRegistry = "", rawSources = [] } = config;
167
1037
  switch (endpoint) {
168
1038
  case "search": {
169
1039
  const perPage = Math.min(Math.max(Math.trunc(payload?.perPage ?? 10) || 10, 1), Math.trunc(perPageMax) || 30);
@@ -182,6 +1052,54 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
182
1052
  const result = await verifyPlugins({ repos: payload?.repos, sources: rawSources });
183
1053
  return rpcOk(result);
184
1054
  }
1055
+ case "compat": {
1056
+ // Browsing-time conflict badge: statically scan each repo's manifest
1057
+ // (and declared bundle patch, fetched as text) against the profile —
1058
+ // the same checks the install preflight runs, minus the probe install.
1059
+ const profile = String(payload?.profile ?? defaultProfile).trim();
1060
+ let profileDir;
1061
+ try {
1062
+ profileDir = resolveProfileDir(profile);
1063
+ } catch (error) {
1064
+ return rpcFail(new Error(`invalid profile: ${error.message}`));
1065
+ }
1066
+ const repos = [...new Set((Array.isArray(payload?.repos) ? payload.repos : []).map(String)
1067
+ .filter((repo) => /^[^@/\s][^/\s]*\/[^/\s]+$/.test(repo) && !repo.includes("..")))].slice(0, 30);
1068
+ if (repos.length === 0) return rpcOk({ results: {} });
1069
+ await verifyPlugins({ repos, sources: rawSources }); // populates the manifest cache
1070
+ let fingerprint;
1071
+ const results = {};
1072
+ await mapLimit(repos, NETWORK_CONCURRENCY, async (repo) => {
1073
+ const manifest = cachedRepoManifest(repo);
1074
+ if (manifest === undefined || typeof manifest !== "object") {
1075
+ results[repo] = { state: "unknown", summary: "无法获取插件清单,兼容性未知" };
1076
+ return;
1077
+ }
1078
+ try {
1079
+ let patchText;
1080
+ if (typeof manifest.dsh?.bundle?.patch === "string") {
1081
+ patchText = await fetchRawFile(repo, manifest.dsh.bundle.patch, { sources: rawSources });
1082
+ }
1083
+ fingerprint ??= computeProfileFingerprint(profileDir);
1084
+ const cacheKey = `${fingerprint}::${repo}`;
1085
+ const hit = compatCacheGet(cacheKey);
1086
+ if (hit !== undefined) { results[repo] = hit; return; }
1087
+ const report = inspectRemoteCandidate({ profileDir, manifest, patchText, spec: `github:${repo}` });
1088
+ const entry = {
1089
+ state: report.verdict === "blocked" ? "conflict" : report.verdict === "warning" ? "warning" : "compatible",
1090
+ name: report.candidate.name,
1091
+ summary: report.summary,
1092
+ issues: report.issues.slice(0, 3).map(({ severity, title }) => ({ severity, title })),
1093
+ patchChecked: typeof manifest.dsh?.bundle?.patch === "string" ? report.issues.every((item) => item.code !== "patch-unverified") : true,
1094
+ };
1095
+ compatCacheSet(cacheKey, entry);
1096
+ results[repo] = entry;
1097
+ } catch {
1098
+ results[repo] = { state: "unknown", summary: "兼容性检查失败" };
1099
+ }
1100
+ });
1101
+ return rpcOk({ results });
1102
+ }
185
1103
  case "updates": {
186
1104
  const profile = String(payload?.profile ?? defaultProfile).trim();
187
1105
  let deps;
@@ -193,7 +1111,6 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
193
1111
  }
194
1112
  const registry = await registryFor(profile, npmRegistry);
195
1113
  const results = {};
196
- // 同 verifyPlugins 的 worker 池,而不是对所有依赖一次性扇出。
197
1114
  await mapLimit(deps, NETWORK_CONCURRENCY, async (dep) => {
198
1115
  if (dep.kind === "missing") { results[dep.name] = { latest: null }; return; }
199
1116
  const info = await npmPackageInfo(dep.name, { registry });
@@ -216,8 +1133,32 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
216
1133
  }
217
1134
  return rpcOk(listInstalled(profile));
218
1135
  }
1136
+ case "preflight": {
1137
+ const profile = String(payload?.profile ?? defaultProfile).trim();
1138
+ let spec;
1139
+ try {
1140
+ spec = normalizeSpec(payload?.spec);
1141
+ assertSafeSpec(spec);
1142
+ } catch (error) {
1143
+ return rpcFail(error);
1144
+ }
1145
+ try {
1146
+ const registry = await registryFor(profile, npmRegistry);
1147
+ spec = await preferNpmSpec({ spec, registry, sources: rawSources });
1148
+ const { report } = await runPreflight({ profile, spec });
1149
+ return rpcOk(report);
1150
+ } catch (error) {
1151
+ return rpcFail(error);
1152
+ }
1153
+ }
219
1154
  case "install": {
220
1155
  const profile = String(payload?.profile ?? defaultProfile).trim();
1156
+ let session;
1157
+ try {
1158
+ session = requireBrowserSession(payload?.session);
1159
+ } catch (error) {
1160
+ return rpcFail(error);
1161
+ }
221
1162
  let spec;
222
1163
  try {
223
1164
  spec = normalizeSpec(payload?.spec);
@@ -225,38 +1166,87 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
225
1166
  } catch (error) {
226
1167
  return rpcFail(error);
227
1168
  }
228
- // npm tarball 优先(小而快、带 integrity);registry 条目不同源的包名
229
- // 视为抢注,回退 github: 全仓库 spec。查的 registry 必须是 pnpm 实际
230
- // 安装用的那个,否则镜像用户这里永远比对不上、次次退化成全仓库克隆。
231
1169
  const registry = await registryFor(profile, npmRegistry);
232
1170
  spec = await preferNpmSpec({ spec, registry, sources: rawSources });
233
- // 宿主依赖硬拦:dependencies 里拖着 @deepseek-ai/* 的包装进 profile
234
- // 就是双模块实例 + 全工具调度崩溃(宿主无任何护栏,市场是最后防线)。
235
1171
  try {
236
1172
  await assertSafeToInstall({ spec, registry, sources: rawSources });
237
1173
  } catch (error) {
238
1174
  return rpcFail(error);
239
1175
  }
1176
+ const allowBuildScripts = Array.isArray(payload?.allowBuildScripts)
1177
+ ? payload.allowBuildScripts.map((name) => String(name))
1178
+ : undefined;
1179
+ const approvalToken = typeof payload?.approvalToken === "string" && payload.approvalToken.trim().length > 0
1180
+ ? payload.approvalToken.trim()
1181
+ : undefined;
1182
+
240
1183
  try {
241
- const profileDir = resolveProfileDir(profile);
242
- if (!existsSync(join(profileDir, "package.json"))) ensureProfile(profile);
1184
+ assertValidApprovalInvocation(allowBuildScripts, approvalToken);
243
1185
  } catch (error) {
244
- return rpcFail(new Error(`invalid profile: ${error.message}`));
1186
+ return rpcFail(error);
1187
+ }
1188
+
1189
+ let preflight;
1190
+ let acceptWarnings = false;
1191
+ let acceptWarningsActive = false;
1192
+ let approvedProof = undefined;
1193
+ try {
1194
+ preflight = await runPreflight({ profile, spec });
1195
+ if (approvalToken !== undefined) {
1196
+ const consumeResult = consumeApprovalToken({
1197
+ token: approvalToken,
1198
+ profile,
1199
+ profileDir: preflight.profileDir,
1200
+ spec,
1201
+ preflightReport: preflight.report,
1202
+ allowBuildScripts,
1203
+ surface: "browser",
1204
+ owner: session,
1205
+ });
1206
+ if (!consumeResult.valid) {
1207
+ return rpcFail(new Error(`invalid approval token: ${consumeResult.reason}`));
1208
+ }
1209
+ acceptWarnings = consumeResult.warningConsent;
1210
+ acceptWarningsActive = consumeResult.warningConsent;
1211
+ approvedProof = consumeResult.proof;
1212
+ } else {
1213
+ acceptWarnings = payload?.acceptWarnings === true;
1214
+ acceptWarningsActive = acceptWarnings;
1215
+ }
1216
+ enforcePreflight(preflight.report, acceptWarnings, `install ${spec}`);
1217
+ } catch (error) {
1218
+ return rpcFail(error);
245
1219
  }
1220
+
1221
+ pinPreflight(preflight.profileDir, spec);
246
1222
  try {
247
- // 构建脚本的同意是「点名」的:只放行清单里这几个包。重试时被拦的集合
248
- // 变了(依赖更新、换了版本),旧的同意不会顺延到新出现的包上。
249
- const allowBuildScripts = Array.isArray(payload?.allowBuildScripts)
250
- ? payload.allowBuildScripts.map((name) => String(name))
251
- : undefined;
252
- const jobId = tracker.start({ profile, spec, allowBuildScripts });
253
- return rpcOk({ jobId, profile, spec });
1223
+ const jobId = tracker.start({
1224
+ profile,
1225
+ spec,
1226
+ allowBuildScripts,
1227
+ approvedProof,
1228
+ preflight: preflight.report,
1229
+ profileDir: preflight.profileDir,
1230
+ acceptWarningsActive,
1231
+ surface: "browser",
1232
+ session,
1233
+ onSettled: (outcome) => {
1234
+ if (outcome?.status === "completed") invalidatePreflightFor(preflight.profileDir);
1235
+ },
1236
+ });
1237
+ return rpcOk({ jobId, profile, spec, preflight: { verdict: preflight.report.verdict, summary: preflight.report.summary } });
254
1238
  } catch (error) {
255
1239
  return rpcFail(error);
256
1240
  }
257
1241
  }
258
1242
  case "uninstall": {
259
1243
  const profile = String(payload?.profile ?? defaultProfile).trim();
1244
+ let session;
1245
+ try {
1246
+ session = requireBrowserSession(payload?.session);
1247
+ } catch (error) {
1248
+ return rpcFail(error);
1249
+ }
260
1250
  const packageName = String(payload?.package ?? "").trim();
261
1251
  if (packageName.length === 0) return rpcFail(new Error("uninstall: package name is required"));
262
1252
  try {
@@ -264,8 +1254,9 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
264
1254
  } catch (error) {
265
1255
  return rpcFail(error);
266
1256
  }
1257
+ let profileDir;
267
1258
  try {
268
- const profileDir = resolveProfileDir(profile);
1259
+ profileDir = resolveProfileDir(profile);
269
1260
  if (!existsSync(join(profileDir, "package.json"))) {
270
1261
  return rpcFail(new Error(`profile "${profile}" has no package.json — nothing installed to remove`));
271
1262
  }
@@ -273,7 +1264,20 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
273
1264
  return rpcFail(new Error(`invalid profile: ${error.message}`));
274
1265
  }
275
1266
  try {
276
- const jobId = tracker.start({ profile, spec: packageName, verb: "remove" });
1267
+ const jobId = tracker.start({
1268
+ profile,
1269
+ spec: packageName,
1270
+ verb: "remove",
1271
+ profileDir,
1272
+ surface: "browser",
1273
+ session,
1274
+ onSettled: (outcome) => {
1275
+ if (outcome?.status === "completed") {
1276
+ invalidatePreflightFor(profileDir);
1277
+ clearApprovalTokensFor(profile);
1278
+ }
1279
+ },
1280
+ });
277
1281
  return rpcOk({ jobId, profile, package: packageName });
278
1282
  } catch (error) {
279
1283
  return rpcFail(error);
@@ -281,33 +1285,54 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
281
1285
  }
282
1286
  case "job": {
283
1287
  try {
284
- return rpcOk(tracker.get(payload?.jobId));
1288
+ const session = requireBrowserSession(payload?.session);
1289
+ return rpcOk(tracker.get(payload?.jobId, session));
285
1290
  } catch (error) {
286
1291
  return rpcFail(error);
287
1292
  }
288
1293
  }
289
1294
  case "restart": {
290
- // 一键重启:detached 拉起新 dsh 进程(用当前进程的 argv 重建启动命令)
291
- // 后退出自己。仅 loopback 直连可调(channel 级 authority 已限制);
292
- // allowRestart:false 时禁用(进程由 systemd/pm2 等托管时接管重启)。
293
- if (allowRestart !== true) return rpcFail(new Error("restart disabled by config (allowRestart: false)"));
294
- const script = process.argv[1];
295
- const scriptArgs = process.argv.slice(2);
296
- if (typeof script !== "string" || script.length === 0 || !existsSync(script)) {
297
- return rpcFail(new Error("cannot determine the dsh launch command for an automatic restart — please restart manually"));
298
- }
299
- const relaunch = `"${process.execPath}" "${script}"${scriptArgs.length > 0 ? ` ${scriptArgs.map((arg) => `"${arg}"`).join(" ")}` : ""}`;
300
- const launcher = process.platform === "win32"
301
- ? `timeout /t 2 /nobreak >nul & ${relaunch}`
302
- : `sleep 2 && ${relaunch}`;
303
- const child = spawn(launcher, { shell: true, detached: true, stdio: "ignore", cwd: process.cwd(), windowsHide: true });
1295
+ const profile = String(payload?.profile ?? defaultProfile).trim();
1296
+ try {
1297
+ requireBrowserSession(payload?.session);
1298
+ } catch (error) {
1299
+ return rpcFail(error);
1300
+ }
1301
+ const plan = resolveRestartLaunchPlan({ profile, config });
1302
+ if (!plan.ok) {
1303
+ return rpcFail(new Error(plan.error));
1304
+ }
1305
+ const child = spawn(plan.nodePath, plan.args, {
1306
+ shell: false,
1307
+ detached: true,
1308
+ stdio: "ignore",
1309
+ cwd: process.cwd(),
1310
+ windowsHide: true,
1311
+ });
304
1312
  child.unref();
305
- setTimeout(() => process.exit(0), 1500);
1313
+ setTimeout(() => process.exit(0), 1000);
306
1314
  return rpcOk({ restarting: true });
307
1315
  }
308
1316
  case "jobCancel": {
309
1317
  try {
310
- return rpcOk({ result: tracker.cancel(payload?.jobId) });
1318
+ const session = requireBrowserSession(payload?.session);
1319
+ return rpcOk({ result: tracker.cancel(payload?.jobId, session) });
1320
+ } catch (error) {
1321
+ return rpcFail(error);
1322
+ }
1323
+ }
1324
+ case "jobDismiss": {
1325
+ try {
1326
+ const session = requireBrowserSession(payload?.session);
1327
+ const token = typeof payload?.token === "string" && payload.token.trim().length > 0 ? payload.token.trim() : undefined;
1328
+ let dismissed = false;
1329
+ if (payload?.jobId) {
1330
+ dismissed = tracker.dismiss(payload.jobId, session) || dismissed;
1331
+ }
1332
+ if (token) {
1333
+ dismissed = invalidateApprovalToken(token, session, "browser") || dismissed;
1334
+ }
1335
+ return rpcOk({ dismissed });
311
1336
  } catch (error) {
312
1337
  return rpcFail(error);
313
1338
  }
@@ -318,13 +1343,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
318
1343
  }
319
1344
 
320
1345
  /**
321
- * Register the /market RPC channel once the Connection service exists (web
322
- * profiles). `ctx.inject` defers the callback until the service is provided —
323
- * activation order never races — and in headless/test profiles the callback
324
- * simply never runs, so the agent tools remain the only surface there.
325
- * @param ctx - plugin context.
326
- * @param config - the row config.
327
- * @param token - GitHub token from the environment.
1346
+ * Register the /market RPC channel once the Connection service exists.
328
1347
  */
329
1348
  function registerRpcChannel(ctx, config, token) {
330
1349
  const tracker = createJobTracker();
@@ -333,9 +1352,6 @@ function registerRpcChannel(ctx, config, token) {
333
1352
  try {
334
1353
  return await rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker);
335
1354
  } catch (error) {
336
- // 没有这层兜底时连接层只会回一个 HTTP 500 "transport failure",
337
- // 真实异常既到不了浏览器也不留痕。透传错误文本,同时把堆栈
338
- // 打进 dsh 进程的 stderr(前台运行时可见)。
339
1355
  console.error(`[dsh-plugin-mall] /market/${String(endpoint)} failed:`, error);
340
1356
  return rpcFail(error);
341
1357
  }
@@ -350,7 +1366,7 @@ export function apply(ctx, config = {}) {
350
1366
  ctx.systemPrompt.section({
351
1367
  name: "tool:market",
352
1368
  order: 120,
353
- text: "The dsh plugin marketplace tools are available: market_search discovers plugins on the GitHub dsh-plugin topic, market_info inspects one repository, market_install installs a plugin into a dsh profile as a background job (poll with job_output), market_uninstall removes an installed plugin from a dsh profile as a background job, and market_installed lists a profile's plugins. A successful market_install or market_uninstall only takes effect after the dsh process restarts — remind the user to restart. Prefer plugins with meaningful stars and a dsh.bundle declaration (market_info shows both). If market_install stops for install-script approval, that decision is the user's: show them the reported package names and commands and wait for an answer — never approve on their behalf.",
1369
+ text: "The dsh plugin marketplace tools are available: market_search discovers plugins on the GitHub dsh-plugin topic, market_info inspects one repository, market_install installs a plugin into a dsh profile as a background job (poll with job_output), market_uninstall removes an installed plugin from a dsh profile as a background job, and market_installed lists a profile's plugins. A successful market_install or market_uninstall only takes effect after the dsh process restarts — remind the user to restart. Prefer plugins with meaningful stars and a dsh.bundle declaration (market_info shows both). market_install runs an isolated preflight before installing (the candidate is probed with install scripts disabled and scanned for conflicts); a blocker refuses the install and warnings require acceptWarnings: true after the user confirms them — never set it on the user's behalf. If market_install stops for install-script approval, that decision is also the user's: show them the reported package names and commands and wait for an answer — never approve on their behalf.",
354
1370
  });
355
1371
 
356
1372
  ctx.tools.register(defineTool({
@@ -428,7 +1444,7 @@ export function apply(ctx, config = {}) {
428
1444
 
429
1445
  ctx.tools.register(defineTool({
430
1446
  name: "market_install",
431
- description: "Install a plugin into a local dsh profile by running `pnpm add` in that profile's directory, reconciling the profile's bundle layer list, and — for browser-side UI plugins (`dsh.client`) — registering a loader row in the profile's cordis.patch.yml. Same flow as `dsh plugin --profile <name> add <spec>`. ALWAYS runs as a background job: the call returns a job id immediately; poll with job_output and cancel with job_kill. If pnpm blocks a dependency's install scripts, the job STOPS and reports which packages want to run install-time code, what those commands are, and whether each is the plugin itself or a transitive dependency the user never chose — nothing is executed and the profile is left untouched. Relay that list to the user verbatim, and only call again with `allowBuildScripts` naming the packages they approved. A successful install only takes effect after the dsh process restarts.",
1447
+ description: "Install a plugin into a local dsh profile by running `pnpm add` in that profile's directory, reconciling the profile's bundle layer list, and — for browser-side UI plugins (`dsh.client`) — registering a loader row in the profile's cordis.patch.yml. Same flow as `dsh plugin --profile <name> add <spec>`. ALWAYS runs as a background job: the call returns a job id immediately; poll with job_output and cancel with job_kill. The install is gated by an isolated preflight (the candidate is installed with scripts disabled into a throwaway directory and scanned for manifest/patch conflicts, host-module shadowing, and version/OS incompatibilities). A blocker refuses the install outright; a warning requires the USER's explicit consent via `acceptWarnings: true` — show the reported warnings verbatim and get their answer first, never consent on their behalf. If pnpm blocks a dependency's install scripts, the job STOPS and reports which packages want to run install-time code, what those commands are, whether each is the plugin itself or a transitive dependency, and issues a one-shot approval token. Relay that list to the user verbatim, and only call again with `allowBuildScripts` naming the packages they approved along with `approvalToken`. A successful install only takes effect after the dsh process restarts.",
432
1448
  parameters: {
433
1449
  spec: {
434
1450
  type: "string",
@@ -439,6 +1455,14 @@ export function apply(ctx, config = {}) {
439
1455
  type: "string",
440
1456
  description: `Target profile under $DSH_HOME/profiles. Defaults to "${defaultProfile}".`,
441
1457
  },
1458
+ acceptWarnings: {
1459
+ type: "boolean",
1460
+ description: "Set true only after the USER has explicitly confirmed they accept the preflight warnings the previous call reported. Without it, an install whose preflight found only warnings is refused. Never set this on your own initiative.",
1461
+ },
1462
+ approvalToken: {
1463
+ type: "string",
1464
+ description: "Opaque one-shot approval token issued when a previous install paused for install script approval. Required on retry if the install had accepted preflight warnings.",
1465
+ },
442
1466
  allowBuildScripts: {
443
1467
  type: "array",
444
1468
  items: { type: "string" },
@@ -463,23 +1487,84 @@ export function apply(ctx, config = {}) {
463
1487
  const registry = await registryFor(profile, npmRegistry);
464
1488
  const spec = await preferNpmSpec({ spec: normalized, registry, sources: rawSources });
465
1489
  await assertSafeToInstall({ spec, registry, sources: rawSources });
466
- let profileDir;
467
- try {
468
- profileDir = resolveProfileDir(profile);
469
- } catch (error) {
470
- throw new Error(`market_install: invalid profile: ${error.message}`);
471
- }
472
- if (!existsSync(join(profileDir, "package.json"))) {
473
- ensureProfile(profile);
474
- }
475
1490
  const allowBuildScripts = Array.isArray(args.allowBuildScripts)
476
1491
  ? args.allowBuildScripts.map((name) => String(name))
477
1492
  : undefined;
1493
+ const approvalToken = typeof args.approvalToken === "string" && args.approvalToken.trim().length > 0
1494
+ ? args.approvalToken.trim()
1495
+ : undefined;
1496
+ assertValidApprovalInvocation(allowBuildScripts, approvalToken);
1497
+
1498
+ const preflight = await runPreflight({ profile, spec });
1499
+ let acceptWarnings = false;
1500
+ let acceptWarningsActive = false;
1501
+ let approvedProof = undefined;
1502
+ const agentOwner = requireAgentApprovalOwner(exec);
1503
+ if (approvalToken !== undefined) {
1504
+ const consumeResult = consumeApprovalToken({
1505
+ token: approvalToken,
1506
+ profile,
1507
+ profileDir: preflight.profileDir,
1508
+ spec,
1509
+ preflightReport: preflight.report,
1510
+ allowBuildScripts,
1511
+ surface: "agent",
1512
+ owner: agentOwner,
1513
+ });
1514
+ if (!consumeResult.valid) {
1515
+ throw new Error(`market_install: invalid approval token: ${consumeResult.reason}`);
1516
+ }
1517
+ acceptWarnings = consumeResult.warningConsent;
1518
+ acceptWarningsActive = consumeResult.warningConsent;
1519
+ approvedProof = consumeResult.proof;
1520
+ } else {
1521
+ acceptWarnings = args.acceptWarnings === true;
1522
+ acceptWarningsActive = acceptWarnings;
1523
+ }
1524
+
1525
+ enforcePreflight(preflight.report, acceptWarnings, `market_install ${spec}`);
1526
+ pinPreflight(preflight.profileDir, spec);
1527
+
1528
+ const runProducer = () => {
1529
+ const producer = runInstall({ profile, spec, allowBuildScripts, approvedProof, preflight: preflight.report });
1530
+ const done = Promise.resolve(producer.done)
1531
+ .catch((error) => ({
1532
+ status: "failed",
1533
+ detail: `install of ${spec} hit an internal error: ${error?.message ?? String(error)}`,
1534
+ }))
1535
+ .then((outcome) => {
1536
+ const status = outcome?.status ?? "failed";
1537
+ if (status === "completed") {
1538
+ invalidatePreflightFor(preflight.profileDir);
1539
+ clearApprovalTokensFor(profile, spec);
1540
+ } else if (outcome?.needsApproval && outcome.needsApproval.length > 0) {
1541
+ clearApprovalTokensFor(profile, spec, { surface: "agent", owner: agentOwner });
1542
+ const token = issueApprovalToken({
1543
+ profile,
1544
+ profileDir: preflight.profileDir,
1545
+ spec,
1546
+ preflightReport: preflight.report,
1547
+ needsApproval: outcome.needsApproval,
1548
+ proof: outcome.proof,
1549
+ surface: "agent",
1550
+ owner: agentOwner,
1551
+ acceptWarningsActive,
1552
+ });
1553
+ outcome.approvalToken = token;
1554
+ outcome.detail = `${outcome.detail ?? ""}\n\nApproval token (pass to approvalToken on retry): ${token}`;
1555
+ } else {
1556
+ clearApprovalTokensFor(profile, spec, { surface: "agent", owner: agentOwner });
1557
+ }
1558
+ return outcome;
1559
+ });
1560
+ return { cancel: producer.cancel, done, readOutput: producer.readOutput };
1561
+ };
1562
+
478
1563
  const jobId = ctx.jobs.start({
479
1564
  kind: "dsh-plugin-install",
480
1565
  label: `dsh plugin --profile ${profile} add ${spec}`,
481
1566
  ...exec.agent ? { owner: exec.agent } : {},
482
- run: () => runInstall({ profile, spec, allowBuildScripts }),
1567
+ run: runProducer,
483
1568
  });
484
1569
  return { kind: "background", jobId };
485
1570
  },
@@ -521,16 +1606,33 @@ export function apply(ctx, config = {}) {
521
1606
  const packageName = String(args.package ?? "").trim();
522
1607
  if (packageName.length === 0) throw new Error("market_uninstall: package name is required");
523
1608
  assertSafeSpec(packageName);
1609
+ let profileDir;
524
1610
  try {
525
- resolveProfileDir(profile);
1611
+ profileDir = resolveProfileDir(profile);
526
1612
  } catch (error) {
527
1613
  throw new Error(`market_uninstall: invalid profile: ${error.message}`);
528
1614
  }
1615
+ const runProducer = () => {
1616
+ const producer = runRemove({ profile, packageName });
1617
+ const done = Promise.resolve(producer.done)
1618
+ .catch((error) => ({
1619
+ status: "failed",
1620
+ detail: `remove of ${packageName} hit an internal error: ${error?.message ?? String(error)}`,
1621
+ }))
1622
+ .then((outcome) => {
1623
+ if (outcome?.status === "completed") {
1624
+ invalidatePreflightFor(profileDir);
1625
+ clearApprovalTokensFor(profile);
1626
+ }
1627
+ return outcome;
1628
+ });
1629
+ return { cancel: producer.cancel, done, readOutput: producer.readOutput };
1630
+ };
529
1631
  const jobId = ctx.jobs.start({
530
1632
  kind: "dsh-plugin-uninstall",
531
1633
  label: `dsh plugin --profile ${profile} remove ${packageName}`,
532
1634
  ...exec.agent ? { owner: exec.agent } : {},
533
- run: () => runRemove({ profile, packageName }),
1635
+ run: runProducer,
534
1636
  });
535
1637
  return { kind: "background", jobId };
536
1638
  },
@@ -572,10 +1674,470 @@ export function apply(ctx, config = {}) {
572
1674
  }),
573
1675
  }));
574
1676
 
575
- // Browser surface: the /market RPC channel backs the Settings → Plugins →
576
- // 插件市场 tab shipped in src/client.js.
577
1677
  registerRpcChannel(ctx, config, token);
578
1678
  }
579
1679
 
580
- // NOTE: no `export default` the cordis loader unwraps `exports.default ?? exports`,
581
- // so a default export would drop `inject`/`Config`/`name` and leave a bare apply function.
1680
+ // ── offline fixtures / self-test ────────────────────────────────────────────
1681
+
1682
+ export async function runSelfTests() {
1683
+ let failed = 0;
1684
+ const check = (label, ok, extra = "") => {
1685
+ if (ok) {
1686
+ console.log(` PASS ${label}`);
1687
+ } else {
1688
+ failed++;
1689
+ console.error(` FAIL ${label}${extra ? ` (${extra})` : ""}`);
1690
+ }
1691
+ };
1692
+
1693
+ const root = mkdtempSync(join(tmpdir(), "dsh-mall-index-selftest-"));
1694
+ try {
1695
+ const profileDir = join(root, "profiles", "web");
1696
+ mkdirSync(profileDir, { recursive: true });
1697
+ writeFileSync(join(profileDir, "package.json"), JSON.stringify({ name: "profile-web", dependencies: { "dep-a": "1.0.0" } }));
1698
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), "packages:\n - .\n");
1699
+ writeFileSync(join(profileDir, "cordis.patch.yml"), "[]\n");
1700
+ mkdirSync(join(profileDir, "node_modules", "dep-a"), { recursive: true });
1701
+ writeFileSync(join(profileDir, "node_modules", "dep-a", "package.json"), JSON.stringify({ name: "dep-a", version: "1.0.0" }));
1702
+
1703
+ // ── 1. computeProfileFingerprint & direct-only inspection ────────────────
1704
+ const fp1 = computeProfileFingerprint(profileDir);
1705
+ check("fingerprint 是非空 64 位 SHA-256 字符串", typeof fp1 === "string" && fp1.length === 64);
1706
+ const fp1Repeat = computeProfileFingerprint(profileDir);
1707
+ check("fingerprint 计算幂等", fp1 === fp1Repeat);
1708
+
1709
+ // 修改 cordis.patch.yml 改变 fingerprint
1710
+ writeFileSync(join(profileDir, "cordis.patch.yml"), "- name: test\n");
1711
+ const fp2 = computeProfileFingerprint(profileDir);
1712
+ check("受保护文件修改 (cordis.patch.yml) → fingerprint 变化", fp1 !== fp2);
1713
+
1714
+ // 修改依赖版本改变 fingerprint
1715
+ writeFileSync(join(profileDir, "node_modules", "dep-a", "package.json"), JSON.stringify({ name: "dep-a", version: "1.0.1" }));
1716
+ const fp3 = computeProfileFingerprint(profileDir);
1717
+ check("已装直系依赖版本变化 → fingerprint 变化", fp2 !== fp3);
1718
+
1719
+ // 依赖清单 name 不匹配 (corrupt)
1720
+ writeFileSync(join(profileDir, "node_modules", "dep-a", "package.json"), JSON.stringify({ name: "wrong-name", version: "1.0.1" }));
1721
+ const fpCorrupt = computeProfileFingerprint(profileDir);
1722
+ check("依赖 package.json 名称不匹配被判定为 corrupt 且改变 fingerprint", fpCorrupt !== fp3);
1723
+
1724
+ // 祖先目录隔离:在 root 下建立 node_modules/dep-ancestor,但 profileDir/node_modules 下没有
1725
+ mkdirSync(join(root, "node_modules", "dep-ancestor"), { recursive: true });
1726
+ writeFileSync(join(root, "node_modules", "dep-ancestor", "package.json"), JSON.stringify({ name: "dep-ancestor", version: "2.0.0" }));
1727
+ writeFileSync(join(profileDir, "package.json"), JSON.stringify({ name: "profile-web", dependencies: { "dep-a": "1.0.0", "dep-ancestor": "2.0.0" } }));
1728
+ writeFileSync(join(profileDir, "node_modules", "dep-a", "package.json"), JSON.stringify({ name: "dep-a", version: "1.0.0" }));
1729
+ const fpNoAncestor = computeProfileFingerprint(profileDir);
1730
+
1731
+ // 在 profileDir/node_modules/dep-ancestor 下真正创建
1732
+ mkdirSync(join(profileDir, "node_modules", "dep-ancestor"), { recursive: true });
1733
+ writeFileSync(join(profileDir, "node_modules", "dep-ancestor", "package.json"), JSON.stringify({ name: "dep-ancestor", version: "2.0.0" }));
1734
+ const fpWithDirect = computeProfileFingerprint(profileDir);
1735
+ check("fingerprint 绝不向上回溯祖先 node_modules (仅认 profile direct node_modules)", fpNoAncestor !== fpWithDirect);
1736
+
1737
+ // ── 2. First-call allowBuildScripts rejection helper ─────────────────────
1738
+ let firstCallRejected = false;
1739
+ try {
1740
+ assertValidApprovalInvocation(["some-pkg"], undefined);
1741
+ } catch (err) {
1742
+ firstCallRejected = /cannot be specified on initial install/.test(err.message);
1743
+ }
1744
+ check("首次调用携带 allowBuildScripts 无审批 token 被拒绝", firstCallRejected);
1745
+
1746
+ let emptyTokenRejected = false;
1747
+ try {
1748
+ assertValidApprovalInvocation(["some-pkg"], "");
1749
+ } catch (err) {
1750
+ emptyTokenRejected = /cannot be specified on initial install/.test(err.message);
1751
+ }
1752
+ check("携带空字符串 token 调用 allowBuildScripts 被拒绝", emptyTokenRejected);
1753
+
1754
+ let validCallAllowed = true;
1755
+ try {
1756
+ assertValidApprovalInvocation(undefined, undefined);
1757
+ assertValidApprovalInvocation([], undefined);
1758
+ assertValidApprovalInvocation(["some-pkg"], "mkt-appr-abc123");
1759
+ } catch {
1760
+ validCallAllowed = false;
1761
+ }
1762
+ check("无 allowBuildScripts 或携带合法 token 允许通过", validCallAllowed);
1763
+
1764
+ const ownerA = requireAgentApprovalOwner({ agent: { id: "agent-session-123" } });
1765
+ const ownerB = requireAgentApprovalOwner({ agent: { id: "agent-session-123" } });
1766
+ let missingAgentRejected = false;
1767
+ try { requireAgentApprovalOwner({}); } catch { missingAgentRejected = true; }
1768
+ check("agent 审批 owner 使用稳定标量 ID(不绑定瞬态对象引用)且缺失时 fail closed", ownerA === ownerB && ownerA === "agent-session-123" && missingAgentRejected);
1769
+
1770
+ // ── 3. Safe-preflight token issuance & warning consent carrying ──────────
1771
+ const cleanPreflightReport = {
1772
+ verdict: "clean",
1773
+ summary: "clean install",
1774
+ issues: [],
1775
+ };
1776
+ const proofFor = (candidateName, packageNames = [candidateName]) => ({
1777
+ candidate: { name: candidateName, version: "1.0.0", scripts: { install: "node install.js" }, contentHash: "a".repeat(64) },
1778
+ blockedPackages: packageNames.map((packageName, index) => ({ name: packageName, version: "1.0.0", selector: `${packageName}@1.0.0`, direct: packageName === candidateName, scripts: { install: "node install.js" }, contentHash: String(index + 1).repeat(64) })),
1779
+ });
1780
+ const disclosureFor = (proof) => proof.blockedPackages.map((entry) => ({ ...entry }));
1781
+ const sampleProof = {
1782
+ candidate: { name: "safe-pkg", version: "1.0.0", scripts: { install: "node install.js" }, contentHash: "a".repeat(64) },
1783
+ blockedPackages: [{ name: "safe-pkg", version: "1.0.0", selector: "safe-pkg@1.0.0", direct: true, scripts: { install: "node install.js" }, contentHash: "a".repeat(64) }],
1784
+ };
1785
+ const safeNeedsApproval = disclosureFor(sampleProof);
1786
+ const safeToken = issueApprovalToken({
1787
+ profile: "web",
1788
+ profileDir,
1789
+ spec: "safe-pkg@1.0.0",
1790
+ preflightReport: cleanPreflightReport,
1791
+ needsApproval: safeNeedsApproval,
1792
+ proof: sampleProof,
1793
+ surface: "agent",
1794
+ owner: "agent-safe",
1795
+ acceptWarningsActive: false,
1796
+ });
1797
+ check("安全 preflight 下依然签发审批 token", typeof safeToken === "string" && safeToken.startsWith("mkt-appr-"));
1798
+
1799
+ let staleDisclosureRejected = false;
1800
+ try {
1801
+ issueApprovalToken({
1802
+ profile: "web",
1803
+ profileDir,
1804
+ spec: "safe-pkg@1.0.0",
1805
+ preflightReport: cleanPreflightReport,
1806
+ needsApproval: safeNeedsApproval.map((entry) => ({ ...entry, scripts: { install: "different command" } })),
1807
+ proof: sampleProof,
1808
+ surface: "agent",
1809
+ owner: "agent-safe",
1810
+ });
1811
+ } catch (error) {
1812
+ staleDisclosureRejected = /disclosure identity, scripts, or content hash/.test(error.message);
1813
+ }
1814
+ check("审批展示与物化 proof 的脚本/哈希不一致时拒绝签发 token", staleDisclosureRejected);
1815
+
1816
+ // 尝试用不匹配的 profileDir 消费 token(验证 realProfileDir 绑定)
1817
+ const fakeOtherProfileDir = join(root, "profiles", "other-profile");
1818
+ mkdirSync(fakeOtherProfileDir, { recursive: true });
1819
+ writeFileSync(join(fakeOtherProfileDir, "package.json"), "{}");
1820
+ const mismatchProfileDirRes = consumeApprovalToken({
1821
+ token: safeToken,
1822
+ profile: "web",
1823
+ profileDir: fakeOtherProfileDir,
1824
+ spec: "safe-pkg@1.0.0",
1825
+ preflightReport: cleanPreflightReport,
1826
+ allowBuildScripts: ["safe-pkg"],
1827
+ surface: "agent",
1828
+ owner: "agent-safe",
1829
+ });
1830
+ check("profileDir realpath 不匹配时 token 消费被拒绝", !mismatchProfileDirRes.valid && /profile directory mismatch/.test(mismatchProfileDirRes.reason));
1831
+
1832
+ // 重新签发用于正常消费验证
1833
+ const safeToken2 = issueApprovalToken({
1834
+ profile: "web",
1835
+ profileDir,
1836
+ spec: "safe-pkg@1.0.0",
1837
+ preflightReport: cleanPreflightReport,
1838
+ needsApproval: safeNeedsApproval,
1839
+ proof: sampleProof,
1840
+ surface: "agent",
1841
+ owner: "agent-safe",
1842
+ acceptWarningsActive: false,
1843
+ });
1844
+
1845
+ const safeConsume = consumeApprovalToken({
1846
+ token: safeToken2,
1847
+ profile: "web",
1848
+ profileDir,
1849
+ spec: "safe-pkg@1.0.0",
1850
+ preflightReport: cleanPreflightReport,
1851
+ allowBuildScripts: ["safe-pkg"],
1852
+ surface: "agent",
1853
+ owner: "agent-safe",
1854
+ });
1855
+ check("安全 preflight token 消费成功且返回 proof 与 warningConsent", safeConsume.valid && safeConsume.warningConsent === false && serializeCanonicalProof(safeConsume.proof) === serializeCanonicalProof(sampleProof));
1856
+
1857
+ const warnPreflightReport = {
1858
+ verdict: "warning",
1859
+ summary: "warning found",
1860
+ issues: [{ severity: "warn", code: "MANIFEST_PATCH_OVERWRITE", title: "warn", detail: "detail" }],
1861
+ };
1862
+ const warnProof = proofFor("warn-pkg");
1863
+ const warnToken = issueApprovalToken({
1864
+ profile: "web",
1865
+ profileDir,
1866
+ spec: "warn-pkg@1.0.0",
1867
+ preflightReport: warnPreflightReport,
1868
+ needsApproval: disclosureFor(warnProof),
1869
+ proof: warnProof,
1870
+ surface: "browser",
1871
+ owner: "sess-warn",
1872
+ acceptWarningsActive: true,
1873
+ });
1874
+ const warnConsume = consumeApprovalToken({
1875
+ token: warnToken,
1876
+ profile: "web",
1877
+ profileDir,
1878
+ spec: "warn-pkg@1.0.0",
1879
+ preflightReport: warnPreflightReport,
1880
+ allowBuildScripts: ["warn-pkg"],
1881
+ surface: "browser",
1882
+ owner: "sess-warn",
1883
+ });
1884
+ check("含警告 preflight 经确认后 token 消费携带 warningConsent 为 true", warnConsume.valid && warnConsume.warningConsent === true);
1885
+
1886
+ // ── 4. Exact package-set equality in consumeApprovalToken ────────────────
1887
+ const multiProof = proofFor("multi-pkg", ["pkg-a", "pkg-b"]);
1888
+ const multiPkgs = disclosureFor(multiProof).reverse();
1889
+ const testIssue = () => issueApprovalToken({
1890
+ profile: "web",
1891
+ profileDir,
1892
+ spec: "multi-pkg",
1893
+ preflightReport: cleanPreflightReport,
1894
+ needsApproval: multiPkgs,
1895
+ proof: multiProof,
1896
+ surface: "agent",
1897
+ owner: "agent-test",
1898
+ });
1899
+
1900
+ // 4a. Exact match (sorted automatically)
1901
+ const exactTok = testIssue();
1902
+ const exactRes = consumeApprovalToken({
1903
+ token: exactTok,
1904
+ profile: "web",
1905
+ profileDir,
1906
+ spec: "multi-pkg",
1907
+ preflightReport: cleanPreflightReport,
1908
+ allowBuildScripts: ["pkg-b", "pkg-a"],
1909
+ surface: "agent",
1910
+ owner: "agent-test",
1911
+ });
1912
+ check("完整包名集合(乱序输入)匹配成功", exactRes.valid);
1913
+
1914
+ // 4b. Subset rejected
1915
+ const subTok = testIssue();
1916
+ const subRes = consumeApprovalToken({
1917
+ token: subTok,
1918
+ profile: "web",
1919
+ profileDir,
1920
+ spec: "multi-pkg",
1921
+ preflightReport: cleanPreflightReport,
1922
+ allowBuildScripts: ["pkg-a"],
1923
+ surface: "agent",
1924
+ owner: "agent-test",
1925
+ });
1926
+ check("子集包名消费被拒绝", !subRes.valid && /package count/.test(subRes.reason));
1927
+
1928
+ // 4c. Extra package rejected
1929
+ const extraTok = testIssue();
1930
+ const extraRes = consumeApprovalToken({
1931
+ token: extraTok,
1932
+ profile: "web",
1933
+ profileDir,
1934
+ spec: "multi-pkg",
1935
+ preflightReport: cleanPreflightReport,
1936
+ allowBuildScripts: ["pkg-a", "pkg-b", "pkg-c"],
1937
+ surface: "agent",
1938
+ owner: "agent-test",
1939
+ });
1940
+ check("超集/额外包名消费被拒绝", !extraRes.valid && /package count/.test(extraRes.reason));
1941
+
1942
+ // 4d. Duplicate package rejected
1943
+ const dupTok = testIssue();
1944
+ const dupRes = consumeApprovalToken({
1945
+ token: dupTok,
1946
+ profile: "web",
1947
+ profileDir,
1948
+ spec: "multi-pkg",
1949
+ preflightReport: cleanPreflightReport,
1950
+ allowBuildScripts: ["pkg-a", "pkg-a"],
1951
+ surface: "agent",
1952
+ owner: "agent-test",
1953
+ });
1954
+ check("重复包名消费被拒绝", !dupRes.valid && /duplicate package name/.test(dupRes.reason));
1955
+
1956
+ // 4e. Invalid package name rejected
1957
+ const invTok = testIssue();
1958
+ const invRes = consumeApprovalToken({
1959
+ token: invTok,
1960
+ profile: "web",
1961
+ profileDir,
1962
+ spec: "multi-pkg",
1963
+ preflightReport: cleanPreflightReport,
1964
+ allowBuildScripts: ["pkg-a", "../invalid"],
1965
+ surface: "agent",
1966
+ owner: "agent-test",
1967
+ });
1968
+ check("非法/含路径穿越包名消费被拒绝", !invRes.valid && /invalid package name/.test(invRes.reason));
1969
+
1970
+ // 4f. Empty array rejected
1971
+ const emptyTok = testIssue();
1972
+ const emptyRes = consumeApprovalToken({
1973
+ token: emptyTok,
1974
+ profile: "web",
1975
+ profileDir,
1976
+ spec: "multi-pkg",
1977
+ preflightReport: cleanPreflightReport,
1978
+ allowBuildScripts: [],
1979
+ surface: "agent",
1980
+ owner: "agent-test",
1981
+ });
1982
+ check("空包名数组消费被拒绝", !emptyRes.valid && /cannot be empty/.test(emptyRes.reason));
1983
+
1984
+ // ── 5. Cross-browser session rejection & token isolation ─────────────────
1985
+ const browserProof = proofFor("browser-pkg");
1986
+ const browserTok = issueApprovalToken({
1987
+ profile: "web",
1988
+ profileDir,
1989
+ spec: "browser-pkg",
1990
+ preflightReport: cleanPreflightReport,
1991
+ needsApproval: disclosureFor(browserProof),
1992
+ proof: browserProof,
1993
+ surface: "browser",
1994
+ owner: "session-alpha",
1995
+ });
1996
+ const crossSessionRes = consumeApprovalToken({
1997
+ token: browserTok,
1998
+ profile: "web",
1999
+ profileDir,
2000
+ spec: "browser-pkg",
2001
+ preflightReport: cleanPreflightReport,
2002
+ allowBuildScripts: ["browser-pkg"],
2003
+ surface: "browser",
2004
+ owner: "session-beta",
2005
+ });
2006
+ check("跨浏览器 session 消费审批 token 被拒绝且销毁", !crossSessionRes.valid && /session mismatch/.test(crossSessionRes.reason));
2007
+
2008
+ // 跨 surface (browser vs agent)
2009
+ const agentProof = proofFor("agent-pkg");
2010
+ const agentTok = issueApprovalToken({
2011
+ profile: "web",
2012
+ profileDir,
2013
+ spec: "agent-pkg",
2014
+ preflightReport: cleanPreflightReport,
2015
+ needsApproval: disclosureFor(agentProof),
2016
+ proof: agentProof,
2017
+ surface: "agent",
2018
+ owner: "agent-1",
2019
+ });
2020
+ const crossSurfaceRes = consumeApprovalToken({
2021
+ token: agentTok,
2022
+ profile: "web",
2023
+ profileDir,
2024
+ spec: "agent-pkg",
2025
+ preflightReport: cleanPreflightReport,
2026
+ allowBuildScripts: ["agent-pkg"],
2027
+ surface: "browser",
2028
+ owner: "browser-sess",
2029
+ });
2030
+ check("跨 surface (agent vs browser) 消费审批 token 失败且被销毁", !crossSurfaceRes.valid && /surface mismatch/.test(crossSurfaceRes.reason));
2031
+
2032
+ // Tracker 隔离与 session 校验
2033
+ const trackerProof = proofFor("foo-script");
2034
+ let needsApprovalOutcome = {
2035
+ status: "needsApproval",
2036
+ needsApproval: disclosureFor(trackerProof),
2037
+ proof: trackerProof,
2038
+ };
2039
+ const approvalProducer = {
2040
+ cancel: () => {},
2041
+ done: Promise.resolve(needsApprovalOutcome),
2042
+ readOutput: () => "build scripts needed",
2043
+ };
2044
+ const sessionTracker = createJobTracker({
2045
+ producerFactory: () => approvalProducer,
2046
+ });
2047
+ const sessionJobId = sessionTracker.start({
2048
+ profile: "web",
2049
+ spec: "foo-script",
2050
+ profileDir,
2051
+ surface: "browser",
2052
+ session: "session-alpha",
2053
+ });
2054
+ await new Promise((resolvePromise) => setImmediate(resolvePromise));
2055
+
2056
+ const snapDiffSession = sessionTracker.get(sessionJobId, "session-beta").snapshot;
2057
+ check("不同 session 查询 job 时不会暴露 approvalToken", snapDiffSession.approvalToken === undefined);
2058
+
2059
+ const snapSameSession = sessionTracker.get(sessionJobId, "session-alpha").snapshot;
2060
+ check("相同 session 查询 job 时可获取 approvalToken", typeof snapSameSession.approvalToken === "string");
2061
+
2062
+ let cancelRefused = false;
2063
+ try {
2064
+ sessionTracker.cancel(sessionJobId, "session-beta");
2065
+ } catch (err) {
2066
+ cancelRefused = /unauthorized/.test(err.message);
2067
+ }
2068
+ check("不同 session 取消 job 被拒绝", cancelRefused);
2069
+
2070
+ const dismissDiff = sessionTracker.dismiss(sessionJobId, "session-beta");
2071
+ check("不同 session dismiss job 返回 false", dismissDiff === false);
2072
+
2073
+ const dismissSame = sessionTracker.dismiss(sessionJobId, "session-alpha");
2074
+ check("相同 session dismiss job 成功且 token 被注销", dismissSame === true);
2075
+ const snapAfterDismiss = sessionTracker.get(sessionJobId, "session-alpha").snapshot;
2076
+ check("dismiss 后 job snapshot 中 approvalToken 为 undefined", snapAfterDismiss.approvalToken === undefined);
2077
+
2078
+ // ── 6. Windows profile names & restart plan ──────────────────────────────
2079
+ check("合法 profile 名称识别", isSafeProfileName("web", true) && isSafeProfileName("profile_1", true) && isSafeProfileName("dev-test", true));
2080
+ check("Windows 尾随点拒绝", !isSafeProfileName("web.", true) && !isSafeProfileName("test..", true));
2081
+ check("Windows 尾随空格拒绝", !isSafeProfileName("web ", true) && !isSafeProfileName("test ", true));
2082
+
2083
+ const reservedDevices = ["CON", "prn", "aux", "nul", "COM1", "com9", "lpt1", "LPT9", "con.txt", "PRN.json", "aux.yaml", "NUL.js", "COM1.d", "lpt9.log"];
2084
+ let allDevicesRejected = true;
2085
+ for (const dev of reservedDevices) {
2086
+ if (isSafeProfileName(dev, true)) {
2087
+ allDevicesRejected = false;
2088
+ break;
2089
+ }
2090
+ }
2091
+ check("Windows 保留设备名 (含扩展名及大小写) 全部拒绝", allDevicesRejected);
2092
+
2093
+ // isWindows: true —— 这两条断言的是 Windows 路径语义,与运行平台无关地钉死;
2094
+ // 否则 Linux CI 上 "CON"/"web." 是合法名,plan 会因下游原因失败、错误对不上。
2095
+ const restartBadPlan = resolveRestartLaunchPlan({ profile: "CON", config: { allowRestart: true }, isWindows: true });
2096
+ check("保留设备名 profile 重启 plan fail-closed", !restartBadPlan.ok && /reserved Windows device name/.test(restartBadPlan.error));
2097
+
2098
+ const restartDotPlan = resolveRestartLaunchPlan({ profile: "web.", config: { allowRestart: true }, isWindows: true });
2099
+ check("尾随点 profile 重启 plan fail-closed", !restartDotPlan.ok && /dot or space/.test(restartDotPlan.error));
2100
+
2101
+ // ── 7. Tracker isolation: producer.done rejection handling ───────────────
2102
+ let settledOutcome = null;
2103
+ const rejectingProducer = {
2104
+ cancel: () => {},
2105
+ done: Promise.reject(new Error("simulated spawn failure")),
2106
+ readOutput: () => "",
2107
+ };
2108
+ let producerCalls = 0;
2109
+ const tracker = createJobTracker({
2110
+ producerFactory: () => {
2111
+ producerCalls++;
2112
+ return rejectingProducer;
2113
+ },
2114
+ });
2115
+ const jobId = tracker.start({
2116
+ profile: "fixture-profile",
2117
+ spec: "fail-pkg",
2118
+ profileDir,
2119
+ onSettled: (outcome) => { settledOutcome = outcome; },
2120
+ });
2121
+ await new Promise((resolvePromise) => setImmediate(resolvePromise));
2122
+ const trackerSnapshot = tracker.get(jobId, "").snapshot;
2123
+ check(
2124
+ "tracker rejection fixture 使用注入 producer,不触碰真实 profile",
2125
+ producerCalls === 1 && trackerSnapshot.status === "failed" && settledOutcome?.status === "failed",
2126
+ );
2127
+ } finally {
2128
+ rmSync(root, { recursive: true, force: true });
2129
+ }
2130
+
2131
+ return failed;
2132
+ }
2133
+
2134
+ if (process.argv.includes("--self-test")) {
2135
+ console.log("index.js self-test:");
2136
+ runSelfTests().then((failed) => {
2137
+ console.log(`index.js tests finished with ${failed} failures.`);
2138
+ process.exit(failed === 0 ? 0 : 1);
2139
+ }).catch((err) => {
2140
+ console.error("Self-test threw:", err);
2141
+ process.exit(1);
2142
+ });
2143
+ }