dsh-code-server-app 0.3.12 → 0.3.13

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 CHANGED
@@ -213,36 +213,43 @@ only the editor knows, and lets editor gestures drive the current session.
213
213
  every start, so **the bridge never reported any state**. To turn the bridge off use the plugin setting
214
214
  `editorBridge=false` (no mount, no tools) rather than uninstalling the extension from the Extensions view.
215
215
 
216
- ### The channels (since 0.3.9 they live on DSH's webServer under `/code-server-bridge`)
216
+ ### The channels (since 0.3.13 over **local IPC**: a Windows named pipe / unix socket)
217
217
 
218
218
  ```
219
219
  extension → host POST /code-server-bridge/sync one round trip: push editor state + take pending events
220
220
  extension → host POST /code-server-bridge/ask push an editor question into the current session
221
221
  extension → host GET /code-server-bridge/health unauthenticated liveness probe
222
222
  extension → host POST /code-server-bridge/event extension reports open/close etc. (host log tail)
223
- host → extension <extensionsDir>/.dshcs-bridge/bridge.json base URL + token, re-read by the extension every 5s
223
+ host → extension <extensionsDir>/.dshcs-bridge/bridge.json endpoint + token, re-read every 5s
224
224
  (the directory is announced via the host-injected `DSHCS_EXTENSIONS_DIR` — the extension lives in
225
225
  the built-in tree now, so it cannot derive it from its own path)
226
226
  ```
227
227
 
228
- > **Why not under `/api` (fixed in 0.3.9)**: Connection puts a Host/Origin/cookie fence on `/api`
229
- > (`requestRejection` in `packages/client/connection/src/index.ts` → 401 without a cookie), while the bridge's
230
- > client is a **Node process inside the extension host** — it can never hold a browser cookie, so its requests were
231
- > rejected before ever reaching the plugin's route. Measured on 0.3.7: the extension polled
232
- > `/api/code-server/bridge/sync` and got either 405 (it reached the launcher/VS Code instead) or 401 (the /api
233
- > fence) the bridge had never actually synced. It now mounts on DSH's own webServer with its own token as the
234
- > only gate. The cost: the bridge needs DSH to provide `webServer` **the web profile has it, desktop does not**.
235
- > On desktop the host writes no `bridge.json` (dormant beats pointing at a dead address) and says so in the log;
236
- > **file opening is unaffected** (it uses the signal file and works in every mode).
228
+ Requests use `http.request({ socketPath })` (`fetch` has no socket support) and **no port is ever opened**.
229
+
230
+ > **Why not HTTP (settled in 0.3.13, all three measured)**
231
+ > 1. **Desktop has no HTTP surface at all**: the renderer calls `host.fetch()` through Electron IPC
232
+ > (`createSharedFetchHandler('/api')` in `apps/desktop-host/src/index.ts:308`) an in-process call, unreachable
233
+ > from another process; the only HTTP a plugin can mount is the web profile's `webServer`.
234
+ > 2. **`/api` cannot carry it either**: Connection puts a Host/Origin/cookie fence on `/api`
235
+ > (`requestRejection` in `packages/client/connection/src/index.ts` 401 without a cookie), while the bridge's
236
+ > client is a **Node process inside the extension host** it can never hold a browser cookie. Measured on 0.3.7:
237
+ > polling `/api/code-server/bridge/sync` returned either 405 (it reached the launcher/VS Code) or 401 (the fence)
238
+ > — the bridge had never actually synced.
239
+ > 3. The two ends are **processes on the same machine** anyway (extension host ← the IDE the plugin spawned ← the
240
+ > plugin). Local IPC is strictly smaller than a port: no network surface, no Host/Origin confused-deputy path, and
241
+ > **web and desktop share one path**. Token auth stays (see below); the Windows pipe name carries a random suffix
242
+ > and the POSIX socket file is `chmod 0600`.
243
+ >
244
+ > History: 0.3.9–0.3.12 mounted it on DSH's `webServer` prefix — which left desktop permanently dormant.
237
245
 
238
246
  **Why state is pushed, not pulled**: the extension host is a child process of the VS Code server and **listens on
239
247
  no port** — the host cannot call into it. Editor state therefore rides the extension's own polling request, and
240
248
  the host caches it for the tools (at most one 600 ms cycle behind; older than 10 s and the tool says so instead
241
249
  of passing stale data off as fresh).
242
250
 
243
- **Why no SSE/WebSocket**: the extension host has no HTTP server of its own, and everything DSH can offer is
244
- request/response (the Connection fetch channel allows only `GET | HEAD | POST`; streaming would need the WS mux
245
- already owned by `dsh-api-gateway`). Polling also buys two useful properties: it is idempotent (a dropped event
251
+ **Why no SSE/WebSocket**: the extension host has no HTTP server of its own; the bridge's shape is one
252
+ request/response round trip every 600 ms. Polling also buys two useful properties: it is idempotent (a dropped event
246
253
  only costs one notification — the data always lives in the editor) and the cached state is inherently fresh.
247
254
 
248
255
  ### Security model (four invariants; read before touching `lib/bridge.mjs`)
@@ -592,7 +599,7 @@ Host/Origin fence and browser auth); in the desktop profile `apps/desktop-host`
592
599
  | POST | `/api/code-server/stop` | Stop and recycle the process tree |
593
600
  | POST | `/api/code-server/setup` | **Compatibility no-op**: since 0.1.36 dependencies are installed by the package manager, so this only re-runs the env self-check and returns |
594
601
  | POST | `/api/code-server/open-file` | body `{ file }` — writes the signal consumed by the built-in `dshcs-open-file` extension to open the file in code-server |
595
- | GET | `/code-server-bridge/health` | editor-bridge liveness (**unauthenticated**; no editor data). Mounted on DSH's webServer, not under `/api` |
602
+ | GET | `/code-server-bridge/health` | editor-bridge liveness (**unauthenticated**; no editor data). Runs over **local IPC** (named pipe / unix socket), not under `/api`, and needs no `webServer` |
596
603
  | POST | `/code-server-bridge/sync` | editor bridge: the extension pushes state (`{context, diagnostics, workspace, at}`) and takes back events; `?since=<seq>` is the event cursor. Requires `x-dshcs-bridge-token`, and **any Origin header is 403** |
597
604
  | POST | `/code-server-bridge/ask` | editor bridge: push an editor question into the current session (`{text, file?, lineStart?, lineEnd?, selection?, languageId?}`); **409** when no session can receive it |
598
605
  | POST | `/code-server-bridge/event` | editor bridge: extension reports open/close and similar (host log tail). Requires the token |
@@ -610,7 +617,11 @@ Host/Origin fence and browser auth); in the desktop profile `apps/desktop-host`
610
617
  - The right-sidebar tab, guide entry box, file-address claim, and settings card behave the same as in web (code-server remains an
611
618
  iframe to the local `http://127.0.0.1:<port>`; the desktop renderer uses `webSecurity: true` with no CSP, so the cross-origin iframe loads).
612
619
  The desktop build ships `dsh-client-ui-sidebar-right` in its seed package set as well, so the 0.2.3 "right-sidebar DSH only" rule is
613
- not a regression for desktop; the only difference is the missing `webServer`, where `serve: dsh` falls back to loopback.
620
+ not a regression for desktop; the only difference is the missing `webServer`, where `serve: dsh` falls back to loopback (that path
621
+ genuinely needs a webServer).
622
+ - **The editor bridge works on desktop since 0.3.13**: it runs over local IPC (named pipe) and does not involve `webServer` at all —
623
+ the extension host is a child of the IDE the plugin itself spawned, so both ends are on the same machine. The host injects
624
+ `DSHCS_EXTENSIONS_DIR`, the extension finds `bridge.json`, and `/status` reports `bridge.supported=true` with the pipe name.
614
625
  - Install into the desktop profile through the **desktop plugin manager** (not the CLI, see below).
615
626
  - **Desktop installs face a 24-hour supply-chain policy (measured 2026-09-10; this is how 0.2.4 got installed)**:
616
627
  - the CLI path is unavailable: `dsh plugin --profile desktop …` is rejected (*"profile "desktop" is managed exclusively by the
@@ -671,13 +682,11 @@ What remains on the plugin side:
671
682
 
672
683
  ## Known limitations
673
684
 
674
- - **The editor bridge needs DSH to provide `webServer`** (corrected in 0.3.9): its client is a Node process inside
675
- the extension host, which can only reach DSH over HTTP at DSH's own origin (the bridge mounts under
676
- `BRIDGE_BASE` with its own token). **The web profile has webServer (both `serve: dsh` and loopback) → the bridge
677
- works; desktop has none it stays disabled** (`bridge.supported=false`, no `bridge.json` is written, one log
678
- line explains it). **File opening is unaffected**: it uses the signal file and works regardless of mode.
679
- Up to 0.3.7 the bridge was registered under `/api/code-server/bridge/*` and was killed by Connection's cookie
680
- fence (401) — that was a bug.
685
+ - **~~The editor bridge needs DSH to provide `webServer`~~ no longer true (fixed in 0.3.13)**: the bridge now runs
686
+ over **local IPC** (Windows named pipe / unix socket via `http.request({ socketPath })`), so **web and desktop share
687
+ one path**, with no `webServer` and no open port. History: 0.3.9–0.3.12 mounted it under DSH's webServer prefix
688
+ (⇒ desktop stayed dormant); up to 0.3.7 it was registered under `/api/code-server/bridge/*` and was killed by
689
+ Connection's cookie fence (401). **File opening** was never affected (it uses the signal file).
681
690
  - **`/code-server-bridge/health`'s `bridge` field does not mean the extension is running** (clarified in 0.3.12):
682
691
  it only says the bridge *target* is configured. Whether the extension actually runs shows up in the exthost log
683
692
  or by simply calling `editor_context` — 0.3.0–0.3.11 sat in the state "health says bridge:true, extension never
package/README.md CHANGED
@@ -208,34 +208,40 @@ DSH 用**资源地址**命名文件,`openFile` 只负责把地址交给右侧栏
208
208
  并永远跳过它 —— 每一轮启动都再标一次,**桥因此从来没有上报过状态**。
209
209
  要关掉桥请用插件设置 `editorBridge=false`(不挂桥、不注册工具),不要再指望在扩展视图里卸载它。
210
210
 
211
- ### 三条通道(0.3.9 起走 DSH webServer `/code-server-bridge`)
211
+ ### 三条通道(0.3.13 起走**本机 IPC**:Windows 命名管道 / unix socket)
212
212
 
213
213
  ```
214
214
  扩展 → host POST /code-server-bridge/sync 一趟来回:上报编辑器状态 + 取回待处理事件
215
215
  扩展 → host POST /code-server-bridge/ask 把编辑器里的提问投进当前会话
216
216
  扩展 → host GET /code-server-bridge/health 无鉴权探活(便于重启后一眼确认)
217
217
  扩展 → host POST /code-server-bridge/event 扩展上报打开/关闭文件等(进 host 日志尾)
218
- host → 扩展 <extensionsDir>/.dshcs-bridge/bridge.json base URL + 令牌(扩展每 5s 重读)
218
+ host → 扩展 <extensionsDir>/.dshcs-bridge/bridge.json 端点 + 令牌(扩展每 5s 重读)
219
219
  (目录由 host 注入的 `DSHCS_EXTENSIONS_DIR` 告知 —— 扩展装在内置目录里,自己推不出来)
220
220
  ```
221
221
 
222
- > **为什么不在 `/api`(0.3.9 修正)**:Connection 给 `/api` 装了 Host/Origin/cookie fence
223
- > (`packages/client/connection/src/index.ts` 里 `requestRejection` → 无 cookie 即 401),而桥的客户端
224
- > 是扩展宿主里的 **Node 进程** —— 它永远拿不到浏览器 cookie,请求在到达插件路由之前就被挡掉了。
225
- > 实测(0.3.7):扩展按 `/api/code-server/bridge/sync` 轮询,要么 405(打到 launcher/VS Code)
226
- > 要么 401(打到 DSH 的 /api fence),**桥从来没有真正同步过**。
227
- > 现在桥挂在 DSH 自己的 webServer 前缀下,鉴权完全由桥自己的令牌承担(见下)。
228
- > 代价:桥需要 DSH 提供 `webServer` —— **web profile 有,desktop 没有**。
229
- > desktop host 不写 `bridge.json`(宁可休眠,不可指向死地址),并在日志里说明;
230
- > **文件打开不受影响**(它走信号文件,与 serve 模式无关)
222
+ 请求走 `http.request({ socketPath })`(`fetch` 不支持 socket),**不开任何端口**。
223
+
224
+ > **为什么不是 HTTP(0.3.13 定论,三条都实测过)**
225
+ > 1. **desktop 根本没有 HTTP 面**:渲染进程经 Electron IPC 调 `host.fetch()`
226
+ > (`apps/desktop-host/src/index.ts:308``createSharedFetchHandler('/api')`)—— 那是进程内函数调用,
227
+ > 进程外不可达;插件能挂 HTTP 的只有 web profile 的 `webServer`。
228
+ > 2. **`/api` 也不行**:Connection 给 `/api` 装了 Host/Origin/cookie fence
229
+ > (`packages/client/connection/src/index.ts` `requestRejection` cookie 即 401),而桥的客户端
230
+ > 是扩展宿主里的 **Node 进程** —— 它永远拿不到浏览器 cookie。实测(0.3.7):扩展按
231
+ > `/api/code-server/bridge/sync` 轮询,要么 405(打到 launcher/VS Code)、要么 401(打到 /api fence),
232
+ > **桥从来没有真正同步过**。
233
+ > 3. 桥的两端本来就是**同一台机器上的两个进程**(扩展宿主 ← 插件 spawn 的 IDE ← 插件)。
234
+ > 本机 IPC 比开端口更小:没有网络面、没有 Host/Origin 混淆代理问题,**web 与 desktop 走同一条路**。
235
+ > 令牌校验照旧保留(见下),Windows 管道名带随机后缀、POSIX socket 文件 `chmod 0600`。
236
+ >
237
+ > 历史:0.3.9–0.3.12 挂在 DSH 的 `webServer` 前缀下 —— 于是 desktop 永远休眠(没有 webServer)。
231
238
 
232
239
  **为什么状态是"推"而不是"拉"**:扩展宿主是 VS Code server 的一个子进程,**不监听任何端口** ——
233
240
  host 反向请求不到它。所以编辑器状态只能在扩展主动发起的那趟轮询里带上来,host 缓存后给工具读
234
241
  (缓存滞后最多一个轮询周期 600ms,超过 10s 没更新就判为过期,工具会明说"状态已过期");
235
242
 
236
- **为什么不用 SSE/WebSocket**:扩展宿主里没有 HTTP 服务器,而 DSH 侧能给的无非是请求/响应
237
- (Connection 的 fetch 通道只允许 `GET | HEAD | POST`,流式要另走已被 `dsh-api-gateway` 占用的 WS mux)。
238
- 轮询反而给了两条好性质:幂等(丢一次事件只是少一次提示,数据本身永远在编辑器里),以及状态天然最新(每趟都刷新)。
243
+ **为什么不用 SSE/WebSocket**:扩展宿主里没有 HTTP 服务器,而桥的形态是"每 600ms 一趟请求/响应"。
244
+ 轮询给了两条好性质:幂等(丢一次事件只是少一次提示,数据本身永远在编辑器里),以及状态天然最新(每趟都刷新)
239
245
 
240
246
  ### 安全模型(四条不变量,改 `lib/bridge.mjs` 之前先读)
241
247
 
