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,339 @@
1
+ /**
2
+ * Agent Runtime 的入口工厂 —— **默认不启用**,显式 `start()` 才动。
3
+ *
4
+ * 设计约束(来自任务与设计稿):
5
+ * · 不碰 DSH 现有链路:本模块只被 bridge 侧显式调用,不 import 任何 dsh 相关模块;
6
+ * · 默认关:`enabled` 默认 false(也可用 `DSH_AGENT_ENABLED=1` 打开),关闭时**不探测、不占端口、不写文件**;
7
+ * · 启动失败**不抛错**:返回带 `status` 的对象,让挂载方决定"降级成只通知 / 不渲染入口";
8
+ * · 日志只走回调,不直写 stdout;不记录命令原文。
9
+ *
10
+ * ## 挂载方式(给 Lead,详见 agent/README.md)
11
+ *
12
+ * ```js
13
+ * 用法:从 ./agent/index.mjs 导入 createAgentRuntime(不要在这里写 import 语句)
14
+ *
15
+ * ⚠️ 本行以下原本是一句 import 示例,被插件运行时的「闭包扫描」当成了真实依赖
16
+ * (它按正则扫 `from "./..."`,**连注释也算**),于是要求运行时里存在
17
+ * `clients/dsh-remote/agent/agent/index.mjs` → 半装自检永远不通过。
18
+ * 规则:agent/ 下的任何文件**不要在注释里写 `from "./xxx"` 形式的句子**。
19
+ * const runtime = createAgentRuntime({ log: (e) => logger.info(JSON.stringify(e)) });
20
+ * const r = await runtime.start(); // 默认 enabled=false → 立即返回 disabled
21
+ * if (r.ok) {
22
+ * // 给 claude 的启动参数:把 r.launch.args 追加到 CLI 参数
23
+ * // 手机端审批回调:createAgentRuntime({ decide }) 里接
24
+ * }
25
+ * ```
26
+ *
27
+ * ## 状态与降级
28
+ *
29
+ * | status | 含义 | 挂载方该做什么 |
30
+ * |---|---|---|
31
+ * | `disabled` | 未启用 | 不渲染 Agent 入口 |
32
+ * | `cli-missing` | 找不到 Claude Code | 入口灰显 + "请安装 Claude Code" |
33
+ * | `version-unsupported` / `version-unknown` | 版本不够/认不出 | 入口灰显 + "请升级 Claude Code ≥ X" |
34
+ * | `existing-hooks` | 本机已有审批钩子 | **只通知模式**(不回执),UI 明示"远程审批已让位" |
35
+ * | `bridge-error` | 端口/文件写入失败 | 只通知模式 + 错误原因(含排障码) |
36
+ * | `ready` | 审批通道就绪 | 正常启用 |
37
+ */
38
+
39
+ import os from "node:os";
40
+
41
+ import { detectClaude, probeNpmRoot } from "./detect.mjs";
42
+ import { createPermissionBridge, DEFAULT_DECISION_TTL_MS, DEFAULT_HOOK_TIMEOUT_SEC } from "./permission-bridge.mjs";
43
+ import { resolveMinVersion } from "./version.mjs";
44
+
45
+ /** 环境变量:总开关(默认关)。 */
46
+ export const ENABLED_ENV = "DSH_AGENT_ENABLED";
47
+ /** 环境变量:强制只通知模式(企业策略/排障用)。 */
48
+ export const NOTIFY_ONLY_ENV = "DSH_AGENT_NOTIFY_ONLY";
49
+ /** 环境变量:决策窗口 ms。 */
50
+ export const TTL_ENV = "DSH_AGENT_PERMISSION_TTL_MS";
51
+ /** 环境变量:写进临时 settings 的 hook timeout(秒)。 */
52
+ export const HOOK_TIMEOUT_ENV = "DSH_AGENT_HOOK_TIMEOUT_SEC";
53
+ /** 环境变量:桥接端口(0=随机;测试可固定)。 */
54
+ export const PORT_ENV = "DSH_AGENT_BRIDGE_PORT";
55
+ /** 环境变量:临时 settings 父目录(默认 os.tmpdir())。 */
56
+ export const SETTINGS_DIR_ENV = "DSH_AGENT_SETTINGS_DIR";
57
+ /**
58
+ * 环境变量:探测结果缓存时长(ms,默认 5 分钟)。
59
+ *
60
+ * 为什么要缓存:探测要 spawn 一次 `claude --version`。设置面板每次重绘都探,就是
61
+ * "常驻轮询 spawn 子进程" —— 杀软会盯上这种模式(设计稿 §5.3)。缓存把探测压到
62
+ * 用户主动打开面板的频率,同时保留 `probe({force:true})` 给"一键重测"用。
63
+ */
64
+ export const PROBE_CACHE_ENV = "DSH_AGENT_PROBE_CACHE_MS";
65
+ /** 默认探测缓存 5 分钟(远长于面板重绘,短于用户"我刚装了/刚升级"的耐心)。 */
66
+ export const DEFAULT_PROBE_CACHE_MS = 5 * 60_000;
67
+
68
+ /** 对外状态(UI 直接映射文案)。 */
69
+ export const AGENT_STATUS = Object.freeze({
70
+ DISABLED: "disabled",
71
+ CLI_MISSING: "cli-missing",
72
+ VERSION_UNSUPPORTED: "version-unsupported",
73
+ VERSION_UNKNOWN: "version-unknown",
74
+ EXISTING_HOOKS: "existing-hooks",
75
+ BRIDGE_ERROR: "bridge-error",
76
+ READY: "ready"
77
+ });
78
+
79
+ function envFlag(env, name) {
80
+ const v = String(env?.[name] ?? "").trim().toLowerCase();
81
+ return v === "1" || v === "true" || v === "yes" || v === "on";
82
+ }
83
+
84
+ function envInt(env, name, def) {
85
+ const raw = String(env?.[name] ?? "").trim();
86
+ if (!raw) return def;
87
+ const n = Number(raw);
88
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : def;
89
+ }
90
+
91
+ /**
92
+ * 创建 runtime(**不启动**)。
93
+ *
94
+ * @param {object} opts
95
+ * @param {boolean} [opts.enabled] 默认 false(默认关)
96
+ * @param {(req:object)=>Promise<{decision:"allow"|"deny",message?:string}>} [opts.decide]
97
+ * 审批决策来源。M2 第 2 周不接手机;缺省 = fail-closed(TTL 到期 deny)。
98
+ * @param {(entry:object)=>void} [opts.log] 结构化日志
99
+ * @param {object} [opts.deps] 透传给 detect/bridge 的可注入依赖(测试用)
100
+ * @param {object} [opts.env] 默认 process.env
101
+ */
102
+ export function createAgentRuntime({
103
+ enabled = false,
104
+ decide,
105
+ log,
106
+ deps = {},
107
+ env = process.env
108
+ } = {}) {
109
+ const logger = typeof log === "function" ? log : () => {};
110
+ const isEnabled = envFlag(env, ENABLED_ENV) || enabled === true;
111
+ const notifyOnlyForced = envFlag(env, NOTIFY_ONLY_ENV);
112
+
113
+ let bridge = null;
114
+ let status = Object.freeze({ ok: false, status: AGENT_STATUS.DISABLED, mode: "notify-only", reason: "Agent 功能未启用" });
115
+ let started = false;
116
+ let probeCache = null; // {at, value} —— 避免面板重绘反复 spawn CLI
117
+
118
+ /** 停止 / 未启动时的状态(stop 后必须回到它,不能残留上一次的 ready)。 */
119
+ function disabledStatus(reason = "Agent 功能未启用") {
120
+ return Object.freeze({ ok: false, status: AGENT_STATUS.DISABLED, mode: "notify-only", reason });
121
+ }
122
+
123
+ /** 探测(不占端口、不写文件):给"入口灰显 + 原因"用。 */
124
+ function probe() {
125
+ const minInfo = resolveMinVersion(env);
126
+ const detection = detectClaude({
127
+ env,
128
+ cwd: deps.cwd || process.cwd(),
129
+ homeDir: deps.homeDir || os.homedir(),
130
+ execPath: deps.execPath || process.execPath,
131
+ fs: deps.fs,
132
+ spawnSync: deps.spawnSync,
133
+ execFileSync: deps.execFileSync,
134
+ npmRoot: deps.npmRoot,
135
+ platform: deps.platform,
136
+ extraSettingsPaths: deps.extraSettingsPaths,
137
+ probeTimeoutMs: deps.probeTimeoutMs,
138
+ isExecutable: deps.isExecutable,
139
+ realpath: deps.realpath
140
+ });
141
+ return { minInfo, detection };
142
+ }
143
+
144
+ return {
145
+ /** 同步读当前状态(挂载方画 UI 用)。 */
146
+ status() {
147
+ return { ...status };
148
+ },
149
+
150
+ /**
151
+ * 探测但不启动("入口灰显"场景;不打端口、不写盘)。
152
+ * 结果默认缓存 5 分钟(`DSH_AGENT_PROBE_CACHE_MS` 可调),避免面板重绘反复 spawn;
153
+ * `probe({ force: true })` 强制重测("我刚升级了/刚装好")。
154
+ */
155
+ probe({ force = false } = {}) {
156
+ const clock = typeof deps.now === "function" ? deps.now : Date.now;
157
+ const maxAge = envInt(env, PROBE_CACHE_ENV, DEFAULT_PROBE_CACHE_MS);
158
+ const at = clock();
159
+ if (!force && probeCache && at - probeCache.at >= 0 && at - probeCache.at < maxAge) {
160
+ return probeCache.value;
161
+ }
162
+ const { detection } = probe();
163
+ const value = {
164
+ ok: detection.ok,
165
+ status: detection.status,
166
+ mode: detection.mode,
167
+ reason: detection.reason || "",
168
+ claude: detection.cli || null,
169
+ version: detection.version || null,
170
+ gate: detection.gate || null,
171
+ hooks: detection.hooks || null,
172
+ warnings: detection.warnings || []
173
+ };
174
+ probeCache = { at, value };
175
+ return value;
176
+ },
177
+
178
+ /**
179
+ * 启动审批桥接。**永不抛错**:任何失败都转成 `{ok:false,status}`。
180
+ * @returns {Promise<{ok:boolean,status:string,mode:string,reason?:string,
181
+ * launch?:{args:string[]},permission?:object,stop?:Function}>}
182
+ */
183
+ async start() {
184
+ if (started && bridge) {
185
+ return { ...status, stop: () => this.stop() };
186
+ }
187
+
188
+ // ① 默认关:连探测都不做(不占资源、不暴露能力)
189
+ if (!isEnabled) {
190
+ status = Object.freeze({
191
+ ok: false,
192
+ status: AGENT_STATUS.DISABLED,
193
+ mode: "notify-only",
194
+ reason: `Agent 功能未启用(设置 ${ENABLED_ENV}=1 或传入 { enabled: true })`
195
+ });
196
+ return { ...status };
197
+ }
198
+
199
+ // ② 探测:找 CLI / 版本门禁 / 既有钩子
200
+ let probed;
201
+ try {
202
+ probed = probe();
203
+ } catch (err) {
204
+ status = Object.freeze({
205
+ ok: false,
206
+ status: AGENT_STATUS.BRIDGE_ERROR,
207
+ mode: "notify-only",
208
+ reason: `能力探测异常: ${String(err?.message || err).slice(0, 200)}`
209
+ });
210
+ logger({ level: "error", event: "agent.probe-error", message: status.reason });
211
+ return { ...status };
212
+ }
213
+ const { detection, minInfo } = probed;
214
+ const base = {
215
+ claude: detection.cli || null,
216
+ version: detection.version || null,
217
+ minVersion: minInfo.minText,
218
+ hooks: detection.hooks || null
219
+ };
220
+
221
+ if (!detection.ok) {
222
+ const mapped =
223
+ detection.status === "existing-hooks"
224
+ ? AGENT_STATUS.EXISTING_HOOKS
225
+ : detection.status === "cli-missing"
226
+ ? AGENT_STATUS.CLI_MISSING
227
+ : detection.status === "version-unsupported"
228
+ ? AGENT_STATUS.VERSION_UNSUPPORTED
229
+ : detection.status === "version-unknown"
230
+ ? AGENT_STATUS.VERSION_UNKNOWN
231
+ : AGENT_STATUS.BRIDGE_ERROR;
232
+ status = Object.freeze({
233
+ ok: false,
234
+ status: mapped,
235
+ mode: "notify-only",
236
+ reason: detection.reason || "",
237
+ ...base,
238
+ warnings: detection.warnings || []
239
+ });
240
+ logger({ level: mapped === AGENT_STATUS.BRIDGE_ERROR ? "error" : "warn", event: "agent.unavailable", status: mapped, reason: status.reason });
241
+ // 让位模式(existing-hooks)将来要给"只通知"用:这里把 claude 信息也带出去,挂载方仍可启动 CLI(只是不注入钩子)
242
+ return { ...status };
243
+ }
244
+
245
+ // ③ 强制只通知:不注入钩子,明确返回 mode
246
+ if (notifyOnlyForced) {
247
+ status = Object.freeze({
248
+ ok: false,
249
+ status: AGENT_STATUS.EXISTING_HOOKS,
250
+ mode: "notify-only",
251
+ reason: `已由 ${NOTIFY_ONLY_ENV} 强制只通知模式(不注入审批钩子)`,
252
+ ...base
253
+ });
254
+ logger({ level: "info", event: "agent.notify-only", reason: "forced-by-env" });
255
+ return { ...status };
256
+ }
257
+
258
+ // ④ 起桥接
259
+ const ttlMs = envInt(env, TTL_ENV, DEFAULT_DECISION_TTL_MS);
260
+ const hookTimeoutSec = envInt(env, HOOK_TIMEOUT_ENV, DEFAULT_HOOK_TIMEOUT_SEC);
261
+ const port = envInt(env, PORT_ENV, 0);
262
+ const settingsDir = String(env?.[SETTINGS_DIR_ENV] ?? "").trim() || undefined;
263
+
264
+ try {
265
+ bridge = createPermissionBridge({
266
+ port,
267
+ ttlMs,
268
+ hookTimeoutSec,
269
+ settingsDir,
270
+ decide: decide || ((req) => {
271
+ logger({ level: "warn", event: "agent.no-decider", requestId: req.requestId, toolName: req.toolName });
272
+ // 没有决策来源 = fail-closed:立刻拒绝(不等 90s,用户侧无感但语义正确)
273
+ return { decision: "deny", message: "远程审批通道未接入决策来源,已拒绝" };
274
+ }),
275
+ log: (e) => logger(e),
276
+ timers: deps.timers,
277
+ now: deps.now,
278
+ fs: deps.bridgeFs,
279
+ token: deps.token,
280
+ maxPending: deps.maxPending,
281
+ maxBodyBytes: deps.maxBodyBytes
282
+ });
283
+ const startedBridge = await bridge.start();
284
+ started = true;
285
+ status = Object.freeze({
286
+ ok: true,
287
+ status: AGENT_STATUS.READY,
288
+ mode: "approval",
289
+ reason: "",
290
+ ...base,
291
+ port: startedBridge.port,
292
+ settingsPath: startedBridge.settingsPath,
293
+ launch: {
294
+ // 挂载方把这些参数追加到 claude CLI 调用里即可
295
+ args: ["--permission-mode", "default", "--settings", startedBridge.settingsPath]
296
+ },
297
+ permission: {
298
+ ttlMs,
299
+ hookTimeoutSec,
300
+ pendingCount: startedBridge.pendingCount,
301
+ url: startedBridge.url
302
+ }
303
+ });
304
+ logger({ level: "info", event: "agent.ready", port: startedBridge.port, version: base.version?.text || "" });
305
+ return { ...status, stop: () => this.stop() };
306
+ } catch (err) {
307
+ // 端口被占/临时目录不可写等:**不启用**,退回只通知
308
+ await this.stop().catch(() => {});
309
+ status = Object.freeze({
310
+ ok: false,
311
+ status: AGENT_STATUS.BRIDGE_ERROR,
312
+ mode: "notify-only",
313
+ reason: `审批桥接启动失败: ${String(err?.message || err).slice(0, 200)}`,
314
+ ...base
315
+ });
316
+ logger({ level: "error", event: "agent.bridge-error", message: status.reason });
317
+ return { ...status };
318
+ }
319
+ },
320
+
321
+ /** 停止:关端口、删临时 settings。幂等、永远不抛。 */
322
+ async stop() {
323
+ const b = bridge;
324
+ bridge = null;
325
+ started = false;
326
+ status = disabledStatus("Agent 已停止");
327
+ if (!b) return { ok: true };
328
+ try {
329
+ await b.stop();
330
+ return { ok: true };
331
+ } catch (err) {
332
+ logger({ level: "warn", event: "agent.stop-error", message: String(err?.message || err).slice(0, 200) });
333
+ return { ok: false, error: String(err?.message || err).slice(0, 200) };
334
+ }
335
+ }
336
+ };
337
+ }
338
+
339
+ export { probeNpmRoot };