dsh-remote-bridge 2.5.1 → 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.d.ts CHANGED
@@ -79,7 +79,32 @@ declare class WorkspaceDatabase {
79
79
  readonly trashDir: string;
80
80
  private readonly db;
81
81
  private closed;
82
+ /**
83
+ * 「设备集合或设备权限发生过写」的进程内单调计数。
84
+ *
85
+ * ## 为什么需要它
86
+ *
87
+ * WebSocket 广播的每一条事件都要确认「这条连接背后的设备还有效吗、scope/root 变了吗」
88
+ * —— 旧实现是**每条事件查一次库**(`getActiveDevice`)。多条连接 × 持续事件
89
+ * 就是成百次纯浪费的 SQLite 查询。
90
+ *
91
+ * 有了这个计数,`server.ts` 可以只在**它变了**的时候重新水化设备
92
+ * (特性探测:getter 不存在就退回逐事件查库,所以迟到或缺席都不会更差)。
93
+ *
94
+ * ## 语义(与 perf-reader 的 PERF-1 契约一致)
95
+ *
96
+ * 「任何改变**设备是否存在 / scope / rootIds** 的写」都要 +1。当前四处:
97
+ * `exchangePairing` / `updateDevice` / `revokeDevice` / `purgeRevokedDevices`。
98
+ *
99
+ * 根(roots)的增删**不** +1:设备记录本身没变;`/roots` 那条路径本来就是实时查库。
100
+ *
101
+ * 保守性:宁可多 +1(多一次缓存失效,代价是一次查询),不可漏 +1
102
+ * (漏了就是「撤销后仍能收事件」—— 那正是 `device-revocation` 要防的事)。
103
+ */
104
+ private devicesRevisionCounter;
82
105
  constructor(dataDir: string);
106
+ /** 当前设备状态版本号。用途见字段注释(`server.ts` 的广播缓存失效)。 */
107
+ get devicesRevision(): number;
83
108
  /** 关闭数据库。**幂等** —— 重复调用不抛错。 */
84
109
  close(): void;
85
110
  /**
@@ -92,8 +117,16 @@ declare class WorkspaceDatabase {
92
117
  dumpSchema(): string;
93
118
  getSetting(key: string): string | undefined;
94
119
  setSetting(key: string, value: string): void;
120
+ /**
121
+ * 登记一条授权根。
122
+ *
123
+ * @param options.requestId - 发起这次登记的那条 HTTP 请求的 id。
124
+ * 审计的用途不只是「记下发生了什么」,还要能回答「**哪一次请求**干的」;
125
+ * 不传(插件启动期登记内置根)时审计行的 `request_id` 为 null。
126
+ */
95
127
  addRoot(inputPath: string, label?: string, options?: {
96
128
  allowDuplicate?: boolean;
129
+ requestId?: string;
97
130
  }): Promise<StoredRoot>;
98
131
  listRoots(): StoredRoot[];
99
132
  getRoot(id: string): StoredRoot | undefined;
@@ -126,11 +159,27 @@ declare class WorkspaceDatabase {
126
159
  */
127
160
  ensureBuiltinRoot(realPath: string, label: string): Promise<StoredRoot | undefined>;
128
161
  removeRoot(id: string): void;
129
- createPairing(rootIds: string[], scopes: DeviceScope[], ttlMs?: number): {
162
+ /**
163
+ * 生成一枚配对码。
164
+ *
165
+ * @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
166
+ */
167
+ createPairing(rootIds: string[], scopes: DeviceScope[], ttlMs?: number, options?: {
168
+ requestId?: string;
169
+ }): {
130
170
  code: string;
131
171
  expiresAt: number;
132
172
  };
133
- exchangePairing(code: string, deviceName: string): {
173
+ /**
174
+ * 用配对码换一枚设备令牌。
175
+ *
176
+ * 审计**在事务内**写(FUNC-5):它是「这台设备被授权了」的唯一留痕,
177
+ * 与设备行同生共死。审计写不进去 → 整个兑换回滚 → 客户端重试,
178
+ * 而不是「拿到令牌但库里没有这笔授权的记录」。
179
+ */
180
+ exchangePairing(code: string, deviceName: string, options?: {
181
+ requestId?: string;
182
+ }): {
134
183
  token: string;
135
184
  device: DeviceView;
136
185
  };
@@ -139,7 +188,18 @@ declare class WorkspaceDatabase {
139
188
  private hydrateDevice;
140
189
  listDevices(): DeviceView[];
141
190
  isDeviceActive(id: string): boolean;
142
- updateDevice(id: string, scopes: DeviceScope[], rootIds: string[]): DeviceView;
191
+ /**
192
+ * 改一台设备的 scope 与 root 授权。
193
+ *
194
+ * 审计**在事务内**写(FUNC-5):它是「谁的权限被扩大了」的唯一留痕。
195
+ * 审计失败 → 整个更新回滚。宁可让用户重试,也不能出现「权限已经变宽、
196
+ * 但没有任何记录说是谁在什么时候改的」—— 那是不可审计的提权。
197
+ *
198
+ * @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
199
+ */
200
+ updateDevice(id: string, scopes: DeviceScope[], rootIds: string[], options?: {
201
+ requestId?: string;
202
+ }): DeviceView;
143
203
  /**
144
204
  * 撤销设备 = **直接删除这一行**(用户要求:已撤销的不显示,并自动从数据库中删除)。
145
205
  *
@@ -153,7 +213,18 @@ declare class WorkspaceDatabase {
153
213
  * - `authenticate` 仍保留 `revoked_at IS NULL` 作为兜底,万一有别处留下的标记行,
154
214
  * 它们依然登不进来 —— 两层保险不冲突。
155
215
  */
156
- revokeDevice(id: string): void;
216
+ /**
217
+ * 撤销一台设备 = 删除设备行。
218
+ *
219
+ * 审计与删除**在同一事务里**(FUNC-5),且顺序是「先留痕、再删除」:
220
+ * 审计的 `actor_id` 不带外键,删设备不会把记录带走。反过来若先删后写、
221
+ * 而写审计失败,就会留下「设备已经没了,但没有任何记录说谁撤的」。
222
+ *
223
+ * @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
224
+ */
225
+ revokeDevice(id: string, options?: {
226
+ requestId?: string;
227
+ }): void;
157
228
  /**
158
229
  * 一次性清理历史遗留的「标记式」撤销设备(`revoked_at IS NOT NULL` 的行)。
159
230
  *
@@ -171,8 +242,68 @@ declare class WorkspaceDatabase {
171
242
  * 干净的办法制造这种行,而它不该走生产代码路径 —— 所以显式开一个测试专用入口,
172
243
  * 并起一个自见其明的名字,免得有人把它当成正常 API。
173
244
  */
245
+ /**
246
+ * 仅供测试:把某台设备的「最后使用时间/创建时间」拨到过去。
247
+ *
248
+ * 生产路径没法造出「30 天没动过」的设备(认证一次就刷新 `last_seen_at`),而
249
+ * `pruneInactiveDevices` 的核心语义恰恰只在这种时间跨度上才看得见。与
250
+ * `markRevokedForTest` 同样的理由:显式开一个自见其明的测试入口,别让人误当 API。
251
+ */
252
+ backdateDeviceForTest(id: string, options: {
253
+ lastSeenAt?: number;
254
+ createdAt?: number;
255
+ }): void;
174
256
  markRevokedForTest(id: string): void;
257
+ /**
258
+ * 按「最后使用时间」批量清掉长期没动过的设备。
259
+ *
260
+ * 「最后使用」取 `last_seen_at`(每次令牌认证成功都会刷新),从没认证过的设备退回
261
+ * `created_at` —— 否则一条配好就再没开过的设备会永远躲过清理,而那恰恰是用户最想
262
+ * 一键清掉的那类(换手机、临时配对、旧测试探针)。
263
+ *
264
+ * 与 `purgeRevokedDevices` 的区别:后者清的是历史遗留的「标记式撤销」脏行,这里清的是
265
+ * **仍然有效、但已经不用了**的设备 —— 用户的原话是「一键清理多少天未使用设备」。
266
+ *
267
+ * 语义边界(故意这么定):
268
+ * - 只删设备行 + 级联的 `device_roots`,不碰审计(审计的 `actor_id` 不是外键);
269
+ * - **不删本机正在用的那台**(`keepDeviceId`):调用方就是它,删掉自己会让当前
270
+ * 客户端立刻 401,看起来像「点一下清理把手机踢下线了」;
271
+ * - 天数被夹在 1..3650,避免 `0` 或负数变成「清空所有设备」;
272
+ * - 返回真正删掉的条数,0 也是一次合法结果。
273
+ */
274
+ pruneInactiveDevices(olderThanDays: number, options?: {
275
+ keepDeviceId?: string;
276
+ requestId?: string;
277
+ }): number;
175
278
  purgeRevokedDevices(): number;
279
+ /**
280
+ * 删掉早于 `cutoffMs` 的幂等回放记录,返回删除行数。
281
+ *
282
+ * 由 `retention.ts`(perf-reader)按调度调用,本方法只做 SQL —— 不判断策略、
283
+ * 不写日志,所以它可以被单独测试。
284
+ *
285
+ * 为什么用 `created_at` 而不是加一列 `expires_at`:24 小时的过期**完全**由
286
+ * `created_at` 推导得出,加列会让每次插入多写一个字段,还多一次 migration 风险。
287
+ */
288
+ pruneIdempotency(cutoffMs: number): number;
289
+ /**
290
+ * 清理审计:先按时间删,再兜「总数超过 `keepMax` 就删最旧的」。
291
+ *
292
+ * ## 为什么是两段而不是一段
293
+ *
294
+ * 只按时间删不够:桥可以被一个「短时间高频写入」的场景灌满审计表
295
+ * (例如每秒几十条 `chat.delta` 对应的写操作),时间窗内的量仍然能压垮库。
296
+ * 只按数量删也不够:用户可能希望「一年前的都清掉」,而不在乎条数。
297
+ *
298
+ * ## `keepMax` 是**下限保护**
299
+ *
300
+ * 即使 `cutoffMs` 设成了未来(把全部行都判成过期),也**至少**保留最近
301
+ * `keepMax` 条 —— 一句写错的调用不该把审计清空。审计被清空本身就等于
302
+ * 「抹掉证据」,这个方向必须由这一层挡住。
303
+ *
304
+ * 返回删除的行数(两段之和)。
305
+ */
306
+ pruneAudit(cutoffMs: number, keepMax: number): number;
176
307
  insertTrash(record: TrashRecord): void;
177
308
  updateTrashStatus(id: string, status: string): void;
178
309
  getTrash(id: string): TrashRecord | undefined;
@@ -204,15 +335,56 @@ declare class WorkspaceDatabase {
204
335
  private hasRootColumn;
205
336
  }
206
337
  //#endregion
207
- //#region src/host/settings-adapter.d.ts
338
+ //#region src/host/settings-namespace.d.ts
339
+ /**
340
+ * 内核 `settings.describe()` 的命名空间视图(DSH 0.2.x)。
341
+ *
342
+ * 字段比 2.6.0 之前多:`autoGenerate` / `applies` / `secrets` 都原样透出,
343
+ * 手机端要靠 `secrets[].path` 判断「哪些字段是密钥位、只读」,比按键名猜可靠。
344
+ */
208
345
  interface SettingsNamespaceView {
209
346
  ns: string;
347
+ autoGenerate?: unknown;
210
348
  schema?: unknown;
211
349
  value: unknown;
212
350
  base?: unknown;
213
351
  user?: unknown;
352
+ applies?: unknown;
353
+ secrets?: SettingsNamespaceSecret[];
214
354
  revision: number;
215
355
  }
356
+ /** 一个密钥位的路径与「是否已设置」;**只有路径,没有值**。 */
357
+ interface SettingsNamespaceSecret {
358
+ path: string[];
359
+ set: boolean;
360
+ }
361
+ type SettingsNamespaceReason = 'HOST_READONLY' | 'HIGH_RISK_NAMESPACE';
362
+ /** 手机端看到的单个命名空间:内核视图 + 能不能写 + 为什么不能写。 */
363
+ interface SettingsNamespaceEntry {
364
+ ns: string;
365
+ revision: number;
366
+ schema?: unknown;
367
+ value: unknown;
368
+ base?: unknown;
369
+ user?: unknown;
370
+ autoGenerate?: unknown;
371
+ applies?: unknown;
372
+ secrets?: SettingsNamespaceSecret[];
373
+ writable: boolean;
374
+ reason?: SettingsNamespaceReason;
375
+ }
376
+ /**
377
+ * `GET /api/v1/settings/namespaces` 的响应。
378
+ *
379
+ * 刻意**不**透出内核的 `hasDocument`:它对手机端没有决策价值,而响应多一个字段
380
+ * 就等于契约多一个字段(另一侧已按 `{writable, namespaces}` 定稿)。
381
+ */
382
+ interface SettingsNamespacesView {
383
+ writable: boolean;
384
+ namespaces: SettingsNamespaceEntry[];
385
+ }
386
+ //#endregion
387
+ //#region src/host/settings-adapter.d.ts
216
388
  interface ConfigurableProvider {
217
389
  provider: string;
218
390
  displayName: string;
@@ -377,7 +549,31 @@ interface CustomProviderCapability {
377
549
  }
378
550
  declare class DshSettingsAdapter {
379
551
  private readonly api;
380
- constructor(api: SettingsApiProxy);
552
+ private readonly options;
553
+ /**
554
+ * @param options.writeNamespaces 额外允许手机端写入的设置命名空间(精确 ns 或
555
+ * `前缀*`),与内置策略(`ui-*` / 以 `locale` 结尾)叠加;默认为空。
556
+ */
557
+ constructor(api: SettingsApiProxy, options?: {
558
+ writeNamespaces?: readonly string[];
559
+ });
560
+ /**
561
+ * 列出全部设置命名空间(通用设置面的读侧)。
562
+ *
563
+ * 与 `listProviders()` 的区别:它不理解任何 provider 语义,原样透出内核按插件
564
+ * 划分的命名空间,由手机端按 `schema` 自己长表单 —— 新装插件不必等 App 发版。
565
+ */
566
+ describeNamespaces(): Promise<SettingsNamespacesView>;
567
+ /**
568
+ * 按路径改一个命名空间(通用设置面的写侧)。
569
+ *
570
+ * 校验顺序是刻意的:ns 策略 → ops 结构 → 内核 describe(host 可写性)→ mutate。
571
+ * 先验策略既省一次内核往返,也保证「高危命名空间」无论 ops 怎么错都先得到 403。
572
+ */
573
+ mutateNamespace(ns: string, ops: unknown, expectedRevision?: number): Promise<SettingsNamespaceEntry>;
574
+ private get writeNamespaces();
575
+ /** 内核 settings 服务缺失时给 503(而不是把 `TypeError` 漏成 500)。 */
576
+ private settingsService;
381
577
  listProviders(): Promise<{
382
578
  writable: boolean;
383
579
  revisionByNamespace: Record<string, number>;
@@ -399,7 +595,33 @@ declare class DshSettingsAdapter {
399
595
  api?: string;
400
596
  apiKey?: string;
401
597
  }): Promise<ProviderModelView[]>;
598
+ /**
599
+ * 取一个 provider 的上下文(目录条目 + 设置命名空间 + 当前 profile)。
600
+ *
601
+ * ## 为什么加一层「单请求内复用」
602
+ *
603
+ * 修复前 `updateProvider()` 会**连续**调它两次(一次在开头做校验、一次在
604
+ * `createProvider`/`removeProvider` 里),而每次 `providerContext` 都要打
605
+ * **两次**内核 RPC(`llm.providers` + `settings.describe`)。于是一次 PATCH
606
+ * 实际可能产生 4~5 次内核往返 —— 而它们问的是**同一份数据**。
607
+ *
608
+ * 这里用一个极短的 TTL(默认 1 秒,覆盖「同一请求内」的连续调用)缓存结果。
609
+ * 为什么不用「请求作用域」的显式传参:那要改每个方法的签名,而调用点散在
610
+ * router 与 adapter 两处 —— 收益远不抵这次改动。1 秒 TTL 的副作用是:
611
+ * 同一秒内两次改同一个 provider 时,第二次读到的是第一次之前的快照 ——
612
+ * 而 `settings.mutate` 有 `expectedRevision` 做真正的并发控制,所以不会写坏。
613
+ */
614
+ private readonly providerContextCache;
402
615
  private providerContext;
616
+ /**
617
+ * 写操作之后**必须**让缓存失效。
618
+ *
619
+ * 为什么不能只靠 TTL:`updateProvider` 改完 baseURL 之后,同一个请求尾部的
620
+ * `listProviders()` 要给出新值。靠 TTL 等 1 秒等于把一个「刚刚改成功」的
621
+ * 界面回滚成旧值 —— 那会被用户当成「改了没生效」。
622
+ */
623
+ private invalidateProviderContext;
624
+ private loadProviderContext;
403
625
  }
404
626
  //#endregion
405
627
  //#region src/host/dsh-0.2-bridge.d.ts
@@ -1422,6 +1644,31 @@ declare class DshChatAdapter {
1422
1644
  private readonly api;
1423
1645
  private readonly database;
1424
1646
  private readonly commandServices?;
1647
+ /**
1648
+ * 会话 → 可见性快照的短 TTL 缓存。
1649
+ *
1650
+ * ## 为什么需要(这是本文件最热的一处浪费)
1651
+ *
1652
+ * `requireSession()` 会在**几乎每个** API 调用里跑一遍 `listSessions()`,
1653
+ * 而 `listSessions` 一次要打两次内核 RPC(`sessions.list` + `workspace.list`)
1654
+ * 加一次 `database.listRoots()`。也就是说 history / commands / approvals /
1655
+ * models / questions / prompt 每条请求都白花两次内核往返 —— 只为回答
1656
+ * 「这个 session 能不能被这台设备看见」。
1657
+ *
1658
+ * ## 为什么 TTL 是 2 秒(短到近乎无感,长到足以省掉重复)
1659
+ *
1660
+ * 客户端在打开一个会话时会**连续**打四五个接口(历史、审批、提问、命令、模型),
1661
+ * 2 秒足以把这一串并成一两次真实查询。再长就会让「刚建的会话看不到」
1662
+ * 变成一个可感受到的延迟 —— 而那正是我们要避免的另一类 bug。
1663
+ *
1664
+ * ## 缓存的是什么(关键:缓存的是**授权结论**,不是会话内容)
1665
+ *
1666
+ * 只缓存「这个 sessionId 对当前设备可见吗」这一个布尔结论,且按 `principal.id`
1667
+ * 分桶。**不缓存会话正文**:正文会被 `rename`/`archive`/新消息改掉,
1668
+ * 缓存它等于制造陈旧数据;而可见性只随「根授权变没变」与「会话删没删」变,
1669
+ * 两者都是低频且由用户操作驱动的。
1670
+ */
1671
+ private readonly sessionVisibility;
1425
1672
  private readonly sessionRootIds;
1426
1673
  private readonly sessionRootLookups;
1427
1674
  private readonly pendingApprovals;
@@ -1603,6 +1850,15 @@ declare class DshChatAdapter {
1603
1850
  */
1604
1851
  private commandAgent;
1605
1852
  private requireSession;
1853
+ /**
1854
+ * 记一条可见性结论,并顺手把过期条目清掉。
1855
+ *
1856
+ * 为什么在这里做清扫而不是定时器:缓存条目数与「设备数 × 会话数」同阶,
1857
+ * 对单机部署来说是千级 —— 每次写入顺手扫一遍完全够,而且**没有定时器泄漏**。
1858
+ * (本仓的纪律:服务端不引入 `setInterval`,见 `server.ts` 的心跳是唯一的例外,
1859
+ * 且它 `unref` 过。)
1860
+ */
1861
+ private rememberVisibility;
1606
1862
  private requireWorkspace;
1607
1863
  private visibleWorkspaces;
1608
1864
  private requireAuthorizedRoot;
@@ -1633,10 +1889,35 @@ interface AuthServiceOptions {
1633
1889
  declare class AuthService {
1634
1890
  private readonly database;
1635
1891
  private readonly options;
1636
- private readonly authFailures;
1637
- readonly pairingAttempts: FixedWindowLimiter;
1638
- readonly requests: FixedWindowLimiter;
1892
+ /**
1893
+ * 两个「按对端分类」分开计的桶(键 = `<class>:<remoteAddress>`)。
1894
+ *
1895
+ * 阈值刻意**两边一致**:分桶的目的是「互不连坐」,不是「给本机开后门」。
1896
+ * 如果本机侧更宽,那它就成了一条「怎么把自己伪装成本机」的动机 —— 而伪装是免费的
1897
+ * (不发头即可),于是更宽的那一侧等于被取消。
1898
+ * 阈值相等时,攻击者伪装成 local 只会消耗 local 配额,**没有额外收益**。
1899
+ *
1900
+ * `requests`:120/60s。`authFailures`:20/5min。
1901
+ */
1902
+ private readonly authFailuresByPeer;
1903
+ /** 配对兑换:本机与隧道分开。隧道侧公网暴力尝试不该锁死本机的配对。 */
1904
+ readonly pairingAttemptsByPeer: {
1905
+ local: FixedWindowLimiter;
1906
+ tunnelled: FixedWindowLimiter;
1907
+ };
1908
+ readonly requestsByPeer: {
1909
+ local: FixedWindowLimiter;
1910
+ tunnelled: FixedWindowLimiter;
1911
+ };
1639
1912
  constructor(database: WorkspaceDatabase, options?: AuthServiceOptions);
1913
+ /** 这套服务当前的对端分类(`tunnelActive` 走 fail-closed 探针)。 */
1914
+ private peerOf;
1915
+ /** 按对端取请求配额桶。 */
1916
+ requestLimiter(req: IncomingMessage): FixedWindowLimiter;
1917
+ /** 按对端取认证失败桶。 */
1918
+ failureLimiter(req: IncomingMessage): FixedWindowLimiter;
1919
+ /** 按对端取配对尝试桶(`router.ts` 的 exchange 端点用)。 */
1920
+ pairingLimiter(req: IncomingMessage): FixedWindowLimiter;
1640
1921
  /**
1641
1922
  * 隧道是否在跑。探针抛错时**按「在跑」处理**(fail closed):探针读的是我们自己
1642
1923
  * 对象上的 getter,它抛错意味着状态不可知;状态不可知时放行管理面正是这次修复要
@@ -1849,23 +2130,82 @@ interface TunnelPublicState {
1849
2130
  /** 对外地址,已统一为 `https://` 前缀原文,供配置文本直接使用。 */
1850
2131
  url: string | null;
1851
2132
  running: boolean;
2133
+ /**
2134
+ * 兜底的**英文短消息**(调试/日志用)。
2135
+ *
2136
+ * 2.5.2 起它**已脱敏**:不含完整 URL、不含绝对路径(见 `sanitizeDownloadError`)。
2137
+ * 客户端应优先用 `errorKey` 本地化;这个字段只在没有键可用时兜底。
2138
+ */
1852
2139
  error?: string;
2140
+ /**
2141
+ * 结构化错误键(客户端据此本地化)。渲染优先级 **errorKey > error**。
2142
+ */
2143
+ errorKey?: TunnelErrorKey;
2144
+ /** `errorKey` 的插值参数。 */
2145
+ errorParams?: Record<string, string | number>;
1853
2146
  }
1854
2147
  /** 「创建云隧道」进行中任务的阶段状态,供前端轮询显示进度。`percent=null` 表示该阶段无确定百分比(流动条)。 */
1855
2148
  interface TunnelActivity {
1856
2149
  /** 阶段标识:locating / downloading / unpacking / starting;`idle` 表示当前无进行中任务。 */
1857
2150
  stage: 'idle' | 'locating' | 'downloading' | 'unpacking' | 'starting';
1858
- /** 人类可读的阶段文案(如「已找到本机二进制,直接复用」)。 */
1859
- label: string;
2151
+ /**
2152
+ * 阶段文案的 **i18n 键**(不是给人看的句子)。
2153
+ *
2154
+ * 2.5.2 起取代原来的 `label: string`:服务端不再决定用哪种语言说这句话,
2155
+ * 由客户端按自己的界面语言渲染。`''` 表示 idle(无进行中任务)。
2156
+ *
2157
+ * 键清单是**跨端契约**(见 `TunnelStageKeys`),改名会让 UI 显示原始键名。
2158
+ * `''` = idle(没有进行中的任务,UI 不显示进度条)。
2159
+ */
2160
+ labelKey: TunnelStageKey | '';
2161
+ /** 键里的插值参数(如下载进度的 `{ mb, all }`)。 */
2162
+ labelParams?: Record<string, string | number>;
1860
2163
  /** 0-100 的进度百分比;null = 该阶段无法量化(前端显示流动条)。 */
1861
2164
  percent: number | null;
1862
2165
  }
2166
+ /**
2167
+ * 隧道阶段文案的 i18n 键 —— **跨端契约,不得随意改名**。
2168
+ *
2169
+ * 客户端(`dsh-companion` / 桌面面板)按这套键查词典。改名 = UI 直接显示键名。
2170
+ */
2171
+ type TunnelStageKey =
2172
+ /** 正在定位本机二进制。 */
2173
+ 'tunnelStageLocating' |
2174
+ /** 已找到本机二进制,直接复用(不需要下载)。 */
2175
+ 'tunnelStageBinaryFound' |
2176
+ /** 正在从官方 GitHub 下载。 */
2177
+ 'tunnelStageDownloadGithub' |
2178
+ /** 正在从官方 deb 仓库下载。 */
2179
+ 'tunnelStageDownloadDeb' |
2180
+ /** 下载中,带进度:`labelParams = { mb, all }`。 */
2181
+ 'tunnelStageDownloadProgress' |
2182
+ /** 下载完成但**没有校验和可用**:内容未经验证(H5 的可见化)。 */
2183
+ 'tunnelStageDownloadUnverified' |
2184
+ /** 正在解包 deb 并提取二进制。 */
2185
+ 'tunnelStageUnpackDeb' |
2186
+ /** 正在写入二进制并赋执行权限。 */
2187
+ 'tunnelStageWriteBinary' |
2188
+ /** 正在启动 cloudflared 隧道。 */
2189
+ 'tunnelStageStarting';
2190
+ /**
2191
+ * 隧道错误的结构化键。与 `error`(兜底英文短消息)并存:
2192
+ * 客户端渲染优先级 **errorKey > error**。
2193
+ */
2194
+ type TunnelErrorKey =
2195
+ /** cloudflared 进程退出且没拿到地址:`errorParams = { code, signal }`。 */
2196
+ 'tunnelErrorExited' |
2197
+ /** 下载/解包失败(已脱敏,无参数)。 */
2198
+ 'tunnelErrorDownload' |
2199
+ /** 已经在运行,拒绝重复启动。 */
2200
+ 'tunnelErrorAlreadyRunning';
1863
2201
  /** 一次 cloudflared 子进程生命周期 + 对外状态机。 */
1864
2202
  declare class CloudflaredTunnelManager {
1865
2203
  private process;
1866
2204
  private url;
1867
2205
  private channel;
1868
2206
  private errorText;
2207
+ private errorKey;
2208
+ private errorParams;
1869
2209
  private listeners;
1870
2210
  private activity;
1871
2211
  /** 当前进行中任务的阶段进度(无任务时 stage 为 idle)。 */
@@ -1888,7 +2228,13 @@ declare class CloudflaredTunnelManager {
1888
2228
  get publicState(): TunnelPublicState;
1889
2229
  /** 订阅状态变化;返回取消函数。 */
1890
2230
  onStatus(cb: () => void): () => void;
2231
+ /** 取消一个状态订阅(onStatus 返回的函数与 waitForUrl 共用)。 */
2232
+ private offStatus;
1891
2233
  private emit;
2234
+ /**
2235
+ * 记录一次错误。`error` 保留脱敏后的短消息(兜底/日志),
2236
+ * `errorKey` + `errorParams` 供客户端本地化(优先级 errorKey > error)。
2237
+ */
1892
2238
  private setError;
1893
2239
  /**
1894
2240
  * 启动一次 Quick Tunnel。
@@ -1896,6 +2242,18 @@ declare class CloudflaredTunnelManager {
1896
2242
  * @param port bridge 监听端口,cloudflared 把外网请求转发到 localhost:port
1897
2243
  */
1898
2244
  startQuick(exe: string, port: number): Promise<TunnelPublicState>;
2245
+ /**
2246
+ * 等待子进程把随机域名打印到 stdout(spawn 后 `url` 通常还要几秒才有)。
2247
+ *
2248
+ * 存在的理由(2.5.2):`POST /manage/cloudflare/create` 发生在隧道**起来之前**,
2249
+ * 此刻管理面还没被判据 0 关门;一旦在这次请求里等出地址并顺带出令牌配置文本,
2250
+ * 客户端就不必在隧道运行后再打一次 `/manage/config/text`(那时必然 403,
2251
+ * 这正是「一键开隧道+出令牌」失效的根因)。
2252
+ *
2253
+ * 任一条件满足即结算:地址出现 → resolve(url);进程退出/超时 → resolve(null)
2254
+ * (不抛,调用方据 null 决定要不要让客户端走轮询补一步)。调用方传 AbortSignal 可提前走人。
2255
+ */
2256
+ waitForUrl(timeoutMs?: number, signal?: AbortSignal): Promise<string | null>;
1899
2257
  /** 停止当前隧道。 */
1900
2258
  stop(): TunnelPublicState;
1901
2259
  /** 注入已抓到的地址 + 通道(命名隧道用)。 */
@@ -2020,6 +2378,37 @@ declare class ApiRouter {
2020
2378
  handle(req: IncomingMessage, res: ServerResponse, stripPrefix?: string): Promise<void>;
2021
2379
  private dispatch;
2022
2380
  private dispatchManagement;
2381
+ /**
2382
+ * 打开隧道并(可选)顺带产出配置文本 —— HTTP `POST /manage/cloudflare/create` 与
2383
+ * IM `/tunnel` 命令**共用**这一条实现。
2384
+ *
2385
+ * 抽出来的原因:两条入口的前半段完全一致(确保 cloudflared → startQuick → 等随机域名 →
2386
+ * 建配置文本),差别只在「谁在调用」——HTTP 侧要先把请求体读干净(流会随时间关闭),
2387
+ * 命令侧没有请求体。复制一份会让「一键出令牌」在两条路径上慢慢漂移。
2388
+ *
2389
+ * @param createBody - 已解析的请求体(HTTP 侧)或 `{ configText: true }`(命令侧)。
2390
+ * @param requestId - 审计与配对记录共用的请求 ID。
2391
+ * @param actorLabel - 审计里的操作者标签:`loopback-admin`(本机管理面)或 `im-command`。
2392
+ * @returns 合并了「等到地址」的 state,以及(wantConfig 且拿到地址时)配置文本。
2393
+ */
2394
+ private createTunnel;
2395
+ /**
2396
+ * IM `/tunnel` 命令的宿主侧入口:开隧道并返回一行可粘贴的配置文本。
2397
+ *
2398
+ * 与 HTTP 路径的两点差别:
2399
+ *
2400
+ * 1. **隧道已在运行时直接复用当前地址**,不重复 spawn(第二次 `startQuick` 会撞
2401
+ * `TUNNEL_ALREADY_RUNNING`,而用户在 IM 里连发两条是很常见的)。
2402
+ * 2. 配置文本的 endpoint 只用隧道地址,设备名缺省由 `buildConfigText` 决定 —— 命令
2403
+ * 不接受参数,所以没有 `endpointLabel` / `rootIds` 的透传面。
2404
+ *
2405
+ * @throws 当 12 秒内没拿到公网地址,或 cloudflared 下载失败时。
2406
+ */
2407
+ openTunnelForCommand(): Promise<{
2408
+ url: string;
2409
+ text: string;
2410
+ deviceName: string;
2411
+ }>;
2023
2412
  /**
2024
2413
  * 隧道状态视图。
2025
2414
  *
@@ -2035,6 +2424,19 @@ declare class ApiRouter {
2035
2424
  * 评估结论见报告:本轮选择「远程一律 403」而不是「远程读得到但 `url` 为 null」,
2036
2425
  * 因为后者需要为状态端点在判据 1 上再开一个口子,而那正是这次修复要消灭的东西。
2037
2426
  */
2427
+ /**
2428
+ * 生成一行可粘贴的 `DSH1:` 配置文本(POST /manage/config/text 与「一键开隧道并出令牌」
2429
+ * 的 create 端点共用,避免两处各写一套配对/兑换/编码逻辑而漂移)。
2430
+ *
2431
+ * 入参字段与 /manage/config/text 的窄格式一致:
2432
+ * - endpoints:显式给出则不再做本机网卡探测(隧道场景必须只塞隧道地址,不能混入 LAN);
2433
+ * - rootIds / scopes / deviceName / displayName / port:全部可选,缺省沿用原端点口径;
2434
+ * - scopes 缺省是**五个非破坏性默认权限**(不含 files.delete / settings.write),
2435
+ * 调用方若显式传 DEVICE_SCOPES 会放大手机权限,create 一键路径不传。
2436
+ *
2437
+ * 刻意不返回 pairing.expiresAt:那是配对码的十分钟有效期,设备令牌本身不过期。
2438
+ */
2439
+ private buildConfigText;
2038
2440
  private tunnelStateFor;
2039
2441
  private dispatchEntries;
2040
2442
  private dispatchContent;
@@ -2056,6 +2458,31 @@ interface RemoteApiServerOptions {
2056
2458
  chat: DshChatAdapter;
2057
2459
  events: WorkspaceEventBus;
2058
2460
  handle: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
2461
+ /**
2462
+ * 同时允许的 WebSocket 连接数上限(默认 [DEFAULT_MAX_CONNECTIONS])。
2463
+ *
2464
+ * 为什么必须有:`sockets` 此前是一个无界 Map —— 一个循环重连的客户端就能堆出任意多
2465
+ * 连接,每条都参与广播、每条都查一次库。上限在**鉴权之后**才判定,所以它不会成为
2466
+ * 未鉴权用户可探测的攻击面。
2467
+ */
2468
+ maxConnections?: number;
2469
+ /** 单帧最大字节数(默认 1 MiB)。超限由 `ws` 直接 `close(1009)`。 */
2470
+ maxPayloadBytes?: number;
2471
+ /** 心跳间隔(默认 30 s)。测试传小值,不必干等半分钟。 */
2472
+ heartbeatIntervalMs?: number;
2473
+ /** 单连接事件队列上限(默认 512)。溢出按「丢可重建帧」策略处理。 */
2474
+ socketQueueLimit?: number;
2475
+ /** 发送缓冲高水位(默认 4 MiB)。连续两次越过就 `close(1013)`。 */
2476
+ highWaterMarkBytes?: number;
2477
+ /**
2478
+ * 运行时能力开关,喂给 `connectionReadyData()`。
2479
+ *
2480
+ * 为什么在构造参数里而不是全局变量:能力位是**这台服务端此刻**能做什么,
2481
+ * 同一进程里可能有多个实例(测试、重绑),用实例参数才不会被互相污染。
2482
+ */
2483
+ capabilities?: {
2484
+ jobs?: boolean;
2485
+ };
2059
2486
  }
2060
2487
  declare class RemoteApiServer implements RemoteControl {
2061
2488
  private readonly options;
@@ -2070,8 +2497,15 @@ declare class RemoteApiServer implements RemoteControl {
2070
2497
  private readonly webSockets;
2071
2498
  private readonly eventsAbort;
2072
2499
  private unsubscribeEvents;
2073
- private broadcastQueue;
2500
+ /** 所有连接的 drain 完成信号;`Promise.all` 它们即可等排空。 */
2501
+ private readonly pendingDrains;
2502
+ private heartbeatTimer;
2503
+ private changeTimer;
2074
2504
  private changing;
2505
+ private readonly maxConnections;
2506
+ private readonly socketQueueLimit;
2507
+ private readonly highWaterMarkBytes;
2508
+ private readonly heartbeatIntervalMs;
2075
2509
  constructor(options: RemoteApiServerOptions);
2076
2510
  start(): Promise<void>;
2077
2511
  stop(): Promise<void>;
@@ -2087,12 +2521,39 @@ declare class RemoteApiServer implements RemoteControl {
2087
2521
  disable(): RemoteOperationView;
2088
2522
  configure(host: string, port: number): RemoteOperationView;
2089
2523
  private scheduleChange;
2524
+ /** 取消还没触发的监听变更。幂等:没定时器就什么都不做。 */
2525
+ private clearChangeTimer;
2090
2526
  private performChange;
2091
2527
  private rebind;
2092
2528
  private bind;
2093
2529
  private createBoundServer;
2530
+ /**
2531
+ * 关掉当前监听与全部连接,**有界**。
2532
+ *
2533
+ * `server.close()` 的回调要等所有连接结束;而**升级过的 WebSocket 不在
2534
+ * `closeAllConnections()` 的管理范围里**(HTTP 层已经不跟踪它了)。于是只要有一条
2535
+ * 连接读不动(暂停读 / 半开),`close()` 就永远等不到回调 —— 插件卸载会挂死。
2536
+ *
2537
+ * 所以这里的顺序是:先礼后兵 —— 发 1012(客户端会看到「服务端重绑」而不是异常断开),
2538
+ * 等一小段宽限期;宽限期到就 `terminate()`,同时让 `close()` 的等待一起收尾。
2539
+ */
2094
2540
  private closeCurrent;
2095
2541
  private handleUpgrade;
2542
+ /**
2543
+ * 心跳循环:每 `heartbeatIntervalMs` 检查一次存活标记。
2544
+ *
2545
+ * 时序要看清:`isAlive` **先读后置假**,并且只在本轮「活着」时才补一次 `ping`。
2546
+ * 于是:
2547
+ * - 本轮活着 → 标记为可疑,再 ping 一次;
2548
+ * - 本轮仍可疑(上一轮 ping 没换来 pong)→ `terminate()`。
2549
+ *
2550
+ * 结果是**连续错过两个周期**才踢人。偶发丢一个包不会误杀在线设备,真正断掉的
2551
+ * (手机切网 / 熄屏后 NAT 表项过期)最多两个周期就清掉 —— 而修复前它只能等
2552
+ * TCP 自己超时,等于「半开连接长期占着额度,还在每轮广播里被白查一次库」。
2553
+ *
2554
+ * `unref()` 必需:这个定时器永不结束,不 unref 会让进程退不掉。
2555
+ */
2556
+ private startHeartbeat;
2096
2557
  /**
2097
2558
  * 把待处理的**审批与提问**补推给刚建立的这条连接。
2098
2559
  *
@@ -2102,15 +2563,78 @@ declare class RemoteApiServer implements RemoteControl {
2102
2563
  * 重连窗口里产生的审批就会静默消失(内核阻塞着等决定、手机上却一片安静)。
2103
2564
  * 重放把「新连接一定能拿到仍待处理的审批」变成**服务端保证**,而不是客户端的义务。
2104
2565
  *
2105
- * 三个刻意的取舍:
2566
+ * 四个刻意的取舍:
2106
2567
  * - **不 await**:重放要按会话做授权过滤(异步),握手不能为它排队;
2107
2568
  * - **只发这一条连接**:不走 `broadcast`,避免每次有人重连就骚扰所有在线设备;
2108
2569
  * - **失败静默**:重放是补救措施,它出错不该影响这条连接正常收事件。
2109
- * 客户端侧「连接后/亮屏后主动刷新」仍是兜底,但不再是唯一依赖。
2570
+ * 客户端侧「连接后/亮屏后主动刷新」仍是兜底,但不再是唯一依赖;
2571
+ * - **走连接自己的队列**:重放帧与随后的广播帧必须保序。直接 `socket.send`
2572
+ * 会让「重放的第 2 帧」插到「广播的第 1 帧」前面去。
2110
2573
  */
2111
2574
  private replayPendingInteractions;
2112
- private broadcast;
2575
+ /** 当前设备授权版本;`database` 还没提供就返回 undefined(退回逐事件查库的旧路径)。 */
2576
+ private devicesRevisionOf;
2577
+ /**
2578
+ * 广播入口:**只做快照 + 入队**,全程同步。
2579
+ *
2580
+ * 这里是宿主事件循环上的热路径 —— 订阅回调里的任何 `await`(查库、查内核、
2581
+ * 发网络)都会反过来把宿主的 `push` 拖住。修复前正是这样:一条全局 promise 链
2582
+ * 串起「事件 × 连接」,一个慢判定让所有事件、所有客户端一起排队,`stop()`
2583
+ * 还得 `await` 那条链。
2584
+ */
2585
+ private enqueueBroadcast;
2586
+ /**
2587
+ * 单连接入队:溢出时按「先丢可重建帧」处理,并给客户端一个 resync 信号。
2588
+ *
2589
+ * 为什么溢出不断连:手机端解码慢一次不该被踢下线(那会触发一条重连风暴,
2590
+ * 而重连风暴又会把刚建好的连接再挤掉)。但「静默丢帧」更糟 —— 客户端会以为
2591
+ * 事件流还连续,于是它手里的状态永远停在丢帧之前。所以丢的时候记一笔,
2592
+ * 随后补发 `connection.resyncRequired`,把「静默」变成「告知」。
2593
+ */
2594
+ private enqueue;
2595
+ /** 保证一条连接只有一个 drain 循环(否则同一连接内的顺序会被打乱)。 */
2596
+ private startDrain;
2597
+ /**
2598
+ * 一条连接的发送循环。
2599
+ *
2600
+ * `await` 只发生在「这条连接自己的准备工作」上(刷新设备身份、判断会话授权)。
2601
+ * 于是慢连接只慢它自己 —— 这正是与修复前的根本区别。
2602
+ */
2603
+ private drain;
2604
+ /**
2605
+ * 记录一次「越过发送高水位」,并安排复查。
2606
+ *
2607
+ * @returns 这条连接是否已经被关掉(调用方据此决定还要不要继续 drain)。
2608
+ *
2609
+ * 两拍才关是刻意的:一次越线可能只是客户端的短暂抖动(切网、GC),
2610
+ * 立刻踢下线会制造重连风暴。但**必须有复查** —— 否则「第二拍」永远不会发生,
2611
+ * 连接就卡在「不发也不关」的僵尸态,比直接断掉更难排查。
2612
+ */
2613
+ private noteHighWater;
2614
+ private sendFrame;
2615
+ /** 清掉这条连接自己的定时器。**必须**在连接被摘掉时调用,否则定时器会拖住进程。 */
2616
+ private clearSocketTimers;
2617
+ private closeBackpressured;
2618
+ /**
2619
+ * 按需刷新这条连接的身份快照。
2620
+ *
2621
+ * 有 `devicesRevision` 时:只有版本变了才查库 —— 正常情况**一件事都不做**,
2622
+ * 而修复前是「每个事件、每条连接都查一次 `getActiveDevice`」。
2623
+ * 没有该 getter 时退回旧行为(逐事件查库):老数据库上功能不变,只是慢一点。
2624
+ * 这比「广播路径依赖一个可能不存在的 API」安全得多,也让两侧改动可以分批落地。
2625
+ *
2626
+ * @returns 最新身份;设备已被撤销时返回 undefined。
2627
+ */
2628
+ private refreshPrincipal;
2113
2629
  private eventAllowed;
2630
+ /**
2631
+ * 等所有连接排空,**有上限**。
2632
+ *
2633
+ * `stop()` 不能无限等:一条读不动的 socket 会让插件卸载挂死,进而拖住进程退出。
2634
+ * 超时后照样会断开它们(`closeCurrent` 负责),只是不再尝试把队列里的东西送出去 ——
2635
+ * 卸载路径上「送达」本来就不是目标。
2636
+ */
2637
+ private drainAll;
2114
2638
  private trimOperations;
2115
2639
  }
2116
2640
  /** 一条挂在 DSH webserver 上的插件自有路由。 */
@@ -2164,6 +2688,14 @@ interface Config {
2164
2688
  port?: number;
2165
2689
  remoteHost?: string;
2166
2690
  maxUploadBytes?: number;
2691
+ /**
2692
+ * 额外允许手机端写入的设置命名空间(默认空)。
2693
+ *
2694
+ * 精确 ns(`ui-chat`)或通配前缀(`llm-*`)。与内置策略叠加:`ui-*` 与以
2695
+ * `locale` 结尾的命名空间本来就可写;其余默认只读,因为改模型/权限等于把
2696
+ * 遥控器交出去(理由见 docs/project/SECURITY.md)。
2697
+ */
2698
+ settingsWriteNamespaces?: readonly string[];
2167
2699
  }
2168
2700
  interface HostContext extends Dsh02HostServices {
2169
2701
  webServer: Parameters<typeof attachEmbeddedRoutes>[0];