refactor(runtime): compose consumers over fs and subprocess
This commit is contained in:
@@ -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,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.
|
||||
@@ -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,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
|
||||
@@ -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.
|
||||
@@ -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 进程会占用内存直到释放。
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs"
|
||||
},
|
||||
{
|
||||
"path": "../lsp"
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user