@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/package.json +1 -1
- package/src/client.js +8 -5
- package/src/github.js +44 -3
- package/src/index.js +804 -140
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1117
|
+
? factory({ profile, spec, verb })
|
|
992
1118
|
: verb === "remove"
|
|
993
1119
|
? runRemove({ profile, packageName: spec })
|
|
994
|
-
:
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
*
|
|
1288
|
-
*
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
|
|
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
|
|
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"
|
|
1300
|
-
|
|
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
|
-
*
|
|
1307
|
-
*
|
|
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
|
-
|
|
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
|
|
1413
|
-
owner
|
|
1569
|
+
surface,
|
|
1570
|
+
owner,
|
|
1414
1571
|
});
|
|
1415
1572
|
if (!consumeResult.valid) {
|
|
1416
|
-
return { status: "failed", detail: `
|
|
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
|
|
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
|
|
1454
|
-
owner
|
|
1614
|
+
surface,
|
|
1615
|
+
owner,
|
|
1455
1616
|
acceptWarningsActive,
|
|
1456
1617
|
});
|
|
1457
1618
|
outcome.approvalToken = token;
|
|
1458
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1793
|
-
|
|
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
|
-
|
|
1817
|
-
|
|
2004
|
+
// profile 名非法当场报错,与 market_uninstall / agent 工具一致——不是
|
|
2005
|
+
// 一个注定失败的后台 job。顺带取「动手之前」的磁盘状态:producer 的
|
|
2006
|
+
// 取消文案要靠它区分「profile 从未被动过」和「预检把不存在的 profile
|
|
2007
|
+
// 建出来了」。
|
|
2008
|
+
let installProfileDir;
|
|
2009
|
+
let profileExisted = true;
|
|
1818
2010
|
try {
|
|
1819
|
-
|
|
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
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
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
|
-
|
|
1875
|
-
preflight: preflight.report,
|
|
1876
|
-
profileDir: preflight.profileDir,
|
|
1877
|
-
acceptWarningsActive,
|
|
2048
|
+
approvalToken,
|
|
1878
2049
|
surface: "browser",
|
|
1879
|
-
session,
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
}
|
|
1886
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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;
|