dsh-code-server-app 0.3.11 → 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
@@ -206,35 +206,50 @@ only the editor knows, and lets editor gestures drive the current session.
206
206
  - **Everything is read-only**: the bridge never writes files, applies edits, or runs commands. The agent's writes
207
207
  still go through its own `fs` tools; the bridge only *knows about* them.
208
208
  - Status bar shows `$(plug) DSH` while connected (click it for the log in the "DSH Editor Bridge" output channel).
209
+ - **The extension ships as a built-in** (fixed in 0.3.12): `dshcs-editor-bridge` is installed into
210
+ `<tree>/lib/vscode/extensions/` next to `dshcs-open-file`. 0.3.0–0.3.11 installed it as a *user* extension
211
+ instead, and the VS Code server marks any extension that sits in the user extensions folder but in no profile
212
+ manifest as removed (`.obsolete`, log line `Marked extension as removed`) and then skips it forever — re-marked on
213
+ every start, so **the bridge never reported any state**. To turn the bridge off use the plugin setting
214
+ `editorBridge=false` (no mount, no tools) rather than uninstalling the extension from the Extensions view.
209
215
 
210
- ### 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)
211
217
 
212
218
  ```
213
219
  extension → host POST /code-server-bridge/sync one round trip: push editor state + take pending events
214
220
  extension → host POST /code-server-bridge/ask push an editor question into the current session
215
221
  extension → host GET /code-server-bridge/health unauthenticated liveness probe
216
222
  extension → host POST /code-server-bridge/event extension reports open/close etc. (host log tail)
217
- 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
+ (the directory is announced via the host-injected `DSHCS_EXTENSIONS_DIR` — the extension lives in
225
+ the built-in tree now, so it cannot derive it from its own path)
218
226
  ```
219
227
 
220
- > **Why not under `/api` (fixed in 0.3.9)**: Connection puts a Host/Origin/cookie fence on `/api`
221
- > (`requestRejection` in `packages/client/connection/src/index.ts` → 401 without a cookie), while the bridge's
222
- > client is a **Node process inside the extension host** — it can never hold a browser cookie, so its requests were
223
- > rejected before ever reaching the plugin's route. Measured on 0.3.7: the extension polled
224
- > `/api/code-server/bridge/sync` and got either 405 (it reached the launcher/VS Code instead) or 401 (the /api
225
- > fence) the bridge had never actually synced. It now mounts on DSH's own webServer with its own token as the
226
- > only gate. The cost: the bridge needs DSH to provide `webServer` **the web profile has it, desktop does not**.
227
- > On desktop the host writes no `bridge.json` (dormant beats pointing at a dead address) and says so in the log;
228
- > **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.
229
245
 
230
246
  **Why state is pushed, not pulled**: the extension host is a child process of the VS Code server and **listens on
231
247
  no port** — the host cannot call into it. Editor state therefore rides the extension's own polling request, and
232
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
233
249
  of passing stale data off as fresh).
234
250
 
235
- **Why no SSE/WebSocket**: the extension host has no HTTP server of its own, and everything DSH can offer is
236
- request/response (the Connection fetch channel allows only `GET | HEAD | POST`; streaming would need the WS mux
237
- 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
238
253
  only costs one notification — the data always lives in the editor) and the cached state is inherently fresh.
239
254
 
240
255
  ### Security model (four invariants; read before touching `lib/bridge.mjs`)
@@ -584,7 +599,7 @@ Host/Origin fence and browser auth); in the desktop profile `apps/desktop-host`
584
599
  | POST | `/api/code-server/stop` | Stop and recycle the process tree |
