dsh-remote-bridge 2.6.0 → 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.js CHANGED
@@ -19,6 +19,23 @@ const DEVICE_SCOPES = [
19
19
  "settings.read",
20
20
  "settings.write"
21
21
  ];
22
+ /**
23
+ * 授予新设备时的默认权限(**五个非破坏性权限**)。
24
+ *
25
+ * 刻意不含 `files.delete` 与 `settings.write`:这两个是破坏性权限,应当是用户显式
26
+ * 要求才授予。`settings.write` 尤其危险 —— 拿到它就能改供应商 `baseURL`,把模型
27
+ * 请求连同真实凭据引到任意地址(SEC-H11),所以它绝不能是默认值。
28
+ *
29
+ * 三条路径必须共用这一份,否则「同一个产品里三处默认权限不一样」这种缺陷会反复
30
+ * 复发:服务端生成配置文本时的缺省、Web UI 配对码面板的初始勾选、隧道一键路径。
31
+ */
32
+ const DEFAULT_DEVICE_SCOPES = [
33
+ "chat.read",
34
+ "chat.write",
35
+ "files.read",
36
+ "files.write",
37
+ "settings.read"
38
+ ];
22
39
  const ADMIN_PRINCIPAL = {
23
40
  kind: "admin",
24
41
  id: "loopback-admin",
@@ -26,6 +43,164 @@ const ADMIN_PRINCIPAL = {
26
43
  rootIds: "all"
27
44
  };
28
45
  //#endregion
46
+ //#region src/host/device-identity.ts
47
+ /**
48
+ * 设备身份的自动识别:从请求头推断「这是台什么设备」。
49
+ *
50
+ * ## 为什么需要它
51
+ *
52
+ * 起因(用户 2026-10-23 反馈):鸿蒙手机在设备表里被登记成「Android 设备」。
53
+ * 根因是配置文本路径的 `deviceName` 缺省值硬编码成 `'Android 设备'` ——
54
+ * 那条路径**在生成配置文本时**就把设备行写进库了,而那时桥对「将来谁来用
55
+ * 这个令牌」一无所知。桌面端/隧道一键生成的配置全顶包这个名字,设备表里
56
+ * 躺着 8 条一模一样的「Android 设备」,谁也认不出谁。
57
+ *
58
+ * 正确的时序是「先有连接,再有身份」:配对时桥只知道「有个设备要连」,
59
+ * 真正的平台与型号要等设备**第一次发请求**时才知道。所以这里做两件事:
60
+ *
61
+ * 1. 定义一个结构化上报头(`X-Dsh-Device: <platform>/<model>`),客户端
62
+ * 愿意报就报 —— 这是唯一准确的信息源。
63
+ * 2. 客户端没报时,从 `User-Agent` 兜底推断平台。型号在 UA 里常常拿不到,
64
+ * 拿不到就**留空**,不猜。
65
+ *
66
+ * ## 为什么不用 UA 硬解析型号
67
+ *
68
+ * okhttp(安卓)与 ArkWeb(鸿蒙)默认 UA 里都**没有**机型。硬从 UA 抠一个
69
+ * 型号出来只会得到错的东西,而错的型号比没有型号更糟:用户会以为那是真的。
70
+ * 所以 UA 只用于判平台,型号只认结构化上报。
71
+ *
72
+ * ## 安全边界
73
+ *
74
+ * 这两个字段是**客户端自称**的,不是可信身份。它们只用于界面显示与排查,
75
+ * 绝不参与任何授权判断(授权只看 `scopes` / `rootIds`)。因此这里对长度与
76
+ * 字符集做裁剪(防止往界面里塞控制字符或超长串),但不做「验真」。
77
+ */
78
+ /** 已知平台。`unknown` 表示「认出来了但不属于下面任何一个」,不是「没认出来」。 */
79
+ const DEVICE_PLATFORMS = [
80
+ "android",
81
+ "harmonyos",
82
+ "ios",
83
+ "ipados",
84
+ "web",
85
+ "windows",
86
+ "macos",
87
+ "linux"
88
+ ];
89
+ /** 型号的最大长度。超过就截断 —— 它只用于显示,不需要完整。 */
90
+ const MAX_MODEL_LENGTH = 60;
91
+ /** 结构化上报头。格式 `platform/model`,model 可省。 */
92
+ const DEVICE_HEADER = "x-dsh-device";
93
+ /**
94
+ * 配对时设备名的占位值。
95
+ *
96
+ * **刻意平台无关**:此前这里(以及隧道路径)硬编码成 `'Android 设备'`,于是
97
+ * 鸿蒙、iOS、桌面上配对的设备全被登记成安卓。桥在生成配置文本时根本不知道
98
+ * 将来是谁来用这个令牌,所以占位名不该声称任何平台。
99
+ *
100
+ * 与 `database.ts` 的 `exchangePairing` 兜底(`'Mobile device'`)取同一个值:
101
+ * 两条路都是「我还不知道这是啥」,不该给出两个不同的说法。
102
+ *
103
+ * 这个占位名会被标记为 `nameSource: 'default'`,设备第一次带身份连上来时
104
+ * 自动换成「平台 · 型号」—— 所以它通常只在设备表里短暂出现。
105
+ */
106
+ const DEFAULT_DEVICE_NAME = "Mobile device";
107
+ function isPlatform(value) {
108
+ return DEVICE_PLATFORMS.includes(value);
109
+ }
110
+ /**
111
+ * 清洗型号:去掉控制字符、压缩空白、裁剪长度。
112
+ *
113
+ * 不做 HTML 转义 —— 那是渲染层的事,在这一层转义会让「存进库里的值与
114
+ * 客户端报的不一样」,排查时反而更难对上。React 默认转义文本节点。
115
+ */
116
+ function cleanModel(raw) {
117
+ const stripped = raw.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
118
+ if (stripped === "") return void 0;
119
+ return stripped.length > MAX_MODEL_LENGTH ? stripped.slice(0, MAX_MODEL_LENGTH) : stripped;
120
+ }
121
+ /**
122
+ * 从结构化上报头解析。
123
+ *
124
+ * 格式宽松:`android/Pixel 9`、`harmonyos`(只报平台)、`harmonyos/ICL-AL10`。
125
+ * 平台必须在白名单里,否则整条丢弃 —— 一个自造的 `platform` 值会让界面
126
+ * 显示一个不存在的平台标签,不如不显示。
127
+ */
128
+ function parseDeviceHeader(raw) {
129
+ if (raw === void 0) return {};
130
+ const value = raw.trim();
131
+ if (value === "") return {};
132
+ const slash = value.indexOf("/");
133
+ const platform = (slash === -1 ? value : value.slice(0, slash)).trim().toLowerCase();
134
+ if (!isPlatform(platform)) return {};
135
+ const model = slash === -1 ? void 0 : cleanModel(value.slice(slash + 1));
136
+ return model === void 0 ? { platform } : {
137
+ platform,
138
+ model
139
+ };
140
+ }
141
+ /**
142
+ * 从 User-Agent 兜底推断平台。
143
+ *
144
+ * 顺序有讲究:鸿蒙的 UA 里同时含 `Linux` 与 `Android`(兼容串),必须先判
145
+ * 鸿蒙再判安卓,否则鸿蒙会被认成安卓 —— 那正是这次要修的那个错误。
146
+ */
147
+ function parseUserAgent(raw) {
148
+ if (raw === void 0) return {};
149
+ const ua = raw.trim();
150
+ if (ua === "") return {};
151
+ if (/harmonyos|openharmony|arkweb/i.test(ua)) return { platform: "harmonyos" };
152
+ if (/ipad/i.test(ua)) return { platform: "ipados" };
153
+ if (/iphone|ipod/i.test(ua)) return { platform: "ios" };
154
+ const android = /android[^;)]*;\s*([^;)]+?)\s*[;)]/i.exec(ua);
155
+ if (/android/i.test(ua)) {
156
+ const model = android === null ? void 0 : cleanModel(android[1] ?? "");
157
+ return model === void 0 ? { platform: "android" } : {
158
+ platform: "android",
159
+ model
160
+ };
161
+ }
162
+ if (/windows nt/i.test(ua)) return { platform: "windows" };
163
+ if (/macintosh|mac os x/i.test(ua)) return { platform: "macos" };
164
+ if (/x11|linux/i.test(ua)) return { platform: "linux" };
165
+ return {};
166
+ }
167
+ /**
168
+ * 解析一次请求的设备身份。结构化头优先,UA 只补缺。
169
+ *
170
+ * 「补缺」而不是「合并」:客户端明确报了 `harmonyos` 时,UA 里那个兼容用的
171
+ * `Android` 不该把它盖掉。
172
+ */
173
+ function parseDeviceIdentity(headers) {
174
+ const header = headers[DEVICE_HEADER];
175
+ const declared = parseDeviceHeader(Array.isArray(header) ? header[0] : header);
176
+ const fallback = parseUserAgent(Array.isArray(headers["user-agent"]) ? headers["user-agent"][0] : headers["user-agent"]);
177
+ return {
178
+ ...declared.platform !== void 0 ? { platform: declared.platform } : fallback.platform === void 0 ? {} : { platform: fallback.platform },
179
+ ...declared.model !== void 0 ? { model: declared.model } : fallback.model === void 0 ? {} : { model: fallback.model }
180
+ };
181
+ }
182
+ /**
183
+ * 用探测到的身份拼一个设备显示名,格式 `平台 · 型号`(与用户手工改的
184
+ * `HarmonyOS · ICL-AL10` 一致)。拿不到平台时返回 `undefined` —— 调用方
185
+ * 应当保留原名字,而不是塞一个更糟的占位。
186
+ */
187
+ function describeDevice(identity) {
188
+ const platform = identity.platform;
189
+ if (platform === void 0) return void 0;
190
+ const label = PLATFORM_LABEL[platform];
191
+ return identity.model === void 0 ? label : `${label} · ${identity.model}`;
192
+ }
193
+ const PLATFORM_LABEL = {
194
+ android: "Android",
195
+ harmonyos: "HarmonyOS",
196
+ ios: "iOS",
197
+ ipados: "iPadOS",
198
+ web: "Web",
199
+ windows: "Windows",
200
+ macos: "macOS",
201
+ linux: "Linux"
202
+ };
203
+ //#endregion
29
204
  //#region src/host/errors.ts
