dsh-remote-bridge 2.6.0 → 2.7.0
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 +68 -12
- package/README.md +59 -11
- package/docs/api/asyncapi.yaml +1 -1
- package/docs/api/openapi.yaml +61 -4
- package/kotlin-sdk/src/main/kotlin/ai/deepseek/dsh/workspace/DshWorkspaceClient.kt +15 -1
- package/lib/client.cjs +758 -124
- package/lib/index.d.ts +196 -14
- package/lib/index.js +814 -46
- 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。
|
|
@@ -2551,6 +2748,92 @@ function readArchivedSessionIds(registry) {
|
|
|
2551
2748
|
return value.filter((item) => typeof item === "string");
|
|
2552
2749
|
}
|
|
2553
2750
|
/**
|
|
2751
|
+
* 容量元数据的缓存寿命。
|
|
2752
|
+
*
|
|
2753
|
+
* 与内核 `dsh-llm` 的 `DEFAULT_MODELS_MAX_AGE_MS = 600000` 对齐:那边是模型
|
|
2754
|
+
* **列表**的新鲜度,这边是**单个模型容量**的新鲜度,同一批配置变动(改
|
|
2755
|
+
* baseURL、换 API key、升级内核)会同时让两者失效,跟齐一个数量级最不容易
|
|
2756
|
+
* 出现「列表变了容量没变」的错配。
|
|
2757
|
+
*/
|
|
2758
|
+
const MODEL_CAPACITY_TTL_MS = 6e5;
|
|
2759
|
+
/** 缓存条目上限;只是防内存无界,正常部署里 provider×model 远小于这个数。 */
|
|
2760
|
+
const MODEL_CAPACITY_CACHE_LIMIT = 512;
|
|
2761
|
+
const modelCapacityCache = /* @__PURE__ */ new Map();
|
|
2762
|
+
/**
|
|
2763
|
+
* 把一个模型条目的容量字段补齐。
|
|
2764
|
+
*
|
|
2765
|
+
* **为什么不能直接从 `modelCatalog()` 拿**:0.2.x 的 `ModelCatalogModel` 只有
|
|
2766
|
+
* `{ id, name, description?, reasoning? }` —— 目录里没有 `contextWindow` /
|
|
2767
|
+
* `maxTokens` / `inputModalities`。这三个只在 `LlmResolvedModelInfo` 里,得走
|
|
2768
|
+
* `llm.resolveModelInfo(provider, model)`。目录条目自带容量是 1.x 的旧形状。
|
|
2769
|
+
*
|
|
2770
|
+
* 失败一律退回「不填」:模型选择器少显示一行容量,比整张目录报错好得多。
|
|
2771
|
+
* 注意**不缓存失败**——一次瞬时 RPC 抖动不该让某个模型十分钟没有容量。
|
|
2772
|
+
*/
|
|
2773
|
+
async function modelCapacityOf(host, provider, model, signal) {
|
|
2774
|
+
const key = `${provider}\u0000${model}`;
|
|
2775
|
+
const cached = modelCapacityCache.get(key);
|
|
2776
|
+
if (cached !== void 0 && Date.now() - cached.at < MODEL_CAPACITY_TTL_MS) return cached.capacity;
|
|
2777
|
+
let capacity = {};
|
|
2778
|
+
try {
|
|
2779
|
+
const info = await host.llm.resolveModelInfo(provider, model, signal);
|
|
2780
|
+
const contextWindow = info?.context?.contextWindow;
|
|
2781
|
+
const maxTokens = info?.defaultMaxTokens;
|
|
2782
|
+
capacity = {
|
|
2783
|
+
...typeof contextWindow === "number" && contextWindow > 0 ? { contextWindow } : {},
|
|
2784
|
+
...typeof maxTokens === "number" && maxTokens > 0 ? { maxTokens } : {}
|
|
2785
|
+
};
|
|
2786
|
+
} catch {
|
|
2787
|
+
return {};
|
|
2788
|
+
}
|
|
2789
|
+
if (modelCapacityCache.size >= MODEL_CAPACITY_CACHE_LIMIT) {
|
|
2790
|
+
const oldest = modelCapacityCache.keys().next();
|
|
2791
|
+
if (oldest.done !== true) modelCapacityCache.delete(oldest.value);
|
|
2792
|
+
}
|
|
2793
|
+
modelCapacityCache.set(key, {
|
|
2794
|
+
at: Date.now(),
|
|
2795
|
+
capacity
|
|
2796
|
+
});
|
|
2797
|
+
return capacity;
|
|
2798
|
+
}
|
|
2799
|
+
/**
|
|
2800
|
+
* 给一张 provider group 列表补容量字段。
|
|
2801
|
+
*
|
|
2802
|
+
* 并发查而不是串行:目录动辄几十个模型,串行 RPC 会把模型选择器拖成几秒空白。
|
|
2803
|
+
* 单个模型失败不影响其他模型(`modelCapacityOf` 内部吞掉)。
|
|
2804
|
+
*
|
|
2805
|
+
* 对**形状**宽容:`groups` 是 `unknown`(内核给什么形状就是什么形状,我们只
|
|
2806
|
+
* 投影自己认识的字段),认不出来的条目原样透传,保证向前兼容。
|
|
2807
|
+
*/
|
|
2808
|
+
async function enrichModelGroups(host, groups, signal) {
|
|
2809
|
+
return Promise.all(groups.map(async (group) => {
|
|
2810
|
+
if (typeof group !== "object" || group === null) return group;
|
|
2811
|
+
const view = group;
|
|
2812
|
+
const providerId = typeof view.id === "string" ? view.id : void 0;
|
|
2813
|
+
if (providerId === void 0 || !Array.isArray(view.models)) return group;
|
|
2814
|
+
const models = await Promise.all(view.models.map(async (model) => {
|
|
2815
|
+
if (typeof model !== "object" || model === null) return model;
|
|
2816
|
+
const entry = model;
|
|
2817
|
+
const modelId = typeof entry.id === "string" ? entry.id : void 0;
|
|
2818
|
+
if (modelId === void 0) return model;
|
|
2819
|
+
const capacity = await modelCapacityOf(host, providerId, modelId, signal);
|
|
2820
|
+
const filled = {
|
|
2821
|
+
...entry.contextWindow === void 0 && capacity.contextWindow !== void 0 ? { contextWindow: capacity.contextWindow } : {},
|
|
2822
|
+
...entry.maxTokens === void 0 && capacity.maxTokens !== void 0 ? { maxTokens: capacity.maxTokens } : {}
|
|
2823
|
+
};
|
|
2824
|
+
if (filled.contextWindow === void 0 && filled.maxTokens === void 0) return model;
|
|
2825
|
+
return {
|
|
2826
|
+
...model,
|
|
2827
|
+
...filled
|
|
2828
|
+
};
|
|
2829
|
+
}));
|
|
2830
|
+
return {
|
|
2831
|
+
...group,
|
|
2832
|
+
models
|
|
2833
|
+
};
|
|
2834
|
+
}));
|
|
2835
|
+
}
|
|
2836
|
+
/**
|
|
2554
2837
|
* Build the `apiProxy` replacement out of 0.2.x host services.
|
|
2555
2838
|
*
|
|
2556
2839
|
* Every method keeps the old `{ rpcId, payload }` request shape and the old
|
|
@@ -2745,10 +3028,12 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2745
3028
|
} catch {}
|
|
2746
3029
|
const catalog = await host.sessionController.modelCatalog();
|
|
2747
3030
|
const selection = (await host.sessionController.projections({ sessionId }, signal))?.values?.["modelSelection"];
|
|
3031
|
+
const current = selection?.next ?? selection?.lastUsed ?? catalog.default;
|
|
3032
|
+
const groups = Array.isArray(catalog.groups) ? catalog.groups : [];
|
|
2748
3033
|
return {
|
|
2749
|
-
current
|
|
3034
|
+
current,
|
|
2750
3035
|
routable: Array.isArray(catalog.routableProviders) && catalog.routableProviders.length > 0,
|
|
2751
|
-
groups:
|
|
3036
|
+
groups: await enrichModelGroups(host, groups, signal),
|
|
2752
3037
|
failures: Array.isArray(catalog.failures) ? catalog.failures.map((entry) => ({
|
|
2753
3038
|
provider: typeof entry.id === "string" ? entry.id : String(entry.name ?? ""),
|
|
2754
3039
|
message: typeof entry.message === "string" ? entry.message : ""
|
|
@@ -2909,7 +3194,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2909
3194
|
models: () => call("llm.models", async (signal) => {
|
|
2910
3195
|
const view = await host.sessionController.modelCatalog();
|
|
2911
3196
|
return {
|
|
2912
|
-
groups: Array.isArray(view.groups) ? view.groups : [],
|
|
3197
|
+
groups: await enrichModelGroups(host, Array.isArray(view.groups) ? view.groups : [], signal),
|
|
2913
3198
|
failures: Array.isArray(view.failures) ? view.failures : []
|
|
2914
3199
|
};
|
|
2915
3200
|
}),
|
|
@@ -2926,7 +3211,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2926
3211
|
}
|
|
2927
3212
|
//#endregion
|
|
2928
3213
|
//#region src/shared/version.ts
|
|
2929
|
-
const PLUGIN_VERSION = "2.
|
|
3214
|
+
const PLUGIN_VERSION = "2.7.0";
|
|
2930
3215
|
//#endregion
|
|
2931
3216
|
//#region src/host/capabilities.ts
|
|
2932
3217
|
/**
|
|
@@ -3000,6 +3285,7 @@ const BRIDGE_CAPABILITIES = [
|
|
|
3000
3285
|
"model-catalog-v1",
|
|
3001
3286
|
"plugin-inventory-v1",
|
|
3002
3287
|
"plugin-toggle-v1",
|
|
3288
|
+
"settings-namespaces-v1",
|
|
3003
3289
|
"approvals-v1",
|
|
3004
3290
|
"questions-v1",
|
|
3005
3291
|
"turn-failure-v1",
|
|
@@ -3440,9 +3726,10 @@ var DshChatAdapter = class {
|
|
|
3440
3726
|
}
|
|
3441
3727
|
async createSessionInWorkspace(principal, workspaceId, options) {
|
|
3442
3728
|
if ((await this.listWorkspaces(principal)).find((item) => item.id === workspaceId) === void 0) throw new ApiError(404, "WORKSPACE_NOT_FOUND", "The DSH workspace is not visible to this device.");
|
|
3729
|
+
const sessionId = options.sessionId ?? (options.reuseBlank === false ? void 0 : await this.blankSessionInWorkspace(workspaceId, options.agentPreset));
|
|
3443
3730
|
const payload = {
|
|
3444
3731
|
workspaceId,
|
|
3445
|
-
...
|
|
3732
|
+
...sessionId === void 0 ? {} : { sessionId },
|
|
3446
3733
|
...options.agentPreset === void 0 ? {} : { agentPreset: options.agentPreset }
|
|
3447
3734
|
};
|
|
3448
3735
|
const value = unwrapDsh(await this.api.sessions.create({
|
|
@@ -3455,9 +3742,11 @@ var DshChatAdapter = class {
|
|
|
3455
3742
|
};
|
|
3456
3743
|
}
|
|
3457
3744
|
async createSession(principal, rootId, relativePath, options) {
|
|
3745
|
+
const resolved = await resolveAuthorizedPath(this.requireAuthorizedRoot(principal, rootId).realPath, relativePath);
|
|
3746
|
+
const sessionId = options.sessionId ?? (options.reuseBlank === false ? void 0 : await this.blankSessionAtPath(resolved.absolutePath, options.agentPreset));
|
|
3458
3747
|
const payload = {
|
|
3459
|
-
cwd:
|
|
3460
|
-
...
|
|
3748
|
+
cwd: resolved.absolutePath,
|
|
3749
|
+
...sessionId === void 0 ? {} : { sessionId },
|
|
3461
3750
|
...options.agentPreset === void 0 ? {} : { agentPreset: options.agentPreset }
|
|
3462
3751
|
};
|
|
3463
3752
|
const value = unwrapDsh(await this.api.sessions.create({
|
|
@@ -3469,6 +3758,69 @@ var DshChatAdapter = class {
|
|
|
3469
3758
|
...value.agentPreset === void 0 ? {} : { agentPreset: value.agentPreset }
|
|
3470
3759
|
};
|
|
3471
3760
|
}
|
|
3761
|
+
/**
|
|
3762
|
+
* 挑一个可复用的空白会话 —— DSH 0.2.1 起桌面端的「New Session 回到空白会话」语义。
|
|
3763
|
+
*
|
|
3764
|
+
* **内核自己不做这件事。** `sessionController.create` 只有调用方显式传了 `sessionId`
|
|
3765
|
+
* 才会走 `claim` / `reuseLive` 去复用;不传就无条件 `createMinted` 新建一个。
|
|
3766
|
+
* 「跳过 preset 不匹配的空白会话」那层挑选写在**客户端**(`dsh-client-ui-workspace`
|
|
3767
|
+
* 的 `connectWorkspace` → `createInWorkspace`)里,而桥走 REST 直连内核,没有这一层 ——
|
|
3768
|
+
* 于是手机端每点一次「新会话」就多一个空白会话,攒成一串永远没人用的空壳。
|
|
3769
|
+
*
|
|
3770
|
+
* 这里把客户端那条判据原样搬过来(`blank && cwd 匹配 && 未归档 && preset 匹配`),
|
|
3771
|
+
* 取用顺序也照抄:**按内核返回的列表顺序取第一个**,不自己按时间排序 —— 同一份数据
|
|
3772
|
+
* 在桌面与手机上应当落到同一条会话上,桥没有理由发明第三种顺序。
|
|
3773
|
+
*
|
|
3774
|
+
* preset 的口径也与内核一致:空白会话**没声明** preset 时对任何预设都算可用
|
|
3775
|
+
* (只有 `typeof listed === 'string'` 才比较)。「没声明」不等于「声明了别的」——
|
|
3776
|
+
* 把前者当成不匹配会让所有老会话都挑不中。
|
|
3777
|
+
*/
|
|
3778
|
+
reusableBlankId(sessions, archived, agentPreset, matches) {
|
|
3779
|
+
for (const session of sessions) {
|
|
3780
|
+
if (!session.blank || archived.has(session.sessionId) || !matches(session)) continue;
|
|
3781
|
+
const listed = session.agentPreset ?? session.projections?.values["agentPreset"];
|
|
3782
|
+
if (agentPreset !== void 0 && typeof listed === "string" && listed !== agentPreset) continue;
|
|
3783
|
+
return session.sessionId;
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
/**
|
|
3787
|
+
* 工作区维度上找一个可复用的空白会话。
|
|
3788
|
+
*
|
|
3789
|
+
* 归属判据用**内核自己的两个字段**:`session.cwd === workspace.path` 且
|
|
3790
|
+
* `workspace.sessionIds` 含它 —— 与客户端 `createInWorkspace` 逐字同义。
|
|
3791
|
+
* 这里重新拉一次原始 workspace 列表(而不是复用 `listWorkspaces` 的投影结果),
|
|
3792
|
+
* 因为 `ChatWorkspaceView` 刻意不含 `sessionIds` 与绝对路径。
|
|
3793
|
+
*/
|
|
3794
|
+
async blankSessionInWorkspace(workspaceId, agentPreset) {
|
|
3795
|
+
const [sessionsResponse, workspaceResponse] = await Promise.all([this.api.sessions.list({
|
|
3796
|
+
rpcId: randomUUID(),
|
|
3797
|
+
payload: {}
|
|
3798
|
+
}), this.api.workspace.list({
|
|
3799
|
+
rpcId: randomUUID(),
|
|
3800
|
+
payload: {}
|
|
3801
|
+
})]);
|
|
3802
|
+
const workspaceValue = unwrapDsh(workspaceResponse);
|
|
3803
|
+
const summary = workspaceValue.items.find((item) => item.workspaceId === workspaceId);
|
|
3804
|
+
if (summary === void 0) return void 0;
|
|
3805
|
+
return this.reusableBlankId(unwrapDsh(sessionsResponse).items, new Set(workspaceValue.archivedSessionIds), agentPreset, (session) => session.cwd === summary.path && summary.sessionIds.includes(session.sessionId));
|
|
3806
|
+
}
|
|
3807
|
+
/**
|
|
3808
|
+
* 绝对路径维度上找一个可复用的空白会话(老客户端的 `rootId` + `path` 入口)。
|
|
3809
|
+
*
|
|
3810
|
+
* 这里没有 workspace 摘要可用,判据就只剩「同一 cwd」—— 与客户端按 workspace
|
|
3811
|
+
* 挑选相比少了两道(`sessionIds` 归属、workspace 存在性),但那两道在
|
|
3812
|
+
* `rootId` + `path` 这条路上本来就由授权根解析替代了。
|
|
3813
|
+
*/
|
|
3814
|
+
async blankSessionAtPath(cwd, agentPreset) {
|
|
3815
|
+
const [sessionsResponse, workspaceResponse] = await Promise.all([this.api.sessions.list({
|
|
3816
|
+
rpcId: randomUUID(),
|
|
3817
|
+
payload: {}
|
|
3818
|
+
}), this.api.workspace.list({
|
|
3819
|
+
rpcId: randomUUID(),
|
|
3820
|
+
payload: {}
|
|
3821
|
+
})]);
|
|
3822
|
+
return this.reusableBlankId(unwrapDsh(sessionsResponse).items, new Set(unwrapDsh(workspaceResponse).archivedSessionIds), agentPreset, (session) => session.cwd === cwd);
|
|
3823
|
+
}
|
|
3472
3824
|
async history(principal, sessionId, options) {
|
|
3473
3825
|
await this.requireSession(principal, sessionId);
|
|
3474
3826
|
const payload = {
|
|
@@ -4467,7 +4819,8 @@ var WorkspaceDatabase = class {
|
|
|
4467
4819
|
const scopes = parseScopes(row.scopes_json);
|
|
4468
4820
|
const rootIds = JSON.parse(row.roots_json);
|
|
4469
4821
|
this.db.prepare("UPDATE pairings SET used_at = ? WHERE code_hash = ?").run(now, row.code_hash);
|
|
4470
|
-
|
|
4822
|
+
const nameSource = options.nameSource ?? "declared";
|
|
4823
|
+
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
4824
|
const grant = this.db.prepare("INSERT INTO device_roots(device_id,root_id) VALUES(?,?)");
|
|
4472
4825
|
for (const rootId of rootIds) grant.run(id, rootId);
|
|
4473
4826
|
this.audit("device", id, "pairing.exchange", void 0, void 0, { name }, options.requestId);
|
|
@@ -4480,7 +4833,8 @@ var WorkspaceDatabase = class {
|
|
|
4480
4833
|
name,
|
|
4481
4834
|
scopes,
|
|
4482
4835
|
rootIds,
|
|
4483
|
-
createdAt: now
|
|
4836
|
+
createdAt: now,
|
|
4837
|
+
nameSource
|
|
4484
4838
|
}
|
|
4485
4839
|
};
|
|
4486
4840
|
} catch (error) {
|
|
@@ -4508,9 +4862,79 @@ var WorkspaceDatabase = class {
|
|
|
4508
4862
|
scopes: parseScopes(row.scopes_json),
|
|
4509
4863
|
rootIds: rootRows.map((item) => item.root_id),
|
|
4510
4864
|
createdAt: row.created_at,
|
|
4511
|
-
...touchLastSeen ? { lastSeenAt: now } : row.last_seen_at === null ? {} : { lastSeenAt: row.last_seen_at }
|
|
4865
|
+
...touchLastSeen ? { lastSeenAt: now } : row.last_seen_at === null ? {} : { lastSeenAt: row.last_seen_at },
|
|
4866
|
+
...identityFields(row)
|
|
4512
4867
|
};
|
|
4513
4868
|
}
|
|
4869
|
+
/**
|
|
4870
|
+
* 记录一次探测到的设备身份,必要时顺手把占位名改成「平台 · 型号」。
|
|
4871
|
+
*
|
|
4872
|
+
* 时序说明:配对时桥只知道「有个设备要连」,真正的平台与型号要等设备
|
|
4873
|
+
* **第一次发请求**才拿得到。所以这个方法由认证路径调用(见 `auth.ts`
|
|
4874
|
+
* 的 `principal`),而不是配对路径。
|
|
4875
|
+
*
|
|
4876
|
+
* ## 什么时候改名
|
|
4877
|
+
*
|
|
4878
|
+
* 只有 `name_source` 是 `default`(配对时的占位名)或 `detected`(上次自动
|
|
4879
|
+
* 生成的)才改名。`declared`(用户手工改的)**永不动** —— 用户起的名比机器
|
|
4880
|
+
* 猜的准,而且他能改一次就能改第二次,但机器每次连接都覆盖就再也留不住了。
|
|
4881
|
+
*
|
|
4882
|
+
* 老库的 `name_source` 是 NULL:那些行里的名字多半是硬编码的「Android 设备」,
|
|
4883
|
+
* 但**也可能是用户手工改过的**(此前没有任何改名通道,所以实际上只有占位名;
|
|
4884
|
+
* 不过万一有人直接改过库,我们不能替他决定)。保守起见把 NULL 当作
|
|
4885
|
+
* `declared` 处理 —— 名字不动,只补 platform/model 两列。
|
|
4886
|
+
*
|
|
4887
|
+
* ## 为什么不做成「每次请求都写库」
|
|
4888
|
+
*
|
|
4889
|
+
* 调用方只在**探测结果与库里不同**时才调这里(见 `auth.ts`),所以正常情况
|
|
4890
|
+
* 下一台设备一生只写一次。这不是性能考虑,而是避免把一次读请求变成写请求:
|
|
4891
|
+
* 写请求会碰 WAL、会影响 `devicesRevisionCounter`,进而触发全体客户端重拉设备表。
|
|
4892
|
+
*
|
|
4893
|
+
* @returns 是否真的改了库(调用方据此决定要不要递增设备版本号)。
|
|
4894
|
+
*/
|
|
4895
|
+
recordDeviceIdentity(id, identity) {
|
|
4896
|
+
const row = this.db.prepare("SELECT platform, model, name_source, name FROM devices WHERE id = ? AND revoked_at IS NULL").get(id);
|
|
4897
|
+
if (row === void 0) return false;
|
|
4898
|
+
const platform = identity.platform ?? row.platform;
|
|
4899
|
+
const model = identity.model ?? row.model;
|
|
4900
|
+
const source = row.name_source ?? "declared";
|
|
4901
|
+
const mayRename = source === "default" || source === "detected";
|
|
4902
|
+
const suggested = identity.suggestedName;
|
|
4903
|
+
const rename = mayRename && suggested !== void 0 && suggested !== row.name;
|
|
4904
|
+
const platformChanged = platform !== row.platform;
|
|
4905
|
+
const modelChanged = model !== row.model;
|
|
4906
|
+
if (!platformChanged && !modelChanged && !rename) return false;
|
|
4907
|
+
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);
|
|
4908
|
+
this.devicesRevisionCounter += 1;
|
|
4909
|
+
return true;
|
|
4910
|
+
}
|
|
4911
|
+
/**
|
|
4912
|
+
* 用户手工改名。
|
|
4913
|
+
*
|
|
4914
|
+
* 一旦走这条路,`name_source` 就变成 `declared`,此后任何自动探测都**不再**
|
|
4915
|
+
* 覆盖它。这是「用户意图优先于机器推断」的唯一实现点,所以单独一个方法而不是
|
|
4916
|
+
* 塞进 `updateDevice`:权限变更与改名是两件不同的事,审计动作也不该混。
|
|
4917
|
+
*/
|
|
4918
|
+
renameDevice(id, name, options = {}) {
|
|
4919
|
+
const trimmed = name.trim().slice(0, 120);
|
|
4920
|
+
if (trimmed === "") throw new ApiError(400, "BODY_INVALID", "name must not be empty.");
|
|
4921
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
4922
|
+
try {
|
|
4923
|
+
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.");
|
|
4924
|
+
this.audit("admin", "loopback-admin", "device.rename", void 0, void 0, {
|
|
4925
|
+
deviceId: id,
|
|
4926
|
+
name: trimmed
|
|
4927
|
+
}, options.requestId);
|
|
4928
|
+
this.db.exec("COMMIT");
|
|
4929
|
+
} catch (error) {
|
|
4930
|
+
this.db.exec("ROLLBACK");
|
|
4931
|
+
throw error;
|
|
4932
|
+
}
|
|
4933
|
+
this.devicesRevisionCounter += 1;
|
|
4934
|
+
const device = this.getActiveDevice(id);
|
|
4935
|
+
if (device === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
|
|
4936
|
+
return device;
|
|
4937
|
+
}
|
|
4514
4938
|
listDevices() {
|
|
4515
4939
|
const rows = this.db.prepare("SELECT * FROM devices WHERE revoked_at IS NULL ORDER BY created_at DESC").all();
|
|
4516
4940
|
const rootQuery = this.db.prepare("SELECT root_id FROM device_roots WHERE device_id = ? ORDER BY root_id");
|
|
@@ -4521,7 +4945,8 @@ var WorkspaceDatabase = class {
|
|
|
4521
4945
|
rootIds: rootQuery.all(row.id).map((item) => item.root_id),
|
|
4522
4946
|
createdAt: row.created_at,
|
|
4523
4947
|
...row.last_seen_at === null ? {} : { lastSeenAt: row.last_seen_at },
|
|
4524
|
-
...row.revoked_at === null ? {} : { revokedAt: row.revoked_at }
|
|
4948
|
+
...row.revoked_at === null ? {} : { revokedAt: row.revoked_at },
|
|
4949
|
+
...identityFields(row)
|
|
4525
4950
|
}));
|
|
4526
4951
|
}
|
|
4527
4952
|
isDeviceActive(id) {
|
|
@@ -4805,6 +5230,9 @@ var WorkspaceDatabase = class {
|
|
|
4805
5230
|
CREATE INDEX IF NOT EXISTS trash_root_idx ON trash(root_id, created_at DESC);
|
|
4806
5231
|
`);
|
|
4807
5232
|
if (!this.hasRootColumn("builtin")) this.db.exec("ALTER TABLE roots ADD COLUMN builtin INTEGER NOT NULL DEFAULT 0");
|
|
5233
|
+
if (!this.hasDeviceColumn("platform")) this.db.exec("ALTER TABLE devices ADD COLUMN platform TEXT");
|
|
5234
|
+
if (!this.hasDeviceColumn("model")) this.db.exec("ALTER TABLE devices ADD COLUMN model TEXT");
|
|
5235
|
+
if (!this.hasDeviceColumn("name_source")) this.db.exec("ALTER TABLE devices ADD COLUMN name_source TEXT");
|
|
4808
5236
|
if (this.rootsPathIsUnique()) this.dropRootsUniqueConstraint();
|
|
4809
5237
|
}
|
|
4810
5238
|
/** `roots.real_path` 上是否还带 UNIQUE 约束。 */
|
|
@@ -4847,12 +5275,34 @@ var WorkspaceDatabase = class {
|
|
|
4847
5275
|
}
|
|
4848
5276
|
/** `roots` 表是否已有指定列。 */
|
|
4849
5277
|
hasRootColumn(column) {
|
|
4850
|
-
return this.
|
|
5278
|
+
return this.hasColumn("roots", column);
|
|
5279
|
+
}
|
|
5280
|
+
/** `devices` 表是否已有指定列。 */
|
|
5281
|
+
hasDeviceColumn(column) {
|
|
5282
|
+
return this.hasColumn("devices", column);
|
|
5283
|
+
}
|
|
5284
|
+
hasColumn(table, column) {
|
|
5285
|
+
return this.db.prepare(`PRAGMA table_info(${table})`).all().some((entry) => entry.name === column);
|
|
4851
5286
|
}
|
|
4852
5287
|
};
|
|
4853
5288
|
function hashSecret(secret) {
|
|
4854
5289
|
return createHash("sha256").update(secret, "utf8").digest("hex");
|
|
4855
5290
|
}
|
|
5291
|
+
/**
|
|
5292
|
+
* 把库里三列身份摊成 `DeviceView` 的可选字段。
|
|
5293
|
+
*
|
|
5294
|
+
* 老库这三列是 NULL —— 此时**整个键都不写**,而不是写 `platform: undefined`:
|
|
5295
|
+
* 前者在 JSON 里不存在(老客户端忽略未知字段,新客户端看到 `undefined`),
|
|
5296
|
+
* 后者会被 `JSON.stringify` 丢掉,两种结果一样,但省略更诚实。
|
|
5297
|
+
*/
|
|
5298
|
+
function identityFields(row) {
|
|
5299
|
+
const source = row.name_source;
|
|
5300
|
+
return {
|
|
5301
|
+
...row.platform === null || row.platform === void 0 ? {} : { platform: row.platform },
|
|
5302
|
+
...row.model === null || row.model === void 0 ? {} : { model: row.model },
|
|
5303
|
+
...source === "default" || source === "detected" || source === "declared" ? { nameSource: source } : {}
|
|
5304
|
+
};
|
|
5305
|
+
}
|
|
4856
5306
|
function normalizePairingCode(code) {
|
|
4857
5307
|
return code.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
|
|
4858
5308
|
}
|
|
@@ -6051,6 +6501,40 @@ function defaultDisplayName() {
|
|
|
6051
6501
|
//#endregion
|
|
6052
6502
|
//#region src/host/plugin-inventory.ts
|
|
6053
6503
|
/**
|
|
6504
|
+
* 插件清单(plugin inventory)。
|
|
6505
|
+
*
|
|
6506
|
+
* ## 为什么需要它
|
|
6507
|
+
*
|
|
6508
|
+
* 用户在手机上使用时的反馈是「功能单调点,无法查看装的插件情况」:想知道这台机器上
|
|
6509
|
+
* 装了哪些插件、有没有出问题的,必须回到电脑前的 WebUI。
|
|
6510
|
+
*
|
|
6511
|
+
* 而插件的 API 面(`/api/v1`)此前有 10 个端点,**没有任何一个与插件相关**;
|
|
6512
|
+
* loopback 管理面(`/manage/status`)也只回 `remote` / `roots` / `devices`。
|
|
6513
|
+
* 于是这个信息在远程侧完全不可见。本模块补上这个缺口。
|
|
6514
|
+
*
|
|
6515
|
+
* ## 数据源为什么是这两个文件
|
|
6516
|
+
*
|
|
6517
|
+
* DSH 把每个 profile 的插件状态放在两个地方,缺一不可:
|
|
6518
|
+
*
|
|
6519
|
+
* - `<profiledir>/package.json` 的 `dependencies`:**装了哪些包**(含版本声明);
|
|
6520
|
+
* - 同一个 `package.json` 的 `dsh.profile.bundles`:**实际加载了哪些包**。
|
|
6521
|
+
*
|
|
6522
|
+
* 两者之差是有意义的信息,而不是噪声:
|
|
6523
|
+
*
|
|
6524
|
+
* - 在 `dependencies` 里但不在 `bundles` 里 → **装了但没启用**(例如用户装了备用插件先不加载);
|
|
6525
|
+
* - 在 `bundles` 里但 `node_modules` 下找不到 → **声明加载却缺失**,这通常意味着启动会出问题,
|
|
6526
|
+
* 是用户最需要被提前告知的状态。
|
|
6527
|
+
*
|
|
6528
|
+
* 另外,`node_modules/<name>/package.json` 里的 `version` 才是**实际装上的版本**。
|
|
6529
|
+
* 只看 `dependencies` 的 `^0.4.5` 无法回答「我现在跑的是哪个版本」。
|
|
6530
|
+
*
|
|
6531
|
+
* ## 安全边界
|
|
6532
|
+
*
|
|
6533
|
+
* 本模块只读两个位置的文件,并把结果**限制为**「包名 + 版本 + 状态」三种信息,
|
|
6534
|
+
* 不回传任何文件内容、绝对路径之外的系统信息、或 `.env` / 凭据相关内容。
|
|
6535
|
+
* `profilePath` 会返回,因为用户需要它来定位问题,且它本来就是本机路径。
|
|
6536
|
+
*/
|
|
6537
|
+
/**
|
|
6054
6538
|
* 读包清单时的并发上限。
|
|
6055
6539
|
*
|
|
6056
6540
|
* 为什么不是 `Promise.all` 全放:`names` 的长度由用户装了多少插件决定,
|
|
@@ -6364,6 +6848,40 @@ function unavailable(profile, profilePath, reason) {
|
|
|
6364
6848
|
items: []
|
|
6365
6849
|
};
|
|
6366
6850
|
}
|
|
6851
|
+
/**
|
|
6852
|
+
* 启用 / 停用插件(「插件」页的写操作)。
|
|
6853
|
+
*
|
|
6854
|
+
* ## 为什么不是调 `dsh plugin`
|
|
6855
|
+
*
|
|
6856
|
+
* `dsh plugin <anything>` 会**原样转发给 pnpm**。实测踩过一次:本想查
|
|
6857
|
+
* `dsh plugin enable --help`,结果 pnpm 直接进入安装流程(打出 `Packages: +4 -85`)——
|
|
6858
|
+
* 它在**动依赖树**。启用/停用插件根本不需要碰依赖(包已经在 `node_modules` 里了),
|
|
6859
|
+
* 走 pnpm 等于为了挪一个字符串去重算整棵依赖,风险与收益完全不成比例。
|
|
6860
|
+
*
|
|
6861
|
+
* ## 真正该改的是什么
|
|
6862
|
+
*
|
|
6863
|
+
* 只改 **`dsh.profile.bundles` 数组**。内核源码注释写明:
|
|
6864
|
+
*
|
|
6865
|
+
* > stack its patch layers (bundle layers in `dsh.profile.bundles` order …)
|
|
6866
|
+
*
|
|
6867
|
+
* 也就是说这个数组**就是加载清单,且顺序即加载顺序**。加一个名字 = 启用,
|
|
6868
|
+
* 删一个名字 = 停用。依赖声明(`dependencies`)原封不动 —— 包仍在磁盘上,
|
|
6869
|
+
* 随时可以再启用,不必重新下载。
|
|
6870
|
+
*
|
|
6871
|
+
* ## 三道安全阀
|
|
6872
|
+
*
|
|
6873
|
+
* 1. **拒绝改官方包**(`@deepseek-ai/` 前缀):它们随 DSH 主包安装、不由 profile 管理,
|
|
6874
|
+
* 改了 bundles 也加载不出来,反而把清单搞成与实际不符。
|
|
6875
|
+
* 2. **写前必备份**:`package.json.bak-<时间戳>` 落在同目录。profile 的 package.json
|
|
6876
|
+
* 一旦写坏,dsh 下次**起不来**,而用户此刻在手机上、够不着电脑 —— 备份是唯一退路。
|
|
6877
|
+
* 3. **先写临时文件再 rename**:`rename` 在同分区是原子的。直接写原文件的话,
|
|
6878
|
+
* 写到一半断电/被杀会留下一个残缺的 JSON,后果同上。
|
|
6879
|
+
*
|
|
6880
|
+
* ## 必须重启才生效
|
|
6881
|
+
*
|
|
6882
|
+
* bundles 只在**启动时**读一次。改了文件不等于改了运行中的进程 —— 这一点会原样
|
|
6883
|
+
* 回给调用方(`restartRequired: true`),由界面明确告诉用户,别让人以为点了没反应。
|
|
6884
|
+
*/
|
|
6367
6885
|
var PluginToggleError = class extends Error {
|
|
6368
6886
|
code;
|
|
6369
6887
|
constructor(code, message) {
|
|
@@ -6388,7 +6906,7 @@ async function togglePlugin(name, enable, input = {}) {
|
|
|
6388
6906
|
const profilePath = path.join(resolveDshHome(input), "profiles", profile);
|
|
6389
6907
|
const manifestPath = path.join(profilePath, "package.json");
|
|
6390
6908
|
const raw = await readFile(manifestPath, "utf8").catch(() => null);
|
|
6391
|
-
if (raw === null) throw new PluginToggleError("MANIFEST_UNREADABLE", `Cannot read ${
|
|
6909
|
+
if (raw === null) throw new PluginToggleError("MANIFEST_UNREADABLE", `Cannot read the manifest of profile "${profile}".`);
|
|
6392
6910
|
let manifest;
|
|
6393
6911
|
try {
|
|
6394
6912
|
manifest = JSON.parse(raw);
|
|
@@ -6878,12 +7396,18 @@ var ApiRouter = class {
|
|
|
6878
7396
|
scopes: [...principal.scopes],
|
|
6879
7397
|
rootIds: "all"
|
|
6880
7398
|
});
|
|
6881
|
-
else
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
7399
|
+
else {
|
|
7400
|
+
const stored = this.options.database.getActiveDevice(principal.id);
|
|
7401
|
+
sendJson(res, 200, {
|
|
7402
|
+
id: principal.id,
|
|
7403
|
+
name: principal.name,
|
|
7404
|
+
scopes: [...principal.scopes],
|
|
7405
|
+
rootIds: [...principal.rootIds],
|
|
7406
|
+
...stored?.platform === void 0 ? {} : { platform: stored.platform },
|
|
7407
|
+
...stored?.model === void 0 ? {} : { model: stored.model },
|
|
7408
|
+
...stored?.nameSource === void 0 ? {} : { nameSource: stored.nameSource }
|
|
7409
|
+
});
|
|
7410
|
+
}
|
|
6887
7411
|
return;
|
|
6888
7412
|
}
|
|
6889
7413
|
if (method === "GET" && pathname === "/api/v1/roots") {
|
|
@@ -6999,10 +7523,11 @@ var ApiRouter = class {
|
|
|
6999
7523
|
...typeof body.apiKey === "string" ? { apiKey: body.apiKey } : {},
|
|
7000
7524
|
...Number.isSafeInteger(body.expectedRevision) ? { expectedRevision: body.expectedRevision } : {}
|
|
7001
7525
|
};
|
|
7002
|
-
await this.options.settings.createProvider(input);
|
|
7526
|
+
const created = await this.options.settings.createProvider(input);
|
|
7003
7527
|
this.audit(principal, "settings.provider.create", void 0, void 0, requestId, {
|
|
7004
7528
|
providerId: input.id,
|
|
7005
|
-
credentialChanged: typeof input.apiKey === "string" && input.apiKey.trim() !== ""
|
|
7529
|
+
credentialChanged: typeof input.apiKey === "string" && input.apiKey.trim() !== "",
|
|
7530
|
+
baseURLHost: created.baseURLHost
|
|
7006
7531
|
});
|
|
7007
7532
|
sendJson(res, 201, await this.options.settings.listProviders());
|
|
7008
7533
|
return;
|
|
@@ -7065,11 +7590,12 @@ var ApiRouter = class {
|
|
|
7065
7590
|
if (method === "PATCH") {
|
|
7066
7591
|
this.options.auth.requireScope(principal, "settings.write");
|
|
7067
7592
|
const body = await readJson(req, 1e6);
|
|
7068
|
-
await this.options.settings.updateProvider(providerId, body);
|
|
7593
|
+
const updated = await this.options.settings.updateProvider(providerId, body);
|
|
7069
7594
|
this.audit(principal, "settings.provider.update", void 0, void 0, requestId, {
|
|
7070
7595
|
providerId,
|
|
7071
7596
|
fields: Object.keys(body).filter((field) => field !== "apiKey"),
|
|
7072
|
-
credentialChanged: Object.hasOwn(body, "apiKey")
|
|
7597
|
+
credentialChanged: Object.hasOwn(body, "apiKey"),
|
|
7598
|
+
...updated.baseURLHost === void 0 ? {} : { baseURLHost: updated.baseURLHost }
|
|
7073
7599
|
});
|
|
7074
7600
|
sendJson(res, 200, await this.options.settings.listProviders());
|
|
7075
7601
|
return;
|
|
@@ -7094,9 +7620,12 @@ var ApiRouter = class {
|
|
|
7094
7620
|
...typeof body.api === "string" ? { api: body.api } : {},
|
|
7095
7621
|
...typeof body.apiKey === "string" ? { apiKey: body.apiKey } : {}
|
|
7096
7622
|
};
|
|
7097
|
-
const
|
|
7098
|
-
this.audit(principal, "settings.provider.discover", void 0, void 0, requestId, {
|
|
7099
|
-
|
|
7623
|
+
const discovered = await this.options.settings.discover(providerId, draft);
|
|
7624
|
+
this.audit(principal, "settings.provider.discover", void 0, void 0, requestId, {
|
|
7625
|
+
providerId,
|
|
7626
|
+
...discovered.baseURLHost === void 0 ? {} : { baseURLHost: discovered.baseURLHost }
|
|
7627
|
+
});
|
|
7628
|
+
sendJson(res, 200, { models: discovered.models });
|
|
7100
7629
|
return;
|
|
7101
7630
|
}
|
|
7102
7631
|
if (pathname === "/api/v1/chat/sessions") {
|
|
@@ -7116,7 +7645,8 @@ var ApiRouter = class {
|
|
|
7116
7645
|
}
|
|
7117
7646
|
const options = {
|
|
7118
7647
|
...typeof body.sessionId === "string" ? { sessionId: body.sessionId } : {},
|
|
7119
|
-
...typeof body.agentPreset === "string" ? { agentPreset: body.agentPreset } : {}
|
|
7648
|
+
...typeof body.agentPreset === "string" ? { agentPreset: body.agentPreset } : {},
|
|
7649
|
+
...typeof body.reuseBlank === "boolean" ? { reuseBlank: body.reuseBlank } : {}
|
|
7120
7650
|
};
|
|
7121
7651
|
const usesWorkspace = typeof body.workspaceId === "string";
|
|
7122
7652
|
const usesLegacyPath = typeof body.rootId === "string" && typeof body.path === "string";
|
|
@@ -7425,9 +7955,17 @@ var ApiRouter = class {
|
|
|
7425
7955
|
const deviceRoute = pathname.match(/^\/manage\/devices\/([^/]+)$/);
|
|
7426
7956
|
if (deviceRoute !== null && method === "PATCH") {
|
|
7427
7957
|
const body = await readJson(req, 64e3);
|
|
7958
|
+
const deviceId = decodePathSegment(deviceRoute[1]);
|
|
7959
|
+
if (body.name !== void 0 && typeof body.name !== "string") throw new ApiError(400, "BODY_INVALID", "name must be a string.");
|
|
7960
|
+
if (body.scopes === void 0 && body.rootIds === void 0) {
|
|
7961
|
+
if (body.name === void 0) throw new ApiError(400, "BODY_INVALID", "Either name or the scopes/rootIds pair is required.");
|
|
7962
|
+
sendJson(res, 200, this.options.database.renameDevice(deviceId, body.name, { requestId }));
|
|
7963
|
+
return;
|
|
7964
|
+
}
|
|
7428
7965
|
const scopes = validateScopes(body.scopes);
|
|
7429
7966
|
const rootIds = validateStringArray(body.rootIds, "rootIds");
|
|
7430
|
-
|
|
7967
|
+
if (typeof body.name === "string") this.options.database.renameDevice(deviceId, body.name, { requestId });
|
|
7968
|
+
sendJson(res, 200, this.options.database.updateDevice(deviceId, scopes, rootIds, { requestId }));
|
|
7431
7969
|
return;
|
|
7432
7970
|
}
|
|
7433
7971
|
if (deviceRoute !== null && method === "DELETE") {
|
|
@@ -7604,7 +8142,7 @@ var ApiRouter = class {
|
|
|
7604
8142
|
const { state, configText } = await this.createTunnel({ configText: true }, requestId, "im-command");
|
|
7605
8143
|
if (configText === void 0 || state.url === null) throw new Error("隧道已启动,但 12 秒内没拿到公网地址。稍等片刻再发一次 /tunnel 可复用当前地址。");
|
|
7606
8144
|
const text = typeof configText.text === "string" ? configText.text : "";
|
|
7607
|
-
const deviceName = typeof configText.deviceName === "string" ? configText.deviceName :
|
|
8145
|
+
const deviceName = typeof configText.deviceName === "string" ? configText.deviceName : DEFAULT_DEVICE_NAME;
|
|
7608
8146
|
return {
|
|
7609
8147
|
url: state.url,
|
|
7610
8148
|
text,
|
|
@@ -7644,10 +8182,13 @@ var ApiRouter = class {
|
|
|
7644
8182
|
if (endpoints.length === 0) throw new ApiError(400, "NO_ENDPOINT", "No usable local address was detected. Provide endpoints explicitly.");
|
|
7645
8183
|
const scopes = body.scopes === void 0 ? [...DEFAULT_CONFIG_SCOPES] : validateScopes(body.scopes);
|
|
7646
8184
|
const rootIds = body.rootIds === void 0 ? this.options.database.listRoots().map((root) => root.id) : validateStringArray(body.rootIds, "rootIds");
|
|
7647
|
-
const deviceName = readOptionalText(body.deviceName) ?? "
|
|
8185
|
+
const deviceName = readOptionalText(body.deviceName) ?? "Mobile device";
|
|
7648
8186
|
const displayName = readOptionalText(body.displayName) ?? defaultDisplayName();
|
|
7649
8187
|
const pairing = this.options.database.createPairing(rootIds, scopes, void 0, { requestId });
|
|
7650
|
-
const exchanged = this.options.database.exchangePairing(pairing.code, deviceName, {
|
|
8188
|
+
const exchanged = this.options.database.exchangePairing(pairing.code, deviceName, {
|
|
8189
|
+
requestId,
|
|
8190
|
+
...readOptionalText(body.deviceName) === void 0 ? { nameSource: "default" } : { nameSource: "declared" }
|
|
8191
|
+
});
|
|
7651
8192
|
return {
|
|
7652
8193
|
text: encodeConfigText({
|
|
7653
8194
|
displayName,
|
|
@@ -7960,16 +8501,11 @@ function validateStringArray(value, name) {
|
|
|
7960
8501
|
/**
|
|
7961
8502
|
* 生成配置文本时的默认设备权限。
|
|
7962
8503
|
*
|
|
7963
|
-
*
|
|
7964
|
-
*
|
|
8504
|
+
* 定义已上移到 `shared/contracts.ts` 的 `DEFAULT_DEVICE_SCOPES`:Web UI 的配对码面板
|
|
8505
|
+
* 原先默认勾满七档(含 `settings.write`),与服务端这里的五档缺省互相矛盾 ——
|
|
8506
|
+
* 同一份默认值散落两处正是它跑偏的原因。
|
|
7965
8507
|
*/
|
|
7966
|
-
const DEFAULT_CONFIG_SCOPES =
|
|
7967
|
-
"chat.read",
|
|
7968
|
-
"chat.write",
|
|
7969
|
-
"files.read",
|
|
7970
|
-
"files.write",
|
|
7971
|
-
"settings.read"
|
|
7972
|
-
];
|
|
8508
|
+
const DEFAULT_CONFIG_SCOPES = DEFAULT_DEVICE_SCOPES;
|
|
7973
8509
|
function readOptionalText(value) {
|
|
7974
8510
|
if (typeof value !== "string") return null;
|
|
7975
8511
|
const text = value.trim();
|
|
@@ -8837,6 +9373,149 @@ function failResponse(res, error) {
|
|
|
8837
9373
|
res.end(body);
|
|
8838
9374
|
}
|
|
8839
9375
|
//#endregion
|
|
9376
|
+
//#region src/host/provider-url-policy.ts
|
|
9377
|
+
/**
|
|
9378
|
+
* 供应商 `baseURL` 的地址策略(SEC-H11)。
|
|
9379
|
+
*
|
|
9380
|
+
* 为什么需要这一层:`discover` / `createProvider` / `updateProvider` 三个入口都把
|
|
9381
|
+
* 客户端给的 `baseURL` 原样交给内核,内核会**带着该供应商已配置的真实凭据**去请求它。
|
|
9382
|
+
* 攻击者只要持 `settings.write`,就能把 baseURL 指向自己的服务器,收到
|
|
9383
|
+
* `Authorization: Bearer <真实 key>` —— 这是最直接的一次性凭据窃取;同一机制也可
|
|
9384
|
+
* 用作 SSRF 探内网。
|
|
9385
|
+
*
|
|
9386
|
+
* 两条独立的闸门:
|
|
9387
|
+
* 1. **协议白名单**:只放 `http:` / `https:`。`file:`、`ftp:`、`gopher:` 之类没有任何
|
|
9388
|
+
* 正当用途,却是把「一次 HTTP 请求」变成别的语义的经典手法。
|
|
9389
|
+
* 2. **私网地址**:默认拒绝 RFC1918 / 回环 / 链路本地(含云元数据 `169.254.169.254`)。
|
|
9390
|
+
* 本地 LLM(ollama、LM Studio 常见于 `127.0.0.1:11434`)是正当用例,因此由部署方
|
|
9391
|
+
* 用插件配置 `allowPrivateProviderHosts` 显式放行,而不是默认开口子。
|
|
9392
|
+
*
|
|
9393
|
+
* 判定只看**字面量**,不做 DNS 解析:解析后校验会引入 TOCTOU(校验时是公网 IP、
|
|
9394
|
+
* 请求时 DNS 换成内网),反而给出「已校验」的错觉。真正挡住 DNS 重绑定的做法是
|
|
9395
|
+
* 在连接层固定已解析地址,那超出本层职责;这里选择把攻击面收窄到「攻击者必须控制
|
|
9396
|
+
* 一个公网域名」,并让审计记下目标 host 以便事后追查。
|
|
9397
|
+
*/
|
|
9398
|
+
/** 私网 / 保留地址判定需要的最小区间,够覆盖本机、家庭网络与云元数据。 */
|
|
9399
|
+
const PRIVATE_V4 = [
|
|
9400
|
+
[0, 16777215],
|
|
9401
|
+
[167772160, 184549375],
|
|
9402
|
+
[1681915904, 1686110207],
|
|
9403
|
+
[2130706432, 2147483647],
|
|
9404
|
+
[2851995648, 2852061183],
|
|
9405
|
+
[2886729728, 2887778303],
|
|
9406
|
+
[3232235520, 3232301055]
|
|
9407
|
+
];
|
|
9408
|
+
/**
|
|
9409
|
+
* 校验并归一化 `baseURL`,返回可用于审计的 `host[:port]`。
|
|
9410
|
+
*
|
|
9411
|
+
* 抛 400 `PROVIDER_BASE_URL_INVALID`(形状/协议不合法)或
|
|
9412
|
+
* 403 `PROVIDER_BASE_URL_FORBIDDEN`(指向私网且未放行)。两个码分开是刻意的:
|
|
9413
|
+
* 前者是客户端填错,后者是策略拒绝,客户端不该把它们当同一类错误处理。
|
|
9414
|
+
*
|
|
9415
|
+
* 返回值带端口:审计要能回答「凭据被打到哪个地址」,而同一台内网主机上
|
|
9416
|
+
* `:11434`(ollama)与 `:8080`(随便什么)是不同的东西。
|
|
9417
|
+
*/
|
|
9418
|
+
function validateProviderBaseURL(raw, options = {}) {
|
|
9419
|
+
const trimmed = raw.trim();
|
|
9420
|
+
if (trimmed === "") throw new ApiError(400, "PROVIDER_BASE_URL_REQUIRED", "baseURL is required.");
|
|
9421
|
+
let url;
|
|
9422
|
+
try {
|
|
9423
|
+
url = new URL(trimmed);
|
|
9424
|
+
} catch {
|
|
9425
|
+
throw new ApiError(400, "PROVIDER_BASE_URL_INVALID", "baseURL must be an absolute URL.");
|
|
9426
|
+
}
|
|
9427
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new ApiError(400, "PROVIDER_BASE_URL_INVALID", "baseURL must use http or https.");
|
|
9428
|
+
if (url.hostname === "") throw new ApiError(400, "PROVIDER_BASE_URL_INVALID", "baseURL must include a host.");
|
|
9429
|
+
const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
9430
|
+
const target = url.port === "" ? host : `${host}:${url.port}`;
|
|
9431
|
+
if (!isPrivateHost(host)) return target;
|
|
9432
|
+
if ((options.allowPrivateHosts ?? []).some((entry) => {
|
|
9433
|
+
const candidate = entry.trim().toLowerCase();
|
|
9434
|
+
if (candidate === "") return false;
|
|
9435
|
+
return candidate.includes(":") ? candidate === target : candidate === host;
|
|
9436
|
+
})) return target;
|
|
9437
|
+
throw new ApiError(403, "PROVIDER_BASE_URL_FORBIDDEN", "baseURL points at a private or loopback address; the deployment must allow it explicitly.", { host: target });
|
|
9438
|
+
}
|
|
9439
|
+
/**
|
|
9440
|
+
* 判断 host 是否落在私网 / 回环 / 链路本地范围。
|
|
9441
|
+
*
|
|
9442
|
+
* 只认 IPv4 点分十进制、IPv6 字面量与 `localhost`。**不解析域名**:一个指向
|
|
9443
|
+
* `127.0.0.1` 的公网域名会从这里漏过去 —— 这是本层刻意的边界,见文件头注释。
|
|
9444
|
+
*/
|
|
9445
|
+
function isPrivateHost(host) {
|
|
9446
|
+
const lower = host.toLowerCase();
|
|
9447
|
+
if (lower === "localhost" || lower.endsWith(".localhost")) return true;
|
|
9448
|
+
if (lower.includes(":")) return isPrivateIPv6(lower);
|
|
9449
|
+
const v4 = parseIPv4(lower);
|
|
9450
|
+
if (v4 === void 0) return false;
|
|
9451
|
+
return PRIVATE_V4.some(([from, to]) => v4 >= from && v4 <= to);
|
|
9452
|
+
}
|
|
9453
|
+
function parseIPv4(host) {
|
|
9454
|
+
const parts = host.split(".");
|
|
9455
|
+
if (parts.length !== 4) return void 0;
|
|
9456
|
+
let value = 0;
|
|
9457
|
+
for (const part of parts) {
|
|
9458
|
+
if (!/^\d{1,3}$/.test(part)) return void 0;
|
|
9459
|
+
const octet = Number(part);
|
|
9460
|
+
if (octet > 255) return void 0;
|
|
9461
|
+
value = value * 256 + octet;
|
|
9462
|
+
}
|
|
9463
|
+
return value;
|
|
9464
|
+
}
|
|
9465
|
+
function isPrivateIPv6(host) {
|
|
9466
|
+
if (host === "::1" || host === "::") return true;
|
|
9467
|
+
if (/^f[cd][0-9a-f]{2}:/.test(host)) return true;
|
|
9468
|
+
if (/^fe[89ab][0-9a-f]:/.test(host)) return true;
|
|
9469
|
+
const embedded = embeddedIPv4(host);
|
|
9470
|
+
if (embedded !== void 0) return isPrivateHost(embedded);
|
|
9471
|
+
return false;
|
|
9472
|
+
}
|
|
9473
|
+
/**
|
|
9474
|
+
* 从 IPv6 字面量里取出内嵌的 IPv4 点分形式(仅 `::ffff:0:0/96` 与 `::/96` 两种),
|
|
9475
|
+
* 没有则返回 `undefined`。
|
|
9476
|
+
*
|
|
9477
|
+
* 为什么要自己解析:`new URL()` 会把 `::ffff:127.0.0.1` 归一化成十六进制形式
|
|
9478
|
+
* `::ffff:7f00:1`,所以只匹配点分十进制等于没挡;而标准库没有暴露「前 80 位是否
|
|
9479
|
+
* 为零」的判断,为这一处引依赖不划算。
|
|
9480
|
+
*
|
|
9481
|
+
* 解析失败一律返回 `undefined`(按公网处理),与 `isPrivateHost` 对未知输入的态度一致。
|
|
9482
|
+
*/
|
|
9483
|
+
function embeddedIPv4(host) {
|
|
9484
|
+
let normalized = host;
|
|
9485
|
+
const dotted = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(host);
|
|
9486
|
+
if (dotted !== null) {
|
|
9487
|
+
const v4 = parseIPv4(dotted[1]);
|
|
9488
|
+
if (v4 === void 0) return void 0;
|
|
9489
|
+
const hi = (v4 >>> 16 & 65535).toString(16);
|
|
9490
|
+
const lo = (v4 & 65535).toString(16);
|
|
9491
|
+
normalized = `${host.slice(0, dotted.index)}${hi}:${lo}`;
|
|
9492
|
+
}
|
|
9493
|
+
const [head, tail] = normalized.includes("::") ? normalized.split("::", 2) : [normalized, void 0];
|
|
9494
|
+
const headGroups = head === void 0 || head === "" ? [] : head.split(":");
|
|
9495
|
+
const tailGroups = tail === void 0 || tail === "" ? [] : tail.split(":");
|
|
9496
|
+
const missing = 8 - headGroups.length - tailGroups.length;
|
|
9497
|
+
if (tail === void 0 ? headGroups.length !== 8 : missing < 0) return void 0;
|
|
9498
|
+
const groups = [
|
|
9499
|
+
...headGroups,
|
|
9500
|
+
...tail === void 0 ? [] : Array(missing).fill("0"),
|
|
9501
|
+
...tailGroups
|
|
9502
|
+
];
|
|
9503
|
+
if (groups.length !== 8) return void 0;
|
|
9504
|
+
const parsed = [];
|
|
9505
|
+
for (const group of groups) {
|
|
9506
|
+
if (!/^[0-9a-f]{1,4}$/.test(group)) return void 0;
|
|
9507
|
+
parsed.push(Number.parseInt(group, 16));
|
|
9508
|
+
}
|
|
9509
|
+
if (!(parsed.slice(0, 5).every((value) => value === 0) && (parsed[5] === 65535 || parsed[5] === 0))) return void 0;
|
|
9510
|
+
const v4 = (parsed[6] << 16 | parsed[7]) >>> 0;
|
|
9511
|
+
return [
|
|
9512
|
+
24,
|
|
9513
|
+
16,
|
|
9514
|
+
8,
|
|
9515
|
+
0
|
|
9516
|
+
].map((shift) => v4 >>> shift & 255).join(".");
|
|
9517
|
+
}
|
|
9518
|
+
//#endregion
|
|
8840
9519
|
//#region src/host/settings-adapter.ts
|
|
8841
9520
|
/**
|
|
8842
9521
|
* provider 上下文的复用窗口。
|
|
@@ -8853,6 +9532,9 @@ var DshSettingsAdapter = class {
|
|
|
8853
9532
|
/**
|
|
8854
9533
|
* @param options.writeNamespaces 额外允许手机端写入的设置命名空间(精确 ns 或
|
|
8855
9534
|
* `前缀*`),与内置策略(`ui-*` / 以 `locale` 结尾)叠加;默认为空。
|
|
9535
|
+
* @param options.allowPrivateProviderHosts 允许供应商 `baseURL` 指向的私网 / 回环
|
|
9536
|
+
* host 白名单(默认空 = 一律拒绝)。要连本地 LLM 的部署方显式填,理由见
|
|
9537
|
+
* `provider-url-policy.ts` 文件头。
|
|
8856
9538
|
*/
|
|
8857
9539
|
constructor(api, options = {}) {
|
|
8858
9540
|
this.api = api;
|
|
@@ -8909,6 +9591,10 @@ var DshSettingsAdapter = class {
|
|
|
8909
9591
|
get writeNamespaces() {
|
|
8910
9592
|
return this.options.writeNamespaces ?? [];
|
|
8911
9593
|
}
|
|
9594
|
+
/** 交给地址策略的选项;集中一处,三个入口不会各配各的。 */
|
|
9595
|
+
get baseURLOptions() {
|
|
9596
|
+
return { allowPrivateHosts: this.options.allowPrivateProviderHosts ?? [] };
|
|
9597
|
+
}
|
|
8912
9598
|
/** 内核 settings 服务缺失时给 503(而不是把 `TypeError` 漏成 500)。 */
|
|
8913
9599
|
settingsService() {
|
|
8914
9600
|
const settings = this.api.settings;
|
|
@@ -8996,11 +9682,20 @@ var DshSettingsAdapter = class {
|
|
|
8996
9682
|
payload: {}
|
|
8997
9683
|
}));
|
|
8998
9684
|
}
|
|
9685
|
+
/**
|
|
9686
|
+
* 更新供应商配置。
|
|
9687
|
+
*
|
|
9688
|
+
* 返回 `baseURLHost`(仅当本次 patch 动了 `baseURL` 时存在)供路由层写审计:
|
|
9689
|
+
* 攻击者可以用 discover 把凭据发到任意地址,事后必须能从审计里看出「打了哪」。
|
|
9690
|
+
* 只记 host 不记完整 URL —— path/query 可能带凭据,审计表不该成为新的泄露点。
|
|
9691
|
+
*/
|
|
8999
9692
|
async updateProvider(providerId, patch) {
|
|
9000
9693
|
const context = await this.providerContext(providerId);
|
|
9001
9694
|
if (!context.writable) throw new ApiError(409, "SETTINGS_READ_ONLY", "DSH settings are read-only.");
|
|
9002
9695
|
if (context.entry.settingsNs === "") throw new ApiError(409, "PROVIDER_NOT_CONFIGURABLE", "This provider has no writable settings section.");
|
|
9003
9696
|
const ops = [];
|
|
9697
|
+
let baseURLHost;
|
|
9698
|
+
const previousHost = hostOf(effectiveBaseURL(context));
|
|
9004
9699
|
for (const field of [
|
|
9005
9700
|
"displayName",
|
|
9006
9701
|
"baseURL",
|
|
@@ -9010,6 +9705,7 @@ var DshSettingsAdapter = class {
|
|
|
9010
9705
|
]) {
|
|
9011
9706
|
if (!(field in patch)) continue;
|
|
9012
9707
|
const value = patch[field];
|
|
9708
|
+
if (field === "baseURL" && typeof value === "string" && value.trim() !== "") baseURLHost = validateProviderBaseURL(value, this.baseURLOptions);
|
|
9013
9709
|
ops.push(value === null || value === "" ? {
|
|
9014
9710
|
op: "unset",
|
|
9015
9711
|
path: [...context.entry.settingsPath, field]
|
|
@@ -9019,6 +9715,10 @@ var DshSettingsAdapter = class {
|
|
|
9019
9715
|
value
|
|
9020
9716
|
});
|
|
9021
9717
|
}
|
|
9718
|
+
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.", {
|
|
9719
|
+
previousHost,
|
|
9720
|
+
host: baseURLHost
|
|
9721
|
+
});
|
|
9022
9722
|
if (patch.models !== void 0) {
|
|
9023
9723
|
if (patch.models === null) ops.push({
|
|
9024
9724
|
op: "unset",
|
|
@@ -9061,11 +9761,12 @@ var DshSettingsAdapter = class {
|
|
|
9061
9761
|
}));
|
|
9062
9762
|
}
|
|
9063
9763
|
this.invalidateProviderContext(providerId);
|
|
9764
|
+
return baseURLHost === void 0 ? {} : { baseURLHost };
|
|
9064
9765
|
}
|
|
9065
9766
|
async createProvider(input) {
|
|
9066
9767
|
const id = input.id.trim();
|
|
9067
9768
|
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
|
-
|
|
9769
|
+
const baseURLHost = validateProviderBaseURL(input.baseURL, this.baseURLOptions);
|
|
9069
9770
|
if (input.models.length === 0) throw new ApiError(400, "PROVIDER_MODELS_REQUIRED", "At least one model is required.");
|
|
9070
9771
|
validateModels(input.models);
|
|
9071
9772
|
const [directoryResponse, settingsResponse] = await Promise.all([this.api.llm.providers({
|
|
@@ -9112,6 +9813,7 @@ var DshSettingsAdapter = class {
|
|
|
9112
9813
|
}
|
|
9113
9814
|
}));
|
|
9114
9815
|
this.invalidateProviderContext(id);
|
|
9816
|
+
return { baseURLHost };
|
|
9115
9817
|
}
|
|
9116
9818
|
async removeProvider(providerId, expectedRevision) {
|
|
9117
9819
|
const context = await this.providerContext(providerId);
|
|
@@ -9130,18 +9832,35 @@ var DshSettingsAdapter = class {
|
|
|
9130
9832
|
}));
|
|
9131
9833
|
this.invalidateProviderContext(providerId);
|
|
9132
9834
|
}
|
|
9835
|
+
/**
|
|
9836
|
+
* 发现供应商可用模型。
|
|
9837
|
+
*
|
|
9838
|
+
* `draft.baseURL` 是**临时覆盖**(不写配置),但内核会带着该供应商已配置的真实
|
|
9839
|
+
* 凭据去请求它 —— 这正是 SEC-H11 的窃取路径:攻击者把自己的服务器填进来即可收到
|
|
9840
|
+
* `Authorization: Bearer <真实 key>`。因此临时覆盖与持久化写入过同一道地址策略。
|
|
9841
|
+
*
|
|
9842
|
+
* 返回 `baseURLHost` 供路由层审计;不传 `baseURL` 时(用配置里的地址)同样返回,
|
|
9843
|
+
* 这样「打了哪个地址」在任何情况下都能追溯。
|
|
9844
|
+
*/
|
|
9133
9845
|
async discover(providerId, draft) {
|
|
9846
|
+
const context = await this.providerContext(providerId);
|
|
9847
|
+
const override = draft.baseURL === void 0 || draft.baseURL === "" ? void 0 : validateProviderBaseURL(draft.baseURL, this.baseURLOptions);
|
|
9134
9848
|
const payload = {
|
|
9135
|
-
settingsNs:
|
|
9849
|
+
settingsNs: context.entry.settingsNs,
|
|
9136
9850
|
provider: providerId,
|
|
9137
9851
|
...draft.baseURL === void 0 || draft.baseURL === "" ? {} : { baseURL: draft.baseURL },
|
|
9138
9852
|
...draft.api === void 0 || draft.api === "" ? {} : { api: draft.api },
|
|
9139
9853
|
...draft.apiKey === void 0 || draft.apiKey === "" ? {} : { apiKey: draft.apiKey }
|
|
9140
9854
|
};
|
|
9141
|
-
|
|
9855
|
+
const models = unwrapDsh(await this.api.llm.discoverModels({
|
|
9142
9856
|
rpcId: randomUUID(),
|
|
9143
9857
|
payload
|
|
9144
9858
|
})).models;
|
|
9859
|
+
const configured = effectiveBaseURL(context);
|
|
9860
|
+
return {
|
|
9861
|
+
models,
|
|
9862
|
+
...override !== void 0 ? { baseURLHost: override } : configured === "" ? {} : { baseURLHost: hostOf(configured) }
|
|
9863
|
+
};
|
|
9145
9864
|
}
|
|
9146
9865
|
/**
|
|
9147
9866
|
* 取一个 provider 的上下文(目录条目 + 设置命名空间 + 当前 profile)。
|
|
@@ -9200,9 +9919,23 @@ var DshSettingsAdapter = class {
|
|
|
9200
9919
|
entry,
|
|
9201
9920
|
writable: settings.writable,
|
|
9202
9921
|
namespace,
|
|
9203
|
-
profile: recordAt(namespace?.value, entry.settingsPath)
|
|
9922
|
+
profile: recordAt(namespace?.value, entry.settingsPath),
|
|
9923
|
+
baseProfile: recordAt(namespace?.base, entry.settingsPath)
|
|
9204
9924
|
};
|
|
9205
9925
|
}
|
|
9926
|
+
/**
|
|
9927
|
+
* 该供应商当前是否已经存了凭据。
|
|
9928
|
+
*
|
|
9929
|
+
* 单独一次 `credentials.describe` 只在「要换 baseURL host」时才调 —— 那是罕见路径,
|
|
9930
|
+
* 而正常保存(改 models / 显示名 / 协议)不该为此多付一次内核往返。
|
|
9931
|
+
*/
|
|
9932
|
+
async providerHoldsCredential(providerId, context) {
|
|
9933
|
+
const ref = text(context.profile?.apiKeyEnv) ?? keyRef(providerId);
|
|
9934
|
+
return unwrapDsh(await this.api.credentials.describe({
|
|
9935
|
+
rpcId: randomUUID(),
|
|
9936
|
+
payload: { refs: [ref] }
|
|
9937
|
+
})).credentials[ref]?.configured === true;
|
|
9938
|
+
}
|
|
9206
9939
|
};
|
|
9207
9940
|
/**
|
|
9208
9941
|
* 把内核 `settings.mutate` 的错误翻成契约错误码。
|
|
@@ -9299,6 +10032,38 @@ function positiveInteger(value) {
|
|
|
9299
10032
|
function keyRef(provider) {
|
|
9300
10033
|
return provider === "deepseek-official" ? "DEEPSEEK_API_KEY" : `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
|
|
9301
10034
|
}
|
|
10035
|
+
/**
|
|
10036
|
+
* 取一个供应商**当前生效**的 baseURL。
|
|
10037
|
+
*
|
|
10038
|
+
* 三层回退的顺序是刻意的:
|
|
10039
|
+
* 1. `value`(`SettingsNamespaceView.value`)是内核合并后的生效值,首选;
|
|
10040
|
+
* 2. `base` 是内核给出的基线 —— 用户没覆盖过 baseURL 时,生效地址只在这里;
|
|
10041
|
+
* 3. 适配器目录的 `defaultBaseURL` 是最后兜底(内置供应商走它)。
|
|
10042
|
+
*
|
|
10043
|
+
* 只看第 1 层会把「用基线地址的供应商」误判成「地址未知」,从而让重定向闸门
|
|
10044
|
+
* 对它静默失效 —— 那正是攻击者想要的。
|
|
10045
|
+
*/
|
|
10046
|
+
function effectiveBaseURL(context) {
|
|
10047
|
+
const fromValue = text(context.profile?.baseURL);
|
|
10048
|
+
if (fromValue !== void 0) return fromValue;
|
|
10049
|
+
const fromBase = text(context.baseProfile?.baseURL);
|
|
10050
|
+
if (fromBase !== void 0) return fromBase;
|
|
10051
|
+
return text(context.entry.defaultBaseURL) ?? "";
|
|
10052
|
+
}
|
|
10053
|
+
/**
|
|
10054
|
+
* 取 URL 的 `host[:port]` 供审计用;解析失败时返回原串。
|
|
10055
|
+
*
|
|
10056
|
+
* 只用于「配置里的地址」(已经过地址策略或来自适配器目录),所以不需要再校验,
|
|
10057
|
+
* 但也不能因为一个畸形配置让 discover 整体失败 —— 审计字段缺失不该阻断业务。
|
|
10058
|
+
*/
|
|
10059
|
+
function hostOf(value) {
|
|
10060
|
+
try {
|
|
10061
|
+
const url = new URL(value);
|
|
10062
|
+
return url.port === "" ? url.hostname.toLowerCase() : `${url.hostname.toLowerCase()}:${url.port}`;
|
|
10063
|
+
} catch {
|
|
10064
|
+
return value;
|
|
10065
|
+
}
|
|
10066
|
+
}
|
|
9302
10067
|
//#endregion
|
|
9303
10068
|
//#region src/index.ts
|
|
9304
10069
|
/**
|
|
@@ -9376,7 +10141,10 @@ async function apply(ctx, config = {}) {
|
|
|
9376
10141
|
agents: ctx.agents,
|
|
9377
10142
|
commands: ctx.commands
|
|
9378
10143
|
});
|
|
9379
|
-
const settings = new DshSettingsAdapter(apiProxy, {
|
|
10144
|
+
const settings = new DshSettingsAdapter(apiProxy, {
|
|
10145
|
+
writeNamespaces: config.settingsWriteNamespaces ?? [],
|
|
10146
|
+
allowPrivateProviderHosts: config.allowPrivateProviderHosts ?? []
|
|
10147
|
+
});
|
|
9380
10148
|
let hostServiceReport;
|
|
9381
10149
|
try {
|
|
9382
10150
|
hostServiceReport = probeHostServices(ctx, PLUGIN_VERSION);
|