@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/README.md +2 -2
- package/package.json +1 -1
- package/src/cli.js +47 -1
- package/src/client.js +44 -4
- package/src/github.js +15 -8
- package/src/guard.js +209 -6
- package/src/index.js +748 -100
- package/src/installer.js +310 -35
- package/src/restart-protocol.js +144 -0
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
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
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
|
-
*
|
|
1261
|
-
*
|
|
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
|
|
1295
|
+
function preflightRefusal(report, acceptWarnings, label) {
|
|
1264
1296
|
if (report.verdict === "blocked") {
|
|
1265
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
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
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
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
|
|
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}");
|
|
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
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
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:
|
|
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
|
});
|