@1e0zj/dsh-plugin-mall 0.4.1 → 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/package.json +1 -1
- package/src/client.js +8 -5
- package/src/github.js +57 -9
- package/src/guard.js +209 -6
- package/src/index.js +1280 -183
- package/src/installer.js +310 -35
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,9 +26,9 @@ 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
|
-
import { preflightInstall, inspectRemoteCandidate, recoverProfile, describeRollbackRebuild } from "./guard.js";
|
|
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";
|
|
32
33
|
|
|
33
34
|
export const name = "@1e0zj/dsh-plugin-mall";
|
|
@@ -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);
|
|
@@ -350,11 +362,98 @@ function pinPreflight(profileDir, spec) {
|
|
|
350
362
|
/**
|
|
351
363
|
* Run the isolated preflight for a resolved install spec, reusing a fresh
|
|
352
364
|
* cache entry ONLY if the profile fingerprint matches.
|
|
365
|
+
*
|
|
366
|
+
* `signal` cancels the probe. Nothing extra is needed to protect the cache:
|
|
367
|
+
* preflightInstall THROWS an AbortError on cancellation instead of returning a
|
|
368
|
+
* report, so control never reaches preflightCache.set() below and a cancelled
|
|
369
|
+
* run leaves the cache exactly as it found it. (Had cancellation come back as
|
|
370
|
+
* a `blocked` report, that fabricated verdict would have been cached for the
|
|
371
|
+
* whole TTL and every later install of this spec refused with it.)
|
|
372
|
+
*/
|
|
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.
|
|
353
402
|
*/
|
|
354
|
-
|
|
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 }) {
|
|
355
454
|
let profileDir;
|
|
356
455
|
try {
|
|
357
|
-
profileDir = resolveProfileDir(profile);
|
|
456
|
+
profileDir = _profileDir ?? resolveProfileDir(profile);
|
|
358
457
|
} catch (error) {
|
|
359
458
|
throw new Error(`invalid profile: ${error.message}`);
|
|
360
459
|
}
|
|
@@ -374,10 +473,41 @@ async function runPreflight({ profile, spec, force = false, onOutput }) {
|
|
|
374
473
|
&& (isPinned(validCached) || Date.now() - validCached.at < PREFLIGHT_TTL);
|
|
375
474
|
|
|
376
475
|
if (!force && fresh) {
|
|
377
|
-
|
|
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); // 核不住或对不上:作废,下面重跑探装
|
|
378
508
|
}
|
|
379
509
|
|
|
380
|
-
const report = await
|
|
510
|
+
const report = await _preflightInstall({ profileDir, spec, onOutput, signal });
|
|
381
511
|
preflightCache.set(key, {
|
|
382
512
|
report,
|
|
383
513
|
fingerprint: currentFingerprint,
|
|
@@ -549,6 +679,16 @@ export function consumeApprovalToken({
|
|
|
549
679
|
return { valid: false, reason: "invalid or already consumed approval token" };
|
|
550
680
|
}
|
|
551
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
|
+
|
|
552
692
|
// Atomically delete token on validation to guarantee one-shot
|
|
553
693
|
approvalTokens.delete(cleanToken);
|
|
554
694
|
|
|
@@ -571,12 +711,6 @@ export function consumeApprovalToken({
|
|
|
571
711
|
return { valid: false, reason: "approval token profile directory mismatch" };
|
|
572
712
|
}
|
|
573
713
|
}
|
|
574
|
-
if (record.surface !== surface) {
|
|
575
|
-
return { valid: false, reason: "approval token surface mismatch (cannot reuse between browser and agent)" };
|
|
576
|
-
}
|
|
577
|
-
if (record.owner !== (owner ?? "")) {
|
|
578
|
-
return { valid: false, reason: `approval token ${surface === "browser" ? "session" : "owner"} mismatch` };
|
|
579
|
-
}
|
|
580
714
|
if (
|
|
581
715
|
createHash("sha256").update(record.disclosureSerialized).digest("hex") !== record.disclosureDigest
|
|
582
716
|
|| createHash("sha256").update(record.proofSerialized).digest("hex") !== record.proofDigest
|
|
@@ -967,11 +1101,6 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
967
1101
|
profile,
|
|
968
1102
|
spec,
|
|
969
1103
|
verb = "add",
|
|
970
|
-
allowBuildScripts,
|
|
971
|
-
approvedProof,
|
|
972
|
-
preflight,
|
|
973
|
-
profileDir,
|
|
974
|
-
acceptWarningsActive = false,
|
|
975
1104
|
surface = "browser",
|
|
976
1105
|
session,
|
|
977
1106
|
onSettled,
|
|
@@ -980,11 +1109,15 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
980
1109
|
const id = `market-${++trackerCounter}`;
|
|
981
1110
|
const kind = verb === "remove" ? "dsh-plugin-uninstall" : "dsh-plugin-install";
|
|
982
1111
|
const factory = startProducerFactory ?? producerFactory;
|
|
1112
|
+
// install 必须带 producerFactory:createInstallJobProducer 是整条链
|
|
1113
|
+
// (含审批 token 签发)的唯一所有者,tracker 不再自己拼 runInstall——
|
|
1114
|
+
// 那条老路没有预检、没有 token 语义,只是历史上预检跑在 RPC 里时的
|
|
1115
|
+
// 残余。remove 仍可直接起 runRemove。
|
|
983
1116
|
const producer = typeof factory === "function"
|
|
984
|
-
? factory({ profile, spec, verb
|
|
1117
|
+
? factory({ profile, spec, verb })
|
|
985
1118
|
: verb === "remove"
|
|
986
1119
|
? runRemove({ profile, packageName: spec })
|
|
987
|
-
:
|
|
1120
|
+
: (() => { throw new Error("install jobs require a producerFactory — the preflight chain owns the producer, not the tracker"); })();
|
|
988
1121
|
|
|
989
1122
|
const record = {
|
|
990
1123
|
id,
|
|
@@ -1019,25 +1152,13 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
1019
1152
|
record.staleOnRestart = outcome?.staleOnRestart === true;
|
|
1020
1153
|
record.finishedAt = Date.now();
|
|
1021
1154
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
profileDir,
|
|
1030
|
-
spec,
|
|
1031
|
-
preflightReport: preflight,
|
|
1032
|
-
needsApproval: outcome.needsApproval,
|
|
1033
|
-
proof: outcome.proof,
|
|
1034
|
-
surface: record.surface,
|
|
1035
|
-
owner: record.session,
|
|
1036
|
-
acceptWarningsActive,
|
|
1037
|
-
});
|
|
1038
|
-
record.approvalToken = token;
|
|
1039
|
-
} else {
|
|
1040
|
-
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;
|
|
1041
1162
|
}
|
|
1042
1163
|
|
|
1043
1164
|
try {
|
|
@@ -1275,20 +1396,281 @@ function renderPreflightIssue(entry) {
|
|
|
1275
1396
|
}
|
|
1276
1397
|
|
|
1277
1398
|
/**
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
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.
|
|
1280
1407
|
*/
|
|
1281
|
-
function
|
|
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
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Why this install must not proceed — or undefined when it may.
|
|
1416
|
+
*
|
|
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.
|
|
1436
|
+
*/
|
|
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 } = {}) {
|
|
1282
1449
|
if (report.verdict === "blocked") {
|
|
1283
|
-
|
|
1284
|
-
error.preflight = report;
|
|
1285
|
-
throw error;
|
|
1450
|
+
return `${label}: ${report.summary}\n${report.issues.filter((entry) => entry.severity === "block").map(renderPreflightIssue).join("\n")}`;
|
|
1286
1451
|
}
|
|
1287
|
-
if (report.verdict === "warning"
|
|
1288
|
-
const
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
+
}
|
|
1291
1465
|
}
|
|
1466
|
+
return undefined;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
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).
|
|
1473
|
+
*
|
|
1474
|
+
* Why it all lives in here (issue #8): `ctx.jobs.start()` treats `run()` as a
|
|
1475
|
+
* synchronous start boundary, so anything awaited before that call happens
|
|
1476
|
+
* outside the task runtime. The old market_install awaited the registry query,
|
|
1477
|
+
* the anti-squatting resolve and the isolated probe install first — tens of
|
|
1478
|
+
* seconds during which the tool call had not returned, no job id existed, the
|
|
1479
|
+
* work appeared in no job log, and `job_kill` had nothing to kill. The tool's
|
|
1480
|
+
* own description promised the opposite ("ALWAYS runs as a background job:
|
|
1481
|
+
* the call returns a job id immediately"). Moving the chain in here makes that
|
|
1482
|
+
* promise true and, as a side effect, gives the slow phases a kill handle.
|
|
1483
|
+
*
|
|
1484
|
+
* One phase is deliberately NOT cancellable: the registry lookup. `resolveRegistry`
|
|
1485
|
+
* caches a PROMISE per profile, so threading the signal into it would leave a
|
|
1486
|
+
* permanently rejected promise in that cache after one cancel — every later
|
|
1487
|
+
* registry query for the profile would fail, and unlike the npmPackageInfo cache
|
|
1488
|
+
* it never expires. That is the same cache-poisoning bug this change set exists
|
|
1489
|
+
* to remove, traded for at most the few seconds `pnpm config get registry` takes
|
|
1490
|
+
* on a cold profile. So cancellation there lands on the throwIfAborted() right
|
|
1491
|
+
* after it instead.
|
|
1492
|
+
*
|
|
1493
|
+
* Two invariants this shape has to keep:
|
|
1494
|
+
* - `done` NEVER rejects. It is the single settlement path the jobs runtime
|
|
1495
|
+
* consumes, so a refusal, a cancellation and an internal error all come
|
|
1496
|
+
* back as ordinary outcome objects.
|
|
1497
|
+
* - `cancel()` is synchronous and idempotent. It aborts the preflight phase
|
|
1498
|
+
* through the AbortController and, once pnpm owns the profile, hands over
|
|
1499
|
+
* to the installer's own cancel (which tree-kills and waits for 'close'
|
|
1500
|
+
* before rolling back).
|
|
1501
|
+
*
|
|
1502
|
+
* The `_`-prefixed parameters are test seams only; production passes none.
|
|
1503
|
+
*/
|
|
1504
|
+
function createInstallJobProducer({
|
|
1505
|
+
profile,
|
|
1506
|
+
spec: requestedSpec,
|
|
1507
|
+
acceptWarnings: acceptWarningsRequested = false,
|
|
1508
|
+
reportDigest,
|
|
1509
|
+
allowBuildScripts,
|
|
1510
|
+
approvalToken,
|
|
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,
|
|
1517
|
+
npmRegistry = "",
|
|
1518
|
+
rawSources = [],
|
|
1519
|
+
profileExisted = true,
|
|
1520
|
+
profileDir: requestedProfileDir,
|
|
1521
|
+
_registryFor = registryFor,
|
|
1522
|
+
_preferNpmSpec = preferNpmSpec,
|
|
1523
|
+
_assertSafeToInstall = assertSafeToInstall,
|
|
1524
|
+
_runPreflight = runPreflight,
|
|
1525
|
+
_runInstall = runInstall,
|
|
1526
|
+
}) {
|
|
1527
|
+
const controller = new AbortController();
|
|
1528
|
+
const { signal } = controller;
|
|
1529
|
+
let inner; // the runInstall producer — only exists once pnpm is about to run
|
|
1530
|
+
let cancelled = false;
|
|
1531
|
+
|
|
1532
|
+
// 预检阶段没有 inner 可以问,它的输出先攒在这里。
|
|
1533
|
+
const preflightChunks = [];
|
|
1534
|
+
const pushPreflight = (text) => { preflightChunks.push(String(text ?? "")); };
|
|
1535
|
+
|
|
1536
|
+
const cancel = () => {
|
|
1537
|
+
if (cancelled) return; // 幂等:job_kill 可能被按多次,abort 也只该发生一次
|
|
1538
|
+
cancelled = true;
|
|
1539
|
+
controller.abort(); // 预检阶段:掐断 registry 请求与探针 pnpm
|
|
1540
|
+
inner?.cancel(); // 安装阶段:交给 installer 的 tree-kill + 回滚
|
|
1541
|
+
};
|
|
1542
|
+
|
|
1543
|
+
const done = (async () => {
|
|
1544
|
+
const registry = await _registryFor(profile, npmRegistry);
|
|
1545
|
+
signal.throwIfAborted();
|
|
1546
|
+
// 防抢注解析可能把 owner/repo 换成 npm 包名,后面每一步(预检、token
|
|
1547
|
+
// 比对、pnpm)都必须用这个解析后的 spec,否则重试时 token 的 spec 对不上。
|
|
1548
|
+
const spec = await _preferNpmSpec({ spec: requestedSpec, registry, sources: rawSources, signal });
|
|
1549
|
+
signal.throwIfAborted();
|
|
1550
|
+
await _assertSafeToInstall({ spec, registry, sources: rawSources, signal });
|
|
1551
|
+
signal.throwIfAborted();
|
|
1552
|
+
|
|
1553
|
+
pushPreflight(`[dsh-plugin-mall] 预检 ${spec}:隔离目录探装(脚本禁用)\n`);
|
|
1554
|
+
const preflight = await _runPreflight({ profile, spec, onOutput: pushPreflight, signal, registry, sources: rawSources });
|
|
1555
|
+
signal.throwIfAborted(); // 命中缓存时预检不会自己抛,这里补一次取消检查
|
|
1556
|
+
pushPreflight(`[dsh-plugin-mall] 预检结论:${preflight.report.verdict}\n`);
|
|
1557
|
+
|
|
1558
|
+
let acceptWarnings = false;
|
|
1559
|
+
let acceptWarningsActive = false;
|
|
1560
|
+
let approvedProof;
|
|
1561
|
+
if (approvalToken !== undefined) {
|
|
1562
|
+
const consumeResult = consumeApprovalToken({
|
|
1563
|
+
token: approvalToken,
|
|
1564
|
+
profile,
|
|
1565
|
+
profileDir: preflight.profileDir,
|
|
1566
|
+
spec,
|
|
1567
|
+
preflightReport: preflight.report,
|
|
1568
|
+
allowBuildScripts,
|
|
1569
|
+
surface,
|
|
1570
|
+
owner,
|
|
1571
|
+
});
|
|
1572
|
+
if (!consumeResult.valid) {
|
|
1573
|
+
return { status: "failed", detail: `invalid approval token: ${consumeResult.reason}` };
|
|
1574
|
+
}
|
|
1575
|
+
acceptWarnings = consumeResult.warningConsent;
|
|
1576
|
+
acceptWarningsActive = consumeResult.warningConsent;
|
|
1577
|
+
approvedProof = consumeResult.proof;
|
|
1578
|
+
} else {
|
|
1579
|
+
acceptWarnings = acceptWarningsRequested === true;
|
|
1580
|
+
acceptWarningsActive = acceptWarnings;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const refusal = preflightRefusal(preflight.report, acceptWarnings, `market_install ${spec}`, {
|
|
1584
|
+
digestProvided: reportDigest,
|
|
1585
|
+
fingerprint: preflight.fingerprint,
|
|
1586
|
+
consentBoundByToken: approvalToken !== undefined, // consume 已带报告摘要比对
|
|
1587
|
+
});
|
|
1588
|
+
if (refusal !== undefined) {
|
|
1589
|
+
// 拒绝是这个 job 的正常结局,不是异常:作为 failed 的 detail 回去,
|
|
1590
|
+
// 模型从 job_output 就能读到逐条 BLOCK/WARN。
|
|
1591
|
+
return { status: "failed", detail: refusal };
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
pinPreflight(preflight.profileDir, spec);
|
|
1595
|
+
signal.throwIfAborted(); // 从这行往后,取消归 installer 管
|
|
1596
|
+
inner = _runInstall({ profile, spec, allowBuildScripts, approvedProof, preflight: preflight.report });
|
|
1597
|
+
if (signal.aborted) inner.cancel(); // 上一行之前就取消过的话,补一次转交
|
|
1598
|
+
const outcome = await inner.done; // installer 的 done 同样永不 reject
|
|
1599
|
+
|
|
1600
|
+
const status = outcome?.status ?? "failed";
|
|
1601
|
+
if (status === "completed") {
|
|
1602
|
+
invalidatePreflightFor(preflight.profileDir);
|
|
1603
|
+
clearApprovalTokensFor(profile, spec);
|
|
1604
|
+
} else if (outcome?.needsApproval && outcome.needsApproval.length > 0) {
|
|
1605
|
+
clearApprovalTokensFor(profile, spec, { surface, owner });
|
|
1606
|
+
try {
|
|
1607
|
+
const token = issueApprovalToken({
|
|
1608
|
+
profile,
|
|
1609
|
+
profileDir: preflight.profileDir,
|
|
1610
|
+
spec,
|
|
1611
|
+
preflightReport: preflight.report,
|
|
1612
|
+
needsApproval: outcome.needsApproval,
|
|
1613
|
+
proof: outcome.proof,
|
|
1614
|
+
surface,
|
|
1615
|
+
owner,
|
|
1616
|
+
acceptWarningsActive,
|
|
1617
|
+
});
|
|
1618
|
+
outcome.approvalToken = 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
|
+
}
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
// 签发会因为凭证不完整(proof 缺失/不匹配)抛错。以前这段跑在 `.then`
|
|
1630
|
+
// 里,抛出去就把 done 变成 rejected —— 官方明说 done 必须不 reject,
|
|
1631
|
+
// 而且那样一来「pnpm 拦下了安装脚本」这条真正的结论会被一条内部错误
|
|
1632
|
+
// 顶掉。改成写进 detail:结论照常送达,同时明说这次没法重试。
|
|
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.`;
|
|
1634
|
+
}
|
|
1635
|
+
} else {
|
|
1636
|
+
clearApprovalTokensFor(profile, spec, { surface, owner });
|
|
1637
|
+
}
|
|
1638
|
+
return outcome;
|
|
1639
|
+
})().catch((error) => {
|
|
1640
|
+
if (isAbortError(error)) {
|
|
1641
|
+
// 取消时探装全在临时目录里,正式 profile 没被装进任何东西。唯一的例外
|
|
1642
|
+
// 是 profile 本来就不存在——预检会先 ensureProfile() 把它建出来
|
|
1643
|
+
// (package.json / cordis.patch.yml / pnpm-workspace.yaml 真的落盘)。
|
|
1644
|
+
//
|
|
1645
|
+
// 「本来不存在」不等于「我们建了」:ensureProfile() 在 runPreflight 里,
|
|
1646
|
+
// 而取消可能发生在更早的 registry 查询、防抢注解析或宿主遮蔽检查阶段,
|
|
1647
|
+
// 那时磁盘上一个字节都还没写。所以这里查磁盘的当前事实,而不是拿开工前
|
|
1648
|
+
// 的快照去推断——推断会随着链路上再加一步就悄悄失真,实地检查不会。
|
|
1649
|
+
const profileCreatedHere = profileExisted === false
|
|
1650
|
+
&& requestedProfileDir !== undefined
|
|
1651
|
+
&& existsSync(join(requestedProfileDir, "package.json"));
|
|
1652
|
+
return {
|
|
1653
|
+
status: "killed",
|
|
1654
|
+
detail: profileCreatedHere
|
|
1655
|
+
? `install of ${requestedSpec} was cancelled during preflight — no packages were installed, but the profile did not exist and was initialized before the probe started`
|
|
1656
|
+
: `install of ${requestedSpec} was cancelled during preflight — the profile was never modified`,
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
return { status: "failed", detail: `install of ${requestedSpec} hit an error: ${error?.message ?? String(error)}` };
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
return {
|
|
1663
|
+
cancel,
|
|
1664
|
+
done,
|
|
1665
|
+
// 顺序依赖:预检阶段与安装阶段严格先后,上面的 async 体在 _runInstall
|
|
1666
|
+
// 之前不会再往 preflightChunks 里写。所以「先排空缓冲、再问 inner」得到的
|
|
1667
|
+
// 就是真实时间顺序;两个阶段若哪天并行了,这里必须改成带时间戳的合并。
|
|
1668
|
+
readOutput: () => {
|
|
1669
|
+
const buffered = preflightChunks.length === 0 ? "" : preflightChunks.splice(0).join("");
|
|
1670
|
+
const live = typeof inner?.readOutput === "function" ? inner.readOutput() : "";
|
|
1671
|
+
return buffered + live;
|
|
1672
|
+
},
|
|
1673
|
+
};
|
|
1292
1674
|
}
|
|
1293
1675
|
|
|
1294
1676
|
/** Clip long strings for compact model-facing output. */
|
|
@@ -1575,9 +1957,28 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1575
1957
|
session,
|
|
1576
1958
|
run: async (push) => {
|
|
1577
1959
|
push(`[dsh-plugin-mall] 预检 ${resolved}:隔离目录探装(脚本禁用)\n`);
|
|
1578
|
-
const { report } = await runPreflight({ profile, spec: resolved, onOutput: (text) => push(text) });
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
+
};
|
|
1581
1982
|
},
|
|
1582
1983
|
});
|
|
1583
1984
|
return rpcOk({ jobId, profile, spec: resolved });
|
|
@@ -1600,12 +2001,17 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1600
2001
|
} catch (error) {
|
|
1601
2002
|
return rpcFail(error);
|
|
1602
2003
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
2004
|
+
// profile 名非法当场报错,与 market_uninstall / agent 工具一致——不是
|
|
2005
|
+
// 一个注定失败的后台 job。顺带取「动手之前」的磁盘状态:producer 的
|
|
2006
|
+
// 取消文案要靠它区分「profile 从未被动过」和「预检把不存在的 profile
|
|
2007
|
+
// 建出来了」。
|
|
2008
|
+
let installProfileDir;
|
|
2009
|
+
let profileExisted = true;
|
|
1605
2010
|
try {
|
|
1606
|
-
|
|
2011
|
+
installProfileDir = resolveProfileDir(profile);
|
|
2012
|
+
profileExisted = existsSync(join(installProfileDir, "package.json"));
|
|
1607
2013
|
} catch (error) {
|
|
1608
|
-
return rpcFail(error);
|
|
2014
|
+
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
1609
2015
|
}
|
|
1610
2016
|
const allowBuildScripts = Array.isArray(payload?.allowBuildScripts)
|
|
1611
2017
|
? payload.allowBuildScripts.map((name) => String(name))
|
|
@@ -1620,58 +2026,35 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1620
2026
|
return rpcFail(error);
|
|
1621
2027
|
}
|
|
1622
2028
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
surface: "browser",
|
|
1638
|
-
owner: session,
|
|
1639
|
-
});
|
|
1640
|
-
if (!consumeResult.valid) {
|
|
1641
|
-
return rpcFail(new Error(`invalid approval token: ${consumeResult.reason}`));
|
|
1642
|
-
}
|
|
1643
|
-
acceptWarnings = consumeResult.warningConsent;
|
|
1644
|
-
acceptWarningsActive = consumeResult.warningConsent;
|
|
1645
|
-
approvedProof = consumeResult.proof;
|
|
1646
|
-
} else {
|
|
1647
|
-
acceptWarnings = payload?.acceptWarnings === true;
|
|
1648
|
-
acceptWarningsActive = acceptWarnings;
|
|
1649
|
-
}
|
|
1650
|
-
enforcePreflight(preflight.report, acceptWarnings, `install ${spec}`);
|
|
1651
|
-
} catch (error) {
|
|
1652
|
-
return rpcFail(error);
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
pinPreflight(preflight.profileDir, spec);
|
|
1656
|
-
try {
|
|
1657
|
-
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({
|
|
1658
2043
|
profile,
|
|
1659
2044
|
spec,
|
|
2045
|
+
acceptWarnings: payload?.acceptWarnings === true,
|
|
2046
|
+
reportDigest: typeof payload?.acceptedReportDigest === "string" ? payload.acceptedReportDigest.trim() : undefined,
|
|
1660
2047
|
allowBuildScripts,
|
|
1661
|
-
|
|
1662
|
-
preflight: preflight.report,
|
|
1663
|
-
profileDir: preflight.profileDir,
|
|
1664
|
-
acceptWarningsActive,
|
|
2048
|
+
approvalToken,
|
|
1665
2049
|
surface: "browser",
|
|
1666
|
-
session,
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
}
|
|
2050
|
+
owner: session,
|
|
2051
|
+
npmRegistry,
|
|
2052
|
+
rawSources,
|
|
2053
|
+
profileExisted,
|
|
2054
|
+
profileDir: installProfileDir,
|
|
2055
|
+
}),
|
|
2056
|
+
});
|
|
2057
|
+
return rpcOk({ jobId, profile, spec });
|
|
1675
2058
|
}
|
|
1676
2059
|
case "uninstall": {
|
|
1677
2060
|
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
@@ -1702,7 +2085,6 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1702
2085
|
profile,
|
|
1703
2086
|
spec: packageName,
|
|
1704
2087
|
verb: "remove",
|
|
1705
|
-
profileDir,
|
|
1706
2088
|
surface: "browser",
|
|
1707
2089
|
session,
|
|
1708
2090
|
onSettled: (outcome) => {
|
|
@@ -1873,7 +2255,7 @@ export function apply(ctx, config = {}) {
|
|
|
1873
2255
|
ctx.systemPrompt.section({
|
|
1874
2256
|
name: "tool:market",
|
|
1875
2257
|
order: 120,
|
|
1876
|
-
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
|
|
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.",
|
|
1877
2259
|
});
|
|
1878
2260
|
|
|
1879
2261
|
ctx.tools.register(defineTool({
|
|
@@ -1951,7 +2333,7 @@ export function apply(ctx, config = {}) {
|
|
|
1951
2333
|
|
|
1952
2334
|
ctx.tools.register(defineTool({
|
|
1953
2335
|
name: "market_install",
|
|
1954
|
-
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
|
|
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.",
|
|
1955
2337
|
parameters: {
|
|
1956
2338
|
spec: {
|
|
1957
2339
|
type: "string",
|
|
@@ -1966,6 +2348,10 @@ export function apply(ctx, config = {}) {
|
|
|
1966
2348
|
type: "boolean",
|
|
1967
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.",
|
|
1968
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
|
+
},
|
|
1969
2355
|
approvalToken: {
|
|
1970
2356
|
type: "string",
|
|
1971
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.",
|
|
@@ -1984,16 +2370,30 @@ export function apply(ctx, config = {}) {
|
|
|
1984
2370
|
},
|
|
1985
2371
|
render: (args, value) => [{
|
|
1986
2372
|
type: "text",
|
|
1987
|
-
text: `started background job ${value.jobId} (${args.spec} → profile "${args.profile ?? defaultProfile}");
|
|
2373
|
+
text: `started background job ${value.jobId} (${args.spec} → profile "${args.profile ?? defaultProfile}"); the preflight runs inside it, so poll job_output for the verdict and cancel with job_kill. Restart dsh after a successful install.`,
|
|
1988
2374
|
}],
|
|
1989
2375
|
},
|
|
2376
|
+
// 只做本地、同步、必须在返回 job id 之前失败的检查。任何需要 await 的
|
|
2377
|
+
// 步骤都在 createInstallJobProducer 里(见那里的注释):在这里 await,
|
|
2378
|
+
// 等于让工具在没有 job id、没有日志、job_kill 够不着的状态下干几十秒活。
|
|
1990
2379
|
async execute(args, exec) {
|
|
1991
2380
|
const profile = String(args.profile ?? defaultProfile).trim();
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2381
|
+
// profile 名非法要当场报错,而不是变成一个注定失败的后台 job——
|
|
2382
|
+
// 与 market_uninstall 一致。
|
|
2383
|
+
let installProfileDir;
|
|
2384
|
+
let profileExisted;
|
|
2385
|
+
try {
|
|
2386
|
+
// 顺便记下 profile 本来存不存在:预检会给尚未初始化的 profile 调
|
|
2387
|
+
// ensureProfile()(真的落盘 package.json 等文件),所以取消时那句
|
|
2388
|
+
// 「profile 从未被修改」对新建的 profile 并不成立。这里是唯一还能
|
|
2389
|
+
// 看到「动手之前」状态的位置。
|
|
2390
|
+
installProfileDir = resolveProfileDir(profile);
|
|
2391
|
+
profileExisted = existsSync(join(installProfileDir, "package.json"));
|
|
2392
|
+
} catch (error) {
|
|
2393
|
+
throw new Error(`market_install: invalid profile: ${error.message}`);
|
|
2394
|
+
}
|
|
2395
|
+
const spec = normalizeSpec(args.spec);
|
|
2396
|
+
assertSafeSpec(spec);
|
|
1997
2397
|
const allowBuildScripts = Array.isArray(args.allowBuildScripts)
|
|
1998
2398
|
? args.allowBuildScripts.map((name) => String(name))
|
|
1999
2399
|
: undefined;
|
|
@@ -2001,77 +2401,31 @@ export function apply(ctx, config = {}) {
|
|
|
2001
2401
|
? args.approvalToken.trim()
|
|
2002
2402
|
: undefined;
|
|
2003
2403
|
assertValidApprovalInvocation(allowBuildScripts, approvalToken);
|
|
2004
|
-
|
|
2005
|
-
const preflight = await runPreflight({ profile, spec });
|
|
2006
|
-
let acceptWarnings = false;
|
|
2007
|
-
let acceptWarningsActive = false;
|
|
2008
|
-
let approvedProof = undefined;
|
|
2404
|
+
// 审批归属必须在这里取:exec 是本次调用的门面,producer 里已经拿不到。
|
|
2009
2405
|
const agentOwner = requireAgentApprovalOwner(exec);
|
|
2010
|
-
if (approvalToken !== undefined) {
|
|
2011
|
-
const consumeResult = consumeApprovalToken({
|
|
2012
|
-
token: approvalToken,
|
|
2013
|
-
profile,
|
|
2014
|
-
profileDir: preflight.profileDir,
|
|
2015
|
-
spec,
|
|
2016
|
-
preflightReport: preflight.report,
|
|
2017
|
-
allowBuildScripts,
|
|
2018
|
-
surface: "agent",
|
|
2019
|
-
owner: agentOwner,
|
|
2020
|
-
});
|
|
2021
|
-
if (!consumeResult.valid) {
|
|
2022
|
-
throw new Error(`market_install: invalid approval token: ${consumeResult.reason}`);
|
|
2023
|
-
}
|
|
2024
|
-
acceptWarnings = consumeResult.warningConsent;
|
|
2025
|
-
acceptWarningsActive = consumeResult.warningConsent;
|
|
2026
|
-
approvedProof = consumeResult.proof;
|
|
2027
|
-
} else {
|
|
2028
|
-
acceptWarnings = args.acceptWarnings === true;
|
|
2029
|
-
acceptWarningsActive = acceptWarnings;
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
enforcePreflight(preflight.report, acceptWarnings, `market_install ${spec}`);
|
|
2033
|
-
pinPreflight(preflight.profileDir, spec);
|
|
2034
|
-
|
|
2035
|
-
const runProducer = () => {
|
|
2036
|
-
const producer = runInstall({ profile, spec, allowBuildScripts, approvedProof, preflight: preflight.report });
|
|
2037
|
-
const done = Promise.resolve(producer.done)
|
|
2038
|
-
.catch((error) => ({
|
|
2039
|
-
status: "failed",
|
|
2040
|
-
detail: `install of ${spec} hit an internal error: ${error?.message ?? String(error)}`,
|
|
2041
|
-
}))
|
|
2042
|
-
.then((outcome) => {
|
|
2043
|
-
const status = outcome?.status ?? "failed";
|
|
2044
|
-
if (status === "completed") {
|
|
2045
|
-
invalidatePreflightFor(preflight.profileDir);
|
|
2046
|
-
clearApprovalTokensFor(profile, spec);
|
|
2047
|
-
} else if (outcome?.needsApproval && outcome.needsApproval.length > 0) {
|
|
2048
|
-
clearApprovalTokensFor(profile, spec, { surface: "agent", owner: agentOwner });
|
|
2049
|
-
const token = issueApprovalToken({
|
|
2050
|
-
profile,
|
|
2051
|
-
profileDir: preflight.profileDir,
|
|
2052
|
-
spec,
|
|
2053
|
-
preflightReport: preflight.report,
|
|
2054
|
-
needsApproval: outcome.needsApproval,
|
|
2055
|
-
proof: outcome.proof,
|
|
2056
|
-
surface: "agent",
|
|
2057
|
-
owner: agentOwner,
|
|
2058
|
-
acceptWarningsActive,
|
|
2059
|
-
});
|
|
2060
|
-
outcome.approvalToken = token;
|
|
2061
|
-
outcome.detail = `${outcome.detail ?? ""}\n\nApproval token (pass to approvalToken on retry): ${token}`;
|
|
2062
|
-
} else {
|
|
2063
|
-
clearApprovalTokensFor(profile, spec, { surface: "agent", owner: agentOwner });
|
|
2064
|
-
}
|
|
2065
|
-
return outcome;
|
|
2066
|
-
});
|
|
2067
|
-
return { cancel: producer.cancel, done, readOutput: producer.readOutput };
|
|
2068
|
-
};
|
|
2069
2406
|
|
|
2407
|
+
// 刻意不把 exec.signal 接进 producer:它是这一次工具调用的取消信号,
|
|
2408
|
+
// 而这个调用马上就返回了。接上去等于 job 刚起就被 abort。后台任务的
|
|
2409
|
+
// 取消句柄是 job_kill → producer.cancel()。
|
|
2070
2410
|
const jobId = ctx.jobs.start({
|
|
2071
2411
|
kind: "dsh-plugin-install",
|
|
2412
|
+
// label 用归一后的 spec:防抢注解析要联网,属于 job 内部的事。
|
|
2072
2413
|
label: `dsh plugin --profile ${profile} add ${spec}`,
|
|
2073
2414
|
...exec.agent ? { owner: exec.agent } : {},
|
|
2074
|
-
run:
|
|
2415
|
+
run: () => createInstallJobProducer({
|
|
2416
|
+
profile,
|
|
2417
|
+
spec,
|
|
2418
|
+
acceptWarnings: args.acceptWarnings === true,
|
|
2419
|
+
reportDigest: typeof args.reportDigest === "string" ? args.reportDigest.trim() : undefined,
|
|
2420
|
+
allowBuildScripts,
|
|
2421
|
+
approvalToken,
|
|
2422
|
+
surface: "agent",
|
|
2423
|
+
owner: agentOwner,
|
|
2424
|
+
npmRegistry,
|
|
2425
|
+
rawSources,
|
|
2426
|
+
profileExisted,
|
|
2427
|
+
profileDir: installProfileDir,
|
|
2428
|
+
}),
|
|
2075
2429
|
});
|
|
2076
2430
|
return { kind: "background", jobId };
|
|
2077
2431
|
},
|
|
@@ -2512,7 +2866,21 @@ export async function runSelfTests() {
|
|
|
2512
2866
|
surface: "browser",
|
|
2513
2867
|
owner: "session-beta",
|
|
2514
2868
|
});
|
|
2515
|
-
|
|
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}`);
|
|
2516
2884
|
|
|
2517
2885
|
// 跨 surface (browser vs agent)
|
|
2518
2886
|
const agentProof = proofFor("agent-pkg");
|
|
@@ -2536,14 +2904,40 @@ export async function runSelfTests() {
|
|
|
2536
2904
|
surface: "browser",
|
|
2537
2905
|
owner: "browser-sess",
|
|
2538
2906
|
});
|
|
2539
|
-
check("跨 surface (agent vs browser) 消费审批 token
|
|
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}`);
|
|
2540
2920
|
|
|
2541
|
-
// Tracker 隔离与 session
|
|
2921
|
+
// Tracker 隔离与 session 校验。
|
|
2922
|
+
// 审批 token 由 producer 签发(createInstallJobProducer 是唯一签发者),
|
|
2923
|
+
// tracker 只把 outcome.approvalToken 摘到 record 上——这里的假 producer
|
|
2924
|
+
// 照真实流程先签好、挂在 outcome 里带出来。
|
|
2542
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
|
+
});
|
|
2543
2936
|
let needsApprovalOutcome = {
|
|
2544
2937
|
status: "needsApproval",
|
|
2545
2938
|
needsApproval: disclosureFor(trackerProof),
|
|
2546
2939
|
proof: trackerProof,
|
|
2940
|
+
approvalToken: trackerTok,
|
|
2547
2941
|
};
|
|
2548
2942
|
const approvalProducer = {
|
|
2549
2943
|
cancel: () => {},
|
|
@@ -2556,7 +2950,6 @@ export async function runSelfTests() {
|
|
|
2556
2950
|
const sessionJobId = sessionTracker.start({
|
|
2557
2951
|
profile: "web",
|
|
2558
2952
|
spec: "foo-script",
|
|
2559
|
-
profileDir,
|
|
2560
2953
|
surface: "browser",
|
|
2561
2954
|
session: "session-alpha",
|
|
2562
2955
|
});
|
|
@@ -2564,9 +2957,15 @@ export async function runSelfTests() {
|
|
|
2564
2957
|
|
|
2565
2958
|
const snapDiffSession = sessionTracker.get(sessionJobId, "session-beta").snapshot;
|
|
2566
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));
|
|
2567
2965
|
|
|
2568
2966
|
const snapSameSession = sessionTracker.get(sessionJobId, "session-alpha").snapshot;
|
|
2569
|
-
check("
|
|
2967
|
+
check("tracker 复制 producer 签发的 approvalToken(同 session 可见)",
|
|
2968
|
+
snapSameSession.approvalToken === trackerTok);
|
|
2570
2969
|
|
|
2571
2970
|
let cancelRefused = false;
|
|
2572
2971
|
try {
|
|
@@ -2859,7 +3258,6 @@ export async function runSelfTests() {
|
|
|
2859
3258
|
const jobId = tracker.start({
|
|
2860
3259
|
profile: "fixture-profile",
|
|
2861
3260
|
spec: "fail-pkg",
|
|
2862
|
-
profileDir,
|
|
2863
3261
|
onSettled: (outcome) => { settledOutcome = outcome; },
|
|
2864
3262
|
});
|
|
2865
3263
|
await new Promise((resolvePromise) => setImmediate(resolvePromise));
|
|
@@ -3042,6 +3440,695 @@ export async function runSelfTests() {
|
|
|
3042
3440
|
const rolledLog = quietLog();
|
|
3043
3441
|
runStartupRecovery("profile-a", { recover: () => ({ action: "rolled-back", reason: "静态校验未通过" }), log: rolledLog });
|
|
3044
3442
|
check("回滚路径播报原因", rolledLog.lines.some((line) => line.includes("rolled back") && line.includes("静态校验未通过")));
|
|
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
|
+
|
|
3717
|
+
// ── 13. market_install:整条链跑在 job 里(issue #8)────────────────────
|
|
3718
|
+
// 原来 registry 查询 → 防抢注解析 → 隔离预检全在 ctx.jobs.start() 之前 await,
|
|
3719
|
+
// 于是几十秒里没有 job id、没有日志、job_kill 够不着,而工具描述写的是
|
|
3720
|
+
// "returns a job id immediately"。这一组钉的是搬进 producer 之后的三条语义:
|
|
3721
|
+
// 拒绝是 job 的结局(不是异常)、通过才跑 pnpm、进行中能真的取消。
|
|
3722
|
+
// 注意:done 永不 reject 是硬约束,所以下面每条都直接 await done 拿结果。
|
|
3723
|
+
{
|
|
3724
|
+
// done 永不 reject 是这组的前提,所以每条都直接 await 它——可一旦回归让
|
|
3725
|
+
// done 干脆不结算,await 就会永远挂住,而挂住的 Node 是「事件循环空了」
|
|
3726
|
+
// 正常退出:退出码 0,`finished with N failures` 那行压根不打印,CI 全绿。
|
|
3727
|
+
// 所以每个 await 都套上超时,把「没结算」变成一条会红的断言。
|
|
3728
|
+
const settleWithin = async (promise, ms, label) => {
|
|
3729
|
+
let timer;
|
|
3730
|
+
const timeout = new Promise((resolveTimeout) => {
|
|
3731
|
+
timer = setTimeout(() => resolveTimeout({ status: `<${label}:${ms}ms 内没有结算>` }), ms);
|
|
3732
|
+
});
|
|
3733
|
+
try {
|
|
3734
|
+
return await Promise.race([promise, timeout]);
|
|
3735
|
+
} finally {
|
|
3736
|
+
clearTimeout(timer);
|
|
3737
|
+
}
|
|
3738
|
+
};
|
|
3739
|
+
const cleanReport = { verdict: "clean", summary: "无冲突", issues: [] };
|
|
3740
|
+
const seams = (overrides) => ({
|
|
3741
|
+
_registryFor: async () => "https://registry.npmjs.org",
|
|
3742
|
+
_preferNpmSpec: async ({ spec }) => spec,
|
|
3743
|
+
_assertSafeToInstall: async () => {},
|
|
3744
|
+
...overrides,
|
|
3745
|
+
});
|
|
3746
|
+
const neverInstall = (counter) => () => {
|
|
3747
|
+
counter.calls++;
|
|
3748
|
+
return { cancel: () => {}, done: Promise.resolve({ status: "completed" }), readOutput: () => "" };
|
|
3749
|
+
};
|
|
3750
|
+
|
|
3751
|
+
// 13a. 预检 blocker → failed 的 job,逐条 BLOCK 落在 detail 里,pnpm 不跑。
|
|
3752
|
+
const blockedInstalls = { calls: 0 };
|
|
3753
|
+
const blockedProducer = createInstallJobProducer({
|
|
3754
|
+
profile: "web",
|
|
3755
|
+
spec: "bad-pkg",
|
|
3756
|
+
agentOwner: "agent-selftest",
|
|
3757
|
+
...seams({
|
|
3758
|
+
_runPreflight: async ({ onOutput }) => {
|
|
3759
|
+
onOutput?.("probe log line\n");
|
|
3760
|
+
return {
|
|
3761
|
+
report: {
|
|
3762
|
+
verdict: "blocked",
|
|
3763
|
+
summary: "候选包会改坏这个 profile",
|
|
3764
|
+
issues: [{ severity: "block", title: "重复挂载", detail: "两行指向同一个模块" }],
|
|
3765
|
+
},
|
|
3766
|
+
profileDir,
|
|
3767
|
+
fingerprint: "fp-blocked",
|
|
3768
|
+
};
|
|
3769
|
+
},
|
|
3770
|
+
_runInstall: neverInstall(blockedInstalls),
|
|
3771
|
+
}),
|
|
3772
|
+
});
|
|
3773
|
+
const blockedOutcome = await settleWithin(blockedProducer.done, 5000, "blocker job");
|
|
3774
|
+
check("预检 blocker → job 结算为 failed(不是抛异常)", blockedOutcome?.status === "failed");
|
|
3775
|
+
check("预检 blocker → detail 带上逐条 BLOCK", /\[BLOCK\] 重复挂载/.test(blockedOutcome?.detail ?? ""));
|
|
3776
|
+
check("预检 blocker → pnpm 一次都不跑", blockedInstalls.calls === 0);
|
|
3777
|
+
const blockedLog = blockedProducer.readOutput();
|
|
3778
|
+
check("预检输出进入 job 日志(此前它根本不存在于任何 job)",
|
|
3779
|
+
blockedLog.includes("probe log line") && blockedLog.includes("预检结论:blocked"));
|
|
3780
|
+
|
|
3781
|
+
// 13a2. warning 未获用户确认,等价于拒绝——并且指明补 acceptWarnings。
|
|
3782
|
+
const warnInstalls = { calls: 0 };
|
|
3783
|
+
const warnOutcome = await settleWithin(createInstallJobProducer({
|
|
3784
|
+
profile: "web",
|
|
3785
|
+
spec: "warn-pkg",
|
|
3786
|
+
agentOwner: "agent-selftest",
|
|
3787
|
+
...seams({
|
|
3788
|
+
_runPreflight: async () => ({
|
|
3789
|
+
report: {
|
|
3790
|
+
verdict: "warning",
|
|
3791
|
+
summary: "有需要确认的改动",
|
|
3792
|
+
issues: [{ severity: "warn", title: "替换整块 config", detail: "sandbox-policy" }],
|
|
3793
|
+
},
|
|
3794
|
+
profileDir,
|
|
3795
|
+
fingerprint: "fp-warn",
|
|
3796
|
+
}),
|
|
3797
|
+
_runInstall: neverInstall(warnInstalls),
|
|
3798
|
+
}),
|
|
3799
|
+
}).done, 5000, "warning job");
|
|
3800
|
+
check("预检 warning 未确认 → failed 且提示 acceptWarnings",
|
|
3801
|
+
warnOutcome?.status === "failed" && /acceptWarnings: true/.test(warnOutcome?.detail ?? "") && warnInstalls.calls === 0);
|
|
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
|
+
|
|
3975
|
+
// 13b. 预检通过 → 进入安装。同时钉两件事:pnpm 拿到的是防抢注解析后的
|
|
3976
|
+
// spec(label 用的是归一 spec,两者可以不同),以及 readOutput 的顺序。
|
|
3977
|
+
let preflightSpec;
|
|
3978
|
+
let installedWith;
|
|
3979
|
+
const cleanProducer = createInstallJobProducer({
|
|
3980
|
+
profile: "web",
|
|
3981
|
+
spec: "owner/repo",
|
|
3982
|
+
agentOwner: "agent-selftest",
|
|
3983
|
+
...seams({
|
|
3984
|
+
_preferNpmSpec: async () => "resolved-pkg",
|
|
3985
|
+
_runPreflight: async ({ spec, onOutput }) => {
|
|
3986
|
+
preflightSpec = spec;
|
|
3987
|
+
onOutput?.("probe ok\n");
|
|
3988
|
+
return { report: cleanReport, profileDir, fingerprint: "fp-clean" };
|
|
3989
|
+
},
|
|
3990
|
+
_runInstall: (options) => {
|
|
3991
|
+
installedWith = options;
|
|
3992
|
+
const chunks = ["pnpm add output\n"];
|
|
3993
|
+
return {
|
|
3994
|
+
cancel: () => {},
|
|
3995
|
+
done: Promise.resolve({ status: "completed", detail: "installed" }),
|
|
3996
|
+
readOutput: () => chunks.splice(0).join(""),
|
|
3997
|
+
};
|
|
3998
|
+
},
|
|
3999
|
+
}),
|
|
4000
|
+
});
|
|
4001
|
+
const cleanOutcome = await settleWithin(cleanProducer.done, 5000, "clean job");
|
|
4002
|
+
check("预检通过 → 进入安装并结算 completed", cleanOutcome?.status === "completed");
|
|
4003
|
+
check("预检与 pnpm 都用防抢注解析后的 spec",
|
|
4004
|
+
preflightSpec === "resolved-pkg" && installedWith?.spec === "resolved-pkg");
|
|
4005
|
+
const cleanLog = cleanProducer.readOutput();
|
|
4006
|
+
check("readOutput 先排空预检缓冲、再接 install 输出",
|
|
4007
|
+
cleanLog.includes("pnpm add output")
|
|
4008
|
+
&& cleanLog.indexOf("probe ok") < cleanLog.indexOf("pnpm add output"));
|
|
4009
|
+
|
|
4010
|
+
// 13c. 进行中取消:预检还在跑就按 job_kill。预检必须收到 AbortSignal,
|
|
4011
|
+
// 结算为 killed 且明说 profile 没被动过,pnpm 阶段一步都不许进。
|
|
4012
|
+
const cancelInstalls = { calls: 0 };
|
|
4013
|
+
let preflightSignal;
|
|
4014
|
+
const cancelProducer = createInstallJobProducer({
|
|
4015
|
+
profile: "web",
|
|
4016
|
+
spec: "slow-pkg",
|
|
4017
|
+
agentOwner: "agent-selftest",
|
|
4018
|
+
...seams({
|
|
4019
|
+
_runPreflight: ({ signal }) => new Promise((_resolve, rejectPreflight) => {
|
|
4020
|
+
preflightSignal = signal;
|
|
4021
|
+
// 真实的 preflightInstall 在取消时抛 AbortError(guard.js),照抄。
|
|
4022
|
+
signal.addEventListener("abort", () => {
|
|
4023
|
+
const error = new Error("preflight cancelled");
|
|
4024
|
+
error.name = "AbortError";
|
|
4025
|
+
rejectPreflight(error);
|
|
4026
|
+
}, { once: true });
|
|
4027
|
+
}),
|
|
4028
|
+
_runInstall: neverInstall(cancelInstalls),
|
|
4029
|
+
}),
|
|
4030
|
+
});
|
|
4031
|
+
await new Promise((resolveTick) => setImmediate(resolveTick)); // 跑到预检那一步
|
|
4032
|
+
cancelProducer.cancel();
|
|
4033
|
+
cancelProducer.cancel(); // 幂等:面板/模型都可能连按两次
|
|
4034
|
+
const cancelOutcome = await settleWithin(cancelProducer.done, 5000, "取消后的 job");
|
|
4035
|
+
check("进行中取消 → 预检确实收到了 AbortSignal", preflightSignal?.aborted === true);
|
|
4036
|
+
check("进行中取消 → 结算为 killed", cancelOutcome?.status === "killed");
|
|
4037
|
+
check("进行中取消 → 明说 profile 未被改动", /never modified/.test(cancelOutcome?.detail ?? ""));
|
|
4038
|
+
check("进行中取消 → 不进入 pnpm 阶段", cancelInstalls.calls === 0);
|
|
4039
|
+
|
|
4040
|
+
// 13c2. profile 本来就不存在的情况。runPreflight 会先 ensureProfile(),
|
|
4041
|
+
// 那是真的落盘(package.json / cordis.patch.yml / pnpm-workspace.yaml),
|
|
4042
|
+
// 所以「the profile was never modified」对它是假话——用户会照着这句
|
|
4043
|
+
// 认定磁盘上什么都没多出来。
|
|
4044
|
+
const freshInstalls = { calls: 0 };
|
|
4045
|
+
const freshProducer = createInstallJobProducer({
|
|
4046
|
+
profile: "web",
|
|
4047
|
+
spec: "slow-pkg",
|
|
4048
|
+
agentOwner: "agent-selftest",
|
|
4049
|
+
profileExisted: false,
|
|
4050
|
+
profileDir, // 预检已经把它 ensureProfile 出来了(这个目录有 package.json)
|
|
4051
|
+
...seams({
|
|
4052
|
+
_runPreflight: ({ signal }) => new Promise((_resolve, rejectPreflight) => {
|
|
4053
|
+
signal.addEventListener("abort", () => {
|
|
4054
|
+
const error = new Error("preflight cancelled");
|
|
4055
|
+
error.name = "AbortError";
|
|
4056
|
+
rejectPreflight(error);
|
|
4057
|
+
}, { once: true });
|
|
4058
|
+
}),
|
|
4059
|
+
_runInstall: neverInstall(freshInstalls),
|
|
4060
|
+
}),
|
|
4061
|
+
});
|
|
4062
|
+
await new Promise((resolveTick) => setImmediate(resolveTick));
|
|
4063
|
+
freshProducer.cancel();
|
|
4064
|
+
const freshOutcome = await settleWithin(freshProducer.done, 5000, "未初始化 profile 的取消");
|
|
4065
|
+
check("未初始化 profile 取消 → 仍结算为 killed", freshOutcome?.status === "killed");
|
|
4066
|
+
check("未初始化 profile 取消 → 不谎称「从未修改」",
|
|
4067
|
+
!/never modified/.test(freshOutcome?.detail ?? ""));
|
|
4068
|
+
check("未初始化 profile 取消 → 如实说明 profile 已被初始化",
|
|
4069
|
+
/was initialized before the probe started/.test(freshOutcome?.detail ?? ""));
|
|
4070
|
+
check("未初始化 profile 取消 → 仍然不进入 pnpm 阶段", freshInstalls.calls === 0);
|
|
4071
|
+
|
|
4072
|
+
// 13c3. 取消发生在预检**之前**(registry 查询这一段)。ensureProfile()
|
|
4073
|
+
// 在 runPreflight 里,这时磁盘上一个字节都还没写,所以即便 profile
|
|
4074
|
+
// 本来不存在,也绝不能说「已经把它初始化了」——那会让用户去找一个
|
|
4075
|
+
// 根本不存在的目录。判据必须是磁盘的当前事实,不是开工前的快照。
|
|
4076
|
+
const earlyInstalls = { calls: 0 };
|
|
4077
|
+
let earlyPreflightCalls = 0;
|
|
4078
|
+
let releaseRegistry;
|
|
4079
|
+
const registryGate = new Promise((resolveGate) => { releaseRegistry = resolveGate; });
|
|
4080
|
+
const earlyProducer = createInstallJobProducer({
|
|
4081
|
+
profile: "web",
|
|
4082
|
+
spec: "slow-pkg",
|
|
4083
|
+
agentOwner: "agent-selftest",
|
|
4084
|
+
profileExisted: false,
|
|
4085
|
+
profileDir: join(root, "profile-that-was-never-created"),
|
|
4086
|
+
...seams({
|
|
4087
|
+
// 真实的 registryFor 不吃 signal(见 producer 注释),照此模拟:
|
|
4088
|
+
// 它跑完之后才轮到 throwIfAborted 生效。
|
|
4089
|
+
_registryFor: async () => { await registryGate; return "https://registry.npmjs.org"; },
|
|
4090
|
+
_runPreflight: () => { earlyPreflightCalls++; throw new Error("不该走到预检"); },
|
|
4091
|
+
_runInstall: neverInstall(earlyInstalls),
|
|
4092
|
+
}),
|
|
4093
|
+
});
|
|
4094
|
+
await new Promise((resolveTick) => setImmediate(resolveTick));
|
|
4095
|
+
earlyProducer.cancel(); // 还卡在 registry 查询里
|
|
4096
|
+
releaseRegistry();
|
|
4097
|
+
const earlyOutcome = await settleWithin(earlyProducer.done, 5000, "预检之前的取消");
|
|
4098
|
+
check("预检前取消 → 结算为 killed", earlyOutcome?.status === "killed");
|
|
4099
|
+
check("预检前取消 → 根本没进预检", earlyPreflightCalls === 0 && earlyInstalls.calls === 0);
|
|
4100
|
+
check("预检前取消 → 不谎称已初始化 profile(磁盘上什么都没建)",
|
|
4101
|
+
!/was initialized before the probe started/.test(earlyOutcome?.detail ?? "")
|
|
4102
|
+
&& /the profile was never modified/.test(earlyOutcome?.detail ?? ""));
|
|
4103
|
+
|
|
4104
|
+
// 13d. 审批 token 签发失败(这里用缺失的 proof 触发)。以前这段跑在
|
|
4105
|
+
// .then 里,一抛就把 done 变成 rejected —— 违反「done 必须不 reject」,
|
|
4106
|
+
// 而且会把「pnpm 拦下了安装脚本」这条真结论顶掉。
|
|
4107
|
+
let approvalRejected = false;
|
|
4108
|
+
const approvalOutcome = await settleWithin(createInstallJobProducer({
|
|
4109
|
+
profile: "web",
|
|
4110
|
+
spec: "scripty-pkg",
|
|
4111
|
+
agentOwner: "agent-selftest",
|
|
4112
|
+
...seams({
|
|
4113
|
+
_runPreflight: async () => ({ report: cleanReport, profileDir, fingerprint: "fp-scripty" }),
|
|
4114
|
+
_runInstall: () => ({
|
|
4115
|
+
cancel: () => {},
|
|
4116
|
+
done: Promise.resolve({
|
|
4117
|
+
status: "failed",
|
|
4118
|
+
detail: "installing scripty-pkg requires running install-time code — approval needed.",
|
|
4119
|
+
needsApproval: [{ name: "scripty-pkg", version: "1.0.0", scripts: { install: "node install.js" } }],
|
|
4120
|
+
// proof 缺失 → issueApprovalToken 必抛
|
|
4121
|
+
}),
|
|
4122
|
+
readOutput: () => "",
|
|
4123
|
+
}),
|
|
4124
|
+
}),
|
|
4125
|
+
}).done.catch(() => { approvalRejected = true; return undefined; }), 5000, "审批 token 签发失败的 job");
|
|
4126
|
+
check("token 签发失败不会把 done 变成 rejected", !approvalRejected && approvalOutcome !== undefined);
|
|
4127
|
+
check("token 签发失败 → 原结论保留", /requires running install-time code/.test(approvalOutcome?.detail ?? ""));
|
|
4128
|
+
check("token 签发失败 → detail 明说这次没法用 allowBuildScripts 重试",
|
|
4129
|
+
/no approval token could be issued/.test(approvalOutcome?.detail ?? ""));
|
|
4130
|
+
check("token 签发失败 → 不对外交出 approvalToken", approvalOutcome?.approvalToken === undefined);
|
|
4131
|
+
}
|
|
3045
4132
|
} finally {
|
|
3046
4133
|
rmSync(root, { recursive: true, force: true });
|
|
3047
4134
|
}
|
|
@@ -3051,10 +4138,20 @@ export async function runSelfTests() {
|
|
|
3051
4138
|
|
|
3052
4139
|
if (process.argv.includes("--self-test")) {
|
|
3053
4140
|
console.log("index.js self-test:");
|
|
4141
|
+
// 挂住的 suite 不会失败,会「成功」:await 一个永不结算的 promise 之后事件
|
|
4142
|
+
// 循环就空了,Node 正常退出,退出码 0,而 `finished with N failures` 那行
|
|
4143
|
+
// 根本没打印——CI 看到的是全绿。这个看门狗刻意不 unref(unref 掉就拦不住
|
|
4144
|
+
// 那次正常退出了),跑完由下面 clearTimeout 收掉。
|
|
4145
|
+
const watchdog = setTimeout(() => {
|
|
4146
|
+
console.error("index.js self-test: 超时未跑完——有 fixture 挂住了(producer 的 done 从未结算?)");
|
|
4147
|
+
process.exit(1);
|
|
4148
|
+
}, 120000);
|
|
3054
4149
|
runSelfTests().then((failed) => {
|
|
4150
|
+
clearTimeout(watchdog);
|
|
3055
4151
|
console.log(`index.js tests finished with ${failed} failures.`);
|
|
3056
4152
|
process.exit(failed === 0 ? 0 : 1);
|
|
3057
4153
|
}).catch((err) => {
|
|
4154
|
+
clearTimeout(watchdog);
|
|
3058
4155
|
console.error("Self-test threw:", err);
|
|
3059
4156
|
process.exit(1);
|
|
3060
4157
|
});
|