585
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 |
586
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 |
587
- | 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` |
588
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** |
589
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 |
590
605
  | POST | `/code-server-bridge/event` | editor bridge: extension reports open/close and similar (host log tail). Requires the token |
@@ -602,7 +617,11 @@ Host/Origin fence and browser auth); in the desktop profile `apps/desktop-host`
602
617
  - The right-sidebar tab, guide entry box, file-address claim, and settings card behave the same as in web (code-server remains an
603
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).
604
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
605
- 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.
606
625
  - Install into the desktop profile through the **desktop plugin manager** (not the CLI, see below).
607
626
  - **Desktop installs face a 24-hour supply-chain policy (measured 2026-09-10; this is how 0.2.4 got installed)**:
608
627
  - the CLI path is unavailable: `dsh plugin --profile desktop …` is rejected (*"profile "desktop" is managed exclusively by the
@@ -663,13 +682,15 @@ What remains on the plugin side:
663
682
 
664
683
  ## Known limitations
665
684
 
666
- - **The editor bridge needs DSH to provide `webServer`** (corrected in 0.3.9): its client is a Node process inside
667
- the extension host, which can only reach DSH over HTTP at DSH's own origin (the bridge mounts under
668
- `BRIDGE_BASE` with its own token). **The web profile has webServer (both `serve: dsh` and loopback) → the bridge
669
- works; desktop has none it stays disabled** (`bridge.supported=false`, no `bridge.json` is written, one log
670
- line explains it). **File opening is unaffected**: it uses the signal file and works regardless of mode.
671
- Up to 0.3.7 the bridge was registered under `/api/code-server/bridge/*` and was killed by Connection's cookie
672
- 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).
690
+ - **`/code-server-bridge/health`'s `bridge` field does not mean the extension is running** (clarified in 0.3.12):
691
+ it only says the bridge *target* is configured. Whether the extension actually runs shows up in the exthost log
692
+ or by simply calling `editor_context` — 0.3.0–0.3.11 sat in the state "health says bridge:true, extension never
693
+ loaded" (cause above: the user-level install was marked `.obsolete`).
673
694
  - **Bridged state can lag by up to 600 ms**, and the tools say "stale" rather than serving data older than 10 s.
674
695
  - **Unsaved buffers are reported, not taken over.** The agent still edits via its own `fs` tools, i.e. against
675
696
  disk. What the bridge adds is a notice *before* writing a dirty file, a diff *after*, and a warning instead of
package/README.md CHANGED
@@ -202,34 +202,46 @@ DSH 用**资源地址**命名文件,`openFile` 只负责把地址交给右侧栏
202
202
  桥只是"知道它写了什么"。
203
203
  - 编辑器侧的入口还有状态栏的 `$(plug) DSH`(连通时显示,点击打开日志),日志在输出面板
204
204
  「DSH Editor Bridge」里 —— 出问题时先看它。
205
+ - **扩展装在内置目录**(0.3.12 修正):`dshcs-editor-bridge` 与 `dshcs-open-file` 一样装进
206
+ `<树>/lib/vscode/extensions/`。0.3.0–0.3.11 装的是用户级目录,而 VS Code 服务端会把
207
+ "在用户扩展目录里、不在任何 profile 清单里"的扩展标进 `.obsolete`(日志 `Marked extension as removed`)
208
+ 并永远跳过它 —— 每一轮启动都再标一次,**桥因此从来没有上报过状态**。
209
+ 要关掉桥请用插件设置 `editorBridge=false`(不挂桥、不注册工具),不要再指望在扩展视图里卸载它。
205
210
 
206
- ### 三条通道(0.3.9 起走 DSH webServer `/code-server-bridge`)
211
+ ### 三条通道(0.3.13 起走**本机 IPC**:Windows 命名管道 / unix socket)
207
212
 
208
213
  ```
209
214
  扩展 → host POST /code-server-bridge/sync 一趟来回:上报编辑器状态 + 取回待处理事件
210
215
  扩展 → host POST /code-server-bridge/ask 把编辑器里的提问投进当前会话
211
216
  扩展 → host GET /code-server-bridge/health 无鉴权探活(便于重启后一眼确认)
212
217
  扩展 → host POST /code-server-bridge/event 扩展上报打开/关闭文件等(进 host 日志尾)
213
- host → 扩展 <extensionsDir>/.dshcs-bridge/bridge.json base URL + 令牌(扩展每 5s 重读)
218
+ host → 扩展 <extensionsDir>/.dshcs-bridge/bridge.json 端点 + 令牌(扩展每 5s 重读)
219
+ (目录由 host 注入的 `DSHCS_EXTENSIONS_DIR` 告知 —— 扩展装在内置目录里,自己推不出来)
214
220
  ```
215
221
 
216
- > **为什么不在 `/api`(0.3.9 修正)**:Connection 给 `/api` 装了 Host/Origin/cookie fence
217
- > (`packages/client/connection/src/index.ts` 里 `requestRejection` → 无 cookie 即 401),而桥的客户端
218
- > 是扩展宿主里的 **Node 进程** —— 它永远拿不到浏览器 cookie,请求在到达插件路由之前就被挡掉了。
219
- > 实测(0.3.7):扩展按 `/api/code-server/bridge/sync` 轮询,要么 405(打到 launcher/VS Code)
220
- > 要么 401(打到 DSH 的 /api fence),**桥从来没有真正同步过**。
221
- > 现在桥挂在 DSH 自己的 webServer 前缀下,鉴权完全由桥自己的令牌承担(见下)。
222
- > 代价:桥需要 DSH 提供 `webServer` —— **web profile 有,desktop 没有**。
223
- > desktop host 不写 `bridge.json`(宁可休眠,不可指向死地址),并在日志里说明;
224
- > **文件打开不受影响**(它走信号文件,与 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)。
225
238
 
226
239
  **为什么状态是"推"而不是"拉"**:扩展宿主是 VS Code server 的一个子进程,**不监听任何端口** ——
227
240
  host 反向请求不到它。所以编辑器状态只能在扩展主动发起的那趟轮询里带上来,host 缓存后给工具读
228
241
  (缓存滞后最多一个轮询周期 600ms,超过 10s 没更新就判为过期,工具会明说"状态已过期");
229
242
 
230
- **为什么不用 SSE/WebSocket**:扩展宿主里没有 HTTP 服务器,而 DSH 侧能给的无非是请求/响应
231
- (Connection 的 fetch 通道只允许 `GET | HEAD | POST`,流式要另走已被 `dsh-api-gateway` 占用的 WS mux)。
232
- 轮询反而给了两条好性质:幂等(丢一次事件只是少一次提示,数据本身永远在编辑器里),以及状态天然最新(每趟都刷新)。
243
+ **为什么不用 SSE/WebSocket**:扩展宿主里没有 HTTP 服务器,而桥的形态是"每 600ms 一趟请求/响应"。
244
+ 轮询给了两条好性质:幂等(丢一次事件只是少一次提示,数据本身永远在编辑器里),以及状态天然最新(每趟都刷新)
233
245
 
234
246
  ### 安全模型(四条不变量,改 `lib/bridge.mjs` 之前先读)
235
247
 
@@ -587,13 +599,14 @@ desktop profile 由 `apps/desktop-host` 把 `/api/*` 交给同一个 `createShar
587
599
  | POST | `/api/code-server/stop` | 停止并回收进程树 |
588
600
  | POST | `/api/code-server/setup` | **兼容空操作**:0.1.36 起依赖由包管理器安装,调用只重新自检 `env` 并返回 |
589
601
  | POST | `/api/code-server/open-file` | body `{ file }` — 写信号文件,由内置扩展 `dshcs-open-file` 在 code-server 中打开 |
590
- | GET | `/code-server-bridge/health` | 编辑器桥探活(**无鉴权**;只回答"桥活着吗",不含任何编辑器数据)。挂 DSH webServer,不在 `/api` |
602
+ | GET | `/code-server-bridge/health` | 编辑器桥探活(**无鉴权**;只回答"桥活着吗",不含任何编辑器数据)。走**本机 IPC**(命名管道 / unix socket),不在 `/api` 下、也不需要 `webServer` |
591
603
  | POST | `/code-server-bridge/sync` | 编辑器桥:扩展上报状态(`{context, diagnostics, workspace, at}`)并取回事件;`?since=<seq>` 是事件游标。需 `x-dshcs-bridge-token`,**带 Origin 一律 403** |
592
604
  | POST | `/code-server-bridge/ask` | 编辑器桥:把编辑器里的提问投进当前会话(`{text, file?, lineStart?, lineEnd?, selection?, languageId?}`);没有可投递的会话时回 **409** |
593
605
  | POST | `/code-server-bridge/event` | 编辑器桥:扩展上报打开/关闭文件等(进 host 日志尾)。需令牌 |
594
606
 
595
607
  > 桥的四条路由都自带令牌鉴权(它们**不依赖** DSH 的 cookie fence —— 扩展宿主拿不到浏览器 cookie),
596
- > 且永远只读。这也是它们**不能**挂在 `/api` 下的原因(见「与 DSH 的协同」)。
608
+ > 且永远只读。传输是本机 IPC(0.3.13 起),所以 **web 与 desktop 同一套**:
609
+ > 端点由 host 写在 `bridge.json` 的 `pipe` 字段里,扩展用 `http.request({ socketPath })` 访问。
597
610
 
598
611
  > 除桥之外,插件不再注册任何插件自有 HTTP 路由;code-server 图标已内联为 data URI(client bundle 内),
599
612
  > 因此客户端不请求任何插件自有 HTTP 资源。
@@ -605,7 +618,10 @@ desktop profile 由 `apps/desktop-host` 把 `/api/*` 交给同一个 `createShar
605
618
  - 右侧栏标签、guide 入口框、文件地址认领、设置卡片在 desktop 下与 web 相同(code-server 仍是本机 `http://127.0.0.1:<port>` 的 iframe;
606
619
  桌面端 `webSecurity: true` 且页面无 CSP 限制,跨源 iframe 正常加载)。
607
620
  桌面端同样自带 `dsh-client-ui-sidebar-right`(见 desktop 构建 seed 包列表),因此 0.2.3 的
608
- "只支持带右侧栏的 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`/管道名。
609
625
  - 安装到 desktop profile:桌面端插件管理窗(**不是** CLI,见下)。
610
626
  - **桌面端安装的 24 小时供应链策略(实测,2026-09-10,已用它装上 0.2.4)**:
611
627
  - CLI 路径不可用:`dsh plugin --profile desktop …` 会被拒绝(*"profile "desktop" is managed exclusively by the Electron application"*),
@@ -645,12 +661,14 @@ desktop profile 由 `apps/desktop-host` 把 `/api/*` 交给同一个 `createShar
645
661
 
646
662
  ## 已知限制
647
663
 
648
- - **编辑器桥需要 DSH 提供 `webServer`**(0.3.9 修正):桥的客户端是扩展宿主里的 Node 进程,
649
- 它只能通过 HTTP 打到 DSH 自己的 origin(桥挂在 `BRIDGE_BASE` 前缀下,自带令牌鉴权)。
650
- **web profile(`serve: dsh` loopback 都行)有 webServer 桥可用;desktop 没有 → 不启用**
651
- (status `bridge.supported=false`,host 不写 `bridge.json`,日志里说明一次)。
652
- **文件打开不受影响**:它走信号文件,与 serve 模式和 webServer 都无关。
653
- 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
+ **文件打开**从来不受影响(它走信号文件)。
669
+ - **`/code-server-bridge/health``bridge` 字段不代表扩展在跑**(0.3.12 澄清):它只表示"桥的目标已就绪"
670
+ 扩展是否真的在跑,看 exthost 日志里有没有它的激活记录,或直接用 `editor_context` 试一次 ——
671
+ 0.3.0–0.3.11 就是"health 说 bridge:true、扩展却从没被加载"的状态(原因见上:用户级安装被标 `.obsolete`)。
654
672
  - **桥的状态有最多 600ms 滞后**:扩展每 600ms 推一次;超过 10s 没更新时工具会明说"状态已过期"
655
673
  而不是拿旧数据当新数据(例如用户在 IDE 里关掉面板之后)。
656
674
  - **未保存缓冲区是"上报"而不是"接管"**:agent 仍然通过它自己的 `fs` 工具按磁盘内容编辑。
@@ -74,12 +74,6 @@ function log(message) {
74
74
  if (output !== null) output.appendLine(`[${new Date().toISOString()}] ${message}`);
75
75
  }
76
76
 
77
- /** 取扩展所在目录(桥配置与它同级:extensionsDir/.dshcs-bridge/)。 */
78
- function extensionDir() {
79
- // __dirname = <extensionsDir>/dshcs-editor-bridge/lib → 上溯两级即 extensionsDir
80
- return path.resolve(__dirname, '..', '..');
81
- }
82
-
83
77
  /** 是否落在某个工作区文件夹内(不能把 workspaceFolder 之外的路径喂给 DSH)。 */
