@1e0zj/dsh-plugin-mall 0.1.18 → 0.2.1
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 +111 -5
- package/package.json +6 -2
- package/src/cli.js +1638 -0
- package/src/client.js +306 -67
- package/src/github.js +71 -3
- package/src/guard.js +2413 -0
- package/src/index.js +1761 -88
- package/src/installer.js +1585 -77
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,
|
|
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, recoverProfile } 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,952 @@ 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, onOutput }) {
|
|
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, onOutput });
|
|
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
|
+
/**
|
|
862
|
+
* A visible, log-streaming job for a phase that is not install/remove.
|
|
863
|
+
* Preflight above all: it used to run inside the RPC call before any job
|
|
864
|
+
* existed, so the panel sat empty for the seconds the probe took. Now the
|
|
865
|
+
* job appears the instant the user clicks and the probe's pnpm output
|
|
866
|
+
* streams into it. The verdict rides `extras` on the snapshot so the
|
|
867
|
+
* polling client can continue into the install (or raise a risk card).
|
|
868
|
+
* Cancel is advisory — the probe has no kill handle — so it just marks the
|
|
869
|
+
* record killed and discards the outcome; the throwaway probe directory
|
|
870
|
+
* lives in the system tmpdir and is reclaimed by the OS.
|
|
871
|
+
*/
|
|
872
|
+
startCustom({ kind, label, profile, spec, surface = "browser", session, run }) {
|
|
873
|
+
const id = `market-${++trackerCounter}`;
|
|
874
|
+
const queue = [];
|
|
875
|
+
const push = (text) => { queue.push(String(text ?? "")); };
|
|
876
|
+
const record = {
|
|
877
|
+
id,
|
|
878
|
+
kind,
|
|
879
|
+
label,
|
|
880
|
+
profile,
|
|
881
|
+
spec,
|
|
882
|
+
surface,
|
|
883
|
+
session: session ?? "",
|
|
884
|
+
status: "running",
|
|
885
|
+
detail: undefined,
|
|
886
|
+
extras: undefined,
|
|
887
|
+
startedAt: Date.now(),
|
|
888
|
+
finishedAt: undefined,
|
|
889
|
+
cancelled: false,
|
|
890
|
+
readOutput: () => (queue.length === 0 ? "" : queue.splice(0).join("")),
|
|
891
|
+
};
|
|
892
|
+
(async () => {
|
|
893
|
+
try {
|
|
894
|
+
const outcome = await run(push);
|
|
895
|
+
if (record.cancelled) return; // 用户已放弃,结果作废
|
|
896
|
+
record.status = outcome?.status ?? "failed";
|
|
897
|
+
record.detail = outcome?.detail;
|
|
898
|
+
record.extras = outcome?.extras;
|
|
899
|
+
} catch (error) {
|
|
900
|
+
if (record.cancelled) return;
|
|
901
|
+
record.status = "failed";
|
|
902
|
+
record.detail = error?.message ?? String(error);
|
|
903
|
+
} finally {
|
|
904
|
+
if (!record.cancelled) record.finishedAt = Date.now();
|
|
905
|
+
}
|
|
906
|
+
})();
|
|
907
|
+
records.set(id, record);
|
|
908
|
+
prune();
|
|
909
|
+
return id;
|
|
910
|
+
},
|
|
911
|
+
|
|
912
|
+
get(jobId, session) {
|
|
913
|
+
const record = records.get(String(jobId));
|
|
914
|
+
if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
|
|
915
|
+
const isSameSession = record.surface !== "browser" || (record.session !== "" && record.session === session);
|
|
916
|
+
return {
|
|
917
|
+
snapshot: {
|
|
918
|
+
id: record.id,
|
|
919
|
+
kind: record.kind,
|
|
920
|
+
label: record.label,
|
|
921
|
+
status: record.status,
|
|
922
|
+
detail: record.detail,
|
|
923
|
+
needsApproval: record.needsApproval,
|
|
924
|
+
approvalToken: isSameSession ? record.approvalToken : undefined,
|
|
925
|
+
// extras(如预检结论)同样只对同一 session 可见,与 approvalToken 同规格。
|
|
926
|
+
extras: isSameSession ? record.extras : undefined,
|
|
927
|
+
spec: record.spec,
|
|
928
|
+
startedAt: record.startedAt,
|
|
929
|
+
finishedAt: record.finishedAt,
|
|
930
|
+
},
|
|
931
|
+
output: typeof record.readOutput === "function"
|
|
932
|
+
? record.readOutput()
|
|
933
|
+
: typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "",
|
|
934
|
+
};
|
|
935
|
+
},
|
|
936
|
+
|
|
937
|
+
cancel(jobId, session) {
|
|
938
|
+
const record = records.get(String(jobId));
|
|
939
|
+
if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
|
|
940
|
+
if (record.surface === "browser" && (record.session === "" || record.session !== session)) {
|
|
941
|
+
throw new Error("unauthorized to cancel job from another session");
|
|
942
|
+
}
|
|
943
|
+
if (record.cancelled !== undefined) { // startCustom:无句柄可杀,立即标 killed 并作废结果
|
|
944
|
+
record.cancelled = true;
|
|
945
|
+
if (record.status === "running") {
|
|
946
|
+
record.status = "killed";
|
|
947
|
+
record.finishedAt = Date.now();
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (typeof record.producer?.cancel === "function") {
|
|
951
|
+
record.producer.cancel();
|
|
952
|
+
}
|
|
953
|
+
if (record.approvalToken) {
|
|
954
|
+
invalidateApprovalToken(record.approvalToken, record.session || undefined, record.surface);
|
|
955
|
+
record.approvalToken = undefined;
|
|
956
|
+
}
|
|
957
|
+
clearApprovalTokensFor(record.profile, record.spec, { surface: record.surface, owner: record.session });
|
|
958
|
+
return "requested";
|
|
959
|
+
},
|
|
960
|
+
|
|
961
|
+
dismiss(jobId, session) {
|
|
962
|
+
const record = records.get(String(jobId));
|
|
963
|
+
if (record === undefined) return false;
|
|
964
|
+
if (record.surface === "browser" && (record.session === "" || record.session !== session)) {
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
if (record.approvalToken) {
|
|
968
|
+
invalidateApprovalToken(record.approvalToken, record.session || undefined, record.surface);
|
|
969
|
+
record.approvalToken = undefined;
|
|
970
|
+
}
|
|
971
|
+
return true;
|
|
972
|
+
},
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** Render one preflight issue as a compact line for model/error output. */
|
|
977
|
+
function renderPreflightIssue(entry) {
|
|
978
|
+
const badge = entry.severity === "block" ? "BLOCK" : "WARN";
|
|
979
|
+
return ` [${badge}] ${entry.title}: ${entry.detail}`;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Enforce a preflight verdict for an install. Throws when a blocker exists, or
|
|
984
|
+
* when there are only warnings and acceptWarnings is not true.
|
|
985
|
+
*/
|
|
986
|
+
function enforcePreflight(report, acceptWarnings, label) {
|
|
987
|
+
if (report.verdict === "blocked") {
|
|
988
|
+
const error = new Error(`${label}: ${report.summary}\n${report.issues.filter((entry) => entry.severity === "block").map(renderPreflightIssue).join("\n")}`);
|
|
989
|
+
error.preflight = report;
|
|
990
|
+
throw error;
|
|
991
|
+
}
|
|
992
|
+
if (report.verdict === "warning" && acceptWarnings !== true) {
|
|
993
|
+
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.`);
|
|
994
|
+
error.preflight = report;
|
|
995
|
+
throw error;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
48
999
|
/** Clip long strings for compact model-facing output. */
|
|
49
1000
|
function clip(text, max) {
|
|
50
1001
|
const trimmed = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
@@ -131,39 +1082,20 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
|
|
|
131
1082
|
};
|
|
132
1083
|
|
|
133
1084
|
// ── 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
1085
|
|
|
141
1086
|
function rpcOk(value) {
|
|
142
1087
|
return { ok: true, value };
|
|
143
1088
|
}
|
|
144
1089
|
|
|
145
1090
|
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
1091
|
return { ok: false, error: { code: "internal", message: error?.message ?? String(error), details: {} } };
|
|
150
1092
|
}
|
|
151
1093
|
|
|
152
1094
|
/**
|
|
153
|
-
* Dispatch one /market RPC endpoint.
|
|
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.
|
|
1095
|
+
* Dispatch one /market RPC endpoint.
|
|
164
1096
|
*/
|
|
165
1097
|
async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
166
|
-
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30,
|
|
1098
|
+
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, npmRegistry = "", rawSources = [] } = config;
|
|
167
1099
|
switch (endpoint) {
|
|
168
1100
|
case "search": {
|
|
169
1101
|
const perPage = Math.min(Math.max(Math.trunc(payload?.perPage ?? 10) || 10, 1), Math.trunc(perPageMax) || 30);
|
|
@@ -182,6 +1114,54 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
182
1114
|
const result = await verifyPlugins({ repos: payload?.repos, sources: rawSources });
|
|
183
1115
|
return rpcOk(result);
|
|
184
1116
|
}
|
|
1117
|
+
case "compat": {
|
|
1118
|
+
// Browsing-time conflict badge: statically scan each repo's manifest
|
|
1119
|
+
// (and declared bundle patch, fetched as text) against the profile —
|
|
1120
|
+
// the same checks the install preflight runs, minus the probe install.
|
|
1121
|
+
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
1122
|
+
let profileDir;
|
|
1123
|
+
try {
|
|
1124
|
+
profileDir = resolveProfileDir(profile);
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
1127
|
+
}
|
|
1128
|
+
const repos = [...new Set((Array.isArray(payload?.repos) ? payload.repos : []).map(String)
|
|
1129
|
+
.filter((repo) => /^[^@/\s][^/\s]*\/[^/\s]+$/.test(repo) && !repo.includes("..")))].slice(0, 30);
|
|
1130
|
+
if (repos.length === 0) return rpcOk({ results: {} });
|
|
1131
|
+
await verifyPlugins({ repos, sources: rawSources }); // populates the manifest cache
|
|
1132
|
+
let fingerprint;
|
|
1133
|
+
const results = {};
|
|
1134
|
+
await mapLimit(repos, NETWORK_CONCURRENCY, async (repo) => {
|
|
1135
|
+
const manifest = cachedRepoManifest(repo);
|
|
1136
|
+
if (manifest === undefined || typeof manifest !== "object") {
|
|
1137
|
+
results[repo] = { state: "unknown", summary: "无法获取插件清单,兼容性未知" };
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
let patchText;
|
|
1142
|
+
if (typeof manifest.dsh?.bundle?.patch === "string") {
|
|
1143
|
+
patchText = await fetchRawFile(repo, manifest.dsh.bundle.patch, { sources: rawSources });
|
|
1144
|
+
}
|
|
1145
|
+
fingerprint ??= computeProfileFingerprint(profileDir);
|
|
1146
|
+
const cacheKey = `${fingerprint}::${repo}`;
|
|
1147
|
+
const hit = compatCacheGet(cacheKey);
|
|
1148
|
+
if (hit !== undefined) { results[repo] = hit; return; }
|
|
1149
|
+
const report = inspectRemoteCandidate({ profileDir, manifest, patchText, spec: `github:${repo}` });
|
|
1150
|
+
const entry = {
|
|
1151
|
+
state: report.verdict === "blocked" ? "conflict" : report.verdict === "warning" ? "warning" : "compatible",
|
|
1152
|
+
name: report.candidate.name,
|
|
1153
|
+
summary: report.summary,
|
|
1154
|
+
issues: report.issues.slice(0, 3).map(({ severity, title }) => ({ severity, title })),
|
|
1155
|
+
patchChecked: typeof manifest.dsh?.bundle?.patch === "string" ? report.issues.every((item) => item.code !== "patch-unverified") : true,
|
|
1156
|
+
};
|
|
1157
|
+
compatCacheSet(cacheKey, entry);
|
|
1158
|
+
results[repo] = entry;
|
|
1159
|
+
} catch {
|
|
1160
|
+
results[repo] = { state: "unknown", summary: "兼容性检查失败" };
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
return rpcOk({ results });
|
|
1164
|
+
}
|
|
185
1165
|
case "updates": {
|
|
186
1166
|
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
187
1167
|
let deps;
|
|
@@ -193,7 +1173,6 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
193
1173
|
}
|
|
194
1174
|
const registry = await registryFor(profile, npmRegistry);
|
|
195
1175
|
const results = {};
|
|
196
|
-
// 同 verifyPlugins 的 worker 池,而不是对所有依赖一次性扇出。
|
|
197
1176
|
await mapLimit(deps, NETWORK_CONCURRENCY, async (dep) => {
|
|
198
1177
|
if (dep.kind === "missing") { results[dep.name] = { latest: null }; return; }
|
|
199
1178
|
const info = await npmPackageInfo(dep.name, { registry });
|
|
@@ -216,8 +1195,56 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
216
1195
|
}
|
|
217
1196
|
return rpcOk(listInstalled(profile));
|
|
218
1197
|
}
|
|
1198
|
+
case "preflight": {
|
|
1199
|
+
// 预检本身做成 job:点击安装的瞬间任务就出现在面板里,探针的 pnpm
|
|
1200
|
+
// 输出实时流入——此前预检阻塞在 RPC 里,面板数秒空白只有按钮干等。
|
|
1201
|
+
// 结论经 snapshot.extras 返回,轮询端据此后接安装或出风险卡片;
|
|
1202
|
+
// 缓存同时被填热,随后的 install 调用不再重复探装。
|
|
1203
|
+
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
1204
|
+
let session;
|
|
1205
|
+
try {
|
|
1206
|
+
// 与 install 同规格:job 归属浏览器 session,extras 才只对本人可见。
|
|
1207
|
+
session = requireBrowserSession(payload?.session);
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
return rpcFail(error);
|
|
1210
|
+
}
|
|
1211
|
+
let spec;
|
|
1212
|
+
try {
|
|
1213
|
+
spec = normalizeSpec(payload?.spec);
|
|
1214
|
+
assertSafeSpec(spec);
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
return rpcFail(error);
|
|
1217
|
+
}
|
|
1218
|
+
try {
|
|
1219
|
+
const registry = await registryFor(profile, npmRegistry);
|
|
1220
|
+
const resolved = await preferNpmSpec({ spec, registry, sources: rawSources });
|
|
1221
|
+
const jobId = tracker.startCustom({
|
|
1222
|
+
kind: "dsh-plugin-preflight",
|
|
1223
|
+
label: `preflight ${resolved}`,
|
|
1224
|
+
profile,
|
|
1225
|
+
spec: resolved,
|
|
1226
|
+
surface: "browser",
|
|
1227
|
+
session,
|
|
1228
|
+
run: async (push) => {
|
|
1229
|
+
push(`[dsh-plugin-mall] 预检 ${resolved}:隔离目录探装(脚本禁用)\n`);
|
|
1230
|
+
const { report } = await runPreflight({ profile, spec: resolved, onOutput: (text) => push(text) });
|
|
1231
|
+
push(`[dsh-plugin-mall] 预检结论:${report.verdict}\n`);
|
|
1232
|
+
return { status: "completed", detail: `预检完成:${report.verdict}`, extras: report };
|
|
1233
|
+
},
|
|
1234
|
+
});
|
|
1235
|
+
return rpcOk({ jobId, profile, spec: resolved });
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
return rpcFail(error);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
219
1240
|
case "install": {
|
|
220
1241
|
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
1242
|
+
let session;
|
|
1243
|
+
try {
|
|
1244
|
+
session = requireBrowserSession(payload?.session);
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
return rpcFail(error);
|
|
1247
|
+
}
|
|
221
1248
|
let spec;
|
|
222
1249
|
try {
|
|
223
1250
|
spec = normalizeSpec(payload?.spec);
|
|
@@ -225,38 +1252,87 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
225
1252
|
} catch (error) {
|
|
226
1253
|
return rpcFail(error);
|
|
227
1254
|
}
|
|
228
|
-
// npm tarball 优先(小而快、带 integrity);registry 条目不同源的包名
|
|
229
|
-
// 视为抢注,回退 github: 全仓库 spec。查的 registry 必须是 pnpm 实际
|
|
230
|
-
// 安装用的那个,否则镜像用户这里永远比对不上、次次退化成全仓库克隆。
|
|
231
1255
|
const registry = await registryFor(profile, npmRegistry);
|
|
232
1256
|
spec = await preferNpmSpec({ spec, registry, sources: rawSources });
|
|
233
|
-
// 宿主依赖硬拦:dependencies 里拖着 @deepseek-ai/* 的包装进 profile
|
|
234
|
-
// 就是双模块实例 + 全工具调度崩溃(宿主无任何护栏,市场是最后防线)。
|
|
235
1257
|
try {
|
|
236
1258
|
await assertSafeToInstall({ spec, registry, sources: rawSources });
|
|
237
1259
|
} catch (error) {
|
|
238
1260
|
return rpcFail(error);
|
|
239
1261
|
}
|
|
1262
|
+
const allowBuildScripts = Array.isArray(payload?.allowBuildScripts)
|
|
1263
|
+
? payload.allowBuildScripts.map((name) => String(name))
|
|
1264
|
+
: undefined;
|
|
1265
|
+
const approvalToken = typeof payload?.approvalToken === "string" && payload.approvalToken.trim().length > 0
|
|
1266
|
+
? payload.approvalToken.trim()
|
|
1267
|
+
: undefined;
|
|
1268
|
+
|
|
240
1269
|
try {
|
|
241
|
-
|
|
242
|
-
if (!existsSync(join(profileDir, "package.json"))) ensureProfile(profile);
|
|
1270
|
+
assertValidApprovalInvocation(allowBuildScripts, approvalToken);
|
|
243
1271
|
} catch (error) {
|
|
244
|
-
return rpcFail(
|
|
1272
|
+
return rpcFail(error);
|
|
245
1273
|
}
|
|
1274
|
+
|
|
1275
|
+
let preflight;
|
|
1276
|
+
let acceptWarnings = false;
|
|
1277
|
+
let acceptWarningsActive = false;
|
|
1278
|
+
let approvedProof = undefined;
|
|
246
1279
|
try {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
1280
|
+
preflight = await runPreflight({ profile, spec });
|
|
1281
|
+
if (approvalToken !== undefined) {
|
|
1282
|
+
const consumeResult = consumeApprovalToken({
|
|
1283
|
+
token: approvalToken,
|
|
1284
|
+
profile,
|
|
1285
|
+
profileDir: preflight.profileDir,
|
|
1286
|
+
spec,
|
|
1287
|
+
preflightReport: preflight.report,
|
|
1288
|
+
allowBuildScripts,
|
|
1289
|
+
surface: "browser",
|
|
1290
|
+
owner: session,
|
|
1291
|
+
});
|
|
1292
|
+
if (!consumeResult.valid) {
|
|
1293
|
+
return rpcFail(new Error(`invalid approval token: ${consumeResult.reason}`));
|
|
1294
|
+
}
|
|
1295
|
+
acceptWarnings = consumeResult.warningConsent;
|
|
1296
|
+
acceptWarningsActive = consumeResult.warningConsent;
|
|
1297
|
+
approvedProof = consumeResult.proof;
|
|
1298
|
+
} else {
|
|
1299
|
+
acceptWarnings = payload?.acceptWarnings === true;
|
|
1300
|
+
acceptWarningsActive = acceptWarnings;
|
|
1301
|
+
}
|
|
1302
|
+
enforcePreflight(preflight.report, acceptWarnings, `install ${spec}`);
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
return rpcFail(error);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
pinPreflight(preflight.profileDir, spec);
|
|
1308
|
+
try {
|
|
1309
|
+
const jobId = tracker.start({
|
|
1310
|
+
profile,
|
|
1311
|
+
spec,
|
|
1312
|
+
allowBuildScripts,
|
|
1313
|
+
approvedProof,
|
|
1314
|
+
preflight: preflight.report,
|
|
1315
|
+
profileDir: preflight.profileDir,
|
|
1316
|
+
acceptWarningsActive,
|
|
1317
|
+
surface: "browser",
|
|
1318
|
+
session,
|
|
1319
|
+
onSettled: (outcome) => {
|
|
1320
|
+
if (outcome?.status === "completed") invalidatePreflightFor(preflight.profileDir);
|
|
1321
|
+
},
|
|
1322
|
+
});
|
|
1323
|
+
return rpcOk({ jobId, profile, spec, preflight: { verdict: preflight.report.verdict, summary: preflight.report.summary } });
|
|
254
1324
|
} catch (error) {
|
|
255
1325
|
return rpcFail(error);
|
|
256
1326
|
}
|
|
257
1327
|
}
|
|
258
1328
|
case "uninstall": {
|
|
259
1329
|
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
1330
|
+
let session;
|
|
1331
|
+
try {
|
|
1332
|
+
session = requireBrowserSession(payload?.session);
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
return rpcFail(error);
|
|
1335
|
+
}
|
|
260
1336
|
const packageName = String(payload?.package ?? "").trim();
|
|
261
1337
|
if (packageName.length === 0) return rpcFail(new Error("uninstall: package name is required"));
|
|
262
1338
|
try {
|
|
@@ -264,8 +1340,9 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
264
1340
|
} catch (error) {
|
|
265
1341
|
return rpcFail(error);
|
|
266
1342
|
}
|
|
1343
|
+
let profileDir;
|
|
267
1344
|
try {
|
|
268
|
-
|
|
1345
|
+
profileDir = resolveProfileDir(profile);
|
|
269
1346
|
if (!existsSync(join(profileDir, "package.json"))) {
|
|
270
1347
|
return rpcFail(new Error(`profile "${profile}" has no package.json — nothing installed to remove`));
|
|
271
1348
|
}
|
|
@@ -273,7 +1350,20 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
273
1350
|
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
274
1351
|
}
|
|
275
1352
|
try {
|
|
276
|
-
const jobId = tracker.start({
|
|
1353
|
+
const jobId = tracker.start({
|
|
1354
|
+
profile,
|
|
1355
|
+
spec: packageName,
|
|
1356
|
+
verb: "remove",
|
|
1357
|
+
profileDir,
|
|
1358
|
+
surface: "browser",
|
|
1359
|
+
session,
|
|
1360
|
+
onSettled: (outcome) => {
|
|
1361
|
+
if (outcome?.status === "completed") {
|
|
1362
|
+
invalidatePreflightFor(profileDir);
|
|
1363
|
+
clearApprovalTokensFor(profile);
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
});
|
|
277
1367
|
return rpcOk({ jobId, profile, package: packageName });
|
|
278
1368
|
} catch (error) {
|
|
279
1369
|
return rpcFail(error);
|
|
@@ -281,33 +1371,54 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
281
1371
|
}
|
|
282
1372
|
case "job": {
|
|
283
1373
|
try {
|
|
284
|
-
|
|
1374
|
+
const session = requireBrowserSession(payload?.session);
|
|
1375
|
+
return rpcOk(tracker.get(payload?.jobId, session));
|
|
285
1376
|
} catch (error) {
|
|
286
1377
|
return rpcFail(error);
|
|
287
1378
|
}
|
|
288
1379
|
}
|
|
289
1380
|
case "restart": {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
:
|
|
303
|
-
|
|
1381
|
+
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
1382
|
+
try {
|
|
1383
|
+
requireBrowserSession(payload?.session);
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
return rpcFail(error);
|
|
1386
|
+
}
|
|
1387
|
+
const plan = resolveRestartLaunchPlan({ profile, config });
|
|
1388
|
+
if (!plan.ok) {
|
|
1389
|
+
return rpcFail(new Error(plan.error));
|
|
1390
|
+
}
|
|
1391
|
+
const child = spawn(plan.nodePath, plan.args, {
|
|
1392
|
+
shell: false,
|
|
1393
|
+
detached: true,
|
|
1394
|
+
stdio: "ignore",
|
|
1395
|
+
cwd: process.cwd(),
|
|
1396
|
+
windowsHide: true,
|
|
1397
|
+
});
|
|
304
1398
|
child.unref();
|
|
305
|
-
setTimeout(() => process.exit(0),
|
|
1399
|
+
setTimeout(() => process.exit(0), 1000);
|
|
306
1400
|
return rpcOk({ restarting: true });
|
|
307
1401
|
}
|
|
308
1402
|
case "jobCancel": {
|
|
309
1403
|
try {
|
|
310
|
-
|
|
1404
|
+
const session = requireBrowserSession(payload?.session);
|
|
1405
|
+
return rpcOk({ result: tracker.cancel(payload?.jobId, session) });
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
return rpcFail(error);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
case "jobDismiss": {
|
|
1411
|
+
try {
|
|
1412
|
+
const session = requireBrowserSession(payload?.session);
|
|
1413
|
+
const token = typeof payload?.token === "string" && payload.token.trim().length > 0 ? payload.token.trim() : undefined;
|
|
1414
|
+
let dismissed = false;
|
|
1415
|
+
if (payload?.jobId) {
|
|
1416
|
+
dismissed = tracker.dismiss(payload.jobId, session) || dismissed;
|
|
1417
|
+
}
|
|
1418
|
+
if (token) {
|
|
1419
|
+
dismissed = invalidateApprovalToken(token, session, "browser") || dismissed;
|
|
1420
|
+
}
|
|
1421
|
+
return rpcOk({ dismissed });
|
|
311
1422
|
} catch (error) {
|
|
312
1423
|
return rpcFail(error);
|
|
313
1424
|
}
|
|
@@ -318,13 +1429,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
318
1429
|
}
|
|
319
1430
|
|
|
320
1431
|
/**
|
|
321
|
-
* Register the /market RPC channel once the Connection service exists
|
|
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.
|
|
1432
|
+
* Register the /market RPC channel once the Connection service exists.
|
|
328
1433
|
*/
|
|
329
1434
|
function registerRpcChannel(ctx, config, token) {
|
|
330
1435
|
const tracker = createJobTracker();
|
|
@@ -333,9 +1438,6 @@ function registerRpcChannel(ctx, config, token) {
|
|
|
333
1438
|
try {
|
|
334
1439
|
return await rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker);
|
|
335
1440
|
} catch (error) {
|
|
336
|
-
// 没有这层兜底时连接层只会回一个 HTTP 500 "transport failure",
|
|
337
|
-
// 真实异常既到不了浏览器也不留痕。透传错误文本,同时把堆栈
|
|
338
|
-
// 打进 dsh 进程的 stderr(前台运行时可见)。
|
|
339
1441
|
console.error(`[dsh-plugin-mall] /market/${String(endpoint)} failed:`, error);
|
|
340
1442
|
return rpcFail(error);
|
|
341
1443
|
}
|
|
@@ -347,10 +1449,35 @@ export function apply(ctx, config = {}) {
|
|
|
347
1449
|
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, npmRegistry = "", rawSources = [] } = config;
|
|
348
1450
|
const token = process.env.GITHUB_TOKEN ?? process.env.DSH_MARKET_GITHUB_TOKEN;
|
|
349
1451
|
|
|
1452
|
+
// Startup recovery. A pending install marker blocks every later install and
|
|
1453
|
+
// uninstall in that profile until something resolves it — and until now the
|
|
1454
|
+
// only thing that did was `guard launch`, a wrapper nobody uses: people type
|
|
1455
|
+
// `dsh web`. One install then wedged the profile permanently, with an error
|
|
1456
|
+
// telling users to run a CLI they have never heard of.
|
|
1457
|
+
//
|
|
1458
|
+
// Reaching `apply` IS the proof the pending install did not break the host:
|
|
1459
|
+
// this code only runs because dsh booted far enough to compose the profile
|
|
1460
|
+
// and load this plugin. So resolve the marker right here — recoverProfile
|
|
1461
|
+
// commits when the profile validates and rolls back when it does not. The
|
|
1462
|
+
// grace-window probation of `guard launch` stays strictly better (it also
|
|
1463
|
+
// catches a crash seconds later); this is the floor for a plain start.
|
|
1464
|
+
try {
|
|
1465
|
+
const result = recoverProfile(resolveProfileDir(defaultProfile));
|
|
1466
|
+
if (result.action === "committed") {
|
|
1467
|
+
console.log(`[dsh-plugin-mall] startup recovery: committed the pending install for profile "${defaultProfile}"`);
|
|
1468
|
+
} else if (result.action === "rolled-back") {
|
|
1469
|
+
console.warn(`[dsh-plugin-mall] startup recovery: rolled back the pending install for profile "${defaultProfile}" — ${result.reason ?? "profile failed validation"}`);
|
|
1470
|
+
}
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
// 恢复失败绝不能拖垮插件加载:报出来,让市场照常可用(用户还能手动
|
|
1473
|
+
// `dsh-plugin-guard guard recover`),而不是连界面都进不去。
|
|
1474
|
+
console.error("[dsh-plugin-mall] startup recovery failed:", error);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
350
1477
|
ctx.systemPrompt.section({
|
|
351
1478
|
name: "tool:market",
|
|
352
1479
|
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.",
|
|
1480
|
+
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
1481
|
});
|
|
355
1482
|
|
|
356
1483
|
ctx.tools.register(defineTool({
|
|
@@ -428,7 +1555,7 @@ export function apply(ctx, config = {}) {
|
|
|
428
1555
|
|
|
429
1556
|
ctx.tools.register(defineTool({
|
|
430
1557
|
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,
|
|
1558
|
+
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
1559
|
parameters: {
|
|
433
1560
|
spec: {
|
|
434
1561
|
type: "string",
|
|
@@ -439,6 +1566,14 @@ export function apply(ctx, config = {}) {
|
|
|
439
1566
|
type: "string",
|
|
440
1567
|
description: `Target profile under $DSH_HOME/profiles. Defaults to "${defaultProfile}".`,
|
|
441
1568
|
},
|
|
1569
|
+
acceptWarnings: {
|
|
1570
|
+
type: "boolean",
|
|
1571
|
+
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.",
|
|
1572
|
+
},
|
|
1573
|
+
approvalToken: {
|
|
1574
|
+
type: "string",
|
|
1575
|
+
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.",
|
|
1576
|
+
},
|
|
442
1577
|
allowBuildScripts: {
|
|
443
1578
|
type: "array",
|
|
444
1579
|
items: { type: "string" },
|
|
@@ -463,23 +1598,84 @@ export function apply(ctx, config = {}) {
|
|
|
463
1598
|
const registry = await registryFor(profile, npmRegistry);
|
|
464
1599
|
const spec = await preferNpmSpec({ spec: normalized, registry, sources: rawSources });
|
|
465
1600
|
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
1601
|
const allowBuildScripts = Array.isArray(args.allowBuildScripts)
|
|
476
1602
|
? args.allowBuildScripts.map((name) => String(name))
|
|
477
1603
|
: undefined;
|
|
1604
|
+
const approvalToken = typeof args.approvalToken === "string" && args.approvalToken.trim().length > 0
|
|
1605
|
+
? args.approvalToken.trim()
|
|
1606
|
+
: undefined;
|
|
1607
|
+
assertValidApprovalInvocation(allowBuildScripts, approvalToken);
|
|
1608
|
+
|
|
1609
|
+
const preflight = await runPreflight({ profile, spec });
|
|
1610
|
+
let acceptWarnings = false;
|
|
1611
|
+
let acceptWarningsActive = false;
|
|
1612
|
+
let approvedProof = undefined;
|
|
1613
|
+
const agentOwner = requireAgentApprovalOwner(exec);
|
|
1614
|
+
if (approvalToken !== undefined) {
|
|
1615
|
+
const consumeResult = consumeApprovalToken({
|
|
1616
|
+
token: approvalToken,
|
|
1617
|
+
profile,
|
|
1618
|
+
profileDir: preflight.profileDir,
|
|
1619
|
+
spec,
|
|
1620
|
+
preflightReport: preflight.report,
|
|
1621
|
+
allowBuildScripts,
|
|
1622
|
+
surface: "agent",
|
|
1623
|
+
owner: agentOwner,
|
|
1624
|
+
});
|
|
1625
|
+
if (!consumeResult.valid) {
|
|
1626
|
+
throw new Error(`market_install: invalid approval token: ${consumeResult.reason}`);
|
|
1627
|
+
}
|
|
1628
|
+
acceptWarnings = consumeResult.warningConsent;
|
|
1629
|
+
acceptWarningsActive = consumeResult.warningConsent;
|
|
1630
|
+
approvedProof = consumeResult.proof;
|
|
1631
|
+
} else {
|
|
1632
|
+
acceptWarnings = args.acceptWarnings === true;
|
|
1633
|
+
acceptWarningsActive = acceptWarnings;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
enforcePreflight(preflight.report, acceptWarnings, `market_install ${spec}`);
|
|
1637
|
+
pinPreflight(preflight.profileDir, spec);
|
|
1638
|
+
|
|
1639
|
+
const runProducer = () => {
|
|
1640
|
+
const producer = runInstall({ profile, spec, allowBuildScripts, approvedProof, preflight: preflight.report });
|
|
1641
|
+
const done = Promise.resolve(producer.done)
|
|
1642
|
+
.catch((error) => ({
|
|
1643
|
+
status: "failed",
|
|
1644
|
+
detail: `install of ${spec} hit an internal error: ${error?.message ?? String(error)}`,
|
|
1645
|
+
}))
|
|
1646
|
+
.then((outcome) => {
|
|
1647
|
+
const status = outcome?.status ?? "failed";
|
|
1648
|
+
if (status === "completed") {
|
|
1649
|
+
invalidatePreflightFor(preflight.profileDir);
|
|
1650
|
+
clearApprovalTokensFor(profile, spec);
|
|
1651
|
+
} else if (outcome?.needsApproval && outcome.needsApproval.length > 0) {
|
|
1652
|
+
clearApprovalTokensFor(profile, spec, { surface: "agent", owner: agentOwner });
|
|
1653
|
+
const token = issueApprovalToken({
|
|
1654
|
+
profile,
|
|
1655
|
+
profileDir: preflight.profileDir,
|
|
1656
|
+
spec,
|
|
1657
|
+
preflightReport: preflight.report,
|
|
1658
|
+
needsApproval: outcome.needsApproval,
|
|
1659
|
+
proof: outcome.proof,
|
|
1660
|
+
surface: "agent",
|
|
1661
|
+
owner: agentOwner,
|
|
1662
|
+
acceptWarningsActive,
|
|
1663
|
+
});
|
|
1664
|
+
outcome.approvalToken = token;
|
|
1665
|
+
outcome.detail = `${outcome.detail ?? ""}\n\nApproval token (pass to approvalToken on retry): ${token}`;
|
|
1666
|
+
} else {
|
|
1667
|
+
clearApprovalTokensFor(profile, spec, { surface: "agent", owner: agentOwner });
|
|
1668
|
+
}
|
|
1669
|
+
return outcome;
|
|
1670
|
+
});
|
|
1671
|
+
return { cancel: producer.cancel, done, readOutput: producer.readOutput };
|
|
1672
|
+
};
|
|
1673
|
+
|
|
478
1674
|
const jobId = ctx.jobs.start({
|
|
479
1675
|
kind: "dsh-plugin-install",
|
|
480
1676
|
label: `dsh plugin --profile ${profile} add ${spec}`,
|
|
481
1677
|
...exec.agent ? { owner: exec.agent } : {},
|
|
482
|
-
run:
|
|
1678
|
+
run: runProducer,
|
|
483
1679
|
});
|
|
484
1680
|
return { kind: "background", jobId };
|
|
485
1681
|
},
|
|
@@ -521,16 +1717,33 @@ export function apply(ctx, config = {}) {
|
|
|
521
1717
|
const packageName = String(args.package ?? "").trim();
|
|
522
1718
|
if (packageName.length === 0) throw new Error("market_uninstall: package name is required");
|
|
523
1719
|
assertSafeSpec(packageName);
|
|
1720
|
+
let profileDir;
|
|
524
1721
|
try {
|
|
525
|
-
resolveProfileDir(profile);
|
|
1722
|
+
profileDir = resolveProfileDir(profile);
|
|
526
1723
|
} catch (error) {
|
|
527
1724
|
throw new Error(`market_uninstall: invalid profile: ${error.message}`);
|
|
528
1725
|
}
|
|
1726
|
+
const runProducer = () => {
|
|
1727
|
+
const producer = runRemove({ profile, packageName });
|
|
1728
|
+
const done = Promise.resolve(producer.done)
|
|
1729
|
+
.catch((error) => ({
|
|
1730
|
+
status: "failed",
|
|
1731
|
+
detail: `remove of ${packageName} hit an internal error: ${error?.message ?? String(error)}`,
|
|
1732
|
+
}))
|
|
1733
|
+
.then((outcome) => {
|
|
1734
|
+
if (outcome?.status === "completed") {
|
|
1735
|
+
invalidatePreflightFor(profileDir);
|
|
1736
|
+
clearApprovalTokensFor(profile);
|
|
1737
|
+
}
|
|
1738
|
+
return outcome;
|
|
1739
|
+
});
|
|
1740
|
+
return { cancel: producer.cancel, done, readOutput: producer.readOutput };
|
|
1741
|
+
};
|
|
529
1742
|
const jobId = ctx.jobs.start({
|
|
530
1743
|
kind: "dsh-plugin-uninstall",
|
|
531
1744
|
label: `dsh plugin --profile ${profile} remove ${packageName}`,
|
|
532
1745
|
...exec.agent ? { owner: exec.agent } : {},
|
|
533
|
-
run:
|
|
1746
|
+
run: runProducer,
|
|
534
1747
|
});
|
|
535
1748
|
return { kind: "background", jobId };
|
|
536
1749
|
},
|
|
@@ -572,10 +1785,470 @@ export function apply(ctx, config = {}) {
|
|
|
572
1785
|
}),
|
|
573
1786
|
}));
|
|
574
1787
|
|
|
575
|
-
// Browser surface: the /market RPC channel backs the Settings → Plugins →
|
|
576
|
-
// 插件市场 tab shipped in src/client.js.
|
|
577
1788
|
registerRpcChannel(ctx, config, token);
|
|
578
1789
|
}
|
|
579
1790
|
|
|
580
|
-
//
|
|
581
|
-
|
|
1791
|
+
// ── offline fixtures / self-test ────────────────────────────────────────────
|
|
1792
|
+
|
|
1793
|
+
export async function runSelfTests() {
|
|
1794
|
+
let failed = 0;
|
|
1795
|
+
const check = (label, ok, extra = "") => {
|
|
1796
|
+
if (ok) {
|
|
1797
|
+
console.log(` PASS ${label}`);
|
|
1798
|
+
} else {
|
|
1799
|
+
failed++;
|
|
1800
|
+
console.error(` FAIL ${label}${extra ? ` (${extra})` : ""}`);
|
|
1801
|
+
}
|
|
1802
|
+
};
|
|
1803
|
+
|
|
1804
|
+
const root = mkdtempSync(join(tmpdir(), "dsh-mall-index-selftest-"));
|
|
1805
|
+
try {
|
|
1806
|
+
const profileDir = join(root, "profiles", "web");
|
|
1807
|
+
mkdirSync(profileDir, { recursive: true });
|
|
1808
|
+
writeFileSync(join(profileDir, "package.json"), JSON.stringify({ name: "profile-web", dependencies: { "dep-a": "1.0.0" } }));
|
|
1809
|
+
writeFileSync(join(profileDir, "pnpm-workspace.yaml"), "packages:\n - .\n");
|
|
1810
|
+
writeFileSync(join(profileDir, "cordis.patch.yml"), "[]\n");
|
|
1811
|
+
mkdirSync(join(profileDir, "node_modules", "dep-a"), { recursive: true });
|
|
1812
|
+
writeFileSync(join(profileDir, "node_modules", "dep-a", "package.json"), JSON.stringify({ name: "dep-a", version: "1.0.0" }));
|
|
1813
|
+
|
|
1814
|
+
// ── 1. computeProfileFingerprint & direct-only inspection ────────────────
|
|
1815
|
+
const fp1 = computeProfileFingerprint(profileDir);
|
|
1816
|
+
check("fingerprint 是非空 64 位 SHA-256 字符串", typeof fp1 === "string" && fp1.length === 64);
|
|
1817
|
+
const fp1Repeat = computeProfileFingerprint(profileDir);
|
|
1818
|
+
check("fingerprint 计算幂等", fp1 === fp1Repeat);
|
|
1819
|
+
|
|
1820
|
+
// 修改 cordis.patch.yml 改变 fingerprint
|
|
1821
|
+
writeFileSync(join(profileDir, "cordis.patch.yml"), "- name: test\n");
|
|
1822
|
+
const fp2 = computeProfileFingerprint(profileDir);
|
|
1823
|
+
check("受保护文件修改 (cordis.patch.yml) → fingerprint 变化", fp1 !== fp2);
|
|
1824
|
+
|
|
1825
|
+
// 修改依赖版本改变 fingerprint
|
|
1826
|
+
writeFileSync(join(profileDir, "node_modules", "dep-a", "package.json"), JSON.stringify({ name: "dep-a", version: "1.0.1" }));
|
|
1827
|
+
const fp3 = computeProfileFingerprint(profileDir);
|
|
1828
|
+
check("已装直系依赖版本变化 → fingerprint 变化", fp2 !== fp3);
|
|
1829
|
+
|
|
1830
|
+
// 依赖清单 name 不匹配 (corrupt)
|
|
1831
|
+
writeFileSync(join(profileDir, "node_modules", "dep-a", "package.json"), JSON.stringify({ name: "wrong-name", version: "1.0.1" }));
|
|
1832
|
+
const fpCorrupt = computeProfileFingerprint(profileDir);
|
|
1833
|
+
check("依赖 package.json 名称不匹配被判定为 corrupt 且改变 fingerprint", fpCorrupt !== fp3);
|
|
1834
|
+
|
|
1835
|
+
// 祖先目录隔离:在 root 下建立 node_modules/dep-ancestor,但 profileDir/node_modules 下没有
|
|
1836
|
+
mkdirSync(join(root, "node_modules", "dep-ancestor"), { recursive: true });
|
|
1837
|
+
writeFileSync(join(root, "node_modules", "dep-ancestor", "package.json"), JSON.stringify({ name: "dep-ancestor", version: "2.0.0" }));
|
|
1838
|
+
writeFileSync(join(profileDir, "package.json"), JSON.stringify({ name: "profile-web", dependencies: { "dep-a": "1.0.0", "dep-ancestor": "2.0.0" } }));
|
|
1839
|
+
writeFileSync(join(profileDir, "node_modules", "dep-a", "package.json"), JSON.stringify({ name: "dep-a", version: "1.0.0" }));
|
|
1840
|
+
const fpNoAncestor = computeProfileFingerprint(profileDir);
|
|
1841
|
+
|
|
1842
|
+
// 在 profileDir/node_modules/dep-ancestor 下真正创建
|
|
1843
|
+
mkdirSync(join(profileDir, "node_modules", "dep-ancestor"), { recursive: true });
|
|
1844
|
+
writeFileSync(join(profileDir, "node_modules", "dep-ancestor", "package.json"), JSON.stringify({ name: "dep-ancestor", version: "2.0.0" }));
|
|
1845
|
+
const fpWithDirect = computeProfileFingerprint(profileDir);
|
|
1846
|
+
check("fingerprint 绝不向上回溯祖先 node_modules (仅认 profile direct node_modules)", fpNoAncestor !== fpWithDirect);
|
|
1847
|
+
|
|
1848
|
+
// ── 2. First-call allowBuildScripts rejection helper ─────────────────────
|
|
1849
|
+
let firstCallRejected = false;
|
|
1850
|
+
try {
|
|
1851
|
+
assertValidApprovalInvocation(["some-pkg"], undefined);
|
|
1852
|
+
} catch (err) {
|
|
1853
|
+
firstCallRejected = /cannot be specified on initial install/.test(err.message);
|
|
1854
|
+
}
|
|
1855
|
+
check("首次调用携带 allowBuildScripts 无审批 token 被拒绝", firstCallRejected);
|
|
1856
|
+
|
|
1857
|
+
let emptyTokenRejected = false;
|
|
1858
|
+
try {
|
|
1859
|
+
assertValidApprovalInvocation(["some-pkg"], "");
|
|
1860
|
+
} catch (err) {
|
|
1861
|
+
emptyTokenRejected = /cannot be specified on initial install/.test(err.message);
|
|
1862
|
+
}
|
|
1863
|
+
check("携带空字符串 token 调用 allowBuildScripts 被拒绝", emptyTokenRejected);
|
|
1864
|
+
|
|
1865
|
+
let validCallAllowed = true;
|
|
1866
|
+
try {
|
|
1867
|
+
assertValidApprovalInvocation(undefined, undefined);
|
|
1868
|
+
assertValidApprovalInvocation([], undefined);
|
|
1869
|
+
assertValidApprovalInvocation(["some-pkg"], "mkt-appr-abc123");
|
|
1870
|
+
} catch {
|
|
1871
|
+
validCallAllowed = false;
|
|
1872
|
+
}
|
|
1873
|
+
check("无 allowBuildScripts 或携带合法 token 允许通过", validCallAllowed);
|
|
1874
|
+
|
|
1875
|
+
const ownerA = requireAgentApprovalOwner({ agent: { id: "agent-session-123" } });
|
|
1876
|
+
const ownerB = requireAgentApprovalOwner({ agent: { id: "agent-session-123" } });
|
|
1877
|
+
let missingAgentRejected = false;
|
|
1878
|
+
try { requireAgentApprovalOwner({}); } catch { missingAgentRejected = true; }
|
|
1879
|
+
check("agent 审批 owner 使用稳定标量 ID(不绑定瞬态对象引用)且缺失时 fail closed", ownerA === ownerB && ownerA === "agent-session-123" && missingAgentRejected);
|
|
1880
|
+
|
|
1881
|
+
// ── 3. Safe-preflight token issuance & warning consent carrying ──────────
|
|
1882
|
+
const cleanPreflightReport = {
|
|
1883
|
+
verdict: "clean",
|
|
1884
|
+
summary: "clean install",
|
|
1885
|
+
issues: [],
|
|
1886
|
+
};
|
|
1887
|
+
const proofFor = (candidateName, packageNames = [candidateName]) => ({
|
|
1888
|
+
candidate: { name: candidateName, version: "1.0.0", scripts: { install: "node install.js" }, contentHash: "a".repeat(64) },
|
|
1889
|
+
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) })),
|
|
1890
|
+
});
|
|
1891
|
+
const disclosureFor = (proof) => proof.blockedPackages.map((entry) => ({ ...entry }));
|
|
1892
|
+
const sampleProof = {
|
|
1893
|
+
candidate: { name: "safe-pkg", version: "1.0.0", scripts: { install: "node install.js" }, contentHash: "a".repeat(64) },
|
|
1894
|
+
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) }],
|
|
1895
|
+
};
|
|
1896
|
+
const safeNeedsApproval = disclosureFor(sampleProof);
|
|
1897
|
+
const safeToken = issueApprovalToken({
|
|
1898
|
+
profile: "web",
|
|
1899
|
+
profileDir,
|
|
1900
|
+
spec: "safe-pkg@1.0.0",
|
|
1901
|
+
preflightReport: cleanPreflightReport,
|
|
1902
|
+
needsApproval: safeNeedsApproval,
|
|
1903
|
+
proof: sampleProof,
|
|
1904
|
+
surface: "agent",
|
|
1905
|
+
owner: "agent-safe",
|
|
1906
|
+
acceptWarningsActive: false,
|
|
1907
|
+
});
|
|
1908
|
+
check("安全 preflight 下依然签发审批 token", typeof safeToken === "string" && safeToken.startsWith("mkt-appr-"));
|
|
1909
|
+
|
|
1910
|
+
let staleDisclosureRejected = false;
|
|
1911
|
+
try {
|
|
1912
|
+
issueApprovalToken({
|
|
1913
|
+
profile: "web",
|
|
1914
|
+
profileDir,
|
|
1915
|
+
spec: "safe-pkg@1.0.0",
|
|
1916
|
+
preflightReport: cleanPreflightReport,
|
|
1917
|
+
needsApproval: safeNeedsApproval.map((entry) => ({ ...entry, scripts: { install: "different command" } })),
|
|
1918
|
+
proof: sampleProof,
|
|
1919
|
+
surface: "agent",
|
|
1920
|
+
owner: "agent-safe",
|
|
1921
|
+
});
|
|
1922
|
+
} catch (error) {
|
|
1923
|
+
staleDisclosureRejected = /disclosure identity, scripts, or content hash/.test(error.message);
|
|
1924
|
+
}
|
|
1925
|
+
check("审批展示与物化 proof 的脚本/哈希不一致时拒绝签发 token", staleDisclosureRejected);
|
|
1926
|
+
|
|
1927
|
+
// 尝试用不匹配的 profileDir 消费 token(验证 realProfileDir 绑定)
|
|
1928
|
+
const fakeOtherProfileDir = join(root, "profiles", "other-profile");
|
|
1929
|
+
mkdirSync(fakeOtherProfileDir, { recursive: true });
|
|
1930
|
+
writeFileSync(join(fakeOtherProfileDir, "package.json"), "{}");
|
|
1931
|
+
const mismatchProfileDirRes = consumeApprovalToken({
|
|
1932
|
+
token: safeToken,
|
|
1933
|
+
profile: "web",
|
|
1934
|
+
profileDir: fakeOtherProfileDir,
|
|
1935
|
+
spec: "safe-pkg@1.0.0",
|
|
1936
|
+
preflightReport: cleanPreflightReport,
|
|
1937
|
+
allowBuildScripts: ["safe-pkg"],
|
|
1938
|
+
surface: "agent",
|
|
1939
|
+
owner: "agent-safe",
|
|
1940
|
+
});
|
|
1941
|
+
check("profileDir realpath 不匹配时 token 消费被拒绝", !mismatchProfileDirRes.valid && /profile directory mismatch/.test(mismatchProfileDirRes.reason));
|
|
1942
|
+
|
|
1943
|
+
// 重新签发用于正常消费验证
|
|
1944
|
+
const safeToken2 = issueApprovalToken({
|
|
1945
|
+
profile: "web",
|
|
1946
|
+
profileDir,
|
|
1947
|
+
spec: "safe-pkg@1.0.0",
|
|
1948
|
+
preflightReport: cleanPreflightReport,
|
|
1949
|
+
needsApproval: safeNeedsApproval,
|
|
1950
|
+
proof: sampleProof,
|
|
1951
|
+
surface: "agent",
|
|
1952
|
+
owner: "agent-safe",
|
|
1953
|
+
acceptWarningsActive: false,
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
const safeConsume = consumeApprovalToken({
|
|
1957
|
+
token: safeToken2,
|
|
1958
|
+
profile: "web",
|
|
1959
|
+
profileDir,
|
|
1960
|
+
spec: "safe-pkg@1.0.0",
|
|
1961
|
+
preflightReport: cleanPreflightReport,
|
|
1962
|
+
allowBuildScripts: ["safe-pkg"],
|
|
1963
|
+
surface: "agent",
|
|
1964
|
+
owner: "agent-safe",
|
|
1965
|
+
});
|
|
1966
|
+
check("安全 preflight token 消费成功且返回 proof 与 warningConsent", safeConsume.valid && safeConsume.warningConsent === false && serializeCanonicalProof(safeConsume.proof) === serializeCanonicalProof(sampleProof));
|
|
1967
|
+
|
|
1968
|
+
const warnPreflightReport = {
|
|
1969
|
+
verdict: "warning",
|
|
1970
|
+
summary: "warning found",
|
|
1971
|
+
issues: [{ severity: "warn", code: "MANIFEST_PATCH_OVERWRITE", title: "warn", detail: "detail" }],
|
|
1972
|
+
};
|
|
1973
|
+
const warnProof = proofFor("warn-pkg");
|
|
1974
|
+
const warnToken = issueApprovalToken({
|
|
1975
|
+
profile: "web",
|
|
1976
|
+
profileDir,
|
|
1977
|
+
spec: "warn-pkg@1.0.0",
|
|
1978
|
+
preflightReport: warnPreflightReport,
|
|
1979
|
+
needsApproval: disclosureFor(warnProof),
|
|
1980
|
+
proof: warnProof,
|
|
1981
|
+
surface: "browser",
|
|
1982
|
+
owner: "sess-warn",
|
|
1983
|
+
acceptWarningsActive: true,
|
|
1984
|
+
});
|
|
1985
|
+
const warnConsume = consumeApprovalToken({
|
|
1986
|
+
token: warnToken,
|
|
1987
|
+
profile: "web",
|
|
1988
|
+
profileDir,
|
|
1989
|
+
spec: "warn-pkg@1.0.0",
|
|
1990
|
+
preflightReport: warnPreflightReport,
|
|
1991
|
+
allowBuildScripts: ["warn-pkg"],
|
|
1992
|
+
surface: "browser",
|
|
1993
|
+
owner: "sess-warn",
|
|
1994
|
+
});
|
|
1995
|
+
check("含警告 preflight 经确认后 token 消费携带 warningConsent 为 true", warnConsume.valid && warnConsume.warningConsent === true);
|
|
1996
|
+
|
|
1997
|
+
// ── 4. Exact package-set equality in consumeApprovalToken ────────────────
|
|
1998
|
+
const multiProof = proofFor("multi-pkg", ["pkg-a", "pkg-b"]);
|
|
1999
|
+
const multiPkgs = disclosureFor(multiProof).reverse();
|
|
2000
|
+
const testIssue = () => issueApprovalToken({
|
|
2001
|
+
profile: "web",
|
|
2002
|
+
profileDir,
|
|
2003
|
+
spec: "multi-pkg",
|
|
2004
|
+
preflightReport: cleanPreflightReport,
|
|
2005
|
+
needsApproval: multiPkgs,
|
|
2006
|
+
proof: multiProof,
|
|
2007
|
+
surface: "agent",
|
|
2008
|
+
owner: "agent-test",
|
|
2009
|
+
});
|
|
2010
|
+
|
|
2011
|
+
// 4a. Exact match (sorted automatically)
|
|
2012
|
+
const exactTok = testIssue();
|
|
2013
|
+
const exactRes = consumeApprovalToken({
|
|
2014
|
+
token: exactTok,
|
|
2015
|
+
profile: "web",
|
|
2016
|
+
profileDir,
|
|
2017
|
+
spec: "multi-pkg",
|
|
2018
|
+
preflightReport: cleanPreflightReport,
|
|
2019
|
+
allowBuildScripts: ["pkg-b", "pkg-a"],
|
|
2020
|
+
surface: "agent",
|
|
2021
|
+
owner: "agent-test",
|
|
2022
|
+
});
|
|
2023
|
+
check("完整包名集合(乱序输入)匹配成功", exactRes.valid);
|
|
2024
|
+
|
|
2025
|
+
// 4b. Subset rejected
|
|
2026
|
+
const subTok = testIssue();
|
|
2027
|
+
const subRes = consumeApprovalToken({
|
|
2028
|
+
token: subTok,
|
|
2029
|
+
profile: "web",
|
|
2030
|
+
profileDir,
|
|
2031
|
+
spec: "multi-pkg",
|
|
2032
|
+
preflightReport: cleanPreflightReport,
|
|
2033
|
+
allowBuildScripts: ["pkg-a"],
|
|
2034
|
+
surface: "agent",
|
|
2035
|
+
owner: "agent-test",
|
|
2036
|
+
});
|
|
2037
|
+
check("子集包名消费被拒绝", !subRes.valid && /package count/.test(subRes.reason));
|
|
2038
|
+
|
|
2039
|
+
// 4c. Extra package rejected
|
|
2040
|
+
const extraTok = testIssue();
|
|
2041
|
+
const extraRes = consumeApprovalToken({
|
|
2042
|
+
token: extraTok,
|
|
2043
|
+
profile: "web",
|
|
2044
|
+
profileDir,
|
|
2045
|
+
spec: "multi-pkg",
|
|
2046
|
+
preflightReport: cleanPreflightReport,
|
|
2047
|
+
allowBuildScripts: ["pkg-a", "pkg-b", "pkg-c"],
|
|
2048
|
+
surface: "agent",
|
|
2049
|
+
owner: "agent-test",
|
|
2050
|
+
});
|
|
2051
|
+
check("超集/额外包名消费被拒绝", !extraRes.valid && /package count/.test(extraRes.reason));
|
|
2052
|
+
|
|
2053
|
+
// 4d. Duplicate package rejected
|
|
2054
|
+
const dupTok = testIssue();
|
|
2055
|
+
const dupRes = consumeApprovalToken({
|
|
2056
|
+
token: dupTok,
|
|
2057
|
+
profile: "web",
|
|
2058
|
+
profileDir,
|
|
2059
|
+
spec: "multi-pkg",
|
|
2060
|
+
preflightReport: cleanPreflightReport,
|
|
2061
|
+
allowBuildScripts: ["pkg-a", "pkg-a"],
|
|
2062
|
+
surface: "agent",
|
|
2063
|
+
owner: "agent-test",
|
|
2064
|
+
});
|
|
2065
|
+
check("重复包名消费被拒绝", !dupRes.valid && /duplicate package name/.test(dupRes.reason));
|
|
2066
|
+
|
|
2067
|
+
// 4e. Invalid package name rejected
|
|
2068
|
+
const invTok = testIssue();
|
|
2069
|
+
const invRes = consumeApprovalToken({
|
|
2070
|
+
token: invTok,
|
|
2071
|
+
profile: "web",
|
|
2072
|
+
profileDir,
|
|
2073
|
+
spec: "multi-pkg",
|
|
2074
|
+
preflightReport: cleanPreflightReport,
|
|
2075
|
+
allowBuildScripts: ["pkg-a", "../invalid"],
|
|
2076
|
+
surface: "agent",
|
|
2077
|
+
owner: "agent-test",
|
|
2078
|
+
});
|
|
2079
|
+
check("非法/含路径穿越包名消费被拒绝", !invRes.valid && /invalid package name/.test(invRes.reason));
|
|
2080
|
+
|
|
2081
|
+
// 4f. Empty array rejected
|
|
2082
|
+
const emptyTok = testIssue();
|
|
2083
|
+
const emptyRes = consumeApprovalToken({
|
|
2084
|
+
token: emptyTok,
|
|
2085
|
+
profile: "web",
|
|
2086
|
+
profileDir,
|
|
2087
|
+
spec: "multi-pkg",
|
|
2088
|
+
preflightReport: cleanPreflightReport,
|
|
2089
|
+
allowBuildScripts: [],
|
|
2090
|
+
surface: "agent",
|
|
2091
|
+
owner: "agent-test",
|
|
2092
|
+
});
|
|
2093
|
+
check("空包名数组消费被拒绝", !emptyRes.valid && /cannot be empty/.test(emptyRes.reason));
|
|
2094
|
+
|
|
2095
|
+
// ── 5. Cross-browser session rejection & token isolation ─────────────────
|
|
2096
|
+
const browserProof = proofFor("browser-pkg");
|
|
2097
|
+
const browserTok = issueApprovalToken({
|
|
2098
|
+
profile: "web",
|
|
2099
|
+
profileDir,
|
|
2100
|
+
spec: "browser-pkg",
|
|
2101
|
+
preflightReport: cleanPreflightReport,
|
|
2102
|
+
needsApproval: disclosureFor(browserProof),
|
|
2103
|
+
proof: browserProof,
|
|
2104
|
+
surface: "browser",
|
|
2105
|
+
owner: "session-alpha",
|
|
2106
|
+
});
|
|
2107
|
+
const crossSessionRes = consumeApprovalToken({
|
|
2108
|
+
token: browserTok,
|
|
2109
|
+
profile: "web",
|
|
2110
|
+
profileDir,
|
|
2111
|
+
spec: "browser-pkg",
|
|
2112
|
+
preflightReport: cleanPreflightReport,
|
|
2113
|
+
allowBuildScripts: ["browser-pkg"],
|
|
2114
|
+
surface: "browser",
|
|
2115
|
+
owner: "session-beta",
|
|
2116
|
+
});
|
|
2117
|
+
check("跨浏览器 session 消费审批 token 被拒绝且销毁", !crossSessionRes.valid && /session mismatch/.test(crossSessionRes.reason));
|
|
2118
|
+
|
|
2119
|
+
// 跨 surface (browser vs agent)
|
|
2120
|
+
const agentProof = proofFor("agent-pkg");
|
|
2121
|
+
const agentTok = issueApprovalToken({
|
|
2122
|
+
profile: "web",
|
|
2123
|
+
profileDir,
|
|
2124
|
+
spec: "agent-pkg",
|
|
2125
|
+
preflightReport: cleanPreflightReport,
|
|
2126
|
+
needsApproval: disclosureFor(agentProof),
|
|
2127
|
+
proof: agentProof,
|
|
2128
|
+
surface: "agent",
|
|
2129
|
+
owner: "agent-1",
|
|
2130
|
+
});
|
|
2131
|
+
const crossSurfaceRes = consumeApprovalToken({
|
|
2132
|
+
token: agentTok,
|
|
2133
|
+
profile: "web",
|
|
2134
|
+
profileDir,
|
|
2135
|
+
spec: "agent-pkg",
|
|
2136
|
+
preflightReport: cleanPreflightReport,
|
|
2137
|
+
allowBuildScripts: ["agent-pkg"],
|
|
2138
|
+
surface: "browser",
|
|
2139
|
+
owner: "browser-sess",
|
|
2140
|
+
});
|
|
2141
|
+
check("跨 surface (agent vs browser) 消费审批 token 失败且被销毁", !crossSurfaceRes.valid && /surface mismatch/.test(crossSurfaceRes.reason));
|
|
2142
|
+
|
|
2143
|
+
// Tracker 隔离与 session 校验
|
|
2144
|
+
const trackerProof = proofFor("foo-script");
|
|
2145
|
+
let needsApprovalOutcome = {
|
|
2146
|
+
status: "needsApproval",
|
|
2147
|
+
needsApproval: disclosureFor(trackerProof),
|
|
2148
|
+
proof: trackerProof,
|
|
2149
|
+
};
|
|
2150
|
+
const approvalProducer = {
|
|
2151
|
+
cancel: () => {},
|
|
2152
|
+
done: Promise.resolve(needsApprovalOutcome),
|
|
2153
|
+
readOutput: () => "build scripts needed",
|
|
2154
|
+
};
|
|
2155
|
+
const sessionTracker = createJobTracker({
|
|
2156
|
+
producerFactory: () => approvalProducer,
|
|
2157
|
+
});
|
|
2158
|
+
const sessionJobId = sessionTracker.start({
|
|
2159
|
+
profile: "web",
|
|
2160
|
+
spec: "foo-script",
|
|
2161
|
+
profileDir,
|
|
2162
|
+
surface: "browser",
|
|
2163
|
+
session: "session-alpha",
|
|
2164
|
+
});
|
|
2165
|
+
await new Promise((resolvePromise) => setImmediate(resolvePromise));
|
|
2166
|
+
|
|
2167
|
+
const snapDiffSession = sessionTracker.get(sessionJobId, "session-beta").snapshot;
|
|
2168
|
+
check("不同 session 查询 job 时不会暴露 approvalToken", snapDiffSession.approvalToken === undefined);
|
|
2169
|
+
|
|
2170
|
+
const snapSameSession = sessionTracker.get(sessionJobId, "session-alpha").snapshot;
|
|
2171
|
+
check("相同 session 查询 job 时可获取 approvalToken", typeof snapSameSession.approvalToken === "string");
|
|
2172
|
+
|
|
2173
|
+
let cancelRefused = false;
|
|
2174
|
+
try {
|
|
2175
|
+
sessionTracker.cancel(sessionJobId, "session-beta");
|
|
2176
|
+
} catch (err) {
|
|
2177
|
+
cancelRefused = /unauthorized/.test(err.message);
|
|
2178
|
+
}
|
|
2179
|
+
check("不同 session 取消 job 被拒绝", cancelRefused);
|
|
2180
|
+
|
|
2181
|
+
const dismissDiff = sessionTracker.dismiss(sessionJobId, "session-beta");
|
|
2182
|
+
check("不同 session dismiss job 返回 false", dismissDiff === false);
|
|
2183
|
+
|
|
2184
|
+
const dismissSame = sessionTracker.dismiss(sessionJobId, "session-alpha");
|
|
2185
|
+
check("相同 session dismiss job 成功且 token 被注销", dismissSame === true);
|
|
2186
|
+
const snapAfterDismiss = sessionTracker.get(sessionJobId, "session-alpha").snapshot;
|
|
2187
|
+
check("dismiss 后 job snapshot 中 approvalToken 为 undefined", snapAfterDismiss.approvalToken === undefined);
|
|
2188
|
+
|
|
2189
|
+
// ── 6. Windows profile names & restart plan ──────────────────────────────
|
|
2190
|
+
check("合法 profile 名称识别", isSafeProfileName("web", true) && isSafeProfileName("profile_1", true) && isSafeProfileName("dev-test", true));
|
|
2191
|
+
check("Windows 尾随点拒绝", !isSafeProfileName("web.", true) && !isSafeProfileName("test..", true));
|
|
2192
|
+
check("Windows 尾随空格拒绝", !isSafeProfileName("web ", true) && !isSafeProfileName("test ", true));
|
|
2193
|
+
|
|
2194
|
+
const reservedDevices = ["CON", "prn", "aux", "nul", "COM1", "com9", "lpt1", "LPT9", "con.txt", "PRN.json", "aux.yaml", "NUL.js", "COM1.d", "lpt9.log"];
|
|
2195
|
+
let allDevicesRejected = true;
|
|
2196
|
+
for (const dev of reservedDevices) {
|
|
2197
|
+
if (isSafeProfileName(dev, true)) {
|
|
2198
|
+
allDevicesRejected = false;
|
|
2199
|
+
break;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
check("Windows 保留设备名 (含扩展名及大小写) 全部拒绝", allDevicesRejected);
|
|
2203
|
+
|
|
2204
|
+
// isWindows: true —— 这两条断言的是 Windows 路径语义,与运行平台无关地钉死;
|
|
2205
|
+
// 否则 Linux CI 上 "CON"/"web." 是合法名,plan 会因下游原因失败、错误对不上。
|
|
2206
|
+
const restartBadPlan = resolveRestartLaunchPlan({ profile: "CON", config: { allowRestart: true }, isWindows: true });
|
|
2207
|
+
check("保留设备名 profile 重启 plan fail-closed", !restartBadPlan.ok && /reserved Windows device name/.test(restartBadPlan.error));
|
|
2208
|
+
|
|
2209
|
+
const restartDotPlan = resolveRestartLaunchPlan({ profile: "web.", config: { allowRestart: true }, isWindows: true });
|
|
2210
|
+
check("尾随点 profile 重启 plan fail-closed", !restartDotPlan.ok && /dot or space/.test(restartDotPlan.error));
|
|
2211
|
+
|
|
2212
|
+
// ── 7. Tracker isolation: producer.done rejection handling ───────────────
|
|
2213
|
+
let settledOutcome = null;
|
|
2214
|
+
const rejectingProducer = {
|
|
2215
|
+
cancel: () => {},
|
|
2216
|
+
done: Promise.reject(new Error("simulated spawn failure")),
|
|
2217
|
+
readOutput: () => "",
|
|
2218
|
+
};
|
|
2219
|
+
let producerCalls = 0;
|
|
2220
|
+
const tracker = createJobTracker({
|
|
2221
|
+
producerFactory: () => {
|
|
2222
|
+
producerCalls++;
|
|
2223
|
+
return rejectingProducer;
|
|
2224
|
+
},
|
|
2225
|
+
});
|
|
2226
|
+
const jobId = tracker.start({
|
|
2227
|
+
profile: "fixture-profile",
|
|
2228
|
+
spec: "fail-pkg",
|
|
2229
|
+
profileDir,
|
|
2230
|
+
onSettled: (outcome) => { settledOutcome = outcome; },
|
|
2231
|
+
});
|
|
2232
|
+
await new Promise((resolvePromise) => setImmediate(resolvePromise));
|
|
2233
|
+
const trackerSnapshot = tracker.get(jobId, "").snapshot;
|
|
2234
|
+
check(
|
|
2235
|
+
"tracker rejection fixture 使用注入 producer,不触碰真实 profile",
|
|
2236
|
+
producerCalls === 1 && trackerSnapshot.status === "failed" && settledOutcome?.status === "failed",
|
|
2237
|
+
);
|
|
2238
|
+
} finally {
|
|
2239
|
+
rmSync(root, { recursive: true, force: true });
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
return failed;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
if (process.argv.includes("--self-test")) {
|
|
2246
|
+
console.log("index.js self-test:");
|
|
2247
|
+
runSelfTests().then((failed) => {
|
|
2248
|
+
console.log(`index.js tests finished with ${failed} failures.`);
|
|
2249
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
2250
|
+
}).catch((err) => {
|
|
2251
|
+
console.error("Self-test threw:", err);
|
|
2252
|
+
process.exit(1);
|
|
2253
|
+
});
|
|
2254
|
+
}
|