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/README.en.md +53 -11
- package/README.md +95 -12
- package/docs/api/asyncapi.yaml +1 -1
- package/docs/api/openapi.yaml +190 -4
- package/kotlin-sdk/src/main/kotlin/ai/deepseek/dsh/workspace/DshWorkspaceClient.kt +6 -0
- package/lib/client.cjs +160 -83
- package/lib/index.d.ts +261 -5
- package/lib/index.js +1184 -92
- 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.
|
|
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",
|
|
@@ -3978,7 +4176,7 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
|
|
|
3978
4176
|
}
|
|
3979
4177
|
};
|
|
3980
4178
|
}
|
|
3981
|
-
if (payloadType === "session/event" && typeof payload.sessionId === "string" && isRecord$
|
|
4179
|
+
if (payloadType === "session/event" && typeof payload.sessionId === "string" && isRecord$2(payload.event)) {
|
|
3982
4180
|
const event = payload.event;
|
|
3983
4181
|
const eventType = typeof event.type === "string" ? event.type : "unknown";
|
|
3984
4182
|
const seq = typeof event.seq === "number" ? event.seq : void 0;
|
|
@@ -3988,9 +4186,9 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
|
|
|
3988
4186
|
...seq === void 0 ? {} : { seq }
|
|
3989
4187
|
};
|
|
3990
4188
|
const assistantData = event.data;
|
|
3991
|
-
if (eventType === "assistant/chunk" && isRecord$
|
|
4189
|
+
if (eventType === "assistant/chunk" && isRecord$2(assistantData)) {
|
|
3992
4190
|
const rawChunk = assistantData.chunk;
|
|
3993
|
-
if (isRecord$
|
|
4191
|
+
if (isRecord$2(rawChunk)) {
|
|
3994
4192
|
const kind = rawChunk.type === "text-delta" ? "text" : rawChunk.type === "reasoning-delta" ? "reasoning" : void 0;
|
|
3995
4193
|
if (kind !== void 0 && typeof rawChunk.text === "string" && typeof rawChunk.index === "number") return {
|
|
3996
4194
|
id: frame.rpcId,
|
|
@@ -4008,7 +4206,7 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
|
|
|
4008
4206
|
}
|
|
4009
4207
|
}
|
|
4010
4208
|
if (eventType === "user/message" || eventType === "assistant/message") {
|
|
4011
|
-
const eventData = isRecord$
|
|
4209
|
+
const eventData = isRecord$2(event.data) ? event.data : {};
|
|
4012
4210
|
return {
|
|
4013
4211
|
id: frame.rpcId,
|
|
4014
4212
|
type: "chat.message.committed",
|
|
@@ -4022,8 +4220,8 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
|
|
|
4022
4220
|
};
|
|
4023
4221
|
}
|
|
4024
4222
|
if (eventType === "deliverables/presented") {
|
|
4025
|
-
const data = isRecord$
|
|
4026
|
-
const files = isRecord$
|
|
4223
|
+
const data = isRecord$2(payload.event) ? payload.event.data : void 0;
|
|
4224
|
+
const files = isRecord$2(data) && Array.isArray(data.files) ? data.files : [];
|
|
4027
4225
|
return {
|
|
4028
4226
|
id: `${payload.sessionId}:deliverables:${seq ?? files.length}`,
|
|
4029
4227
|
type: "chat.deliverables.presented",
|
|
@@ -4051,7 +4249,7 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
|
|
|
4051
4249
|
};
|
|
4052
4250
|
}
|
|
4053
4251
|
}
|
|
4054
|
-
const turnReason = eventType === "turn/end" && isRecord$
|
|
4252
|
+
const turnReason = eventType === "turn/end" && isRecord$2(event.data) ? event.data.reason : void 0;
|
|
4055
4253
|
return {
|
|
4056
4254
|
id: frame.rpcId,
|
|
4057
4255
|
type: eventType === "turn/start" ? "chat.turn.start" : "chat.turn.end",
|
|
@@ -4111,12 +4309,12 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
|
|
|
4111
4309
|
* 用本地化文案「API 密钥无效」顶上。这里保持原样转发,不替客户端猜。
|
|
4112
4310
|
*/
|
|
4113
4311
|
function turnFailureOf(event) {
|
|
4114
|
-
const reason = isRecord$
|
|
4115
|
-
if (!isRecord$
|
|
4312
|
+
const reason = isRecord$2(event.data) ? event.data.reason : void 0;
|
|
4313
|
+
if (!isRecord$2(reason)) return void 0;
|
|
4116
4314
|
const kind = reason.kind;
|
|
4117
4315
|
if (kind === "error") {
|
|
4118
4316
|
const error = reason.error;
|
|
4119
|
-
if (!isRecord$
|
|
4317
|
+
if (!isRecord$2(error)) return { message: String(error ?? "本轮运行失败") };
|
|
4120
4318
|
const code = typeof error.code === "string" ? error.code : void 0;
|
|
4121
4319
|
const message = typeof error.message === "string" ? error.message : JSON.stringify(error);
|
|
4122
4320
|
return code === void 0 ? { message } : {
|
|
@@ -4124,13 +4322,13 @@ function turnFailureOf(event) {
|
|
|
4124
4322
|
code
|
|
4125
4323
|
};
|
|
4126
4324
|
}
|
|
4127
|
-
if (kind === "aborted" && isRecord$
|
|
4325
|
+
if (kind === "aborted" && isRecord$2(reason.reason) && reason.reason.kind === "hook" && reason.reason.reason === "deepseek-account/signed-out") return {
|
|
4128
4326
|
message: "Stopped because you signed out of DeepSeek.",
|
|
4129
4327
|
code: "ACCOUNT_SIGNED_OUT",
|
|
4130
4328
|
stopped: true
|
|
4131
4329
|
};
|
|
4132
4330
|
}
|
|
4133
|
-
function isRecord$
|
|
4331
|
+
function isRecord$2(value) {
|
|
4134
4332
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4135
4333
|
}
|
|
4136
4334
|
function matchRoot(roots, cwd) {
|
|
@@ -4216,13 +4414,13 @@ function findToolCallDetail(events, callId) {
|
|
|
4216
4414
|
if (callId === void 0) return void 0;
|
|
4217
4415
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
4218
4416
|
const candidate = events[index];
|
|
4219
|
-
if (!isRecord$
|
|
4220
|
-
const event = isRecord$
|
|
4221
|
-
if (event.type !== "tool/call" || !isRecord$
|
|
4417
|
+
if (!isRecord$2(candidate)) continue;
|
|
4418
|
+
const event = isRecord$2(candidate.event) ? candidate.event : candidate;
|
|
4419
|
+
if (event.type !== "tool/call" || !isRecord$2(event.data)) continue;
|
|
4222
4420
|
const data = event.data;
|
|
4223
4421
|
if ((typeof data.callId === "string" ? data.callId : typeof data.id === "string" ? data.id : void 0) !== callId) continue;
|
|
4224
4422
|
const args = decodeToolArguments(data.arguments);
|
|
4225
|
-
if (isRecord$
|
|
4423
|
+
if (isRecord$2(args) && typeof args.command === "string") return args.command;
|
|
4226
4424
|
if (typeof args === "string") return args;
|
|
4227
4425
|
if (args !== void 0) return JSON.stringify(args, null, 2);
|
|
4228
4426
|
}
|
|
@@ -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) {
|
|
@@ -4616,9 +4887,62 @@ var WorkspaceDatabase = class {
|
|
|
4616
4887
|
* 干净的办法制造这种行,而它不该走生产代码路径 —— 所以显式开一个测试专用入口,
|
|
4617
4888
|
* 并起一个自见其明的名字,免得有人把它当成正常 API。
|
|
4618
4889
|
*/
|
|
4890
|
+
/**
|
|
4891
|
+
* 仅供测试:把某台设备的「最后使用时间/创建时间」拨到过去。
|
|
4892
|
+
*
|
|
4893
|
+
* 生产路径没法造出「30 天没动过」的设备(认证一次就刷新 `last_seen_at`),而
|
|
4894
|
+
* `pruneInactiveDevices` 的核心语义恰恰只在这种时间跨度上才看得见。与
|
|
4895
|
+
* `markRevokedForTest` 同样的理由:显式开一个自见其明的测试入口,别让人误当 API。
|
|
4896
|
+
*/
|
|
4897
|
+
backdateDeviceForTest(id, options) {
|
|
4898
|
+
if (options.lastSeenAt !== void 0) this.db.prepare("UPDATE devices SET last_seen_at = ? WHERE id = ?").run(options.lastSeenAt, id);
|
|
4899
|
+
if (options.createdAt !== void 0) this.db.prepare("UPDATE devices SET created_at = ? WHERE id = ?").run(options.createdAt, id);
|
|
4900
|
+
}
|
|
4619
4901
|
markRevokedForTest(id) {
|
|
4620
4902
|
this.db.prepare("UPDATE devices SET revoked_at = ? WHERE id = ?").run(Date.now(), id);
|
|
4621
4903
|
}
|
|
4904
|
+
/**
|
|
4905
|
+
* 按「最后使用时间」批量清掉长期没动过的设备。
|
|
4906
|
+
*
|
|
4907
|
+
* 「最后使用」取 `last_seen_at`(每次令牌认证成功都会刷新),从没认证过的设备退回
|
|
4908
|
+
* `created_at` —— 否则一条配好就再没开过的设备会永远躲过清理,而那恰恰是用户最想
|
|
4909
|
+
* 一键清掉的那类(换手机、临时配对、旧测试探针)。
|
|
4910
|
+
*
|
|
4911
|
+
* 与 `purgeRevokedDevices` 的区别:后者清的是历史遗留的「标记式撤销」脏行,这里清的是
|
|
4912
|
+
* **仍然有效、但已经不用了**的设备 —— 用户的原话是「一键清理多少天未使用设备」。
|
|
4913
|
+
*
|
|
4914
|
+
* 语义边界(故意这么定):
|
|
4915
|
+
* - 只删设备行 + 级联的 `device_roots`,不碰审计(审计的 `actor_id` 不是外键);
|
|
4916
|
+
* - **不删本机正在用的那台**(`keepDeviceId`):调用方就是它,删掉自己会让当前
|
|
4917
|
+
* 客户端立刻 401,看起来像「点一下清理把手机踢下线了」;
|
|
4918
|
+
* - 天数被夹在 1..3650,避免 `0` 或负数变成「清空所有设备」;
|
|
4919
|
+
* - 返回真正删掉的条数,0 也是一次合法结果。
|
|
4920
|
+
*/
|
|
4921
|
+
pruneInactiveDevices(olderThanDays, options = {}) {
|
|
4922
|
+
const days = Math.min(3650, Math.max(1, Math.trunc(olderThanDays)));
|
|
4923
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1e3;
|
|
4924
|
+
const rows = this.db.prepare("SELECT id, name FROM devices WHERE COALESCE(last_seen_at, created_at) < ? AND id IS NOT ?").all(cutoff, options.keepDeviceId ?? null);
|
|
4925
|
+
if (rows.length === 0) {
|
|
4926
|
+
this.devicesRevisionCounter += 1;
|
|
4927
|
+
return 0;
|
|
4928
|
+
}
|
|
4929
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
4930
|
+
try {
|
|
4931
|
+
const statement = this.db.prepare("DELETE FROM devices WHERE id = ?");
|
|
4932
|
+
for (const row of rows) statement.run(row.id);
|
|
4933
|
+
this.audit("admin", "loopback-admin", "device.prune", void 0, void 0, {
|
|
4934
|
+
olderThanDays: days,
|
|
4935
|
+
pruned: rows.length,
|
|
4936
|
+
names: rows.map((row) => row.name)
|
|
4937
|
+
}, options.requestId);
|
|
4938
|
+
this.db.exec("COMMIT");
|
|
4939
|
+
} catch (error) {
|
|
4940
|
+
this.db.exec("ROLLBACK");
|
|
4941
|
+
throw error;
|
|
4942
|
+
}
|
|
4943
|
+
this.devicesRevisionCounter += 1;
|
|
4944
|
+
return rows.length;
|
|
4945
|
+
}
|
|
4622
4946
|
purgeRevokedDevices() {
|
|
4623
4947
|
if (this.db.prepare("SELECT id FROM devices WHERE revoked_at IS NOT NULL").all().length === 0) {
|
|
4624
4948
|
this.devicesRevisionCounter += 1;
|
|
@@ -4752,6 +5076,9 @@ var WorkspaceDatabase = class {
|
|
|
4752
5076
|
CREATE INDEX IF NOT EXISTS trash_root_idx ON trash(root_id, created_at DESC);
|
|
4753
5077
|
`);
|
|
4754
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");
|
|
4755
5082
|
if (this.rootsPathIsUnique()) this.dropRootsUniqueConstraint();
|
|
4756
5083
|
}
|
|
4757
5084
|
/** `roots.real_path` 上是否还带 UNIQUE 约束。 */
|
|
@@ -4794,12 +5121,34 @@ var WorkspaceDatabase = class {
|
|
|
4794
5121
|
}
|
|
4795
5122
|
/** `roots` 表是否已有指定列。 */
|
|
4796
5123
|
hasRootColumn(column) {
|
|
4797
|
-
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);
|
|
4798
5132
|
}
|
|
4799
5133
|
};
|
|
4800
5134
|
function hashSecret(secret) {
|
|
4801
5135
|
return createHash("sha256").update(secret, "utf8").digest("hex");
|
|
4802
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
|
+
}
|
|
4803
5152
|
function normalizePairingCode(code) {
|
|
4804
5153
|
return code.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
|
|
4805
5154
|
}
|
|
@@ -5448,6 +5797,176 @@ function probeHostServices(host, pluginVersion) {
|
|
|
5448
5797
|
};
|
|
5449
5798
|
}
|
|
5450
5799
|
//#endregion
|
|
5800
|
+
//#region src/host/im-command.ts
|
|
5801
|
+
/**
|
|
5802
|
+
* `/tunnel` —— 从即时通讯(微信 / 飞书 / 钉钉 / 企业微信 …)里远程开隧道并取回配置文本。
|
|
5803
|
+
*
|
|
5804
|
+
* ## 为什么需要它
|
|
5805
|
+
*
|
|
5806
|
+
* 用户常见的困境是「人已经出门,才发现电脑上的 Cloudflare 隧道没开,配置文本也没发给自己」。
|
|
5807
|
+
* 此时公网入口不存在,WebUI 与 App 都够不着这台机器 —— 唯一的反向通道是 IM 机器人的
|
|
5808
|
+
* **长连接**(手机/IM 云 → 本机主动连出去,不需要任何入站端口)。
|
|
5809
|
+
*
|
|
5810
|
+
* 本模块只做一件事:注册一条 `/tunnel` 命令,把「开隧道 + 生成 DSH1 配置文本」的结果
|
|
5811
|
+
* 直接作为聊天消息回给发送者。**不做停止、不做改配置、不读任何文件** —— 攻击面刻意压到
|
|
5812
|
+
* 「最多被人开一次隧道」,而隧道本身不授予任何数据面权限(数据面仍要设备令牌)。
|
|
5813
|
+
*
|
|
5814
|
+
* ## 门禁在哪里(重要)
|
|
5815
|
+
*
|
|
5816
|
+
* 本命令**自己不做身份校验**,它依赖 IM 插件在派发命令之前执行的访问策略:
|
|
5817
|
+
* `@xmanrui/dsh-im` 的每个机器人都有 `canExecuteCommands` / `defaultCanExecuteCommands`,
|
|
5818
|
+
* 非白名单发送者的 `/xxx` 根本到不了这里(`lib/index.js` 的 access policy 判定)。因此
|
|
5819
|
+
* 部署侧的必要条件是:**机器人保持 allowlist 模式,且只把自己加进白名单**。
|
|
5820
|
+
* 这条约束写在 README「远程开隧道」一节与 SECURITY.md 里,别只在代码注释里说。
|
|
5821
|
+
*
|
|
5822
|
+
* ## 为什么直接调宿主 API 而不是走 HTTP 自调用
|
|
5823
|
+
*
|
|
5824
|
+
* 「已开隧道」这一状态下,`/manage` 管理面被判据 0 整体关门(`auth.ts` 的
|
|
5825
|
+
* `TUNNEL_MODE_ADMIN_DISABLED`),`/manage/config/text` 必然 403 —— 走 HTTP 就取不到
|
|
5826
|
+
* 配置文本。命令在插件进程内执行,与路由共用同一个隧道管理器与同一套建配置逻辑,
|
|
5827
|
+
* 天然绕开这个自锁,且不需要为它额外豁免任何端点。
|
|
5828
|
+
*
|
|
5829
|
+
* @module dsh-remote-bridge/im-command
|
|
5830
|
+
*/
|
|
5831
|
+
/** 命令名(不带斜杠)。 */
|
|
5832
|
+
const TUNNEL_COMMAND_NAME = "tunnel";
|
|
5833
|
+
/** 命令描述:会出现在 IM 的 `/help` 里。 */
|
|
5834
|
+
const TUNNEL_COMMAND_DESCRIPTION = "开启 Cloudflare 隧道并返回一行配置文本(DSH1:…)";
|
|
5835
|
+
/** 把结果拼成聊天消息。 */
|
|
5836
|
+
function formatTunnelReply(result) {
|
|
5837
|
+
return [
|
|
5838
|
+
"云隧道已开启。",
|
|
5839
|
+
`地址:${result.url}`,
|
|
5840
|
+
`设备名:${result.deviceName}`,
|
|
5841
|
+
"配置文本(一行,复制后粘贴到 App 的「导入配置」):",
|
|
5842
|
+
result.text
|
|
5843
|
+
].join("\n");
|
|
5844
|
+
}
|
|
5845
|
+
/**
|
|
5846
|
+
* 创建 `/tunnel` 的命令定义(不注册,纯数据,便于单测)。
|
|
5847
|
+
*
|
|
5848
|
+
* 参数只接受空串:本命令刻意**不接受**任何开关,避免出现「`/tunnel stop`」这种需要
|
|
5849
|
+
* 额外鉴权语义的入口。带参数一律回用法说明。
|
|
5850
|
+
*/
|
|
5851
|
+
function createTunnelCommand(deps) {
|
|
5852
|
+
/** 同一时刻只允许一次开隧道(IM 侧重发、双端同时发都可能撞上)。 */
|
|
5853
|
+
let inFlight = false;
|
|
5854
|
+
return {
|
|
5855
|
+
name: TUNNEL_COMMAND_NAME,
|
|
5856
|
+
description: TUNNEL_COMMAND_DESCRIPTION,
|
|
5857
|
+
input: { hint: "(无需参数)" },
|
|
5858
|
+
async handler(invocation) {
|
|
5859
|
+
if (invocation.rawInput.trim().length > 0) return {
|
|
5860
|
+
kind: "error",
|
|
5861
|
+
text: "用法:/tunnel(不需要参数)"
|
|
5862
|
+
};
|
|
5863
|
+
if (inFlight) return {
|
|
5864
|
+
kind: "error",
|
|
5865
|
+
text: "上一次开隧道还没结束,请稍等几秒再发一次。"
|
|
5866
|
+
};
|
|
5867
|
+
inFlight = true;
|
|
5868
|
+
try {
|
|
5869
|
+
const result = await deps.openTunnel();
|
|
5870
|
+
deps.logger?.info(`[dsh-remote-bridge] /tunnel 已开启隧道 ${result.url}`);
|
|
5871
|
+
return {
|
|
5872
|
+
kind: "success",
|
|
5873
|
+
text: formatTunnelReply(result)
|
|
5874
|
+
};
|
|
5875
|
+
} catch (cause) {
|
|
5876
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
5877
|
+
deps.logger?.warn(cause instanceof Error ? cause : new Error(message));
|
|
5878
|
+
return {
|
|
5879
|
+
kind: "error",
|
|
5880
|
+
text: `开隧道失败:${message}`
|
|
5881
|
+
};
|
|
5882
|
+
} finally {
|
|
5883
|
+
inFlight = false;
|
|
5884
|
+
}
|
|
5885
|
+
}
|
|
5886
|
+
};
|
|
5887
|
+
}
|
|
5888
|
+
/**
|
|
5889
|
+
* 注册 `/tunnel` 并返回注销函数。
|
|
5890
|
+
*
|
|
5891
|
+
* 注册是**全局层**(不是 agent 子层),所以任何 IM 会话、任何工作区里都能用 ——
|
|
5892
|
+
* 这正是本命令的用途:开机时人不在电脑边。是否允许某个人执行,由 IM 插件的
|
|
5893
|
+
* 访问策略决定(见模块头注释)。
|
|
5894
|
+
*/
|
|
5895
|
+
function registerTunnelCommand(registry, deps) {
|
|
5896
|
+
return registry.register(createTunnelCommand(deps));
|
|
5897
|
+
}
|
|
5898
|
+
/**
|
|
5899
|
+
* ## 为什么除命令之外还要一个工具
|
|
5900
|
+
*
|
|
5901
|
+
* `@xmanrui/dsh-im` 5.4.0 的斜杠命令表是**封闭的**:内置 31 条,另外只把
|
|
5902
|
+
* `/compact` `/clear` `/permission` 三条委派给宿主(`lib/index.js` 里
|
|
5903
|
+
* `executeCommand("/compact")` 等固定字面量),**没有**「未知命令转交宿主命令表」的通用派发,
|
|
5904
|
+
* 也从不调用 `commands.list`。所以在 IM 里发 `/tunnel` 到不了上面那个命令处理函数 ——
|
|
5905
|
+
* 它会当成一句普通消息交给智能体。
|
|
5906
|
+
*
|
|
5907
|
+
* 于是 IM 侧真正可用的入口是**工具**:用户用自然语言说一句(「帮我开隧道,把配置发我」),
|
|
5908
|
+
* 智能体调用本工具,结果直接回到聊天里。命令与工具共用同一个 `openTunnel()`,
|
|
5909
|
+
* 语义完全一致;两者的差别只是「谁来触发」。
|
|
5910
|
+
*/
|
|
5911
|
+
/** 工具名(模型可见)。 */
|
|
5912
|
+
const TUNNEL_TOOL_NAME = "open_tunnel_config";
|
|
5913
|
+
/**
|
|
5914
|
+
* 模型可见描述。
|
|
5915
|
+
*
|
|
5916
|
+
* 写英文是惯例(工具描述面向模型,与仓内其他描述同语言),并显式写清
|
|
5917
|
+
* 「用户说开隧道/取配置文本时调用」,否则模型倾向于自己用 bash 拼一条 curl。
|
|
5918
|
+
*/
|
|
5919
|
+
const TUNNEL_TOOL_DESCRIPTION = "Open the Cloudflare quick tunnel for this DSH host and return a one-line DSH1: config text that a phone app can paste to connect. Use it whenever the user asks to start the tunnel, to get the config text, or says they are away from the computer and need remote access. It never stops the tunnel and never touches files.";
|
|
5920
|
+
/** 创建工具定义(不注册,便于单测)。 */
|
|
5921
|
+
function createTunnelTool(deps) {
|
|
5922
|
+
return {
|
|
5923
|
+
name: TUNNEL_TOOL_NAME,
|
|
5924
|
+
description: TUNNEL_TOOL_DESCRIPTION,
|
|
5925
|
+
parameters: {},
|
|
5926
|
+
output: {
|
|
5927
|
+
schema: {
|
|
5928
|
+
type: "object",
|
|
5929
|
+
additionalProperties: false,
|
|
5930
|
+
properties: {
|
|
5931
|
+
url: {
|
|
5932
|
+
type: "string",
|
|
5933
|
+
required: true
|
|
5934
|
+
},
|
|
5935
|
+
text: {
|
|
5936
|
+
type: "string",
|
|
5937
|
+
required: true
|
|
5938
|
+
},
|
|
5939
|
+
deviceName: {
|
|
5940
|
+
type: "string",
|
|
5941
|
+
required: true
|
|
5942
|
+
}
|
|
5943
|
+
},
|
|
5944
|
+
required: true
|
|
5945
|
+
},
|
|
5946
|
+
render: (_args, value) => [{
|
|
5947
|
+
type: "text",
|
|
5948
|
+
text: `Tunnel is up at ${value.url}`
|
|
5949
|
+
}, {
|
|
5950
|
+
type: "text",
|
|
5951
|
+
text: value.text
|
|
5952
|
+
}]
|
|
5953
|
+
},
|
|
5954
|
+
async execute() {
|
|
5955
|
+
const result = await deps.openTunnel();
|
|
5956
|
+
deps.logger?.info(`[dsh-remote-bridge] ${TUNNEL_TOOL_NAME} 已开启隧道 ${result.url}`);
|
|
5957
|
+
return {
|
|
5958
|
+
url: result.url,
|
|
5959
|
+
text: result.text,
|
|
5960
|
+
deviceName: result.deviceName
|
|
5961
|
+
};
|
|
5962
|
+
}
|
|
5963
|
+
};
|
|
5964
|
+
}
|
|
5965
|
+
/** 注册工具并返回注销函数。注册是全局层:任何会话(含 IM 会话)都能调用。 */
|
|
5966
|
+
function registerTunnelTool(registry, deps) {
|
|
5967
|
+
return registry.register(createTunnelTool(deps));
|
|
5968
|
+
}
|
|
5969
|
+
//#endregion
|
|
5451
5970
|
//#region src/host/retention.ts
|
|
5452
5971
|
/**
|
|
5453
5972
|
* 数据保留(retention)调度器:把**会无限增长的两张表**按时间清掉。
|
|
@@ -6165,7 +6684,7 @@ async function togglePlugin(name, enable, input = {}) {
|
|
|
6165
6684
|
const profilePath = path.join(resolveDshHome(input), "profiles", profile);
|
|
6166
6685
|
const manifestPath = path.join(profilePath, "package.json");
|
|
6167
6686
|
const raw = await readFile(manifestPath, "utf8").catch(() => null);
|
|
6168
|
-
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}".`);
|
|
6169
6688
|
let manifest;
|
|
6170
6689
|
try {
|
|
6171
6690
|
manifest = JSON.parse(raw);
|
|
@@ -6308,6 +6827,129 @@ async function inspectResolvedPath(root, wirePath) {
|
|
|
6308
6827
|
};
|
|
6309
6828
|
}
|
|
6310
6829
|
//#endregion
|
|
6830
|
+
//#region src/host/settings-namespace.ts
|
|
6831
|
+
/**
|
|
6832
|
+
* 通用设置命名空间的纯逻辑:写允许策略、密钥脱敏、ops 校验与响应投影。
|
|
6833
|
+
*
|
|
6834
|
+
* 为什么单独成文件(2.6.0):这一层不碰任何内核 RPC,全部可以纯单测;
|
|
6835
|
+
* 而 `settings-adapter.ts` 已贴着 `AGENTS.md` §3.2 的 600 行单文件预算。
|
|
6836
|
+
* 拆出来之后两边都不再挤,adapter 只保留「调内核 + 映射错误」的那几行。
|
|
6837
|
+
*
|
|
6838
|
+
* 契约见 `docs/api/openapi.yaml` 的 SettingsNamespace* 与 `docs/project/API.md`
|
|
6839
|
+
* 的「通用设置命名空间」一节;安全判据的理由见 `docs/project/SECURITY.md`。
|
|
6840
|
+
*/
|
|
6841
|
+
const MAX_OPS = 32;
|
|
6842
|
+
/**
|
|
6843
|
+
* 键名兜底脱敏正则。
|
|
6844
|
+
*
|
|
6845
|
+
* **不是主要机制**:内核 `settings.describe()` 已经用 `redactSecrets: true` 读过一遍,
|
|
6846
|
+
* role('secret') 的字段不会随响应出网(见 dsh-api-settings-controller 的
|
|
6847
|
+
* `namespaceView`/`describe`)。这里是第二道保险 —— 万一某个自定义 schema 漏标了
|
|
6848
|
+
* role('secret'),或者凭据被写进了普通字段,桥也不会把它端给手机。
|
|
6849
|
+
*
|
|
6850
|
+
* `apiKeyEnv` 是环境变量**名**不是密钥值,一并遮蔽是刻意的:名字本身能暴露
|
|
6851
|
+
* 「这台机器用哪家 key」,而 App 读设置根本不需要这个名字。
|
|
6852
|
+
*/
|
|
6853
|
+
const SECRET_KEY = /^(apiKey|secret|token|password|credential|apiKeyEnv)$/i;
|
|
6854
|
+
/**
|
|
6855
|
+
* 写允许策略:`ui-*`(界面类插件设置)与 `*locale`(语言/区域)默认可写。
|
|
6856
|
+
*
|
|
6857
|
+
* 其余一律默认只读,尤其是能改「模型、权限、子代理」的命名空间 —— 手机一旦
|
|
6858
|
+
* 丢了或被恶意 App 拿到令牌,改模型等于把请求引到攻击者的端点,改权限等于
|
|
6859
|
+
* 把审批闸门拆掉。需要放行时由部署者显式配 `settingsWriteNamespaces`。
|
|
6860
|
+
*/
|
|
6861
|
+
function isSettingsNamespaceWriteAllowed(ns, extra = []) {
|
|
6862
|
+
if (ns.startsWith("ui-") || ns.endsWith("locale")) return true;
|
|
6863
|
+
return extra.some((pattern) => pattern.endsWith("*") ? ns.startsWith(pattern.slice(0, -1)) : ns === pattern);
|
|
6864
|
+
}
|
|
6865
|
+
/**
|
|
6866
|
+
* 单个命名空间不可写的原因。
|
|
6867
|
+
*
|
|
6868
|
+
* 宿主设置文档整体只读优先报 `HOST_READONLY`:那与 ns 策略无关,修策略也没用。
|
|
6869
|
+
*/
|
|
6870
|
+
function settingsNamespaceReason(hostWritable, ns, extra = []) {
|
|
6871
|
+
if (!hostWritable) return "HOST_READONLY";
|
|
6872
|
+
if (!isSettingsNamespaceWriteAllowed(ns, extra)) return "HIGH_RISK_NAMESPACE";
|
|
6873
|
+
}
|
|
6874
|
+
/**
|
|
6875
|
+
* 递归遮蔽密钥:键名命中 {@link SECRET_KEY},或路径整体命中内核给出的
|
|
6876
|
+
* `secrets[].path`(前者是兜底,后者才是权威)。
|
|
6877
|
+
*
|
|
6878
|
+
* 路径按 `\u0000` 拼接比较,避免 `['a.b']` 与 `['a','b']` 混淆;
|
|
6879
|
+
* 数组下标也进路径,密钥恰好是数组元素时同样能命中。
|
|
6880
|
+
*/
|
|
6881
|
+
function redactSettingsSecrets(value, secretPaths = []) {
|
|
6882
|
+
const exact = new Set(secretPaths.map((path) => path.join("\0")));
|
|
6883
|
+
return walk(value, []);
|
|
6884
|
+
function walk(current, path) {
|
|
6885
|
+
if (path.length > 0 && exact.has(path.join("\0"))) return "***";
|
|
6886
|
+
if (typeof current === "string") {
|
|
6887
|
+
const key = path.at(-1);
|
|
6888
|
+
return key !== void 0 && SECRET_KEY.test(key) ? "***" : current;
|
|
6889
|
+
}
|
|
6890
|
+
if (Array.isArray(current)) return current.map((item, index) => walk(item, [...path, String(index)]));
|
|
6891
|
+
if (isRecord$1(current)) return Object.fromEntries(Object.entries(current).map(([key, item]) => [key, walk(item, [...path, key])]));
|
|
6892
|
+
return current;
|
|
6893
|
+
}
|
|
6894
|
+
}
|
|
6895
|
+
/** 校验并规范化请求里的 `ops`;结构不合法一律 400 `SETTINGS_OPS_INVALID`。 */
|
|
6896
|
+
function parseSettingsNamespaceOps(input) {
|
|
6897
|
+
if (!Array.isArray(input) || input.length === 0 || input.length > MAX_OPS) throw new ApiError(400, "SETTINGS_OPS_INVALID", `ops must be an array of 1 to ${MAX_OPS} operations.`);
|
|
6898
|
+
return input.map((raw, index) => {
|
|
6899
|
+
const where = `ops[${index}]`;
|
|
6900
|
+
if (!isRecord$1(raw)) throw invalid(`${where} must be an object.`);
|
|
6901
|
+
if (raw.op !== "set" && raw.op !== "unset") throw invalid(`${where}.op must be "set" or "unset".`);
|
|
6902
|
+
if (!Array.isArray(raw.path) || raw.path.some((segment) => typeof segment !== "string")) throw invalid(`${where}.path must be an array of strings.`);
|
|
6903
|
+
const path = [...raw.path];
|
|
6904
|
+
if (raw.op === "unset") return {
|
|
6905
|
+
op: "unset",
|
|
6906
|
+
path
|
|
6907
|
+
};
|
|
6908
|
+
if (!Object.hasOwn(raw, "value")) throw invalid(`${where}.value is required for "set".`);
|
|
6909
|
+
return {
|
|
6910
|
+
op: "set",
|
|
6911
|
+
path,
|
|
6912
|
+
value: raw.value
|
|
6913
|
+
};
|
|
6914
|
+
});
|
|
6915
|
+
}
|
|
6916
|
+
/** 审计用字段清单:只记路径,**绝不记值**。根路径(空数组)不产生字段名。 */
|
|
6917
|
+
function settingsNamespaceOpFields(ops) {
|
|
6918
|
+
return [...new Set(ops.map((op) => op.path.join(".")).filter((field) => field !== ""))];
|
|
6919
|
+
}
|
|
6920
|
+
/** 本次写入是否碰到了密钥位(审计 `credentialChanged`);只看路径,不看值。 */
|
|
6921
|
+
function settingsNamespaceCredentialChanged(ops, secrets) {
|
|
6922
|
+
if (secrets === void 0 || secrets.length === 0) return false;
|
|
6923
|
+
const exact = new Set(secrets.map((secret) => secret.path.join("\0")));
|
|
6924
|
+
return ops.some((op) => exact.has(op.path.join("\0")));
|
|
6925
|
+
}
|
|
6926
|
+
/** 内核视图 → 对外视图:叠加写策略并脱敏。 */
|
|
6927
|
+
function projectSettingsNamespace(view, hostWritable, extra = []) {
|
|
6928
|
+
const writable = hostWritable && isSettingsNamespaceWriteAllowed(view.ns, extra);
|
|
6929
|
+
const secretPaths = (view.secrets ?? []).map((secret) => secret.path);
|
|
6930
|
+
const project = (value) => redactSettingsSecrets(value, secretPaths);
|
|
6931
|
+
const reason = settingsNamespaceReason(hostWritable, view.ns, extra);
|
|
6932
|
+
return {
|
|
6933
|
+
ns: view.ns,
|
|
6934
|
+
revision: view.revision,
|
|
6935
|
+
...view.schema === void 0 ? {} : { schema: view.schema },
|
|
6936
|
+
value: project(view.value),
|
|
6937
|
+
...view.base === void 0 ? {} : { base: project(view.base) },
|
|
6938
|
+
...view.user === void 0 ? {} : { user: project(view.user) },
|
|
6939
|
+
...view.autoGenerate === void 0 ? {} : { autoGenerate: view.autoGenerate },
|
|
6940
|
+
...view.applies === void 0 ? {} : { applies: view.applies },
|
|
6941
|
+
...view.secrets === void 0 ? {} : { secrets: view.secrets },
|
|
6942
|
+
writable,
|
|
6943
|
+
...reason === void 0 ? {} : { reason }
|
|
6944
|
+
};
|
|
6945
|
+
}
|
|
6946
|
+
function invalid(message) {
|
|
6947
|
+
return new ApiError(400, "SETTINGS_OPS_INVALID", message);
|
|
6948
|
+
}
|
|
6949
|
+
function isRecord$1(value) {
|
|
6950
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6951
|
+
}
|
|
6952
|
+
//#endregion
|
|
6311
6953
|
//#region src/host/wire-json.ts
|
|
6312
6954
|
/**
|
|
6313
6955
|
* 出网 JSON 的安全序列化:把**孤立 UTF-16 代理项**换成 U+FFFD。
|
|
@@ -6532,12 +7174,18 @@ var ApiRouter = class {
|
|
|
6532
7174
|
scopes: [...principal.scopes],
|
|
6533
7175
|
rootIds: "all"
|
|
6534
7176
|
});
|
|
6535
|
-
else
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
|
|
6539
|
-
|
|
6540
|
-
|
|
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
|
+
}
|
|
6541
7189
|
return;
|
|
6542
7190
|
}
|
|
6543
7191
|
if (method === "GET" && pathname === "/api/v1/roots") {
|
|
@@ -6653,10 +7301,11 @@ var ApiRouter = class {
|
|
|
6653
7301
|
...typeof body.apiKey === "string" ? { apiKey: body.apiKey } : {},
|
|
6654
7302
|
...Number.isSafeInteger(body.expectedRevision) ? { expectedRevision: body.expectedRevision } : {}
|
|
6655
7303
|
};
|
|
6656
|
-
await this.options.settings.createProvider(input);
|
|
7304
|
+
const created = await this.options.settings.createProvider(input);
|
|
6657
7305
|
this.audit(principal, "settings.provider.create", void 0, void 0, requestId, {
|
|
6658
7306
|
providerId: input.id,
|
|
6659
|
-
credentialChanged: typeof input.apiKey === "string" && input.apiKey.trim() !== ""
|
|
7307
|
+
credentialChanged: typeof input.apiKey === "string" && input.apiKey.trim() !== "",
|
|
7308
|
+
baseURLHost: created.baseURLHost
|
|
6660
7309
|
});
|
|
6661
7310
|
sendJson(res, 201, await this.options.settings.listProviders());
|
|
6662
7311
|
return;
|
|
@@ -6667,6 +7316,27 @@ var ApiRouter = class {
|
|
|
6667
7316
|
sendJson(res, 200, await this.options.settings.catalog());
|
|
6668
7317
|
return;
|
|
6669
7318
|
}
|
|
7319
|
+
if (method === "GET" && pathname === "/api/v1/settings/namespaces") {
|
|
7320
|
+
this.options.auth.requireScope(principal, "settings.read");
|
|
7321
|
+
sendJson(res, 200, await this.options.settings.describeNamespaces());
|
|
7322
|
+
return;
|
|
7323
|
+
}
|
|
7324
|
+
const settingsNamespaceRoute = pathname.match(/^\/api\/v1\/settings\/namespaces\/([^/]+)$/);
|
|
7325
|
+
if (settingsNamespaceRoute !== null && method === "PATCH") {
|
|
7326
|
+
this.options.auth.requireScope(principal, "settings.write");
|
|
7327
|
+
const ns = decodePathSegment(settingsNamespaceRoute[1]);
|
|
7328
|
+
const body = await readJson(req, 256e3);
|
|
7329
|
+
const record = typeof body === "object" && body !== null ? body : {};
|
|
7330
|
+
const ops = parseSettingsNamespaceOps(record.ops);
|
|
7331
|
+
const entry = await this.options.settings.mutateNamespace(ns, ops, settingsExpectedRevision(record.expectedRevision));
|
|
7332
|
+
this.audit(principal, "settings.namespace.mutate", void 0, void 0, requestId, {
|
|
7333
|
+
ns,
|
|
7334
|
+
fields: settingsNamespaceOpFields(ops),
|
|
7335
|
+
credentialChanged: settingsNamespaceCredentialChanged(ops, entry.secrets)
|
|
7336
|
+
});
|
|
7337
|
+
sendJson(res, 200, entry);
|
|
7338
|
+
return;
|
|
7339
|
+
}
|
|
6670
7340
|
if (method === "GET" && pathname === "/api/v1/settings/plugins") {
|
|
6671
7341
|
this.options.auth.requireScope(principal, "settings.read");
|
|
6672
7342
|
const inventory = await readPluginInventory();
|
|
@@ -6698,11 +7368,12 @@ var ApiRouter = class {
|
|
|
6698
7368
|
if (method === "PATCH") {
|
|
6699
7369
|
this.options.auth.requireScope(principal, "settings.write");
|
|
6700
7370
|
const body = await readJson(req, 1e6);
|
|
6701
|
-
await this.options.settings.updateProvider(providerId, body);
|
|
7371
|
+
const updated = await this.options.settings.updateProvider(providerId, body);
|
|
6702
7372
|
this.audit(principal, "settings.provider.update", void 0, void 0, requestId, {
|
|
6703
7373
|
providerId,
|
|
6704
7374
|
fields: Object.keys(body).filter((field) => field !== "apiKey"),
|
|
6705
|
-
credentialChanged: Object.hasOwn(body, "apiKey")
|
|
7375
|
+
credentialChanged: Object.hasOwn(body, "apiKey"),
|
|
7376
|
+
...updated.baseURLHost === void 0 ? {} : { baseURLHost: updated.baseURLHost }
|
|
6706
7377
|
});
|
|
6707
7378
|
sendJson(res, 200, await this.options.settings.listProviders());
|
|
6708
7379
|
return;
|
|
@@ -6727,9 +7398,12 @@ var ApiRouter = class {
|
|
|
6727
7398
|
...typeof body.api === "string" ? { api: body.api } : {},
|
|
6728
7399
|
...typeof body.apiKey === "string" ? { apiKey: body.apiKey } : {}
|
|
6729
7400
|
};
|
|
6730
|
-
const
|
|
6731
|
-
this.audit(principal, "settings.provider.discover", void 0, void 0, requestId, {
|
|
6732
|
-
|
|
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 });
|
|
6733
7407
|
return;
|
|
6734
7408
|
}
|
|
6735
7409
|
if (pathname === "/api/v1/chat/sessions") {
|
|
@@ -7049,12 +7723,26 @@ var ApiRouter = class {
|
|
|
7049
7723
|
sendJson(res, 200, { items: this.options.database.listDevices() });
|
|
7050
7724
|
return;
|
|
7051
7725
|
}
|
|
7726
|
+
if (method === "POST" && pathname === "/manage/devices/prune") {
|
|
7727
|
+
const raw = (await readJson(req, 64e3)).olderThanDays;
|
|
7728
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || !Number.isInteger(raw) || raw < 1 || raw > 3650) throw new ApiError(400, "INVALID_OLDER_THAN_DAYS", "olderThanDays must be an integer between 1 and 3650.");
|
|
7729
|
+
sendJson(res, 200, { pruned: this.options.database.pruneInactiveDevices(raw, { requestId }) });
|
|
7730
|
+
return;
|
|
7731
|
+
}
|
|
7052
7732
|
const deviceRoute = pathname.match(/^\/manage\/devices\/([^/]+)$/);
|
|
7053
7733
|
if (deviceRoute !== null && method === "PATCH") {
|
|
7054
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
|
+
}
|
|
7055
7742
|
const scopes = validateScopes(body.scopes);
|
|
7056
7743
|
const rootIds = validateStringArray(body.rootIds, "rootIds");
|
|
7057
|
-
|
|
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 }));
|
|
7058
7746
|
return;
|
|
7059
7747
|
}
|
|
7060
7748
|
if (deviceRoute !== null && method === "DELETE") {
|
|
@@ -7130,44 +7818,9 @@ var ApiRouter = class {
|
|
|
7130
7818
|
}
|
|
7131
7819
|
if (this.options.tunnels !== void 0 && method === "POST" && pathname === "/manage/cloudflare/create") {
|
|
7132
7820
|
const createBody = await readJson(req, 64e3).catch(() => ({}));
|
|
7133
|
-
const
|
|
7134
|
-
|
|
7135
|
-
let exe;
|
|
7136
|
-
try {
|
|
7137
|
-
exe = await ensureCloudflared(binDir ?? this.options.database.dataDir, (a) => {
|
|
7138
|
-
this.options.tunnels.reportActivity(a);
|
|
7139
|
-
});
|
|
7140
|
-
} catch (cause) {
|
|
7141
|
-
this.options.tunnels.clearActivity();
|
|
7142
|
-
throw new ApiError(502, "TUNNEL_DOWNLOAD_FAILED", "Failed to download or unpack cloudflared. Check the network and retry, or set CLOUDFLARED_MIRROR.", {
|
|
7143
|
-
errorKey: "tunnelErrorDownload",
|
|
7144
|
-
...cause instanceof Error ? { reason: sanitizeDownloadError(cause.message) } : {}
|
|
7145
|
-
});
|
|
7146
|
-
}
|
|
7147
|
-
const port = this.options.remote.status().configuredPort ?? 3090;
|
|
7148
|
-
const state = await this.options.tunnels.startQuick(exe, port);
|
|
7149
|
-
let configText;
|
|
7150
|
-
let resolvedUrl = state.url;
|
|
7151
|
-
if (wantConfig) {
|
|
7152
|
-
const tunnelUrl = state.url ?? await this.options.tunnels.waitForUrl();
|
|
7153
|
-
resolvedUrl = tunnelUrl;
|
|
7154
|
-
if (tunnelUrl !== null) configText = this.buildConfigText({
|
|
7155
|
-
endpoints: [{
|
|
7156
|
-
label: readOptionalText(createBody.endpointLabel) ?? "云隧道",
|
|
7157
|
-
baseUrl: tunnelUrl
|
|
7158
|
-
}],
|
|
7159
|
-
...createBody.rootIds === void 0 ? {} : { rootIds: createBody.rootIds },
|
|
7160
|
-
...createBody.deviceName === void 0 ? {} : { deviceName: createBody.deviceName }
|
|
7161
|
-
}, requestId);
|
|
7162
|
-
}
|
|
7163
|
-
this.options.tunnels.clearActivity();
|
|
7164
|
-
this.options.database.audit("admin", "loopback-admin", "cloudflare.create", void 0, void 0, {}, requestId);
|
|
7165
|
-
const responseState = resolvedUrl === state.url ? state : {
|
|
7821
|
+
const { state, configText } = await this.createTunnel(createBody, requestId, "loopback-admin");
|
|
7822
|
+
sendJson(res, 201, configText === void 0 ? state : {
|
|
7166
7823
|
...state,
|
|
7167
|
-
url: resolvedUrl
|
|
7168
|
-
};
|
|
7169
|
-
sendJson(res, 201, configText === void 0 ? responseState : {
|
|
7170
|
-
...responseState,
|
|
7171
7824
|
configText
|
|
7172
7825
|
});
|
|
7173
7826
|
return;
|
|
@@ -7181,6 +7834,99 @@ var ApiRouter = class {
|
|
|
7181
7834
|
throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
|
|
7182
7835
|
}
|
|
7183
7836
|
/**
|
|
7837
|
+
* 打开隧道并(可选)顺带产出配置文本 —— HTTP `POST /manage/cloudflare/create` 与
|
|
7838
|
+
* IM `/tunnel` 命令**共用**这一条实现。
|
|
7839
|
+
*
|
|
7840
|
+
* 抽出来的原因:两条入口的前半段完全一致(确保 cloudflared → startQuick → 等随机域名 →
|
|
7841
|
+
* 建配置文本),差别只在「谁在调用」——HTTP 侧要先把请求体读干净(流会随时间关闭),
|
|
7842
|
+
* 命令侧没有请求体。复制一份会让「一键出令牌」在两条路径上慢慢漂移。
|
|
7843
|
+
*
|
|
7844
|
+
* @param createBody - 已解析的请求体(HTTP 侧)或 `{ configText: true }`(命令侧)。
|
|
7845
|
+
* @param requestId - 审计与配对记录共用的请求 ID。
|
|
7846
|
+
* @param actorLabel - 审计里的操作者标签:`loopback-admin`(本机管理面)或 `im-command`。
|
|
7847
|
+
* @returns 合并了「等到地址」的 state,以及(wantConfig 且拿到地址时)配置文本。
|
|
7848
|
+
*/
|
|
7849
|
+
async createTunnel(createBody, requestId, actorLabel) {
|
|
7850
|
+
const wantConfig = createBody.configText === true;
|
|
7851
|
+
const binDir = this.options.cloudflaredBinDir;
|
|
7852
|
+
let exe;
|
|
7853
|
+
try {
|
|
7854
|
+
exe = await ensureCloudflared(binDir ?? this.options.database.dataDir, (a) => {
|
|
7855
|
+
this.options.tunnels.reportActivity(a);
|
|
7856
|
+
});
|
|
7857
|
+
} catch (cause) {
|
|
7858
|
+
this.options.tunnels.clearActivity();
|
|
7859
|
+
throw new ApiError(502, "TUNNEL_DOWNLOAD_FAILED", "Failed to download or unpack cloudflared. Check the network and retry, or set CLOUDFLARED_MIRROR.", {
|
|
7860
|
+
errorKey: "tunnelErrorDownload",
|
|
7861
|
+
...cause instanceof Error ? { reason: sanitizeDownloadError(cause.message) } : {}
|
|
7862
|
+
});
|
|
7863
|
+
}
|
|
7864
|
+
const port = this.options.remote.status().configuredPort ?? 3090;
|
|
7865
|
+
const state = await this.options.tunnels.startQuick(exe, port);
|
|
7866
|
+
let configText;
|
|
7867
|
+
let resolvedUrl = state.url;
|
|
7868
|
+
if (wantConfig) {
|
|
7869
|
+
const tunnelUrl = state.url ?? await this.options.tunnels.waitForUrl();
|
|
7870
|
+
resolvedUrl = tunnelUrl;
|
|
7871
|
+
if (tunnelUrl !== null) configText = this.buildConfigText({
|
|
7872
|
+
endpoints: [{
|
|
7873
|
+
label: readOptionalText(createBody.endpointLabel) ?? "云隧道",
|
|
7874
|
+
baseUrl: tunnelUrl
|
|
7875
|
+
}],
|
|
7876
|
+
...createBody.rootIds === void 0 ? {} : { rootIds: createBody.rootIds },
|
|
7877
|
+
...createBody.deviceName === void 0 ? {} : { deviceName: createBody.deviceName }
|
|
7878
|
+
}, requestId);
|
|
7879
|
+
}
|
|
7880
|
+
this.options.tunnels.clearActivity();
|
|
7881
|
+
this.options.database.audit("admin", actorLabel, "cloudflare.create", void 0, void 0, {}, requestId);
|
|
7882
|
+
return {
|
|
7883
|
+
state: resolvedUrl === state.url ? state : {
|
|
7884
|
+
...state,
|
|
7885
|
+
url: resolvedUrl
|
|
7886
|
+
},
|
|
7887
|
+
...configText === void 0 ? {} : { configText }
|
|
7888
|
+
};
|
|
7889
|
+
}
|
|
7890
|
+
/**
|
|
7891
|
+
* IM `/tunnel` 命令的宿主侧入口:开隧道并返回一行可粘贴的配置文本。
|
|
7892
|
+
*
|
|
7893
|
+
* 与 HTTP 路径的两点差别:
|
|
7894
|
+
*
|
|
7895
|
+
* 1. **隧道已在运行时直接复用当前地址**,不重复 spawn(第二次 `startQuick` 会撞
|
|
7896
|
+
* `TUNNEL_ALREADY_RUNNING`,而用户在 IM 里连发两条是很常见的)。
|
|
7897
|
+
* 2. 配置文本的 endpoint 只用隧道地址,设备名缺省由 `buildConfigText` 决定 —— 命令
|
|
7898
|
+
* 不接受参数,所以没有 `endpointLabel` / `rootIds` 的透传面。
|
|
7899
|
+
*
|
|
7900
|
+
* @throws 当 12 秒内没拿到公网地址,或 cloudflared 下载失败时。
|
|
7901
|
+
*/
|
|
7902
|
+
async openTunnelForCommand() {
|
|
7903
|
+
const tunnels = this.options.tunnels;
|
|
7904
|
+
if (tunnels === void 0) throw new Error("隧道功能未启用。");
|
|
7905
|
+
const requestId = randomUUID();
|
|
7906
|
+
const current = tunnels.publicState;
|
|
7907
|
+
if (current.running === true && current.url !== null) {
|
|
7908
|
+
const reused = this.buildConfigText({ endpoints: [{
|
|
7909
|
+
label: "云隧道",
|
|
7910
|
+
baseUrl: current.url
|
|
7911
|
+
}] }, requestId);
|
|
7912
|
+
this.options.database.audit("admin", "im-command", "cloudflare.create", void 0, void 0, { reused: true }, requestId);
|
|
7913
|
+
return {
|
|
7914
|
+
url: current.url,
|
|
7915
|
+
text: reused.text,
|
|
7916
|
+
deviceName: reused.deviceName
|
|
7917
|
+
};
|
|
7918
|
+
}
|
|
7919
|
+
const { state, configText } = await this.createTunnel({ configText: true }, requestId, "im-command");
|
|
7920
|
+
if (configText === void 0 || state.url === null) throw new Error("隧道已启动,但 12 秒内没拿到公网地址。稍等片刻再发一次 /tunnel 可复用当前地址。");
|
|
7921
|
+
const text = typeof configText.text === "string" ? configText.text : "";
|
|
7922
|
+
const deviceName = typeof configText.deviceName === "string" ? configText.deviceName : DEFAULT_DEVICE_NAME;
|
|
7923
|
+
return {
|
|
7924
|
+
url: state.url,
|
|
7925
|
+
text,
|
|
7926
|
+
deviceName
|
|
7927
|
+
};
|
|
7928
|
+
}
|
|
7929
|
+
/**
|
|
7184
7930
|
* 隧道状态视图。
|
|
7185
7931
|
*
|
|
7186
7932
|
* **这里不做脱敏,因为用不上**:判据 1 已经把 Cloudflare 边缘头(`Cf-Connecting-IP` /
|
|
@@ -7213,10 +7959,13 @@ var ApiRouter = class {
|
|
|
7213
7959
|
if (endpoints.length === 0) throw new ApiError(400, "NO_ENDPOINT", "No usable local address was detected. Provide endpoints explicitly.");
|
|
7214
7960
|
const scopes = body.scopes === void 0 ? [...DEFAULT_CONFIG_SCOPES] : validateScopes(body.scopes);
|
|
7215
7961
|
const rootIds = body.rootIds === void 0 ? this.options.database.listRoots().map((root) => root.id) : validateStringArray(body.rootIds, "rootIds");
|
|
7216
|
-
const deviceName = readOptionalText(body.deviceName) ?? "
|
|
7962
|
+
const deviceName = readOptionalText(body.deviceName) ?? "Mobile device";
|
|
7217
7963
|
const displayName = readOptionalText(body.displayName) ?? defaultDisplayName();
|
|
7218
7964
|
const pairing = this.options.database.createPairing(rootIds, scopes, void 0, { requestId });
|
|
7219
|
-
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
|
+
});
|
|
7220
7969
|
return {
|
|
7221
7970
|
text: encodeConfigText({
|
|
7222
7971
|
displayName,
|
|
@@ -7529,16 +8278,11 @@ function validateStringArray(value, name) {
|
|
|
7529
8278
|
/**
|
|
7530
8279
|
* 生成配置文本时的默认设备权限。
|
|
7531
8280
|
*
|
|
7532
|
-
*
|
|
7533
|
-
*
|
|
8281
|
+
* 定义已上移到 `shared/contracts.ts` 的 `DEFAULT_DEVICE_SCOPES`:Web UI 的配对码面板
|
|
8282
|
+
* 原先默认勾满七档(含 `settings.write`),与服务端这里的五档缺省互相矛盾 ——
|
|
8283
|
+
* 同一份默认值散落两处正是它跑偏的原因。
|
|
7534
8284
|
*/
|
|
7535
|
-
const DEFAULT_CONFIG_SCOPES =
|
|
7536
|
-
"chat.read",
|
|
7537
|
-
"chat.write",
|
|
7538
|
-
"files.read",
|
|
7539
|
-
"files.write",
|
|
7540
|
-
"settings.read"
|
|
7541
|
-
];
|
|
8285
|
+
const DEFAULT_CONFIG_SCOPES = DEFAULT_DEVICE_SCOPES;
|
|
7542
8286
|
function readOptionalText(value) {
|
|
7543
8287
|
if (typeof value !== "string") return null;
|
|
7544
8288
|
const text = value.trim();
|
|
@@ -7572,6 +8316,17 @@ function optionalInteger(value) {
|
|
|
7572
8316
|
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new ApiError(400, "NUMBER_INVALID", "A query parameter must be a non-negative integer.");
|
|
7573
8317
|
return parsed;
|
|
7574
8318
|
}
|
|
8319
|
+
/**
|
|
8320
|
+
* 请求体里的 `expectedRevision`。
|
|
8321
|
+
*
|
|
8322
|
+
* 不复用 `optionalInteger()`:它读的是查询串(字符串)且错误码是 `NUMBER_INVALID`;
|
|
8323
|
+
* 这里是 JSON 数字,非法时属于「ops 请求体不合法」,契约要求 `SETTINGS_OPS_INVALID`。
|
|
8324
|
+
*/
|
|
8325
|
+
function settingsExpectedRevision(value) {
|
|
8326
|
+
if (value === void 0) return void 0;
|
|
8327
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new ApiError(400, "SETTINGS_OPS_INVALID", "expectedRevision must be a non-negative integer.");
|
|
8328
|
+
return value;
|
|
8329
|
+
}
|
|
7575
8330
|
function commandName(line) {
|
|
7576
8331
|
return /^\/([a-z][a-z0-9_-]*)/u.exec(line)?.[1] ?? "unknown";
|
|
7577
8332
|
}
|
|
@@ -8395,6 +9150,149 @@ function failResponse(res, error) {
|
|
|
8395
9150
|
res.end(body);
|
|
8396
9151
|
}
|
|
8397
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
|
|
8398
9296
|
//#region src/host/settings-adapter.ts
|
|
8399
9297
|
/**
|
|
8400
9298
|
* provider 上下文的复用窗口。
|
|
@@ -8407,8 +9305,78 @@ const PROVIDER_CONTEXT_TTL_MS = 1e3;
|
|
|
8407
9305
|
const PROVIDER_CONTEXT_CACHE_LIMIT = 128;
|
|
8408
9306
|
var DshSettingsAdapter = class {
|
|
8409
9307
|
api;
|
|
8410
|
-
|
|
9308
|
+
options;
|
|
9309
|
+
/**
|
|
9310
|
+
* @param options.writeNamespaces 额外允许手机端写入的设置命名空间(精确 ns 或
|
|
9311
|
+
* `前缀*`),与内置策略(`ui-*` / 以 `locale` 结尾)叠加;默认为空。
|
|
9312
|
+
* @param options.allowPrivateProviderHosts 允许供应商 `baseURL` 指向的私网 / 回环
|
|
9313
|
+
* host 白名单(默认空 = 一律拒绝)。要连本地 LLM 的部署方显式填,理由见
|
|
9314
|
+
* `provider-url-policy.ts` 文件头。
|
|
9315
|
+
*/
|
|
9316
|
+
constructor(api, options = {}) {
|
|
8411
9317
|
this.api = api;
|
|
9318
|
+
this.options = options;
|
|
9319
|
+
}
|
|
9320
|
+
/**
|
|
9321
|
+
* 列出全部设置命名空间(通用设置面的读侧)。
|
|
9322
|
+
*
|
|
9323
|
+
* 与 `listProviders()` 的区别:它不理解任何 provider 语义,原样透出内核按插件
|
|
9324
|
+
* 划分的命名空间,由手机端按 `schema` 自己长表单 —— 新装插件不必等 App 发版。
|
|
9325
|
+
*/
|
|
9326
|
+
async describeNamespaces() {
|
|
9327
|
+
const described = unwrapDsh(await this.settingsService().describe({
|
|
9328
|
+
rpcId: randomUUID(),
|
|
9329
|
+
payload: {}
|
|
9330
|
+
}));
|
|
9331
|
+
return {
|
|
9332
|
+
writable: described.writable,
|
|
9333
|
+
namespaces: described.namespaces.map((view) => projectSettingsNamespace(view, described.writable, this.writeNamespaces))
|
|
9334
|
+
};
|
|
9335
|
+
}
|
|
9336
|
+
/**
|
|
9337
|
+
* 按路径改一个命名空间(通用设置面的写侧)。
|
|
9338
|
+
*
|
|
9339
|
+
* 校验顺序是刻意的:ns 策略 → ops 结构 → 内核 describe(host 可写性)→ mutate。
|
|
9340
|
+
* 先验策略既省一次内核往返,也保证「高危命名空间」无论 ops 怎么错都先得到 403。
|
|
9341
|
+
*/
|
|
9342
|
+
async mutateNamespace(ns, ops, expectedRevision) {
|
|
9343
|
+
if (!isSettingsNamespaceWriteAllowed(ns, this.writeNamespaces)) throw new ApiError(403, "SETTINGS_NAMESPACE_READONLY", `The settings namespace "${ns}" is not writable from this bridge.`, {
|
|
9344
|
+
ns,
|
|
9345
|
+
reason: "HIGH_RISK_NAMESPACE"
|
|
9346
|
+
});
|
|
9347
|
+
const normalized = parseSettingsNamespaceOps(ops);
|
|
9348
|
+
const described = unwrapDsh(await this.settingsService().describe({
|
|
9349
|
+
rpcId: randomUUID(),
|
|
9350
|
+
payload: {}
|
|
9351
|
+
}));
|
|
9352
|
+
if (!described.writable) throw new ApiError(409, "SETTINGS_READ_ONLY", "DSH settings are read-only.");
|
|
9353
|
+
let mutated;
|
|
9354
|
+
try {
|
|
9355
|
+
mutated = unwrapDsh(await this.settingsService().mutate({
|
|
9356
|
+
rpcId: randomUUID(),
|
|
9357
|
+
payload: {
|
|
9358
|
+
ns,
|
|
9359
|
+
ops: normalized,
|
|
9360
|
+
...expectedRevision === void 0 ? {} : { expectedRevision }
|
|
9361
|
+
}
|
|
9362
|
+
}));
|
|
9363
|
+
} catch (error) {
|
|
9364
|
+
throw mapSettingsMutationError(error);
|
|
9365
|
+
}
|
|
9366
|
+
return projectSettingsNamespace(mutated, described.writable, this.writeNamespaces);
|
|
9367
|
+
}
|
|
9368
|
+
get writeNamespaces() {
|
|
9369
|
+
return this.options.writeNamespaces ?? [];
|
|
9370
|
+
}
|
|
9371
|
+
/** 交给地址策略的选项;集中一处,三个入口不会各配各的。 */
|
|
9372
|
+
get baseURLOptions() {
|
|
9373
|
+
return { allowPrivateHosts: this.options.allowPrivateProviderHosts ?? [] };
|
|
9374
|
+
}
|
|
9375
|
+
/** 内核 settings 服务缺失时给 503(而不是把 `TypeError` 漏成 500)。 */
|
|
9376
|
+
settingsService() {
|
|
9377
|
+
const settings = this.api.settings;
|
|
9378
|
+
if (settings === void 0) throw new ApiError(503, "SETTINGS_UNAVAILABLE", "The DSH settings service is not available.");
|
|
9379
|
+
return settings;
|
|
8412
9380
|
}
|
|
8413
9381
|
async listProviders() {
|
|
8414
9382
|
const [directoryResponse, settingsResponse, catalogResponse] = await Promise.all([
|
|
@@ -8491,11 +9459,20 @@ var DshSettingsAdapter = class {
|
|
|
8491
9459
|
payload: {}
|
|
8492
9460
|
}));
|
|
8493
9461
|
}
|
|
9462
|
+
/**
|
|
9463
|
+
* 更新供应商配置。
|
|
9464
|
+
*
|
|
9465
|
+
* 返回 `baseURLHost`(仅当本次 patch 动了 `baseURL` 时存在)供路由层写审计:
|
|
9466
|
+
* 攻击者可以用 discover 把凭据发到任意地址,事后必须能从审计里看出「打了哪」。
|
|
9467
|
+
* 只记 host 不记完整 URL —— path/query 可能带凭据,审计表不该成为新的泄露点。
|
|
9468
|
+
*/
|
|
8494
9469
|
async updateProvider(providerId, patch) {
|
|
8495
9470
|
const context = await this.providerContext(providerId);
|
|
8496
9471
|
if (!context.writable) throw new ApiError(409, "SETTINGS_READ_ONLY", "DSH settings are read-only.");
|
|
8497
9472
|
if (context.entry.settingsNs === "") throw new ApiError(409, "PROVIDER_NOT_CONFIGURABLE", "This provider has no writable settings section.");
|
|
8498
9473
|
const ops = [];
|
|
9474
|
+
let baseURLHost;
|
|
9475
|
+
const previousHost = hostOf(effectiveBaseURL(context));
|
|
8499
9476
|
for (const field of [
|
|
8500
9477
|
"displayName",
|
|
8501
9478
|
"baseURL",
|
|
@@ -8505,6 +9482,7 @@ var DshSettingsAdapter = class {
|
|
|
8505
9482
|
]) {
|
|
8506
9483
|
if (!(field in patch)) continue;
|
|
8507
9484
|
const value = patch[field];
|
|
9485
|
+
if (field === "baseURL" && typeof value === "string" && value.trim() !== "") baseURLHost = validateProviderBaseURL(value, this.baseURLOptions);
|
|
8508
9486
|
ops.push(value === null || value === "" ? {
|
|
8509
9487
|
op: "unset",
|
|
8510
9488
|
path: [...context.entry.settingsPath, field]
|
|
@@ -8514,6 +9492,10 @@ var DshSettingsAdapter = class {
|
|
|
8514
9492
|
value
|
|
8515
9493
|
});
|
|
8516
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
|
+
});
|
|
8517
9499
|
if (patch.models !== void 0) {
|
|
8518
9500
|
if (patch.models === null) ops.push({
|
|
8519
9501
|
op: "unset",
|
|
@@ -8556,11 +9538,12 @@ var DshSettingsAdapter = class {
|
|
|
8556
9538
|
}));
|
|
8557
9539
|
}
|
|
8558
9540
|
this.invalidateProviderContext(providerId);
|
|
9541
|
+
return baseURLHost === void 0 ? {} : { baseURLHost };
|
|
8559
9542
|
}
|
|
8560
9543
|
async createProvider(input) {
|
|
8561
9544
|
const id = input.id.trim();
|
|
8562
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.");
|
|
8563
|
-
|
|
9546
|
+
const baseURLHost = validateProviderBaseURL(input.baseURL, this.baseURLOptions);
|
|
8564
9547
|
if (input.models.length === 0) throw new ApiError(400, "PROVIDER_MODELS_REQUIRED", "At least one model is required.");
|
|
8565
9548
|
validateModels(input.models);
|
|
8566
9549
|
const [directoryResponse, settingsResponse] = await Promise.all([this.api.llm.providers({
|
|
@@ -8607,6 +9590,7 @@ var DshSettingsAdapter = class {
|
|
|
8607
9590
|
}
|
|
8608
9591
|
}));
|
|
8609
9592
|
this.invalidateProviderContext(id);
|
|
9593
|
+
return { baseURLHost };
|
|
8610
9594
|
}
|
|
8611
9595
|
async removeProvider(providerId, expectedRevision) {
|
|
8612
9596
|
const context = await this.providerContext(providerId);
|
|
@@ -8625,18 +9609,35 @@ var DshSettingsAdapter = class {
|
|
|
8625
9609
|
}));
|
|
8626
9610
|
this.invalidateProviderContext(providerId);
|
|
8627
9611
|
}
|
|
9612
|
+
/**
|
|
9613
|
+
* 发现供应商可用模型。
|
|
9614
|
+
*
|
|
9615
|
+
* `draft.baseURL` 是**临时覆盖**(不写配置),但内核会带着该供应商已配置的真实
|
|
9616
|
+
* 凭据去请求它 —— 这正是 SEC-H11 的窃取路径:攻击者把自己的服务器填进来即可收到
|
|
9617
|
+
* `Authorization: Bearer <真实 key>`。因此临时覆盖与持久化写入过同一道地址策略。
|
|
9618
|
+
*
|
|
9619
|
+
* 返回 `baseURLHost` 供路由层审计;不传 `baseURL` 时(用配置里的地址)同样返回,
|
|
9620
|
+
* 这样「打了哪个地址」在任何情况下都能追溯。
|
|
9621
|
+
*/
|
|
8628
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);
|
|
8629
9625
|
const payload = {
|
|
8630
|
-
settingsNs:
|
|
9626
|
+
settingsNs: context.entry.settingsNs,
|
|
8631
9627
|
provider: providerId,
|
|
8632
9628
|
...draft.baseURL === void 0 || draft.baseURL === "" ? {} : { baseURL: draft.baseURL },
|
|
8633
9629
|
...draft.api === void 0 || draft.api === "" ? {} : { api: draft.api },
|
|
8634
9630
|
...draft.apiKey === void 0 || draft.apiKey === "" ? {} : { apiKey: draft.apiKey }
|
|
8635
9631
|
};
|
|
8636
|
-
|
|
9632
|
+
const models = unwrapDsh(await this.api.llm.discoverModels({
|
|
8637
9633
|
rpcId: randomUUID(),
|
|
8638
9634
|
payload
|
|
8639
9635
|
})).models;
|
|
9636
|
+
const configured = effectiveBaseURL(context);
|
|
9637
|
+
return {
|
|
9638
|
+
models,
|
|
9639
|
+
...override !== void 0 ? { baseURLHost: override } : configured === "" ? {} : { baseURLHost: hostOf(configured) }
|
|
9640
|
+
};
|
|
8640
9641
|
}
|
|
8641
9642
|
/**
|
|
8642
9643
|
* 取一个 provider 的上下文(目录条目 + 设置命名空间 + 当前 profile)。
|
|
@@ -8695,10 +9696,38 @@ var DshSettingsAdapter = class {
|
|
|
8695
9696
|
entry,
|
|
8696
9697
|
writable: settings.writable,
|
|
8697
9698
|
namespace,
|
|
8698
|
-
profile: recordAt(namespace?.value, entry.settingsPath)
|
|
9699
|
+
profile: recordAt(namespace?.value, entry.settingsPath),
|
|
9700
|
+
baseProfile: recordAt(namespace?.base, entry.settingsPath)
|
|
8699
9701
|
};
|
|
8700
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
|
+
}
|
|
8701
9716
|
};
|
|
9717
|
+
/**
|
|
9718
|
+
* 把内核 `settings.mutate` 的错误翻成契约错误码。
|
|
9719
|
+
*
|
|
9720
|
+
* `unwrapDsh()` 的通用映射只认 `busy` / `locked` 为 409,所以内核的
|
|
9721
|
+
* `settings/conflict` 会落到 400 `DSH_SETTINGS_CONFLICT` —— 这里按契约改回
|
|
9722
|
+
* 409 `SETTINGS_CONFLICT`(客户端据此提示「刷新后重试」而不是「参数不对」)。
|
|
9723
|
+
* `settings/rejected` 是 ops 在 schema 上不合法,落回 400 `SETTINGS_OPS_INVALID`。
|
|
9724
|
+
*/
|
|
9725
|
+
function mapSettingsMutationError(error) {
|
|
9726
|
+
if (!(error instanceof ApiError)) return error;
|
|
9727
|
+
if (error.code.includes("SETTINGS") && error.code.includes("CONFLICT")) return new ApiError(409, "SETTINGS_CONFLICT", "Settings changed since they were read; refresh and retry.", error.details);
|
|
9728
|
+
if (error.code.includes("SETTINGS") && error.code.includes("REJECTED")) return new ApiError(400, "SETTINGS_OPS_INVALID", error.message, error.details);
|
|
9729
|
+
return error;
|
|
9730
|
+
}
|
|
8702
9731
|
function projectConfig(profile, inheritedModels) {
|
|
8703
9732
|
const baseURL = text(profile?.baseURL);
|
|
8704
9733
|
const api = text(profile?.api);
|
|
@@ -8780,6 +9809,38 @@ function positiveInteger(value) {
|
|
|
8780
9809
|
function keyRef(provider) {
|
|
8781
9810
|
return provider === "deepseek-official" ? "DEEPSEEK_API_KEY" : `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
|
|
8782
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
|
+
}
|
|
8783
9844
|
//#endregion
|
|
8784
9845
|
//#region src/index.ts
|
|
8785
9846
|
/**
|
|
@@ -8857,7 +9918,10 @@ async function apply(ctx, config = {}) {
|
|
|
8857
9918
|
agents: ctx.agents,
|
|
8858
9919
|
commands: ctx.commands
|
|
8859
9920
|
});
|
|
8860
|
-
const settings = new DshSettingsAdapter(apiProxy
|
|
9921
|
+
const settings = new DshSettingsAdapter(apiProxy, {
|
|
9922
|
+
writeNamespaces: config.settingsWriteNamespaces ?? [],
|
|
9923
|
+
allowPrivateProviderHosts: config.allowPrivateProviderHosts ?? []
|
|
9924
|
+
});
|
|
8861
9925
|
let hostServiceReport;
|
|
8862
9926
|
try {
|
|
8863
9927
|
hostServiceReport = probeHostServices(ctx, PLUGIN_VERSION);
|
|
@@ -8908,6 +9972,34 @@ async function apply(ctx, config = {}) {
|
|
|
8908
9972
|
path: IMAGE_ROUTE,
|
|
8909
9973
|
handler: imageRoute
|
|
8910
9974
|
}]);
|
|
9975
|
+
try {
|
|
9976
|
+
ctx.effect(() => registerTunnelCommand(ctx.commands, {
|
|
9977
|
+
openTunnel: () => router.openTunnelForCommand(),
|
|
9978
|
+
logger: {
|
|
9979
|
+
info: (message) => ctx.logger.info(message),
|
|
9980
|
+
warn: (error) => ctx.logger.warn(error)
|
|
9981
|
+
}
|
|
9982
|
+
}), "dsh-remote-bridge /tunnel command");
|
|
9983
|
+
} catch (error) {
|
|
9984
|
+
ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: /tunnel command registration failed (ignored): ${String(error)}`));
|
|
9985
|
+
}
|
|
9986
|
+
let toolsService;
|
|
9987
|
+
try {
|
|
9988
|
+
toolsService = reflect?.get?.("tools", false);
|
|
9989
|
+
} catch {
|
|
9990
|
+
toolsService = void 0;
|
|
9991
|
+
}
|
|
9992
|
+
if (toolsService !== void 0) try {
|
|
9993
|
+
ctx.effect(() => registerTunnelTool(toolsService, {
|
|
9994
|
+
openTunnel: () => router.openTunnelForCommand(),
|
|
9995
|
+
logger: {
|
|
9996
|
+
info: (message) => ctx.logger.info(message),
|
|
9997
|
+
warn: (error) => ctx.logger.warn(error)
|
|
9998
|
+
}
|
|
9999
|
+
}), "dsh-remote-bridge open_tunnel_config tool");
|
|
10000
|
+
} catch (error) {
|
|
10001
|
+
ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: open_tunnel_config registration failed (ignored): ${String(error)}`));
|
|
10002
|
+
}
|
|
8911
10003
|
ctx.effect(() => async () => {
|
|
8912
10004
|
disposeEmbedded();
|
|
8913
10005
|
await remote.stop();
|