@1e0zj/dsh-plugin-mall 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // services, `Config` validates the row's config, `name` is the plugin name.
15
15
 
16
16
  import z from "@deepseek-ai/schemastery";
17
+ import { valid as validExactVersion, maxSatisfying } from "semver";
17
18
  import { defineTool } from "@deepseek-ai/dsh-tools";
18
19
  import { existsSync, readFileSync, realpathSync, mkdirSync, mkdtempSync, writeFileSync, rmSync, openSync, closeSync, writeSync } from "node:fs";
19
20
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -25,7 +26,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
25
26
  import { createRequire } from "node:module";
26
27
  import { tmpdir } from "node:os";
27
28
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
28
- import { repoInfo, searchPlugins, verifyPlugins, cachedRepoManifest, fetchRawFile, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
29
+ import { repoInfo, searchPlugins, verifyPlugins, cachedRepoManifest, fetchRawFile, preferNpmSpec, npmPackageInfo, npmPackageVersions, npmNameOf, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
29
30
  import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof, persistPluginDisabled } from "./installer.js";
30
31
  import { preflightInstall, inspectRemoteCandidate, recoverProfile, describeRollbackRebuild, isAbortError } from "./guard.js";
31
32
  import { createRestartHelperReadyMessage, RESTART_HELPER_READY_TYPE, RESTART_RESPONSE_DRAIN_MS, superviseRestartHelper } from "./restart-protocol.js";
@@ -336,9 +337,20 @@ function invalidatePreflightFor(profileDir) {
336
337
  * never caches warning consent.
337
338
  */
338
339
  function pinPreflight(profileDir, spec) {
340
+ // pin 只给核得住的引用:不可变(精确版本/钉死的 sha)或可再校验的
341
+ // (npm tag/range,读取路径会重新核)。file:/link:/URL/未钉 sha 的
342
+ // github 拿不到可信身份,pin 了也只是把一条注定要丢弃的缓存钉在
343
+ // 那里——读取路径对它们一律重跑,这里就别给「已核过」的假象。
344
+ if (specIdentityKind(spec) === null) return;
339
345
  const key = preflightCacheKey(profileDir, spec);
340
346
  const cached = preflightCache.get(key);
341
347
  if (cached === undefined) return;
348
+ // blocked 一律不 pin(ok === true 的只有 safe/warning)。探装失败的
349
+ // blocker 最要命:网络抖一下就是一份 ok:false 的 blocked,而 spec 若是
350
+ // 精确版本这类不可变形态,身份再校验也不会拦——钉住等于把一次临时失败
351
+ // 固化 10 分钟,网络恢复也不再重试。真正的冲突 blocker 也没有「用户
352
+ // 读完再继续」的后继流程,pin 本就没有服务对象。
353
+ if (cached.report?.ok !== true) return;
342
354
  const currentFingerprint = computeProfileFingerprint(profileDir);
343
355
  if (cached.fingerprint !== currentFingerprint) {
344
356
  preflightCache.delete(key);
@@ -358,10 +370,90 @@ function pinPreflight(profileDir, spec) {
358
370
  * a `blocked` report, that fabricated verdict would have been cached for the
359
371
  * whole TTL and every later install of this spec refused with it.)
360
372
  */
361
- async function runPreflight({ profile, spec, force = false, onOutput, signal }) {
373
+
374
+ /**
375
+ * The "owner/repo" part of a github: spec, or null. Anchored end-to-end: an
376
+ * earlier version let the lazy repo group stop early (`github:owner/repo`
377
+ * came out as `owner/r`) because the `.git` alternative was optional and
378
+ * nothing forced the match to run to the end — every identity lookup then
379
+ * keyed on a repo that does not exist and quietly failed open.
380
+ */
381
+ export function githubSpecRepo(raw) {
382
+ const match = /^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.exec(String(raw ?? ""));
383
+ return match === null ? null : match[1];
384
+ }
385
+
386
+ /**
387
+ * What a cache-reuse staleness check can rely on for this spec shape.
388
+ *
389
+ * "immutable" exact npm version (npm forbids overwriting a published
390
+ * version) or github:...#<full 40-hex sha>. Nothing can
391
+ * drift; reuse unconditionally.
392
+ * "npm-tag" bare name / @latest / @* — resolves to whatever `latest`
393
+ * is now; verifiable against the install registry.
394
+ * "npm-range" pkg@^1.2.0 — an in-range release changes what pnpm picks;
395
+ * verifiable via the packument.
396
+ * null UNVERIFIABLE, not immutable: file:/link:/URL tarballs (the
397
+ * content can change in place), github without a pinned sha
398
+ * (same version can point at different code — comparing
399
+ * name/version proves nothing), owner/repo, anything else.
400
+ * No trusted identity is obtainable cheaply, so the cache is
401
+ * never reused for these — see runPreflight.
402
+ */
403
+ export function specIdentityKind(raw) {
404
+ const spec = String(raw ?? "");
405
+ if (/^(?:file:|link:|https?:\/\/)/i.test(spec)) return null;
406
+ if (/^github:/i.test(spec)) {
407
+ const pinned = /^github:[^/\s]+\/[^/\s]+?(?:\.git)?#([0-9a-f]{40})$/i.exec(spec);
408
+ return pinned !== null ? "immutable" : null;
409
+ }
410
+ if (!/^@/.test(spec) && spec.includes("/")) return null; // owner/repo:不可核验
411
+ const name = npmNameOf(spec);
412
+ if (name === null) return null;
413
+ // 注意 scoped 裸名(@scope/name)没有 range:判定依据是「名字之后还有没有
414
+ // 东西」,不是字符串里有没有 @——scope 前缀本身就带 @。
415
+ const range = spec.length > name.length ? spec.slice(name.length + 1) : undefined;
416
+ if (range === undefined || range === "latest" || range === "*") return "npm-tag";
417
+ return validExactVersion(range) === null ? "npm-range" : "immutable";
418
+ }
419
+
420
+ /**
421
+ * What this spec resolves to RIGHT NOW ({name, version}), for the verifiable
422
+ * shapes — the cheap half of "reuse equals rerun". The expensive half is the
423
+ * probe itself; this is one registry read per cache hit.
424
+ *
425
+ * `fresh` is the point: the registry helpers cache for minutes, and a cached
426
+ * "current version" is exactly the staleness this check exists to detect —
427
+ * the candidate published 2.0.0 five minutes into the cache TTL would sail
428
+ * through as "still 1.0.0". Queries here bypass the read cache (they still
429
+ * populate it). The registry is the one pnpm will install from (mirrors
430
+ * included), so whatever lag it has applies to the install equally —
431
+ * verifying against npmjs while installing from npmmirror would be the
432
+ * wrong kind of fresh.
433
+ *
434
+ * Returns undefined when the resolution is unreachable — the caller treats
435
+ * that as "could not verify", which invalidates, never as "verified".
436
+ */
437
+ async function resolveSpecIdentity({ spec, registry, sources, signal }) {
438
+ const kind = specIdentityKind(spec);
439
+ if (kind !== "npm-tag" && kind !== "npm-range") return undefined;
440
+ const name = npmNameOf(String(spec));
441
+ if (name === null) return undefined;
442
+ if (kind === "npm-tag") {
443
+ const info = await npmPackageInfo(name, { registry, signal, fresh: true });
444
+ return info === null || typeof info.latest !== "string" ? undefined : { name, version: info.latest };
445
+ }
446
+ const range = String(spec).slice(name.length + 1);
447
+ const versions = await npmPackageVersions(name, { registry, signal, fresh: true });
448
+ if (versions === null || versions.length === 0) return undefined;
449
+ const best = maxSatisfying(versions, range);
450
+ return best === null ? undefined : { name, version: best };
451
+ }
452
+
453
+ async function runPreflight({ profile, spec, force = false, onOutput, signal, registry, sources, _profileDir, _resolveSpecIdentity = resolveSpecIdentity, _preflightInstall = preflightInstall }) {
362
454
  let profileDir;
363
455
  try {
364
- profileDir = resolveProfileDir(profile);
456
+ profileDir = _profileDir ?? resolveProfileDir(profile);
365
457
  } catch (error) {
366
458
  throw new Error(`invalid profile: ${error.message}`);
367
459
  }
@@ -381,10 +473,41 @@ async function runPreflight({ profile, spec, force = false, onOutput, signal })
381
473
  && (isPinned(validCached) || Date.now() - validCached.at < PREFLIGHT_TTL);
382
474
 
383
475
  if (!force && fresh) {
384
- return { report: validCached.report, profileDir, fingerprint: currentFingerprint };
476
+ // 缓存复用的前提是「重跑必然得到同一份结论」,缺一側都不算数:
477
+ // profile 一侧 —— 指纹一致(上面已查);
478
+ // 候选一侧 —— spec 是不可变引用(精确版本/钉死的 sha),或者能重新
479
+ // 解析出与缓存报告一致的身份。
480
+ // 除此之外一律丢弃重跑:file:/link:/URL 的内容能原地变;未钉 sha 的
481
+ // github 同版本能换代码,name/version 比对证明不了任何事;registry
482
+ // 查不到「当前值」同样是没核住。以前这里 fail-open(核不上就沿用旧
483
+ // 报告),等于给所有核不住的形态开了永久通道。
484
+ const candidate = validCached.report?.candidate;
485
+ const kind = specIdentityKind(spec);
486
+ if (kind === "immutable") {
487
+ return { report: validCached.report, profileDir, fingerprint: currentFingerprint };
488
+ }
489
+ let verified = false;
490
+ if ((kind === "npm-tag" || kind === "npm-range")
491
+ && typeof candidate?.name === "string" && typeof candidate?.version === "string") {
492
+ let current;
493
+ try {
494
+ current = await _resolveSpecIdentity({ spec, registry, sources, signal });
495
+ } catch (error) {
496
+ if (isAbortError(error)) throw error; // 取消不是「核不上」,照旧上抛
497
+ current = undefined;
498
+ }
499
+ verified = current !== undefined
500
+ && typeof current.version === "string"
501
+ && current.name === candidate.name
502
+ && current.version === candidate.version;
503
+ }
504
+ if (verified) {
505
+ return { report: validCached.report, profileDir, fingerprint: currentFingerprint };
506
+ }
507
+ preflightCache.delete(key); // 核不住或对不上:作废,下面重跑探装
385
508
  }
386
509
 
387
- const report = await preflightInstall({ profileDir, spec, onOutput, signal });
510
+ const report = await _preflightInstall({ profileDir, spec, onOutput, signal });
388
511
  preflightCache.set(key, {
389
512
  report,
390
513
  fingerprint: currentFingerprint,
@@ -556,6 +679,16 @@ export function consumeApprovalToken({
556
679
  return { valid: false, reason: "invalid or already consumed approval token" };
557
680
  }
558
681
 
682
+ // 归属校验先于销毁。token 一旦泄漏(哪怕只泄漏给另一个 session),任何
683
+ // 拿到它的人都不该能靠「试一下」把别人的批准流程烧掉——错误归属的尝试
684
+ // 原样退回,token 留给真正的 owner。其余校验维持「一试即焚」的一次性。
685
+ if (record.surface !== surface) {
686
+ return { valid: false, reason: "approval token surface mismatch (cannot reuse between browser and agent)" };
687
+ }
688
+ if (record.owner !== (owner ?? "")) {
689
+ return { valid: false, reason: `approval token ${surface === "browser" ? "session" : "owner"} mismatch` };
690
+ }
691
+
559
692
  // Atomically delete token on validation to guarantee one-shot
560
693
  approvalTokens.delete(cleanToken);
561
694
 
@@ -578,12 +711,6 @@ export function consumeApprovalToken({
578
711
  return { valid: false, reason: "approval token profile directory mismatch" };
579
712
  }
580
713
  }
581
- if (record.surface !== surface) {
582
- return { valid: false, reason: "approval token surface mismatch (cannot reuse between browser and agent)" };
583
- }
584
- if (record.owner !== (owner ?? "")) {
585
- return { valid: false, reason: `approval token ${surface === "browser" ? "session" : "owner"} mismatch` };
586
- }
587
714
  if (
588
715
  createHash("sha256").update(record.disclosureSerialized).digest("hex") !== record.disclosureDigest
589
716
  || createHash("sha256").update(record.proofSerialized).digest("hex") !== record.proofDigest
@@ -974,11 +1101,6 @@ export function createJobTracker({ producerFactory } = {}) {
974
1101
  profile,
975
1102
  spec,
976
1103
  verb = "add",
977
- allowBuildScripts,
978
- approvedProof,
979
- preflight,
980
- profileDir,
981
- acceptWarningsActive = false,
982
1104
  surface = "browser",
983
1105
  session,
984
1106
  onSettled,
@@ -987,11 +1109,15 @@ export function createJobTracker({ producerFactory } = {}) {
987
1109
  const id = `market-${++trackerCounter}`;
988
1110
  const kind = verb === "remove" ? "dsh-plugin-uninstall" : "dsh-plugin-install";
989
1111
  const factory = startProducerFactory ?? producerFactory;
1112
+ // install 必须带 producerFactory:createInstallJobProducer 是整条链
1113
+ // (含审批 token 签发)的唯一所有者,tracker 不再自己拼 runInstall——
1114
+ // 那条老路没有预检、没有 token 语义,只是历史上预检跑在 RPC 里时的
1115
+ // 残余。remove 仍可直接起 runRemove。
990
1116
  const producer = typeof factory === "function"
991
- ? factory({ profile, spec, verb, allowBuildScripts, approvedProof, preflight, profileDir })
1117
+ ? factory({ profile, spec, verb })
992
1118
  : verb === "remove"
993
1119
  ? runRemove({ profile, packageName: spec })
994
- : runInstall({ profile, spec, allowBuildScripts, approvedProof, preflight });
1120
+ : (() => { throw new Error("install jobs require a producerFactory — the preflight chain owns the producer, not the tracker"); })();
995
1121
 
996
1122
  const record = {
997
1123
  id,
@@ -1026,25 +1152,13 @@ export function createJobTracker({ producerFactory } = {}) {
1026
1152
  record.staleOnRestart = outcome?.staleOnRestart === true;
1027
1153
  record.finishedAt = Date.now();
1028
1154
 
1029
- if (status === "completed") {
1030
- if (profileDir) invalidatePreflightFor(profileDir);
1031
- clearApprovalTokensFor(profile, spec);
1032
- } else if (outcome?.needsApproval && outcome.needsApproval.length > 0) {
1033
- clearApprovalTokensFor(profile, spec, { surface: record.surface, owner: record.session });
1034
- const token = issueApprovalToken({
1035
- profile,
1036
- profileDir,
1037
- spec,
1038
- preflightReport: preflight,
1039
- needsApproval: outcome.needsApproval,
1040
- proof: outcome.proof,
1041
- surface: record.surface,
1042
- owner: record.session,
1043
- acceptWarningsActive,
1044
- });
1045
- record.approvalToken = token;
1046
- } else {
1047
- clearApprovalTokensFor(profile, spec, { surface: record.surface, owner: record.session });
1155
+ // 审批 token producer 签发(createInstallJobProducer 是唯一签发
1156
+ // 者:它手里才有预检报告与 profileDir,也才能把签发失败写进 detail
1157
+ // 而不是顶掉结论)。tracker 只把 outcome 里带出来的 token 摘到
1158
+ // record 上,供同 session 的快照下发——不签发、不清理,避免同一份
1159
+ // 职责在两条路径上各写一份、再各自漂移。
1160
+ if (typeof outcome?.approvalToken === "string" && outcome.approvalToken.length > 0) {
1161
+ record.approvalToken = outcome.approvalToken;
1048
1162
  }
1049
1163
 
1050
1164
  try {
@@ -1281,44 +1395,81 @@ function renderPreflightIssue(entry) {
1281
1395
  return ` [${badge}] ${entry.title}: ${entry.detail}`;
1282
1396
  }
1283
1397
 
1398
+ /**
1399
+ * The verdict plus every issue verbatim, as job-log text.
1400
+ *
1401
+ * The browser used to log only the verdict and hand the reasons to the risk
1402
+ * card through `extras`. Closing that card — or clicking "install anyway" —
1403
+ * took the reasons with it, and nothing else on the page ever had them. The
1404
+ * job log is the part that survives, so the evidence belongs here; the card
1405
+ * stays what it should be, the place to make the decision. The agent path
1406
+ * has carried the individual BLOCK/WARN lines in its job detail all along.
1407
+ */
1408
+ function preflightVerdictLog(report) {
1409
+ const lines = [`[dsh-plugin-mall] 预检结论:${report?.verdict}\n`];
1410
+ for (const entry of report?.issues ?? []) lines.push(`${renderPreflightIssue(entry)}\n`);
1411
+ return lines.join("");
1412
+ }
1413
+
1284
1414
  /**
1285
1415
  * Why this install must not proceed — or undefined when it may.
1286
1416
  *
1287
- * Split out of enforcePreflight() because the two call sites need the verdict
1288
- * in two different shapes. The browser RPC decides BEFORE any job exists, so
1289
- * an exception is the right carrier: it aborts the request and becomes an
1290
- * rpcFail. The agent tool now decides INSIDE the job, where an exception is
1291
- * exactly the wrong carrier a producer's `done` must never reject, and "the
1292
- * preflight refused this candidate" is not an internal error but the job's
1293
- * legitimate outcome, so it has to travel as text on a `failed` outcome.
1417
+ * Both surfaces (agent tool and browser RPC) decide INSIDE the job, so the
1418
+ * verdict travels as text on a `failed` outcome a producer's `done` must
1419
+ * never reject, and "the preflight refused this candidate" is not an internal
1420
+ * error but the job's legitimate ending. It used to have an exception-shaped
1421
+ * twin (enforcePreflight) for the browser, back when the browser decided
1422
+ * before any job existed; that path is gone and so is the twin.
1423
+ */
1424
+ /**
1425
+ * The identity of ONE preflight verdict: which candidate, which profile state,
1426
+ * which issues. Consent to install despite warnings binds to this — a boolean
1427
+ * `acceptWarnings: true` carries no such identity, so the report changing
1428
+ * between the user's confirmation and the retry used to slip different
1429
+ * warnings through under an old yes (profile edits force a re-probe; the new
1430
+ * report can carry entirely different warnings).
1431
+ *
1432
+ * Candidate name+version come from the probe's own manifest, so a re-probe of
1433
+ * a drifted mutable spec produces a different digest even when the issue list
1434
+ * happens to read the same. 16 hex chars — this detects change, it is not a
1435
+ * security boundary.
1294
1436
  */
1295
- function preflightRefusal(report, acceptWarnings, label) {
1437
+ export function preflightConsentDigest(report, fingerprint) {
1438
+ const canonical = JSON.stringify({
1439
+ verdict: report?.verdict ?? null,
1440
+ candidateName: report?.candidate?.name ?? null,
1441
+ candidateVersion: report?.candidate?.version ?? null,
1442
+ fingerprint: fingerprint ?? null,
1443
+ issues: (report?.issues ?? []).map((entry) => [entry.severity ?? null, entry.title ?? null, entry.detail ?? null]),
1444
+ });
1445
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
1446
+ }
1447
+
1448
+ function preflightRefusal(report, acceptWarnings, label, { digestProvided, fingerprint, consentBoundByToken = false } = {}) {
1296
1449
  if (report.verdict === "blocked") {
1297
1450
  return `${label}: ${report.summary}\n${report.issues.filter((entry) => entry.severity === "block").map(renderPreflightIssue).join("\n")}`;
1298
1451
  }
1299
- if (report.verdict === "warning" && acceptWarnings !== true) {
1300
- return `${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.`;
1452
+ if (report.verdict === "warning") {
1453
+ const digest = preflightConsentDigest(report, fingerprint);
1454
+ const warnLines = report.issues.filter((entry) => entry.severity === "warn").map(renderPreflightIssue).join("\n");
1455
+ // 审批 token 自带报告摘要比对(consumeApprovalToken),它给出的同意已经
1456
+ // 绑定了报告;只有裸布尔 acceptWarnings 这条道需要在这里比对 digest。
1457
+ const consentMatches = acceptWarnings === true
1458
+ && (consentBoundByToken === true || digestProvided === digest);
1459
+ if (!consentMatches) {
1460
+ const why = acceptWarnings === true
1461
+ ? "the preflight report changed since the warnings were confirmed (or no report digest was supplied), so the earlier yes cannot carry over. Current warnings:"
1462
+ : `${report.summary}`;
1463
+ return `${label}: ${why}\n${warnLines}\n\nCurrent report digest: ${digest}. To continue, show these warnings to the user and, after their explicit confirmation, call again with acceptWarnings: true and reportDigest: ${digest}.`;
1464
+ }
1301
1465
  }
1302
1466
  return undefined;
1303
1467
  }
1304
1468
 
1305
1469
  /**
1306
- * Enforce a preflight verdict for an install. Throws when a blocker exists, or
1307
- * when there are only warnings and acceptWarnings is not true.
1308
- */
1309
- function enforcePreflight(report, acceptWarnings, label) {
1310
- const refusal = preflightRefusal(report, acceptWarnings, label);
1311
- if (refusal !== undefined) {
1312
- const error = new Error(refusal);
1313
- error.preflight = report;
1314
- throw error;
1315
- }
1316
- }
1317
-
1318
- /**
1319
- * The whole agent-side install — registry lookup, spec resolution, host-shadow
1320
- * check, isolated preflight, approval-token consumption, verdict, and pnpm —
1321
- * as ONE job producer.
1470
+ * The whole install registry lookup, spec resolution, host-shadow check,
1471
+ * isolated preflight, approval-token consumption, verdict, and pnpm as ONE
1472
+ * job producer, for BOTH surfaces (agent tool and browser RPC).
1322
1473
  *
1323
1474
  * Why it all lives in here (issue #8): `ctx.jobs.start()` treats `run()` as a
1324
1475
  * synchronous start boundary, so anything awaited before that call happens
@@ -1354,9 +1505,15 @@ function createInstallJobProducer({
1354
1505
  profile,
1355
1506
  spec: requestedSpec,
1356
1507
  acceptWarnings: acceptWarningsRequested = false,
1508
+ reportDigest,
1357
1509
  allowBuildScripts,
1358
1510
  approvalToken,
1359
- agentOwner,
1511
+ // 审批 token 的归属:agent 工具传 { surface: "agent", owner: <agent 标量
1512
+ // id> },浏览器 RPC 传 { surface: "browser", owner: <session> }。签发、
1513
+ // 消费、清理都从这里走——producer 是审批 token 的唯一签发者,tracker 只
1514
+ // 复制 outcome.approvalToken,不再自己签。
1515
+ surface = "agent",
1516
+ owner,
1360
1517
  npmRegistry = "",
1361
1518
  rawSources = [],
1362
1519
  profileExisted = true,
@@ -1394,7 +1551,7 @@ function createInstallJobProducer({
1394
1551
  signal.throwIfAborted();
1395
1552
 
1396
1553
  pushPreflight(`[dsh-plugin-mall] 预检 ${spec}:隔离目录探装(脚本禁用)\n`);
1397
- const preflight = await _runPreflight({ profile, spec, onOutput: pushPreflight, signal });
1554
+ const preflight = await _runPreflight({ profile, spec, onOutput: pushPreflight, signal, registry, sources: rawSources });
1398
1555
  signal.throwIfAborted(); // 命中缓存时预检不会自己抛,这里补一次取消检查
1399
1556
  pushPreflight(`[dsh-plugin-mall] 预检结论:${preflight.report.verdict}\n`);
1400
1557
 
@@ -1409,11 +1566,11 @@ function createInstallJobProducer({
1409
1566
  spec,
1410
1567
  preflightReport: preflight.report,
1411
1568
  allowBuildScripts,
1412
- surface: "agent",
1413
- owner: agentOwner,
1569
+ surface,
1570
+ owner,
1414
1571
  });
1415
1572
  if (!consumeResult.valid) {
1416
- return { status: "failed", detail: `market_install: invalid approval token: ${consumeResult.reason}` };
1573
+ return { status: "failed", detail: `invalid approval token: ${consumeResult.reason}` };
1417
1574
  }
1418
1575
  acceptWarnings = consumeResult.warningConsent;
1419
1576
  acceptWarningsActive = consumeResult.warningConsent;
@@ -1423,7 +1580,11 @@ function createInstallJobProducer({
1423
1580
  acceptWarningsActive = acceptWarnings;
1424
1581
  }
1425
1582
 
1426
- const refusal = preflightRefusal(preflight.report, acceptWarnings, `market_install ${spec}`);
1583
+ const refusal = preflightRefusal(preflight.report, acceptWarnings, `market_install ${spec}`, {
1584
+ digestProvided: reportDigest,
1585
+ fingerprint: preflight.fingerprint,
1586
+ consentBoundByToken: approvalToken !== undefined, // consume 已带报告摘要比对
1587
+ });
1427
1588
  if (refusal !== undefined) {
1428
1589
  // 拒绝是这个 job 的正常结局,不是异常:作为 failed 的 detail 回去,
1429
1590
  // 模型从 job_output 就能读到逐条 BLOCK/WARN。
@@ -1441,7 +1602,7 @@ function createInstallJobProducer({
1441
1602
  invalidatePreflightFor(preflight.profileDir);
1442
1603
  clearApprovalTokensFor(profile, spec);
1443
1604
  } else if (outcome?.needsApproval && outcome.needsApproval.length > 0) {
1444
- clearApprovalTokensFor(profile, spec, { surface: "agent", owner: agentOwner });
1605
+ clearApprovalTokensFor(profile, spec, { surface, owner });
1445
1606
  try {
1446
1607
  const token = issueApprovalToken({
1447
1608
  profile,
@@ -1450,21 +1611,29 @@ function createInstallJobProducer({
1450
1611
  preflightReport: preflight.report,
1451
1612
  needsApproval: outcome.needsApproval,
1452
1613
  proof: outcome.proof,
1453
- surface: "agent",
1454
- owner: agentOwner,
1614
+ surface,
1615
+ owner,
1455
1616
  acceptWarningsActive,
1456
1617
  });
1457
1618
  outcome.approvalToken = token;
1458
- outcome.detail = `${outcome.detail ?? ""}\n\nApproval token (pass to approvalToken on retry): ${token}`;
1619
+ // token 只写进 agent detail:agent job_output 由宿主按 owner
1620
+ // 隔离,模型重试时要从这里读到 token。浏览器的 detail 是任务面板
1621
+ // 明文展示的字段,tracker.get/list 又无条件下发它(只有独立的
1622
+ // approvalToken 字段做 session 隔离)——拼进去等于把 token 发给
1623
+ // 所有 session。浏览器侧 token 只走 outcome.approvalToken →
1624
+ // record.approvalToken → 同 session 的快照字段。
1625
+ if (surface === "agent") {
1626
+ outcome.detail = `${outcome.detail ?? ""}\n\nApproval token (pass to approvalToken on retry): ${token}`;
1627
+ }
1459
1628
  } catch (error) {
1460
1629
  // 签发会因为凭证不完整(proof 缺失/不匹配)抛错。以前这段跑在 `.then`
1461
1630
  // 里,抛出去就把 done 变成 rejected —— 官方明说 done 必须不 reject,
1462
1631
  // 而且那样一来「pnpm 拦下了安装脚本」这条真正的结论会被一条内部错误
1463
1632
  // 顶掉。改成写进 detail:结论照常送达,同时明说这次没法重试。
1464
- outcome.detail = `${outcome.detail ?? ""}\n\nNOTE: no approval token could be issued (${error?.message ?? String(error)}), so allowBuildScripts cannot be used to retry this run — start a fresh market_install instead.`;
1633
+ outcome.detail = `${outcome.detail ?? ""}\n\nNOTE: no approval token could be issued (${error?.message ?? String(error)}), so allowBuildScripts cannot be used to retry this run — start a fresh install instead.`;
1465
1634
  }
1466
1635
  } else {
1467
- clearApprovalTokensFor(profile, spec, { surface: "agent", owner: agentOwner });
1636
+ clearApprovalTokensFor(profile, spec, { surface, owner });
1468
1637
  }
1469
1638
  return outcome;
1470
1639
  })().catch((error) => {
@@ -1788,9 +1957,28 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1788
1957
  session,
1789
1958
  run: async (push) => {
1790
1959
  push(`[dsh-plugin-mall] 预检 ${resolved}:隔离目录探装(脚本禁用)\n`);
1791
- const { report } = await runPreflight({ profile, spec: resolved, onOutput: (text) => push(text) });
1792
- push(`[dsh-plugin-mall] 预检结论:${report.verdict}\n`);
1793
- return { status: "completed", detail: `预检完成:${report.verdict}`, extras: report };
1960
+ const { report, profileDir: probedDir, fingerprint: probedFingerprint } = await runPreflight({ profile, spec: resolved, onOutput: (text) => push(text), registry, sources: rawSources });
1961
+ // 结论 + 逐条原因都进日志。extras 只喂给风险卡片,卡片一关就什么
1962
+ // 都不剩了;日志是留得住的那一份。
1963
+ push(preflightVerdictLog(report));
1964
+ // 钉住这份结论,别让用户的思考时间把它作废。
1965
+ //
1966
+ // PREFLIGHT_TTL 只有 30 秒,而有警告时下一步正是让用户读完风险卡片
1967
+ // 再决定——读两条「无法验证宿主依赖」基本必然超过 30 秒,于是点下
1968
+ // 「继续安装」时缓存已过期,隔离探装整个重跑一遍:用户看到的是确认
1969
+ // 之后又干等几十秒,而那几十秒里没有任何反馈。
1970
+ //
1971
+ // pin 不会让判断变陈旧:pinPreflight 先比对 profile 指纹,profile
1972
+ // 有任何改动这条缓存就立刻作废而不是被钉住。钉的是「同一个 profile
1973
+ // 状态下的同一次结论」,10 分钟内复用与重跑完全等价。
1974
+ pinPreflight(probedDir, resolved);
1975
+ // extras 额外带 consentDigest:风险卡片确认时原样回传,装的时候
1976
+ // 与当前报告比对——同意绑定的是「这份报告」,不是一次布尔值。
1977
+ return {
1978
+ status: "completed",
1979
+ detail: `预检完成:${report.verdict}`,
1980
+ extras: { ...report, consentDigest: preflightConsentDigest(report, probedFingerprint) },
1981
+ };
1794
1982
  },
1795
1983
  });
1796
1984
  return rpcOk({ jobId, profile, spec: resolved });
@@ -1813,12 +2001,17 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1813
2001
  } catch (error) {
1814
2002
  return rpcFail(error);
1815
2003
  }
1816
- const registry = await registryFor(profile, npmRegistry);
1817
- spec = await preferNpmSpec({ spec, registry, sources: rawSources });
2004
+ // profile 名非法当场报错,与 market_uninstall / agent 工具一致——不是
2005
+ // 一个注定失败的后台 job。顺带取「动手之前」的磁盘状态:producer
2006
+ // 取消文案要靠它区分「profile 从未被动过」和「预检把不存在的 profile
2007
+ // 建出来了」。
2008
+ let installProfileDir;
2009
+ let profileExisted = true;
1818
2010
  try {
1819
- await assertSafeToInstall({ spec, registry, sources: rawSources });
2011
+ installProfileDir = resolveProfileDir(profile);
2012
+ profileExisted = existsSync(join(installProfileDir, "package.json"));
1820
2013
  } catch (error) {
1821
- return rpcFail(error);
2014
+ return rpcFail(new Error(`invalid profile: ${error.message}`));
1822
2015
  }
1823
2016
  const allowBuildScripts = Array.isArray(payload?.allowBuildScripts)
1824
2017
  ? payload.allowBuildScripts.map((name) => String(name))
@@ -1833,58 +2026,35 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1833
2026
  return rpcFail(error);
1834
2027
  }
1835
2028
 
1836
- let preflight;
1837
- let acceptWarnings = false;
1838
- let acceptWarningsActive = false;
1839
- let approvedProof = undefined;
1840
- try {
1841
- preflight = await runPreflight({ profile, spec });
1842
- if (approvalToken !== undefined) {
1843
- const consumeResult = consumeApprovalToken({
1844
- token: approvalToken,
1845
- profile,
1846
- profileDir: preflight.profileDir,
1847
- spec,
1848
- preflightReport: preflight.report,
1849
- allowBuildScripts,
1850
- surface: "browser",
1851
- owner: session,
1852
- });
1853
- if (!consumeResult.valid) {
1854
- return rpcFail(new Error(`invalid approval token: ${consumeResult.reason}`));
1855
- }
1856
- acceptWarnings = consumeResult.warningConsent;
1857
- acceptWarningsActive = consumeResult.warningConsent;
1858
- approvedProof = consumeResult.proof;
1859
- } else {
1860
- acceptWarnings = payload?.acceptWarnings === true;
1861
- acceptWarningsActive = acceptWarnings;
1862
- }
1863
- enforcePreflight(preflight.report, acceptWarnings, `install ${spec}`);
1864
- } catch (error) {
1865
- return rpcFail(error);
1866
- }
1867
-
1868
- pinPreflight(preflight.profileDir, spec);
1869
- try {
1870
- const jobId = tracker.start({
2029
+ // 到这里为止只做本地、同步、必须在返回 job id 之前失败的检查——与
2030
+ // market_install execute() 同构。registry 解析、防抢注、宿主遮蔽
2031
+ // 检查、预检(缓存/身份再校验)、token 消费、警告关卡与 pnpm 全部在
2032
+ // producer 里跑:旧行为把整段 await 在 tracker.start 之前,用户点
2033
+ // 「继续安装」后风险框立刻消失而任务条目几十秒不动,预检 TTL 一过还
2034
+ // 会把隔离探装整个重跑一遍(那个洞已由结算即 pin 堵上,这里是根治)。
2035
+ // 结论形态随之对齐 agent 路径:blocker/未确认警告不再让 RPC 报错,
2036
+ // 而是作为 job 的 failed outcome 送达,日志里看得到原文。
2037
+ const jobId = tracker.start({
2038
+ profile,
2039
+ spec,
2040
+ surface: "browser",
2041
+ session,
2042
+ producerFactory: () => createInstallJobProducer({
1871
2043
  profile,
1872
2044
  spec,
2045
+ acceptWarnings: payload?.acceptWarnings === true,
2046
+ reportDigest: typeof payload?.acceptedReportDigest === "string" ? payload.acceptedReportDigest.trim() : undefined,
1873
2047
  allowBuildScripts,
1874
- approvedProof,
1875
- preflight: preflight.report,
1876
- profileDir: preflight.profileDir,
1877
- acceptWarningsActive,
2048
+ approvalToken,
1878
2049
  surface: "browser",
1879
- session,
1880
- onSettled: (outcome) => {
1881
- if (outcome?.status === "completed") invalidatePreflightFor(preflight.profileDir);
1882
- },
1883
- });
1884
- return rpcOk({ jobId, profile, spec, preflight: { verdict: preflight.report.verdict, summary: preflight.report.summary } });
1885
- } catch (error) {
1886
- return rpcFail(error);
1887
- }
2050
+ owner: session,
2051
+ npmRegistry,
2052
+ rawSources,
2053
+ profileExisted,
2054
+ profileDir: installProfileDir,
2055
+ }),
2056
+ });
2057
+ return rpcOk({ jobId, profile, spec });
1888
2058
  }
1889
2059
  case "uninstall": {
1890
2060
  const profile = String(payload?.profile ?? defaultProfile).trim();
@@ -1915,7 +2085,6 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1915
2085
  profile,
1916
2086
  spec: packageName,
1917
2087
  verb: "remove",
1918
- profileDir,
1919
2088
  surface: "browser",
1920
2089
  session,
1921
2090
  onSettled: (outcome) => {
@@ -2086,7 +2255,7 @@ export function apply(ctx, config = {}) {
2086
2255
  ctx.systemPrompt.section({
2087
2256
  name: "tool:market",
2088
2257
  order: 120,
2089
- 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); that preflight runs inside the background job, so its verdict — including a refusal — arrives through job_output rather than as an immediate error from the call. 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.",
2258
+ 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); that preflight runs inside the background job, so its verdict — including a refusal — arrives through job_output rather than as an immediate error from the call. A blocker refuses the install and warnings require acceptWarnings: true together with the reportDigest from the failed job after the user confirms them — never set either 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.",
2090
2259
  });
2091
2260
 
2092
2261
  ctx.tools.register(defineTool({
@@ -2164,7 +2333,7 @@ export function apply(ctx, config = {}) {
2164
2333
 
2165
2334
  ctx.tools.register(defineTool({
2166
2335
  name: "market_install",
2167
- 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, and the call itself does almost nothing: it validates the profile name, the spec and the approval arguments, then returns a job id. Everything slow happens INSIDE the job — resolving the spec against the npm registry, the host-module shadowing check, the isolated preflight (the candidate is installed with scripts disabled into a throwaway directory and scanned for manifest/patch conflicts and version/OS incompatibilities), and pnpm itself. Poll with job_output; cancel with job_kill (a cancel during the preflight leaves the profile untouched). Because the preflight runs inside the job, ITS VERDICT ARRIVES AS THE JOB'S OUTCOME, not as an error from this call: a blocker, or a warning the user has not confirmed, ends the job as `failed` with the individual issues in its detail — read them there and relay them verbatim. A warning is cleared by calling again with `acceptWarnings: true`, and only after the USER has explicitly confirmed it; 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.",
2336
+ 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, and the call itself does almost nothing: it validates the profile name, the spec and the approval arguments, then returns a job id. Everything slow happens INSIDE the job — resolving the spec against the npm registry, the host-module shadowing check, the isolated preflight (the candidate is installed with scripts disabled into a throwaway directory and scanned for manifest/patch conflicts and version/OS incompatibilities), and pnpm itself. Poll with job_output; cancel with job_kill (a cancel during the preflight leaves the profile untouched). Because the preflight runs inside the job, ITS VERDICT ARRIVES AS THE JOB'S OUTCOME, not as an error from this call: a blocker, or a warning the user has not confirmed, ends the job as `failed` with the individual issues in its detail — read them there and relay them verbatim. A warning is cleared by calling again with `acceptWarnings: true` AND `reportDigest` set to the \"Current report digest\" printed in that failed job's detail — the consent is bound to that exact report, so if the candidate or the profile changed in between, the retry fails again with the NEW warnings; never consent on the user's 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.",
2168
2337
  parameters: {
2169
2338
  spec: {
2170
2339
  type: "string",
@@ -2179,6 +2348,10 @@ export function apply(ctx, config = {}) {
2179
2348
  type: "boolean",
2180
2349
  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.",
2181
2350
  },
2351
+ reportDigest: {
2352
+ type: "string",
2353
+ description: "The \"Current report digest\" printed by the failed job you are retrying. The warning consent is bound to that exact report: if the candidate package or the profile changed in between, the digest no longer matches and the job fails again with the NEW warnings — show those to the user and confirm anew. Required whenever acceptWarnings is true.",
2354
+ },
2182
2355
  approvalToken: {
2183
2356
  type: "string",
2184
2357
  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.",
@@ -2243,9 +2416,11 @@ export function apply(ctx, config = {}) {
2243
2416
  profile,
2244
2417
  spec,
2245
2418
  acceptWarnings: args.acceptWarnings === true,
2419
+ reportDigest: typeof args.reportDigest === "string" ? args.reportDigest.trim() : undefined,
2246
2420
  allowBuildScripts,
2247
2421
  approvalToken,
2248
- agentOwner,
2422
+ surface: "agent",
2423
+ owner: agentOwner,
2249
2424
  npmRegistry,
2250
2425
  rawSources,
2251
2426
  profileExisted,
@@ -2691,7 +2866,21 @@ export async function runSelfTests() {
2691
2866
  surface: "browser",
2692
2867
  owner: "session-beta",
2693
2868
  });
2694
- check("跨浏览器 session 消费审批 token 被拒绝且销毁", !crossSessionRes.valid && /session mismatch/.test(crossSessionRes.reason));
2869
+ // 归属不符的尝试**不许销毁** token:一旦 token 经由任何渠道泄漏,拿到
2870
+ // 它的人也不能靠「试一下」烧掉别人的批准流程。归属校验先于一次性销毁。
2871
+ check("跨浏览器 session 消费审批 token 被拒绝且不销毁", !crossSessionRes.valid && /session mismatch/.test(crossSessionRes.reason));
2872
+ const rightfulSessionRes = consumeApprovalToken({
2873
+ token: browserTok,
2874
+ profile: "web",
2875
+ profileDir,
2876
+ spec: "browser-pkg",
2877
+ preflightReport: cleanPreflightReport,
2878
+ allowBuildScripts: ["browser-pkg"],
2879
+ surface: "browser",
2880
+ owner: "session-alpha",
2881
+ });
2882
+ check("被异 session 碰过的 token 仍归正主消费",
2883
+ rightfulSessionRes.valid === true, `reason=${rightfulSessionRes.reason}`);
2695
2884
 
2696
2885
  // 跨 surface (browser vs agent)
2697
2886
  const agentProof = proofFor("agent-pkg");
@@ -2715,14 +2904,40 @@ export async function runSelfTests() {
2715
2904
  surface: "browser",
2716
2905
  owner: "browser-sess",
2717
2906
  });
2718
- check("跨 surface (agent vs browser) 消费审批 token 失败且被销毁", !crossSurfaceRes.valid && /surface mismatch/.test(crossSurfaceRes.reason));
2907
+ check("跨 surface (agent vs browser) 消费审批 token 失败且不被销毁",
2908
+ !crossSurfaceRes.valid && /surface mismatch/.test(crossSurfaceRes.reason));
2909
+ const rightfulAgentRes = consumeApprovalToken({
2910
+ token: agentTok,
2911
+ profile: "web",
2912
+ profileDir,
2913
+ spec: "agent-pkg",
2914
+ preflightReport: cleanPreflightReport,
2915
+ allowBuildScripts: ["agent-pkg"],
2916
+ surface: "agent",
2917
+ owner: "agent-1",
2918
+ });
2919
+ check("被跨 surface 碰过的 token 仍归正主消费", rightfulAgentRes.valid === true, `reason=${rightfulAgentRes.reason}`);
2719
2920
 
2720
- // Tracker 隔离与 session 校验
2921
+ // Tracker 隔离与 session 校验。
2922
+ // 审批 token 由 producer 签发(createInstallJobProducer 是唯一签发者),
2923
+ // tracker 只把 outcome.approvalToken 摘到 record 上——这里的假 producer
2924
+ // 照真实流程先签好、挂在 outcome 里带出来。
2721
2925
  const trackerProof = proofFor("foo-script");
2926
+ const trackerTok = issueApprovalToken({
2927
+ profile: "web",
2928
+ profileDir,
2929
+ spec: "foo-script",
2930
+ preflightReport: cleanPreflightReport,
2931
+ needsApproval: disclosureFor(trackerProof),
2932
+ proof: trackerProof,
2933
+ surface: "browser",
2934
+ owner: "session-alpha",
2935
+ });
2722
2936
  let needsApprovalOutcome = {
2723
2937
  status: "needsApproval",
2724
2938
  needsApproval: disclosureFor(trackerProof),
2725
2939
  proof: trackerProof,
2940
+ approvalToken: trackerTok,
2726
2941
  };
2727
2942
  const approvalProducer = {
2728
2943
  cancel: () => {},
@@ -2735,7 +2950,6 @@ export async function runSelfTests() {
2735
2950
  const sessionJobId = sessionTracker.start({
2736
2951
  profile: "web",
2737
2952
  spec: "foo-script",
2738
- profileDir,
2739
2953
  surface: "browser",
2740
2954
  session: "session-alpha",
2741
2955
  });
@@ -2743,9 +2957,15 @@ export async function runSelfTests() {
2743
2957
 
2744
2958
  const snapDiffSession = sessionTracker.get(sessionJobId, "session-beta").snapshot;
2745
2959
  check("不同 session 查询 job 时不会暴露 approvalToken", snapDiffSession.approvalToken === undefined);
2960
+ // 整个序列化结果都不含 token——不只看 approvalToken 字段:detail、日志、
2961
+ // 任何嵌套位置藏一份都算泄漏。
2962
+ check("不同 session 的 get/list 序列化结果整体不含 token",
2963
+ !JSON.stringify(sessionTracker.get(sessionJobId, "session-beta")).includes(trackerTok)
2964
+ && !JSON.stringify(sessionTracker.list("session-beta")).includes(trackerTok));
2746
2965
 
2747
2966
  const snapSameSession = sessionTracker.get(sessionJobId, "session-alpha").snapshot;
2748
- check("相同 session 查询 job 时可获取 approvalToken", typeof snapSameSession.approvalToken === "string");
2967
+ check("tracker 复制 producer 签发的 approvalToken(同 session 可见)",
2968
+ snapSameSession.approvalToken === trackerTok);
2749
2969
 
2750
2970
  let cancelRefused = false;
2751
2971
  try {
@@ -3038,7 +3258,6 @@ export async function runSelfTests() {
3038
3258
  const jobId = tracker.start({
3039
3259
  profile: "fixture-profile",
3040
3260
  spec: "fail-pkg",
3041
- profileDir,
3042
3261
  onSettled: (outcome) => { settledOutcome = outcome; },
3043
3262
  });
3044
3263
  await new Promise((resolvePromise) => setImmediate(resolvePromise));
@@ -3222,6 +3441,279 @@ export async function runSelfTests() {
3222
3441
  runStartupRecovery("profile-a", { recover: () => ({ action: "rolled-back", reason: "静态校验未通过" }), log: rolledLog });
3223
3442
  check("回滚路径播报原因", rolledLog.lines.some((line) => line.includes("rolled back") && line.includes("静态校验未通过")));
3224
3443
 
3444
+ // ── 12a. 预检 job 结算即 pin:用户的思考时间不该让结论作废 ──────────────
3445
+ //
3446
+ // 有警告时下一步是让用户读风险卡片再决定,而 PREFLIGHT_TTL 只有 30 秒。
3447
+ // 读两条警告基本必然超时,于是点「继续安装」时缓存已过期、隔离探装整个
3448
+ // 重跑一遍——用户看到的就是确认之后又干等几十秒。
3449
+ {
3450
+ const pinSpec = "pin-me";
3451
+ const pinKey = preflightCacheKey(profileDir, pinSpec);
3452
+ preflightCache.set(pinKey, {
3453
+ report: { ok: true, verdict: "warning", summary: "", issues: [] },
3454
+ fingerprint: computeProfileFingerprint(profileDir),
3455
+ at: Date.now(),
3456
+ pinnedAt: undefined,
3457
+ });
3458
+ pinPreflight(profileDir, pinSpec);
3459
+ check("预检结算后 pin 生效(warning 是可行动结论)", isPinned(preflightCache.get(pinKey)) === true);
3460
+
3461
+ // 把落库时间推到 TTL 之外——没有 pin 的话这条已经该重跑了。
3462
+ preflightCache.get(pinKey).at = Date.now() - (PREFLIGHT_TTL + 5000);
3463
+ const stale = preflightCache.get(pinKey);
3464
+ check("超过 30 秒 TTL 后,pin 仍让它有效(不必重跑探装)",
3465
+ isPinned(stale) === true && Date.now() - stale.at > PREFLIGHT_TTL);
3466
+
3467
+ // 但 pin 绝不能护住一个已经对不上 profile 的结论:指纹一变就丢弃。
3468
+ // 这是 pin 可以放心提前打的全部理由。
3469
+ const patchPath = join(profileDir, "cordis.patch.yml");
3470
+ const patchBefore = readFileSync(patchPath, "utf8");
3471
+ writeFileSync(patchPath, `${patchBefore}\n- name: pin-test-drift\n`);
3472
+ pinPreflight(profileDir, pinSpec);
3473
+ check("profile 一变 → pin 拒绝钉住并丢弃缓存", preflightCache.get(pinKey) === undefined);
3474
+ writeFileSync(patchPath, patchBefore);
3475
+
3476
+ // blocked 不 pin。探装失败(网络抖动)产出的是 ok:false 的 blocked,
3477
+ // 而 immutable spec 的身份再校验不设防——钉住等于把一次临时失败固化
3478
+ // 10 分钟,网络恢复也不会再试。真冲突的 blocked 同样没有「用户读完
3479
+ // 再继续」的后继流程。落库时间推到 TTL 外之后必须重跑探装。
3480
+ const failSpec = "immutable-fail@1.0.0";
3481
+ const failKey = preflightCacheKey(profileDir, failSpec);
3482
+ const failReport = {
3483
+ ok: false,
3484
+ verdict: "blocked",
3485
+ candidate: { name: undefined, version: undefined, kind: "unknown", rows: [] },
3486
+ issues: [{ severity: "block", title: "预检执行失败", detail: "network unreachable" }],
3487
+ summary: "预检执行失败,正式 profile 未被修改",
3488
+ };
3489
+ preflightCache.set(failKey, {
3490
+ report: failReport,
3491
+ fingerprint: computeProfileFingerprint(profileDir),
3492
+ at: Date.now(),
3493
+ pinnedAt: undefined,
3494
+ });
3495
+ pinPreflight(profileDir, failSpec);
3496
+ check("blocked(探装失败)不被 pin", isPinned(preflightCache.get(failKey)) === false);
3497
+ preflightCache.get(failKey).at = Date.now() - (PREFLIGHT_TTL + 5000); // 落库时间推出 TTL
3498
+ let failProbes = 0;
3499
+ const failOutcome = await runPreflight({
3500
+ profile: "unused-by-fixture",
3501
+ spec: failSpec,
3502
+ _profileDir: profileDir,
3503
+ _preflightInstall: async () => { failProbes++; return { ok: true, verdict: "safe", summary: "", issues: [], candidate: { name: "immutable-fail", version: "1.0.0", kind: "bundle", rows: [] } }; },
3504
+ });
3505
+ check("blocked 超过 TTL → 重新探装(临时失败不会被钉 10 分钟)",
3506
+ failProbes === 1 && failOutcome.report.verdict === "safe",
3507
+ `probes=${failProbes} verdict=${failOutcome.report.verdict}`);
3508
+ }
3509
+
3510
+ // ── 12b1. spec 形态判定:不可变可复用 / 可核验须再核 / 其余不可复用 ──────
3511
+ {
3512
+ const cases = [
3513
+ ["pkg", "npm-tag"],
3514
+ ["pkg@latest", "npm-tag"],
3515
+ ["pkg@*", "npm-tag"],
3516
+ ["@scope/pkg", "npm-tag"], // scoped 裸名没有 range——判定看名字后有没有东西,不看 @
3517
+ ["pkg@^1.2.0", "npm-range"],
3518
+ ["@scope/pkg@~2.0.0", "npm-range"],
3519
+ ["pkg@1.2.3", "immutable"], // 精确版本:npm 禁止覆盖已发布版本
3520
+ ["pkg@1.2.3-beta.1", "immutable"],
3521
+ ["github:owner/repo", null], // 未钉 sha:同版本能换代码,name/version 证明不了任何事
3522
+ ["github:owner/repo#main", null], // 分支名不是身份
3523
+ ["github:owner/repo.git", null],
3524
+ ["github:owner/repo#a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "immutable"], // 40 位 sha 钉死
3525
+ ["file:../local.tgz", null], // 内容能原地变
3526
+ ["link:../pkg", null],
3527
+ ["https://example.com/pkg.tgz", null],
3528
+ ["owner/repo", null],
3529
+ ];
3530
+ let kindOk = true;
3531
+ for (const [spec, expected] of cases) {
3532
+ if (specIdentityKind(spec) !== expected) { kindOk = false; console.error(` specIdentityKind(${JSON.stringify(spec)}) = ${JSON.stringify(specIdentityKind(spec))},预期 ${JSON.stringify(expected)}`); }
3533
+ }
3534
+ check("spec 形态判定表(immutable/npm-tag/npm-range/不可核验)", kindOk);
3535
+
3536
+ // github repo 提取的正则回归:懒匹配 + 可选后缀曾把 owner/repo 截成
3537
+ // owner/r,身份查询全部打在不存在的仓库上、无声 fail-open。
3538
+ const repoCases = [
3539
+ ["github:owner/repo", "owner/repo"],
3540
+ ["github:owner/repo.git", "owner/repo"],
3541
+ ["github:owner/repo#main", "owner/repo"],
3542
+ ["github:owner/repo#a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "owner/repo"],
3543
+ ["github:owner-with-dash/repo.with.dots", "owner-with-dash/repo.with.dots"],
3544
+ ["owner/repo", null],
3545
+ ["github:owner", null],
3546
+ ];
3547
+ let repoOk = true;
3548
+ for (const [spec, expected] of repoCases) {
3549
+ if (githubSpecRepo(spec) !== expected) { repoOk = false; console.error(` githubSpecRepo(${JSON.stringify(spec)}) = ${JSON.stringify(githubSpecRepo(spec))},预期 ${JSON.stringify(expected)}`); }
3550
+ }
3551
+ check("github spec 的 repo 提取不截断", repoOk);
3552
+ }
3553
+
3554
+ // ── 12b2. 缓存复用的候选身份再校验:fail-closed ─────────────────────────
3555
+ //
3556
+ // 复用必须同时核住两侧:profile 指纹 + 候选身份。核不住的形态
3557
+ // (file:/link:/URL、未钉 sha 的 github)没有可信身份可言,registry
3558
+ // 查不到「当前值」同样算没核住——一律丢弃缓存重跑。这里曾把「核不上
3559
+ // 就沿用旧报告」当正确答案(fail-open),等于给所有核不住的形态开了
3560
+ // 永久通道;fixture 一并翻转。
3561
+ {
3562
+ const idSpec = "mutable-pkg";
3563
+ const idKey = preflightCacheKey(profileDir, idSpec);
3564
+ const probeCalls = { count: 0 };
3565
+ const probeReport = (name, version) => ({
3566
+ verdict: "safe", summary: "", issues: [],
3567
+ candidate: { name, version, kind: "bundle", rows: [] },
3568
+ });
3569
+ const seedCache = (spec, name, version) => {
3570
+ preflightCache.set(preflightCacheKey(profileDir, spec), {
3571
+ report: probeReport(name, version),
3572
+ fingerprint: computeProfileFingerprint(profileDir),
3573
+ at: Date.now(),
3574
+ pinnedAt: Date.now(),
3575
+ });
3576
+ };
3577
+ let resolveCalls = 0;
3578
+ const run = (spec, resolve) => runPreflight({
3579
+ profile: "unused-by-fixture",
3580
+ spec,
3581
+ _profileDir: profileDir,
3582
+ registry: "https://registry.npmjs.org",
3583
+ sources: [],
3584
+ _resolveSpecIdentity: typeof resolve === "function" ? async (args) => { resolveCalls++; return resolve(args); } : undefined,
3585
+ _preflightInstall: async () => { probeCalls.count++; return probeReport("mutable-pkg", "2.0.0"); },
3586
+ });
3587
+ const probesBefore = () => probeCalls.count;
3588
+
3589
+ // a) 可核验形态 + 身份一致 → 复用缓存,探装不跑。
3590
+ seedCache(idSpec, "mutable-pkg", "1.0.0");
3591
+ const probesBeforeA = probeCalls.count;
3592
+ const same = await run(idSpec, async () => ({ name: "mutable-pkg", version: "1.0.0" }));
3593
+ check("身份一致 → 复用缓存不重跑探装",
3594
+ same.report.candidate.version === "1.0.0" && probeCalls.count === probesBeforeA,
3595
+ `version=${same.report.candidate.version} probes=${probeCalls.count}`);
3596
+
3597
+ // b) latest 漂了 → 缓存作废、重跑、新报告落缓存。重跑装的正是新版,
3598
+ // 新报告的 digest 随之变化——同意绑定由此接上。
3599
+ const drifted = await run(idSpec, async () => ({ name: "mutable-pkg", version: "2.0.0" }));
3600
+ check("候选漂移 → 丢弃缓存重跑探装",
3601
+ drifted.report.candidate.version === "2.0.0" && probeCalls.count === 1,
3602
+ `version=${drifted.report.candidate.version} probes=${probeCalls.count}`);
3603
+ check("漂移重跑后缓存里存的是新报告",
3604
+ preflightCache.get(idKey)?.report?.candidate?.version === "2.0.0");
3605
+
3606
+ // c) 核不上(registry 不可达)→ 丢弃缓存重跑,不再沿用旧报告。
3607
+ seedCache(idSpec, "mutable-pkg", "1.0.0");
3608
+ const unreachable = await run(idSpec, async () => undefined);
3609
+ check("身份核不上 → 丢弃缓存重跑(fail-closed)",
3610
+ unreachable.report.candidate.version === "2.0.0" && probeCalls.count === 2,
3611
+ `version=${unreachable.report.candidate.version} probes=${probeCalls.count}`);
3612
+
3613
+ // d) 解析结果没有 version → 同样没核住,丢弃重跑。
3614
+ seedCache(idSpec, "mutable-pkg", "1.0.0");
3615
+ const noVersion = await run(idSpec, async () => ({ name: "mutable-pkg", version: null }));
3616
+ check("解析结果没有 version → 丢弃缓存重跑",
3617
+ noVersion.report.candidate.version === "2.0.0" && probeCalls.count === 3);
3618
+
3619
+ // e) 不可核验形态(未钉 sha 的 github)→ 身份查询根本不发起,直接重跑。
3620
+ const ghSpec = "github:owner/repo";
3621
+ seedCache(ghSpec, "some-pkg", "1.0.0");
3622
+ const resolvesBefore = resolveCalls;
3623
+ const ghRun = await run(ghSpec, async () => ({ name: "some-pkg", version: "1.0.0" }));
3624
+ check("未钉 sha 的 github → 不发起身份查询,直接丢弃重跑",
3625
+ resolveCalls === resolvesBefore && ghRun.report.candidate.version === "2.0.0" && probeCalls.count === 4,
3626
+ `resolves=${resolveCalls - resolvesBefore} probes=${probeCalls.count}`);
3627
+ const fileRun = await run("file:../local.tgz", async () => ({ name: "x", version: "1.0.0" }));
3628
+ check("file: 路径 → 同样不可核验,直接重跑",
3629
+ fileRun.report.candidate.version === "2.0.0" && probeCalls.count === 5);
3630
+
3631
+ // f) 不可变形态(精确版本)→ 不发起查询,无条件复用。
3632
+ const exactSpec = "fixed-pkg@1.2.3";
3633
+ seedCache(exactSpec, "fixed-pkg", "1.2.3");
3634
+ const resolvesBeforeExact = resolveCalls;
3635
+ const exactRun = await run(exactSpec, async () => { throw new Error("不可变形态不该发起身份查询"); });
3636
+ check("精确版本 → 不发起身份查询,复用缓存",
3637
+ resolveCalls === resolvesBeforeExact && exactRun.report.candidate.version === "1.2.3",
3638
+ `resolves=${resolveCalls - resolvesBeforeExact}`);
3639
+
3640
+ // g) 取消上抛,且缓存保持原样——取消不是「核不上」,不许走丢弃分支。
3641
+ seedCache(idSpec, "mutable-pkg", "1.0.0");
3642
+ let aborted = false;
3643
+ try {
3644
+ await run(idSpec, async () => { const error = new Error("cancelled"); error.name = "AbortError"; throw error; });
3645
+ } catch (error) { aborted = error?.name === "AbortError"; }
3646
+ check("身份再校验中取消 → 上抛 AbortError 且缓存原样",
3647
+ aborted && preflightCache.get(idKey)?.report?.candidate?.version === "1.0.0" && probeCalls.count === 5);
3648
+
3649
+ // h) 不可核验的形态不 pin——pin 了也只是把注定要丢弃的缓存钉在原地。
3650
+ seedCache("github:owner/pin-test", "some-pkg", "1.0.0");
3651
+ const pinGuardKey = preflightCacheKey(profileDir, "github:owner/pin-test");
3652
+ preflightCache.get(pinGuardKey).pinnedAt = undefined; // 种子不带 pin
3653
+ pinPreflight(profileDir, "github:owner/pin-test");
3654
+ check("不可核验形态 → pinPreflight 拒绝钉住", isPinned(preflightCache.get(pinGuardKey)) === false);
3655
+ }
3656
+
3657
+ // ── 12b. 预检的告警原文必须留在 job 日志里 ──────────────────────────────
3658
+ //
3659
+ // 此前浏览器侧只 push 了一行 verdict,逐条原因走 extras 进风险卡片。卡片
3660
+ // 一关(或点了「继续安装」)那些原因就再也找不回来了,而用户回头想弄清
3661
+ // 「刚才到底警告了什么」只有日志可查。
3662
+ {
3663
+ const log = preflightVerdictLog({
3664
+ verdict: "warning",
3665
+ issues: [
3666
+ { severity: "warn", title: "无法验证宿主依赖", detail: "需要 @deepseek-ai/dsh-client-ui-slots@^0.1.0-rc.8,但预检无法解析宿主版本。" },
3667
+ { severity: "block", title: "重复挂载", detail: "两行指向同一个模块" },
3668
+ ],
3669
+ });
3670
+ check("预检日志带结论", /预检结论:warning/.test(log));
3671
+ check("预检日志逐条带 WARN 原文(含 detail,不是只有标题)",
3672
+ /\[WARN\] 无法验证宿主依赖: .*dsh-client-ui-slots@\^0\.1\.0-rc\.8/.test(log));
3673
+ check("预检日志逐条带 BLOCK 原文", /\[BLOCK\] 重复挂载: 两行指向同一个模块/.test(log));
3674
+ check("没有 issues 时不炸、仍带结论",
3675
+ preflightVerdictLog({ verdict: "safe" }) === "[dsh-plugin-mall] 预检结论:safe\n");
3676
+ }
3677
+
3678
+ // ── 12b3. 预检 job 全链路(startCustom → get):日志行与 digest 真的
3679
+ // 能走完 tracker 的整条路。只测 formatter 测不出「extras 序列化下发」
3680
+ // 这一段——前端拿 digest 全靠它。
3681
+ {
3682
+ const integrationReport = {
3683
+ verdict: "warning",
3684
+ summary: "有需要确认的改动",
3685
+ candidate: { name: "x", version: "1.0.0" },
3686
+ issues: [{ severity: "warn", title: "替换整块 config", detail: "sandbox-policy" }],
3687
+ };
3688
+ const integrationTracker = createJobTracker();
3689
+ const integrationId = integrationTracker.startCustom({
3690
+ kind: "dsh-plugin-preflight",
3691
+ label: "preflight x",
3692
+ profile: "web",
3693
+ spec: "x",
3694
+ surface: "browser",
3695
+ session: "sess-i",
3696
+ run: async (push) => {
3697
+ push("[dsh-plugin-mall] 预检 x:隔离目录探装(脚本禁用)\n");
3698
+ push(preflightVerdictLog(integrationReport));
3699
+ return {
3700
+ status: "completed",
3701
+ detail: `预检完成:${integrationReport.verdict}`,
3702
+ extras: { ...integrationReport, consentDigest: preflightConsentDigest(integrationReport, "fp-i") },
3703
+ };
3704
+ },
3705
+ });
3706
+ await new Promise((resolvePromise) => setImmediate(resolvePromise));
3707
+ const integrationDelta = integrationTracker.get(integrationId, "sess-i");
3708
+ const integrationLog = integrationDelta.output ?? "";
3709
+ check("预检 job 集成:结论与逐条 WARN 进快照输出",
3710
+ /预检结论:warning/.test(integrationLog) && /\[WARN\] 替换整块 config: sandbox-policy/.test(integrationLog),
3711
+ `output=${JSON.stringify(integrationLog)}`);
3712
+ check("预检 job 集成:digest 随 extras 送达",
3713
+ integrationDelta.snapshot?.extras?.consentDigest === preflightConsentDigest(integrationReport, "fp-i"),
3714
+ `extras=${JSON.stringify(integrationDelta.snapshot?.extras)?.slice(0, 120)}`);
3715
+ }
3716
+
3225
3717
  // ── 13. market_install:整条链跑在 job 里(issue #8)────────────────────
3226
3718
  // 原来 registry 查询 → 防抢注解析 → 隔离预检全在 ctx.jobs.start() 之前 await,
3227
3719
  // 于是几十秒里没有 job id、没有日志、job_kill 够不着,而工具描述写的是
@@ -3308,6 +3800,178 @@ export async function runSelfTests() {
3308
3800
  check("预检 warning 未确认 → failed 且提示 acceptWarnings",
3309
3801
  warnOutcome?.status === "failed" && /acceptWarnings: true/.test(warnOutcome?.detail ?? "") && warnInstalls.calls === 0);
3310
3802
 
3803
+ // 13a2b. digest 的敏感性:报告的任何一个承重维度变了,digest 必须变。
3804
+ // 「同意绑定的是这份报告」靠它成立——漏一个维度,那个维度上的漂移就
3805
+ // 能从旧同意底下溜过去。
3806
+ {
3807
+ const consentReport = {
3808
+ verdict: "warning",
3809
+ candidate: { name: "warn-pkg", version: "1.0.0" },
3810
+ issues: [{ severity: "warn", title: "替换整块 config", detail: "sandbox-policy" }],
3811
+ };
3812
+ const d0 = preflightConsentDigest(consentReport, "fp-consent");
3813
+ check("digest 对同一输入稳定", d0 === preflightConsentDigest(consentReport, "fp-consent"));
3814
+ check("issues 变化 → digest 变",
3815
+ d0 !== preflightConsentDigest({ ...consentReport, issues: [{ severity: "warn", title: "替换整块 config", detail: "别的块" }] }, "fp-consent"));
3816
+ check("候选版本变化 → digest 变",
3817
+ d0 !== preflightConsentDigest({ ...consentReport, candidate: { name: "warn-pkg", version: "2.0.0" } }, "fp-consent"));
3818
+ check("profile 指纹变化 → digest 变", d0 !== preflightConsentDigest(consentReport, "fp-other"));
3819
+ check("verdict 变化 → digest 变", d0 !== preflightConsentDigest({ ...consentReport, verdict: "safe" }, "fp-consent"));
3820
+ }
3821
+
3822
+ // 13a3. 同意绑定 digest:acceptWarnings:true 不再是无条件的通行证。
3823
+ // 用户确认警告到重试之间,报告可能整个换过(profile 变了触发重跑、
3824
+ // 候选发了新版)——布尔同意不得沿用,必须重新看新的警告。
3825
+ {
3826
+ const consentReport = {
3827
+ verdict: "warning",
3828
+ candidate: { name: "warn-pkg", version: "1.0.0" },
3829
+ issues: [{ severity: "warn", title: "替换整块 config", detail: "sandbox-policy" }],
3830
+ };
3831
+ const consentFingerprint = "fp-consent";
3832
+ const goodDigest = preflightConsentDigest(consentReport, consentFingerprint);
3833
+
3834
+ // a) digest 匹配 → 通过警告关卡,进入安装。
3835
+ const matchInstalls = { calls: 0 };
3836
+ const matchOutcome = await settleWithin(createInstallJobProducer({
3837
+ profile: "web",
3838
+ spec: "warn-pkg",
3839
+ agentOwner: "agent-selftest",
3840
+ acceptWarnings: true,
3841
+ reportDigest: goodDigest,
3842
+ ...seams({
3843
+ _runPreflight: async () => ({ report: consentReport, profileDir, fingerprint: consentFingerprint }),
3844
+ _runInstall: neverInstall(matchInstalls),
3845
+ }),
3846
+ }).done, 5000, "digest 匹配");
3847
+ check("警告同意 digest 匹配 → 进入安装",
3848
+ matchOutcome?.status === "completed" && matchInstalls.calls === 1,
3849
+ `status=${matchOutcome?.status} calls=${matchInstalls.calls} detail=${matchOutcome?.detail}`);
3850
+
3851
+ // b) digest 过期:重跑后报告变了(多了一条警告、候选升了版本)。
3852
+ // 拒绝,且 detail 给出**新** digest——模型照着新警告重新确认。
3853
+ const driftedReport = {
3854
+ verdict: "warning",
3855
+ candidate: { name: "warn-pkg", version: "2.0.0" },
3856
+ issues: [
3857
+ { severity: "warn", title: "替换整块 config", detail: "sandbox-policy" },
3858
+ { severity: "warn", title: "新版本的额外改动", detail: "loader-id 顶掉现有行" },
3859
+ ],
3860
+ };
3861
+ const newDigest = preflightConsentDigest(driftedReport, consentFingerprint);
3862
+ const driftInstalls = { calls: 0 };
3863
+ const driftOutcome = await settleWithin(createInstallJobProducer({
3864
+ profile: "web",
3865
+ spec: "warn-pkg",
3866
+ agentOwner: "agent-selftest",
3867
+ acceptWarnings: true,
3868
+ reportDigest: goodDigest, // 用户当初确认的是旧报告的 digest
3869
+ ...seams({
3870
+ _runPreflight: async () => ({ report: driftedReport, profileDir, fingerprint: consentFingerprint }),
3871
+ _runInstall: neverInstall(driftInstalls),
3872
+ }),
3873
+ }).done, 5000, "digest 过期");
3874
+ check("报告变了 → 旧 digest 拒绝安装",
3875
+ driftOutcome?.status === "failed" && driftInstalls.calls === 0,
3876
+ `status=${driftOutcome?.status} calls=${driftInstalls.calls}`);
3877
+ check("报告变了 → 拒绝时展示新警告原文", /新版本的额外改动/.test(driftOutcome?.detail ?? ""));
3878
+ check("报告变了 → 拒绝时给出新 digest 供重新确认",
3879
+ driftOutcome?.detail?.includes(newDigest) === true && !driftOutcome.detail.includes(goodDigest));
3880
+
3881
+ // c) acceptWarnings:true 但压根没给 digest → 同样拒绝。
3882
+ const bareInstalls = { calls: 0 };
3883
+ const bareOutcome = await settleWithin(createInstallJobProducer({
3884
+ profile: "web",
3885
+ spec: "warn-pkg",
3886
+ agentOwner: "agent-selftest",
3887
+ acceptWarnings: true,
3888
+ ...seams({
3889
+ _runPreflight: async () => ({ report: consentReport, profileDir, fingerprint: consentFingerprint }),
3890
+ _runInstall: neverInstall(bareInstalls),
3891
+ }),
3892
+ }).done, 5000, "无 digest");
3893
+ check("acceptWarnings:true 无 digest → 拒绝并索要 digest",
3894
+ bareOutcome?.status === "failed" && /reportDigest/.test(bareOutcome?.detail ?? "") && bareInstalls.calls === 0);
3895
+
3896
+ // d) 审批 token 路径不需要裸 digest:consumeApprovalToken 自己比对
3897
+ // 报告摘要(那次真实事故「preflight report changed」就是它拦的),
3898
+ // 再要求一份裸 digest 属于重复关卡。
3899
+ check("审批 token 路径不受裸 digest 关卡影响",
3900
+ preflightRefusal(consentReport, true, "lbl", { fingerprint: consentFingerprint, consentBoundByToken: true }) === undefined);
3901
+ }
3902
+
3903
+ // 13a4. surface/owner 参数化:producer 是唯一签发者,浏览器 surface
3904
+ // 签出的 token 必须归属那个 session——参数要是接错线(漏传、写死
3905
+ // agent),token 会落在错误的归属域里,跨域消费的隔离就形同虚设。
3906
+ {
3907
+ const surfProof = proofFor("surf-pkg");
3908
+ const surfOutcome = await settleWithin(createInstallJobProducer({
3909
+ profile: "web",
3910
+ spec: "surf-pkg",
3911
+ surface: "browser",
3912
+ owner: "session-surf",
3913
+ ...seams({
3914
+ _runPreflight: async () => ({ report: cleanReport, profileDir, fingerprint: "fp-surf" }),
3915
+ _runInstall: () => ({
3916
+ cancel: () => {},
3917
+ done: Promise.resolve({
3918
+ status: "needsApproval",
3919
+ detail: "approval needed",
3920
+ needsApproval: disclosureFor(surfProof),
3921
+ proof: surfProof,
3922
+ }),
3923
+ readOutput: () => "",
3924
+ }),
3925
+ }),
3926
+ }).done, 5000, "surface job");
3927
+ const surfToken = surfOutcome?.approvalToken;
3928
+ const surfConsume = typeof surfToken === "string"
3929
+ ? consumeApprovalToken({
3930
+ token: surfToken,
3931
+ profile: "web",
3932
+ profileDir,
3933
+ spec: "surf-pkg",
3934
+ preflightReport: cleanReport,
3935
+ allowBuildScripts: ["surf-pkg"],
3936
+ surface: "browser",
3937
+ owner: "session-surf",
3938
+ })
3939
+ : { valid: false, reason: "no token" };
3940
+ check("browser surface 的 producer 签发归属该 session 的 token",
3941
+ surfConsume.valid === true,
3942
+ `token=${typeof surfToken} reason=${surfConsume.reason}`);
3943
+ // token 绝不进 browser 的 detail:tracker.get/list 无条件下发 detail,
3944
+ // 只有独立的 approvalToken 字段做 session 隔离——拼进去等于发给
3945
+ // 所有 session。浏览器只走 outcome.approvalToken → 同 session 快照。
3946
+ check("browser surface 的 detail 不含 token(防跨 session 泄漏)",
3947
+ !String(surfOutcome?.detail ?? "").includes(String(surfToken)),
3948
+ `detail=${String(surfOutcome?.detail ?? "").slice(0, 120)}`);
3949
+
3950
+ // agent 的 detail 必须仍然带 token:宿主按 owner 隔离 job_output,
3951
+ // 模型重试全靠从 detail 里读到它。
3952
+ const agentProof = proofFor("surf-agent-pkg");
3953
+ const agentDetailOutcome = await settleWithin(createInstallJobProducer({
3954
+ profile: "web",
3955
+ spec: "surf-agent-pkg",
3956
+ agentOwner: "agent-surf",
3957
+ ...seams({
3958
+ _runPreflight: async () => ({ report: cleanReport, profileDir, fingerprint: "fp-surf-agent" }),
3959
+ _runInstall: () => ({
3960
+ cancel: () => {},
3961
+ done: Promise.resolve({
3962
+ status: "needsApproval",
3963
+ detail: "approval needed",
3964
+ needsApproval: disclosureFor(agentProof),
3965
+ proof: agentProof,
3966
+ }),
3967
+ readOutput: () => "",
3968
+ }),
3969
+ }),
3970
+ }).done, 5000, "agent surface job");
3971
+ check("agent surface 的 detail 仍带 token(模型重试要读它)",
3972
+ /Approval token \(pass to approvalToken on retry\)/.test(String(agentDetailOutcome?.detail ?? "")));
3973
+ }
3974
+
3311
3975
  // 13b. 预检通过 → 进入安装。同时钉两件事:pnpm 拿到的是防抢注解析后的
3312
3976
  // spec(label 用的是归一 spec,两者可以不同),以及 readOutput 的顺序。
3313
3977
  let preflightSpec;