dsh-remote-bridge 2.5.0 → 2.5.2

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
  *
@@ -173,6 +244,34 @@ declare class WorkspaceDatabase {
173
244
  */
174
245
  markRevokedForTest(id: string): void;
175
246
  purgeRevokedDevices(): number;
247
+ /**
248
+ * 删掉早于 `cutoffMs` 的幂等回放记录,返回删除行数。
249
+ *
250
+ * 由 `retention.ts`(perf-reader)按调度调用,本方法只做 SQL —— 不判断策略、
251
+ * 不写日志,所以它可以被单独测试。
252
+ *
253
+ * 为什么用 `created_at` 而不是加一列 `expires_at`:24 小时的过期**完全**由
254
+ * `created_at` 推导得出,加列会让每次插入多写一个字段,还多一次 migration 风险。
255
+ */
256
+ pruneIdempotency(cutoffMs: number): number;
257
+ /**
258
+ * 清理审计:先按时间删,再兜「总数超过 `keepMax` 就删最旧的」。
259
+ *
260
+ * ## 为什么是两段而不是一段
261
+ *
262
+ * 只按时间删不够:桥可以被一个「短时间高频写入」的场景灌满审计表
263
+ * (例如每秒几十条 `chat.delta` 对应的写操作),时间窗内的量仍然能压垮库。
264
+ * 只按数量删也不够:用户可能希望「一年前的都清掉」,而不在乎条数。
265
+ *
266
+ * ## `keepMax` 是**下限保护**
267
+ *
268
+ * 即使 `cutoffMs` 设成了未来(把全部行都判成过期),也**至少**保留最近
269
+ * `keepMax` 条 —— 一句写错的调用不该把审计清空。审计被清空本身就等于
270
+ * 「抹掉证据」,这个方向必须由这一层挡住。
271
+ *
272
+ * 返回删除的行数(两段之和)。
273
+ */
274
+ pruneAudit(cutoffMs: number, keepMax: number): number;
176
275
  insertTrash(record: TrashRecord): void;
177
276
  updateTrashStatus(id: string, status: string): void;
178
277
  getTrash(id: string): TrashRecord | undefined;
@@ -399,7 +498,33 @@ declare class DshSettingsAdapter {
399
498
  api?: string;
400
499
  apiKey?: string;
401
500
  }): Promise<ProviderModelView[]>;
501
+ /**
502
+ * 取一个 provider 的上下文(目录条目 + 设置命名空间 + 当前 profile)。
503
+ *
504
+ * ## 为什么加一层「单请求内复用」
505
+ *
506
+ * 修复前 `updateProvider()` 会**连续**调它两次(一次在开头做校验、一次在
507
+ * `createProvider`/`removeProvider` 里),而每次 `providerContext` 都要打
508
+ * **两次**内核 RPC(`llm.providers` + `settings.describe`)。于是一次 PATCH
509
+ * 实际可能产生 4~5 次内核往返 —— 而它们问的是**同一份数据**。
510
+ *
511
+ * 这里用一个极短的 TTL(默认 1 秒,覆盖「同一请求内」的连续调用)缓存结果。
512
+ * 为什么不用「请求作用域」的显式传参:那要改每个方法的签名,而调用点散在
513
+ * router 与 adapter 两处 —— 收益远不抵这次改动。1 秒 TTL 的副作用是:
514
+ * 同一秒内两次改同一个 provider 时,第二次读到的是第一次之前的快照 ——
515
+ * 而 `settings.mutate` 有 `expectedRevision` 做真正的并发控制,所以不会写坏。
516
+ */
517
+ private readonly providerContextCache;
402
518
  private providerContext;
519
+ /**
520
+ * 写操作之后**必须**让缓存失效。
521
+ *
522
+ * 为什么不能只靠 TTL:`updateProvider` 改完 baseURL 之后,同一个请求尾部的
523
+ * `listProviders()` 要给出新值。靠 TTL 等 1 秒等于把一个「刚刚改成功」的
524
+ * 界面回滚成旧值 —— 那会被用户当成「改了没生效」。
525
+ */
526
+ private invalidateProviderContext;
527
+ private loadProviderContext;
403
528
  }
404
529
  //#endregion
