dsh-code-server-app 0.2.14 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bridge.mjs ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * lib/bridge.mjs — 「编辑器桥」的宿主侧基元(0.3.0)。
3
+ *
4
+ * 编辑器桥把树内扩展 `dshcs-editor-bridge` 与 DSH 连起来,让 agent 拿到只有编辑器才知道的
5
+ * 信息(未保存缓冲区、诊断、活动选区),并让用户在编辑器里的动作反过来驱动 DSH。
6
+ *
7
+ * ## 三条通道(为什么是这三种机制)
8
+ *
9
+ * - **配置/凭据,host → 扩展**:`<extensionsDir>/.dshcs-bridge/bridge.json`(原子写)。
10
+ * host 重启会让端口与令牌轮换,而 IDE 进程可能被 adopt 继续活着 —— 扩展必须能"每次请求前重读",
11
+ * 所以走文件而不是环境变量(env 在子进程启动那一刻就定死了)。令牌不进 argv(本机任意进程都能读
12
+ * 命令行),与 `path-token` 同一决策。
13
+ * - **扩展 → host:一个轮询里同时**上报编辑器状态、取回待处理事件**(`POST /bridge/sync`)。
14
+ * 扩展宿主里**没有 HTTP 服务器**(Node 的扩展宿主只是 VS Code server 的一个子进程,
15
+ * 不监听端口),所以 host **不能**反向请求它 —— 编辑器状态必须由扩展主动推上来。
16
+ * 反过来 host → 扩展 也不用 SSE/WS:`ctx.connection.fetch.register` 的 methods 只允许
17
+ * `GET | HEAD | POST`,流式要另走已被 `dsh-api-gateway` 占用的 WS mux。
18
+ * 于是轮询成了唯一干净的形态,而且它顺带给了两条性质:幂等(丢一次事件只是少一次提示,
19
+ * 数据本身永远在编辑器里)、以及**状态天然是最新的**(每次轮询都刷新缓存)。
20
+ * - **host → 扩展的"事件"**:同一个轮询的响应体(环形缓冲 + `since` 游标)。
21
+ * 事件只是"去看一眼这个文件"的提示,不是数据。
22
+ *
23
+ * ## 认证(关键:桥绕开了 DSH 的 cookie fence,所以自带令牌)
24
+ *
25
+ * `/api/*` 的 Host/Origin/cookie 校验由 Connection 在分发前做
26
+ * (`dsh-client-connection/lib/index.js`:`requestRejection` → 403 不可信 / 401 无 cookie)。
27
+ * 扩展宿主是 Node 进程:`fetch` **不带 Origin**,Host 是 `127.0.0.1:<port>`(在 trustedHosts 内)
28
+ * → 过 403;但它**拿不到浏览器 cookie** → 必然 401。所以桥路由必须自带独立令牌校验,
29
+ * 且**不能**依赖 Connection 的认证。反过来,凭令牌就能调用,因此:
30
+ *
31
+ * ## 安全不变量(改这个文件之前先读这四条)
32
+ *
33
+ * 1. **`/api/code-server/bridge/*` 命名空间永久只读。** 不允许出现任何写文件、改文档、执行命令、
34
+ * 拉起进程的路由 —— `bridge.json` 里那个令牌对本机同用户进程可读,爆炸半径必须封在
35
+ * "泄露编辑器里的信息",绝不能变成任意文件写 / 任意命令执行。
36
+ * 2. **带 `Origin` 的请求一律 403。** 浏览器发起的请求必带 Origin,扩展宿主进程不带。
37
+ * 这条只排除浏览器,不误伤扩展(也顺带挡住 CSRF / DNS rebinding 这类"用你的浏览器打本机端口")。
38
+ * 3. **路径收敛在编辑器当前工作区**(由扩展侧 `workspace.getWorkspaceFolder` 判定)。
39
+ * 4. **有界。** 事件缓冲 64 条、请求/响应体上限见下方常量。
40
+ */
41
+
42
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
43
+ import * as fs from 'node:fs';
44
+ import * as path from 'node:path';
45
+
46
+ /** 桥端点目录名(位于 extensionsDir 下 —— 扩展必须能读到它)。 */
47
+ export const BRIDGE_DIRNAME = '.dshcs-bridge';
48
+
49
+ /** 桥配置文件名。 */
50
+ export const BRIDGE_FILENAME = 'bridge.json';
51
+
52
+ /** 桥路由前缀(与既有 `/api/code-server/*` 同命名空间,便于统一审计)。 */
53
+ export const BRIDGE_BASE = '/api/code-server/bridge';
54
+
55
+ /** 事件环形缓冲上限:超出即丢最旧的(事件是提示,不是数据)。 */
56
+ export const EVENT_RING_MAX = 64;
57
+
58
+ /** 请求/响应体上限(超出直接拒绝,避免一次把内存吃满)。 */
59
+ export const MAX_BODY_BYTES = 256 * 1024;
60
+ export const MAX_UPSTREAM_BYTES = 256 * 1024;
61
+
62
+ /** 单次上游调用超时。 */
63
+ export const UPSTREAM_TIMEOUT_MS = 3000;
64
+
65
+ /** 令牌形状(与 `path-token` 同一字符集;VS Code 自己的 token 校验也接受)。 */
66
+ const TOKEN_RE = /^[0-9A-Za-z_-]{16,128}$/;
67
+
68
+ /** 令牌的名字:扩展读 bridge.json 拿它,host 用它命名请求头。**改这里必须同步扩展侧**
69
+ * (扩展是随包分发的静态文件,不做版本协商 —— 对齐靠两边同名常量 + 脚本测试里的一致性断言)。 */
70
+ export const BRIDGE_TOKEN_HEADER = 'x-dshcs-bridge-token';
71
+
72
+ /** 每次新启动 / 每次 adopt 都轮换的桥令牌(24 字节 → base64url 32 位)。 */
73
+ export function mintBridgeToken() {
74
+ return randomBytes(24).toString('base64url');
75
+ }
76
+
77
+ /**
78
+ * 由实际监听地址算桥的基址。
79
+ * @param {string} host 绑定地址(仅回环)
80
+ * @param {number} port 实际端口
81
+ */
82
+ export function bridgeUrl(host, port) {
83
+ const h = host === '::1' ? '[::1]' : host;
84
+ return `http://${h}:${port}`;
85
+ }
86
+
87
+ /** 桥配置文件路径(`<extensionsDir>/.dshcs-bridge/bridge.json`)。 */
88
+ export function bridgeFile(extensionsDir) {
89
+ return path.join(extensionsDir, BRIDGE_DIRNAME, BRIDGE_FILENAME);
90
+ }
91
+
92
+ /**
93
+ * 原子写桥配置。扩展每 5s 重读一次;需要时(端口/令牌变化)由 host 重写。
94
+ * @param {string} extensionsDir 扩展目录
95
+ * @param {{url: string, token: string, pid: number|null, startedAt: number|null}} value
96
+ */
97
+ export function writeBridgeConfig(extensionsDir, value) {
98
+ const file = bridgeFile(extensionsDir);
99
+ fs.mkdirSync(path.dirname(file), { recursive: true });
100
+ const payload = JSON.stringify({
101
+ version: 1,
102
+ url: value.url,
103
+ token: value.token,
104
+ pid: value.pid,
105
+ startedAt: value.startedAt,
106
+ writtenAt: Date.now(),
107
+ }, null, 2);
108
+ // 原子替换:扩展可能正好读到一半(Windows 上 rename 到已存在目标是覆盖语义)。
109
+ const tmp = `${file}.${process.pid}.tmp`;
110
+ fs.writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 });
111
+ try {
112
+ fs.renameSync(tmp, file);
113
+ } catch (err) {
114
+ fs.rmSync(tmp, { force: true });
115
+ throw err;
116
+ }
117
+ return file;
118
+ }
119
+
120
+ /** 删除桥配置(停止 IDE / 插件卸载时调用)。 */
121
+ export function removeBridgeConfig(extensionsDir) {
122
+ try {
123
+ fs.rmSync(bridgeFile(extensionsDir), { force: true });
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ /** 读回桥配置(诊断用;缺失或格式不对返回 null)。 */
131
+ export function readBridgeConfig(extensionsDir) {
132
+ try {
133
+ const raw = JSON.parse(fs.readFileSync(bridgeFile(extensionsDir), 'utf8'));
134
+ if (raw === null || typeof raw !== 'object') return null;
135
+ if (typeof raw.url !== 'string' || !TOKEN_RE.test(String(raw.token))) return null;
136
+ return raw;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /** 定长时间比较;长度不同直接 false(不泄露前缀信息)。 */
143
+ function tokenEquals(a, b) {
144
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
145
+ const ab = Buffer.from(a, 'utf8');
146
+ const bb = Buffer.from(b, 'utf8');
147
+ if (ab.length !== bb.length) return false;
148
+ return timingSafeEqual(ab, bb);
149
+ }
150
+
151
+ /** 浏览器发起的请求必带 Origin;`null` 是沙箱 iframe / data: 页面的字面量取值,同样是浏览器。 */
152
+ function originIsBrowser(origin) {
153
+ if (typeof origin !== 'string') return false;
154
+ if (origin === '') return false; // 有些代理会写成空串 —— 当"没有 Origin"处理
155
+ return true;
156
+ }
157
+
158
+ /**
159
+ * 桥路由的准入检查。
160
+ *
161
+ * 返回 `null` = 放行;返回 `Response` = 直接回给调用方。
162
+ * 顺序有意义:**先看 Origin**(浏览器一律拒绝,且不因为令牌碰巧对就放行 ——
163
+ * 否则等于给浏览器一个"令牌对不对"的 oracle),再看令牌。两者都不消耗资源,故放在最前面。
164
+ *
165
+ * @param {Request} request 桥路由收到的请求
166
+ * @param {string|null} expectedToken 当前桥令牌(未启用时 null)
167
+ */
168
+ export function bridgeGuard(request, expectedToken) {
169
+ // 不变量 2:浏览器发起必带 Origin(`null` 也算)。扩展宿主(Node)不带。
170
+ const origin = request.headers.get('origin');
171
+ if (originIsBrowser(origin)) {
172
+ return new Response(JSON.stringify({ ok: false, error: 'bridge 不接受带 Origin 的请求(浏览器一律拒绝)' }), {
173
+ status: 403,
174
+ headers: { 'content-type': 'application/json; charset=utf-8' },
175
+ });
176
+ }
177
+ if (typeof expectedToken !== 'string' || expectedToken === '') {
178
+ return new Response(JSON.stringify({ ok: false, error: '编辑器桥未启用' }), {
179
+ status: 503,
180
+ headers: { 'content-type': 'application/json; charset=utf-8' },
181
+ });
182
+ }
183
+ const provided = request.headers.get(BRIDGE_TOKEN_HEADER);
184
+ if (!tokenEquals(provided ?? '', expectedToken)) {
185
+ return new Response(JSON.stringify({ ok: false, error: 'unauthorized' }), {
186
+ status: 401,
187
+ headers: { 'content-type': 'application/json; charset=utf-8' },
188
+ });
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /**
194
+ * 调扩展侧 HTTP 面(仅回环)。
195
+ *
196
+ * 只发 GET/POST;**永远不带 Origin**(Node 默认行为),因此扩展侧同样能把浏览器挡在外面。
197
+ * `signal` 来自宿主工具调用的 `exec.signal`,取消即中断。
198
+ *
199
+ * @param {{base: string, token: string}} target 桥目标(启用时由 host 维护)
200
+ * @param {string} route 形如 `/api/code-server/bridge/context`
201
+ * @param {{method?: 'GET'|'POST', body?: unknown, query?: Record<string, string>, signal?: AbortSignal}} [options]
202
+ */
203
+ export async function callBridge(target, route, options = {}) {
204
+ const method = options.method ?? 'GET';
205
+ const url = new URL(route, target.base);
206
+ if (options.query !== undefined) {
207
+ for (const [key, value] of Object.entries(options.query)) {
208
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
209
+ }
210
+ }
211
+ const headers = { 'x-dshcs-bridge-token': target.token, accept: 'application/json' };
212
+ let body;
213
+ if (options.body !== undefined) {
214
+ body = JSON.stringify(options.body);
215
+ headers['content-type'] = 'application/json';
216
+ }
217
+ // 上游超时与调用方取消取先到者;AbortSignal.any 缺失时退化为仅上游超时(Node 24 有)。
218
+ const timeout = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
219
+ const signal = options.signal === undefined
220
+ ? timeout
221
+ : (typeof AbortSignal.any === 'function' ? AbortSignal.any([timeout, options.signal]) : timeout);
222
+ const response = await fetch(url, { method, headers, body, signal });
223
+ const text = await response.text();
224
+ if (!response.ok) {
225
+ throw new Error(`bridge ${route} → HTTP ${response.status}${text === '' ? '' : `: ${text.slice(0, 300)}`}`);
226
+ }
227
+ if (text.length > MAX_UPSTREAM_BYTES) throw new Error(`bridge ${route} 响应过大(${text.length} 字节)`);
228
+ const parsed = text === '' ? null : JSON.parse(text);
229
+ if (parsed === null || typeof parsed !== 'object') throw new Error(`bridge ${route} 响应不是 JSON 对象`);
230
+ if (parsed.ok === false) throw new Error(`bridge ${route}: ${parsed.error ?? '扩展未提供原因'}`);
231
+ return parsed;
232
+ }
233
+
234
+ /**
235
+ * 事件环形缓冲。host 侧唯一的事件源是 `ctx.on('tools/result')`。
236
+ *
237
+ * 语义:**不是可靠队列**。60/64 条只是让扩展在下一次轮询时"追上",超出即丢最旧的;
238
+ * host 重启后 seq 归零,扩展用 `since=0` 重新对齐(不承诺断点续传)。
239
+ */
240
+ export function createEventRing(max = EVENT_RING_MAX) {
241
+ /** @type {{seq: number, kind: string, time: number}[]} */
242
+ let items = [];
243
+ let nextSeq = 1;
244
+ return {
245
+ /** 推入一条事件(有界:超出丢最旧)。 */
246
+ push(kind, fields = {}) {
247
+ const event = { seq: nextSeq, kind, time: Date.now(), ...fields };
248
+ nextSeq += 1;
249
+ items.push(event);
250
+ if (items.length > max) items = items.slice(items.length - max);
251
+ return event;
252
+ },
253
+ /** 取 `seq > since` 的事件(游标轮询);`since` 非法按 0 处理。 */
254
+ since(seq) {
255
+ const from = Number.isSafeInteger(seq) && seq > 0 ? seq : 0;
256
+ return items.filter((e) => e.seq > from);
257
+ },
258
+ /** 最高已分配 seq(扩展用它判断是否有空洞 —— 空洞只意味着"漏了提示",不需要处理)。 */
259
+ lastSeq() {
260
+ return nextSeq - 1;
261
+ },
262
+ /** 当前缓冲条数(诊断用)。 */
263
+ size() {
264
+ return items.length;
265
+ },
266
+ /** 清空(IDE 进程换掉时调用)。 */
267
+ reset() {
268
+ items = [];
269
+ nextSeq = 1;
270
+ },
271
+ };
272
+ }
273
+
274
+ /**
275
+ * 校验桥请求体不超过上限(少数 route 需要;Connection 的 buffered 模式已有一层宽上限)。
276
+ * @param {unknown} value 已解析的 JSON
277
+ */
278
+ export function bodyWithinLimit(value) {
279
+ try {
280
+ return JSON.stringify(value ?? null).length <= MAX_BODY_BYTES;
281
+ } catch {
282
+ return false;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * 造一个「编辑器状态缓存」。
288
+ *
289
+ * 为什么需要它:扩展宿主**没有 HTTP 服务器**,host 反向请求不到它。所以编辑器状态由扩展在
290
+ * 每次轮询里推上来(见 `POST /bridge/sync`),这里缓存最近一份,agent 的工具调用来读它。
291
+ *
292
+ * 新鲜度:`/sync` 是 600ms 一次的轮询,所以缓存最多滞后一个轮询周期;缓存还带 `at`,
293
+ * 工具输出里会带上这个时间戳,让模型能判断"这是不是刚刚的状态"。
294
+ * 太久没更新(默认 10s)时标记 `stale` —— 比如用户在 IDE 里把面板关了、或扩展被禁用。
295
+ */
296
+ export function createContextCache(staleAfterMs = 10000) {
297
+ let snapshot = null;
298
+ return {
299
+ /** 扩展推上来的新状态(`{context, diagnostics}`)。 */
300
+ update(value) {
301
+ snapshot = {
302
+ context: value !== null && typeof value.context === 'object' ? value.context : null,
303
+ diagnostics: Array.isArray(value?.diagnostics) ? value.diagnostics : [],
304
+ at: Date.now(),
305
+ };
306
+ return snapshot;
307
+ },
308
+ /** 当前缓存(null = 还没有扩展上报过)。 */
309
+ get() {
310
+ return snapshot;
311
+ },
312
+ /** 是否已超过 `staleAfterMs` 没更新。 */
313
+ isStale() {
314
+ return snapshot === null || Date.now() - snapshot.at > staleAfterMs;
315
+ },
316
+ /** 距上次更新的毫秒数(null = 从未上报)。 */
317
+ ageMs() {
318
+ return snapshot === null ? null : Date.now() - snapshot.at;
319
+ },
320
+ clear() {
321
+ snapshot = null;
322
+ },
323
+ };
324
+ }
325
+
@@ -0,0 +1,91 @@
1
+ /**
2
+ * lib/dsh-resolve.mjs — 从 DSH 部署里解析 DSH 自己的包(0.3.0)。
3
+ *
4
+ * 插件**不**把 `@deepseek-ai/*` 写进 `dependencies`:它们是 DSH 的一部分,版本交给部署决定
5
+ * (与 schemastery 在 `lib/index.js` 顶部的同一决策)。常规 import 失败时按三个位置回退:
6
+ * 1. 常规 ESM 解析(开发期 profile 的 node_modules 里通常有);
7
+ * 2. npm 全局布局的 DSH 部署副本(`%APPDATA%\npm\node_modules\@deepseek-ai\dsh`);
8
+ * 3. `$DSH_HOME/profiles/node_modules`(profile 层级的安装位置)。
9
+ *
10
+ * 注意解析出来的模块与 DSH host 用的是**同一份**(同一路径的 require 缓存),所以
11
+ * `createUserMessage` 出来的对象能直接喂给 `agent.followup()`。
12
+ */
13
+
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import { createRequire } from 'node:module';
17
+
18
+ /** 按 2、3 两个布局给出 DSH 的入口 package.json(存在的才返回)。 */
19
+ function dshEntryCandidates() {
20
+ const candidates = [];
21
+ const appData = process.env.APPDATA;
22
+ if (typeof appData === 'string' && appData !== '') {
23
+ candidates.push(path.join(appData, 'npm', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'));
24
+ }
25
+ const home = process.env.DSH_HOME;
26
+ if (typeof home === 'string' && home !== '') {
27
+ candidates.push(path.join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'));
28
+ }
29
+ return candidates;
30
+ }
31
+
32
+ /** 第一个存在的 DSH 入口(诊断用;不存在返回 null)。 */
33
+ export function dshEntry() {
34
+ for (const candidate of dshEntryCandidates()) {
35
+ try {
36
+ if (fs.existsSync(candidate)) return candidate;
37
+ } catch {
38
+ // 下一个候选
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /** DSH 入口处的 require(用于 CJS 形态的包);都不可用返回 null。 */
45
+ export function dshRequire() {
46
+ const entry = dshEntry();
47
+ if (entry === null) return null;
48
+ try {
49
+ return createRequire(entry);
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * 解析一个 DSH 包(ESM import 优先,再回退 CJS require)。
57
+ * @param {string} name 包名,如 `@deepseek-ai/dsh-tools`
58
+ * @returns {Promise<object|null>} 模块命名空间,解析不到返回 null
59
+ */
60
+ export async function loadDshModule(name) {
61
+ try {
62
+ // 变量说明符:让打包器/静态检查不要把这行当成硬依赖。
63
+ const specifier = name;
64
+ const mod = await import(specifier);
65
+ if (mod !== null && mod !== undefined) return mod;
66
+ } catch {
67
+ // 回退到部署副本
68
+ }
69
+ const req = dshRequire();
70
+ if (req !== null) {
71
+ try {
72
+ const mod = req(name);
73
+ if (mod !== null && mod !== undefined) return mod;
74
+ } catch {
75
+ // 解析失败
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * 解析一个 DSH 包并取其中一个具名导出。
83
+ * @param {string} name 包名
84
+ * @param {string} exportName 导出名
85
+ */
86
+ export async function loadDshExport(name, exportName) {
87
+ const mod = await loadDshModule(name);
88
+ if (mod === null) return null;
89
+ const value = mod[exportName];
90
+ return value === undefined ? null : value;
91
+ }