84
78
  function isInWorkspace(fsPath) {
85
79
  try {
@@ -359,7 +353,7 @@ async function pollOnce() {
359
353
  }
360
354
  if (!connected) {
361
355
  connected = true;
362
- log(`已连接宿主 ${client.config.url}`);
356
+ log(`已连接宿主(${client.config.pipe})`);
363
357
  updateStatusBar();
364
358
  }
365
359
  lastPollAt = Date.now();
@@ -384,7 +378,7 @@ function updateStatusBar() {
384
378
  if (statusBar === null) return;
385
379
  if (connected && client !== null && client.config !== null) {
386
380
  statusBar.text = '$(plug) DSH';
387
- 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点击查看日志`;
388
382
  statusBar.command = 'dsh-code-server.showBridgeLog';
389
383
  statusBar.show();
390
384
  } else {
@@ -462,12 +456,15 @@ function activate(context) {
462
456
  vscode.workspace.registerTextDocumentContentProvider('dshcs-old', oldSideProvider),
463
457
  );
464
458
 
465
- client = createClient({ extensionsDir: extensionDir() });
459
+ // 配置目录由 bridge-client 统一解析(host 注入的 DSHCS_EXTENSIONS_DIR → 自身位置反推)
460
+ // **不要在这里自己算**:0.3.0–0.3.11 那段 `resolve(__dirname,'..','..')` 比 <extensionsDir>
461
+ // 还高一级,任何布局都读不到 bridge.json ⇒ 桥一直休眠(见 docs 第 18 节)。
462
+ client = createClient();
466
463
  if (client.refresh() === null) {
467
464
  log('未找到桥配置(休眠)。DSH 插件启用编辑器桥并启动 IDE 后,这里会自动连上。');
468
465
  } else {
469
466
  client.restore();
470
- log(`发现桥配置:${client.config.url}`);
467
+ log(`发现桥配置:${client.config.pipe}`);
471
468
  }
472
469
 
473
470
  // host 请求时才现算,这里只维护"上次计数",用于日志与将来的变化上报。
@@ -514,9 +511,9 @@ function activate(context) {
514
511
  // 定期重读配置(令牌/端口轮换后最多 CONFIG_REREAD_MS 恢复)。
515
512
  const refreshTimer = setInterval(() => {
516
513
  if (client === null) return;
517
- const before = client.config === null ? null : client.config.url;
514
+ const before = client.config === null ? null : client.config.pipe;
518
515
  const next = client.refresh();
519
- const after = next === null ? null : next.url;
516
+ const after = next === null ? null : next.pipe;
520
517
  if (before !== after) {
521
518
  log(`桥目标变化:${before ?? '(休眠)'} → ${after ?? '(休眠)'}`);
522
519
  if (after !== null) client.restore();
@@ -6,59 +6,94 @@
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
 
39
- /** 桥配置路径;`extensionsDir` 可由调用方给(测试用),默认从 __dirname 反推。 */
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
+
57
+ /** 桥配置所在目录(=`<extensionsDir>/.dshcs-bridge`)。
58
+ *
59
+ * 取值顺序(0.3.12 修正 —— 这里以前写错了,是"桥永远休眠"的另一半原因):
60
+ * 1. **host 注入的 `DSHCS_EXTENSIONS_DIR`**(launcher 的 env 会被扩展宿主继承,与 dshcs-open-file
61
+ * 用 `DSHCS_OPEN_FILE_SIGNAL` 是同一套做法)。0.3.12 起桥扩展装在**内置**目录(树里),
62
+ * 与 <extensionsDir> 不再同级,只能靠 host 告诉它;
63
+ * 2. 调用方显式给的 `extensionsDir`(测试用);
64
+ * 3. 从本文件反推:`<extensionsDir>/dshcs-editor-bridge/lib/` 上溯两级。
65
+ * —— 注意 `extension.js` 曾自己算过一次,算成了三级(比 <extensionsDir> 还高一级),
66
+ * 即使按老的用户级布局也读不到配置。计算只留在这里一处。
67
+ */
68
+ function defaultExtensionsDir() {
69
+ const fromEnv = process.env.DSHCS_EXTENSIONS_DIR;
70
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv;
71
+ return path.resolve(__dirname, '..', '..');
72
+ }
73
+
74
+ /** 桥配置路径;`extensionsDir` 可由调用方给(测试用),默认见 `defaultExtensionsDir`。 */
40
75
  function bridgeFile(extensionsDir) {
41
- const dir = extensionsDir ?? path.resolve(__dirname, '..', '..');
76
+ const dir = extensionsDir ?? defaultExtensionsDir();
42
77
  return path.join(dir, BRIDGE_DIRNAME, BRIDGE_FILENAME);
43
78
  }
44
79
 
45
80
  /** 扩展自己的小状态文件(since 游标),与桥配置同目录。 */
46
81
  function stateFile(extensionsDir) {
47
- const dir = extensionsDir ?? path.resolve(__dirname, '..', '..');
82
+ const dir = extensionsDir ?? defaultExtensionsDir();
48
83
  return path.join(dir, BRIDGE_DIRNAME, STATE_FILENAME);
49
84
  }
50
85
 
51
86
  /**
52
87
  * 读桥配置。
53
- * @returns {{url: string, token: string, pid: number|null}|null} null = 未配置/格式不对 → 休眠
88
+ * @returns {{pipe: string, token: string, pid: number|null}|null} null = 未配置/格式不对 → 休眠
54
89
  */
55
90
  function readBridgeConfig(extensionsDir) {
56
91
  try {
57
92
  const raw = JSON.parse(fs.readFileSync(bridgeFile(extensionsDir), 'utf8'));
58
93
  if (raw === null || typeof raw !== 'object') return null;
59
- 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;
60
95
  if (!TOKEN_RE.test(String(raw.token))) return null;
61
- 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 };
62
97
  } catch {
63
98
  return null;
64
99
  }
@@ -91,22 +126,68 @@ function writeState(extensionsDir, state) {
91
126
 
92
127
  /** host 侧拒绝时的统一错误(带 status,便于区分 401/503/403)。 */
93
128
  class BridgeError extends Error {
94
- constructor(message, status) {
129
+ constructor(message, status, code) {
95
130
  super(message);
96
131
  this.name = 'BridgeError';
97
132
  this.status = status;
133
+ /** 底层 IO 错误码(EPERM/ENOENT/ECONNREFUSED…);状态栏与日志据此区分"权限/不存在/没在跑"。 */
134
+ this.code = code;
98
135
  }
99
136
  }
100
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
+
101
181
  /**
102
182
  * 造一个桥客户端。
103
183
  *
104
- * @param {{extensionsDir?: string, fetchImpl?: Function, now?: Function}} [options]
105
- * `fetchImpl` / `now` 可注入,便于单测(默认用 Node 18+ 的全局 fetch)。
184
+ * @param {{extensionsDir?: string, requestImpl?: Function, now?: Function}} [options]
185
+ * `requestImpl` / `now` 可注入,便于单测(默认走 `http.request({socketPath})`)
106
186
  */
107
187
  function createClient(options) {
108
188
  const extensionsDir = options && options.extensionsDir !== undefined ? options.extensionsDir : undefined;
109
- const fetchImpl = (options && options.fetchImpl) || ((...args) => fetch(...args));
189
+ /** 传输:本机 IPC 上的一个 HTTP 请求。注入点只在测试里用。 */
190
+ const requestImpl = (options && options.requestImpl) || defaultRequest;
110
191
  /** 当前配置(null = 休眠)。 */
111
192
  let config = null;
112
193
  /** 上次读配置的时间(避免每 600ms 都碰磁盘)。 */
@@ -119,8 +200,8 @@ function createClient(options) {
119
200
  if (!force && config !== null && now - configReadAt < CONFIG_REREAD_MS) return config;
120
201
  configReadAt = now;
121
202
  const next = readBridgeConfig(extensionsDir);
122
- if (next !== null && config !== null && next.url !== config.url) {
123
- // 端口/实例变了:游标失去意义(旧实例的事件不该在新实例上重放)。
203
+ if (next !== null && config !== null && next.pipe !== config.pipe) {
204
+ // 端点/实例变了:游标失去意义(旧实例的事件不该在新实例上重放)。
124
205
  since = 0;
125
206
  }
126
207
  if (next !== null && config !== null && next.pid !== config.pid) since = 0;
@@ -133,24 +214,21 @@ function createClient(options) {
133
214
  const current = refreshConfig(true);
134
215
  if (current === null) throw new BridgeError('编辑器桥未配置(休眠中)', 0);
135
216
  const headers = Object.assign({ [TOKEN_HEADER]: current.token }, (init && init.headers) || {});
136
- const controller = new AbortController();
137
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
138
217
  let response;
139
218
  try {
140
- 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
+ });
141
227
  } catch (error) {
142
- throw new BridgeError(`无法连接宿主:${error && error.message ? error.message : String(error)}`, 0);
143
- } finally {
144
- clearTimeout(timer);
145
- }
146
- const text = await response.text();
147
- let body = null;
148
- try {
149
- body = text === '' ? null : JSON.parse(text);
150
- } catch {
151
- body = null;
228
+ throw new BridgeError(`无法连接宿主:${error && error.message ? error.message : String(error)}`, 0, error && error.code ? error.code : undefined);
152
229
  }
153
- if (!response.ok) {
230
+ const body = response !== null && typeof response.json === 'object' ? response.json : null;
231
+ if (response.status < 200 || response.status >= 300) {
154
232
  const reason = body !== null && typeof body.error === 'string' ? body.error : `HTTP ${response.status}`;
155
233
  throw new BridgeError(reason, response.status);
156
234
  }
@@ -201,7 +279,7 @@ function createClient(options) {
201
279
  }
202
280
  return { ok: true, events };
203
281
  } catch (error) {
204
- return { ok: false, error: error.message, status: error.status };
282
+ return { ok: false, error: error.message, status: error.status, code: error.code };
205
283
  }
206
284
  },
207
285
  /** 把"选中内容 + 问题"投给 DSH 的当前会话。 */
@@ -236,6 +314,9 @@ module.exports = {
236
314
  CONFIG_REREAD_MS,
237
315
  REQUEST_TIMEOUT_MS,
238
316
  BridgeError,
317
+ isBridgeEndpoint,
318
+ defaultExtensionsDir,
319
+ defaultRequest,
239
320
  bridgeFile,
240
321
  stateFile,
241
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';
@@ -185,13 +185,13 @@ function launcherPath() {
185
185
  /** 扩展安装目标:`placement: 'builtin'` 时优先 VS Code「内置扩展」目录 = <树>/lib/vscode/extensions
186
186
  * (位于程序内置目录的扩展被 VS Code 视为内置——用户视图显示为"内置",**不能卸载**);
187
187
  * `placement: 'user'` 时只用 --extensions-dir(用户级扩展,可被用户禁用/卸载)。
188
- * 返回 { dst, builtin }——builtin=true 时是核心路径;找不到树时回退用户级。 */
189
- function extensionTarget(extensionsDir, name, placement = 'builtin') {
188
+ * 返回 { dst, builtin }——builtin=true 时是核心路径;找不到树时回退用户级。
189
+ * `treeRoot` 可注入(测试用):默认 `vsRoot()`,注入后测试不会碰真实树。 */
190
+ function extensionTarget(extensionsDir, name, placement = 'builtin', treeRoot = vsRoot()) {
190
191
  if (placement === 'builtin') {
191
192
  try {
192
- const root = vsRoot();
193
- if (root !== null) {
194
- const vscodeExt = path.join(root, 'lib', 'vscode', 'extensions');
193
+ if (treeRoot !== null) {
194
+ const vscodeExt = path.join(treeRoot, 'lib', 'vscode', 'extensions');
195
195
  if (fs.existsSync(vscodeExt)) {
196
196
  return { dst: path.join(vscodeExt, name), builtin: true };
197
197
  }
@@ -203,12 +203,19 @@ function extensionTarget(extensionsDir, name, placement = 'builtin') {
203
203
 
204
204
  /** 树内自带扩展清单。
205
205
  * - dshcs-open-file:host 信号文件 → VS Code 打开文件(可带行号)。**内置**(用户不需要看到它)。
206
- * - dshcs-editor-bridge(0.3.0):编辑器桥的扩展侧。**必须用户级** —— 它是可选能力,
207
- * 用户得有办法一键禁用;内置扩展按 VS Code 语义不能禁用。代价是用户可能卸载它,
208
- * 这没关系:host 侧本来就按"桥不可用"降级。 */
206
+ * - dshcs-editor-bridge(0.3.0):编辑器桥的扩展侧。**必须内置**(0.3.12 修正;0.3.0–0.3.11 是用户级)。
207
+ * 为什么"直接往 <extensions-dir> 拷目录"装不进用户级:VS Code 服务端启动时
208
+ * `ExtensionsWatcher.initialize()` `deleteExtensionsNotInProfiles()` 会把
209
+ * 「在用户扩展目录里、但不在任何 profile 的 extensions.json 里」的扩展写进 `<extensions-dir>/.obsolete`
210
+ * (服务端日志 `Marked extension as removed dsh-code-server-app.dshcs-editor-bridge-0.1.0`),
211
+ * 扫描器从此不再看它;下一轮它自然又不在 profile 里 ⇒ **每启动一次就再标一次,自锁**。
212
+ * 实测(本机 profile,10 次启动 10 条标记)与源码位置:`out/server-main.js` 的
213
+ * `ExtensionsWatcher#initialize` / `ExtensionsScannerService#setExtensionsForRemoval`。
214
+ * 内置目录(<树>/lib/vscode/extensions,与 dshcs-open-file 同处)不参与 profile 机制。
215
+ * 用户的"关掉它"由插件设置 `editorBridge=false`(不挂桥、不注册工具)承担,不再依赖 VS Code 的卸载。 */
209
216
  const BUNDLED_EXTENSIONS = [
210
217
  { name: 'dshcs-open-file', placement: 'builtin' },
211
- { name: 'dshcs-editor-bridge', placement: 'user' },
218
+ { name: 'dshcs-editor-bridge', placement: 'builtin' },
212
219
  ];
213
220
 
214
221
  /** 递归列出扩展源目录里的文件(相对路径,posix 分隔)。
@@ -233,19 +240,56 @@ export function listExtensionFiles(srcDir) {
233
240
  return out;
234
241
  }
235
242
 
243
+ /** 清掉 VS Code 写给本插件扩展的"已移除"标记(`<extensions-dir>/.obsolete`)。
244
+ * 机制:`ExtensionsWatcher.initialize()` 把"在用户扩展目录里、不在任何 profile 里"的扩展标成 removed,
245
+ * 扫描器随后永远跳过它 ⇒ **自锁**(见 BUNDLED_EXTENSIONS 的注释)。0.3.0–0.3.11 的桥扩展就卡在这里:
246
+ * 文件装全了、路由通了,扩展却一次都没被加载(`editor_context` 永远报"扩展还没有上报状态")。
247
+ * 别的扩展的条目原样保留;清空后直接删掉文件(VS Code 需要时会自己重建)。
248
+ * 导出供测试直接验。 */
249
+ export function clearObsoleteMarkers(extensionsDir, ids) {
250
+ const file = path.join(extensionsDir, '.obsolete');
251
+ if (ids.length === 0 || !fs.existsSync(file)) return [];
252
+ let map;
253
+ try {
254
+ map = JSON.parse(fs.readFileSync(file, 'utf8'));
255
+ } catch {
256
+ return [];
257
+ }
258
+ if (map === null || typeof map !== 'object' || Array.isArray(map)) return [];
259
+ const cleared = [];
260
+ for (const key of Object.keys(map)) {
261
+ if (ids.some((id) => key === id || key.startsWith(`${id}-`))) {
262
+ delete map[key];
263
+ cleared.push(key);
264
+ }
265
+ }
266
+ if (cleared.length === 0) return [];
267
+ try {
268
+ if (Object.keys(map).length === 0) fs.rmSync(file, { force: true });
269
+ else fs.writeFileSync(file, JSON.stringify(map), 'utf8');
270
+ } catch {
271
+ // 清标记失败不该拦住安装:扩展仍会被扫描器跳过,但文件是新的(下次启动还会再试)
272
+ }
273
+ return cleared;
274
+ }
275
+
236
276
  /** 内置扩展安装:每次启动调用 —— 缺失**或内容有变化**即同步(自愈,且插件升级后能更新已装的旧副本)。
237
277
  * 源目录里已不存在的文件会从目标里删掉(否则旧版本的 `lib/` 会一直留着)。
238
- * 同时清理"放错位置"的旧副本(用户级/内置两份同时存在会让 VS Code 打架)
239
- * 导出供测试直接调(apply 期不装扩展,只有 start 才装) */
240
- export function installBundledExtensions(extensionsDir, userDataDir) {
278
+ * 同时清理"放错位置"的旧副本(用户级/内置两份同时存在会让 VS Code 打架),并清掉 `.obsolete` 自锁标记。
279
+ * 导出供测试直接调(apply 期不装扩展,只有 start 才装);`options.treeRoot` 供测试注入假树。 */
280
+ export function installBundledExtensions(extensionsDir, userDataDir, options = {}) {
241
281
  const here = path.dirname(fileURLToPath(import.meta.url));
282
+ const treeRoot = options.treeRoot === undefined ? vsRoot() : options.treeRoot;
283
+ const managedIds = [];
242
284
  for (const ext of BUNDLED_EXTENSIONS) {
243
285
  try {
244
286
  const src = path.join(here, '..', 'assets', 'extensions', ext.name);
245
287
  const manifest = path.join(src, 'package.json');
246
288
  if (!fs.existsSync(manifest)) continue;
289
+ const manifestJson = JSON.parse(fs.readFileSync(manifest, 'utf8'));
290
+ managedIds.push(`${manifestJson.publisher ?? 'dsh-code-server-app'}.${manifestJson.name ?? ext.name}`);
247
291
  const files = listExtensionFiles(src);
248
- const target = extensionTarget(extensionsDir, ext.name, ext.placement);
292
+ const target = extensionTarget(extensionsDir, ext.name, ext.placement, treeRoot);
249
293
  const dst = target.dst;
250
294
  const stale = files.filter((name) => {
251
295
  const to = path.join(dst, name);
@@ -280,8 +324,8 @@ export function installBundledExtensions(extensionsDir, userDataDir) {
280
324
  };
281
325
  walkDst('');
282
326
  }
283
- // 清理放错位置的副本:dshcs-open-file 的旧用户级副本 / 编辑器桥的错误内置副本。
284
- const wrong = extensionTarget(extensionsDir, ext.name, ext.placement === 'builtin' ? 'user' : 'builtin');
327
+ // 清理放错位置的副本:编辑器桥的旧用户级副本(0.3.11 及更早)/ dshcs-open-file 的旧用户级副本。
328
+ const wrong = extensionTarget(extensionsDir, ext.name, ext.placement === 'builtin' ? 'user' : 'builtin', treeRoot);
285
329
  if (wrong.dst !== dst && fs.existsSync(wrong.dst)) {
286
330
  fs.rmSync(wrong.dst, { recursive: true, force: true });
287
331
  console.log(`[code-server] 清理放错位置的扩展副本 ${wrong.dst}(${ext.name} 应装在${target.builtin ? '内置' : '用户级'}目录)`);
@@ -292,6 +336,11 @@ export function installBundledExtensions(extensionsDir, userDataDir) {
292
336
  console.warn(`[code-server] bundled extension ${ext.name} install failed:`, err && err.message ? err.message : String(err));
293
337
  }
294
338
  }
339
+ const cleared = clearObsoleteMarkers(extensionsDir, managedIds);
340
+ if (cleared.length > 0) {
341
+ console.log(`[code-server] 清掉 VS Code 的"已移除"标记(.obsolete):${cleared.join(',')}`
342
+ + '(留着的话扫描器会永远跳过这些扩展,编辑器桥就永远没有状态)');
343
+ }
295
344
  void userDataDir;
296
345
  }
297
346
 
@@ -540,12 +589,17 @@ export async function apply(ctx, config) {
540
589
  if (next.editorBridge !== bridgeSetting) {
541
590
  bridgeSetting = next.editorBridge;
542
591
  console.log(`[code-server] editorBridge updated: ${bridgeSetting}`);
543
- // 关掉 → 立刻下线(删配置 + 注销工具);开启 → IDE 在跑就补一份配置。
592
+ // 关掉 → 立刻下线(关监听口 + 删配置 + 注销工具);开启 → 起监听口,IDE 在跑就补一份配置。
544
593
  if (bridgeSetting === false) {
545
594
  clearBridgeRuntime();
546
- } else if (state.status === 'running' && state.pid !== null) {
547
- bridgeToken ??= mintBridgeToken();
548
- 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
+ });
549
603
  }
550
604
  }
551
605
  }
@@ -640,27 +694,53 @@ export async function apply(ctx, config) {
640
694
 
641
695
  /** 本次启动生成的新桥令牌;null = 本实例没有令牌(adopt 旧实例时会回读 bridge.json)。 */
642
696
  let bridgeToken = null;
643
- /** 桥的 webServer 挂载点(0.3.9)。null = 本部署没有可挂载的 HTTP 面(desktop)。 */
644
- let bridgeMountDispose = null;
645
- /** "桥不可用"只提示一次,避免每次起停都刷屏。 */
697
+ /** 桥的本机 IPC 监听口(0.3.13)。null = 未起(未启用/起失败)。*/
698
+ let bridgeListener = null;
699
+ /** 监听口启动中的 promise(避免并发起两次)。 */
700
+ let bridgeListenerStarting = null;
701
+ /** "桥监听起不来"只提示一次,避免每次起停都刷屏。 */
646
702
  let bridgeUnavailableLogged = false;
647
703
 
648
- /** 扩展宿主(Node 进程)能到达的 DSH origin;null = 本部署没有这样的 HTTP 面。
704
+ /** 桥的传输是**本机 IPC**(Windows 命名管道 / 其它平台 unix socket),不是 HTTP
649
705
  *
650
- * 为什么不能用 launcher 的 host:port(0.3.7 的错误做法):launcher 只服务 workbench,
651
- * **没有任何 `/api` 路由** —— 实测 `POST http://127.0.0.1:8090/<path-token>/api/code-server/bridge/sync`
652
- * 会穿透到 VS Code server 并得到 405。
653
- * 为什么也不能挂在 `/api` (0.3.7 的第二个错误):Connection `/api` 装了 cookie fence
654
- * (`packages/client/connection/src/index.ts`:`requestRejection` → 401),而扩展宿主是 Node 进程、
655
- * 永远拿不到浏览器 cookie 请求在到达插件路由之前就被 401 掉了。
656
- * 所以桥改挂 DSH 自己的 webServer 前缀(`BRIDGE_BASE`),自带令牌校验(见 bridge.mjs 的安全不变量)。 */
657
- function bridgeOrigin() {
658
- if (webServerSvc === undefined || bridgeMountDispose === null) return null;
659
- const port = typeof webServerSvc.port === 'number' ? webServerSvc.port : null;
660
- if (port === null || !Number.isSafeInteger(port) || port <= 0) return null;
661
- const raw = typeof webServerSvc.config?.host === 'string' ? webServerSvc.config.host : '';
662
- const host = raw === '' || raw === '0.0.0.0' || raw === '::' ? '127.0.0.1' : raw;
663
- 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(() => {});
664
744
  }
665
745
 
666
746
  /** 桥的四条路由(后缀 → Fetch 风格 handler)。做成函数而非常量:handler 声明在文件后段,
@@ -674,7 +754,7 @@ export async function apply(ctx, config) {
674
754
  ];
675
755
  }
676
756
 
677
- /** 把 Fetch 风格 handler 适配成 DSH webServer Node 路由(req/res ⇄ Request/Response)。 */
757
+ /** 把 Fetch 风格 handler 适配成本机 IPC 监听口的 Node 路由(req/res ⇄ Request/Response)。 */
678
758
  function nodeRouteFromFetch(dispatch) {
679
759
  return async (req, res) => {
680
760
  try {
@@ -735,53 +815,44 @@ export async function apply(ctx, config) {
735
815
  isLive: () => bridgeMeta !== null && !bridgeContext.isStale(),
736
816
  });
737
817
 
738
- /** 同步桥运行时:写入/更新 bridge.json,并(就绪时)注册编辑器工具。
739
- * 端口、令牌、pid 三者任一变化都重写 —— 扩展每 5s 重读,故不需要任何推送。
740
- * **没有扩展可达的 origin 时(desktop)不写配置**,并清掉可能遗留的旧配置(宁可休眠,不可指向死地址)。 */
818
+ /** 同步桥运行时:写入/更新 bridge.json(端点 = 本机 IPC 路径 + 令牌 + pid)
819
+ * 端点、令牌、pid 三者任一变化都重写 —— 扩展每 5s 重读,故不需要任何推送。
820
+ * 监听口起不来时**不写配置**(宁可休眠,不可指向死端点),并说明一次。 */
741
821
  function syncBridgeRuntime({ pid, startedAt }) {
742
822
  if (!bridgeEnabled()) return;
743
- const base = bridgeOrigin();
744
- if (base === null) {
823
+ const handle = bridgeListener;
824
+ if (handle === null) {
745
825
  if (bridgeMeta !== null) {
746
826
  bridgeMeta = null;
747
- try { removeBridgeConfig(bridgeExtensionsDir); } catch { /* 删不掉也不影响:扩展会因不可达而休眠 */ }
748
- }
749
- if (!bridgeUnavailableLogged) {
750
- bridgeUnavailableLogged = true;
751
- console.warn('[code-server] 编辑器桥:本部署没有扩展可达的 HTTP 面(desktop 无 webServer)→ 桥不启用'
752
- + '(文件打开走信号文件,不受影响)');
827
+ try { removeBridgeConfig(bridgeExtensionsDir); } catch { /* 删不掉也不影响:扩展会因端点在而连不上 */ }
753
828
  }
754
829
  return;
755
830
  }
756
831
  bridgeToken ??= mintBridgeToken();
757
- const changed = bridgeMeta === null || bridgeMeta.url !== base || bridgeMeta.token !== bridgeToken || bridgeMeta.pid !== pid;
758
- 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 };
759
834
  try {
760
- writeBridgeConfig(bridgeExtensionsDir, { url: base, token: bridgeToken, pid, startedAt: startedAt ?? null });
835
+ writeBridgeConfig(bridgeExtensionsDir, { pipe: handle.path, token: bridgeToken, pid, startedAt: startedAt ?? null });
761
836
  } catch (err) {
762
837
  console.warn(`[code-server] 编辑器桥配置写入失败(${bridgeExtensionsDir}):${err && err.message ? err.message : err}`);
763
838
  return;
764
839
  }
765
840
  if (changed) {
766
- // 只打印"已启用",不打印令牌本身(与 path-token 同一决策)。
767
- 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')})`);
768
843
  }
769
844
  ensureBridgeTools();
770
845
  }
771
846
 
772
847
  /**
773
- * 接管实例时对齐桥令牌:磁盘上已有配置且基址一致 → 沿用(避免无谓轮换打乱正在运行的扩展);
848
+ * 接管实例时对齐桥令牌:磁盘上已有配置、端点一致且是本机 IPC → 沿用(避免无谓轮换打乱正在运行的扩展);
774
849
  * 否则 mint 新的(扩展下次轮询就会读到新的 bridge.json,一次请求的失败无所谓)。
775
850
  */
776
851
  function adoptBridgeRuntime(pid, startedAt) {
777
852
  if (!bridgeEnabled()) return;
778
- const base = bridgeOrigin();
779
- if (base === null) {
780
- syncBridgeRuntime({ pid, startedAt });
781
- return;
782
- }
783
853
  const existing = readBridgeConfig(bridgeExtensionsDir);
784
- 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))
785
856
  ? String(existing.token)
786
857
  : mintBridgeToken();
787
858
  syncBridgeRuntime({ pid, startedAt });
@@ -811,7 +882,7 @@ export async function apply(ctx, config) {
811
882
  return null;
812
883
  }
813
884
 
814
- /** 桥就绪时注册编辑器工具;`tools` / `defineTool` 缺失时静默退化为"只有 HTTP 面"。 */
885
+ /** 桥就绪时注册编辑器工具;`tools` / `defineTool` 缺失时静默退化为"只有本机 IPC 面"。 */
815
886
  function ensureBridgeTools() {
816
887
  if (bridgeMeta === null || bridgeToolDispose !== null) return;
817
888
  Promise.resolve(registerEditorTools(ctx, {
@@ -820,7 +891,7 @@ export async function apply(ctx, config) {
820
891
  }))
821
892
  .then((dispose) => {
822
893
  if (dispose === null) {
823
- console.log('[code-server] 编辑器桥:工具服务不可用,仅提供 HTTP 面(editor_context/editor_diagnostics 未注册)');
894
+ console.log('[code-server] 编辑器桥:工具服务不可用,仅提供本机 IPC 面(editor_context/editor_diagnostics 未注册)');
824
895
  return;
825
896
  }
826
897
  // 期间桥可能已经被停掉(IDE 退出):立刻回滚,避免留下永远不可用的工具。
@@ -854,19 +925,6 @@ export async function apply(ctx, config) {
854
925
  dshMount = null;
855
926
  console.error(`[code-server] serve=dsh 挂载失败(将回退 loopback):${error && error.message ? error.message : error}`);
856
927
  }
857
- // 编辑器桥(0.3.9):挂在自己的前缀上,自带令牌 —— 走 /api 会被 Connection 的 cookie fence 401
858
- // (扩展宿主是 Node 进程,没有浏览器 cookie),详见 bridgeOrigin() 的注释。
859
- try {
860
- bridgeMountDispose = wsCtx.webServer.register({
861
- kind: 'prefix',
862
- path: BRIDGE_BASE,
863
- handler: nodeRouteFromFetch((url, method, headers, body) => dispatchBridge(url, method, headers, body)),
864
- });
865
- console.log(`[code-server] 编辑器桥挂载就绪:${BRIDGE_BASE}/* (DSH webServer,桥令牌鉴权)`);
866
- } catch (error) {
867
- bridgeMountDispose = null;
868
- console.warn(`[code-server] 编辑器桥挂载失败(桥不可用):${error && error.message ? error.message : error}`);
869
- }
870
928
  });
871
929
 
872
930
  ctx.effect(() => () => {
@@ -876,11 +934,11 @@ export async function apply(ctx, config) {
876
934
  }
877
935
  }, 'code-server: dsh mount');
878
936
 
879
- ctx.effect(() => () => {
880
- if (bridgeMountDispose === null) return;
881
- try { bridgeMountDispose(); } catch { /* ignore */ }
882
- bridgeMountDispose = null;
883
- }, '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');
884
942
 
885
943
  /** 回环模式的客户端 URL:随机端口 + 路径令牌(令牌是 URL 路径的一段,浏览器会把子请求与 WS
886
944
  * 一并带过去 —— 这正是不走 VS Code 自带 cookie 令牌的原因,见 launcher 文件头"安全模型")。 */
@@ -919,8 +977,10 @@ export async function apply(ctx, config) {
919
977
  enabled: bridgeEnabled(),
920
978
  live: bridgeMeta !== null,
921
979
  toolsRegistered: bridgeToolDispose !== null,
922
- supported: state.serve === 'loopback',
923
- 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,
924
984
  file: path.join(bridgeMetaDir, 'bridge.json'),
925
985
  },
926
986
  env: state.env,
@@ -1209,6 +1269,11 @@ export async function apply(ctx, config) {
1209
1269
  const env = { ...process.env };
1210
1270
  // 内置扩展信号文件路径(host → 扩展 打开文件)
1211
1271
  env.DSHCS_OPEN_FILE_SIGNAL = openFileSignalPath(userDataDir);
1272
+ // 扩展目录(host → 扩展 找桥配置 `/.dshcs-bridge/bridge.json`)。
1273
+ // **必须注入**:0.3.12 起桥扩展装在**内置**目录(树里),与 <extensionsDir> 不同级,
1274
+ // 扩展再也不能靠"自己的路径上溯两级"找到配置(0.3.0–0.3.11 那个上溯是错的,多上溯了一级,
1275
+ // 就算装在用户级也读不到配置 ⇒ 桥一直是休眠态)。
1276
+ env.DSHCS_EXTENSIONS_DIR = extensionsDir;
1212
1277
  // ?v= 缓存击穿标记:按"插件版本 + VS Code 树"生成 —— 只在真升级时让渲染器丢掉旧 bundle,
1213
1278
  // 平时正常命中缓存(workbench.js 有 18MB,每次启动都重拉不划算)。
1214
1279
  env.DSHCS_HTML_TAG = `${pluginVersion()}-${productPath() ?? 'dev'}`.replace(/[^A-Za-z0-9._-]/g, '');
@@ -1485,11 +1550,13 @@ export async function apply(ctx, config) {
1485
1550
 
1486
1551
  async function handleBridgeHealth() {
1487
1552
  // 无鉴权:只回一句"桥活着吗",不含任何编辑器数据(便于重启后一眼确认)。
1553
+ // 注意:bridge=true 只表示"配置已就绪",**不代表扩展在跑**(见 README 已知限制)。
1488
1554
  return jsonResponse({
1489
1555
  ok: true,
1490
1556
  bridge: bridgeMeta !== null,
1491
1557
  pid: state.pid,
1492
- url: bridgeMeta === null ? null : bridgeMeta.url,
1558
+ transport: 'ipc',
1559
+ endpoint: bridgeMeta === null ? null : bridgeMeta.pipe,
1493
1560
  });
1494
1561
  }
1495
1562
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-code-server-app",
3
- "version": "0.3.11",
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:06:47.539Z",
6
+ "preparedAt": "2026-09-13T05:03:27.361Z",
7
7
  "source": "registry",
8
8
  "node": "v24.21.0",
9
9
  "platform": "win32",