@@ -593,13 +599,14 @@ desktop profile 由 `apps/desktop-host` 把 `/api/*` 交给同一个 `createShar
593
599
  | POST | `/api/code-server/stop` | 停止并回收进程树 |
594
600
  | POST | `/api/code-server/setup` | **兼容空操作**:0.1.36 起依赖由包管理器安装,调用只重新自检 `env` 并返回 |
595
601
  | POST | `/api/code-server/open-file` | body `{ file }` — 写信号文件,由内置扩展 `dshcs-open-file` 在 code-server 中打开 |
596
- | GET | `/code-server-bridge/health` | 编辑器桥探活(**无鉴权**;只回答"桥活着吗",不含任何编辑器数据)。挂 DSH webServer,不在 `/api` |
602
+ | GET | `/code-server-bridge/health` | 编辑器桥探活(**无鉴权**;只回答"桥活着吗",不含任何编辑器数据)。走**本机 IPC**(命名管道 / unix socket),不在 `/api` 下、也不需要 `webServer` |
597
603
  | POST | `/code-server-bridge/sync` | 编辑器桥:扩展上报状态(`{context, diagnostics, workspace, at}`)并取回事件;`?since=<seq>` 是事件游标。需 `x-dshcs-bridge-token`,**带 Origin 一律 403** |
598
604
  | POST | `/code-server-bridge/ask` | 编辑器桥:把编辑器里的提问投进当前会话(`{text, file?, lineStart?, lineEnd?, selection?, languageId?}`);没有可投递的会话时回 **409** |
599
605
  | POST | `/code-server-bridge/event` | 编辑器桥:扩展上报打开/关闭文件等(进 host 日志尾)。需令牌 |
600
606
 
601
607
  > 桥的四条路由都自带令牌鉴权(它们**不依赖** DSH 的 cookie fence —— 扩展宿主拿不到浏览器 cookie),
602
- > 且永远只读。这也是它们**不能**挂在 `/api` 下的原因(见「与 DSH 的协同」)。
608
+ > 且永远只读。传输是本机 IPC(0.3.13 起),所以 **web 与 desktop 同一套**:
609
+ > 端点由 host 写在 `bridge.json` 的 `pipe` 字段里,扩展用 `http.request({ socketPath })` 访问。
603
610
 
604
611
  > 除桥之外,插件不再注册任何插件自有 HTTP 路由;code-server 图标已内联为 data URI(client bundle 内),
605
612
  > 因此客户端不请求任何插件自有 HTTP 资源。
@@ -611,7 +618,10 @@ desktop profile 由 `apps/desktop-host` 把 `/api/*` 交给同一个 `createShar
611
618
  - 右侧栏标签、guide 入口框、文件地址认领、设置卡片在 desktop 下与 web 相同(code-server 仍是本机 `http://127.0.0.1:<port>` 的 iframe;
612
619
  桌面端 `webSecurity: true` 且页面无 CSP 限制,跨源 iframe 正常加载)。
613
620
  桌面端同样自带 `dsh-client-ui-sidebar-right`(见 desktop 构建 seed 包列表),因此 0.2.3 的
614
- "只支持带右侧栏的 DSH" 对 desktop 不构成降级;唯一差别是 desktop 无 `webServer`,`serve: dsh` 会自动回退 loopback。
621
+ "只支持带右侧栏的 DSH" 对 desktop 不构成降级;`serve: dsh` 会自动回退 loopback(那条路确实需要 webServer)
622
+ - **编辑器桥在 desktop 下可用(0.3.13 起)**:桥走本机 IPC(命名管道),与 `webServer` 无关 ——
623
+ 扩展宿主是插件自己 spawn 的 IDE 的子进程,两端都在同一台机器上。host 注入 `DSHCS_EXTENSIONS_DIR` 后
624
+ 扩展即可找到 `bridge.json`;`/status` 的 `bridge.supported/endpoint` 在 desktop 下同样是 `true`/管道名。
615
625
  - 安装到 desktop profile:桌面端插件管理窗(**不是** CLI,见下)。
616
626
  - **桌面端安装的 24 小时供应链策略(实测,2026-09-10,已用它装上 0.2.4)**:
617
627
  - CLI 路径不可用:`dsh plugin --profile desktop …` 会被拒绝(*"profile "desktop" is managed exclusively by the Electron application"*),
@@ -651,12 +661,11 @@ desktop profile 由 `apps/desktop-host` 把 `/api/*` 交给同一个 `createShar
651
661
 
652
662
  ## 已知限制
653
663
 
654
- - **编辑器桥需要 DSH 提供 `webServer`**(0.3.9 修正):桥的客户端是扩展宿主里的 Node 进程,
655
- 它只能通过 HTTP 打到 DSH 自己的 origin(桥挂在 `BRIDGE_BASE` 前缀下,自带令牌鉴权)。
656
- **web profile(`serve: dsh` loopback 都行)有 webServer 桥可用;desktop 没有 → 不启用**
657
- (status `bridge.supported=false`,host 不写 `bridge.json`,日志里说明一次)。
658
- **文件打开不受影响**:它走信号文件,与 serve 模式和 webServer 都无关。
659
- 0.3.7 及以前把桥挂在 `/api/code-server/bridge/*`,被 Connection 的 cookie fence 401 挡死 —— 那是个 bug。
664
+ - **~~编辑器桥需要 DSH 提供 `webServer`~~ 已不成立(0.3.13 修正)**:桥改走**本机 IPC**
665
+ (Windows 命名管道 / unix socket,`http.request({ socketPath })`),**web 与 desktop 同一套**,
666
+ 不需要 `webServer`、也不开端口。历史:0.3.9–0.3.12 挂在 DSH webServer 前缀下 desktop 永远休眠;
667
+ 0.3.7 及以前挂在 `/api/code-server/bridge/*` 被 Connection 的 cookie fence 401 挡死。
668
+ **文件打开**从来不受影响(它走信号文件)。
660
669
  - **`/code-server-bridge/health` 的 `bridge` 字段不代表扩展在跑**(0.3.12 澄清):它只表示"桥的目标已就绪"。
661
670
  扩展是否真的在跑,看 exthost 日志里有没有它的激活记录,或直接用 `editor_context` 试一次 ——
662
671
  0.3.0–0.3.11 就是"health 说 bridge:true、扩展却从没被加载"的状态(原因见上:用户级安装被标 `.obsolete`)。
@@ -353,7 +353,7 @@ async function pollOnce() {
353
353
  }
354
354
  if (!connected) {
355
355
  connected = true;
356
- log(`已连接宿主 ${client.config.url}`);
356
+ log(`已连接宿主(${client.config.pipe})`);
357
357
  updateStatusBar();
358
358
  }
359
359
  lastPollAt = Date.now();
@@ -378,7 +378,7 @@ function updateStatusBar() {
378
378
  if (statusBar === null) return;
379
379
  if (connected && client !== null && client.config !== null) {
380
380
  statusBar.text = '$(plug) DSH';
381
- statusBar.tooltip = `编辑器桥已连接:${client.config.url}\n上次轮询:${lastPollAt === 0 ? '—' : new Date(lastPollAt).toLocaleTimeString()}\n点击查看日志`;
381
+ statusBar.tooltip = `编辑器桥已连接:${client.config.pipe}\n上次轮询:${lastPollAt === 0 ? '—' : new Date(lastPollAt).toLocaleTimeString()}\n点击查看日志`;
382
382
  statusBar.command = 'dsh-code-server.showBridgeLog';
383
383
  statusBar.show();
384
384
  } else {
@@ -464,7 +464,7 @@ function activate(context) {
464
464
  log('未找到桥配置(休眠)。DSH 插件启用编辑器桥并启动 IDE 后,这里会自动连上。');
465
465
  } else {
466
466
  client.restore();
467
- log(`发现桥配置:${client.config.url}`);
467
+ log(`发现桥配置:${client.config.pipe}`);
468
468
  }
469
469
 
470
470
  // host 请求时才现算,这里只维护"上次计数",用于日志与将来的变化上报。
@@ -511,9 +511,9 @@ function activate(context) {
511
511
  // 定期重读配置(令牌/端口轮换后最多 CONFIG_REREAD_MS 恢复)。
512
512
  const refreshTimer = setInterval(() => {
513
513
  if (client === null) return;
514
- const before = client.config === null ? null : client.config.url;
514
+ const before = client.config === null ? null : client.config.pipe;
515
515
  const next = client.refresh();
516
- const after = next === null ? null : next.url;
516
+ const after = next === null ? null : next.pipe;
517
517
  if (before !== after) {
518
518
  log(`桥目标变化:${before ?? '(休眠)'} → ${after ?? '(休眠)'}`);
519
519
  if (after !== null) client.restore();
@@ -6,36 +6,54 @@
6
6
  //
7
7
  // 三条通道里属于扩展的两条:
8
8
  // 1. 读 `<extensionsDir>/.dshcs-bridge/bridge.json` —— host 写,扩展**每次请求前重读**
9
- // (host 重启会让端口与令牌轮换,而 IDE 进程可能被 adopt 继续活着,env 方案跟不上);
9
+ // (host 重启会让端点与令牌轮换,而 IDE 进程可能被 adopt 继续活着,env 方案跟不上);
10
10
  // 2. `POST <BRIDGE_BASE>/sync?since=N` —— **一趟来回同时做两件事**:
11
11
  // 把编辑器状态(活动文件/脏缓冲区/诊断)推给 host,并取回 host 推来的 agent 改动提示。
12
12
  // 为什么合并:扩展宿主里没有 HTTP 服务器,host 反向请求不到它,状态只能由扩展推上来;
13
13
  // 而轮询本来就在跑,合并成一个请求就省掉了第二个定时器与一次往返。
14
14
  // 带着 x-dshcs-bridge-token 头。
15
+ //
16
+ // **传输是本机 IPC(0.3.13 起)**:配置里的 `pipe` 是 Windows 命名管道名或 unix socket 路径,
17
+ // 请求走 `http.request({ socketPath })`。不走 HTTP 的三个原因(都实测过):desktop 没有 HTTP 面
18
+ // (渲染进程经 Electron IPC 调 host.fetch)、`/api` 有浏览器 cookie fence(扩展宿主拿不到 cookie)、
19
+ // DSH 的 webServer 前缀只有 web profile 有。详见 lib/bridge-ipc.mjs 与 docs 第 19 节。
15
20
 
16
21
  'use strict';
17
22
 
18
23
  const fs = require('fs');
19
24
  const path = require('path');
25
+ const http = require('http');
20
26
 
21
27
  /** 与 host 侧 lib/bridge.mjs 的常量保持一致(改动必须两边同步;scripts/test-bridge-extension.mjs
22
28
  * 里有一条一致性断言,会把两边的字面量放在一起比)。 */
