@1e0zj/dsh-plugin-mall 0.4.12 → 0.4.15
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 +72 -0
- package/package.json +1 -1
- package/src/cli.js +4 -3
- package/src/client.js +33 -6
- package/src/guard.js +174 -6
- package/src/index.js +104 -14
- package/src/installer.js +577 -31
- package/src/terminal.js +11 -0
package/README.md
CHANGED
|
@@ -128,6 +128,44 @@ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
|
|
|
128
128
|
> `dsh-plugin-guard recover` does repair this.
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
## Update stuck at the install-script approval card? (updates *to* 0.4.15)
|
|
132
|
+
|
|
133
|
+
Symptom: updating the marketplace itself pauses at "安装期代码需要确认", and
|
|
134
|
+
clicking 允许 fails with `allowBuildScripts cannot be specified on initial
|
|
135
|
+
install`. Clicking again does not help.
|
|
136
|
+
|
|
137
|
+
Who hits it: only profiles that have **never approved a dependency with install
|
|
138
|
+
scripts** (in practice `node-pty`, pulled in transitively). If your profile's
|
|
139
|
+
`pnpm-workspace.yaml` already carries `allowBuilds: {node-pty: true}` — most
|
|
140
|
+
profiles that have installed anything do — the approval card never appears and
|
|
141
|
+
the update just works.
|
|
142
|
+
|
|
143
|
+
Cause: pnpm installs the new marketplace package before the approval gate, which
|
|
144
|
+
swaps the running plugin on disk; dsh replays the assembly tree and the whole
|
|
145
|
+
marketplace UI remounts, losing the browser session the approval token was
|
|
146
|
+
issued to. The token is then unrecoverable, so the retry arrives without it.
|
|
147
|
+
|
|
148
|
+
Recovery — restart dsh (startup recovery rolls the paused install back), then
|
|
149
|
+
either update from the CLI:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
# <profile> = web, guard-test, ...
|
|
153
|
+
dsh plugin --profile <profile> add @1e0zj/dsh-plugin-mall@0.4.15
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
or pre-approve the build scripts once and update from the page as usual:
|
|
157
|
+
|
|
158
|
+
```yaml
|
|
159
|
+
# <profile>/pnpm-workspace.yaml
|
|
160
|
+
allowBuilds:
|
|
161
|
+
'node-pty@1.1.0': true
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
> Fixed from 0.4.15 onward — the session identity now survives a remount. The
|
|
165
|
+
> fix cannot rescue the hop *to* 0.4.15 itself: during that update both the
|
|
166
|
+
> page's pre-swap code and the running dsh process are still the old version.
|
|
167
|
+
|
|
168
|
+
|
|
131
169
|
## Agent tools
|
|
132
170
|
|
|
133
171
|
| Tool | What it does |
|
|
@@ -282,6 +320,40 @@ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
|
|
|
282
320
|
> `dsh-plugin-guard recover` 确实能修这个故障。
|
|
283
321
|
|
|
284
322
|
|
|
323
|
+
## 更新卡在批准卡上?(更新**到** 0.4.15 时可能遇到)
|
|
324
|
+
|
|
325
|
+
症状:更新市场自己,停在「安装期代码需要确认」,点「允许」报
|
|
326
|
+
`allowBuildScripts cannot be specified on initial install`,再点还是这样。
|
|
327
|
+
|
|
328
|
+
谁会遇到:只有**从未批准过带安装脚本的依赖**的 profile(实际就是传递依赖
|
|
329
|
+
`node-pty`)。如果你的 `pnpm-workspace.yaml` 里已经有
|
|
330
|
+
`allowBuilds: {node-pty: true}`——装过东西的 profile 基本都有——批准卡根本
|
|
331
|
+
不会出现,更新一次过。
|
|
332
|
+
|
|
333
|
+
原因:pnpm 在批准闸之前就把新版市场装进了 node_modules,正在运行的插件实体
|
|
334
|
+
被替换,dsh 随之重放装配树、整个市场 UI 重挂载,签发审批 token 时的那个浏览器
|
|
335
|
+
session 一起没了。token 从此取不回来,重试自然是「没带 token」。
|
|
336
|
+
|
|
337
|
+
出路——重启 dsh(启动恢复会把暂停的安装撤回),然后二选一。命令行更新:
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
# <profile> = web、guard-test……
|
|
341
|
+
dsh plugin --profile <profile> add @1e0zj/dsh-plugin-mall@0.4.15
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
或者先把构建脚本批准一次,再照常从页面更新:
|
|
345
|
+
|
|
346
|
+
```yaml
|
|
347
|
+
# <profile>/pnpm-workspace.yaml
|
|
348
|
+
allowBuilds:
|
|
349
|
+
'node-pty@1.1.0': true
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
> 0.4.15 起已修复——session 身份现在扛得住重挂载。但这个修复救不了「更新到
|
|
353
|
+
> 0.4.15」这一跳本身:那次更新时,页面上 swap 之前的代码和正在跑的 dsh 进程
|
|
354
|
+
> 都还是旧版。
|
|
355
|
+
|
|
356
|
+
|
|
285
357
|
## 工作原理
|
|
286
358
|
|
|
287
359
|
- 双面包(dual-face)插件:`dsh.bundle` 半边挂在 **host 平面**(profile bundle 层),
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
resolveDshHome,
|
|
62
62
|
rollbackPendingSnapshot,
|
|
63
63
|
validateInstalledProfile,
|
|
64
|
+
validatePendingProfile,
|
|
64
65
|
validateRemoveCompletion,
|
|
65
66
|
} from "./guard.js";
|
|
66
67
|
// github.js imports node builtins only — the host-independence of this CLI is
|
|
@@ -502,7 +503,7 @@ async function cmdAdd({ spec, profile, home, acceptWarnings }) {
|
|
|
502
503
|
// rolled back immediately; otherwise the marker stays pending and the next
|
|
503
504
|
// dsh startup (or `guard recover`) commits it once the plugin actually
|
|
504
505
|
// loads.
|
|
505
|
-
const validation =
|
|
506
|
+
const validation = validatePendingProfile(profileDir);
|
|
506
507
|
if (result.exitCode === 0 && validation.ok) {
|
|
507
508
|
console.log(`[guard] installed ${spec} into profile "${profile}".`);
|
|
508
509
|
console.log("Restart dsh to load it. On the next startup — or via `node src/cli.js guard recover` — the pending snapshot is committed once the profile proves loadable; if dsh fails to boot, the same command rolls it back.");
|
|
@@ -596,7 +597,7 @@ async function cmdRemove({
|
|
|
596
597
|
|
|
597
598
|
let validation;
|
|
598
599
|
try {
|
|
599
|
-
validation =
|
|
600
|
+
validation = validatePendingProfile(profileDir);
|
|
600
601
|
} catch (error) {
|
|
601
602
|
rollbackAndThrow(`remove completed but static profile validation threw: ${error.message}`);
|
|
602
603
|
}
|
|
@@ -1327,7 +1328,7 @@ async function cmdLaunch({
|
|
|
1327
1328
|
}
|
|
1328
1329
|
let validation;
|
|
1329
1330
|
try {
|
|
1330
|
-
validation =
|
|
1331
|
+
validation = validatePendingProfile(profileDir, marker);
|
|
1331
1332
|
} catch (error) {
|
|
1332
1333
|
throw new Error(`static validation of profile "${profile}" failed — refusing to launch: ${error.message}`);
|
|
1333
1334
|
}
|
package/src/client.js
CHANGED
|
@@ -157,6 +157,36 @@ window.__ModuleLoader__.load({
|
|
|
157
157
|
throw new Error("Secure browser randomness is unavailable; plugin marketplace actions are disabled");
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// 与 src/index.js 的 BROWSER_SESSION_RE 保持一致:window 上的值同样是
|
|
161
|
+
// 不可信输入(别的脚本能写),读出来必须重新过格式校验。
|
|
162
|
+
var BROWSER_SESSION_RE = /^sess_(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i;
|
|
163
|
+
var SESSION_NONCE_PROP = "__dshPluginMallSession";
|
|
164
|
+
// nonce 挂在 window 上(进程内,不落任何存储)。
|
|
165
|
+
//
|
|
166
|
+
// 要解决的:更新插件市场自身会替换正在运行的市场包,组装树随之重放、
|
|
167
|
+
// 整个市场 UI 重挂载——useRef 里的 nonce 归零,而审批 token 只发给签发
|
|
168
|
+
// 时的同一个 session,于是 needsApproval 暂停的任务在重挂载后永远拿不回
|
|
169
|
+
// token,用户点「允许」必报 initial install。重挂载是 React 层的(页面
|
|
170
|
+
// 没有重新加载),window 对象自始至终是同一个,进程内值正好覆盖它。
|
|
171
|
+
//
|
|
172
|
+
// 不用 sessionStorage:它在 window.open(同源) 和浏览器「复制标签页」时
|
|
173
|
+
// 会被复制给新标签页。jobs 镜像里本就带着 approvalToken,nonce 再一起
|
|
174
|
+
// 复制过去,新标签页就同时拿到了 token 和它的归属身份——后端会认成同一
|
|
175
|
+
// 个 session,跨 tab 隔离(47db0c4)就此失效。window 上的值不随标签页
|
|
176
|
+
// 复制,新标签页天然是新身份。
|
|
177
|
+
//
|
|
178
|
+
// 代价:页面刷新(F5)后 nonce 重新生成,刷新前签发的 token 作废。这是
|
|
179
|
+
// 对的边界——刷新是用户主动重来,暂停的任务重启 dsh 即可撤回;而重挂载
|
|
180
|
+
// 是用户没做任何事的情况下被系统掀了桌子,那才是必须兜住的。
|
|
181
|
+
function readOrCreateBrowserSession(host) {
|
|
182
|
+
var scope = host || window;
|
|
183
|
+
var current = scope[SESSION_NONCE_PROP];
|
|
184
|
+
if (typeof current === "string" && BROWSER_SESSION_RE.test(current)) return current;
|
|
185
|
+
var nonce = generateSessionNonce();
|
|
186
|
+
scope[SESSION_NONCE_PROP] = nonce;
|
|
187
|
+
return nonce;
|
|
188
|
+
}
|
|
189
|
+
|
|
160
190
|
// ── job polling ─────────────────────────────────────────────────────────
|
|
161
191
|
// onPreflightSettled:预检 job(kind=dsh-plugin-preflight)落定时回调,
|
|
162
192
|
// 携带 (spec, report)。safe 由调用方直接续装;有风险由调用方出内联卡片。
|
|
@@ -443,7 +473,7 @@ window.__ModuleLoader__.load({
|
|
|
443
473
|
meta: clip(props.spec || "", 50),
|
|
444
474
|
actions: actions,
|
|
445
475
|
},
|
|
446
|
-
|
|
476
|
+
h("div", { className: "mkt_desc" }, "pnpm 已拦截下列安装脚本,尚未执行任何插件代码。允许后会先复核制品再运行;暂不允许时,重启 dsh 会自动撤回这次安装。"),
|
|
447
477
|
pkgs.map(function (p, index) {
|
|
448
478
|
var facts = [];
|
|
449
479
|
if (typeof p.weeklyDownloads === "number") facts.push("周下载 " + p.weeklyDownloads.toLocaleString());
|
|
@@ -609,7 +639,6 @@ window.__ModuleLoader__.load({
|
|
|
609
639
|
job.needsApproval && job.needsApproval.length > 0
|
|
610
640
|
? h(ApprovalRequest, {
|
|
611
641
|
spec: job.spec,
|
|
612
|
-
detail: job.detail,
|
|
613
642
|
needsApproval: job.needsApproval,
|
|
614
643
|
busy: props.approving === job.spec,
|
|
615
644
|
onApprove: function (names) {
|
|
@@ -790,7 +819,7 @@ window.__ModuleLoader__.load({
|
|
|
790
819
|
// Opaque one-shot approval tokens issued from needsApproval outcomes (spec -> token).
|
|
791
820
|
// Cleared on success, unrelated failure, cancel, modal close, or spec change.
|
|
792
821
|
// No boolean warning-consent map is kept.
|
|
793
|
-
var sessionNonce = useRef(
|
|
822
|
+
var sessionNonce = useRef(readOrCreateBrowserSession()).current;
|
|
794
823
|
var approvalTokensRef = useRef({});
|
|
795
824
|
var restartControlRef = useRef({ mounted: true, ping: null });
|
|
796
825
|
var clearRestartPing = useCallback(function () {
|
|
@@ -1046,9 +1075,7 @@ window.__ModuleLoader__.load({
|
|
|
1046
1075
|
// 响应语义是「已调度交接」而不是「重启成功」:新宿主何时接管
|
|
1047
1076
|
// 由下面的轮询判断。这里只把输出会出现在哪里告诉用户。
|
|
1048
1077
|
scheduledLogPath = value && value.logPath ? String(value.logPath) : null;
|
|
1049
|
-
setRestartNote(
|
|
1050
|
-
? "重启已调度——dsh 正在新的控制台窗口中启动,窗口里 Ctrl+C 可停止;输出同时写入 " + scheduledLogPath
|
|
1051
|
-
: "重启已调度,输出与日志:" + (scheduledLogPath || "<home>/guard/ 下"));
|
|
1078
|
+
setRestartNote("重启中,输出与日志:" + (scheduledLogPath || "<home>/guard/ 下"));
|
|
1052
1079
|
var tries = 0;
|
|
1053
1080
|
var ping = setInterval(function () {
|
|
1054
1081
|
if (restartControlRef.current.mounted !== true) {
|
package/src/guard.js
CHANGED
|
@@ -24,8 +24,10 @@ import {
|
|
|
24
24
|
import { homedir, tmpdir } from "node:os";
|
|
25
25
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
26
26
|
import { createRequire } from "node:module";
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
27
28
|
import { JSON_SCHEMA, Type, load } from "js-yaml";
|
|
28
29
|
import { satisfies, validRange } from "semver";
|
|
30
|
+
import { stripTerminalControlSequences } from "./terminal.js";
|
|
29
31
|
|
|
30
32
|
// Official patches (e.g. @deepseek-ai/dsh-base, dsh-web-app) mark raw JS
|
|
31
33
|
// expressions with the scalar tag `!!js`. Construct the loader's own marker —
|
|
@@ -1450,6 +1452,84 @@ export function validateInstalledProfile(profileDir) {
|
|
|
1450
1452
|
};
|
|
1451
1453
|
}
|
|
1452
1454
|
|
|
1455
|
+
/** Stable identity for one installed-profile blocker across a transaction. */
|
|
1456
|
+
function staticBlockerFingerprint(entry) {
|
|
1457
|
+
const canonical = (value) => {
|
|
1458
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
1459
|
+
if (value !== null && typeof value === "object") {
|
|
1460
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
1461
|
+
}
|
|
1462
|
+
return value;
|
|
1463
|
+
};
|
|
1464
|
+
return createHash("sha256").update(JSON.stringify(canonical(entry))).digest("hex");
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function staticBlockerFingerprints(validation) {
|
|
1468
|
+
return validation.issues
|
|
1469
|
+
.filter((entry) => entry.severity === "block")
|
|
1470
|
+
.map(staticBlockerFingerprint);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Validate the live profile relative to the state captured before a pending
|
|
1475
|
+
* transaction. An unchanged blocker that was already present did not come
|
|
1476
|
+
* from this install/remove and must not make the transaction roll back. New
|
|
1477
|
+
* markers carry the baseline; old markers do not, and deliberately retain the
|
|
1478
|
+
* old fail-closed behaviour.
|
|
1479
|
+
*/
|
|
1480
|
+
export function validatePendingProfile(profileDir, pending = readValidatedPendingSnapshot(profileDir)) {
|
|
1481
|
+
const validation = validateInstalledProfile(profileDir);
|
|
1482
|
+
if (!Array.isArray(pending?.baselineBlockers)) {
|
|
1483
|
+
return { ...validation, allIssues: validation.issues, preexistingIssues: [] };
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
|
|
1487
|
+
const remaining = new Map();
|
|
1488
|
+
for (const fingerprint of pending.baselineBlockers) {
|
|
1489
|
+
remaining.set(fingerprint, (remaining.get(fingerprint) ?? 0) + 1);
|
|
1490
|
+
}
|
|
1491
|
+
const issues = [];
|
|
1492
|
+
const preexistingIssues = [];
|
|
1493
|
+
for (const entry of validation.issues) {
|
|
1494
|
+
if (entry.severity !== "block") {
|
|
1495
|
+
issues.push(entry);
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
// Updating the package that owns (or participates in) an old blocker must
|
|
1499
|
+
// prove that blocker was actually fixed. Only unrelated historical issues
|
|
1500
|
+
// receive baseline treatment.
|
|
1501
|
+
const candidateRelated = entry.package === candidateName
|
|
1502
|
+
|| (Array.isArray(entry.conflictsWith) && entry.conflictsWith.includes(candidateName));
|
|
1503
|
+
if (candidateRelated) {
|
|
1504
|
+
issues.push(entry);
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
const fingerprint = staticBlockerFingerprint(entry);
|
|
1508
|
+
const count = remaining.get(fingerprint) ?? 0;
|
|
1509
|
+
if (count > 0) {
|
|
1510
|
+
preexistingIssues.push(entry);
|
|
1511
|
+
remaining.set(fingerprint, count - 1);
|
|
1512
|
+
} else {
|
|
1513
|
+
issues.push(entry);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
const blockers = issues.filter((entry) => entry.severity === "block");
|
|
1517
|
+
const warnings = issues.filter((entry) => entry.severity === "warn");
|
|
1518
|
+
const verdict = blockers.length > 0 ? "blocked" : warnings.length > 0 ? "warning" : "safe";
|
|
1519
|
+
return {
|
|
1520
|
+
ok: blockers.length === 0,
|
|
1521
|
+
verdict,
|
|
1522
|
+
issues,
|
|
1523
|
+
allIssues: validation.issues,
|
|
1524
|
+
preexistingIssues,
|
|
1525
|
+
summary: blockers.length > 0
|
|
1526
|
+
? `发现 ${blockers.length} 个本次新增阻断问题、${preexistingIssues.length} 个既有阻断问题、${warnings.length} 个警告`
|
|
1527
|
+
: preexistingIssues.length > 0
|
|
1528
|
+
? `本次变更未新增阻断问题(保留 ${preexistingIssues.length} 个既有阻断问题)`
|
|
1529
|
+
: validation.summary,
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1453
1533
|
/**
|
|
1454
1534
|
* Remove leftover `node_modules` entries for packages that are no longer part
|
|
1455
1535
|
* of the profile's declared dependencies. pnpm hoists each direct dependency
|
|
@@ -1723,7 +1803,7 @@ function spawnCapture(command, args, options, onOutput, { signal, treeKill = fal
|
|
|
1723
1803
|
let child;
|
|
1724
1804
|
const chunks = [];
|
|
1725
1805
|
const push = (value) => {
|
|
1726
|
-
const text = value
|
|
1806
|
+
const text = stripTerminalControlSequences(value);
|
|
1727
1807
|
chunks.push(text);
|
|
1728
1808
|
onOutput?.(text);
|
|
1729
1809
|
};
|
|
@@ -1831,7 +1911,7 @@ export async function preflightInstall({ profileDir, spec, onOutput, signal }) {
|
|
|
1831
1911
|
const result = await spawnCapture(
|
|
1832
1912
|
plan.command,
|
|
1833
1913
|
probeAddArgs(spec),
|
|
1834
|
-
{ cwd: probeDir, env: process.env, shell: plan.shell },
|
|
1914
|
+
{ cwd: probeDir, env: pnpmGuardEnv(process.env), shell: plan.shell },
|
|
1835
1915
|
onOutput,
|
|
1836
1916
|
{ signal, treeKill: plan.treeKill },
|
|
1837
1917
|
);
|
|
@@ -1998,6 +2078,9 @@ function assertStoredTransactionMatches(pending) {
|
|
|
1998
2078
|
if (pending.operation === "remove" && stored.metadata.packageName !== pending.metadata.packageName) {
|
|
1999
2079
|
throw new Error("pending remove packageName does not match snapshot.json — refusing to act on it (left untouched for manual inspection)");
|
|
2000
2080
|
}
|
|
2081
|
+
if (JSON.stringify(stored.baselineBlockers) !== JSON.stringify(pending.baselineBlockers)) {
|
|
2082
|
+
throw new Error("pending marker baseline blockers do not match snapshot.json — refusing to act on it (left untouched for manual inspection)");
|
|
2083
|
+
}
|
|
2001
2084
|
}
|
|
2002
2085
|
|
|
2003
2086
|
/**
|
|
@@ -2037,6 +2120,12 @@ function sanitizeSnapshot(marker, home) {
|
|
|
2037
2120
|
if (!Array.isArray(marker.dependencies) || marker.dependencies.some((name) => typeof name !== "string" || !NPM_PACKAGE_NAME_RE.test(name))) {
|
|
2038
2121
|
throw new Error("pending marker has missing or corrupt dependency metadata — refusing to act on it (left untouched for manual inspection)");
|
|
2039
2122
|
}
|
|
2123
|
+
if (marker.baselineBlockers !== undefined && (
|
|
2124
|
+
!Array.isArray(marker.baselineBlockers)
|
|
2125
|
+
|| marker.baselineBlockers.some((fingerprint) => typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint))
|
|
2126
|
+
)) {
|
|
2127
|
+
throw new Error("pending marker has corrupt baseline blocker metadata — refusing to act on it (left untouched for manual inspection)");
|
|
2128
|
+
}
|
|
2040
2129
|
validatePendingTransaction(marker);
|
|
2041
2130
|
const profileDir = resolve(String(marker.profileDir ?? ""));
|
|
2042
2131
|
const profilesRoot = resolve(join(home, "profiles"));
|
|
@@ -2090,7 +2179,12 @@ export function createProfileSnapshot(profileDir, metadata = {}) {
|
|
|
2090
2179
|
} catch {
|
|
2091
2180
|
/* manifest unreadable — the rest of the snapshot still captures the bytes */
|
|
2092
2181
|
}
|
|
2093
|
-
|
|
2182
|
+
// Static validation after an install must judge what THIS transaction
|
|
2183
|
+
// changed. Without this baseline, an unrelated legacy plugin that already
|
|
2184
|
+
// shadowed host modules made every later update look guilty and forced the
|
|
2185
|
+
// marketplace itself back to its old version on restart.
|
|
2186
|
+
const baselineBlockers = staticBlockerFingerprints(validateInstalledProfile(profileDir));
|
|
2187
|
+
const snapshot = { version: SNAPSHOT_VERSION, id, dir, profileDir, createdAt: Date.now(), files, dependencies, baselineBlockers, operation: normalizedMetadata.operation, metadata: normalizedMetadata };
|
|
2094
2188
|
writeFileSync(join(dir, "snapshot.json"), JSON.stringify(snapshot, undefined, 2) + "\n");
|
|
2095
2189
|
return snapshot;
|
|
2096
2190
|
}
|
|
@@ -2127,7 +2221,7 @@ export function markPendingSnapshot(snapshot, record = {}) {
|
|
|
2127
2221
|
if (existsSync(markerPath)) {
|
|
2128
2222
|
throw new Error(`profile already has a pending install marker at ${markerPath} — run \`dsh-plugin-guard guard recover\` (or let dsh startup recovery consume it) before installing again`);
|
|
2129
2223
|
}
|
|
2130
|
-
const pending = { ...snapshot, ...record, pendingAt: Date.now() };
|
|
2224
|
+
const pending = { ...snapshot, ...record, baselineBlockers: snapshot.baselineBlockers, pendingAt: Date.now() };
|
|
2131
2225
|
// Reject producer bugs before persisting them. The read/recovery boundary
|
|
2132
2226
|
// repeats this validation because the marker is attacker-controllable.
|
|
2133
2227
|
validatePendingTransaction(pending);
|
|
@@ -2212,6 +2306,13 @@ function addedDependencyNames(pending, originalDependencies = pending?.dependenc
|
|
|
2212
2306
|
export function pnpmGuardEnv(base = process.env) {
|
|
2213
2307
|
return {
|
|
2214
2308
|
...base,
|
|
2309
|
+
// Browser jobs render plain text rather than a terminal. Prevent pnpm from
|
|
2310
|
+
// emitting colours at the source; output capture also strips CSI controls
|
|
2311
|
+
// defensively in case a child ignores these conventional switches.
|
|
2312
|
+
NO_COLOR: "1",
|
|
2313
|
+
FORCE_COLOR: "0",
|
|
2314
|
+
npm_config_color: "false",
|
|
2315
|
+
NPM_CONFIG_COLOR: "false",
|
|
2215
2316
|
npm_config_auto_install_peers: "false",
|
|
2216
2317
|
NPM_CONFIG_AUTO_INSTALL_PEERS: "false",
|
|
2217
2318
|
};
|
|
@@ -2855,7 +2956,7 @@ export function recoverProfile(profileDir) {
|
|
|
2855
2956
|
rebuild: rolled?.rebuild,
|
|
2856
2957
|
};
|
|
2857
2958
|
}
|
|
2858
|
-
const validation =
|
|
2959
|
+
const validation = validatePendingProfile(profileDir, pending);
|
|
2859
2960
|
const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
|
|
2860
2961
|
const removeValidation = isRemove
|
|
2861
2962
|
? validateRemoveCompletion(pending.profileDir, candidateName)
|
|
@@ -3958,6 +4059,58 @@ async function selfTest() {
|
|
|
3958
4059
|
if (existsSync(snap.dir)) throw new Error("recoverProfile commit should delete the snapshot dir");
|
|
3959
4060
|
}
|
|
3960
4061
|
|
|
4062
|
+
// A blocker that predates the transaction is still a real profile problem,
|
|
4063
|
+
// but it is not evidence that this update broke the profile. This is the
|
|
4064
|
+
// reported marketplace self-update failure: an unrelated legacy plugin
|
|
4065
|
+
// shadows host modules, so the new mall version used to be rolled back.
|
|
4066
|
+
{
|
|
4067
|
+
const p = join(root, "profiles", "preexisting-blocker");
|
|
4068
|
+
mkdirSync(join(p, "node_modules", "legacy"), { recursive: true });
|
|
4069
|
+
mkdirSync(join(p, "node_modules", "good"), { recursive: true });
|
|
4070
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { legacy: "1.0.0", good: "1.0.0" } }));
|
|
4071
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
4072
|
+
writeFileSync(join(p, "node_modules", "legacy", "package.json"), JSON.stringify({
|
|
4073
|
+
name: "legacy", version: "1.0.0", dependencies: { "@deepseek-ai/dsh-llm": "*" },
|
|
4074
|
+
}));
|
|
4075
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0" }));
|
|
4076
|
+
if (validateInstalledProfile(p).issues.filter((entry) => entry.code === "host-module-shadow").length !== 1) {
|
|
4077
|
+
throw new Error("pre-existing blocker fixture prerequisite failed");
|
|
4078
|
+
}
|
|
4079
|
+
const snap = createProfileSnapshot(p, { fixture: true });
|
|
4080
|
+
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
|
|
4081
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { legacy: "1.0.0", good: "2.0.0" } }));
|
|
4082
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
|
|
4083
|
+
const relative = validatePendingProfile(p);
|
|
4084
|
+
if (!relative.ok || relative.preexistingIssues.length !== 1 || relative.issues.some((entry) => entry.severity === "block")) {
|
|
4085
|
+
throw new Error(`unchanged pre-existing blocker must not condemn this update: ${JSON.stringify(relative)}`);
|
|
4086
|
+
}
|
|
4087
|
+
const rec = recoverProfile(p);
|
|
4088
|
+
if (rec.action !== "committed") throw new Error(`unchanged pre-existing blocker should commit this update, got ${rec.action}`);
|
|
4089
|
+
if (readJson(join(p, "node_modules", "good", "package.json")).version !== "2.0.0") {
|
|
4090
|
+
throw new Error("committing past a pre-existing blocker must keep the updated candidate");
|
|
4091
|
+
}
|
|
4092
|
+
console.log("PASS pending 基线:既有宿主模块 blocker 不再回滚无关更新");
|
|
4093
|
+
|
|
4094
|
+
const candidateProfile = join(root, "profiles", "candidate-own-blocker");
|
|
4095
|
+
mkdirSync(join(candidateProfile, "node_modules", "legacy"), { recursive: true });
|
|
4096
|
+
writeFileSync(join(candidateProfile, "package.json"), JSON.stringify({ dependencies: { legacy: "1.0.0" } }));
|
|
4097
|
+
writeFileSync(join(candidateProfile, "cordis.patch.yml"), "[]\n");
|
|
4098
|
+
writeFileSync(join(candidateProfile, "node_modules", "legacy", "package.json"), JSON.stringify({
|
|
4099
|
+
name: "legacy", version: "1.0.0", dependencies: { "@deepseek-ai/dsh-llm": "*" },
|
|
4100
|
+
}));
|
|
4101
|
+
const ownSnap = createProfileSnapshot(candidateProfile, { fixture: true });
|
|
4102
|
+
markPendingSnapshot(ownSnap, { spec: "legacy@2.0.0", preflight: { candidate: { name: "legacy", version: "2.0.0", kind: "plain" } } });
|
|
4103
|
+
writeFileSync(join(candidateProfile, "node_modules", "legacy", "package.json"), JSON.stringify({
|
|
4104
|
+
name: "legacy", version: "2.0.0", dependencies: { "@deepseek-ai/dsh-llm": "*" },
|
|
4105
|
+
}));
|
|
4106
|
+
const own = validatePendingProfile(candidateProfile);
|
|
4107
|
+
if (own.ok || !own.issues.some((entry) => entry.code === "host-module-shadow")) {
|
|
4108
|
+
throw new Error("an updated candidate must not inherit an exemption for its own old blocker");
|
|
4109
|
+
}
|
|
4110
|
+
commitPendingSnapshot(candidateProfile);
|
|
4111
|
+
console.log("PASS pending 基线:候选自身的既有 blocker 必须在更新中修复");
|
|
4112
|
+
}
|
|
4113
|
+
|
|
3961
4114
|
// Approval-pause mark, part 1: a paused marker must NEVER commit on
|
|
3962
4115
|
// recovery — not even when the static validation would pass (the version
|
|
3963
4116
|
// sits there with its build scripts never approved; committing would drop
|
|
@@ -4602,6 +4755,21 @@ async function selfTest() {
|
|
|
4602
4755
|
if (env.npm_config_auto_install_peers !== "false" || env.NPM_CONFIG_AUTO_INSTALL_PEERS !== "false") {
|
|
4603
4756
|
throw new Error("pnpmGuardEnv must disable peer auto-install");
|
|
4604
4757
|
}
|
|
4758
|
+
if (env.NO_COLOR !== "1" || env.FORCE_COLOR !== "0"
|
|
4759
|
+
|| env.npm_config_color !== "false" || env.NPM_CONFIG_COLOR !== "false") {
|
|
4760
|
+
throw new Error("pnpmGuardEnv must disable terminal colours for browser job logs");
|
|
4761
|
+
}
|
|
4762
|
+
|
|
4763
|
+
let streamed = "";
|
|
4764
|
+
const coloured = await spawnCapture(
|
|
4765
|
+
process.execPath,
|
|
4766
|
+
["-e", "process.stdout.write('\\u001b[96mprobe ok\\u001b[39m\\n')"],
|
|
4767
|
+
{ env: process.env, shell: false },
|
|
4768
|
+
(text) => { streamed += text; },
|
|
4769
|
+
);
|
|
4770
|
+
if (coloured.exitCode !== 0 || coloured.output !== "probe ok\n" || streamed !== "probe ok\n") {
|
|
4771
|
+
throw new Error(`spawnCapture must strip terminal controls from captured and streamed logs: ${JSON.stringify({ coloured, streamed })}`);
|
|
4772
|
+
}
|
|
4605
4773
|
}
|
|
4606
4774
|
|
|
4607
4775
|
// candidateRestoredCompatible: version vs the restored dependency spec.
|
|
@@ -5279,4 +5447,4 @@ if (process.argv[1]?.endsWith("guard.js") && process.argv.includes("--self-test"
|
|
|
5279
5447
|
console.error(`FAIL ${error.stack ?? error.message}`);
|
|
5280
5448
|
process.exitCode = 1;
|
|
5281
5449
|
});
|
|
5282
|
-
}
|
|
5450
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1026,13 +1026,12 @@ export function resolveRestartLaunchPlan({ profile, config = {}, isWindows }) {
|
|
|
1026
1026
|
|
|
1027
1027
|
const nodePath = process.execPath;
|
|
1028
1028
|
const originalDshArgs = process.argv.slice(2);
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
const
|
|
1035
|
-
const dshArgs = suppressOpen ? [...originalDshArgs, "--no-open"] : [...originalDshArgs];
|
|
1029
|
+
// Restart the exact command the user started. `--no-open` belongs to newer
|
|
1030
|
+
// Web profiles, not to the stable dsh launcher contract; adding it here made
|
|
1031
|
+
// older hosts reject the successor with "unknown option '--no-open'" after
|
|
1032
|
+
// the outgoing process had already exited. A duplicate browser tab is less
|
|
1033
|
+
// harmful than inventing an argv capability the running host never proved.
|
|
1034
|
+
const dshArgs = [...originalDshArgs];
|
|
1036
1035
|
// The outgoing host names itself so `guard launch` can wait for it to be
|
|
1037
1036
|
// gone before binding the port — see --await-exit in cli.js.
|
|
1038
1037
|
const args = [cliPath, "guard", "launch", "--profile", name, "--await-exit", String(process.pid), "--", nodePath, dshEntry, ...dshArgs];
|
|
@@ -1045,7 +1044,6 @@ export function resolveRestartLaunchPlan({ profile, config = {}, isWindows }) {
|
|
|
1045
1044
|
dshEntry,
|
|
1046
1045
|
dshArgs,
|
|
1047
1046
|
profile: name,
|
|
1048
|
-
suppressedBrowserOpen: suppressOpen,
|
|
1049
1047
|
awaitExitPid: process.pid,
|
|
1050
1048
|
};
|
|
1051
1049
|
}
|
|
@@ -3219,6 +3217,98 @@ export async function runSelfTests() {
|
|
|
3219
3217
|
check("tracker 复制 producer 签发的 approvalToken(同 session 可见)",
|
|
3220
3218
|
snapSameSession.approvalToken === trackerTok);
|
|
3221
3219
|
|
|
3220
|
+
// 异 session 即使拿到 token 字符串也消费不掉,且归属失败的尝试不许把
|
|
3221
|
+
// 正主的 token 烧掉(报 session mismatch,不是 already consumed)。
|
|
3222
|
+
// jobs 镜像里带着 token,复制标签页能拿到那串字符——挡住它的正是归属。
|
|
3223
|
+
const wrongOwnerConsume = consumeApprovalToken({
|
|
3224
|
+
token: trackerTok,
|
|
3225
|
+
profile: "web",
|
|
3226
|
+
profileDir,
|
|
3227
|
+
spec: "foo-script",
|
|
3228
|
+
preflightReport: cleanPreflightReport,
|
|
3229
|
+
surface: "browser",
|
|
3230
|
+
owner: "session-beta",
|
|
3231
|
+
});
|
|
3232
|
+
check("异 session 即使拿到 token 字符串也消费失败(session mismatch)",
|
|
3233
|
+
wrongOwnerConsume.valid === false && /session mismatch/.test(wrongOwnerConsume.reason),
|
|
3234
|
+
`reason=${wrongOwnerConsume.reason}`);
|
|
3235
|
+
check("归属失败的尝试没有烧掉 token(正主仍可取回)",
|
|
3236
|
+
sessionTracker.get(sessionJobId, "session-alpha").snapshot.approvalToken === trackerTok);
|
|
3237
|
+
|
|
3238
|
+
// ── 前端 session 身份(client.js 的真实源码,跑在受控假环境里)────────
|
|
3239
|
+
//
|
|
3240
|
+
// 这段测的是浏览器那半边:nonce 怎么生成、什么时候复用、什么时候必须
|
|
3241
|
+
// 换新。它从 src/client.js 里把函数定义原样抠出来执行——不是在这里重写
|
|
3242
|
+
// 一份等价逻辑,否则改坏了 client.js 测试照样绿。
|
|
3243
|
+
//
|
|
3244
|
+
// 钉住的四件事,每一件都对应一个真实故障:
|
|
3245
|
+
// 1. 重挂载复用 → 不复用就是 initial install 死锁(本次修的 bug);
|
|
3246
|
+
// 2. 新 window 必换新 → 换不了就是跨 tab 冒用别人的审批身份;
|
|
3247
|
+
// 3. 非法值必换新 → 别的脚本写坏了不能就这么发给后端;
|
|
3248
|
+
// 4. 全程不碰任何存储 → sessionStorage 会随「复制标签页」被复制,
|
|
3249
|
+
// 而 jobs 镜像里就带着 token,一起复制过去等于连身份带凭证都送出。
|
|
3250
|
+
{
|
|
3251
|
+
const clientSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "client.js"), "utf8");
|
|
3252
|
+
// 从 `function name(` 起按花括号配平截取,拿到完整定义。
|
|
3253
|
+
const extractFunction = (name) => {
|
|
3254
|
+
const start = clientSource.indexOf(`function ${name}(`);
|
|
3255
|
+
if (start === -1) throw new Error(`client.js 里找不到 function ${name}`);
|
|
3256
|
+
let depth = 0;
|
|
3257
|
+
let seenBrace = false;
|
|
3258
|
+
for (let i = start; i < clientSource.length; i++) {
|
|
3259
|
+
const ch = clientSource[i];
|
|
3260
|
+
if (ch === "{") { depth++; seenBrace = true; } else if (ch === "}") depth--;
|
|
3261
|
+
if (seenBrace && depth === 0) return clientSource.slice(start, i + 1);
|
|
3262
|
+
}
|
|
3263
|
+
throw new Error(`function ${name} 的花括号不配平`);
|
|
3264
|
+
};
|
|
3265
|
+
const reLine = clientSource.split("\n").find((line) => line.includes("var BROWSER_SESSION_RE ="));
|
|
3266
|
+
const propLine = clientSource.split("\n").find((line) => line.includes("var SESSION_NONCE_PROP ="));
|
|
3267
|
+
if (reLine === undefined || propLine === undefined) throw new Error("client.js 里找不到 session 常量定义");
|
|
3268
|
+
const snippet = [
|
|
3269
|
+
reLine.trim(),
|
|
3270
|
+
propLine.trim(),
|
|
3271
|
+
extractFunction("generateSessionNonce"),
|
|
3272
|
+
extractFunction("readOrCreateBrowserSession"),
|
|
3273
|
+
"return { readOrCreateBrowserSession: readOrCreateBrowserSession, SESSION_NONCE_PROP: SESSION_NONCE_PROP, BROWSER_SESSION_RE: BROWSER_SESSION_RE };",
|
|
3274
|
+
].join("\n");
|
|
3275
|
+
// window 传 undefined:函数只该碰传进来的 scope,碰全局 window 就 ReferenceError。
|
|
3276
|
+
const front = new Function("window", snippet)(undefined);
|
|
3277
|
+
|
|
3278
|
+
// 假标签页:带一个会记账的 sessionStorage,用来证明它一次都没被碰过。
|
|
3279
|
+
let storageTouches = 0;
|
|
3280
|
+
const makeTab = () => ({
|
|
3281
|
+
sessionStorage: {
|
|
3282
|
+
getItem: () => { storageTouches++; return null; },
|
|
3283
|
+
setItem: () => { storageTouches++; },
|
|
3284
|
+
},
|
|
3285
|
+
});
|
|
3286
|
+
|
|
3287
|
+
const tabA = makeTab();
|
|
3288
|
+
const first = front.readOrCreateBrowserSession(tabA);
|
|
3289
|
+
check("前端 session:首次挂载生成合法 nonce 并挂在 window 上",
|
|
3290
|
+
front.BROWSER_SESSION_RE.test(first) && tabA[front.SESSION_NONCE_PROP] === first, `nonce=${first}`);
|
|
3291
|
+
|
|
3292
|
+
const remounted = front.readOrCreateBrowserSession(tabA);
|
|
3293
|
+
check("前端 session:同一页面重挂载复用同一个 nonce(token 因此仍归本人)",
|
|
3294
|
+
remounted === first);
|
|
3295
|
+
|
|
3296
|
+
const tabB = makeTab();
|
|
3297
|
+
const otherTab = front.readOrCreateBrowserSession(tabB);
|
|
3298
|
+
check("前端 session:新 window(新 tab / 复制标签页)拿到的是另一个 nonce",
|
|
3299
|
+
front.BROWSER_SESSION_RE.test(otherTab) && otherTab !== first);
|
|
3300
|
+
|
|
3301
|
+
const tampered = makeTab();
|
|
3302
|
+
tampered[front.SESSION_NONCE_PROP] = "sess_not-a-valid-nonce";
|
|
3303
|
+
const replaced = front.readOrCreateBrowserSession(tampered);
|
|
3304
|
+
check("前端 session:window 上的值格式非法 → 丢弃并重新生成",
|
|
3305
|
+
front.BROWSER_SESSION_RE.test(replaced) && replaced !== "sess_not-a-valid-nonce"
|
|
3306
|
+
&& tampered[front.SESSION_NONCE_PROP] === replaced);
|
|
3307
|
+
|
|
3308
|
+
check("前端 session:全程不读写任何存储(sessionStorage 会随复制标签页一起复制,jobs 镜像里带着 token)",
|
|
3309
|
+
storageTouches === 0, `touches=${storageTouches}`);
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3222
3312
|
let cancelRefused = false;
|
|
3223
3313
|
try {
|
|
3224
3314
|
sessionTracker.cancel(sessionJobId, "session-beta");
|
|
@@ -3350,15 +3440,15 @@ export async function runSelfTests() {
|
|
|
3350
3440
|
const dshArgs = plan.args.slice(dashDash + 3); // -- node <dshEntry> …
|
|
3351
3441
|
check("重启带 --await-exit 且是本进程 pid", plan.args[plan.args.indexOf("--await-exit") + 1] === String(process.pid) && plan.awaitExitPid === process.pid);
|
|
3352
3442
|
check("--await-exit 排在 `--` 之前(是 guard 的参数,不是 dsh 的)", plan.args.indexOf("--await-exit") < dashDash);
|
|
3353
|
-
check("
|
|
3354
|
-
check("原始 dsh 参数原样保留", dshArgs.
|
|
3443
|
+
check("重启不注入宿主版本相关参数", dshArgs.join(" ") === "--profile web" && !dshArgs.includes("--no-open"));
|
|
3444
|
+
check("原始 dsh 参数原样保留", dshArgs.join(" ") === "--profile web");
|
|
3355
3445
|
check("plan 附带可见模式所需的 dshArgs", plan.dshArgs.join(" ") === dshArgs.join(" "));
|
|
3356
3446
|
|
|
3357
|
-
//
|
|
3447
|
+
// 用户自己传给宿主的参数仍逐字保留。
|
|
3358
3448
|
process.argv = [process.execPath, "/x/bin.js", "--profile", "web", "--no-open"];
|
|
3359
3449
|
const already = resolveRestartLaunchPlan({ profile: "web", config: { allowRestart: true } });
|
|
3360
3450
|
const alreadyArgs = already.ok ? already.args.slice(already.args.indexOf("--") + 3) : [];
|
|
3361
|
-
check("
|
|
3451
|
+
check("用户原有 --no-open 原样保留", already.ok && alreadyArgs.filter((a) => a === "--no-open").length === 1);
|
|
3362
3452
|
} else {
|
|
3363
3453
|
// 裸检出里解析不到官方 dsh 入口,plan 只能 fail——说清楚,别假装验过。
|
|
3364
3454
|
console.log(` SKIP 重启 argv fixture(${plan.error})`);
|
|
@@ -3546,7 +3636,7 @@ export async function runSelfTests() {
|
|
|
3546
3636
|
readyFile: join(fileRoot, "r1.json"),
|
|
3547
3637
|
cwd: "C:/w",
|
|
3548
3638
|
command: "C:/node/node.exe",
|
|
3549
|
-
args: ["C:/dsh/index.js", "web"
|
|
3639
|
+
args: ["C:/dsh/index.js", "web"],
|
|
3550
3640
|
};
|
|
3551
3641
|
check("plan payload 校验通过", validateRestartPlanPayload(basePlan).ok === true);
|
|
3552
3642
|
for (const [override, needle] of [
|
|
@@ -3744,7 +3834,7 @@ export async function runSelfTests() {
|
|
|
3744
3834
|
nodePath: process.execPath,
|
|
3745
3835
|
cliPath: join(visibleRoot, "cli.js"),
|
|
3746
3836
|
dshEntry: join(visibleRoot, "dsh-entry.js"),
|
|
3747
|
-
dshArgs: ["--profile", "web"
|
|
3837
|
+
dshArgs: ["--profile", "web"],
|
|
3748
3838
|
profile: "web",
|
|
3749
3839
|
awaitExitPid: 4242,
|
|
3750
3840
|
};
|