dsh-remote-web 0.7.9 → 0.7.10-beta.10

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.
@@ -0,0 +1,399 @@
1
+ /**
2
+ * 能力探测:找 Claude Code、问它版本、看用户有没有自己的审批钩子。
3
+ *
4
+ * ## 三件事,一件也不能省
5
+ *
6
+ * ① **找 CLI**(四级解析,见 `buildClaudeCandidates`)。真实世界里 Claude Code 至少有三种装法:
7
+ * npm 全局(`npm i -g @anthropic-ai/claude-code`)、原生安装器、以及"npm 装了但 postinstall 没跑"
8
+ * (此时 `bin/claude.exe` 只有占位、要靠 `cli-wrapper.cjs` 兜底)。**只认一种装法 = 一部分用户直接不可用**。
9
+ *
10
+ * ② **问版本**。第 1 周实测:2.1.195 不咨询 `PermissionRequest` 钩子,2.1.318 可以(报告 §2.2)。
11
+ * 版本不够就必须**明确不可用**,不能静默丢审批。
12
+ *
13
+ * ③ **看既有审批钩子**。实测:用户/项目已有的 `PermissionRequest` 钩子会被我们"抢先放行"
14
+ * (项目钩子 deny + 我们 allow → 工具执行了,报告 §6.5)。企业里那是策略拦截,绕过去是安全事故。
15
+ * 所以只要发现既有钩子,我们就**让位**(notify-only),不注入自己的。
16
+ *
17
+ * ## 为什么实现得这么"碎"
18
+ *
19
+ * 每个外部动作(找文件、跑进程、读配置)都通过参数注入,默认实现用 Node 内置。
20
+ * 这样单测可以完全不依赖真机:不 spawn 真 claude、不读真 `~/.claude`。
21
+ * 这是"零依赖 + 可测"的代价,也是 M2 敢在没有 Windows 真机的情况下先把逻辑写对的前提。
22
+ */
23
+
24
+ import fsSync from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { spawnSync as nodeSpawnSync, execFileSync as nodeExecFileSync } from "node:child_process";
28
+
29
+ import {
30
+ checkVersionGate,
31
+ formatVersion,
32
+ parseClaudeVersion,
33
+ resolveMinVersion
34
+ } from "./version.mjs";
35
+
36
+ /** 显式覆盖 CLI 路径(排障/企业定制)。 */
37
+ export const CLAUDE_BIN_ENV = "DSH_AGENT_CLAUDE_BIN";
38
+ /** 显式覆盖 npm 全局根(Windows 自定义 prefix、以及单测注入)。 */
39
+ export const NPM_ROOT_ENV = "DSH_AGENT_NPM_ROOT";
40
+
41
+ /** npm 包名(与 `npm root -g` 拼出包内路径)。 */
42
+ const PKG = "@anthropic-ai/claude-code";
43
+ /** 包内原生可执行名。**注意:macOS/Linux 上也是 `claude.exe` 这个文件名**(install.cjs 的复制目标)。 */
44
+ const PKG_EXE = "bin/claude.exe";
45
+ /** postinstall 没跑时的兜底启动器(Node 脚本,跨平台)。 */
46
+ const PKG_WRAPPER = "cli-wrapper.cjs";
47
+
48
+ /** 钩子扫描覆盖的 settings 位置(只读,不写)。 */
49
+ export const SETTINGS_SCOPES = Object.freeze([
50
+ { scope: "user", rel: path.join(".claude", "settings.json") },
51
+ { scope: "project", rel: path.join(".claude", "settings.json") },
52
+ { scope: "local", rel: path.join(".claude", "settings.local.json") }
53
+ ]);
54
+
55
+ const DEFAULT_FS = {
56
+ existsSync: (p) => fsSync.existsSync(p),
57
+ statSync: (p) => fsSync.statSync(p),
58
+ readFileSync: (p, enc) => fsSync.readFileSync(p, enc),
59
+ realpathSync: (p) => fsSync.realpathSync(p),
60
+ constants: fsSync.constants
61
+ };
62
+
63
+ /** 按平台拆 PATH(Windows 用 `;`,其余用 `:`)。 */
64
+ export function splitPathList(pathEnv, platform = process.platform) {
65
+ const sep = platform === "win32" ? ";" : ":";
66
+ return String(pathEnv ?? "")
67
+ .split(sep)
68
+ .map((s) => s.trim())
69
+ .filter(Boolean);
70
+ }
71
+
72
+ /** 可执行判定:存在 + 是文件 + (posix)有执行位。Windows 只看扩展名(.exe)。 */
73
+ function defaultIsExecutable(p, { fs, platform }) {
74
+ try {
75
+ const st = fs.statSync(p);
76
+ if (!st.isFile()) return false;
77
+ if (platform === "win32") return /\.exe$/i.test(p);
78
+ // 有执行位才算(防止把普通文本文件当 CLI 跑)
79
+ return (Number(st.mode) & 0o111) !== 0;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /** 解析符号链接到真实文件(macOS 的 /opt/homebrew/bin/claude 是指向包内 exe 的软链)。 */
86
+ function defaultRealpath(p, fs) {
87
+ try {
88
+ return fs.realpathSync(p);
89
+ } catch {
90
+ return p;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 找 npm 全局根。**不抛错**(探测阶段任何异常都不该让启动崩掉)。
96
+ *
97
+ * 顺序:
98
+ * 1. `DSH_AGENT_NPM_ROOT`(显式覆盖 / 单测注入);
99
+ * 2. `npm root -g`(**Windows 上这条基本走不通**:`npm.cmd` 在 `shell:false` 下不可执行;
100
+ * 这里仍试一次,失败就往下走,不报错);
101
+ * 3. 平台启发式路径(Windows 尤其是 main 路径:`%APPDATA%\npm\node_modules`)。
102
+ *
103
+ * 为什么 Windows 必须有启发式:只看 PATH 只能看到 `claude.cmd`(Node 无 shell 跑不了它),
104
+ * 真正可执行的是 npm 全局根下的 `@anthropic-ai/claude-code/bin/claude.exe`。
105
+ * 找不到 npm 根 = Windows 上直接"不可用",所以这一步值得多给几个候选。
106
+ */
107
+ export function probeNpmRoot({
108
+ execFileSync = nodeExecFileSync,
109
+ platform = process.platform,
110
+ env = {},
111
+ fs = DEFAULT_FS,
112
+ execPath = process.execPath
113
+ } = {}) {
114
+ const override = String(env?.[NPM_ROOT_ENV] ?? "").trim();
115
+ if (override) return override;
116
+
117
+ try {
118
+ const npmExe = platform === "win32" ? "npm.cmd" : "npm";
119
+ const out = execFileSync(npmExe, ["root", "-g"], {
120
+ encoding: "utf8",
121
+ timeout: 8000,
122
+ windowsHide: true,
123
+ shell: false
124
+ });
125
+ const line = String(out ?? "").trim().split(/\r?\n/).filter(Boolean).pop() || "";
126
+ if (line) return line;
127
+ } catch {
128
+ /* 往下走启发式 */
129
+ }
130
+
131
+ const hasPkg = (root) => {
132
+ try {
133
+ return fs.existsSync(path.join(root, "@anthropic-ai", "claude-code"));
134
+ } catch {
135
+ return false;
136
+ }
137
+ };
138
+
139
+ const nodeDir = path.dirname(execPath || "");
140
+ const guesses =
141
+ platform === "win32"
142
+ ? [
143
+ env?.APPDATA ? path.join(env.APPDATA, "npm", "node_modules") : "",
144
+ path.join(nodeDir, "node_modules"),
145
+ env?.ProgramFiles ? path.join(env.ProgramFiles, "nodejs", "node_modules") : ""
146
+ ]
147
+ : [
148
+ path.join(nodeDir, "..", "lib", "node_modules"),
149
+ "/opt/homebrew/lib/node_modules",
150
+ "/usr/local/lib/node_modules",
151
+ "/usr/lib/node_modules"
152
+ ];
153
+
154
+ const list = guesses.filter(Boolean);
155
+ const hit = list.find(hasPkg);
156
+ return hit || list[0] || "";
157
+ }
158
+
159
+ /**
160
+ * 构造 CLI 候选(按优先级从高到低)。**每一项都只是候选**,最终由 `--version` 探测决定谁可用。
161
+ *
162
+ * 1. `DSH_AGENT_CLAUDE_BIN`(显式覆盖,运维/企业定制)
163
+ * 2. PATH 上的 `claude`(posix 会解软链到真实可执行;Windows 只认 `claude.exe`,**不用 .cmd**)
164
+ * 3. npm 全局包内原生 exe:`<npm root -g>/@anthropic-ai/claude-code/bin/claude.exe`
165
+ * 4. `node <npm root -g>/@anthropic-ai/claude-code/cli-wrapper.cjs`(postinstall 未跑时兜底)
166
+ *
167
+ * ⚠️ 为什么不直接用 Windows 的 `claude.cmd`:Node 的 `spawn` 不带 shell 时**不会**解析 `.cmd`
168
+ * (且自 CVE-2024-27980 起拒绝执行),带 shell 又会给用户输入开出命令注入面。
169
+ * 所以要么解析到真实 exe,要么走 wrapper。
170
+ *
171
+ * @returns {Array<{kind:string, command:string, argsPrefix:string[], pathHint?:string}>}
172
+ */
173
+ export function buildClaudeCandidates({
174
+ env = process.env,
175
+ platform = process.platform,
176
+ execPath = process.execPath,
177
+ fs = DEFAULT_FS,
178
+ npmRoot = "",
179
+ isExecutable = (p) => defaultIsExecutable(p, { fs, platform }),
180
+ realpath = (p) => defaultRealpath(p, fs)
181
+ } = {}) {
182
+ const out = [];
183
+ const seen = new Set();
184
+ const push = (c) => {
185
+ // 去重只看"实际要执行的命令 + 参数":同一条命令不该因为来源不同被探测两次
186
+ // (显式 env 与 PATH 指到同一个可执行文件很常见)
187
+ const key = `${c.command}:${(c.argsPrefix || []).join(",")}`;
188
+ if (seen.has(key)) return;
189
+ seen.add(key);
190
+ out.push(c);
191
+ };
192
+
193
+ // 1) 显式覆盖:用户说了算(是否可用仍要探测)
194
+ const explicit = String(env?.[CLAUDE_BIN_ENV] ?? "").trim();
195
+ if (explicit) {
196
+ if (/\.cjs$/i.test(explicit)) {
197
+ push({ kind: "explicit-wrapper", command: execPath, argsPrefix: [explicit], pathHint: explicit });
198
+ } else {
199
+ push({ kind: "explicit", command: explicit, argsPrefix: [], pathHint: explicit });
200
+ }
201
+ }
202
+
203
+ // 2) PATH 上的 claude
204
+ for (const dir of splitPathList(env?.PATH, platform)) {
205
+ const names = platform === "win32" ? ["claude.exe"] : ["claude"];
206
+ for (const name of names) {
207
+ const p = path.join(dir, name);
208
+ if (!isExecutable(p)) continue;
209
+ const real = platform === "win32" ? p : realpath(p);
210
+ push({ kind: "path", command: real, argsPrefix: [], pathHint: p });
211
+ break;
212
+ }
213
+ }
214
+
215
+ // 3) npm 全局包内原生 exe(Windows 上这是最可靠的一条:不依赖 PATH 里的 .cmd)
216
+ if (npmRoot) {
217
+ const exe = path.join(npmRoot, "@anthropic-ai", "claude-code", PKG_EXE);
218
+ if (isExecutable(exe)) push({ kind: "npm-global-exe", command: exe, argsPrefix: [], pathHint: exe });
219
+
220
+ // 4) wrapper 兜底(Node 脚本;跨平台,实测可跑)
221
+ const wrapper = path.join(npmRoot, "@anthropic-ai", "claude-code", PKG_WRAPPER);
222
+ try {
223
+ if (fs.statSync(wrapper).isFile()) {
224
+ push({ kind: "npm-wrapper", command: execPath, argsPrefix: [wrapper], pathHint: wrapper });
225
+ }
226
+ } catch {
227
+ /* 不存在就跳过 */
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /** 探测一个候选的版本。失败不抛错,返回带原因的 object。 */
234
+ export function probeCandidateVersion(candidate, { spawnSync = nodeSpawnSync, timeoutMs = 10_000 } = {}) {
235
+ try {
236
+ const r = spawnSync(candidate.command, [...(candidate.argsPrefix || []), "--version"], {
237
+ encoding: "utf8",
238
+ timeout: timeoutMs,
239
+ windowsHide: true,
240
+ shell: false,
241
+ stdio: ["ignore", "pipe", "pipe"]
242
+ });
243
+ const stdout = String(r?.stdout ?? "");
244
+ const stderr = String(r?.stderr ?? "");
245
+ if (r?.error) {
246
+ return { ok: false, reason: `启动失败: ${r.error.code || r.error.message}`, stdout, stderr };
247
+ }
248
+ if (typeof r?.status === "number" && r.status !== 0) {
249
+ return { ok: false, reason: `退出码 ${r.status}`, stdout, stderr };
250
+ }
251
+ const version = parseClaudeVersion(stdout) || parseClaudeVersion(stderr);
252
+ if (!version) return { ok: false, reason: "输出里没有版本号", stdout, stderr };
253
+ return { ok: true, version, stdout, stderr };
254
+ } catch (err) {
255
+ return { ok: false, reason: `探测异常: ${err?.code || err?.message || err}`, stdout: "", stderr: "" };
256
+ }
257
+ }
258
+
259
+ /**
260
+ * 扫描已有 `PermissionRequest` 钩子(**只读**,绝不写任何 settings)。
261
+ *
262
+ * 找到就必须让位:实测我们的 allow 会抢先于用户/企业自己的 deny 钩子生效(报告 §6.5)。
263
+ *
264
+ * @returns {{present:boolean, sources:Array<{path:string,scope:string}>, warnings:Array<{path:string,error:string}>}}
265
+ */
266
+ export function scanExistingPermissionHooks({
267
+ homeDir = os.homedir(),
268
+ cwd = process.cwd(),
269
+ fs = DEFAULT_FS,
270
+ extraPaths = []
271
+ } = {}) {
272
+ const targets = [];
273
+ for (const { scope, rel } of SETTINGS_SCOPES) {
274
+ const base = scope === "user" ? homeDir : cwd;
275
+ if (!base) continue;
276
+ targets.push({ path: path.join(base, rel), scope });
277
+ }
278
+ for (const p of extraPaths) targets.push({ path: p, scope: "extra" });
279
+
280
+ const sources = [];
281
+ const warnings = [];
282
+ for (const t of targets) {
283
+ let text;
284
+ try {
285
+ text = fs.readFileSync(t.path, "utf8");
286
+ } catch {
287
+ continue; // 不存在 = 正常情况,不告警
288
+ }
289
+ let json;
290
+ try {
291
+ json = JSON.parse(text);
292
+ } catch {
293
+ warnings.push({ path: t.path, error: "invalid-json" });
294
+ continue;
295
+ }
296
+ const list = json && typeof json === "object" ? json?.hooks?.PermissionRequest : undefined;
297
+ if (Array.isArray(list) && list.length > 0) sources.push({ path: t.path, scope: t.scope });
298
+ }
299
+ return { present: sources.length > 0, sources, warnings };
300
+ }
301
+
302
+ /**
303
+ * 一站式探测:找 CLI → 问版本 → 门禁 → 扫既有钩子。
304
+ *
305
+ * **永远不抛错**(返回 `ok:false` + `status`),因为它是 bridge 启动路径上的第一环,
306
+ * 抛错会让挂载方陷入"要不要 try/catch"的不确定性。
307
+ *
308
+ * status 取值:`ok` | `cli-missing` | `version-unknown` | `version-unsupported` | `existing-hooks` | `error`
309
+ */
310
+ export function detectClaude({
311
+ env = process.env,
312
+ platform = process.platform,
313
+ cwd = process.cwd(),
314
+ homeDir = os.homedir(),
315
+ execPath = process.execPath,
316
+ fs = DEFAULT_FS,
317
+ spawnSync = nodeSpawnSync,
318
+ execFileSync = nodeExecFileSync,
319
+ npmRoot,
320
+ extraSettingsPaths = [],
321
+ probeTimeoutMs = 10_000,
322
+ isExecutable,
323
+ realpath
324
+ } = {}) {
325
+ const minInfo = resolveMinVersion(env);
326
+ const warnings = [];
327
+ if (minInfo.warning) warnings.push({ code: "min-version-env", message: minInfo.warning });
328
+
329
+ const hooks = scanExistingPermissionHooks({ homeDir, cwd, fs, extraPaths: extraSettingsPaths });
330
+ for (const w of hooks.warnings) warnings.push({ code: "settings-unreadable", ...w });
331
+
332
+ let root = npmRoot;
333
+ if (root === undefined) root = probeNpmRoot({ execFileSync, platform, env });
334
+
335
+ let candidates;
336
+ try {
337
+ candidates = buildClaudeCandidates({ env, platform, execPath, fs, npmRoot: root, isExecutable, realpath });
338
+ } catch (err) {
339
+ return {
340
+ ok: false,
341
+ status: "error",
342
+ mode: "notify-only",
343
+ reason: `构建 CLI 候选时异常: ${err?.message || err}`,
344
+ min: { version: minInfo.min, text: minInfo.minText, source: minInfo.source },
345
+ hooks,
346
+ warnings
347
+ };
348
+ }
349
+
350
+ const attempts = [];
351
+ for (const c of candidates) {
352
+ const r = probeCandidateVersion(c, { spawnSync, timeoutMs: probeTimeoutMs });
353
+ attempts.push({ kind: c.kind, path: c.pathHint || c.command, ok: r.ok, reason: r.ok ? "" : r.reason });
354
+ if (!r.ok) continue;
355
+
356
+ const gate = checkVersionGate({
357
+ version: r.version,
358
+ min: minInfo.min,
359
+ minText: minInfo.minText,
360
+ versionText: formatVersion(r.version)
361
+ });
362
+ const base = {
363
+ min: { version: minInfo.min, text: minInfo.minText, source: minInfo.source },
364
+ cli: { kind: c.kind, path: c.pathHint || c.command, command: c.command, argsPrefix: c.argsPrefix || [] },
365
+ version: { ...r.version, text: formatVersion(r.version) },
366
+ gate,
367
+ hooks,
368
+ attempts,
369
+ warnings
370
+ };
371
+ if (!gate.ok) return { ...base, ok: false, status: gate.status, mode: "notify-only", reason: gate.reason };
372
+ if (hooks.present) {
373
+ return {
374
+ ...base,
375
+ ok: false,
376
+ status: "existing-hooks",
377
+ mode: "notify-only",
378
+ reason: "检测到本机已有 PermissionRequest 审批钩子,远程审批已让位(只通知、不回执),避免绕过本机策略",
379
+ hookSources: hooks.sources
380
+ };
381
+ }
382
+ return { ...base, ok: true, status: "ok", mode: "approval", reason: "" };
383
+ }
384
+
385
+ // 所有候选都探测失败
386
+ const anyCandidate = candidates.length > 0;
387
+ return {
388
+ ok: false,
389
+ status: anyCandidate ? "version-unknown" : "cli-missing",
390
+ mode: "notify-only",
391
+ reason: anyCandidate
392
+ ? "找到了 CLI 候选但都拿不到版本(可能安装损坏,或该版本不支持 --version 的输出格式)"
393
+ : "没有找到 Claude Code(未安装,或不在 PATH / npm 全局目录里)",
394
+ min: { version: minInfo.min, text: minInfo.minText, source: minInfo.source },
395
+ hooks,
396
+ attempts,
397
+ warnings
398
+ };
399
+ }