dsh-remote-bridge 2.9.1 → 2.10.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
@@ -1,3 +1,4 @@
1
+ import { DatabaseSync } from "node:sqlite";
1
2
  import { IncomingMessage, ServerResponse } from "node:http";
2
3
  import { WebSocket } from "ws";
3
4
  //#region src/shared/contracts.d.ts
@@ -75,13 +76,18 @@ interface DevicePrincipal {
75
76
  }
76
77
  type Principal = AdminPrincipal | DevicePrincipal;
77
78
  //#endregion
78
- //#region src/host/database.d.ts
79
+ //#region src/host/db/roots.d.ts
79
80
  interface StoredRoot extends RootView {
80
81
  realPath: string;
81
82
  }
83
+ //#endregion
84
+ //#region src/host/db/devices.d.ts
82
85
  interface AuthenticatedDevice extends DeviceView {
83
86
  tokenHash: string;
84
87
  }
88
+ //#endregion
89
+ //#region src/host/db/trash.d.ts
90
+ /** 回收站里的一条待恢复记录。 */
85
91
  interface TrashRecord {
86
92
  id: string;
87
93
  rootId: string;
@@ -93,11 +99,19 @@ interface TrashRecord {
93
99
  createdAt: number;
94
100
  status: string;
95
101
  }
102
+ //#endregion
103
+ //#region src/host/database.d.ts
96
104
  declare class WorkspaceDatabase {
97
105
  readonly dataDir: string;
98
106
  readonly trashDir: string;
99
107
  private readonly db;
100
108
  private closed;
109
+ private readonly auditTable;
110
+ private readonly roots;
111
+ private readonly devices;
112
+ private readonly pairings;
113
+ private readonly trash;
114
+ private readonly idempotency;
101
115
  /**
102
116
  * 「设备集合或设备权限发生过写」的进程内单调计数。
103
117
  *
@@ -119,6 +133,9 @@ declare class WorkspaceDatabase {
119
133
  *
120
134
  * 保守性:宁可多 +1(多一次缓存失效,代价是一次查询),不可漏 +1
121
135
  * (漏了就是「撤销后仍能收事件」—— 那正是 `device-revocation` 要防的事)。
136
+ *
137
+ * 拆分后计数器留在组合根:`devices` 与 `pairings` 两个表族都会触发它,
138
+ * 计数器若放任何一方,另一方就得 import 对方 —— 回调注入切断了这条边。
122
139
  */
123
140
  private devicesRevisionCounter;
124
141
  constructor(dataDir: string);
@@ -126,63 +143,26 @@ declare class WorkspaceDatabase {
126
143
  get devicesRevision(): number;
127
144
  /** 关闭数据库。**幂等** —— 重复调用不抛错。 */
128
145
  close(): void;
129
- /**
130
- * 导出 `roots` 的建表语句(只读,诊断与迁移测试用)。
131
- *
132
- * 存在的理由:迁移要判断「UNIQUE 还在不在」,单看 `PRAGMA table_info` 看不出来 ——
133
- * 表结构读出来一模一样,区别在约束上。这个方法让「重建真的发生了」可以被断言,
134
- * 而不是靠迁移没报错来推测。
135
- */
136
- dumpSchema(): string;
137
146
  getSetting(key: string): string | undefined;
138
147
  setSetting(key: string, value: string): void;
139
- /**
140
- * 登记一条授权根。
141
- *
142
- * @param options.requestId - 发起这次登记的那条 HTTP 请求的 id。
143
- * 审计的用途不只是「记下发生了什么」,还要能回答「**哪一次请求**干的」;
144
- * 不传(插件启动期登记内置根)时审计行的 `request_id` 为 null。
145
- */
148
+ /** 登记一条授权根。`options.requestId` 是发起登记的 HTTP 请求 id(审计可追溯)。 */
146
149
  addRoot(inputPath: string, label?: string, options?: {
147
150
  allowDuplicate?: boolean;
148
151
  requestId?: string;
149
152
  }): Promise<StoredRoot>;
150
153
  listRoots(): StoredRoot[];
151
154
  getRoot(id: string): StoredRoot | undefined;
152
- /**
153
- * 登记「DSH 工作区」根 —— **幂等**。
154
- *
155
- * 用户要求:当前 DSH 的工作目录要显示成一个授权根,**不可删除、但可以正常访问**。
156
- * 它不是另起一张表,而是**一条普通的根**加一个 `builtin` 标记:配对、`files.*`
157
- * 授权、回收站、写盘全走现成逻辑,少一套需要同步维护的规则。
158
- *
159
- * ## 同目录已有一条普通根时,另起一条而不是复用它
160
- *
161
- * 这是实测踩到的现实情况:用户早于这个功能**自己**加过同一条目录(本机那条是
162
- * 10-08 手工加的),路径和 DSH 工作区一模一样。
163
- *
164
- * 初版在这里「尊重用户选择」直接返回,结果就是**内置项永远登记不上** —— 因为它
165
- * 每次都撞上那条已有的普通根然后什么都不做。界面上那个「不可删除」也就永远出不来。
166
- * 用户的选择和功能的正确性在这里是冲突的:用户要的是「这条要不可删」,不是
167
- * 「这条不能变」。
168
- *
169
- * 所以改成**另起一条**:用户原来那条保持可删(那是他的目录,他有权删),内置那条
170
- * 独立存在。代价是列表里同一个路径出现两行 —— 这是「两条记录、两种权限」的必然
171
- * 结果,比让功能失效或擅自改他的配置都更可接受。
172
- *
173
- * 幂等不再靠 UNIQUE(它已经被去掉了),而是靠「已存在 `builtin = 1` 的同路径就
174
- * 直接返回」。插件**每次启动都可以无脑调一次**。
175
- *
176
- * 只有传入路径确实存在才登记:工作区可能被用户删掉,这时留一条指向虚空的授权根,
177
- * 手机浏览时会一路报错,比不显示更糟。
178
- */
155
+ /** 登记「DSH 工作区」内置根 —— 幂等,插件每次启动都可调。 */
179
156
  ensureBuiltinRoot(realPath: string, label: string): Promise<StoredRoot | undefined>;
180
157
  removeRoot(id: string): void;
181
158
  /**
182
- * 生成一枚配对码。
159
+ * 导出 `roots` 的建表语句(只读,诊断与迁移测试用)。
183
160
  *
184
- * @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
161
+ * 迁移要判断「UNIQUE 还在不在」,单看 `PRAGMA table_info` 看不出来 ——
162
+ * 表结构读出来一模一样,区别在约束上。这个方法让「重建真的发生了」可以被断言。
185
163
  */
164
+ dumpSchema(): string;
165
+ /** 生成一枚配对码。`options.requestId` 是发起请求的 HTTP requestId(审计可追溯)。 */
186
166
  createPairing(rootIds: string[], scopes: DeviceScope[], ttlMs?: number, options?: {
187
167
  requestId?: string;
188
168
  }): {
@@ -192,6 +172,11 @@ declare class WorkspaceDatabase {
192
172
  /**
193
173
  * 用配对码换一枚设备令牌。
194
174
  *
175
+ * 这是**跨表族事务**,所以它留在组合根:pairings + devices + device_roots +
176
+ * audit 四张表的写入必须在一个事务里。表族模块只提供事务内原语
177
+ * (`pairings.lookup` / `pairings.markUsed` / `devices.insertForPairing` /
178
+ * `devices.grantRoots`),由这里拼装。
179
+ *
195
180
  * 审计**在事务内**写(FUNC-5):它是「这台设备被授权了」的唯一留痕,
196
181
  * 与设备行同生共死。审计写不进去 → 整个兑换回滚 → 客户端重试,
197
182
  * 而不是「拿到令牌但库里没有这笔授权的记录」。
@@ -205,171 +190,47 @@ declare class WorkspaceDatabase {
205
190
  };
206
191
  authenticate(token: string): AuthenticatedDevice | undefined;
207
192
  getActiveDevice(id: string): AuthenticatedDevice | undefined;
208
- private hydrateDevice;
209
- /**
210
- * 记录一次探测到的设备身份,必要时顺手把占位名改成「平台 · 型号」。
211
- *
212
- * 时序说明:配对时桥只知道「有个设备要连」,真正的平台与型号要等设备
213
- * **第一次发请求**才拿得到。所以这个方法由认证路径调用(见 `auth.ts`
214
- * 的 `principal`),而不是配对路径。
215
- *
216
- * ## 什么时候改名
217
- *
218
- * 只有 `name_source` 是 `default`(配对时的占位名)或 `detected`(上次自动
219
- * 生成的)才改名。`declared`(用户手工改的)**永不动** —— 用户起的名比机器
220
- * 猜的准,而且他能改一次就能改第二次,但机器每次连接都覆盖就再也留不住了。
221
- *
222
- * 老库的 `name_source` 是 NULL:那些行里的名字多半是硬编码的「Android 设备」,
223
- * 但**也可能是用户手工改过的**(此前没有任何改名通道,所以实际上只有占位名;
224
- * 不过万一有人直接改过库,我们不能替他决定)。保守起见把 NULL 当作
225
- * `declared` 处理 —— 名字不动,只补 platform/model 两列。
226
- *
227
- * ## 为什么不做成「每次请求都写库」
228
- *
229
- * 调用方只在**探测结果与库里不同**时才调这里(见 `auth.ts`),所以正常情况
230
- * 下一台设备一生只写一次。这不是性能考虑,而是避免把一次读请求变成写请求:
231
- * 写请求会碰 WAL、会影响 `devicesRevisionCounter`,进而触发全体客户端重拉设备表。
232
- *
233
- * @returns 是否真的改了库(调用方据此决定要不要递增设备版本号)。
234
- */
193
+ /** 记录探测到的设备身份;返回是否真的改了库(调用方据此决定要不要递增版本号)。 */
235
194
  recordDeviceIdentity(id: string, identity: {
236
195
  platform?: string;
237
196
  model?: string;
238
197
  suggestedName?: string;
239
198
  }): boolean;
240
- /**
241
- * 用户手工改名。
242
- *
243
- * 一旦走这条路,`name_source` 就变成 `declared`,此后任何自动探测都**不再**
244
- * 覆盖它。这是「用户意图优先于机器推断」的唯一实现点,所以单独一个方法而不是
245
- * 塞进 `updateDevice`:权限变更与改名是两件不同的事,审计动作也不该混。
246
- */
199
+ /** 用户手工改名;改完 `name_source` 变 `declared`,自动探测永不再覆盖。 */
247
200
  renameDevice(id: string, name: string, options?: {
248
201
  requestId?: string;
249
202
  }): DeviceView;
203
+ /** 只列活跃设备(用户要求「已撤销的不显示」)。 */
250
204
  listDevices(): DeviceView[];
251
205
  isDeviceActive(id: string): boolean;
252
- /**
253
- * 改一台设备的 scope 与 root 授权。
254
- *
255
- * 审计**在事务内**写(FUNC-5):它是「谁的权限被扩大了」的唯一留痕。
256
- * 审计失败 → 整个更新回滚。宁可让用户重试,也不能出现「权限已经变宽、
257
- * 但没有任何记录说是谁在什么时候改的」—— 那是不可审计的提权。
258
- *
259
- * @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
260
- */
206
+ /** 改一台设备的 scope 与 root 授权。审计在事务内(FUNC-5)。 */
261
207
  updateDevice(id: string, scopes: DeviceScope[], rootIds: string[], options?: {
262
208
  requestId?: string;
263
209
  }): DeviceView;
264
- /**
265
- * 撤销设备 = **直接删除这一行**(用户要求:已撤销的不显示,并自动从数据库中删除)。
266
- *
267
- * 原先是打 `revoked_at` 标记保留行,两处都不好:
268
- * - 界面上会出现一长串灰掉的「已撤销」,本机实测堆到了 32 条,全是历史测试与旧设备;
269
- * - 行留着就等于凭据的哈希还在库里,是个长期负担 —— 令牌已经没用了,留着没有意义。
270
- *
271
- * 删除是安全的,因为:
272
- * - `device_roots` 是 `ON DELETE CASCADE`,授权行自动清掉;
273
- * - `audit.actor_id` 是**普通字符串不是外键**,审计留痕(谁在什么时候撤了谁)一条不少;
274
- * - `authenticate` 仍保留 `revoked_at IS NULL` 作为兜底,万一有别处留下的标记行,
275
- * 它们依然登不进来 —— 两层保险不冲突。
276
- */
277
- /**
278
- * 撤销一台设备 = 删除设备行。
279
- *
280
- * 审计与删除**在同一事务里**(FUNC-5),且顺序是「先留痕、再删除」:
281
- * 审计的 `actor_id` 不带外键,删设备不会把记录带走。反过来若先删后写、
282
- * 而写审计失败,就会留下「设备已经没了,但没有任何记录说谁撤的」。
283
- *
284
- * @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
285
- */
210
+ /** 撤销设备 = 直接删除设备行(审计先留痕再删,同事务)。 */
286
211
  revokeDevice(id: string, options?: {
287
212
  requestId?: string;
288
213
  }): void;
289
- /**
290
- * 一次性清理历史遗留的「标记式」撤销设备(`revoked_at IS NOT NULL` 的行)。
291
- *
292
- * 撤销改成直接删除之前,每次撤销都只是打个标记,于是库越用越臃肿 —— 本机实测堆到
293
- * **32 条**,全是历史测试探针和早已弃用的旧手机,界面上就是一长串灰字。
294
- *
295
- * 幂等:没有这种行时返回 0,不报错。插件每次启动调一次即可。
296
- * 走 `revokeDevice` 的删除语义,但**不能**借它写 `device.revoke` 审计 ——
297
- * 那会把「系统清理」记成管理员逐个撤销,审计会失真。清理本身只写一条汇总。
298
- */
299
- /**
300
- * 仅供测试:造一行「撤销改成删除之前」的标记式撤销数据。
301
- *
302
- * 真实场景来自历史库(每次撤销只打 `revoked_at` 标记,于是越积越多)。测试需要个
303
- * 干净的办法制造这种行,而它不该走生产代码路径 —— 所以显式开一个测试专用入口,
304
- * 并起一个自见其明的名字,免得有人把它当成正常 API。
305
- */
306
- /**
307
- * 仅供测试:把某台设备的「最后使用时间/创建时间」拨到过去。
308
- *
309
- * 生产路径没法造出「30 天没动过」的设备(认证一次就刷新 `last_seen_at`),而
310
- * `pruneInactiveDevices` 的核心语义恰恰只在这种时间跨度上才看得见。与
311
- * `markRevokedForTest` 同样的理由:显式开一个自见其明的测试入口,别让人误当 API。
312
- */
214
+ /** 仅供测试:把设备的 last_seen_at / created_at 拨到过去。 */
313
215
  backdateDeviceForTest(id: string, options: {
314
216
  lastSeenAt?: number;
315
217
  createdAt?: number;
316
218
  }): void;
219
+ /** 仅供测试:造一行历史遗留的「标记式撤销」数据。 */
317
220
  markRevokedForTest(id: string): void;
318
- /**
319
- * 按「最后使用时间」批量清掉长期没动过的设备。
320
- *
321
- * 「最后使用」取 `last_seen_at`(每次令牌认证成功都会刷新),从没认证过的设备退回
322
- * `created_at` —— 否则一条配好就再没开过的设备会永远躲过清理,而那恰恰是用户最想
323
- * 一键清掉的那类(换手机、临时配对、旧测试探针)。
324
- *
325
- * 与 `purgeRevokedDevices` 的区别:后者清的是历史遗留的「标记式撤销」脏行,这里清的是
326
- * **仍然有效、但已经不用了**的设备 —— 用户的原话是「一键清理多少天未使用设备」。
327
- *
328
- * 语义边界(故意这么定):
329
- * - 只删设备行 + 级联的 `device_roots`,不碰审计(审计的 `actor_id` 不是外键);
330
- * - **不删本机正在用的那台**(`keepDeviceId`):调用方就是它,删掉自己会让当前
331
- * 客户端立刻 401,看起来像「点一下清理把手机踢下线了」;
332
- * - 天数被夹在 1..3650,避免 `0` 或负数变成「清空所有设备」;
333
- * - 返回真正删掉的条数,0 也是一次合法结果。
334
- */
221
+ /** 按「最后使用时间」批量清掉长期没动过的设备,返回删除条数。 */
335
222
  pruneInactiveDevices(olderThanDays: number, options?: {
336
223
  keepDeviceId?: string;
337
224
  requestId?: string;
338
225
  }): number;
226
+ /** 一次性清理历史遗留的「标记式撤销」设备,返回删除条数。 */
339
227
  purgeRevokedDevices(): number;
340
- /**
341
- * 删掉早于 `cutoffMs` 的幂等回放记录,返回删除行数。
342
- *
343
- * 由 `retention.ts`(perf-reader)按调度调用,本方法只做 SQL —— 不判断策略、
344
- * 不写日志,所以它可以被单独测试。
345
- *
346
- * 为什么用 `created_at` 而不是加一列 `expires_at`:24 小时的过期**完全**由
347
- * `created_at` 推导得出,加列会让每次插入多写一个字段,还多一次 migration 风险。
348
- */
349
- pruneIdempotency(cutoffMs: number): number;
350
- /**
351
- * 清理审计:先按时间删,再兜「总数超过 `keepMax` 就删最旧的」。
352
- *
353
- * ## 为什么是两段而不是一段
354
- *
355
- * 只按时间删不够:桥可以被一个「短时间高频写入」的场景灌满审计表
356
- * (例如每秒几十条 `chat.delta` 对应的写操作),时间窗内的量仍然能压垮库。
357
- * 只按数量删也不够:用户可能希望「一年前的都清掉」,而不在乎条数。
358
- *
359
- * ## `keepMax` 是**下限保护**
360
- *
361
- * 即使 `cutoffMs` 设成了未来(把全部行都判成过期),也**至少**保留最近
362
- * `keepMax` 条 —— 一句写错的调用不该把审计清空。审计被清空本身就等于
363
- * 「抹掉证据」,这个方向必须由这一层挡住。
364
- *
365
- * 返回删除的行数(两段之和)。
366
- */
367
- pruneAudit(cutoffMs: number, keepMax: number): number;
368
228
  insertTrash(record: TrashRecord): void;
369
229
  updateTrashStatus(id: string, status: string): void;
370
230
  getTrash(id: string): TrashRecord | undefined;
371
231
  listTrash(rootIds?: ReadonlySet<string>): TrashRecord[];
372
232
  deleteTrashRecord(id: string): void;
233
+ /** 追加一条审计行。安全边界:只存 id/时间/动作/详情 JSON,绝不存令牌与正文。 */
373
234
  audit(actorType: string, actorId: string, action: string, rootId?: string, relativePath?: string, details?: Record<string, unknown>, requestId?: string): void;
374
235
  listAudit(limit?: number): Record<string, unknown>[];
375
236
  readIdempotent(deviceId: string, key: string): {
@@ -377,26 +238,10 @@ declare class WorkspaceDatabase {
377
238
  body: unknown;
378
239
  } | undefined;
379
240
  saveIdempotent(deviceId: string, key: string, status: number, body: unknown): void;
380
- private migrate;
381
- /** `roots.real_path` 上是否还带 UNIQUE 约束。 */
382
- private rootsPathIsUnique;
383
- /**
384
- * 重建 `roots` 去掉 UNIQUE。
385
- *
386
- * 这是一次**有真实数据风险**的操作,所以每一步都写清楚理由:
387
- * - `PRAGMA foreign_keys=OFF`:`device_roots.root_id` 引用 `roots(id)`,而重建要
388
- * DROP 掉被引用的那张表。开着外键会直接失败。
389
- * - 事务:`new_roots` 建好、数据搬完、**最后**才 DROP 老表并改名。中途失败会整体
390
- * 回滚,原表原封不动。
391
- * - 搬数据用显式列名而不是 `SELECT *`:老表可能还没有 `builtin` 列(两种迁移顺序
392
- * 都可能出现),按列名搬才不会在某一台机器上错位。
393
- */
394
- private dropRootsUniqueConstraint;
395
- /** `roots` 表是否已有指定列。 */
396
- private hasRootColumn;
397
- /** `devices` 表是否已有指定列。 */
398
- private hasDeviceColumn;
399
- private hasColumn;
241
+ /** 删掉早于 `cutoffMs` 的幂等回放记录(`retention.ts` 按调度调用)。 */
242
+ pruneIdempotency(cutoffMs: number): number;
243
+ /** 清理审计:先按时间删,再兜「总数超过 keepMax 删最旧的」(retention 策略见 retention.ts)。 */
244
+ pruneAudit(cutoffMs: number, keepMax: number): number;
400
245
  }
401
246
  //#endregion
402
247
  //#region src/host/settings-namespace.d.ts
@@ -725,7 +570,7 @@ declare class DshSettingsAdapter {
725
570
  private providerHoldsCredential;
726
571
  }
727
572
  //#endregion
728
- //#region src/host/dsh-0.2-bridge.d.ts
573
+ //#region src/host/bridge/kernel-services.d.ts
729
574
  /**
730
575
  * Prompt content a 0.2.x Host accepts — mirrors its `PromptContentPart` union.
731
576
  *
@@ -1206,6 +1051,8 @@ interface Dsh02HostServices {
1206
1051
  prepend?: boolean;
1207
1052
  }): () => void;
1208
1053
  }
1054
+ //#endregion
1055
+ //#region src/host/bridge/attachments.d.ts
1209
1056
  /**
1210
1057
  * 需要交接时,把每个 `image` 部件换成**两行文本**;否则原样返回。
1211
1058
  *
@@ -2286,6 +2133,11 @@ interface MobileGatewayOptions {
2286
2133
  heartbeatIntervalMs?: number;
2287
2134
  /** 单连接发送队列深度,默认 {@link DEFAULT_MOBILE_QUEUE_LIMIT}。 */
2288
2135
  queueLimit?: number;
2136
+ /**
2137
+ * 真实 `ws` 单帧上限(server.ts 同源下发,默认 1 MiB)。
2138
+ * 语音的 audioLimit 按它算,保证应用层限额与底层 close(1009) 一致(issue #10)。
2139
+ */
2140
+ maxPayloadBytes?: number;
2289
2141
  }
2290
2142
  declare class MobileGatewayWsHandler {
2291
2143
  private readonly database;
@@ -2333,10 +2185,12 @@ declare class MobileGatewayWsHandler {
2333
2185
  private readonly heartbeatIntervalMs;
2334
2186
  /** 单连接发送队列深度(溢出时先丢可重建帧)。 */
2335
2187
  private readonly queueLimit;
2336
- /** 心跳定时器(首个连接接入时惰性启动,{@link stop} 时清掉)。 */
2337
- private heartbeatTimer;
2188
+ /** 真实 `ws` 单帧上限(server.ts 同源下发);语音 audioLimit 按它算(issue #10)。 */
2189
+ private readonly maxPayloadBytes;
2338
2190
  constructor(database: WorkspaceDatabase, chat: DshChatAdapter, auth: AuthService, events: WorkspaceEventBus, capabilitiesFlags: CapabilityFlags, options?: MobileGatewayOptions);
2339
2191
  private readonly eventBridge;
2192
+ /** 连接生命周期策略(上限 + 心跳),见 ws-connection-policy.ts。 */
2193
+ private readonly connectionPolicy;
2340
2194
  /**
2341
2195
  * 判定这次 upgrade 是否属于 /ws/mobile。server.ts 负责实际的 WebSocket 升级,
2342
2196
  * 升级完成后再把 WebSocket 交给 onConnection。
@@ -2376,16 +2230,6 @@ declare class MobileGatewayWsHandler {
2376
2230
  private handlePing;
2377
2231
  private handleSubscribe;
2378
2232
  private handleUnsubscribe;
2379
- /**
2380
- * 处理 `message` 帧:若未带 sessionId,先在用户首个授权根目录下建一个空会话;
2381
- * 再把内容送进 chat.prompt。响应 `sent`,客户端后续通过 WS 的 `event` 帧
2382
- * (或 REST /api/v1/events)拿回流式增量。
2383
- */
2384
- private handleMessageFrame;
2385
- private handleWorkspaces;
2386
- private handleSessions;
2387
- private handleHistory;
2388
- private handleAttachment;
2389
2233
  /**
2390
2234
  * 向一条连接发一条帧(带背压队列)。
2391
2235
  *
@@ -2428,161 +2272,21 @@ declare class MobileGatewayWsHandler {
2428
2272
  private closeWithCode;
2429
2273
  /** 关闭所有连接(server.stop 时调用)。 */
2430
2274
  stop(timeoutMs?: number): Promise<void>;
2431
- /**
2432
- * 心跳循环:每 `heartbeatIntervalMs` ping 一次,**连续错过两个周期**才 terminate。
2433
- *
2434
- * 为什么必须有这一步:`/ws/mobile` 是手机上的长连接,切网、熄屏、NAT 表项过期都会留下
2435
- * **半开连接** —— 内核这边没有任何事件,TCP 要等自己超时才报错(默认以分钟计),期间
2436
- * 这条连接一直占着 `sockets` 里的额度、每次广播还白查一遍库。API 文档承诺「连续错过
2437
- * 2 个心跳周期 terminate」,这里就是把它兑现。
2438
- *
2439
- * 两个刻意的取舍:
2440
- * - `terminate()` 而不是 `close()`:对端已经不在,优雅挥手没人收,只会多等一个超时;
2441
- * `close`/`error` 里注册的 `teardown` 会照常摘表、摘事件桥、中止语音与文件传输;
2442
- * - 存活标记用底层 `pong` 控制帧:客户端库自动回,不需要业务代码配合(业务层的
2443
- * `ping` 帧是另一回事,用于测 RTT)。
2444
- */
2445
- private startHeartbeat;
2446
- private clearHeartbeat;
2447
2275
  /**
2448
2276
  * 连接数上限(供 server.ts 在 **Upgrade 之前**判定是否回 429)。
2449
2277
  *
2450
- * 为什么要在 upgrade 前判:升级之后就只能发 WS 关闭码,`Retry-After` 根本无法表达,
2451
- * 客户端也拿不到「稍后再试」这个语义(只会看到一次莫名其妙的断连)。
2278
+ * 上限与心跳的**实现**在 `ws-connection-policy.ts`;这里保留两个访问器,server.ts 的调用面
2279
+ * 不变 —— 它只认 `MobileGatewayWsHandler`,不该为了读一个数字去依赖策略类。
2452
2280
  */
2453
2281
  connectionLimit(): number;
2454
2282
  /** 当前连接数(供 metrics / 观测)。 */
2455
2283
  connectionCount(): number;
2456
2284
  }
2457
2285
  //#endregion
2458
- //#region src/host/host-service-probe.d.ts
2459
- interface ServiceProbe {
2460
- name: string;
2461
- /** 服务是否可解析。`false` = cordis 认为没有插件提供它。 */
2462
- available: boolean;
2463
- /** 解析失败时的错误信息(成功时为 undefined)。 */
2464
- error?: string;
2465
- /** 解析到的值是否像一个可用的服务对象(非 null/非 undefined)。 */
2466
- resolvable: boolean;
2467
- }
2468
- interface HostServiceReport {
2469
- /** 探针执行时的内核运行时版本,供比对「哪个版本注入面是这样」。 */
2470
- pluginVersion: string;
2471
- injected: ServiceProbe[];
2472
- observed: ServiceProbe[];
2473
- /** 注入面摘要:注入了几个、缺几个。缺的那几个就是「功能可能静默不工作」的清单。 */
2474
- summary: {
2475
- injectedTotal: number;
2476
- injectedAvailable: number;
2477
- injectedMissing: readonly string[];
2478
- observedAvailable: readonly string[];
2479
- };
2480
- }
2481
- //#endregion
2482
- //#region src/host/job-adapter.d.ts
2483
- /**
2484
- * 后台任务(background jobs)。
2485
- *
2486
- * ## 为什么需要它
2487
- *
2488
- * 远程场景下有个真焦虑:用户在手机上让 agent 跑个长任务(bash 构建、批量处理),
2489
- * 然后切到别的会话或者放下手机 —— **此时没有任何地方能看到「它还在跑吗」,
2490
- * 更没有地方能「让它停下」**。任务跑飞了只能干等,或者回到电脑前。
2491
- *
2492
- * 而内核一直有这套能力:`dsh-api-job-controller` 在 web profile 里挂着,
2493
- * 提供 roster 流(`list`)、单任务输出观察流(`follow`)与人类取消(`kill`)。
2494
- * 官方 WebUI 有 Jobs 面板(`client-ui-jobs` + `IconJobs`),本桥此前 26 个能力位里
2495
- * **一个 job 相关的都没有**。
2496
- *
2497
- * ## 为什么 REST 只读第一帧,不做长连接
2498
- *
2499
- * 内核的 `list` 是 `AsyncIterable<JobListFrame>`,但内核注释写得很清楚:
2500
- * 「Whole-set replacement, so a reconnect's first frame is already the truth」——
2501
- * **首帧就是完整真相**。所以:
2502
- *
2503
- * - REST `GET /jobs` 读第一帧就够,返回完整清单;
2504
- * - 不把 HTTP 变成长连接:手机端长连接耗电、断线重连复杂、而且清单本来就该「拉一次就是真的」;
2505
- * - 实时推送留给后续(若要,走 WebSocket 事件帧,与 chat.* 同一条通道)。
2506
- *
2507
- * ## 为什么端点挂在会话维度下
2508
- *
2509
- * 内核的 `list(request)` 与 `kill(request)` **都要 `sessionId`** —— 返回的是
2510
- * 「该会话自己的任务 + 所有无主任务」,owner fence 是唯一的访问规则。
2511
- * 所以本模块的每个方法都要调用方给出 `sessionId`,**不能替客户端猜** ——
2512
- * 猜错会让一个会话看到另一个会话的任务。`kill` 同理:请求里的 session 就是
2513
- * 那次围栏读写的调用者。
2514
- *
2515
- * 所以 REST 也必须是会话维度的:`/chat/sessions/{sessionId}/jobs`。
2516
- * 做成顶层 `/jobs` 就得让客户端额外猜一个 session,那是把内核的围栏语义
2517
- * 搬到桥外面,早晚出「一个会话看到另一个会话的任务」这种事。
2518
- *
2519
- * 可见性由内核裁决,本模块不二次判断:内核抛 `job/not-found` 就转成 404。
2520
- */
2521
- /** 一个后台任务的对外视图(内核 `JobView` 的结构投影)。 */
2522
- interface JobView {
2523
- id: string;
2524
- kind: string;
2525
- label: string;
2526
- /** 属主会话;无主任务没有这个字段(任何调用方都可见)。 */
2527
- owner?: string;
2528
- status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed';
2529
- /** 生产者的实时进度行(`3/10`、当前阶段);终态时被清空。 */
2530
- progress?: string;
2531
- /** 终态原因(`exit code: 3`);记录过的 kill 原因会并进来。 */
2532
- detail?: string;
2533
- /** 注册时刻(epoch ms)。 */
2534
- startedAt: number;
2535
- /** 落定时刻(epoch ms);存活中无此字段。 */
2536
- finishedAt?: number;
2537
- }
2538
- /** `GET /jobs` 的响应。 */
2539
- interface JobListResult {
2540
- jobs: JobView[];
2541
- }
2542
- /** `POST /jobs/{jobId}/kill` 的响应。 */
2543
- interface JobKillResult {
2544
- jobId: string;
2545
- /** `requested` = 已受理;`already-finished` = 已是终态,无需再杀。 */
2546
- outcome: 'requested' | 'already-finished';
2547
- }
2548
- declare class JobAdapter {
2549
- private readonly host;
2550
- constructor(host: {
2551
- jobController: JobControllerLike;
2552
- });
2553
- /**
2554
- * 列出某个会话可见的后台任务。
2555
- *
2556
- * 读流的**第一帧**(内核保证它是全量 truth)。若流首帧就不是 `rows`
2557
- * (内核升级改了帧型),明确报错而不是返回空清单 ——
2558
- * 空清单会被客户端理解成「没有任务」,那是**假的**。
2559
- */
2560
- list(sessionId: string, signal: AbortSignal): Promise<JobListResult>;
2561
- /**
2562
- * 读一个任务已保留的输出(从指定字节偏移继续)。
2563
- *
2564
- * 内核的 `follow` 是流:`opened` 锚点 → 若干 `output` 批次 → 终态 `status`。
2565
- * 这里**只读到第一个终态帧或流结束为止**,把批次摊平成一个文本块 ——
2566
- * 手机端要看的是「这任务打印了什么」,不是逐帧的字节偏移记账。
2567
- *
2568
- * 但 `next` 偏移会一并返回:客户端想增量续读时,拿它当 `from` 再调一次即可,
2569
- * 不必从头拉。`lossy` 也透出去,让客户端知道「有字节被留存策略吃掉了」,
2570
- * 而不是把一段对不上的输出当成完整的。
2571
- */
2572
- follow(sessionId: string, jobId: string, from: number | undefined, signal: AbortSignal): Promise<{
2573
- jobId: string;
2574
- text: string;
2575
- next: number;
2576
- lossy: boolean;
2577
- status?: JobView['status'];
2578
- }>;
2579
- /**
2580
- * 终止一个后台任务。
2581
- *
2582
- * 内核的 `kill` 是**同步**的,且只有 `requested` / `already-finished` 两种返回;
2583
- * 「这个任务不存在或该会话看不到它」走 `job/not-found` 远程错误,在这里转成 404。
2584
- */
2585
- kill(sessionId: string, jobId: string): JobKillResult;
2286
+ //#region src/host/bff-rest.d.ts
2287
+ /** 宿主 `ctx.schedule` 服务在本层只需要 `create`(远程面没有新建,见 `bff-schedule.ts` 文件头)。 */
2288
+ interface ScheduleCreatePort {
2289
+ create: (sessionId: string, request: Record<string, unknown>) => Promise<unknown>;
2586
2290
  }
2587
2291
  //#endregion
2588
2292
  //#region src/host/cloudflared.d.ts
@@ -2818,10 +2522,134 @@ declare class FileService {
2818
2522
  private notify;
2819
2523
  }
2820
2524
  //#endregion
2821
- //#region src/host/bff-rest.d.ts
2822
- /** 宿主 `ctx.schedule` 服务在本层只需要 `create`(远程面没有新建,见 `bff-schedule.ts` 文件头)。 */
2823
- interface ScheduleCreatePort {
2824
- create: (sessionId: string, request: Record<string, unknown>) => Promise<unknown>;
2525
+ //#region src/host/host-service-probe.d.ts
2526
+ interface ServiceProbe {
2527
+ name: string;
2528
+ /** 服务是否可解析。`false` = cordis 认为没有插件提供它。 */
2529
+ available: boolean;
2530
+ /** 解析失败时的错误信息(成功时为 undefined)。 */
2531
+ error?: string;
2532
+ /** 解析到的值是否像一个可用的服务对象(非 null/非 undefined)。 */
2533
+ resolvable: boolean;
2534
+ }
2535
+ interface HostServiceReport {
2536
+ /** 探针执行时的内核运行时版本,供比对「哪个版本注入面是这样」。 */
2537
+ pluginVersion: string;
2538
+ injected: ServiceProbe[];
2539
+ observed: ServiceProbe[];
2540
+ /** 注入面摘要:注入了几个、缺几个。缺的那几个就是「功能可能静默不工作」的清单。 */
2541
+ summary: {
2542
+ injectedTotal: number;
2543
+ injectedAvailable: number;
2544
+ injectedMissing: readonly string[];
2545
+ observedAvailable: readonly string[];
2546
+ };
2547
+ }
2548
+ //#endregion
2549
+ //#region src/host/job-adapter.d.ts
2550
+ /**
2551
+ * 后台任务(background jobs)。
2552
+ *
2553
+ * ## 为什么需要它
2554
+ *
2555
+ * 远程场景下有个真焦虑:用户在手机上让 agent 跑个长任务(bash 构建、批量处理),
2556
+ * 然后切到别的会话或者放下手机 —— **此时没有任何地方能看到「它还在跑吗」,
2557
+ * 更没有地方能「让它停下」**。任务跑飞了只能干等,或者回到电脑前。
2558
+ *
2559
+ * 而内核一直有这套能力:`dsh-api-job-controller` 在 web profile 里挂着,
2560
+ * 提供 roster 流(`list`)、单任务输出观察流(`follow`)与人类取消(`kill`)。
2561
+ * 官方 WebUI 有 Jobs 面板(`client-ui-jobs` + `IconJobs`),本桥此前 26 个能力位里
2562
+ * **一个 job 相关的都没有**。
2563
+ *
2564
+ * ## 为什么 REST 只读第一帧,不做长连接
2565
+ *
2566
+ * 内核的 `list` 是 `AsyncIterable<JobListFrame>`,但内核注释写得很清楚:
2567
+ * 「Whole-set replacement, so a reconnect's first frame is already the truth」——
2568
+ * **首帧就是完整真相**。所以:
2569
+ *
2570
+ * - REST `GET /jobs` 读第一帧就够,返回完整清单;
2571
+ * - 不把 HTTP 变成长连接:手机端长连接耗电、断线重连复杂、而且清单本来就该「拉一次就是真的」;
2572
+ * - 实时推送留给后续(若要,走 WebSocket 事件帧,与 chat.* 同一条通道)。
2573
+ *
2574
+ * ## 为什么端点挂在会话维度下
2575
+ *
2576
+ * 内核的 `list(request)` 与 `kill(request)` **都要 `sessionId`** —— 返回的是
2577
+ * 「该会话自己的任务 + 所有无主任务」,owner fence 是唯一的访问规则。
2578
+ * 所以本模块的每个方法都要调用方给出 `sessionId`,**不能替客户端猜** ——
2579
+ * 猜错会让一个会话看到另一个会话的任务。`kill` 同理:请求里的 session 就是
2580
+ * 那次围栏读写的调用者。
2581
+ *
2582
+ * 所以 REST 也必须是会话维度的:`/chat/sessions/{sessionId}/jobs`。
2583
+ * 做成顶层 `/jobs` 就得让客户端额外猜一个 session,那是把内核的围栏语义
2584
+ * 搬到桥外面,早晚出「一个会话看到另一个会话的任务」这种事。
2585
+ *
2586
+ * 可见性由内核裁决,本模块不二次判断:内核抛 `job/not-found` 就转成 404。
2587
+ */
2588
+ /** 一个后台任务的对外视图(内核 `JobView` 的结构投影)。 */
2589
+ interface JobView {
2590
+ id: string;
2591
+ kind: string;
2592
+ label: string;
2593
+ /** 属主会话;无主任务没有这个字段(任何调用方都可见)。 */
2594
+ owner?: string;
2595
+ status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed';
2596
+ /** 生产者的实时进度行(`3/10`、当前阶段);终态时被清空。 */
2597
+ progress?: string;
2598
+ /** 终态原因(`exit code: 3`);记录过的 kill 原因会并进来。 */
2599
+ detail?: string;
2600
+ /** 注册时刻(epoch ms)。 */
2601
+ startedAt: number;
2602
+ /** 落定时刻(epoch ms);存活中无此字段。 */
2603
+ finishedAt?: number;
2604
+ }
2605
+ /** `GET /jobs` 的响应。 */
2606
+ interface JobListResult {
2607
+ jobs: JobView[];
2608
+ }
2609
+ /** `POST /jobs/{jobId}/kill` 的响应。 */
2610
+ interface JobKillResult {
2611
+ jobId: string;
2612
+ /** `requested` = 已受理;`already-finished` = 已是终态,无需再杀。 */
2613
+ outcome: 'requested' | 'already-finished';
2614
+ }
2615
+ declare class JobAdapter {
2616
+ private readonly host;
2617
+ constructor(host: {
2618
+ jobController: JobControllerLike;
2619
+ });
2620
+ /**
2621
+ * 列出某个会话可见的后台任务。
2622
+ *
2623
+ * 读流的**第一帧**(内核保证它是全量 truth)。若流首帧就不是 `rows`
2624
+ * (内核升级改了帧型),明确报错而不是返回空清单 ——
2625
+ * 空清单会被客户端理解成「没有任务」,那是**假的**。
2626
+ */
2627
+ list(sessionId: string, signal: AbortSignal): Promise<JobListResult>;
2628
+ /**
2629
+ * 读一个任务已保留的输出(从指定字节偏移继续)。
2630
+ *
2631
+ * 内核的 `follow` 是流:`opened` 锚点 → 若干 `output` 批次 → 终态 `status`。
2632
+ * 这里**只读到第一个终态帧或流结束为止**,把批次摊平成一个文本块 ——
2633
+ * 手机端要看的是「这任务打印了什么」,不是逐帧的字节偏移记账。
2634
+ *
2635
+ * 但 `next` 偏移会一并返回:客户端想增量续读时,拿它当 `from` 再调一次即可,
2636
+ * 不必从头拉。`lossy` 也透出去,让客户端知道「有字节被留存策略吃掉了」,
2637
+ * 而不是把一段对不上的输出当成完整的。
2638
+ */
2639
+ follow(sessionId: string, jobId: string, from: number | undefined, signal: AbortSignal): Promise<{
2640
+ jobId: string;
2641
+ text: string;
2642
+ next: number;
2643
+ lossy: boolean;
2644
+ status?: JobView['status'];
2645
+ }>;
2646
+ /**
2647
+ * 终止一个后台任务。
2648
+ *
2649
+ * 内核的 `kill` 是**同步**的,且只有 `requested` / `already-finished` 两种返回;
2650
+ * 「这个任务不存在或该会话看不到它」走 `job/not-found` 远程错误,在这里转成 404。
2651
+ */
2652
+ kill(sessionId: string, jobId: string): JobKillResult;
2825
2653
  }
2826
2654
  //#endregion
2827
2655
  //#region src/host/router.d.ts
@@ -2915,75 +2743,11 @@ declare class ApiRouter {
2915
2743
  handle(req: IncomingMessage, res: ServerResponse, stripPrefix?: string): Promise<void>;
2916
2744
  private dispatch;
2917
2745
  private dispatchManagement;
2918
- /**
2919
- * 打开隧道并(可选)顺带产出配置文本 —— HTTP `POST /manage/cloudflare/create` 与
2920
- * IM `/tunnel` 命令**共用**这一条实现。
2921
- *
2922
- * 抽出来的原因:两条入口的前半段完全一致(确保 cloudflared → startQuick → 等随机域名 →
2923
- * 建配置文本),差别只在「谁在调用」——HTTP 侧要先把请求体读干净(流会随时间关闭),
2924
- * 命令侧没有请求体。复制一份会让「一键出令牌」在两条路径上慢慢漂移。
2925
- *
2926
- * @param createBody - 已解析的请求体(HTTP 侧)或 `{ configText: true }`(命令侧)。
2927
- * @param requestId - 审计与配对记录共用的请求 ID。
2928
- * @param actorLabel - 审计里的操作者标签:`loopback-admin`(本机管理面)或 `im-command`。
2929
- * @returns 合并了「等到地址」的 state,以及(wantConfig 且拿到地址时)配置文本。
2930
- */
2931
- private createTunnel;
2932
- /**
2933
- * IM `/tunnel` 命令的宿主侧入口:开隧道并返回一行可粘贴的配置文本。
2934
- *
2935
- * 与 HTTP 路径的两点差别:
2936
- *
2937
- * 1. **隧道已在运行时直接复用当前地址**,不重复 spawn(第二次 `startQuick` 会撞
2938
- * `TUNNEL_ALREADY_RUNNING`,而用户在 IM 里连发两条是很常见的)。
2939
- * 2. 配置文本的 endpoint 只用隧道地址,设备名缺省由 `buildConfigText` 决定 —— 命令
2940
- * 不接受参数,所以没有 `endpointLabel` / `rootIds` 的透传面。
2941
- *
2942
- * @throws 当 12 秒内没拿到公网地址,或 cloudflared 下载失败时。
2943
- */
2944
2746
  openTunnelForCommand(): Promise<{
2945
2747
  url: string;
2946
2748
  text: string;
2947
2749
  deviceName: string;
2948
2750
  }>;
2949
- /**
2950
- * 隧道状态视图。
2951
- *
2952
- * **这里不做脱敏,因为用不上**:判据 1 已经把 Cloudflare 边缘头(`Cf-Connecting-IP` /
2953
- * `Cf-Ray` / …)算作「前面有代理」的证据,于是任何真实隧道来源的请求在
2954
- * `requireAdmin` 就被 403 挡住了,根本到不了这个方法。能走到这里的只有真·本机请求
2955
- * (判据 1/2/3 全过),所以 `url` 可以照常给它 —— 那是桌面端「用隧道地址生成配置文本」
2956
- * 的依据。
2957
- *
2958
- * 为什么仍然保留这个方法而不是直接 `publicState`:脱敏的位置要留在一个**语义明确**的
2959
- * 出口上。将来若有人为了「让远程客户端也能读到隧道是否开着」而放宽判据 1,
2960
- * 必须在这里重新定义脱敏,而不是顺手把 `url` 一起放出去。
2961
- * 评估结论见报告:本轮选择「远程一律 403」而不是「远程读得到但 `url` 为 null」,
2962
- * 因为后者需要为状态端点在判据 1 上再开一个口子,而那正是这次修复要消灭的东西。
2963
- */
2964
- /**
2965
- * 生成一行可粘贴的 `DSH1:` 配置文本(POST /manage/config/text 与「一键开隧道并出令牌」
2966
- * 的 create 端点共用,避免两处各写一套配对/兑换/编码逻辑而漂移)。
2967
- *
2968
- * 入参字段与 /manage/config/text 的窄格式一致:
2969
- * - endpoints:显式给出则不再做本机网卡探测(隧道场景必须只塞隧道地址,不能混入 LAN);
2970
- * - rootIds / scopes / deviceName / displayName / port:全部可选,缺省沿用原端点口径;
2971
- * - scopes 缺省是**五个非破坏性默认权限**(不含 files.delete / settings.write),
2972
- * 调用方若显式传 DEVICE_SCOPES 会放大手机权限,create 一键路径不传。
2973
- *
2974
- * 刻意不返回 pairing.expiresAt:那是配对码的十分钟有效期,设备令牌本身不过期。
2975
- */
2976
- private buildConfigText;
2977
- private tunnelStateFor;
2978
- private dispatchEntries;
2979
- private dispatchContent;
2980
- private requireRoot;
2981
- private authorizedRoots;
2982
- /** 兜底交接目录:没配就用状态目录下的 `image-handoff`(与 `index.ts` 的默认一致)。 */
2983
- private imageFallbackDir;
2984
- private audit;
2985
- private readIdempotent;
2986
- private saveIdempotent;
2987
2751
  }
2988
2752
  //#endregion
2989
2753
  //#region src/host/server.d.ts
@@ -3235,6 +2999,15 @@ interface Config {
3235
2999
  port?: number;
3236
3000
  remoteHost?: string;
3237
3001
  maxUploadBytes?: number;
3002
+ /**
3003
+ * WS 单帧最大字节数(默认 1 MiB,超限由 `ws` 直接 `close(1009)`)。
3004
+ *
3005
+ * 同时约束 `/api/v1/events` 与 `/ws/mobile` 两条 upgrade(同一 `WebSocketServer`)。
3006
+ * 调大它手机端才能发大图原图、语音 base64 帧才不受 ~785 KB 的隐性卡口 ——
3007
+ * dsh-companion#12 的 P2:客户端已按 700 KiB 压缩兜底,这里给想发更大原图的
3008
+ * 部署开口子。默认值刻意不变,现有部署行为零变化。
3009
+ */
3010
+ maxPayloadBytes?: number;
3238
3011
  /**
3239
3012
  * 额外允许手机端写入的设置命名空间(默认空)。
3240
3013
  *