30
205
  var ApiError = class extends Error {
31
206
  status;
@@ -563,15 +738,37 @@ var AuthService = class {
563
738
  this.authFailuresByPeer[peer].consume(key);
564
739
  throw new ApiError(401, "TOKEN_INVALID", "The device token is invalid or revoked.");
565
740
  }
741
+ const renamed = this.recordIdentity(req, device);
566
742
  return {
567
743
  kind: "device",
568
744
  id: device.id,
569
- name: device.name,
745
+ name: renamed ?? device.name,
570
746
  scopes: new Set(device.scopes),
571
747
  rootIds: new Set(device.rootIds)
572
748
  };
573
749
  }
574
750
  /**
751
+ * 把本次请求探测到的身份记进库,并(在允许时)更新设备显示名。
752
+ *
753
+ * 吞掉一切异常的理由见调用点注释:设备名是显示信息,不该成为连接的前置条件。
754
+ *
755
+ * @returns 改名成功时的新名字;没改名(或探测不到)时 `undefined`。
756
+ */
757
+ recordIdentity(req, device) {
758
+ try {
759
+ const identity = parseDeviceIdentity(req.headers);
760
+ if (identity.platform === void 0 && identity.model === void 0) return void 0;
761
+ const suggestedName = describeDevice(identity);
762
+ return this.database.recordDeviceIdentity(device.id, {
763
+ ...identity.platform === void 0 ? {} : { platform: identity.platform },
764
+ ...identity.model === void 0 ? {} : { model: identity.model },
765
+ ...suggestedName === void 0 ? {} : { suggestedName }
766
+ }) ? suggestedName : void 0;
767
+ } catch {
768
+ return;
769
+ }
770
+ }
771
+ /**
575
772
  * `/manage/**` 的守门人。
576
773
  *
577
774
  * @param options.allowTunnelMode - 只有「隧道状态下读/停隧道」这几个端点会传 true。
@@ -2926,7 +3123,7 @@ function createDsh02ApiProxy(host, options = {}) {
2926
3123
  }
2927
3124
  //#endregion
2928
3125
  //#region src/shared/version.ts
2929
- const PLUGIN_VERSION = "2.6.0";
3126
+ const PLUGIN_VERSION = "2.6.1";
2930
3127
  //#endregion
2931
3128
  //#region src/host/capabilities.ts
2932
3129
  /**
@@ -3000,6 +3197,7 @@ const BRIDGE_CAPABILITIES = [
3000
3197
  "model-catalog-v1",
3001
3198
  "plugin-inventory-v1",
3002
3199
  "plugin-toggle-v1",
3200
+ "settings-namespaces-v1",
3003
3201
  "approvals-v1",
3004
3202
  "questions-v1",
3005
3203
  "turn-failure-v1",
@@ -4467,7 +4665,8 @@ var WorkspaceDatabase = class {
4467
4665
  const scopes = parseScopes(row.scopes_json);
4468
4666
  const rootIds = JSON.parse(row.roots_json);
4469
4667
  this.db.prepare("UPDATE pairings SET used_at = ? WHERE code_hash = ?").run(now, row.code_hash);
4470
- this.db.prepare("INSERT INTO devices(id,name,token_hash,scopes_json,created_at) VALUES(?,?,?,?,?)").run(id, name, hashSecret(token), JSON.stringify(scopes), now);
4668
+ const nameSource = options.nameSource ?? "declared";
4669
+ this.db.prepare("INSERT INTO devices(id,name,token_hash,scopes_json,created_at,name_source) VALUES(?,?,?,?,?,?)").run(id, name, hashSecret(token), JSON.stringify(scopes), now, nameSource);
4471
4670
  const grant = this.db.prepare("INSERT INTO device_roots(device_id,root_id) VALUES(?,?)");
4472
4671
  for (const rootId of rootIds) grant.run(id, rootId);
4473
4672
  this.audit("device", id, "pairing.exchange", void 0, void 0, { name }, options.requestId);
@@ -4480,7 +4679,8 @@ var WorkspaceDatabase = class {
4480
4679
  name,
4481
4680
  scopes,
4482
4681
  rootIds,
4483
- createdAt: now
4682
+ createdAt: now,
4683
+ nameSource
4484
4684
  }
4485
4685
  };
4486
4686
  } catch (error) {
@@ -4508,9 +4708,79 @@ var WorkspaceDatabase = class {
4508
4708
  scopes: parseScopes(row.scopes_json),
4509
4709
  rootIds: rootRows.map((item) => item.root_id),
4510
4710
  createdAt: row.created_at,
4511
- ...touchLastSeen ? { lastSeenAt: now } : row.last_seen_at === null ? {} : { lastSeenAt: row.last_seen_at }
4711
+ ...touchLastSeen ? { lastSeenAt: now } : row.last_seen_at === null ? {} : { lastSeenAt: row.last_seen_at },
4712
+ ...identityFields(row)
4512
4713
  };
4513
4714
  }
4715
+ /**
4716
+ * 记录一次探测到的设备身份,必要时顺手把占位名改成「平台 · 型号」。
4717
+ *
4718
+ * 时序说明:配对时桥只知道「有个设备要连」,真正的平台与型号要等设备
4719
+ * **第一次发请求**才拿得到。所以这个方法由认证路径调用(见 `auth.ts`
4720
+ * 的 `principal`),而不是配对路径。
4721
+ *
4722
+ * ## 什么时候改名
4723
+ *
4724
+ * 只有 `name_source` 是 `default`(配对时的占位名)或 `detected`(上次自动
4725
+ * 生成的)才改名。`declared`(用户手工改的)**永不动** —— 用户起的名比机器
4726
+ * 猜的准,而且他能改一次就能改第二次,但机器每次连接都覆盖就再也留不住了。
4727
+ *
4728
+ * 老库的 `name_source` 是 NULL:那些行里的名字多半是硬编码的「Android 设备」,
4729
+ * 但**也可能是用户手工改过的**(此前没有任何改名通道,所以实际上只有占位名;
4730
+ * 不过万一有人直接改过库,我们不能替他决定)。保守起见把 NULL 当作
4731
+ * `declared` 处理 —— 名字不动,只补 platform/model 两列。
4732
+ *
4733
+ * ## 为什么不做成「每次请求都写库」
4734
+ *
4735
+ * 调用方只在**探测结果与库里不同**时才调这里(见 `auth.ts`),所以正常情况
4736
+ * 下一台设备一生只写一次。这不是性能考虑,而是避免把一次读请求变成写请求:
4737
+ * 写请求会碰 WAL、会影响 `devicesRevisionCounter`,进而触发全体客户端重拉设备表。
4738
+ *
4739
+ * @returns 是否真的改了库(调用方据此决定要不要递增设备版本号)。
4740
+ */
4741
+ recordDeviceIdentity(id, identity) {
4742
+ const row = this.db.prepare("SELECT platform, model, name_source, name FROM devices WHERE id = ? AND revoked_at IS NULL").get(id);
4743
+ if (row === void 0) return false;
4744
+ const platform = identity.platform ?? row.platform;
4745
+ const model = identity.model ?? row.model;
4746
+ const source = row.name_source ?? "declared";
4747
+ const mayRename = source === "default" || source === "detected";
4748
+ const suggested = identity.suggestedName;
4749
+ const rename = mayRename && suggested !== void 0 && suggested !== row.name;
4750
+ const platformChanged = platform !== row.platform;
4751
+ const modelChanged = model !== row.model;
4752
+ if (!platformChanged && !modelChanged && !rename) return false;
4753
+ this.db.prepare("UPDATE devices SET platform = ?, model = ?, name = ?, name_source = ? WHERE id = ?").run(platform ?? null, model ?? null, rename ? suggested : row.name, rename ? "detected" : row.name_source ?? "declared", id);
4754
+ this.devicesRevisionCounter += 1;
4755
+ return true;
4756
+ }
4757
+ /**
4758
+ * 用户手工改名。
4759
+ *
4760
+ * 一旦走这条路,`name_source` 就变成 `declared`,此后任何自动探测都**不再**
4761
+ * 覆盖它。这是「用户意图优先于机器推断」的唯一实现点,所以单独一个方法而不是
4762
+ * 塞进 `updateDevice`:权限变更与改名是两件不同的事,审计动作也不该混。
4763
+ */
4764
+ renameDevice(id, name, options = {}) {
4765
+ const trimmed = name.trim().slice(0, 120);
4766
+ if (trimmed === "") throw new ApiError(400, "BODY_INVALID", "name must not be empty.");
4767
+ this.db.exec("BEGIN IMMEDIATE");
4768
+ try {
4769
+ if (this.db.prepare("UPDATE devices SET name = ?, name_source = 'declared' WHERE id = ? AND revoked_at IS NULL").run(trimmed, id).changes === 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
4770
+ this.audit("admin", "loopback-admin", "device.rename", void 0, void 0, {
4771
+ deviceId: id,
4772
+ name: trimmed
4773
+ }, options.requestId);
4774
+ this.db.exec("COMMIT");
4775
+ } catch (error) {
4776
+ this.db.exec("ROLLBACK");
4777
+ throw error;
4778
+ }
4779
+ this.devicesRevisionCounter += 1;
4780
+ const device = this.getActiveDevice(id);
4781
+ if (device === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
4782
+ return device;
4783
+ }
4514
4784
  listDevices() {
4515
4785
  const rows = this.db.prepare("SELECT * FROM devices WHERE revoked_at IS NULL ORDER BY created_at DESC").all();
4516
4786
  const rootQuery = this.db.prepare("SELECT root_id FROM device_roots WHERE device_id = ? ORDER BY root_id");
@@ -4521,7 +4791,8 @@ var WorkspaceDatabase = class {
4521
4791
  rootIds: rootQuery.all(row.id).map((item) => item.root_id),
4522
4792
  createdAt: row.created_at,
4523
4793
  ...row.last_seen_at === null ? {} : { lastSeenAt: row.last_seen_at },
4524
- ...row.revoked_at === null ? {} : { revokedAt: row.revoked_at }
4794
+ ...row.revoked_at === null ? {} : { revokedAt: row.revoked_at },
4795
+ ...identityFields(row)
4525
4796
  }));
4526
4797
  }
