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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -19,13 +19,16 @@ import { existsSync, readFileSync, realpathSync, mkdirSync, mkdtempSync, writeFi
19
19
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
20
20
  import { spawn } from "node:child_process";
21
21
  import { createHash, randomBytes } from "node:crypto";
22
+ import { EventEmitter } from "node:events";
23
+ import { performance } from "node:perf_hooks";
22
24
  import { fileURLToPath, pathToFileURL } from "node:url";
23
25
  import { createRequire } from "node:module";
24
26
  import { tmpdir } from "node:os";
25
27
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
26
28
  import { repoInfo, searchPlugins, verifyPlugins, cachedRepoManifest, fetchRawFile, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
27
29
  import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof, persistPluginDisabled } from "./installer.js";
28
- import { preflightInstall, inspectRemoteCandidate, recoverProfile, describeRollbackRebuild } from "./guard.js";
30
+ import { preflightInstall, inspectRemoteCandidate, recoverProfile, describeRollbackRebuild, isAbortError } from "./guard.js";
31
+ import { createRestartHelperReadyMessage, RESTART_HELPER_READY_TYPE, RESTART_RESPONSE_DRAIN_MS, superviseRestartHelper } from "./restart-protocol.js";
29
32
 
30
33
  export const name = "@1e0zj/dsh-plugin-mall";
31
34
  // `loader` 用来读装配树、并对单个 entry 做热开关(entry.update)。读法照抄
@@ -288,12 +291,10 @@ export function computeProfileFingerprint(profileDir) {
288
291
  const compatCache = new Map();
289
292
  const COMPAT_TTL = 600000;
290
293
 
291
- // When this plugin instance loaded — for a host process this is effectively the
292
- // host's start time (the loader mounts plugins at boot). Sent with `jobs` so a
293
- // remounted client can tell "completed, restart still pending" from "completed
294
- // and the restart already happened": a finishedAt older than this value means
295
- // the task's Restart-dsh button has already done its job.
296
- const pluginLoadedAt = Date.now();
294
+ // Node captures performance.timeOrigin once at process startup. It therefore
295
+ // stays byte-for-byte stable across Cordis remounts/HMR while still changing
296
+ // for a successor process, even if the wall clock is corrected backwards.
297
+ const hostProcessStartedAt = Math.floor(performance.timeOrigin);
297
298
 
298
299
  function compatCacheGet(key) {
299
300
  const cached = compatCache.get(key);
@@ -349,8 +350,15 @@ function pinPreflight(profileDir, spec) {
349
350
  /**
350
351
  * Run the isolated preflight for a resolved install spec, reusing a fresh
351
352
  * cache entry ONLY if the profile fingerprint matches.
353
+ *
354
+ * `signal` cancels the probe. Nothing extra is needed to protect the cache:
355
+ * preflightInstall THROWS an AbortError on cancellation instead of returning a
356
+ * report, so control never reaches preflightCache.set() below and a cancelled
357
+ * run leaves the cache exactly as it found it. (Had cancellation come back as
358
+ * a `blocked` report, that fabricated verdict would have been cached for the
359
+ * whole TTL and every later install of this spec refused with it.)
352
360
  */
353
- async function runPreflight({ profile, spec, force = false, onOutput }) {
361
+ async function runPreflight({ profile, spec, force = false, onOutput, signal }) {
354
362
  let profileDir;
355
363
  try {
356
364
  profileDir = resolveProfileDir(profile);
@@ -376,7 +384,7 @@ async function runPreflight({ profile, spec, force = false, onOutput }) {
376
384
  return { report: validCached.report, profileDir, fingerprint: currentFingerprint };
377
385
  }
378
386
 
379
- const report = await preflightInstall({ profileDir, spec, onOutput });
387
+ const report = await preflightInstall({ profileDir, spec, onOutput, signal });
380
388
  preflightCache.set(key, {
381
389
  report,
382
390
  fingerprint: currentFingerprint,
@@ -923,6 +931,23 @@ export function restartLogPath(profileDir, profile) {
923
931
  return join(dirname(dirname(profileDir)), "guard", `restart-${profile}.log`);
924
932
  }
925
933
 
934
+ function appendRestartDiagnostic(logPath, message) {
935
+ const line = `[dsh-plugin-mall] ${message}\n`;
936
+ console.error(line.trimEnd());
937
+ if (logPath === undefined) return;
938
+ let fd;
939
+ try {
940
+ fd = openSync(logPath, "a");
941
+ writeSync(fd, line);
942
+ } catch {
943
+ /* the console diagnostic is still useful */
944
+ } finally {
945
+ if (fd !== undefined) {
946
+ try { closeSync(fd); } catch { /* already closed */ }
947
+ }
948
+ }
949
+ }
950
+
926
951
  // ── in-process job tracker for browser RPC ───────────────────────────────────
927
952
 
928
953
  let trackerCounter = 0;
@@ -1257,22 +1282,228 @@ function renderPreflightIssue(entry) {
1257
1282
  }
1258
1283
 
1259
1284
  /**
1260
- * Enforce a preflight verdict for an install. Throws when a blocker exists, or
1261
- * when there are only warnings and acceptWarnings is not true.
1285
+ * Why this install must not proceed or undefined when it may.
1286
+ *
1287
+ * Split out of enforcePreflight() because the two call sites need the verdict
1288
+ * in two different shapes. The browser RPC decides BEFORE any job exists, so
1289
+ * an exception is the right carrier: it aborts the request and becomes an
1290
+ * rpcFail. The agent tool now decides INSIDE the job, where an exception is
1291
+ * exactly the wrong carrier — a producer's `done` must never reject, and "the
1292
+ * preflight refused this candidate" is not an internal error but the job's
1293
+ * legitimate outcome, so it has to travel as text on a `failed` outcome.
1262
1294
  */
1263
- function enforcePreflight(report, acceptWarnings, label) {
1295
+ function preflightRefusal(report, acceptWarnings, label) {
1264
1296
  if (report.verdict === "blocked") {
1265
- const error = new Error(`${label}: ${report.summary}\n${report.issues.filter((entry) => entry.severity === "block").map(renderPreflightIssue).join("\n")}`);
1266
- error.preflight = report;
1267
- throw error;
1297
+ return `${label}: ${report.summary}\n${report.issues.filter((entry) => entry.severity === "block").map(renderPreflightIssue).join("\n")}`;
1268
1298
  }
1269
1299
  if (report.verdict === "warning" && acceptWarnings !== true) {
1270
- const error = new Error(`${label}: ${report.summary}\n${report.issues.filter((entry) => entry.severity === "warn").map(renderPreflightIssue).join("\n")}\n\nTo continue, show these warnings to the user and, after their explicit confirmation, call again with acceptWarnings: true.`);
1300
+ return `${label}: ${report.summary}\n${report.issues.filter((entry) => entry.severity === "warn").map(renderPreflightIssue).join("\n")}\n\nTo continue, show these warnings to the user and, after their explicit confirmation, call again with acceptWarnings: true.`;
1301
+ }
1302
+ return undefined;
1303
+ }
1304
+
1305
+ /**
1306
+ * Enforce a preflight verdict for an install. Throws when a blocker exists, or
1307
+ * when there are only warnings and acceptWarnings is not true.
1308
+ */
1309
+ function enforcePreflight(report, acceptWarnings, label) {
1310
+ const refusal = preflightRefusal(report, acceptWarnings, label);
1311
+ if (refusal !== undefined) {
1312
+ const error = new Error(refusal);
1271
1313
  error.preflight = report;
1272
1314
  throw error;
1273
1315
  }
1274
1316
  }
1275
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.
1322
+ *
1323
+ * Why it all lives in here (issue #8): `ctx.jobs.start()` treats `run()` as a
1324
+ * synchronous start boundary, so anything awaited before that call happens
1325
+ * outside the task runtime. The old market_install awaited the registry query,
1326
+ * the anti-squatting resolve and the isolated probe install first — tens of
1327
+ * seconds during which the tool call had not returned, no job id existed, the
1328
+ * work appeared in no job log, and `job_kill` had nothing to kill. The tool's
1329
+ * own description promised the opposite ("ALWAYS runs as a background job:
1330
+ * the call returns a job id immediately"). Moving the chain in here makes that
1331
+ * promise true and, as a side effect, gives the slow phases a kill handle.
1332
+ *
1333
+ * One phase is deliberately NOT cancellable: the registry lookup. `resolveRegistry`
1334
+ * caches a PROMISE per profile, so threading the signal into it would leave a
1335
+ * permanently rejected promise in that cache after one cancel — every later
1336
+ * registry query for the profile would fail, and unlike the npmPackageInfo cache
1337
+ * it never expires. That is the same cache-poisoning bug this change set exists
1338
+ * to remove, traded for at most the few seconds `pnpm config get registry` takes
1339
+ * on a cold profile. So cancellation there lands on the throwIfAborted() right
1340
+ * after it instead.
1341
+ *
1342
+ * Two invariants this shape has to keep:
1343
+ * - `done` NEVER rejects. It is the single settlement path the jobs runtime
1344
+ * consumes, so a refusal, a cancellation and an internal error all come
1345
+ * back as ordinary outcome objects.
1346
+ * - `cancel()` is synchronous and idempotent. It aborts the preflight phase
1347
+ * through the AbortController and, once pnpm owns the profile, hands over
1348
+ * to the installer's own cancel (which tree-kills and waits for 'close'
1349
+ * before rolling back).
1350
+ *
1351
+ * The `_`-prefixed parameters are test seams only; production passes none.
1352
+ */
1353
+ function createInstallJobProducer({
1354
+ profile,
1355
+ spec: requestedSpec,
1356
+ acceptWarnings: acceptWarningsRequested = false,
1357
+ allowBuildScripts,
1358
+ approvalToken,
1359
+ agentOwner,
1360
+ npmRegistry = "",
1361
+ rawSources = [],
1362
+ profileExisted = true,
1363
+ profileDir: requestedProfileDir,
1364
+ _registryFor = registryFor,
1365
+ _preferNpmSpec = preferNpmSpec,
1366
+ _assertSafeToInstall = assertSafeToInstall,
1367
+ _runPreflight = runPreflight,
1368
+ _runInstall = runInstall,
1369
+ }) {
1370
+ const controller = new AbortController();
1371
+ const { signal } = controller;
1372
+ let inner; // the runInstall producer — only exists once pnpm is about to run
1373
+ let cancelled = false;
1374
+
1375
+ // 预检阶段没有 inner 可以问,它的输出先攒在这里。
1376
+ const preflightChunks = [];
1377
+ const pushPreflight = (text) => { preflightChunks.push(String(text ?? "")); };
1378
+
1379
+ const cancel = () => {
1380
+ if (cancelled) return; // 幂等:job_kill 可能被按多次,abort 也只该发生一次
1381
+ cancelled = true;
1382
+ controller.abort(); // 预检阶段:掐断 registry 请求与探针 pnpm
1383
+ inner?.cancel(); // 安装阶段:交给 installer 的 tree-kill + 回滚
1384
+ };
1385
+
1386
+ const done = (async () => {
1387
+ const registry = await _registryFor(profile, npmRegistry);
1388
+ signal.throwIfAborted();
1389
+ // 防抢注解析可能把 owner/repo 换成 npm 包名,后面每一步(预检、token
1390
+ // 比对、pnpm)都必须用这个解析后的 spec,否则重试时 token 的 spec 对不上。
1391
+ const spec = await _preferNpmSpec({ spec: requestedSpec, registry, sources: rawSources, signal });
1392
+ signal.throwIfAborted();
1393
+ await _assertSafeToInstall({ spec, registry, sources: rawSources, signal });
1394
+ signal.throwIfAborted();
1395
+
1396
+ pushPreflight(`[dsh-plugin-mall] 预检 ${spec}:隔离目录探装(脚本禁用)\n`);
1397
+ const preflight = await _runPreflight({ profile, spec, onOutput: pushPreflight, signal });
1398
+ signal.throwIfAborted(); // 命中缓存时预检不会自己抛,这里补一次取消检查
1399
+ pushPreflight(`[dsh-plugin-mall] 预检结论:${preflight.report.verdict}\n`);
1400
+
1401
+ let acceptWarnings = false;
1402
+ let acceptWarningsActive = false;
1403
+ let approvedProof;
1404
+ if (approvalToken !== undefined) {
1405
+ const consumeResult = consumeApprovalToken({
1406
+ token: approvalToken,
1407
+ profile,
1408
+ profileDir: preflight.profileDir,
1409
+ spec,
1410
+ preflightReport: preflight.report,
1411
+ allowBuildScripts,
1412
+ surface: "agent",
1413
+ owner: agentOwner,
1414
+ });
1415
+ if (!consumeResult.valid) {
1416
+ return { status: "failed", detail: `market_install: invalid approval token: ${consumeResult.reason}` };
1417
+ }
1418
+ acceptWarnings = consumeResult.warningConsent;
1419
+ acceptWarningsActive = consumeResult.warningConsent;
1420
+ approvedProof = consumeResult.proof;
1421
+ } else {
1422
+ acceptWarnings = acceptWarningsRequested === true;
1423
+ acceptWarningsActive = acceptWarnings;
1424
+ }
1425
+
1426
+ const refusal = preflightRefusal(preflight.report, acceptWarnings, `market_install ${spec}`);
1427
+ if (refusal !== undefined) {
1428
+ // 拒绝是这个 job 的正常结局,不是异常:作为 failed 的 detail 回去,
1429
+ // 模型从 job_output 就能读到逐条 BLOCK/WARN。
1430
+ return { status: "failed", detail: refusal };
1431
+ }
1432
+
1433
+ pinPreflight(preflight.profileDir, spec);
1434
+ signal.throwIfAborted(); // 从这行往后,取消归 installer 管
1435
+ inner = _runInstall({ profile, spec, allowBuildScripts, approvedProof, preflight: preflight.report });
1436
+ if (signal.aborted) inner.cancel(); // 上一行之前就取消过的话,补一次转交
1437
+ const outcome = await inner.done; // installer 的 done 同样永不 reject
1438
+
1439
+ const status = outcome?.status ?? "failed";
1440
+ if (status === "completed") {
1441
+ invalidatePreflightFor(preflight.profileDir);
1442
+ clearApprovalTokensFor(profile, spec);
1443
+ } else if (outcome?.needsApproval && outcome.needsApproval.length > 0) {
1444
+ clearApprovalTokensFor(profile, spec, { surface: "agent", owner: agentOwner });
1445
+ try {
1446
+ const token = issueApprovalToken({
1447
+ profile,
1448
+ profileDir: preflight.profileDir,
1449
+ spec,
1450
+ preflightReport: preflight.report,
1451
+ needsApproval: outcome.needsApproval,
1452
+ proof: outcome.proof,
1453
+ surface: "agent",
1454
+ owner: agentOwner,
1455
+ acceptWarningsActive,
1456
+ });
1457
+ outcome.approvalToken = token;
1458
+ outcome.detail = `${outcome.detail ?? ""}\n\nApproval token (pass to approvalToken on retry): ${token}`;
1459
+ } catch (error) {
1460
+ // 签发会因为凭证不完整(proof 缺失/不匹配)抛错。以前这段跑在 `.then`
1461
+ // 里,抛出去就把 done 变成 rejected —— 官方明说 done 必须不 reject,
1462
+ // 而且那样一来「pnpm 拦下了安装脚本」这条真正的结论会被一条内部错误
1463
+ // 顶掉。改成写进 detail:结论照常送达,同时明说这次没法重试。
1464
+ outcome.detail = `${outcome.detail ?? ""}\n\nNOTE: no approval token could be issued (${error?.message ?? String(error)}), so allowBuildScripts cannot be used to retry this run — start a fresh market_install instead.`;
1465
+ }
1466
+ } else {
1467
+ clearApprovalTokensFor(profile, spec, { surface: "agent", owner: agentOwner });
1468
+ }
1469
+ return outcome;
1470
+ })().catch((error) => {
1471
+ if (isAbortError(error)) {
1472
+ // 取消时探装全在临时目录里,正式 profile 没被装进任何东西。唯一的例外
1473
+ // 是 profile 本来就不存在——预检会先 ensureProfile() 把它建出来
1474
+ // (package.json / cordis.patch.yml / pnpm-workspace.yaml 真的落盘)。
1475
+ //
1476
+ // 「本来不存在」不等于「我们建了」:ensureProfile() 在 runPreflight 里,
1477
+ // 而取消可能发生在更早的 registry 查询、防抢注解析或宿主遮蔽检查阶段,
1478
+ // 那时磁盘上一个字节都还没写。所以这里查磁盘的当前事实,而不是拿开工前
1479
+ // 的快照去推断——推断会随着链路上再加一步就悄悄失真,实地检查不会。
1480
+ const profileCreatedHere = profileExisted === false
1481
+ && requestedProfileDir !== undefined
1482
+ && existsSync(join(requestedProfileDir, "package.json"));
1483
+ return {
1484
+ status: "killed",
1485
+ detail: profileCreatedHere
1486
+ ? `install of ${requestedSpec} was cancelled during preflight — no packages were installed, but the profile did not exist and was initialized before the probe started`
1487
+ : `install of ${requestedSpec} was cancelled during preflight — the profile was never modified`,
1488
+ };
1489
+ }
1490
+ return { status: "failed", detail: `install of ${requestedSpec} hit an error: ${error?.message ?? String(error)}` };
1491
+ });
1492
+
1493
+ return {
1494
+ cancel,
1495
+ done,
1496
+ // 顺序依赖:预检阶段与安装阶段严格先后,上面的 async 体在 _runInstall
1497
+ // 之前不会再往 preflightChunks 里写。所以「先排空缓冲、再问 inner」得到的
1498
+ // 就是真实时间顺序;两个阶段若哪天并行了,这里必须改成带时间戳的合并。
1499
+ readOutput: () => {
1500
+ const buffered = preflightChunks.length === 0 ? "" : preflightChunks.splice(0).join("");
1501
+ const live = typeof inner?.readOutput === "function" ? inner.readOutput() : "";
1502
+ return buffered + live;
1503
+ },
1504
+ };
1505
+ }
1506
+
1276
1507
  /** Clip long strings for compact model-facing output. */
1277
1508
  function clip(text, max) {
1278
1509
  const trimmed = String(text ?? "").replace(/\s+/g, " ").trim();
@@ -1713,7 +1944,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1713
1944
  // dropping all React state. The task records live here.
1714
1945
  try {
1715
1946
  const session = requireBrowserSession(payload?.session);
1716
- return rpcOk({ jobs: tracker.list(session), hostStartedAt: pluginLoadedAt });
1947
+ return rpcOk({ jobs: tracker.list(session), hostStartedAt: hostProcessStartedAt });
1717
1948
  } catch (error) {
1718
1949
  return rpcFail(error);
1719
1950
  }
@@ -1727,6 +1958,12 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1727
1958
  }
1728
1959
  const plan = resolveRestartLaunchPlan({ profile, config });
1729
1960
  if (!plan.ok) {
1961
+ let diagnosticPath;
1962
+ try {
1963
+ diagnosticPath = restartLogPath(resolveProfileDir(profile), profile);
1964
+ mkdirSync(dirname(diagnosticPath), { recursive: true });
1965
+ } catch { /* invalid profile/home: console remains the diagnostic sink */ }
1966
+ appendRestartDiagnostic(diagnosticPath, `restart plan rejected: ${plan.error}; old Host remains running`);
1730
1967
  return rpcFail(new Error(plan.error));
1731
1968
  }
1732
1969
  // Everything the restart prints goes to a file. `stdio: "ignore"` used to
@@ -1745,17 +1982,50 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1745
1982
  console.error(`[dsh-plugin-mall] restart log unavailable (${error.message}); continuing without it`);
1746
1983
  logFd = undefined;
1747
1984
  }
1748
- const child = spawn(plan.nodePath, plan.args, {
1749
- shell: false,
1750
- detached: true,
1751
- stdio: logFd === undefined ? "ignore" : ["ignore", logFd, logFd],
1752
- cwd: process.cwd(),
1753
- windowsHide: true,
1985
+ let child;
1986
+ try {
1987
+ child = spawn(plan.nodePath, plan.args, {
1988
+ shell: false,
1989
+ detached: true,
1990
+ // fd 3 is an IPC channel used only for the readiness handshake. An
1991
+ // old/incompatible CLI either exits on --await-exit or times out; it
1992
+ // can never make the current Host leave merely by spawning.
1993
+ stdio: ["ignore", logFd ?? "ignore", logFd ?? "ignore", "ipc"],
1994
+ cwd: process.cwd(),
1995
+ windowsHide: true,
1996
+ });
1997
+ } catch (error) {
1998
+ if (logFd !== undefined) closeSync(logFd);
1999
+ appendRestartDiagnostic(logPath, `restart helper could not be spawned: ${error.message}; old Host remains running`);
2000
+ return rpcFail(new Error(`automatic restart helper could not be spawned; the current dsh is still running${logPath ? ` (see ${logPath})` : ""}`));
2001
+ }
2002
+ if (logFd !== undefined) closeSync(logFd); // the child holds its own duplicates
2003
+
2004
+ const handoff = superviseRestartHelper(child, {
2005
+ awaitExitPid: plan.awaitExitPid,
2006
+ onFailure: (message) => appendRestartDiagnostic(logPath, `${message}; old Host remains running`),
1754
2007
  });
1755
- child.unref();
1756
- if (logFd !== undefined) closeSync(logFd); // the child holds its own duplicate
1757
- setTimeout(() => process.exit(0), 1000);
1758
- return rpcOk({ restarting: true, logPath });
2008
+ let disposeHandoffEffect;
2009
+ try {
2010
+ // Cordis runs this disposer on HMR/config unload. In particular, the
2011
+ // process-exit timer can no longer outlive the plugin instance that
2012
+ // created it and kill the Host one second later.
2013
+ disposeHandoffEffect = ctx.effect(
2014
+ () => handoff.dispose,
2015
+ "@1e0zj/dsh-plugin-mall: restart handoff",
2016
+ );
2017
+ } catch (error) {
2018
+ handoff.dispose();
2019
+ appendRestartDiagnostic(logPath, `restart handoff could not join the plugin lifecycle: ${error.message}; old Host remains running`);
2020
+ return rpcFail(new Error("automatic restart was cancelled because the marketplace plugin is unloading; the current dsh is still running"));
2021
+ }
2022
+
2023
+ const accepted = await handoff.ready;
2024
+ if (!accepted.ok) {
2025
+ await disposeHandoffEffect();
2026
+ return rpcFail(new Error(`${accepted.error}; the current dsh is still running${logPath ? ` (see ${logPath})` : ""}`));
2027
+ }
2028
+ return rpcOk({ restarting: true, handoffAccepted: true, logPath });
1759
2029
  }
1760
2030
  case "jobCancel": {
1761
2031
  try {
@@ -1816,7 +2086,7 @@ export function apply(ctx, config = {}) {
1816
2086
  ctx.systemPrompt.section({
1817
2087
  name: "tool:market",
1818
2088
  order: 120,
1819
- text: "The dsh plugin marketplace tools are available: market_search discovers plugins on the GitHub dsh-plugin topic, market_info inspects one repository, market_install installs a plugin into a dsh profile as a background job (poll with job_output), market_uninstall removes an installed plugin from a dsh profile as a background job, and market_installed lists a profile's plugins. A successful market_install or market_uninstall only takes effect after the dsh process restarts — remind the user to restart. Prefer plugins with meaningful stars and a dsh.bundle declaration (market_info shows both). market_install runs an isolated preflight before installing (the candidate is probed with install scripts disabled and scanned for conflicts); a blocker refuses the install and warnings require acceptWarnings: true after the user confirms them — never set it on the user's behalf. If market_install stops for install-script approval, that decision is also the user's: show them the reported package names and commands and wait for an answer — never approve on their behalf.",
2089
+ text: "The dsh plugin marketplace tools are available: market_search discovers plugins on the GitHub dsh-plugin topic, market_info inspects one repository, market_install installs a plugin into a dsh profile as a background job (poll with job_output), market_uninstall removes an installed plugin from a dsh profile as a background job, and market_installed lists a profile's plugins. A successful market_install or market_uninstall only takes effect after the dsh process restarts — remind the user to restart. Prefer plugins with meaningful stars and a dsh.bundle declaration (market_info shows both). market_install runs an isolated preflight before installing (the candidate is probed with install scripts disabled and scanned for conflicts); that preflight runs inside the background job, so its verdict — including a refusal — arrives through job_output rather than as an immediate error from the call. A blocker refuses the install and warnings require acceptWarnings: true after the user confirms them — never set it on the user's behalf. If market_install stops for install-script approval, that decision is also the user's: show them the reported package names and commands and wait for an answer — never approve on their behalf.",
1820
2090
  });
1821
2091
 
1822
2092
  ctx.tools.register(defineTool({
@@ -1894,7 +2164,7 @@ export function apply(ctx, config = {}) {
1894
2164
 
1895
2165
  ctx.tools.register(defineTool({
1896
2166
  name: "market_install",
1897
- description: "Install a plugin into a local dsh profile by running `pnpm add` in that profile's directory, reconciling the profile's bundle layer list, and — for browser-side UI plugins (`dsh.client`) — registering a loader row in the profile's cordis.patch.yml. Same flow as `dsh plugin --profile <name> add <spec>`. ALWAYS runs as a background job: the call returns a job id immediately; poll with job_output and cancel with job_kill. The install is gated by an isolated preflight (the candidate is installed with scripts disabled into a throwaway directory and scanned for manifest/patch conflicts, host-module shadowing, and version/OS incompatibilities). A blocker refuses the install outright; a warning requires the USER's explicit consent via `acceptWarnings: true` — show the reported warnings verbatim and get their answer first, never consent on their behalf. If pnpm blocks a dependency's install scripts, the job STOPS and reports which packages want to run install-time code, what those commands are, whether each is the plugin itself or a transitive dependency, and issues a one-shot approval token. Relay that list to the user verbatim, and only call again with `allowBuildScripts` naming the packages they approved along with `approvalToken`. A successful install only takes effect after the dsh process restarts.",
2167
+ description: "Install a plugin into a local dsh profile by running `pnpm add` in that profile's directory, reconciling the profile's bundle layer list, and — for browser-side UI plugins (`dsh.client`) — registering a loader row in the profile's cordis.patch.yml. Same flow as `dsh plugin --profile <name> add <spec>`. ALWAYS runs as a background job, and the call itself does almost nothing: it validates the profile name, the spec and the approval arguments, then returns a job id. Everything slow happens INSIDE the job resolving the spec against the npm registry, the host-module shadowing check, the isolated preflight (the candidate is installed with scripts disabled into a throwaway directory and scanned for manifest/patch conflicts and version/OS incompatibilities), and pnpm itself. Poll with job_output; cancel with job_kill (a cancel during the preflight leaves the profile untouched). Because the preflight runs inside the job, ITS VERDICT ARRIVES AS THE JOB'S OUTCOME, not as an error from this call: a blocker, or a warning the user has not confirmed, ends the job as `failed` with the individual issues in its detail read them there and relay them verbatim. A warning is cleared by calling again with `acceptWarnings: true`, and only after the USER has explicitly confirmed it; never consent on their behalf. If pnpm blocks a dependency's install scripts, the job STOPS and reports which packages want to run install-time code, what those commands are, whether each is the plugin itself or a transitive dependency, and issues a one-shot approval token. Relay that list to the user verbatim, and only call again with `allowBuildScripts` naming the packages they approved along with `approvalToken`. A successful install only takes effect after the dsh process restarts.",
1898
2168
  parameters: {
1899
2169
  spec: {
1900
2170
  type: "string",
@@ -1927,16 +2197,30 @@ export function apply(ctx, config = {}) {
1927
2197
  },
1928
2198
  render: (args, value) => [{
1929
2199
  type: "text",
1930
- text: `started background job ${value.jobId} (${args.spec} → profile "${args.profile ?? defaultProfile}"); poll with job_output, cancel with job_kill. Restart dsh after a successful install.`,
2200
+ 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.`,
1931
2201
  }],
1932
2202
  },
2203
+ // 只做本地、同步、必须在返回 job id 之前失败的检查。任何需要 await 的
2204
+ // 步骤都在 createInstallJobProducer 里(见那里的注释):在这里 await,
2205
+ // 等于让工具在没有 job id、没有日志、job_kill 够不着的状态下干几十秒活。
1933
2206
  async execute(args, exec) {
1934
2207
  const profile = String(args.profile ?? defaultProfile).trim();
1935
- const normalized = normalizeSpec(args.spec);
1936
- assertSafeSpec(normalized);
1937
- const registry = await registryFor(profile, npmRegistry);
1938
- const spec = await preferNpmSpec({ spec: normalized, registry, sources: rawSources });
1939
- await assertSafeToInstall({ spec, registry, sources: rawSources });
2208
+ // profile 名非法要当场报错,而不是变成一个注定失败的后台 job——
2209
+ // 与 market_uninstall 一致。
2210
+ let installProfileDir;
2211
+ let profileExisted;
2212
+ try {
2213
+ // 顺便记下 profile 本来存不存在:预检会给尚未初始化的 profile 调
2214
+ // ensureProfile()(真的落盘 package.json 等文件),所以取消时那句
2215
+ // 「profile 从未被修改」对新建的 profile 并不成立。这里是唯一还能
2216
+ // 看到「动手之前」状态的位置。
2217
+ installProfileDir = resolveProfileDir(profile);
2218
+ profileExisted = existsSync(join(installProfileDir, "package.json"));
2219
+ } catch (error) {
2220
+ throw new Error(`market_install: invalid profile: ${error.message}`);
2221
+ }
2222
+ const spec = normalizeSpec(args.spec);
2223
+ assertSafeSpec(spec);
1940
2224
  const allowBuildScripts = Array.isArray(args.allowBuildScripts)
1941
2225
  ? args.allowBuildScripts.map((name) => String(name))
1942
2226
  : undefined;
@@ -1944,77 +2228,29 @@ export function apply(ctx, config = {}) {
1944
2228
  ? args.approvalToken.trim()
1945
2229
  : undefined;
1946
2230
  assertValidApprovalInvocation(allowBuildScripts, approvalToken);
1947
-
1948
- const preflight = await runPreflight({ profile, spec });
1949
- let acceptWarnings = false;
1950
- let acceptWarningsActive = false;
1951
- let approvedProof = undefined;
2231
+ // 审批归属必须在这里取:exec 是本次调用的门面,producer 里已经拿不到。
1952
2232
  const agentOwner = requireAgentApprovalOwner(exec);
1953
- if (approvalToken !== undefined) {
1954
- const consumeResult = consumeApprovalToken({
1955
- token: approvalToken,
1956
- profile,
1957
- profileDir: preflight.profileDir,
1958
- spec,
1959
- preflightReport: preflight.report,
1960
- allowBuildScripts,
1961
- surface: "agent",
1962
- owner: agentOwner,
1963
- });
1964
- if (!consumeResult.valid) {
1965
- throw new Error(`market_install: invalid approval token: ${consumeResult.reason}`);
1966
- }
1967
- acceptWarnings = consumeResult.warningConsent;
1968
- acceptWarningsActive = consumeResult.warningConsent;
1969
- approvedProof = consumeResult.proof;
1970
- } else {
1971
- acceptWarnings = args.acceptWarnings === true;
1972
- acceptWarningsActive = acceptWarnings;
1973
- }
1974
-
1975
- enforcePreflight(preflight.report, acceptWarnings, `market_install ${spec}`);
1976
- pinPreflight(preflight.profileDir, spec);
1977
-
1978
- const runProducer = () => {
1979
- const producer = runInstall({ profile, spec, allowBuildScripts, approvedProof, preflight: preflight.report });
1980
- const done = Promise.resolve(producer.done)
1981
- .catch((error) => ({
1982
- status: "failed",
1983
- detail: `install of ${spec} hit an internal error: ${error?.message ?? String(error)}`,
1984
- }))
1985
- .then((outcome) => {
1986
- const status = outcome?.status ?? "failed";
1987
- if (status === "completed") {
1988
- invalidatePreflightFor(preflight.profileDir);
1989
- clearApprovalTokensFor(profile, spec);
1990
- } else if (outcome?.needsApproval && outcome.needsApproval.length > 0) {
1991
- clearApprovalTokensFor(profile, spec, { surface: "agent", owner: agentOwner });
1992
- const token = issueApprovalToken({
1993
- profile,
1994
- profileDir: preflight.profileDir,
1995
- spec,
1996
- preflightReport: preflight.report,
1997
- needsApproval: outcome.needsApproval,
1998
- proof: outcome.proof,
1999
- surface: "agent",
2000
- owner: agentOwner,
2001
- acceptWarningsActive,
2002
- });
2003
- outcome.approvalToken = token;
2004
- outcome.detail = `${outcome.detail ?? ""}\n\nApproval token (pass to approvalToken on retry): ${token}`;
2005
- } else {
2006
- clearApprovalTokensFor(profile, spec, { surface: "agent", owner: agentOwner });
2007
- }
2008
- return outcome;
2009
- });
2010
- return { cancel: producer.cancel, done, readOutput: producer.readOutput };
2011
- };
2012
2233
 
2234
+ // 刻意不把 exec.signal 接进 producer:它是这一次工具调用的取消信号,
2235
+ // 而这个调用马上就返回了。接上去等于 job 刚起就被 abort。后台任务的
2236
+ // 取消句柄是 job_kill → producer.cancel()。
2013
2237
  const jobId = ctx.jobs.start({
2014
2238
  kind: "dsh-plugin-install",
2239
+ // label 用归一后的 spec:防抢注解析要联网,属于 job 内部的事。
2015
2240
  label: `dsh plugin --profile ${profile} add ${spec}`,
2016
2241
  ...exec.agent ? { owner: exec.agent } : {},
2017
- run: runProducer,
2242
+ run: () => createInstallJobProducer({
2243
+ profile,
2244
+ spec,
2245
+ acceptWarnings: args.acceptWarnings === true,
2246
+ allowBuildScripts,
2247
+ approvalToken,
2248
+ agentOwner,
2249
+ npmRegistry,
2250
+ rawSources,
2251
+ profileExisted,
2252
+ profileDir: installProfileDir,
2253
+ }),
2018
2254
  });
2019
2255
  return { kind: "background", jobId };
2020
2256
  },
@@ -2627,6 +2863,164 @@ export async function runSelfTests() {
2627
2863
 
2628
2864
  check("重启日志落在 <home>/guard/ 下", restartLogPath(join("/h", "profiles", "web"), "web").replace(/\\/g, "/").endsWith("/h/guard/restart-web.log"));
2629
2865
 
2866
+ {
2867
+ const dshHomeBefore = process.env.DSH_HOME;
2868
+ const consoleErrorBefore = console.error;
2869
+ const diagnostics = [];
2870
+ const fixtureHome = join(root, "restart-plan-home");
2871
+ let response;
2872
+ let logged = "";
2873
+ try {
2874
+ process.env.DSH_HOME = fixtureHome;
2875
+ console.error = (...args) => { diagnostics.push(args.join(" ")); };
2876
+ response = await rpcDispatch(
2877
+ {},
2878
+ "restart",
2879
+ { profile: "web", session: `sess_${"a".repeat(32)}` },
2880
+ { defaultProfile: "web", allowRestart: false },
2881
+ undefined,
2882
+ {},
2883
+ );
2884
+ logged = readFileSync(join(fixtureHome, "guard", "restart-web.log"), "utf8");
2885
+ } finally {
2886
+ console.error = consoleErrorBefore;
2887
+ if (dshHomeBefore === undefined) delete process.env.DSH_HOME;
2888
+ else process.env.DSH_HOME = dshHomeBefore;
2889
+ }
2890
+ check(
2891
+ "重启 plan 失败 → 页面返回原错误且诊断同时落盘",
2892
+ response?.ok === false && /restart disabled/.test(response.error?.message)
2893
+ && /restart plan rejected: restart disabled/.test(logged)
2894
+ && diagnostics.some((line) => /restart plan rejected/.test(line)),
2895
+ );
2896
+ }
2897
+
2898
+ // The restart helper is a protocol peer, not merely a spawned pid. These
2899
+ // fixtures pin the fail-safe: every pre-handoff failure keeps the old Host
2900
+ // alive, while only an explicit, stable readiness message permits exit.
2901
+ {
2902
+ class FakeRestartChild extends EventEmitter {
2903
+ constructor() {
2904
+ super();
2905
+ this.connected = true;
2906
+ this.exitCode = null;
2907
+ this.signalCode = null;
2908
+ this.killed = false;
2909
+ this.killCalls = 0;
2910
+ this.unrefCalls = 0;
2911
+ this.disconnectCalls = 0;
2912
+ }
2913
+ kill() { this.killed = true; this.killCalls++; }
2914
+ unref() { this.unrefCalls++; }
2915
+ disconnect() { this.connected = false; this.disconnectCalls++; }
2916
+ exit(code, signal = null) {
2917
+ this.exitCode = code;
2918
+ this.signalCode = signal;
2919
+ this.emit("exit", code, signal);
2920
+ }
2921
+ }
2922
+ const wait = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
2923
+ const supervise = (child, overrides = {}) => {
2924
+ let hostExits = 0;
2925
+ const failures = [];
2926
+ const handoff = superviseRestartHelper(child, {
2927
+ awaitExitPid: 4242,
2928
+ handshakeTimeoutMs: 30,
2929
+ stabilityMs: 4,
2930
+ responseDelayMs: 4,
2931
+ onHostExit: () => { hostExits++; },
2932
+ onFailure: (message, meta) => { failures.push({ message, meta }); },
2933
+ ...overrides,
2934
+ });
2935
+ return { handoff, failures, hostExits: () => hostExits };
2936
+ };
2937
+
2938
+ const spawnErrorChild = new FakeRestartChild();
2939
+ const spawnError = supervise(spawnErrorChild);
2940
+ spawnErrorChild.emit("error", new Error("ENOENT"));
2941
+ const spawnErrorReady = await spawnError.handoff.ready;
2942
+ await wait(10);
2943
+ check(
2944
+ "重启 helper spawn error → 旧 Host 不退出且 error listener 已清理",
2945
+ !spawnErrorReady.ok && /ENOENT/.test(spawnErrorReady.error)
2946
+ && spawnError.hostExits() === 0
2947
+ && spawnErrorChild.listenerCount("error") === 0,
2948
+ );
2949
+
2950
+ const usageChild = new FakeRestartChild();
2951
+ const usage = supervise(usageChild);
2952
+ usageChild.exit(2);
2953
+ const usageReady = await usage.handoff.ready;
2954
+ await wait(10);
2955
+ check(
2956
+ "旧 CLI 因 --await-exit 以 code 2 快速退出 → 旧 Host 保持运行",
2957
+ !usageReady.ok && /code 2/.test(usageReady.error) && usage.hostExits() === 0,
2958
+ );
2959
+
2960
+ const silentChild = new FakeRestartChild();
2961
+ const silent = supervise(silentChild, { handshakeTimeoutMs: 8 });
2962
+ const silentReady = await silent.handoff.ready;
2963
+ check(
2964
+ "helper 建立后静默至握手超时 → 终止 helper 且旧 Host 保持运行",
2965
+ !silentReady.ok && /did not acknowledge/.test(silentReady.error)
2966
+ && silentChild.killCalls === 1 && silent.hostExits() === 0
2967
+ && silentChild.listenerCount("message") === 0,
2968
+ );
2969
+
2970
+ const mismatchChild = new FakeRestartChild();
2971
+ const mismatch = supervise(mismatchChild);
2972
+ mismatchChild.emit("message", { type: RESTART_HELPER_READY_TYPE, protocol: 99, awaitExitPid: 4242 });
2973
+ const mismatchReady = await mismatch.handoff.ready;
2974
+ check(
2975
+ "同版本不同内容/协议不匹配 → fail closed,不靠 package version 放行",
2976
+ !mismatchReady.ok && /protocol mismatch/.test(mismatchReady.error)
2977
+ && mismatchChild.killCalls === 1 && mismatch.hostExits() === 0,
2978
+ );
2979
+
2980
+ const unstableChild = new FakeRestartChild();
2981
+ const unstable = supervise(unstableChild, { stabilityMs: 20 });
2982
+ unstableChild.emit("message", createRestartHelperReadyMessage(4242));
2983
+ unstableChild.exit(1);
2984
+ const unstableReady = await unstable.handoff.ready;
2985
+ check(
2986
+ "helper 握手后在稳定窗口内退出 → 取消旧 Host 退出",
2987
+ !unstableReady.ok && unstable.hostExits() === 0,
2988
+ );
2989
+
2990
+ const healthyChild = new FakeRestartChild();
2991
+ const healthy = supervise(healthyChild);
2992
+ healthyChild.emit("message", createRestartHelperReadyMessage(4242));
2993
+ const healthyReady = await healthy.handoff.ready;
2994
+ await wait(12);
2995
+ check(
2996
+ "helper 明确握手并活过稳定窗口 → 旧 Host 只退出一次",
2997
+ healthyReady.ok && healthy.hostExits() === 1
2998
+ && healthyChild.disconnectCalls === 1 && healthyChild.unrefCalls === 1,
2999
+ );
3000
+
3001
+ const disposedChild = new FakeRestartChild();
3002
+ const disposed = supervise(disposedChild, { responseDelayMs: 40 });
3003
+ disposedChild.emit("message", createRestartHelperReadyMessage(4242));
3004
+ const disposedReady = await disposed.handoff.ready;
3005
+ disposed.handoff.dispose(); // mirrors the disposer returned from ctx.effect()
3006
+ await wait(50);
3007
+ check(
3008
+ "插件卸载/HMR disposer → 清理退出 timer、结束 helper、旧 Host 不退出",
3009
+ disposedReady.ok && disposed.hostExits() === 0 && disposedChild.killCalls === 1
3010
+ && disposedChild.listenerCount("exit") === 0,
3011
+ );
3012
+
3013
+ check(
3014
+ "默认保留旧实现的一秒 RPC 响应排空窗口",
3015
+ RESTART_RESPONSE_DRAIN_MS === 1000,
3016
+ );
3017
+ check(
3018
+ "进程启动标识来自稳定的 performance.timeOrigin",
3019
+ Number.isFinite(hostProcessStartedAt)
3020
+ && hostProcessStartedAt > 0 && hostProcessStartedAt <= Date.now(),
3021
+ );
3022
+ }
3023
+
2630
3024
  // ── 7. Tracker isolation: producer.done rejection handling ───────────────
2631
3025
  let settledOutcome = null;
2632
3026
  const rejectingProducer = {
@@ -2827,6 +3221,250 @@ export async function runSelfTests() {
2827
3221
  const rolledLog = quietLog();
2828
3222
  runStartupRecovery("profile-a", { recover: () => ({ action: "rolled-back", reason: "静态校验未通过" }), log: rolledLog });
2829
3223
  check("回滚路径播报原因", rolledLog.lines.some((line) => line.includes("rolled back") && line.includes("静态校验未通过")));
3224
+
3225
+ // ── 13. market_install:整条链跑在 job 里(issue #8)────────────────────
3226
+ // 原来 registry 查询 → 防抢注解析 → 隔离预检全在 ctx.jobs.start() 之前 await,
3227
+ // 于是几十秒里没有 job id、没有日志、job_kill 够不着,而工具描述写的是
3228
+ // "returns a job id immediately"。这一组钉的是搬进 producer 之后的三条语义:
3229
+ // 拒绝是 job 的结局(不是异常)、通过才跑 pnpm、进行中能真的取消。
3230
+ // 注意:done 永不 reject 是硬约束,所以下面每条都直接 await done 拿结果。
3231
+ {
3232
+ // done 永不 reject 是这组的前提,所以每条都直接 await 它——可一旦回归让
3233
+ // done 干脆不结算,await 就会永远挂住,而挂住的 Node 是「事件循环空了」
3234
+ // 正常退出:退出码 0,`finished with N failures` 那行压根不打印,CI 全绿。
3235
+ // 所以每个 await 都套上超时,把「没结算」变成一条会红的断言。
3236
+ const settleWithin = async (promise, ms, label) => {
3237
+ let timer;
3238
+ const timeout = new Promise((resolveTimeout) => {
3239
+ timer = setTimeout(() => resolveTimeout({ status: `<${label}:${ms}ms 内没有结算>` }), ms);
3240
+ });
3241
+ try {
3242
+ return await Promise.race([promise, timeout]);
3243
+ } finally {
3244
+ clearTimeout(timer);
3245
+ }
3246
+ };
3247
+ const cleanReport = { verdict: "clean", summary: "无冲突", issues: [] };
3248
+ const seams = (overrides) => ({
3249
+ _registryFor: async () => "https://registry.npmjs.org",
3250
+ _preferNpmSpec: async ({ spec }) => spec,
3251
+ _assertSafeToInstall: async () => {},
3252
+ ...overrides,
3253
+ });
3254
+ const neverInstall = (counter) => () => {
3255
+ counter.calls++;
3256
+ return { cancel: () => {}, done: Promise.resolve({ status: "completed" }), readOutput: () => "" };
3257
+ };
3258
+
3259
+ // 13a. 预检 blocker → failed 的 job,逐条 BLOCK 落在 detail 里,pnpm 不跑。
3260
+ const blockedInstalls = { calls: 0 };
3261
+ const blockedProducer = createInstallJobProducer({
3262
+ profile: "web",
3263
+ spec: "bad-pkg",
3264
+ agentOwner: "agent-selftest",
3265
+ ...seams({
3266
+ _runPreflight: async ({ onOutput }) => {
3267
+ onOutput?.("probe log line\n");
3268
+ return {
3269
+ report: {
3270
+ verdict: "blocked",
3271
+ summary: "候选包会改坏这个 profile",
3272
+ issues: [{ severity: "block", title: "重复挂载", detail: "两行指向同一个模块" }],
3273
+ },
3274
+ profileDir,
3275
+ fingerprint: "fp-blocked",
3276
+ };
3277
+ },
3278
+ _runInstall: neverInstall(blockedInstalls),
3279
+ }),
3280
+ });
3281
+ const blockedOutcome = await settleWithin(blockedProducer.done, 5000, "blocker job");
3282
+ check("预检 blocker → job 结算为 failed(不是抛异常)", blockedOutcome?.status === "failed");
3283
+ check("预检 blocker → detail 带上逐条 BLOCK", /\[BLOCK\] 重复挂载/.test(blockedOutcome?.detail ?? ""));
3284
+ check("预检 blocker → pnpm 一次都不跑", blockedInstalls.calls === 0);
3285
+ const blockedLog = blockedProducer.readOutput();
3286
+ check("预检输出进入 job 日志(此前它根本不存在于任何 job)",
3287
+ blockedLog.includes("probe log line") && blockedLog.includes("预检结论:blocked"));
3288
+
3289
+ // 13a2. warning 未获用户确认,等价于拒绝——并且指明补 acceptWarnings。
3290
+ const warnInstalls = { calls: 0 };
3291
+ const warnOutcome = await settleWithin(createInstallJobProducer({
3292
+ profile: "web",
3293
+ spec: "warn-pkg",
3294
+ agentOwner: "agent-selftest",
3295
+ ...seams({
3296
+ _runPreflight: async () => ({
3297
+ report: {
3298
+ verdict: "warning",
3299
+ summary: "有需要确认的改动",
3300
+ issues: [{ severity: "warn", title: "替换整块 config", detail: "sandbox-policy" }],
3301
+ },
3302
+ profileDir,
3303
+ fingerprint: "fp-warn",
3304
+ }),
3305
+ _runInstall: neverInstall(warnInstalls),
3306
+ }),
3307
+ }).done, 5000, "warning job");
3308
+ check("预检 warning 未确认 → failed 且提示 acceptWarnings",
3309
+ warnOutcome?.status === "failed" && /acceptWarnings: true/.test(warnOutcome?.detail ?? "") && warnInstalls.calls === 0);
3310
+
3311
+ // 13b. 预检通过 → 进入安装。同时钉两件事:pnpm 拿到的是防抢注解析后的
3312
+ // spec(label 用的是归一 spec,两者可以不同),以及 readOutput 的顺序。
3313
+ let preflightSpec;
3314
+ let installedWith;
3315
+ const cleanProducer = createInstallJobProducer({
3316
+ profile: "web",
3317
+ spec: "owner/repo",
3318
+ agentOwner: "agent-selftest",
3319
+ ...seams({
3320
+ _preferNpmSpec: async () => "resolved-pkg",
3321
+ _runPreflight: async ({ spec, onOutput }) => {
3322
+ preflightSpec = spec;
3323
+ onOutput?.("probe ok\n");
3324
+ return { report: cleanReport, profileDir, fingerprint: "fp-clean" };
3325
+ },
3326
+ _runInstall: (options) => {
3327
+ installedWith = options;
3328
+ const chunks = ["pnpm add output\n"];
3329
+ return {
3330
+ cancel: () => {},
3331
+ done: Promise.resolve({ status: "completed", detail: "installed" }),
3332
+ readOutput: () => chunks.splice(0).join(""),
3333
+ };
3334
+ },
3335
+ }),
3336
+ });
3337
+ const cleanOutcome = await settleWithin(cleanProducer.done, 5000, "clean job");
3338
+ check("预检通过 → 进入安装并结算 completed", cleanOutcome?.status === "completed");
3339
+ check("预检与 pnpm 都用防抢注解析后的 spec",
3340
+ preflightSpec === "resolved-pkg" && installedWith?.spec === "resolved-pkg");
3341
+ const cleanLog = cleanProducer.readOutput();
3342
+ check("readOutput 先排空预检缓冲、再接 install 输出",
3343
+ cleanLog.includes("pnpm add output")
3344
+ && cleanLog.indexOf("probe ok") < cleanLog.indexOf("pnpm add output"));
3345
+
3346
+ // 13c. 进行中取消:预检还在跑就按 job_kill。预检必须收到 AbortSignal,
3347
+ // 结算为 killed 且明说 profile 没被动过,pnpm 阶段一步都不许进。
3348
+ const cancelInstalls = { calls: 0 };
3349
+ let preflightSignal;
3350
+ const cancelProducer = createInstallJobProducer({
3351
+ profile: "web",
3352
+ spec: "slow-pkg",
3353
+ agentOwner: "agent-selftest",
3354
+ ...seams({
3355
+ _runPreflight: ({ signal }) => new Promise((_resolve, rejectPreflight) => {
3356
+ preflightSignal = signal;
3357
+ // 真实的 preflightInstall 在取消时抛 AbortError(guard.js),照抄。
3358
+ signal.addEventListener("abort", () => {
3359
+ const error = new Error("preflight cancelled");
3360
+ error.name = "AbortError";
3361
+ rejectPreflight(error);
3362
+ }, { once: true });
3363
+ }),
3364
+ _runInstall: neverInstall(cancelInstalls),
3365
+ }),
3366
+ });
3367
+ await new Promise((resolveTick) => setImmediate(resolveTick)); // 跑到预检那一步
3368
+ cancelProducer.cancel();
3369
+ cancelProducer.cancel(); // 幂等:面板/模型都可能连按两次
3370
+ const cancelOutcome = await settleWithin(cancelProducer.done, 5000, "取消后的 job");
3371
+ check("进行中取消 → 预检确实收到了 AbortSignal", preflightSignal?.aborted === true);
3372
+ check("进行中取消 → 结算为 killed", cancelOutcome?.status === "killed");
3373
+ check("进行中取消 → 明说 profile 未被改动", /never modified/.test(cancelOutcome?.detail ?? ""));
3374
+ check("进行中取消 → 不进入 pnpm 阶段", cancelInstalls.calls === 0);
3375
+
3376
+ // 13c2. profile 本来就不存在的情况。runPreflight 会先 ensureProfile(),
3377
+ // 那是真的落盘(package.json / cordis.patch.yml / pnpm-workspace.yaml),
3378
+ // 所以「the profile was never modified」对它是假话——用户会照着这句
3379
+ // 认定磁盘上什么都没多出来。
3380
+ const freshInstalls = { calls: 0 };
3381
+ const freshProducer = createInstallJobProducer({
3382
+ profile: "web",
3383
+ spec: "slow-pkg",
3384
+ agentOwner: "agent-selftest",
3385
+ profileExisted: false,
3386
+ profileDir, // 预检已经把它 ensureProfile 出来了(这个目录有 package.json)
3387
+ ...seams({
3388
+ _runPreflight: ({ signal }) => new Promise((_resolve, rejectPreflight) => {
3389
+ signal.addEventListener("abort", () => {
3390
+ const error = new Error("preflight cancelled");
3391
+ error.name = "AbortError";
3392
+ rejectPreflight(error);
3393
+ }, { once: true });
3394
+ }),
3395
+ _runInstall: neverInstall(freshInstalls),
3396
+ }),
3397
+ });
3398
+ await new Promise((resolveTick) => setImmediate(resolveTick));
3399
+ freshProducer.cancel();
3400
+ const freshOutcome = await settleWithin(freshProducer.done, 5000, "未初始化 profile 的取消");
3401
+ check("未初始化 profile 取消 → 仍结算为 killed", freshOutcome?.status === "killed");
3402
+ check("未初始化 profile 取消 → 不谎称「从未修改」",
3403
+ !/never modified/.test(freshOutcome?.detail ?? ""));
3404
+ check("未初始化 profile 取消 → 如实说明 profile 已被初始化",
3405
+ /was initialized before the probe started/.test(freshOutcome?.detail ?? ""));
3406
+ check("未初始化 profile 取消 → 仍然不进入 pnpm 阶段", freshInstalls.calls === 0);
3407
+
3408
+ // 13c3. 取消发生在预检**之前**(registry 查询这一段)。ensureProfile()
3409
+ // 在 runPreflight 里,这时磁盘上一个字节都还没写,所以即便 profile
3410
+ // 本来不存在,也绝不能说「已经把它初始化了」——那会让用户去找一个
3411
+ // 根本不存在的目录。判据必须是磁盘的当前事实,不是开工前的快照。
3412
+ const earlyInstalls = { calls: 0 };
3413
+ let earlyPreflightCalls = 0;
3414
+ let releaseRegistry;
3415
+ const registryGate = new Promise((resolveGate) => { releaseRegistry = resolveGate; });
3416
+ const earlyProducer = createInstallJobProducer({
3417
+ profile: "web",
3418
+ spec: "slow-pkg",
3419
+ agentOwner: "agent-selftest",
3420
+ profileExisted: false,
3421
+ profileDir: join(root, "profile-that-was-never-created"),
3422
+ ...seams({
3423
+ // 真实的 registryFor 不吃 signal(见 producer 注释),照此模拟:
3424
+ // 它跑完之后才轮到 throwIfAborted 生效。
3425
+ _registryFor: async () => { await registryGate; return "https://registry.npmjs.org"; },
3426
+ _runPreflight: () => { earlyPreflightCalls++; throw new Error("不该走到预检"); },
3427
+ _runInstall: neverInstall(earlyInstalls),
3428
+ }),
3429
+ });
3430
+ await new Promise((resolveTick) => setImmediate(resolveTick));
3431
+ earlyProducer.cancel(); // 还卡在 registry 查询里
3432
+ releaseRegistry();
3433
+ const earlyOutcome = await settleWithin(earlyProducer.done, 5000, "预检之前的取消");
3434
+ check("预检前取消 → 结算为 killed", earlyOutcome?.status === "killed");
3435
+ check("预检前取消 → 根本没进预检", earlyPreflightCalls === 0 && earlyInstalls.calls === 0);
3436
+ check("预检前取消 → 不谎称已初始化 profile(磁盘上什么都没建)",
3437
+ !/was initialized before the probe started/.test(earlyOutcome?.detail ?? "")
3438
+ && /the profile was never modified/.test(earlyOutcome?.detail ?? ""));
3439
+
3440
+ // 13d. 审批 token 签发失败(这里用缺失的 proof 触发)。以前这段跑在
3441
+ // .then 里,一抛就把 done 变成 rejected —— 违反「done 必须不 reject」,
3442
+ // 而且会把「pnpm 拦下了安装脚本」这条真结论顶掉。
3443
+ let approvalRejected = false;
3444
+ const approvalOutcome = await settleWithin(createInstallJobProducer({
3445
+ profile: "web",
3446
+ spec: "scripty-pkg",
3447
+ agentOwner: "agent-selftest",
3448
+ ...seams({
3449
+ _runPreflight: async () => ({ report: cleanReport, profileDir, fingerprint: "fp-scripty" }),
3450
+ _runInstall: () => ({
3451
+ cancel: () => {},
3452
+ done: Promise.resolve({
3453
+ status: "failed",
3454
+ detail: "installing scripty-pkg requires running install-time code — approval needed.",
3455
+ needsApproval: [{ name: "scripty-pkg", version: "1.0.0", scripts: { install: "node install.js" } }],
3456
+ // proof 缺失 → issueApprovalToken 必抛
3457
+ }),
3458
+ readOutput: () => "",
3459
+ }),
3460
+ }),
3461
+ }).done.catch(() => { approvalRejected = true; return undefined; }), 5000, "审批 token 签发失败的 job");
3462
+ check("token 签发失败不会把 done 变成 rejected", !approvalRejected && approvalOutcome !== undefined);
3463
+ check("token 签发失败 → 原结论保留", /requires running install-time code/.test(approvalOutcome?.detail ?? ""));
3464
+ check("token 签发失败 → detail 明说这次没法用 allowBuildScripts 重试",
3465
+ /no approval token could be issued/.test(approvalOutcome?.detail ?? ""));
3466
+ check("token 签发失败 → 不对外交出 approvalToken", approvalOutcome?.approvalToken === undefined);
3467
+ }
2830
3468
  } finally {
2831
3469
  rmSync(root, { recursive: true, force: true });
2832
3470
  }
@@ -2836,10 +3474,20 @@ export async function runSelfTests() {
2836
3474
 
2837
3475
  if (process.argv.includes("--self-test")) {
2838
3476
  console.log("index.js self-test:");
3477
+ // 挂住的 suite 不会失败,会「成功」:await 一个永不结算的 promise 之后事件
3478
+ // 循环就空了,Node 正常退出,退出码 0,而 `finished with N failures` 那行
3479
+ // 根本没打印——CI 看到的是全绿。这个看门狗刻意不 unref(unref 掉就拦不住
3480
+ // 那次正常退出了),跑完由下面 clearTimeout 收掉。
3481
+ const watchdog = setTimeout(() => {
3482
+ console.error("index.js self-test: 超时未跑完——有 fixture 挂住了(producer 的 done 从未结算?)");
3483
+ process.exit(1);
3484
+ }, 120000);
2839
3485
  runSelfTests().then((failed) => {
3486
+ clearTimeout(watchdog);
2840
3487
  console.log(`index.js tests finished with ${failed} failures.`);
2841
3488
  process.exit(failed === 0 ? 0 : 1);
2842
3489
  }).catch((err) => {
3490
+ clearTimeout(watchdog);
2843
3491
  console.error("Self-test threw:", err);
2844
3492
  process.exit(1);
2845
3493
  });