dsh-remote-bridge 2.5.2 → 2.6.0

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/lib/index.js CHANGED
@@ -2926,7 +2926,7 @@ function createDsh02ApiProxy(host, options = {}) {
2926
2926
  }
2927
2927
  //#endregion
2928
2928
  //#region src/shared/version.ts
2929
- const PLUGIN_VERSION = "2.5.2";
2929
+ const PLUGIN_VERSION = "2.6.0";
2930
2930
  //#endregion
2931
2931
  //#region src/host/capabilities.ts
2932
2932
  /**
@@ -3978,7 +3978,7 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
3978
3978
  }
3979
3979
  };
3980
3980
  }
3981
- if (payloadType === "session/event" && typeof payload.sessionId === "string" && isRecord$1(payload.event)) {
3981
+ if (payloadType === "session/event" && typeof payload.sessionId === "string" && isRecord$2(payload.event)) {
3982
3982
  const event = payload.event;
3983
3983
  const eventType = typeof event.type === "string" ? event.type : "unknown";
3984
3984
  const seq = typeof event.seq === "number" ? event.seq : void 0;
@@ -3988,9 +3988,9 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
3988
3988
  ...seq === void 0 ? {} : { seq }
3989
3989
  };
3990
3990
  const assistantData = event.data;
3991
- if (eventType === "assistant/chunk" && isRecord$1(assistantData)) {
3991
+ if (eventType === "assistant/chunk" && isRecord$2(assistantData)) {
3992
3992
  const rawChunk = assistantData.chunk;
3993
- if (isRecord$1(rawChunk)) {
3993
+ if (isRecord$2(rawChunk)) {
3994
3994
  const kind = rawChunk.type === "text-delta" ? "text" : rawChunk.type === "reasoning-delta" ? "reasoning" : void 0;
3995
3995
  if (kind !== void 0 && typeof rawChunk.text === "string" && typeof rawChunk.index === "number") return {
3996
3996
  id: frame.rpcId,
@@ -4008,7 +4008,7 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
4008
4008
  }
4009
4009
  }
4010
4010
  if (eventType === "user/message" || eventType === "assistant/message") {
4011
- const eventData = isRecord$1(event.data) ? event.data : {};
4011
+ const eventData = isRecord$2(event.data) ? event.data : {};
4012
4012
  return {
4013
4013
  id: frame.rpcId,
4014
4014
  type: "chat.message.committed",
@@ -4022,8 +4022,8 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
4022
4022
  };
4023
4023
  }
4024
4024
  if (eventType === "deliverables/presented") {
4025
- const data = isRecord$1(payload.event) ? payload.event.data : void 0;
4026
- const files = isRecord$1(data) && Array.isArray(data.files) ? data.files : [];
4025
+ const data = isRecord$2(payload.event) ? payload.event.data : void 0;
4026
+ const files = isRecord$2(data) && Array.isArray(data.files) ? data.files : [];
4027
4027
  return {
4028
4028
  id: `${payload.sessionId}:deliverables:${seq ?? files.length}`,
4029
4029
  type: "chat.deliverables.presented",
@@ -4051,7 +4051,7 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
4051
4051
  };
4052
4052
  }
4053
4053
  }
4054
- const turnReason = eventType === "turn/end" && isRecord$1(event.data) ? event.data.reason : void 0;
4054
+ const turnReason = eventType === "turn/end" && isRecord$2(event.data) ? event.data.reason : void 0;
4055
4055
  return {
4056
4056
  id: frame.rpcId,
4057
4057
  type: eventType === "turn/start" ? "chat.turn.start" : "chat.turn.end",
@@ -4111,12 +4111,12 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
4111
4111
  * 用本地化文案「API 密钥无效」顶上。这里保持原样转发,不替客户端猜。
4112
4112
  */
