dsh-code-server-app 0.3.46 → 0.3.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,2320 +1,2340 @@
1
- /**
2
- * dsh-code-server — host 半部:VS Code server 子进程(lib/launcher.mjs)的生命周期/状态 + /api/code-server JSON API。
3
- *
4
- * 模型:不运行 code-server 的 Node 服务层,直接由 lib/launcher.mjs 驱动树里的
5
- * <树>/lib/vscode/out/server-main.js(树 = @<scope>/dshcs-vscode-server 的 vscode/ 子目录);
6
- * 依据与实测见 docs/analysis-code-server-as-dsh-plugin.md。零外部依赖(只用 Node 内置模块)。
7
- * 静态 profile 插件:exports.name 与 cordis.patch.yml 行 id 一致;inject = ['connection','settings']。
8
- *
9
- * 路由经 ctx.connection.fetch.register 挂在 Connection 的共享 /api 通道上(**不依赖 webServer**):
10
- * web profile 由 Connection 把 /api 挂到 webServer;desktop 由 apps/desktop-host 交给同一个
11
- * createSharedFetchHandler('/api')(IPC 帧管道)→ 客户端只需同源 fetch('/api/code-server/<op>')。
12
- * GET status | POST start{cwd?} | stop | setup(兼容空操作) | open-file{file,line?} | ui-mode{sidebar}
13
- *
14
- * 不变量:进程生命周期归本插件(启动写 pid.json,停止树级终止);host 重启后按 pid.json + /healthz
15
- * adopt 存活实例(不重复启动、不误杀);auth 固定 none(0.2.0 起不再支持口令);插件销毁时回收进程与路由。
16
- * 旧版 DSH(客户端上报 {sidebar:false})会在此回收"自动预启动"的实例并停止预启动。
17
- */
18
-
19
- import { spawn, execFile, execFileSync } from 'node:child_process';
20
- import { randomBytes } from 'node:crypto';
21
- import * as fs from 'node:fs';
22
- import * as path from 'node:path';
23
- import * as os from 'node:os';
24
- import * as http from 'node:http';
25
- import { fileURLToPath } from 'node:url';
26
- import { createRequire } from 'node:module';
27
- import {
28
- PACKAGE_ROOT,
29
- codeServerPackageName,
30
- legacyInstallRoot,
31
- productPath,
32
- readTreeVersion,
33
- vendoredVersion,
34
- vendorReady,
35
- vsRoot,
36
- vsServerEntry,
37
- } from './vendor.js';
38
- import { ensureRuntimeLayout, nativeRuntimeStatus } from './native.js';
39
- import { MOUNT_PATH, mountOnWebServer } from './serve-dsh.mjs';
40
- import { DEFAULT_CLAIM_EXTENSIONS, describeClaimPolicy, normalizeClaimExtensions } from './claim-types.js';
41
- import {
42
- BRIDGE_BASE,
43
- BRIDGE_DIRNAME,
44
- bodyWithinLimit,
45
- bridgeGuard,
46
- createContextCache,
47
- createEventRing,
48
- mintBridgeToken,
49
- readBridgeConfig,
50
- removeBridgeConfig,
51
- writeBridgeConfig,
52
- } from './bridge.mjs';
53
- import { bridgeEndpointPath, startBridgeListener } from './bridge-ipc.mjs';
54
- import { registerEditorPrompt, registerEditorTools, setPromptLiveProbe } from './bridge-tools.mjs';
55
- import { deliverEditorPrompt } from './bridge-session.mjs';
56
- import { dshEntry } from './dsh-resolve.mjs';
57
- import { registerBridgeObserver } from './bridge-observe.mjs';
58
- import { createThreadRegistry } from './bridge-thread.mjs';
59
- import { createApprovalBoard, createApprovalInterceptor, DEFAULT_HOLD_MS, PANEL_OUTCOMES } from './bridge-approval.mjs';
60
-
61
- // schemastery 由 DSH 部署自带(官方核心依赖),仿 auto-open-web 的解析策略:
62
- // 常规 import 优先,不可用时回退到全局 npm 布局的 DSH 部署副本。
63
- let z = null;
64
- try {
65
- z = (await import('@deepseek-ai/schemastery')).default;
66
- } catch {
67
- try {
68
- const globalRoot = process.env.APPDATA ? path.join(process.env.APPDATA, 'npm', 'node_modules') : '';
69
- const dshEntry = path.join(globalRoot, '@deepseek-ai', 'dsh', 'package.json');
70
- if (fs.existsSync(dshEntry)) z = createRequire(dshEntry)('@deepseek-ai/schemastery');
71
- } catch {
72
- /* 两次解析均失败 → 下方抛错 */
73
- }
74
- }
75
- if (z === null || z === undefined) {
76
- throw new Error(
77
- '[code-server] schemastery not found (neither local nor DSH deployment); ' +
78
- 'this plugin cannot build its settings schema. Check the DSH deployment.',
79
- );
80
- }
81
-
82
- export const name = 'code-server';
83
- // connection:client↔host 通道(web 走 webServer 的 /api,desktop 走 IPC 帧管道);
84
- // settings:设置卡片的数据域。两者在 web 与 desktop profile 下都存在。
85
- export const inject = ['connection', 'settings'];
86
-
87
- /** 设置命名空间:卡片经官方 settings 域读写,持久化到官方 settings 文档。 */
88
- export const SETTINGS_NS = 'code-server';
89
-
90
- /** 设置卡片 schema(只留三个可改设置:claimExtensions / fullscreenOnOpen / keepResident)。
91
- * 旧设置文档里残留的 reserveComposer / windowedOpen / fileOpenScope 不再出现在 schema 里 ——
92
- * schemastery 的 object 非 strict 会把未知键原样 merge 进解析结果,既不报错也不影响取值,故无需迁移。 */
93
- export const Config = z.object({
94
- /** 服务方式:loopback(默认)独立回环端口;dsh 挂到 DSH webServer 的 /code-server
95
- * (同源、无额外端口、复用 DSH 的 Host/Origin + cookie 防护;需要 DSH 提供 webServer 服务,
96
- * 缺失或注册失败时自动回退 loopback)。没有卡片行:改 cordis.patch.yml 的 config.serve
97
- * 或设置文档里的 code-server.serve。 */
98
- serve: z.union([z.const('loopback'), z.const('dsh')]).default('loopback'),
99
- /** keepResident=true(默认)客户端在宿主 running 后把 IDE 预先加载到"停放区",
100
- * 切标签/收起侧栏不再重载;false 则只在打开面板时才加载(省内存)。 */
101
- keepResident: z.boolean().default(true),
102
- /** 认领类型(0.2.11):按扩展名决定哪些文件交给 VS Code 打开(分号分隔,`*` = 其余类型也认领,
103
- * `!ext` = 不认领,排除优先)。**不再区分 session/absolute 作用域** —— 所有
104
- * `dsh-resource://file/**` 地址一视同仁。语法、默认值与解析见 lib/claim-types.js。 */
105
- claimExtensions: z.string().default(DEFAULT_CLAIM_EXTENSIONS),
106
- /** fullscreenOnOpen=true(默认):打开 Code Server 标签(含点开产物/交付文件)时,
107
- * 客户端把右侧栏切到全屏(铺满窗口);false 则保持 DSH 默认的 push(与对话并排)。
108
- * 只影响"打开那一刻":之后用户点「退出全屏」不会被抢回去。 */
109
- fullscreenOnOpen: z.boolean().default(true),
110
- /** editorBridge=true(默认):启用「编辑器桥」(0.3.0)—— 树内扩展 dshcs-editor-bridge 与
111
- * host 之间建立**只读**通道,给 agent 提供 only-the-editor-knows 的上下文(未保存缓冲区、
112
- * 诊断、活动选区),并让编辑器里的动作能驱动 DSH。关闭后不写 bridge.json、不注册编辑器工具,
113
- * 扩展会休眠(它读不到配置就不做任何事)。安全模型见 lib/bridge.mjs 顶部。 */
114
- editorBridge: z.boolean().default(true),
115
- });
116
-
117
- const DEFAULT_CONFIG = {
118
- bin: '',
119
- host: '127.0.0.1',
120
- /** 0(默认)= 每次启动由系统分配空闲端口(launcher 把实际端口写进 endpoint 文件,host 读回);
121
- * 显式给端口则照用(用于需要固定地址的场景)。 */
122
- port: 0,
123
- auth: 'none',
124
- serve: 'loopback',
125
- userDataDir: '',
126
- extensionsDir: '',
127
- locale: '',
128
- readyTimeoutMs: 60000,
129
- /** 编辑器桥(0.3.0):见 Config.editorBridge。行配置与设置文档都可关;设置卡片暂不提供行。 */
130
- editorBridge: true,
131
- };
132
-
133
- const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
134
- const LOG_TAIL_MAX = 6000;
135
- /** 路径令牌格式(与 launcher 的一致;字符集同时满足 VS Code 自己的 token 校验)。 */
136
- const TOKEN_RE = /^[0-9A-Za-z_-]{16,128}$/;
137
-
138
- function dshHome() {
139
- return process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
140
- }
141
-
142
- function dataRoot(config) {
143
- return path.join(dshHome(), 'code-server');
144
- }
145
-
146
- function pidFile(config) {
147
- return path.join(dataRoot(config), 'pid.json');
148
- }
149
-
150
- /** 路径令牌文件(0600 语义:位于用户 profile 下,默认 ACL 仅本人可读)。 */
151
- function tokenFile(config) {
152
- return path.join(dataRoot(config), 'path-token');
153
- }
154
-
155
- /** launcher 回写实际监听地址的文件(端口 0 时 host 只能从这里知道端口)。 */
156
- function endpointFile(config) {
157
- return path.join(dataRoot(config), 'endpoint.json');
158
- }
159
-
160
- function isAlive(pid) {
161
- if (!Number.isInteger(pid) || pid <= 0) return false;
162
- try {
163
- process.kill(pid, 0);
164
- return true;
165
- } catch (err) {
166
- return err && err.code === 'EPERM';
167
- }
168
- }
169
-
170
- function win32() {
171
- return process.platform === 'win32';
172
- }
173
-
174
- /** 本插件的 package.json 版本(?v= 缓存击穿标记用;读不到就退回 'dev')。 */
175
- function pluginVersion() {
176
- try {
177
- return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version ?? 'dev';
178
- } catch {
179
- return 'dev';
180
- }
181
- }
182
-
183
- /** 插件自带 launcher(<pkg>/lib/launcher.mjs);它直接驱动 VS Code server(0.2.0 模型)。 */
184
- function launcherPath() {
185
- return path.join(path.dirname(fileURLToPath(import.meta.url)), 'launcher.mjs');
186
- }
187
-
188
- /** 扩展安装目标:`placement: 'builtin'` 时优先 VS Code「内置扩展」目录 = <树>/lib/vscode/extensions
189
- * (位于程序内置目录的扩展被 VS Code 视为内置——用户视图显示为"内置",**不能卸载**);
190
- * `placement: 'user'` 时只用 --extensions-dir(用户级扩展,可被用户禁用/卸载)。
191
- * 返回 { dst, builtin }——builtin=true 时是核心路径;找不到树时回退用户级。
192
- * `treeRoot` 可注入(测试用):默认 `vsRoot()`,注入后测试不会碰真实树。 */
193
- function extensionTarget(extensionsDir, name, placement = 'builtin', treeRoot = vsRoot()) {
194
- if (placement === 'builtin') {
195
- try {
196
- if (treeRoot !== null) {
197
- const vscodeExt = path.join(treeRoot, 'lib', 'vscode', 'extensions');
198
- if (fs.existsSync(vscodeExt)) {
199
- return { dst: path.join(vscodeExt, name), builtin: true };
200
- }
201
- }
202
- } catch { /* fall through */ }
203
- }
204
- return { dst: path.join(extensionsDir, name), builtin: false };
205
- }
206
-
207
- /** 树内自带扩展清单。
208
- * - dshcs-open-file:host 信号文件 → VS Code 打开文件(可带行号)。**内置**(用户不需要看到它)。
209
- * - dshcs-editor-bridge(0.3.0):编辑器桥的扩展侧。**必须内置**(0.3.12 修正;0.3.0–0.3.11 是用户级)。
210
- * 为什么"直接往 <extensions-dir> 拷目录"装不进用户级:VS Code 服务端启动时
211
- * `ExtensionsWatcher.initialize()` → `deleteExtensionsNotInProfiles()` 会把
212
- * 「在用户扩展目录里、但不在任何 profile 的 extensions.json 里」的扩展写进 `<extensions-dir>/.obsolete`
213
- * (服务端日志 `Marked extension as removed dsh-code-server-app.dshcs-editor-bridge-0.1.0`),
214
- * 扫描器从此不再看它;下一轮它自然又不在 profile 里 ⇒ **每启动一次就再标一次,自锁**。
215
- * 实测(本机 profile,10 次启动 10 条标记)与源码位置:`out/server-main.js` 的
216
- * `ExtensionsWatcher#initialize` / `ExtensionsScannerService#setExtensionsForRemoval`。
217
- * 内置目录(<树>/lib/vscode/extensions,与 dshcs-open-file 同处)不参与 profile 机制。
218
- * 用户的"关掉它"由插件设置 `editorBridge=false`(不挂桥、不注册工具)承担,不再依赖 VS Code 的卸载。 */
219
- const BUNDLED_EXTENSIONS = [
220
- { name: 'dshcs-open-file', placement: 'builtin' },
221
- { name: 'dshcs-editor-bridge', placement: 'builtin' },
222
- ];
223
-
224
- /** 递归列出扩展源目录里的文件(相对路径,posix 分隔)。
225
- * 为什么必须递归:编辑器桥(0.3.7 起)把纯逻辑放在 `lib/*.js` 里(便于单测,不 require('vscode')),
226
- * 只拷 `extension.js` 会让扩展加载即 `Cannot find module './lib/bridge-client.js'` —— 而 VS Code
227
- * 只会把这种失败记成一条 `Marked extension as removed`,界面上什么都没发生。
228
- * 跳过开发期杂物;`.dshcs-bridge` 是运行期元数据目录,不属于扩展本体。
229
- * 导出供 scripts/test-bridge-routes.mjs 直接验(它不启动 IDE,而安装发生在 start 里)。 */
230
- export function listExtensionFiles(srcDir) {
231
- const skip = new Set(['node_modules', '.git', '.dshcs-bridge', 'test', 'tests']);
232
- const out = [];
233
- const walk = (rel) => {
234
- const abs = rel === '' ? srcDir : path.join(srcDir, rel);
235
- for (const ent of fs.readdirSync(abs, { withFileTypes: true })) {
236
- if (skip.has(ent.name)) continue;
237
- const next = rel === '' ? ent.name : `${rel}/${ent.name}`;
238
- if (ent.isDirectory()) walk(next);
239
- else if (ent.isFile()) out.push(next);
240
- }
241
- };
242
- walk('');
243
- return out;
244
- }
245
-
246
- /** 清掉 VS Code 写给本插件扩展的"已移除"标记(`<extensions-dir>/.obsolete`)。
247
- * 机制:`ExtensionsWatcher.initialize()` 把"在用户扩展目录里、不在任何 profile 里"的扩展标成 removed,
248
- * 扫描器随后永远跳过它 ⇒ **自锁**(见 BUNDLED_EXTENSIONS 的注释)。0.3.0–0.3.11 的桥扩展就卡在这里:
249
- * 文件装全了、路由通了,扩展却一次都没被加载(`editor_context` 永远报"扩展还没有上报状态")。
250
- * 别的扩展的条目原样保留;清空后直接删掉文件(VS Code 需要时会自己重建)。
251
- * 导出供测试直接验。 */
252
- export function clearObsoleteMarkers(extensionsDir, ids) {
253
- const file = path.join(extensionsDir, '.obsolete');
254
- if (ids.length === 0 || !fs.existsSync(file)) return [];
255
- let map;
256
- try {
257
- map = JSON.parse(fs.readFileSync(file, 'utf8'));
258
- } catch {
259
- return [];
260
- }
261
- if (map === null || typeof map !== 'object' || Array.isArray(map)) return [];
262
- const cleared = [];
263
- for (const key of Object.keys(map)) {
264
- if (ids.some((id) => key === id || key.startsWith(`${id}-`))) {
265
- delete map[key];
266
- cleared.push(key);
267
- }
268
- }
269
- if (cleared.length === 0) return [];
270
- try {
271
- if (Object.keys(map).length === 0) fs.rmSync(file, { force: true });
272
- else fs.writeFileSync(file, JSON.stringify(map), 'utf8');
273
- } catch {
274
- // 清标记失败不该拦住安装:扩展仍会被扫描器跳过,但文件是新的(下次启动还会再试)
275
- }
276
- return cleared;
277
- }
278
-
279
- /** 内置扩展安装:每次启动调用 —— 缺失**或内容有变化**即同步(自愈,且插件升级后能更新已装的旧副本)。
280
- * 源目录里已不存在的文件会从目标里删掉(否则旧版本的 `lib/` 会一直留着)。
281
- * 同时清理"放错位置"的旧副本(用户级/内置两份同时存在会让 VS Code 打架),并清掉 `.obsolete` 自锁标记。
282
- * 导出供测试直接调(apply 期不装扩展,只有 start 才装);`options.treeRoot` 供测试注入假树。
283
- * @returns {{updated: string[], cleared: string[]}} `updated` = 本次真的写了文件/删了文件的扩展名
284
- * (adopt 路径据此判断"运行中的 IDE 里是旧代码");`cleared` = 本次清掉的 `.obsolete` 键。 */
285
- export function installBundledExtensions(extensionsDir, userDataDir, options = {}) {
286
- const here = path.dirname(fileURLToPath(import.meta.url));
287
- const treeRoot = options.treeRoot === undefined ? vsRoot() : options.treeRoot;
288
- const managedIds = [];
289
- const updated = [];
290
- for (const ext of BUNDLED_EXTENSIONS) {
291
- try {
292
- const src = path.join(here, '..', 'assets', 'extensions', ext.name);
293
- const manifest = path.join(src, 'package.json');
294
- if (!fs.existsSync(manifest)) continue;
295
- const manifestJson = JSON.parse(fs.readFileSync(manifest, 'utf8'));
296
- managedIds.push(`${manifestJson.publisher ?? 'dsh-code-server-app'}.${manifestJson.name ?? ext.name}`);
297
- const files = listExtensionFiles(src);
298
- const target = extensionTarget(extensionsDir, ext.name, ext.placement, treeRoot);
299
- const dst = target.dst;
300
- const stale = files.filter((name) => {
301
- const to = path.join(dst, name);
302
- if (!fs.existsSync(to)) return true;
303
- try {
304
- return !fs.readFileSync(path.join(src, name)).equals(fs.readFileSync(to));
305
- } catch {
306
- return true;
307
- }
308
- });
309
- if (stale.length > 0) {
310
- for (const name of stale) {
311
- const to = path.join(dst, name);
312
- fs.mkdirSync(path.dirname(to), { recursive: true });
313
- fs.copyFileSync(path.join(src, name), to);
314
- }
315
- }
316
- // 删除目标里多出来的文件(相对源目录):升级后旧文件不该留在扩展目录里
317
- const removed = [];
318
- if (fs.existsSync(dst)) {
319
- const wanted = new Set(files);
320
- const walkDst = (rel) => {
321
- const abs = rel === '' ? dst : path.join(dst, rel);
322
- for (const ent of fs.readdirSync(abs, { withFileTypes: true })) {
323
- const next = rel === '' ? ent.name : `${rel}/${ent.name}`;
324
- if (ent.isDirectory()) walkDst(next);
325
- else if (ent.isFile() && !wanted.has(next)) {
326
- fs.rmSync(path.join(dst, next), { force: true });
327
- removed.push(next);
328
- }
329
- }
330
- };
331
- walkDst('');
332
- }
333
- // 清理放错位置的副本:编辑器桥的旧用户级副本(0.3.11 及更早)/ dshcs-open-file 的旧用户级副本。
334
- const wrong = extensionTarget(extensionsDir, ext.name, ext.placement === 'builtin' ? 'user' : 'builtin', treeRoot);
335
- if (wrong.dst !== dst && fs.existsSync(wrong.dst)) {
336
- fs.rmSync(wrong.dst, { recursive: true, force: true });
337
- console.log(`[code-server] 清理放错位置的扩展副本 ${wrong.dst}(${ext.name} 应装在${target.builtin ? '内置' : '用户级'}目录)`);
338
- }
339
- console.log(`[code-server] bundled extension ${ext.name} -> ${dst}${target.builtin ? ' (内置,不可卸载)' : ' (用户级,可禁用)'}`
340
- + `${stale.length > 0 ? ` [更新 ${stale.join(',')}]` : ''}${removed.length > 0 ? ` [清理 ${removed.join(',')}]` : ''}`);
341
- if (stale.length > 0 || removed.length > 0) updated.push(ext.name);
342
- } catch (err) {
343
- console.warn(`[code-server] bundled extension ${ext.name} install failed:`, err && err.message ? err.message : String(err));
344
- }
345
- }
346
- const cleared = clearObsoleteMarkers(extensionsDir, managedIds);
347
- if (cleared.length > 0) {
348
- console.log(`[code-server] 清掉 VS Code 的"已移除"标记(.obsolete):${cleared.join(',')}`
349
- + '(留着的话扫描器会永远跳过这些扩展,编辑器桥就永远没有状态)');
350
- }
351
- void userDataDir;
352
- return { updated, cleared };
353
- }
354
-
355
- /** 打开文件信号文件:<user-data>/User/dshcs-open.json(扩展轮询此文件)。 */
356
- function openFileSignalPath(userDataDir) {
357
- return path.join(userDataDir, 'User', 'dshcs-open.json');
358
- }
359
-
360
- /** 已安装的内置扩展里最新的文件 mtime(ms);取不到返回 null。
361
- *
362
- * 用途:**adopt(接管正在运行的 IDE)时判断"里面跑的是不是升级前的旧代码"**。
363
- * 扩展代码只在扩展宿主进程启动时被加载一次 —— 文件是新的不代表跑着的进程里是新的。
364
- * `mtime > IDE 进程启动时间` 说明这些文件是在该进程起来之后写的,那个进程不可能加载过它们。
365
- * (可能偏保守:之后重载过窗口就没事了 —— 所以提示写成"可能/重载即可"。) */
366
- export function newestBundledExtensionMtime(extensionsDir, options = {}) {
367
- const treeRoot = options.treeRoot === undefined ? vsRoot() : options.treeRoot;
368
- let newest = null;
369
- for (const ext of BUNDLED_EXTENSIONS) {
370
- const target = extensionTarget(extensionsDir, ext.name, ext.placement, treeRoot);
371
- if (!fs.existsSync(target.dst)) continue;
372
- let files;
373
- try {
374
- files = listExtensionFiles(target.dst);
375
- } catch {
376
- continue;
377
- }
378
- for (const rel of files) {
379
- try {
380
- const at = fs.statSync(path.join(target.dst, rel)).mtimeMs;
381
- if (newest === null || at > newest) newest = at;
382
- } catch {
383
- // 单个文件 stat 失败不影响结论
384
- }
385
- }
386
- }
387
- return newest;
388
- }
389
-
390
- /** 插件 package.json 里声明的内部依赖(纯 JS 直装集;排除 VS Code 树包本身)。 */
391
- function declaredInnerDeps() {
392
- try {
393
- const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'));
394
- return Object.keys(manifest.dependencies ?? {})
395
- .filter((name) => !/^@[^/]+\/dshcs-(vscode-server|code-server)$/.test(name));
396
- } catch {
397
- return [];
398
- }
399
- }
400
-
401
- /** 从 VS Code 树位置解析内部依赖(与运行时同一套向上查找规则)。
402
- * 先试 `<name>/package.json`(ESM-only 包没有 require 入口),再退回 `<name>` 本身。 */
403
- function checkInnerDeps() {
404
- const declared = declaredInnerDeps();
405
- const root = vsRoot();
406
- const from = createRequire(path.join(root ?? PACKAGE_ROOT, 'package.json'));
407
- const missing = [];
408
- let resolved = 0;
409
- for (const name of declared) {
410
- let ok = false;
411
- for (const spec of [`${name}/package.json`, name]) {
412
- try {
413
- from.resolve(spec);
414
- ok = true;
415
- break;
416
- } catch { /* 试下一个 */ }
417
- }
418
- if (ok) resolved += 1;
419
- else missing.push(name);
420
- }
421
- return { declared: declared.length, resolved, missing };
422
- }
423
-
424
- /** 环境检测:VS Code 树 + server 入口 + 内部依赖 + 预编译原生包。
425
- * 返回 { ok, tree, entry, productPath, vscodeInner, innerDeps, treeVersion, vendored, upToDate, nativeRuntime, node, platform, arch }。 */
426
- function envCheck() {
427
- const root = vsRoot();
428
- const entry = vsServerEntry();
429
- const inner = checkInnerDeps();
430
- const natives = nativeRuntimeStatus();
431
- const installed = root !== null ? readTreeVersion(root) : null;
432
- const vendored = vendoredVersion();
433
- return {
434
- ok: entry !== null && inner.missing.length === 0
435
- && natives.resolved > 0 && natives.missing.length === 0,
436
- tree: root !== null ? root.replace(/\\/g, '/') : null,
437
- entry: entry !== null ? entry.replace(/\\/g, '/') : null,
438
- productPath: productPath(root),
439
- vscodeInner: inner.missing.length === 0,
440
- innerDeps: { declared: inner.declared, resolved: inner.resolved, missing: inner.missing },
441
- treeVersion: installed,
442
- vendored,
443
- upToDate: installed !== null && vendored !== null && installed === vendored,
444
- // 本平台预编译原生产物(重打包子包按真名装进依赖图,运行时按原名补 junction)
445
- nativeRuntime: {
446
- name: natives.name,
447
- source: natives.source,
448
- version: natives.version,
449
- packages: natives.resolved,
450
- missing: natives.missing,
451
- },
452
- node: process.version,
453
- platform: process.platform,
454
- arch: process.arch,
455
- };
456
- }
457
-
458
- /** 解析启动方式:
459
- * - 默认:插件自带 launcher(node lib/launcher.mjs)+ 内置 VS Code 树;
460
- * - `bin` 显式配置时按旧模型当逃生舱:可执行文件 / code-server 的 out/node/entry.js。 */
461
- function resolveLaunch(config, serve) {
462
- const configured = typeof config.bin === 'string' && config.bin !== '' && config.bin !== DEFAULT_CONFIG.bin
463
- ? config.bin
464
- : null;
465
- if (configured === null) {
466
- const root = vsRoot();
467
- if (root === null || vsServerEntry() === null) {
468
- throw new Error(
469
- '找不到 VS Code 树(缺 lib/vscode/out/server-main.js)。请重新安装插件'
470
- + '(`dsh plugin --profile web add dsh-code-server-app`),'
471
- + '开发期先用 `node scripts/vendor-vscode-server.mjs --dev-links` 生成 vendor/vscode。',
472
- );
473
- }
474
- return { kind: 'launcher', script: launcherPath(), tree: root };
475
- }
476
- console.log(`[code-server] resolveLaunch: 使用显式配置的 bin=${configured}(launcher 旁路)`);
477
- if (/\.(js|mjs|cjs)$/i.test(configured)) {
478
- if (!fs.existsSync(configured)) throw new Error(`配置的入口不存在: ${configured}`);
479
- return { kind: 'node', script: configured };
480
- }
481
- if (path.isAbsolute(configured)) return { kind: 'bin', command: configured };
482
- try {
483
- if (win32()) {
484
- const out = execFileSync('where.exe', [configured], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
485
- const lines = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
486
- return { kind: 'bin', command: lines.find((l) => /\.cmd$/i.test(l)) ?? lines.find((l) => /\.exe$/i.test(l)) ?? lines[0] };
487
- }
488
- const out = execFileSync('which', [configured], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
489
- return { kind: 'bin', command: out.split('\n')[0].trim() };
490
- } catch {
491
- throw new Error(`配置的 bin 未找到("${configured}" 不在 PATH)`);
492
- }
493
- }
494
-
495
- /** 命名管道名(仅 dsh 模式使用):按 profile/pid 稳定,避免撞名。 */
496
- function pipeName() {
497
- const key = `${process.pid}`;
498
- return win32() ? `\\\\.\\pipe\\dshcs-vscode-${key}` : path.join(os.tmpdir(), `dshcs-vscode-${key}.sock`);
499
- }
500
-
501
- function healthCheck(host, port, token, timeoutMs = 1500) {
502
- const requestPath = token === null || token === undefined ? '/healthz' : `/${token}/healthz`;
503
- return new Promise((resolve) => {
504
- const req = http.get({ host, port, path: requestPath, timeout: timeoutMs, method: 'GET' }, (res) => {
505
- res.resume();
506
- resolve({ ok: res.statusCode >= 200 && res.statusCode < 500, statusCode: res.statusCode });
507
- });
508
- req.on('error', () => resolve({ ok: false }));
509
- req.on('timeout', () => {
510
- req.destroy();
511
- resolve({ ok: false });
512
- });
513
- });
514
- }
515
-
516
- /** 命名管道上的 /healthz 探针(dsh 模式;管道由 launcher 监听)。 */
517
- function healthCheckPipe(pipe, timeoutMs = 1500) {
518
- return new Promise((resolve) => {
519
- const req = http.request({ socketPath: pipe, path: '/healthz', method: 'GET', timeout: timeoutMs }, (res) => {
520
- res.resume();
521
- resolve({ ok: res.statusCode >= 200 && res.statusCode < 500, statusCode: res.statusCode });
522
- });
523
- req.on('error', () => resolve({ ok: false }));
524
- req.on('timeout', () => {
525
- req.destroy();
526
- resolve({ ok: false });
527
- });
528
- req.end();
529
- });
530
- }
531
-
532
- function readPidFile(config) {
533
- try {
534
- const raw = JSON.parse(fs.readFileSync(pidFile(config), 'utf8'));
535
- return raw && typeof raw.pid === 'number' ? raw : null;
536
- } catch {
537
- return null;
538
- }
539
- }
540
-
541
- function writePidFile(config, record) {
542
- fs.mkdirSync(path.dirname(pidFile(config)), { recursive: true });
543
- fs.writeFileSync(pidFile(config), JSON.stringify(record, null, 2), 'utf8');
544
- }
545
-
546
- function removePidFile(config) {
547
- try {
548
- fs.rmSync(pidFile(config), { force: true });
549
- } catch {
550
- // ignore
551
- }
552
- }
553
-
554
- /** 读路径令牌;文件缺失或格式不对返回 null(不抛:调用方据此判定"没有可接管的实例")。 */
555
- function readToken(config) {
556
- try {
557
- const raw = fs.readFileSync(tokenFile(config), 'utf8').trim();
558
- return TOKEN_RE.test(raw) ? raw : null;
559
- } catch {
560
- return null;
561
- }
562
- }
563
-
564
- /** 生成并写入新的路径令牌(每次**新启动**都换;原子写,0600)。 */
565
- function rotateToken(config) {
566
- const token = randomBytes(24).toString('base64url'); // [A-Za-z0-9_-] 共 32 位
567
- const file = tokenFile(config);
568
- fs.mkdirSync(path.dirname(file), { recursive: true });
569
- const tmp = `${file}.${process.pid}.tmp`;
570
- fs.writeFileSync(tmp, token, { encoding: 'utf8', mode: 0o600 });
571
- fs.renameSync(tmp, file);
572
- return token;
573
- }
574
-
575
- /** 读 launcher 回写的监听端点(端口 0 时唯一可信来源);缺失/不可用返回 null。 */
576
- function readEndpoint(config) {
577
- try {
578
- const raw = JSON.parse(fs.readFileSync(endpointFile(config), 'utf8'));
579
- return raw && Number.isInteger(raw.pid) && Number.isInteger(raw.port) && raw.port > 0 ? raw : null;
580
- } catch {
581
- return null;
582
- }
583
- }
584
-
585
- export async function apply(ctx, config) {
586
- const cfg = { ...DEFAULT_CONFIG, ...(config ?? {}) };
587
-
588
- // ---- 设置:行配置为种子;settings 命名空间持久化(设置卡片写入) ----
589
- const settingsSvc = ctx.get('settings');
590
- // connection 是 host↔client 通道(必需);提前取出,供 DSH 同源挂载的 fence 使用。
591
- const connection = ctx.get('connection');
592
- if (connection === undefined || connection.fetch === undefined) {
593
- console.error('[code-server] connection service unavailable; plugin registered but idle');
594
- return;
595
- }
596
- let serveSetting = 'loopback';
597
- let keepResident = true; // 客户端常驻预热(0.2.2)
598
- let claimExtensions = DEFAULT_CLAIM_EXTENSIONS; // 认领类型清单(0.2.11,取代 fileOpenScope)
599
- let fullscreenOnOpen = true; // 客户端"打开标签即全屏右侧栏"(0.2.9)
600
- /** 编辑器桥开关(0.3.0):行配置 cfg.editorBridge 是种子,设置文档里可改并实时生效。 */
601
- let bridgeSetting = cfg.editorBridge !== false;
602
- if (settingsSvc !== undefined && typeof settingsSvc.register === 'function') {
603
- try {
604
- const scope = settingsSvc.register(SETTINGS_NS, Config);
605
- const rawDoc = settingsSvc.get(SETTINGS_NS);
606
- if (rawDoc !== undefined && rawDoc !== null) {
607
- const resolved = scope.get();
608
- serveSetting = resolved && resolved.serve === 'dsh' ? 'dsh' : 'loopback';
609
- keepResident = resolved && typeof resolved.keepResident === 'boolean' ? resolved.keepResident : true;
610
- claimExtensions = resolved && typeof resolved.claimExtensions === 'string'
611
- ? normalizeClaimExtensions(resolved.claimExtensions)
612
- : DEFAULT_CLAIM_EXTENSIONS;
613
- fullscreenOnOpen = resolved && typeof resolved.fullscreenOnOpen === 'boolean' ? resolved.fullscreenOnOpen : true;
614
- bridgeSetting = resolved && typeof resolved.editorBridge === 'boolean' ? resolved.editorBridge : bridgeSetting;
615
- }
616
- scope.watch((next) => {
617
- if (next != null && (next.serve === 'dsh' || next.serve === 'loopback')) {
618
- if (next.serve !== serveSetting) {
619
- serveSetting = next.serve;
620
- console.log(`[code-server] serve updated: ${serveSetting}(下次启动生效)`);
621
- }
622
- }
623
- if (next != null && typeof next.editorBridge === 'boolean') {
624
- if (next.editorBridge !== bridgeSetting) {
625
- bridgeSetting = next.editorBridge;
626
- console.log(`[code-server] editorBridge updated: ${bridgeSetting}`);
627
- // 关掉 → 立刻下线(关监听口 + 删配置 + 注销工具);开启 → 起监听口,IDE 在跑就补一份配置。
628
- if (bridgeSetting === false) {
629
- clearBridgeRuntime();
630
- void stopBridgeListener();
631
- } else {
632
- void ensureBridgeListener().then(() => {
633
- if (state.status === 'running' && state.pid !== null) {
634
- bridgeToken ??= mintBridgeToken();
635
- syncBridgeRuntime({ pid: state.pid, startedAt: state.startedAt });
636
- }
637
- });
638
- }
639
- }
640
- }
641
- if (next != null && typeof next.keepResident === 'boolean') {
642
- keepResident = next.keepResident;
643
- console.log(`[code-server] keepResident updated: ${keepResident}`);
644
- }
645
- if (next != null && typeof next.claimExtensions === 'string') {
646
- const normalized = normalizeClaimExtensions(next.claimExtensions);
647
- if (normalized !== claimExtensions) {
648
- claimExtensions = normalized;
649
- console.log(`[code-server] claimExtensions updated: ${claimExtensions} (${describeClaimPolicy(claimExtensions)})`);
650
- }
651
- }
652
- if (next != null && typeof next.fullscreenOnOpen === 'boolean') {
653
- if (next.fullscreenOnOpen !== fullscreenOnOpen) {
654
- fullscreenOnOpen = next.fullscreenOnOpen;
655
- console.log(`[code-server] fullscreenOnOpen updated: ${fullscreenOnOpen}`);
656
- }
657
- }
658
- });
659
- } catch (error) {
660
- console.error(`[code-server] settings unavailable; using defaults (claimExtensions=${DEFAULT_CLAIM_EXTENSIONS}, fullscreenOnOpen=true, keepResident=true): ${error.message}`);
661
- }
662
- }
663
-
664
- // 依赖布局自愈必须发生在任何解析之前:精简树不含 lib/vscode/node_modules,
665
- // 由这里按插件依赖图补 junction(幂等;重装后自愈)。
666
- const layout = ensureRuntimeLayout();
667
-
668
- // ---- 服务方式:settings/行配置请求 dsh,但只有本 deployment 真的提供 webServer 时才用 ----
669
- let webServerSvc = ctx.get('webServer');
670
- let dshMount = null; // { disposers, upgradePath } —— 由下面的 ctx.inject 填充
671
-
672
- /** 生效的服务方式:loopback(默认,独立回环端口)| dsh(DSH 同源挂载)。 */
673
- function requestedServe() {
674
- const want = serveSetting === 'dsh' || cfg.serve === 'dsh' ? 'dsh' : 'loopback';
675
- if (want === 'loopback') return 'loopback';
676
- if (webServerSvc === undefined || dshMount === null) {
677
- console.warn('[code-server] serve=dsh 但 webServer 不可用/挂载失败 → 回退 loopback');
678
- return 'loopback';
679
- }
680
- return 'dsh';
681
- }
682
-
683
- const state = {
684
- status: 'stopped', // stopped | starting | running | stopping | error
685
- pid: null,
686
- port: cfg.port,
687
- /** 本实例的路径令牌(回环模式;不写日志、不进 argv)。 */
688
- token: null,
689
- cwd: null,
690
- /** 进程**启动时**的工作目录(诊断用):运行时切工作区只改 cwd,进程 cwd 保持这里不变。 */
691
- launchCwd: null,
692
- version: null,
693
- error: null,
694
- logTail: '',
695
- startedAt: null,
696
- adopted: false,
697
- serve: 'loopback', // 实际生效的服务方式(loopback | dsh)
698
- pipe: null, // dsh 模式下的命名管道
699
- /** 客户端上报的 UI 载体能力:null=未上报 | true=带右侧栏(受支持) | false=旧版 DSH(只提示,不预启动) */
700
- sidebarUi: null,
701
- /** 本实例是否由插件的自动预启动拉起(旧版 DSH 上报后据此回收,不动用户/被 adopt 的实例) */
702
- prestarted: false,
703
- env: envCheck(), // 环境检测(VS Code 树 / server 入口 / 内部依赖 / 预编译原生包)
704
- setup: { running: false, done: true, ok: true, logTail: '0.1.36 起由包管理器安装依赖,无需「安装环境」步骤', startedAt: null, finishedAt: null }, // 兼容旧客户端
705
- };
706
-
707
- let child = null;
708
- let pollTimer = null;
709
- let disposeKilled = false;
710
-
711
- // ---- 编辑器桥(0.3.0):配置/凭据写在 <extensionsDir>/.dshcs-bridge/bridge.json ----
712
- // 这里只算路径;是否启用还要看 cfg.editorBridge 与 setting 值。详见 lib/bridge.mjs 顶部。
713
- const bridgeRoot = dataRoot(cfg);
714
- const bridgeUserDataDir = cfg.userDataDir || path.join(bridgeRoot, 'user-data');
715
- const bridgeExtensionsDir = cfg.extensionsDir || path.join(bridgeRoot, 'extensions');
716
- const bridgeMetaDir = path.join(bridgeExtensionsDir, BRIDGE_DIRNAME);
717
- /** 已写入的桥配置(供桥路由与 /status 读回);null = 未启用/未就绪。 */
718
- let bridgeMeta = null;
719
- /** 桥工具(editor_context / editor_diagnostics)的 disposer;null = 未注册。 */
720
- let bridgeToolDispose = null;
721
- /** 提示词段落的 disposer(只注册一次;文本按桥是否存活渲染)。 */
722
- let bridgePromptDispose = null;
723
- /** agent 写操作观察器的 disposer。 */
724
- let bridgeObserveDispose = null;
725
- /** 会话流订阅 / 授权拦截的 disposer。 */
726
- let bridgeAnswerDispose = null;
727
- /** 推给编辑器的事件环形缓冲(tools/result → 扩展轮询)。 */
728
- const bridgeEvents = createEventRing();
729
- /** 编辑器状态缓存:扩展在每次 /sync 里推上来,agent 的工具调用来读它。 */
730
- const bridgeContext = createContextCache();
731
- /** 被面板观看的会话 → 对话条目流(只渲染新内容;见 lib/bridge-thread.mjs)。 */
732
- const bridgeThread = createThreadRegistry({
733
- resolveController: () => {
734
- try {
735
- return ctx.get('sessionController');
736
- } catch {
737
- return undefined;
738
- }
739
- },
740
- log: (message) => console.warn(`[code-server] 编辑器桥会话流:${message}`),
741
- });
742
- /** 面板的授权决策登记表 + agent 作用域拦截器(见 lib/bridge-approval.mjs)。 */
743
- const bridgeApprovalBoard = createApprovalBoard();
744
- /**
745
- * 谁在"看着这个会话" —— 授权拦截据此决定要不要抢答(没人看就直接交给官方链路):
746
- * - `ids`:编辑器扩展面板声明要看的会话(每趟 /sync 上报);
747
- * - `dialogIds`:DSH 页面里的对话框(0.3.24)声明要看的会话;
748
- * - `at`:最近一次声明的时间(超过 5s 视为没人看)。
749
- */
750
- const bridgeWatch = { ids: [], dialogIds: [], at: 0 };
751
- /** 上次回给扩展的对话流修订号(0.3.27:没变化就不重传整份快照)。 */
752
- let bridgeThreadRevSent = -1;
753
- /** 上次记录过的"有没有人在看"(0.3.30:状态翻转才写诊断日志)。 */
754
- let bridgeWatchLogged = false;
755
- /** 首帧是否已写过诊断(0.3.31:"一直没人看"也要留下证据)。 */
756
- let bridgeWatchWroteFirst = false;
757
- /** 上次写过的 hasPanel 判据原文(变了才写,避免每 600ms 刷屏)。 */
758
- let bridgeHasPanelLine = '';
759
- /** 客户端(self-declared)是否能把授权卡片画出来(0.3.30)。 */
760
- let bridgeApprovalsUi = false;
761
- let bridgeApprovalsUiLogged = false;
762
-
763
- /**
764
- * 授权/看护状态的**诊断日志**(0.3.30):写到 <home>/.dsh/code-server/bridge-approval.log。
765
- * "卡片为什么不出现"只有三个问题:有没有人在看 / 请求到没到 / 谁答的 —— 三件事都在宿主进程里,
766
- * 用户看不到、我也读不到;落成有界文件后一次复现就能定论。
767
- */
768
- function bridgeDiag(message) {
769
- try {
770
- const dir = path.join(os.homedir(), '.dsh', 'code-server');
771
- fs.mkdirSync(dir, { recursive: true });
772
- const file2 = path.join(dir, 'bridge-approval.log');
773
- try {
774
- if (fs.statSync(file2).size > 256 * 1024) fs.writeFileSync(file2, '');
775
- } catch {
776
- // 首次写入:文件不存在
777
- }
778
- fs.appendFileSync(file2, new Date().toISOString() + ' ' + message + '\n');
779
- } catch {
780
- // 诊断失败绝不影响主流程
781
- }
782
- }
783
-
784
- /** 当前所有"在看"的会话(去重)。 */
785
- function watchedSessions() {
786
- const all = [...bridgeWatch.ids, ...bridgeWatch.dialogIds];
787
- return [...new Set(all.filter((id) => typeof id === 'string' && id !== ''))].slice(0, 4);
788
- }
789
- /** 现在有没有人在看(授权拦截的唯一判据)。 */
790
- function hasWatcher() {
791
- const sessions = watchedSessions();
792
- if (sessions.length === 0) return false;
793
- // **对话框是宿主侧状态**(只在 /ask/close 时消失):不拿轮询新鲜度去判它 ——
794
- // 浏览器把后台标签的定时器节流到分钟级时,900ms 的心跳一断、5 秒窗口就过期,
795
- // 我们会误判"没人看"而放弃拦截(用户实测:授权卡片因此跑到 DSH 界面)。
796
- if (bridgeWatch.dialogIds.length > 0) return true;
797
- // 编辑器面板靠 /sync 的 600ms 心跳;窗口放宽到 60 秒(正常心跳远小于它)。
798
- return Date.now() - bridgeWatch.at < 60000;
799
- }
800
-
801
- const bridgeApproval = createApprovalInterceptor({
802
- board: bridgeApprovalBoard,
803
- holdMs: DEFAULT_HOLD_MS,
804
- // **只在客户端能画卡片时才抢答**(0.3.30):面板/对话框在看 **且** 它自己声明 approvalsUi。
805
- // 反例(用户实测):面板处于"宿主没有对话流能力"的坏状态时我们仍然抢答 ⇒ 官方卡片不出现、
806
- // 面板也不画 ⇒ 用户看到的是"没弹出授权",请求在宿主里干等 5 分钟。
807
- // 客户端能画卡片 = 编辑器面板自报可以的 **或** 对话框开着且有会话(0.3.33)。
808
- // "活着"由 /ask/state 轮询证明)。0.3.31 的日志给出了反例:用户从**对话框**提问 ⇒
809
- // 编辑器面板是 null ⇒ 旧判据误判成"画不出卡片",授权被让给了 DSH 界面。
810
- hasPanel: () => {
811
- const watcher = hasWatcher();
812
- const dialogCapable = askDialogLive();
813
- const verdict = watcher && (bridgeApprovalsUi || dialogCapable);
814
- // 判据的**原始值**一起落日志(0.3.35):只记结论的话,出问题还是只能猜。
815
- const line = 'hasPanel -> ' + verdict + ' (hasWatcher=' + watcher + ' approvalsUi=' + bridgeApprovalsUi
816
- + ' dialogLive=' + dialogCapable + ' polls=' + askDialog.polls + ' open=' + askDialog.open
817
- + ' session=' + String(askDialog.sessionId) + ')';
818
- if (line !== bridgeHasPanelLine) { bridgeHasPanelLine = line; bridgeDiag(line); }
819
- return verdict;
820
- },
821
- log: (message) => {
822
- console.warn(`[code-server] 编辑器桥授权:${message}`);
823
- bridgeDiag(`approval: ${message}`);
824
- },
825
- });
826
-
827
- /**
828
- * 「问 DSH」对话框的宿主侧状态(0.3.24)。
829
- *
830
- * 对话渲染从编辑器的 webview 面板搬到了 **DSH 页面里的浮动对话框**(client 半部,见 src/factory.js):
831
- * 面板是编辑器里的一个 tab/一列,而用户要的是"像 DSH 那样的对话框"。host 这边只负责:
832
- * - 打开/关闭状态(编辑器扩展右键 → 桥 `/event` 上报 `ask-open`);
833
- * - 状态与动作的 HTTP 面(`/api/code-server/ask/*`,与 DSH 同源、吃它的 cookie/Origin 校验);
834
- * - 把面板产物(thread.js / thread.css)交给对话框注入(Lib 版本与 DSH 界面同版本,见 build-webview.mjs)。
835
- */
836
- const askDialog = { open: false, armed: false, mode: 'selection', at: 0, sessionId: null, polls: 0, lastPollAt: 0 };
837
-
838
- /**
839
- * 对话框"活着吗" —— **能力位必须以此为准**(0.3.25)。
840
- *
841
- * 0.3.24 的教训:宿主无条件报 `askDialog: true`,扩展于是不再开编辑器面板;而客户端半部只要没跑起来
842
- * (浏览器还缓存着旧 bundle / 没硬刷新),就**没有任何一方在看这个会话** ⇒ 授权悄悄落到 DSH 界面的
843
- * 官方卡片,用户在编辑器里什么都看不到。现在只有**真的收到过 `/ask/state` 轮询**(5 秒内)才算有
844
- * 这个能力;否则扩展退回编辑器面板 —— 那是验证过、能看能点的 UI。
845
- */
846
- function askDialogLive() {
847
- // 对话框每 900ms 问一次 /ask/state —— 那就是"活跃心跳"。心跳一旦建立,在 TTL 内
848
- // **整个 DSH 侧都把授权拦截到编辑器侧**(用户的要求:对话框开着就持续发活跃通知)。
849
- // TTL 取 10 分钟:浏览器后台节流会把 900ms 拖到分钟级,短 TTL 会让"人就在编辑器旁边"
850
- // 被误判成"没人看"(实测连续踩了两轮)。
851
- const TTL_MS = 10 * 60 * 1000;
852
- // **必须在"武装"状态下**才算有能力显示卡片(0.3.40):
853
- // 只认"最近轮询过"会让**已经关掉的对话框**在 10 分钟内继续被当成能显示卡片的 UI ——
854
- // 那时我们仍会接住请求,而卡片无处显示(就是用户说的"没弹出授权")。
855
- // armed 在 /ask/state 轮询时置位,在 /ask/close 时立刻清除 ⇒ 关掉之后下一次判据复查(≤500ms)
856
- // 就会把正在等待的请求交回官方卡片。
857
- if (askDialog.armed === true && askDialog.polls > 0 && Date.now() - askDialog.lastPollAt < TTL_MS) return true;
858
- // 心跳还没建立(刚重启)时:对话框开着且有会话也算。
859
- return askDialog.open && askDialog.sessionId !== null;
860
- }
861
-
862
- /** 缓存里的活动编辑器 → 提问上下文(mode 决定带不带行号/选区,与扩展侧 0.3.21 的语义一致)。 */
863
- function askContextFromCache(mode) {
864
- const cached = bridgeContext.get();
865
- const active = cached === null ? null : cached.context?.active ?? null;
866
- if (active === null || active === undefined) return null;
867
- const selection = active.selection ?? null;
868
- const selectedText = typeof active.selectedText === 'string' ? active.selectedText : '';
869
- const hasSelection = mode === 'selection' && selection !== null && selectedText !== '';
870
- return {
871
- file: typeof active.path === 'string' && active.path !== '' ? active.path : null,
872
- lineStart: hasSelection ? selection.startLine ?? null : null,
873
- lineEnd: hasSelection ? selection.endLine ?? null : null,
874
- selection: hasSelection ? selectedText : null,
875
- languageId: typeof active.language === 'string' && active.language !== '' ? active.language : null,
876
- };
877
- }
878
-
879
- /** 对话框那一行上下文描述(标题栏用)。 */
880
- function askContextLine(mode) {
881
- const context = askContextFromCache(mode);
882
- if (context === null || context.file === null) return '来自编辑器';
883
- const base = context.file.replace(/^.*[\\/]/, '');
884
- const where = Number.isSafeInteger(context.lineStart)
885
- ? `${base}:${context.lineStart}${Number.isSafeInteger(context.lineEnd) && context.lineEnd !== context.lineStart ? `-${context.lineEnd}` : ''}`
886
- : base;
887
- return mode === 'file' ? `来自编辑器:${where} · 针对当前文件` : `来自编辑器:${where} · 针对选中内容`;
888
- }
889
-
890
- /**
891
- * 对话框要的状态 —— **形状必须与面板载荷一致**(`lib/ask-panel.js` 的 `applyPayload`:
892
- * 扁平的 entries / approvals / …),因为对话框里跑的就是同一个面板应用。
893
- */
894
- function askState() {
895
- const watch = watchedSessions();
896
- const snapshot = bridgeThread.supported()
897
- ? bridgeThread.snapshot(watch)
898
- : { available: false, sessionId: null, cursor: null, rev: 0, entries: [], error: '宿主没有 sessionController.follow(DSH 版本过旧)' };
899
- return {
900
- ok: true,
901
- open: askDialog.open,
902
- mode: askDialog.mode,
903
- contextText: askContextLine(askDialog.mode),
904
- entries: Array.isArray(snapshot.entries) ? snapshot.entries : [],
905
- available: snapshot.available === true,
906
- threadError: typeof snapshot.error === 'string' && snapshot.error !== '' ? snapshot.error : null,
907
- approvals: bridgeApprovalBoard.snapshot(),
908
- approvalHoldMs: DEFAULT_HOLD_MS,
909
- status: 'idle',
910
- statusText: '',
911
- error: null,
912
- sessionId: snapshot.sessionId ?? askDialog.sessionId ?? null,
913
- uiVersion: uiVersion(),
914
- dshVersion: dshVersion(),
915
- };
916
- }
917
-
918
- /** 修订号:对话条目 / 待决授权 / 开关状态任何一处变了就变。 */
919
- function askRevision() {
920
- return bridgeThread.rev(watchedSessions()) * 8 + bridgeApprovalBoard.rev() * 8 + (askDialog.open ? 1 : 0) + (askDialog.mode === 'file' ? 2 : 0);
921
- }
922
-
923
- /** 对话框刷新它的"我在看"时间戳(每趟 /state 都算一次)。 */
924
- function refreshDialogWatch() {
925
- bridgeWatch.dialogIds = askDialog.open && askDialog.sessionId !== null ? [askDialog.sessionId] : [];
926
- bridgeWatch.at = Date.now();
927
- }
928
-
929
- /**
930
- * 回答一条待决授权 —— 桥的路由与对话框的路由共用同一套校验(见 lib/bridge-approval.mjs 的安全约束)。
931
- * @returns {{status: number, body: object}}
932
- */
933
- function answerApproval(id, outcome) {
934
- if (typeof id !== 'string' || id === '') return { status: 400, body: { ok: false, error: '需要 id 字段' } };
935
- if (!PANEL_OUTCOMES.includes(outcome)) {
936
- return { status: 400, body: { ok: false, error: `outcome 只能取 ${PANEL_OUTCOMES.join(' | ')}` } };
937
- }
938
- if (bridgeApprovalBoard.get(id) === null) return { status: 409, body: { ok: false, error: '未知或已处理的授权请求' } };
939
- const ok = bridgeApprovalBoard.answer(id, outcome);
940
- return { status: ok ? 200 : 409, body: { ok, outcome: ok ? outcome : null } };
941
- }
942
-
943
- /** 面板产物(thread.js / thread.css):对话框把它们注入 DSH 页面,所以由 host 读盘给文本。 */
944
- let askBundleCache = null;
945
- function askBundle() {
946
- if (askBundleCache !== null) return askBundleCache;
947
- const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'extensions', 'dshcs-editor-bridge', 'webview');
948
- const read = (name) => {
949
- try {
950
- return fs.readFileSync(path.join(dir, name), 'utf8');
951
- } catch {
952
- return null;
953
- }
954
- };
955
- const js = read('thread.js');
956
- const css = read('thread.css');
957
- // 产物缺失(打包漏了 build:webview)→ 明确报出来,不返回半个面板。
958
- if (js === null || css === null) {
959
- console.warn(`[code-server] 「问 DSH」对话框的产物缺失:${dir}(请在源码里跑 pnpm run build:webview)`);
960
- return null;
961
- }
962
- askBundleCache = { js, css };
963
- return askBundleCache;
964
- }
965
-
966
-
967
- /** 本部署的 DSH 版本(诊断用;面板要的是下面那个"界面版本")。 */
968
- function dshVersion() {
969
- try {
970
- const entry = dshEntry();
971
- if (entry === undefined || entry === null) return null;
972
- const manifest = JSON.parse(fs.readFileSync(path.join(path.dirname(entry), 'package.json'), 'utf8'));
973
- return typeof manifest.version === 'string' ? manifest.version : null;
974
- } catch {
975
- return null;
976
- }
977
- }
978
-
979
- /**
980
- * 本部署**界面**的版本(0.3.22)。
981
- *
982
- * 面板里的 markdown 渲染器是从 `@deepseek-ai/dsh-client-ui-primitives` 打包进去的,而那份
983
- * 渲染器就装在 `@deepseek-ai/dsh-web-frontend` 里(DSH Web 的前端包)。面板拿这个版本和
984
- * 自己打包时的版本比一比,不一致就在面板顶部显示一行提示 —— 版本错配的排版差异不许静默。
985
- */
986
- function uiVersion() {
987
- try {
988
- const entry = dshEntry();
989
- if (entry === undefined || entry === null) return null;
990
- const dir = path.dirname(entry);
991
- const candidates = [
992
- path.join(dir, 'node_modules', '@deepseek-ai', 'dsh-web-frontend', 'package.json'),
993
- path.join(dir, '..', 'dsh-web-frontend', 'package.json'),
994
- ];
995
- for (const candidate of candidates) {
996
- try {
997
- const manifest = JSON.parse(fs.readFileSync(candidate, 'utf8'));
998
- if (typeof manifest.version === 'string') return manifest.version;
999
- } catch {
1000
- // 下一个候选
1001
- }
1002
- }
1003
- return null;
1004
- } catch {
1005
- return null;
1006
- }
1007
- }
1008
-
1009
- /** 本次启动生成的新桥令牌;null = 本实例没有令牌(adopt 旧实例时会回读 bridge.json)。 */
1010
- let bridgeToken = null;
1011
- /** 桥的本机 IPC 监听口(0.3.13)。null = 未起(未启用/起失败)。*/
1012
- let bridgeListener = null;
1013
- /** 监听口启动中的 promise(避免并发起两次)。 */
1014
- let bridgeListenerStarting = null;
1015
- /** "桥监听起不来"只提示一次,避免每次起停都刷屏。 */
1016
- let bridgeUnavailableLogged = false;
1017
-
1018
- /** 桥的传输是**本机 IPC**(Windows 命名管道 / 其它平台 unix socket),不是 HTTP。
1019
- *
1020
- * 为什么不走 HTTP(0.3.13 定论,三条都实测过):
1021
- * ① launcher 的 host:port 只服务 workbench,没有任何桥路由(0.3.7 的实测:405);
1022
- * ② `/api` 有 Connection 的浏览器 cookie fence,扩展宿主是 Node 进程、拿不到 cookie(0.3.7–0.3.9);
1023
- * ③ 挂 DSH 的 `webServer` 前缀(0.3.9–0.3.12)只有 **web profile** 有 —— desktop 的渲染进程
1024
- * 经 Electron IPC 调 `host.fetch()`(`apps/desktop-host/src/index.ts:308`),**根本没有 HTTP 面**。
1025
- * 桥的双方本来就是同一台机器上的两个进程,本机 IPC 让 web 与 desktop 走**同一条**路。
1026
- * 详见 lib/bridge-ipc.mjs 顶部。 */
1027
- function ensureBridgeListener() {
1028
- if (bridgeListener !== null) return Promise.resolve(bridgeListener);
1029
- if (bridgeListenerStarting !== null) return bridgeListenerStarting;
1030
- const socketPath = bridgeEndpointPath(bridgeRoot, process.pid);
1031
- bridgeListenerStarting = startBridgeListener({
1032
- socketPath,
1033
- handler: nodeRouteFromFetch((url, method, headers, body) => dispatchBridge(url, method, headers, body)),
1034
- log: (message) => console.warn(`[code-server] ${message}`),
1035
- }).then((handle) => {
1036
- bridgeListener = handle;
1037
- bridgeListenerStarting = null;
1038
- console.log(`[code-server] 编辑器桥监听就绪:${handle.path}(本机 IPC,桥令牌鉴权)`);
1039
- return handle;
1040
- }).catch((error) => {
1041
- bridgeListenerStarting = null;
1042
- if (!bridgeUnavailableLogged) {
1043
- bridgeUnavailableLogged = true;
1044
- console.warn(`[code-server] 编辑器桥监听失败,桥不启用:${error && error.code ? error.code : error && error.message ? error.message : error}`
1045
- + '(文件打开走信号文件,不受影响)');
1046
- }
1047
- return null;
1048
- });
1049
- return bridgeListenerStarting;
1050
- }
1051
-
1052
- /** 关掉本机 IPC 监听口(禁用桥 / 插件卸载)。 */
1053
- function stopBridgeListener() {
1054
- const handle = bridgeListener;
1055
- bridgeListener = null;
1056
- if (handle === null) return Promise.resolve();
1057
- return handle.close().catch(() => {});
1058
- }
1059
-
1060
- /** 桥的路由(后缀 → Fetch 风格 handler)。做成函数而非常量:handler 声明在文件后段,
1061
- * 函数声明会提升,而常量在挂载时可能还在 TDZ。
1062
- * `/approve` 是唯一的非只读路由(只回答既有授权请求,见 handleBridgeApprove)。 */
1063
- function bridgeRouteTable() {
1064
- return [
1065
- { suffix: '/health', methods: ['GET'], fetch: handleBridgeHealth },
1066
- { suffix: '/sync', methods: ['POST'], fetch: handleBridgeSync },
1067
- { suffix: '/ask', methods: ['POST'], fetch: handleBridgeAsk },
1068
- { suffix: '/event', methods: ['POST'], fetch: handleBridgeEvent },
1069
- { suffix: '/approve', methods: ['POST'], fetch: handleBridgeApprove },
1070
- ];
1071
- }
1072
-
1073
- /** 把 Fetch 风格 handler 适配成本机 IPC 监听口的 Node 路由(req/res ⇄ Request/Response)。 */
1074
- function nodeRouteFromFetch(dispatch) {
1075
- return async (req, res) => {
1076
- try {
1077
- const chunks = [];
1078
- for await (const chunk of req) chunks.push(chunk);
1079
- const body = Buffer.concat(chunks);
1080
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
1081
- const method = (req.method ?? 'GET').toUpperCase();
1082
- const response = await dispatch(url, method, req.headers, body);
1083
- const buffer = Buffer.from(await response.arrayBuffer());
1084
- const headers = {};
1085
- response.headers.forEach((value, key) => { headers[key] = value; });
1086
- res.writeHead(response.status, headers);
1087
- res.end(buffer);
1088
- } catch (error) {
1089
- if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
1090
- if (!res.writableEnded) res.end('bridge route error');
1091
- console.warn(`[code-server] 编辑器桥路由异常:${error && error.message ? error.message : error}`);
1092
- }
1093
- };
1094
- }
1095
-
1096
- /** 桥挂载点的分发:按后缀查表,方法不符 405,未知路径 404(绝不落到 VS Code 那边)。 */
1097
- async function dispatchBridge(url, method, headers, body) {
1098
- const suffix = url.pathname.slice(BRIDGE_BASE.length) || '/';
1099
- const route = bridgeRouteTable().find((item) => item.suffix === suffix);
1100
- if (route === undefined) return jsonResponse({ ok: false, error: 'unknown bridge route' }, 404);
1101
- if (!route.methods.includes(method)) return jsonResponse({ ok: false, error: 'method not allowed' }, 405);
1102
- const request = new Request(url, {
1103
- method,
1104
- headers,
1105
- ...(method === 'GET' || method === 'HEAD' ? {} : { body }),
1106
- });
1107
- // Origin 必须从 Node 的原始 headers 读:undici 的 Request 构造器把 `origin` 当 forbidden header
1108
- // 归一化掉了,再读 request.headers 会得到 null,bridgeGuard 那道 403 就静默失效。
1109
- request.dshcsRawHeaders = { get: (name) => headers[String(name).toLowerCase()] ?? null };
1110
- return route.fetch(request);
1111
- }
1112
-
1113
- /** 桥是否启用(行配置为种子,设置文档里可实时改)。 */
1114
- function bridgeEnabled() {
1115
- return bridgeSetting !== false;
1116
- }
1117
-
1118
- /** 提示词段落在插件激活时注册一次:文本按"桥是否存活"渲染(桥停时为空串 → DSH 丢弃该段),
1119
- * 所以在 IDE 从未启动的部署里它也只是一段空注册,不产生任何提示词开销。 */
1120
- setPromptLiveProbe(() => bridgeMeta !== null);
1121
- bridgePromptDispose = registerEditorPrompt(ctx);
1122
- if (bridgePromptDispose === null && bridgeEnabled()) {
1123
- console.warn('[code-server] 编辑器桥:systemPrompt 服务不可用,提示词段落未注册(工具仍可用)');
1124
- }
1125
-
1126
- // 观察 agent 的写操作(→ 编辑器 diff 提示)并在写脏文件前附一条提醒。
1127
- // 与 IDE 是否在跑无关:桥没起来时 isLive() 为 false,dirty 检查直接跳过。
1128
- bridgeObserveDispose = registerBridgeObserver(ctx, {
1129
- emit: (kind, fields) => bridgeEvents.push(kind, fields),
1130
- context: () => bridgeContext.get(),
1131
- isLive: () => bridgeMeta !== null && !bridgeContext.isStale(),
1132
- });
1133
-
1134
- // 会话流与授权拦截都是**按会话**建立的(在 /ask 成功时 watch + intercept),
1135
- // 这里只登记卸载钩子:面板关掉 / IDE 停掉时把订阅与 pending 授权一起收干净。
1136
- bridgeAnswerDispose = () => {
1137
- bridgeThread.dispose();
1138
- bridgeApproval.dispose();
1139
- bridgeApprovalBoard.clear();
1140
- bridgeWatch.ids = [];
1141
- bridgeWatch.at = 0;
1142
- };
1143
-
1144
- /** 桥配置要写的目录(可能有**两个**):
1145
- *
1146
- * ① `<extensionsDir>/.dshcs-bridge/` —— 权威位置;host 侧 /status 与诊断都指向它,
1147
- * 扩展在有 `DSHCS_EXTENSIONS_DIR` 时也读它;
1148
- * ② `<树>/lib/vscode/extensions/.dshcs-bridge/` —— **内置扩展自己会去找的位置**。
1149
- * 为什么需要 ②:`bridge-client` 的兜底是"从自身位置反推"(`<ext>/lib/` 上溯两级),
1150
- * 对内置扩展就是 `<树>/lib/vscode/extensions`。而环境变量只在 host **spawn** IDE 时才注入 ——
1151
- * **adopt(接管正在运行的 IDE)拿不到**:那个进程是上一次启动的,env 早已定死。
1152
- * 没有 ② 的话,被接管的 IDE 即使重载窗口也读不到配置,桥只能等 IDE 重启。
1153
- * 两份内容完全一致(同一个 write/remove 一起写),不存在优先级问题。 */
1154
- function bridgeConfigDirs() {
1155
- const dirs = [bridgeExtensionsDir];
1156
- try {
1157
- const target = extensionTarget(bridgeExtensionsDir, 'dshcs-editor-bridge', 'builtin');
1158
- if (target.builtin) {
1159
- const sibling = path.join(path.dirname(target.dst), BRIDGE_DIRNAME);
1160
- if (!dirs.includes(sibling)) dirs.push(sibling);
1161
- }
1162
- } catch {
1163
- // 树不可用 → 只有权威位置
1164
- }
1165
- return dirs;
1166
- }
1167
-
1168
- /** 同步桥运行时:写入/更新 bridge.json(端点 = 本机 IPC 路径 + 令牌 + pid)。
1169
- * 端点、令牌、pid 三者任一变化都重写 —— 扩展每 5s 重读,故不需要任何推送。
1170
- * 监听口起不来时**不写配置**(宁可休眠,不可指向死端点),并说明一次。 */
1171
- function syncBridgeRuntime({ pid, startedAt }) {
1172
- if (!bridgeEnabled()) return;
1173
- const handle = bridgeListener;
1174
- if (handle === null) {
1175
- if (bridgeMeta !== null) {
1176
- bridgeMeta = null;
1177
- for (const dir of bridgeConfigDirs()) {
1178
- try { removeBridgeConfig(dir); } catch { /* 删不掉也不影响:扩展会因端点在而连不上 */ }
1179
- }
1180
- }
1181
- return;
1182
- }
1183
- bridgeToken ??= mintBridgeToken();
1184
- const changed = bridgeMeta === null || bridgeMeta.pipe !== handle.path || bridgeMeta.token !== bridgeToken || bridgeMeta.pid !== pid;
1185
- bridgeMeta = { pipe: handle.path, token: bridgeToken, pid, startedAt: startedAt ?? null };
1186
- const value = { pipe: handle.path, token: bridgeToken, pid, startedAt: startedAt ?? null };
1187
- for (const dir of bridgeConfigDirs()) {
1188
- try {
1189
- writeBridgeConfig(dir, value);
1190
- } catch (err) {
1191
- console.warn(`[code-server] 编辑器桥配置写入失败(${dir}):${err && err.message ? err.message : err}`);
1192
- }
1193
- }
1194
- if (changed) {
1195
- // 只打印端点与配置文件位置,不打印令牌本身(与 path-token 同一决策)。
1196
- console.log(`[code-server] 编辑器桥:已启用(${handle.path}${BRIDGE_BASE}/*,令牌文件 ${path.join(bridgeMetaDir, 'bridge.json')})`);
1197
- }
1198
- ensureBridgeTools();
1199
- }
1200
-
1201
- /**
1202
- * 接管实例时对齐桥令牌:磁盘上已有配置、端点一致且是本机 IPC → 沿用(避免无谓轮换打乱正在运行的扩展);
1203
- * 否则 mint 新的(扩展下次轮询就会读到新的 bridge.json,一次请求的失败无所谓)。
1204
- */
1205
- function adoptBridgeRuntime(pid, startedAt) {
1206
- if (!bridgeEnabled()) return;
1207
- const existing = readBridgeConfig(bridgeExtensionsDir);
1208
- const endpoint = bridgeListener === null ? null : bridgeListener.path;
1209
- bridgeToken = existing !== null && endpoint !== null && existing.pipe === endpoint && TOKEN_RE.test(String(existing.token))
1210
- ? String(existing.token)
1211
- : mintBridgeToken();
1212
- syncBridgeRuntime({ pid, startedAt });
1213
- }
1214
-
1215
- /** 注销桥运行时(停止 IDE / 插件卸载):删配置 + 注销工具 + 清缓存,扩展随即休眠。 */
1216
- function clearBridgeRuntime() {
1217
- bridgeToolDispose = disposeSafely(bridgeToolDispose);
1218
- bridgeContext.clear();
1219
- bridgeEvents.reset();
1220
- if (bridgeMeta === null) return;
1221
- bridgeMeta = null;
1222
- for (const dir of bridgeConfigDirs()) {
1223
- try {
1224
- removeBridgeConfig(dir);
1225
- } catch {
1226
- // 配置删不掉不影响正确性(扩展会因 IDE 不可达而休眠)
1227
- }
1228
- }
1229
- }
1230
-
1231
- function disposeSafely(dispose) {
1232
- if (typeof dispose !== 'function') return null;
1233
- try {
1234
- dispose();
1235
- } catch (err) {
1236
- console.warn(`[code-server] 桥工具注销失败:${err && err.message ? err.message : err}`);
1237
- }
1238
- return null;
1239
- }
1240
-
1241
- /** 桥就绪时注册编辑器工具;`tools` / `defineTool` 缺失时静默退化为"只有本机 IPC 面"。 */
1242
- function ensureBridgeTools() {
1243
- if (bridgeMeta === null || bridgeToolDispose !== null) return;
1244
- Promise.resolve(registerEditorTools(ctx, {
1245
- target: () => bridgeMeta,
1246
- cache: () => bridgeContext,
1247
- }))
1248
- .then((dispose) => {
1249
- if (dispose === null) {
1250
- console.log('[code-server] 编辑器桥:工具服务不可用,仅提供本机 IPC 面(editor_context/editor_diagnostics 未注册)');
1251
- return;
1252
- }
1253
- // 期间桥可能已经被停掉(IDE 退出):立刻回滚,避免留下永远不可用的工具。
1254
- if (bridgeMeta === null) {
1255
- disposeSafely(dispose);
1256
- return;
1257
- }
1258
- bridgeToolDispose = dispose;
1259
- console.log('[code-server] 编辑器桥:已注册 editor_context / editor_diagnostics');
1260
- })
1261
- .catch((err) => {
1262
- console.warn(`[code-server] 编辑器桥工具注册失败:${err && err.message ? err.message : err}`);
1263
- });
1264
- }
1265
-
1266
- // ---- DSH 同源挂载(serve=dsh):把 /code-server 注册到 DSH 自己的 webServer ----
1267
- // webServer 只在 web profile 存在(desktop 显式禁用该行)→ 用 ctx.inject 特性检测,
1268
- // 缺失时 requestedServe() 自动回退 loopback。
1269
- ctx.inject(['webServer'], (wsCtx) => {
1270
- webServerSvc = wsCtx.webServer;
1271
- try {
1272
- dshMount = mountOnWebServer({
1273
- webServer: wsCtx.webServer,
1274
- connection,
1275
- // 目标随时可变:launcher 重启会换管道/端口,未运行时返回 null(路由回 503)
1276
- getTarget: () => (state.serve === 'dsh' && state.pipe !== null ? { kind: 'pipe', pipe: state.pipe } : null),
1277
- productPath: state.env?.productPath ?? productPath() ?? 'stable',
1278
- });
1279
- console.log(`[code-server] serve=dsh 挂载就绪:${MOUNT_PATH}/ (HTTP)+ ${dshMount.upgradePath} (WS)`);
1280
- } catch (error) {
1281
- dshMount = null;
1282
- console.error(`[code-server] serve=dsh 挂载失败(将回退 loopback):${error && error.message ? error.message : error}`);
1283
- }
1284
- });
1285
-
1286
- ctx.effect(() => () => {
1287
- if (dshMount === null) return;
1288
- for (const dispose of dshMount.disposers) {
1289
- try { dispose(); } catch { /* ignore */ }
1290
- }
1291
- }, 'code-server: dsh mount');
1292
-
1293
- // 编辑器桥的本机 IPC 监听口(web 与 desktop 同一套;不依赖 webServer,见 ensureBridgeListener)。
1294
- ctx.effect(() => {
1295
- if (bridgeEnabled()) void ensureBridgeListener();
1296
- return () => { void stopBridgeListener(); };
1297
- }, 'code-server: bridge ipc listener');
1298
-
1299
- /** 回环模式的客户端 URL:随机端口 + 路径令牌(令牌是 URL 路径的一段,浏览器会把子请求与 WS
1300
- * 一并带过去 —— 这正是不走 VS Code 自带 cookie 令牌的原因,见 launcher 文件头"安全模型")。 */
1301
- function loopbackUrl() {
1302
- const token = typeof state.token === 'string' && state.token !== '' ? `${state.token}/` : '';
1303
- return `http://${cfg.host}:${state.port}/${token}`;
1304
- }
1305
-
1306
- function snapshot() {
1307
- const running = state.status === 'running' && state.pid !== null;
1308
- const dshMode = state.serve === 'dsh';
1309
- return {
1310
- ok: state.status !== 'error' || running,
1311
- running,
1312
- status: state.status,
1313
- serve: state.serve,
1314
- port: dshMode ? null : state.port,
1315
- host: dshMode ? null : cfg.host,
1316
- pid: state.pid,
1317
- cwd: state.cwd,
1318
- launchCwd: state.launchCwd,
1319
- // dsh 模式:同源相对地址(由 DSH webServer 的 prefix 路由提供服务);
1320
- // loopback:随机端口 + 路径令牌(令牌只出现在这个 URL 里,客户端据此加载 iframe)
1321
- url: running ? (dshMode ? '/code-server/' : loopbackUrl()) : null,
1322
- productPath: state.env?.productPath ?? null,
1323
- version: state.version,
1324
- error: state.error,
1325
- logTail: state.logTail.slice(-LOG_TAIL_MAX),
1326
- adopted: state.adopted,
1327
- keepResident,
1328
- claimExtensions, // 客户端据此按扩展名决定认领哪些文件(0.2.11,取代 fileOpenScope)
1329
- fullscreenOnOpen, // 客户端据此决定"打开标签即全屏右侧栏"(0.2.9)
1330
- sidebarUi: state.sidebarUi,
1331
- /** 编辑器桥状态(0.3.0,只读诊断面;**不含令牌** —— 令牌只在 bridge.json 里)。 */
1332
- bridge: {
1333
- enabled: bridgeEnabled(),
1334
- live: bridgeMeta !== null,
1335
- toolsRegistered: bridgeToolDispose !== null,
1336
- /** 端点已就绪(本机 IPC 监听口在)。0.3.13 起 web 与 desktop 都是 true(不再依赖 webServer)。 */
1337
- supported: bridgeListener !== null,
1338
- /** 本机 IPC 端点(命名管道 / unix socket);null = 监听口没起来。 */
1339
- endpoint: bridgeListener === null ? null : bridgeListener.path,
1340
- file: path.join(bridgeMetaDir, 'bridge.json'),
1341
- },
1342
- env: state.env,
1343
- setup: {
1344
- running: state.setup.running,
1345
- done: state.setup.done,
1346
- ok: state.setup.ok,
1347
- logTail: state.setup.logTail.slice(-LOG_TAIL_MAX),
1348
- },
1349
- lastSetupError: readLastSetupError(),
1350
- };
1351
- }
1352
-
1353
- /** 读旧版(≤0.1.35)环境安装失败标记文件;新版不再有安装步骤 → 通常为 null。
1354
- * 标记在旧安装根:<profile>\.code-server-app\last-setup-error.json。 */
1355
- function readLastSetupError() {
1356
- try {
1357
- const root = legacyInstallRoot();
1358
- if (root === null) return null;
1359
- const marker = path.join(root, 'last-setup-error.json');
1360
- if (!fs.existsSync(marker)) return null;
1361
- const raw = fs.readFileSync(marker, 'utf8');
1362
- const j = JSON.parse(raw);
1363
- return {
1364
- at: j && typeof j.at === 'string' ? j.at : null,
1365
- error: j && typeof j.error === 'string' ? j.error : '安装脚本失败(无详情)',
1366
- };
1367
- } catch {
1368
- return null;
1369
- }
1370
- }
1371
-
1372
- function appendLog(chunk) {
1373
- try {
1374
- const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
1375
- state.logTail = (state.logTail + text).slice(-LOG_TAIL_MAX * 2);
1376
- } catch {
1377
- // ignore
1378
- }
1379
- }
1380
-
1381
- function stopPolling() {
1382
- if (pollTimer !== null) {
1383
- clearInterval(pollTimer);
1384
- pollTimer = null;
1385
- }
1386
- }
1387
-
1388
- function killTree(pid) {
1389
- return new Promise((resolve) => {
1390
- if (!isAlive(pid)) return resolve(false);
1391
- if (win32()) {
1392
- execFile('taskkill.exe', ['/PID', String(pid), '/T', '/F'], (err) => resolve(!err));
1393
- } else {
1394
- try {
1395
- process.kill(-pid, 'SIGTERM');
1396
- } catch {
1397
- try {
1398
- process.kill(pid, 'SIGTERM');
1399
- } catch {
1400
- return resolve(false);
1401
- }
1402
- }
1403
- const timer = setTimeout(() => {
1404
- try {
1405
- process.kill(-pid, 'SIGKILL');
1406
- } catch {
1407
- try {
1408
- process.kill(pid, 'SIGKILL');
1409
- } catch {
1410
- // gone
1411
- }
1412
- }
1413
- resolve(true);
1414
- }, 3000);
1415
- timer.unref?.();
1416
- }
1417
- });
1418
- }
1419
-
1420
- async function stop(reason) {
1421
- stopPolling();
1422
- const pid = state.pid;
1423
- if (child !== null) {
1424
- child.stdout?.removeAllListeners?.('data');
1425
- child.stderr?.removeAllListeners?.('data');
1426
- try {
1427
- child.removeAllListeners?.('exit');
1428
- child.removeAllListeners?.('error');
1429
- } catch {
1430
- // ignore
1431
- }
1432
- }
1433
- child = null;
1434
- if (pid) {
1435
- await killTree(pid);
1436
- }
1437
- removePidFile(cfg);
1438
- clearBridgeRuntime(); // 桥在 IDE 停止时同步下线:配置删除,扩展随即休眠
1439
- state.status = 'stopped';
1440
- state.pid = null;
1441
- state.cwd = null;
1442
- state.launchCwd = null;
1443
- state.token = null;
1444
- state.port = cfg.port;
1445
- state.startedAt = null;
1446
- state.adopted = false;
1447
- if (reason) state.error = null;
1448
- }
1449
-
1450
- /** 就绪探针:loopback 走 TCP(带路径令牌),dsh 模式走命名管道。 */
1451
- function probeReady(timeoutMs = 1500) {
1452
- if (state.pipe !== null) return healthCheckPipe(state.pipe, timeoutMs);
1453
- return healthCheck(cfg.host, state.port, state.token, timeoutMs);
1454
- }
1455
-
1456
- function beginPollingReady() {
1457
- stopPolling();
1458
- const deadline = Date.now() + (Number(cfg.readyTimeoutMs) || DEFAULT_CONFIG.readyTimeoutMs);
1459
- // 首探延迟 800ms(spawn 后进程初始化),之后每 500ms 一次——缩短"已就绪但 UI 在转"的窗口
1460
- let first = true;
1461
- pollTimer = setInterval(async () => {
1462
- if (first) {
1463
- first = false;
1464
- return; // 等 800ms 才首次探测(给 Node 启动留时间)
1465
- }
1466
- const probe = await probeReady();
1467
- if (probe.ok) {
1468
- stopPolling();
1469
- state.status = 'running';
1470
- return;
1471
- }
1472
- if (Date.now() > deadline) {
1473
- stopPolling();
1474
- state.status = 'error';
1475
- state.error = `启动超时(${cfg.readyTimeoutMs}ms 内 /healthz 未就绪);启动日志尾部:\n${state.logTail.slice(-2000)}`;
1476
- }
1477
- }, 500);
1478
- }
1479
-
1480
- /** 启动串行化:apply 期的**预启动**与界面点击的启动可能并发,而两者写同一份 pid.json、
1481
- * 抢同一个端口 —— 并发进入会双开 launcher(第二个绑不上端口 ⇒ 状态被顶成 error,
1482
- * 表现为"点一下启动就报端口被占用")。串成一条链后,第二个调用会看到 running/starting
1483
- * 并按既有逻辑复用。 */
1484
- let startChain = Promise.resolve();
1485
- function start(cwdArg) {
1486
- const run = startChain.then(() => startInner(cwdArg), () => startInner(cwdArg));
1487
- startChain = run.then(() => {}, () => {});
1488
- return run;
1489
- }
1490
-
1491
- /** 已运行实例切换工作区:**只改"当前 workbench 目录",不重启进程**。
1492
- *
1493
- * 为什么够用:工作区目录由客户端 URL 的 `&folder=<绝对路径>` 决定(见 src/factory.js 的
1494
- * buildPageUrl),服务进程自己的 cwd 只影响它 spawn 时对相对路径的解析;真正"换目录"是
1495
- * workbench 拿新 folder 重新导航(客户端导航 iframe)。
1496
- * 旧行为(0.2.11 及以前)在这里 stop('restart') + 重新 launch:切一次工作区就整进程重启,
1497
- * 编辑缓冲、终端、扩展宿主状态全丢,而且侧栏收起时是在后台悄悄发生。
1498
- * 进程 cwd 保持在 launchCwd 里,诊断时能看出"这个实例是哪个目录拉起来的"。 */
1499
- function adoptWorkspace(cwd) {
1500
- const previous = state.cwd;
1501
- state.cwd = cwd;
1502
- console.log(`[code-server] workspace switch: ${previous ?? '(none)'} -> ${cwd}`
1503
- + `(不重启进程,进程 cwd 仍是 ${state.launchCwd ?? '(none)'};workbench 用新的 folder 重新导航)`);
1504
- return snapshot();
1505
- }
1506
-
1507
- async function startInner(cwdArg) {
1508
- const cwd = typeof cwdArg === 'string' && cwdArg.trim() !== '' ? cwdArg : undefined;
1509
- if (state.status === 'running' && state.pid !== null) {
1510
- if (cwd === undefined || cwd === state.cwd) return snapshot();
1511
- return adoptWorkspace(cwd);
1512
- }
1513
- // 上一次启动仍在进行:等待它结算后再决定(运行中则同上面一样只换目录,不重启)
1514
- if (state.status === 'starting') {
1515
- const deadline = Date.now() + 10000;
1516
- while (state.status === 'starting' && Date.now() < deadline) {
1517
- await new Promise((r) => setTimeout(r, 250));
1518
- }
1519
- if (state.status === 'starting') {
1520
- state.status = 'error';
1521
- state.error = '启动长时间未结算(10s),请查看启动日志;后可重试';
1522
- stopPolling();
1523
- return snapshot();
1524
- }
1525
- if (state.status === 'running') {
1526
- if (cwd === undefined || cwd === state.cwd) return snapshot();
1527
- return adoptWorkspace(cwd);
1528
- }
1529
- }
1530
-
1531
- const serve = requestedServe(); // loopback | dsh(按 DSH 是否提供 webServer 定)
1532
- const launch = resolveLaunch(cfg, serve); // throws with install guidance when missing
1533
-
1534
- if (serve === 'loopback' && !LOOPBACK_HOSTS.has(cfg.host)) {
1535
- state.status = 'error';
1536
- state.error = `serve=loopback 仅允许回环绑定(当前 host="${cfg.host}");`
1537
- + '如需对外提供服务,请改用 serve=dsh(挂到 DSH 同源路径,由 DSH 统一防护)';
1538
- return snapshot();
1539
- }
1540
- if (cfg.auth === 'password') {
1541
- console.warn('[code-server] 0.2.0 起不再支持口令认证(argon2 已移除),按 auth=none 运行');
1542
- }
1543
-
1544
- // 数据目录(adopt 路径也要用:被接管的 IDE 同样需要最新的内置扩展文件)
1545
- const root = dataRoot(cfg);
1546
- const userDataDir = cfg.userDataDir || path.join(root, 'user-data');
1547
- const extensionsDir = cfg.extensionsDir || path.join(root, 'extensions');
1548
- fs.mkdirSync(userDataDir, { recursive: true });
1549
- fs.mkdirSync(extensionsDir, { recursive: true });
1550
-
1551
- // 端口占用则尝试 adopt(仅"显式指定端口"时;默认 port=0 由系统分配,不存在占用问题)。
1552
- // 探针必须带路径令牌(被接管的实例是我们自己拉的,令牌在令牌文件里)——
1553
- // 没有令牌文件说明那个实例不是本插件当前这代拉起来的,不接管。
1554
- if (serve === 'loopback' && cfg.port !== 0) {
1555
- const existingToken = readToken(cfg);
1556
- const probe = await healthCheck(cfg.host, cfg.port, existingToken, 800);
1557
- if (probe.ok) {
1558
- const record = readPidFile(cfg);
1559
- if (record && isAlive(record.pid)) {
1560
- state.serve = 'loopback';
1561
- state.status = 'running';
1562
- state.pid = record.pid;
1563
- state.port = cfg.port;
1564
- state.token = existingToken;
1565
- state.cwd = cwd ?? record.cwd ?? null;
1566
- state.launchCwd = record.launchCwd ?? record.cwd ?? null;
1567
- state.startedAt = record.startedAt ?? null;
1568
- state.adopted = true;
1569
- // 被接管的 IDE **不会**重新加载扩展:文件可以同步,但扩展宿主里跑的仍是上次启动时那份代码。
1570
- // 桥的传输/协议变更(0.3.13 的 url → pipe)会让旧代码读到 v2 配置后休眠 —— 必须明说,否则
1571
- // 表现又是那句无法自查的"编辑器还没有上报状态"。
1572
- const synced = installBundledExtensions(extensionsDir, userDataDir);
1573
- const newest = newestBundledExtensionMtime(extensionsDir);
1574
- const staleCode = synced.updated.length > 0
1575
- || (newest !== null && record.startedAt !== null && newest > record.startedAt);
1576
- if (staleCode) {
1577
- console.warn('[code-server] 内置扩展文件比当前 IDE 进程新'
1578
- + `${synced.updated.length > 0 ? `(本次更新:${synced.updated.join(',')})` : ''}:`
1579
- + '被接管的 IDE 不会重新加载扩展,里面跑的**可能仍是旧代码**。'
1580
- + '若编辑器桥没有状态(editor_context 报"编辑器还没有上报状态"),'
1581
- + '请在 Code Server 标签里重载一次窗口(或重启 IDE)让扩展宿主读到新代码');
1582
- }
1583
- adoptBridgeRuntime(record.pid, state.startedAt);
1584
- return snapshot();
1585
- }
1586
- state.status = 'error';
1587
- state.error = `端口 ${cfg.port} 已被占用且没有有效的 pid.json 记录(拒绝误杀);请释放端口或把 port 改回 0(随机端口)`;
1588
- return snapshot();
1589
- }
1590
- }
1591
-
1592
- // 安装树内自带扩展(dshcs-open-file:host 信号文件 → VS Code 打开文件;
1593
- // dshcs-editor-bridge:编辑器桥的扩展侧,0.3.0)
1594
- installBundledExtensions(extensionsDir, userDataDir);
1595
-
1596
- const args = launch.kind === 'launcher'
1597
- ? [
1598
- launch.script,
1599
- '--tree', launch.tree,
1600
- '--user-data-dir', userDataDir,
1601
- '--extensions-dir', extensionsDir,
1602
- '--parent-pid', String(process.pid),
1603
- ]
1604
- : [
1605
- '--bind-addr', `${cfg.host}:${cfg.port}`,
1606
- '--auth', 'none',
1607
- '--user-data-dir', userDataDir,
1608
- '--extensions-dir', extensionsDir,
1609
- '--disable-telemetry',
1610
- '--disable-update-check',
1611
- ];
1612
- if (launch.kind === 'launcher') {
1613
- if (serve === 'dsh') {
1614
- state.pipe = pipeName();
1615
- args.push('--pipe', state.pipe);
1616
- } else {
1617
- state.pipe = null;
1618
- // 随机端口(port=0)+ 路径令牌:令牌**不进程 argv**(本机任意进程都能读命令行),
1619
- // 走文件传递;实际端口由 launcher 写 endpoint 文件回报。
1620
- fs.rmSync(endpointFile(cfg), { force: true }); // 清掉上一实例的记录,避免读到旧端口
1621
- state.token = rotateToken(cfg);
1622
- args.push('--port', String(cfg.port), '--host', cfg.host,
1623
- '--token-file', tokenFile(cfg), '--endpoint-file', endpointFile(cfg));
1624
- }
1625
- if (typeof cfg.locale === 'string' && cfg.locale !== '') args.push('--locale', cfg.locale);
1626
- }
1627
- if (cwd !== undefined && launch.kind !== 'launcher') args.push(cwd);
1628
-
1629
- state.serve = serve;
1630
- state.error = null;
1631
- state.logTail = '';
1632
- state.status = 'starting';
1633
- state.port = serve === 'dsh' ? null : cfg.port;
1634
- state.cwd = cwd ?? null;
1635
- state.launchCwd = cwd ?? null;
1636
- state.startedAt = Date.now();
1637
- state.adopted = false;
1638
-
1639
- const env = { ...process.env };
1640
- // 内置扩展信号文件路径(host → 扩展 打开文件)
1641
- env.DSHCS_OPEN_FILE_SIGNAL = openFileSignalPath(userDataDir);
1642
- // 扩展目录(host → 扩展 找桥配置 `/.dshcs-bridge/bridge.json`)。
1643
- // **必须注入**:0.3.12 起桥扩展装在**内置**目录(树里),与 <extensionsDir> 不同级,
1644
- // 扩展再也不能靠"自己的路径上溯两级"找到配置(0.3.0–0.3.11 那个上溯是错的,多上溯了一级,
1645
- // 就算装在用户级也读不到配置 ⇒ 桥一直是休眠态)。
1646
- env.DSHCS_EXTENSIONS_DIR = extensionsDir;
1647
- // ?v= 缓存击穿标记:按"插件版本 + VS Code 树"生成 —— 只在真升级时让渲染器丢掉旧 bundle,
1648
- // 平时正常命中缓存(workbench.js 有 18MB,每次启动都重拉不划算)。
1649
- env.DSHCS_HTML_TAG = `${pluginVersion()}-${productPath() ?? 'dev'}`.replace(/[^A-Za-z0-9._-]/g, '');
1650
- // 0.3.45 起不再需要给子进程加 NODE_PATH:重打包原生包按**真名**挂在插件依赖上,而它们要的
1651
- // **原始名字**(node-pty / @vscode/sqlite3 …)由 ensureRuntimeLayout() 在树里补 junction ——
1652
- // 目录链同时满足 ESM import 与 CJS require,NODE_PATH 只对 CJS 生效且属兜底。
1653
-
1654
- let proc;
1655
- try {
1656
- // 启动前再自愈一次依赖布局(插件重装/树被替换后可能丢失;幂等且只做存在性检查)
1657
- ensureRuntimeLayout();
1658
- const isCmd = launch.kind === 'bin' && win32() && /\.cmd$/i.test(launch.command);
1659
- const command = launch.kind === 'bin' ? launch.command : process.execPath;
1660
- const spawnArgs = launch.kind === 'bin' ? args : [launch.script, ...args];
1661
- // shell 仅对 .cmd shim(Windows npm 全局包)必要:它必须经 cmd.exe 解析。
1662
- // 含空格路径由 spawn 数组传参,不再经 shell 拼接,避免 'C:\Program' 拆分。
1663
- proc = spawn(isCmd ? `"${command}"` : command, spawnArgs, {
1664
- cwd: cwd ?? process.cwd(),
1665
- env,
1666
- shell: isCmd,
1667
- windowsHide: true,
1668
- detached: !win32() && !isCmd,
1669
- stdio: ['ignore', 'pipe', 'pipe'],
1670
- });
1671
- } catch (err) {
1672
- state.status = 'error';
1673
- state.error = `spawn 失败: ${err && err.message ? err.message : String(err)}`;
1674
- return snapshot();
1675
- }
1676
- child = proc;
1677
- state.pid = proc.pid ?? null;
1678
- /** 实例记录:pid 一到手就先写(崩溃/回收要靠它);端口 0 时在拿到实际端口后再补写一次。 */
1679
- const writeInstanceRecord = () => writePidFile(cfg, {
1680
- pid: proc.pid,
1681
- startedAt: state.startedAt,
1682
- cwd: cwd ?? null,
1683
- launchCwd: cwd ?? null,
1684
- host: serve === 'dsh' ? null : cfg.host,
1685
- port: serve === 'dsh' ? null : state.port,
1686
- serve,
1687
- pipe: state.pipe,
1688
- launchKind: launch.kind,
1689
- launchCommand: launch.kind === 'bin' ? launch.command : launch.script,
1690
- tokenized: state.token !== null,
1691
- });
1692
- writeInstanceRecord();
1693
-
1694
- proc.stdout?.on?.('data', appendLog);
1695
- proc.stderr?.on?.('data', appendLog);
1696
-
1697
- // 端口 0(默认)时 host 只能从 endpoint 文件知道实际端口:等 launcher 写完再开始就绪轮询。
1698
- if (launch.kind === 'launcher' && serve === 'loopback' && cfg.port === 0) {
1699
- const deadline = Date.now() + 10000;
1700
- let endpoint = null;
1701
- while (endpoint === null && Date.now() < deadline) {
1702
- endpoint = readEndpoint(cfg);
1703
- if (endpoint !== null && endpoint.pid !== (proc.pid ?? -1)) endpoint = null; // 旧进程的残留记录
1704
- if (endpoint === null) await new Promise((r) => setTimeout(r, 100));
1705
- }
1706
- if (endpoint === null) {
1707
- state.status = 'error';
1708
- state.error = 'launcher 未回报监听端口(endpoint 文件缺失);请查看启动日志';
1709
- return snapshot();
1710
- }
1711
- state.port = endpoint.port;
1712
- writeInstanceRecord();
1713
- console.log(`[code-server] 随机端口: ${state.port}(host=${endpoint.host}, pid=${endpoint.pid})`);
1714
- }
1715
- // 编辑器桥:端口已定(loopback + 已知端口)时写入配置,扩展随即能连上来。
1716
- // 新启动会轮换桥令牌 —— 与路径令牌同一时机(每次新启动都换)。
1717
- // dsh 模式(无独立端口)不启用:桥的 Host 白名单假设是 127.0.0.1:<实际端口>,管道模式没有端口。
1718
- // 桥与 serve 模式无关:只要求"扩展宿主能到达 DSH 的 HTTP 面"(web 有 webServer;
1719
- // desktop 没有 → syncBridgeRuntime 会自己判定并保持休眠)。
1720
- bridgeToken = mintBridgeToken();
1721
- syncBridgeRuntime({
1722
- pid: proc.pid ?? null,
1723
- startedAt: state.startedAt,
1724
- });
1725
-
1726
- proc.on('error', (err) => {
1727
- if (child !== proc) return;
1728
- child = null;
1729
- removePidFile(cfg);
1730
- clearBridgeRuntime();
1731
- state.status = 'error';
1732
- state.error = `code-server 启动失败: ${err && err.message ? err.message : String(err)}\n${state.logTail.slice(-1000)}`;
1733
- });
1734
-
1735
- proc.on('exit', (code, signal) => {
1736
- if (child !== proc) return; // 已被 stop/dispose 接管
1737
- child = null;
1738
- removePidFile(cfg);
1739
- clearBridgeRuntime();
1740
- if (disposeKilled) return;
1741
- state.status = 'error';
1742
- state.error = `code-server 意外退出${code !== null ? `(exit ${code})` : signal ? `(signal ${signal})` : ''}:\n${state.logTail.slice(-1500)}`;
1743
- });
1744
-
1745
- beginPollingReady();
1746
- return snapshot();
1747
- }
1748
-
1749
- /** 0.1.36 起没有「安装环境」步骤:code-server 随插件包发布,内部依赖与预编译原生模块
1750
- * 都由包管理器在 `dsh plugin add` 时装好。这里只做一次重新自检(兼容旧客户端的按钮)。 */
1751
- function refreshEnv() {
1752
- state.env = envCheck();
1753
- state.setup = {
1754
- running: false,
1755
- done: true,
1756
- ok: state.env.ok,
1757
- logTail: state.env.ok
1758
- ? '环境已就绪(依赖由包管理器安装,无需安装步骤)'
1759
- : `环境未就绪:${describeEnvProblem(state.env)}`,
1760
- startedAt: null,
1761
- finishedAt: Date.now(),
1762
- };
1763
- return state.setup;
1764
- }
1765
-
1766
- /** 环境问题的一句话描述(状态卡/日志共用)。 */
1767
- function describeEnvProblem(env) {
1768
- const parts = [];
1769
- if (env.tree === null) parts.push(`缺少 VS Code 树(包内 vendor/vscode 或 dshcs-vscode-server 未安装)`);
1770
- else if (env.entry === null) parts.push('树里缺少 lib/vscode/out/server-main.js');
1771
- if (env.vscodeInner !== true) {
1772
- const missing = Array.isArray(env.innerDeps?.missing) ? env.innerDeps.missing : [];
1773
- parts.push(`内部依赖未装全(缺 ${missing.length} 个:${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ' …' : ''})`);
1774
- }
1775
- if (env.nativeRuntime !== null && env.nativeRuntime.source === null) {
1776
- parts.push(`预编译原生模块未安装(${env.nativeRuntime.name})`);
1777
- } else if (Array.isArray(env.nativeRuntime?.missing) && env.nativeRuntime.missing.length > 0) {
1778
- parts.push(`原生模块解析失败:${env.nativeRuntime.missing.slice(0, 5).join(', ')}`);
1779
- }
1780
- return parts.length > 0 ? parts.join('; ') : '未知问题';
1781
- }
1782
-
1783
- // ---- /api/code-server 路由(Connection 共享通道;web 与 desktop 通用) ----
1784
- const API_BASE = '/api/code-server';
1785
-
1786
- /** JSON 响应(与旧 webServer JSON API 相同的载荷形状)。 */
1787
- function jsonResponse(value, status = 200) {
1788
- return new Response(JSON.stringify(value), {
1789
- status,
1790
- headers: { 'content-type': 'application/json; charset=utf-8' },
1791
- });
1792
- }
1793
-
1794
- /** 读取 JSON 请求体;空体 / 非法 JSON 回退为 {}(旧实现是逐块 best-effort 合并)。 */
1795
- async function readJsonBody(request) {
1796
- try {
1797
- const value = await request.json();
1798
- return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
1799
- } catch {
1800
- return {};
1801
- }
1802
- }
1803
-
1804
- /** 统一的失败载荷(HTTP 200 + ok:false,客户端按 ok 判定)。 */
1805
- function failureResponse(err) {
1806
- return jsonResponse({
1807
- ok: false,
1808
- error: err && err.message ? err.message : String(err),
1809
- runner: 'code-server',
1810
- });
1811
- }
1812
-
1813
- async function handleStatus() {
1814
- return jsonResponse(snapshot());
1815
- }
1816
-
1817
- async function handleStart(request) {
1818
- try {
1819
- const body = await readJsonBody(request);
1820
- const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
1821
- return jsonResponse(await start(cwd));
1822
- } catch (err) {
1823
- return failureResponse(err);
1824
- }
1825
- }
1826
-
1827
- async function handleStop() {
1828
- try {
1829
- await stop('user');
1830
- return jsonResponse(snapshot());
1831
- } catch (err) {
1832
- return failureResponse(err);
1833
- }
1834
- }
1835
-
1836
- async function handleSetup() {
1837
- try {
1838
- // 兼容旧客户端:0.1.36 起没有安装步骤,这里只重新自检并返回结果。
1839
- const setup = refreshEnv();
1840
- return jsonResponse({
1841
- ...snapshot(),
1842
- ok: true,
1843
- message: setup.ok ? '环境已就绪,无需安装' : `环境未就绪:${setup.logTail}`,
1844
- error: null,
1845
- });
1846
- } catch (err) {
1847
- return failureResponse(err);
1848
- }
1849
- }
1850
-
1851
- async function handleOpenFile(request) {
1852
- try {
1853
- // 打开文件:host 写信号文件,内置扩展(dshcs-open-file)在 VS Code 中打开它。
1854
- // 0.2.5 起客户端只在"认领到文件地址"时调用本路由(address → 绝对路径);line 可选(1 基)。
1855
- const body = await readJsonBody(request);
1856
- const file = typeof body.file === 'string' ? body.file : null;
1857
- if (file === null || file === '') {
1858
- return jsonResponse({ ok: false, error: '需要 file 字段(要打开的绝对路径)' }, 400);
1859
- }
1860
- const line = Number.isSafeInteger(body.line) && body.line > 0 ? body.line : null;
1861
- const root = dataRoot(cfg);
1862
- const userDataDir = cfg.userDataDir || path.join(root, 'user-data');
1863
- const signal = openFileSignalPath(userDataDir);
1864
- fs.mkdirSync(path.dirname(signal), { recursive: true });
1865
- fs.writeFileSync(signal, JSON.stringify(line === null ? { file, ts: Date.now() } : { file, line, ts: Date.now() }), 'utf8');
1866
- return jsonResponse({ ok: true, signal });
1867
- } catch (err) {
1868
- return failureResponse(err);
1869
- }
1870
- }
1871
-
1872
- /**
1873
- * 客户端上报 UI 载体能力(0.2.3):{ sidebar: false } = 旧版 DSH(没有右侧栏服务)。
1874
- * 旧版 DSH 上本插件不提供任何入口 → 这里回收「由本插件自动预启动」的实例,避免留下用不上的 IDE 进程;
1875
- * 用户手动启动的实例(adopted)不动,后续也不再有 UI 触发启动。
1876
- */
1877
- async function handleUiMode(request) {
1878
- try {
1879
- const body = await readJsonBody(request);
1880
- if (body !== null && typeof body.sidebar === 'boolean') {
1881
- const next = body.sidebar;
1882
- if (state.sidebarUi !== next) {
1883
- state.sidebarUi = next;
1884
- console.log(`[code-server] 客户端 UI 载体:${next ? '右侧栏(受支持)' : '无右侧栏(旧版 DSH,只给设置页提示)'}`);
1885
- }
1886
- if (next === false && state.prestarted === true && state.adopted !== true) {
1887
- state.prestarted = false;
1888
- if (state.status === 'running' || state.status === 'starting') {
1889
- console.warn('[code-server] 旧版 DSH:回收自动预启动的实例(旧版已不受支持)');
1890
- await stop('legacy-ui').catch(err => {
1891
- console.error(`[code-server] 回收失败:${err && err.message ? err.message : err}`);
1892
- });
1893
- }
1894
- }
1895
- }
1896
- return jsonResponse({ ok: true, sidebarUi: state.sidebarUi });
1897
- } catch (err) {
1898
- return failureResponse(err);
1899
- }
1900
- }
1901
-
1902
- // ---- 编辑器桥路由(0.3.0)----
1903
- // 这些路由**不依赖** DSH 的 cookie 认证:扩展宿主是 Node 进程,拿不到浏览器 cookie,
1904
- // 所以自带独立令牌(见 lib/bridge.mjs 顶部的安全不变量)。命名空间永久只读。
1905
-
1906
- /** 每个桥路由都先过 guard;返回 Response 表示拒绝,调用方直接返回它。 */
1907
- function bridgeRejection(request) {
1908
- return bridgeGuard(request, bridgeMeta === null ? null : bridgeMeta.token);
1909
- }
1910
-
1911
- /** 桥不可达时的统一 503(扩展侧没跑 / IDE 刚起还没加载扩展)。 */
1912
- function bridgeDown(reason) {
1913
- return jsonResponse({ ok: false, error: reason }, 503);
1914
- }
1915
-
1916
- async function handleBridgeHealth() {
1917
- // 无鉴权:只回一句"桥活着吗",不含任何编辑器数据(便于重启后一眼确认)。
1918
- // 注意:bridge=true 只表示"配置已就绪",**不代表扩展在跑**(见 README 已知限制)。
1919
- return jsonResponse({
1920
- ok: true,
1921
- bridge: bridgeMeta !== null,
1922
- pid: state.pid,
1923
- transport: 'ipc',
1924
- endpoint: bridgeMeta === null ? null : bridgeMeta.pipe,
1925
- });
1926
- }
1927
-
1928
- async function handleBridgeSync(request) {
1929
- const denied = bridgeRejection(request);
1930
- if (denied !== null) return denied;
1931
- let body;
1932
- try {
1933
- body = await readJsonBody(request);
1934
- } catch {
1935
- return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
1936
- }
1937
- if (!bodyWithinLimit(body)) return jsonResponse({ ok: false, error: '上报内容过大' }, 413);
1938
- bridgeContext.update(body);
1939
- // 取事件 + 推进游标:与上报同一趟来回,扩展不需要第二个定时器。
1940
- let since = 0;
1941
- try {
1942
- const raw = new URL(request.url).searchParams.get('since');
1943
- const parsed = raw === null ? 0 : Number.parseInt(raw, 10);
1944
- if (Number.isSafeInteger(parsed) && parsed > 0) since = parsed;
1945
- } catch {
1946
- since = 0;
1947
- }
1948
- const events = bridgeEvents.since(since);
1949
- bridgeEvents.reset();
1950
- // 面板声明它要看哪些会话(数组;空 = 关掉了)。据此对齐订阅 + 判断"面板在不在看"
1951
- // (授权拦截只在面板看着的时候才抢答,否则一律交给官方链路)。
1952
- const watch = Array.isArray(body.watch) ? body.watch.filter((id) => typeof id === 'string' && id !== '') : [];
1953
- bridgeWatch.ids = watch.slice(0, 4);
1954
- bridgeApprovalsUi = body.approvalsUi === true;
1955
- if (bridgeApprovalsUi !== bridgeApprovalsUiLogged) {
1956
- bridgeApprovalsUiLogged = bridgeApprovalsUi;
1957
- bridgeDiag('approvalsUi → ' + bridgeApprovalsUi + '(客户端可否显示授权卡片)');
1958
- }
1959
- bridgeWatch.at = Date.now();
1960
- bridgeThread.sync(watchedSessions());
1961
- // 看护状态变化就写一行;并且**首帧必写**(0.3.31:只记"翻转"会让"一直没人看"这种情况在日志里
1962
- // 完全空白,事后无从判断 —— 上一轮就是这样被拖了一整轮)。
1963
- const watchingNow = hasWatcher();
1964
- if (watchingNow !== bridgeWatchLogged || bridgeWatchWroteFirst !== true) {
1965
- bridgeWatchLogged = watchingNow;
1966
- bridgeWatchWroteFirst = true;
1967
- bridgeDiag('hasWatcher → ' + watchingNow
1968
- + ' (面板=[' + bridgeWatch.ids.join(',') + '] 对话框=[' + bridgeWatch.dialogIds.join(',') + ']'
1969
- + ' approvalsUi=' + bridgeApprovalsUi + ')');
1970
- }
1971
- // 请求过对话框、客户端半部却从没来问过 → 说清楚原因(多半是浏览器还缓存着旧的客户端 bundle)。
1972
- if (askDialog.open && !askDialogLive() && askDialog.warned !== true) {
1973
- askDialog.warned = true;
1974
- console.warn('[code-server] 请求了「问 DSH」对话框,但客户端半部没有轮询 /ask/state:'
1975
- + '浏览器可能还在用旧的客户端 bundle(硬刷新 / 清 Code Cache)。'
1976
- + '此期间提问会退回编辑器面板,授权也在面板里批。');
1977
- }
1978
- // **对话流只在变化时回传**(0.3.27):以前每趟 /sync 都塞整份快照(≤120 条 × ≤8KB,可达 ~1MB / 600ms),
1979
- // 实测把 /sync 拖成 3 秒超时 —— 扩展收不到响应 ⇒ 面板看不到 approvals ⇒ 授权卡片永远不出现
1980
- // (卡片在宿主里干等到窗口结束才交给 DSH 界面)。现在给修订号,没变化就不重传。
1981
- const threadRev = bridgeThread.rev(watchedSessions());
1982
- const snapshot = bridgeThread.supported()
1983
- ? bridgeThread.snapshot(watchedSessions())
1984
- : { available: false, sessionId: null, cursor: null, rev: 0, entries: [], error: '宿主没有 sessionController.follow(DSH 版本过旧)' };
1985
- // **按"客户端已经拿到哪一版"决定发不发**(0.3.29 修):0.3.28 比的是宿主自己上次发过的版本,
1986
- // 于是"面板是后开的"这种情形下宿主认为没变化、什么都不发,面板手里一份快照都没有 ⇒ 旧模型把它
1987
- // 当成"宿主没有对话流能力"(用户实测到的那条误报)。现在由客户端用 threadRev 声明自己持有的版本;
1988
- // 不带这个字段的旧扩展一律收整份(不省流量,但绝不误报)。
1989
- const peerRev = Number.isSafeInteger(body.threadRev) ? body.threadRev : null;
1990
- const threadChanged = peerRev === null || peerRev !== threadRev || snapshot.available === false;
1991
- bridgeThreadRevSent = threadRev;
1992
- // 对话流走独立字段:每个会话给有界条目(见 lib/bridge-thread.mjs),
1993
- // 授权待决列表也一并回去(面板显示卡片)。
1994
- return jsonResponse({
1995
- ok: true,
1996
- events,
1997
- // thread 缺省 = 没变化(扩展沿用上一份);threadRev 用来区分"没变化"与"旧宿主没有这个字段"。
1998
- threadRev,
1999
- thread: threadChanged ? snapshot : undefined,
2000
- approvals: bridgeApprovalBoard.snapshot(),
2001
- // 授权卡片要在面板上倒计时,窗口长度由 host 给(面板不猜)。
2002
- approvalHoldMs: DEFAULT_HOLD_MS,
2003
- dshVersion: dshVersion(),
2004
- // 界面版本:面板据此提示"内置渲染器与当前 DSH 界面版本不一致"。
2005
- uiVersion: uiVersion(),
2006
- // 能力探测(0.3.24;0.3.25 起以"客户端半部真的在轮询"为准):
2007
- // 只有对话框活着的时候才让扩展走 ask-open;否则扩展开编辑器面板(那里也能批授权)。
2008
- askDialog: askDialogLive(),
2009
- // 诊断用:请求过对话框但客户端没响应(浏览器缓存旧 bundle?)。
2010
- askDialogRequested: askDialog.open,
2011
- });
2012
- }
2013
-
2014
- async function handleBridgeAsk(request) {
2015
- const denied = bridgeRejection(request);
2016
- if (denied !== null) return denied;
2017
- let body;
2018
- try {
2019
- body = await readJsonBody(request);
2020
- } catch {
2021
- return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
2022
- }
2023
- const text = typeof body.text === 'string' ? body.text : '';
2024
- if (text === '') return jsonResponse({ ok: false, error: '需要 text 字段(要问 DSH 的话)' }, 400);
2025
- const file = typeof body.file === 'string' && body.file !== '' ? body.file : null;
2026
- const lineStart = Number.isSafeInteger(body.lineStart) ? body.lineStart : null;
2027
- const lineEnd = Number.isSafeInteger(body.lineEnd) ? body.lineEnd : null;
2028
- const selection = typeof body.selection === 'string' && body.selection !== '' ? body.selection : null;
2029
- // 交给 bridge-session.mjs(agent 投递);它内部特性探测 agents,缺失时返回
2030
- // {ok:false, code:'NO_AGENT'} —— 这里原样透传,并把 NO_AGENT 映射成 409。
2031
- const result = await deliverEditorPrompt(ctx, {
2032
- text,
2033
- file,
2034
- lineStart,
2035
- lineEnd,
2036
- selection,
2037
- languageId: typeof body.languageId === 'string' ? body.languageId : null,
2038
- });
2039
- // 会话流 + 授权拦截都在这里建立(只针对**被问过的**那个会话,agent 作用域):
2040
- // - thread:面板像 DSH 对话那样显示"新内容"(见 lib/bridge-thread.mjs);
2041
- // - approval:该会话的敏感动作(工作区外写入 / 命令执行)先在面板里问一句,
2042
- // 8 秒没人答就交给官方链路(见 lib/bridge-approval.mjs)。
2043
- if (result.ok === true && typeof result.sessionId === 'string' && result.sessionId !== '') {
2044
- const ok = bridgeThread.watch(result.sessionId);
2045
- if (!ok) console.warn('[code-server] 编辑器桥:会话订阅未建立(宿主没有 sessionController.follow)');
2046
- bridgeApproval.intercept(result.agent, result.sessionId);
2047
- }
2048
- // `agent` 是内部句柄,不能进 JSON。
2049
- const { agent: _agent, ...payload } = result;
2050
- void _agent;
2051
- return jsonResponse(payload, result.ok === true ? 200 : (result.code === 'NO_AGENT' ? 409 : 200));
2052
- }
2053
-
2054
- /**
2055
- * 面板对授权请求的决策(0.3.22)。
2056
- *
2057
- * **这是命名空间里唯一的非只读路由**,约束写死在实现里:
2058
- * - 只能回答**本 host 发起、仍未决**的请求(单次使用,用后即废);
2059
- * - outcome 只接受 `allowed-once` / `rejected`;
2060
- * - 不接受任何自由文本、路径或命令参数 —— 它只能"回答问题",不能"发起动作"。
2061
- */
2062
- async function handleBridgeApprove(request) {
2063
- const denied = bridgeRejection(request);
2064
- if (denied !== null) return denied;
2065
- let body;
2066
- try {
2067
- body = await readJsonBody(request);
2068
- } catch {
2069
- return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
2070
- }
2071
- const verdict = answerApproval(
2072
- typeof body.id === 'string' ? body.id : '',
2073
- typeof body.outcome === 'string' ? body.outcome : '',
2074
- );
2075
- return jsonResponse(verdict.body, verdict.status);
2076
- }
2077
-
2078
- // ---------------------------------------------------------------- 「问 DSH」对话框(client 半部)
2079
-
2080
- /** 对话框每 900ms 问一次:给修订号,没变就只回一个数字(不重传几十 KB 的对话流)。 */
2081
- async function handleAskState(request) {
2082
- // 这一条就是"客户端半部活着"的证据(能力位 askDialog 以它为准,见 askDialogLive)。
2083
- askDialog.polls += 1;
2084
- // 有轮询 = 对话框活着 ⇒ 武装(关掉时 /ask/close 会立刻解除)。
2085
- askDialog.armed = true;
2086
- askDialog.lastPollAt = Date.now();
2087
- // 心跳证据(0.3.35):首次 + 每 40 次写一行,证明"对话框在持续发活跃通知"。
2088
- if (askDialog.polls % 40 === 1) {
2089
- bridgeDiag('dialog heartbeat #' + askDialog.polls + ' (open=' + askDialog.open + ' session=' + String(askDialog.sessionId) + ')');
2090
- }
2091
- if (askDialog.polls === 1) {
2092
- console.log('[code-server] 「问 DSH」对话框已连上客户端半部:/ask/state 开始轮询');
2093
- }
2094
- refreshDialogWatch();
2095
- const current = askRevision();
2096
- let rev = null;
2097
- try {
2098
- const raw = new URL(request.url).searchParams.get('rev');
2099
- const parsed = raw === null ? Number.NaN : Number.parseInt(raw, 10);
2100
- if (Number.isSafeInteger(parsed) && parsed >= 0) rev = parsed;
2101
- } catch {
2102
- rev = null;
2103
- }
2104
- if (rev !== null && rev === current) return jsonResponse({ ok: true, changed: false, rev: current });
2105
- return jsonResponse({ ...askState(), changed: true, rev: current });
2106
- }
2107
-
2108
- /** 对话框发一条提问:上下文取宿主缓存里的编辑器状态(扩展每 600ms 上报)。 */
2109
- async function handleAskSend(request) {
2110
- let body;
2111
- try {
2112
- body = await readJsonBody(request);
2113
- } catch {
2114
- return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
2115
- }
2116
- const text = typeof body.text === 'string' ? body.text.trim() : '';
2117
- if (text === '') return jsonResponse({ ok: false, error: '需要 text 字段(要问 DSH 的话)' }, 400);
2118
- const mode = body.mode === 'file' ? 'file' : askDialog.mode;
2119
- const context = askContextFromCache(mode);
2120
- const result = await deliverEditorPrompt(ctx, {
2121
- text,
2122
- file: context === null ? null : context.file,
2123
- lineStart: context === null ? null : context.lineStart,
2124
- lineEnd: context === null ? null : context.lineEnd,
2125
- selection: context === null ? null : context.selection,
2126
- languageId: context === null ? null : context.languageId,
2127
- });
2128
- if (result.ok === true && typeof result.sessionId === 'string' && result.sessionId !== '') {
2129
- askDialog.sessionId = result.sessionId;
2130
- askDialog.mode = mode;
2131
- refreshDialogWatch();
2132
- bridgeThread.watch(result.sessionId);
2133
- bridgeApproval.intercept(result.agent, result.sessionId);
2134
- }
2135
- const { agent: _agent, ...payload } = result;
2136
- void _agent;
2137
- return jsonResponse(payload, result.ok === true ? 200 : (result.code === 'NO_AGENT' ? 409 : 200));
2138
- }
2139
-
2140
- /** 对话框回答授权(与桥的 /approve 同一套校验)。 */
2141
- async function handleAskApprove(request) {
2142
- let body;
2143
- try {
2144
- body = await readJsonBody(request);
2145
- } catch {
2146
- return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
2147
- }
2148
- const verdict = answerApproval(
2149
- typeof body.id === 'string' ? body.id : '',
2150
- typeof body.outcome === 'string' ? body.outcome : '',
2151
- );
2152
- return jsonResponse(verdict.body, verdict.status);
2153
- }
2154
-
2155
- /** 对话框关掉:不再看会话 ⇒ 待决授权按"没人看"交回官方链路(拦截器的轮询会立刻放行)。 */
2156
- async function handleAskClose() {
2157
- askDialog.open = false;
2158
- // **关闭即解除武装**(0.3.40):否则 10 分钟 TTL 内我们还会继续拦截,而卡片已经没地方显示。
2159
- askDialog.armed = false;
2160
- askDialog.lastPollAt = 0;
2161
- askDialog.sessionId = null;
2162
- refreshDialogWatch();
2163
- return jsonResponse({ ok: true });
2164
- }
2165
-
2166
- /** 面板产物(thread.js / thread.css)给对话框注入用。 */
2167
- async function handleAskBundle() {
2168
- const bundle = askBundle();
2169
- if (bundle === null) {
2170
- return jsonResponse({ ok: false, error: '面板产物缺失:请在插件源码里跑 pnpm run build:webview,或重装插件' }, 503);
2171
- }
2172
- return jsonResponse({ ok: true, rev: askRevision(), ...bundle });
2173
- }
2174
-
2175
- async function handleBridgeEvent(request) {
2176
- const denied = bridgeRejection(request);
2177
- if (denied !== null) return denied;
2178
- const body = await readJsonBody(request);
2179
- const kind = typeof body.kind === 'string' ? body.kind : '';
2180
- if (kind === '') return jsonResponse({ ok: false, error: '需要 kind 字段' }, 400);
2181
- // 右键「问 DSH」→ 扩展上报 ask-open:host 打开 DSH 页面里的对话框(client 半部轮询到就拿去显示)。
2182
- if (kind === 'ask-open') {
2183
- askDialog.open = true;
2184
- askDialog.mode = body.mode === 'file' ? 'file' : 'selection';
2185
- askDialog.at = Date.now();
2186
- refreshDialogWatch();
2187
- appendLog(`[bridge] ask-open mode=${askDialog.mode}\n`);
2188
- return jsonResponse({ ok: true, mode: askDialog.mode, contextText: askContextLine(askDialog.mode) });
2189
- }
2190
- // 扩展上报的编辑器侧事件(打开/关闭文件等):目前只进日志尾,供诊断时回看。
2191
- appendLog(`[bridge] ${kind}${typeof body.path === 'string' ? ` ${body.path}` : ''}\n`);
2192
- return jsonResponse({ ok: true });
2193
- }
2194
-
2195
-
2196
- // 每条操作一条 exact Fetch 路由。desktop 的 assetHandler 只把 /api/* 交给
2197
- // createSharedFetchHandler('/api'),所以路径必须落在 /api 下。
2198
- const disposers = [ { path: `${API_BASE}/status`, methods: ['GET'], fetch: handleStatus },
2199
- { path: `${API_BASE}/start`, methods: ['POST'], fetch: handleStart },
2200
- { path: `${API_BASE}/stop`, methods: ['POST'], fetch: handleStop },
2201
- { path: `${API_BASE}/setup`, methods: ['POST'], fetch: handleSetup },
2202
- { path: `${API_BASE}/open-file`, methods: ['POST'], fetch: handleOpenFile },
2203
- { path: `${API_BASE}/ui-mode`, methods: ['POST'], fetch: handleUiMode },
2204
- // ---- 「问 DSH」对话框(0.3.24):DSH 页面里的 client 半部调这几条 ----
2205
- // 这里**在 /api 下**是对的:调用方是 DSH 页面自己(同源 + cookie),与扩展宿主(必须走本机 IPC)不同。
2206
- { path: `${API_BASE}/ask/state`, methods: ['GET'], fetch: handleAskState },
2207
- { path: `${API_BASE}/ask/send`, methods: ['POST'], fetch: handleAskSend },
2208
- { path: `${API_BASE}/ask/approve`, methods: ['POST'], fetch: handleAskApprove },
2209
- { path: `${API_BASE}/ask/close`, methods: ['POST'], fetch: handleAskClose },
2210
- { path: `${API_BASE}/ask/bundle`, methods: ['GET'], fetch: handleAskBundle },
2211
- // ---- 编辑器桥的 4 条路由**不在 /api 下**(0.3.9 修正)----
2212
- // 原因:Connection 给 `/api` 装了 cookie fence(无 cookie → 401),而扩展宿主是 Node 进程、
2213
- // 永远拿不到浏览器 cookie ⇒ 挂在这里的路由根本到不了(实测:带令牌也被 401/405 挡回)。
2214
- // 现在挂到 DSH webServer 的 `${BRIDGE_BASE}/*`,自带桥令牌鉴权(见该处注释与 lib/bridge.mjs 的安全不变量)。
2215
- ].map(route => connection.fetch.register({
2216
- path: route.path,
2217
- methods: route.methods,
2218
- requestBody: 'buffered',
2219
- fetch: route.fetch,
2220
- }));
2221
-
2222
- ctx.effect(() => {
2223
- return () => {
2224
- disposeKilled = true;
2225
- stopPolling();
2226
- bridgeObserveDispose = disposeSafely(bridgeObserveDispose);
2227
- bridgeAnswerDispose = disposeSafely(bridgeAnswerDispose);
2228
- bridgePromptDispose = disposeSafely(bridgePromptDispose);
2229
- clearBridgeRuntime();
2230
- for (const d of disposers) {
2231
- try {
2232
- // connection.fetch.register 的 disposer 是异步的(返回 Promise);
2233
- // 注册本身也挂在当前 fiber 上,这里显式回收只是双保险。
2234
- Promise.resolve(d()).catch(() => {});
2235
- } catch {
2236
- // ignore
2237
- }
2238
- }
2239
- if (child !== null || (state.pid !== null && isAlive(state.pid))) {
2240
- const pid = state.pid;
2241
- child = null;
2242
- if (pid) {
2243
- killTree(pid).then(() => removePidFile(cfg));
2244
- }
2245
- }
2246
- };
2247
- }, 'code-server: lifecycle');
2248
-
2249
- // ---- 环境自检:code-server 就在插件目录里就地运行,依赖由包管理器装好,无任何安装步骤 ----
2250
- const record = readPidFile(cfg);
2251
- const liveInstance = record !== undefined && record !== null && isAlive(record.pid);
2252
-
2253
- function maybePrestart() {
2254
- // 环境就绪且未运行 → 后台自动拉起(不等待),首次打开侧栏标签时 iframe 立即加载。
2255
- // 旧版 DSH(客户端上报 sidebarUi=false)没有可用入口 → 不预启动,也不占端口。
2256
- if (state.sidebarUi === false) return;
2257
- if (state.env.ok && state.status !== 'running' && state.status !== 'starting') {
2258
- state.prestarted = true;
2259
- start().catch((err) => {
2260
- state.prestarted = false;
2261
- console.error('[code-server] prestart failed:', err && err.message ? err.message : String(err));
2262
- });
2263
- }
2264
- }
2265
-
2266
- state.env = envCheck();
2267
- // layout 已在本函数开头算过(envCheck 之前):这里只报告结果。
2268
- if (layout.created.length > 0) {
2269
- console.log(`[code-server] 已补齐 ${layout.created.length} 个依赖链接: ${layout.created.join(', ')}`);
2270
- }
2271
- if (layout.failed.length > 0) {
2272
- console.warn(`[code-server] 依赖链接创建失败: ${layout.failed.join('; ')}`);
2273
- }
2274
- if (!vendorReady()) {
2275
- console.warn(`[code-server] 找不到 VS Code 树:平台无关包 ${codeServerPackageName()} 未安装,`
2276
- + '且包内 vendor/vscode 不存在(开发期请先 `node scripts/vendor-vscode-server.mjs --dev-links`)');
2277
- } else {
2278
- console.log(`[code-server] VS Code 树: ${vsRoot()}(productPath=${state.env.productPath ?? '?'})`);
2279
- }
2280
- if (!state.env.ok) {
2281
- console.warn(`[code-server] 环境未就绪: ${describeEnvProblem(state.env)}`);
2282
- }
2283
- const legacyRoot = legacyInstallRoot();
2284
- if (legacyRoot !== null && fs.existsSync(legacyRoot)) {
2285
- console.log(`[code-server] 检测到旧版安装根 ${legacyRoot}(0.1.35 及更早遗留,已不再使用,可安全删除)`);
2286
- }
2287
-
2288
- // DSH host 重启后 adopt(仅 loopback 模式):pid.json 有效、进程存活、令牌文件在、endpoint 记录的端口
2289
- // 上 /healthz(带令牌)响应 → 接管。端口 0 时不能拿 cfg.port 去探(那是个占位值),必须用 endpoint 文件。
2290
- // dsh 模式的 launcher 带 --parent-pid 看门狗:host 一退出它就自行退出,不存在可接管的孤儿。
2291
- if (liveInstance && (record.serve !== 'dsh')) {
2292
- const adoptedToken = readToken(cfg);
2293
- const endpoint = readEndpoint(cfg);
2294
- const adoptPort = endpoint !== null && endpoint.pid === record.pid ? endpoint.port : (cfg.port !== 0 ? cfg.port : null);
2295
- const probe = adoptPort === null || adoptedToken === null
2296
- ? { ok: false }
2297
- : await healthCheck(cfg.host, adoptPort, adoptedToken, 800);
2298
- if (probe.ok) {
2299
- state.serve = 'loopback';
2300
- state.status = 'running';
2301
- state.pid = record.pid;
2302
- state.port = adoptPort;
2303
- state.token = adoptedToken;
2304
- state.cwd = record.cwd ?? null;
2305
- state.launchCwd = record.launchCwd ?? record.cwd ?? null;
2306
- state.startedAt = record.startedAt ?? null;
2307
- state.adopted = true;
2308
- console.log(`[code-server] adopted running instance pid=${record.pid} port=${adoptPort}(令牌已启用)`);
2309
- adoptBridgeRuntime(record.pid, state.startedAt);
2310
- } else {
2311
- removePidFile(cfg);
2312
- }
2313
- } else if (liveInstance) {
2314
- removePidFile(cfg); // 上次是 dsh 模式(管道随进程消失)→ 清理陈旧记录
2315
- }
2316
-
2317
- maybePrestart();
2318
-
2319
- console.log(`[code-server] static plugin loaded (serve=${requestedServe()} host=${cfg.host} port=${cfg.port === 0 ? '0=随机' : cfg.port})`);
2320
- }
1
+ /**
2
+ * dsh-code-server — host 半部:VS Code server 子进程(lib/launcher.mjs)的生命周期/状态 + /api/code-server JSON API。
3
+ *
4
+ * 模型:不运行 code-server 的 Node 服务层,直接由 lib/launcher.mjs 驱动树里的
5
+ * <树>/lib/vscode/out/server-main.js(树 = @<scope>/dshcs-vscode-server 的 vscode/ 子目录);
6
+ * 依据与实测见 docs/analysis-code-server-as-dsh-plugin.md。零外部依赖(只用 Node 内置模块)。
7
+ * 静态 profile 插件:exports.name 与 cordis.patch.yml 行 id 一致;inject = ['connection','settings']。
8
+ *
9
+ * 路由经 ctx.connection.fetch.register 挂在 Connection 的共享 /api 通道上(**不依赖 webServer**):
10
+ * web profile 由 Connection 把 /api 挂到 webServer;desktop 由 apps/desktop-host 交给同一个
11
+ * createSharedFetchHandler('/api')(IPC 帧管道)→ 客户端只需同源 fetch('/api/code-server/<op>')。
12
+ * GET status | POST start{cwd?} | stop | setup(兼容空操作) | open-file{file,line?} | ui-mode{sidebar}
13
+ *
14
+ * 不变量:进程生命周期归本插件(启动写 pid.json,停止树级终止);host 重启后按 pid.json + /healthz
15
+ * adopt 存活实例(不重复启动、不误杀);auth 固定 none(0.2.0 起不再支持口令);插件销毁时回收进程与路由。
16
+ * 旧版 DSH(客户端上报 {sidebar:false})会在此回收"自动预启动"的实例并停止预启动。
17
+ */
18
+
19
+ import { spawn, execFile, execFileSync } from 'node:child_process';
20
+ import { randomBytes } from 'node:crypto';
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
23
+ import * as os from 'node:os';
24
+ import * as http from 'node:http';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { createRequire } from 'node:module';
27
+ import {
28
+ PACKAGE_ROOT,
29
+ codeServerPackageName,
30
+ legacyInstallRoot,
31
+ productPath,
32
+ readTreeVersion,
33
+ vendoredVersion,
34
+ vendorReady,
35
+ vsRoot,
36
+ vsServerEntry,
37
+ } from './vendor.js';
38
+ import { ensureRuntimeLayout, nativeRuntimeStatus } from './native.js';
39
+ import { MOUNT_PATH, mountOnWebServer } from './serve-dsh.mjs';
40
+ import { DEFAULT_CLAIM_EXTENSIONS, describeClaimPolicy, normalizeClaimExtensions } from './claim-types.js';
41
+ import {
42
+ BRIDGE_BASE,
43
+ BRIDGE_DIRNAME,
44
+ bodyWithinLimit,
45
+ bridgeGuard,
46
+ createContextCache,
47
+ createEventRing,
48
+ mintBridgeToken,
49
+ readBridgeConfig,
50
+ removeBridgeConfig,
51
+ writeBridgeConfig,
52
+ } from './bridge.mjs';
53
+ import { bridgeEndpointPath, startBridgeListener } from './bridge-ipc.mjs';
54
+ import { registerEditorPrompt, registerEditorTools, setPromptLiveProbe } from './bridge-tools.mjs';
55
+ import { deliverEditorPrompt } from './bridge-session.mjs';
56
+ import { dshEntry, dshRequire } from './dsh-resolve.mjs';
57
+ import { registerBridgeObserver } from './bridge-observe.mjs';
58
+ import { createThreadRegistry } from './bridge-thread.mjs';
59
+ import { createApprovalBoard, createApprovalInterceptor, DEFAULT_HOLD_MS, PANEL_OUTCOMES } from './bridge-approval.mjs';
60
+
61
+ // schemastery 由 DSH 部署自带(官方核心依赖),仿 auto-open-web 的解析策略:
62
+ // 常规 import 优先,不可用时回退到 DSH 部署里的那一份。
63
+ //
64
+ // 回退走 lib/dsh-resolve.mjs 的 dshRequire() —— 位置表就一份,%APPDATA%\npm\node_modules\@deepseek-ai\dsh
65
+ // **和** $DSH_HOME/profiles/node_modules\@deepseek-ai\dsh。只查前者是不够的(0.3.47 的安装冒烟实测):
66
+ // 只要 CLI 不是「普通用户 npm 全局装」,前者就不存在 ——
67
+ // · CI / nvm / 工具缓存:npm 的全局前缀是 node 安装目录,不是 %APPDATA%\npm;
68
+ // · pnpm global:装到 pnpm 自己的目录;
69
+ // · 而后者由 DSH 每次 boot 自己 heal 出来(profile 层 junction),生产里一定在。
70
+ // 两条都拿不到时才抛错(不是静默降级:设置 schema 建不出来,插件没有意义)。
71
+ let z = null;
72
+ try {
73
+ z = (await import('@deepseek-ai/schemastery')).default;
74
+ } catch {
75
+ try {
76
+ const req = dshRequire();
77
+ if (req !== null) z = req('@deepseek-ai/schemastery');
78
+ } catch {
79
+ /* 两次解析均失败 → 下方抛错 */
80
+ }
81
+ }
82
+ if (z === null || z === undefined) {
83
+ throw new Error(
84
+ '[code-server] schemastery not found (neither local nor DSH deployment); ' +
85
+ 'this plugin cannot build its settings schema. Check the DSH deployment.',
86
+ );
87
+ }
88
+
89
+ export const name = 'code-server';
90
+ // connection:client↔host 通道(web 走 webServer 的 /api,desktop 走 IPC 帧管道);
91
+ // settings:设置卡片的数据域。两者在 web 与 desktop profile 下都存在。
92
+ export const inject = ['connection', 'settings'];
93
+
94
+ /** 设置命名空间:卡片经官方 settings 域读写,持久化到官方 settings 文档。 */
95
+ export const SETTINGS_NS = 'code-server';
96
+
97
+ /** 设置卡片 schema(只留三个可改设置:claimExtensions / fullscreenOnOpen / keepResident)。
98
+ * 旧设置文档里残留的 reserveComposer / windowedOpen / fileOpenScope 不再出现在 schema 里 ——
99
+ * schemastery 的 object 非 strict 会把未知键原样 merge 进解析结果,既不报错也不影响取值,故无需迁移。 */
100
+ export const Config = z.object({
101
+ /** 服务方式:loopback(默认)独立回环端口;dsh 挂到 DSH webServer 的 /code-server
102
+ * (同源、无额外端口、复用 DSH 的 Host/Origin + cookie 防护;需要 DSH 提供 webServer 服务,
103
+ * 缺失或注册失败时自动回退 loopback)。没有卡片行:改 cordis.patch.yml 的 config.serve
104
+ * 或设置文档里的 code-server.serve。 */
105
+ serve: z.union([z.const('loopback'), z.const('dsh')]).default('loopback'),
106
+ /** keepResident=true(默认)客户端在宿主 running 后把 IDE 预先加载到"停放区",
107
+ * 切标签/收起侧栏不再重载;false 则只在打开面板时才加载(省内存)。 */
108
+ keepResident: z.boolean().default(true),
109
+ /** 认领类型(0.2.11):按扩展名决定哪些文件交给 VS Code 打开(分号分隔,`*` = 其余类型也认领,
110
+ * `!ext` = 不认领,排除优先)。**不再区分 session/absolute 作用域** —— 所有
111
+ * `dsh-resource://file/**` 地址一视同仁。语法、默认值与解析见 lib/claim-types.js。 */
112
+ claimExtensions: z.string().default(DEFAULT_CLAIM_EXTENSIONS),
113
+ /** fullscreenOnOpen=true(默认):打开 Code Server 标签(含点开产物/交付文件)时,
114
+ * 客户端把右侧栏切到全屏(铺满窗口);false 则保持 DSH 默认的 push(与对话并排)。
115
+ * 只影响"打开那一刻":之后用户点「退出全屏」不会被抢回去。 */
116
+ fullscreenOnOpen: z.boolean().default(true),
117
+ /** editorBridge=true(默认):启用「编辑器桥」(0.3.0)—— 树内扩展 dshcs-editor-bridge 与
118
+ * host 之间建立**只读**通道,给 agent 提供 only-the-editor-knows 的上下文(未保存缓冲区、
119
+ * 诊断、活动选区),并让编辑器里的动作能驱动 DSH。关闭后不写 bridge.json、不注册编辑器工具,
120
+ * 扩展会休眠(它读不到配置就不做任何事)。安全模型见 lib/bridge.mjs 顶部。 */
121
+ editorBridge: z.boolean().default(true),
122
+ });
123
+
124
+ const DEFAULT_CONFIG = {
125
+ bin: '',
126
+ host: '127.0.0.1',
127
+ /** 0(默认)= 每次启动由系统分配空闲端口(launcher 把实际端口写进 endpoint 文件,host 读回);
128
+ * 显式给端口则照用(用于需要固定地址的场景)。 */
129
+ port: 0,
130
+ auth: 'none',
131
+ serve: 'loopback',
132
+ userDataDir: '',
133
+ extensionsDir: '',
134
+ locale: '',
135
+ readyTimeoutMs: 60000,
136
+ /** 编辑器桥(0.3.0):见 Config.editorBridge。行配置与设置文档都可关;设置卡片暂不提供行。 */
137
+ editorBridge: true,
138
+ };
139
+
140
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
141
+ const LOG_TAIL_MAX = 6000;
142
+ /** 路径令牌格式(与 launcher 的一致;字符集同时满足 VS Code 自己的 token 校验)。 */
143
+ const TOKEN_RE = /^[0-9A-Za-z_-]{16,128}$/;
144
+
145
+ function dshHome() {
146
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
147
+ }
148
+
149
+ function dataRoot(config) {
150
+ return path.join(dshHome(), 'code-server');
151
+ }
152
+
153
+ function pidFile(config) {
154
+ return path.join(dataRoot(config), 'pid.json');
155
+ }
156
+
157
+ /** 路径令牌文件(0600 语义:位于用户 profile 下,默认 ACL 仅本人可读)。 */
158
+ function tokenFile(config) {
159
+ return path.join(dataRoot(config), 'path-token');
160
+ }
161
+
162
+ /** launcher 回写实际监听地址的文件(端口 0 时 host 只能从这里知道端口)。 */
163
+ function endpointFile(config) {
164
+ return path.join(dataRoot(config), 'endpoint.json');
165
+ }
166
+
167
+ function isAlive(pid) {
168
+ if (!Number.isInteger(pid) || pid <= 0) return false;
169
+ try {
170
+ process.kill(pid, 0);
171
+ return true;
172
+ } catch (err) {
173
+ return err && err.code === 'EPERM';
174
+ }
175
+ }
176
+
177
+ function win32() {
178
+ return process.platform === 'win32';
179
+ }
180
+
181
+ /** 本插件的 package.json 版本(?v= 缓存击穿标记用;读不到就退回 'dev')。 */
182
+ function pluginVersion() {
183
+ try {
184
+ return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version ?? 'dev';
185
+ } catch {
186
+ return 'dev';
187
+ }
188
+ }
189
+
190
+ /** 插件自带 launcher(<pkg>/lib/launcher.mjs);它直接驱动 VS Code server(0.2.0 模型)。 */
191
+ function launcherPath() {
192
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), 'launcher.mjs');
193
+ }
194
+
195
+ /** 扩展安装目标:`placement: 'builtin'` 时优先 VS Code「内置扩展」目录 = <树>/lib/vscode/extensions
196
+ * (位于程序内置目录的扩展被 VS Code 视为内置——用户视图显示为"内置",**不能卸载**);
197
+ * `placement: 'user'` 时只用 --extensions-dir(用户级扩展,可被用户禁用/卸载)。
198
+ * 返回 { dst, builtin }——builtin=true 时是核心路径;找不到树时回退用户级。
199
+ * `treeRoot` 可注入(测试用):默认 `vsRoot()`,注入后测试不会碰真实树。 */
200
+ function extensionTarget(extensionsDir, name, placement = 'builtin', treeRoot = vsRoot()) {
201
+ if (placement === 'builtin') {
202
+ try {
203
+ if (treeRoot !== null) {
204
+ const vscodeExt = path.join(treeRoot, 'lib', 'vscode', 'extensions');
205
+ if (fs.existsSync(vscodeExt)) {
206
+ return { dst: path.join(vscodeExt, name), builtin: true };
207
+ }
208
+ }
209
+ } catch { /* fall through */ }
210
+ }
211
+ return { dst: path.join(extensionsDir, name), builtin: false };
212
+ }
213
+
214
+ /** 树内自带扩展清单。
215
+ * - dshcs-open-file:host 信号文件 → VS Code 打开文件(可带行号)。**内置**(用户不需要看到它)。
216
+ * - dshcs-editor-bridge(0.3.0):编辑器桥的扩展侧。**必须内置**(0.3.12 修正;0.3.0–0.3.11 是用户级)。
217
+ * 为什么"直接往 <extensions-dir> 拷目录"装不进用户级:VS Code 服务端启动时
218
+ * `ExtensionsWatcher.initialize()` → `deleteExtensionsNotInProfiles()` 会把
219
+ * 「在用户扩展目录里、但不在任何 profile 的 extensions.json 里」的扩展写进 `<extensions-dir>/.obsolete`
220
+ * (服务端日志 `Marked extension as removed dsh-code-server-app.dshcs-editor-bridge-0.1.0`),
221
+ * 扫描器从此不再看它;下一轮它自然又不在 profile 里 ⇒ **每启动一次就再标一次,自锁**。
222
+ * 实测(本机 profile,10 次启动 10 条标记)与源码位置:`out/server-main.js` 的
223
+ * `ExtensionsWatcher#initialize` / `ExtensionsScannerService#setExtensionsForRemoval`。
224
+ * 内置目录(<树>/lib/vscode/extensions,与 dshcs-open-file 同处)不参与 profile 机制。
225
+ * 用户的"关掉它"由插件设置 `editorBridge=false`(不挂桥、不注册工具)承担,不再依赖 VS Code 的卸载。 */
226
+ const BUNDLED_EXTENSIONS = [
227
+ { name: 'dshcs-open-file', placement: 'builtin' },
228
+ { name: 'dshcs-editor-bridge', placement: 'builtin' },
229
+ ];
230
+
231
+ /** 递归列出扩展源目录里的文件(相对路径,posix 分隔)。
232
+ * 为什么必须递归:编辑器桥(0.3.7 起)把纯逻辑放在 `lib/*.js` 里(便于单测,不 require('vscode')),
233
+ * 只拷 `extension.js` 会让扩展加载即 `Cannot find module './lib/bridge-client.js'` —— 而 VS Code
234
+ * 只会把这种失败记成一条 `Marked extension as removed`,界面上什么都没发生。
235
+ * 跳过开发期杂物;`.dshcs-bridge` 是运行期元数据目录,不属于扩展本体。
236
+ * 导出供 scripts/test-bridge-routes.mjs 直接验(它不启动 IDE,而安装发生在 start 里)。 */
237
+ export function listExtensionFiles(srcDir) {
238
+ const skip = new Set(['node_modules', '.git', '.dshcs-bridge', 'test', 'tests']);
239
+ const out = [];
240
+ const walk = (rel) => {
241
+ const abs = rel === '' ? srcDir : path.join(srcDir, rel);
242
+ for (const ent of fs.readdirSync(abs, { withFileTypes: true })) {
243
+ if (skip.has(ent.name)) continue;
244
+ const next = rel === '' ? ent.name : `${rel}/${ent.name}`;
245
+ if (ent.isDirectory()) walk(next);
246
+ else if (ent.isFile()) out.push(next);
247
+ }
248
+ };
249
+ walk('');
250
+ return out;
251
+ }
252
+
253
+ /** 清掉 VS Code 写给本插件扩展的"已移除"标记(`<extensions-dir>/.obsolete`)。
254
+ * 机制:`ExtensionsWatcher.initialize()` 把"在用户扩展目录里、不在任何 profile 里"的扩展标成 removed,
255
+ * 扫描器随后永远跳过它 ⇒ **自锁**(见 BUNDLED_EXTENSIONS 的注释)。0.3.0–0.3.11 的桥扩展就卡在这里:
256
+ * 文件装全了、路由通了,扩展却一次都没被加载(`editor_context` 永远报"扩展还没有上报状态")。
257
+ * 别的扩展的条目原样保留;清空后直接删掉文件(VS Code 需要时会自己重建)。
258
+ * 导出供测试直接验。 */
259
+ export function clearObsoleteMarkers(extensionsDir, ids) {
260
+ const file = path.join(extensionsDir, '.obsolete');
261
+ if (ids.length === 0 || !fs.existsSync(file)) return [];
262
+ let map;
263
+ try {
264
+ map = JSON.parse(fs.readFileSync(file, 'utf8'));
265
+ } catch {
266
+ return [];
267
+ }
268
+ if (map === null || typeof map !== 'object' || Array.isArray(map)) return [];
269
+ const cleared = [];
270
+ for (const key of Object.keys(map)) {
271
+ if (ids.some((id) => key === id || key.startsWith(`${id}-`))) {
272
+ delete map[key];
273
+ cleared.push(key);
274
+ }
275
+ }
276
+ if (cleared.length === 0) return [];
277
+ try {
278
+ if (Object.keys(map).length === 0) fs.rmSync(file, { force: true });
279
+ else fs.writeFileSync(file, JSON.stringify(map), 'utf8');
280
+ } catch {
281
+ // 清标记失败不该拦住安装:扩展仍会被扫描器跳过,但文件是新的(下次启动还会再试)
282
+ }
283
+ return cleared;
284
+ }
285
+
286
+ /** 内置扩展安装:每次启动调用 —— 缺失**或内容有变化**即同步(自愈,且插件升级后能更新已装的旧副本)。
287
+ * 源目录里已不存在的文件会从目标里删掉(否则旧版本的 `lib/` 会一直留着)。
288
+ * 同时清理"放错位置"的旧副本(用户级/内置两份同时存在会让 VS Code 打架),并清掉 `.obsolete` 自锁标记。
289
+ * 导出供测试直接调(apply 期不装扩展,只有 start 才装);`options.treeRoot` 供测试注入假树。
290
+ * @returns {{updated: string[], cleared: string[]}} `updated` = 本次真的写了文件/删了文件的扩展名
291
+ * (adopt 路径据此判断"运行中的 IDE 里是旧代码");`cleared` = 本次清掉的 `.obsolete` 键。 */
292
+ export function installBundledExtensions(extensionsDir, userDataDir, options = {}) {
293
+ const here = path.dirname(fileURLToPath(import.meta.url));
294
+ const treeRoot = options.treeRoot === undefined ? vsRoot() : options.treeRoot;
295
+ const managedIds = [];
296
+ const updated = [];
297
+ for (const ext of BUNDLED_EXTENSIONS) {
298
+ try {
299
+ const src = path.join(here, '..', 'assets', 'extensions', ext.name);
300
+ const manifest = path.join(src, 'package.json');
301
+ if (!fs.existsSync(manifest)) continue;
302
+ const manifestJson = JSON.parse(fs.readFileSync(manifest, 'utf8'));
303
+ managedIds.push(`${manifestJson.publisher ?? 'dsh-code-server-app'}.${manifestJson.name ?? ext.name}`);
304
+ const files = listExtensionFiles(src);
305
+ const target = extensionTarget(extensionsDir, ext.name, ext.placement, treeRoot);
306
+ const dst = target.dst;
307
+ const stale = files.filter((name) => {
308
+ const to = path.join(dst, name);
309
+ if (!fs.existsSync(to)) return true;
310
+ try {
311
+ return !fs.readFileSync(path.join(src, name)).equals(fs.readFileSync(to));
312
+ } catch {
313
+ return true;
314
+ }
315
+ });
316
+ if (stale.length > 0) {
317
+ for (const name of stale) {
318
+ const to = path.join(dst, name);
319
+ fs.mkdirSync(path.dirname(to), { recursive: true });
320
+ fs.copyFileSync(path.join(src, name), to);
321
+ }
322
+ }
323
+ // 删除目标里多出来的文件(相对源目录):升级后旧文件不该留在扩展目录里
324
+ const removed = [];
325
+ if (fs.existsSync(dst)) {
326
+ const wanted = new Set(files);
327
+ const walkDst = (rel) => {
328
+ const abs = rel === '' ? dst : path.join(dst, rel);
329
+ for (const ent of fs.readdirSync(abs, { withFileTypes: true })) {
330
+ const next = rel === '' ? ent.name : `${rel}/${ent.name}`;
331
+ if (ent.isDirectory()) walkDst(next);
332
+ else if (ent.isFile() && !wanted.has(next)) {
333
+ fs.rmSync(path.join(dst, next), { force: true });
334
+ removed.push(next);
335
+ }
336
+ }
337
+ };
338
+ walkDst('');
339
+ }
340
+ // 清理放错位置的副本:编辑器桥的旧用户级副本(0.3.11 及更早)/ dshcs-open-file 的旧用户级副本。
341
+ const wrong = extensionTarget(extensionsDir, ext.name, ext.placement === 'builtin' ? 'user' : 'builtin', treeRoot);
342
+ if (wrong.dst !== dst && fs.existsSync(wrong.dst)) {
343
+ fs.rmSync(wrong.dst, { recursive: true, force: true });
344
+ console.log(`[code-server] 清理放错位置的扩展副本 ${wrong.dst}(${ext.name} 应装在${target.builtin ? '内置' : '用户级'}目录)`);
345
+ }
346
+ console.log(`[code-server] bundled extension ${ext.name} -> ${dst}${target.builtin ? ' (内置,不可卸载)' : ' (用户级,可禁用)'}`
347
+ + `${stale.length > 0 ? ` [更新 ${stale.join(',')}]` : ''}${removed.length > 0 ? ` [清理 ${removed.join(',')}]` : ''}`);
348
+ if (stale.length > 0 || removed.length > 0) updated.push(ext.name);
349
+ } catch (err) {
350
+ console.warn(`[code-server] bundled extension ${ext.name} install failed:`, err && err.message ? err.message : String(err));
351
+ }
352
+ }
353
+ const cleared = clearObsoleteMarkers(extensionsDir, managedIds);
354
+ if (cleared.length > 0) {
355
+ console.log(`[code-server] 清掉 VS Code 的"已移除"标记(.obsolete):${cleared.join(',')}`
356
+ + '(留着的话扫描器会永远跳过这些扩展,编辑器桥就永远没有状态)');
357
+ }
358
+ void userDataDir;
359
+ return { updated, cleared };
360
+ }
361
+
362
+ /** 打开文件信号文件:<user-data>/User/dshcs-open.json(扩展轮询此文件)。 */
363
+ function openFileSignalPath(userDataDir) {
364
+ return path.join(userDataDir, 'User', 'dshcs-open.json');
365
+ }
366
+
367
+ /** 已安装的内置扩展里最新的文件 mtime(ms);取不到返回 null。
368
+ *
369
+ * 用途:**adopt(接管正在运行的 IDE)时判断"里面跑的是不是升级前的旧代码"**。
370
+ * 扩展代码只在扩展宿主进程启动时被加载一次 —— 文件是新的不代表跑着的进程里是新的。
371
+ * `mtime > IDE 进程启动时间` 说明这些文件是在该进程起来之后写的,那个进程不可能加载过它们。
372
+ * (可能偏保守:之后重载过窗口就没事了 —— 所以提示写成"可能/重载即可"。) */
373
+ export function newestBundledExtensionMtime(extensionsDir, options = {}) {
374
+ const treeRoot = options.treeRoot === undefined ? vsRoot() : options.treeRoot;
375
+ let newest = null;
376
+ for (const ext of BUNDLED_EXTENSIONS) {
377
+ const target = extensionTarget(extensionsDir, ext.name, ext.placement, treeRoot);
378
+ if (!fs.existsSync(target.dst)) continue;
379
+ let files;
380
+ try {
381
+ files = listExtensionFiles(target.dst);
382
+ } catch {
383
+ continue;
384
+ }
385
+ for (const rel of files) {
386
+ try {
387
+ const at = fs.statSync(path.join(target.dst, rel)).mtimeMs;
388
+ if (newest === null || at > newest) newest = at;
389
+ } catch {
390
+ // 单个文件 stat 失败不影响结论
391
+ }
392
+ }
393
+ }
394
+ return newest;
395
+ }
396
+
397
+ /** 插件 package.json 里声明的内部依赖(纯 JS 直装集;排除 VS Code 树包本身)。 */
398
+ function declaredInnerDeps() {
399
+ try {
400
+ const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'));
401
+ return Object.keys(manifest.dependencies ?? {})
402
+ .filter((name) => !/^@[^/]+\/dshcs-(vscode-server|code-server)$/.test(name));
403
+ } catch {
404
+ return [];
405
+ }
406
+ }
407
+
408
+ /** 从 VS Code 树位置解析内部依赖(与运行时同一套向上查找规则)。
409
+ * 先试 `<name>/package.json`(ESM-only 包没有 require 入口),再退回 `<name>` 本身。 */
410
+ function checkInnerDeps() {
411
+ const declared = declaredInnerDeps();
412
+ const root = vsRoot();
413
+ const from = createRequire(path.join(root ?? PACKAGE_ROOT, 'package.json'));
414
+ const missing = [];
415
+ let resolved = 0;
416
+ for (const name of declared) {
417
+ let ok = false;
418
+ for (const spec of [`${name}/package.json`, name]) {
419
+ try {
420
+ from.resolve(spec);
421
+ ok = true;
422
+ break;
423
+ } catch { /* 试下一个 */ }
424
+ }
425
+ if (ok) resolved += 1;
426
+ else missing.push(name);
427
+ }
428
+ return { declared: declared.length, resolved, missing };
429
+ }
430
+
431
+ /** 环境检测:VS Code 树 + server 入口 + 内部依赖 + 预编译原生包。
432
+ * 返回 { ok, tree, entry, productPath, vscodeInner, innerDeps, treeVersion, vendored, upToDate, nativeRuntime, node, platform, arch }。 */
433
+ function envCheck() {
434
+ const root = vsRoot();
435
+ const entry = vsServerEntry();
436
+ const inner = checkInnerDeps();
437
+ const natives = nativeRuntimeStatus();
438
+ const installed = root !== null ? readTreeVersion(root) : null;
439
+ const vendored = vendoredVersion();
440
+ return {
441
+ ok: entry !== null && inner.missing.length === 0
442
+ && natives.resolved > 0 && natives.missing.length === 0,
443
+ tree: root !== null ? root.replace(/\\/g, '/') : null,
444
+ entry: entry !== null ? entry.replace(/\\/g, '/') : null,
445
+ productPath: productPath(root),
446
+ vscodeInner: inner.missing.length === 0,
447
+ innerDeps: { declared: inner.declared, resolved: inner.resolved, missing: inner.missing },
448
+ treeVersion: installed,
449
+ vendored,
450
+ upToDate: installed !== null && vendored !== null && installed === vendored,
451
+ // 本平台预编译原生产物(重打包子包按真名装进依赖图,运行时按原名补 junction)
452
+ nativeRuntime: {
453
+ name: natives.name,
454
+ source: natives.source,
455
+ version: natives.version,
456
+ packages: natives.resolved,
457
+ missing: natives.missing,
458
+ },
459
+ node: process.version,
460
+ platform: process.platform,
461
+ arch: process.arch,
462
+ };
463
+ }
464
+
465
+ /** 解析启动方式:
466
+ * - 默认:插件自带 launcher(node lib/launcher.mjs)+ 内置 VS Code 树;
467
+ * - `bin` 显式配置时按旧模型当逃生舱:可执行文件 / code-server 的 out/node/entry.js。 */
468
+ function resolveLaunch(config, serve) {
469
+ const configured = typeof config.bin === 'string' && config.bin !== '' && config.bin !== DEFAULT_CONFIG.bin
470
+ ? config.bin
471
+ : null;
472
+ if (configured === null) {
473
+ const root = vsRoot();
474
+ if (root === null || vsServerEntry() === null) {
475
+ throw new Error(
476
+ '找不到 VS Code 树(缺 lib/vscode/out/server-main.js)。请重新安装插件'
477
+ + '(`dsh plugin --profile web add dsh-code-server-app`),'
478
+ + '开发期先用 `node scripts/vendor-vscode-server.mjs --dev-links` 生成 vendor/vscode。',
479
+ );
480
+ }
481
+ return { kind: 'launcher', script: launcherPath(), tree: root };
482
+ }
483
+ console.log(`[code-server] resolveLaunch: 使用显式配置的 bin=${configured}(launcher 旁路)`);
484
+ if (/\.(js|mjs|cjs)$/i.test(configured)) {
485
+ if (!fs.existsSync(configured)) throw new Error(`配置的入口不存在: ${configured}`);
486
+ return { kind: 'node', script: configured };
487
+ }
488
+ if (path.isAbsolute(configured)) return { kind: 'bin', command: configured };
489
+ try {
490
+ if (win32()) {
491
+ const out = execFileSync('where.exe', [configured], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
492
+ const lines = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
493
+ return { kind: 'bin', command: lines.find((l) => /\.cmd$/i.test(l)) ?? lines.find((l) => /\.exe$/i.test(l)) ?? lines[0] };
494
+ }
495
+ const out = execFileSync('which', [configured], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
496
+ return { kind: 'bin', command: out.split('\n')[0].trim() };
497
+ } catch {
498
+ throw new Error(`配置的 bin 未找到("${configured}" 不在 PATH)`);
499
+ }
500
+ }
501
+
502
+ /** 命名管道名(仅 dsh 模式使用):按 profile/pid 稳定,避免撞名。 */
503
+ function pipeName() {
504
+ const key = `${process.pid}`;
505
+ return win32() ? `\\\\.\\pipe\\dshcs-vscode-${key}` : path.join(os.tmpdir(), `dshcs-vscode-${key}.sock`);
506
+ }
507
+
508
+ function healthCheck(host, port, token, timeoutMs = 1500) {
509
+ const requestPath = token === null || token === undefined ? '/healthz' : `/${token}/healthz`;
510
+ return new Promise((resolve) => {
511
+ const req = http.get({ host, port, path: requestPath, timeout: timeoutMs, method: 'GET' }, (res) => {
512
+ res.resume();
513
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 500, statusCode: res.statusCode });
514
+ });
515
+ req.on('error', () => resolve({ ok: false }));
516
+ req.on('timeout', () => {
517
+ req.destroy();
518
+ resolve({ ok: false });
519
+ });
520
+ });
521
+ }
522
+
523
+ /** 命名管道上的 /healthz 探针(dsh 模式;管道由 launcher 监听)。 */
524
+ function healthCheckPipe(pipe, timeoutMs = 1500) {
525
+ return new Promise((resolve) => {
526
+ const req = http.request({ socketPath: pipe, path: '/healthz', method: 'GET', timeout: timeoutMs }, (res) => {
527
+ res.resume();
528
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 500, statusCode: res.statusCode });
529
+ });
530
+ req.on('error', () => resolve({ ok: false }));
531
+ req.on('timeout', () => {
532
+ req.destroy();
533
+ resolve({ ok: false });
534
+ });
535
+ req.end();
536
+ });
537
+ }
538
+
539
+ function readPidFile(config) {
540
+ try {
541
+ const raw = JSON.parse(fs.readFileSync(pidFile(config), 'utf8'));
542
+ return raw && typeof raw.pid === 'number' ? raw : null;
543
+ } catch {
544
+ return null;
545
+ }
546
+ }
547
+
548
+ function writePidFile(config, record) {
549
+ fs.mkdirSync(path.dirname(pidFile(config)), { recursive: true });
550
+ fs.writeFileSync(pidFile(config), JSON.stringify(record, null, 2), 'utf8');
551
+ }
552
+
553
+ function removePidFile(config) {
554
+ try {
555
+ fs.rmSync(pidFile(config), { force: true });
556
+ } catch {
557
+ // ignore
558
+ }
559
+ }
560
+
561
+ /** 读路径令牌;文件缺失或格式不对返回 null(不抛:调用方据此判定"没有可接管的实例")。 */
562
+ function readToken(config) {
563
+ try {
564
+ const raw = fs.readFileSync(tokenFile(config), 'utf8').trim();
565
+ return TOKEN_RE.test(raw) ? raw : null;
566
+ } catch {
567
+ return null;
568
+ }
569
+ }
570
+
571
+ /** 生成并写入新的路径令牌(每次**新启动**都换;原子写,0600)。 */
572
+ function rotateToken(config) {
573
+ const token = randomBytes(24).toString('base64url'); // [A-Za-z0-9_-] 共 32 位
574
+ const file = tokenFile(config);
575
+ fs.mkdirSync(path.dirname(file), { recursive: true });
576
+ const tmp = `${file}.${process.pid}.tmp`;
577
+ fs.writeFileSync(tmp, token, { encoding: 'utf8', mode: 0o600 });
578
+ fs.renameSync(tmp, file);
579
+ return token;
580
+ }
581
+
582
+ /** 读 launcher 回写的监听端点(端口 0 时唯一可信来源);缺失/不可用返回 null。 */
583
+ function readEndpoint(config) {
584
+ try {
585
+ const raw = JSON.parse(fs.readFileSync(endpointFile(config), 'utf8'));
586
+ return raw && Number.isInteger(raw.pid) && Number.isInteger(raw.port) && raw.port > 0 ? raw : null;
587
+ } catch {
588
+ return null;
589
+ }
590
+ }
591
+
592
+ export async function apply(ctx, config) {
593
+ const cfg = { ...DEFAULT_CONFIG, ...(config ?? {}) };
594
+
595
+ // ---- 设置:行配置为种子;settings 命名空间持久化(设置卡片写入) ----
596
+ const settingsSvc = ctx.get('settings');
597
+ // connection 是 host↔client 通道(必需);提前取出,供 DSH 同源挂载的 fence 使用。
598
+ const connection = ctx.get('connection');
599
+ if (connection === undefined || connection.fetch === undefined) {
600
+ console.error('[code-server] connection service unavailable; plugin registered but idle');
601
+ return;
602
+ }
603
+ let serveSetting = 'loopback';
604
+ let keepResident = true; // 客户端常驻预热(0.2.2)
605
+ let claimExtensions = DEFAULT_CLAIM_EXTENSIONS; // 认领类型清单(0.2.11,取代 fileOpenScope)
606
+ let fullscreenOnOpen = true; // 客户端"打开标签即全屏右侧栏"(0.2.9)
607
+ /** 编辑器桥开关(0.3.0):行配置 cfg.editorBridge 是种子,设置文档里可改并实时生效。 */
608
+ let bridgeSetting = cfg.editorBridge !== false;
609
+ if (settingsSvc !== undefined && typeof settingsSvc.register === 'function') {
610
+ try {
611
+ const scope = settingsSvc.register(SETTINGS_NS, Config);
612
+ const rawDoc = settingsSvc.get(SETTINGS_NS);
613
+ if (rawDoc !== undefined && rawDoc !== null) {
614
+ const resolved = scope.get();
615
+ serveSetting = resolved && resolved.serve === 'dsh' ? 'dsh' : 'loopback';
616
+ keepResident = resolved && typeof resolved.keepResident === 'boolean' ? resolved.keepResident : true;
617
+ claimExtensions = resolved && typeof resolved.claimExtensions === 'string'
618
+ ? normalizeClaimExtensions(resolved.claimExtensions)
619
+ : DEFAULT_CLAIM_EXTENSIONS;
620
+ fullscreenOnOpen = resolved && typeof resolved.fullscreenOnOpen === 'boolean' ? resolved.fullscreenOnOpen : true;
621
+ bridgeSetting = resolved && typeof resolved.editorBridge === 'boolean' ? resolved.editorBridge : bridgeSetting;
622
+ }
623
+ scope.watch((next) => {
624
+ if (next != null && (next.serve === 'dsh' || next.serve === 'loopback')) {
625
+ if (next.serve !== serveSetting) {
626
+ serveSetting = next.serve;
627
+ console.log(`[code-server] serve updated: ${serveSetting}(下次启动生效)`);
628
+ }
629
+ }
630
+ if (next != null && typeof next.editorBridge === 'boolean') {
631
+ if (next.editorBridge !== bridgeSetting) {
632
+ bridgeSetting = next.editorBridge;
633
+ console.log(`[code-server] editorBridge updated: ${bridgeSetting}`);
634
+ // 关掉 → 立刻下线(关监听口 + 删配置 + 注销工具);开启 → 起监听口,IDE 在跑就补一份配置。
635
+ if (bridgeSetting === false) {
636
+ clearBridgeRuntime();
637
+ void stopBridgeListener();
638
+ } else {
639
+ void ensureBridgeListener().then(() => {
640
+ if (state.status === 'running' && state.pid !== null) {
641
+ bridgeToken ??= mintBridgeToken();
642
+ syncBridgeRuntime({ pid: state.pid, startedAt: state.startedAt });
643
+ }
644
+ });
645
+ }
646
+ }
647
+ }
648
+ if (next != null && typeof next.keepResident === 'boolean') {
649
+ keepResident = next.keepResident;
650
+ console.log(`[code-server] keepResident updated: ${keepResident}`);
651
+ }
652
+ if (next != null && typeof next.claimExtensions === 'string') {
653
+ const normalized = normalizeClaimExtensions(next.claimExtensions);
654
+ if (normalized !== claimExtensions) {
655
+ claimExtensions = normalized;
656
+ console.log(`[code-server] claimExtensions updated: ${claimExtensions} (${describeClaimPolicy(claimExtensions)})`);
657
+ }
658
+ }
659
+ if (next != null && typeof next.fullscreenOnOpen === 'boolean') {
660
+ if (next.fullscreenOnOpen !== fullscreenOnOpen) {
661
+ fullscreenOnOpen = next.fullscreenOnOpen;
662
+ console.log(`[code-server] fullscreenOnOpen updated: ${fullscreenOnOpen}`);
663
+ }
664
+ }
665
+ });
666
+ } catch (error) {
667
+ console.error(`[code-server] settings unavailable; using defaults (claimExtensions=${DEFAULT_CLAIM_EXTENSIONS}, fullscreenOnOpen=true, keepResident=true): ${error.message}`);
668
+ }
669
+ }
670
+
671
+ // 依赖布局自愈必须发生在任何解析之前:精简树不含 lib/vscode/node_modules,
672
+ // 由这里按插件依赖图补 junction(幂等;重装后自愈)。
673
+ const layout = ensureRuntimeLayout();
674
+
675
+ // ---- 服务方式:settings/行配置请求 dsh,但只有本 deployment 真的提供 webServer 时才用 ----
676
+ let webServerSvc = ctx.get('webServer');
677
+ let dshMount = null; // { disposers, upgradePath } —— 由下面的 ctx.inject 填充
678
+
679
+ /** 生效的服务方式:loopback(默认,独立回环端口)| dsh(DSH 同源挂载)。 */
680
+ function requestedServe() {
681
+ const want = serveSetting === 'dsh' || cfg.serve === 'dsh' ? 'dsh' : 'loopback';
682
+ if (want === 'loopback') return 'loopback';
683
+ if (webServerSvc === undefined || dshMount === null) {
684
+ console.warn('[code-server] serve=dsh 但 webServer 不可用/挂载失败 → 回退 loopback');
685
+ return 'loopback';
686
+ }
687
+ return 'dsh';
688
+ }
689
+
690
+ const state = {
691
+ status: 'stopped', // stopped | starting | running | stopping | error
692
+ pid: null,
693
+ port: cfg.port,
694
+ /** 本实例的路径令牌(回环模式;不写日志、不进 argv)。 */
695
+ token: null,
696
+ cwd: null,
697
+ /** 进程**启动时**的工作目录(诊断用):运行时切工作区只改 cwd,进程 cwd 保持这里不变。 */
698
+ launchCwd: null,
699
+ version: null,
700
+ error: null,
701
+ logTail: '',
702
+ startedAt: null,
703
+ adopted: false,
704
+ serve: 'loopback', // 实际生效的服务方式(loopback | dsh)
705
+ pipe: null, // dsh 模式下的命名管道
706
+ /** 客户端上报的 UI 载体能力:null=未上报 | true=带右侧栏(受支持) | false=旧版 DSH(只提示,不预启动) */
707
+ sidebarUi: null,
708
+ /** 本实例是否由插件的自动预启动拉起(旧版 DSH 上报后据此回收,不动用户/被 adopt 的实例) */
709
+ prestarted: false,
710
+ env: envCheck(), // 环境检测(VS Code 树 / server 入口 / 内部依赖 / 预编译原生包)
711
+ setup: { running: false, done: true, ok: true, logTail: '0.1.36 起由包管理器安装依赖,无需「安装环境」步骤', startedAt: null, finishedAt: null }, // 兼容旧客户端
712
+ };
713
+
714
+ let child = null;
715
+ let pollTimer = null;
716
+ let disposeKilled = false;
717
+
718
+ // ---- 编辑器桥(0.3.0):配置/凭据写在 <extensionsDir>/.dshcs-bridge/bridge.json ----
719
+ // 这里只算路径;是否启用还要看 cfg.editorBridge 与 setting 值。详见 lib/bridge.mjs 顶部。
720
+ const bridgeRoot = dataRoot(cfg);
721
+ const bridgeUserDataDir = cfg.userDataDir || path.join(bridgeRoot, 'user-data');
722
+ const bridgeExtensionsDir = cfg.extensionsDir || path.join(bridgeRoot, 'extensions');
723
+ const bridgeMetaDir = path.join(bridgeExtensionsDir, BRIDGE_DIRNAME);
724
+ /** 已写入的桥配置(供桥路由与 /status 读回);null = 未启用/未就绪。 */
725
+ let bridgeMeta = null;
726
+ /** 桥工具(editor_context / editor_diagnostics)的 disposer;null = 未注册。 */
727
+ let bridgeToolDispose = null;
728
+ /** 提示词段落的 disposer(只注册一次;文本按桥是否存活渲染)。 */
729
+ let bridgePromptDispose = null;
730
+ /** agent 写操作观察器的 disposer。 */
731
+ let bridgeObserveDispose = null;
732
+ /** 会话流订阅 / 授权拦截的 disposer。 */
733
+ let bridgeAnswerDispose = null;
734
+ /** 推给编辑器的事件环形缓冲(tools/result → 扩展轮询)。 */
735
+ const bridgeEvents = createEventRing();
736
+ /** 编辑器状态缓存:扩展在每次 /sync 里推上来,agent 的工具调用来读它。 */
737
+ const bridgeContext = createContextCache();
738
+ /** 被面板观看的会话 → 对话条目流(只渲染新内容;见 lib/bridge-thread.mjs)。 */
739
+ const bridgeThread = createThreadRegistry({
740
+ resolveController: () => {
741
+ try {
742
+ return ctx.get('sessionController');
743
+ } catch {
744
+ return undefined;
745
+ }
746
+ },
747
+ log: (message) => console.warn(`[code-server] 编辑器桥会话流:${message}`),
748
+ });
749
+ /** 面板的授权决策登记表 + agent 作用域拦截器(见 lib/bridge-approval.mjs)。 */
750
+ const bridgeApprovalBoard = createApprovalBoard();
751
+ /**
752
+ * 谁在"看着这个会话" —— 授权拦截据此决定要不要抢答(没人看就直接交给官方链路):
753
+ * - `ids`:编辑器扩展面板声明要看的会话(每趟 /sync 上报);
754
+ * - `dialogIds`:DSH 页面里的对话框(0.3.24)声明要看的会话;
755
+ * - `at`:最近一次声明的时间(超过 5s 视为没人看)。
756
+ */
757
+ const bridgeWatch = { ids: [], dialogIds: [], at: 0 };
758
+ /** 上次回给扩展的对话流修订号(0.3.27:没变化就不重传整份快照)。 */
759
+ let bridgeThreadRevSent = -1;
760
+ /** 上次记录过的"有没有人在看"(0.3.30:状态翻转才写诊断日志)。 */
761
+ let bridgeWatchLogged = false;
762
+ /** 首帧是否已写过诊断(0.3.31:"一直没人看"也要留下证据)。 */
763
+ let bridgeWatchWroteFirst = false;
764
+ /** 上次写过的 hasPanel 判据原文(变了才写,避免每 600ms 刷屏)。 */
765
+ let bridgeHasPanelLine = '';
766
+ /** 客户端(self-declared)是否能把授权卡片画出来(0.3.30)。 */
767
+ let bridgeApprovalsUi = false;
768
+ let bridgeApprovalsUiLogged = false;
769
+
770
+ /**
771
+ * 授权/看护状态的**诊断日志**(0.3.30):写到 <home>/.dsh/code-server/bridge-approval.log。
772
+ * "卡片为什么不出现"只有三个问题:有没有人在看 / 请求到没到 / 谁答的 —— 三件事都在宿主进程里,
773
+ * 用户看不到、我也读不到;落成有界文件后一次复现就能定论。
774
+ */
775
+ function bridgeDiag(message) {
776
+ try {
777
+ const dir = path.join(os.homedir(), '.dsh', 'code-server');
778
+ fs.mkdirSync(dir, { recursive: true });
779
+ const file2 = path.join(dir, 'bridge-approval.log');
780
+ try {
781
+ if (fs.statSync(file2).size > 256 * 1024) fs.writeFileSync(file2, '');
782
+ } catch {
783
+ // 首次写入:文件不存在
784
+ }
785
+ fs.appendFileSync(file2, new Date().toISOString() + ' ' + message + '\n');
786
+ } catch {
787
+ // 诊断失败绝不影响主流程
788
+ }
789
+ }
790
+
791
+ /** 当前所有"在看"的会话(去重)。 */
792
+ function watchedSessions() {
793
+ const all = [...bridgeWatch.ids, ...bridgeWatch.dialogIds];
794
+ return [...new Set(all.filter((id) => typeof id === 'string' && id !== ''))].slice(0, 4);
795
+ }
796
+ /** 现在有没有人在看(授权拦截的唯一判据)。 */
797
+ function hasWatcher() {
798
+ const sessions = watchedSessions();
799
+ if (sessions.length === 0) return false;
800
+ // **对话框是宿主侧状态**(只在 /ask/close 时消失):不拿轮询新鲜度去判它 ——
801
+ // 浏览器把后台标签的定时器节流到分钟级时,900ms 的心跳一断、5 秒窗口就过期,
802
+ // 我们会误判"没人看"而放弃拦截(用户实测:授权卡片因此跑到 DSH 界面)。
803
+ if (bridgeWatch.dialogIds.length > 0) return true;
804
+ // 编辑器面板靠 /sync 的 600ms 心跳;窗口放宽到 60 秒(正常心跳远小于它)。
805
+ return Date.now() - bridgeWatch.at < 60000;
806
+ }
807
+
808
+ const bridgeApproval = createApprovalInterceptor({
809
+ board: bridgeApprovalBoard,
810
+ holdMs: DEFAULT_HOLD_MS,
811
+ // **只在客户端能画卡片时才抢答**(0.3.30):面板/对话框在看 **且** 它自己声明 approvalsUi。
812
+ // 反例(用户实测):面板处于"宿主没有对话流能力"的坏状态时我们仍然抢答 ⇒ 官方卡片不出现、
813
+ // 面板也不画 ⇒ 用户看到的是"没弹出授权",请求在宿主里干等 5 分钟。
814
+ // 客户端能画卡片 = 编辑器面板自报可以的 **或** 对话框开着且有会话(0.3.33)。
815
+ // "活着"由 /ask/state 轮询证明)。0.3.31 的日志给出了反例:用户从**对话框**提问 ⇒
816
+ // 编辑器面板是 null ⇒ 旧判据误判成"画不出卡片",授权被让给了 DSH 界面。
817
+ hasPanel: () => {
818
+ const watcher = hasWatcher();
819
+ const dialogCapable = askDialogLive();
820
+ const verdict = watcher && (bridgeApprovalsUi || dialogCapable);
821
+ // 判据的**原始值**一起落日志(0.3.35):只记结论的话,出问题还是只能猜。
822
+ const line = 'hasPanel -> ' + verdict + ' (hasWatcher=' + watcher + ' approvalsUi=' + bridgeApprovalsUi
823
+ + ' dialogLive=' + dialogCapable + ' polls=' + askDialog.polls + ' open=' + askDialog.open
824
+ + ' session=' + String(askDialog.sessionId) + ')';
825
+ if (line !== bridgeHasPanelLine) { bridgeHasPanelLine = line; bridgeDiag(line); }
826
+ return verdict;
827
+ },
828
+ log: (message) => {
829
+ console.warn(`[code-server] 编辑器桥授权:${message}`);
830
+ bridgeDiag(`approval: ${message}`);
831
+ },
832
+ });
833
+
834
+ /**
835
+ * 「问 DSH」对话框的宿主侧状态(0.3.24)。
836
+ *
837
+ * 对话渲染从编辑器的 webview 面板搬到了 **DSH 页面里的浮动对话框**(client 半部,见 src/factory.js):
838
+ * 面板是编辑器里的一个 tab/一列,而用户要的是"像 DSH 那样的对话框"。host 这边只负责:
839
+ * - 打开/关闭状态(编辑器扩展右键 → 桥 `/event` 上报 `ask-open`);
840
+ * - 状态与动作的 HTTP 面(`/api/code-server/ask/*`,与 DSH 同源、吃它的 cookie/Origin 校验);
841
+ * - 把面板产物(thread.js / thread.css)交给对话框注入(Lib 版本与 DSH 界面同版本,见 build-webview.mjs)。
842
+ */
843
+ const askDialog = { open: false, armed: false, mode: 'selection', at: 0, sessionId: null, polls: 0, lastPollAt: 0 };
844
+
845
+ /**
846
+ * 对话框"活着吗" —— **能力位必须以此为准**(0.3.25)。
847
+ *
848
+ * 0.3.24 的教训:宿主无条件报 `askDialog: true`,扩展于是不再开编辑器面板;而客户端半部只要没跑起来
849
+ * (浏览器还缓存着旧 bundle / 没硬刷新),就**没有任何一方在看这个会话** ⇒ 授权悄悄落到 DSH 界面的
850
+ * 官方卡片,用户在编辑器里什么都看不到。现在只有**真的收到过 `/ask/state` 轮询**(5 秒内)才算有
851
+ * 这个能力;否则扩展退回编辑器面板 —— 那是验证过、能看能点的 UI。
852
+ */
853
+ function askDialogLive() {
854
+ // 对话框每 900ms 问一次 /ask/state —— 那就是"活跃心跳"。心跳一旦建立,在 TTL 内
855
+ // **整个 DSH 侧都把授权拦截到编辑器侧**(用户的要求:对话框开着就持续发活跃通知)。
856
+ // TTL 取 10 分钟:浏览器后台节流会把 900ms 拖到分钟级,短 TTL 会让"人就在编辑器旁边"
857
+ // 被误判成"没人看"(实测连续踩了两轮)。
858
+ const TTL_MS = 10 * 60 * 1000;
859
+ // **必须在"武装"状态下**才算有能力显示卡片(0.3.40):
860
+ // 只认"最近轮询过"会让**已经关掉的对话框**在 10 分钟内继续被当成能显示卡片的 UI ——
861
+ // 那时我们仍会接住请求,而卡片无处显示(就是用户说的"没弹出授权")。
862
+ // armed 在 /ask/state 轮询时置位,在 /ask/close 时立刻清除 ⇒ 关掉之后下一次判据复查(≤500ms)
863
+ // 就会把正在等待的请求交回官方卡片。
864
+ if (askDialog.armed === true && askDialog.polls > 0 && Date.now() - askDialog.lastPollAt < TTL_MS) return true;
865
+ // 心跳还没建立(刚重启)时:对话框开着且有会话也算。
866
+ return askDialog.open && askDialog.sessionId !== null;
867
+ }
868
+
869
+ /** 缓存里的活动编辑器 → 提问上下文(mode 决定带不带行号/选区,与扩展侧 0.3.21 的语义一致)。 */
870
+ function askContextFromCache(mode) {
871
+ const cached = bridgeContext.get();
872
+ const active = cached === null ? null : cached.context?.active ?? null;
873
+ if (active === null || active === undefined) return null;
874
+ const selection = active.selection ?? null;
875
+ const selectedText = typeof active.selectedText === 'string' ? active.selectedText : '';
876
+ const hasSelection = mode === 'selection' && selection !== null && selectedText !== '';
877
+ return {
878
+ file: typeof active.path === 'string' && active.path !== '' ? active.path : null,
879
+ lineStart: hasSelection ? selection.startLine ?? null : null,
880
+ lineEnd: hasSelection ? selection.endLine ?? null : null,
881
+ selection: hasSelection ? selectedText : null,
882
+ languageId: typeof active.language === 'string' && active.language !== '' ? active.language : null,
883
+ };
884
+ }
885
+
886
+ /** 对话框那一行上下文描述(标题栏用)。 */
887
+ function askContextLine(mode) {
888
+ const context = askContextFromCache(mode);
889
+ if (context === null || context.file === null) return '来自编辑器';
890
+ const base = context.file.replace(/^.*[\\/]/, '');
891
+ const where = Number.isSafeInteger(context.lineStart)
892
+ ? `${base}:${context.lineStart}${Number.isSafeInteger(context.lineEnd) && context.lineEnd !== context.lineStart ? `-${context.lineEnd}` : ''}`
893
+ : base;
894
+ return mode === 'file' ? `来自编辑器:${where} · 针对当前文件` : `来自编辑器:${where} · 针对选中内容`;
895
+ }
896
+
897
+ /**
898
+ * 对话框要的状态 —— **形状必须与面板载荷一致**(`lib/ask-panel.js` 的 `applyPayload`:
899
+ * 扁平的 entries / approvals / …),因为对话框里跑的就是同一个面板应用。
900
+ */
901
+ function askState() {
902
+ const watch = watchedSessions();
903
+ const snapshot = bridgeThread.supported()
904
+ ? bridgeThread.snapshot(watch)
905
+ : { available: false, sessionId: null, cursor: null, rev: 0, entries: [], error: '宿主没有 sessionController.follow(DSH 版本过旧)' };
906
+ return {
907
+ ok: true,
908
+ open: askDialog.open,
909
+ mode: askDialog.mode,
910
+ contextText: askContextLine(askDialog.mode),
911
+ entries: Array.isArray(snapshot.entries) ? snapshot.entries : [],
912
+ available: snapshot.available === true,
913
+ threadError: typeof snapshot.error === 'string' && snapshot.error !== '' ? snapshot.error : null,
914
+ approvals: bridgeApprovalBoard.snapshot(),
915
+ approvalHoldMs: DEFAULT_HOLD_MS,
916
+ status: 'idle',
917
+ statusText: '',
918
+ error: null,
919
+ sessionId: snapshot.sessionId ?? askDialog.sessionId ?? null,
920
+ uiVersion: uiVersion(),
921
+ dshVersion: dshVersion(),
922
+ };
923
+ }
924
+
925
+ /** 修订号:对话条目 / 待决授权 / 开关状态任何一处变了就变。 */
926
+ function askRevision() {
927
+ return bridgeThread.rev(watchedSessions()) * 8 + bridgeApprovalBoard.rev() * 8 + (askDialog.open ? 1 : 0) + (askDialog.mode === 'file' ? 2 : 0);
928
+ }
929
+
930
+ /** 对话框刷新它的"我在看"时间戳(每趟 /state 都算一次)。 */
931
+ function refreshDialogWatch() {
932
+ bridgeWatch.dialogIds = askDialog.open && askDialog.sessionId !== null ? [askDialog.sessionId] : [];
933
+ bridgeWatch.at = Date.now();
934
+ }
935
+
936
+ /**
937
+ * 回答一条待决授权 —— 桥的路由与对话框的路由共用同一套校验(见 lib/bridge-approval.mjs 的安全约束)。
938
+ * @returns {{status: number, body: object}}
939
+ */
940
+ function answerApproval(id, outcome) {
941
+ if (typeof id !== 'string' || id === '') return { status: 400, body: { ok: false, error: '需要 id 字段' } };
942
+ if (!PANEL_OUTCOMES.includes(outcome)) {
943
+ return { status: 400, body: { ok: false, error: `outcome 只能取 ${PANEL_OUTCOMES.join(' | ')}` } };
944
+ }
945
+ if (bridgeApprovalBoard.get(id) === null) return { status: 409, body: { ok: false, error: '未知或已处理的授权请求' } };
946
+ const ok = bridgeApprovalBoard.answer(id, outcome);
947
+ return { status: ok ? 200 : 409, body: { ok, outcome: ok ? outcome : null } };
948
+ }
949
+
950
+ /** 面板产物(thread.js / thread.css):对话框把它们注入 DSH 页面,所以由 host 读盘给文本。 */
951
+ let askBundleCache = null;
952
+ function askBundle() {
953
+ if (askBundleCache !== null) return askBundleCache;
954
+ const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'extensions', 'dshcs-editor-bridge', 'webview');
955
+ const read = (name) => {
956
+ try {
957
+ return fs.readFileSync(path.join(dir, name), 'utf8');
958
+ } catch {
959
+ return null;
960
+ }
961
+ };
962
+ const js = read('thread.js');
963
+ const css = read('thread.css');
964
+ // 产物缺失(打包漏了 build:webview)→ 明确报出来,不返回半个面板。
965
+ if (js === null || css === null) {
966
+ console.warn(`[code-server] 「问 DSH」对话框的产物缺失:${dir}(请在源码里跑 pnpm run build:webview)`);
967
+ return null;
968
+ }
969
+ askBundleCache = { js, css };
970
+ return askBundleCache;
971
+ }
972
+
973
+
974
+ /** 本部署的 DSH 版本(诊断用;面板要的是下面那个"界面版本")。 */
975
+ function dshVersion() {
976
+ try {
977
+ const entry = dshEntry();
978
+ if (entry === undefined || entry === null) return null;
979
+ const manifest = JSON.parse(fs.readFileSync(path.join(path.dirname(entry), 'package.json'), 'utf8'));
980
+ return typeof manifest.version === 'string' ? manifest.version : null;
981
+ } catch {
982
+ return null;
983
+ }
984
+ }
985
+
986
+ /**
987
+ * 本部署**界面**的版本(0.3.22)。
988
+ *
989
+ * 面板里的 markdown 渲染器是从 `@deepseek-ai/dsh-client-ui-primitives` 打包进去的,而那份
990
+ * 渲染器就装在 `@deepseek-ai/dsh-web-frontend` 里(DSH Web 的前端包)。面板拿这个版本和
991
+ * 自己打包时的版本比一比,不一致就在面板顶部显示一行提示 —— 版本错配的排版差异不许静默。
992
+ */
993
+ function uiVersion() {
994
+ try {
995
+ const entry = dshEntry();
996
+ if (entry === undefined || entry === null) return null;
997
+ const dir = path.dirname(entry);
998
+ const candidates = [
999
+ path.join(dir, 'node_modules', '@deepseek-ai', 'dsh-web-frontend', 'package.json'),
1000
+ path.join(dir, '..', 'dsh-web-frontend', 'package.json'),
1001
+ ];
1002
+ for (const candidate of candidates) {
1003
+ try {
1004
+ const manifest = JSON.parse(fs.readFileSync(candidate, 'utf8'));
1005
+ if (typeof manifest.version === 'string') return manifest.version;
1006
+ } catch {
1007
+ // 下一个候选
1008
+ }
1009
+ }
1010
+ return null;
1011
+ } catch {
1012
+ return null;
1013
+ }
1014
+ }
1015
+
1016
+ /** 本次启动生成的新桥令牌;null = 本实例没有令牌(adopt 旧实例时会回读 bridge.json)。 */
1017
+ let bridgeToken = null;
1018
+ /** 桥的本机 IPC 监听口(0.3.13)。null = 未起(未启用/起失败)。*/
1019
+ let bridgeListener = null;
1020
+ /** 监听口启动中的 promise(避免并发起两次)。 */
1021
+ let bridgeListenerStarting = null;
1022
+ /** "桥监听起不来"只提示一次,避免每次起停都刷屏。 */
1023
+ let bridgeUnavailableLogged = false;
1024
+
1025
+ /** 桥的传输是**本机 IPC**(Windows 命名管道 / 其它平台 unix socket),不是 HTTP。
1026
+ *
1027
+ * 为什么不走 HTTP(0.3.13 定论,三条都实测过):
1028
+ * ① launcher 的 host:port 只服务 workbench,没有任何桥路由(0.3.7 的实测:405);
1029
+ * ② `/api` 有 Connection 的浏览器 cookie fence,扩展宿主是 Node 进程、拿不到 cookie(0.3.7–0.3.9);
1030
+ * ③ 挂 DSH 的 `webServer` 前缀(0.3.9–0.3.12)只有 **web profile** 有 —— desktop 的渲染进程
1031
+ * 经 Electron IPC 调 `host.fetch()`(`apps/desktop-host/src/index.ts:308`),**根本没有 HTTP 面**。
1032
+ * 桥的双方本来就是同一台机器上的两个进程,本机 IPC 让 web 与 desktop 走**同一条**路。
1033
+ * 详见 lib/bridge-ipc.mjs 顶部。 */
1034
+ function ensureBridgeListener() {
1035
+ if (bridgeListener !== null) return Promise.resolve(bridgeListener);
1036
+ if (bridgeListenerStarting !== null) return bridgeListenerStarting;
1037
+ const socketPath = bridgeEndpointPath(bridgeRoot, process.pid);
1038
+ // **POSIX 上 socket 文件必须落在已存在的目录里**(Windows 命名管道由内核管理,不需要目录 ——
1039
+ // 所以这个洞只在 Linux/macOS 上暴露)。之前没人建 <DSH_HOME>/code-server:它通常是别的东西
1040
+ // (pid.json / endpoint.json)顺手建的,桥起得比它们早就没有目录 ⇒ listen() 失败 ⇒ 桥静默不可用。
1041
+ // 2026-09-16 ubuntu runner 实测:报告 EACCES(端点目录不可写/不存在),而 windows 那条腿全绿。
1042
+ if (process.platform !== 'win32') {
1043
+ try {
1044
+ fs.mkdirSync(path.dirname(socketPath), { recursive: true });
1045
+ } catch (error) {
1046
+ console.warn(`[code-server] 编辑器桥:建不了端点目录 ${path.dirname(socketPath)}`
1047
+ + `(${error && error.code ? error.code : error})`);
1048
+ }
1049
+ }
1050
+ bridgeListenerStarting = startBridgeListener({
1051
+ socketPath,
1052
+ handler: nodeRouteFromFetch((url, method, headers, body) => dispatchBridge(url, method, headers, body)),
1053
+ log: (message) => console.warn(`[code-server] ${message}`),
1054
+ }).then((handle) => {
1055
+ bridgeListener = handle;
1056
+ bridgeListenerStarting = null;
1057
+ console.log(`[code-server] 编辑器桥监听就绪:${handle.path}(本机 IPC,桥令牌鉴权)`);
1058
+ return handle;
1059
+ }).catch((error) => {
1060
+ bridgeListenerStarting = null;
1061
+ if (!bridgeUnavailableLogged) {
1062
+ bridgeUnavailableLogged = true;
1063
+ console.warn(`[code-server] 编辑器桥监听失败,桥不启用:${error && error.code ? error.code : error && error.message ? error.message : error}`
1064
+ + `(端点 ${socketPath};目录存在=${fs.existsSync(path.dirname(socketPath))}`
1065
+ + ')(文件打开走信号文件,不受影响)');
1066
+ }
1067
+ return null;
1068
+ });
1069
+ return bridgeListenerStarting;
1070
+ }
1071
+
1072
+ /** 关掉本机 IPC 监听口(禁用桥 / 插件卸载)。 */
1073
+ function stopBridgeListener() {
1074
+ const handle = bridgeListener;
1075
+ bridgeListener = null;
1076
+ if (handle === null) return Promise.resolve();
1077
+ return handle.close().catch(() => {});
1078
+ }
1079
+
1080
+ /** 桥的路由(后缀 → Fetch 风格 handler)。做成函数而非常量:handler 声明在文件后段,
1081
+ * 函数声明会提升,而常量在挂载时可能还在 TDZ。
1082
+ * `/approve` 是唯一的非只读路由(只回答既有授权请求,见 handleBridgeApprove)。 */
1083
+ function bridgeRouteTable() {
1084
+ return [
1085
+ { suffix: '/health', methods: ['GET'], fetch: handleBridgeHealth },
1086
+ { suffix: '/sync', methods: ['POST'], fetch: handleBridgeSync },
1087
+ { suffix: '/ask', methods: ['POST'], fetch: handleBridgeAsk },
1088
+ { suffix: '/event', methods: ['POST'], fetch: handleBridgeEvent },
1089
+ { suffix: '/approve', methods: ['POST'], fetch: handleBridgeApprove },
1090
+ ];
1091
+ }
1092
+
1093
+ /** 把 Fetch 风格 handler 适配成本机 IPC 监听口的 Node 路由(req/res ⇄ Request/Response)。 */
1094
+ function nodeRouteFromFetch(dispatch) {
1095
+ return async (req, res) => {
1096
+ try {
1097
+ const chunks = [];
1098
+ for await (const chunk of req) chunks.push(chunk);
1099
+ const body = Buffer.concat(chunks);
1100
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
1101
+ const method = (req.method ?? 'GET').toUpperCase();
1102
+ const response = await dispatch(url, method, req.headers, body);
1103
+ const buffer = Buffer.from(await response.arrayBuffer());
1104
+ const headers = {};
1105
+ response.headers.forEach((value, key) => { headers[key] = value; });
1106
+ res.writeHead(response.status, headers);
1107
+ res.end(buffer);
1108
+ } catch (error) {
1109
+ if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
1110
+ if (!res.writableEnded) res.end('bridge route error');
1111
+ console.warn(`[code-server] 编辑器桥路由异常:${error && error.message ? error.message : error}`);
1112
+ }
1113
+ };
1114
+ }
1115
+
1116
+ /** 桥挂载点的分发:按后缀查表,方法不符 405,未知路径 404(绝不落到 VS Code 那边)。 */
1117
+ async function dispatchBridge(url, method, headers, body) {
1118
+ const suffix = url.pathname.slice(BRIDGE_BASE.length) || '/';
1119
+ const route = bridgeRouteTable().find((item) => item.suffix === suffix);
1120
+ if (route === undefined) return jsonResponse({ ok: false, error: 'unknown bridge route' }, 404);
1121
+ if (!route.methods.includes(method)) return jsonResponse({ ok: false, error: 'method not allowed' }, 405);
1122
+ const request = new Request(url, {
1123
+ method,
1124
+ headers,
1125
+ ...(method === 'GET' || method === 'HEAD' ? {} : { body }),
1126
+ });
1127
+ // Origin 必须从 Node 的原始 headers 读:undici 的 Request 构造器把 `origin` 当 forbidden header
1128
+ // 归一化掉了,再读 request.headers 会得到 null,bridgeGuard 那道 403 就静默失效。
1129
+ request.dshcsRawHeaders = { get: (name) => headers[String(name).toLowerCase()] ?? null };
1130
+ return route.fetch(request);
1131
+ }
1132
+
1133
+ /** 桥是否启用(行配置为种子,设置文档里可实时改)。 */
1134
+ function bridgeEnabled() {
1135
+ return bridgeSetting !== false;
1136
+ }
1137
+
1138
+ /** 提示词段落在插件激活时注册一次:文本按"桥是否存活"渲染(桥停时为空串 → DSH 丢弃该段),
1139
+ * 所以在 IDE 从未启动的部署里它也只是一段空注册,不产生任何提示词开销。 */
1140
+ setPromptLiveProbe(() => bridgeMeta !== null);
1141
+ bridgePromptDispose = registerEditorPrompt(ctx);
1142
+ if (bridgePromptDispose === null && bridgeEnabled()) {
1143
+ console.warn('[code-server] 编辑器桥:systemPrompt 服务不可用,提示词段落未注册(工具仍可用)');
1144
+ }
1145
+
1146
+ // 观察 agent 的写操作(→ 编辑器 diff 提示)并在写脏文件前附一条提醒。
1147
+ // 与 IDE 是否在跑无关:桥没起来时 isLive() 为 false,dirty 检查直接跳过。
1148
+ bridgeObserveDispose = registerBridgeObserver(ctx, {
1149
+ emit: (kind, fields) => bridgeEvents.push(kind, fields),
1150
+ context: () => bridgeContext.get(),
1151
+ isLive: () => bridgeMeta !== null && !bridgeContext.isStale(),
1152
+ });
1153
+
1154
+ // 会话流与授权拦截都是**按会话**建立的(在 /ask 成功时 watch + intercept),
1155
+ // 这里只登记卸载钩子:面板关掉 / IDE 停掉时把订阅与 pending 授权一起收干净。
1156
+ bridgeAnswerDispose = () => {
1157
+ bridgeThread.dispose();
1158
+ bridgeApproval.dispose();
1159
+ bridgeApprovalBoard.clear();
1160
+ bridgeWatch.ids = [];
1161
+ bridgeWatch.at = 0;
1162
+ };
1163
+
1164
+ /** 桥配置要写的目录(可能有**两个**):
1165
+ *
1166
+ * ① `<extensionsDir>/.dshcs-bridge/` —— 权威位置;host 侧 /status 与诊断都指向它,
1167
+ * 扩展在有 `DSHCS_EXTENSIONS_DIR` 时也读它;
1168
+ * ② `<树>/lib/vscode/extensions/.dshcs-bridge/` —— **内置扩展自己会去找的位置**。
1169
+ * 为什么需要 ②:`bridge-client` 的兜底是"从自身位置反推"(`<ext>/lib/` 上溯两级),
1170
+ * 对内置扩展就是 `<树>/lib/vscode/extensions`。而环境变量只在 host **spawn** IDE 时才注入 ——
1171
+ * **adopt(接管正在运行的 IDE)拿不到**:那个进程是上一次启动的,env 早已定死。
1172
+ * 没有 ② 的话,被接管的 IDE 即使重载窗口也读不到配置,桥只能等 IDE 重启。
1173
+ * 两份内容完全一致(同一个 write/remove 一起写),不存在优先级问题。 */
1174
+ function bridgeConfigDirs() {
1175
+ const dirs = [bridgeExtensionsDir];
1176
+ try {
1177
+ const target = extensionTarget(bridgeExtensionsDir, 'dshcs-editor-bridge', 'builtin');
1178
+ if (target.builtin) {
1179
+ const sibling = path.join(path.dirname(target.dst), BRIDGE_DIRNAME);
1180
+ if (!dirs.includes(sibling)) dirs.push(sibling);
1181
+ }
1182
+ } catch {
1183
+ // 树不可用 → 只有权威位置
1184
+ }
1185
+ return dirs;
1186
+ }
1187
+
1188
+ /** 同步桥运行时:写入/更新 bridge.json(端点 = 本机 IPC 路径 + 令牌 + pid)。
1189
+ * 端点、令牌、pid 三者任一变化都重写 —— 扩展每 5s 重读,故不需要任何推送。
1190
+ * 监听口起不来时**不写配置**(宁可休眠,不可指向死端点),并说明一次。 */
1191
+ function syncBridgeRuntime({ pid, startedAt }) {
1192
+ if (!bridgeEnabled()) return;
1193
+ const handle = bridgeListener;
1194
+ if (handle === null) {
1195
+ if (bridgeMeta !== null) {
1196
+ bridgeMeta = null;
1197
+ for (const dir of bridgeConfigDirs()) {
1198
+ try { removeBridgeConfig(dir); } catch { /* 删不掉也不影响:扩展会因端点在而连不上 */ }
1199
+ }
1200
+ }
1201
+ return;
1202
+ }
1203
+ bridgeToken ??= mintBridgeToken();
1204
+ const changed = bridgeMeta === null || bridgeMeta.pipe !== handle.path || bridgeMeta.token !== bridgeToken || bridgeMeta.pid !== pid;
1205
+ bridgeMeta = { pipe: handle.path, token: bridgeToken, pid, startedAt: startedAt ?? null };
1206
+ const value = { pipe: handle.path, token: bridgeToken, pid, startedAt: startedAt ?? null };
1207
+ for (const dir of bridgeConfigDirs()) {
1208
+ try {
1209
+ writeBridgeConfig(dir, value);
1210
+ } catch (err) {
1211
+ console.warn(`[code-server] 编辑器桥配置写入失败(${dir}):${err && err.message ? err.message : err}`);
1212
+ }
1213
+ }
1214
+ if (changed) {
1215
+ // 只打印端点与配置文件位置,不打印令牌本身(与 path-token 同一决策)。
1216
+ console.log(`[code-server] 编辑器桥:已启用(${handle.path}${BRIDGE_BASE}/*,令牌文件 ${path.join(bridgeMetaDir, 'bridge.json')})`);
1217
+ }
1218
+ ensureBridgeTools();
1219
+ }
1220
+
1221
+ /**
1222
+ * 接管实例时对齐桥令牌:磁盘上已有配置、端点一致且是本机 IPC → 沿用(避免无谓轮换打乱正在运行的扩展);
1223
+ * 否则 mint 新的(扩展下次轮询就会读到新的 bridge.json,一次请求的失败无所谓)。
1224
+ */
1225
+ function adoptBridgeRuntime(pid, startedAt) {
1226
+ if (!bridgeEnabled()) return;
1227
+ const existing = readBridgeConfig(bridgeExtensionsDir);
1228
+ const endpoint = bridgeListener === null ? null : bridgeListener.path;
1229
+ bridgeToken = existing !== null && endpoint !== null && existing.pipe === endpoint && TOKEN_RE.test(String(existing.token))
1230
+ ? String(existing.token)
1231
+ : mintBridgeToken();
1232
+ syncBridgeRuntime({ pid, startedAt });
1233
+ }
1234
+
1235
+ /** 注销桥运行时(停止 IDE / 插件卸载):删配置 + 注销工具 + 清缓存,扩展随即休眠。 */
1236
+ function clearBridgeRuntime() {
1237
+ bridgeToolDispose = disposeSafely(bridgeToolDispose);
1238
+ bridgeContext.clear();
1239
+ bridgeEvents.reset();
1240
+ if (bridgeMeta === null) return;
1241
+ bridgeMeta = null;
1242
+ for (const dir of bridgeConfigDirs()) {
1243
+ try {
1244
+ removeBridgeConfig(dir);
1245
+ } catch {
1246
+ // 配置删不掉不影响正确性(扩展会因 IDE 不可达而休眠)
1247
+ }
1248
+ }
1249
+ }
1250
+
1251
+ function disposeSafely(dispose) {
1252
+ if (typeof dispose !== 'function') return null;
1253
+ try {
1254
+ dispose();
1255
+ } catch (err) {
1256
+ console.warn(`[code-server] 桥工具注销失败:${err && err.message ? err.message : err}`);
1257
+ }
1258
+ return null;
1259
+ }
1260
+
1261
+ /** 桥就绪时注册编辑器工具;`tools` / `defineTool` 缺失时静默退化为"只有本机 IPC 面"。 */
1262
+ function ensureBridgeTools() {
1263
+ if (bridgeMeta === null || bridgeToolDispose !== null) return;
1264
+ Promise.resolve(registerEditorTools(ctx, {
1265
+ target: () => bridgeMeta,
1266
+ cache: () => bridgeContext,
1267
+ }))
1268
+ .then((dispose) => {
1269
+ if (dispose === null) {
1270
+ console.log('[code-server] 编辑器桥:工具服务不可用,仅提供本机 IPC 面(editor_context/editor_diagnostics 未注册)');
1271
+ return;
1272
+ }
1273
+ // 期间桥可能已经被停掉(IDE 退出):立刻回滚,避免留下永远不可用的工具。
1274
+ if (bridgeMeta === null) {
1275
+ disposeSafely(dispose);
1276
+ return;
1277
+ }
1278
+ bridgeToolDispose = dispose;
1279
+ console.log('[code-server] 编辑器桥:已注册 editor_context / editor_diagnostics');
1280
+ })
1281
+ .catch((err) => {
1282
+ console.warn(`[code-server] 编辑器桥工具注册失败:${err && err.message ? err.message : err}`);
1283
+ });
1284
+ }
1285
+
1286
+ // ---- DSH 同源挂载(serve=dsh):把 /code-server 注册到 DSH 自己的 webServer ----
1287
+ // webServer 只在 web profile 存在(desktop 显式禁用该行)→ 用 ctx.inject 特性检测,
1288
+ // 缺失时 requestedServe() 自动回退 loopback。
1289
+ ctx.inject(['webServer'], (wsCtx) => {
1290
+ webServerSvc = wsCtx.webServer;
1291
+ try {
1292
+ dshMount = mountOnWebServer({
1293
+ webServer: wsCtx.webServer,
1294
+ connection,
1295
+ // 目标随时可变:launcher 重启会换管道/端口,未运行时返回 null(路由回 503)
1296
+ getTarget: () => (state.serve === 'dsh' && state.pipe !== null ? { kind: 'pipe', pipe: state.pipe } : null),
1297
+ productPath: state.env?.productPath ?? productPath() ?? 'stable',
1298
+ });
1299
+ console.log(`[code-server] serve=dsh 挂载就绪:${MOUNT_PATH}/ (HTTP)+ ${dshMount.upgradePath} (WS)`);
1300
+ } catch (error) {
1301
+ dshMount = null;
1302
+ console.error(`[code-server] serve=dsh 挂载失败(将回退 loopback):${error && error.message ? error.message : error}`);
1303
+ }
1304
+ });
1305
+
1306
+ ctx.effect(() => () => {
1307
+ if (dshMount === null) return;
1308
+ for (const dispose of dshMount.disposers) {
1309
+ try { dispose(); } catch { /* ignore */ }
1310
+ }
1311
+ }, 'code-server: dsh mount');
1312
+
1313
+ // 编辑器桥的本机 IPC 监听口(web 与 desktop 同一套;不依赖 webServer,见 ensureBridgeListener)。
1314
+ ctx.effect(() => {
1315
+ if (bridgeEnabled()) void ensureBridgeListener();
1316
+ return () => { void stopBridgeListener(); };
1317
+ }, 'code-server: bridge ipc listener');
1318
+
1319
+ /** 回环模式的客户端 URL:随机端口 + 路径令牌(令牌是 URL 路径的一段,浏览器会把子请求与 WS
1320
+ * 一并带过去 —— 这正是不走 VS Code 自带 cookie 令牌的原因,见 launcher 文件头"安全模型")。 */
1321
+ function loopbackUrl() {
1322
+ const token = typeof state.token === 'string' && state.token !== '' ? `${state.token}/` : '';
1323
+ return `http://${cfg.host}:${state.port}/${token}`;
1324
+ }
1325
+
1326
+ function snapshot() {
1327
+ const running = state.status === 'running' && state.pid !== null;
1328
+ const dshMode = state.serve === 'dsh';
1329
+ return {
1330
+ ok: state.status !== 'error' || running,
1331
+ running,
1332
+ status: state.status,
1333
+ serve: state.serve,
1334
+ port: dshMode ? null : state.port,
1335
+ host: dshMode ? null : cfg.host,
1336
+ pid: state.pid,
1337
+ cwd: state.cwd,
1338
+ launchCwd: state.launchCwd,
1339
+ // dsh 模式:同源相对地址(由 DSH webServer 的 prefix 路由提供服务);
1340
+ // loopback:随机端口 + 路径令牌(令牌只出现在这个 URL 里,客户端据此加载 iframe)
1341
+ url: running ? (dshMode ? '/code-server/' : loopbackUrl()) : null,
1342
+ productPath: state.env?.productPath ?? null,
1343
+ version: state.version,
1344
+ error: state.error,
1345
+ logTail: state.logTail.slice(-LOG_TAIL_MAX),
1346
+ adopted: state.adopted,
1347
+ keepResident,
1348
+ claimExtensions, // 客户端据此按扩展名决定认领哪些文件(0.2.11,取代 fileOpenScope)
1349
+ fullscreenOnOpen, // 客户端据此决定"打开标签即全屏右侧栏"(0.2.9)
1350
+ sidebarUi: state.sidebarUi,
1351
+ /** 编辑器桥状态(0.3.0,只读诊断面;**不含令牌** —— 令牌只在 bridge.json 里)。 */
1352
+ bridge: {
1353
+ enabled: bridgeEnabled(),
1354
+ live: bridgeMeta !== null,
1355
+ toolsRegistered: bridgeToolDispose !== null,
1356
+ /** 端点已就绪(本机 IPC 监听口在)。0.3.13 起 web 与 desktop 都是 true(不再依赖 webServer)。 */
1357
+ supported: bridgeListener !== null,
1358
+ /** 本机 IPC 端点(命名管道 / unix socket);null = 监听口没起来。 */
1359
+ endpoint: bridgeListener === null ? null : bridgeListener.path,
1360
+ file: path.join(bridgeMetaDir, 'bridge.json'),
1361
+ },
1362
+ env: state.env,
1363
+ setup: {
1364
+ running: state.setup.running,
1365
+ done: state.setup.done,
1366
+ ok: state.setup.ok,
1367
+ logTail: state.setup.logTail.slice(-LOG_TAIL_MAX),
1368
+ },
1369
+ lastSetupError: readLastSetupError(),
1370
+ };
1371
+ }
1372
+
1373
+ /** 读旧版(≤0.1.35)环境安装失败标记文件;新版不再有安装步骤 → 通常为 null。
1374
+ * 标记在旧安装根:<profile>\.code-server-app\last-setup-error.json。 */
1375
+ function readLastSetupError() {
1376
+ try {
1377
+ const root = legacyInstallRoot();
1378
+ if (root === null) return null;
1379
+ const marker = path.join(root, 'last-setup-error.json');
1380
+ if (!fs.existsSync(marker)) return null;
1381
+ const raw = fs.readFileSync(marker, 'utf8');
1382
+ const j = JSON.parse(raw);
1383
+ return {
1384
+ at: j && typeof j.at === 'string' ? j.at : null,
1385
+ error: j && typeof j.error === 'string' ? j.error : '安装脚本失败(无详情)',
1386
+ };
1387
+ } catch {
1388
+ return null;
1389
+ }
1390
+ }
1391
+
1392
+ function appendLog(chunk) {
1393
+ try {
1394
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
1395
+ state.logTail = (state.logTail + text).slice(-LOG_TAIL_MAX * 2);
1396
+ } catch {
1397
+ // ignore
1398
+ }
1399
+ }
1400
+
1401
+ function stopPolling() {
1402
+ if (pollTimer !== null) {
1403
+ clearInterval(pollTimer);
1404
+ pollTimer = null;
1405
+ }
1406
+ }
1407
+
1408
+ function killTree(pid) {
1409
+ return new Promise((resolve) => {
1410
+ if (!isAlive(pid)) return resolve(false);
1411
+ if (win32()) {
1412
+ execFile('taskkill.exe', ['/PID', String(pid), '/T', '/F'], (err) => resolve(!err));
1413
+ } else {
1414
+ try {
1415
+ process.kill(-pid, 'SIGTERM');
1416
+ } catch {
1417
+ try {
1418
+ process.kill(pid, 'SIGTERM');
1419
+ } catch {
1420
+ return resolve(false);
1421
+ }
1422
+ }
1423
+ const timer = setTimeout(() => {
1424
+ try {
1425
+ process.kill(-pid, 'SIGKILL');
1426
+ } catch {
1427
+ try {
1428
+ process.kill(pid, 'SIGKILL');
1429
+ } catch {
1430
+ // gone
1431
+ }
1432
+ }
1433
+ resolve(true);
1434
+ }, 3000);
1435
+ timer.unref?.();
1436
+ }
1437
+ });
1438
+ }
1439
+
1440
+ async function stop(reason) {
1441
+ stopPolling();
1442
+ const pid = state.pid;
1443
+ if (child !== null) {
1444
+ child.stdout?.removeAllListeners?.('data');
1445
+ child.stderr?.removeAllListeners?.('data');
1446
+ try {
1447
+ child.removeAllListeners?.('exit');
1448
+ child.removeAllListeners?.('error');
1449
+ } catch {
1450
+ // ignore
1451
+ }
1452
+ }
1453
+ child = null;
1454
+ if (pid) {
1455
+ await killTree(pid);
1456
+ }
1457
+ removePidFile(cfg);
1458
+ clearBridgeRuntime(); // 桥在 IDE 停止时同步下线:配置删除,扩展随即休眠
1459
+ state.status = 'stopped';
1460
+ state.pid = null;
1461
+ state.cwd = null;
1462
+ state.launchCwd = null;
1463
+ state.token = null;
1464
+ state.port = cfg.port;
1465
+ state.startedAt = null;
1466
+ state.adopted = false;
1467
+ if (reason) state.error = null;
1468
+ }
1469
+
1470
+ /** 就绪探针:loopback 走 TCP(带路径令牌),dsh 模式走命名管道。 */
1471
+ function probeReady(timeoutMs = 1500) {
1472
+ if (state.pipe !== null) return healthCheckPipe(state.pipe, timeoutMs);
1473
+ return healthCheck(cfg.host, state.port, state.token, timeoutMs);
1474
+ }
1475
+
1476
+ function beginPollingReady() {
1477
+ stopPolling();
1478
+ const deadline = Date.now() + (Number(cfg.readyTimeoutMs) || DEFAULT_CONFIG.readyTimeoutMs);
1479
+ // 首探延迟 800ms(spawn 后进程初始化),之后每 500ms 一次——缩短"已就绪但 UI 在转"的窗口
1480
+ let first = true;
1481
+ pollTimer = setInterval(async () => {
1482
+ if (first) {
1483
+ first = false;
1484
+ return; // 等 800ms 才首次探测(给 Node 启动留时间)
1485
+ }
1486
+ const probe = await probeReady();
1487
+ if (probe.ok) {
1488
+ stopPolling();
1489
+ state.status = 'running';
1490
+ return;
1491
+ }
1492
+ if (Date.now() > deadline) {
1493
+ stopPolling();
1494
+ state.status = 'error';
1495
+ state.error = `启动超时(${cfg.readyTimeoutMs}ms 内 /healthz 未就绪);启动日志尾部:\n${state.logTail.slice(-2000)}`;
1496
+ }
1497
+ }, 500);
1498
+ }
1499
+
1500
+ /** 启动串行化:apply 期的**预启动**与界面点击的启动可能并发,而两者写同一份 pid.json、
1501
+ * 抢同一个端口 —— 并发进入会双开 launcher(第二个绑不上端口 ⇒ 状态被顶成 error,
1502
+ * 表现为"点一下启动就报端口被占用")。串成一条链后,第二个调用会看到 running/starting
1503
+ * 并按既有逻辑复用。 */
1504
+ let startChain = Promise.resolve();
1505
+ function start(cwdArg) {
1506
+ const run = startChain.then(() => startInner(cwdArg), () => startInner(cwdArg));
1507
+ startChain = run.then(() => {}, () => {});
1508
+ return run;
1509
+ }
1510
+
1511
+ /** 已运行实例切换工作区:**只改"当前 workbench 目录",不重启进程**。
1512
+ *
1513
+ * 为什么够用:工作区目录由客户端 URL 的 `&folder=<绝对路径>` 决定(见 src/factory.js 的
1514
+ * buildPageUrl),服务进程自己的 cwd 只影响它 spawn 时对相对路径的解析;真正"换目录"是
1515
+ * workbench 拿新 folder 重新导航(客户端导航 iframe)。
1516
+ * 旧行为(0.2.11 及以前)在这里 stop('restart') + 重新 launch:切一次工作区就整进程重启,
1517
+ * 编辑缓冲、终端、扩展宿主状态全丢,而且侧栏收起时是在后台悄悄发生。
1518
+ * 进程 cwd 保持在 launchCwd 里,诊断时能看出"这个实例是哪个目录拉起来的"。 */
1519
+ function adoptWorkspace(cwd) {
1520
+ const previous = state.cwd;
1521
+ state.cwd = cwd;
1522
+ console.log(`[code-server] workspace switch: ${previous ?? '(none)'} -> ${cwd}`
1523
+ + `(不重启进程,进程 cwd 仍是 ${state.launchCwd ?? '(none)'};workbench 用新的 folder 重新导航)`);
1524
+ return snapshot();
1525
+ }
1526
+
1527
+ async function startInner(cwdArg) {
1528
+ const cwd = typeof cwdArg === 'string' && cwdArg.trim() !== '' ? cwdArg : undefined;
1529
+ if (state.status === 'running' && state.pid !== null) {
1530
+ if (cwd === undefined || cwd === state.cwd) return snapshot();
1531
+ return adoptWorkspace(cwd);
1532
+ }
1533
+ // 上一次启动仍在进行:等待它结算后再决定(运行中则同上面一样只换目录,不重启)
1534
+ if (state.status === 'starting') {
1535
+ const deadline = Date.now() + 10000;
1536
+ while (state.status === 'starting' && Date.now() < deadline) {
1537
+ await new Promise((r) => setTimeout(r, 250));
1538
+ }
1539
+ if (state.status === 'starting') {
1540
+ state.status = 'error';
1541
+ state.error = '启动长时间未结算(10s),请查看启动日志;后可重试';
1542
+ stopPolling();
1543
+ return snapshot();
1544
+ }
1545
+ if (state.status === 'running') {
1546
+ if (cwd === undefined || cwd === state.cwd) return snapshot();
1547
+ return adoptWorkspace(cwd);
1548
+ }
1549
+ }
1550
+
1551
+ const serve = requestedServe(); // loopback | dsh(按 DSH 是否提供 webServer 定)
1552
+ const launch = resolveLaunch(cfg, serve); // throws with install guidance when missing
1553
+
1554
+ if (serve === 'loopback' && !LOOPBACK_HOSTS.has(cfg.host)) {
1555
+ state.status = 'error';
1556
+ state.error = `serve=loopback 仅允许回环绑定(当前 host="${cfg.host}");`
1557
+ + '如需对外提供服务,请改用 serve=dsh(挂到 DSH 同源路径,由 DSH 统一防护)';
1558
+ return snapshot();
1559
+ }
1560
+ if (cfg.auth === 'password') {
1561
+ console.warn('[code-server] 0.2.0 起不再支持口令认证(argon2 已移除),按 auth=none 运行');
1562
+ }
1563
+
1564
+ // 数据目录(adopt 路径也要用:被接管的 IDE 同样需要最新的内置扩展文件)
1565
+ const root = dataRoot(cfg);
1566
+ const userDataDir = cfg.userDataDir || path.join(root, 'user-data');
1567
+ const extensionsDir = cfg.extensionsDir || path.join(root, 'extensions');
1568
+ fs.mkdirSync(userDataDir, { recursive: true });
1569
+ fs.mkdirSync(extensionsDir, { recursive: true });
1570
+
1571
+ // 端口占用则尝试 adopt(仅"显式指定端口"时;默认 port=0 由系统分配,不存在占用问题)。
1572
+ // 探针必须带路径令牌(被接管的实例是我们自己拉的,令牌在令牌文件里)——
1573
+ // 没有令牌文件说明那个实例不是本插件当前这代拉起来的,不接管。
1574
+ if (serve === 'loopback' && cfg.port !== 0) {
1575
+ const existingToken = readToken(cfg);
1576
+ const probe = await healthCheck(cfg.host, cfg.port, existingToken, 800);
1577
+ if (probe.ok) {
1578
+ const record = readPidFile(cfg);
1579
+ if (record && isAlive(record.pid)) {
1580
+ state.serve = 'loopback';
1581
+ state.status = 'running';
1582
+ state.pid = record.pid;
1583
+ state.port = cfg.port;
1584
+ state.token = existingToken;
1585
+ state.cwd = cwd ?? record.cwd ?? null;
1586
+ state.launchCwd = record.launchCwd ?? record.cwd ?? null;
1587
+ state.startedAt = record.startedAt ?? null;
1588
+ state.adopted = true;
1589
+ // 被接管的 IDE **不会**重新加载扩展:文件可以同步,但扩展宿主里跑的仍是上次启动时那份代码。
1590
+ // 桥的传输/协议变更(0.3.13 的 url → pipe)会让旧代码读到 v2 配置后休眠 —— 必须明说,否则
1591
+ // 表现又是那句无法自查的"编辑器还没有上报状态"。
1592
+ const synced = installBundledExtensions(extensionsDir, userDataDir);
1593
+ const newest = newestBundledExtensionMtime(extensionsDir);
1594
+ const staleCode = synced.updated.length > 0
1595
+ || (newest !== null && record.startedAt !== null && newest > record.startedAt);
1596
+ if (staleCode) {
1597
+ console.warn('[code-server] 内置扩展文件比当前 IDE 进程新'
1598
+ + `${synced.updated.length > 0 ? `(本次更新:${synced.updated.join(',')})` : ''}:`
1599
+ + '被接管的 IDE 不会重新加载扩展,里面跑的**可能仍是旧代码**。'
1600
+ + '若编辑器桥没有状态(editor_context 报"编辑器还没有上报状态"),'
1601
+ + '请在 Code Server 标签里重载一次窗口(或重启 IDE)让扩展宿主读到新代码');
1602
+ }
1603
+ adoptBridgeRuntime(record.pid, state.startedAt);
1604
+ return snapshot();
1605
+ }
1606
+ state.status = 'error';
1607
+ state.error = `端口 ${cfg.port} 已被占用且没有有效的 pid.json 记录(拒绝误杀);请释放端口或把 port 改回 0(随机端口)`;
1608
+ return snapshot();
1609
+ }
1610
+ }
1611
+
1612
+ // 安装树内自带扩展(dshcs-open-file:host 信号文件 → VS Code 打开文件;
1613
+ // dshcs-editor-bridge:编辑器桥的扩展侧,0.3.0)
1614
+ installBundledExtensions(extensionsDir, userDataDir);
1615
+
1616
+ const args = launch.kind === 'launcher'
1617
+ ? [
1618
+ launch.script,
1619
+ '--tree', launch.tree,
1620
+ '--user-data-dir', userDataDir,
1621
+ '--extensions-dir', extensionsDir,
1622
+ '--parent-pid', String(process.pid),
1623
+ ]
1624
+ : [
1625
+ '--bind-addr', `${cfg.host}:${cfg.port}`,
1626
+ '--auth', 'none',
1627
+ '--user-data-dir', userDataDir,
1628
+ '--extensions-dir', extensionsDir,
1629
+ '--disable-telemetry',
1630
+ '--disable-update-check',
1631
+ ];
1632
+ if (launch.kind === 'launcher') {
1633
+ if (serve === 'dsh') {
1634
+ state.pipe = pipeName();
1635
+ args.push('--pipe', state.pipe);
1636
+ } else {
1637
+ state.pipe = null;
1638
+ // 随机端口(port=0)+ 路径令牌:令牌**不进程 argv**(本机任意进程都能读命令行),
1639
+ // 走文件传递;实际端口由 launcher 写 endpoint 文件回报。
1640
+ fs.rmSync(endpointFile(cfg), { force: true }); // 清掉上一实例的记录,避免读到旧端口
1641
+ state.token = rotateToken(cfg);
1642
+ args.push('--port', String(cfg.port), '--host', cfg.host,
1643
+ '--token-file', tokenFile(cfg), '--endpoint-file', endpointFile(cfg));
1644
+ }
1645
+ if (typeof cfg.locale === 'string' && cfg.locale !== '') args.push('--locale', cfg.locale);
1646
+ }
1647
+ if (cwd !== undefined && launch.kind !== 'launcher') args.push(cwd);
1648
+
1649
+ state.serve = serve;
1650
+ state.error = null;
1651
+ state.logTail = '';
1652
+ state.status = 'starting';
1653
+ state.port = serve === 'dsh' ? null : cfg.port;
1654
+ state.cwd = cwd ?? null;
1655
+ state.launchCwd = cwd ?? null;
1656
+ state.startedAt = Date.now();
1657
+ state.adopted = false;
1658
+
1659
+ const env = { ...process.env };
1660
+ // 内置扩展信号文件路径(host → 扩展 打开文件)
1661
+ env.DSHCS_OPEN_FILE_SIGNAL = openFileSignalPath(userDataDir);
1662
+ // 扩展目录(host → 扩展 找桥配置 `/.dshcs-bridge/bridge.json`)。
1663
+ // **必须注入**:0.3.12 起桥扩展装在**内置**目录(树里),与 <extensionsDir> 不同级,
1664
+ // 扩展再也不能靠"自己的路径上溯两级"找到配置(0.3.0–0.3.11 那个上溯是错的,多上溯了一级,
1665
+ // 就算装在用户级也读不到配置 ⇒ 桥一直是休眠态)。
1666
+ env.DSHCS_EXTENSIONS_DIR = extensionsDir;
1667
+ // ?v= 缓存击穿标记:按"插件版本 + VS Code 树"生成 —— 只在真升级时让渲染器丢掉旧 bundle,
1668
+ // 平时正常命中缓存(workbench.js 有 18MB,每次启动都重拉不划算)。
1669
+ env.DSHCS_HTML_TAG = `${pluginVersion()}-${productPath() ?? 'dev'}`.replace(/[^A-Za-z0-9._-]/g, '');
1670
+ // 0.3.45 起不再需要给子进程加 NODE_PATH:重打包原生包按**真名**挂在插件依赖上,而它们要的
1671
+ // **原始名字**(node-pty / @vscode/sqlite3 …)由 ensureRuntimeLayout() 在树里补 junction ——
1672
+ // 目录链同时满足 ESM import 与 CJS require,NODE_PATH 只对 CJS 生效且属兜底。
1673
+
1674
+ let proc;
1675
+ try {
1676
+ // 启动前再自愈一次依赖布局(插件重装/树被替换后可能丢失;幂等且只做存在性检查)
1677
+ ensureRuntimeLayout();
1678
+ const isCmd = launch.kind === 'bin' && win32() && /\.cmd$/i.test(launch.command);
1679
+ const command = launch.kind === 'bin' ? launch.command : process.execPath;
1680
+ const spawnArgs = launch.kind === 'bin' ? args : [launch.script, ...args];
1681
+ // shell 仅对 .cmd shim(Windows npm 全局包)必要:它必须经 cmd.exe 解析。
1682
+ // 含空格路径由 spawn 数组传参,不再经 shell 拼接,避免 'C:\Program' 拆分。
1683
+ proc = spawn(isCmd ? `"${command}"` : command, spawnArgs, {
1684
+ cwd: cwd ?? process.cwd(),
1685
+ env,
1686
+ shell: isCmd,
1687
+ windowsHide: true,
1688
+ detached: !win32() && !isCmd,
1689
+ stdio: ['ignore', 'pipe', 'pipe'],
1690
+ });
1691
+ } catch (err) {
1692
+ state.status = 'error';
1693
+ state.error = `spawn 失败: ${err && err.message ? err.message : String(err)}`;
1694
+ return snapshot();
1695
+ }
1696
+ child = proc;
1697
+ state.pid = proc.pid ?? null;
1698
+ /** 实例记录:pid 一到手就先写(崩溃/回收要靠它);端口 0 时在拿到实际端口后再补写一次。 */
1699
+ const writeInstanceRecord = () => writePidFile(cfg, {
1700
+ pid: proc.pid,
1701
+ startedAt: state.startedAt,
1702
+ cwd: cwd ?? null,
1703
+ launchCwd: cwd ?? null,
1704
+ host: serve === 'dsh' ? null : cfg.host,
1705
+ port: serve === 'dsh' ? null : state.port,
1706
+ serve,
1707
+ pipe: state.pipe,
1708
+ launchKind: launch.kind,
1709
+ launchCommand: launch.kind === 'bin' ? launch.command : launch.script,
1710
+ tokenized: state.token !== null,
1711
+ });
1712
+ writeInstanceRecord();
1713
+
1714
+ proc.stdout?.on?.('data', appendLog);
1715
+ proc.stderr?.on?.('data', appendLog);
1716
+
1717
+ // 端口 0(默认)时 host 只能从 endpoint 文件知道实际端口:等 launcher 写完再开始就绪轮询。
1718
+ if (launch.kind === 'launcher' && serve === 'loopback' && cfg.port === 0) {
1719
+ const deadline = Date.now() + 10000;
1720
+ let endpoint = null;
1721
+ while (endpoint === null && Date.now() < deadline) {
1722
+ endpoint = readEndpoint(cfg);
1723
+ if (endpoint !== null && endpoint.pid !== (proc.pid ?? -1)) endpoint = null; // 旧进程的残留记录
1724
+ if (endpoint === null) await new Promise((r) => setTimeout(r, 100));
1725
+ }
1726
+ if (endpoint === null) {
1727
+ state.status = 'error';
1728
+ state.error = 'launcher 未回报监听端口(endpoint 文件缺失);请查看启动日志';
1729
+ return snapshot();
1730
+ }
1731
+ state.port = endpoint.port;
1732
+ writeInstanceRecord();
1733
+ console.log(`[code-server] 随机端口: ${state.port}(host=${endpoint.host}, pid=${endpoint.pid})`);
1734
+ }
1735
+ // 编辑器桥:端口已定(loopback + 已知端口)时写入配置,扩展随即能连上来。
1736
+ // 新启动会轮换桥令牌 —— 与路径令牌同一时机(每次新启动都换)。
1737
+ // dsh 模式(无独立端口)不启用:桥的 Host 白名单假设是 127.0.0.1:<实际端口>,管道模式没有端口。
1738
+ // 桥与 serve 模式无关:只要求"扩展宿主能到达 DSH 的 HTTP 面"(web 有 webServer;
1739
+ // desktop 没有 → syncBridgeRuntime 会自己判定并保持休眠)。
1740
+ bridgeToken = mintBridgeToken();
1741
+ syncBridgeRuntime({
1742
+ pid: proc.pid ?? null,
1743
+ startedAt: state.startedAt,
1744
+ });
1745
+
1746
+ proc.on('error', (err) => {
1747
+ if (child !== proc) return;
1748
+ child = null;
1749
+ removePidFile(cfg);
1750
+ clearBridgeRuntime();
1751
+ state.status = 'error';
1752
+ state.error = `code-server 启动失败: ${err && err.message ? err.message : String(err)}\n${state.logTail.slice(-1000)}`;
1753
+ });
1754
+
1755
+ proc.on('exit', (code, signal) => {
1756
+ if (child !== proc) return; // 已被 stop/dispose 接管
1757
+ child = null;
1758
+ removePidFile(cfg);
1759
+ clearBridgeRuntime();
1760
+ if (disposeKilled) return;
1761
+ state.status = 'error';
1762
+ state.error = `code-server 意外退出${code !== null ? `(exit ${code})` : signal ? `(signal ${signal})` : ''}:\n${state.logTail.slice(-1500)}`;
1763
+ });
1764
+
1765
+ beginPollingReady();
1766
+ return snapshot();
1767
+ }
1768
+
1769
+ /** 0.1.36 起没有「安装环境」步骤:code-server 随插件包发布,内部依赖与预编译原生模块
1770
+ * 都由包管理器在 `dsh plugin add` 时装好。这里只做一次重新自检(兼容旧客户端的按钮)。 */
1771
+ function refreshEnv() {
1772
+ state.env = envCheck();
1773
+ state.setup = {
1774
+ running: false,
1775
+ done: true,
1776
+ ok: state.env.ok,
1777
+ logTail: state.env.ok
1778
+ ? '环境已就绪(依赖由包管理器安装,无需安装步骤)'
1779
+ : `环境未就绪:${describeEnvProblem(state.env)}`,
1780
+ startedAt: null,
1781
+ finishedAt: Date.now(),
1782
+ };
1783
+ return state.setup;
1784
+ }
1785
+
1786
+ /** 环境问题的一句话描述(状态卡/日志共用)。 */
1787
+ function describeEnvProblem(env) {
1788
+ const parts = [];
1789
+ if (env.tree === null) parts.push(`缺少 VS Code 树(包内 vendor/vscode 或 dshcs-vscode-server 未安装)`);
1790
+ else if (env.entry === null) parts.push('树里缺少 lib/vscode/out/server-main.js');
1791
+ if (env.vscodeInner !== true) {
1792
+ const missing = Array.isArray(env.innerDeps?.missing) ? env.innerDeps.missing : [];
1793
+ parts.push(`内部依赖未装全(缺 ${missing.length} 个:${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ' …' : ''})`);
1794
+ }
1795
+ if (env.nativeRuntime !== null && env.nativeRuntime.source === null) {
1796
+ parts.push(`预编译原生模块未安装(${env.nativeRuntime.name})`);
1797
+ } else if (Array.isArray(env.nativeRuntime?.missing) && env.nativeRuntime.missing.length > 0) {
1798
+ parts.push(`原生模块解析失败:${env.nativeRuntime.missing.slice(0, 5).join(', ')}`);
1799
+ }
1800
+ return parts.length > 0 ? parts.join('; ') : '未知问题';
1801
+ }
1802
+
1803
+ // ---- /api/code-server 路由(Connection 共享通道;web 与 desktop 通用) ----
1804
+ const API_BASE = '/api/code-server';
1805
+
1806
+ /** JSON 响应(与旧 webServer JSON API 相同的载荷形状)。 */
1807
+ function jsonResponse(value, status = 200) {
1808
+ return new Response(JSON.stringify(value), {
1809
+ status,
1810
+ headers: { 'content-type': 'application/json; charset=utf-8' },
1811
+ });
1812
+ }
1813
+
1814
+ /** 读取 JSON 请求体;空体 / 非法 JSON 回退为 {}(旧实现是逐块 best-effort 合并)。 */
1815
+ async function readJsonBody(request) {
1816
+ try {
1817
+ const value = await request.json();
1818
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
1819
+ } catch {
1820
+ return {};
1821
+ }
1822
+ }
1823
+
1824
+ /** 统一的失败载荷(HTTP 200 + ok:false,客户端按 ok 判定)。 */
1825
+ function failureResponse(err) {
1826
+ return jsonResponse({
1827
+ ok: false,
1828
+ error: err && err.message ? err.message : String(err),
1829
+ runner: 'code-server',
1830
+ });
1831
+ }
1832
+
1833
+ async function handleStatus() {
1834
+ return jsonResponse(snapshot());
1835
+ }
1836
+
1837
+ async function handleStart(request) {
1838
+ try {
1839
+ const body = await readJsonBody(request);
1840
+ const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
1841
+ return jsonResponse(await start(cwd));
1842
+ } catch (err) {
1843
+ return failureResponse(err);
1844
+ }
1845
+ }
1846
+
1847
+ async function handleStop() {
1848
+ try {
1849
+ await stop('user');
1850
+ return jsonResponse(snapshot());
1851
+ } catch (err) {
1852
+ return failureResponse(err);
1853
+ }
1854
+ }
1855
+
1856
+ async function handleSetup() {
1857
+ try {
1858
+ // 兼容旧客户端:0.1.36 起没有安装步骤,这里只重新自检并返回结果。
1859
+ const setup = refreshEnv();
1860
+ return jsonResponse({
1861
+ ...snapshot(),
1862
+ ok: true,
1863
+ message: setup.ok ? '环境已就绪,无需安装' : `环境未就绪:${setup.logTail}`,
1864
+ error: null,
1865
+ });
1866
+ } catch (err) {
1867
+ return failureResponse(err);
1868
+ }
1869
+ }
1870
+
1871
+ async function handleOpenFile(request) {
1872
+ try {
1873
+ // 打开文件:host 写信号文件,内置扩展(dshcs-open-file)在 VS Code 中打开它。
1874
+ // 0.2.5 起客户端只在"认领到文件地址"时调用本路由(address → 绝对路径);line 可选(1 基)。
1875
+ const body = await readJsonBody(request);
1876
+ const file = typeof body.file === 'string' ? body.file : null;
1877
+ if (file === null || file === '') {
1878
+ return jsonResponse({ ok: false, error: '需要 file 字段(要打开的绝对路径)' }, 400);
1879
+ }
1880
+ const line = Number.isSafeInteger(body.line) && body.line > 0 ? body.line : null;
1881
+ const root = dataRoot(cfg);
1882
+ const userDataDir = cfg.userDataDir || path.join(root, 'user-data');
1883
+ const signal = openFileSignalPath(userDataDir);
1884
+ fs.mkdirSync(path.dirname(signal), { recursive: true });
1885
+ fs.writeFileSync(signal, JSON.stringify(line === null ? { file, ts: Date.now() } : { file, line, ts: Date.now() }), 'utf8');
1886
+ return jsonResponse({ ok: true, signal });
1887
+ } catch (err) {
1888
+ return failureResponse(err);
1889
+ }
1890
+ }
1891
+
1892
+ /**
1893
+ * 客户端上报 UI 载体能力(0.2.3):{ sidebar: false } = 旧版 DSH(没有右侧栏服务)。
1894
+ * 旧版 DSH 上本插件不提供任何入口 → 这里回收「由本插件自动预启动」的实例,避免留下用不上的 IDE 进程;
1895
+ * 用户手动启动的实例(adopted)不动,后续也不再有 UI 触发启动。
1896
+ */
1897
+ async function handleUiMode(request) {
1898
+ try {
1899
+ const body = await readJsonBody(request);
1900
+ if (body !== null && typeof body.sidebar === 'boolean') {
1901
+ const next = body.sidebar;
1902
+ if (state.sidebarUi !== next) {
1903
+ state.sidebarUi = next;
1904
+ console.log(`[code-server] 客户端 UI 载体:${next ? '右侧栏(受支持)' : '无右侧栏(旧版 DSH,只给设置页提示)'}`);
1905
+ }
1906
+ if (next === false && state.prestarted === true && state.adopted !== true) {
1907
+ state.prestarted = false;
1908
+ if (state.status === 'running' || state.status === 'starting') {
1909
+ console.warn('[code-server] 旧版 DSH:回收自动预启动的实例(旧版已不受支持)');
1910
+ await stop('legacy-ui').catch(err => {
1911
+ console.error(`[code-server] 回收失败:${err && err.message ? err.message : err}`);
1912
+ });
1913
+ }
1914
+ }
1915
+ }
1916
+ return jsonResponse({ ok: true, sidebarUi: state.sidebarUi });
1917
+ } catch (err) {
1918
+ return failureResponse(err);
1919
+ }
1920
+ }
1921
+
1922
+ // ---- 编辑器桥路由(0.3.0)----
1923
+ // 这些路由**不依赖** DSH 的 cookie 认证:扩展宿主是 Node 进程,拿不到浏览器 cookie,
1924
+ // 所以自带独立令牌(见 lib/bridge.mjs 顶部的安全不变量)。命名空间永久只读。
1925
+
1926
+ /** 每个桥路由都先过 guard;返回 Response 表示拒绝,调用方直接返回它。 */
1927
+ function bridgeRejection(request) {
1928
+ return bridgeGuard(request, bridgeMeta === null ? null : bridgeMeta.token);
1929
+ }
1930
+
1931
+ /** 桥不可达时的统一 503(扩展侧没跑 / IDE 刚起还没加载扩展)。 */
1932
+ function bridgeDown(reason) {
1933
+ return jsonResponse({ ok: false, error: reason }, 503);
1934
+ }
1935
+
1936
+ async function handleBridgeHealth() {
1937
+ // 无鉴权:只回一句"桥活着吗",不含任何编辑器数据(便于重启后一眼确认)。
1938
+ // 注意:bridge=true 只表示"配置已就绪",**不代表扩展在跑**(见 README 已知限制)。
1939
+ return jsonResponse({
1940
+ ok: true,
1941
+ bridge: bridgeMeta !== null,
1942
+ pid: state.pid,
1943
+ transport: 'ipc',
1944
+ endpoint: bridgeMeta === null ? null : bridgeMeta.pipe,
1945
+ });
1946
+ }
1947
+
1948
+ async function handleBridgeSync(request) {
1949
+ const denied = bridgeRejection(request);
1950
+ if (denied !== null) return denied;
1951
+ let body;
1952
+ try {
1953
+ body = await readJsonBody(request);
1954
+ } catch {
1955
+ return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
1956
+ }
1957
+ if (!bodyWithinLimit(body)) return jsonResponse({ ok: false, error: '上报内容过大' }, 413);
1958
+ bridgeContext.update(body);
1959
+ // 取事件 + 推进游标:与上报同一趟来回,扩展不需要第二个定时器。
1960
+ let since = 0;
1961
+ try {
1962
+ const raw = new URL(request.url).searchParams.get('since');
1963
+ const parsed = raw === null ? 0 : Number.parseInt(raw, 10);
1964
+ if (Number.isSafeInteger(parsed) && parsed > 0) since = parsed;
1965
+ } catch {
1966
+ since = 0;
1967
+ }
1968
+ const events = bridgeEvents.since(since);
1969
+ bridgeEvents.reset();
1970
+ // 面板声明它要看哪些会话(数组;空 = 关掉了)。据此对齐订阅 + 判断"面板在不在看"
1971
+ // (授权拦截只在面板看着的时候才抢答,否则一律交给官方链路)。
1972
+ const watch = Array.isArray(body.watch) ? body.watch.filter((id) => typeof id === 'string' && id !== '') : [];
1973
+ bridgeWatch.ids = watch.slice(0, 4);
1974
+ bridgeApprovalsUi = body.approvalsUi === true;
1975
+ if (bridgeApprovalsUi !== bridgeApprovalsUiLogged) {
1976
+ bridgeApprovalsUiLogged = bridgeApprovalsUi;
1977
+ bridgeDiag('approvalsUi → ' + bridgeApprovalsUi + '(客户端可否显示授权卡片)');
1978
+ }
1979
+ bridgeWatch.at = Date.now();
1980
+ bridgeThread.sync(watchedSessions());
1981
+ // 看护状态变化就写一行;并且**首帧必写**(0.3.31:只记"翻转"会让"一直没人看"这种情况在日志里
1982
+ // 完全空白,事后无从判断 —— 上一轮就是这样被拖了一整轮)。
1983
+ const watchingNow = hasWatcher();
1984
+ if (watchingNow !== bridgeWatchLogged || bridgeWatchWroteFirst !== true) {
1985
+ bridgeWatchLogged = watchingNow;
1986
+ bridgeWatchWroteFirst = true;
1987
+ bridgeDiag('hasWatcher → ' + watchingNow
1988
+ + ' (面板=[' + bridgeWatch.ids.join(',') + '] 对话框=[' + bridgeWatch.dialogIds.join(',') + ']'
1989
+ + ' approvalsUi=' + bridgeApprovalsUi + ')');
1990
+ }
1991
+ // 请求过对话框、客户端半部却从没来问过 → 说清楚原因(多半是浏览器还缓存着旧的客户端 bundle)。
1992
+ if (askDialog.open && !askDialogLive() && askDialog.warned !== true) {
1993
+ askDialog.warned = true;
1994
+ console.warn('[code-server] 请求了「问 DSH」对话框,但客户端半部没有轮询 /ask/state:'
1995
+ + '浏览器可能还在用旧的客户端 bundle(硬刷新 / 清 Code Cache)。'
1996
+ + '此期间提问会退回编辑器面板,授权也在面板里批。');
1997
+ }
1998
+ // **对话流只在变化时回传**(0.3.27):以前每趟 /sync 都塞整份快照(≤120 条 × ≤8KB,可达 ~1MB / 600ms),
1999
+ // 实测把 /sync 拖成 3 秒超时 —— 扩展收不到响应 ⇒ 面板看不到 approvals ⇒ 授权卡片永远不出现
2000
+ // (卡片在宿主里干等到窗口结束才交给 DSH 界面)。现在给修订号,没变化就不重传。
2001
+ const threadRev = bridgeThread.rev(watchedSessions());
2002
+ const snapshot = bridgeThread.supported()
2003
+ ? bridgeThread.snapshot(watchedSessions())
2004
+ : { available: false, sessionId: null, cursor: null, rev: 0, entries: [], error: '宿主没有 sessionController.follow(DSH 版本过旧)' };
2005
+ // **按"客户端已经拿到哪一版"决定发不发**(0.3.29 修):0.3.28 比的是宿主自己上次发过的版本,
2006
+ // 于是"面板是后开的"这种情形下宿主认为没变化、什么都不发,面板手里一份快照都没有 ⇒ 旧模型把它
2007
+ // 当成"宿主没有对话流能力"(用户实测到的那条误报)。现在由客户端用 threadRev 声明自己持有的版本;
2008
+ // 不带这个字段的旧扩展一律收整份(不省流量,但绝不误报)。
2009
+ const peerRev = Number.isSafeInteger(body.threadRev) ? body.threadRev : null;
2010
+ const threadChanged = peerRev === null || peerRev !== threadRev || snapshot.available === false;
2011
+ bridgeThreadRevSent = threadRev;
2012
+ // 对话流走独立字段:每个会话给有界条目(见 lib/bridge-thread.mjs),
2013
+ // 授权待决列表也一并回去(面板显示卡片)。
2014
+ return jsonResponse({
2015
+ ok: true,
2016
+ events,
2017
+ // thread 缺省 = 没变化(扩展沿用上一份);threadRev 用来区分"没变化"与"旧宿主没有这个字段"。
2018
+ threadRev,
2019
+ thread: threadChanged ? snapshot : undefined,
2020
+ approvals: bridgeApprovalBoard.snapshot(),
2021
+ // 授权卡片要在面板上倒计时,窗口长度由 host 给(面板不猜)。
2022
+ approvalHoldMs: DEFAULT_HOLD_MS,
2023
+ dshVersion: dshVersion(),
2024
+ // 界面版本:面板据此提示"内置渲染器与当前 DSH 界面版本不一致"。
2025
+ uiVersion: uiVersion(),
2026
+ // 能力探测(0.3.24;0.3.25 起以"客户端半部真的在轮询"为准):
2027
+ // 只有对话框活着的时候才让扩展走 ask-open;否则扩展开编辑器面板(那里也能批授权)。
2028
+ askDialog: askDialogLive(),
2029
+ // 诊断用:请求过对话框但客户端没响应(浏览器缓存旧 bundle?)。
2030
+ askDialogRequested: askDialog.open,
2031
+ });
2032
+ }
2033
+
2034
+ async function handleBridgeAsk(request) {
2035
+ const denied = bridgeRejection(request);
2036
+ if (denied !== null) return denied;
2037
+ let body;
2038
+ try {
2039
+ body = await readJsonBody(request);
2040
+ } catch {
2041
+ return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
2042
+ }
2043
+ const text = typeof body.text === 'string' ? body.text : '';
2044
+ if (text === '') return jsonResponse({ ok: false, error: '需要 text 字段(要问 DSH 的话)' }, 400);
2045
+ const file = typeof body.file === 'string' && body.file !== '' ? body.file : null;
2046
+ const lineStart = Number.isSafeInteger(body.lineStart) ? body.lineStart : null;
2047
+ const lineEnd = Number.isSafeInteger(body.lineEnd) ? body.lineEnd : null;
2048
+ const selection = typeof body.selection === 'string' && body.selection !== '' ? body.selection : null;
2049
+ // 交给 bridge-session.mjs(agent 投递);它内部特性探测 agents,缺失时返回
2050
+ // {ok:false, code:'NO_AGENT'} —— 这里原样透传,并把 NO_AGENT 映射成 409。
2051
+ const result = await deliverEditorPrompt(ctx, {
2052
+ text,
2053
+ file,
2054
+ lineStart,
2055
+ lineEnd,
2056
+ selection,
2057
+ languageId: typeof body.languageId === 'string' ? body.languageId : null,
2058
+ });
2059
+ // 会话流 + 授权拦截都在这里建立(只针对**被问过的**那个会话,agent 作用域):
2060
+ // - thread:面板像 DSH 对话那样显示"新内容"(见 lib/bridge-thread.mjs);
2061
+ // - approval:该会话的敏感动作(工作区外写入 / 命令执行)先在面板里问一句,
2062
+ // 8 秒没人答就交给官方链路(见 lib/bridge-approval.mjs)。
2063
+ if (result.ok === true && typeof result.sessionId === 'string' && result.sessionId !== '') {
2064
+ const ok = bridgeThread.watch(result.sessionId);
2065
+ if (!ok) console.warn('[code-server] 编辑器桥:会话订阅未建立(宿主没有 sessionController.follow)');
2066
+ bridgeApproval.intercept(result.agent, result.sessionId);
2067
+ }
2068
+ // `agent` 是内部句柄,不能进 JSON。
2069
+ const { agent: _agent, ...payload } = result;
2070
+ void _agent;
2071
+ return jsonResponse(payload, result.ok === true ? 200 : (result.code === 'NO_AGENT' ? 409 : 200));
2072
+ }
2073
+
2074
+ /**
2075
+ * 面板对授权请求的决策(0.3.22)。
2076
+ *
2077
+ * **这是命名空间里唯一的非只读路由**,约束写死在实现里:
2078
+ * - 只能回答**本 host 发起、仍未决**的请求(单次使用,用后即废);
2079
+ * - outcome 只接受 `allowed-once` / `rejected`;
2080
+ * - 不接受任何自由文本、路径或命令参数 —— 它只能"回答问题",不能"发起动作"。
2081
+ */
2082
+ async function handleBridgeApprove(request) {
2083
+ const denied = bridgeRejection(request);
2084
+ if (denied !== null) return denied;
2085
+ let body;
2086
+ try {
2087
+ body = await readJsonBody(request);
2088
+ } catch {
2089
+ return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
2090
+ }
2091
+ const verdict = answerApproval(
2092
+ typeof body.id === 'string' ? body.id : '',
2093
+ typeof body.outcome === 'string' ? body.outcome : '',
2094
+ );
2095
+ return jsonResponse(verdict.body, verdict.status);
2096
+ }
2097
+
2098
+ // ---------------------------------------------------------------- 「问 DSH」对话框(client 半部)
2099
+
2100
+ /** 对话框每 900ms 问一次:给修订号,没变就只回一个数字(不重传几十 KB 的对话流)。 */
2101
+ async function handleAskState(request) {
2102
+ // 这一条就是"客户端半部活着"的证据(能力位 askDialog 以它为准,见 askDialogLive)。
2103
+ askDialog.polls += 1;
2104
+ // 有轮询 = 对话框活着 ⇒ 武装(关掉时 /ask/close 会立刻解除)。
2105
+ askDialog.armed = true;
2106
+ askDialog.lastPollAt = Date.now();
2107
+ // 心跳证据(0.3.35):首次 + 每 40 次写一行,证明"对话框在持续发活跃通知"。
2108
+ if (askDialog.polls % 40 === 1) {
2109
+ bridgeDiag('dialog heartbeat #' + askDialog.polls + ' (open=' + askDialog.open + ' session=' + String(askDialog.sessionId) + ')');
2110
+ }
2111
+ if (askDialog.polls === 1) {
2112
+ console.log('[code-server] 「问 DSH」对话框已连上客户端半部:/ask/state 开始轮询');
2113
+ }
2114
+ refreshDialogWatch();
2115
+ const current = askRevision();
2116
+ let rev = null;
2117
+ try {
2118
+ const raw = new URL(request.url).searchParams.get('rev');
2119
+ const parsed = raw === null ? Number.NaN : Number.parseInt(raw, 10);
2120
+ if (Number.isSafeInteger(parsed) && parsed >= 0) rev = parsed;
2121
+ } catch {
2122
+ rev = null;
2123
+ }
2124
+ if (rev !== null && rev === current) return jsonResponse({ ok: true, changed: false, rev: current });
2125
+ return jsonResponse({ ...askState(), changed: true, rev: current });
2126
+ }
2127
+
2128
+ /** 对话框发一条提问:上下文取宿主缓存里的编辑器状态(扩展每 600ms 上报)。 */
2129
+ async function handleAskSend(request) {
2130
+ let body;
2131
+ try {
2132
+ body = await readJsonBody(request);
2133
+ } catch {
2134
+ return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
2135
+ }
2136
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
2137
+ if (text === '') return jsonResponse({ ok: false, error: '需要 text 字段(要问 DSH 的话)' }, 400);
2138
+ const mode = body.mode === 'file' ? 'file' : askDialog.mode;
2139
+ const context = askContextFromCache(mode);
2140
+ const result = await deliverEditorPrompt(ctx, {
2141
+ text,
2142
+ file: context === null ? null : context.file,
2143
+ lineStart: context === null ? null : context.lineStart,
2144
+ lineEnd: context === null ? null : context.lineEnd,
2145
+ selection: context === null ? null : context.selection,
2146
+ languageId: context === null ? null : context.languageId,
2147
+ });
2148
+ if (result.ok === true && typeof result.sessionId === 'string' && result.sessionId !== '') {
2149
+ askDialog.sessionId = result.sessionId;
2150
+ askDialog.mode = mode;
2151
+ refreshDialogWatch();
2152
+ bridgeThread.watch(result.sessionId);
2153
+ bridgeApproval.intercept(result.agent, result.sessionId);
2154
+ }
2155
+ const { agent: _agent, ...payload } = result;
2156
+ void _agent;
2157
+ return jsonResponse(payload, result.ok === true ? 200 : (result.code === 'NO_AGENT' ? 409 : 200));
2158
+ }
2159
+
2160
+ /** 对话框回答授权(与桥的 /approve 同一套校验)。 */
2161
+ async function handleAskApprove(request) {
2162
+ let body;
2163
+ try {
2164
+ body = await readJsonBody(request);
2165
+ } catch {
2166
+ return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
2167
+ }
2168
+ const verdict = answerApproval(
2169
+ typeof body.id === 'string' ? body.id : '',
2170
+ typeof body.outcome === 'string' ? body.outcome : '',
2171
+ );
2172
+ return jsonResponse(verdict.body, verdict.status);
2173
+ }
2174
+
2175
+ /** 对话框关掉:不再看会话 ⇒ 待决授权按"没人看"交回官方链路(拦截器的轮询会立刻放行)。 */
2176
+ async function handleAskClose() {
2177
+ askDialog.open = false;
2178
+ // **关闭即解除武装**(0.3.40):否则 10 分钟 TTL 内我们还会继续拦截,而卡片已经没地方显示。
2179
+ askDialog.armed = false;
2180
+ askDialog.lastPollAt = 0;
2181
+ askDialog.sessionId = null;
2182
+ refreshDialogWatch();
2183
+ return jsonResponse({ ok: true });
2184
+ }
2185
+
2186
+ /** 面板产物(thread.js / thread.css)给对话框注入用。 */
2187
+ async function handleAskBundle() {
2188
+ const bundle = askBundle();
2189
+ if (bundle === null) {
2190
+ return jsonResponse({ ok: false, error: '面板产物缺失:请在插件源码里跑 pnpm run build:webview,或重装插件' }, 503);
2191
+ }
2192
+ return jsonResponse({ ok: true, rev: askRevision(), ...bundle });
2193
+ }
2194
+
2195
+ async function handleBridgeEvent(request) {
2196
+ const denied = bridgeRejection(request);
2197
+ if (denied !== null) return denied;
2198
+ const body = await readJsonBody(request);
2199
+ const kind = typeof body.kind === 'string' ? body.kind : '';
2200
+ if (kind === '') return jsonResponse({ ok: false, error: '需要 kind 字段' }, 400);
2201
+ // 右键「问 DSH」→ 扩展上报 ask-open:host 打开 DSH 页面里的对话框(client 半部轮询到就拿去显示)。
2202
+ if (kind === 'ask-open') {
2203
+ askDialog.open = true;
2204
+ askDialog.mode = body.mode === 'file' ? 'file' : 'selection';
2205
+ askDialog.at = Date.now();
2206
+ refreshDialogWatch();
2207
+ appendLog(`[bridge] ask-open mode=${askDialog.mode}\n`);
2208
+ return jsonResponse({ ok: true, mode: askDialog.mode, contextText: askContextLine(askDialog.mode) });
2209
+ }
2210
+ // 扩展上报的编辑器侧事件(打开/关闭文件等):目前只进日志尾,供诊断时回看。
2211
+ appendLog(`[bridge] ${kind}${typeof body.path === 'string' ? ` ${body.path}` : ''}\n`);
2212
+ return jsonResponse({ ok: true });
2213
+ }
2214
+
2215
+
2216
+ // 每条操作一条 exact Fetch 路由。desktop 的 assetHandler 只把 /api/* 交给
2217
+ // createSharedFetchHandler('/api'),所以路径必须落在 /api 下。
2218
+ const disposers = [ { path: `${API_BASE}/status`, methods: ['GET'], fetch: handleStatus },
2219
+ { path: `${API_BASE}/start`, methods: ['POST'], fetch: handleStart },
2220
+ { path: `${API_BASE}/stop`, methods: ['POST'], fetch: handleStop },
2221
+ { path: `${API_BASE}/setup`, methods: ['POST'], fetch: handleSetup },
2222
+ { path: `${API_BASE}/open-file`, methods: ['POST'], fetch: handleOpenFile },
2223
+ { path: `${API_BASE}/ui-mode`, methods: ['POST'], fetch: handleUiMode },
2224
+ // ---- 「问 DSH」对话框(0.3.24):DSH 页面里的 client 半部调这几条 ----
2225
+ // 这里**在 /api 下**是对的:调用方是 DSH 页面自己(同源 + cookie),与扩展宿主(必须走本机 IPC)不同。
2226
+ { path: `${API_BASE}/ask/state`, methods: ['GET'], fetch: handleAskState },
2227
+ { path: `${API_BASE}/ask/send`, methods: ['POST'], fetch: handleAskSend },
2228
+ { path: `${API_BASE}/ask/approve`, methods: ['POST'], fetch: handleAskApprove },
2229
+ { path: `${API_BASE}/ask/close`, methods: ['POST'], fetch: handleAskClose },
2230
+ { path: `${API_BASE}/ask/bundle`, methods: ['GET'], fetch: handleAskBundle },
2231
+ // ---- 编辑器桥的 4 条路由**不在 /api 下**(0.3.9 修正)----
2232
+ // 原因:Connection 给 `/api` 装了 cookie fence(无 cookie → 401),而扩展宿主是 Node 进程、
2233
+ // 永远拿不到浏览器 cookie ⇒ 挂在这里的路由根本到不了(实测:带令牌也被 401/405 挡回)。
2234
+ // 现在挂到 DSH webServer 的 `${BRIDGE_BASE}/*`,自带桥令牌鉴权(见该处注释与 lib/bridge.mjs 的安全不变量)。
2235
+ ].map(route => connection.fetch.register({
2236
+ path: route.path,
2237
+ methods: route.methods,
2238
+ requestBody: 'buffered',
2239
+ fetch: route.fetch,
2240
+ }));
2241
+
2242
+ ctx.effect(() => {
2243
+ return () => {
2244
+ disposeKilled = true;
2245
+ stopPolling();
2246
+ bridgeObserveDispose = disposeSafely(bridgeObserveDispose);
2247
+ bridgeAnswerDispose = disposeSafely(bridgeAnswerDispose);
2248
+ bridgePromptDispose = disposeSafely(bridgePromptDispose);
2249
+ clearBridgeRuntime();
2250
+ for (const d of disposers) {
2251
+ try {
2252
+ // connection.fetch.register 的 disposer 是异步的(返回 Promise);
2253
+ // 注册本身也挂在当前 fiber 上,这里显式回收只是双保险。
2254
+ Promise.resolve(d()).catch(() => {});
2255
+ } catch {
2256
+ // ignore
2257
+ }
2258
+ }
2259
+ if (child !== null || (state.pid !== null && isAlive(state.pid))) {
2260
+ const pid = state.pid;
2261
+ child = null;
2262
+ if (pid) {
2263
+ killTree(pid).then(() => removePidFile(cfg));
2264
+ }
2265
+ }
2266
+ };
2267
+ }, 'code-server: lifecycle');
2268
+
2269
+ // ---- 环境自检:code-server 就在插件目录里就地运行,依赖由包管理器装好,无任何安装步骤 ----
2270
+ const record = readPidFile(cfg);
2271
+ const liveInstance = record !== undefined && record !== null && isAlive(record.pid);
2272
+
2273
+ function maybePrestart() {
2274
+ // 环境就绪且未运行 → 后台自动拉起(不等待),首次打开侧栏标签时 iframe 立即加载。
2275
+ // 旧版 DSH(客户端上报 sidebarUi=false)没有可用入口 → 不预启动,也不占端口。
2276
+ if (state.sidebarUi === false) return;
2277
+ if (state.env.ok && state.status !== 'running' && state.status !== 'starting') {
2278
+ state.prestarted = true;
2279
+ start().catch((err) => {
2280
+ state.prestarted = false;
2281
+ console.error('[code-server] prestart failed:', err && err.message ? err.message : String(err));
2282
+ });
2283
+ }
2284
+ }
2285
+
2286
+ state.env = envCheck();
2287
+ // layout 已在本函数开头算过(envCheck 之前):这里只报告结果。
2288
+ if (layout.created.length > 0) {
2289
+ console.log(`[code-server] 已补齐 ${layout.created.length} 个依赖链接: ${layout.created.join(', ')}`);
2290
+ }
2291
+ if (layout.failed.length > 0) {
2292
+ console.warn(`[code-server] 依赖链接创建失败: ${layout.failed.join('; ')}`);
2293
+ }
2294
+ if (!vendorReady()) {
2295
+ console.warn(`[code-server] 找不到 VS Code 树:平台无关包 ${codeServerPackageName()} 未安装,`
2296
+ + '且包内 vendor/vscode 不存在(开发期请先 `node scripts/vendor-vscode-server.mjs --dev-links`)');
2297
+ } else {
2298
+ console.log(`[code-server] VS Code 树: ${vsRoot()}(productPath=${state.env.productPath ?? '?'})`);
2299
+ }
2300
+ if (!state.env.ok) {
2301
+ console.warn(`[code-server] 环境未就绪: ${describeEnvProblem(state.env)}`);
2302
+ }
2303
+ const legacyRoot = legacyInstallRoot();
2304
+ if (legacyRoot !== null && fs.existsSync(legacyRoot)) {
2305
+ console.log(`[code-server] 检测到旧版安装根 ${legacyRoot}(0.1.35 及更早遗留,已不再使用,可安全删除)`);
2306
+ }
2307
+
2308
+ // DSH host 重启后 adopt(仅 loopback 模式):pid.json 有效、进程存活、令牌文件在、endpoint 记录的端口
2309
+ // 上 /healthz(带令牌)响应 → 接管。端口 0 时不能拿 cfg.port 去探(那是个占位值),必须用 endpoint 文件。
2310
+ // dsh 模式的 launcher 带 --parent-pid 看门狗:host 一退出它就自行退出,不存在可接管的孤儿。
2311
+ if (liveInstance && (record.serve !== 'dsh')) {
2312
+ const adoptedToken = readToken(cfg);
2313
+ const endpoint = readEndpoint(cfg);
2314
+ const adoptPort = endpoint !== null && endpoint.pid === record.pid ? endpoint.port : (cfg.port !== 0 ? cfg.port : null);
2315
+ const probe = adoptPort === null || adoptedToken === null
2316
+ ? { ok: false }
2317
+ : await healthCheck(cfg.host, adoptPort, adoptedToken, 800);
2318
+ if (probe.ok) {
2319
+ state.serve = 'loopback';
2320
+ state.status = 'running';
2321
+ state.pid = record.pid;
2322
+ state.port = adoptPort;
2323
+ state.token = adoptedToken;
2324
+ state.cwd = record.cwd ?? null;
2325
+ state.launchCwd = record.launchCwd ?? record.cwd ?? null;
2326
+ state.startedAt = record.startedAt ?? null;
2327
+ state.adopted = true;
2328
+ console.log(`[code-server] adopted running instance pid=${record.pid} port=${adoptPort}(令牌已启用)`);
2329
+ adoptBridgeRuntime(record.pid, state.startedAt);
2330
+ } else {
2331
+ removePidFile(cfg);
2332
+ }
2333
+ } else if (liveInstance) {
2334
+ removePidFile(cfg); // 上次是 dsh 模式(管道随进程消失)→ 清理陈旧记录
2335
+ }
2336
+
2337
+ maybePrestart();
2338
+
2339
+ console.log(`[code-server] static plugin loaded (serve=${requestedServe()} host=${cfg.host} port=${cfg.port === 0 ? '0=随机' : cfg.port})`);
2340
+ }