@1e0zj/dsh-plugin-mall 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/client.js +8 -0
- package/src/guard.js +67 -13
- package/src/installer.js +47 -36
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -182,6 +182,9 @@ window.__ModuleLoader__.load({
|
|
|
182
182
|
// carryFromId:把上一阶段(预检)的日志接过来并撤掉它的条目。一次点击
|
|
183
183
|
// 只应该在面板里留下一个任务,日志连续——而不是 market-1 预检、
|
|
184
184
|
// market-2 安装两条并排,让人以为自己点了两次。
|
|
185
|
+
// 重试同理:同一 spec 上一轮失败/完成的终态条目一并撤掉——用户没点
|
|
186
|
+
// 「清空」就重试时,面板照样只留新一轮一条。旧失败日志不拼进新任务
|
|
187
|
+
// (两轮 pnpm 输出混在一起没法读);running 的不动,那是真并发任务。
|
|
185
188
|
var track = useCallback(function (id, spec, carryFromId) {
|
|
186
189
|
var next = Object.assign({}, jobsRef.current);
|
|
187
190
|
var carried = "";
|
|
@@ -189,6 +192,11 @@ window.__ModuleLoader__.load({
|
|
|
189
192
|
carried = next[carryFromId].output || "";
|
|
190
193
|
delete next[carryFromId];
|
|
191
194
|
}
|
|
195
|
+
for (var key in next) {
|
|
196
|
+
if (key !== id && next[key] && next[key].spec === spec && next[key].status !== "running") {
|
|
197
|
+
delete next[key];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
192
200
|
next[id] = { status: "running", spec: spec, output: carried };
|
|
193
201
|
jobsRef.current = next;
|
|
194
202
|
setJobs(Object.assign({}, next));
|
package/src/guard.js
CHANGED
|
@@ -583,9 +583,9 @@ export function inspectRemoteCandidate({ profileDir, manifest, patchText, spec }
|
|
|
583
583
|
}
|
|
584
584
|
|
|
585
585
|
/** Locate a binary on PATH by explicit extension (no shell, no PATHEXT guessing). */
|
|
586
|
-
function findOnPath(binary, extensions) {
|
|
587
|
-
const separator =
|
|
588
|
-
for (const dir of String(
|
|
586
|
+
function findOnPath(binary, { platform, pathEnv, extensions }) {
|
|
587
|
+
const separator = platform === "win32" ? ";" : ":";
|
|
588
|
+
for (const dir of String(pathEnv ?? "").split(separator)) {
|
|
589
589
|
if (dir.length === 0) continue;
|
|
590
590
|
for (const ext of extensions) {
|
|
591
591
|
const candidate = join(dir, `${binary}${ext}`);
|
|
@@ -596,16 +596,32 @@ function findOnPath(binary, extensions) {
|
|
|
596
596
|
}
|
|
597
597
|
|
|
598
598
|
/**
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
599
|
+
* How to spawn pnpm on this platform. installer.js consumes this too, so the
|
|
600
|
+
* plan lives here exactly once instead of as two drifting copies (the mirror
|
|
601
|
+
* copies had already diverged once; that is how the quoting bug below stayed
|
|
602
|
+
* invisible on both sides).
|
|
603
|
+
* @returns {{ command: string, shell: boolean, treeKill: boolean }}
|
|
604
|
+
* treeKill marks the shell-wrapped case: cancel must taskkill /T the tree.
|
|
602
605
|
*/
|
|
603
|
-
function pnpmSpawnPlan() {
|
|
604
|
-
if (
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
606
|
+
export function pnpmSpawnPlan({ platform = process.platform, pathEnv = process.env.PATH } = {}) {
|
|
607
|
+
if (platform !== "win32") return { command: "pnpm", shell: false, treeKill: false };
|
|
608
|
+
// A real .exe spawns without a shell — cancel then kills pnpm itself.
|
|
609
|
+
const exe = findOnPath("pnpm", { platform, pathEnv, extensions: [".exe"] });
|
|
610
|
+
if (exe !== undefined) return { command: exe, shell: false, treeKill: false };
|
|
611
|
+
// Only the .cmd shim: Node refuses batch files with shell:false (EINVAL
|
|
612
|
+
// since the batch-file argument-injection fix), so a cmd wrapper is
|
|
613
|
+
// unavoidable — flag it so cancel kills the whole tree, not the wrapper.
|
|
614
|
+
const cmd = findOnPath("pnpm", { platform, pathEnv, extensions: [".cmd"] });
|
|
615
|
+
if (cmd === undefined) return { command: "pnpm", shell: true, treeKill: true };
|
|
616
|
+
// The shim usually sits in a directory with a space (`D:\Program Files\nodejs`
|
|
617
|
+
// is Node's default install layout). Under shell:true Node joins command and
|
|
618
|
+
// args with spaces WITHOUT quoting per argument — the same fact the
|
|
619
|
+
// UNSAFE_SPEC_RE comment below argues from — so an unquoted path is cut at
|
|
620
|
+
// the first space and cmd answers `'D:\Program' is not recognized`, killing
|
|
621
|
+
// every preflight and install on such machines. Quote the command ourselves;
|
|
622
|
+
// the args joined after it are fixed flags plus an assertSafeSpec-validated
|
|
623
|
+
// spec, none of which carry spaces.
|
|
624
|
+
return { command: `"${cmd}"`, shell: true, treeKill: true };
|
|
609
625
|
}
|
|
610
626
|
|
|
611
627
|
function spawnCapture(command, args, options, onOutput) {
|
|
@@ -641,7 +657,9 @@ function spawnCapture(command, args, options, onOutput) {
|
|
|
641
657
|
// not only by the agent/browser callers that happen to check first: anything
|
|
642
658
|
// carrying shell metacharacters, or a Windows `file:`/`link:` path with spaces,
|
|
643
659
|
// is refused before a single byte of filesystem work and before pnpm is spawned.
|
|
644
|
-
|
|
660
|
+
// `%` is on the list like everywhere else: cmd performs `%VAR%` expansion, and
|
|
661
|
+
// the expanded value (often full of spaces and semicolons) reshapes the argv.
|
|
662
|
+
const UNSAFE_SPEC_RE = /[;&|`$()<>^%!"*\n\r]/;
|
|
645
663
|
|
|
646
664
|
function assertSafeSpec(spec) {
|
|
647
665
|
const value = String(spec ?? "");
|
|
@@ -1888,6 +1906,12 @@ async function selfTest() {
|
|
|
1888
1906
|
if (rep.verdict !== "blocked" || !rep.issues.some((entry) => entry.code === "unsafe-spec")) {
|
|
1889
1907
|
throw new Error("preflightInstall should reject a spec with shell metacharacters before spawning");
|
|
1890
1908
|
}
|
|
1909
|
+
// `%` 单独成案:它不是 POSIX 元字符,但 cmd 会做 %VAR% 展开,展开值
|
|
1910
|
+
// 里的分号/空格足以重塑 argv——黑名单曾漏掉它,与 cli.js 漂移过。
|
|
1911
|
+
const repPct = await preflightInstall({ profileDir: p, spec: "evil-pkg%PATH%" });
|
|
1912
|
+
if (repPct.verdict !== "blocked" || !repPct.issues.some((entry) => entry.code === "unsafe-spec")) {
|
|
1913
|
+
throw new Error("preflightInstall should reject a spec carrying cmd %VAR% expansion");
|
|
1914
|
+
}
|
|
1891
1915
|
if (process.platform === "win32") {
|
|
1892
1916
|
const repWin = await preflightInstall({ profileDir: p, spec: "file:C:\\some dir\\pkg" });
|
|
1893
1917
|
if (repWin.verdict !== "blocked" || !repWin.issues.some((entry) => entry.code === "unsafe-spec")) {
|
|
@@ -1906,6 +1930,36 @@ async function selfTest() {
|
|
|
1906
1930
|
if (!args.includes("--ignore-scripts")) throw new Error("probe args must keep install scripts disabled");
|
|
1907
1931
|
}
|
|
1908
1932
|
|
|
1933
|
+
// pnpmSpawnPlan (pure, plus a real spawn on Windows): the .cmd shim path
|
|
1934
|
+
// must carry its own quotes. Node's shell:true joins command and args
|
|
1935
|
+
// without per-argument quoting, so `D:\Program Files\nodejs\pnpm.CMD`
|
|
1936
|
+
// would be cut at the first space and cmd would answer
|
|
1937
|
+
// `'D:\Program' is not recognized` — that exact failure blocked every
|
|
1938
|
+
// preflight on a real machine (Node's default install layout has a space).
|
|
1939
|
+
{
|
|
1940
|
+
const shimRoot = join(root, "path with space");
|
|
1941
|
+
mkdirSync(shimRoot, { recursive: true });
|
|
1942
|
+
writeFileSync(join(shimRoot, "pnpm.cmd"), "@echo probe-ok\r\n");
|
|
1943
|
+
const plan = pnpmSpawnPlan({ platform: "win32", pathEnv: shimRoot });
|
|
1944
|
+
if (plan.shell !== true || plan.treeKill !== true || plan.command !== `"${join(shimRoot, "pnpm.cmd")}"`) {
|
|
1945
|
+
throw new Error(`quoted .cmd plan expected, got ${JSON.stringify(plan)}`);
|
|
1946
|
+
}
|
|
1947
|
+
if (pnpmSpawnPlan({ platform: "linux", pathEnv: shimRoot }).command !== "pnpm") {
|
|
1948
|
+
throw new Error("posix plan should spawn pnpm directly");
|
|
1949
|
+
}
|
|
1950
|
+
const noShim = pnpmSpawnPlan({ platform: "win32", pathEnv: join(root, "no-shim-here") });
|
|
1951
|
+
if (noShim.command !== "pnpm" || noShim.shell !== true || noShim.treeKill !== true) {
|
|
1952
|
+
throw new Error(`missing-shim fallback expected, got ${JSON.stringify(noShim)}`);
|
|
1953
|
+
}
|
|
1954
|
+
// 引用不是摆设:Windows 真机端到端 spawn 一轮(CI 是 Linux,只跑静态断言)。
|
|
1955
|
+
if (process.platform === "win32") {
|
|
1956
|
+
const probe = spawnSync(plan.command, ["--version"], { shell: plan.shell, encoding: "utf8", timeout: 15000 });
|
|
1957
|
+
if (probe.status !== 0 || !/probe-ok/.test(probe.stdout ?? "")) {
|
|
1958
|
+
throw new Error(`quoted shim must actually run: status=${probe.status} stdout=${JSON.stringify(probe.stdout)} stderr=${JSON.stringify(probe.stderr)}`);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1909
1963
|
// Rollback reconcile args/env (pure): scripts off, the restored lockfile
|
|
1910
1964
|
// authoritative, peer auto-install off, strictly offline — there is no
|
|
1911
1965
|
// online retry path or args at all, and nothing user-controlled anywhere
|
package/src/installer.js
CHANGED
|
@@ -16,7 +16,7 @@ import { createHash } from "node:crypto";
|
|
|
16
16
|
import { dump, load } from "js-yaml";
|
|
17
17
|
import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
18
18
|
import { describeBuildScripts, npmNameOf } from "./github.js";
|
|
19
|
-
import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, rollbackPendingSnapshot } from "./guard.js";
|
|
19
|
+
import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, pnpmSpawnPlan, rollbackPendingSnapshot } from "./guard.js";
|
|
20
20
|
|
|
21
21
|
// ── spec normalization ──────────────────────────────────────────────────────
|
|
22
22
|
|
|
@@ -1132,8 +1132,10 @@ export function createJobTracker() {
|
|
|
1132
1132
|
|
|
1133
1133
|
// Windows spawn 走 shell,spec 会被拼进 cmd 行;agent 传入的参数不可信。
|
|
1134
1134
|
// 合法的 npm 名 / github:owner\/repo / git·file·link·URL spec 都不含这些
|
|
1135
|
-
// shell
|
|
1136
|
-
|
|
1135
|
+
// shell 元字符——出现即拒绝,宁可误杀不放开命令注入面。`%` 在列:cmd 会
|
|
1136
|
+
// 做 `%VAR%` 环境变量展开,展开结果常含分号/空格,足以改变参数切分。
|
|
1137
|
+
// (cli.js 的同款黑名单一直含 %,此前三处已经漂移。)
|
|
1138
|
+
const UNSAFE_SPEC_RE = /[;&|`$()<>^%!"*\n\r]/;
|
|
1137
1139
|
|
|
1138
1140
|
/** Reject install/remove specs carrying shell metacharacters. */
|
|
1139
1141
|
export function assertSafeSpec(spec) {
|
|
@@ -1258,36 +1260,11 @@ function serializedProducer(lockKey, start) {
|
|
|
1258
1260
|
// (a .cmd shim cannot be spawned with shell:false on modern Node), cancel
|
|
1259
1261
|
// terminates the whole process tree and the done chain waits for the
|
|
1260
1262
|
// wrapper's 'close' before any rollback runs.
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
if (dir.length === 0) continue;
|
|
1267
|
-
for (const ext of extensions) {
|
|
1268
|
-
const candidate = join(dir, `${binary}${ext}`);
|
|
1269
|
-
if (existsSync(candidate)) return candidate;
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
return undefined;
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
/**
|
|
1276
|
-
* How to spawn pnpm on this platform.
|
|
1277
|
-
* @returns {{ command: string, shell: boolean, treeKill: boolean }}
|
|
1278
|
-
* treeKill marks the shell-wrapped case: cancel must taskkill /T the tree.
|
|
1279
|
-
*/
|
|
1280
|
-
function pnpmSpawnPlan({ platform = process.platform, pathEnv = process.env.PATH } = {}) {
|
|
1281
|
-
if (platform !== "win32") return { command: "pnpm", shell: false, treeKill: false };
|
|
1282
|
-
// A real .exe spawns without a shell — cancel then kills pnpm itself.
|
|
1283
|
-
const exe = findOnPath("pnpm", { platform, pathEnv, extensions: [".exe"] });
|
|
1284
|
-
if (exe !== undefined) return { command: exe, shell: false, treeKill: false };
|
|
1285
|
-
// Only the .cmd shim: Node refuses batch files with shell:false (EINVAL
|
|
1286
|
-
// since the batch-file argument-injection fix), so a cmd wrapper is
|
|
1287
|
-
// unavoidable — flag it so cancel kills the whole tree, not the wrapper.
|
|
1288
|
-
const cmd = findOnPath("pnpm", { platform, pathEnv, extensions: [".cmd"] });
|
|
1289
|
-
return { command: cmd ?? "pnpm", shell: true, treeKill: true };
|
|
1290
|
-
}
|
|
1263
|
+
//
|
|
1264
|
+
// The plan itself is pnpmSpawnPlan in guard.js (imported above) — one
|
|
1265
|
+
// implementation, not a local mirror. It quotes the .cmd path: Node's
|
|
1266
|
+
// shell:true joins without per-argument quoting, and `D:\Program Files\…`
|
|
1267
|
+
// would be cut at the first space (`'D:\Program' is not recognized`).
|
|
1291
1268
|
|
|
1292
1269
|
/**
|
|
1293
1270
|
* Terminate a shell-wrapped process tree (Windows): taskkill /T /F kills the
|
|
@@ -2051,6 +2028,17 @@ async function runTransactionFixtures() {
|
|
|
2051
2028
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 1));
|
|
2052
2029
|
const flush = async (rounds = 5) => { for (let index = 0; index < rounds; index++) await tick(); };
|
|
2053
2030
|
|
|
2031
|
+
// 纯函数前置:spec 黑名单。`%` 必须在内——cmd 的 %VAR% 展开元字符,
|
|
2032
|
+
// 展开值里的分号/空格足以重塑 argv;三处黑名单曾漂移(cli.js 一直有,
|
|
2033
|
+
// installer/guard 漏过)。
|
|
2034
|
+
{
|
|
2035
|
+
const rejects = (value) => {
|
|
2036
|
+
try { assertSafeSpec(value); return false; } catch { return true; }
|
|
2037
|
+
};
|
|
2038
|
+
check("assertSafeSpec:cmd 的 %VAR% 展开元字符被拒绝", rejects("evil-pkg%PATH%"));
|
|
2039
|
+
check("assertSafeSpec:正常 spec 不受影响", !rejects("some-plugin@1.0.0") && !rejects("github:owner/repo"));
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2054
2042
|
// 0. hashPackageTree 确定性与符号链接越界防御
|
|
2055
2043
|
{
|
|
2056
2044
|
const tempDir = mkdtempSync(join(tmpdir(), "dsh-mall-selftest-hash-"));
|
|
@@ -2567,7 +2555,9 @@ async function runTransactionFixtures() {
|
|
|
2567
2555
|
}
|
|
2568
2556
|
|
|
2569
2557
|
// 4a. spawn 计划(纯函数):非 Windows 无 shell;Windows 有 .exe 则
|
|
2570
|
-
// shell:false,仅 .cmd 则 shell:true + treeKill
|
|
2558
|
+
// shell:false,仅 .cmd 则 shell:true + treeKill 且 command 自带引号
|
|
2559
|
+
// (.cmd 常在 `D:\Program Files\nodejs` 这类带空格的目录里,shell:true 下
|
|
2560
|
+
// Node 只拼空格不逐参数引用,不引用就从空格截断),全找不到回退 "pnpm"。
|
|
2571
2561
|
{
|
|
2572
2562
|
const shimDir = mkdtempSync(join(tmpdir(), "dsh-mall-selftest-path-"));
|
|
2573
2563
|
try {
|
|
@@ -2577,15 +2567,36 @@ async function runTransactionFixtures() {
|
|
|
2577
2567
|
const missingOk = planMissing.command === "pnpm" && planMissing.shell === true && planMissing.treeKill === true;
|
|
2578
2568
|
writeFileSync(join(shimDir, "pnpm.cmd"), "@echo off\r\n");
|
|
2579
2569
|
const planCmd = pnpmSpawnPlan({ platform: "win32", pathEnv: shimDir });
|
|
2580
|
-
const cmdOk = planCmd.command === join(shimDir, "pnpm.cmd") && planCmd.shell === true && planCmd.treeKill === true;
|
|
2570
|
+
const cmdOk = planCmd.command === `"${join(shimDir, "pnpm.cmd")}"` && planCmd.shell === true && planCmd.treeKill === true;
|
|
2581
2571
|
writeFileSync(join(shimDir, "pnpm.exe"), "MZ");
|
|
2582
2572
|
const planExe = pnpmSpawnPlan({ platform: "win32", pathEnv: shimDir });
|
|
2583
2573
|
const exeOk = planExe.command === join(shimDir, "pnpm.exe") && planExe.shell === false && planExe.treeKill === false;
|
|
2584
2574
|
check(
|
|
2585
|
-
"pnpmSpawnPlan:posix 直起 / win32 优先 .exe 无 shell / 仅 .cmd
|
|
2575
|
+
"pnpmSpawnPlan:posix 直起 / win32 优先 .exe 无 shell / 仅 .cmd 则带引号 + treeKill",
|
|
2586
2576
|
posixOk && missingOk && cmdOk && exeOk,
|
|
2587
2577
|
JSON.stringify({ planPosix, planMissing, planCmd, planExe }),
|
|
2588
2578
|
);
|
|
2579
|
+
// 空格目录不是假想敌:Node 默认装进 Program Files,真实机器上命令从
|
|
2580
|
+
// 空格截断曾让「'D:\Program' 不是内部或外部命令」拦下所有预检。
|
|
2581
|
+
const spaceDir = join(shimDir, "dir with space");
|
|
2582
|
+
mkdirSync(spaceDir, { recursive: true });
|
|
2583
|
+
writeFileSync(join(spaceDir, "pnpm.cmd"), "@echo probe-ok\r\n");
|
|
2584
|
+
const planSpace = pnpmSpawnPlan({ platform: "win32", pathEnv: spaceDir });
|
|
2585
|
+
check(
|
|
2586
|
+
"pnpmSpawnPlan:带空格的 .cmd 路径 → command 自带引号",
|
|
2587
|
+
planSpace.shell === true && planSpace.command === `"${join(spaceDir, "pnpm.cmd")}"`,
|
|
2588
|
+
JSON.stringify(planSpace),
|
|
2589
|
+
);
|
|
2590
|
+
// 引用必须真的可跑:Windows 真机端到端 spawn 一轮假 shim(CI 是 Linux,
|
|
2591
|
+
// 只跑静态断言;Windows 上这一条覆盖完整链路)。
|
|
2592
|
+
if (process.platform === "win32") {
|
|
2593
|
+
const probe = spawnSync(planSpace.command, ["--version"], { shell: planSpace.shell, encoding: "utf8", timeout: 15000 });
|
|
2594
|
+
check(
|
|
2595
|
+
"pnpmSpawnPlan:带引号 command 真实 spawn 不再被空格截断",
|
|
2596
|
+
probe.status === 0 && /probe-ok/.test(probe.stdout ?? ""),
|
|
2597
|
+
`status=${probe.status} stdout=${JSON.stringify((probe.stdout ?? "").slice(0, 80))} stderr=${JSON.stringify((probe.stderr ?? "").slice(0, 80))}`,
|
|
2598
|
+
);
|
|
2599
|
+
}
|
|
2589
2600
|
} finally {
|
|
2590
2601
|
rmSync(shimDir, { recursive: true, force: true });
|
|
2591
2602
|
}
|