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/README.en.md +52 -11
- package/README.md +47 -10
- package/docs/api/asyncapi.yaml +1 -1
- package/docs/api/openapi.yaml +51 -4
- package/kotlin-sdk/src/main/kotlin/ai/deepseek/dsh/workspace/DshWorkspaceClient.kt +6 -0
- package/lib/client.cjs +103 -30
- package/lib/index.d.ts +123 -3
- package/lib/index.js +584 -39
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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.
|
|
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 ${
|
|
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
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
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
|
|
7098
|
-
this.audit(principal, "settings.provider.discover", void 0, void 0, requestId, {
|
|
7099
|
-
|
|
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
|
-
|
|
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 :
|
|
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) ?? "
|
|
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, {
|
|
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
|
-
*
|
|
7964
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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, {
|
|
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);
|