4113
4113
  function turnFailureOf(event) {
4114
- const reason = isRecord$1(event.data) ? event.data.reason : void 0;
4115
- if (!isRecord$1(reason)) return void 0;
4114
+ const reason = isRecord$2(event.data) ? event.data.reason : void 0;
4115
+ if (!isRecord$2(reason)) return void 0;
4116
4116
  const kind = reason.kind;
4117
4117
  if (kind === "error") {
4118
4118
  const error = reason.error;
4119
- if (!isRecord$1(error)) return { message: String(error ?? "本轮运行失败") };
4119
+ if (!isRecord$2(error)) return { message: String(error ?? "本轮运行失败") };
4120
4120
  const code = typeof error.code === "string" ? error.code : void 0;
4121
4121
  const message = typeof error.message === "string" ? error.message : JSON.stringify(error);
4122
4122
  return code === void 0 ? { message } : {
@@ -4124,13 +4124,13 @@ function turnFailureOf(event) {
4124
4124
  code
4125
4125
  };
4126
4126
  }
4127
- if (kind === "aborted" && isRecord$1(reason.reason) && reason.reason.kind === "hook" && reason.reason.reason === "deepseek-account/signed-out") return {
4127
+ if (kind === "aborted" && isRecord$2(reason.reason) && reason.reason.kind === "hook" && reason.reason.reason === "deepseek-account/signed-out") return {
4128
4128
  message: "Stopped because you signed out of DeepSeek.",
4129
4129
  code: "ACCOUNT_SIGNED_OUT",
4130
4130
  stopped: true
4131
4131
  };
4132
4132
  }
4133
- function isRecord$1(value) {
4133
+ function isRecord$2(value) {
4134
4134
  return typeof value === "object" && value !== null && !Array.isArray(value);
4135
4135
  }
4136
4136
  function matchRoot(roots, cwd) {
@@ -4216,13 +4216,13 @@ function findToolCallDetail(events, callId) {
4216
4216
  if (callId === void 0) return void 0;
4217
4217
  for (let index = events.length - 1; index >= 0; index -= 1) {
4218
4218
  const candidate = events[index];
4219
- if (!isRecord$1(candidate)) continue;
4220
- const event = isRecord$1(candidate.event) ? candidate.event : candidate;
4221
- if (event.type !== "tool/call" || !isRecord$1(event.data)) continue;
4219
+ if (!isRecord$2(candidate)) continue;
4220
+ const event = isRecord$2(candidate.event) ? candidate.event : candidate;
4221
+ if (event.type !== "tool/call" || !isRecord$2(event.data)) continue;
4222
4222
  const data = event.data;
4223
4223
  if ((typeof data.callId === "string" ? data.callId : typeof data.id === "string" ? data.id : void 0) !== callId) continue;
4224
4224
  const args = decodeToolArguments(data.arguments);
4225
- if (isRecord$1(args) && typeof args.command === "string") return args.command;
4225
+ if (isRecord$2(args) && typeof args.command === "string") return args.command;
4226
4226
  if (typeof args === "string") return args;
4227
4227
  if (args !== void 0) return JSON.stringify(args, null, 2);
4228
4228
  }
@@ -4616,9 +4616,62 @@ var WorkspaceDatabase = class {
4616
4616
  * 干净的办法制造这种行,而它不该走生产代码路径 —— 所以显式开一个测试专用入口,
4617
4617
  * 并起一个自见其明的名字,免得有人把它当成正常 API。
4618
4618
  */
4619
+ /**
4620
+ * 仅供测试:把某台设备的「最后使用时间/创建时间」拨到过去。
4621
+ *
4622
+ * 生产路径没法造出「30 天没动过」的设备(认证一次就刷新 `last_seen_at`),而
4623
+ * `pruneInactiveDevices` 的核心语义恰恰只在这种时间跨度上才看得见。与
4624
+ * `markRevokedForTest` 同样的理由:显式开一个自见其明的测试入口,别让人误当 API。
4625
+ */
4626
+ backdateDeviceForTest(id, options) {
4627
+ if (options.lastSeenAt !== void 0) this.db.prepare("UPDATE devices SET last_seen_at = ? WHERE id = ?").run(options.lastSeenAt, id);
4628
+ if (options.createdAt !== void 0) this.db.prepare("UPDATE devices SET created_at = ? WHERE id = ?").run(options.createdAt, id);
4629
+ }
4619
4630
  markRevokedForTest(id) {
4620
4631
  this.db.prepare("UPDATE devices SET revoked_at = ? WHERE id = ?").run(Date.now(), id);
4621
4632
  }
4633
+ /**
4634
+ * 按「最后使用时间」批量清掉长期没动过的设备。
4635
+ *
4636
+ * 「最后使用」取 `last_seen_at`(每次令牌认证成功都会刷新),从没认证过的设备退回
4637
+ * `created_at` —— 否则一条配好就再没开过的设备会永远躲过清理,而那恰恰是用户最想
4638
+ * 一键清掉的那类(换手机、临时配对、旧测试探针)。
4639
+ *
4640
+ * 与 `purgeRevokedDevices` 的区别:后者清的是历史遗留的「标记式撤销」脏行,这里清的是
4641
+ * **仍然有效、但已经不用了**的设备 —— 用户的原话是「一键清理多少天未使用设备」。
4642
+ *
4643
+ * 语义边界(故意这么定):
4644
+ * - 只删设备行 + 级联的 `device_roots`,不碰审计(审计的 `actor_id` 不是外键);
4645
+ * - **不删本机正在用的那台**(`keepDeviceId`):调用方就是它,删掉自己会让当前
4646
+ * 客户端立刻 401,看起来像「点一下清理把手机踢下线了」;
4647
+ * - 天数被夹在 1..3650,避免 `0` 或负数变成「清空所有设备」;
4648
+ * - 返回真正删掉的条数,0 也是一次合法结果。
4649
+ */
4650
+ pruneInactiveDevices(olderThanDays, options = {}) {
4651
+ const days = Math.min(3650, Math.max(1, Math.trunc(olderThanDays)));
4652
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1e3;
4653
+ const rows = this.db.prepare("SELECT id, name FROM devices WHERE COALESCE(last_seen_at, created_at) < ? AND id IS NOT ?").all(cutoff, options.keepDeviceId ?? null);
4654
+ if (rows.length === 0) {
4655
+ this.devicesRevisionCounter += 1;
4656
+ return 0;
4657
+ }
4658
+ this.db.exec("BEGIN IMMEDIATE");
4659
+ try {
4660
+ const statement = this.db.prepare("DELETE FROM devices WHERE id = ?");
4661
+ for (const row of rows) statement.run(row.id);
4662
+ this.audit("admin", "loopback-admin", "device.prune", void 0, void 0, {
4663
+ olderThanDays: days,
4664
+ pruned: rows.length,
4665
+ names: rows.map((row) => row.name)
4666
+ }, options.requestId);
4667
+ this.db.exec("COMMIT");
4668
+ } catch (error) {
4669
+ this.db.exec("ROLLBACK");
4670
+ throw error;
4671
+ }
4672
+ this.devicesRevisionCounter += 1;
4673
+ return rows.length;
4674
+ }
4622
4675
  purgeRevokedDevices() {
4623
4676
  if (this.db.prepare("SELECT id FROM devices WHERE revoked_at IS NOT NULL").all().length === 0) {
4624
4677
  this.devicesRevisionCounter += 1;
@@ -5448,6 +5501,176 @@ function probeHostServices(host, pluginVersion) {
5448
5501
  };
5449
5502
  }
5450
5503
  //#endregion
5504
+ //#region src/host/im-command.ts
5505
+ /**
5506
+ * `/tunnel` —— 从即时通讯(微信 / 飞书 / 钉钉 / 企业微信 …)里远程开隧道并取回配置文本。
5507
+ *
5508
+ * ## 为什么需要它
5509
+ *
5510
+ * 用户常见的困境是「人已经出门,才发现电脑上的 Cloudflare 隧道没开,配置文本也没发给自己」。
5511
+ * 此时公网入口不存在,WebUI 与 App 都够不着这台机器 —— 唯一的反向通道是 IM 机器人的
5512
+ * **长连接**(手机/IM 云 → 本机主动连出去,不需要任何入站端口)。
5513
+ *
5514
+ * 本模块只做一件事:注册一条 `/tunnel` 命令,把「开隧道 + 生成 DSH1 配置文本」的结果
5515
+ * 直接作为聊天消息回给发送者。**不做停止、不做改配置、不读任何文件** —— 攻击面刻意压到
5516
+ * 「最多被人开一次隧道」,而隧道本身不授予任何数据面权限(数据面仍要设备令牌)。
5517
+ *
5518
+ * ## 门禁在哪里(重要)
5519
+ *
5520
+ * 本命令**自己不做身份校验**,它依赖 IM 插件在派发命令之前执行的访问策略:
5521
+ * `@xmanrui/dsh-im` 的每个机器人都有 `canExecuteCommands` / `defaultCanExecuteCommands`,
5522
+ * 非白名单发送者的 `/xxx` 根本到不了这里(`lib/index.js` 的 access policy 判定)。因此
5523
+ * 部署侧的必要条件是:**机器人保持 allowlist 模式,且只把自己加进白名单**。
5524
+ * 这条约束写在 README「远程开隧道」一节与 SECURITY.md 里,别只在代码注释里说。
5525
+ *
5526
+ * ## 为什么直接调宿主 API 而不是走 HTTP 自调用
5527
+ *
5528
+ * 「已开隧道」这一状态下,`/manage` 管理面被判据 0 整体关门(`auth.ts` 的
5529
+ * `TUNNEL_MODE_ADMIN_DISABLED`),`/manage/config/text` 必然 403 —— 走 HTTP 就取不到
5530
+ * 配置文本。命令在插件进程内执行,与路由共用同一个隧道管理器与同一套建配置逻辑,
5531
+ * 天然绕开这个自锁,且不需要为它额外豁免任何端点。
5532
+ *
5533
+ * @module dsh-remote-bridge/im-command
5534
+ */
5535
+ /** 命令名(不带斜杠)。 */
5536
+ const TUNNEL_COMMAND_NAME = "tunnel";
5537
+ /** 命令描述:会出现在 IM 的 `/help` 里。 */
5538
+ const TUNNEL_COMMAND_DESCRIPTION = "开启 Cloudflare 隧道并返回一行配置文本(DSH1:…)";
5539
+ /** 把结果拼成聊天消息。 */
5540
+ function formatTunnelReply(result) {
5541
+ return [
5542
+ "云隧道已开启。",
5543
+ `地址:${result.url}`,
5544
+ `设备名:${result.deviceName}`,
5545
+ "配置文本(一行,复制后粘贴到 App 的「导入配置」):",
5546
+ result.text
5547
+ ].join("\n");
5548
+ }
5549
+ /**
5550
+ * 创建 `/tunnel` 的命令定义(不注册,纯数据,便于单测)。
5551
+ *
5552
+ * 参数只接受空串:本命令刻意**不接受**任何开关,避免出现「`/tunnel stop`」这种需要
5553
+ * 额外鉴权语义的入口。带参数一律回用法说明。
5554
+ */
5555
+ function createTunnelCommand(deps) {
5556
+ /** 同一时刻只允许一次开隧道(IM 侧重发、双端同时发都可能撞上)。 */
5557
+ let inFlight = false;
5558
+ return {
5559
+ name: TUNNEL_COMMAND_NAME,
5560
+ description: TUNNEL_COMMAND_DESCRIPTION,
5561
+ input: { hint: "(无需参数)" },
5562
+ async handler(invocation) {
5563
+ if (invocation.rawInput.trim().length > 0) return {
5564
+ kind: "error",
5565
+ text: "用法:/tunnel(不需要参数)"
5566
+ };
5567
+ if (inFlight) return {
5568
+ kind: "error",
5569
+ text: "上一次开隧道还没结束,请稍等几秒再发一次。"
5570
+ };
5571
+ inFlight = true;
5572
+ try {
5573
+ const result = await deps.openTunnel();
5574
+ deps.logger?.info(`[dsh-remote-bridge] /tunnel 已开启隧道 ${result.url}`);
5575
+ return {
5576
+ kind: "success",
5577
+ text: formatTunnelReply(result)
5578
+ };
5579
+ } catch (cause) {
5580
+ const message = cause instanceof Error ? cause.message : String(cause);
5581
+ deps.logger?.warn(cause instanceof Error ? cause : new Error(message));
5582
+ return {
5583
+ kind: "error",
5584
+ text: `开隧道失败:${message}`
5585
+ };
5586
+ } finally {
5587
+ inFlight = false;
5588
+ }
5589
+ }
5590
+ };
5591
+ }
5592
+ /**
5593
+ * 注册 `/tunnel` 并返回注销函数。
5594
+ *
5595
+ * 注册是**全局层**(不是 agent 子层),所以任何 IM 会话、任何工作区里都能用 ——
5596
+ * 这正是本命令的用途:开机时人不在电脑边。是否允许某个人执行,由 IM 插件的
5597
+ * 访问策略决定(见模块头注释)。
5598
+ */
5599
+ function registerTunnelCommand(registry, deps) {
5600
+ return registry.register(createTunnelCommand(deps));
5601
+ }
5602
+ /**
5603
+ * ## 为什么除命令之外还要一个工具
5604
+ *
5605
+ * `@xmanrui/dsh-im` 5.4.0 的斜杠命令表是**封闭的**:内置 31 条,另外只把
5606
+ * `/compact` `/clear` `/permission` 三条委派给宿主(`lib/index.js` 里
5607
+ * `executeCommand("/compact")` 等固定字面量),**没有**「未知命令转交宿主命令表」的通用派发,
5608
+ * 也从不调用 `commands.list`。所以在 IM 里发 `/tunnel` 到不了上面那个命令处理函数 ——
5609
+ * 它会当成一句普通消息交给智能体。
5610
+ *
5611
+ * 于是 IM 侧真正可用的入口是**工具**:用户用自然语言说一句(「帮我开隧道,把配置发我」),
5612
+ * 智能体调用本工具,结果直接回到聊天里。命令与工具共用同一个 `openTunnel()`,
5613
+ * 语义完全一致;两者的差别只是「谁来触发」。
5614
+ */
5615
+ /** 工具名(模型可见)。 */
5616
+ const TUNNEL_TOOL_NAME = "open_tunnel_config";
5617
+ /**
5618
+ * 模型可见描述。
5619
+ *
5620
+ * 写英文是惯例(工具描述面向模型,与仓内其他描述同语言),并显式写清
5621
+ * 「用户说开隧道/取配置文本时调用」,否则模型倾向于自己用 bash 拼一条 curl。
5622
+ */
5623
+ const TUNNEL_TOOL_DESCRIPTION = "Open the Cloudflare quick tunnel for this DSH host and return a one-line DSH1: config text that a phone app can paste to connect. Use it whenever the user asks to start the tunnel, to get the config text, or says they are away from the computer and need remote access. It never stops the tunnel and never touches files.";
5624
+ /** 创建工具定义(不注册,便于单测)。 */
5625
+ function createTunnelTool(deps) {
5626
+ return {
5627
+ name: TUNNEL_TOOL_NAME,
5628
+ description: TUNNEL_TOOL_DESCRIPTION,
5629
+ parameters: {},
5630
+ output: {
5631
+ schema: {
5632
+ type: "object",
5633
+ additionalProperties: false,
5634
+ properties: {
5635
+ url: {
5636
+ type: "string",
5637
+ required: true
5638
+ },
5639
+ text: {
5640
+ type: "string",
5641
+ required: true
5642
+ },
5643
+ deviceName: {
5644
+ type: "string",
5645
+ required: true
5646
+ }
5647
+ },
5648
+ required: true
5649
+ },
5650
+ render: (_args, value) => [{
5651
+ type: "text",
5652
+ text: `Tunnel is up at ${value.url}`
5653
+ }, {
5654
+ type: "text",
5655
+ text: value.text
5656
+ }]
5657
+ },
5658
+ async execute() {
5659
+ const result = await deps.openTunnel();
5660
+ deps.logger?.info(`[dsh-remote-bridge] ${TUNNEL_TOOL_NAME} 已开启隧道 ${result.url}`);
5661
+ return {
5662
+ url: result.url,
5663
+ text: result.text,
5664
+ deviceName: result.deviceName
5665
+ };
5666
+ }
5667
+ };
5668
+ }
5669
+ /** 注册工具并返回注销函数。注册是全局层:任何会话(含 IM 会话)都能调用。 */
5670
+ function registerTunnelTool(registry, deps) {
5671
+ return registry.register(createTunnelTool(deps));
5672
+ }
5673
+ //#endregion
5451
5674
  //#region src/host/retention.ts
5452
5675
  /**
5453
5676
  * 数据保留(retention)调度器:把**会无限增长的两张表**按时间清掉。
@@ -6308,6 +6531,129 @@ async function inspectResolvedPath(root, wirePath) {
6308
6531
  };
6309
6532
  }
6310
6533
  //#endregion
6534
+ //#region src/host/settings-namespace.ts
6535
+ /**
6536
+ * 通用设置命名空间的纯逻辑:写允许策略、密钥脱敏、ops 校验与响应投影。
6537
+ *
6538
+ * 为什么单独成文件(2.6.0):这一层不碰任何内核 RPC,全部可以纯单测;
6539
+ * 而 `settings-adapter.ts` 已贴着 `AGENTS.md` §3.2 的 600 行单文件预算。
6540
+ * 拆出来之后两边都不再挤,adapter 只保留「调内核 + 映射错误」的那几行。
6541
+ *
6542
+ * 契约见 `docs/api/openapi.yaml` 的 SettingsNamespace* 与 `docs/project/API.md`
6543
+ * 的「通用设置命名空间」一节;安全判据的理由见 `docs/project/SECURITY.md`。
6544
+ */
6545
+ const MAX_OPS = 32;
6546
+ /**
6547
+ * 键名兜底脱敏正则。
6548
+ *
6549
+ * **不是主要机制**:内核 `settings.describe()` 已经用 `redactSecrets: true` 读过一遍,
6550
+ * role('secret') 的字段不会随响应出网(见 dsh-api-settings-controller 的
6551
+ * `namespaceView`/`describe`)。这里是第二道保险 —— 万一某个自定义 schema 漏标了
6552
+ * role('secret'),或者凭据被写进了普通字段,桥也不会把它端给手机。
6553
+ *
6554
+ * `apiKeyEnv` 是环境变量**名**不是密钥值,一并遮蔽是刻意的:名字本身能暴露
6555
+ * 「这台机器用哪家 key」,而 App 读设置根本不需要这个名字。
6556
+ */
6557
+ const SECRET_KEY = /^(apiKey|secret|token|password|credential|apiKeyEnv)$/i;
6558
+ /**
6559
+ * 写允许策略:`ui-*`(界面类插件设置)与 `*locale`(语言/区域)默认可写。
6560
+ *
6561
+ * 其余一律默认只读,尤其是能改「模型、权限、子代理」的命名空间 —— 手机一旦
6562
+ * 丢了或被恶意 App 拿到令牌,改模型等于把请求引到攻击者的端点,改权限等于
6563
+ * 把审批闸门拆掉。需要放行时由部署者显式配 `settingsWriteNamespaces`。
6564
+ */
6565
+ function isSettingsNamespaceWriteAllowed(ns, extra = []) {
6566
+ if (ns.startsWith("ui-") || ns.endsWith("locale")) return true;
6567
+ return extra.some((pattern) => pattern.endsWith("*") ? ns.startsWith(pattern.slice(0, -1)) : ns === pattern);
6568
+ }
6569
+ /**
6570
+ * 单个命名空间不可写的原因。
6571
+ *
6572
+ * 宿主设置文档整体只读优先报 `HOST_READONLY`:那与 ns 策略无关,修策略也没用。
6573
+ */
6574
+ function settingsNamespaceReason(hostWritable, ns, extra = []) {
6575
+ if (!hostWritable) return "HOST_READONLY";
6576
+ if (!isSettingsNamespaceWriteAllowed(ns, extra)) return "HIGH_RISK_NAMESPACE";
6577
+ }
6578
+ /**
6579
+ * 递归遮蔽密钥:键名命中 {@link SECRET_KEY},或路径整体命中内核给出的
6580
+ * `secrets[].path`(前者是兜底,后者才是权威)。
6581
+ *
6582
+ * 路径按 `\u0000` 拼接比较,避免 `['a.b']` 与 `['a','b']` 混淆;
6583
+ * 数组下标也进路径,密钥恰好是数组元素时同样能命中。
6584
+ */
6585
+ function redactSettingsSecrets(value, secretPaths = []) {
6586
+ const exact = new Set(secretPaths.map((path) => path.join("\0")));
6587
+ return walk(value, []);
6588
+ function walk(current, path) {
6589
+ if (path.length > 0 && exact.has(path.join("\0"))) return "***";
6590
+ if (typeof current === "string") {
6591
+ const key = path.at(-1);
6592
+ return key !== void 0 && SECRET_KEY.test(key) ? "***" : current;
6593
+ }
6594
+ if (Array.isArray(current)) return current.map((item, index) => walk(item, [...path, String(index)]));
6595
+ if (isRecord$1(current)) return Object.fromEntries(Object.entries(current).map(([key, item]) => [key, walk(item, [...path, key])]));
6596
+ return current;
6597
+ }
6598
+ }
6599
+ /** 校验并规范化请求里的 `ops`;结构不合法一律 400 `SETTINGS_OPS_INVALID`。 */
6600
+ function parseSettingsNamespaceOps(input) {
6601
+ if (!Array.isArray(input) || input.length === 0 || input.length > MAX_OPS) throw new ApiError(400, "SETTINGS_OPS_INVALID", `ops must be an array of 1 to ${MAX_OPS} operations.`);
6602
+ return input.map((raw, index) => {
6603
+ const where = `ops[${index}]`;
6604
+ if (!isRecord$1(raw)) throw invalid(`${where} must be an object.`);
6605
+ if (raw.op !== "set" && raw.op !== "unset") throw invalid(`${where}.op must be "set" or "unset".`);
6606
+ if (!Array.isArray(raw.path) || raw.path.some((segment) => typeof segment !== "string")) throw invalid(`${where}.path must be an array of strings.`);
6607
+ const path = [...raw.path];
6608
+ if (raw.op === "unset") return {
6609
+ op: "unset",
6610
+ path
6611
+ };
6612
+ if (!Object.hasOwn(raw, "value")) throw invalid(`${where}.value is required for "set".`);
6613
+ return {
6614
+ op: "set",
6615
+ path,
6616
+ value: raw.value
6617
+ };
6618
+ });
6619
+ }
6620
+ /** 审计用字段清单:只记路径,**绝不记值**。根路径(空数组)不产生字段名。 */
6621
+ function settingsNamespaceOpFields(ops) {
6622
+ return [...new Set(ops.map((op) => op.path.join(".")).filter((field) => field !== ""))];
6623
+ }
6624
+ /** 本次写入是否碰到了密钥位(审计 `credentialChanged`);只看路径,不看值。 */
6625
+ function settingsNamespaceCredentialChanged(ops, secrets) {
6626
+ if (secrets === void 0 || secrets.length === 0) return false;
6627
+ const exact = new Set(secrets.map((secret) => secret.path.join("\0")));
6628
+ return ops.some((op) => exact.has(op.path.join("\0")));
6629
+ }
6630
+ /** 内核视图 → 对外视图:叠加写策略并脱敏。 */
6631
+ function projectSettingsNamespace(view, hostWritable, extra = []) {
6632
+ const writable = hostWritable && isSettingsNamespaceWriteAllowed(view.ns, extra);
6633
+ const secretPaths = (view.secrets ?? []).map((secret) => secret.path);
6634
+ const project = (value) => redactSettingsSecrets(value, secretPaths);
6635
+ const reason = settingsNamespaceReason(hostWritable, view.ns, extra);
6636
+ return {
6637
+ ns: view.ns,
6638
+ revision: view.revision,
6639
+ ...view.schema === void 0 ? {} : { schema: view.schema },
6640
+ value: project(view.value),
6641
+ ...view.base === void 0 ? {} : { base: project(view.base) },
6642
+ ...view.user === void 0 ? {} : { user: project(view.user) },
6643
+ ...view.autoGenerate === void 0 ? {} : { autoGenerate: view.autoGenerate },
6644
+ ...view.applies === void 0 ? {} : { applies: view.applies },
6645
+ ...view.secrets === void 0 ? {} : { secrets: view.secrets },
6646
+ writable,
6647
+ ...reason === void 0 ? {} : { reason }
6648
+ };
6649
+ }
6650
+ function invalid(message) {
6651
+ return new ApiError(400, "SETTINGS_OPS_INVALID", message);
6652
+ }
6653
+ function isRecord$1(value) {
6654
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6655
+ }
6656
+ //#endregion
6311
6657
  //#region src/host/wire-json.ts
6312
6658
  /**
6313
6659
  * 出网 JSON 的安全序列化:把**孤立 UTF-16 代理项**换成 U+FFFD。
@@ -6667,6 +7013,27 @@ var ApiRouter = class {
6667
7013
  sendJson(res, 200, await this.options.settings.catalog());
6668
7014
  return;
6669
7015
  }
7016
+ if (method === "GET" && pathname === "/api/v1/settings/namespaces") {
7017
+ this.options.auth.requireScope(principal, "settings.read");
7018
+ sendJson(res, 200, await this.options.settings.describeNamespaces());
7019
+ return;
7020
+ }
7021
+ const settingsNamespaceRoute = pathname.match(/^\/api\/v1\/settings\/namespaces\/([^/]+)$/);
7022
+ if (settingsNamespaceRoute !== null && method === "PATCH") {
7023
+ this.options.auth.requireScope(principal, "settings.write");
7024
+ const ns = decodePathSegment(settingsNamespaceRoute[1]);
7025
+ const body = await readJson(req, 256e3);
7026
+ const record = typeof body === "object" && body !== null ? body : {};
7027
+ const ops = parseSettingsNamespaceOps(record.ops);
7028
+ const entry = await this.options.settings.mutateNamespace(ns, ops, settingsExpectedRevision(record.expectedRevision));
7029
+ this.audit(principal, "settings.namespace.mutate", void 0, void 0, requestId, {
7030
+ ns,
7031
+ fields: settingsNamespaceOpFields(ops),
7032
+ credentialChanged: settingsNamespaceCredentialChanged(ops, entry.secrets)
7033
+ });
7034
+ sendJson(res, 200, entry);
7035
+ return;
7036
+ }
6670
7037
  if (method === "GET" && pathname === "/api/v1/settings/plugins") {
6671
7038
  this.options.auth.requireScope(principal, "settings.read");
6672
7039
  const inventory = await readPluginInventory();
@@ -7049,6 +7416,12 @@ var ApiRouter = class {
7049
7416
  sendJson(res, 200, { items: this.options.database.listDevices() });
7050
7417
  return;
7051
7418
  }
7419
+ if (method === "POST" && pathname === "/manage/devices/prune") {
7420
+ const raw = (await readJson(req, 64e3)).olderThanDays;
7421
+ if (typeof raw !== "number" || !Number.isFinite(raw) || !Number.isInteger(raw) || raw < 1 || raw > 3650) throw new ApiError(400, "INVALID_OLDER_THAN_DAYS", "olderThanDays must be an integer between 1 and 3650.");
7422
+ sendJson(res, 200, { pruned: this.options.database.pruneInactiveDevices(raw, { requestId }) });
7423
+ return;
7424
+ }
7052
7425
  const deviceRoute = pathname.match(/^\/manage\/devices\/([^/]+)$/);
7053
7426
  if (deviceRoute !== null && method === "PATCH") {
7054
7427
  const body = await readJson(req, 64e3);
@@ -7130,44 +7503,9 @@ var ApiRouter = class {
7130
7503
  }
7131
7504
  if (this.options.tunnels !== void 0 && method === "POST" && pathname === "/manage/cloudflare/create") {
7132
7505
  const createBody = await readJson(req, 64e3).catch(() => ({}));
7133
- const wantConfig = createBody.configText === true;
7134
- const binDir = this.options.cloudflaredBinDir;
7135
- let exe;
7136
- try {
7137
- exe = await ensureCloudflared(binDir ?? this.options.database.dataDir, (a) => {
7138
- this.options.tunnels.reportActivity(a);
7139
- });
7140
- } catch (cause) {
7141
- this.options.tunnels.clearActivity();
7142
- throw new ApiError(502, "TUNNEL_DOWNLOAD_FAILED", "Failed to download or unpack cloudflared. Check the network and retry, or set CLOUDFLARED_MIRROR.", {
7143
- errorKey: "tunnelErrorDownload",
7144
- ...cause instanceof Error ? { reason: sanitizeDownloadError(cause.message) } : {}
7145
- });
7146
- }
7147
- const port = this.options.remote.status().configuredPort ?? 3090;
7148
- const state = await this.options.tunnels.startQuick(exe, port);
7149
- let configText;
7150
- let resolvedUrl = state.url;
7151
- if (wantConfig) {
7152
- const tunnelUrl = state.url ?? await this.options.tunnels.waitForUrl();
7153
- resolvedUrl = tunnelUrl;
7154
- if (tunnelUrl !== null) configText = this.buildConfigText({
7155
- endpoints: [{
7156
- label: readOptionalText(createBody.endpointLabel) ?? "云隧道",
7157
- baseUrl: tunnelUrl
7158
- }],
7159
- ...createBody.rootIds === void 0 ? {} : { rootIds: createBody.rootIds },
7160
- ...createBody.deviceName === void 0 ? {} : { deviceName: createBody.deviceName }
7161
- }, requestId);
7162
- }
7163
- this.options.tunnels.clearActivity();
7164
- this.options.database.audit("admin", "loopback-admin", "cloudflare.create", void 0, void 0, {}, requestId);
7165
- const responseState = resolvedUrl === state.url ? state : {
7506
+ const { state, configText } = await this.createTunnel(createBody, requestId, "loopback-admin");
7507
+ sendJson(res, 201, configText === void 0 ? state : {
7166
7508
  ...state,
7167
- url: resolvedUrl
7168
- };
7169
- sendJson(res, 201, configText === void 0 ? responseState : {
7170
- ...responseState,
7171
7509
  configText
7172
7510
  });
7173
7511
  return;
@@ -7181,6 +7519,99 @@ var ApiRouter = class {
7181
7519
  throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
7182
7520
  }
7183
7521
  /**
7522
+ * 打开隧道并(可选)顺带产出配置文本 —— HTTP `POST /manage/cloudflare/create` 与
7523
+ * IM `/tunnel` 命令**共用**这一条实现。
7524
+ *
7525
+ * 抽出来的原因:两条入口的前半段完全一致(确保 cloudflared → startQuick → 等随机域名 →
7526
+ * 建配置文本),差别只在「谁在调用」——HTTP 侧要先把请求体读干净(流会随时间关闭),
7527
+ * 命令侧没有请求体。复制一份会让「一键出令牌」在两条路径上慢慢漂移。
7528
+ *
7529
+ * @param createBody - 已解析的请求体(HTTP 侧)或 `{ configText: true }`(命令侧)。
7530
+ * @param requestId - 审计与配对记录共用的请求 ID。
7531
+ * @param actorLabel - 审计里的操作者标签:`loopback-admin`(本机管理面)或 `im-command`。
7532
+ * @returns 合并了「等到地址」的 state,以及(wantConfig 且拿到地址时)配置文本。
7533
+ */
7534
+ async createTunnel(createBody, requestId, actorLabel) {
7535
+ const wantConfig = createBody.configText === true;
7536
+ const binDir = this.options.cloudflaredBinDir;
7537
+ let exe;
7538
+ try {
7539
+ exe = await ensureCloudflared(binDir ?? this.options.database.dataDir, (a) => {
7540
+ this.options.tunnels.reportActivity(a);
7541
+ });
7542
+ } catch (cause) {
7543
+ this.options.tunnels.clearActivity();
7544
+ throw new ApiError(502, "TUNNEL_DOWNLOAD_FAILED", "Failed to download or unpack cloudflared. Check the network and retry, or set CLOUDFLARED_MIRROR.", {
7545
+ errorKey: "tunnelErrorDownload",
7546
+ ...cause instanceof Error ? { reason: sanitizeDownloadError(cause.message) } : {}
7547
+ });
7548
+ }
7549
+ const port = this.options.remote.status().configuredPort ?? 3090;
7550
+ const state = await this.options.tunnels.startQuick(exe, port);
7551
+ let configText;
7552
+ let resolvedUrl = state.url;
7553
+ if (wantConfig) {
7554
+ const tunnelUrl = state.url ?? await this.options.tunnels.waitForUrl();
7555
+ resolvedUrl = tunnelUrl;
7556
+ if (tunnelUrl !== null) configText = this.buildConfigText({
7557
+ endpoints: [{
7558
+ label: readOptionalText(createBody.endpointLabel) ?? "云隧道",
7559
+ baseUrl: tunnelUrl
7560
+ }],
7561
+ ...createBody.rootIds === void 0 ? {} : { rootIds: createBody.rootIds },
7562
+ ...createBody.deviceName === void 0 ? {} : { deviceName: createBody.deviceName }
7563
+ }, requestId);
7564
+ }
7565
+ this.options.tunnels.clearActivity();
7566
+ this.options.database.audit("admin", actorLabel, "cloudflare.create", void 0, void 0, {}, requestId);
7567
+ return {
7568
+ state: resolvedUrl === state.url ? state : {
7569
+ ...state,
7570
+ url: resolvedUrl
7571
+ },
7572
+ ...configText === void 0 ? {} : { configText }
7573
+ };
7574
+ }
7575
+ /**
7576
+ * IM `/tunnel` 命令的宿主侧入口:开隧道并返回一行可粘贴的配置文本。
7577
+ *
7578
+ * 与 HTTP 路径的两点差别:
7579
+ *
7580
+ * 1. **隧道已在运行时直接复用当前地址**,不重复 spawn(第二次 `startQuick` 会撞
7581
+ * `TUNNEL_ALREADY_RUNNING`,而用户在 IM 里连发两条是很常见的)。
7582
+ * 2. 配置文本的 endpoint 只用隧道地址,设备名缺省由 `buildConfigText` 决定 —— 命令
7583
+ * 不接受参数,所以没有 `endpointLabel` / `rootIds` 的透传面。
7584
+ *
7585
+ * @throws 当 12 秒内没拿到公网地址,或 cloudflared 下载失败时。
7586
+ */
7587
+ async openTunnelForCommand() {
7588
+ const tunnels = this.options.tunnels;
7589
+ if (tunnels === void 0) throw new Error("隧道功能未启用。");
7590
+ const requestId = randomUUID();
7591
+ const current = tunnels.publicState;
7592
+ if (current.running === true && current.url !== null) {
7593
+ const reused = this.buildConfigText({ endpoints: [{
7594
+ label: "云隧道",
7595
+ baseUrl: current.url
7596
+ }] }, requestId);
7597
+ this.options.database.audit("admin", "im-command", "cloudflare.create", void 0, void 0, { reused: true }, requestId);
7598
+ return {
7599
+ url: current.url,
7600
+ text: reused.text,
7601
+ deviceName: reused.deviceName
7602
+ };
7603
+ }
7604
+ const { state, configText } = await this.createTunnel({ configText: true }, requestId, "im-command");
7605
+ if (configText === void 0 || state.url === null) throw new Error("隧道已启动,但 12 秒内没拿到公网地址。稍等片刻再发一次 /tunnel 可复用当前地址。");
7606
+ const text = typeof configText.text === "string" ? configText.text : "";
7607
+ const deviceName = typeof configText.deviceName === "string" ? configText.deviceName : "Android 设备";
7608
+ return {
7609
+ url: state.url,
7610
+ text,
7611
+ deviceName
7612
+ };
7613
+ }
7614
+ /**
7184
7615
  * 隧道状态视图。
7185
7616
  *
7186
7617
  * **这里不做脱敏,因为用不上**:判据 1 已经把 Cloudflare 边缘头(`Cf-Connecting-IP` /
@@ -7572,6 +8003,17 @@ function optionalInteger(value) {
7572
8003
  if (!Number.isSafeInteger(parsed) || parsed < 0) throw new ApiError(400, "NUMBER_INVALID", "A query parameter must be a non-negative integer.");
7573
8004
  return parsed;
7574
8005
  }
8006
+ /**
8007
+ * 请求体里的 `expectedRevision`。
8008
+ *
8009
+ * 不复用 `optionalInteger()`:它读的是查询串(字符串)且错误码是 `NUMBER_INVALID`;
8010
+ * 这里是 JSON 数字,非法时属于「ops 请求体不合法」,契约要求 `SETTINGS_OPS_INVALID`。
8011
+ */
8012
+ function settingsExpectedRevision(value) {
8013
+ if (value === void 0) return void 0;
8014
+ if (!Number.isSafeInteger(value) || value < 0) throw new ApiError(400, "SETTINGS_OPS_INVALID", "expectedRevision must be a non-negative integer.");
8015
+ return value;
8016
+ }
7575
8017
  function commandName(line) {
7576
8018
  return /^\/([a-z][a-z0-9_-]*)/u.exec(line)?.[1] ?? "unknown";
7577
8019
  }
@@ -8407,8 +8849,71 @@ const PROVIDER_CONTEXT_TTL_MS = 1e3;
8407
8849
  const PROVIDER_CONTEXT_CACHE_LIMIT = 128;
8408
8850
  var DshSettingsAdapter = class {
8409
8851
  api;
8410
- constructor(api) {
8852
+ options;
8853
+ /**
8854
+ * @param options.writeNamespaces 额外允许手机端写入的设置命名空间(精确 ns 或
8855
+ * `前缀*`),与内置策略(`ui-*` / 以 `locale` 结尾)叠加;默认为空。
8856
+ */
8857
+ constructor(api, options = {}) {
8411
8858
  this.api = api;
8859
+ this.options = options;
8860
+ }
8861
+ /**
8862
+ * 列出全部设置命名空间(通用设置面的读侧)。
8863
+ *
8864
+ * 与 `listProviders()` 的区别:它不理解任何 provider 语义,原样透出内核按插件
8865
+ * 划分的命名空间,由手机端按 `schema` 自己长表单 —— 新装插件不必等 App 发版。
8866
+ */
8867
+ async describeNamespaces() {
8868
+ const described = unwrapDsh(await this.settingsService().describe({
8869
+ rpcId: randomUUID(),
8870
+ payload: {}
8871
+ }));
8872
+ return {
8873
+ writable: described.writable,
8874
+ namespaces: described.namespaces.map((view) => projectSettingsNamespace(view, described.writable, this.writeNamespaces))
8875
+ };
8876
+ }
8877
+ /**
8878
+ * 按路径改一个命名空间(通用设置面的写侧)。
8879
+ *
8880
+ * 校验顺序是刻意的:ns 策略 → ops 结构 → 内核 describe(host 可写性)→ mutate。
8881
+ * 先验策略既省一次内核往返,也保证「高危命名空间」无论 ops 怎么错都先得到 403。
8882
+ */
8883
+ async mutateNamespace(ns, ops, expectedRevision) {
8884
+ if (!isSettingsNamespaceWriteAllowed(ns, this.writeNamespaces)) throw new ApiError(403, "SETTINGS_NAMESPACE_READONLY", `The settings namespace "${ns}" is not writable from this bridge.`, {
8885
+ ns,
8886
+ reason: "HIGH_RISK_NAMESPACE"
8887
+ });
8888
+ const normalized = parseSettingsNamespaceOps(ops);
8889
+ const described = unwrapDsh(await this.settingsService().describe({
8890
+ rpcId: randomUUID(),
8891
+ payload: {}
8892
+ }));
8893
+ if (!described.writable) throw new ApiError(409, "SETTINGS_READ_ONLY", "DSH settings are read-only.");
8894
+ let mutated;
8895
+ try {
8896
+ mutated = unwrapDsh(await this.settingsService().mutate({
8897
+ rpcId: randomUUID(),
8898
+ payload: {
8899
+ ns,
8900
+ ops: normalized,
8901
+ ...expectedRevision === void 0 ? {} : { expectedRevision }
8902
+ }
8903
+ }));
8904
+ } catch (error) {
8905
+ throw mapSettingsMutationError(error);
8906
+ }
8907
+ return projectSettingsNamespace(mutated, described.writable, this.writeNamespaces);
8908
+ }
8909
+ get writeNamespaces() {
8910
+ return this.options.writeNamespaces ?? [];
8911
+ }
8912
+ /** 内核 settings 服务缺失时给 503(而不是把 `TypeError` 漏成 500)。 */
8913
+ settingsService() {
8914
+ const settings = this.api.settings;
8915
+ if (settings === void 0) throw new ApiError(503, "SETTINGS_UNAVAILABLE", "The DSH settings service is not available.");
8916
+ return settings;
8412
8917
  }
8413
8918
  async listProviders() {
8414
8919
  const [directoryResponse, settingsResponse, catalogResponse] = await Promise.all([
@@ -8699,6 +9204,20 @@ var DshSettingsAdapter = class {
8699
9204
  };
8700
9205
  }
8701
9206
  };
9207
+ /**
9208
+ * 把内核 `settings.mutate` 的错误翻成契约错误码。
9209
+ *
9210
+ * `unwrapDsh()` 的通用映射只认 `busy` / `locked` 为 409,所以内核的
9211
+ * `settings/conflict` 会落到 400 `DSH_SETTINGS_CONFLICT` —— 这里按契约改回
9212
+ * 409 `SETTINGS_CONFLICT`(客户端据此提示「刷新后重试」而不是「参数不对」)。
9213
+ * `settings/rejected` 是 ops 在 schema 上不合法,落回 400 `SETTINGS_OPS_INVALID`。
9214
+ */
9215
+ function mapSettingsMutationError(error) {
9216
+ if (!(error instanceof ApiError)) return error;
9217
+ if (error.code.includes("SETTINGS") && error.code.includes("CONFLICT")) return new ApiError(409, "SETTINGS_CONFLICT", "Settings changed since they were read; refresh and retry.", error.details);
9218
+ if (error.code.includes("SETTINGS") && error.code.includes("REJECTED")) return new ApiError(400, "SETTINGS_OPS_INVALID", error.message, error.details);
9219
+ return error;
9220
+ }
8702
9221
  function projectConfig(profile, inheritedModels) {
8703
9222
  const baseURL = text(profile?.baseURL);
8704
9223
  const api = text(profile?.api);
@@ -8857,7 +9376,7 @@ async function apply(ctx, config = {}) {
8857
9376
  agents: ctx.agents,
8858
9377
  commands: ctx.commands
8859
9378
  });
8860
- const settings = new DshSettingsAdapter(apiProxy);
9379
+ const settings = new DshSettingsAdapter(apiProxy, { writeNamespaces: config.settingsWriteNamespaces ?? [] });
8861
9380
  let hostServiceReport;
8862
9381
  try {
8863
9382
  hostServiceReport = probeHostServices(ctx, PLUGIN_VERSION);
@@ -8908,6 +9427,34 @@ async function apply(ctx, config = {}) {
8908
9427
  path: IMAGE_ROUTE,
8909
9428
  handler: imageRoute
8910
9429
  }]);
9430
+ try {
9431
+ ctx.effect(() => registerTunnelCommand(ctx.commands, {
9432
+ openTunnel: () => router.openTunnelForCommand(),
9433
+ logger: {
9434
+ info: (message) => ctx.logger.info(message),
9435
+ warn: (error) => ctx.logger.warn(error)
9436
+ }
9437
+ }), "dsh-remote-bridge /tunnel command");
9438
+ } catch (error) {
9439
+ ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: /tunnel command registration failed (ignored): ${String(error)}`));
9440
+ }
9441
+ let toolsService;
9442
+ try {
9443
+ toolsService = reflect?.get?.("tools", false);
9444
+ } catch {
9445
+ toolsService = void 0;
9446
+ }
9447
+ if (toolsService !== void 0) try {
9448
+ ctx.effect(() => registerTunnelTool(toolsService, {
9449
+ openTunnel: () => router.openTunnelForCommand(),
9450
+ logger: {
9451
+ info: (message) => ctx.logger.info(message),
9452
+ warn: (error) => ctx.logger.warn(error)
9453
+ }
9454
+ }), "dsh-remote-bridge open_tunnel_config tool");
9455
+ } catch (error) {
9456
+ ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: open_tunnel_config registration failed (ignored): ${String(error)}`));
9457
+ }
8911
9458
  ctx.effect(() => async () => {
8912
9459
  disposeEmbedded();
8913
9460
  await remote.stop();