23
29
  const BRIDGE_DIRNAME = '.dshcs-bridge';
24
30
  const BRIDGE_FILENAME = 'bridge.json';
25
- /** 桥的挂载前缀。**故意不在 `/api` 下**:那层有 Connection cookie fence,扩展宿主(Node 进程)
26
- * 拿不到浏览器 cookie,请求会在到达插件路由之前被 401(0.3.9 修正)。 */
31
+ /** 本机 IPC 上的路由前缀(不是 URL 前缀;语义见 lib/bridge.mjs)。 */
27
32
  const BRIDGE_BASE = '/code-server-bridge';
28
33
  const TOKEN_HEADER = 'x-dshcs-bridge-token';
29
34
  const STATE_FILENAME = 'extension-state.json';
30
35
  const REQUEST_TIMEOUT_MS = 3000;
31
36
  /** 轮询间隔:host 侧事件只是"去看一眼这个文件"的提示,600ms 足够且几乎无开销。 */
32
37
  const POLL_INTERVAL_MS = 600;
33
- /** 宿主配置重读间隔(令牌/端口轮换后最多这么久恢复)。 */
38
+ /** 宿主配置重读间隔(端点/令牌轮换后最多这么久恢复)。 */
34
39
  const CONFIG_REREAD_MS = 5000;
35
40
 
41
+ /** Windows 命名管道的统一前缀(`\\.\pipe\` 是本机命名空间)。 */
42
+ const WIN_PIPE_PREFIX = '\\\\.\\pipe\\';
36
43
 
37
44
  const TOKEN_RE = /^[0-9A-Za-z_-]{16,128}$/;
38
45
 
46
+ /** 端点形状校验:Windows 必须是命名管道名,其它平台必须像绝对路径。
47
+ * (与 host 侧 lib/bridge-ipc.mjs 的 `isBridgeEndpoint` 同一条判定,两边各有实现、
48
+ * 一致性由 scripts/test-bridge-extension.mjs 的断言钉住 —— 扩展是随包分发的静态文件,
49
+ * 不能 import host 的代码。) */
50
+ function isBridgeEndpoint(value) {
51
+ if (typeof value !== 'string' || value === '') return false;
52
+ if (process.platform === 'win32') return value.startsWith(WIN_PIPE_PREFIX);
53
+ return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value);
54
+ }
55
+
56
+
39
57
  /** 桥配置所在目录(=`<extensionsDir>/.dshcs-bridge`)。
40
58
  *
41
59
  * 取值顺序(0.3.12 修正 —— 这里以前写错了,是"桥永远休眠"的另一半原因):
@@ -67,15 +85,15 @@ function stateFile(extensionsDir) {
67
85
 
68
86
  /**
69
87
  * 读桥配置。
70
- * @returns {{url: string, token: string, pid: number|null}|null} null = 未配置/格式不对 → 休眠
88
+ * @returns {{pipe: string, token: string, pid: number|null}|null} null = 未配置/格式不对 → 休眠
71
89
  */
