dsh-code-server-app 0.2.13 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +122 -4
- package/README.md +130 -8
- package/assets/extensions/dshcs-editor-bridge/extension.js +539 -0
- package/assets/extensions/dshcs-editor-bridge/lib/bridge-client.js +241 -0
- package/assets/extensions/dshcs-editor-bridge/lib/context-model.js +204 -0
- package/assets/extensions/dshcs-editor-bridge/lib/diff-model.js +123 -0
- package/assets/extensions/dshcs-editor-bridge/package.json +58 -0
- package/cordis.patch.yml +16 -2
- package/lib/bridge-observe.mjs +184 -0
- package/lib/bridge-session.mjs +124 -0
- package/lib/bridge-tools.mjs +330 -0
- package/lib/bridge.mjs +325 -0
- package/lib/client.js +1 -1
- package/lib/dsh-resolve.mjs +91 -0
- package/lib/index.js +472 -64
- package/lib/launcher.mjs +120 -4
- package/package.json +11 -3
- package/vendor/VENDOR.json +1 -1
package/lib/launcher.mjs
CHANGED
|
@@ -9,12 +9,25 @@
|
|
|
9
9
|
* Module 解析、多处 process.exit),且 node-pty/sqlite 崩溃时必须只带走 IDE、不能带走 DSH host。
|
|
10
10
|
*
|
|
11
11
|
* 用法(由 lib/index.js 调用):node lib/launcher.mjs --tree <树根> --user-data-dir <dir>
|
|
12
|
-
* --extensions-dir <dir> (--port <n> [--host 127.0.0.1]
|
|
12
|
+
* --extensions-dir <dir> (--port <n> [--host 127.0.0.1] [--token-file <path>] [--endpoint-file <path>]
|
|
13
|
+
* | --pipe <name>) [--parent-pid <pid>] [--locale zh-cn]
|
|
13
14
|
* 就绪信号:stdout 打印 `dshcs-ready <productPath> <mode> <addr>`。
|
|
15
|
+
*
|
|
16
|
+
* 安全模型(0.2.14 起):
|
|
17
|
+
* - **随机端口**:host 传 `--port 0`,由系统分配空闲端口,launcher 把实际地址写进 `--endpoint-file`
|
|
18
|
+
* (host 读回)。固定端口在 `port` 配置里显式指定时仍然照用。
|
|
19
|
+
* - **路径令牌**:`--token-file` 给出的随机令牌成为 URL 的**路径前缀**(`http://127.0.0.1:<port>/<token>/…`)。
|
|
20
|
+
* 为什么不走 VS Code 自带的 `connection-token`:它靠 `?tkn=` → 302 + `Set-Cookie: vscode-tkn; SameSite=Lax`,
|
|
21
|
+
* 而本插件的桌面端 iframe 是**跨源**的(dsh-app:// → 127.0.0.1),Lax cookie 在跨站子框架里不会被带上
|
|
22
|
+
* ⇒ 那样会把 desktop 直接打挂。路径前缀不需要 cookie:workbench 的资源与 WS 全部由 `location.pathname`
|
|
23
|
+
* 派生(`serve: dsh` 挂载在 /code-server/ 下已验证同一机制),前缀天然跟随每个子请求与 WS 握手。
|
|
24
|
+
* - **Host 白名单**:回环模式下只接受 `127.0.0.1|localhost|[::1]:<实际端口>`,挡 DNS rebinding
|
|
25
|
+
* (即便请求不带 Origin,originAllowed 也放行不了)。
|
|
26
|
+
* - 令牌只存在于文件与 URL 路径里,**不进 argv、不进日志**。
|
|
14
27
|
*/
|
|
15
28
|
|
|
16
29
|
import { createServer as createHttpServer, request as httpRequest } from 'node:http';
|
|
17
|
-
import { createReadStream, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
30
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
18
31
|
import { connect } from 'node:net';
|
|
19
32
|
import { dirname, extname, join, normalize, resolve, sep } from 'node:path';
|
|
20
33
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -36,6 +49,10 @@ function parseArgs(argv) {
|
|
|
36
49
|
parentPid: null,
|
|
37
50
|
locale: null,
|
|
38
51
|
disableProxy: false,
|
|
52
|
+
/** 路径令牌文件(host 写;回环模式下必给)。 */
|
|
53
|
+
tokenFile: null,
|
|
54
|
+
/** 实际监听地址回报文件(launcher 写;host 读)。 */
|
|
55
|
+
endpointFile: null,
|
|
39
56
|
/** ?v= 缓存击穿标记(host 传 DSHCS_HTML_TAG;为空则每次启动生成一个)。 */
|
|
40
57
|
htmlTag: process.env.DSHCS_HTML_TAG ?? null,
|
|
41
58
|
};
|
|
@@ -47,6 +64,8 @@ function parseArgs(argv) {
|
|
|
47
64
|
else if (a === '--host') out.host = argv[++i];
|
|
48
65
|
else if (a === '--port') out.port = Number(argv[++i]);
|
|
49
66
|
else if (a === '--pipe') out.pipe = argv[++i];
|
|
67
|
+
else if (a === '--token-file') out.tokenFile = argv[++i];
|
|
68
|
+
else if (a === '--endpoint-file') out.endpointFile = argv[++i];
|
|
50
69
|
else if (a === '--parent-pid') out.parentPid = Number(argv[++i]);
|
|
51
70
|
else if (a === '--locale') out.locale = argv[++i];
|
|
52
71
|
else if (a === '--disable-proxy') out.disableProxy = true;
|
|
@@ -63,6 +82,20 @@ const tree = resolve(args.tree);
|
|
|
63
82
|
const serverMain = join(tree, 'lib', 'vscode', 'out', 'server-main.js');
|
|
64
83
|
if (!existsSync(serverMain)) fail(`不是一棵 VS Code 树(缺少 ${serverMain})`);
|
|
65
84
|
if (args.pipe === null && !Number.isFinite(args.port)) fail('必须给 --port 或 --pipe');
|
|
85
|
+
// 端口 0 = 由系统分配空闲端口(host 从 --endpoint-file 读回实际值)
|
|
86
|
+
if (args.pipe === null && (!Number.isInteger(args.port) || args.port < 0 || args.port > 65535)) fail(`--port 非法: ${args.port}`);
|
|
87
|
+
|
|
88
|
+
/** 路径令牌:非空 → 要求 URL 以此为前缀(见文件头"安全模型")。 */
|
|
89
|
+
const TOKEN_RE = /^[0-9A-Za-z_-]{16,128}$/;
|
|
90
|
+
const pathToken = (() => {
|
|
91
|
+
if (args.tokenFile === null) return null;
|
|
92
|
+
let raw;
|
|
93
|
+
try { raw = readFileSync(args.tokenFile, 'utf8').trim(); } catch (error) { fail(`读不到令牌文件 ${args.tokenFile}: ${error.message}`); }
|
|
94
|
+
if (!TOKEN_RE.test(raw)) fail(`令牌文件内容不合法(应为 16~128 位 [0-9A-Za-z_-]): ${args.tokenFile}`);
|
|
95
|
+
return raw;
|
|
96
|
+
})();
|
|
97
|
+
if (args.pipe !== null && pathToken !== null) fail('管道模式下不需要 --token-file(DSH 侧已做鉴权)');
|
|
98
|
+
const prefix = pathToken === null ? null : `/${pathToken}`;
|
|
66
99
|
|
|
67
100
|
const userDataDir = resolve(args.userDataDir ?? join(PACKAGE_ROOT, '.dshcs-data', 'user-data'));
|
|
68
101
|
const extensionsDir = resolve(args.extensionsDir ?? join(userDataDir, '..', 'extensions'));
|
|
@@ -253,6 +286,31 @@ function originAllowed(req) {
|
|
|
253
286
|
return host === origin;
|
|
254
287
|
}
|
|
255
288
|
|
|
289
|
+
/** 回环模式下的 Host 白名单:只接受本机字面量 + **实际**监听端口。
|
|
290
|
+
* 为什么必须:originAllowed() 对"不带 Origin"的请求放行(本地工具/测试需要),而 DNS rebinding
|
|
291
|
+
* 恰恰能构造出不带 Origin 的请求 —— 没有这道检查,一个恶意页面就能用 127.0.0.1 以外的域名
|
|
292
|
+
* 打到本机端口上。端口在 listen 之后才确定,故用变量在请求到达时求值。 */
|
|
293
|
+
let boundPort = Number.isFinite(args.port) ? args.port : 0;
|
|
294
|
+
function hostAllowed(req) {
|
|
295
|
+
if (prefix === null) return true; // 管道模式:Host 由 DSH 的 webServer 决定,已过 requestRejection
|
|
296
|
+
const host = getHostHeader(req);
|
|
297
|
+
if (host === undefined || host === '') return false;
|
|
298
|
+
const m = /^(\[[0-9a-fA-F:.]+\]|[^:]+)(?::(\d+))?$/.exec(host);
|
|
299
|
+
if (m === null) return false;
|
|
300
|
+
const name = m[1].replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
|
|
301
|
+
const port = m[2] === undefined ? 80 : Number(m[2]);
|
|
302
|
+
if (port !== boundPort) return false;
|
|
303
|
+
return name === '127.0.0.1' || name === 'localhost' || name === '::1' || name === String(args.host).toLowerCase();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 校验并剥掉路径令牌前缀。返回 null = 前缀不对(调用方回 404,不泄露"这里是个 IDE")。 */
|
|
307
|
+
function stripToken(url) {
|
|
308
|
+
if (prefix === null) return url;
|
|
309
|
+
if (url === prefix) return '/';
|
|
310
|
+
if (url.startsWith(`${prefix}/`)) return url.slice(prefix.length);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
256
314
|
// ---------------------------------------------------------------- HTTP / WS 分发
|
|
257
315
|
|
|
258
316
|
/** 工作台 HTML 的响应改写:给 workbench.js / workbench.css / nls 资源加 `?v=<tag>` 缓存击穿。
|
|
@@ -310,16 +368,39 @@ function interceptHtmlResponse(res) {
|
|
|
310
368
|
|
|
311
369
|
const server = createHttpServer((req, res) => {
|
|
312
370
|
const url = req.url ?? '/';
|
|
371
|
+
// 令牌只出现在 URL 路径里 —— 别让它经 Referer 漏给站外资源
|
|
372
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
373
|
+
if (!hostAllowed(req)) {
|
|
374
|
+
log(`拒绝 Host 头: host=${req.headers.host ?? '-'} ${req.method} ${url}`);
|
|
375
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
376
|
+
res.end('forbidden');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
// 前缀少了结尾斜杠 → 302 补上(否则文档里的相对引用会从根开始解析)
|
|
380
|
+
if (prefix !== null && url.split('?')[0] === prefix) {
|
|
381
|
+
const query = url.slice(prefix.length);
|
|
382
|
+
res.writeHead(302, { location: `${prefix}/${query.startsWith('?') ? query : ''}` });
|
|
383
|
+
res.end();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const stripped = stripToken(url);
|
|
387
|
+
if (stripped === null) {
|
|
388
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
389
|
+
res.end('not found');
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
req.url = stripped;
|
|
313
393
|
// **按路径匹配,别拿整串比**:客户端加载文档时永远带查询串(`?folder=<cwd>`),
|
|
314
394
|
// 用 `url === '/'` 判断会让改写整条失效(0.3.x 就踩过这个坑);`/healthz`、`/manifest.json`
|
|
315
395
|
// 同理要用路径匹配,否则带查询串的探针会掉到 VS Code 那边变成 404。
|
|
316
|
-
const urlPath =
|
|
396
|
+
const urlPath = stripped.split('?')[0];
|
|
317
397
|
if (urlPath === '/healthz') {
|
|
318
398
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
319
399
|
res.end(JSON.stringify({
|
|
320
400
|
ok: true,
|
|
321
401
|
productPath,
|
|
322
402
|
pid: process.pid,
|
|
403
|
+
port: boundPort,
|
|
323
404
|
mode: args.pipe === null ? 'tcp' : 'pipe',
|
|
324
405
|
htmlTag: args.htmlTag,
|
|
325
406
|
recentUpgrades: recentUpgrades.slice(-3),
|
|
@@ -357,6 +438,17 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
357
438
|
const url = req.url ?? '/';
|
|
358
439
|
recentUpgrades.push(url);
|
|
359
440
|
if (recentUpgrades.length > 10) recentUpgrades.shift();
|
|
441
|
+
if (!hostAllowed(req)) {
|
|
442
|
+
log(`拒绝 Host 头的 WebSocket: host=${req.headers.host ?? '-'} url=${url}`);
|
|
443
|
+
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const stripped = stripToken(url);
|
|
447
|
+
if (stripped === null) {
|
|
448
|
+
socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
req.url = stripped;
|
|
360
452
|
// 跨源防护(与 code-server 一致:只卡 upgrade,HTTP 侧 VS Code 仅接受 GET 且状态变更都走 WS)
|
|
361
453
|
if (!originAllowed(req)) {
|
|
362
454
|
log(`拒绝跨源 WebSocket:origin=${req.headers.origin} host=${req.headers.host ?? '-'} url=${url}`);
|
|
@@ -364,7 +456,7 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
364
456
|
return;
|
|
365
457
|
}
|
|
366
458
|
if (!args.disableProxy) {
|
|
367
|
-
const target = proxyTarget(
|
|
459
|
+
const target = proxyTarget(stripped);
|
|
368
460
|
if (target !== null) { handleProxyUpgrade(req, socket, head, target); return; }
|
|
369
461
|
}
|
|
370
462
|
socket.pause();
|
|
@@ -418,11 +510,35 @@ try {
|
|
|
418
510
|
const listenTarget = args.pipe === null ? { host: args.host, port: args.port } : args.pipe;
|
|
419
511
|
server.on('error', (error) => { fail(`监听失败(${JSON.stringify(listenTarget)}): ${error.message}`); });
|
|
420
512
|
|
|
513
|
+
/** 把**实际**监听地址写给 host(端口 0 时 host 只能从这里知道端口;原子写避免读到半截)。 */
|
|
514
|
+
function writeEndpoint(addr) {
|
|
515
|
+
if (args.endpointFile === null) return;
|
|
516
|
+
const payload = {
|
|
517
|
+
host: addr.address,
|
|
518
|
+
port: addr.port,
|
|
519
|
+
pid: process.pid,
|
|
520
|
+
productPath,
|
|
521
|
+
mode: args.pipe === null ? 'tcp' : 'pipe',
|
|
522
|
+
tokenized: prefix !== null,
|
|
523
|
+
startedAt: Date.now(),
|
|
524
|
+
};
|
|
525
|
+
try {
|
|
526
|
+
const tmp = `${args.endpointFile}.${process.pid}.tmp`;
|
|
527
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
528
|
+
renameSync(tmp, args.endpointFile);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
log(`写 endpoint 文件失败(${args.endpointFile}): ${error.message}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
421
534
|
server.listen(listenTarget, () => {
|
|
422
535
|
const addr = server.address();
|
|
423
536
|
const shown = typeof addr === 'string' ? addr : `${addr.address}:${addr.port}`;
|
|
537
|
+
if (typeof addr === 'object' && addr !== null) boundPort = addr.port;
|
|
538
|
+
if (typeof addr === 'object' && addr !== null) writeEndpoint(addr);
|
|
424
539
|
log(`listening on ${shown} (tree=${tree})`);
|
|
425
540
|
log(`user-data-dir=${userDataDir} extensions-dir=${extensionsDir}`);
|
|
541
|
+
log(`path token: ${prefix === null ? '(无)' : '已启用(URL 前缀,不写日志)'}`);
|
|
426
542
|
console.log(`dshcs-ready ${productPath} ${args.pipe === null ? 'tcp' : 'pipe'} ${shown}`);
|
|
427
543
|
});
|
|
428
544
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-code-server-app",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "VS Code (from a code-server release) inside DSH: a right-sidebar tab driven by the plugin's own launcher over the in-process VS Code server (lib/launcher.mjs). The tab claims DSH file addresses (dsh-resource://file/**) by file type (setting claimExtensions), so the product's own produced-file chips, delivered-file previews and inline prose mentions open in the workbench. The IDE is a resident surface moved with Element.moveBefore instead of being remounted, so switching sidebar tabs no longer reloads it. Opening the tab switches the right sidebar to fullscreen by default (setting fullscreenOnOpen). Following a workspace switch is lightweight: the workbench re-navigates with the new ?folder= and the IDE process is not restarted (since 0.2.12). Requires a DSH with the right-sidebar services (sidebarRightTabs/sidebarRight, >= 0.1.5-alpha.1); older DSH versions get a single upgrade notice on the settings page and no other UI. Two serving modes: loopback port (default) or same-origin mount on DSH's own webServer (/code-server, protected by ctx.connection.requestRejection). No code-server Node layer, no argon2, no C++ toolchain.",
|
|
3
|
+
"version": "0.3.6",
|
|
4
|
+
"description": "VS Code (from a code-server release) inside DSH: a right-sidebar tab driven by the plugin's own launcher over the in-process VS Code server (lib/launcher.mjs). Since 0.3.0 the bundle also ships an editor bridge (assets/extensions/dshcs-editor-bridge): a read-only channel between the in-tree VS Code extension host and DSH, giving the agent what only the editor knows (unsaved buffers, language-server diagnostics, the active selection) and letting editor gestures drive the session. The tab claims DSH file addresses (dsh-resource://file/**) by file type (setting claimExtensions), so the product's own produced-file chips, delivered-file previews and inline prose mentions open in the workbench. The IDE is a resident surface moved with Element.moveBefore instead of being remounted, so switching sidebar tabs no longer reloads it. Opening the tab switches the right sidebar to fullscreen by default (setting fullscreenOnOpen). Following a workspace switch is lightweight: the workbench re-navigates with the new ?folder= and the IDE process is not restarted (since 0.2.12). Requires a DSH with the right-sidebar services (sidebarRightTabs/sidebarRight, >= 0.1.5-alpha.1); older DSH versions get a single upgrade notice on the settings page and no other UI. Two serving modes: loopback port (default) or same-origin mount on DSH's own webServer (/code-server, protected by ctx.connection.requestRejection). No code-server Node layer, no argon2, no C++ toolchain.",
|
|
5
5
|
"homepage": "https://github.com/jinsiyu/dsh-code-server-app",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
"lib/index.js",
|
|
42
42
|
"lib/client.js",
|
|
43
43
|
"lib/claim-types.js",
|
|
44
|
+
"lib/bridge.mjs",
|
|
45
|
+
"lib/bridge-tools.mjs",
|
|
46
|
+
"lib/bridge-session.mjs",
|
|
47
|
+
"lib/bridge-observe.mjs",
|
|
48
|
+
"lib/dsh-resolve.mjs",
|
|
44
49
|
"lib/launcher.mjs",
|
|
45
50
|
"lib/serve-dsh.mjs",
|
|
46
51
|
"lib/vendor.js",
|
|
@@ -48,6 +53,7 @@
|
|
|
48
53
|
"assets/favicon.ico",
|
|
49
54
|
"assets/favicon.svg",
|
|
50
55
|
"assets/extensions/dshcs-open-file",
|
|
56
|
+
"assets/extensions/dshcs-editor-bridge",
|
|
51
57
|
"scripts/vendor-vscode-server.mjs",
|
|
52
58
|
"scripts/vendor-code-server.mjs",
|
|
53
59
|
"scripts/vendor-repacks.mjs",
|
|
@@ -117,6 +123,8 @@
|
|
|
117
123
|
"test:launcher-routes": "node scripts/test-launcher-routes.mjs",
|
|
118
124
|
"test:fullscreen": "node scripts/test-sidebar-fullscreen.mjs",
|
|
119
125
|
"test:claim-types": "node scripts/test-claim-types.mjs",
|
|
120
|
-
"test:workspace-switch": "node scripts/test-workspace-switch.mjs"
|
|
126
|
+
"test:workspace-switch": "node scripts/test-workspace-switch.mjs",
|
|
127
|
+
"test:bridge-routes": "node scripts/test-bridge-routes.mjs",
|
|
128
|
+
"test:bridge-extension": "node scripts/test-bridge-extension.mjs"
|
|
121
129
|
}
|
|
122
130
|
}
|
package/vendor/VENDOR.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"vscodeVersion": "1.137.0",
|
|
4
4
|
"productPath": "stable-b11dabdaca0d3369986975be285db92c8795cea5",
|
|
5
5
|
"layout": "vscode-only",
|
|
6
|
-
"preparedAt": "2026-09-
|
|
6
|
+
"preparedAt": "2026-09-11T15:40:32.321Z",
|
|
7
7
|
"source": "registry",
|
|
8
8
|
"node": "v24.13.1",
|
|
9
9
|
"platform": "win32",
|