4527
4798
  isDeviceActive(id) {
@@ -4805,6 +5076,9 @@ var WorkspaceDatabase = class {
4805
5076
  CREATE INDEX IF NOT EXISTS trash_root_idx ON trash(root_id, created_at DESC);
4806
5077
  `);
4807
5078
  if (!this.hasRootColumn("builtin")) this.db.exec("ALTER TABLE roots ADD COLUMN builtin INTEGER NOT NULL DEFAULT 0");
5079
+ if (!this.hasDeviceColumn("platform")) this.db.exec("ALTER TABLE devices ADD COLUMN platform TEXT");
5080
+ if (!this.hasDeviceColumn("model")) this.db.exec("ALTER TABLE devices ADD COLUMN model TEXT");
5081
+ if (!this.hasDeviceColumn("name_source")) this.db.exec("ALTER TABLE devices ADD COLUMN name_source TEXT");
4808
5082
  if (this.rootsPathIsUnique()) this.dropRootsUniqueConstraint();
4809
5083
  }
4810
5084
  /** `roots.real_path` 上是否还带 UNIQUE 约束。 */
@@ -4847,12 +5121,34 @@ var WorkspaceDatabase = class {
4847
5121
  }
4848
5122
  /** `roots` 表是否已有指定列。 */
4849
5123
  hasRootColumn(column) {
4850
- return this.db.prepare("PRAGMA table_info(roots)").all().some((entry) => entry.name === column);
5124
+ return this.hasColumn("roots", column);
5125
+ }
5126
+ /** `devices` 表是否已有指定列。 */
5127
+ hasDeviceColumn(column) {
5128
+ return this.hasColumn("devices", column);
5129
+ }
5130
+ hasColumn(table, column) {
5131
+ return this.db.prepare(`PRAGMA table_info(${table})`).all().some((entry) => entry.name === column);
4851
5132
  }
4852
5133
  };
4853
5134
  function hashSecret(secret) {
4854
5135
  return createHash("sha256").update(secret, "utf8").digest("hex");
4855
5136
  }
5137
+ /**
5138
+ * 把库里三列身份摊成 `DeviceView` 的可选字段。
5139
+ *
5140
+ * 老库这三列是 NULL —— 此时**整个键都不写**,而不是写 `platform: undefined`:
5141
+ * 前者在 JSON 里不存在(老客户端忽略未知字段,新客户端看到 `undefined`),
5142
+ * 后者会被 `JSON.stringify` 丢掉,两种结果一样,但省略更诚实。
5143
+ */
5144
+ function identityFields(row) {
5145
+ const source = row.name_source;
5146
+ return {
5147
+ ...row.platform === null || row.platform === void 0 ? {} : { platform: row.platform },
5148
+ ...row.model === null || row.model === void 0 ? {} : { model: row.model },
5149
+ ...source === "default" || source === "detected" || source === "declared" ? { nameSource: source } : {}
5150
+ };
5151
+ }
4856
5152
  function normalizePairingCode(code) {
4857
5153
  return code.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
4858
5154
  }
@@ -6388,7 +6684,7 @@ async function togglePlugin(name, enable, input = {}) {
6388
6684
  const profilePath = path.join(resolveDshHome(input), "profiles", profile);
6389
6685
  const manifestPath = path.join(profilePath, "package.json");
6390
6686
  const raw = await readFile(manifestPath, "utf8").catch(() => null);
6391
- if (raw === null) throw new PluginToggleError("MANIFEST_UNREADABLE", `Cannot read ${manifestPath}`);
6687
+ if (raw === null) throw new PluginToggleError("MANIFEST_UNREADABLE", `Cannot read the manifest of profile "${profile}".`);
6392
6688
  let manifest;
6393
6689
  try {
6394
6690
  manifest = JSON.parse(raw);
@@ -6878,12 +7174,18 @@ var ApiRouter = class {
6878
7174
  scopes: [...principal.scopes],
6879
7175
  rootIds: "all"
6880
7176
  });
6881
- else sendJson(res, 200, {
6882
- id: principal.id,
6883
- name: principal.name,
6884
- scopes: [...principal.scopes],
6885
- rootIds: [...principal.rootIds]
6886
- });
7177
+ else {
7178
+ const stored = this.options.database.getActiveDevice(principal.id);
7179
+ sendJson(res, 200, {
7180
+ id: principal.id,
7181
+ name: principal.name,
7182
+ scopes: [...principal.scopes],
7183
+ rootIds: [...principal.rootIds],
7184
+ ...stored?.platform === void 0 ? {} : { platform: stored.platform },
7185
+ ...stored?.model === void 0 ? {} : { model: stored.model },
7186
+ ...stored?.nameSource === void 0 ? {} : { nameSource: stored.nameSource }
7187
+ });
7188
+ }
6887
7189
  return;
6888
7190
  }
6889
7191
  if (method === "GET" && pathname === "/api/v1/roots") {
@@ -6999,10 +7301,11 @@ var ApiRouter = class {
6999
7301
  ...typeof body.apiKey === "string" ? { apiKey: body.apiKey } : {},
7000
7302
  ...Number.isSafeInteger(body.expectedRevision) ? { expectedRevision: body.expectedRevision } : {}
7001
7303
  };
7002
- await this.options.settings.createProvider(input);
7304
+ const created = await this.options.settings.createProvider(input);
7003
7305
  this.audit(principal, "settings.provider.create", void 0, void 0, requestId, {
7004
7306
  providerId: input.id,
7005
- credentialChanged: typeof input.apiKey === "string" && input.apiKey.trim() !== ""
7307
+ credentialChanged: typeof input.apiKey === "string" && input.apiKey.trim() !== "",
7308
+ baseURLHost: created.baseURLHost
7006
7309
  });
7007
7310
  sendJson(res, 201, await this.options.settings.listProviders());
7008
7311
  return;
@@ -7065,11 +7368,12 @@ var ApiRouter = class {
7065
7368
  if (method === "PATCH") {
7066
7369
  this.options.auth.requireScope(principal, "settings.write");
7067
7370
  const body = await readJson(req, 1e6);
7068
- await this.options.settings.updateProvider(providerId, body);
7371
+ const updated = await this.options.settings.updateProvider(providerId, body);
7069
7372
  this.audit(principal, "settings.provider.update", void 0, void 0, requestId, {
7070
7373
  providerId,
7071
7374
  fields: Object.keys(body).filter((field) => field !== "apiKey"),
7072
- credentialChanged: Object.hasOwn(body, "apiKey")
7375
+ credentialChanged: Object.hasOwn(body, "apiKey"),
7376
+ ...updated.baseURLHost === void 0 ? {} : { baseURLHost: updated.baseURLHost }
7073
7377
  });
7074
7378
  sendJson(res, 200, await this.options.settings.listProviders());
7075
7379
  return;
@@ -7094,9 +7398,12 @@ var ApiRouter = class {
7094
7398
  ...typeof body.api === "string" ? { api: body.api } : {},
7095
7399
  ...typeof body.apiKey === "string" ? { apiKey: body.apiKey } : {}
7096
7400
  };
7097
- const models = await this.options.settings.discover(providerId, draft);
7098
- this.audit(principal, "settings.provider.discover", void 0, void 0, requestId, { providerId });
7099
- sendJson(res, 200, { models });
7401
+ const discovered = await this.options.settings.discover(providerId, draft);
7402
+ this.audit(principal, "settings.provider.discover", void 0, void 0, requestId, {
7403
+ providerId,
7404
+ ...discovered.baseURLHost === void 0 ? {} : { baseURLHost: discovered.baseURLHost }
7405
+ });
7406
+ sendJson(res, 200, { models: discovered.models });
7100
7407
  return;
7101
7408
  }
7102
7409
  if (pathname === "/api/v1/chat/sessions") {
@@ -7425,9 +7732,17 @@ var ApiRouter = class {
7425
7732
  const deviceRoute = pathname.match(/^\/manage\/devices\/([^/]+)$/);
7426
7733
  if (deviceRoute !== null && method === "PATCH") {
7427
7734
  const body = await readJson(req, 64e3);
7735
+ const deviceId = decodePathSegment(deviceRoute[1]);
7736
+ if (body.name !== void 0 && typeof body.name !== "string") throw new ApiError(400, "BODY_INVALID", "name must be a string.");
7737
+ if (body.scopes === void 0 && body.rootIds === void 0) {
7738
+ if (body.name === void 0) throw new ApiError(400, "BODY_INVALID", "Either name or the scopes/rootIds pair is required.");
7739
+ sendJson(res, 200, this.options.database.renameDevice(deviceId, body.name, { requestId }));
7740
+ return;
7741
+ }
7428
7742
  const scopes = validateScopes(body.scopes);
7429
7743
  const rootIds = validateStringArray(body.rootIds, "rootIds");
7430
- sendJson(res, 200, this.options.database.updateDevice(decodePathSegment(deviceRoute[1]), scopes, rootIds, { requestId }));
7744
+ if (typeof body.name === "string") this.options.database.renameDevice(deviceId, body.name, { requestId });
7745
+ sendJson(res, 200, this.options.database.updateDevice(deviceId, scopes, rootIds, { requestId }));
7431
7746
  return;
7432
7747
  }
7433
7748
  if (deviceRoute !== null && method === "DELETE") {
@@ -7604,7 +7919,7 @@ var ApiRouter = class {
7604
7919
  const { state, configText } = await this.createTunnel({ configText: true }, requestId, "im-command");
7605
7920
  if (configText === void 0 || state.url === null) throw new Error("隧道已启动,但 12 秒内没拿到公网地址。稍等片刻再发一次 /tunnel 可复用当前地址。");
7606
7921
  const text = typeof configText.text === "string" ? configText.text : "";
7607
- const deviceName = typeof configText.deviceName === "string" ? configText.deviceName : "Android 设备";
7922
+ const deviceName = typeof configText.deviceName === "string" ? configText.deviceName : DEFAULT_DEVICE_NAME;
7608
7923
  return {
7609
7924
  url: state.url,
7610
7925
  text,
@@ -7644,10 +7959,13 @@ var ApiRouter = class {
7644
7959
  if (endpoints.length === 0) throw new ApiError(400, "NO_ENDPOINT", "No usable local address was detected. Provide endpoints explicitly.");
7645
7960
  const scopes = body.scopes === void 0 ? [...DEFAULT_CONFIG_SCOPES] : validateScopes(body.scopes);
7646
7961
  const rootIds = body.rootIds === void 0 ? this.options.database.listRoots().map((root) => root.id) : validateStringArray(body.rootIds, "rootIds");
7647
- const deviceName = readOptionalText(body.deviceName) ?? "Android 设备";
7962
+ const deviceName = readOptionalText(body.deviceName) ?? "Mobile device";
7648
7963
  const displayName = readOptionalText(body.displayName) ?? defaultDisplayName();
7649
7964
  const pairing = this.options.database.createPairing(rootIds, scopes, void 0, { requestId });
7650
- const exchanged = this.options.database.exchangePairing(pairing.code, deviceName, { requestId });
7965
+ const exchanged = this.options.database.exchangePairing(pairing.code, deviceName, {
7966
+ requestId,
7967
+ ...readOptionalText(body.deviceName) === void 0 ? { nameSource: "default" } : { nameSource: "declared" }
7968
+ });
7651
7969
  return {
7652
7970
  text: encodeConfigText({
7653
7971
  displayName,
@@ -7960,16 +8278,11 @@ function validateStringArray(value, name) {
7960
8278
  /**
7961
8279
  * 生成配置文本时的默认设备权限。
7962
8280
  *
7963
- * 刻意**不含** `files.delete` 与 `settings.write`:这两个是破坏性权限,应当是用户显式
7964
- * 要求才授予,而不是因为「生成了一段配置文本」就顺手给了。需要时可在请求里显式传 scopes。
8281
+ * 定义已上移到 `shared/contracts.ts` 的 `DEFAULT_DEVICE_SCOPES`:Web UI 的配对码面板
8282
+ * 原先默认勾满七档(含 `settings.write`),与服务端这里的五档缺省互相矛盾 ——
8283
+ * 同一份默认值散落两处正是它跑偏的原因。
7965
8284
  */
7966
- const DEFAULT_CONFIG_SCOPES = [
7967
- "chat.read",
7968
- "chat.write",
7969
- "files.read",
7970
- "files.write",
7971
- "settings.read"
7972
- ];
8285
+ const DEFAULT_CONFIG_SCOPES = DEFAULT_DEVICE_SCOPES;
7973
8286
  function readOptionalText(value) {
7974
8287
  if (typeof value !== "string") return null;
7975
8288
  const text = value.trim();
@@ -8837,6 +9150,149 @@ function failResponse(res, error) {
8837
9150
  res.end(body);
8838
9151
  }
8839
9152
  //#endregion
9153
+ //#region src/host/provider-url-policy.ts
9154
+ /**
9155
+ * 供应商 `baseURL` 的地址策略(SEC-H11)。
9156
+ *
9157
+ * 为什么需要这一层:`discover` / `createProvider` / `updateProvider` 三个入口都把
9158
+ * 客户端给的 `baseURL` 原样交给内核,内核会**带着该供应商已配置的真实凭据**去请求它。
9159
+ * 攻击者只要持 `settings.write`,就能把 baseURL 指向自己的服务器,收到
9160
+ * `Authorization: Bearer <真实 key>` —— 这是最直接的一次性凭据窃取;同一机制也可
9161
+ * 用作 SSRF 探内网。
9162
+ *
9163
+ * 两条独立的闸门:
9164
+ * 1. **协议白名单**:只放 `http:` / `https:`。`file:`、`ftp:`、`gopher:` 之类没有任何
9165
+ * 正当用途,却是把「一次 HTTP 请求」变成别的语义的经典手法。
9166
+ * 2. **私网地址**:默认拒绝 RFC1918 / 回环 / 链路本地(含云元数据 `169.254.169.254`)。
9167
+ * 本地 LLM(ollama、LM Studio 常见于 `127.0.0.1:11434`)是正当用例,因此由部署方
9168
+ * 用插件配置 `allowPrivateProviderHosts` 显式放行,而不是默认开口子。
9169
+ *
9170
+ * 判定只看**字面量**,不做 DNS 解析:解析后校验会引入 TOCTOU(校验时是公网 IP、
9171
+ * 请求时 DNS 换成内网),反而给出「已校验」的错觉。真正挡住 DNS 重绑定的做法是
9172
+ * 在连接层固定已解析地址,那超出本层职责;这里选择把攻击面收窄到「攻击者必须控制
9173
+ * 一个公网域名」,并让审计记下目标 host 以便事后追查。
9174
+ */
9175
+ /** 私网 / 保留地址判定需要的最小区间,够覆盖本机、家庭网络与云元数据。 */
9176
+ const PRIVATE_V4 = [
9177
+ [0, 16777215],
9178
+ [167772160, 184549375],
9179
+ [1681915904, 1686110207],
9180
+ [2130706432, 2147483647],
9181
+ [2851995648, 2852061183],
9182
+ [2886729728, 2887778303],
9183
+ [3232235520, 3232301055]
9184
+ ];
9185
+ /**
9186
+ * 校验并归一化 `baseURL`,返回可用于审计的 `host[:port]`。
9187
+ *
9188
+ * 抛 400 `PROVIDER_BASE_URL_INVALID`(形状/协议不合法)或
9189
+ * 403 `PROVIDER_BASE_URL_FORBIDDEN`(指向私网且未放行)。两个码分开是刻意的:
9190
+ * 前者是客户端填错,后者是策略拒绝,客户端不该把它们当同一类错误处理。
9191
+ *
9192
+ * 返回值带端口:审计要能回答「凭据被打到哪个地址」,而同一台内网主机上
9193
+ * `:11434`(ollama)与 `:8080`(随便什么)是不同的东西。
9194
+ */
9195
+ function validateProviderBaseURL(raw, options = {}) {
9196
+ const trimmed = raw.trim();
9197
+ if (trimmed === "") throw new ApiError(400, "PROVIDER_BASE_URL_REQUIRED", "baseURL is required.");
9198
+ let url;
9199
+ try {
9200
+ url = new URL(trimmed);
9201
+ } catch {
9202
+ throw new ApiError(400, "PROVIDER_BASE_URL_INVALID", "baseURL must be an absolute URL.");
9203
+ }
9204
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new ApiError(400, "PROVIDER_BASE_URL_INVALID", "baseURL must use http or https.");
9205
+ if (url.hostname === "") throw new ApiError(400, "PROVIDER_BASE_URL_INVALID", "baseURL must include a host.");
9206
+ const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
9207
+ const target = url.port === "" ? host : `${host}:${url.port}`;
9208
+ if (!isPrivateHost(host)) return target;
9209
+ if ((options.allowPrivateHosts ?? []).some((entry) => {
9210
+ const candidate = entry.trim().toLowerCase();
9211
+ if (candidate === "") return false;
9212
+ return candidate.includes(":") ? candidate === target : candidate === host;
9213
+ })) return target;
9214
+ throw new ApiError(403, "PROVIDER_BASE_URL_FORBIDDEN", "baseURL points at a private or loopback address; the deployment must allow it explicitly.", { host: target });
9215
+ }
9216
+ /**
9217
+ * 判断 host 是否落在私网 / 回环 / 链路本地范围。
9218
+ *
9219
+ * 只认 IPv4 点分十进制、IPv6 字面量与 `localhost`。**不解析域名**:一个指向
9220
+ * `127.0.0.1` 的公网域名会从这里漏过去 —— 这是本层刻意的边界,见文件头注释。
9221
+ */
9222
+ function isPrivateHost(host) {
9223
+ const lower = host.toLowerCase();
9224
+ if (lower === "localhost" || lower.endsWith(".localhost")) return true;
9225
+ if (lower.includes(":")) return isPrivateIPv6(lower);
9226
+ const v4 = parseIPv4(lower);
9227
+ if (v4 === void 0) return false;
9228
+ return PRIVATE_V4.some(([from, to]) => v4 >= from && v4 <= to);
9229
+ }
9230
+ function parseIPv4(host) {
9231
+ const parts = host.split(".");
9232
+ if (parts.length !== 4) return void 0;
9233
+ let value = 0;
9234
+ for (const part of parts) {
9235
+ if (!/^\d{1,3}$/.test(part)) return void 0;
9236
+ const octet = Number(part);
9237
+ if (octet > 255) return void 0;
9238
+ value = value * 256 + octet;
9239
+ }
9240
+ return value;
9241
+ }
9242
+ function isPrivateIPv6(host) {
9243
+ if (host === "::1" || host === "::") return true;
9244
+ if (/^f[cd][0-9a-f]{2}:/.test(host)) return true;
9245
+ if (/^fe[89ab][0-9a-f]:/.test(host)) return true;
9246
+ const embedded = embeddedIPv4(host);
9247
+ if (embedded !== void 0) return isPrivateHost(embedded);
9248
+ return false;
9249
+ }
9250
+ /**
9251
+ * 从 IPv6 字面量里取出内嵌的 IPv4 点分形式(仅 `::ffff:0:0/96` 与 `::/96` 两种),
9252
+ * 没有则返回 `undefined`。
9253
+ *
9254
+ * 为什么要自己解析:`new URL()` 会把 `::ffff:127.0.0.1` 归一化成十六进制形式
9255
+ * `::ffff:7f00:1`,所以只匹配点分十进制等于没挡;而标准库没有暴露「前 80 位是否
9256
+ * 为零」的判断,为这一处引依赖不划算。
9257
+ *
9258
+ * 解析失败一律返回 `undefined`(按公网处理),与 `isPrivateHost` 对未知输入的态度一致。
9259
+ */
9260
+ function embeddedIPv4(host) {
9261
+ let normalized = host;
9262
+ const dotted = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(host);
9263
+ if (dotted !== null) {
9264
+ const v4 = parseIPv4(dotted[1]);
9265
+ if (v4 === void 0) return void 0;
9266
+ const hi = (v4 >>> 16 & 65535).toString(16);
9267
+ const lo = (v4 & 65535).toString(16);
9268
+ normalized = `${host.slice(0, dotted.index)}${hi}:${lo}`;
9269
+ }
9270
+ const [head, tail] = normalized.includes("::") ? normalized.split("::", 2) : [normalized, void 0];
9271
+ const headGroups = head === void 0 || head === "" ? [] : head.split(":");
9272
+ const tailGroups = tail === void 0 || tail === "" ? [] : tail.split(":");
9273
+ const missing = 8 - headGroups.length - tailGroups.length;
9274
+ if (tail === void 0 ? headGroups.length !== 8 : missing < 0) return void 0;
9275
+ const groups = [
9276
+ ...headGroups,
9277
+ ...tail === void 0 ? [] : Array(missing).fill("0"),
9278
+ ...tailGroups
9279
+ ];
9280
+ if (groups.length !== 8) return void 0;
9281
+ const parsed = [];
9282
+ for (const group of groups) {
9283
+ if (!/^[0-9a-f]{1,4}$/.test(group)) return void 0;
9284
+ parsed.push(Number.parseInt(group, 16));
9285
+ }
9286
+ if (!(parsed.slice(0, 5).every((value) => value === 0) && (parsed[5] === 65535 || parsed[5] === 0))) return void 0;
9287
+ const v4 = (parsed[6] << 16 | parsed[7]) >>> 0;
9288
+ return [
9289
+ 24,
9290
+ 16,
9291
+ 8,
9292
+ 0
9293
+ ].map((shift) => v4 >>> shift & 255).join(".");
9294
+ }
9295
+ //#endregion
8840
9296
  //#region src/host/settings-adapter.ts
8841
9297
  /**
8842
9298
  * provider 上下文的复用窗口。
@@ -8853,6 +9309,9 @@ var DshSettingsAdapter = class {
8853
9309
  /**
8854
9310
  * @param options.writeNamespaces 额外允许手机端写入的设置命名空间(精确 ns 或
8855
9311
  * `前缀*`),与内置策略(`ui-*` / 以 `locale` 结尾)叠加;默认为空。
9312
+ * @param options.allowPrivateProviderHosts 允许供应商 `baseURL` 指向的私网 / 回环
9313
+ * host 白名单(默认空 = 一律拒绝)。要连本地 LLM 的部署方显式填,理由见
9314
+ * `provider-url-policy.ts` 文件头。
8856
9315
  */
8857
9316
  constructor(api, options = {}) {
8858
9317
  this.api = api;
@@ -8909,6 +9368,10 @@ var DshSettingsAdapter = class {
8909
9368
  get writeNamespaces() {
8910
9369
  return this.options.writeNamespaces ?? [];
8911
9370
  }
9371
+ /** 交给地址策略的选项;集中一处,三个入口不会各配各的。 */
9372
+ get baseURLOptions() {
9373
+ return { allowPrivateHosts: this.options.allowPrivateProviderHosts ?? [] };
9374
+ }
8912
9375
  /** 内核 settings 服务缺失时给 503(而不是把 `TypeError` 漏成 500)。 */
8913
9376
  settingsService() {
8914
9377
  const settings = this.api.settings;
@@ -8996,11 +9459,20 @@ var DshSettingsAdapter = class {
8996
9459
  payload: {}
8997
9460
  }));
8998
9461
  }
9462
+ /**
9463
+ * 更新供应商配置。
9464
+ *
9465
+ * 返回 `baseURLHost`(仅当本次 patch 动了 `baseURL` 时存在)供路由层写审计:
9466
+ * 攻击者可以用 discover 把凭据发到任意地址,事后必须能从审计里看出「打了哪」。
9467
+ * 只记 host 不记完整 URL —— path/query 可能带凭据,审计表不该成为新的泄露点。
9468
+ */
8999
9469
  async updateProvider(providerId, patch) {
9000
9470
  const context = await this.providerContext(providerId);
9001
9471
  if (!context.writable) throw new ApiError(409, "SETTINGS_READ_ONLY", "DSH settings are read-only.");
9002
9472
  if (context.entry.settingsNs === "") throw new ApiError(409, "PROVIDER_NOT_CONFIGURABLE", "This provider has no writable settings section.");
9003
9473
  const ops = [];
9474
+ let baseURLHost;
9475
+ const previousHost = hostOf(effectiveBaseURL(context));
9004
9476
  for (const field of [
9005
9477
  "displayName",
9006
9478
  "baseURL",
@@ -9010,6 +9482,7 @@ var DshSettingsAdapter = class {
9010
9482
  ]) {
9011
9483
  if (!(field in patch)) continue;
9012
9484
  const value = patch[field];
9485
+ if (field === "baseURL" && typeof value === "string" && value.trim() !== "") baseURLHost = validateProviderBaseURL(value, this.baseURLOptions);
9013
9486
  ops.push(value === null || value === "" ? {
9014
9487
  op: "unset",
9015
9488
  path: [...context.entry.settingsPath, field]
@@ -9019,6 +9492,10 @@ var DshSettingsAdapter = class {
9019
9492
  value
9020
9493
  });
9021
9494
  }
9495
+ if (baseURLHost !== void 0 && previousHost !== "" && baseURLHost !== previousHost && !(typeof patch.apiKey === "string" && patch.apiKey.trim() !== "") && await this.providerHoldsCredential(providerId, context)) throw new ApiError(403, "PROVIDER_BASE_URL_REPOINT_FORBIDDEN", "Changing baseURL of a provider that already holds a credential requires supplying a new apiKey.", {
9496
+ previousHost,
9497
+ host: baseURLHost
9498
+ });
9022
9499
  if (patch.models !== void 0) {
9023
9500
  if (patch.models === null) ops.push({
9024
9501
  op: "unset",
@@ -9061,11 +9538,12 @@ var DshSettingsAdapter = class {
9061
9538
  }));
9062
9539
  }
9063
9540
  this.invalidateProviderContext(providerId);
9541
+ return baseURLHost === void 0 ? {} : { baseURLHost };
9064
9542
  }
9065
9543
  async createProvider(input) {
9066
9544
  const id = input.id.trim();
9067
9545
  if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(id)) throw new ApiError(400, "PROVIDER_ID_INVALID", "Provider ids must use lowercase letters, digits, and single hyphens, starting with a letter.");
9068
- if (input.baseURL.trim() === "") throw new ApiError(400, "PROVIDER_BASE_URL_REQUIRED", "baseURL is required.");
9546
+ const baseURLHost = validateProviderBaseURL(input.baseURL, this.baseURLOptions);
9069
9547
  if (input.models.length === 0) throw new ApiError(400, "PROVIDER_MODELS_REQUIRED", "At least one model is required.");
9070
9548
  validateModels(input.models);
9071
9549
  const [directoryResponse, settingsResponse] = await Promise.all([this.api.llm.providers({
@@ -9112,6 +9590,7 @@ var DshSettingsAdapter = class {
9112
9590
  }
9113
9591
  }));
9114
9592
  this.invalidateProviderContext(id);
9593
+ return { baseURLHost };
9115
9594
  }
9116
9595
  async removeProvider(providerId, expectedRevision) {
9117
9596
  const context = await this.providerContext(providerId);
@@ -9130,18 +9609,35 @@ var DshSettingsAdapter = class {
9130
9609
  }));
9131
9610
  this.invalidateProviderContext(providerId);
9132
9611
  }
9612
+ /**
9613
+ * 发现供应商可用模型。
9614
+ *
9615
+ * `draft.baseURL` 是**临时覆盖**(不写配置),但内核会带着该供应商已配置的真实
9616
+ * 凭据去请求它 —— 这正是 SEC-H11 的窃取路径:攻击者把自己的服务器填进来即可收到
9617
+ * `Authorization: Bearer <真实 key>`。因此临时覆盖与持久化写入过同一道地址策略。
9618
+ *
9619
+ * 返回 `baseURLHost` 供路由层审计;不传 `baseURL` 时(用配置里的地址)同样返回,
9620
+ * 这样「打了哪个地址」在任何情况下都能追溯。
9621
+ */
9133
9622
  async discover(providerId, draft) {
9623
+ const context = await this.providerContext(providerId);
9624
+ const override = draft.baseURL === void 0 || draft.baseURL === "" ? void 0 : validateProviderBaseURL(draft.baseURL, this.baseURLOptions);
9134
9625
  const payload = {
9135
- settingsNs: (await this.providerContext(providerId)).entry.settingsNs,
9626
+ settingsNs: context.entry.settingsNs,
9136
9627
  provider: providerId,
9137
9628
  ...draft.baseURL === void 0 || draft.baseURL === "" ? {} : { baseURL: draft.baseURL },
9138
9629
  ...draft.api === void 0 || draft.api === "" ? {} : { api: draft.api },
9139
9630
  ...draft.apiKey === void 0 || draft.apiKey === "" ? {} : { apiKey: draft.apiKey }
9140
9631
  };
9141
- return unwrapDsh(await this.api.llm.discoverModels({
9632
+ const models = unwrapDsh(await this.api.llm.discoverModels({
9142
9633
  rpcId: randomUUID(),
9143
9634
  payload
9144
9635
  })).models;
9636
+ const configured = effectiveBaseURL(context);
9637
+ return {
9638
+ models,
9639
+ ...override !== void 0 ? { baseURLHost: override } : configured === "" ? {} : { baseURLHost: hostOf(configured) }
9640
+ };
9145
9641
  }
9146
9642
  /**
9147
9643
  * 取一个 provider 的上下文(目录条目 + 设置命名空间 + 当前 profile)。
@@ -9200,9 +9696,23 @@ var DshSettingsAdapter = class {
9200
9696
  entry,
9201
9697
  writable: settings.writable,
9202
9698
  namespace,
9203
- profile: recordAt(namespace?.value, entry.settingsPath)
9699
+ profile: recordAt(namespace?.value, entry.settingsPath),
9700
+ baseProfile: recordAt(namespace?.base, entry.settingsPath)
9204
9701
  };
9205
9702
  }
9703
+ /**
9704
+ * 该供应商当前是否已经存了凭据。
9705
+ *
9706
+ * 单独一次 `credentials.describe` 只在「要换 baseURL host」时才调 —— 那是罕见路径,
9707
+ * 而正常保存(改 models / 显示名 / 协议)不该为此多付一次内核往返。
9708
+ */
9709
+ async providerHoldsCredential(providerId, context) {
9710
+ const ref = text(context.profile?.apiKeyEnv) ?? keyRef(providerId);
9711
+ return unwrapDsh(await this.api.credentials.describe({
9712
+ rpcId: randomUUID(),
9713
+ payload: { refs: [ref] }
9714
+ })).credentials[ref]?.configured === true;
9715
+ }
9206
9716
  };
9207
9717
  /**
9208
9718
  * 把内核 `settings.mutate` 的错误翻成契约错误码。
@@ -9299,6 +9809,38 @@ function positiveInteger(value) {
9299
9809
  function keyRef(provider) {
9300
9810
  return provider === "deepseek-official" ? "DEEPSEEK_API_KEY" : `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
9301
9811
  }
9812
+ /**
9813
+ * 取一个供应商**当前生效**的 baseURL。
9814
+ *
9815
+ * 三层回退的顺序是刻意的:
9816
+ * 1. `value`(`SettingsNamespaceView.value`)是内核合并后的生效值,首选;
9817
+ * 2. `base` 是内核给出的基线 —— 用户没覆盖过 baseURL 时,生效地址只在这里;
9818
+ * 3. 适配器目录的 `defaultBaseURL` 是最后兜底(内置供应商走它)。
9819
+ *
9820
+ * 只看第 1 层会把「用基线地址的供应商」误判成「地址未知」,从而让重定向闸门
9821
+ * 对它静默失效 —— 那正是攻击者想要的。
9822
+ */
9823
+ function effectiveBaseURL(context) {
9824
+ const fromValue = text(context.profile?.baseURL);
9825
+ if (fromValue !== void 0) return fromValue;
9826
+ const fromBase = text(context.baseProfile?.baseURL);
9827
+ if (fromBase !== void 0) return fromBase;
9828
+ return text(context.entry.defaultBaseURL) ?? "";
9829
+ }
9830
+ /**
9831
+ * 取 URL 的 `host[:port]` 供审计用;解析失败时返回原串。
9832
+ *
9833
+ * 只用于「配置里的地址」(已经过地址策略或来自适配器目录),所以不需要再校验,
9834
+ * 但也不能因为一个畸形配置让 discover 整体失败 —— 审计字段缺失不该阻断业务。
9835
+ */
9836
+ function hostOf(value) {
9837
+ try {
9838
+ const url = new URL(value);
9839
+ return url.port === "" ? url.hostname.toLowerCase() : `${url.hostname.toLowerCase()}:${url.port}`;
9840
+ } catch {
9841
+ return value;
9842
+ }
9843
+ }
9302
9844
  //#endregion
9303
9845
  //#region src/index.ts
9304
9846
  /**
@@ -9376,7 +9918,10 @@ async function apply(ctx, config = {}) {
9376
9918
  agents: ctx.agents,
9377
9919
  commands: ctx.commands
9378
9920
  });
9379
- const settings = new DshSettingsAdapter(apiProxy, { writeNamespaces: config.settingsWriteNamespaces ?? [] });
9921
+ const settings = new DshSettingsAdapter(apiProxy, {
9922
+ writeNamespaces: config.settingsWriteNamespaces ?? [],
9923
+ allowPrivateProviderHosts: config.allowPrivateProviderHosts ?? []
9924
+ });
9380
9925
  let hostServiceReport;
9381
9926
  try {
9382
9927
  hostServiceReport = probeHostServices(ctx, PLUGIN_VERSION);