72
90
  function readBridgeConfig(extensionsDir) {
73
91
  try {
74
92
  const raw = JSON.parse(fs.readFileSync(bridgeFile(extensionsDir), 'utf8'));
75
93
  if (raw === null || typeof raw !== 'object') return null;
76
- if (typeof raw.url !== 'string' || !/^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d+$/.test(raw.url)) return null;
94
+ if (!isBridgeEndpoint(raw.pipe)) return null;
77
95
  if (!TOKEN_RE.test(String(raw.token))) return null;
78
- return { url: raw.url, token: String(raw.token), pid: Number.isSafeInteger(raw.pid) ? raw.pid : null };
96
+ return { pipe: String(raw.pipe), token: String(raw.token), pid: Number.isSafeInteger(raw.pid) ? raw.pid : null };
79
97
  } catch {
80
98
  return null;
81
99
  }
@@ -108,22 +126,68 @@ function writeState(extensionsDir, state) {
108
126
 
109
127
  /** host 侧拒绝时的统一错误(带 status,便于区分 401/503/403)。 */
110
128
  class BridgeError extends Error {
111
- constructor(message, status) {
129
+ constructor(message, status, code) {
112
130
  super(message);
113
131
  this.name = 'BridgeError';
114
132
  this.status = status;
133
+ /** 底层 IO 错误码(EPERM/ENOENT/ECONNREFUSED…);状态栏与日志据此区分"权限/不存在/没在跑"。 */
134
+ this.code = code;
115
135
  }
116
136
  }
117
137
 
138
+ /**
139
+ * 默认传输:本机 IPC(Windows 命名管道 / unix socket)上的一个 HTTP 请求。
140
+ *
141
+ * 不用 `fetch`:WHATWG fetch 不支持 `socketPath`,而桥**必须**绕开网络栈
142
+ * (desktop 没有 HTTP 面;web 上走 HTTP 则要么撞 cookie fence、要么依赖 webServer)。
143
+ * 用 `http.request({socketPath})` 是 Node 里唯一干净的写法 —— 与 host 侧
144
+ * `lib/launcher.mjs` 的 `--pipe`、`lib/index.js` 的 `healthCheckPipe` 同一套。
145
+ *
146
+ * @param {{socketPath: string, path: string, method: string, headers: object,
147
+ * body: string|null, timeoutMs: number}} options
148
+ * @returns {Promise<{status: number, json: unknown}>}
149
+ */
150
+ function defaultRequest({ socketPath, path: routePath, method, headers, body, timeoutMs }) {
151
+ return new Promise((resolve, reject) => {
152
+ const payload = typeof body === 'string' && body !== '' ? Buffer.from(body, 'utf8') : null;
153
+ const req = http.request({
154
+ socketPath,
155
+ path: routePath,
156
+ method,
157
+ headers: payload === null ? headers : Object.assign({ 'content-length': String(payload.length) }, headers),
158
+ timeout: timeoutMs,
159
+ }, (res) => {
160
+ const chunks = [];
161
+ res.on('data', (chunk) => { chunks.push(chunk); });
162
+ res.on('error', reject);
163
+ res.on('end', () => {
164
+ const text = Buffer.concat(chunks).toString('utf8');
165
+ let json = null;
166
+ try {
167
+ json = text === '' ? null : JSON.parse(text);
168
+ } catch {
169
+ json = null;
170
+ }
171
+ resolve({ status: res.statusCode === undefined ? 0 : res.statusCode, json });
172
+ });
173
+ });
174
+ req.on('timeout', () => { req.destroy(new Error(`请求超时(${timeoutMs}ms)`)); });
175
+ req.on('error', reject);
176
+ if (payload !== null) req.write(payload);
177
+ req.end();
178
+ });
179
+ }
180
+
118
181
  /**
119
182
  * 造一个桥客户端。
120
183
  *
121
- * @param {{extensionsDir?: string, fetchImpl?: Function, now?: Function}} [options]
122
- * `fetchImpl` / `now` 可注入,便于单测(默认用 Node 18+ 的全局 fetch)。
184
+ * @param {{extensionsDir?: string, requestImpl?: Function, now?: Function}} [options]
185
+ * `requestImpl` / `now` 可注入,便于单测(默认走 `http.request({socketPath})`)
123
186
  */
124
187
  function createClient(options) {
125
188
  const extensionsDir = options && options.extensionsDir !== undefined ? options.extensionsDir : undefined;
126
- const fetchImpl = (options && options.fetchImpl) || ((...args) => fetch(...args));
189
+ /** 传输:本机 IPC 上的一个 HTTP 请求。注入点只在测试里用。 */
190
+ const requestImpl = (options && options.requestImpl) || defaultRequest;
127
191
  /** 当前配置(null = 休眠)。 */
128
192
  let config = null;
129
193
  /** 上次读配置的时间(避免每 600ms 都碰磁盘)。 */
@@ -136,8 +200,8 @@ function createClient(options) {
136
200
  if (!force && config !== null && now - configReadAt < CONFIG_REREAD_MS) return config;
137
201
  configReadAt = now;
138
202
  const next = readBridgeConfig(extensionsDir);
139
- if (next !== null && config !== null && next.url !== config.url) {
140
- // 端口/实例变了:游标失去意义(旧实例的事件不该在新实例上重放)。
203
+ if (next !== null && config !== null && next.pipe !== config.pipe) {
204
+ // 端点/实例变了:游标失去意义(旧实例的事件不该在新实例上重放)。
141
205
  since = 0;
142
206
  }
143
207
  if (next !== null && config !== null && next.pid !== config.pid) since = 0;
@@ -150,24 +214,21 @@ function createClient(options) {
150
214
  const current = refreshConfig(true);
151
215
  if (current === null) throw new BridgeError('编辑器桥未配置(休眠中)', 0);
152
216
  const headers = Object.assign({ [TOKEN_HEADER]: current.token }, (init && init.headers) || {});
153
- const controller = new AbortController();
154
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
155
217
  let response;
156
218
  try {
157
- response = await fetchImpl(current.url + route, Object.assign({}, init, { headers, signal: controller.signal }));
219
+ response = await requestImpl({
220
+ socketPath: current.pipe,
221
+ path: route,
222
+ method: (init && init.method) || 'GET',
223
+ headers,
224
+ body: init && init.body !== undefined ? init.body : null,
225
+ timeoutMs: REQUEST_TIMEOUT_MS,
226
+ });
158
227
  } catch (error) {
159
- throw new BridgeError(`无法连接宿主:${error && error.message ? error.message : String(error)}`, 0);
160
- } finally {
161
- clearTimeout(timer);
162
- }
163
- const text = await response.text();
164
- let body = null;
165
- try {
166
- body = text === '' ? null : JSON.parse(text);
167
- } catch {
168
- body = null;
228
+ throw new BridgeError(`无法连接宿主:${error && error.message ? error.message : String(error)}`, 0, error && error.code ? error.code : undefined);
169
229
  }
170
- if (!response.ok) {
230
+ const body = response !== null && typeof response.json === 'object' ? response.json : null;
231
+ if (response.status < 200 || response.status >= 300) {
171
232
  const reason = body !== null && typeof body.error === 'string' ? body.error : `HTTP ${response.status}`;
172
233
  throw new BridgeError(reason, response.status);
173
234
  }
@@ -218,7 +279,7 @@ function createClient(options) {
218
279
  }
219
280
  return { ok: true, events };
220
281
  } catch (error) {
221
- return { ok: false, error: error.message, status: error.status };
282
+ return { ok: false, error: error.message, status: error.status, code: error.code };
222
283
  }
223
284
  },
224
285
  /** 把"选中内容 + 问题"投给 DSH 的当前会话。 */
@@ -253,7 +314,9 @@ module.exports = {
253
314
  CONFIG_REREAD_MS,
254
315
  REQUEST_TIMEOUT_MS,
255
316
  BridgeError,
317
+ isBridgeEndpoint,
256
318
  defaultExtensionsDir,
319
+ defaultRequest,
257
320
  bridgeFile,
258
321
  stateFile,
259
322
  readBridgeConfig,
@@ -0,0 +1,113 @@
1
+ /**
2
+ * lib/bridge-ipc.mjs — 编辑器桥的传输层:本机 IPC(Windows 命名管道 / 其它平台 unix socket)。
3
+ *
4
+ * 为什么不走 HTTP(0.3.13 定论,三条都实测过):
5
+ * 1. **desktop 根本没有 HTTP 面**:渲染进程经 Electron IPC 调 `host.fetch()`
6
+ * (`apps/desktop-host/src/index.ts:308` 的 `createSharedFetchHandler('/api')`),
7
+ * 那是**进程内函数调用**,进程外不可达;插件能挂 HTTP 的只有 web profile 的 `webServer`。
8
+ * 2. **`/api` 那条路即使有 webServer 也不行**:Connection 给 `/api` 装了浏览器 cookie fence
9
+ * (`packages/client/connection/src/index.ts:128-134` 的 `requestRejection` → 401),
10
+ * 而桥的客户端是扩展宿主里的 **Node 进程**,永远拿不到浏览器 cookie。
11
+ * 3. 桥的双方本来就是**同一台机器上的两个进程**(扩展宿主 ← 插件 spawn 的 IDE ← 插件),
12
+ * 本机 IPC 比开端口更小:没有网络面、没有 Host/Origin 混淆代理问题。
13
+ *
14
+ * 与 `serve: dsh` 的 IDE 挂载同源:`net`/`http` 的 `listen(path)` + `http.request({socketPath})`
15
+ * 在本机已被验证可用(见 lib/launcher.mjs 的 `--pipe` 与 lib/index.js 的 `healthCheckPipe`)。
16
+ *
17
+ * 安全:令牌校验仍然保留(见 lib/bridge.mjs 的 `bridgeGuard`)。Windows 命名管道用随机后缀
18
+ * (不可猜),POSIX 上 socket 文件 `chmod 0600` 并在关闭时删除。
19
+ */
20
+
21
+ import { chmodSync, readdirSync, rmSync, statSync } from 'node:fs';
22
+ import { randomBytes } from 'node:crypto';
23
+ import { createServer } from 'node:http';
24
+ import { join, dirname } from 'node:path';
25
+
26
+ /** 命名管道的统一前缀(Windows;`\\.\pipe\` 是本机命名空间,不经过网络栈)。 */
27
+ const WIN_PIPE_PREFIX = '\\\\.\\pipe\\';
28
+
29
+ /** 本机 IPC 端点路径。Windows = 命名管道名;其它平台 = <dataRoot>/bridge-<pid>-<rand>.sock。 */
30
+ export function bridgeEndpointPath(root, pid = process.pid, platform = process.platform) {
31
+ const rand = randomBytes(6).toString('hex');
32
+ if (platform === 'win32') return `${WIN_PIPE_PREFIX}dshcs-bridge-${pid}-${rand}`;
33
+ return join(root, `bridge-${pid}-${rand}.sock`);
34
+ }
35
+
36
+ /** 端点是不是本机 IPC(扩展侧与测试共用同一条判定)。 */
37
+ export function isBridgeEndpoint(value, platform = process.platform) {
38
+ if (typeof value !== 'string' || value === '') return false;
39
+ if (platform === 'win32') return value.startsWith(WIN_PIPE_PREFIX);
40
+ return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value);
41
+ }
42
+
43
+ /** POSIX 上清掉上一次崩溃留下的 socket 文件(24h 以前的;不碰别人正在用的)。
44
+ * Windows 命名管道由内核回收,不需要清理。 */
45
+ function sweepStaleSockets(root, log) {
46
+ let names;
47
+ try {
48
+ names = readdirSync(root);
49
+ } catch {
50
+ return;
51
+ }
52
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
53
+ for (const name of names) {
54
+ if (!/^bridge-\d+-[0-9a-f]{12}\.sock$/.test(name)) continue;
55
+ const file = join(root, name);
56
+ try {
57
+ if (statSync(file).mtimeMs < cutoff) rmSync(file, { force: true });
58
+ } catch {
59
+ // 清理失败无关正确性(端点带随机后缀,不会撞名)
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 起一个只服务桥路由的本机 IPC 监听口。
66
+ *
67
+ * @param {{socketPath: string, handler: (req: import('node:http').IncomingMessage,
68
+ * res: import('node:http').ServerResponse) => unknown, log?: (message: string) => void}} options
69
+ * @returns {Promise<{path: string, close: () => Promise<void>}>} 监听成功后的句柄(失败则 reject)
70
+ */
71
+ export function startBridgeListener({ socketPath, handler, log = () => {} }) {
72
+ const isWinPipe = socketPath.startsWith(WIN_PIPE_PREFIX);
73
+ if (!isBridgeEndpoint(socketPath)) {
74
+ return Promise.reject(new Error(`非法的桥端点路径:${socketPath}`));
75
+ }
76
+ if (!isWinPipe) {
77
+ sweepStaleSockets(dirname(socketPath), log);
78
+ // unix socket 文件必须先不存在,否则 listen 直接 EADDRINUSE(端点带随机后缀,撞名不可能)
79
+ try { rmSync(socketPath, { force: true }); } catch { /* ignore */ }
80
+ }
81
+ const server = createServer(handler);
82
+ return new Promise((resolve, reject) => {
83
+ const onError = (error) => {
84
+ server.removeListener('listening', onListening);
85
+ reject(error);
86
+ };
87
+ const onListening = () => {
88
+ server.removeListener('error', onError);
89
+ server.on('error', (error) => log(`桥监听异常:${error && error.code ? error.code : error && error.message ? error.message : error}`));
90
+ if (!isWinPipe) {
91
+ try { chmodSync(socketPath, 0o600); } catch { /* 权限收紧失败不影响可用性 */ }
92
+ }
93
+ resolve({
94
+ path: socketPath,
95
+ close: () => new Promise((done) => {
96
+ server.close(() => {
97
+ if (!isWinPipe) {
98
+ try { rmSync(socketPath, { force: true }); } catch { /* ignore */ }
99
+ }
100
+ done();
101
+ });
102
+ }),
103
+ });
104
+ };
105
+ server.once('error', onError);
106
+ server.once('listening', onListening);
107
+ try {
108
+ server.listen(socketPath);
109
+ } catch (error) {
110
+ onError(error);
111
+ }
112
+ });
113
+ }
package/lib/bridge.mjs CHANGED
@@ -20,13 +20,14 @@
20
20
  * - **host → 扩展的"事件"**:同一个轮询的响应体(环形缓冲 + `since` 游标)。
21
21
  * 事件只是"去看一眼这个文件"的提示,不是数据。
22
22
  *
23
- * ## 认证(关键:桥绕开了 DSH cookie fence,所以自带令牌)
23
+ * ## 认证(关键:桥绕开 DSH 的浏览器 fence,所以自带令牌)
24
24
  *
25
- * `/api/*` Host/Origin/cookie 校验由 Connection 在分发前做
26
- * (`dsh-client-connection/lib/index.js`:`requestRejection` 403 不可信 / 401 cookie)。
27
- * 扩展宿主是 Node 进程:`fetch` **不带 Origin**,Host `127.0.0.1:<port>`(在 trustedHosts 内)
28
- * 403;但它**拿不到浏览器 cookie** → 必然 401。所以桥路由必须自带独立令牌校验,
29
- * 且**不能**依赖 Connection 的认证。反过来,凭令牌就能调用,因此:
25
+ * **传输:本机 IPC**(0.3.13 起)—— Windows 命名管道 / 其它平台 unix socket,见 lib/bridge-ipc.mjs。
26
+ * 为什么不走 DSH HTTP 面:`/api` 的 Host/Origin/cookie 校验由 Connection 在分发前做
27
+ * (`packages/client/connection/src/index.ts`:`requestRejection` 403 不可信 / 401 cookie),
28
+ * 而扩展宿主是 Node 进程:`fetch` **不带 Origin**,但它**拿不到浏览器 cookie** → 必然 401;
29
+ * `webServer` 前缀(0.3.9–0.3.12)则只有 web profile 有 webServer,desktop 根本没有 HTTP 面。
30
+ * 桥两端本来就是同一台机器上的两个进程,所以传输用本机 IPC,鉴权仍走独立令牌:
30
31
  *
31
32
  * ## 安全不变量(改这个文件之前先读这四条)
32
33
  *
@@ -42,6 +43,7 @@
42
43
  import { randomBytes, timingSafeEqual } from 'node:crypto';
43
44
  import * as fs from 'node:fs';
44
45
  import * as path from 'node:path';
46
+ import { isBridgeEndpoint } from './bridge-ipc.mjs';
45
47
 
46
48
  /** 桥端点目录名(位于 extensionsDir 下 —— 扩展必须能读到它)。 */
47
49
  export const BRIDGE_DIRNAME = '.dshcs-bridge';
@@ -49,14 +51,15 @@ export const BRIDGE_DIRNAME = '.dshcs-bridge';
49
51
  /** 桥配置文件名。 */
50
52
  export const BRIDGE_FILENAME = 'bridge.json';
51
53
 
52
- /** 桥路由前缀。
54
+ /** 桥路由路径前缀(本机 IPC 上的路径;**不是** URL 前缀)。
53
55
  *
54
56
  * **故意不放在 `/api` 下(0.3.9 修正)**:Connection 给 `/api` 装了 Host/Origin/cookie fence
55
57
  * (`packages/client/connection/src/index.ts`:`requestRejection` → 无 cookie 即 401),而桥的客户端是
56
58
  * VS Code 扩展宿主里的一个 Node 进程 —— 它**永远拿不到浏览器 cookie**,请求在到达插件路由之前就被挡掉了。
57
59
  * 实测(0.3.7):扩展按 `/api/code-server/bridge/sync` 轮询,请求要么 405(打到 launcher/VS Code)、
58
60
  * 要么 401(打到 DSH 的 /api fence),桥从来没有真正同步过。
59
- * 现在挂到 DSH 自己的 webServer 前缀下,鉴权完全由桥自己的令牌承担(见下方安全不变量)。 */
61
+ * 0.3.9–0.3.12 挂在 DSH webServer 前缀上(只有 web profile 有 webServer);
62
+ * **0.3.13 起走本机 IPC**(Windows 命名管道 / unix socket),web 与 desktop 同一条路 —— 见 lib/bridge-ipc.mjs。 */
60
63
  export const BRIDGE_BASE = '/code-server-bridge';
61
64
 
62
65
  /** 事件环形缓冲上限:超出即丢最旧的(事件是提示,不是数据)。 */
@@ -81,32 +84,23 @@ export function mintBridgeToken() {
81
84
  return randomBytes(24).toString('base64url');
82
85
  }
83
86
 
84
- /**
85
- * 由实际监听地址算桥的基址。
86
- * @param {string} host 绑定地址(仅回环)
87
- * @param {number} port 实际端口
88
- */
89
- export function bridgeUrl(host, port) {
90
- const h = host === '::1' ? '[::1]' : host;
91
- return `http://${h}:${port}`;
92
- }
93
-
94
87
  /** 桥配置文件路径(`<extensionsDir>/.dshcs-bridge/bridge.json`)。 */
95
88
  export function bridgeFile(extensionsDir) {
96
89
  return path.join(extensionsDir, BRIDGE_DIRNAME, BRIDGE_FILENAME);
97
90
  }
98
91
 
99
92
  /**
100
- * 原子写桥配置。扩展每 5s 重读一次;需要时(端口/令牌变化)由 host 重写。
93
+ * 原子写桥配置。扩展每 5s 重读一次;需要时(端点/令牌变化)由 host 重写。
101
94
  * @param {string} extensionsDir 扩展目录
102
- * @param {{url: string, token: string, pid: number|null, startedAt: number|null}} value
95
+ * @param {{pipe: string, token: string, pid: number|null, startedAt: number|null}} value
103
96
  */
104
97
  export function writeBridgeConfig(extensionsDir, value) {
105
98
  const file = bridgeFile(extensionsDir);
106
99
  fs.mkdirSync(path.dirname(file), { recursive: true });
107
100
  const payload = JSON.stringify({
108
- version: 1,
109
- url: value.url,
101
+ version: 2,
102
+ /** 本机 IPC 端点(Windows 命名管道名 / 其它平台 unix socket 绝对路径)。0.3.13 起取代 `url`。 */
103
+ pipe: value.pipe,
110
104
  token: value.token,
111
105
  pid: value.pid,
112
106
  startedAt: value.startedAt,
@@ -134,12 +128,14 @@ export function removeBridgeConfig(extensionsDir) {
134
128
  }
135
129
  }
136
130
 
137
- /** 读回桥配置(诊断用;缺失或格式不对返回 null)。 */
131
+ /** 读回桥配置(诊断用;缺失或格式不对返回 null)。
132
+ * 端点形状与扩展侧同判定(见 lib/bridge-ipc.mjs 的 `isBridgeEndpoint`):
133
+ * Windows 必须是命名管道名,其它平台必须是绝对路径 —— 免得把一个写坏的端点当成有效配置。 */
138
134
  export function readBridgeConfig(extensionsDir) {
139
135
  try {
140
136
  const raw = JSON.parse(fs.readFileSync(bridgeFile(extensionsDir), 'utf8'));
141
137
  if (raw === null || typeof raw !== 'object') return null;
142
- if (typeof raw.url !== 'string' || !TOKEN_RE.test(String(raw.token))) return null;
138
+ if (!isBridgeEndpoint(raw.pipe) || !TOKEN_RE.test(String(raw.token))) return null;
143
139
  return raw;
144
140
  } catch {
145
141
  return null;
package/lib/index.js CHANGED
@@ -43,7 +43,6 @@ import {
43
43
  BRIDGE_DIRNAME,
44
44
  bodyWithinLimit,
45
45
  bridgeGuard,
46
- bridgeUrl,
47
46
  createContextCache,
48
47
  createEventRing,
49
48
  mintBridgeToken,
@@ -51,6 +50,7 @@ import {
51
50
  removeBridgeConfig,
52
51
  writeBridgeConfig,
53
52
  } from './bridge.mjs';
53
+ import { bridgeEndpointPath, startBridgeListener } from './bridge-ipc.mjs';
54
54
  import { registerEditorPrompt, registerEditorTools, setPromptLiveProbe } from './bridge-tools.mjs';
55
55
  import { deliverEditorPrompt } from './bridge-session.mjs';
56
56
  import { registerBridgeObserver } from './bridge-observe.mjs';
@@ -589,12 +589,17 @@ export async function apply(ctx, config) {
589
589
  if (next.editorBridge !== bridgeSetting) {
590
590
  bridgeSetting = next.editorBridge;
591
591
  console.log(`[code-server] editorBridge updated: ${bridgeSetting}`);
592
- // 关掉 → 立刻下线(删配置 + 注销工具);开启 → IDE 在跑就补一份配置。
592
+ // 关掉 → 立刻下线(关监听口 + 删配置 + 注销工具);开启 → 起监听口,IDE 在跑就补一份配置。
593
593
  if (bridgeSetting === false) {
594
594
  clearBridgeRuntime();
595
- } else if (state.status === 'running' && state.pid !== null) {
596
- bridgeToken ??= mintBridgeToken();
597
- syncBridgeRuntime({ pid: state.pid, startedAt: state.startedAt });
595
+ void stopBridgeListener();
596
+ } else {
597
+ void ensureBridgeListener().then(() => {
598
+ if (state.status === 'running' && state.pid !== null) {
599
+ bridgeToken ??= mintBridgeToken();
600
+ syncBridgeRuntime({ pid: state.pid, startedAt: state.startedAt });
601
+ }
602
+ });
598
603
  }
599
604
  }
600
605
  }
@@ -689,27 +694,53 @@ export async function apply(ctx, config) {
689
694
 
690
695
  /** 本次启动生成的新桥令牌;null = 本实例没有令牌(adopt 旧实例时会回读 bridge.json)。 */
691
696
  let bridgeToken = null;
692
- /** 桥的 webServer 挂载点(0.3.9)。null = 本部署没有可挂载的 HTTP 面(desktop)。 */
693
- let bridgeMountDispose = null;
694
- /** "桥不可用"只提示一次,避免每次起停都刷屏。 */
697
+ /** 桥的本机 IPC 监听口(0.3.13)。null = 未起(未启用/起失败)。*/
698
+ let bridgeListener = null;
699
+ /** 监听口启动中的 promise(避免并发起两次)。 */
700
+ let bridgeListenerStarting = null;
701
+ /** "桥监听起不来"只提示一次,避免每次起停都刷屏。 */
695
702
  let bridgeUnavailableLogged = false;
696
703
 
697
- /** 扩展宿主(Node 进程)能到达的 DSH origin;null = 本部署没有这样的 HTTP 面。
704
+ /** 桥的传输是**本机 IPC**(Windows 命名管道 / 其它平台 unix socket),不是 HTTP
698
705
  *
699
- * 为什么不能用 launcher 的 host:port(0.3.7 的错误做法):launcher 只服务 workbench,
700
- * **没有任何 `/api` 路由** —— 实测 `POST http://127.0.0.1:8090/<path-token>/api/code-server/bridge/sync`
701
- * 会穿透到 VS Code server 并得到 405。
702
- * 为什么也不能挂在 `/api` (0.3.7 的第二个错误):Connection `/api` 装了 cookie fence
703
- * (`packages/client/connection/src/index.ts`:`requestRejection` → 401),而扩展宿主是 Node 进程、
704
- * 永远拿不到浏览器 cookie 请求在到达插件路由之前就被 401 掉了。
705
- * 所以桥改挂 DSH 自己的 webServer 前缀(`BRIDGE_BASE`),自带令牌校验(见 bridge.mjs 的安全不变量)。 */
706
- function bridgeOrigin() {
707
- if (webServerSvc === undefined || bridgeMountDispose === null) return null;
708
- const port = typeof webServerSvc.port === 'number' ? webServerSvc.port : null;
709
- if (port === null || !Number.isSafeInteger(port) || port <= 0) return null;
710
- const raw = typeof webServerSvc.config?.host === 'string' ? webServerSvc.config.host : '';
711
- const host = raw === '' || raw === '0.0.0.0' || raw === '::' ? '127.0.0.1' : raw;
712
- return bridgeUrl(host, port);
706
+ * 为什么不走 HTTP(0.3.13 定论,三条都实测过):
707
+ * launcher host:port 只服务 workbench,没有任何桥路由(0.3.7 的实测:405);
708
+ * `/api` Connection 的浏览器 cookie fence,扩展宿主是 Node 进程、拿不到 cookie(0.3.7–0.3.9);
709
+ * 挂 DSH 的 `webServer` 前缀(0.3.9–0.3.12)只有 **web profile** —— desktop 的渲染进程
710
+ * 经 Electron IPC 调 `host.fetch()`(`apps/desktop-host/src/index.ts:308`),**根本没有 HTTP 面**。
711
+ * 桥的双方本来就是同一台机器上的两个进程,本机 IPC web desktop 走**同一条**路。
712
+ * 详见 lib/bridge-ipc.mjs 顶部。 */
713
+ function ensureBridgeListener() {
714
+ if (bridgeListener !== null) return Promise.resolve(bridgeListener);
715
+ if (bridgeListenerStarting !== null) return bridgeListenerStarting;
716
+ const socketPath = bridgeEndpointPath(bridgeRoot, process.pid);
717
+ bridgeListenerStarting = startBridgeListener({
718
+ socketPath,
719
+ handler: nodeRouteFromFetch((url, method, headers, body) => dispatchBridge(url, method, headers, body)),
720
+ log: (message) => console.warn(`[code-server] ${message}`),
721
+ }).then((handle) => {
722
+ bridgeListener = handle;
723
+ bridgeListenerStarting = null;
724
+ console.log(`[code-server] 编辑器桥监听就绪:${handle.path}(本机 IPC,桥令牌鉴权)`);
725
+ return handle;
726
+ }).catch((error) => {
727
+ bridgeListenerStarting = null;
728
+ if (!bridgeUnavailableLogged) {
729
+ bridgeUnavailableLogged = true;
730
+ console.warn(`[code-server] 编辑器桥监听失败,桥不启用:${error && error.code ? error.code : error && error.message ? error.message : error}`
731
+ + '(文件打开走信号文件,不受影响)');
732
+ }
733
+ return null;
734
+ });
735
+ return bridgeListenerStarting;
736
+ }
737
+
738
+ /** 关掉本机 IPC 监听口(禁用桥 / 插件卸载)。 */
739
+ function stopBridgeListener() {
740
+ const handle = bridgeListener;
741
+ bridgeListener = null;
742
+ if (handle === null) return Promise.resolve();
743
+ return handle.close().catch(() => {});
713
744
  }
714
745
 
715
746
  /** 桥的四条路由(后缀 → Fetch 风格 handler)。做成函数而非常量:handler 声明在文件后段,
@@ -723,7 +754,7 @@ export async function apply(ctx, config) {
723
754
  ];
724
755
  }
725
756
 
726
- /** 把 Fetch 风格 handler 适配成 DSH webServer Node 路由(req/res ⇄ Request/Response)。 */
757
+ /** 把 Fetch 风格 handler 适配成本机 IPC 监听口的 Node 路由(req/res ⇄ Request/Response)。 */
727
758
  function nodeRouteFromFetch(dispatch) {
728
759
  return async (req, res) => {
729
760
  try {
@@ -784,53 +815,44 @@ export async function apply(ctx, config) {
784
815
  isLive: () => bridgeMeta !== null && !bridgeContext.isStale(),
785
816
  });
786
817
 
787
- /** 同步桥运行时:写入/更新 bridge.json,并(就绪时)注册编辑器工具。
788
- * 端口、令牌、pid 三者任一变化都重写 —— 扩展每 5s 重读,故不需要任何推送。
789
- * **没有扩展可达的 origin 时(desktop)不写配置**,并清掉可能遗留的旧配置(宁可休眠,不可指向死地址)。 */
818
+ /** 同步桥运行时:写入/更新 bridge.json(端点 = 本机 IPC 路径 + 令牌 + pid)
819
+ * 端点、令牌、pid 三者任一变化都重写 —— 扩展每 5s 重读,故不需要任何推送。
820
+ * 监听口起不来时**不写配置**(宁可休眠,不可指向死端点),并说明一次。 */
790
821
  function syncBridgeRuntime({ pid, startedAt }) {
791
822
  if (!bridgeEnabled()) return;
792
- const base = bridgeOrigin();
793
- if (base === null) {
823
+ const handle = bridgeListener;
824
+ if (handle === null) {
794
825
  if (bridgeMeta !== null) {
795
826
  bridgeMeta = null;
796
- try { removeBridgeConfig(bridgeExtensionsDir); } catch { /* 删不掉也不影响:扩展会因不可达而休眠 */ }
797
- }
798
- if (!bridgeUnavailableLogged) {
799
- bridgeUnavailableLogged = true;
800
- console.warn('[code-server] 编辑器桥:本部署没有扩展可达的 HTTP 面(desktop 无 webServer)→ 桥不启用'
801
- + '(文件打开走信号文件,不受影响)');
827
+ try { removeBridgeConfig(bridgeExtensionsDir); } catch { /* 删不掉也不影响:扩展会因端点在而连不上 */ }
802
828
  }
803
829
  return;
804
830
  }
805
831
  bridgeToken ??= mintBridgeToken();
806
- const changed = bridgeMeta === null || bridgeMeta.url !== base || bridgeMeta.token !== bridgeToken || bridgeMeta.pid !== pid;
807
- bridgeMeta = { url: base, token: bridgeToken, pid, startedAt: startedAt ?? null };
832
+ const changed = bridgeMeta === null || bridgeMeta.pipe !== handle.path || bridgeMeta.token !== bridgeToken || bridgeMeta.pid !== pid;
833
+ bridgeMeta = { pipe: handle.path, token: bridgeToken, pid, startedAt: startedAt ?? null };
808
834
  try {
809
- writeBridgeConfig(bridgeExtensionsDir, { url: base, token: bridgeToken, pid, startedAt: startedAt ?? null });
835
+ writeBridgeConfig(bridgeExtensionsDir, { pipe: handle.path, token: bridgeToken, pid, startedAt: startedAt ?? null });
810
836
  } catch (err) {
811
837
  console.warn(`[code-server] 编辑器桥配置写入失败(${bridgeExtensionsDir}):${err && err.message ? err.message : err}`);
812
838
  return;
813
839
  }
814
840
  if (changed) {
815
- // 只打印"已启用",不打印令牌本身(与 path-token 同一决策)。
816
- console.log(`[code-server] 编辑器桥:已启用(${base}${BRIDGE_BASE},令牌文件 ${path.join(bridgeMetaDir, 'bridge.json')})`);
841
+ // 只打印端点与配置文件位置,不打印令牌本身(与 path-token 同一决策)。
842
+ console.log(`[code-server] 编辑器桥:已启用(${handle.path}${BRIDGE_BASE}/*,令牌文件 ${path.join(bridgeMetaDir, 'bridge.json')})`);
817
843
  }
818
844
  ensureBridgeTools();
819
845
  }
820
846
 
821
847
  /**
822
- * 接管实例时对齐桥令牌:磁盘上已有配置且基址一致 → 沿用(避免无谓轮换打乱正在运行的扩展);
848
+ * 接管实例时对齐桥令牌:磁盘上已有配置、端点一致且是本机 IPC → 沿用(避免无谓轮换打乱正在运行的扩展);
823
849
  * 否则 mint 新的(扩展下次轮询就会读到新的 bridge.json,一次请求的失败无所谓)。
824
850
  */
825
851
  function adoptBridgeRuntime(pid, startedAt) {
826
852
  if (!bridgeEnabled()) return;
827
- const base = bridgeOrigin();
828
- if (base === null) {
829
- syncBridgeRuntime({ pid, startedAt });
830
- return;
831
- }
832
853
  const existing = readBridgeConfig(bridgeExtensionsDir);
833
- bridgeToken = existing !== null && existing.url === base && TOKEN_RE.test(String(existing.token))
854
+ const endpoint = bridgeListener === null ? null : bridgeListener.path;
855
+ bridgeToken = existing !== null && endpoint !== null && existing.pipe === endpoint && TOKEN_RE.test(String(existing.token))
834
856
  ? String(existing.token)
835
857
  : mintBridgeToken();
836
858
  syncBridgeRuntime({ pid, startedAt });
@@ -860,7 +882,7 @@ export async function apply(ctx, config) {
860
882
  return null;
861
883
  }
862
884
 
863
- /** 桥就绪时注册编辑器工具;`tools` / `defineTool` 缺失时静默退化为"只有 HTTP 面"。 */
885
+ /** 桥就绪时注册编辑器工具;`tools` / `defineTool` 缺失时静默退化为"只有本机 IPC 面"。 */
864
886
  function ensureBridgeTools() {
865
887
  if (bridgeMeta === null || bridgeToolDispose !== null) return;
866
888
  Promise.resolve(registerEditorTools(ctx, {
@@ -869,7 +891,7 @@ export async function apply(ctx, config) {
869
891
  }))
870
892
  .then((dispose) => {
871
893
  if (dispose === null) {
872
- console.log('[code-server] 编辑器桥:工具服务不可用,仅提供 HTTP 面(editor_context/editor_diagnostics 未注册)');
894
+ console.log('[code-server] 编辑器桥:工具服务不可用,仅提供本机 IPC 面(editor_context/editor_diagnostics 未注册)');
873
895
  return;
874
896
  }
875
897
  // 期间桥可能已经被停掉(IDE 退出):立刻回滚,避免留下永远不可用的工具。
@@ -903,19 +925,6 @@ export async function apply(ctx, config) {
903
925
  dshMount = null;
904
926
  console.error(`[code-server] serve=dsh 挂载失败(将回退 loopback):${error && error.message ? error.message : error}`);
905
927
  }
906
- // 编辑器桥(0.3.9):挂在自己的前缀上,自带令牌 —— 走 /api 会被 Connection 的 cookie fence 401
907
- // (扩展宿主是 Node 进程,没有浏览器 cookie),详见 bridgeOrigin() 的注释。
908
- try {
909
- bridgeMountDispose = wsCtx.webServer.register({
910
- kind: 'prefix',
911
- path: BRIDGE_BASE,
912
- handler: nodeRouteFromFetch((url, method, headers, body) => dispatchBridge(url, method, headers, body)),
913
- });
914
- console.log(`[code-server] 编辑器桥挂载就绪:${BRIDGE_BASE}/* (DSH webServer,桥令牌鉴权)`);
915
- } catch (error) {
916
- bridgeMountDispose = null;
917
- console.warn(`[code-server] 编辑器桥挂载失败(桥不可用):${error && error.message ? error.message : error}`);
918
- }
919
928
  });
920
929
 
921
930
  ctx.effect(() => () => {
@@ -925,11 +934,11 @@ export async function apply(ctx, config) {
925
934
  }
926
935
  }, 'code-server: dsh mount');
927
936
 
928
- ctx.effect(() => () => {
929
- if (bridgeMountDispose === null) return;
930
- try { bridgeMountDispose(); } catch { /* ignore */ }
931
- bridgeMountDispose = null;
932
- }, 'code-server: bridge mount');
937
+ // 编辑器桥的本机 IPC 监听口(web desktop 同一套;不依赖 webServer,见 ensureBridgeListener)。
938
+ ctx.effect(() => {
939
+ if (bridgeEnabled()) void ensureBridgeListener();
940
+ return () => { void stopBridgeListener(); };
941
+ }, 'code-server: bridge ipc listener');
933
942
 
934
943
  /** 回环模式的客户端 URL:随机端口 + 路径令牌(令牌是 URL 路径的一段,浏览器会把子请求与 WS
935
944
  * 一并带过去 —— 这正是不走 VS Code 自带 cookie 令牌的原因,见 launcher 文件头"安全模型")。 */
@@ -968,8 +977,10 @@ export async function apply(ctx, config) {
968
977
  enabled: bridgeEnabled(),
969
978
  live: bridgeMeta !== null,
970
979
  toolsRegistered: bridgeToolDispose !== null,
971
- supported: state.serve === 'loopback',
972
- url: bridgeMeta === null ? null : bridgeMeta.url,
980
+ /** 端点已就绪(本机 IPC 监听口在)。0.3.13 web 与 desktop 都是 true(不再依赖 webServer)。 */
981
+ supported: bridgeListener !== null,
982
+ /** 本机 IPC 端点(命名管道 / unix socket);null = 监听口没起来。 */
983
+ endpoint: bridgeListener === null ? null : bridgeListener.path,
973
984
  file: path.join(bridgeMetaDir, 'bridge.json'),
974
985
  },
975
986
  env: state.env,
@@ -1539,11 +1550,13 @@ export async function apply(ctx, config) {
1539
1550
 
1540
1551
  async function handleBridgeHealth() {
1541
1552
  // 无鉴权:只回一句"桥活着吗",不含任何编辑器数据(便于重启后一眼确认)。
1553
+ // 注意:bridge=true 只表示"配置已就绪",**不代表扩展在跑**(见 README 已知限制)。
1542
1554
  return jsonResponse({
1543
1555
  ok: true,
1544
1556
  bridge: bridgeMeta !== null,
1545
1557
  pid: state.pid,
1546
- url: bridgeMeta === null ? null : bridgeMeta.url,
1558
+ transport: 'ipc',
1559
+ endpoint: bridgeMeta === null ? null : bridgeMeta.pipe,
1547
1560
  });
1548
1561
  }
1549
1562
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-code-server-app",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
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": {
@@ -39,6 +39,7 @@
39
39
  "lib/client.js",
40
40
  "lib/claim-types.js",
41
41
  "lib/bridge.mjs",
42
+ "lib/bridge-ipc.mjs",
42
43
  "lib/bridge-tools.mjs",
43
44
  "lib/bridge-session.mjs",
44
45
  "lib/bridge-observe.mjs",
@@ -3,7 +3,7 @@
3
3
  "vscodeVersion": "1.137.0",
4
4
  "productPath": "stable-b11dabdaca0d3369986975be285db92c8795cea5",
5
5
  "layout": "vscode-only",
6
- "preparedAt": "2026-09-12T17:39:03.492Z",
6
+ "preparedAt": "2026-09-13T05:03:27.361Z",
7
7
  "source": "registry",
8
8
  "node": "v24.21.0",
9
9
  "platform": "win32",