refactor(runtime): compose consumers over fs and subprocess

This commit is contained in:
Tianyi Cui
2026-08-08 21:27:55 +08:00
parent 987d477cfc
commit 8917ff8ef4
116 changed files with 2828 additions and 1122 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/README.md
README.md: 4964d78f1096d1a4bc78fa80c6b5febaf24a5661
README.zh.md: 93b872002cf1f53cdbb96ff42402bfeb9557575f
README.md: 7fbdf071735673fb0158f6fa66148be1c644a433
README.zh.md: e059dbd80b7e38c0e447e54178162316dfd127c7
+6 -4
View File
@@ -6,8 +6,10 @@ The language-server capability seam: an abstract LSP interface, a generic stdio
| Package | Role | ctx key |
|---|---|---|
| [`lsp/`](lsp/README.md) | LSP provider seam and shared vocabulary | `ctx.lsp` |
| [`lsp-local/`](lsp-local/README.md) | Local stdio language-server backend | registers providers on `ctx.lsp` |
| [`tool-lsp/`](tool-lsp/README.md) | Model-facing semantic-navigation tool | registers on `ctx.tools` |
| `lsp/` | Abstract LSP seam (provider registry by branded id + extension mapping, per-query selection, vocabulary, `LspError`) | `ctx.lsp` |
| `lsp-local/` | Generic multi-server stdio backend over `ctx.fs` and `ctx.subprocess` (JSON-RPC, transient-open queries) | (registers providers on `ctx.lsp`) |
| `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) |
Providers register semantic capabilities; the tool owns the model-facing contract. The child READMEs document operation, protocol, and presentation details, while the [LSP capability-seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) owns the rationale.
The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation.
See the [LSP capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the design rationale, including why documents open transiently per query, why the stdio host consumes the shared filesystem/subprocess execution world, and why extension ownership is exclusive within one runtime.
+7 -5
View File
@@ -2,12 +2,14 @@
[English](README.md) | 中文
语言服务器能力 seam:抽象 LSP 接口、通用 stdio 提供方面向模型的 `lsp` 工具。这些全是**产品**包。
语言服务器能力 seam:抽象 LSP 接口、通用 stdio 提供方,以及面向模型的 `lsp` 工具。这些全是**产品** 包。
| 包 | 职责 | ctx key |
|---|---|---|
| [`lsp/`](lsp/README.md) | LSP 提供方 seam 和共享词汇 | `ctx.lsp` |
| [`lsp-local/`](lsp-local/README.md) | 本地 stdio 语言服务器后端 | 在 `ctx.lsp` 上注册提供方 |
| [`tool-lsp/`](tool-lsp/README.md) | 面向模型的语义导航工具 | 注册到 `ctx.tools` |
| `lsp/` | 抽象 LSP seam(按品牌化 id + 扩展名映射组织的提供方注册表、逐查询选择、词汇、`LspError` | `ctx.lsp` |
| `lsp-local/` | 基于 `ctx.fs``ctx.subprocess` 的通用多服务器 stdio 后端(JSON-RPC、临时打开查询) | `ctx.lsp` 上注册提供方 |
| `tool-lsp/` | 面向模型的 `lsp` 工具(四种操作、从 1 开始的 UTF-16 光标坐标) | 注册到 `ctx.tools` |
提供方注册语义能力;工具负责面向模型的契约。子 README 记录操作、协议和呈现细节,[LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)负责设计原理
接口位于 `lsp/lsp/`。该 seam 恰好公开四种语义操作:`goToDefinition``findReferences``goToImplementation``hover`,且不提供通用 JSON-RPC 逃生口;因此,替换提供方不会改变模型请求导航的方式,也不会让协议载荷或未经评审的修改进入模型契约。提供方注册的是**能力** 而非工具;`tool-lsp` 是面向模型名称、schema、提示词指引和呈现的唯一 owner
设计原理见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md),其中也解释了文档为何在每次查询时临时打开、stdio 主机为何使用共享的文件系统/子进程执行环境,以及扩展名归属为何在同一运行时内互斥。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/lsp-local/README.md
README.md: 37676a82fb5d45b40ca86507259aca9509d25a43
README.zh.md: 9e8b7f4f4395985bdbc29c1d911520b3559d7e0c
README.md: 2c5ab309f3557ad77696a881b41d164e65bd24fe
README.zh.md: d4493832964a5ac0d804602c18774bb106ddcbd9
+10 -8
View File
@@ -2,18 +2,19 @@
English | [中文](README.zh.md)
A **generic local stdio language-server backend** for `ctx.lsp`. One plugin instance accepts a named server table and registers one isolated provider per entry. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in `cordis.yml` overlays.
A **generic stdio language-server backend** for `ctx.lsp`. One plugin instance accepts a named server table and registers one isolated provider per entry. It reads through `ctx.fs` and launches through `ctx.subprocess`, so the server and source always inhabit the mounted execution world. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in `cordis.yml` overlays.
Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
## What it does
- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes.
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- Lazily single-flights one server process per `(server id, canonical workspace target)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
- Uses a compatibility-first **transient-open** sequence per query: resolve and boundedly read the source through `ctx.fs`, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel.
- After protocol shutdown fails, terminates the server's descendant tree through the subprocess seam (POSIX process-group signaling; Windows `taskkill /T /F`). Tree-kill delivery is contained like every group signal — it races server exit — and quiescence is confirmed by the handle's tree-liveness wait rather than by the kill's own outcome.
- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
- Resolves the server executable, cwd, process, and protocol streams through `ctx.subprocess`; `initialize.processId` is `null` because another machine or PID namespace must not monitor the harness process.
- Uses `ctx.fs` canonical containment, file URIs, and stable bounded reads, but emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
## Configuration
@@ -23,7 +24,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v
|---|---|---|
| `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. |
| `args` | `[]` | Arguments passed to the executable. |
| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`PASSWORD`/`SECRET`/`TOKEN` are not forwarded); an explicit `DSH_*` entry merges after the seam's scrub of ambient ones. |
| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); an explicit `DSH_*` entry merges after the seam's scrub of ambient ones. |
| `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). |
| `initializationOptions` | `null` | Static `initialize` options forwarded to the server. |
| `configuration` | `null` | Static answer to every `workspace/configuration` item. |
@@ -41,7 +42,7 @@ Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: {
## Security boundary
The provider trusts its configured server and claims no sandbox confinement. It canonicalizes and reads source through Node APIs, rejecting a source that is missing, non-regular, non-UTF-8, oversized, or whose canonical path resolves outside the canonical workspace (symlink aliases share one instance). Result locations may be external, but an external path cannot become a query source. The first implementation therefore requires trusted host-local deployment; restricted, remote, or virtual workspaces require another provider.
The provider trusts its configured server and claims no sandbox confinement. It delegates canonical identity, containment, no-follow/stable bounded reads, UTF-8 validation, and file-URI encoding to `ctx.fs`; it rejects missing, non-regular, non-UTF-8, oversized, or canonically out-of-workspace query sources before server startup. Result locations may be external, but an external path cannot become a query source. A deployment must mount filesystem and subprocess providers for the same execution world; split-world composition is invalid.
## Model Experience
@@ -53,6 +54,7 @@ No direct invalidation; `dsh-tool-lsp` owns request-prefix changes.
## Known Limitations and Deferred Work
- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW | O_NONBLOCK` (final-component symlink guard plus nonblocking rejection of FIFOs) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks.
- **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; compatibility with one TypeScript server does not imply cross-language support.
- **No confinement policy** — this package trusts the configured server and does not sandbox its process; a restricted deployment must supply appropriate process/filesystem providers or a same-world sandbox wrapper.
- **Execution-world URI rendering** — the stdio host produces provider-owned `file:` URIs. The current model tool renders them with the harness host's path library, so a Windows harness paired with a POSIX remote execution world may show remote locations as URIs or non-native paths; protocol queries remain correct.
- **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim.
- **Per-server/workspace serialization latency** — parallel agents sharing one server and workspace queue behind one process; long-lived workspace processes consume memory until disposal.
+14 -12
View File
@@ -2,18 +2,19 @@
[English](README.md) | 中文
`ctx.lsp` 的**通用本地 stdio 语言服务器后端**。一个插件实例接受一张命名服务器表,并逐配置项注册一个隔离的提供方。这是通用主机,而不是语言服务器目录或安装器:部署需要显式配置命令与映射,预设应放在 `cordis.yml` overlay 中。
`ctx.lsp` 的**通用 stdio 语言服务器后端**。一个插件实例接受一张命名服务器表,并逐配置项注册一个隔离的提供方。它通过 `ctx.fs` 读取,并通过 `ctx.subprocess` 启动,因此服务器与源文件始终位于所挂载的同一执行环境。这是通用主机,而不是语言服务器目录或安装器:部署需要显式配置命令与映射,preset 应放在 `cordis.yml` overlay 中。
Namespace 插件(`name``inject``Config``apply`,无默认导出)。
## 功能
- 在注册前解析每项服务器局部设置;无效映射或注册冲突会回滚较早配置项,因此加载失败不会留下提供方路由。
- 每个 `(server id, canonical workspace realpath)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其 dispose(资源释放)完成,并在新进程上重试该查询一次。
- 每次查询都使用兼容性优先的**临时打开**序列:通过 Node API 规范化并读取源文件`textDocument/didOpen`(版本 1、完整文本)、所请求操作,然后执行 `textDocument/didClose`,该操作位于 `finally` 中。写入 `didOpen` 失败或取消时,会在池复用该实例前将其终止。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。
- 每个 `(server id, canonical workspace target)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其释放,并在新进程上重试该查询一次。
- 每次查询都使用兼容性优先的**临时打开** 序列:通过 `ctx.fs` 解析源文件并进行有界读取`textDocument/didOpen`(版本 1、完整文本)、所请求操作,然后执行 `textDocument/didClose`,该操作位于 `finally` 中。写入 `didOpen` 失败或取消时,会先终止实例再允许池复用。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。
- 通过一条逐 Workspace、可中止的队列,串行执行每个源读取/打开/查询/关闭生命周期,因此排队调用只会在轮到自身时读取当前源;不同 Workspace 并行运行。
- 协议 shutdown 失败后,经由进程 seam 终止服务器后代树(POSIX 进程组信号;Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。
- 通过子进程 host namespace 中的 Node 文件系统 API 读取源文件,绝不使用 `ctx.fs`,也不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略
- 协议 shutdown 失败后,经由进程管理器 seam 终止服务器后代树(POSIX 进程组信号;Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。
- 通过 `ctx.subprocess` 解析服务器可执行文件、cwd、进程与协议流;`initialize.processId``null`,因为另一台机器或 PID 命名空间不得监控 harness 进程
- 使用 `ctx.fs` 提供的规范 containment、文件 URI 与稳定有界读取,但不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。
## 配置
@@ -23,13 +24,13 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
|---|---|---|
| `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 |
| `args` | `[]` | 传给可执行文件的参数。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY``PASSWORD``SECRET``TOKEN` 的变量不会转发);显式 `DSH_*` 条目在 seam 清除环境中同名值之后合并。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY``SECRET``TOKEN` 的变量不会转发);显式 `DSH_*` 条目在 seam 清除环境中同名值之后合并。 |
| `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id(例如 `{ '.ts': 'typescript' }`)。 |
| `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 |
| `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 |
| `maxMessageBytes` | `16000000` | 从服务器接受的单条 framed 消息最大大小。 |
| `maxStderrBytes` | `1000000` | 为诊断保留的 stderr 尾部最大大小。 |
| `maxDocumentBytes` | `4000000` | 该主机可打开的源文件大小上限。 |
| `maxDocumentBytes` | `4000000` | 该主机可打开的最大源文件。 |
| `shutdownTimeoutMs` | `5000` | 升级前用于优雅 `shutdown``exit` 的预算。 |
| `killGraceMs` | `2000` | 请求取消及 SIGTERM→SIGKILL 升级的宽限期。 |
@@ -37,11 +38,11 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
## 协议行为
初始化会声明 `general.positionEncodings: ['utf-16']``workspace: { workspaceFolders: true, configuration: true }``textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及定义与实现使用的 `linkSupport: true`,且不进行动态注册。服务器返回的能力具有最终决定权:不受支持的操作,或缺少临时打开/关闭的同步方式,会使查询失败。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值都属于协议错误。客户端通过静态配置回答 `workspace/configuration`,接受生命周期记账请求,并拒绝 `workspace/applyEdit`:它绝不应用编辑或运行命令。导航直接映射 `Location`,并从 `LocationLink``targetUri` + `targetSelectionRange` 映射;hover 规范化会取得有效的 `MarkupContent.value`,保留 string `MarkedString`,把带 language tag 的值渲染为围栏代码,并用一个空行连接数组。缺失结果、格式错误的范围或位置,以及格式错误的 hover 编码,都会结构化 `LSP_MALFORMED_RESPONSE` 错误的形式失败。
初始化会声明 `general.positionEncodings: ['utf-16']``workspace: { workspaceFolders: true, configuration: true }``textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及定义与实现使用的 `linkSupport: true`,且不进行动态注册。服务器返回的能力具有最终决定权:不受支持的操作,或缺少临时打开/关闭的同步方式,会使查询失败。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值都属于协议错误。客户端通过静态配置回答 `workspace/configuration`,接受生命周期记账请求,并拒绝 `workspace/applyEdit`:它绝不应用编辑或运行命令。导航直接映射 `Location`,并从 `LocationLink``targetUri` + `targetSelectionRange` 映射;hover 规范化会取得有效的 `MarkupContent.value`,保留 string `MarkedString`,把带 language tag 的值渲染为围栏代码,并用一个空行连接数组。缺失结果、格式错误的范围或位置,以及格式错误的 hover 编码,都会作为结构化 `LSP_MALFORMED_RESPONSE` 错误失败。
## 安全边界
提供方信任其配置的服务器,不提供任何沙箱隔离。它通过 Node API 规范化并读取源文件,拒绝缺失、非普通文件、非 UTF-8、过大或规范路径位于规范 Workspace 外部的源文件(符号链接别名共享一个实例)。结果位置可以在外部,但外部路径不能成为查询源。因此,第一版要求可信的主机本地部署;受限、远程或虚拟 Workspace 需要另一个提供方
提供方信任其配置的服务器,不声明任何沙箱限制。它把规范身份、containment、不跟随符号链接的稳定有界读取、UTF-8 校验与文件 URI 编码委托给 `ctx.fs`;服务器启动前,系统会拒绝缺失、非普通文件、非 UTF-8、过大或规范路径位于工作区外的查询源。结果位置可以在外部,但外部路径不能成为查询源。部署必须为同一执行环境挂载文件系统与子进程提供方;分裂执行环境的组合无效
## 模型体验
@@ -53,6 +54,7 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
## 已知限制与暂缓事项
- **仅限可信主机本地环境**:没有沙箱隔离,也没有私有 cache/temp 写入契约;支持不受信任 binary 或受限/远程/虚拟 Workspace,需要后续的进程/文件系统契约及不同提供方(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。限制逻辑先解析 `realpath`,再通过一个带 `O_NOFOLLOW | O_NONBLOCK` 的 handle 打开源文件(最终组件符号链接防护,并以非阻塞方式拒绝 FIFO),同时进行有界读取;并发修改方如果在解析与打开之间把*祖先*目录替换为符号链接,会造成残余 TOCTOU。在该可信部署模型下接受此风险,不使用不可移植的 `openat` 逐 segment 遍历来封闭
- **临时打开兼容性下限**:同步能力省略打开/关闭(或声明 `None`)的服务器不受支持,即使关闭文档查询能够工作;与一个 TypeScript 服务器兼容,并不表示支持其他语言
- **逐服务器/Workspace 串行化延迟**:共享同一个服务器与 Workspace 的并行 agent(智能体)会在一个进程后排队;长生命周期 Workspace 进程会占用内存直到 dispose
- **不提供隔离策略**:这个包(package)信任配置的服务器,不会对其进程执行沙箱化;受限部署必须提供适当的进程/文件系统提供方,或包装同一执行环境的沙箱
- **执行环境 URI 渲染**:stdio 主机生成归提供方所有的 `file:` URI。当前面向模型的工具使用 harness 宿主的路径库渲染这些 URI,因此 Windows harness 与 POSIX 远程执行环境配对时,可能把远程位置显示为 URI 或非本机路径;协议查询仍然正确
- **临时打开兼容性下限**:同步能力省略打开/关闭(或声明 `None`)的服务器不受支持,即使关闭文档查询能够工作;固定的 TypeScript e2e 只建立一项兼容性下限,不代表跨语言承诺
- **逐服务器/Workspace 串行化延迟**:共享同一个服务器与 Workspace 的并行 agent 会在一个进程后排队;长生命周期 Workspace 进程会占用内存直到释放。
+3
View File
@@ -26,6 +26,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-lsp": "^0.0.1",
@@ -38,6 +39,8 @@
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
+3 -4
View File
@@ -22,7 +22,7 @@ export interface ConnectionSpec {
readonly args: readonly string[]
/** The child's working directory (the canonical workspace). */
readonly cwd: string
/** The child's environment (credential-scrubbed, with overrides applied). */
/** Explicit child environment overrides; the subprocess provider owns its ambient scrub. */
readonly env: Record<string, string>
/** Largest single framed message accepted from the server. */
readonly maxMessageBytes: number
@@ -98,9 +98,8 @@ export class LspConnection {
stderr: { maxBytes: spec.maxStderrBytes },
},
graceMs: spec.killGraceMs,
// spec.env mixes the scrubbed base with explicit config entries; the
// seam merges the whole map after its own ambient scrub, so a
// configured DSH_* fact reaches the child.
// The seam merges explicit config entries after its ambient scrub, so a
// configured credential or DSH_* fact reaches the child deliberately.
env: spec.env,
})
/* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
+67 -115
View File
@@ -1,154 +1,106 @@
/**
* Host-filesystem source access for the local provider, using Node APIs directly in the
* subprocess's namespace (never `ctx.fs`): only the LSP result is model-visible, so a query does not
* satisfy read-before-write policy and emits no `fs/observed`. Canonicalization derives target
* identity from `realpath`, so symlink aliases share a workspace; a source is rejected before server
* startup when it is missing, non-regular, non-UTF-8, oversized, or canonically outside the
* workspace. External result locations are allowed, but an external path can never become a query
* source.
* @module @deepseek-ai/dsh-lsp-local/host
*/
/** Filesystem-seam source access for the generic stdio LSP provider. */
import { constants } from 'node:fs'
import { open, realpath, stat } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import { isAbsolute, resolve as resolvePath, sep } from 'node:path'
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
import { throwIfAborted } from './abort.ts'
/** A validated source: its canonical absolute path and current UTF-8 text. */
export interface HostSource {
/** The canonical (realpath-resolved) absolute path, inside the canonical workspace. */
/** A canonical workspace in the filesystem/subprocess execution world. */
export interface HostWorkspace {
/** Stable filesystem identity used for provider pooling. */
readonly target: FsTarget
/** Canonical absolute path accepted as a subprocess cwd. */
readonly canonicalPath: string
/** The file's current text, read as UTF-8. */
/** Canonical file URI sent during LSP initialization. */
readonly fileUrl: string
}
/** A validated source and the exact URI sent to the language server. */
export interface HostSource {
/** Canonical file URI in the execution world's platform syntax. */
readonly fileUrl: string
/** Current complete UTF-8 text. */
readonly text: string
}
/**
* Canonicalize a workspace root: it must exist and be a directory. The returned realpath supplies
* process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity, so symlinked roots
* collapse to one instance.
* @param workspaceRoot - the caller's workspace root (absolute).
* @param signal - optional cancellation observed around each filesystem operation.
* @returns the canonical directory path.
* @throws Error when the path is missing or not a directory.
* Resolve and validate one workspace through `ctx.fs`.
* @param fs - filesystem provider sharing the language server's execution world.
* @param workspaceRoot - caller-supplied workspace path.
* @param signal - optional cancellation around provider operations.
* @returns stable identity plus process path and file URI.
*/
export async function canonicalizeWorkspace(workspaceRoot: string, signal?: AbortSignal): Promise<string> {
export async function canonicalizeWorkspace(
fs: FileSystem,
workspaceRoot: string,
signal?: AbortSignal,
): Promise<HostWorkspace> {
throwIfAborted(signal)
let canonical: string
let target: FsTarget
try {
canonical = await realpath(workspaceRoot)
} catch (error) {
throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`)
target = await fs.resolve(workspaceRoot, signal === undefined ? {} : { signal })
} catch (error: unknown) {
throwIfAborted(signal)
throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`, { cause: error })
}
throwIfAborted(signal)
const info = await stat(canonical)
const info = await fs.stat(target, signal)
throwIfAborted(signal)
if (!info.isDirectory()) {
if (info?.type !== 'directory') {
throw new Error(`workspace root "${workspaceRoot}" is not a directory`)
}
return canonical
return {
target,
canonicalPath: fs.processPath(target),
fileUrl: fs.fileUrl(target),
}
}
/**
* Resolve, canonicalize, validate, and read a query source in one pass. A relative `filePath`
* resolves against `canonicalWorkspace`; an absolute one is taken directly. The canonical target
* must be a regular UTF-8 file no larger than `maxDocumentBytes`, and must lie inside the canonical
* workspace.
* @param filePath - the model-supplied source path (relative or absolute).
* @param canonicalWorkspace - the already-canonicalized workspace root.
* @param maxDocumentBytes - the largest source this host will open.
* @param signal - optional cancellation observed throughout resolution, validation, and reading.
* @returns the canonical path and current UTF-8 text.
* @throws Error when the source is missing, non-regular, oversized, non-UTF-8, or out of workspace.
* Resolve, contain, and atomically read one bounded query source through
* `ctx.fs`. The provider's bounded read owns stable-handle and no-follow
* mechanics; this layer owns only LSP-facing validation and messages.
* @param fs - filesystem provider sharing the server's execution world.
* @param filePath - absolute source path or path relative to `workspace`.
* @param workspace - already-canonical workspace.
* @param maxDocumentBytes - largest complete source accepted by this host.
* @param signal - optional cancellation.
* @returns canonical file URI and current text.
*/
export async function readHostSource(
fs: FileSystem,
filePath: string,
canonicalWorkspace: string,
workspace: HostWorkspace,
maxDocumentBytes: number,
signal?: AbortSignal,
): Promise<HostSource> {
throwIfAborted(signal)
const requested = isAbsolute(filePath) ? filePath : resolvePath(canonicalWorkspace, filePath)
let canonicalPath: string
let target: FsTarget
try {
canonicalPath = await realpath(requested)
} catch (error) {
throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`)
target = await fs.resolve(filePath, {
cwd: workspace.canonicalPath,
...signal === undefined ? {} : { signal },
})
} catch (error: unknown) {
throwIfAborted(signal)
throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`, { cause: error })
}
throwIfAborted(signal)
if (!isInside(canonicalWorkspace, canonicalPath)) {
if (!fs.contains(workspace.target, target)) {
throw new Error(`source "${filePath}" resolves outside the workspace`)
}
// Open ONE handle after containment, then stat and read through it: a concurrent replace between
// realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we
// actually read (no path-based TOCTOU). O_NOFOLLOW rejects the final component being swapped for a
// symlink between realpath and open (which would otherwise escape the workspace).
// O_NONBLOCK prevents a FIFO with no writer from hanging before fstat can reject it as nonregular.
const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
let text: string
try {
text = await fs.readTextBounded(target, maxDocumentBytes, signal)
} catch (error: unknown) {
throwIfAborted(signal)
const info = await handle.stat()
throwIfAborted(signal)
if (!info.isFile()) {
throw new Error(`source "${filePath}" is not a regular file`)
}
if (info.size > maxDocumentBytes) {
throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
}
// Bound the read to the cap even if the file grew after stat: read one extra byte and reject on
// overflow, so a concurrent grow cannot defeat the memory bound.
const buffer = await readCapped(handle, maxDocumentBytes, filePath, signal)
const text = decodeUtf8Strict(buffer, filePath)
throwIfAborted(signal)
return { canonicalPath, text }
} finally {
await handle.close()
throw new Error(`source "${filePath}" could not be opened safely: ${messageOf(error)}`, { cause: error })
}
throwIfAborted(signal)
return {
fileUrl: fs.fileUrl(target),
text,
}
}
/** Read at most `maxBytes` from the handle, rejecting when the source overflows the cap. */
async function readCapped(
handle: FileHandle,
maxBytes: number,
filePath: string,
signal?: AbortSignal,
): Promise<Buffer> {
const limit = maxBytes + 1
const chunk = Buffer.allocUnsafe(limit)
let total = 0
for (;;) {
throwIfAborted(signal)
const { bytesRead } = await handle.read(chunk, total, limit - total, total)
throwIfAborted(signal)
if (bytesRead === 0) break
total += bytesRead
/* v8 ignore next 3 -- overflow requires the file to grow past the cap between stat and read (a concurrent mutation); defensive. */
if (total > maxBytes) {
throw new Error(`source "${filePath}" grew past the ${maxBytes}-byte limit while reading`)
}
}
return chunk.subarray(0, total)
}
/** Whether `child` is the workspace itself or a descendant of it (both already canonical). */
function isInside(workspace: string, child: string): boolean {
if (child === workspace) return true
/* v8 ignore next -- a canonical non-root workspace never ends with a separator; the guard covers the filesystem root. */
const base = workspace.endsWith(sep) ? workspace : workspace + sep
return child.startsWith(base)
}
/** Decode strictly as UTF-8: a fatal decoder rejects only malformed bytes, keeping a legitimate U+FFFD. */
function decodeUtf8Strict(buffer: Buffer, filePath: string): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
} catch {
throw new Error(`source "${filePath}" is not valid UTF-8 text`)
}
}
/** Extract a message from an unknown thrown value without leaking `any`. */
function messageOf(error: unknown): string {
/* v8 ignore next -- Node fs rejections are always Error instances; the String() fallback is defensive. */
return error instanceof Error ? error.message : String(error)
}
+40 -75
View File
@@ -1,18 +1,16 @@
/**
* Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table
* of server commands and registers one isolated provider for each entry. Every provider lazily
* single-flights one server process per canonical workspace realpath, serves transient-open queries
* single-flights one server process per canonical workspace target, serves transient-open queries
* through it, and replaces a selected transport that fails before or during the next read-only
* query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`)
* and trust their configured servers — no sandbox confinement.
* query. Providers read sources through `ctx.fs` and launch servers through
* `ctx.subprocess`, so both local and remote implementations share one host.
*
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
* unregisters from `ctx.lsp` and tears down every live server.
* @module @deepseek-ai/dsh-lsp-local
*/
import { accessSync, constants, statSync } from 'node:fs'
import { delimiter, isAbsolute, join } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp'
@@ -24,9 +22,9 @@ import type {
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { canonicalizeWorkspace, readHostSource } from './host.ts'
import type { HostWorkspace } from './host.ts'
import { LspInstance } from './instance.ts'
import type { ConnectionSpawner } from './connection.ts'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { InstanceSpec } from './instance.ts'
export { canonicalizeWorkspace, readHostSource } from './host.ts'
@@ -46,10 +44,7 @@ export { LspConnection } from './connection.ts'
export const name = 'lsp-local'
/** Services required by this plugin. */
export const inject = ['lsp', 'subprocess']
/** Credential-shaped ambient env vars are NOT forwarded to the child by default. */
export const inject = ['fs', 'lsp', 'subprocess']
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
@@ -91,6 +86,7 @@ export interface Config {
/** One server config after schemastery fills every default. */
type ResolvedServerConfig = Required<LspLocalServerConfig>
type WorkspaceKey = HostWorkspace['target']['targetKey']
const LspLocalServerConfig: z<LspLocalServerConfig> = z.object({
command: z.string().required(),
@@ -114,23 +110,28 @@ export const Config: z<Config> = z.object({
* Register the configured stdio LSP providers. Resolves every executable at load (after credential
* scrubbing) before publishing any provider; each process launches lazily on its first matching
* query.
* @param ctx - the plugin context (must inject `lsp`).
* @param ctx - the plugin context carrying `fs`, `lsp`, and `subprocess`.
* @param config - the resolved plugin configuration (schemastery has filled every default).
*/
export function apply(ctx: Context, config: Config): void {
export async function apply(ctx: Context, config: Config): Promise<void> {
const entries = Object.entries(config.servers)
if (entries.length === 0) throw new Error('lsp-local: servers must contain at least one server')
// Resolve every server-local setting before registration so a bad later command or bound cannot
// publish an earlier provider. Registry-level mapping conflicts are rolled back below.
const providers = entries.map(([providerId, rawConfig]) => {
const providers = await Promise.all(entries.map(async ([providerId, rawConfig]) => {
if (providerId.trim() === '') throw new Error('lsp-local: server ids must be non-empty strings')
const resolved = rawConfig as ResolvedServerConfig
validateServerConfig(providerId, resolved)
const childEnv = buildChildEnv(resolved.env)
const executable = resolveExecutable(resolved.command, childEnv)
return new LocalLspProvider(providerId, resolved, childEnv, executable, spec => ctx.subprocess.spawn(spec))
})
const executable = await ctx.subprocess.resolveExecutable(resolved.command, resolved.env)
return new LocalLspProvider(
providerId,
ctx.fs,
resolved,
executable,
spec => ctx.subprocess.spawn(spec),
)
}))
ctx.effect(() => {
const disposers: Array<() => void> = []
@@ -180,16 +181,16 @@ function assertPositiveInteger(providerId: string, name: string, value: number):
class LocalLspProvider implements LspProvider {
readonly id: LspProviderId
readonly extensionToLanguage: Readonly<Record<string, string>>
/** One live instance per canonical workspace realpath. */
private readonly instances = new Map<string, LspInstance>()
/** One live instance per stable canonical workspace identity. */
private readonly instances = new Map<WorkspaceKey, LspInstance>()
/** One complete source-read→open→query→close serialization tail per canonical workspace. */
private readonly queues = new Map<string, Promise<void>>()
private readonly queues = new Map<WorkspaceKey, Promise<void>>()
private disposed = false
constructor(
providerId: string,
private readonly fs: Context['fs'],
private readonly config: ResolvedServerConfig,
private readonly childEnv: Record<string, string>,
private readonly executable: string,
private readonly spawner: ConnectionSpawner,
) {
@@ -211,19 +212,20 @@ class LocalLspProvider implements LspProvider {
}
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
// Honor an already-aborted signal before host I/O so a canceled request never starts a server.
// Honor an already-aborted signal before provider I/O so a canceled request never starts a server.
this.assertActive(signal)
const workspace = await canonicalizeWorkspace(request.workspaceRoot, signal)
const workspace = await canonicalizeWorkspace(this.fs, request.workspaceRoot, signal)
this.assertActive(signal)
return this.enqueue(workspace, signal, async () => {
const workspaceKey = workspace.target.targetKey
return this.enqueue(workspaceKey, signal, async () => {
this.assertActive(signal)
// Read inside the workspace queue but before spawning: a queued query sees current bytes when
// its turn starts, while an invalid source still cannot leave an idle process pooled.
const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes, signal)
const source = await readHostSource(this.fs, request.filePath, workspace, this.config.maxDocumentBytes, signal)
// Disposal may have snapshotted the instance map while host I/O was pending. Re-check before a
// synchronous get-or-create so every spawned process remains owned by teardown.
this.assertActive(signal)
let instance = this.instanceFor(workspace)
let instance = this.instanceFor(workspaceKey, workspace)
try {
return await instance.query(request, source, signal)
} catch (error) {
@@ -231,22 +233,22 @@ class LocalLspProvider implements LspProvider {
// read-only, so replace that transport once and retry transparently.
if (!instance.isTransportFailure(error)) throw error
await instance.dispose()
this.evictIfCurrent(workspace, instance)
this.evictIfCurrent(workspaceKey, instance)
this.assertActive(signal)
instance = this.instanceFor(workspace)
instance = this.instanceFor(workspaceKey, workspace)
return await instance.query(request, source, signal)
} finally {
// Reach quiescence before dropping a dead slot; a replacement must survive this ownership check.
if (instance.dead) {
await instance.dispose()
this.evictIfCurrent(workspace, instance)
this.evictIfCurrent(workspaceKey, instance)
}
}
})
}
/** Serialize one complete query lifecycle for a canonical workspace. */
private enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
private enqueue<T>(workspace: WorkspaceKey, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
const previous = this.queues.get(workspace) ?? Promise.resolve()
const result = abortable(previous, signal).then(run)
// The tail follows the actual prior work even when this caller aborts its wait. It never rejects,
@@ -260,27 +262,28 @@ class LocalLspProvider implements LspProvider {
}
/** Return or synchronously publish the one instance for a canonical workspace. */
private instanceFor(workspace: string): LspInstance {
private instanceFor(workspaceKey: WorkspaceKey, workspace: HostWorkspace): LspInstance {
this.assertActive()
const existing = this.instances.get(workspace)
const existing = this.instances.get(workspaceKey)
if (existing !== undefined) return existing
const created = this.createInstance(workspace)
this.instances.set(workspace, created)
this.instances.set(workspaceKey, created)
return created
}
/** Drop the slot iff it still contains this instance. */
private evictIfCurrent(workspace: string, instance: LspInstance): void {
private evictIfCurrent(workspace: WorkspaceKey, instance: LspInstance): void {
/* v8 ignore next -- mismatch requires another query to replace the slot before this finally runs. */
if (this.instances.get(workspace) === instance) this.instances.delete(workspace)
}
private createInstance(workspace: string): LspInstance {
private createInstance(workspace: HostWorkspace): LspInstance {
const spec: InstanceSpec = {
command: this.executable,
args: this.config.args,
cwd: workspace,
env: this.childEnv,
cwd: workspace.canonicalPath,
workspaceUri: workspace.fileUrl,
env: this.config.env,
configuration: this.config.configuration,
initializationOptions: this.config.initializationOptions,
maxMessageBytes: this.config.maxMessageBytes,
@@ -304,41 +307,3 @@ class LocalLspProvider implements LspProvider {
this.queues.clear()
}
}
/** The seam's scrubbed parent env (credential-shaped and DSH_* names dropped), plus the config's explicit env. */
function buildChildEnv(extra: Record<string, string>): Record<string, string> {
return { ...scrubbedParentEnv(), ...extra }
}
/**
* Resolve the server executable to an absolute path: an absolute command is verified directly; a
* bare command is looked up on the child's PATH. Fails loudly when nothing is executable.
*/
function resolveExecutable(command: string, childEnv: Record<string, string>): string {
if (isAbsolute(command)) {
// Verify an absolute command too, so an unavailable one fails at load, not on the first query.
if (!isExecutableFileSync(command)) {
throw new Error(`lsp-local: command "${command}" is not an executable file`)
}
return command
}
/* v8 ignore next -- buildChildEnv always sets PATH from the ambient env; the further fallbacks are defensive. */
const pathValue = childEnv.PATH ?? process.env.PATH ?? ''
for (const dir of pathValue.split(delimiter)) {
if (dir === '') continue
const candidate = join(dir, command)
if (isExecutableFileSync(candidate)) return candidate
}
throw new Error(`lsp-local: command "${command}" was not found on PATH`)
}
/** Synchronous regular-file and executable check used only at load-time resolution. */
function isExecutableFileSync(path: string): boolean {
try {
if (!statSync(path).isFile()) return false
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}
+8 -5
View File
@@ -7,7 +7,6 @@
* @module @deepseek-ai/dsh-lsp-local/instance
*/
import { pathToFileURL } from 'node:url'
import { LspError } from '@deepseek-ai/dsh-lsp'
import type {
LspOperation,
@@ -31,6 +30,8 @@ import {
/** Everything an instance needs beyond the connection spec. */
export interface InstanceSpec extends ConnectionSpec {
/** Canonical workspace file URI supplied by the filesystem provider. */
readonly workspaceUri: string
/** Static `initialize` options forwarded to the server. */
readonly initializationOptions: unknown
/** Graceful `shutdown`/`exit` budget before escalation (ms). */
@@ -108,9 +109,11 @@ export class LspInstance {
private async initialize(): Promise<void> {
const initializeResult = await this.connection.request('initialize', {
processId: process.pid,
rootUri: pathToFileURL(this.spec.cwd).href,
workspaceFolders: [{ uri: pathToFileURL(this.spec.cwd).href, name: 'workspace' }],
// A subprocess provider may run in another PID namespace or machine;
// the host PID would let the server monitor an unrelated process.
processId: null,
rootUri: this.spec.workspaceUri,
workspaceFolders: [{ uri: this.spec.workspaceUri, name: 'workspace' }],
capabilities: CLIENT_CAPABILITIES,
initializationOptions: this.spec.initializationOptions,
}) as WireInitializeResult
@@ -147,7 +150,7 @@ export class LspInstance {
throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION')
}
const uri = pathToFileURL(source.canonicalPath).href
const uri = source.fileUrl
let opened = false
try {
/* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */
@@ -16,8 +16,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
const seamLib = join(pkgDir, '../lsp/lib/index.js')
const fsLib = join(pkgDir, '../../fs/fs-local/lib/index.js')
const subprocessLib = join(pkgDir, '../../subprocess/subprocess-local/lib/index.js')
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) && existsSync(subprocessLib)
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) && existsSync(fsLib) && existsSync(subprocessLib)
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -42,10 +43,12 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
const { Context } = await import('cordis')
const { default: Lsp } = await import('@deepseek-ai/dsh-lsp')
const LspLocal = await import('@deepseek-ai/dsh-lsp-local')
const { default: LocalFileSystem } = await import('@deepseek-ai/dsh-fs-local')
const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local')
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await ctx.plugin(LspLocal, {
servers: {
fake: {
+46 -22
View File
@@ -4,7 +4,10 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { realpath } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { Context } from 'cordis'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { deadline } from '@deepseek-ai/dsh-timeout'
import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local'
@@ -12,84 +15,105 @@ const execFileAsync = promisify(execFile)
let root: string
let ws: string
let ctx: Context
let fs: LocalFileSystem
beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-host-')))
ws = join(root, 'ws')
await mkdir(ws)
ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: root })
fs = ctx.fs as LocalFileSystem
})
afterEach(async () => {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
})
const BIG = 1_000_000
async function workspace() {
return await canonicalizeWorkspace(fs, ws)
}
async function readSource(filePath: string, maxBytes = BIG, signal?: AbortSignal) {
return await readHostSource(fs, filePath, await workspace(), maxBytes, signal)
}
describe('canonicalizeWorkspace', () => {
it('returns the realpath of a directory', async () => {
expect(await canonicalizeWorkspace(ws)).toBe(ws)
expect((await workspace()).canonicalPath).toBe(ws)
})
it('resolves a symlinked workspace to its target so aliases share identity', async () => {
const link = join(root, 'ws-link')
await symlink(ws, link)
expect(await canonicalizeWorkspace(link)).toBe(ws)
expect((await canonicalizeWorkspace(fs, link)).canonicalPath).toBe(ws)
})
it('rejects a missing workspace', async () => {
await expect(canonicalizeWorkspace(join(root, 'nope'))).rejects.toThrow(/cannot be resolved/)
await expect(canonicalizeWorkspace(fs, join(root, 'nope'))).rejects.toThrow(/not a directory/)
})
it('rejects a non-directory workspace', async () => {
const file = join(root, 'file.txt')
await writeFile(file, 'x')
await expect(canonicalizeWorkspace(file)).rejects.toThrow(/not a directory/)
await expect(canonicalizeWorkspace(fs, file)).rejects.toThrow(/not a directory/)
})
})
describe('readHostSource', () => {
it('reads a relative path against the workspace', async () => {
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
const source = await readHostSource('a.ts', ws, BIG)
expect(source.canonicalPath).toBe(join(ws, 'a.ts'))
const source = await readSource('a.ts')
expect(source.fileUrl).toBe(pathToFileURL(join(ws, 'a.ts')).href)
expect(source.text).toBe('const x = 1\n')
})
it('reads an absolute path inside the workspace', async () => {
const abs = join(ws, 'b.ts')
await writeFile(abs, 'b')
const source = await readHostSource(abs, ws, BIG)
expect(source.canonicalPath).toBe(abs)
const source = await readSource(abs)
expect(source.fileUrl).toBe(pathToFileURL(abs).href)
})
it('accepts a source reached through a symlink that stays inside the workspace', async () => {
await mkdir(join(ws, 'real'))
await writeFile(join(ws, 'real', 'c.ts'), 'c')
await symlink(join(ws, 'real'), join(ws, 'linked'))
const source = await readHostSource('linked/c.ts', ws, BIG)
expect(source.canonicalPath).toBe(join(ws, 'real', 'c.ts'))
const source = await readSource('linked/c.ts')
expect(source.fileUrl).toBe(pathToFileURL(join(ws, 'real', 'c.ts')).href)
})
it('rejects a source whose canonical path escapes the workspace via symlink', async () => {
const outside = join(root, 'outside.ts')
await writeFile(outside, 'secret')
await symlink(outside, join(ws, 'escape.ts'))
await expect(readHostSource('escape.ts', ws, BIG)).rejects.toThrow(/outside the workspace/)
await expect(readSource('escape.ts')).rejects.toThrow(/outside the workspace/)
})
it('rejects an absolute source outside the workspace', async () => {
const outside = join(root, 'out.ts')
await writeFile(outside, 'x')
await expect(readHostSource(outside, ws, BIG)).rejects.toThrow(/outside the workspace/)
await expect(readSource(outside)).rejects.toThrow(/outside the workspace/)
})
it('rejects a missing source', async () => {
await expect(readHostSource('nope.ts', ws, BIG)).rejects.toThrow(/cannot be resolved/)
await expect(readSource('nope.ts')).rejects.toThrow(/not found/)
})
it('wraps a provider failure while resolving the source', async () => {
const canonical = await workspace()
fs.resolve = async () => { throw 'raw resolve failure' }
await expect(readHostSource(fs, 'broken.ts', canonical, BIG))
.rejects.toThrow('source "broken.ts" cannot be resolved: raw resolve failure')
})
it('rejects a non-regular source (directory)', async () => {
await mkdir(join(ws, 'dir'))
await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
await expect(readSource('dir')).rejects.toThrow(/not a regular file/)
})
// Windows has no filesystem FIFO; the directory case above pins non-regular rejection there.
@@ -97,36 +121,36 @@ describe('readHostSource', () => {
const fifo = join(ws, 'pipe.ts')
await execFileAsync('mkfifo', [fifo])
using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')
await expect(readHostSource('pipe.ts', ws, BIG, d.signal)).rejects.toThrow(/not a regular file/)
await expect(readSource('pipe.ts', BIG, d.signal)).rejects.toThrow(/not a regular file/)
})
it('honors a pre-aborted source read before filesystem work', async () => {
const controller = new AbortController()
controller.abort(new Error('source read cancelled'))
await expect(readHostSource('missing.ts', ws, BIG, controller.signal)).rejects.toThrow(/source read cancelled/)
await expect(readSource('missing.ts', BIG, controller.signal)).rejects.toThrow(/source read cancelled/)
})
it('treats the workspace root itself as inside, then rejects it as non-regular', async () => {
// filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the
// directory then fails the regular-file check.
await expect(readHostSource('.', ws, BIG)).rejects.toThrow(/not a regular file/)
// The filesystem containment primitive accepts the workspace itself; the
// bounded read then rejects the directory as non-regular.
await expect(readSource('.')).rejects.toThrow(/not a regular file/)
})
it('rejects an oversized source', async () => {
await writeFile(join(ws, 'big.ts'), 'x'.repeat(100))
await expect(readHostSource('big.ts', ws, 10)).rejects.toThrow(/over the 10-byte limit/)
await expect(readSource('big.ts', 10)).rejects.toThrow(/10-byte limit/)
})
it('rejects a non-UTF-8 source', async () => {
await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00]))
await expect(readHostSource('bin.ts', ws, BIG)).rejects.toThrow(/not valid UTF-8/)
await expect(readSource('bin.ts')).rejects.toThrow(/invalid UTF-8|binary file/)
})
it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => {
// The literal replacement char is valid UTF-8; a fatal decoder must accept it (only malformed
// byte sequences are rejected).
await writeFile(join(ws, 'repl.ts'), 'const s = ""\n')
const source = await readHostSource('repl.ts', ws, BIG)
const source = await readSource('repl.ts')
expect(source.text).toBe('const s = ""\n')
})
})
+16 -1
View File
@@ -3,6 +3,8 @@ import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promi
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
import { encodeMessage } from '@deepseek-ai/dsh-lsp-local'
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
@@ -15,6 +17,8 @@ const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.u
let root: string
let ws: string
let ctx: Context
let fs: LocalFileSystem
let live: LspInstance[] = []
beforeEach(async () => {
@@ -22,11 +26,15 @@ beforeEach(async () => {
ws = join(root, 'ws')
await mkdir(ws)
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: root })
fs = ctx.fs as LocalFileSystem
})
afterEach(async () => {
for (const instance of live) await instance.dispose()
live = []
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
})
@@ -39,6 +47,7 @@ function makeInstance(
command: process.execPath,
args: [fixtureServer],
cwd: ws,
workspaceUri: pathToFileURL(ws).href,
env: { ...scrubbedParentEnv(), ...env },
configuration: { setting: 42 },
initializationOptions: { init: true },
@@ -58,7 +67,12 @@ function query(operation: LspProviderQuery['operation'] = 'goToDefinition'): Lsp
/** Run a query against an instance, reading the source first the way the provider does. */
async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'goToDefinition', signal?: AbortSignal): Promise<LspQueryResult> {
const source = await readHostSource('a.ts', ws, 4_000_000)
const workspace = {
target: await fs.resolve(ws),
canonicalPath: ws,
fileUrl: pathToFileURL(ws).href,
}
const source = await readHostSource(fs, 'a.ts', workspace, 4_000_000)
return instance.query(query(operation), source, signal)
}
@@ -68,6 +82,7 @@ function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}):
command: process.execPath,
args: ['-e', script],
cwd: ws,
workspaceUri: pathToFileURL(ws).href,
env: scrubbedParentEnv(),
configuration: null,
initializationOptions: null,
@@ -8,6 +8,7 @@ import { Context } from 'cordis'
import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { deadline } from '@deepseek-ai/dsh-timeout'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
@@ -47,6 +48,7 @@ async function mount(
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
const registrationSpy = captureProvider === undefined
? undefined
@@ -79,6 +81,7 @@ describe('lsp-local end to end over a fake server', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await ctx.plugin(LspLocal, {
servers: {
typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }),
@@ -322,6 +325,7 @@ describe('lsp-local end to end over a fake server', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, {
servers: {
missing: {
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { Context } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
@@ -44,6 +45,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('onpath', {
command: 'fake-lsp',
args: [],
@@ -57,6 +59,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('nope', {
command: 'fake-lsp',
args: [],
@@ -71,6 +74,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
// Grab the provider instance by registering, then dispose the whole plugin fiber.
const lsp = ctx.lsp
const fiber = await ctx.plugin(LspLocal, config('disp', {
@@ -88,6 +92,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('bad-budget', {
command: process.execPath,
args: ['-e', ''],
@@ -101,6 +106,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('bad-cap', {
command: process.execPath,
args: ['-e', ''],
@@ -114,6 +120,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('bad-timer', {
command: process.execPath,
args: ['-e', ''],
@@ -130,6 +137,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('abs-bad', {
command: notExe,
args: [],
@@ -142,6 +150,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('abs-directory', {
command: ws,
args: [],
@@ -154,6 +163,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/)
await ctx.fiber.dispose()
})
@@ -162,6 +172,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('', {
command: process.execPath,
extensionToLanguage: { '.ts': 'typescript' },
@@ -173,6 +184,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, {
servers: {
valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
@@ -187,6 +199,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, {
servers: {
first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
@@ -11,6 +11,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
@@ -54,6 +55,7 @@ beforeAll(async () => {
ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await ctx.plugin(LspLocal, {
servers: {
typescript: {
+3
View File
@@ -26,6 +26,9 @@
{
"path": "../../llm/llm"
},
{
"path": "../../fs/fs"
},
{
"path": "../lsp"
},
+2
View File
@@ -38,11 +38,13 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
"@deepseek-ai/dsh-lsp-local": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import Lsp from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
@@ -50,6 +51,7 @@ async function mount(hang: boolean, timeoutMs?: number): Promise<Context> {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(Lsp)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {