dsh-remote-bridge 2.5.2 → 2.6.1

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
@@ -26,6 +26,24 @@ interface DeviceView {
26
26
  createdAt: number;
27
27
  lastSeenAt?: number;
28
28
  revokedAt?: number;
29
+ /**
30
+ * 设备自称/被推断出的平台与型号(`android` / `harmonyos` / …)。
31
+ *
32
+ * **可选**:老设备从未上报过就是 `undefined`,客户端必须能接受缺省。
33
+ * 这两个字段只用于界面显示与排查,**不参与任何授权判断** —— 它们是客户端
34
+ * 自称的,不是可信身份。
35
+ */
36
+ platform?: string;
37
+ model?: string;
38
+ /**
39
+ * `name` 是怎么来的,决定后续能不能自动改名。
40
+ *
41
+ * - `default`:配对时的占位名(`'Android 设备'` 之类)。设备第一次带身份
42
+ * 连上来时**允许**被自动改成「平台 · 型号」。
43
+ * - `detected`:由探测到的身份自动生成。后续身份变了仍可再自动更新。
44
+ * - `declared`:用户手工改的。**永不再自动覆盖** —— 用户起的名比机器猜的准。
45
+ */
46
+ nameSource?: 'default' | 'detected' | 'declared';
29
47
  }
30
48
  interface FileEntryView {
31
49
  name: string;
@@ -179,6 +197,7 @@ declare class WorkspaceDatabase {
179
197
  */
180
198
  exchangePairing(code: string, deviceName: string, options?: {
181
199
  requestId?: string;
200
+ nameSource?: 'default' | 'declared';
182
201
  }): {
183
202
  token: string;
184
203
  device: DeviceView;
@@ -186,6 +205,47 @@ declare class WorkspaceDatabase {
186
205
  authenticate(token: string): AuthenticatedDevice | undefined;
187
206
  getActiveDevice(id: string): AuthenticatedDevice | undefined;
188
207
  private hydrateDevice;
208
+ /**
209
+ * 记录一次探测到的设备身份,必要时顺手把占位名改成「平台 · 型号」。
210
+ *
211
+ * 时序说明:配对时桥只知道「有个设备要连」,真正的平台与型号要等设备
212
+ * **第一次发请求**才拿得到。所以这个方法由认证路径调用(见 `auth.ts`
213
+ * 的 `principal`),而不是配对路径。
214
+ *
215
+ * ## 什么时候改名
216
+ *
217
+ * 只有 `name_source` 是 `default`(配对时的占位名)或 `detected`(上次自动
218
+ * 生成的)才改名。`declared`(用户手工改的)**永不动** —— 用户起的名比机器
219
+ * 猜的准,而且他能改一次就能改第二次,但机器每次连接都覆盖就再也留不住了。
220
+ *
221
+ * 老库的 `name_source` 是 NULL:那些行里的名字多半是硬编码的「Android 设备」,
222
+ * 但**也可能是用户手工改过的**(此前没有任何改名通道,所以实际上只有占位名;
223
+ * 不过万一有人直接改过库,我们不能替他决定)。保守起见把 NULL 当作
224
+ * `declared` 处理 —— 名字不动,只补 platform/model 两列。
225
+ *
226
+ * ## 为什么不做成「每次请求都写库」
227
+ *
228
+ * 调用方只在**探测结果与库里不同**时才调这里(见 `auth.ts`),所以正常情况
229
+ * 下一台设备一生只写一次。这不是性能考虑,而是避免把一次读请求变成写请求:
230
+ * 写请求会碰 WAL、会影响 `devicesRevisionCounter`,进而触发全体客户端重拉设备表。
231
+ *
232
+ * @returns 是否真的改了库(调用方据此决定要不要递增设备版本号)。
233
+ */
234
+ recordDeviceIdentity(id: string, identity: {
235
+ platform?: string;
236
+ model?: string;
237
+ suggestedName?: string;
238
+ }): boolean;
239
+ /**
240
+ * 用户手工改名。
241
+ *
242
+ * 一旦走这条路,`name_source` 就变成 `declared`,此后任何自动探测都**不再**
243
+ * 覆盖它。这是「用户意图优先于机器推断」的唯一实现点,所以单独一个方法而不是
244
+ * 塞进 `updateDevice`:权限变更与改名是两件不同的事,审计动作也不该混。
245
+ */
246
+ renameDevice(id: string, name: string, options?: {
247
+ requestId?: string;
248
+ }): DeviceView;
189
249
  listDevices(): DeviceView[];
190
250
  isDeviceActive(id: string): boolean;
191
251
  /**
@@ -242,7 +302,39 @@ declare class WorkspaceDatabase {
242
302
  * 干净的办法制造这种行,而它不该走生产代码路径 —— 所以显式开一个测试专用入口,
243
303
  * 并起一个自见其明的名字,免得有人把它当成正常 API。
244
304
  */
305
+ /**
306
+ * 仅供测试:把某台设备的「最后使用时间/创建时间」拨到过去。
307
+ *
308
+ * 生产路径没法造出「30 天没动过」的设备(认证一次就刷新 `last_seen_at`),而
309
+ * `pruneInactiveDevices` 的核心语义恰恰只在这种时间跨度上才看得见。与
310
+ * `markRevokedForTest` 同样的理由:显式开一个自见其明的测试入口,别让人误当 API。
311
+ */
312
+ backdateDeviceForTest(id: string, options: {
313
+ lastSeenAt?: number;
314
+ createdAt?: number;
315
+ }): void;
245
316
  markRevokedForTest(id: string): void;
317
+ /**
318
+ * 按「最后使用时间」批量清掉长期没动过的设备。
319
+ *
320
+ * 「最后使用」取 `last_seen_at`(每次令牌认证成功都会刷新),从没认证过的设备退回
321
+ * `created_at` —— 否则一条配好就再没开过的设备会永远躲过清理,而那恰恰是用户最想
322
+ * 一键清掉的那类(换手机、临时配对、旧测试探针)。
323
+ *
324
+ * 与 `purgeRevokedDevices` 的区别:后者清的是历史遗留的「标记式撤销」脏行,这里清的是
325
+ * **仍然有效、但已经不用了**的设备 —— 用户的原话是「一键清理多少天未使用设备」。
326
+ *
327
+ * 语义边界(故意这么定):
328
+ * - 只删设备行 + 级联的 `device_roots`,不碰审计(审计的 `actor_id` 不是外键);
329
+ * - **不删本机正在用的那台**(`keepDeviceId`):调用方就是它,删掉自己会让当前
330
+ * 客户端立刻 401,看起来像「点一下清理把手机踢下线了」;
331
+ * - 天数被夹在 1..3650,避免 `0` 或负数变成「清空所有设备」;
332
+ * - 返回真正删掉的条数,0 也是一次合法结果。
333
+ */
334
+ pruneInactiveDevices(olderThanDays: number, options?: {
335
+ keepDeviceId?: string;
336
+ requestId?: string;
337
+ }): number;
246
338
  purgeRevokedDevices(): number;
247
339
  /**
248
340
  * 删掉早于 `cutoffMs` 的幂等回放记录,返回删除行数。
@@ -301,17 +393,61 @@ declare class WorkspaceDatabase {
301
393
  private dropRootsUniqueConstraint;
302
394
  /** `roots` 表是否已有指定列。 */
303
395
  private hasRootColumn;
396
+ /** `devices` 表是否已有指定列。 */
397
+ private hasDeviceColumn;
398
+ private hasColumn;
304
399
  }
305
400
  //#endregion
306
- //#region src/host/settings-adapter.d.ts
401
+ //#region src/host/settings-namespace.d.ts
402
+ /**
403
+ * 内核 `settings.describe()` 的命名空间视图(DSH 0.2.x)。
404
+ *
405
+ * 字段比 2.6.0 之前多:`autoGenerate` / `applies` / `secrets` 都原样透出,
406
+ * 手机端要靠 `secrets[].path` 判断「哪些字段是密钥位、只读」,比按键名猜可靠。
407
+ */
307
408
  interface SettingsNamespaceView {
308
409
  ns: string;
410
+ autoGenerate?: unknown;
309
411
  schema?: unknown;
310
412
  value: unknown;
311
413
  base?: unknown;
312
414
  user?: unknown;
415
+ applies?: unknown;
416
+ secrets?: SettingsNamespaceSecret[];
417
+ revision: number;
418
+ }
419
+ /** 一个密钥位的路径与「是否已设置」;**只有路径,没有值**。 */
420
+ interface SettingsNamespaceSecret {
421
+ path: string[];
422
+ set: boolean;
423
+ }
424
+ type SettingsNamespaceReason = 'HOST_READONLY' | 'HIGH_RISK_NAMESPACE';
425
+ /** 手机端看到的单个命名空间:内核视图 + 能不能写 + 为什么不能写。 */
426
+ interface SettingsNamespaceEntry {
427
+ ns: string;
313
428
  revision: number;
429
+ schema?: unknown;
430
+ value: unknown;
431
+ base?: unknown;
432
+ user?: unknown;
433
+ autoGenerate?: unknown;
434
+ applies?: unknown;
435
+ secrets?: SettingsNamespaceSecret[];
436
+ writable: boolean;
437
+ reason?: SettingsNamespaceReason;
438
+ }
439
+ /**
440
+ * `GET /api/v1/settings/namespaces` 的响应。
441
+ *
442
+ * 刻意**不**透出内核的 `hasDocument`:它对手机端没有决策价值,而响应多一个字段
443
+ * 就等于契约多一个字段(另一侧已按 `{writable, namespaces}` 定稿)。
444
+ */
445
+ interface SettingsNamespacesView {
446
+ writable: boolean;
447
+ namespaces: SettingsNamespaceEntry[];
314
448
  }
449
+ //#endregion
450
+ //#region src/host/settings-adapter.d.ts
315
451
  interface ConfigurableProvider {
316
452
  provider: string;
317
453
  displayName: string;
@@ -476,7 +612,37 @@ interface CustomProviderCapability {
476
612
  }
477
613
  declare class DshSettingsAdapter {
478
614
  private readonly api;
479
- constructor(api: SettingsApiProxy);
615
+ private readonly options;
616
+ /**
617
+ * @param options.writeNamespaces 额外允许手机端写入的设置命名空间(精确 ns 或
618
+ * `前缀*`),与内置策略(`ui-*` / 以 `locale` 结尾)叠加;默认为空。
619
+ * @param options.allowPrivateProviderHosts 允许供应商 `baseURL` 指向的私网 / 回环
620
+ * host 白名单(默认空 = 一律拒绝)。要连本地 LLM 的部署方显式填,理由见
621
+ * `provider-url-policy.ts` 文件头。
622
+ */
623
+ constructor(api: SettingsApiProxy, options?: {
624
+ writeNamespaces?: readonly string[];
625
+ allowPrivateProviderHosts?: readonly string[];
626
+ });
627
+ /**
628
+ * 列出全部设置命名空间(通用设置面的读侧)。
629
+ *
630
+ * 与 `listProviders()` 的区别:它不理解任何 provider 语义,原样透出内核按插件
631
+ * 划分的命名空间,由手机端按 `schema` 自己长表单 —— 新装插件不必等 App 发版。
632
+ */
633
+ describeNamespaces(): Promise<SettingsNamespacesView>;
634
+ /**
635
+ * 按路径改一个命名空间(通用设置面的写侧)。
636
+ *
637
+ * 校验顺序是刻意的:ns 策略 → ops 结构 → 内核 describe(host 可写性)→ mutate。
638
+ * 先验策略既省一次内核往返,也保证「高危命名空间」无论 ops 怎么错都先得到 403。
639
+ */
640
+ mutateNamespace(ns: string, ops: unknown, expectedRevision?: number): Promise<SettingsNamespaceEntry>;
641
+ private get writeNamespaces();
642
+ /** 交给地址策略的选项;集中一处,三个入口不会各配各的。 */
643
+ private get baseURLOptions();
644
+ /** 内核 settings 服务缺失时给 503(而不是把 `TypeError` 漏成 500)。 */
645
+ private settingsService;
480
646
  listProviders(): Promise<{
481
647
  writable: boolean;
482
648
  revisionByNamespace: Record<string, number>;
@@ -490,14 +656,38 @@ declare class DshSettingsAdapter {
490
656
  message: string;
491
657
  }[];
492
658
  }>;
493
- updateProvider(providerId: string, patch: ProviderPatch): Promise<void>;
494
- createProvider(input: CustomProviderCreate): Promise<void>;
659
+ /**
660
+ * 更新供应商配置。
661
+ *
662
+ * 返回 `baseURLHost`(仅当本次 patch 动了 `baseURL` 时存在)供路由层写审计:
663
+ * 攻击者可以用 discover 把凭据发到任意地址,事后必须能从审计里看出「打了哪」。
664
+ * 只记 host 不记完整 URL —— path/query 可能带凭据,审计表不该成为新的泄露点。
665
+ */
666
+ updateProvider(providerId: string, patch: ProviderPatch): Promise<{
667
+ baseURLHost?: string;
668
+ }>;
669
+ createProvider(input: CustomProviderCreate): Promise<{
670
+ baseURLHost: string;
671
+ }>;
495
672
  removeProvider(providerId: string, expectedRevision?: number): Promise<void>;
673
+ /**
674
+ * 发现供应商可用模型。
675
+ *
676
+ * `draft.baseURL` 是**临时覆盖**(不写配置),但内核会带着该供应商已配置的真实
677
+ * 凭据去请求它 —— 这正是 SEC-H11 的窃取路径:攻击者把自己的服务器填进来即可收到
678
+ * `Authorization: Bearer <真实 key>`。因此临时覆盖与持久化写入过同一道地址策略。
679
+ *
680
+ * 返回 `baseURLHost` 供路由层审计;不传 `baseURL` 时(用配置里的地址)同样返回,
681
+ * 这样「打了哪个地址」在任何情况下都能追溯。
682
+ */
496
683
  discover(providerId: string, draft: {
497
684
  baseURL?: string;
498
685
  api?: string;
499
686
  apiKey?: string;
500
- }): Promise<ProviderModelView[]>;
687
+ }): Promise<{
688
+ models: ProviderModelView[];
689
+ baseURLHost?: string;
690
+ }>;
501
691
  /**
502
692
  * 取一个 provider 的上下文(目录条目 + 设置命名空间 + 当前 profile)。
503
693
  *
@@ -525,6 +715,13 @@ declare class DshSettingsAdapter {
525
715
  */
526
716
  private invalidateProviderContext;
527
717
  private loadProviderContext;
718
+ /**
719
+ * 该供应商当前是否已经存了凭据。
720
+ *
721
+ * 单独一次 `credentials.describe` 只在「要换 baseURL host」时才调 —— 那是罕见路径,
722
+ * 而正常保存(改 models / 显示名 / 协议)不该为此多付一次内核往返。
723
+ */
724
+ private providerHoldsCredential;
528
725
  }
529
726
  //#endregion
530
727
  //#region src/host/dsh-0.2-bridge.d.ts
@@ -1830,6 +2027,14 @@ declare class AuthService {
1830
2027
  principal(req: IncomingMessage, options?: {
1831
2028
  allowAdmin?: boolean;
1832
2029
  }): Principal;
2030
+ /**
2031
+ * 把本次请求探测到的身份记进库,并(在允许时)更新设备显示名。
2032
+ *
2033
+ * 吞掉一切异常的理由见调用点注释:设备名是显示信息,不该成为连接的前置条件。
2034
+ *
2035
+ * @returns 改名成功时的新名字;没改名(或探测不到)时 `undefined`。
2036
+ */
2037
+ private recordIdentity;
1833
2038
  /**
1834
2039
  * `/manage/**` 的守门人。
1835
2040
  *
@@ -2281,6 +2486,37 @@ declare class ApiRouter {
2281
2486
  handle(req: IncomingMessage, res: ServerResponse, stripPrefix?: string): Promise<void>;
2282
2487
  private dispatch;
2283
2488
  private dispatchManagement;
2489
+ /**
2490
+ * 打开隧道并(可选)顺带产出配置文本 —— HTTP `POST /manage/cloudflare/create` 与
2491
+ * IM `/tunnel` 命令**共用**这一条实现。
2492
+ *
2493
+ * 抽出来的原因:两条入口的前半段完全一致(确保 cloudflared → startQuick → 等随机域名 →
2494
+ * 建配置文本),差别只在「谁在调用」——HTTP 侧要先把请求体读干净(流会随时间关闭),
2495
+ * 命令侧没有请求体。复制一份会让「一键出令牌」在两条路径上慢慢漂移。
2496
+ *
2497
+ * @param createBody - 已解析的请求体(HTTP 侧)或 `{ configText: true }`(命令侧)。
2498
+ * @param requestId - 审计与配对记录共用的请求 ID。
2499
+ * @param actorLabel - 审计里的操作者标签:`loopback-admin`(本机管理面)或 `im-command`。
2500
+ * @returns 合并了「等到地址」的 state,以及(wantConfig 且拿到地址时)配置文本。
2501
+ */
2502
+ private createTunnel;
2503
+ /**
2504
+ * IM `/tunnel` 命令的宿主侧入口:开隧道并返回一行可粘贴的配置文本。
2505
+ *
2506
+ * 与 HTTP 路径的两点差别:
2507
+ *
2508
+ * 1. **隧道已在运行时直接复用当前地址**,不重复 spawn(第二次 `startQuick` 会撞
2509
+ * `TUNNEL_ALREADY_RUNNING`,而用户在 IM 里连发两条是很常见的)。
2510
+ * 2. 配置文本的 endpoint 只用隧道地址,设备名缺省由 `buildConfigText` 决定 —— 命令
2511
+ * 不接受参数,所以没有 `endpointLabel` / `rootIds` 的透传面。
2512
+ *
2513
+ * @throws 当 12 秒内没拿到公网地址,或 cloudflared 下载失败时。
2514
+ */
2515
+ openTunnelForCommand(): Promise<{
2516
+ url: string;
2517
+ text: string;
2518
+ deviceName: string;
2519
+ }>;
2284
2520
  /**
2285
2521
  * 隧道状态视图。
2286
2522
  *
@@ -2560,6 +2796,26 @@ interface Config {
2560
2796
  port?: number;
2561
2797
  remoteHost?: string;
2562
2798
  maxUploadBytes?: number;
2799
+ /**
2800
+ * 额外允许手机端写入的设置命名空间(默认空)。
2801
+ *
2802
+ * 精确 ns(`ui-chat`)或通配前缀(`llm-*`)。与内置策略叠加:`ui-*` 与以
2803
+ * `locale` 结尾的命名空间本来就可写;其余默认只读,因为改模型/权限等于把
2804
+ * 遥控器交出去(理由见 docs/project/SECURITY.md)。
2805
+ */
2806
+ settingsWriteNamespaces?: readonly string[];
2807
+ /**
2808
+ * 允许供应商 `baseURL` 指向的私网 / 回环 host 白名单(默认空 = 一律拒绝)。
2809
+ *
2810
+ * 默认拒绝的理由:手机端改 `baseURL` 就等于把模型请求(含 `Authorization:
2811
+ * Bearer <真实 key>`)引到任意地址,这是 SEC-H11 的窃取路径。默认拒绝把
2812
+ * 「谁能连内网」变成部署方的显式决定。
2813
+ *
2814
+ * 要连本地 LLM(ollama / LM Studio 之类)时填这里,推荐带端口:
2815
+ * `["127.0.0.1:11434", "localhost:1234"]`。带端口的条目只放行该端口,裸
2816
+ * host 条目放行该主机的任意端口(面更大)。规则见 `src/host/provider-url-policy.ts`。
2817
+ */
2818
+ allowPrivateProviderHosts?: readonly string[];
2563
2819
  }
2564
2820
  interface HostContext extends Dsh02HostServices {
2565
2821
  webServer: Parameters<typeof attachEmbeddedRoutes>[0];