405
530
  //#region src/host/dsh-0.2-bridge.d.ts
@@ -1422,6 +1547,31 @@ declare class DshChatAdapter {
1422
1547
  private readonly api;
1423
1548
  private readonly database;
1424
1549
  private readonly commandServices?;
1550
+ /**
1551
+ * 会话 → 可见性快照的短 TTL 缓存。
1552
+ *
1553
+ * ## 为什么需要(这是本文件最热的一处浪费)
1554
+ *
1555
+ * `requireSession()` 会在**几乎每个** API 调用里跑一遍 `listSessions()`,
1556
+ * 而 `listSessions` 一次要打两次内核 RPC(`sessions.list` + `workspace.list`)
1557
+ * 加一次 `database.listRoots()`。也就是说 history / commands / approvals /
1558
+ * models / questions / prompt 每条请求都白花两次内核往返 —— 只为回答
1559
+ * 「这个 session 能不能被这台设备看见」。
1560
+ *
1561
+ * ## 为什么 TTL 是 2 秒(短到近乎无感,长到足以省掉重复)
1562
+ *
1563
+ * 客户端在打开一个会话时会**连续**打四五个接口(历史、审批、提问、命令、模型),
1564
+ * 2 秒足以把这一串并成一两次真实查询。再长就会让「刚建的会话看不到」
1565
+ * 变成一个可感受到的延迟 —— 而那正是我们要避免的另一类 bug。
1566
+ *
1567
+ * ## 缓存的是什么(关键:缓存的是**授权结论**,不是会话内容)
1568
+ *
1569
+ * 只缓存「这个 sessionId 对当前设备可见吗」这一个布尔结论,且按 `principal.id`
1570
+ * 分桶。**不缓存会话正文**:正文会被 `rename`/`archive`/新消息改掉,
1571
+ * 缓存它等于制造陈旧数据;而可见性只随「根授权变没变」与「会话删没删」变,
1572
+ * 两者都是低频且由用户操作驱动的。
1573
+ */
1574
+ private readonly sessionVisibility;
1425
1575
  private readonly sessionRootIds;
1426
1576
  private readonly sessionRootLookups;
1427
1577
  private readonly pendingApprovals;
@@ -1603,6 +1753,15 @@ declare class DshChatAdapter {
1603
1753
  */
1604
1754
  private commandAgent;
1605
1755
  private requireSession;
1756
+ /**
1757
+ * 记一条可见性结论,并顺手把过期条目清掉。
1758
+ *
1759
+ * 为什么在这里做清扫而不是定时器:缓存条目数与「设备数 × 会话数」同阶,
1760
+ * 对单机部署来说是千级 —— 每次写入顺手扫一遍完全够,而且**没有定时器泄漏**。
1761
+ * (本仓的纪律:服务端不引入 `setInterval`,见 `server.ts` 的心跳是唯一的例外,
1762
+ * 且它 `unref` 过。)
1763
+ */
1764
+ private rememberVisibility;
1606
1765
  private requireWorkspace;
1607
1766
  private visibleWorkspaces;
1608
1767
  private requireAuthorizedRoot;
@@ -1621,16 +1780,65 @@ declare class FixedWindowLimiter {
1621
1780
  }
1622
1781
  //#endregion
1623
1782
  //#region src/host/auth.d.ts
1783
+ interface AuthServiceOptions {
1784
+ /**
1785
+ * 隧道运行状态探针(`CloudflaredTunnelManager.tunnelActive`)。
1786
+ *
1787
+ * 用函数而不是快照:隧道随时启停,鉴权发生在请求那一刻,读的必须是当下状态。
1788
+ * 不注入(测试、未接隧道的构建)即为「没有隧道」,判据 0 恒不触发。
1789
+ */
1790
+ tunnelActive?: () => boolean;
1791
+ }
1624
1792
  declare class AuthService {
1625
1793
  private readonly database;
1626
- private readonly authFailures;
1627
- readonly pairingAttempts: FixedWindowLimiter;
1628
- readonly requests: FixedWindowLimiter;
1629
- constructor(database: WorkspaceDatabase);
1794
+ private readonly options;
1795
+ /**
1796
+ * 两个「按对端分类」分开计的桶(键 = `<class>:<remoteAddress>`)。
1797
+ *
1798
+ * 阈值刻意**两边一致**:分桶的目的是「互不连坐」,不是「给本机开后门」。
1799
+ * 如果本机侧更宽,那它就成了一条「怎么把自己伪装成本机」的动机 —— 而伪装是免费的
1800
+ * (不发头即可),于是更宽的那一侧等于被取消。
1801
+ * 阈值相等时,攻击者伪装成 local 只会消耗 local 配额,**没有额外收益**。
1802
+ *
1803
+ * `requests`:120/60s。`authFailures`:20/5min。
1804
+ */
1805
+ private readonly authFailuresByPeer;
1806
+ /** 配对兑换:本机与隧道分开。隧道侧公网暴力尝试不该锁死本机的配对。 */
1807
+ readonly pairingAttemptsByPeer: {
1808
+ local: FixedWindowLimiter;
1809
+ tunnelled: FixedWindowLimiter;
1810
+ };
1811
+ readonly requestsByPeer: {
1812
+ local: FixedWindowLimiter;
1813
+ tunnelled: FixedWindowLimiter;
1814
+ };
1815
+ constructor(database: WorkspaceDatabase, options?: AuthServiceOptions);
1816
+ /** 这套服务当前的对端分类(`tunnelActive` 走 fail-closed 探针)。 */
1817
+ private peerOf;
1818
+ /** 按对端取请求配额桶。 */
1819
+ requestLimiter(req: IncomingMessage): FixedWindowLimiter;
1820
+ /** 按对端取认证失败桶。 */
1821
+ failureLimiter(req: IncomingMessage): FixedWindowLimiter;
1822
+ /** 按对端取配对尝试桶(`router.ts` 的 exchange 端点用)。 */
1823
+ pairingLimiter(req: IncomingMessage): FixedWindowLimiter;
1824
+ /**
1825
+ * 隧道是否在跑。探针抛错时**按「在跑」处理**(fail closed):探针读的是我们自己
1826
+ * 对象上的 getter,它抛错意味着状态不可知;状态不可知时放行管理面正是这次修复要
1827
+ * 消灭的那类决定。
1828
+ */
1829
+ private tunnelRunning;
1630
1830
  principal(req: IncomingMessage, options?: {
1631
1831
  allowAdmin?: boolean;
1632
1832
  }): Principal;
1633
- requireAdmin(req: IncomingMessage): Principal;
1833
+ /**
1834
+ * `/manage/**` 的守门人。
1835
+ *
1836
+ * @param options.allowTunnelMode - 只有「隧道状态下读/停隧道」这几个端点会传 true。
1837
+ * 见 `adminRefusal` 的同名说明与 `router.ts` 的 `TUNNEL_MODE_EXEMPT_PATHS`。
1838
+ */
1839
+ requireAdmin(req: IncomingMessage, options?: {
1840
+ allowTunnelMode?: boolean;
1841
+ }): Principal;
1634
1842
  requireScope(principal: Principal, scope: DeviceScope): void;
1635
1843
  requireRoot(principal: Principal, rootId: string): void;
1636
1844
  }
@@ -1825,23 +2033,82 @@ interface TunnelPublicState {
1825
2033
  /** 对外地址,已统一为 `https://` 前缀原文,供配置文本直接使用。 */
1826
2034
  url: string | null;
1827
2035
  running: boolean;
2036
+ /**
2037
+ * 兜底的**英文短消息**(调试/日志用)。
2038
+ *
2039
+ * 2.5.2 起它**已脱敏**:不含完整 URL、不含绝对路径(见 `sanitizeDownloadError`)。
2040
+ * 客户端应优先用 `errorKey` 本地化;这个字段只在没有键可用时兜底。
2041
+ */
1828
2042
  error?: string;
2043
+ /**
2044
+ * 结构化错误键(客户端据此本地化)。渲染优先级 **errorKey > error**。
2045
+ */
2046
+ errorKey?: TunnelErrorKey;
2047
+ /** `errorKey` 的插值参数。 */
2048
+ errorParams?: Record<string, string | number>;
1829
2049
  }
1830
2050
  /** 「创建云隧道」进行中任务的阶段状态,供前端轮询显示进度。`percent=null` 表示该阶段无确定百分比(流动条)。 */
1831
2051
  interface TunnelActivity {
1832
2052
  /** 阶段标识:locating / downloading / unpacking / starting;`idle` 表示当前无进行中任务。 */
1833
2053
  stage: 'idle' | 'locating' | 'downloading' | 'unpacking' | 'starting';
1834
- /** 人类可读的阶段文案(如「已找到本机二进制,直接复用」)。 */
1835
- label: string;
2054
+ /**
2055
+ * 阶段文案的 **i18n 键**(不是给人看的句子)。
2056
+ *
2057
+ * 2.5.2 起取代原来的 `label: string`:服务端不再决定用哪种语言说这句话,
2058
+ * 由客户端按自己的界面语言渲染。`''` 表示 idle(无进行中任务)。
2059
+ *
2060
+ * 键清单是**跨端契约**(见 `TunnelStageKeys`),改名会让 UI 显示原始键名。
2061
+ * `''` = idle(没有进行中的任务,UI 不显示进度条)。
2062
+ */
2063
+ labelKey: TunnelStageKey | '';
2064
+ /** 键里的插值参数(如下载进度的 `{ mb, all }`)。 */
2065
+ labelParams?: Record<string, string | number>;
1836
2066
  /** 0-100 的进度百分比;null = 该阶段无法量化(前端显示流动条)。 */
1837
2067
  percent: number | null;
1838
2068
  }
2069
+ /**
2070
+ * 隧道阶段文案的 i18n 键 —— **跨端契约,不得随意改名**。
2071
+ *
2072
+ * 客户端(`dsh-companion` / 桌面面板)按这套键查词典。改名 = UI 直接显示键名。
2073
+ */
2074
+ type TunnelStageKey =
2075
+ /** 正在定位本机二进制。 */
2076
+ 'tunnelStageLocating' |
2077
+ /** 已找到本机二进制,直接复用(不需要下载)。 */
2078
+ 'tunnelStageBinaryFound' |
2079
+ /** 正在从官方 GitHub 下载。 */
2080
+ 'tunnelStageDownloadGithub' |
2081
+ /** 正在从官方 deb 仓库下载。 */
2082
+ 'tunnelStageDownloadDeb' |
2083
+ /** 下载中,带进度:`labelParams = { mb, all }`。 */
2084
+ 'tunnelStageDownloadProgress' |
2085
+ /** 下载完成但**没有校验和可用**:内容未经验证(H5 的可见化)。 */
2086
+ 'tunnelStageDownloadUnverified' |
2087
+ /** 正在解包 deb 并提取二进制。 */
2088
+ 'tunnelStageUnpackDeb' |
2089
+ /** 正在写入二进制并赋执行权限。 */
2090
+ 'tunnelStageWriteBinary' |
2091
+ /** 正在启动 cloudflared 隧道。 */
2092
+ 'tunnelStageStarting';
2093
+ /**
2094
+ * 隧道错误的结构化键。与 `error`(兜底英文短消息)并存:
2095
+ * 客户端渲染优先级 **errorKey > error**。
2096
+ */
2097
+ type TunnelErrorKey =
2098
+ /** cloudflared 进程退出且没拿到地址:`errorParams = { code, signal }`。 */
2099
+ 'tunnelErrorExited' |
2100
+ /** 下载/解包失败(已脱敏,无参数)。 */
2101
+ 'tunnelErrorDownload' |
2102
+ /** 已经在运行,拒绝重复启动。 */
2103
+ 'tunnelErrorAlreadyRunning';
1839
2104
  /** 一次 cloudflared 子进程生命周期 + 对外状态机。 */
1840
2105
  declare class CloudflaredTunnelManager {
1841
2106
  private process;
1842
2107
  private url;
1843
2108
  private channel;
1844
2109
  private errorText;
2110
+ private errorKey;
2111
+ private errorParams;
1845
2112
  private listeners;
1846
2113
  private activity;
1847
2114
  /** 当前进行中任务的阶段进度(无任务时 stage 为 idle)。 */
@@ -1850,10 +2117,27 @@ declare class CloudflaredTunnelManager {
1850
2117
  reportActivity(a: TunnelActivity): void;
1851
2118
  /** 任务结束(成功或失败)时清空进行中进度。 */
1852
2119
  clearActivity(): void;
2120
+ /**
2121
+ * 是否有隧道在运行 —— 管理面鉴权的**判据 0**。
2122
+ *
2123
+ * 判据取自**进程持有状态**,不是请求头,所以它对「隧道把外部流量转进 127.0.0.1」
2124
+ * 这条绕过路径 100% 可靠:不管转发进来的请求长什么样,只要隧道开着,管理面就关门。
2125
+ * 见 `auth.ts` 的 `adminRefusal` 与 `PITFALLS.md` §隧道让回环判据失效。
2126
+ *
2127
+ * `running` 与 `publicState.running` 是同一个表达式(同一份真相),这里单独暴露是为了
2128
+ * 让鉴权层不必构造一个含 url 的视图对象 —— 少一处把地址带进意外地方的机会。
2129
+ */
2130
+ get tunnelActive(): boolean;
1853
2131
  get publicState(): TunnelPublicState;
1854
2132
  /** 订阅状态变化;返回取消函数。 */
1855
2133
  onStatus(cb: () => void): () => void;
2134
+ /** 取消一个状态订阅(onStatus 返回的函数与 waitForUrl 共用)。 */
2135
+ private offStatus;
1856
2136
  private emit;
2137
+ /**
2138
+ * 记录一次错误。`error` 保留脱敏后的短消息(兜底/日志),
2139
+ * `errorKey` + `errorParams` 供客户端本地化(优先级 errorKey > error)。
2140
+ */
1857
2141
  private setError;
1858
2142
  /**
1859
2143
  * 启动一次 Quick Tunnel。
@@ -1861,6 +2145,18 @@ declare class CloudflaredTunnelManager {
1861
2145
  * @param port bridge 监听端口,cloudflared 把外网请求转发到 localhost:port
1862
2146
  */
1863
2147
  startQuick(exe: string, port: number): Promise<TunnelPublicState>;
2148
+ /**
2149
+ * 等待子进程把随机域名打印到 stdout(spawn 后 `url` 通常还要几秒才有)。
2150
+ *
2151
+ * 存在的理由(2.5.2):`POST /manage/cloudflare/create` 发生在隧道**起来之前**,
2152
+ * 此刻管理面还没被判据 0 关门;一旦在这次请求里等出地址并顺带出令牌配置文本,
2153
+ * 客户端就不必在隧道运行后再打一次 `/manage/config/text`(那时必然 403,
2154
+ * 这正是「一键开隧道+出令牌」失效的根因)。
2155
+ *
2156
+ * 任一条件满足即结算:地址出现 → resolve(url);进程退出/超时 → resolve(null)
2157
+ * (不抛,调用方据 null 决定要不要让客户端走轮询补一步)。调用方传 AbortSignal 可提前走人。
2158
+ */
2159
+ waitForUrl(timeoutMs?: number, signal?: AbortSignal): Promise<string | null>;
1864
2160
  /** 停止当前隧道。 */
1865
2161
  stop(): TunnelPublicState;
1866
2162
  /** 注入已抓到的地址 + 通道(命名隧道用)。 */
@@ -1985,6 +2281,35 @@ declare class ApiRouter {
1985
2281
  handle(req: IncomingMessage, res: ServerResponse, stripPrefix?: string): Promise<void>;
1986
2282
  private dispatch;
1987
2283
  private dispatchManagement;
2284
+ /**
2285
+ * 隧道状态视图。
2286
+ *
2287
+ * **这里不做脱敏,因为用不上**:判据 1 已经把 Cloudflare 边缘头(`Cf-Connecting-IP` /
2288
+ * `Cf-Ray` / …)算作「前面有代理」的证据,于是任何真实隧道来源的请求在
2289
+ * `requireAdmin` 就被 403 挡住了,根本到不了这个方法。能走到这里的只有真·本机请求
2290
+ * (判据 1/2/3 全过),所以 `url` 可以照常给它 —— 那是桌面端「用隧道地址生成配置文本」
2291
+ * 的依据。
2292
+ *
2293
+ * 为什么仍然保留这个方法而不是直接 `publicState`:脱敏的位置要留在一个**语义明确**的
2294
+ * 出口上。将来若有人为了「让远程客户端也能读到隧道是否开着」而放宽判据 1,
2295
+ * 必须在这里重新定义脱敏,而不是顺手把 `url` 一起放出去。
2296
+ * 评估结论见报告:本轮选择「远程一律 403」而不是「远程读得到但 `url` 为 null」,
2297
+ * 因为后者需要为状态端点在判据 1 上再开一个口子,而那正是这次修复要消灭的东西。
2298
+ */
2299
+ /**
2300
+ * 生成一行可粘贴的 `DSH1:` 配置文本(POST /manage/config/text 与「一键开隧道并出令牌」
2301
+ * 的 create 端点共用,避免两处各写一套配对/兑换/编码逻辑而漂移)。
2302
+ *
2303
+ * 入参字段与 /manage/config/text 的窄格式一致:
2304
+ * - endpoints:显式给出则不再做本机网卡探测(隧道场景必须只塞隧道地址,不能混入 LAN);
2305
+ * - rootIds / scopes / deviceName / displayName / port:全部可选,缺省沿用原端点口径;
2306
+ * - scopes 缺省是**五个非破坏性默认权限**(不含 files.delete / settings.write),
2307
+ * 调用方若显式传 DEVICE_SCOPES 会放大手机权限,create 一键路径不传。
2308
+ *
2309
+ * 刻意不返回 pairing.expiresAt:那是配对码的十分钟有效期,设备令牌本身不过期。
2310
+ */
2311
+ private buildConfigText;
2312
+ private tunnelStateFor;
1988
2313
  private dispatchEntries;
1989
2314
  private dispatchContent;
1990
2315
  private requireRoot;
@@ -2005,6 +2330,31 @@ interface RemoteApiServerOptions {
2005
2330
  chat: DshChatAdapter;
2006
2331
  events: WorkspaceEventBus;
2007
2332
  handle: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
2333
+ /**
2334
+ * 同时允许的 WebSocket 连接数上限(默认 [DEFAULT_MAX_CONNECTIONS])。
2335
+ *
2336
+ * 为什么必须有:`sockets` 此前是一个无界 Map —— 一个循环重连的客户端就能堆出任意多
2337
+ * 连接,每条都参与广播、每条都查一次库。上限在**鉴权之后**才判定,所以它不会成为
2338
+ * 未鉴权用户可探测的攻击面。
2339
+ */
2340
+ maxConnections?: number;
2341
+ /** 单帧最大字节数(默认 1 MiB)。超限由 `ws` 直接 `close(1009)`。 */
2342
+ maxPayloadBytes?: number;
2343
+ /** 心跳间隔(默认 30 s)。测试传小值,不必干等半分钟。 */
2344
+ heartbeatIntervalMs?: number;
2345
+ /** 单连接事件队列上限(默认 512)。溢出按「丢可重建帧」策略处理。 */
2346
+ socketQueueLimit?: number;
2347
+ /** 发送缓冲高水位(默认 4 MiB)。连续两次越过就 `close(1013)`。 */
2348
+ highWaterMarkBytes?: number;
2349
+ /**
2350
+ * 运行时能力开关,喂给 `connectionReadyData()`。
2351
+ *
2352
+ * 为什么在构造参数里而不是全局变量:能力位是**这台服务端此刻**能做什么,
2353
+ * 同一进程里可能有多个实例(测试、重绑),用实例参数才不会被互相污染。
2354
+ */
2355
+ capabilities?: {
2356
+ jobs?: boolean;
2357
+ };
2008
2358
  }
2009
2359
  declare class RemoteApiServer implements RemoteControl {
2010
2360
  private readonly options;
@@ -2019,8 +2369,15 @@ declare class RemoteApiServer implements RemoteControl {
2019
2369
  private readonly webSockets;
2020
2370
  private readonly eventsAbort;
2021
2371
  private unsubscribeEvents;
2022
- private broadcastQueue;
2372
+ /** 所有连接的 drain 完成信号;`Promise.all` 它们即可等排空。 */
2373
+ private readonly pendingDrains;
2374
+ private heartbeatTimer;
2375
+ private changeTimer;
2023
2376
  private changing;
2377
+ private readonly maxConnections;
2378
+ private readonly socketQueueLimit;
2379
+ private readonly highWaterMarkBytes;
2380
+ private readonly heartbeatIntervalMs;
2024
2381
  constructor(options: RemoteApiServerOptions);
2025
2382
  start(): Promise<void>;
2026
2383
  stop(): Promise<void>;
@@ -2036,12 +2393,39 @@ declare class RemoteApiServer implements RemoteControl {
2036
2393
  disable(): RemoteOperationView;
2037
2394
  configure(host: string, port: number): RemoteOperationView;
2038
2395
  private scheduleChange;
2396
+ /** 取消还没触发的监听变更。幂等:没定时器就什么都不做。 */
2397
+ private clearChangeTimer;
2039
2398
  private performChange;
2040
2399
  private rebind;
2041
2400
  private bind;
2042
2401
  private createBoundServer;
2402
+ /**
2403
+ * 关掉当前监听与全部连接,**有界**。
2404
+ *
2405
+ * `server.close()` 的回调要等所有连接结束;而**升级过的 WebSocket 不在
2406
+ * `closeAllConnections()` 的管理范围里**(HTTP 层已经不跟踪它了)。于是只要有一条
2407
+ * 连接读不动(暂停读 / 半开),`close()` 就永远等不到回调 —— 插件卸载会挂死。
2408
+ *
2409
+ * 所以这里的顺序是:先礼后兵 —— 发 1012(客户端会看到「服务端重绑」而不是异常断开),
2410
+ * 等一小段宽限期;宽限期到就 `terminate()`,同时让 `close()` 的等待一起收尾。
2411
+ */
2043
2412
  private closeCurrent;
2044
2413
  private handleUpgrade;
2414
+ /**
2415
+ * 心跳循环:每 `heartbeatIntervalMs` 检查一次存活标记。
2416
+ *
2417
+ * 时序要看清:`isAlive` **先读后置假**,并且只在本轮「活着」时才补一次 `ping`。
2418
+ * 于是:
2419
+ * - 本轮活着 → 标记为可疑,再 ping 一次;
2420
+ * - 本轮仍可疑(上一轮 ping 没换来 pong)→ `terminate()`。
2421
+ *
2422
+ * 结果是**连续错过两个周期**才踢人。偶发丢一个包不会误杀在线设备,真正断掉的
2423
+ * (手机切网 / 熄屏后 NAT 表项过期)最多两个周期就清掉 —— 而修复前它只能等
2424
+ * TCP 自己超时,等于「半开连接长期占着额度,还在每轮广播里被白查一次库」。
2425
+ *
2426
+ * `unref()` 必需:这个定时器永不结束,不 unref 会让进程退不掉。
2427
+ */
2428
+ private startHeartbeat;
2045
2429
  /**
2046
2430
  * 把待处理的**审批与提问**补推给刚建立的这条连接。
2047
2431
  *
@@ -2051,15 +2435,78 @@ declare class RemoteApiServer implements RemoteControl {
2051
2435
  * 重连窗口里产生的审批就会静默消失(内核阻塞着等决定、手机上却一片安静)。
2052
2436
  * 重放把「新连接一定能拿到仍待处理的审批」变成**服务端保证**,而不是客户端的义务。
2053
2437
  *
2054
- * 三个刻意的取舍:
2438
+ * 四个刻意的取舍:
2055
2439
  * - **不 await**:重放要按会话做授权过滤(异步),握手不能为它排队;
2056
2440
  * - **只发这一条连接**:不走 `broadcast`,避免每次有人重连就骚扰所有在线设备;
2057
2441
  * - **失败静默**:重放是补救措施,它出错不该影响这条连接正常收事件。
2058
- * 客户端侧「连接后/亮屏后主动刷新」仍是兜底,但不再是唯一依赖。
2442
+ * 客户端侧「连接后/亮屏后主动刷新」仍是兜底,但不再是唯一依赖;
2443
+ * - **走连接自己的队列**:重放帧与随后的广播帧必须保序。直接 `socket.send`
2444
+ * 会让「重放的第 2 帧」插到「广播的第 1 帧」前面去。
2059
2445
  */
2060
2446
  private replayPendingInteractions;
2061
- private broadcast;
2447
+ /** 当前设备授权版本;`database` 还没提供就返回 undefined(退回逐事件查库的旧路径)。 */
2448
+ private devicesRevisionOf;
2449
+ /**
2450
+ * 广播入口:**只做快照 + 入队**,全程同步。
2451
+ *
2452
+ * 这里是宿主事件循环上的热路径 —— 订阅回调里的任何 `await`(查库、查内核、
2453
+ * 发网络)都会反过来把宿主的 `push` 拖住。修复前正是这样:一条全局 promise 链
2454
+ * 串起「事件 × 连接」,一个慢判定让所有事件、所有客户端一起排队,`stop()`
2455
+ * 还得 `await` 那条链。
2456
+ */
2457
+ private enqueueBroadcast;
2458
+ /**
2459
+ * 单连接入队:溢出时按「先丢可重建帧」处理,并给客户端一个 resync 信号。
2460
+ *
2461
+ * 为什么溢出不断连:手机端解码慢一次不该被踢下线(那会触发一条重连风暴,
2462
+ * 而重连风暴又会把刚建好的连接再挤掉)。但「静默丢帧」更糟 —— 客户端会以为
2463
+ * 事件流还连续,于是它手里的状态永远停在丢帧之前。所以丢的时候记一笔,
2464
+ * 随后补发 `connection.resyncRequired`,把「静默」变成「告知」。
2465
+ */
2466
+ private enqueue;
2467
+ /** 保证一条连接只有一个 drain 循环(否则同一连接内的顺序会被打乱)。 */
2468
+ private startDrain;
2469
+ /**
2470
+ * 一条连接的发送循环。
2471
+ *
2472
+ * `await` 只发生在「这条连接自己的准备工作」上(刷新设备身份、判断会话授权)。
2473
+ * 于是慢连接只慢它自己 —— 这正是与修复前的根本区别。
2474
+ */
2475
+ private drain;
2476
+ /**
2477
+ * 记录一次「越过发送高水位」,并安排复查。
2478
+ *
2479
+ * @returns 这条连接是否已经被关掉(调用方据此决定还要不要继续 drain)。
2480
+ *
2481
+ * 两拍才关是刻意的:一次越线可能只是客户端的短暂抖动(切网、GC),
2482
+ * 立刻踢下线会制造重连风暴。但**必须有复查** —— 否则「第二拍」永远不会发生,
2483
+ * 连接就卡在「不发也不关」的僵尸态,比直接断掉更难排查。
2484
+ */
2485
+ private noteHighWater;
2486
+ private sendFrame;
2487
+ /** 清掉这条连接自己的定时器。**必须**在连接被摘掉时调用,否则定时器会拖住进程。 */
2488
+ private clearSocketTimers;
2489
+ private closeBackpressured;
2490
+ /**
2491
+ * 按需刷新这条连接的身份快照。
2492
+ *
2493
+ * 有 `devicesRevision` 时:只有版本变了才查库 —— 正常情况**一件事都不做**,
2494
+ * 而修复前是「每个事件、每条连接都查一次 `getActiveDevice`」。
2495
+ * 没有该 getter 时退回旧行为(逐事件查库):老数据库上功能不变,只是慢一点。
2496
+ * 这比「广播路径依赖一个可能不存在的 API」安全得多,也让两侧改动可以分批落地。
2497
+ *
2498
+ * @returns 最新身份;设备已被撤销时返回 undefined。
2499
+ */
2500
+ private refreshPrincipal;
2062
2501
  private eventAllowed;
2502
+ /**
2503
+ * 等所有连接排空,**有上限**。
2504
+ *
2505
+ * `stop()` 不能无限等:一条读不动的 socket 会让插件卸载挂死,进而拖住进程退出。
2506
+ * 超时后照样会断开它们(`closeCurrent` 负责),只是不再尝试把队列里的东西送出去 ——
2507
+ * 卸载路径上「送达」本来就不是目标。
2508
+ */
2509
+ private drainAll;
2063
2510
  private trimOperations;
2064
2511
  }
2065
2512
  /** 一条挂在 DSH webserver 上的插件自有路由。 */