Merge remote-tracking branch 'origin/feat/windows-pwsh-default' into feat/windows-acl-sandbox

# Conflicts:
#	docs/module-graph.md
#	knip.json
#	packages/pty/pty-local/tests/index.spec.ts
#	scripts/check-workspace-constraints.ts
This commit is contained in:
Huanqi Cao
2026-08-09 00:29:55 +08:00
286 changed files with 15528 additions and 1684 deletions

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/README.md
README.md: 229feae568ba6e40a9c633696097eff46fd5bc95
README.zh.md: b84aef020a7e3edf305df709d399fbc7b093b6a3
README.md: 98fc87a6b26a42c58bb63cba96cd6af384f3ec71
README.zh.md: 86a316dca77efd5e5eb59e3fca7d53890a34901d

View File

@@ -6,7 +6,7 @@ Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and fun
## Hierarchy
Packages live at `packages/<group>/<pkg>/`; groups are containers, while names remain `@deepseek-ai/dsh-<pkg>`. **Each group README is the canonical package/ctx-key map.**
Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Group READMEs own package/ctx-key maps.**
| Group | Role | Release expectation |
|---|---|---|
@@ -16,6 +16,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface |
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`e2b/`](e2b/README.md) | E2B providers | POC |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |

View File

@@ -6,7 +6,7 @@
## 层级结构
`packages/<group>/<pkg>/`组是容器,包名仍为 `@deepseek-ai/dsh-<pkg>`。**每个组 README 是规范的ctx 键映射。**
按组置`packages/<group>/<pkg>/`;包名仍为 `@deepseek-ai/dsh-<pkg>`。**组 README 负责ctx 键映射。**
| 组 | 职责 | 发布预期 |
|---|---|---|
@@ -16,6 +16,7 @@
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定表面 |
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定表面 |
| [`llm/`](llm/README.md) | LLM大语言模型能力系列抽象服务 + 提供方适配器 | 产品:稳定表面 |
| [`e2b/`](e2b/README.md) | E2B 提供方 | POC |
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列spawn seam + 本地进程树实现 | 产品:稳定表面 |
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 |

View File

@@ -119,6 +119,8 @@ describe('spawn construction (pure, every platform)', () => {
/** A subprocess service that records spawn specs and settles instantly. */
class CapturingSubprocessService extends SubprocessService {
specs: SubprocessSpawnSpec[] = []
override async resolveExecutable(command: string): Promise<string> { return command }
override spawnTerminal(): Promise<never> { throw new Error('pwsh spawns pipes, never terminals') }
private readonly reader: SubprocessOutputReader = {
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
}

View File

@@ -2203,6 +2203,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
prompt: request => Promise.resolve(ok(request, {
messageId: `fixture-message-${request.payload.childSessionId}` as never,
})),
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
@@ -2748,6 +2749,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'subagent.list': return this.api.subagents.list(request)
case 'subagent.history': return this.api.subagents.history(request)
case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
case 'subagent.interrupt': return this.api.subagents.interrupt(request)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)

View File

@@ -126,6 +126,9 @@ export class FakeApiClient implements IApiClient {
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
messageId: 'fake-message' as never,
}))),
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({
accepted: true as const,
}))),
}
readonly host: IApiClient['host'] = {

View File

@@ -281,17 +281,22 @@ export class Session implements SessionFace {
/**
* Stop the active turn while the Host preserves pending inbox work; failures
* land in promptError (same error-strip display slot).
* land in promptError (same error-strip display slot). A continuable
* subagent address routes through `subagent.interrupt`, whose durable
* parent-address authority works without a live parent Agent; a one-shot
* address stays uncancellable (the UI offers no stop action, so this arm is
* defensive).
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {
if (this.address !== undefined) {
const address = this.address
if (address !== undefined && address.mode === 'one-shot') {
const result: RpcResult<{ accepted: true }> = {
ok: false,
error: {
code: 'subagent-delivery-unavailable',
message: 'subagent activation cancellation is unavailable',
details: { childSessionId: this.address.childSessionId },
details: { childSessionId: address.childSessionId },
},
}
this.promptError = { op: 'stop', error: result.error }
@@ -300,7 +305,9 @@ export class Session implements SessionFace {
}
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
result = address !== undefined
? (await this.api.subagents.interrupt(address)).result
: (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
} catch (error) {
result = transportError(error)
}

View File

@@ -140,10 +140,14 @@ export class FakeApiClient implements IApiClient {
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
= () => Promise.resolve(ok({ accepted: true as const }))
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)),
}
readonly host: IApiClient['host'] = {

View File

@@ -639,7 +639,7 @@ describe('paging', () => {
})
describe('prompt and cancel errors', () => {
it('routes an addressed child through non-activating history and continuation prompt only', async () => {
it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
@@ -650,7 +650,7 @@ describe('prompt and cancel errors', () => {
const cancelled = await session.cancel()
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 },
])
@@ -660,15 +660,37 @@ describe('prompt and cancel errors', () => {
content: [{ type: 'text', text: '继续' }],
},
])
expect(api.callsOf('subagent.interrupt')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
])
expect(api.callsOf('session.history')).toEqual([])
expect(api.callsOf('session.prompt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
// A successful interrupt leaves no stop error behind.
expect(session.getSnapshot().promptError).toBeNull()
expect(session.getSnapshot().subagent).toEqual({
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
})
it('lands an interrupt business failure in promptError with op=stop', async () => {
const api = new FakeApiClient()
api.onSubagentInterrupt = () => Promise.resolve(err({
code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
}) as never)
const session = new Session(SID, api, {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
await session.open()
const cancelled = await session.cancel()
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } })
expect(session.getSnapshot().promptError).toMatchObject({
op: 'stop', error: { code: 'subagent-unauthorized' },
})
})
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
@@ -676,12 +698,16 @@ describe('prompt and cancel errors', () => {
})
await session.open()
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
const cancelled = await session.cancel()
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([])
expect(api.callsOf('subagent.interrupt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
})
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {

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/client/ui-conversation/README.md
README.md: 3e3a6b1a09cbed77700fb656882178efd3744a80
README.zh.md: f3f25426156b3778859ec8f64e8d947362b967ab
README.md: f5cf9b72b4f8f4ce7b1a07c0c1e30f5213edf678
README.zh.md: 90c4dc4cc169b2cbcd7e94275a692e3d062cfe98

View File

@@ -34,7 +34,7 @@ Keyboard message submission resolves delivery from the addressed session's runni
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.

View File

@@ -34,7 +34,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher而非附件入口它要求当前会话的 `SlashController` 基于 textarea 当前 selection只打开 `/` trigger 的 `command` source同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 均缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher而非附件入口它要求当前会话的 `SlashController` 基于 textarea 当前 selection只打开 `/` trigger 的 `command` source同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 均缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM大语言模型和工具的墙钟时间这些是关于「屏幕上有什么」的窗口作用域事实而非账目压缩compaction使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。

View File

@@ -17,6 +17,7 @@ export const zh = {
'placeholder.plan': PLAN_NEXT_ACTION_ZH,
'placeholder.default': '给智能体发消息',
'placeholder.unavailable': '会话不可用',
'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行',
'placeholder.hero': '描述你想要构建的内容',
'placeholder.workspace': '选择一个工作区开始',
'input.commands': '命令',
@@ -159,6 +160,7 @@ export const en = {
'placeholder.plan': PLAN_NEXT_ACTION_EN,
'placeholder.default': 'Message the agent',
'placeholder.unavailable': 'Session unavailable',
'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run',
'placeholder.hero': 'Describe what you want to build',
'placeholder.workspace': 'Choose a workspace to start',
'input.commands': 'Commands',

View File

@@ -10,7 +10,7 @@
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
viewport bottom inside the centered message column; textarea on top, action
row below, one primary circle button bottom-right. Input width rides the
row below, primary action controls bottom-right. Input width rides the
column (--dsh-composer-card-max-width = chat content + 32px, 16px per side,
is a cap, not a fixed size — layout rule: the box shrinks with the center
column keeping its clearance). Hero variant = the same card centered in the

View File

@@ -83,11 +83,15 @@ export function InputBar({
// (undefined = capability absent → the chip renders nothing).
const permissions = useProjection('permissions')
// A continuable child without its live parent cannot accept human input,
// but its independent Stop below stays available while it runs.
const continuable = subagent?.address.mode === 'continuable'
const parentOffline = continuable && !subagent.parentAvailable
// Queue cut 1: running input stays free; locked = session removed, the
// inert no-workspace state, or the machine faces absent (no session). The
// transient machine locks (adjudicating pending / submitting) render
// read-only the draft stays visible and focused, keystrokes drop.
const disabled = removed || inert || !live || blocked !== undefined
// inert no-workspace state, the machine faces absent (no session), or a
// parent-offline continuable child. An owner block also disables input;
// adjudicating and submitting render read-only so the draft stays visible.
const disabled = removed || inert || !live || blocked !== undefined || parentOffline
const locked = disabled
// The model seat is the ONE control a block leaves live: every block this
// contract has is cleared by choosing a model, so locking it too would leave
@@ -350,11 +354,14 @@ export function InputBar({
if (el !== null) toggleCommandMenu?.(selectionOf(el))
}
const ordinary = subagent === null
const stopping = running && ordinary
const primaryLabel = stopping ? t('input.stop') : t('input.send')
// Ordinary sessions retain their primary Send/Stop toggle. A continuable
// child keeps Send as the primary action and exposes Stop independently so
// pointer users can queue follow-ups while its current turn is running.
const primaryStops = running && subagent === null
const interruptible = running && continuable
const primaryLabel = primaryStops ? t('input.stop') : t('input.send')
const onPrimary = (): void => {
if (stopping) {
if (primaryStops) {
stop?.()
return
}
@@ -478,9 +485,11 @@ export function InputBar({
disabled={locked}
readOnly={machineBusy}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
placeholder={placeholder ?? (parentOffline
? t('placeholder.parentOffline')
: disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}
@@ -521,16 +530,32 @@ export function InputBar({
{renderSlot('conversation.input.model', { locked: modelSeatLocked })}
<ContextMeter useProjection={useProjection} t={t} />
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
{interruptible && (
<Tooltip label={t('input.stop')} side="top" delayMs={500}>
<button
type="button"
className={css.primary}
aria-label={t('input.stop')}
disabled={stop === undefined}
onMouseDown={keepFocus}
onClick={stop}
>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>
</button>
</Tooltip>
)}
<Tooltip label={primaryLabel} side="top" delayMs={500}>
<button
type="button"
className={css.primary}
aria-label={primaryLabel}
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{stopping ? (
{primaryStops ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
// semantics (input stays free; primary turns stop), the machine pending lock,
// semantics (input stays free; continuable children keep Send beside Stop), the machine pending lock,
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
@@ -143,11 +143,14 @@ function bench(over?: BenchOptions) {
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
const stopping = over?.running === true && over.subagent === undefined
const primaryStops = over?.running === true && over.subagent === undefined
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
`button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`,
)!
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
return {
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher,
}
}
describe('Enter semantics', () => {
@@ -250,8 +253,8 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue')
})
it('running subagent primary admits a follow-up instead of exposing Stop', () => {
const { button, sink, stop } = bench({
it('running continuable subagent keeps Send beside an independent Stop', () => {
const { button, interruptButton, textarea, sink, stop } = bench({
running: true,
draft: '后续消息',
subagent: {
@@ -264,22 +267,53 @@ describe('running and lock semantics (queue cut 1)', () => {
},
})
expect(button.getAttribute('aria-label')).toBe('发送消息')
expect(interruptButton).not.toBeNull()
expect(textarea.disabled).toBe(false)
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('后续消息', 'queue')
expect(stop).not.toHaveBeenCalled()
fireEvent.click(interruptButton!)
expect(stop).toHaveBeenCalledTimes(1)
})
const empty = bench({
it('parent-offline running continuable locks Send but keeps independent Stop usable', () => {
const { button, interruptButton, textarea, stop, view } = bench({
running: true,
draft: '',
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable',
},
parentAvailable: false,
},
})
expect(textarea.disabled).toBe(true)
expect(textarea.placeholder).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
expect(button.getAttribute('aria-label')).toBe('发送消息')
expect(button.disabled).toBe(true)
expect(interruptButton?.disabled).toBe(false)
fireEvent.click(interruptButton!)
expect(stop).toHaveBeenCalledTimes(1)
})
it('running one-shot subagent never exposes Stop', () => {
const { button, interruptButton, stop } = bench({
running: true,
draft: '不可停止',
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'one-shot',
},
parentAvailable: true,
},
})
expect(empty.button.disabled).toBe(true)
expect(button.getAttribute('aria-label')).toBe('发送消息')
expect(interruptButton).toBeNull()
expect(stop).not.toHaveBeenCalled()
})
it('keeps both running subagent Enter gestures on Queue transport', () => {

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/client/ui-subagent/README.md
README.md: cb210b219a8c66985eb4e1370468372eed9614b4
README.zh.md: 857e92d05a7ed2d0df9398acc9698db13b0c6eb2
README.md: abd94eb7a3b5e7c4d6d79f7a2ed5f95d471e64e2
README.zh.md: 8686902819918c9637a8aa87d5135135a8dd4996

View File

@@ -6,7 +6,7 @@ Web subagent feature owner: contributes the lazily expandable catalog tree to `c
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable and the child is not running, with copy explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer, whose input and Send action are disabled but whose independent Stop stays usable, and the takeover returns once it stops. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes prompts through `subagent.prompt`: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, while an independent Stop routes through `subagent.interrupt`. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) and the [current-turn interrupt Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md).
Subagent-origin Session rows are omitted from the ordinary sidebar, so the parent header catalog is their navigation entry point. Ordinary forks remain in the sidebar.
@@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens.
## Known Limitations and Deferred Work
- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes neither Activation identity nor an authority-safe cancel button.
- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes no Activation identity; stopping is limited to the composer's current-turn Stop for a running continuable child.
- **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics.

View File

@@ -6,7 +6,7 @@ Web subagent 功能 owner向 `conversation.session.header.actions` 贡献可
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running``inactive` 活动状态和由日志支撑的可选 title尾随列则在上行显示提供方的持久化 token 用量总计在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRightArrowLeft 展开和折叠分支ArrowUpArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
one-shot child 始终选用只读编辑器,并将 transcript文本记录说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome其会话通过 `subagent.prompt` 路由child 运行期间输入操作仍为 Send因为每条后续消息都会进入 child 的 FIFO inbox且已寻址会话绝不公开 Stop。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) 规定。
one-shot child 始终选用只读编辑器,并将 transcript文本记录说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器,并以文案说明恢复路径;此类 child 仍在运行期间selector 会让位给普通编辑器——其输入区与 Send 操作被禁用,但独立的 Stop 保持可用,停止后只读替代恢复。确切 parent 存活时,可继续 child 保留普通输入 chrome其会话通过 `subagent.prompt` 路由提示词:child 运行期间输入 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox而独立的 Stop 经由 `subagent.interrupt` 路由。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) 与[当前轮次中断 Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md) 规定。
普通侧边栏会省略带 subagent origin 的会话行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。
@@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript文本记录
## 已知限制与暂缓事项
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 不公开 Activation 身份,也不公开符合授权边界的取消按钮
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 不公开 Activation 身份;停止能力仅限编辑器上针对运行中可继续 child 的当前轮次 Stop
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。

View File

@@ -43,7 +43,11 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc
const subagent = owner.session?.subagent
if (subagent === undefined || subagent === null) return null
if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' }
return subagent.parentAvailable ? null : { reason: 'parent-unavailable' }
if (subagent.parentAvailable) return null
// A RUNNING parent-offline continuable child keeps the default composer:
// its input is disabled there, but the same primary Stop stays available so
// the child can be interrupted. Once it stops, this takeover returns.
return owner.session?.running === true ? null : { reason: 'parent-unavailable' }
}
/**

View File

@@ -161,19 +161,26 @@ describe('apply', () => {
const select = composerEntry.select as (owner: ComposerChainProps) => SubagentReadOnlyMatch | null
const owner = (
subagent: ConversationSnapshot['subagent'] | undefined,
running = false,
): ComposerChainProps => ({
interactions: [],
session: subagent === undefined
? undefined
: ({ subagent } as unknown as ConversationSnapshot),
: ({ subagent, running } as unknown as ConversationSnapshot),
})
expect(select(owner(undefined))).toBeNull()
expect(select(owner(null))).toBeNull()
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true })))
.toEqual({ reason: 'one-shot' })
// One-shot stays read-only even while running: it has no stop action.
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true)))
.toEqual({ reason: 'one-shot' })
expect(select(owner({ address, parentAvailable: true }))).toBeNull()
expect(select(owner({ address, parentAvailable: false })))
.toEqual({ reason: 'parent-unavailable' })
// A RUNNING parent-offline continuable yields the default composer, whose
// disabled input still carries the primary Stop; stopped, it takes back over.
expect(select(owner({ address, parentAvailable: false }, true))).toBeNull()
})
})

View File

@@ -75,6 +75,14 @@ class RecordingFileSystem extends FileSystem {
return { targetKey: FsTargetKey(absolute), displayPath: absolute }
}
override processPath(target: FsTarget): string { return String(target.targetKey) }
override fileUrl(target: FsTarget): string { return `file://${target.targetKey}` }
override contains(parent: FsTarget, child: FsTarget): boolean {
return child.targetKey === parent.targetKey || String(child.targetKey).startsWith(`${parent.targetKey}/`)
}
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
if (signal !== undefined) this.signals.push(signal)
signal?.throwIfAborted()

View File

@@ -304,6 +304,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'e2b',
summary: 'Creates one lazily consumable E2B SDK handle and deletes the sandbox at timeout or disposal.',
methods: [
{
signature: 'async getSandbox(): Promise<Sandbox>',
jsDoc: '/**\n * Return the shared live SDK handle.\n * @returns the created sandbox after the configured cwd exists.\n * @throws when E2B rejects creation or the service is disposing.\n */',
},
],
},
{
key: 'fs',
summary: 'Abstract filesystem provider.',
@@ -312,6 +322,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>',
jsDoc: '/**\n * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a\n * remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence\n * async even though the local backend only normalizes + realpaths.\n *\n * @param path - the path to resolve; relative paths resolve against `opts.cwd`.\n * @param opts - optional cwd override and cancellation signal.\n * @returns the stable target; the same file yields the same `targetKey`.\n */',
},
{
signature: 'abstract processPath(target: FsTarget): string',
jsDoc: '/**\n * Return the canonical absolute path a subprocess in this filesystem\'s\n * execution world can open. The path is deliberately separate from\n * {@link FsTarget.targetKey}: consumers may pass this value to another OS\n * capability, but must continue treating the target key as opaque.\n * @param target - the resolved target whose process path is required.\n * @returns an absolute path in the backend\'s execution world.\n */',
},
{
signature: 'abstract fileUrl(target: FsTarget): string',
jsDoc: '/**\n * Return the canonical `file:` URI for a target in this filesystem\'s\n * execution world. Backends own URI encoding because the host platform may\n * differ from the execution platform.\n * @param target - the resolved target to encode.\n * @returns the target\'s canonical file URI.\n */',
},
{
signature: 'abstract contains(parent: FsTarget, child: FsTarget): boolean',
jsDoc: '/**\n * Test canonical containment without exposing or parsing backend target\n * keys. Both targets must come from this provider.\n * @param parent - canonical directory target.\n * @param child - canonical candidate target.\n * @returns true when `child` is `parent` or a descendant of it.\n */',
},
{
signature: 'abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>',
jsDoc: '/**\n * Return target metadata, or `undefined` when the target does not exist.\n * @param target - the resolved target to stat.\n * @param signal - aborts the metadata round-trip.\n * @returns metadata only, never content; undefined for an absent target.\n */',
@@ -928,6 +950,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise<MessageId>',
jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so every accepted message has\n * one observable order.\n * @param parent - the exact live direct parent authorizing this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, parent authority is\n * rejected, or the message was not admitted.\n */',
},
{
signature: 'interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void',
jsDoc: '/**\n * Interrupt one live continuable child\'s current turn under a human parent\n * address or an exact live ancestor Agent. Fire-and-return: the cancel\n * signal is issued before this returns, but the target may keep running\n * until it observes the signal. Unclaimed pending inbox work, the Activation,\n * and published descendants are preserved; claimed work is not requeued.\n * Once the interrupted driver is idle, a waking send resumes the parked FIFO\n * queue. An absent target — including a one-shot or unknown id —\n * is an accepted no-op, as is a manager-less composition, which cannot own a\n * live Activation.\n * @param targetSessionId - the durable child session id to interrupt.\n * @param authority - the human parent address or exact live ancestor Agent.\n * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the\n * live target.\n */',
},
{
signature: 'async reportFrom( child: Agent, content: ContentBlock[], options: SubagentReportOptions, ): Promise<MessageId>',
jsDoc: '/**\n * Deliver selected content from one live continuable child to its durable\n * direct parent. The child is the authority credential; callers cannot name a\n * recipient. Reporting does not conclude the child\'s turn or Activation.\n * @param child - exact live reporting child.\n * @param content - selected model-facing content.\n * @param options - parent scheduling and pre-acceptance cancellation.\n * @returns the stable identity of the parent-accepted message.\n * @throws when continuation services are unavailable, sender authorization\n * fails, or the direct parent is not live.\n */',
@@ -944,6 +970,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>',
jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit down a three-rung ladder — the registry\'s watermark\n * snapshot for a live child; for a cold one, a durable projection-cache\n * row when the optional cache serves an own-suffix identity (its `seq`\n * gate proves the value postdates the fork seed, where a child\'s own\n * descriptor is immutable once appended), else one persistence inspection\n * folded through the registry. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n * and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n * store is not mounted, or the caller cancels the listing.\n */',
},
{
signature: 'listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>',
jsDoc: '/**\n * Enumerate the root\'s complete session-backed subagent tree in stable\n * pre-order from one live-preferred corpus, without loading or resuming an\n * Agent. Ordinary sessions and one-shot children remain traversal nodes so\n * continuable descendants below them are discovered; each returned entry\n * adds its durable `parentId` and root-relative `depth`. Identity resolution,\n * diagnostics, optional persistence, and cancellation follow the same\n * projection-backed contract as {@link listChildren}.\n * @param rootSessionId - session whose complete descendant tree is listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n * and observed around every read await.\n * @returns children and per-candidate diagnostics with tree position, in\n * stable pre-order.\n * @throws {@link SubagentError} under the same conditions as {@link listChildren}.\n */',
},
{
signature: 'registerProvider(provider: SubagentProvider): () => void',
jsDoc: '/**\n * Register a provider under its name. Registration is effect-scoped and HMR\n * safe; removing a provider blocks new starts but does not revoke runs that\n * were already returned to their holders.\n * @param provider - the trusted provider implementation.\n * @returns the exact Cordis effect disposer.\n */',
@@ -966,10 +996,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
key: 'subprocess',
summary: 'Abstract subprocess service.',
methods: [
{
signature: 'abstract resolveExecutable( command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal, ): Promise<string>',
jsDoc: '/**\n * Resolve one configured executable in this provider\'s execution world.\n * Absolute paths are verified; bare names use the provider\'s scrubbed PATH\n * plus explicit environment overrides. Relative paths containing separators\n * are rejected: no current consumer defines which directory they would\n * resolve against, so providers fail loud instead of guessing.\n * @param command - absolute executable path or bare PATH name.\n * @param env - explicit environment entries used for lookup.\n * @param signal - aborts remote or local lookup.\n * @returns a canonical executable path.\n */',
},
{
signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle',
jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.\n * @returns the live process handle (streams/readers, signalling, outcome promise).\n */',
},
{
signature: 'abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>',
jsDoc: '/**\n * Allocate a real terminal and start one owned process session. This is the\n * only non-pipe process primitive: implementations own terminal byte I/O,\n * foreground groups, signals, and complete session-tree cleanup.\n * @param spec - fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\n * @returns the live terminal handle after allocation succeeds.\n */',
},
],
},
{
@@ -2779,6 +2817,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubagentCapabilities',
declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}',
},
{
name: 'SubagentDescendantListEntry',
declaration: 'export type SubagentDescendantListEntry = SubagentListEntry & {\n readonly parentId: SessionId;\n readonly depth: number;\n};',
},
{
name: 'SubagentDescriptorData',
declaration: 'export type SubagentDescriptorData = OneShotSubagentDescriptorData | ContinuableSubagentDescriptorData;',
@@ -2787,6 +2829,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubagentFollowupOptions',
declaration: 'export interface SubagentFollowupOptions {\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n}',
},
{
name: 'SubagentInterruptAuthority',
declaration: 'export type SubagentInterruptAuthority = {\n readonly kind: \'user\';\n readonly parentSessionId: SessionId;\n} | {\n readonly kind: \'ancestor\';\n readonly agent: Agent;\n};',
},
{
name: 'SubagentListEntry',
declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n readonly hasChildren: boolean;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};',
@@ -2863,6 +2909,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubprocessStdio',
declaration: 'export interface SubprocessStdio {\n stdin: SubprocessStdinMode;\n stdout: SubprocessOutputMode;\n stderr: SubprocessOutputMode;\n}',
},
{
name: 'SubprocessTerminalForeground',
declaration: 'export interface SubprocessTerminalForeground {\n processGroupId: number;\n inputWaiting: boolean;\n}',
},
{
name: 'SubprocessTerminalHandle',
declaration: 'export interface SubprocessTerminalHandle {\n readonly pid: number;\n readonly output: Readable;\n readonly done: Promise<SubprocessOutcome>;\n write(data: string): Promise<void>;\n inspectForeground(): Promise<SubprocessTerminalForeground | undefined>;\n signalForeground(signal: SubprocessTerminalSignal): Promise<number>;\n terminate(): Promise<void>;\n}',
},
{
name: 'SubprocessTerminalSignal',
declaration: 'export type SubprocessTerminalSignal = \'SIGINT\' | \'SIGTERM\' | \'SIGKILL\' | \'SIGTSTP\' | \'SIGHUP\';',
},
{
name: 'SubprocessTerminalSpawnSpec',
declaration: 'export interface SubprocessTerminalSpawnSpec {\n argv: readonly string[];\n cwd: string;\n env?: Record<string, string> | undefined;\n rows: number;\n cols: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n}',
},
{
name: 'SurfaceEvent',
declaration: 'export type SurfaceEvent = SessionEvent<SurfaceEventType> & {\n surfaceOp: SurfaceOp;\n};',

View File

@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
@@ -49,6 +49,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
expect(bash?.sources.bash).toBe('packages/bash/tool-bash/src/index.ts')
const control = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent-control')
expect(control?.sources).toEqual({
interrupt_agent: 'packages/subagent/tool-subagent-control/src/index.ts',
list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts',
send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
})

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/e2b/README.md
README.md: f9758e2748aaa2acffb0e928752b2b0fb70cd0a4
README.zh.md: de068c16d4309499f5dcaa2d08cd9b6cc3023d94

15
packages/e2b/README.md Normal file
View File

@@ -0,0 +1,15 @@
# e2b/ — E2B remote runtime family
English | [中文](README.zh.md)
An experimental provider-composition POC that places one filesystem/process execution world in an E2B Linux sandbox. E2B supplies only sandbox lifecycle and the two fundamental OS adapters; provider-neutral consumers build higher capabilities above them.
| Package | ctx key | Role |
|---|---|---|
| [`e2b`](e2b/README.md) (`@deepseek-ai/dsh-e2b`) | `ctx.e2b` | Create one sandbox, prepare its working/runtime directories, expose the shared SDK handle, and delete it on timeout or disposal |
| [`fs-e2b`](fs-e2b/README.md) (`@deepseek-ai/dsh-fs-e2b`) | `ctx.fs` | Implement the filesystem seam over E2B Filesystem APIs |
| [`subprocess-e2b`](subprocess-e2b/README.md) (`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | Implement executable lookup, managed process groups and stdio, remote spill files, and terminal sessions over E2B Commands and PTY APIs |
The existing [`dsh-bash-local`](../bash/bash-local/README.md), [`dsh-pty-local`](../pty/pty-local/README.md), and [`dsh-lsp-local`](../lsp/lsp-local/README.md) need no E2B-specific forks. They delegate every execution-world operation to `ctx.fs` and `ctx.subprocess`, so mounting the two E2B adapters places their mutable work in the same sandbox.
This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, higher-level protocol state, or E2B SDK buffers. The [portable execution-world decision](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md) owns both the generic composition and this POC boundary.

15
packages/e2b/README.zh.md Normal file
View File

@@ -0,0 +1,15 @@
# e2b/ — E2B 远程运行时家族
[English](README.md) | 中文
这是一个实验性提供方组合 POC把一个文件系统进程执行环境放进 E2B Linux 沙箱。E2B 只提供沙箱生命周期与两个基础 OS 适配器;提供方无关的消费方在其上构建更高层能力。
| 包package | ctx 键 | 职责 |
|---|---|---|
| [`e2b`](e2b/README.md)`@deepseek-ai/dsh-e2b` | `ctx.e2b` | 创建一个沙箱,准备其工作目录与运行时目录,公开共享 SDK 句柄,并在超时或资源释放时将其删除 |
| [`fs-e2b`](fs-e2b/README.md)`@deepseek-ai/dsh-fs-e2b` | `ctx.fs` | 通过 E2B Filesystem API 实现文件系统 seam |
| [`subprocess-e2b`](subprocess-e2b/README.md)`@deepseek-ai/dsh-subprocess-e2b` | `ctx.subprocess` | 通过 E2B Commands 与 PTY API 实现可执行文件查找、受管进程组与 stdio、远程 spill 文件及终端会话 |
现有的 [`dsh-bash-local`](../bash/bash-local/README.md)、[`dsh-pty-local`](../pty/pty-local/README.md) 和 [`dsh-lsp-local`](../lsp/lsp-local/README.md) 无需 E2B 专用 fork。它们把执行环境中的所有操作委托给 `ctx.fs``ctx.subprocess`,因此挂载这两个 E2B 适配器后,它们执行的可变操作都发生在同一个沙箱内。
该边界不会迁移 harness 进程、Cordis 对象、模型调用、agent智能体会话状态、会话持久化、skill技能、更高层协议状态或 E2B SDK 缓冲。[可移植执行世界决策](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md)同时界定通用组合和此 POC 边界。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/e2b/e2b/README.md
README.md: 7ade7c3d6522d8fa6d54d7011766238451b17c9a
README.zh.md: b683f33d2211106cf422e780b2b37904b0c640ad

View File

@@ -0,0 +1,44 @@
# @deepseek-ai/dsh-e2b
English | [中文](README.zh.md)
Shared lifecycle owner for one E2B sandbox. The filesystem and subprocess adapters inject `ctx.e2b`, await its single SDK handle, and therefore inhabit the same remote Linux working tree and process world. The package pins `e2b@2.29.1`; the [family map](../README.md) lists the opt-in composition.
## Configuration
```yaml
- id: e2b
name: '@deepseek-ai/dsh-e2b'
config:
cwd: /home/user/workspace
timeoutMs: 300000
- id: subprocess-e2b
name: '@deepseek-ai/dsh-subprocess-e2b'
- id: fs-e2b
name: '@deepseek-ai/dsh-fs-e2b'
```
`apiKey` is optional and otherwise reads `E2B_API_KEY`; the key configures the host SDK connection and is never installed in the sandbox. `cwd` defaults to `/home/user/workspace` and must be an absolute POSIX path. `timeoutMs` defaults to five minutes and controls the sandbox lifetime; expiry deletes the sandbox.
## Lifecycle and ownership
Construction starts one sandbox creation. Before resolving `getSandbox()`, the service creates `cwd` and the private `cwd/.dsh-e2b` adapter-state directory, verifies that the reserved path is a real directory rather than a symlink or another file type, then sets it to mode `0700`. Each adapter-internal E2B command shell receives a fresh randomized root-level `HOME`, so the SDK's fixed login shell does not resolve profile files from the mutable user home before the control command.
Disposal first prevents new handle acquisition, then awaits setup and deletes the sandbox. A `SandboxNotFoundError` means expiry or another owner already deleted it and is accepted as quiescence. Initial directory setup failure makes one deletion attempt; the configured E2B timeout bounds a second failure. Provider plugins must load after this owner and dispose before it.
## Model Experience
None, as this shared runtime owner registers no model-visible context; provider adapters and their consumers own any rendered effects.
#### KV Cache effect
No direct invalidation; this package does not contribute request tokens.
## Known Limitations and Deferred Work
- **This is not a whole-harness runtime** — Cordis services, agent/session state, session logs, LLM requests, skills, and SDK-side buffers stay in the host process.
- **Sandbox state is ephemeral** — disposal and timeout delete the sandbox; reconnect, pause/leave retention, templates, volumes, and snapshots are outside this POC.
- **No deployment platform is configured** — network policy, host-workspace synchronization, and sandbox discovery are outside this POC.
- **`cwd` is a resolution convention, not containment** — adapters and commands can address other sandbox paths; E2B network access retains the base image's policy.

View File

@@ -0,0 +1,44 @@
# @deepseek-ai/dsh-e2b
[English](README.md) | 中文
一个 E2B 沙箱的共享生命周期所有者。文件系统与进程管理适配器注入 `ctx.e2b`,等待其唯一的 SDK 句柄,因此处于同一个远程 Linux 工作树与进程环境中。本包固定使用 `e2b@2.29.1`;可选组合见[包族索引](../README.md)。
## 配置
```yaml
- id: e2b
name: '@deepseek-ai/dsh-e2b'
config:
cwd: /home/user/workspace
timeoutMs: 300000
- id: subprocess-e2b
name: '@deepseek-ai/dsh-subprocess-e2b'
- id: fs-e2b
name: '@deepseek-ai/dsh-fs-e2b'
```
`apiKey` 可省略;省略时读取 `E2B_API_KEY`。该密钥只配置宿主 SDK 连接,绝不会安装进沙箱。`cwd` 默认为 `/home/user/workspace`,并且必须是绝对 POSIX 路径。`timeoutMs` 默认为 5 分钟并控制沙箱生命周期;超时会删除沙箱。
## 生命周期与所有权
构造阶段会启动一次沙箱创建。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。每个适配器内部的 E2B 命令 shell 都会获得一个位于根目录下、全新随机生成的 `HOME`,因此 SDK 固定使用的登录 shell 不会在控制命令之前解析可变用户主目录中的配置文件。
资源释放会先阻止继续获取新句柄,再等待初始化完成,然后删除沙箱。`SandboxNotFoundError` 表示沙箱已因超时或被另一个所有者删除,因此可视为完全停稳。初始目录设置失败时会尝试删除一次;若该尝试也失败,则由已配置的 E2B 超时约束沙箱的存活时间。提供方插件必须在该所有者之后加载,并在其之前 dispose资源释放
## 模型体验
无。本共享运行时所有者不注册模型可见上下文;提供方适配器及其消费方拥有所有渲染效果。
#### KV Cache 影响
不会直接失效;本包不会贡献请求 token。
## 已知限制与延后工作
- **这不是完整的 harness 运行时**Cordis 服务、agent智能体会话状态、会话日志、LLM大语言模型请求、skill技能和 SDK 侧缓冲仍留在宿主进程中。
- **沙箱状态是短暂的**资源释放和超时都会删除沙箱重新连接、pause/leave 保留、模板、卷和快照均不在本 POC 范围内。
- **没有配置部署平台**:网络策略、宿主工作区同步和沙箱发现均不在本 POC 范围内。
- **`cwd` 是解析约定,而不是包含边界**适配器和命令可以访问沙箱中的其他路径E2B 网络访问也继续采用基础镜像的策略。

View File

@@ -0,0 +1,40 @@
{
"name": "@deepseek-ai/dsh-e2b",
"description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"e2b": "2.29.1",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,182 @@
/**
* Shared ownership of one E2B sandbox. Capability adapters await the same SDK
* handle, so filesystem and process operations inhabit one remote Linux world.
* @module @deepseek-ai/dsh-e2b
*/
import { randomUUID } from 'node:crypto'
import { posix } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { FileType, Sandbox, SandboxNotFoundError } from 'e2b'
export {
CommandExitError,
FileNotFoundError,
FileType,
Sandbox,
SandboxNotFoundError,
} from 'e2b'
export type { CommandHandle, CommandResult, EntryInfo } from 'e2b'
/**
* Quote one opaque argument for the SDK's unavoidable `/bin/bash -l -c` layer.
* @param value - Exact argument value to preserve.
* @returns A single shell word with no interpolation.
*/
export function quoteE2BShellArg(value: string): string {
return `'${value.replaceAll('\'', "'\"'\"'")}'`
}
/**
* Isolate E2B's hard-coded login shell behind a fresh randomized home path.
* @param overrides - Additional environment entries for the internal command.
* @returns A fresh mutable map that the E2B SDK may extend.
*/
export function e2bControlEnvs(
overrides: Readonly<Record<string, string>> = {},
): Record<string, string> {
return { ...overrides, HOME: `/.dsh-e2b-control-${randomUUID()}` }
}
/** Configuration for the shared E2B sandbox owner. */
export interface Config {
/** API key; omission reads `E2B_API_KEY`. It is never forwarded into the sandbox. */
apiKey?: string
/** Shared remote working directory, created before adapters receive the sandbox. */
cwd?: string
/** E2B sandbox lifetime in milliseconds; expiry always deletes the sandbox. */
timeoutMs?: number
}
interface ResolvedConfig {
apiKey: string
cwd: string
timeoutMs: number
}
interface SchemaResolvedConfig extends Config {
cwd: string
timeoutMs: number
}
declare module 'cordis' {
interface Context {
e2b: E2BSandboxService
}
}
/**
* Creates one lazily consumable E2B SDK handle and deletes the sandbox at
* timeout or disposal. Creation begins at plugin construction; adapters await
* {@link getSandbox} before their first operation.
*/
export class E2BSandboxService extends Service {
static Config: z<Config> = z.object({
apiKey: z.string(),
cwd: z.string().default('/home/user/workspace'),
timeoutMs: z.number().default(300_000),
})
/** Validated remote working directory shared by provider adapters. */
readonly cwd: string
/** Remote directory reserved for adapter-owned process and terminal state. */
readonly runtimeRoot: string
private readonly config: ResolvedConfig
private readonly ready: Promise<Sandbox>
private disposed = false
constructor(ctx: Context, config: Config) {
super(ctx, 'e2b')
// Schemastery fills these fields before construction; the type does not encode that step.
const resolved = config as SchemaResolvedConfig
const apiKey = config.apiKey ?? process.env.E2B_API_KEY
this.config = {
apiKey: apiKey ?? '',
cwd: resolved.cwd,
timeoutMs: resolved.timeoutMs,
}
this.validate()
this.cwd = this.config.cwd
this.runtimeRoot = posix.join(this.cwd, '.dsh-e2b')
this.ready = this.open()
// A deployment may load the owner before any adapter uses it. Keep a
// failed eager connection observed; getSandbox() still returns the error.
void this.ready.catch(() => {})
ctx.effect(() => async () => {
this.disposed = true
let sandbox: Sandbox
try {
sandbox = await this.ready
} catch (_sandboxSetupFailure) {
// open() either acquired no sandbox or already made the POC's one rollback attempt.
return
}
try {
await sandbox.kill()
} catch (error: unknown) {
if (!(error instanceof SandboxNotFoundError)) throw error
}
}, 'e2b sandbox teardown')
}
/**
* Return the shared live SDK handle.
* @returns the created sandbox after the configured cwd exists.
* @throws when E2B rejects creation or the service is disposing.
*/
async getSandbox(): Promise<Sandbox> {
if (this.disposed) throw new Error('E2B sandbox service is disposing')
const sandbox = await this.ready
// Disposal can race the awaited sandbox readiness despite the synchronous precheck.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Awaiting readiness yields to disposal.
if (this.disposed) throw new Error('E2B sandbox service is disposing')
return sandbox
}
private validate(): void {
if (this.config.apiKey.length === 0) {
throw new Error('dsh-e2b: configure apiKey or set E2B_API_KEY')
}
if (!posix.isAbsolute(this.config.cwd)) {
throw new Error(`dsh-e2b: cwd must be an absolute Linux path: ${this.config.cwd}`)
}
if (!Number.isFinite(this.config.timeoutMs) || this.config.timeoutMs <= 0) {
throw new Error('dsh-e2b: timeoutMs must be a positive finite number')
}
}
private async open(): Promise<Sandbox> {
const sandbox = await Sandbox.create({
apiKey: this.config.apiKey,
timeoutMs: this.config.timeoutMs,
secure: true,
lifecycle: { onTimeout: 'kill' },
})
try {
await sandbox.files.makeDir(this.cwd)
await sandbox.files.makeDir(this.runtimeRoot)
const runtimeRoot = await sandbox.files.getInfo(this.runtimeRoot)
if (runtimeRoot.type !== FileType.DIR || runtimeRoot.symlinkTarget !== undefined) {
throw new Error(`dsh-e2b: runtime root must be a real directory: ${this.runtimeRoot}`)
}
await sandbox.commands.run(
`chmod 700 -- ${quoteE2BShellArg(this.runtimeRoot)}`,
{ envs: e2bControlEnvs() },
)
return sandbox
} catch (error: unknown) {
try {
await sandbox.kill()
} catch (_sandboxSetupRollbackFailure) {
// TODO(e2b-setup-rollback): Add retry state only if a real double failure
// outlives E2B's configured sandbox timeout.
}
throw error
}
}
}
export default E2BSandboxService

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-e2b`.
* @module @deepseek-ai/dsh-e2b/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-e2b'
/** Cordis companion plugin name. */
export const name = 'e2b-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: sandbox creation and teardown have one SDK promise and
* no independent event or mutable-data relationship to cross-check.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,182 @@
import { access } from 'node:fs/promises'
import { join, posix } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import {
FileNotFoundError,
Sandbox,
SandboxNotFoundError,
} from '@deepseek-ai/dsh-e2b'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
const fixtureRoot = fileURLToPath(new URL('../../../../examples/headless-agent/tests/fixtures/e2b/e2b/', import.meta.url))
const binScript = join(fixtureRoot, 'bin.ts')
const configPath = join(fixtureRoot, 'cordis.yml')
const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
it('scrubs credentials before actual E2B command and PTY login shells', async () => {
const apiKey = process.env.E2B_API_KEY
if (apiKey === undefined) throw new Error('E2B_API_KEY disappeared before the PTY environment test')
const sandbox = await Sandbox.create({
apiKey,
envs: { NPM_TOKEN: 'sentinel-secret', DSH_STALE: 'sentinel-stale', KEEP: 'visible' },
timeoutMs: 60_000,
secure: true,
lifecycle: { onTimeout: 'kill' },
})
try {
const profileLeakPath = '/home/user/dsh-e2b-bootstrap-profile-leak'
const hostileProfile = [
'if [[ "${NPM_TOKEN-}" == "sentinel-secret" ]]; then',
` printf leaked > ${profileLeakPath}`,
'fi',
'',
].join('\n')
await sandbox.files.write([
{ path: '/home/user/.bash_profile', data: hostileProfile },
{ path: '/home/user/.profile', data: hostileProfile },
{ path: '/home/user/.bashrc', data: hostileProfile },
])
const ctx = new Context()
ctx.provide('e2b', {
cwd: '/home/user',
runtimeRoot: '/home/user/.dsh-e2b',
getSandbox: async () => sandbox,
} as never)
ctx.provide('sandboxPolicy', {
defaultMode: 'danger-full-access',
workspaceRoot: '/home/user',
} as never)
const ptyFiber = await ctx.plugin(PtyService)
const subprocessFiber = await ctx.plugin(E2BSubprocessService)
const node = await ctx.subprocess.resolveExecutable('node')
const relativeNodePath = posix.relative(ctx.e2b.cwd, posix.dirname(node)) || '.'
await expect(ctx.subprocess.resolveExecutable('node', { PATH: relativeNodePath })).resolves.toBe(node)
await expect(sandbox.files.read(profileLeakPath)).rejects.toBeInstanceOf(FileNotFoundError)
const environmentProbe = ctx.subprocess.spawn({
argv: ['/bin/bash', '-c', [
'dsh_leak=0',
'for dsh_pid in "$PPID" $(ps -o pid= --ppid "$PPID"); do',
' [[ "$dsh_pid" == "$$" ]] && continue',
' if tr "\\0" "\\n" < "/proc/$dsh_pid/environ" 2>/dev/null | grep -Fqx "NPM_TOKEN=sentinel-secret"; then dsh_leak=1; fi',
'done',
'printf "DIRECT=<%s> LEAK=<%s>\\n" "${NPM_TOKEN-}" "$dsh_leak"',
].join('\n')],
cwd: '/home/user',
stdio: { stdin: 'ignore', stdout: { maxBytes: 1_024 }, stderr: { maxBytes: 1_024 } },
graceMs: 500,
env: {},
})
await expect(environmentProbe.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(environmentProbe.collected.stdout?.readFrom(0).text).toBe('DIRECT=<> LEAK=<0>\n')
await expect(sandbox.files.read(profileLeakPath)).rejects.toBeInstanceOf(FileNotFoundError)
const ownerId = SessionId('e2b-pty-env-owner')
const ownerSession = Session.create(ownerId)
const owner: Agent = {
id: ownerId,
options: {},
session: ownerSession,
inbox: new Inbox(ownerSession, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx,
send() {},
followup() {},
steer() {},
inject() {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
const backend = new LocalPtyBackend(ctx, {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'],
rows: 24, cols: 80,
scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384,
pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000,
handoffGraceMs: 500, timeoutMs: 5_000, disposeGraceMs: 1_000,
})
const session = await backend.spawn({ sessionId: PtySessionId('env'), owner, type: 'shell' })
const result = await session.startSend({
text: "printf 'NPM=<%s> DSH=<%s> KEEP=<%s>\\n' \"$NPM_TOKEN\" \"$DSH_STALE\" \"$KEEP\"",
submit: true,
}).done
expect(result.viewport).toContain('NPM=<> DSH=<> KEEP=<visible>')
expect(result.viewport).not.toContain('sentinel-secret')
expect(result.viewport).not.toContain('sentinel-stale')
await expect(sandbox.files.read(profileLeakPath)).rejects.toBeInstanceOf(FileNotFoundError)
await session.close('environment test complete')
await subprocessFiber.dispose()
await ptyFiber.dispose()
} finally {
await sandbox.kill().catch(() => false)
}
}, 70_000)
it('runs FS, Bash, PTY, and LSP in one sandbox and deletes it', async () => {
const { stdout, stderr } = await runLoaderSmoke({
label: 'E2B composition',
tempDirPrefix: 'dsh-e2b-composition-',
binScript,
libBinScript: binScript,
configPath,
tsconfigPath,
env: {
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
},
processTimeoutMs: 180_000,
inspect: async (cwd) => {
for (const name of ['from-fs.txt', 'from-bash.txt', 'multibyte # file.ts', 'fixture-lsp.mjs']) {
await expect(access(join(cwd, name))).rejects.toMatchObject({ code: 'ENOENT' })
}
},
})
expect(stderr).toBe('')
const output = JSON.parse(stdout) as Record<string, unknown>
expect(output).toMatchObject({
bashRead: 'versioned-by-fs\n',
fsRead: 'written-by-bash\n',
explicitEnvironment: true,
splitUtf8Output: '你好',
hover: {
kind: 'hover',
hover: { contents: '**remote hover** 你好 café' },
},
definition: {
kind: 'locations',
locations: [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } } }],
},
terminal: {
echo: { waitReason: 'stdin_read', sessionStatus: { kind: 'running' } },
signal: { delivered: true },
interrupted: { sessionStatus: { kind: 'running' } },
treeCleanup: true,
},
})
const terminalMotd = (output.terminal as { motd: string }).motd
expect(terminalMotd.length).toBeGreaterThan(0)
expect(terminalMotd).not.toContain('exec /bin/bash')
expect(terminalMotd).not.toContain('.dsh-e2b/terminals/')
expect((output.terminal as { echo: { viewport: string } }).echo.viewport).toContain('PTY-你好')
expect((output.terminal as { scrollback: string }).scrollback).toContain('PTY-你好')
expect((output.terminal as { signal: { targetPgid: number } }).signal.targetPgid).toBeGreaterThan(0)
expect(['stdin_read', 'inferred_idle']).toContain(
(output.terminal as { interrupted: { waitReason: string } }).interrupted.waitReason,
)
const apiKey = process.env.E2B_API_KEY
if (apiKey === undefined) throw new Error('E2B_API_KEY disappeared during the live composition test')
await expect(Sandbox.getInfo(String(output.sandboxId), { apiKey })).rejects.toBeInstanceOf(SandboxNotFoundError)
await expect.poll(async () => {
const sandboxes = await Sandbox.list({ apiKey }).nextItems()
return sandboxes.some(sandbox => sandbox.sandboxId === output.sandboxId)
}, { interval: 250, timeout: 5_000 }).toBe(false)
}, 195_000)
})

View File

@@ -0,0 +1,247 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Mock } from 'vitest'
import { Context } from 'cordis'
import type { Sandbox as SandboxType } from 'e2b'
import E2BSandboxService, {
e2bControlEnvs,
FileType,
SandboxNotFoundError,
quoteE2BShellArg,
} from '@deepseek-ai/dsh-e2b'
import * as E2BInvariant from '../src/invariant.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
const sdk = vi.hoisted(() => ({
create: vi.fn(),
}))
vi.mock('e2b', async (importOriginal) => {
const actual = await importOriginal<typeof import('e2b')>()
// The mock replaces only the SDK's static factory surface and is never constructed.
// oxlint-disable-next-line typescript/no-extraneous-class -- The SDK contract is a class with a static factory.
class FakeSandbox {
static create(...args: unknown[]): unknown {
return sdk.create(...args)
}
}
return { ...actual, Sandbox: FakeSandbox }
})
interface SandboxFixture {
sandbox: SandboxType
makeDir: ReturnType<typeof vi.fn>
getInfo: ReturnType<typeof vi.fn>
run: Mock<RunCommand>
kill: ReturnType<typeof vi.fn>
}
type RunCommand = (
command: string,
options?: { envs?: Record<string, string> },
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
function fakeSandbox(id = 'sandbox-1'): SandboxFixture {
const makeDir = vi.fn().mockResolvedValue(true)
const getInfo = vi.fn().mockResolvedValue({ type: FileType.DIR })
const run = vi.fn<RunCommand>().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' })
const kill = vi.fn().mockResolvedValue(undefined)
const sandbox = {
sandboxId: id,
files: { makeDir, getInfo },
commands: { run },
kill,
} as unknown as SandboxType
return { sandbox, makeDir, getInfo, run, kill }
}
beforeEach(() => {
sdk.create.mockReset()
vi.unstubAllEnvs()
})
describe('E2BSandboxService', () => {
it('gives each SDK login shell a fresh non-overridable control home', () => {
const first = e2bControlEnvs({ HOME: '/hostile', NPM_TOKEN: '' })
const second = e2bControlEnvs()
expect(first.HOME).toMatch(/^\/\.dsh-e2b-control-/)
expect(first).toEqual({ HOME: first.HOME, NPM_TOKEN: '' })
expect(first.HOME).not.toBe(second.HOME)
})
it('creates one protected shared sandbox and kills it on default disposal', async () => {
const fixture = fakeSandbox()
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
const service = ctx.e2b
await expect(service.getSandbox()).resolves.toBe(fixture.sandbox)
expect(service.cwd).toBe('/home/user/workspace')
expect(service.runtimeRoot).toBe('/home/user/workspace/.dsh-e2b')
expect(sdk.create).toHaveBeenCalledWith({
apiKey: 'test-key',
timeoutMs: 300_000,
secure: true,
lifecycle: { onTimeout: 'kill' },
})
expect(fixture.makeDir).toHaveBeenNthCalledWith(1, '/home/user/workspace')
expect(fixture.makeDir).toHaveBeenNthCalledWith(2, '/home/user/workspace/.dsh-e2b')
expect(fixture.getInfo).toHaveBeenCalledWith('/home/user/workspace/.dsh-e2b')
const runOptions = fixture.run.mock.calls[0]?.[1]
expect(runOptions?.envs?.HOME).toMatch(/^\/\.dsh-e2b-control-/)
expect(fixture.run).toHaveBeenCalledWith(
"chmod 700 -- '/home/user/workspace/.dsh-e2b'",
{ envs: { HOME: runOptions?.envs?.HOME } },
)
await fiber.dispose()
expect(fixture.kill).toHaveBeenCalledOnce()
await expect(service.getSandbox()).rejects.toThrow(/disposing/)
})
it('rejects handle acquisition when disposal starts during setup', async () => {
const fixture = fakeSandbox()
const opening = Promise.withResolvers<SandboxType>()
sdk.create.mockReturnValue(opening.promise)
const ctx = new Context()
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
const acquisition = ctx.e2b.getSandbox()
const disposing = fiber.dispose()
opening.resolve(fixture.sandbox)
await expect(acquisition).rejects.toThrow(/disposing/)
await expect(disposing).resolves.toBeUndefined()
expect(fixture.kill).toHaveBeenCalledOnce()
})
it('reads the key from the environment and honors the configured cwd and lifetime', async () => {
vi.stubEnv('E2B_API_KEY', 'environment-key')
const fixture = fakeSandbox('configured-sandbox')
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const fiber = await ctx.plugin(E2BSandboxService, {
cwd: '/workspace/project',
timeoutMs: 60_000,
})
await ctx.e2b.getSandbox()
expect(sdk.create).toHaveBeenCalledWith({
apiKey: 'environment-key',
timeoutMs: 60_000,
secure: true,
lifecycle: { onTimeout: 'kill' },
})
expect(ctx.e2b.cwd).toBe('/workspace/project')
await fiber.dispose()
expect(fixture.kill).toHaveBeenCalledOnce()
})
it('accepts a missing sandbox when disposal itself requests deletion', async () => {
const fixture = fakeSandbox()
fixture.kill.mockRejectedValue(new SandboxNotFoundError('already deleted'))
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const errors: unknown[] = []
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
await ctx.e2b.getSandbox()
await fiber.dispose()
expect(fixture.kill).toHaveBeenCalledOnce()
expect(errors).toEqual([])
})
it('does not classify other disposal failures as an already-gone sandbox', async () => {
const fixture = fakeSandbox()
const failure = new Error('disposition unknown')
fixture.kill.mockRejectedValue(failure)
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const errors: unknown[] = []
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
await ctx.e2b.getSandbox()
await expect(fiber.dispose()).resolves.toBeUndefined()
expect(fixture.kill).toHaveBeenCalledOnce()
expect(errors).toContain(failure)
})
it('kills a newly created sandbox when remote directory setup fails', async () => {
const fixture = fakeSandbox()
fixture.makeDir.mockRejectedValueOnce(new Error('setup failed'))
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
await expect(ctx.e2b.getSandbox()).rejects.toThrow('setup failed')
expect(fixture.kill).toHaveBeenCalledOnce()
await fiber.dispose()
})
it('preserves the setup failure after its one rollback attempt fails', async () => {
const fixture = fakeSandbox()
fixture.run.mockRejectedValueOnce(new Error('chmod failed'))
fixture.kill.mockRejectedValueOnce(new Error('cleanup failed'))
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
await expect(ctx.e2b.getSandbox()).rejects.toThrow('chmod failed')
expect(fixture.kill).toHaveBeenCalledOnce()
await fiber.dispose()
expect(fixture.kill).toHaveBeenCalledOnce()
})
it.each([
['symbolic link', { type: FileType.DIR, symlinkTarget: '/tmp/redirected' }],
['regular file', { type: FileType.FILE }],
])('rejects a reserved runtime root that is a %s', async (_label, info) => {
const fixture = fakeSandbox()
fixture.getInfo.mockResolvedValueOnce(info)
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
await expect(ctx.e2b.getSandbox()).rejects.toThrow('runtime root must be a real directory')
expect(fixture.run).not.toHaveBeenCalled()
expect(fixture.kill).toHaveBeenCalledOnce()
})
it.each([
[{ apiKey: '' }, /configure apiKey/],
[{ apiKey: 'x', cwd: 'relative' }, /absolute Linux path/],
[{ apiKey: 'x', timeoutMs: 0 }, /positive finite/],
] as const)('fails self-contained configuration before opening E2B: %j', async (config, message) => {
vi.stubEnv('E2B_API_KEY', '')
const ctx = new Context()
await expect(ctx.plugin(E2BSandboxService, config)).rejects.toThrow(message)
expect(sdk.create).not.toHaveBeenCalled()
})
it('requires a key when both config and the environment omit it', async () => {
const original = process.env.E2B_API_KEY
delete process.env.E2B_API_KEY
try {
const ctx = new Context()
await expect(ctx.plugin(E2BSandboxService, {})).rejects.toThrow(/configure apiKey/)
} finally {
if (original === undefined) delete process.env.E2B_API_KEY
else process.env.E2B_API_KEY = original
}
})
})
describe('E2B helpers and invariant companion', () => {
it('quotes opaque shell arguments without interpolation', () => {
expect(quoteE2BShellArg("a'b $HOME")).toBe("'a'\"'\"'b $HOME'")
})
it('registers the package-owned empty invariant installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = await ctx.plugin(E2BInvariant).await()
await fiber.dispose()
})
})

View File

@@ -0,0 +1,25 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/brand"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/e2b/fs-e2b/README.md
README.md: cd170bcbe2831b0856b51791a2045382d93c1148
README.zh.md: f97790cf1d4ef90f042df8ed564723e186327f00

View File

@@ -0,0 +1,31 @@
# @deepseek-ai/dsh-fs-e2b
English | [中文](README.zh.md)
E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provider seam. It has no config: load [`@deepseek-ai/dsh-e2b`](../e2b/README.md) first, then this service in place of `dsh-fs-local`. The provider uses the owner's remote cwd and SDK handle, so file tools observe the same world as E2B-backed Bash processes.
## Behavior
- **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; GNU `realpath -mz` supplies canonical target identity without requiring the final file to exist, and ASCII/base64 plus strict NUL framing preserves newline and multibyte paths across the decoded SDK transport. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam; listings reuse returned metadata and resolve symbolic-link entries sequentially. Versions are opaque hashes of E2B metadata plus a per-write extended attribute.
- **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules.
- **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing.
- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, preserve an existing file's POSIX mode, and publish the staged file through E2B's same-filesystem atomic rename. The rename response supplies the committed version, so no fallible metadata request follows the commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. Optional create/version guards keep the base seam's observed-state semantics.
- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before rename. The signal is not forwarded into the rename RPC, so cancellation cannot interrupt the atomic commit; a successful rename is the commit point.
The provider does not copy, mount, or reconcile the host workspace. Giving it a host path as `cwd` creates a remote directory with the same spelling only.
## Model Experience
Indirectly, through [`dsh-tool-fs`](../../fs/tool-fs/README.md), which renders remote UTF-8 content, directory results, mutation acknowledgements, and provider errors while E2B identity and transport remain internal.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **No host synchronization** — an empty E2B cwd stays empty until a tool, command, or external process populates it; local files are neither uploaded nor reflected back.
- **Mutation coordination is host-process-local** — another harness connection or remote command can race the adapter; version guards detect only metadata changes represented by E2B.
- **Reads reopen canonical targets by path** — a concurrent remote path replacement between resolution and stream opening is not fenced by a stable file handle; no observed product defect justifies a provider-specific bounded-read protocol in this POC.
- **Whole-file mutation costs remain** — overwrite diffs and literal edits read complete files into host memory, and every operation incurs E2B controller latency.
- **The POC targets E2B's default Linux image** — it relies on GNU `realpath`/`base64`/`chmod`, same-filesystem rename, streaming reads, and metadata extended attributes; custom templates are outside this POC.

View File

@@ -0,0 +1,31 @@
# @deepseek-ai/dsh-fs-e2b
[English](README.md) | 中文
[`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) 提供方 seam 的 E2B 实现。它没有配置:先加载 [`@deepseek-ai/dsh-e2b`](../e2b/README.md),再用本服务取代 `dsh-fs-local`。该提供方使用所有者的远程 cwd 和 SDK 句柄,因此文件工具观察到的环境与 E2B 后端 Bash 进程相同。
## 行为
- **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析GNU `realpath -mz` 提供规范化目标身份且不要求最终文件存在ASCII/base64 加严格 NUL 分帧会在已解码的 SDK 传输中保留含换行符和多字节字符的路径。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam目录列表会复用已返回的元数据并依次解析符号链接条目。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。
- **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI以及由提供方负责的包含关系检查因此通用进程管理消费方无需解析 E2B 目标 ID也不会套用宿主路径规则。
- **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。
- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,保留现有文件的 POSIX mode并通过 E2B 的同一文件系统原子重命名发布暂存文件。重命名响应会提供已提交的版本因此提交点之后不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF存储时恢复占主导的 CRLF并在宿主进程内按规范化目标串行执行变更。可选的创建版本防护会保留基础 seam 的已观察状态语义。
- **失败与取消**E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在 rename 前立即检查。信号不会传入 rename RPC因此取消无法中断原子提交成功 rename 是提交点。
该提供方不会复制、挂载或协调宿主工作区。把宿主路径用作 `cwd`,只会在远程创建一个拼写相同的目录。
## 模型体验
通过 [`dsh-tool-fs`](../../fs/tool-fs/README.md) 间接影响模型;该工具会渲染远程 UTF-8 内容、目录结果、变更确认和提供方错误,而 E2B 身份及传输保持内部实现。
#### KV Cache 影响
不会直接失效;请求前缀变更由具名消费方负责。
## 已知限制与延后工作
- **不提供宿主同步**:空的 E2B cwd 会一直为空,直到工具、命令或外部进程填充它;本地文件既不会上传,也不会同步回本地。
- **变更协调仅限宿主进程内**:另一个 harness 连接或远程命令可能与适配器发生竞态;版本防护只能检测 E2B 元数据所体现的变更。
- **读取会按路径重新打开规范化目标**:在解析与打开流之间若并发替换远程路径,该操作没有稳定文件句柄提供围栏;在该 POC 中,没有已观察到的产品缺陷能够证明提供方专用的有界读取协议值得引入。
- **仍需承担完整文件变更成本**:覆盖差异和字面量编辑会把完整文件读入宿主内存,每项操作也都会产生 E2B 控制器延迟。
- **该 POC 面向 E2B 默认 Linux 镜像**:它依赖 GNU `realpath``base64``chmod`、同一文件系统内的 rename、流式读取和元数据扩展属性自定义模板不在该 POC 范围内。

View File

@@ -0,0 +1,39 @@
{
"name": "@deepseek-ai/dsh-fs-e2b",
"description": "E2B filesystem implementation for DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-e2b": "^0.0.1",
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-e2b": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,499 @@
/**
* E2B implementation of the filesystem provider seam. Paths, contents, and
* atomic staging files remain inside the shared remote sandbox.
* @module @deepseek-ai/dsh-fs-e2b
*/
import { createHash, randomUUID } from 'node:crypto'
import { Buffer } from 'node:buffer'
import { posix } from 'node:path'
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
FsEditOutcome,
FsEditRequest,
FsInfo,
FsPathInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
import {
CommandExitError,
e2bControlEnvs,
FileNotFoundError,
FileType,
quoteE2BShellArg,
} from '@deepseek-ai/dsh-e2b'
import type { EntryInfo, Sandbox } from '@deepseek-ai/dsh-e2b'
const VERSION_METADATA_KEY = 'dsh-version'
const BINARY_SAMPLE_BYTES = 8192
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
function assertNotAborted(signal: AbortSignal | undefined, operation: string): void {
if (signal?.aborted === true) throw new FsError(`${operation} aborted`, 'FS_ABORTED')
}
function normalizeLineEndings(value: string): string {
return value.replaceAll('\r\n', '\n')
}
function detectsCrlf(value: string): boolean {
const sample = value.slice(0, 4096)
const crlf = sample.split('\r\n').length - 1
const lf = sample.split('\n').length - 1 - crlf
return crlf > lf
}
function restoreLineEndings(value: string, crlf: boolean): string {
return crlf ? normalizeLineEndings(value).replaceAll('\n', '\r\n') : value
}
function decodeText(bytes: Uint8Array, displayPath: string, binarySampleBytes: number): string {
if (bytes.subarray(0, binarySampleBytes).includes(0)) {
throw new FsError(`cannot read "${displayPath}": binary file`, 'FS_NOT_TEXT')
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch (error: unknown) {
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
}
}
function decodeCanonicalPath(encoded: string): string {
if (encoded.length === 0 || !BASE64.test(encoded)) {
throw new Error('fs-e2b: canonical path transport returned invalid base64')
}
const framed = Buffer.from(encoded, 'base64')
if (framed.toString('base64') !== encoded
|| framed.length < 2
|| framed.at(-1) !== 0
|| framed.subarray(0, -1).includes(0)) {
throw new Error('fs-e2b: canonical path transport returned invalid NUL framing')
}
let path: string
try {
path = new TextDecoder('utf-8', { fatal: true }).decode(framed.subarray(0, -1))
} catch (error: unknown) {
throw new Error('fs-e2b: canonical path is not valid UTF-8', { cause: error })
}
if (!posix.isAbsolute(path)) throw new Error('fs-e2b: canonical path is not absolute')
return path
}
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
return signal === undefined ? {} : { signal }
}
function commandOpts(signal: AbortSignal | undefined): { envs: Record<string, string>; signal?: AbortSignal } {
return { envs: e2bControlEnvs(), ...signalOpts(signal) }
}
function entryType(entry: EntryInfo): FsInfo['type'] {
switch (entry.type) {
case FileType.FILE:
return 'file'
case FileType.DIR:
return 'directory'
default:
return 'other'
}
}
function entryVersion(entry: EntryInfo): ReturnType<typeof FsVersion> {
const facts = JSON.stringify([
entry.metadata?.[VERSION_METADATA_KEY],
entry.path,
entry.type,
entry.size,
entry.mode,
entry.modifiedTime?.toISOString(),
entry.symlinkTarget,
])
return FsVersion(`e2b:${createHash('sha256').update(facts).digest('hex')}`)
}
function mapError(error: unknown, operation: string, displayPath: string, signal?: AbortSignal): FsError {
if (error instanceof FsError) return error
if (signal?.aborted === true || (error instanceof DOMException && error.name === 'AbortError')) {
return new FsError(`${operation} aborted`, 'FS_ABORTED', { cause: error })
}
if (error instanceof FileNotFoundError) {
return new FsError(`cannot ${operation} "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
}
if (/permission denied|operation not permitted/i.test(String(error))) {
return new FsError(`cannot ${operation} "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
}
return new FsError(`cannot ${operation} "${displayPath}": ${String(error)}`, 'FS_IO_ERROR', { cause: error })
}
function literalEdit(content: string, request: FsEditRequest, displayPath: string): string {
const oldString = normalizeLineEndings(request.oldString)
const newString = normalizeLineEndings(request.newString)
if (oldString.length === 0) {
throw new FsError(`cannot edit "${displayPath}": old_string must be non-empty`, 'FS_EDIT_NOT_FOUND')
}
let matches = 0
let offset = 0
while (true) {
const found = content.indexOf(oldString, offset)
if (found < 0) break
matches += 1
offset = found + oldString.length
}
if (matches === 0) throw new FsError(`cannot edit "${displayPath}": old_string was not found`, 'FS_EDIT_NOT_FOUND')
if (!request.replaceAll && matches !== 1) {
throw new FsError(`cannot edit "${displayPath}": old_string matched ${matches} times`, 'FS_AMBIGUOUS_EDIT')
}
return request.replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString)
}
/** Remote filesystem backend sharing the sandbox owned by `ctx.e2b`. */
export class E2BFileSystem extends FileSystem {
static inject = ['e2b']
private readonly locks = new Map<string, Promise<unknown>>()
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
assertNotAborted(opts?.signal, 'resolve')
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
const displayPath = posix.resolve(opts?.cwd ?? this.ctx.e2b.cwd, path)
try {
const sandbox = await this.ctx.e2b.getSandbox()
const targetKey = await this.canonicalPath(sandbox, displayPath, opts?.signal)
assertNotAborted(opts?.signal, 'resolve')
return { targetKey: FsTargetKey(targetKey), displayPath }
} catch (error: unknown) {
throw mapError(error, 'resolve', displayPath, opts?.signal)
}
}
override processPath(target: FsTarget): string {
return String(target.targetKey)
}
override fileUrl(target: FsTarget): string {
const path = this.processPath(target)
if (!posix.isAbsolute(path)) throw new Error(`fs-e2b: expected an absolute process path: ${JSON.stringify(path)}`)
return `file://${path.split('/').map(segment => encodeURIComponent(segment)).join('/')}`
}
override contains(parent: FsTarget, child: FsTarget): boolean {
const relative = posix.relative(this.processPath(parent), this.processPath(child))
return relative === '' || (relative !== '..' && !relative.startsWith('../') && !posix.isAbsolute(relative))
}
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
assertNotAborted(signal, 'stat')
const entry = await this.probe(String(target.targetKey), target.displayPath, signal)
if (entry === undefined) return undefined
return {
version: entryVersion(entry),
type: entryType(entry),
...(entry.type === FileType.FILE ? { size: entry.size } : {}),
}
}
override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
assertNotAborted(signal, 'lstat')
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
const displayPath = posix.resolve(opts?.cwd ?? this.ctx.e2b.cwd, path)
const entry = await this.probe(displayPath, displayPath, signal)
if (entry === undefined) return undefined
const type = entry.symlinkTarget !== undefined
? 'symlink' as const
: entry.type === FileType.FILE
? 'file' as const
: entry.type === FileType.DIR
? 'directory' as const
: 'other' as const
return {
version: entryVersion(entry),
type,
...(entry.type === FileType.FILE ? { size: entry.size } : {}),
}
}
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
const sandbox = await this.ctx.e2b.getSandbox()
await this.requireRegular(target, signal)
try {
const bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) })
assertNotAborted(signal, 'read')
return decodeText(bytes, target.displayPath, BINARY_SAMPLE_BYTES)
} catch (error: unknown) {
throw mapError(error, 'read', target.displayPath, signal)
}
}
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
const sandbox = await this.ctx.e2b.getSandbox()
await this.requireRegular(target, signal)
let stream: ReadableStream<Uint8Array>
try {
// The pinned SDK's stream overload lies for empty files: content-length 0
// returns '' instead of a ReadableStream.
const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as
ReadableStream<Uint8Array> | string
stream = typeof read === 'string'
? new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
: read
} catch (error: unknown) {
throw mapError(error, 'read', target.displayPath, signal)
}
const displayPath = target.displayPath
return {
async *[Symbol.asyncIterator](): AsyncGenerator<string> {
const reader = stream.getReader()
const decoder = new TextDecoder('utf-8', { fatal: true })
let sampledBytes = 0
let completed = false
try {
while (true) {
assertNotAborted(signal, 'read')
const next = await reader.read()
if (next.done) break
if (sampledBytes < BINARY_SAMPLE_BYTES) {
const sample = next.value.subarray(0, BINARY_SAMPLE_BYTES - sampledBytes)
if (sample.includes(0)) throw new FsError(`cannot read "${displayPath}": binary file`, 'FS_NOT_TEXT')
sampledBytes += sample.length
}
let text: string
try {
text = decoder.decode(next.value, { stream: true })
} catch (error: unknown) {
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
}
if (text.length > 0) yield text
}
try {
decoder.decode()
} catch (error: unknown) {
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
}
completed = true
} catch (error: unknown) {
throw mapError(error, 'read', displayPath, signal)
} finally {
if (!completed) {
try {
await reader.cancel()
} catch (_streamCancellationFailure) {
// The primary read outcome owns the result; cancellation is best-effort after early stop.
}
}
reader.releaseLock()
}
},
}
}
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
const info = await this.stat(target, signal)
if (info === undefined) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
try {
const sandbox = await this.ctx.e2b.getSandbox()
const listed = await sandbox.files.list(String(target.targetKey), { depth: 1, ...signalOpts(signal) })
const entries: FsDirEntry[] = []
for (const entry of listed) {
const displayPath = posix.join(target.displayPath, entry.name)
const canonical = entry.symlinkTarget === undefined
? entry.path
: await this.canonicalPath(sandbox, entry.path, signal)
const resolved = entry.symlinkTarget === undefined
? entry
: await this.probe(canonical, displayPath, signal)
entries.push({
name: entry.name,
type: resolved === undefined ? 'other' : entryType(resolved),
target: { targetKey: FsTargetKey(canonical), displayPath },
...(resolved !== undefined ? { version: entryVersion(resolved) } : {}),
...(resolved?.type === FileType.FILE ? { size: resolved.size } : {}),
})
}
return entries.sort((left, right) => left.name.localeCompare(right.name))
} catch (error: unknown) {
throw mapError(error, 'list', target.displayPath, signal)
}
}
override async writeText(
target: FsTarget,
content: string,
expected?: FsWriteIntent,
signal?: AbortSignal,
): Promise<FsWriteOutcome> {
return this.withLock(String(target.targetKey), async () => {
const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
if (existing !== undefined && entryType(existing) !== 'file') {
throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
this.checkWriteIntent(existing, expected, target)
const before = existing === undefined ? null : await this.readForDiff(target, signal)
const version = await this.writeAtomic(target, content, existing, signal)
return {
operation: existing === undefined ? 'create' : 'update',
version,
before,
after: normalizeLineEndings(content),
}
})
}
override async editText(
target: FsTarget,
edit: FsEditRequest,
expected?: { version: ReturnType<typeof FsVersion> },
signal?: AbortSignal,
): Promise<FsEditOutcome> {
return this.withLock(String(target.targetKey), async () => {
const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
if (existing === undefined) {
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
}
if (entryType(existing) !== 'file') {
throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
if (expected !== undefined && entryVersion(existing) !== expected.version) {
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
}
const raw = await this.readForEdit(target, signal)
const before = normalizeLineEndings(raw)
const after = literalEdit(before, edit, target.displayPath)
const storage = restoreLineEndings(after, detectsCrlf(raw))
const version = await this.writeAtomic(target, storage, existing, signal)
return { version, before, after }
})
}
private async withLock<T>(targetKey: string, operation: () => Promise<T>): Promise<T> {
const prior = this.locks.get(targetKey) ?? Promise.resolve()
const run = prior.then(operation, operation)
const tail = run.then(() => undefined, () => undefined)
this.locks.set(targetKey, tail)
try {
return await run
} finally {
if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey)
}
}
private async canonicalPath(sandbox: Sandbox, path: string, signal?: AbortSignal): Promise<string> {
try {
const result = await sandbox.commands.run(
`set -o pipefail; realpath -mz -- ${quoteE2BShellArg(path)} | base64 -w0`,
commandOpts(signal),
)
return decodeCanonicalPath(result.stdout)
} catch (error: unknown) {
if (error instanceof CommandExitError) throw new Error(error.stderr || error.message, { cause: error })
throw error
}
}
private async probe(path: string, displayPath: string, signal?: AbortSignal): Promise<EntryInfo | undefined> {
assertNotAborted(signal, 'stat')
try {
const sandbox = await this.ctx.e2b.getSandbox()
const entry = await sandbox.files.getInfo(path, signalOpts(signal))
assertNotAborted(signal, 'stat')
return entry
} catch (error: unknown) {
if (error instanceof FileNotFoundError) return undefined
throw mapError(error, 'stat', displayPath, signal)
}
}
private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise<void> {
const info = await this.stat(target, signal)
if (info === undefined) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
private checkWriteIntent(existing: EntryInfo | undefined, expected: FsWriteIntent | undefined, target: FsTarget): void {
if (expected?.kind === 'createIfAbsent' && existing !== undefined) {
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
}
if (expected?.kind === 'replaceIfVersion') {
if (existing === undefined || entryVersion(existing) !== expected.version) {
throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
}
}
}
private async readForDiff(target: FsTarget, signal?: AbortSignal): Promise<string | null> {
try {
const sandbox = await this.ctx.e2b.getSandbox()
const bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) })
assertNotAborted(signal, 'read')
return normalizeLineEndings(decodeText(bytes, target.displayPath, bytes.length))
} catch (error: unknown) {
if (error instanceof FsError && error.code === 'FS_NOT_TEXT') return null
throw mapError(error, 'read', target.displayPath, signal)
}
}
private async readForEdit(target: FsTarget, signal?: AbortSignal): Promise<string> {
try {
const sandbox = await this.ctx.e2b.getSandbox()
const bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) })
assertNotAborted(signal, 'edit')
return decodeText(bytes, target.displayPath, bytes.length)
} catch (error: unknown) {
throw mapError(error, 'edit', target.displayPath, signal)
}
}
private async writeAtomic(
target: FsTarget,
content: string,
existing: EntryInfo | undefined,
signal?: AbortSignal,
): Promise<ReturnType<typeof FsVersion>> {
assertNotAborted(signal, 'write')
const sandbox = await this.ctx.e2b.getSandbox()
const targetPath = String(target.targetKey)
const versionId = randomUUID()
const stagingDirectory = posix.join(posix.dirname(targetPath), `.dsh-${randomUUID()}.tmp`)
const temporary = posix.join(stagingDirectory, 'content')
let stagingDirectoryCreated = false
try {
const created = await sandbox.files.makeDir(stagingDirectory, signalOpts(signal))
if (!created) throw new Error('private staging directory already exists')
stagingDirectoryCreated = true
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(stagingDirectory)}`, commandOpts(signal))
assertNotAborted(signal, 'write')
await sandbox.files.write(temporary, content, {
metadata: { [VERSION_METADATA_KEY]: versionId },
...signalOpts(signal),
})
assertNotAborted(signal, 'write')
const mode = existing === undefined ? 0o600 : existing.mode & 0o777
await sandbox.commands.run(
`chmod ${mode.toString(8)} -- ${quoteE2BShellArg(temporary)}`,
commandOpts(signal),
)
assertNotAborted(signal, 'write')
const committed = await sandbox.files.rename(temporary, targetPath)
try {
await sandbox.files.remove(stagingDirectory)
} catch (_committedStagingCleanupFailure) {
// The target is already committed; an empty private directory cannot turn that write into a failure.
}
return entryVersion(committed)
} catch (error: unknown) {
if (stagingDirectoryCreated) {
try {
await sandbox.files.remove(stagingDirectory)
} catch (_stagingDirectoryAlreadyAbsentOrCleanupFailed) {
// Only the private staging directory is swallowed; the original failure owns the operation.
}
}
throw mapError(error, 'write', target.displayPath, signal)
}
}
}
export default E2BFileSystem

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-fs-e2b`.
* @module @deepseek-ai/dsh-fs-e2b/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-fs-e2b'
/** Cordis companion plugin name. */
export const name = 'fs-e2b-invariant'
/** Service required before reserving package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: each operation returns the E2B controller's committed
* result directly, with no independent event or cache to cross-check.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,682 @@
import { Buffer } from 'node:buffer'
import { dirname, posix } from 'node:path'
import { Context } from 'cordis'
import {
CommandExitError,
FileNotFoundError,
FileType,
type EntryInfo,
type Sandbox,
} from '@deepseek-ai/dsh-e2b'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import E2BFileSystem from '@deepseek-ai/dsh-fs-e2b'
import * as E2BFsInvariant from '../src/invariant.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { describe, expect, it, vi } from 'vitest'
interface RemoteNode {
type: FileType
data: Uint8Array
mode: number
modified: number
metadata?: Record<string, string>
symlinkTarget?: string
}
function bytes(value: string | readonly number[]): Uint8Array {
return typeof value === 'string' ? new TextEncoder().encode(value) : Uint8Array.from(value)
}
function commandError(exitCode: number, stderr = ''): CommandExitError {
return new CommandExitError({ exitCode, stdout: '', stderr, error: stderr })
}
class FakeRemote {
readonly nodes = new Map<string, RemoteNode>()
readonly writes: Array<{ path: string; data: string; metadata?: Record<string, string> }> = []
readonly writeParentModes: number[] = []
readonly renames: Array<{ from: string; to: string }> = []
readonly removals: string[] = []
readonly commands: string[] = []
streamChunks: Uint8Array[] | undefined
streamKeepOpen = false
readonly streamCancel = vi.fn()
nextCommandError: unknown
nextMakeDirResult: boolean | undefined
nextInfoError: unknown
nextListError: unknown
nextReadError: unknown
nextRenameError: unknown
nextRemoveError: unknown
canonicalOutput: string | undefined
abortAfterRename: AbortController | undefined
disappearOnInfo = new Set<string>()
private clock = 1
constructor() {
this.dir('/')
this.dir('/workspace')
}
dir(path: string): void {
this.nodes.set(path, { type: FileType.DIR, data: bytes(''), mode: 0o755, modified: this.clock++ })
}
file(path: string, data: string | readonly number[], mode = 0o644): void {
this.nodes.set(path, { type: FileType.FILE, data: bytes(data), mode, modified: this.clock++ })
}
other(path: string): void {
this.nodes.set(path, { type: 'other' as FileType, data: bytes(''), mode: 0o600, modified: this.clock++ })
}
symlink(path: string, target: string): void {
this.nodes.set(path, {
type: FileType.FILE,
data: bytes(''),
mode: 0o777,
modified: this.clock++,
symlinkTarget: target,
})
}
mutate(path: string, data: string): void {
const node = this.required(path)
node.data = bytes(data)
node.modified = this.clock++
}
private required(path: string): RemoteNode {
const node = this.nodes.get(path)
if (node === undefined) throw new FileNotFoundError(`missing: ${path}`)
return node
}
private followed(path: string): { path: string; node: RemoteNode; link?: RemoteNode } {
const node = this.required(path)
if (node.symlinkTarget === undefined) return { path, node }
return { path: node.symlinkTarget, node: this.required(node.symlinkTarget), link: node }
}
private info(path: string): EntryInfo {
if (this.disappearOnInfo.delete(path)) throw new FileNotFoundError(`missing: ${path}`)
return this.rawInfo(path)
}
private rawInfo(path: string): EntryInfo {
const followed = this.followed(path)
const node = followed.node
return {
name: posix.basename(path),
path,
type: node.type,
size: node.data.byteLength,
mode: node.mode,
permissions: 'rw-------',
owner: 'user',
group: 'user',
modifiedTime: new Date(node.modified),
...(node.metadata !== undefined ? { metadata: { ...node.metadata } } : {}),
...(followed.link?.symlinkTarget !== undefined ? { symlinkTarget: followed.link.symlinkTarget } : {}),
}
}
private checkAbort(options: { signal?: AbortSignal } | undefined): void {
if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError')
}
readonly sandbox = {
sandboxId: 'fake',
files: {
makeDir: async (path: string, options?: { signal?: AbortSignal }): Promise<boolean> => {
this.checkAbort(options)
if (this.nextMakeDirResult !== undefined) {
const result = this.nextMakeDirResult
this.nextMakeDirResult = undefined
return result
}
if (this.nodes.has(path)) return false
this.dir(path)
return true
},
getInfo: async (path: string, options?: { signal?: AbortSignal }): Promise<EntryInfo> => {
this.checkAbort(options)
if (this.nextInfoError !== undefined) {
const error = this.nextInfoError
this.nextInfoError = undefined
throw error
}
return this.info(path)
},
read: async (path: string, options: { format: 'bytes' | 'stream'; signal?: AbortSignal }): Promise<Uint8Array | ReadableStream<Uint8Array> | string> => {
this.checkAbort(options)
if (this.nextReadError !== undefined) {
const error = this.nextReadError
this.nextReadError = undefined
throw error
}
const data = this.followed(path).node.data
if (options.format === 'bytes') return data.slice()
// Pinned-SDK fidelity: a content-length-0 response returns '' even in stream format.
if (data.length === 0 && this.streamChunks === undefined) return ''
const chunks = this.streamChunks ?? [data.slice()]
return new ReadableStream<Uint8Array>({
start: (controller) => {
for (const chunk of chunks) controller.enqueue(chunk)
if (!this.streamKeepOpen) controller.close()
},
cancel: () => { this.streamCancel() },
})
},
list: async (path: string, options?: { depth?: number; signal?: AbortSignal }): Promise<EntryInfo[]> => {
this.checkAbort(options)
if (this.nextListError !== undefined) {
const error = this.nextListError
this.nextListError = undefined
throw error
}
this.required(path)
return [...this.nodes.keys()]
.filter(candidate => candidate !== path && dirname(candidate) === path)
.map(candidate => this.rawInfo(candidate))
},
write: async (path: string, data: string, options?: { metadata?: Record<string, string>; signal?: AbortSignal }): Promise<object> => {
this.checkAbort(options)
const parent = dirname(path)
if (!this.nodes.has(parent)) this.dir(parent)
this.writeParentModes.push(this.required(parent).mode)
this.nodes.set(path, {
type: FileType.FILE,
data: bytes(data),
mode: 0o644,
modified: this.clock++,
...(options?.metadata !== undefined ? { metadata: { ...options.metadata } } : {}),
})
this.writes.push({ path, data, ...(options?.metadata !== undefined ? { metadata: options.metadata } : {}) })
return {}
},
rename: async (from: string, to: string, options?: { signal?: AbortSignal }): Promise<EntryInfo> => {
this.checkAbort(options)
if (this.nextRenameError !== undefined) {
const error = this.nextRenameError
this.nextRenameError = undefined
throw error
}
const node = this.required(from)
this.nodes.delete(from)
this.nodes.set(to, node)
this.renames.push({ from, to })
this.abortAfterRename?.abort('after commit')
this.checkAbort(options)
return this.info(to)
},
remove: async (path: string): Promise<void> => {
this.removals.push(path)
if (this.nextRemoveError !== undefined) {
const error = this.nextRemoveError
this.nextRemoveError = undefined
throw error
}
for (const candidate of this.nodes.keys()) {
if (candidate === path || candidate.startsWith(`${path}/`)) this.nodes.delete(candidate)
}
},
},
commands: {
run: async (
command: string,
options?: { envs?: Record<string, string>; signal?: AbortSignal },
): Promise<{ exitCode: number; stdout: string; stderr: string }> => {
this.checkAbort(options)
const home = options?.envs?.HOME
expect(home).toMatch(/^\/\.dsh-e2b-control-/)
expect(options?.envs).toEqual({ HOME: home })
this.commands.push(command)
if (this.nextCommandError !== undefined) {
const error = this.nextCommandError
this.nextCommandError = undefined
throw error
}
const realpathPrefix = 'set -o pipefail; realpath -mz -- '
const realpathSuffix = ' | base64 -w0'
if (command.startsWith(realpathPrefix) && command.endsWith(realpathSuffix)) {
const quoted = command.slice(realpathPrefix.length, -realpathSuffix.length)
const input = quoted.slice(1, -1).replaceAll(String.raw`'"'"'`, '\'')
const node = this.nodes.get(input)
const canonical = `${node?.symlinkTarget ?? input}\0`
return {
exitCode: 0,
stdout: this.canonicalOutput ?? Buffer.from(canonical).toString('base64'),
stderr: '',
}
}
const chmod = /^chmod ([0-7]+) -- '([^']+)'$/.exec(command)
if (chmod !== null) this.required(chmod[2]!).mode = Number.parseInt(chmod[1]!, 8)
const move = /^mv -f -- '([^']+)' '([^']+)'$/.exec(command)
if (move !== null) {
if (this.nextRenameError !== undefined) {
const error = this.nextRenameError
this.nextRenameError = undefined
throw error
}
const node = this.required(move[1]!)
this.nodes.delete(move[1]!)
this.nodes.set(move[2]!, node)
this.renames.push({ from: move[1]!, to: move[2]! })
this.abortAfterRename?.abort('after commit')
}
return { exitCode: 0, stdout: '', stderr: '' }
},
},
} as unknown as Sandbox
}
async function setup(remote = new FakeRemote()): Promise<{ ctx: Context; fs: E2BFileSystem; remote: FakeRemote }> {
const ctx = new Context()
const runtime = {
cwd: '/workspace',
runtimeRoot: '/workspace/.dsh-e2b',
getSandbox: async () => remote.sandbox,
} as unknown as E2BSandboxService
ctx.provide('e2b', runtime)
await ctx.plugin(E2BFileSystem)
return { ctx, fs: ctx.fs as E2BFileSystem, remote }
}
async function expectCode(promise: Promise<unknown>, code: string): Promise<void> {
await expect(promise).rejects.toMatchObject({ code })
}
describe('E2BFileSystem identity, metadata, and reads', () => {
it('resolves remote paths, reports symlinks, and lists direct children in stable order', async () => {
const remote = new FakeRemote()
remote.file('/workspace/z.txt', 'z')
remote.file('/workspace/a.txt', 'a')
remote.dir('/workspace/dir')
remote.other('/workspace/special')
remote.file('/workspace/dir/nested.txt', 'nested')
remote.symlink('/workspace/link.txt', '/workspace/a.txt')
const { fs } = await setup(remote)
const link = await fs.resolve('link.txt')
expect(link).toEqual({ targetKey: '/workspace/a.txt', displayPath: '/workspace/link.txt' })
await expect(fs.lstat('link.txt')).resolves.toMatchObject({ type: 'symlink', size: 1 })
await expect(fs.lstat('a.txt')).resolves.toMatchObject({ type: 'file', size: 1 })
await expect(fs.lstat('dir')).resolves.toEqual(expect.objectContaining({ type: 'directory' }))
await expect(fs.lstat('special')).resolves.toEqual(expect.objectContaining({ type: 'other' }))
await expect(fs.lstat('missing')).resolves.toBeUndefined()
await expect(fs.stat(link)).resolves.toMatchObject({ type: 'file', size: 1 })
const directory = await fs.resolve('.')
const listed = await fs.listDir(directory)
expect(listed.map(entry => entry.name)).toEqual(['a.txt', 'dir', 'link.txt', 'special', 'z.txt'])
expect(listed.find(entry => entry.name === 'dir')).toMatchObject({ type: 'directory' })
expect(listed.find(entry => entry.name === 'link.txt')).toMatchObject({
type: 'file',
target: { targetKey: '/workspace/a.txt', displayPath: '/workspace/link.txt' },
})
expect(listed.some(entry => entry.name === 'nested.txt')).toBe(false)
})
it('projects canonical process paths, file URLs, and containment', async () => {
const remote = new FakeRemote()
remote.dir('/workspace/nested')
remote.file('/workspace/nested/multibyte # file.ts', 'text')
remote.file('/outside.ts', 'outside')
const { fs } = await setup(remote)
const workspace = await fs.resolve('/workspace')
const nested = await fs.resolve('/workspace/nested/multibyte # file.ts')
const outside = await fs.resolve('/outside.ts')
expect(fs.processPath(nested)).toBe('/workspace/nested/multibyte # file.ts')
expect(fs.fileUrl(nested)).toBe('file:///workspace/nested/multibyte%20%23%20file.ts')
expect(fs.contains(workspace, workspace)).toBe(true)
expect(fs.contains(workspace, nested)).toBe(true)
expect(fs.contains(nested, workspace)).toBe(false)
expect(fs.contains(workspace, outside)).toBe(false)
expect(() => fs.fileUrl({ targetKey: FsTargetKey('relative'), displayPath: 'relative' }))
.toThrow('expected an absolute process path')
})
it('preserves newline and multibyte canonical paths through strict ASCII framing', async () => {
const remote = new FakeRemote()
const path = '/workspace/你好\nfile.ts'
remote.file(path, 'text')
const { fs } = await setup(remote)
await expect(fs.resolve(path)).resolves.toEqual({ targetKey: path, displayPath: path })
})
it.each([
['invalid base64', '!!!!'],
['missing terminator', Buffer.from('/workspace/file').toString('base64')],
['multiple records', Buffer.from('/workspace/file\0/other\0').toString('base64')],
['invalid UTF-8', Buffer.from([47, 0xff, 0]).toString('base64')],
['relative path', Buffer.from('workspace/file\0').toString('base64')],
])('rejects %s from canonical path transport', async (_label, output) => {
const remote = new FakeRemote()
remote.canonicalOutput = output
const { fs } = await setup(remote)
await expectCode(fs.resolve('file'), 'FS_IO_ERROR')
})
it('reads whole and streamed UTF-8 across chunk boundaries', async () => {
const remote = new FakeRemote()
remote.file('/workspace/text.txt', 'A€B')
remote.streamChunks = [bytes([65, 0xe2]), bytes([0x82, 0xac, 66])]
const { fs } = await setup(remote)
const target = await fs.resolve('text.txt')
await expect(fs.readText(target)).resolves.toBe('A€B')
let streamed = ''
for await (const chunk of await fs.streamText(target)) streamed += chunk
expect(streamed).toBe('A€B')
remote.streamChunks = [bytes([0xe2]), bytes([0x82, 0xac])]
let initiallyBuffered = ''
for await (const chunk of await fs.streamText(target)) initiallyBuffered += chunk
expect(initiallyBuffered).toBe('€')
})
it('streams an empty file even though the pinned SDK returns a non-stream value', async () => {
const remote = new FakeRemote()
remote.file('/workspace/empty.txt', '')
const { fs } = await setup(remote)
let streamed = ''
for await (const chunk of await fs.streamText(await fs.resolve('empty.txt'))) streamed += chunk
expect(streamed).toBe('')
})
it('cancels a remote stream when its consumer stops early', async () => {
const remote = new FakeRemote()
remote.file('/workspace/text.txt', 'ab')
remote.streamChunks = [bytes('a'), bytes('b')]
remote.streamKeepOpen = true
const { fs } = await setup(remote)
const stream = await fs.streamText(await fs.resolve('text.txt'))
for await (const chunk of stream) {
expect(chunk).toBe('a')
break
}
expect(remote.streamCancel).toHaveBeenCalledOnce()
})
it('matches local binary sampling while edits still reject any NUL byte', async () => {
const remote = new FakeRemote()
remote.file('/workspace/late-nul.txt', `${'a'.repeat(8192)}\0tail`)
const { fs } = await setup(remote)
const target = await fs.resolve('late-nul.txt')
await expect(fs.readText(target)).resolves.toContain('\0tail')
remote.streamChunks = [bytes('a'.repeat(8192)), bytes([0, 116])]
let streamed = ''
for await (const chunk of await fs.streamText(target)) streamed += chunk
expect(streamed).toBe(`${'a'.repeat(8192)}\0t`)
await expectCode(fs.editText(target, { oldString: 'tail', newString: 'end', replaceAll: false }), 'FS_NOT_TEXT')
})
it('maps binary, invalid UTF-8, missing, and non-regular read failures', async () => {
const remote = new FakeRemote()
remote.file('/workspace/binary', [0, 1])
remote.file('/workspace/invalid', [0xff])
remote.dir('/workspace/directory')
const { fs } = await setup(remote)
await expectCode(fs.readText(await fs.resolve('binary')), 'FS_NOT_TEXT')
await expectCode(fs.readText(await fs.resolve('invalid')), 'FS_NOT_TEXT')
await expectCode(fs.readText(await fs.resolve('missing')), 'FS_NOT_FOUND')
await expectCode(fs.readText(await fs.resolve('directory')), 'FS_NOT_REGULAR_FILE')
remote.streamChunks = [bytes([0xff])]
const invalid = await fs.streamText(await fs.resolve('invalid'))
await expect((async () => { for await (const _chunk of invalid) void _chunk })()).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
remote.streamChunks = [bytes([0])]
const binary = await fs.streamText(await fs.resolve('binary'))
await expect((async () => { for await (const _chunk of binary) void _chunk })()).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
remote.streamChunks = [bytes([0xe2])]
const incomplete = await fs.streamText(await fs.resolve('invalid'))
await expect((async () => { for await (const _chunk of incomplete) void _chunk })()).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
const raced = await fs.resolve('invalid')
remote.nextReadError = new FileNotFoundError('gone after stat')
await expectCode(fs.streamText(raced), 'FS_NOT_FOUND')
})
it('honors aborts before and during remote reads', async () => {
const remote = new FakeRemote()
remote.file('/workspace/a', 'a')
const { fs } = await setup(remote)
await expectCode(fs.resolve('a', { signal: AbortSignal.abort() }), 'FS_ABORTED')
await expectCode(fs.lstat('a', undefined, AbortSignal.abort()), 'FS_ABORTED')
await expectCode(fs.stat(await fs.resolve('a'), AbortSignal.abort()), 'FS_ABORTED')
remote.nextReadError = new DOMException('aborted', 'AbortError')
await expectCode(fs.readText(await fs.resolve('a')), 'FS_ABORTED')
})
it('rejects empty paths and directory-listing type errors', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file', 'x')
const { fs } = await setup(remote)
await expectCode(fs.resolve(' '), 'FS_NOT_FOUND')
await expectCode(fs.lstat(''), 'FS_NOT_FOUND')
await expectCode(fs.listDir(await fs.resolve('missing')), 'FS_NOT_FOUND')
await expectCode(fs.listDir(await fs.resolve('/workspace/file')), 'FS_NOT_DIRECTORY')
remote.nextListError = new Error('listing transport failed')
await expectCode(fs.listDir(await fs.resolve('/workspace')), 'FS_IO_ERROR')
})
})
describe('E2BFileSystem atomic writes and edits', () => {
it('creates owner-only files and returns metadata after the committed move', async () => {
const { fs, remote } = await setup()
const target = await fs.resolve('new.txt')
const outcome = await fs.writeText(target, 'one\r\ntwo\rthree', { kind: 'createIfAbsent' })
expect(outcome).toMatchObject({ operation: 'create', before: null, after: 'one\ntwo\rthree' })
expect(remote.nodes.get('/workspace/new.txt')?.mode).toBe(0o600)
expect(remote.nodes.get('/workspace/new.txt')?.metadata?.['dsh-version']).toBeDefined()
expect(remote.writeParentModes).toEqual([0o700])
const stagingDirectory = posix.dirname(remote.writes[0]!.path)
expect(posix.dirname(stagingDirectory)).toBe('/workspace')
expect(remote.removals).toContain(stagingDirectory)
await expect(fs.stat(target)).resolves.toMatchObject({ version: outcome.version, size: 14 })
})
it('preserves replacement mode, normalizes only CRLF for diffs, and changes version on external writes', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', 'old\r\nline\rlone', 0o640)
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
const before = (await fs.stat(target))!.version
const outcome = await fs.writeText(target, 'new', { kind: 'replaceIfVersion', version: before })
expect(outcome).toMatchObject({ operation: 'update', before: 'old\nline\rlone', after: 'new' })
expect(remote.nodes.get('/workspace/file.txt')?.mode).toBe(0o640)
const committed = outcome.version
remote.mutate('/workspace/file.txt', 'external')
expect((await fs.stat(target))!.version).not.toBe(committed)
})
it('returns null as the overwrite diff basis for binary or invalid prior content', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', [0xff])
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
await expect(fs.writeText(target, 'valid')).resolves.toMatchObject({ before: null, after: 'valid' })
})
it('fails an overwrite when reading its text diff basis fails for another reason', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', 'prior')
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
remote.nextReadError = new Error('read transport failed')
await expectCode(fs.writeText(target, 'replacement'), 'FS_IO_ERROR')
expect(new TextDecoder().decode(remote.nodes.get('/workspace/file.txt')?.data)).toBe('prior')
})
it('enforces create and version intents before publication', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', 'v1')
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
const version = (await fs.stat(target))!.version
await expectCode(fs.writeText(target, 'blind', { kind: 'createIfAbsent' }), 'FS_NOT_OBSERVED')
remote.mutate('/workspace/file.txt', 'v2')
await expectCode(fs.writeText(target, 'stale', { kind: 'replaceIfVersion', version }), 'FS_STALE_VERSION')
await expectCode(fs.writeText(await fs.resolve('missing'), 'stale', { kind: 'replaceIfVersion', version }), 'FS_STALE_VERSION')
remote.dir('/workspace/dir')
await expectCode(fs.writeText(await fs.resolve('dir'), 'x'), 'FS_NOT_REGULAR_FILE')
})
it('does not turn an abort observed after a successful move into a failed write', async () => {
const remote = new FakeRemote()
const controller = new AbortController()
remote.abortAfterRename = controller
const { fs } = await setup(remote)
await expect(fs.writeText(await fs.resolve('committed'), 'yes', undefined, controller.signal))
.resolves.toMatchObject({ operation: 'create' })
expect(controller.signal.aborted).toBe(true)
})
it('does not turn post-commit staging cleanup failure into a failed write', async () => {
const remote = new FakeRemote()
remote.nextRemoveError = new Error('empty staging cleanup failed')
const { fs } = await setup(remote)
await expect(fs.writeText(await fs.resolve('committed'), 'yes'))
.resolves.toMatchObject({ operation: 'create' })
expect(new TextDecoder().decode(remote.nodes.get('/workspace/committed')?.data)).toBe('yes')
})
it('returns committed rename metadata without a fallible post-commit lookup', async () => {
const remote = new FakeRemote()
const getInfo = vi.spyOn(remote.sandbox.files, 'getInfo')
const { fs } = await setup(remote)
await expect(fs.writeText(await fs.resolve('committed'), 'yes'))
.resolves.toMatchObject({ operation: 'create' })
expect(getInfo).toHaveBeenCalledTimes(1)
expect(remote.renames).toHaveLength(1)
})
it('cleans staging files and maps command, permission, and abort failures', async () => {
const remote = new FakeRemote()
const { fs } = await setup(remote)
const commandTarget = await fs.resolve('command')
remote.nextCommandError = commandError(1, 'chmod failed')
await expectCode(fs.writeText(commandTarget, 'x'), 'FS_IO_ERROR')
expect(remote.removals).toHaveLength(1)
remote.nextRenameError = new Error('permission denied')
await expectCode(fs.writeText(await fs.resolve('permission'), 'x'), 'FS_PERMISSION_DENIED')
remote.nextRemoveError = new Error('cleanup also failed')
remote.nextRenameError = new DOMException('aborted', 'AbortError')
await expectCode(fs.writeText(await fs.resolve('abort'), 'x'), 'FS_ABORTED')
const removalsBeforeCollision = remote.removals.length
remote.nextMakeDirResult = false
await expectCode(fs.writeText(await fs.resolve('collision'), 'x'), 'FS_IO_ERROR')
expect(remote.removals).toHaveLength(removalsBeforeCollision)
})
it('applies literal edits atomically and restores the detected CRLF style', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', 'one\r\ntwo\r\nthree\n')
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
const version = (await fs.stat(target))!.version
const outcome = await fs.editText(
target,
{ oldString: 'two\r\n', newString: 'TWO\r\n', replaceAll: false },
{ version },
)
expect(outcome).toMatchObject({ before: 'one\ntwo\nthree\n', after: 'one\nTWO\nthree\n' })
expect(new TextDecoder().decode(remote.nodes.get('/workspace/file.txt')?.data)).toBe('one\r\nTWO\r\nthree\r\n')
})
it('reports stale and literal-match failures with stable codes', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', 'a a')
remote.dir('/workspace/dir')
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
await expectCode(fs.editText(target, { oldString: '', newString: 'x', replaceAll: false }), 'FS_EDIT_NOT_FOUND')
await expectCode(fs.editText(target, { oldString: 'z', newString: 'x', replaceAll: false }), 'FS_EDIT_NOT_FOUND')
await expectCode(fs.editText(target, { oldString: 'a', newString: 'x', replaceAll: false }), 'FS_AMBIGUOUS_EDIT')
await expect(fs.editText(target, { oldString: 'a', newString: 'x', replaceAll: true }))
.resolves.toMatchObject({ after: 'x x' })
await expectCode(fs.editText(target, { oldString: 'x', newString: 'y', replaceAll: false }, { version: FsVersion('stale') }), 'FS_STALE_VERSION')
await expectCode(fs.editText(await fs.resolve('missing'), { oldString: 'x', newString: 'y', replaceAll: false }), 'FS_STALE_VERSION')
await expectCode(fs.editText(await fs.resolve('dir'), { oldString: 'x', newString: 'y', replaceAll: false }), 'FS_NOT_REGULAR_FILE')
})
it('serializes guarded mutations so only one stale version can win', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', 'base')
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
const version = (await fs.stat(target))!.version
const results = await Promise.allSettled([
fs.writeText(target, 'one', { kind: 'replaceIfVersion', version }),
fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }),
])
expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1)
expect(results.filter(result => result.status === 'rejected')).toHaveLength(1)
})
})
describe('E2B filesystem adapter integration edges', () => {
it('maps canonicalization, permission, and generic provider failures', async () => {
const remote = new FakeRemote()
const { fs } = await setup(remote)
remote.nextCommandError = commandError(1, 'not a directory')
await expectCode(fs.resolve('bad'), 'FS_IO_ERROR')
remote.nextCommandError = commandError(1)
await expectCode(fs.resolve('bad-again'), 'FS_IO_ERROR')
remote.nextCommandError = new Error('canonical transport failed')
await expectCode(fs.resolve('bad-transport'), 'FS_IO_ERROR')
remote.file('/workspace/a', 'a')
const target = await fs.resolve('a')
remote.nextInfoError = new Error('metadata transport failed')
await expectCode(fs.stat(target), 'FS_IO_ERROR')
remote.nextReadError = new Error('operation not permitted')
await expectCode(fs.readText(target), 'FS_PERMISSION_DENIED')
remote.nextReadError = 'transport vanished'
await expectCode(fs.readText(target), 'FS_IO_ERROR')
})
it('uses listing metadata directly and canonicalizes only symbolic links', async () => {
const remote = new FakeRemote()
remote.file('/workspace/a', 'a')
remote.file('/workspace/target', 'target')
remote.file('/workspace/gone', 'gone')
remote.symlink('/workspace/link', '/workspace/target')
remote.symlink('/workspace/vanished-link', '/workspace/gone')
remote.disappearOnInfo.add('/workspace/gone')
const { fs } = await setup(remote)
const directory = await fs.resolve('/workspace')
const commandsBefore = remote.commands.length
const getInfo = vi.spyOn(remote.sandbox.files, 'getInfo')
const listed = await fs.listDir(directory)
expect(listed.find(entry => entry.name === 'a')).toMatchObject({
type: 'file', target: { targetKey: '/workspace/a' }, size: 1,
})
expect(listed.find(entry => entry.name === 'link')).toMatchObject({
type: 'file', target: { targetKey: '/workspace/target' }, size: 6,
})
expect(listed.find(entry => entry.name === 'vanished-link')).toEqual({
name: 'vanished-link',
type: 'other',
target: { targetKey: '/workspace/gone', displayPath: '/workspace/vanished-link' },
})
expect(remote.commands.slice(commandsBefore)).toHaveLength(2)
expect(getInfo).toHaveBeenCalledTimes(3)
})
it('registers the package-owned empty invariant installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = await ctx.plugin(E2BFsInvariant).await()
await fiber.dispose()
})
})

View File

@@ -0,0 +1,25 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../e2b"
},
{
"path": "../../fs/fs"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/e2b/subprocess-e2b/README.md
README.md: 926d4f22f8e96daa103c236121d35e461290221a
README.zh.md: d1634b6d1a701f7f4815135b4ec6bb86ad1a8c87

View File

@@ -0,0 +1,43 @@
# @deepseek-ai/dsh-subprocess-e2b
English | [中文](README.zh.md)
E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) seam. Load [`@deepseek-ai/dsh-e2b`](../e2b/README.md) first, then this service in place of `dsh-subprocess-local`. Existing Bash, PTY, and LSP consumers then execute in the shared remote sandbox without E2B-specific capability packages.
## Configuration
| Key | Default | Meaning |
| --- | --- | --- |
| `pollMs` | `20` | Remote status/liveness poll cadence in milliseconds; each tick is one control-plane request, so a larger value trades exit-observation latency for fewer requests. |
## Behavior
- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the wrapper publishes and the adapter validates its process-group id; stdin and ordinary observation wait for that publication. An owned startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean.
- **Execution-world coordinates** — `cwd` and private `runtimeRoot` come from the shared owner; executable lookup verifies absolute paths or resolves a bare name against the sandbox PATH plus explicit overrides, and rejects relative paths containing separators like every subprocess provider.
- **Linux process groups** — a quoted wrapper starts each argv under `exec setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file instead of treating the SDK command PID as its published identity. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback; TERM delivery or probe failures also force that escalation. Process-table probes treat groups containing only zombie or dead entries as quiescent. Force cleanup succeeds only after a bounded probe finds the group empty; otherwise `waitForExit()` exposes a retryable failure, while proven quiescence makes later termination a no-op. Publication and monitoring failures apply the same cleanup transaction before rejecting. Service disposal rejects new starts, terminates and joins every retained process group, then awaits SDK settlement and private cleanup before the sandbox owner disposes.
- **Environment boundary** — one trusted control-shell probe resolves the sandbox user's login home from its passwd entry and transports the sandbox environment as base64 ASCII for one strict UTF-8 decode; the wrapper then removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names and restores every valid `spec.env` entry as an explicit caller opt-in. Empty names, `=`, and NUL framing violations reject before launch. Subsequent E2B command and PTY login shells receive a fresh randomized root-level `HOME` plus empty overrides for every scrubbed ambient name before user profiles can run; the requested argv receives the serialized environment afterward without changing the sandbox user's umask. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting.
- **Stdio projection** — the remote wrapper branches raw bytes into optional bounded spill files, frames each live chunk as newline-delimited base64 ASCII, and the host incrementally restores bytes across arbitrary SDK callback boundaries. Pipe mode writes those bytes to host Node streams; inherit mode writes them to the harness process streams; collect mode retains a bounded host tail with offset reads. The wrapper publishes the direct command status before waiting for inherited writers. For collect or inherit output, the adapter disconnects an incomplete SDK stream after `graceMs`, withholds its partial spill, and returns that status while retaining the remote group for `waitForExit()` and termination. Natural raw-pipe completion instead awaits lossless transport and preserves backpressure; explicit termination destroys the host pipes and releases blocked output before remote cleanup. Batch and streaming stdin use the SDK handle.
- **Terminal sessions** — `spawnTerminal()` uses E2B's byte PTY API, installs the exact argv and scrubbed environment through private mode-`0600` files, reports the foreground process group, sends real signals, and tears down every live group in the remote terminal session through one retryable awaited `terminate()`; termination rejects new handle operations, aborts and joins in-flight writes, inspections, and signals, and treats zombie-only groups as quiescent. A private random output boundary discards the E2B bootstrap shell's prompt and echoed runner command while preserving every requested-process byte, including its first prompt. Terminal output is pushed to the handle's stream without awaiting host backpressure: a flowing consumer (the PTY backend attaches one at construction) folds bytes into its own bounded state, while a paused consumer buffers in host memory. PTY allocation is awaited through handle publication before cancellation is observed, so owned rollback can clean the published handle. Setup and teardown own the private state transaction, abort pending setup during service disposal, and fence publication; sandbox disposal or timeout bounds a setup rollback that also fails. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`.
- **Sandbox disappearance** — `SandboxNotFoundError` during process or terminal liveness, termination, rollback, or disconnect proves the remote execution world cannot retain work, so cleanup treats it as quiescent; unrelated failures remain observable.
The default E2B base image supplies the runtime and Bash/GNU utilities this adapter invokes: `node`, `bash`, `setsid`, `ps`, `awk`, `tr`, `env`, `base64`, `chmod`, `tee`, `head`, `rm`, `kill`, `id`, and `getent`.
## Model Experience
Indirectly, through consumer seams such as the Bash executor behind `dsh-tool-bash`, which render remote output, exit facts, background deltas, and spill paths.
#### KV Cache effect
No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate the base64 transport even when this adapter exposes bounded raw-byte tails, so the subprocess seam's normal host-memory bound is not achieved and transport retention is larger than the source stream.
- **Synchronous-PID consumers are unsupported** — `pid` remains `-1` during remote startup; consumers that require a positive PID immediately, including the ACP child backend, cannot use this provider unchanged.
- **Private state lives for the sandbox lifetime** — process directories and valid spill files remain under `.dsh-e2b` until the owner deletes the sandbox; this POC supplies no in-sandbox sweep.
- **Control state shares the sandbox user's UID** — E2B runs every command as the same default user, so `0700`/`0600` modes cannot isolate `.dsh-e2b` control files from concurrently running sandbox processes. A background process could rewrite `pid`/`exit-code` or read a not-yet-consumed `environment` file. The adapter validates published values and refuses group ids whose negative form is unsafe to signal (`<= 1`), but real isolation needs an E2B per-command user or an out-of-band control channel.
- **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID PTY input, signalling, and cleanup operations but no atomic identity-bound alternative. The adapter minimizes host round trips and live coverage exercises the reproducible stale-interrupt overlap; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol.
- **The initial environment probe inherits sandbox defaults** — E2B merges command overrides with default environment entries, so the probe cannot blank unknown credential-shaped names before enumerating them. A same-UID untrusted process already in the sandbox could inspect that short-lived control shell; this POC therefore does not support secrets in sandbox-default environment variables and requires an E2B replacement-environment primitive to close the gap.
- **E2B exposes no signal fact** — an adapter-requested `SIGTERM` or `SIGKILL` is reported only when no wrapper-published direct exit code wins; every unrequested SDK exit remains an exit code, including values shaped like `128 + signal`.
- **Exact terminal stdin-wait inspection is unavailable** — E2B exposes the foreground process group but not the syscall evidence needed to prove it is waiting on fd 0, so the generic PTY backend falls back to controlled prompt markers and bounded silence.
- **Linux utility and E2B transport semantics are assumed** — there is no Windows, escaped-session recovery, or network-partition fidelity layer.

View File

@@ -0,0 +1,43 @@
# @deepseek-ai/dsh-subprocess-e2b
[English](README.md) | 中文
[`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) seam 的 E2B 实现。先加载 [`@deepseek-ai/dsh-e2b`](../e2b/README.md),再用本服务取代 `dsh-subprocess-local`。现有的 Bash、PTY 和 LSP 消费方随后会在共享远程沙箱中执行,无需 E2B 专用的功能包package
## 配置
| 键 | 默认值 | 含义 |
| --- | --- | --- |
| `pollMs` | `20` | 远程状态/存活轮询节奏(毫秒);每个 tick 是一次控制面请求,调大该值以牺牲退出观察延迟换取更少的请求。 |
## 行为
- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid``-1`stdin 和常规观察会等待该发布。自有启动信号会在分配前中止环境和私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。
- **执行世界坐标**`cwd` 和私有 `runtimeRoot` 来自共享所有者;可执行文件查找会验证绝对路径,或根据沙箱 PATH 加显式覆盖来解析裸名称,并与所有 subprocess 提供方一致地拒绝含分隔符的相对路径。
- **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退TERM 信号发送或探测失败也会强制触发该升级。进程表探测会把仅含僵尸或已死亡条目的进程组视为完全停稳。强制清理只有在有界探测发现进程组为空后才算成功;否则 `waitForExit()` 会公开可重试的失败,而已证明的完全停稳会让后续终止操作不再执行任何动作。发布失败与监控失败都会在拒绝前执行同一清理事务。服务 dispose资源释放会拒绝新的启动请求、终止并等待每个保留进程组退出再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放。
- **环境边界**:一次受信任的控制 shell 探测会从 passwd 条目解析沙箱用户的登录主目录,以 base64 ASCII 传输沙箱环境,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*``*SECRET*``*TOKEN*`),并把每个有效的 `spec.env` 条目恢复为调用方显式选择。空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。在用户 profile 脚本运行前,此后的 E2B 命令 shell 与 PTY 登录 shell 会获得位于根目录下、全新随机生成的 `HOME`,并为每个被清理的环境变量名设置空值覆盖;之后,请求的 argv 会在不改变沙箱用户 umask 的前提下接收序列化环境。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。
- **stdio 投影**:远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流inherit 模式把字节写入 harness 进程流collect 模式保留有界的宿主尾部,并支持基于偏移量读取。包装层会在等待继承管道的写入方之前发布直接命令状态。对于 collect 或 inherit 输出,超过 `graceMs` 后,适配器会断开未完成的 SDK 流,不公开其中不完整的 spill并返回该状态同时保留远程进程组供 `waitForExit()` 和终止操作使用。原始 pipe 自然完成时,会等待无损传输完成并保留背压;显式终止则会销毁宿主 pipe并在远程清理前释放受阻的输出写入。批量 stdin 和流式 stdin 都使用 SDK 句柄。
- **终端会话**`spawnTerminal()` 使用 E2B 的字节 PTY API以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并通过一项可重试且须等待的 `terminate()` 清理远程终端会话中仍存活的每个进程组;终止会拒绝新的句柄操作,中止并等待在途写入、检查和信号操作结算,并把仅含僵尸进程的进程组视为已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令同时保留请求进程的每个字节包括其第一个提示符。终端输出推入句柄流时不等待宿主背压流动的消费方PTY 后端在构造时就挂上一个把字节折叠进自身的有界状态而暂停的消费方会在宿主内存中缓冲。PTY 分配会一直等待到句柄发布后才观察取消以便由承担清理责任的回滚清理已发布句柄。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup 并阻止发布;若 setup 回滚也失败,则由沙箱 dispose 或超时约束其存活时间。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。
- **沙箱消失**:在进程或终端的存活探测、终止、回滚或断开连接期间出现 `SandboxNotFoundError`,证明远程执行环境无法保留工作,因此清理会将其视为完全停稳;其他故障仍可观察。
E2B 默认基础镜像提供该适配器调用的运行时和 Bash/GNU 工具:`node``bash``setsid``ps``awk``tr``env``base64``chmod``tee``head``rm``kill``id``getent`
## 模型体验
通过消费方 seam 间接影响模型,例如 `dsh-tool-bash` 背后的 Bash 执行器;这些消费方会渲染远程输出、退出事实、后台增量和 spill 路径。
#### KV Cache 影响
不会直接失效;请求前缀变更由具名消费方负责。
## 已知限制与延后工作
- **SDK 仍会在宿主内存中保留完整命令输出**即使本适配器公开的是有界原始字节尾部E2B `CommandHandle.stdout``.stderr` 仍会累积 base64 传输内容,因此无法达到进程管理 seam 通常提供的宿主内存边界,而且传输保留量大于源数据流。
- **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `-1`;包括 ACP 子进程后端在内,要求立即获得正 PID 的消费方无法原样使用本提供方。
- **私有状态随沙箱生命周期存在**:进程目录和有效的 spill 文件会留在 `.dsh-e2b` 下,直到所有者删除沙箱;本 POC 不提供沙箱内清理。
- **控制状态与沙箱用户同 UID**E2B 以同一默认用户运行每条命令,因此 `0700`/`0600` 权限无法把 `.dsh-e2b` 控制文件与并发运行的沙箱进程隔离开。后台进程可以改写 `pid`/`exit-code`,或读取尚未被消费的 `environment` 文件。适配器会验证已发布的值,并拒绝取负后不安全的进程组 ID`<= 1`),但真正的隔离需要 E2B 提供按命令用户或带外控制通道。
- **数值进程身份没有复用围栏**E2B 公开基于数值 PID/PGID 的 PTY 输入、信号发送和清理操作,却没有与身份原子绑定的替代方案。适配器会尽量减少宿主往返,真实环境测试会覆盖可复现的陈旧中断重叠;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案会继续延后。
- **初始环境探测会继承沙箱默认值**E2B 会把命令覆盖与默认环境条目合并,因此探测无法在枚举未知且形似凭据的名称之前将它们置空。一个已在沙箱内运行的同 UID 不可信进程可以检查该短时存在的控制 shell因此该 POC 不支持把 secret 放入沙箱默认环境变量,需要 E2B 的替换环境原语才能弥合该缺口。
- **E2B 不公开信号事实**:适配器请求的 `SIGTERM``SIGKILL` 只有在包装层发布的直接退出码没有胜出时才报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括形似 `128 + signal` 的值。
- **无法精确检查终端 stdin 等待状态**E2B 会公开前台进程组,但不提供证明其正在等待 fd 0 所需的 syscall 证据,因此通用 PTY 后端会回退到受控提示符标记与有界静默机制。
- **依赖 Linux 工具与 E2B 传输语义**:没有 Windows、逃逸会话恢复或网络分区的保真层。

View File

@@ -0,0 +1,44 @@
{
"name": "@deepseek-ai/dsh-subprocess-e2b",
"description": "E2B subprocess implementation for DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-e2b": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-e2b": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,104 @@
/** Shared remote-environment scrubbing for E2B process and terminal launchers. */
import { Buffer } from 'node:buffer'
import { posix } from 'node:path'
import { e2bControlEnvs } from '@deepseek-ai/dsh-e2b'
import type { Sandbox } from '@deepseek-ai/dsh-e2b'
import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess'
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
function remoteEnvironmentEntries(raw: string): Array<readonly [string, string]> {
const entries: Array<readonly [string, string]> = []
for (const entry of raw.split('\0')) {
if (entry.length === 0) continue
const separator = entry.indexOf('=')
if (separator <= 0) continue
entries.push([entry.slice(0, separator), entry.slice(separator + 1)])
}
return entries
}
/**
* Read the remote environment through ASCII base64 so SDK callback chunking cannot corrupt UTF-8.
* @param sandbox - shared E2B execution world.
* @param signal - optional cancellation for the control-plane request.
* @returns the complete NUL-delimited UTF-8 environment.
*/
export async function readRemoteEnvironment(sandbox: Sandbox, signal?: AbortSignal): Promise<string> {
// TODO(e2b-replace-environment): Remove this ambient probe when E2B can start
// a command with a replacement environment instead of merged overrides.
const result = await sandbox.commands.run(
'set -o pipefail; dsh_e2b_passwd="$(getent passwd "$(id -u)")"; IFS=: read -r _ _ _ _ _ dsh_e2b_home _ <<<"$dsh_e2b_passwd"; test -n "$dsh_e2b_home" -a -d "$dsh_e2b_home"; printf \'%s\' "$dsh_e2b_home" | base64 -w 0; printf \'\\n\'; env -0 | base64 -w 0',
{ envs: e2bControlEnvs(), ...(signal === undefined ? {} : { signal }) },
)
const lines = result.stdout.trim().split('\n')
if (lines.length !== 2 || !lines.every(line => BASE64.test(line))) {
throw new Error('subprocess-e2b: remote environment transport returned invalid base64')
}
const [encodedHome, encodedEnvironment] = lines as [string, string]
let home: string
let raw: string
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
home = decoder.decode(Buffer.from(encodedHome, 'base64'))
raw = decoder.decode(Buffer.from(encodedEnvironment, 'base64'))
} catch (error: unknown) {
throw new Error('subprocess-e2b: remote environment is not valid UTF-8', { cause: error })
}
if (!posix.isAbsolute(home) || home.includes('\0')) {
throw new Error(`subprocess-e2b: remote login home is invalid: ${JSON.stringify(home)}`)
}
const environment = new Map(remoteEnvironmentEntries(raw))
environment.set('HOME', home)
return [...environment].map(([name, value]) => `${name}=${value}\0`).join('')
}
/**
* Parse an E2B NUL-delimited environment while removing harness-private and credential-shaped names.
* @param raw - The complete NUL-delimited remote environment.
* @returns Mutable retained entries for the caller to overlay and serialize.
*/
export function scrubRemoteEnvironment(raw: string): Map<string, string> {
const environment = new Map<string, string>()
for (const [name, value] of remoteEnvironmentEntries(raw)) {
if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) continue
environment.set(name, value)
}
return environment
}
/**
* Isolate E2B's fixed login-shell bootstrap from user profiles and ambient credentials.
* @param raw - The complete NUL-delimited remote environment.
* @returns Explicit E2B command or PTY overrides for bootstrap-shell startup.
*/
export function bootstrapEnvironment(raw: string): Record<string, string> {
const environment: Record<string, string> = { TERM: 'dumb' }
for (const [name] of remoteEnvironmentEntries(raw)) {
if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) environment[name] = ''
}
return environment
}
/**
* Overlay explicit entries and serialize one validated E2B environment.
* @param raw - The complete NUL-delimited remote environment.
* @param explicit - Deliberate caller overrides applied after ambient scrubbing; an `undefined` tombstone removes an ambient entry.
* @returns NUL-delimited `name=value` entries accepted by `env -i`.
*/
export function serializeRemoteEnvironment(
raw: string,
explicit: Readonly<NodeJS.ProcessEnv> | undefined,
): string {
const environment = scrubRemoteEnvironment(raw)
for (const [name, value] of Object.entries(explicit ?? {})) {
if (name.length === 0 || name.includes('=') || name.includes('\0') || value?.includes('\0') === true) {
throw new Error('subprocess-e2b: environment entries require non-empty NUL-free names without = and NUL-free values')
}
// An explicit undefined is the seam's tombstone: remove the ambient entry.
if (value === undefined) environment.delete(name)
else environment.set(name, value)
}
return [...environment].map(([name, value]) => `${name}=${value}\0`).join('')
}

View File

@@ -0,0 +1,208 @@
/**
* E2B implementation of the subprocess seam. Each handle starts through the
* shared sandbox and retains command output/status paths in that remote world.
* @module @deepseek-ai/dsh-subprocess-e2b
*/
import { randomUUID } from 'node:crypto'
import { posix } from 'node:path'
import { Context } from 'cordis'
import z from 'schemastery'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type {
SubprocessHandle,
SubprocessSpawnSpec,
SubprocessTerminalHandle,
SubprocessTerminalSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import { e2bControlEnvs, quoteE2BShellArg } from '@deepseek-ai/dsh-e2b'
import { E2BSubprocessHandle } from './process.ts'
import { asError, signalOpts } from './remote.ts'
import { spawnE2BTerminal } from './terminal.ts'
/** Configuration for the E2B subprocess adapter. */
export interface Config {
/** Remote status/liveness poll cadence in milliseconds; each tick is one control-plane request. */
pollMs?: number
}
interface SchemaResolvedConfig extends Config {
pollMs: number
}
interface TerminalSetup {
done: Promise<void>
controller: AbortController
}
/**
* Enforce the seam's documented grace bound (positive, finite, one Node timer),
* matching subprocess-local's spawn-time check; an unbounded grace would make
* the remote force-escalation deadline unreachable.
* @param graceMs - The spec's cleanup grace in milliseconds.
*/
function requireRepresentableGrace(graceMs: number): void {
if (!Number.isFinite(graceMs) || graceMs <= 0 || graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
/** E2B command manager registered as `ctx.subprocess`. */
export class E2BSubprocessService extends SubprocessService {
static inject = ['e2b']
static Config: z<Config> = z.object({
pollMs: z.number().default(20),
})
private readonly live = new Set<E2BSubprocessHandle>()
private readonly terminals = new Set<SubprocessTerminalHandle>()
private readonly terminalSetups = new Set<TerminalSetup>()
private readonly pollMs: number
private disposing = false
/** Create the E2B subprocess service and bind its disposal policy. */
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery fills pollMs before construction; the type does not encode that step.
const { pollMs } = config as SchemaResolvedConfig
if (!Number.isSafeInteger(pollMs) || pollMs <= 0) {
throw new Error('subprocess-e2b: pollMs must be a positive safe integer')
}
this.pollMs = pollMs
ctx.effect(() => async () => {
this.disposing = true
for (const setup of this.terminalSetups) {
setup.controller.abort(new Error('subprocess-e2b: service disposed during terminal setup'))
}
await Promise.all([...this.terminalSetups].map(setup => setup.done))
const handles = [...this.live]
const terminals = [...this.terminals]
const pending: Promise<unknown>[] = []
for (const handle of handles) {
handle.terminate()
pending.push(handle.waitForExit().then(async () => {
await handle.done.catch(() => undefined)
this.live.delete(handle)
}))
}
for (const terminal of terminals) {
pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) }))
}
const outcomes = await Promise.allSettled(pending)
const failures = outcomes.flatMap<unknown>(outcome => outcome.status === 'rejected'
? [outcome.reason as unknown]
: [])
if (failures.length === 1) throw asError(failures[0])
if (failures.length > 1) throw new AggregateError(failures, 'subprocess-e2b: teardown failed')
}, 'e2b subprocess teardown')
}
/** @inheritdoc */
async resolveExecutable(
command: string,
env?: Readonly<Record<string, string>>,
signal?: AbortSignal,
): Promise<string> {
if (command.length === 0) throw new Error('subprocess-e2b: executable name must be non-empty')
signal?.throwIfAborted()
const sandbox = await this.ctx.e2b.getSandbox()
if (posix.isAbsolute(command)) {
await sandbox.commands.run(
`test -f ${quoteE2BShellArg(command)} -a -x ${quoteE2BShellArg(command)}`,
{ envs: e2bControlEnvs(), ...signalOpts(signal) },
)
signal?.throwIfAborted()
return command
}
if (command.includes('/')) {
throw new Error(
`subprocess-e2b: command ${JSON.stringify(command)} is a relative path; use an absolute path or a bare PATH name`,
)
}
const path = env?.PATH
const prefix = path === undefined ? '' : `PATH=${quoteE2BShellArg(path)} `
const result = await sandbox.commands.run(
`${prefix}command -v -- ${quoteE2BShellArg(command)}`,
{ cwd: this.ctx.e2b.cwd, envs: e2bControlEnvs(), ...signalOpts(signal) },
)
signal?.throwIfAborted()
const executable = result.stdout.trim()
if (executable.includes('\n') || (!posix.isAbsolute(executable) && !executable.includes('/'))) {
throw new Error(`subprocess-e2b: executable ${JSON.stringify(command)} did not resolve to one absolute path`)
}
// A relative result comes from a relative PATH entry; the lookup ran with the shared cwd.
return posix.resolve(this.ctx.e2b.cwd, executable)
}
/** @inheritdoc */
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
if (this.disposing) throw new Error('subprocess-e2b: service is disposing')
const program = spec.argv[0]
if (program === undefined || program.length === 0) {
throw new Error('invalid argv: expected a non-empty program name at argv[0]')
}
requireRepresentableGrace(spec.graceMs)
if (spec.signal?.aborted === true) {
throw new Error(`aborted before spawn: ${String(spec.signal.reason)}`)
}
const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'processes', randomUUID())
const handle = new E2BSubprocessHandle(this.ctx.e2b, spec, stateDir, this.pollMs)
this.live.add(handle)
const release = async (): Promise<void> => {
await handle.waitForExit()
this.live.delete(handle)
}
void handle.done.then(release, release).catch((_automaticReleaseFailure: unknown) => {
// Retain the handle so service disposal can retry its cleanup transaction.
})
return handle
}
/** @inheritdoc */
async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
if (this.disposing) throw new Error('subprocess-e2b: service is disposing')
const program = spec.argv[0]
if (program === undefined || program.length === 0) {
throw new Error('subprocess-e2b: terminal argv must contain a program')
}
requireRepresentableGrace(spec.graceMs)
spec.signal?.throwIfAborted()
const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'terminals', randomUUID())
const done = Promise.withResolvers<void>()
const setup: TerminalSetup = { done: done.promise, controller: new AbortController() }
const setupSignal = spec.signal === undefined
? setup.controller.signal
: AbortSignal.any([spec.signal, setup.controller.signal])
this.terminalSetups.add(setup)
try {
const terminal = await spawnE2BTerminal(
this.ctx.e2b,
{ ...spec, signal: setupSignal },
stateDir,
this.pollMs,
)
this.terminals.add(terminal)
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Remote allocation yields to disposal.
if (this.disposing) {
await terminal.terminate()
this.terminals.delete(terminal)
throw new Error('subprocess-e2b: service disposed during terminal setup')
}
const release = async (): Promise<void> => {
await terminal.terminate()
this.terminals.delete(terminal)
}
void terminal.done.then(release, release).catch((_automaticReleaseFailure: unknown) => {
// Retain the terminal so service disposal can retry its cleanup transaction.
})
return terminal
} finally {
this.terminalSetups.delete(setup)
done.resolve()
}
}
}
export default E2BSubprocessService

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-subprocess-e2b`.
* @module @deepseek-ai/dsh-subprocess-e2b/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess-e2b'
/** Cordis companion plugin name. */
export const name = 'subprocess-e2b-invariant'
/** Service required before reserving package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: live remote handles are private teardown ownership,
* and the E2B command event stream is the sole outcome authority.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,131 @@
/** Bounded host-side projection of a complete output file retained in E2B. */
import { Buffer } from 'node:buffer'
import type { SubprocessOutputRead, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
const BASE64_TEXT = /^[A-Za-z0-9+/]+={0,2}$/u
/** Reserved non-base64 frame proving that one remote encoder reached clean EOF. */
export const E2B_OUTPUT_COMPLETE_FRAME = '!dsh-e2b-output-complete!'
/** Incrementally decode newline-delimited base64 frames emitted by one remote encoder. */
export class E2BBase64Decoder {
private pending = ''
private complete = false
/**
* Decode every complete newline-delimited frame in one arbitrarily split SDK callback.
* @param text - ASCII base64 frames from E2B's decoded callback.
* @returns the complete raw bytes made available by this callback.
*/
push(text: string): Buffer {
if (text.length === 0) return Buffer.alloc(0)
this.pending += text
const decoded: Buffer[] = []
for (;;) {
const boundary = this.pending.indexOf('\n')
if (boundary < 0) break
const frame = this.pending.slice(0, boundary)
this.pending = this.pending.slice(boundary + 1)
if (frame === E2B_OUTPUT_COMPLETE_FRAME) {
if (this.complete) throw new Error('subprocess-e2b: duplicate output transport completion')
this.complete = true
continue
}
if (this.complete) throw new Error('subprocess-e2b: output transport continued after completion')
if (!BASE64_TEXT.test(frame)) {
throw new Error('subprocess-e2b: invalid base64 output transport')
}
const bytes = Buffer.from(frame, 'base64')
if (bytes.toString('base64') !== frame) {
throw new Error('subprocess-e2b: invalid base64 output transport')
}
decoded.push(bytes)
}
return Buffer.concat(decoded)
}
/**
* Validate clean encoder completion, or discard an interrupted trailing frame after requested termination.
* @param requireComplete - Whether natural completion requires the reserved EOF frame.
*/
finish(requireComplete = true): void {
if (!requireComplete) {
this.pending = ''
return
}
if (this.pending.length > 0) {
throw new Error('subprocess-e2b: truncated base64 output transport')
}
if (!this.complete) throw new Error('subprocess-e2b: incomplete output transport')
}
}
/** Offset reader used for one collect-mode E2B stream. */
export class E2BOutputReader implements SubprocessOutputReader {
private chunks: Buffer[] = []
private retainedBytes = 0
private totalBytes = 0
private spillValid = true
/**
* Create a bounded reader over one remote spill path.
* @param maxBytes - In-memory tail cap.
* @param maxSpillBytes - Maximum complete remote file size the caller accepts.
* @param spillPath - Remote full-output path.
*/
constructor(
private readonly maxBytes: number,
private readonly maxSpillBytes: number | undefined,
private readonly spillPath: string,
) {}
/** Total bytes observed from the SDK stream. */
get size(): number {
return this.totalBytes
}
/** Stop advertising a remote spill whose writer did not reach clean EOF. */
invalidateSpill(): void {
this.spillValid = false
}
/**
* Append one byte-faithful decoded transport event.
* @param bytes - Raw command bytes recovered from the ASCII SDK transport.
*/
push(bytes: Uint8Array): void {
if (bytes.length === 0) return
const chunk = Buffer.from(bytes)
this.totalBytes += chunk.length
this.chunks.push(chunk)
this.retainedBytes += chunk.length
while (this.retainedBytes > this.maxBytes) {
const head = this.chunks[0] as Buffer
const excess = this.retainedBytes - this.maxBytes
if (head.length <= excess) {
this.chunks.shift()
this.retainedBytes -= head.length
} else {
this.chunks[0] = head.subarray(excess)
this.retainedBytes -= excess
}
}
}
/** @inheritdoc */
readFrom(fromByte: number): SubprocessOutputRead {
const retained = Buffer.concat(this.chunks, this.retainedBytes)
const firstRetained = this.totalBytes - this.retainedBytes
const lossy = fromByte < firstRetained
const start = lossy ? 0 : Math.min(retained.length, Math.max(0, fromByte - firstRetained))
return {
text: retained.subarray(start).toString('utf8'),
nextOffset: this.totalBytes,
lossy,
...(lossy && this.spillValid && this.maxSpillBytes !== undefined && this.totalBytes <= this.maxSpillBytes
? { spillPath: this.spillPath }
: {}),
}
}
}

View File

@@ -0,0 +1,698 @@
/** One asynchronously-started E2B command projected onto the subprocess seam. */
import { Buffer } from 'node:buffer'
import { PassThrough, Writable } from 'node:stream'
import { posix } from 'node:path'
import {
CommandExitError,
e2bControlEnvs,
FileNotFoundError,
SandboxNotFoundError,
quoteE2BShellArg,
} from '@deepseek-ai/dsh-e2b'
import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b'
import type {
SubprocessCollect,
SubprocessHandle,
SubprocessOutcome,
SubprocessOutputMode,
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import { bootstrapEnvironment, readRemoteEnvironment, serializeRemoteEnvironment } from './environment.ts'
import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from './output.ts'
import { asError, commandOpts, signalRemoteGroups, waitTick } from './remote.ts'
const OUTPUT_ENCODER_SOURCE = [
'(async () => {',
' for await (const chunk of process.stdin) {',
" if (!process.stdout.write(chunk.toString('base64') + '\\n')) {",
" await new Promise(resolve => process.stdout.once('drain', resolve))",
' }',
' }',
` if (!process.stdout.write(${JSON.stringify(E2B_OUTPUT_COMPLETE_FRAME)} + '\\n')) {`,
" await new Promise(resolve => process.stdout.once('drain', resolve))",
' }',
'})().catch(() => { process.exitCode = 1 })',
].join('\n')
function isCollect(mode: SubprocessOutputMode): mode is SubprocessCollect {
return mode !== 'pipe' && mode !== 'inherit'
}
function hasSpill(mode: SubprocessOutputMode): mode is SubprocessCollect & { spill: { maxBytes: number } } {
return isCollect(mode) && mode.spill !== undefined
}
function isValidProcessId(value: number): boolean {
return Number.isSafeInteger(value) && value > 0
}
class DeferredStdin extends Writable {
constructor(private readonly ready: Promise<CommandHandle>) {
super({ decodeStrings: false })
}
override _write(chunk: string | Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
void this.ready.then(handle => handle.sendStdin(chunk)).then(
() => { callback() },
(error: unknown) => { callback(asError(error)) },
)
}
override _final(callback: (error?: Error | null) => void): void {
void this.ready.then(handle => handle.closeStdin()).then(
() => { callback() },
(error: unknown) => { callback(asError(error)) },
)
}
}
interface RemotePaths {
pid: string
status: string
environment: string
stdout: string
stderr: string
}
type CommandSettlement =
| { kind: 'result'; result: CommandResult }
| { kind: 'error'; error: unknown }
function withinMs(settlement: Promise<CommandSettlement>, timeoutMs: number): Promise<CommandSettlement | undefined> {
return new Promise<CommandSettlement | undefined>((resolve) => {
const timer = setTimeout(() => { resolve(undefined) }, timeoutMs)
void settlement.then((value) => {
clearTimeout(timer)
resolve(value)
})
})
}
function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string {
const encoder = `"$dsh_e2b_env_bin" -i "$dsh_e2b_node" -e ${quoteE2BShellArg(OUTPUT_ENCODER_SOURCE)}`
const stdoutRedirect = hasSpill(spec.stdio.stdout)
? `> >("$dsh_e2b_tee" --output-error=warn-nopipe >("$dsh_e2b_head" -c ${spec.stdio.stdout.spill.maxBytes} > ${quoteE2BShellArg(paths.stdout)}) | ${encoder} 2>/dev/null)`
: `> >(${encoder} 2>/dev/null)`
const stderrRedirect = hasSpill(spec.stdio.stderr)
? `2> >("$dsh_e2b_tee" --output-error=warn-nopipe >("$dsh_e2b_head" -c ${spec.stdio.stderr.spill.maxBytes} > ${quoteE2BShellArg(paths.stderr)}) | ${encoder} >&2 2>/dev/null)`
: `2> >(${encoder} >&2 2>/dev/null)`
const inner = [
'set +e',
'dsh_e2b_env_bin=$1',
'dsh_e2b_node=$2',
'dsh_e2b_ps=$3',
'dsh_e2b_tr=$4',
'dsh_e2b_tee=$5',
'dsh_e2b_head=$6',
'dsh_e2b_rm=$7',
'shift 7',
'dsh_e2b_pgid="$("$dsh_e2b_ps" -o pgid= -p "$$" | "$dsh_e2b_tr" -d " ")"',
`printf '%s\\n' "$dsh_e2b_pgid" > ${quoteE2BShellArg(paths.pid)}`,
`mapfile -d '' -t dsh_e2b_env < ${quoteE2BShellArg(paths.environment)}`,
`"$dsh_e2b_rm" -f -- ${quoteE2BShellArg(paths.environment)}`,
`"$dsh_e2b_env_bin" -i -- "\${dsh_e2b_env[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
'dsh_e2b_status=$?',
`printf '%s\\n' "$dsh_e2b_status" > ${quoteE2BShellArg(paths.status)}`,
'wait',
'exit "$dsh_e2b_status"',
].join('\n')
const argv = spec.argv.map(quoteE2BShellArg).join(' ')
const bootstrap = [
`mapfile -d '' -t dsh_e2b_env < ${quoteE2BShellArg(paths.environment)}`,
'dsh_e2b_env_bin="$(command -v env)"',
'dsh_e2b_setsid="$(command -v setsid)"',
'dsh_e2b_bash="$(command -v bash)"',
'dsh_e2b_node="$(command -v node)"',
'dsh_e2b_ps="$(command -v ps)"',
'dsh_e2b_tr="$(command -v tr)"',
'dsh_e2b_tee="$(command -v tee)"',
'dsh_e2b_head="$(command -v head)"',
'dsh_e2b_rm="$(command -v rm)"',
'for dsh_e2b_tool in "$dsh_e2b_env_bin" "$dsh_e2b_setsid" "$dsh_e2b_bash" "$dsh_e2b_node" "$dsh_e2b_ps" "$dsh_e2b_tr" "$dsh_e2b_tee" "$dsh_e2b_head" "$dsh_e2b_rm"; do',
' [[ "$dsh_e2b_tool" == /* && -x "$dsh_e2b_tool" ]] || exit 125',
'done',
`exec "$dsh_e2b_env_bin" -i -- "\${dsh_e2b_env[@]}" "$dsh_e2b_setsid" --wait -- "$dsh_e2b_bash" -c ${quoteE2BShellArg(inner)} dsh-e2b "$dsh_e2b_env_bin" "$dsh_e2b_node" "$dsh_e2b_ps" "$dsh_e2b_tr" "$dsh_e2b_tee" "$dsh_e2b_head" "$dsh_e2b_rm" ${argv}`,
].join('\n')
return bootstrap
}
const WAIT_ABORTED = Symbol('wait aborted')
function waitWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T | typeof WAIT_ABORTED> {
if (signal === undefined) return promise
if (signal.aborted) return Promise.resolve(WAIT_ABORTED)
return new Promise<T | typeof WAIT_ABORTED>((resolve) => {
const onAbort = (): void => { cleanup(); resolve(WAIT_ABORTED) }
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) {
onAbort()
return
}
void promise.then((value) => { cleanup(); resolve(value) })
})
}
/** E2B-backed subprocess handle with deferred remote PID acquisition. */
export class E2BSubprocessHandle implements SubprocessHandle {
readonly stdin: Writable | undefined
readonly stdout: PassThrough | undefined
readonly stderr: PassThrough | undefined
readonly collected: SubprocessHandle['collected']
readonly done: Promise<SubprocessOutcome>
private readonly commandState = Promise.withResolvers<CommandHandle | undefined>()
private readonly readyState = Promise.withResolvers<CommandHandle>()
private readonly stdoutDecoder = new E2BBase64Decoder()
private readonly stderrDecoder = new E2BBase64Decoder()
private readonly terminationController = new AbortController()
/** Releases output waits that survive the command outcome, so blocked SDK callbacks settle. */
private readonly outputReleased = new AbortController()
private readonly stdoutReader: E2BOutputReader | undefined
private readonly stderrReader: E2BOutputReader | undefined
private readonly paths: RemotePaths
private controlEnvs: Record<string, string> = {}
private remotePid = -1
private outputTransportError: Error | undefined
private outputDrainExpired = false
private stateDirectoryCreated = false
private quiescenceProven = false
private terminationAttempt: Promise<void> | undefined
private terminationFailure: Error | undefined
private terminationSignal: NodeJS.Signals | null = null
/**
* Begin an E2B command without blocking the synchronous subprocess spawn seam.
* @param runtime - Shared E2B sandbox owner.
* @param spec - Fully resolved subprocess request.
* @param stateDir - Remote directory retaining process identity, status, and valid spills.
* @param pollMs - Remote status/liveness poll cadence.
*/
constructor(
private readonly runtime: E2BSandboxService,
private readonly spec: SubprocessSpawnSpec,
readonly stateDir: string,
private readonly pollMs: number,
) {
this.paths = {
pid: posix.join(stateDir, 'pid'),
status: posix.join(stateDir, 'exit-code'),
environment: posix.join(stateDir, 'environment'),
stdout: posix.join(stateDir, 'stdout.log'),
stderr: posix.join(stateDir, 'stderr.log'),
}
const outMode = spec.stdio.stdout
const errMode = spec.stdio.stderr
this.stdout = outMode === 'pipe' ? new PassThrough() : undefined
this.stderr = errMode === 'pipe' ? new PassThrough() : undefined
this.stdoutReader = isCollect(outMode)
? new E2BOutputReader(outMode.maxBytes, outMode.spill?.maxBytes, this.paths.stdout)
: undefined
this.stderrReader = isCollect(errMode)
? new E2BOutputReader(errMode.maxBytes, errMode.spill?.maxBytes, this.paths.stderr)
: undefined
this.collected = {
...(this.stdoutReader !== undefined ? { stdout: this.stdoutReader } : {}),
...(this.stderrReader !== undefined ? { stderr: this.stderrReader } : {}),
}
this.stdin = spec.stdio.stdin === 'pipe' ? new DeferredStdin(this.readyState.promise) : undefined
void this.readyState.promise.catch(() => {})
spec.signal?.addEventListener('abort', this.onAbort, { once: true })
this.done = this.run()
void this.done.catch(() => {})
if (spec.signal?.aborted === true) this.terminate()
}
/** Remote process id after start; `-1` while E2B startup is pending or after it fails. */
get pid(): number {
return this.remotePid
}
/** @inheritdoc */
terminate(): void {
if (this.quiescenceProven || this.terminationAttempt !== undefined) return
this.terminationController.abort(new Error('subprocess-e2b: command terminated'))
this.stdout?.destroy()
this.stderr?.destroy()
this.terminationFailure = undefined
const attempt = this.terminateRemote()
this.terminationAttempt = attempt
void attempt.then(
() => { this.terminationAttempt = undefined },
(error: unknown) => {
if (!this.quiescenceProven) this.terminationFailure = asError(error)
this.terminationAttempt = undefined
},
)
}
/** @inheritdoc */
async waitForExit(signal?: AbortSignal): Promise<boolean> {
if (this.quiescenceProven) return true
let handle: CommandHandle | undefined
if (this.terminationController.signal.aborted) {
const observed = await waitWithSignal(this.commandState.promise, signal)
if (observed === WAIT_ABORTED) return false
handle = observed
if (handle === undefined) {
this.markQuiescent()
return true
}
if (this.remotePid <= 0) {
const attempt = this.terminationAttempt
if (attempt !== undefined && await waitWithSignal(attempt.catch(() => undefined), signal) === WAIT_ABORTED) {
return false
}
this.throwTerminationFailure()
// Successful pre-publication termination records quiescence; its only other outcome is the failure above.
return true
}
} else {
const observed = await waitWithSignal(
this.readyState.promise.catch(() => this.commandState.promise),
signal,
)
if (observed === WAIT_ABORTED) return false
handle = observed
if (handle === undefined) {
this.markQuiescent()
return true
}
}
this.throwTerminationFailure()
let sandbox: Sandbox
try {
sandbox = await this.runtime.getSandbox()
} catch (error: unknown) {
if (signal?.aborted === true) return false
if (error instanceof SandboxNotFoundError) {
this.markQuiescent()
return true
}
throw error
}
const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
while (await this.groupAlive(sandbox, processGroupId, signal)) {
this.throwTerminationFailure()
if (!await waitTick(this.pollMs, signal)) return false
}
this.throwTerminationFailure()
if (signal?.aborted === true) return false
this.markQuiescent()
return true
}
private readonly onAbort = (): void => { this.terminate() }
private markQuiescent(): void {
this.quiescenceProven = true
this.terminationFailure = undefined
}
private async run(): Promise<SubprocessOutcome> {
let sandbox: Sandbox | undefined
let preparing = true
try {
sandbox = await this.runtime.getSandbox()
await this.prepareState(sandbox)
preparing = false
const handle = await sandbox.commands.run(
commandText(this.spec, this.paths),
{
background: true,
cwd: this.spec.cwd,
envs: e2bControlEnvs(this.controlEnvs),
stdin: this.spec.stdio.stdin !== 'ignore',
timeoutMs: 0,
onStdout: async (data) => { await this.dispatchOutput('stdout', data) },
onStderr: async (data) => { await this.dispatchOutput('stderr', data) },
},
)
const completion = handle.wait()
void completion.catch(() => {})
if (!isValidProcessId(handle.pid)) {
const invalidPid = new Error(`subprocess-e2b: E2B returned invalid command pid ${handle.pid}`)
try {
await handle.kill()
this.markQuiescent()
} catch (cleanupError: unknown) {
this.terminationFailure = asError(cleanupError)
this.commandState.resolve(handle)
throw new AggregateError(
[invalidPid, cleanupError],
'subprocess-e2b: invalid command pid rollback did not reach quiescence',
)
}
throw invalidPid
}
this.commandState.resolve(handle)
try {
this.remotePid = await this.waitForProcessGroupId(sandbox, completion)
} catch (error: unknown) {
try {
await this.rollbackUnpublishedGroup(sandbox, handle)
} catch (cleanupError: unknown) {
throw new AggregateError(
[error, cleanupError],
'subprocess-e2b: process-group publication failed and rollback did not reach quiescence',
)
}
throw error
}
this.readyState.resolve(handle)
await this.writeBatchStdin(handle)
const outcome = await this.waitForCommand(sandbox, handle, completion)
if (this.outputTransportError !== undefined) throw this.outputTransportError
const requireCompleteOutput = this.terminationSignal === null && !this.outputDrainExpired
this.stdoutDecoder.finish(requireCompleteOutput)
this.stderrDecoder.finish(requireCompleteOutput)
await this.finalizeSpills(sandbox)
return outcome
} catch (error: unknown) {
const canceledPreparation = preparing && this.terminationController.signal.aborted
let failure = await this.rollbackPublishedFailure(error)
if (sandbox !== undefined && this.stateDirectoryCreated) {
try {
await this.removeFailedState(sandbox)
} catch (cleanupError: unknown) {
failure = new AggregateError(
[failure, cleanupError],
'subprocess-e2b: command failed and private state cleanup failed',
)
}
}
this.commandState.resolve(undefined)
this.readyState.reject(failure)
if (canceledPreparation && failure === error) return { exitCode: null, signal: 'SIGTERM' }
throw failure
} finally {
this.spec.signal?.removeEventListener('abort', this.onAbort)
this.stdout?.end()
this.stderr?.end()
}
}
private async prepareState(sandbox: Sandbox): Promise<void> {
const signal = this.terminationController.signal
const ambient = await readRemoteEnvironment(sandbox, signal)
this.controlEnvs = bootstrapEnvironment(ambient)
// Own the directory before the request: a cancellation racing a committed
// creation must still enter cleanup (removal tolerates an absent path).
this.stateDirectoryCreated = true
await sandbox.files.makeDir(this.stateDir, { signal })
await sandbox.commands.run(
`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`,
commandOpts(this.controlEnvs, signal),
)
const files = [
{ path: this.paths.pid, data: '' },
{ path: this.paths.status, data: '' },
{ path: this.paths.environment, data: serializeRemoteEnvironment(ambient, this.spec.env) },
...(hasSpill(this.spec.stdio.stdout) ? [{ path: this.paths.stdout, data: '' }] : []),
...(hasSpill(this.spec.stdio.stderr) ? [{ path: this.paths.stderr, data: '' }] : []),
]
await sandbox.files.write(files, { signal })
await sandbox.commands.run(
`chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`,
commandOpts(this.controlEnvs, signal),
)
signal.throwIfAborted()
}
private async writeBatchStdin(handle: CommandHandle): Promise<void> {
if (typeof this.spec.stdio.stdin !== 'object') return
try {
await handle.sendStdin(this.spec.stdio.stdin.data)
await handle.closeStdin()
} catch (_processClosedItsInput) {
// Like the local adapter, batch stdin is best-effort; exit and output remain authoritative.
}
}
private async dispatchOutput(stream: 'stdout' | 'stderr', data: string): Promise<void> {
let bytes: Buffer
try {
bytes = stream === 'stdout' ? this.stdoutDecoder.push(data) : this.stderrDecoder.push(data)
} catch (error: unknown) {
this.outputTransportError ??= asError(error)
const target = stream === 'stdout' ? this.stdout : this.stderr
target?.destroy(this.outputTransportError)
return
}
try {
if (stream === 'stdout') {
this.stdoutReader?.push(bytes)
await this.writeOutput(this.stdout, this.spec.stdio.stdout === 'inherit' ? process.stdout : undefined, bytes)
return
}
this.stderrReader?.push(bytes)
await this.writeOutput(this.stderr, this.spec.stdio.stderr === 'inherit' ? process.stderr : undefined, bytes)
} catch (error: unknown) {
const target = stream === 'stdout' ? this.stdout : this.stderr
target?.destroy(asError(error))
}
}
private async writeOutput(pipe: PassThrough | undefined, inherited: NodeJS.WriteStream | undefined, data: Uint8Array): Promise<void> {
const target = pipe ?? inherited
if (target === undefined || data.length === 0 || this.terminationController.signal.aborted) return
if (target.destroyed) throw new Error('subprocess output stream is closed')
if (target.write(data)) return
await new Promise<void>((resolve, reject) => {
const onDrain = (): void => { cleanup(); resolve() }
const onClose = (): void => { cleanup(); resolve() }
const onRelease = (): void => { cleanup(); resolve() }
const onError = (error: Error): void => { cleanup(); reject(error) }
const cleanup = (): void => {
target.removeListener('drain', onDrain)
target.removeListener('close', onClose)
target.removeListener('error', onError)
this.terminationController.signal.removeEventListener('abort', onRelease)
this.outputReleased.signal.removeEventListener('abort', onRelease)
}
target.once('drain', onDrain)
target.once('close', onClose)
target.once('error', onError)
this.terminationController.signal.addEventListener('abort', onRelease, { once: true })
this.outputReleased.signal.addEventListener('abort', onRelease, { once: true })
if (this.terminationController.signal.aborted || this.outputReleased.signal.aborted) onRelease()
})
}
private async waitForProcessGroupId(sandbox: Sandbox, completion: Promise<CommandResult>): Promise<number> {
const commandSettled = completion.then(
() => true,
() => true,
)
while (true) {
// TODO(e2b-publication-cancel): Join cancellation to the existing
// termination transaction before aborting an in-flight SDK file read.
const raw = await sandbox.files.read(this.paths.pid)
const value = raw.trim()
if (value.length > 0) {
const pid = Number(value)
if (!/^[1-9][0-9]*$/.test(value) || !Number.isSafeInteger(pid)) {
throw new Error(`subprocess-e2b: remote wrapper published invalid process-group id ${JSON.stringify(value)}`)
}
// A same-UID sandbox process can rewrite this file; refuse ids whose
// negative form addresses every process (`kill -- -1`) or init's group.
if (pid <= 1) {
throw new Error(`subprocess-e2b: unsafe published process-group id ${pid}`)
}
return pid
}
const settled = await Promise.race([commandSettled, waitTick(this.pollMs).then(() => false)])
if (settled) throw new Error('subprocess-e2b: remote command exited before publishing its process-group id')
}
}
private async waitForCommand(
sandbox: Sandbox,
handle: CommandHandle,
completion: Promise<CommandResult>,
): Promise<SubprocessOutcome> {
const settlement = completion.then<CommandSettlement, CommandSettlement>(
result => ({ kind: 'result', result }),
(error: unknown) => ({ kind: 'error', error }),
)
const hasPipeOutput = this.spec.stdio.stdout === 'pipe' || this.spec.stdio.stderr === 'pipe'
let completed = hasPipeOutput ? await settlement : undefined
while (true) {
const rawStatus = (await sandbox.files.read(this.paths.status)).trim()
if (rawStatus.length > 0) {
const exitCode = Number(rawStatus)
if (!/^(?:0|[1-9][0-9]*)$/.test(rawStatus) || !Number.isSafeInteger(exitCode) || exitCode > 255) {
throw new Error(`subprocess-e2b: remote wrapper published invalid exit code ${JSON.stringify(rawStatus)}`)
}
if (completed !== undefined) return this.commandOutcome(completed, exitCode)
const drained = await withinMs(settlement, this.spec.graceMs)
if (drained !== undefined) return this.commandOutcome(drained, exitCode)
this.outputDrainExpired = true
this.stdoutReader?.invalidateSpill()
this.stderrReader?.invalidateSpill()
// Release inherited-output waits so a callback blocked on host
// backpressure cannot keep the disconnected SDK settlement pending.
this.outputReleased.abort(new Error('subprocess-e2b: output drain grace expired'))
await handle.disconnect()
return { exitCode, signal: null }
}
if (completed !== undefined) return this.commandOutcome(completed)
// TODO(e2b-status-watch): Replace collect/inherit control-plane polling
// when E2B can observe direct-command exit independently of descendant-held output.
completed = await Promise.race([settlement, waitTick(this.pollMs).then(() => undefined)])
}
}
private commandOutcome(settlement: CommandSettlement, publishedExitCode?: number): SubprocessOutcome {
if (settlement.kind === 'result') {
return { exitCode: publishedExitCode ?? settlement.result.exitCode, signal: null }
}
if (settlement.error instanceof CommandExitError) {
if (publishedExitCode !== undefined) return { exitCode: publishedExitCode, signal: null }
return this.terminationSignal === null
? { exitCode: settlement.error.exitCode, signal: null }
: { exitCode: null, signal: this.terminationSignal }
}
throw settlement.error
}
private async rollbackPublishedFailure(error: unknown): Promise<unknown> {
if (this.remotePid <= 0 || this.quiescenceProven) return error
this.terminate()
try {
await this.waitForExit()
return error
} catch (cleanupError: unknown) {
return new AggregateError(
[asError(error), asError(cleanupError)],
'subprocess-e2b: command monitoring failed and process-group rollback did not reach quiescence',
)
}
}
private async rollbackUnpublishedGroup(sandbox: Sandbox, handle: CommandHandle): Promise<void> {
// The bootstrap ends in an exec chain through the scrubbed environment and
// `setsid`, so E2B's command PID is the provisional group id even before the
// private publication file can be trusted. Kill that group before the SDK-PID
// fallback, then prove no group member survived before rejecting startup.
await this.forceKillGroup(sandbox, handle, handle.pid)
this.markQuiescent()
}
private async terminateRemote(): Promise<void> {
try {
await this.terminateRemoteInSandbox()
} catch (error: unknown) {
if (error instanceof SandboxNotFoundError) {
this.markQuiescent()
return
}
throw error
}
}
private async terminateRemoteInSandbox(): Promise<void> {
const handle = await this.commandState.promise
if (handle === undefined) {
this.markQuiescent()
return
}
if (!isValidProcessId(handle.pid) && this.remotePid <= 0) {
await handle.kill()
this.markQuiescent()
return
}
const sandbox = await this.runtime.getSandbox()
const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
await this.terminateGroup(sandbox, handle, processGroupId)
}
private async terminateGroup(sandbox: Sandbox, handle: CommandHandle, processGroupId: number): Promise<void> {
this.terminationSignal = 'SIGTERM'
try {
await signalRemoteGroups(sandbox, this.controlEnvs, [processGroupId], 'TERM')
if (await this.waitForGroupExit(sandbox, processGroupId)) {
this.markQuiescent()
return
}
} catch (_gracefulTerminationFailure) {
// Failed TERM delivery or observation cannot prove exit; force cleanup still owns the group.
}
this.terminationSignal = 'SIGKILL'
await this.forceKillGroup(sandbox, handle, processGroupId)
this.markQuiescent()
}
private async forceKillGroup(sandbox: Sandbox, handle: CommandHandle, processGroupId: number): Promise<void> {
try {
await signalRemoteGroups(sandbox, this.controlEnvs, [processGroupId], 'KILL')
} catch (_processGroupKillFailure) {
// SDK kill and the final liveness probe remain independent cleanup paths.
}
try {
await handle.kill()
} catch (_sdkKillFailure) {
// The final liveness probe, not either transport's self-report, proves cleanup.
}
if (await this.waitForGroupExit(sandbox, processGroupId)) return
throw new Error(`subprocess-e2b: remote process group ${processGroupId} remained live after force termination`)
}
private async waitForGroupExit(sandbox: Sandbox, processGroupId: number): Promise<boolean> {
const deadline = Date.now() + this.spec.graceMs
while (await this.groupAlive(sandbox, processGroupId)) {
if (Date.now() >= deadline) return false
await waitTick(this.pollMs)
}
return true
}
private throwTerminationFailure(): void {
if (this.terminationFailure !== undefined) throw this.terminationFailure
}
private async groupAlive(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise<boolean> {
const result = await sandbox.commands.run(
`set -o pipefail; ps -eo pgid=,stat= | awk '$1 == ${pid} && $2 !~ /^[ZXx]/ { live=1 } END { if (live) print "live" }'`,
commandOpts(this.controlEnvs, signal),
).catch((error: unknown) => {
if (signal?.aborted === true) return undefined
if (error instanceof SandboxNotFoundError) return { exitCode: 0, stdout: '', stderr: '' }
throw error
})
return result?.stdout.trim() === 'live'
}
private async finalizeSpills(sandbox: Sandbox): Promise<void> {
const removals: Promise<void>[] = []
const collect = (mode: SubprocessOutputMode, reader: E2BOutputReader | undefined, path: string): void => {
if (!hasSpill(mode)) return
// A spill mode is a collect mode, so construction always created its reader.
const size = (reader as E2BOutputReader).size
if (this.outputDrainExpired || size <= mode.maxBytes || size > mode.spill.maxBytes) {
removals.push(sandbox.files.remove(path).catch((_adapterPrivateSpillRemovalFailure: unknown) => {
// The command outcome is authoritative; owner teardown bounds private residue.
}))
}
}
collect(this.spec.stdio.stdout, this.stdoutReader, this.paths.stdout)
collect(this.spec.stdio.stderr, this.stderrReader, this.paths.stderr)
await Promise.all(removals)
}
private async removeFailedState(sandbox: Sandbox): Promise<void> {
const failures: Error[] = []
for (const path of [this.paths.environment, this.stateDir]) {
try {
await sandbox.files.remove(path)
} catch (error: unknown) {
if (!(error instanceof FileNotFoundError)) failures.push(asError(error))
}
}
if (failures.length > 0) {
throw new AggregateError(failures, 'subprocess-e2b: failed to remove private command state')
}
}
}

View File

@@ -0,0 +1,97 @@
/**
* Shared remote-control helpers for the E2B subprocess adapter: SDK option
* shaping, poll ticks, and the one tolerant process-group signal used by both
* the ordinary-process and terminal teardown ladders.
*/
import { CommandExitError, e2bControlEnvs, SandboxNotFoundError } from '@deepseek-ai/dsh-e2b'
import type { Sandbox } from '@deepseek-ai/dsh-e2b'
/**
* Normalize an unknown rejection into an Error.
* @param error - Any thrown or rejected value.
* @returns The value itself when already an Error, else a stringified wrapper.
*/
export function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
/**
* Shape the optional-signal SDK options object.
* @param signal - Optional cancellation for one SDK request.
* @returns An options fragment that omits an undefined signal.
*/
export function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
return signal === undefined ? {} : { signal }
}
/**
* Shape control-shell command options with the isolated HOME override.
* @param envs - Explicit environment entries for the control command.
* @param signal - Optional cancellation for the SDK request.
* @returns Options for `sandbox.commands.run` control invocations.
*/
export function commandOpts(
envs: Record<string, string>,
signal?: AbortSignal,
): { envs: Record<string, string>; signal?: AbortSignal } {
return { envs: e2bControlEnvs(envs), ...signalOpts(signal) }
}
/**
* Resolve after one duration.
* @param ms - Milliseconds to wait.
* @returns Settles after the timeout.
*/
export function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Wait one poll interval or until the signal aborts.
* @param pollMs - Poll cadence in milliseconds.
* @param signal - Optional abort that ends the wait early.
* @returns `true` after a full tick, `false` when aborted first.
*/
export function waitTick(pollMs: number, signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted === true) return Promise.resolve(false)
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve(true)
}, pollMs)
const onAbort = (): void => {
clearTimeout(timer)
resolve(false)
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
/**
* Signal remote process groups, tolerating the shared teardown outcomes: a
* nonzero `kill` (groups already gone) and a disappeared sandbox. Both the
* pgid-keyed process ladder and the sid-keyed terminal ladder deliver signals
* through this single tolerance so they cannot drift apart.
* @param sandbox - Live SDK handle.
* @param envs - Control-shell environment entries.
* @param groups - Positive process-group ids to signal.
* @param signal - `TERM` or `KILL`.
*/
export async function signalRemoteGroups(
sandbox: Sandbox,
envs: Record<string, string>,
groups: readonly number[],
signal: 'TERM' | 'KILL',
): Promise<void> {
// TODO(e2b-pgid-identity): Prefer an atomic identity-bound group signal if E2B adds one;
// a userspace identity precheck cannot close the numeric-PGID reuse race.
try {
await sandbox.commands.run(
`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`,
commandOpts(envs),
)
} catch (error: unknown) {
if (!(error instanceof CommandExitError) && !(error instanceof SandboxNotFoundError)) throw error
}
}

View File

@@ -0,0 +1,567 @@
/** E2B PTY allocation and process-session ownership for the subprocess seam. */
import { Buffer } from 'node:buffer'
import { randomUUID } from 'node:crypto'
import { PassThrough } from 'node:stream'
import { posix } from 'node:path'
import {
CommandExitError,
e2bControlEnvs,
FileNotFoundError,
SandboxNotFoundError,
quoteE2BShellArg,
} from '@deepseek-ai/dsh-e2b'
import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b'
import type {
SubprocessOutcome,
SubprocessTerminalForeground,
SubprocessTerminalHandle,
SubprocessTerminalSignal,
SubprocessTerminalSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import {
bootstrapEnvironment,
readRemoteEnvironment,
serializeRemoteEnvironment,
} from './environment.ts'
import { asError, commandOpts, delay, signalOpts, signalRemoteGroups } from './remote.ts'
const TERMINAL_RUNNER_SOURCE = [
'#!/bin/bash',
'set -euo pipefail',
'dsh_state=$1',
'mapfile -d \'\' -t dsh_env < "$dsh_state/environment"',
'mapfile -d \'\' -t dsh_argv < "$dsh_state/argv"',
'dsh_output_marker=$(<"$dsh_state/output-marker")',
'rm -f -- "$dsh_state/environment" "$dsh_state/argv" "$dsh_state/output-marker" "$dsh_state/runner.bash"',
'if (( ${#dsh_argv[@]} == 0 )); then',
" printf 'terminal runner received empty argv\\n' >&2",
' exit 125',
'fi',
'printf \'%s\' "$dsh_output_marker"',
'exec env -i -- "${dsh_env[@]}" "${dsh_argv[@]}"',
'',
].join('\n')
interface TerminalPaths {
runner: string
environment: string
argv: string
outputMarker: string
}
class BootstrapOutputFilter {
readonly ready: Promise<void>
private readonly readyState = Promise.withResolvers<void>()
private pending = Buffer.alloc(0)
private published = false
constructor(
private readonly marker: Buffer,
private readonly output: PassThrough,
) {
this.ready = this.readyState.promise
}
push(data: Uint8Array): void {
if (this.published) {
this.write(data)
return
}
const combined = Buffer.concat([this.pending, Buffer.from(data)])
const markerOffset = combined.indexOf(this.marker)
if (markerOffset < 0) {
const retained = Math.min(combined.length, this.marker.length - 1)
this.pending = Buffer.from(combined.subarray(combined.length - retained))
return
}
this.published = true
this.pending = Buffer.alloc(0)
this.readyState.resolve()
this.write(combined.subarray(markerOffset + this.marker.length))
}
private write(data: Uint8Array): void {
if (data.length > 0 && !this.output.destroyed) this.output.write(data)
}
}
async function waitForBootstrapOutput(
ready: Promise<void>,
completion: Promise<CommandResult>,
signal?: AbortSignal,
): Promise<void> {
signal?.throwIfAborted()
await new Promise<void>((resolve, reject) => {
let settled = false
let removeAbort: (() => void) | undefined
const finish = (complete: () => void): void => {
if (settled) return
settled = true
removeAbort?.()
complete()
}
const onExit = (): void => {
finish(() => { reject(new Error('subprocess-e2b: terminal exited before publishing its output boundary')) })
}
if (signal !== undefined) {
const onAbort = (): void => {
finish(() => { reject(asError(signal.reason)) })
}
signal.addEventListener('abort', onAbort, { once: true })
removeAbort = () => { signal.removeEventListener('abort', onAbort) }
}
void ready.then(() => { finish(resolve) })
void completion.then(onExit, onExit)
})
}
function parsePositiveId(value: string, message: string): number {
const raw = value.trim()
const id = Number(raw)
if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(id)) throw new Error(message)
return id
}
function serializeValues(values: readonly string[], kind: string): string {
for (const value of values) {
if (value.includes('\0')) throw new Error(`subprocess-e2b: terminal ${kind} must not contain NUL bytes`)
}
return values.map(value => `${value}\0`).join('')
}
async function terminalSessionId(
sandbox: Sandbox,
pid: number,
envs: Record<string, string>,
signal?: AbortSignal,
): Promise<number> {
const result = await sandbox.commands.run(`ps -o sid= -p ${pid}`, commandOpts(envs, signal))
signal?.throwIfAborted()
return parsePositiveId(result.stdout, `subprocess-e2b: cannot resolve process session for terminal ${pid}`)
}
async function sessionProcessGroups(
sandbox: Sandbox,
sessionId: number,
envs: Record<string, string>,
): Promise<number[]> {
let result: CommandResult
try {
result = await sandbox.commands.run(
`set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == ${sessionId} && $3 !~ /^[ZXx]/ { print $2 }'`,
commandOpts(envs),
)
} catch (error: unknown) {
if (error instanceof SandboxNotFoundError) return []
throw error
}
const groups = new Set<number>()
for (const raw of result.stdout.trim().split(/\s+/)) {
if (raw.length === 0) continue
const group = parsePositiveId(
raw,
`subprocess-e2b: invalid process group ${JSON.stringify(raw)} in terminal session ${sessionId}`,
)
if (group <= 1) {
throw new Error(`subprocess-e2b: unsafe process group ${group} in terminal session ${sessionId}`)
}
groups.add(group)
}
return [...groups]
}
async function awaitSessionEmpty(
sandbox: Sandbox,
sessionId: number,
envs: Record<string, string>,
graceMs: number,
pollMs: number,
kill = false,
): Promise<number[]> {
const deadline = Date.now() + graceMs
for (;;) {
const groups = await sessionProcessGroups(sandbox, sessionId, envs)
if (groups.length === 0) return groups
if (kill) {
await signalRemoteGroups(sandbox, envs, groups, 'KILL')
if (Date.now() >= deadline) return await sessionProcessGroups(sandbox, sessionId, envs)
} else if (Date.now() >= deadline) {
return groups
}
await delay(Math.min(pollMs, Math.max(1, deadline - Date.now())))
}
}
async function rollbackUnpublishedTerminal(
sandbox: Sandbox,
handle: CommandHandle,
completion: Promise<CommandResult>,
envs: Record<string, string>,
graceMs: number,
pollMs: number,
): Promise<void> {
let topLevelExited = false
void completion.then(
() => { topLevelExited = true },
() => { topLevelExited = true },
)
const validPid = Number.isSafeInteger(handle.pid) && handle.pid > 1
const attemptFailures: Error[] = []
let sessionId: number | undefined
if (validPid) {
sessionId = handle.pid
try {
sessionId = await terminalSessionId(sandbox, handle.pid, envs)
} catch (_sessionLookupFailure) {
// E2B's PTY leader is also the provisional POSIX session leader, so its
// PID remains usable after the setup lookup itself fails or is canceled.
}
try {
let groups = await sessionProcessGroups(sandbox, sessionId, envs)
if (groups.length > 0) {
await signalRemoteGroups(sandbox, envs, groups, 'TERM')
groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, pollMs)
}
if (groups.length > 0) {
await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, pollMs, true)
}
} catch (error: unknown) {
attemptFailures.push(asError(error))
}
}
// Completion can settle while any awaited provider cleanup above is running.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Provider cleanup yields to completion.
if (!topLevelExited) {
try {
await handle.kill()
} catch (error: unknown) {
if (error instanceof SandboxNotFoundError) return
attemptFailures.push(asError(error))
}
await Promise.race([completion.catch(() => undefined), delay(graceMs)])
}
const proofFailures: Error[] = []
if (sessionId !== undefined) {
try {
const groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, pollMs, true)
if (groups.length > 0) {
proofFailures.push(new Error(
`subprocess-e2b: terminal setup rollback failed; surviving process groups: ${groups.join(', ')}`,
))
}
} catch (error: unknown) {
proofFailures.push(asError(error))
}
}
// The bounded completion race above updates this callback-owned state.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- The callback mutates this after a race.
if (!topLevelExited) {
proofFailures.push(new Error(`subprocess-e2b: terminal setup rollback failed; surviving pid: ${handle.pid}`))
}
if (proofFailures.length > 0) {
throw new AggregateError(
[...attemptFailures, ...proofFailures],
'subprocess-e2b: terminal setup rollback did not reach quiescence',
)
}
try {
await handle.disconnect()
} catch (error: unknown) {
if (!(error instanceof SandboxNotFoundError)) throw error
}
}
/** One E2B PTY and all process groups in its remote process session. */
export class E2BTerminalHandle implements SubprocessTerminalHandle {
readonly pid: number
readonly done: Promise<SubprocessOutcome>
private topLevelExited = false
private cleanup: Promise<void> | undefined
private readonly operationController = new AbortController()
private readonly operations = new Set<Promise<unknown>>()
private terminationSignal: NodeJS.Signals | null = null
constructor(
private readonly sandbox: Sandbox,
private readonly handle: CommandHandle,
readonly output: PassThrough,
private readonly completion: Promise<CommandResult>,
private readonly sessionId: number,
private readonly controlEnvs: Record<string, string>,
private readonly stateDir: string,
private readonly graceMs: number,
private readonly pollMs: number,
) {
this.pid = handle.pid
this.done = this.waitForCommand()
}
// TODO(e2b-pgid-identity): Replace retained numeric PTY/session ids when E2B
// exposes identity-bound input, foreground-signal, and cleanup operations.
/** @inheritdoc */
write(data: string): Promise<void> {
return this.trackOperation(async (signal) => {
if (this.topLevelExited) throw new Error('terminal process has exited')
await this.sandbox.pty.sendInput(this.pid, Buffer.from(data, 'utf8'), { signal })
})
}
/** @inheritdoc */
inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
return this.trackOperation(signal => this.inspectForegroundOnce(signal))
}
/** @inheritdoc */
signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
return this.trackOperation(async (operationSignal) => {
const foreground = await this.inspectForegroundOnce(operationSignal)
if (foreground === undefined) {
throw new Error(`subprocess-e2b: cannot resolve foreground process group for terminal ${this.pid}`)
}
if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
}
await this.sandbox.commands.run(
`kill -${signal.slice(3)} -- -${foreground.processGroupId}`,
commandOpts(this.controlEnvs, operationSignal),
)
return foreground.processGroupId
})
}
/** @inheritdoc */
terminate(): Promise<void> {
if (this.cleanup !== undefined) return this.cleanup
this.operationController.abort(new Error('subprocess-e2b: terminal is terminating'))
const cleanup = this.closeAfterOperations()
this.cleanup = cleanup
void cleanup.catch((_cleanupFailure: unknown) => {
this.cleanup = undefined
})
return cleanup
}
private async inspectForegroundOnce(
signal: AbortSignal,
): Promise<SubprocessTerminalForeground | undefined> {
try {
const result = await this.sandbox.commands.run(
`ps -o tpgid= -p ${this.pid}`,
commandOpts(this.controlEnvs, signal),
)
return {
processGroupId: parsePositiveId(
result.stdout,
`subprocess-e2b: cannot resolve foreground process group for terminal ${this.pid}`,
),
// E2B exposes process-table commands but not the /proc memory access
// needed to prove a specific syscall is waiting on fd 0.
inputWaiting: false,
}
} catch (error: unknown) {
if (error instanceof CommandExitError && (error.exitCode === 1 || this.topLevelExited)) return undefined
throw error
}
}
private trackOperation<T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
if (this.operationController.signal.aborted) {
return Promise.reject(new Error('subprocess-e2b: terminal is terminating'))
}
const pending = operation(this.operationController.signal)
this.operations.add(pending)
void pending.then(
() => { this.operations.delete(pending) },
() => { this.operations.delete(pending) },
)
return pending
}
private async closeAfterOperations(): Promise<void> {
await Promise.allSettled(this.operations)
await this.closeOnce()
}
private async waitForCommand(): Promise<SubprocessOutcome> {
try {
const result = await this.completion
return { exitCode: result.exitCode, signal: null }
} catch (error: unknown) {
if (error instanceof CommandExitError) {
return this.terminationSignal === null
? { exitCode: error.exitCode, signal: null }
: { exitCode: null, signal: this.terminationSignal }
}
this.output.destroy(error instanceof Error ? error : new Error(String(error)))
throw error
} finally {
this.topLevelExited = true
if (!this.output.destroyed) this.output.end()
}
}
private async closeOnce(): Promise<void> {
let groups = await sessionProcessGroups(this.sandbox, this.sessionId, this.controlEnvs)
if (groups.length > 0) {
this.terminationSignal = 'SIGTERM'
await signalRemoteGroups(this.sandbox, this.controlEnvs, groups, 'TERM')
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.controlEnvs, this.graceMs, this.pollMs)
}
if (groups.length === 0 && !this.topLevelExited) {
await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
}
if (groups.length > 0 || !this.topLevelExited) {
this.terminationSignal = 'SIGKILL'
if (!this.topLevelExited) {
try {
await this.handle.kill()
} catch (error: unknown) {
if (error instanceof SandboxNotFoundError) return
throw error
}
}
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.controlEnvs, this.graceMs, this.pollMs, true)
if (!this.topLevelExited) await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
}
if (groups.length > 0) {
throw new Error(`subprocess-e2b: terminal cleanup failed; surviving process groups: ${groups.join(', ')}`)
}
if (!this.topLevelExited) {
throw new Error(`subprocess-e2b: terminal cleanup failed; surviving pid: ${this.pid}`)
}
try {
await this.handle.disconnect()
} catch (error: unknown) {
if (!(error instanceof SandboxNotFoundError)) throw error
}
try {
await this.sandbox.files.remove(this.stateDir)
} catch (_adapterPrivateStateRemovalFailure) {
// The terminal is quiescent; owner teardown bounds private residue.
}
}
}
/**
* Allocate an E2B PTY, replace its bootstrap shell with the requested argv,
* and return only after the private runner has published readiness.
* @param runtime - Shared E2B sandbox owner.
* @param spec - Fully specified terminal-process request.
* @param stateDir - Private remote directory for one startup transaction.
* @param pollMs - Remote session liveness poll cadence.
* @returns The live subprocess terminal handle.
*/
export async function spawnE2BTerminal(
runtime: E2BSandboxService,
spec: SubprocessTerminalSpawnSpec,
stateDir: string,
pollMs: number,
): Promise<E2BTerminalHandle> {
const sandbox = await runtime.getSandbox()
spec.signal?.throwIfAborted()
const paths: TerminalPaths = {
runner: posix.join(stateDir, 'runner.bash'),
environment: posix.join(stateDir, 'environment'),
argv: posix.join(stateDir, 'argv'),
outputMarker: posix.join(stateDir, 'output-marker'),
}
const outputMarker = Buffer.from(`dsh-e2b-bootstrap:${randomUUID()}`)
const output = new PassThrough()
const outputFilter = new BootstrapOutputFilter(outputMarker, output)
let handle: CommandHandle | undefined
let completion: Promise<CommandResult> | undefined
let stateDirectoryCreated = false
let controlEnvs: Record<string, string> = {}
try {
const ambient = await readRemoteEnvironment(sandbox, spec.signal)
controlEnvs = bootstrapEnvironment(ambient)
const environment = serializeRemoteEnvironment(ambient, spec.env)
const argv = serializeValues(spec.argv, 'argv')
stateDirectoryCreated = true
await sandbox.files.makeDir(stateDir, signalOpts(spec.signal))
await sandbox.commands.run(
`chmod 700 -- ${quoteE2BShellArg(stateDir)}`,
commandOpts(controlEnvs, spec.signal),
)
await sandbox.files.write([
{ path: paths.runner, data: TERMINAL_RUNNER_SOURCE },
{ path: paths.environment, data: environment },
{ path: paths.argv, data: argv },
{ path: paths.outputMarker, data: outputMarker.toString('utf8') },
], signalOpts(spec.signal))
await sandbox.commands.run(
`chmod 600 -- ${quoteE2BShellArg(paths.runner)} ${quoteE2BShellArg(paths.environment)} ${quoteE2BShellArg(paths.argv)} ${quoteE2BShellArg(paths.outputMarker)}`,
commandOpts(controlEnvs, spec.signal),
)
handle = await sandbox.pty.create({
rows: spec.rows,
cols: spec.cols,
cwd: spec.cwd,
envs: e2bControlEnvs(controlEnvs),
timeoutMs: 0,
onData: (data) => { outputFilter.push(data) },
})
completion = handle.wait()
void completion.catch(() => {})
spec.signal?.throwIfAborted()
if (!Number.isSafeInteger(handle.pid) || handle.pid <= 0) {
throw new Error(`subprocess-e2b: E2B returned invalid terminal pid ${handle.pid}`)
}
const command = `exec /bin/bash ${quoteE2BShellArg(paths.runner)} ${quoteE2BShellArg(stateDir)}\r`
await sandbox.pty.sendInput(handle.pid, Buffer.from(command), signalOpts(spec.signal))
await waitForBootstrapOutput(outputFilter.ready, completion, spec.signal)
const sessionId = await terminalSessionId(sandbox, handle.pid, controlEnvs, spec.signal)
return new E2BTerminalHandle(
sandbox,
handle,
output,
completion,
sessionId,
controlEnvs,
stateDir,
spec.graceMs,
pollMs,
)
} catch (error: unknown) {
output.destroy()
let terminalQuiescent = handle === undefined
let stateRemoved = !stateDirectoryCreated
const cleanup = async (): Promise<void> => {
const failures: Error[] = []
if (!terminalQuiescent && handle !== undefined) {
try {
if (completion === undefined) await handle.kill()
else await rollbackUnpublishedTerminal(sandbox, handle, completion, controlEnvs, spec.graceMs, pollMs)
terminalQuiescent = true
} catch (cleanupError: unknown) {
if (cleanupError instanceof SandboxNotFoundError) terminalQuiescent = true
else failures.push(asError(cleanupError))
}
}
if (!stateRemoved) {
try {
await sandbox.files.remove(stateDir)
stateRemoved = true
} catch (stateError: unknown) {
if (stateError instanceof FileNotFoundError || stateError instanceof SandboxNotFoundError) stateRemoved = true
else failures.push(asError(stateError))
}
}
if (failures.length > 0) {
throw new AggregateError(failures, 'subprocess-e2b: terminal setup cleanup did not complete')
}
}
try {
await cleanup()
} catch (cleanupError: unknown) {
// TODO(e2b-terminal-setup-rollback): Retain retry state only if a real
// double failure must be recovered before sandbox disposal or timeout.
throw new AggregateError([asError(error), asError(cleanupError)], asError(error).message)
}
throw error
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,926 @@
import { Buffer } from 'node:buffer'
import { once } from 'node:events'
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import {
CommandExitError,
FileNotFoundError,
SandboxNotFoundError,
type CommandHandle,
type CommandResult,
type Sandbox,
} from '@deepseek-ai/dsh-e2b'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import type { SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
import { spawnE2BTerminal } from '../src/terminal.ts'
function commandError(exitCode: number): CommandExitError {
return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` })
}
interface CommandOptions {
signal?: AbortSignal
cwd?: string
envs?: Record<string, string>
}
class FakeTerminalCommandHandle {
pid = 123
disconnects = 0
sdkKills = 0
disconnectError: unknown
sdkKillError: unknown
waitError: unknown
settleOnSdkKill = true
private readonly result = Promise.withResolvers<CommandResult>()
private settled = false
wait(): Promise<CommandResult> {
if (this.waitError !== undefined) throw this.waitError
return this.result.promise
}
async disconnect(): Promise<void> {
this.disconnects += 1
if (this.disconnectError !== undefined) throw this.disconnectError
}
async kill(): Promise<boolean> {
this.sdkKills += 1
if (this.sdkKillError !== undefined) {
const error = this.sdkKillError
if (this.settleOnSdkKill) this.fail(137)
throw error
}
if (this.settleOnSdkKill) this.fail(137)
return true
}
succeed(exitCode = 0): void {
if (this.settled) return
this.settled = true
this.result.resolve({ exitCode, stdout: '', stderr: '' })
}
fail(exitCode: number): void {
if (this.settled) return
this.settled = true
this.result.reject(commandError(exitCode))
}
crash(error: unknown): void {
if (this.settled) return
this.settled = true
this.result.reject(error)
}
asHandle(): CommandHandle {
return this as unknown as CommandHandle
}
}
class FakeTerminalSandbox {
readonly handle = new FakeTerminalCommandHandle()
readonly commands: string[] = []
readonly commandOptions: CommandOptions[] = []
readonly inputs: Array<{ pid: number; data: Buffer }> = []
readonly removed: string[] = []
readonly directories: string[] = []
readonly writes = new Map<string, string>()
createOptions: Parameters<Sandbox['pty']['create']>[0] | undefined
ambient = 'KEEP=visible\0UNICODE=你好\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
sessionId = '123\n'
foreground = '456\n'
groups = [123]
zombieGroups: number[] = []
createError: unknown
writeError: unknown
sendError: unknown
commandFailure: unknown
makeDirRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
sendInputRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
foregroundRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
signalRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
sessionGroupsFailure: unknown
foregroundFailure: unknown
termFailure: unknown
removeError: unknown
clearOnTerm = true
clearOnKill = true
resolvedExecutable = '/usr/bin/node\n'
requestedOutput = 'requested-shell$ '
emitOutputMarker = true
afterSessionLookup: (() => void) | undefined
private createGate: Promise<undefined> | undefined
private releaseCreateGate: (() => void) | undefined
deferCreate(): void {
const gate = Promise.withResolvers<undefined>()
this.createGate = gate.promise
this.releaseCreateGate = () => { gate.resolve(undefined) }
}
releaseCreate(): void {
this.releaseCreateGate?.()
}
readonly sandbox = {
files: {
makeDir: async (path: string, options?: CommandOptions): Promise<boolean> => {
this.directories.push(path)
await this.makeDirRequest?.(options?.signal)
options?.signal?.throwIfAborted()
return true
},
write: async (files: Array<{ path: string; data: string }>): Promise<object[]> => {
for (const file of files) this.writes.set(file.path, file.data)
if (this.writeError !== undefined) throw this.writeError
return files.map(() => ({}))
},
remove: async (path: string): Promise<void> => {
this.removed.push(path)
if (this.removeError !== undefined) throw this.removeError
},
},
commands: {
run: async (command: string, options?: CommandOptions): Promise<CommandResult> => {
this.commands.push(command)
if (options !== undefined) this.commandOptions.push(options)
options?.signal?.throwIfAborted()
if (this.commandFailure !== undefined) {
const error = this.commandFailure
this.commandFailure = undefined
throw error
}
if (command.includes('env -0 | base64')) {
return {
exitCode: 0,
stdout: ['/home/user', this.ambient].map(value => Buffer.from(value).toString('base64')).join('\n'),
stderr: '',
}
}
if (command.includes('command -v -- ')) {
return { exitCode: 0, stdout: this.resolvedExecutable, stderr: '' }
}
if (command.startsWith('ps -o sid=')) {
this.afterSessionLookup?.()
return { exitCode: 0, stdout: this.sessionId, stderr: '' }
}
if (command.startsWith('ps -o tpgid=')) {
await this.foregroundRequest?.(options?.signal)
options?.signal?.throwIfAborted()
if (this.foregroundFailure !== undefined) throw this.foregroundFailure
return { exitCode: 0, stdout: this.foreground, stderr: '' }
}
if (command.startsWith('set -o pipefail; ps -eo sid=')) {
if (this.sessionGroupsFailure !== undefined) throw this.sessionGroupsFailure
const groups = command.includes('stat=') && command.includes('$3 !~ /^[ZXx]/')
? this.groups
: [...this.groups, ...this.zombieGroups]
return { exitCode: 0, stdout: groups.map(group => `${group}\n`).join(''), stderr: '' }
}
if (command.startsWith('kill -TERM -- ')) {
if (this.termFailure !== undefined) throw this.termFailure
if (this.clearOnTerm) {
this.groups = []
this.handle.fail(143)
}
}
if (command.startsWith('kill -INT -- ')) {
await this.signalRequest?.(options?.signal)
options?.signal?.throwIfAborted()
}
if (command.startsWith('kill -KILL -- ') && this.clearOnKill) this.groups = []
return { exitCode: 0, stdout: '', stderr: '' }
},
},
pty: {
create: async (options: Parameters<Sandbox['pty']['create']>[0]): Promise<CommandHandle> => {
this.createOptions = options
if (this.createError !== undefined) throw this.createError
await this.createGate
options.signal?.throwIfAborted()
await options.onData(Buffer.from('buffered banner\n'))
return this.handle.asHandle()
},
sendInput: async (pid: number, data: Uint8Array, options?: { signal?: AbortSignal }): Promise<void> => {
options?.signal?.throwIfAborted()
await this.sendInputRequest?.(options?.signal)
options?.signal?.throwIfAborted()
this.inputs.push({ pid, data: Buffer.from(data) })
if (this.sendError !== undefined) throw this.sendError
if (this.emitOutputMarker && Buffer.from(data).includes(Buffer.from('runner.bash'))) {
const marker = [...this.writes].find(([path]) => path.endsWith('/output-marker'))?.[1]
const onData = this.createOptions?.onData
if (marker !== undefined && onData !== undefined) {
await onData(Buffer.from(Buffer.from(data).toString().replace(/\r$/, '\r\n')))
const split = Math.floor(marker.length / 2)
await onData(Buffer.from(marker.slice(0, split)))
await onData(Buffer.from(marker.slice(split)))
await onData(Buffer.from(this.requestedOutput))
}
}
},
},
} as unknown as Sandbox
}
function runtime(fake: FakeTerminalSandbox): E2BSandboxService {
return {
cwd: '/workspace',
runtimeRoot: '/workspace/.dsh-e2b',
getSandbox: async () => fake.sandbox,
} as unknown as E2BSandboxService
}
function spec(overrides: Partial<SubprocessTerminalSpawnSpec> = {}): SubprocessTerminalSpawnSpec {
return {
argv: ['/bin/bash', '--noprofile', '--norc'],
cwd: '/workspace',
rows: 24,
cols: 80,
graceMs: 5,
env: { TERM: 'dumb', DSH_SESSION_ID: 'owner', TOKEN_EXPLICIT: 'kept' },
...overrides,
}
}
function holdRequestUntilAbort(started: PromiseWithResolvers<AbortSignal>) {
return async (signal: AbortSignal | undefined): Promise<void> => {
if (signal === undefined) throw new Error('expected an operation signal')
signal.throwIfAborted()
started.resolve(signal)
await new Promise<void>((_resolve, reject) => {
signal.addEventListener('abort', () => {
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
}, { once: true })
})
}
}
/** Spawn the terminal under test with the config default the service would pass. */
function testSpawn(
runtime: Parameters<typeof spawnE2BTerminal>[0],
spec: Parameters<typeof spawnE2BTerminal>[1],
stateDir: string,
pollMs = 20,
): ReturnType<typeof spawnE2BTerminal> {
return spawnE2BTerminal(runtime, spec, stateDir, pollMs)
}
describe('E2B terminal allocation', () => {
it('hides bootstrap-shell bytes and preserves requested-shell bytes across the output boundary', async () => {
const fake = new FakeTerminalSandbox()
const terminal = await testSpawn(runtime(fake), spec(), '/runtime/terminal-one')
let output = ''
terminal.output.on('data', (chunk) => { output += String(chunk) })
await new Promise(resolve => setTimeout(resolve, 0))
expect(output).toBe('requested-shell$ ')
expect(output).not.toContain('buffered banner')
expect(output).not.toContain('runner.bash')
expect(fake.createOptions).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace', timeoutMs: 0 })
const controlEnvs = fake.createOptions?.envs
expect(controlEnvs?.HOME).toMatch(/^\/\.dsh-e2b-control-/)
expect(controlEnvs).toEqual({
TERM: 'dumb',
NPM_TOKEN: '',
DSH_STALE: '',
HOME: controlEnvs?.HOME,
})
expect(fake.inputs[0]?.data.toString()).toContain("exec /bin/bash '/runtime/terminal-one/runner.bash'")
expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('KEEP=visible\0')
expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('UNICODE=你好\0')
expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('TOKEN_EXPLICIT=kept\0')
expect(fake.writes.get('/runtime/terminal-one/environment')).not.toContain('secret')
expect(fake.writes.get('/runtime/terminal-one/environment')).not.toContain('DSH_STALE')
expect(fake.writes.get('/runtime/terminal-one/argv')).toBe('/bin/bash\0--noprofile\0--norc\0')
const marker = fake.writes.get('/runtime/terminal-one/output-marker') ?? ''
expect(marker).toMatch(/^dsh-e2b-bootstrap:/)
expect(fake.inputs[0]?.data.toString()).not.toContain(marker)
const runner = fake.writes.get('/runtime/terminal-one/runner.bash') ?? ''
expect(runner).toContain('if (( ${#dsh_argv[@]} == 0 )); then')
expect(runner).toContain('printf \'%s\' "$dsh_output_marker"')
expect(runner).toContain('exec env -i -- "${dsh_env[@]}" "${dsh_argv[@]}"')
expect(runner).not.toContain('\u007f')
terminal.output.destroy()
await fake.createOptions?.onData(Buffer.from('late bootstrap callback'))
expect(output).toBe('requested-shell$ ')
await terminal.write('echo ok\r')
expect(fake.inputs.at(-1)?.data.toString()).toBe('echo ok\r')
await expect(terminal.inspectForeground()).resolves.toEqual({ processGroupId: 456, inputWaiting: false })
await expect(terminal.signalForeground('SIGINT')).resolves.toBe(456)
expect(fake.commands).toContain('kill -INT -- -456')
const terminated = terminal.terminate()
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
await terminated
expect(fake.handle.disconnects).toBe(1)
expect(fake.removed).toContain('/runtime/terminal-one')
})
it('inherits only safe ambient values and limits the allocation signal to setup', async () => {
const fake = new FakeTerminalSandbox()
const controller = new AbortController()
const terminal = await testSpawn(
runtime(fake),
spec({ env: undefined, signal: controller.signal }),
'/runtime/abort-live',
)
const environment = fake.writes.get('/runtime/abort-live/environment') ?? ''
expect(environment).toContain('KEEP=visible\0')
expect(environment).not.toContain('secret')
expect(environment).not.toContain('DSH_STALE')
controller.abort(new Error('stop'))
await terminal.write('still live\r')
expect(fake.inputs.at(-1)?.data.toString()).toBe('still live\r')
await terminal.terminate()
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
})
it('publishes the PTY handle before honoring allocation cancellation', async () => {
const fake = new FakeTerminalSandbox()
fake.deferCreate()
const controller = new AbortController()
const spawning = testSpawn(
runtime(fake),
spec({ signal: controller.signal }),
'/runtime/allocation-cancel',
)
await vi.waitFor(() => { expect(fake.createOptions).toBeDefined() })
controller.abort(new Error('allocation cancelled'))
fake.releaseCreate()
await expect(spawning).rejects.toThrow('allocation cancelled')
expect(fake.createOptions?.signal).toBeUndefined()
expect(fake.groups).toEqual([])
expect(fake.handle.disconnects).toBe(1)
})
it('rejects malformed environment and argv values before PTY allocation', async () => {
const invalidName = new FakeTerminalSandbox()
await expect(testSpawn(runtime(invalidName), spec({ env: { 'BAD=NAME': 'x' } }), '/runtime/name'))
.rejects.toThrow('environment entries')
expect(invalidName.createOptions).toBeUndefined()
const invalidValue = new FakeTerminalSandbox()
await expect(testSpawn(runtime(invalidValue), spec({ env: { BAD: 'x\0y' } }), '/runtime/value'))
.rejects.toThrow('environment entries')
const invalidArg = new FakeTerminalSandbox()
await expect(testSpawn(runtime(invalidArg), spec({ argv: ['/bin/bash', 'x\0y'] }), '/runtime/argv'))
.rejects.toThrow('argv must not contain NUL')
})
it('cleans malformed handles, bootstrap failures, and readiness failures', async () => {
const failedState = new FakeTerminalSandbox()
failedState.writeError = new Error('state write failed')
await expect(testSpawn(runtime(failedState), spec(), '/runtime/state-write'))
.rejects.toThrow('state write failed')
expect(failedState.writes.get('/runtime/state-write/environment')).toContain('KEEP=visible\0')
expect(failedState.removed).toContain('/runtime/state-write')
expect(failedState.createOptions).toBeUndefined()
const stateAlreadyGone = new FakeTerminalSandbox()
stateAlreadyGone.writeError = new Error('state write failed after external cleanup')
stateAlreadyGone.removeError = new FileNotFoundError('state already gone')
await expect(testSpawn(runtime(stateAlreadyGone), spec(), '/runtime/state-gone'))
.rejects.toThrow('state write failed after external cleanup')
const invalidPid = new FakeTerminalSandbox()
invalidPid.handle.pid = 0
await expect(testSpawn(runtime(invalidPid), spec(), '/runtime/invalid-pid'))
.rejects.toThrow('invalid terminal pid 0')
expect(invalidPid.handle.sdkKills).toBe(1)
expect(invalidPid.removed).toContain('/runtime/invalid-pid')
const failedInput = new FakeTerminalSandbox()
failedInput.sendError = new Error('bootstrap failed')
await expect(testSpawn(runtime(failedInput), spec(), '/runtime/input'))
.rejects.toThrow('bootstrap failed')
expect(failedInput.commands).toContain('kill -TERM -- -123')
expect(failedInput.groups).toEqual([])
const invalidSession = new FakeTerminalSandbox()
invalidSession.sessionId = 'not-a-session\n'
invalidSession.clearOnTerm = false
await expect(testSpawn(runtime(invalidSession), spec(), '/runtime/session'))
.rejects.toThrow('cannot resolve process session')
expect(invalidSession.commands).toContain('kill -TERM -- -123')
expect(invalidSession.commands).toContain('kill -KILL -- -123')
expect(invalidSession.groups).toEqual([])
expect(invalidSession.handle.sdkKills).toBe(1)
const lateData = invalidSession.createOptions?.onData
if (lateData === undefined) throw new Error('missing captured terminal callback')
expect(lateData(Buffer.from('late bytes'))).toBeUndefined()
const termFailed = new FakeTerminalSandbox()
termFailed.sendError = new Error('bootstrap failed')
termFailed.termFailure = new Error('TERM transport failed')
await expect(testSpawn(runtime(termFailed), spec(), '/runtime/term-failed'))
.rejects.toThrow('bootstrap failed')
expect(termFailed.commands).toContain('kill -KILL -- -123')
expect(termFailed.handle.sdkKills).toBe(1)
const uninspectable = new FakeTerminalSandbox()
uninspectable.sendError = new Error('bootstrap failed')
uninspectable.sessionGroupsFailure = 'session enumeration failed'
uninspectable.handle.sdkKillError = new Error('PTY kill failed')
let uninspectableFailure: unknown
try {
await testSpawn(runtime(uninspectable), spec(), '/runtime/uninspectable')
} catch (error: unknown) {
uninspectableFailure = error
}
expect(uninspectableFailure).toBeInstanceOf(AggregateError)
expect(uninspectable.handle.sdkKills).toBe(1)
const survivingGroups = new FakeTerminalSandbox()
survivingGroups.sendError = new Error('bootstrap failed')
survivingGroups.clearOnTerm = false
survivingGroups.clearOnKill = false
await expect(testSpawn(runtime(survivingGroups), spec({ graceMs: 1 }), '/runtime/surviving-groups'))
.rejects.toThrow('bootstrap failed')
const survivingPid = new FakeTerminalSandbox()
survivingPid.sendError = new Error('bootstrap failed')
survivingPid.groups = []
survivingPid.handle.settleOnSdkKill = false
await expect(testSpawn(runtime(survivingPid), spec({ graceMs: 1 }), '/runtime/surviving-pid'))
.rejects.toThrow('bootstrap failed')
const waitFailed = new FakeTerminalSandbox()
waitFailed.handle.waitError = new Error('wait failed')
waitFailed.handle.settleOnSdkKill = false
waitFailed.handle.sdkKillError = new Error('kill failed')
await expect(testSpawn(runtime(waitFailed), spec(), '/runtime/wait-failed'))
.rejects.toThrow('wait failed')
expect(waitFailed.handle.sdkKills).toBe(1)
const cleanupFailed = new FakeTerminalSandbox()
cleanupFailed.handle.pid = 0
cleanupFailed.handle.sdkKillError = new Error('kill transport failed')
cleanupFailed.removeError = new Error('remove transport failed')
await expect(testSpawn(runtime(cleanupFailed), spec(), '/runtime/cleanup-failed'))
.rejects.toThrow('invalid terminal pid 0')
const expiredDuringRollback = new FakeTerminalSandbox()
expiredDuringRollback.sendError = new Error('bootstrap failed before timeout')
expiredDuringRollback.groups = []
expiredDuringRollback.handle.settleOnSdkKill = false
expiredDuringRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
expiredDuringRollback.removeError = new SandboxNotFoundError('sandbox expired')
await expect(testSpawn(runtime(expiredDuringRollback), spec(), '/runtime/expired-rollback'))
.rejects.toThrow('bootstrap failed before timeout')
expect(expiredDuringRollback.handle.sdkKills).toBe(1)
const expiredBeforeSdkRollback = new FakeTerminalSandbox()
expiredBeforeSdkRollback.handle.waitError = new Error('wait failed after timeout')
expiredBeforeSdkRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
expiredBeforeSdkRollback.handle.settleOnSdkKill = false
await expect(testSpawn(runtime(expiredBeforeSdkRollback), spec(), '/runtime/expired-sdk-rollback'))
.rejects.toThrow('wait failed after timeout')
const missingDuringDisconnect = new FakeTerminalSandbox()
missingDuringDisconnect.sendError = new Error('bootstrap failed before disconnect')
missingDuringDisconnect.handle.disconnectError = new SandboxNotFoundError('sandbox expired')
await expect(testSpawn(runtime(missingDuringDisconnect), spec(), '/runtime/missing-disconnect'))
.rejects.toThrow('bootstrap failed before disconnect')
const failedDisconnect = new FakeTerminalSandbox()
failedDisconnect.sendError = new Error('bootstrap failed with disconnect failure')
failedDisconnect.handle.disconnectError = new Error('disconnect transport failed')
await expect(testSpawn(runtime(failedDisconnect), spec(), '/runtime/failed-disconnect'))
.rejects.toThrow('bootstrap failed with disconnect failure')
})
it('propagates setup cancellation and provider failures', async () => {
const aborted = new FakeTerminalSandbox()
await expect(testSpawn(runtime(aborted), spec({ signal: AbortSignal.abort(new Error('stop')) }), '/runtime/abort'))
.rejects.toThrow('stop')
const createFailed = new FakeTerminalSandbox()
createFailed.createError = new Error('create failed')
await expect(testSpawn(runtime(createFailed), spec(), '/runtime/create'))
.rejects.toThrow('create failed')
})
it('bounds a missing bootstrap-output boundary by process exit or cancellation', async () => {
const exited = new FakeTerminalSandbox()
exited.emitOutputMarker = false
const exiting = testSpawn(runtime(exited), spec(), '/runtime/missing-output-boundary')
await vi.waitFor(() => { expect(exited.inputs).toHaveLength(1) })
exited.handle.succeed(0)
await expect(exiting).rejects.toThrow('terminal exited before publishing its output boundary')
const cancelled = new FakeTerminalSandbox()
cancelled.emitOutputMarker = false
const controller = new AbortController()
const cancelling = testSpawn(
runtime(cancelled),
spec({ signal: controller.signal }),
'/runtime/cancel-output-boundary',
)
await vi.waitFor(() => { expect(cancelled.inputs).toHaveLength(1) })
await new Promise(resolve => setTimeout(resolve, 0))
controller.abort(new Error('cancel output boundary'))
await expect(cancelling).rejects.toThrow('cancel output boundary')
})
})
describe('E2B terminal lifecycle', () => {
it('aborts and joins in-flight terminal operations before cleanup', async () => {
const fake = new FakeTerminalSandbox()
const terminal = await testSpawn(runtime(fake), spec(), '/runtime/in-flight-operations')
const writeStarted = Promise.withResolvers<AbortSignal>()
const inspectStarted = Promise.withResolvers<AbortSignal>()
const signalStarted = Promise.withResolvers<AbortSignal>()
fake.sendInputRequest = holdRequestUntilAbort(writeStarted)
let foregroundRequests = 0
fake.foregroundRequest = async (signal) => {
foregroundRequests += 1
if (foregroundRequests === 1) await holdRequestUntilAbort(inspectStarted)(signal)
}
let signalCompleted = false
fake.signalRequest = async (operationSignal) => {
await holdRequestUntilAbort(signalStarted)(operationSignal)
signalCompleted = true
}
const write = terminal.write('late input')
const inspect = terminal.inspectForeground()
await Promise.all([writeStarted.promise, inspectStarted.promise])
const signal = terminal.signalForeground('SIGINT')
await signalStarted.promise
const terminating = terminal.terminate()
await expect(write).rejects.toThrow('terminal is terminating')
await expect(inspect).rejects.toThrow('terminal is terminating')
await expect(signal).rejects.toThrow('terminal is terminating')
await terminating
expect(signalCompleted).toBe(false)
expect(fake.inputs).toHaveLength(1)
const commandCount = fake.commands.length
await expect(terminal.write('after termination')).rejects.toThrow('terminal is terminating')
await expect(terminal.inspectForeground()).rejects.toThrow('terminal is terminating')
await expect(terminal.signalForeground('SIGINT')).rejects.toThrow('terminal is terminating')
expect(fake.commands).toHaveLength(commandCount)
})
it('maps ordinary exits, closes output, and reports an absent foreground after exit', async () => {
const fake = new FakeTerminalSandbox()
fake.groups = []
const terminal = await testSpawn(runtime(fake), spec(), '/runtime/natural')
terminal.output.resume()
const ended = once(terminal.output, 'end')
fake.handle.succeed(7)
await expect(terminal.done).resolves.toEqual({ exitCode: 7, signal: null })
await ended
await expect(terminal.write('late')).rejects.toThrow('exited')
fake.foregroundFailure = commandError(1)
await expect(terminal.inspectForeground()).resolves.toBeUndefined()
await expect(terminal.signalForeground('SIGINT')).rejects.toThrow('cannot resolve foreground process group')
await terminal.terminate()
})
it.each([
[7, { exitCode: 7, signal: null }],
[143, { exitCode: 143, signal: null }],
[255, { exitCode: 255, signal: null }],
] as const)('classifies an unrequested command exit %i', async (exitCode, expected) => {
const fake = new FakeTerminalSandbox()
fake.groups = []
const terminal = await testSpawn(runtime(fake), spec(), `/runtime/exit-${exitCode}`)
fake.handle.fail(exitCode)
await expect(terminal.done).resolves.toEqual(expected)
await terminal.terminate()
})
it('treats a terminal session containing only zombies as quiescent', async () => {
const fake = new FakeTerminalSandbox()
fake.groups = []
fake.zombieGroups = [123]
const terminal = await testSpawn(runtime(fake), spec(), '/runtime/zombie-session')
fake.handle.succeed(0)
await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null })
await terminal.terminate()
expect(fake.commands).toContain(
"set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == 123 && $3 !~ /^[ZXx]/ { print $2 }'",
)
})
it('treats a timeout-killed sandbox as quiescent during terminal cleanup', async () => {
const fake = new FakeTerminalSandbox()
const terminal = await testSpawn(runtime(fake), spec(), '/runtime/expired-sandbox')
fake.sessionGroupsFailure = new SandboxNotFoundError('sandbox expired')
fake.handle.succeed(0)
await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null })
await terminal.terminate()
})
it('treats sandbox disappearance during PTY kill as quiescent', async () => {
const fake = new FakeTerminalSandbox()
fake.groups = []
fake.handle.settleOnSdkKill = false
fake.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/expired-pty-kill')
await terminal.terminate()
expect(fake.handle.sdkKills).toBe(1)
})
it('propagates a non-missing PTY kill failure', async () => {
const fake = new FakeTerminalSandbox()
fake.groups = []
fake.handle.settleOnSdkKill = false
fake.handle.sdkKillError = new Error('PTY kill transport failed')
const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/failed-pty-kill')
await expect(terminal.terminate()).rejects.toThrow('PTY kill transport failed')
fake.handle.sdkKillError = undefined
fake.handle.succeed(0)
await terminal.done
await terminal.terminate()
})
it.each([
['accepts sandbox loss', new SandboxNotFoundError('sandbox expired'), true],
['propagates another failure', new Error('disconnect failed'), false],
] as const)('%s while disconnecting a settled terminal', async (_label, failure, accepted) => {
const fake = new FakeTerminalSandbox()
const terminal = await testSpawn(runtime(fake), spec(), `/runtime/disconnect-${accepted}`)
fake.handle.disconnectError = failure
fake.groups = []
fake.handle.succeed(0)
if (accepted) await expect(terminal.terminate()).resolves.toBeUndefined()
else await expect(terminal.terminate()).rejects.toThrow('disconnect failed')
})
it('rejects killing the terminal shell and propagates live foreground failures', async () => {
const fake = new FakeTerminalSandbox()
fake.foreground = '123\n'
const terminal = await testSpawn(runtime(fake), spec(), '/runtime/signal')
await expect(terminal.signalForeground('SIGKILL')).rejects.toThrow('refusing to SIGKILL')
fake.foreground = 'invalid\n'
await expect(terminal.inspectForeground()).rejects.toThrow('cannot resolve foreground')
fake.foregroundFailure = commandError(1)
await expect(terminal.inspectForeground()).resolves.toBeUndefined()
fake.foregroundFailure = commandError(2)
await expect(terminal.inspectForeground()).rejects.toBeInstanceOf(CommandExitError)
fake.clearOnTerm = true
await terminal.terminate()
})
it('sends KILL before checking an expired force-cleanup deadline', async () => {
const fake = new FakeTerminalSandbox()
fake.groups = [123, 456]
fake.clearOnTerm = false
const terminal = await testSpawn(runtime(fake), spec({ graceMs: 0 }), '/runtime/escalate')
const terminating = terminal.terminate()
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
await terminating
expect(fake.commands).toContain('kill -TERM -- -123 -456')
expect(fake.commands).toContain('kill -KILL -- -123 -456')
})
it('surfaces cleanup failures and allows a later retry', async () => {
const fake = new FakeTerminalSandbox()
fake.groups = [1]
const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/retry')
await expect(terminal.terminate()).rejects.toThrow('unsafe process group 1')
fake.groups = []
fake.handle.succeed(0)
await terminal.done
await terminal.terminate()
})
it('propagates a process-group signalling transport failure before retry', async () => {
const fake = new FakeTerminalSandbox()
fake.termFailure = new Error('signal transport failed')
const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/signal-failure')
await expect(terminal.terminate()).rejects.toThrow('signal transport failed')
fake.groups = []
fake.handle.succeed(0)
await terminal.done
await terminal.terminate()
const alreadyExited = new FakeTerminalSandbox()
alreadyExited.termFailure = commandError(1)
const tolerant = await testSpawn(runtime(alreadyExited), spec({ graceMs: 1 }), '/runtime/group-exited')
const tolerantTermination = tolerant.terminate()
await expect(tolerant.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
await tolerantTermination
})
it('keeps command rejection authoritative while cleanup is already waiting', async () => {
const fake = new FakeTerminalSandbox()
fake.groups = []
fake.removeError = new Error('private state already gone')
const terminal = await testSpawn(runtime(fake), spec(), '/runtime/reject-during-cleanup')
terminal.output.on('error', () => {})
const cleanup = terminal.terminate()
await Promise.resolve()
fake.handle.crash(new Error('command transport failed'))
await expect(terminal.done).rejects.toThrow('command transport failed')
await cleanup
})
it('keeps a late command rejection authoritative after PTY kill', async () => {
const fake = new FakeTerminalSandbox()
fake.groups = []
fake.handle.settleOnSdkKill = false
const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/reject-after-kill')
terminal.output.on('error', () => {})
const cleanup = terminal.terminate()
while (fake.handle.sdkKills === 0) await new Promise(resolve => setTimeout(resolve, 0))
await Promise.resolve()
fake.handle.crash(new Error('late command transport failed'))
await expect(terminal.done).rejects.toThrow('late command transport failed')
await cleanup
})
it('reports surviving groups, a surviving top-level pid, and transport failure', async () => {
const survivor = new FakeTerminalSandbox()
survivor.clearOnTerm = false
survivor.clearOnKill = false
const terminal = await testSpawn(runtime(survivor), spec({ graceMs: 1 }), '/runtime/survivor')
await expect(terminal.terminate()).rejects.toThrow('surviving process groups: 123')
const livePid = new FakeTerminalSandbox()
livePid.groups = []
livePid.handle.settleOnSdkKill = false
const live = await testSpawn(runtime(livePid), spec({ graceMs: 1 }), '/runtime/live-pid')
await expect(live.terminate()).rejects.toThrow('surviving pid: 123')
livePid.handle.succeed(0)
await live.done
const crashed = new FakeTerminalSandbox()
crashed.groups = []
const failed = await testSpawn(runtime(crashed), spec(), '/runtime/crashed')
const outputError = once(failed.output, 'error')
crashed.handle.crash('transport gone')
await expect(failed.done).rejects.toEqual('transport gone')
await expect(outputError).resolves.toMatchObject([{ message: 'transport gone' }])
await failed.terminate()
})
})
describe('E2B subprocess terminal service', () => {
async function service(fake = new FakeTerminalSandbox()): Promise<{
ctx: Context
fiber: Awaited<ReturnType<Context['plugin']>>
fake: FakeTerminalSandbox
}> {
const ctx = new Context()
ctx.provide('e2b', runtime(fake))
const fiber = await ctx.plugin(E2BSubprocessService)
return { ctx, fiber, fake }
}
it('resolves remote executables', async () => {
const { ctx, fake } = await service()
await expect(ctx.subprocess.resolveExecutable('/bin/bash')).resolves.toBe('/bin/bash')
await expect(ctx.subprocess.resolveExecutable('node', { PATH: '/custom/bin' }, new AbortController().signal))
.resolves.toBe('/usr/bin/node')
fake.resolvedExecutable = 'tools/bin/node\n'
await expect(ctx.subprocess.resolveExecutable('node', { PATH: 'tools/bin' }))
.resolves.toBe('/workspace/tools/bin/node')
const commandOptions = fake.commandOptions.at(-1)
expect(commandOptions).toMatchObject({ cwd: '/workspace' })
expect(commandOptions?.envs?.HOME).toMatch(/^\/\.dsh-e2b-control-/)
expect(commandOptions?.envs).toEqual({ HOME: commandOptions?.envs?.HOME })
expect((ctx.e2b)).toBeDefined()
})
it('rejects invalid executable lookup inputs and results', async () => {
const { ctx, fake } = await service()
await expect(ctx.subprocess.resolveExecutable('')).rejects.toThrow('non-empty')
await expect(ctx.subprocess.resolveExecutable('./bin/server')).rejects.toThrow('is a relative path')
await expect(ctx.subprocess.resolveExecutable('node_modules/.bin/server')).rejects.toThrow('is a relative path')
await expect(ctx.subprocess.resolveExecutable('node', undefined, AbortSignal.abort(new Error('stop'))))
.rejects.toThrow('stop')
fake.resolvedExecutable = 'node\n'
await expect(ctx.subprocess.resolveExecutable('node')).rejects.toThrow('did not resolve')
fake.resolvedExecutable = '/one\n/two\n'
await expect(ctx.subprocess.resolveExecutable('node')).rejects.toThrow('did not resolve')
})
it('rejects a non-positive poll cadence at load', async () => {
const ctx = new Context()
ctx.provide('e2b', runtime(new FakeTerminalSandbox()))
await expect(ctx.plugin(E2BSubprocessService, { pollMs: 0 }))
.rejects.toThrow('pollMs must be a positive safe integer')
const explicit = await ctx.plugin(E2BSubprocessService, { pollMs: 5 })
await explicit.dispose()
})
it('owns live terminals through service disposal', async () => {
const { ctx, fiber, fake } = await service()
const terminal = await ctx.subprocess.spawnTerminal(spec({ signal: new AbortController().signal }))
await fiber.dispose()
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
expect(fake.handle.disconnects).toBe(1)
})
it('joins and rejects terminal setup that completes during service disposal', async () => {
const fake = new FakeTerminalSandbox()
const { ctx, fiber } = await service(fake)
let disposing: Promise<void> | undefined
fake.afterSessionLookup = () => {
fake.afterSessionLookup = undefined
queueMicrotask(() => {
queueMicrotask(() => { disposing = fiber.dispose() })
})
}
const subprocess = ctx.subprocess
const spawning = ctx.subprocess.spawnTerminal(spec())
const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
await vi.waitFor(() => { expect(disposing).toBeDefined() })
await expect(subprocess.spawnTerminal(spec())).rejects.toThrow('service is disposing')
await rejected
await disposing
expect(fake.groups).toEqual([])
expect(fake.handle.disconnects).toBe(1)
expect(fake.removed.some(path => path.includes('/terminals/'))).toBe(true)
})
it('aborts and rolls back terminal setup that cannot publish its output boundary during disposal', async () => {
const fake = new FakeTerminalSandbox()
fake.emitOutputMarker = false
const { ctx, fiber } = await service(fake)
const spawning = ctx.subprocess.spawnTerminal(spec())
const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
await vi.waitFor(() => { expect(fake.inputs).toHaveLength(1) })
await fiber.dispose()
await rejected
expect(fake.groups).toEqual([])
expect(fake.handle.disconnects).toBe(1)
})
it('owns and cancels terminal state-directory creation during disposal', async () => {
const fake = new FakeTerminalSandbox()
fake.makeDirRequest = async (signal) => {
await new Promise<never>((_resolve, reject) => {
const onAbort = (): void => {
const reason: unknown = signal?.reason
reject(reason instanceof Error ? reason : new Error(String(reason)))
}
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted === true) onAbort()
})
}
const { ctx, fiber } = await service(fake)
const spawning = ctx.subprocess.spawnTerminal(spec())
const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
await vi.waitFor(() => { expect(fake.directories.some(path => path.includes('/terminals/'))).toBe(true) })
await fiber.dispose()
await rejected
expect(fake.removed.some(path => path.includes('/terminals/'))).toBe(true)
expect(fake.createOptions).toBeUndefined()
})
it('releases naturally settled terminals and validates terminal requests', async () => {
const { ctx, fiber, fake } = await service()
for (const request of [
spec({ argv: [] }),
spec({ signal: AbortSignal.abort(new Error('cancelled')) }),
]) {
await expect(ctx.subprocess.spawnTerminal(request)).rejects.toThrow()
}
fake.groups = []
const terminal = await ctx.subprocess.spawnTerminal(spec())
fake.handle.succeed(0)
await terminal.done
await terminal.terminate()
const signals = fake.commands.filter(command => command.startsWith('kill -')).length
await fiber.dispose()
expect(fake.commands.filter(command => command.startsWith('kill -'))).toHaveLength(signals)
})
it('contains a failed automatic terminal release until service disposal retries it', async () => {
const { fiber, fake } = await service()
fake.clearOnTerm = false
fake.clearOnKill = false
const terminal = await (fiber.ctx).subprocess.spawnTerminal(spec({ graceMs: 1 }))
fake.handle.succeed(0)
await terminal.done
await new Promise(resolve => setTimeout(resolve, 10))
expect(fake.commands).toContain('kill -KILL -- -123')
fake.groups = []
await fiber.dispose()
await expect(terminal.terminate()).resolves.toBeUndefined()
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"
},
{
"path": "../../util/timeout"
},
{
"path": "../e2b"
}
]
}

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/fs/README.md
README.md: 108d7d862a1dc6307268e2a93fa00789c952e440
README.zh.md: 8b037cc3192bf6ceb0f0671d62911a8e035db24c
README.md: b15012e882b60847e1ad22edf08d1202ba64fe5b
README.zh.md: 628f6c74894bc67559d49f7cf5d1378d0ece2382

View File

@@ -2,16 +2,20 @@
English | [中文](README.zh.md)
The filesystem capability family: provider seam, interchangeable backends, policy, and model-facing tools. All **product** packages.
The filesystem stack: a provider seam (execution-world paths, bounded text IO, and atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), the model-facing file tools + executor, and the bash-backed discovery tools. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| [`fs/`](fs/README.md) | Filesystem provider seam and policy-event vocabulary | `ctx.fs` |
| [`fs-local/`](fs-local/README.md) | Local-filesystem backend | registers `ctx.fs` |
| [`fs-sandbox/`](fs-sandbox/README.md) | Sandbox-enforcing backend | registers `ctx.fs` |
| [`fs-policy/`](fs-policy/README.md) | Observed-state and mutation policy | `fs/*` listeners |
| [`tool-fs/`](tool-fs/README.md) | Model-facing file tools | registers on `ctx.tools` |
| [`tool-fs-search/`](tool-fs-search/README.md) | Process-backed discovery tools | registers on `ctx.tools` |
| [`tool-str-replace-editor/`](tool-str-replace-editor/README.md) | Model-facing string-replacement editor | registers on `ctx.tools` |
| `fs/` | Provider seam: canonical process paths/file URIs/containment, text IO, and atomic mutation primitives; owns the `fs/*` policy events | `ctx.fs` |
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
| [`e2b/fs-e2b`](../e2b/fs-e2b/README.md) | E2B-backed `FileSystem` implementation sharing the remote runtime owned by `ctx.e2b` | (registers `ctx.fs`) |
| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call mode + workspace root policy (read-only denies, workspace-write contains to the session workspace + temp roots), reads pass through | (registers `ctx.fs`) |
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
Backends replace one another behind `ctx.fs`; policy and tools consume the seam independently. Discovery remains process-backed instead of expanding the provider contract. Child READMEs own containment, mutation, schema, and timeout details.
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas: `fs-sandbox` provides an in-process path fence over the shared sandbox mode ([decision](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)), while `fs-e2b` places file state in the remote execution world shared with the E2B subprocess provider ([decision](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
## No timeouts on file IO
`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web (which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md)) and the bash-backed `glob`/`grep` (whose declared `timeoutMs` is enforced by `@deepseek-ai/dsh-timeout-policy`): those are process-backed, where a deadline can really kill the work. A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries.

View File

@@ -1,17 +1,21 @@
# fs/ - 文件系统能力
# fs/文件系统能力族
[English](README.md) | 中文
文件系统能力家族:提供方 seam、可互换后端、策略和面向模型工具。这些全是**产品**包。
文件系统栈包括:提供方 seam(执行世界路径、有界文本 I/O 与带可选版本防护的原子变更)、本地实现、政策门禁插件(已观察状态、编辑前读取、版本防护的写入/编辑)、面向模型的文件工具与执行器,以及基于 bash 的发现工具。全部都是**产品** 包。
| 包 | 职责 | ctx key |
| 包 | 角色 | ctx |
|---|---|---|
| [`fs/`](fs/README.md) | 文件系统提供方 seam 和策略事件词汇 | `ctx.fs` |
| [`fs-local/`](fs-local/README.md) | 本地文件系统后端 | 注册 `ctx.fs` |
| [`fs-sandbox/`](fs-sandbox/README.md) | 强制执行沙箱的后端 | 注册 `ctx.fs` |
| [`fs-policy/`](fs-policy/README.md) | 已观察状态和修改策略 | `fs/*` 监听器 |
| [`tool-fs/`](tool-fs/README.md) | 面向模型的文件工具 | 注册到 `ctx.tools` |
| [`tool-fs-search/`](tool-fs-search/README.md) | 基于进程的发现工具 | 注册到 `ctx.tools` |
| [`tool-str-replace-editor/`](tool-str-replace-editor/README.md) | 面向模型的字符串替换编辑器 | 注册到 `ctx.tools` |
| `fs/` | 提供方 seam规范化进程路径、文件 URI 与包含关系、文本 I/O 和原子变更原语;拥有 `fs/*` 政策事件 | `ctx.fs` |
| `fs-local/` | 本地文件系统 `FileSystem` 实现 | 注册 `ctx.fs` |
| [`e2b/fs-e2b`](../e2b/fs-e2b/README.md) | 以 E2B 为后端的 `FileSystem` 实现,共享由 `ctx.e2b` 拥有的远程运行时 | 注册 `ctx.fs` |
| `fs-sandbox/` | 强制沙箱的 `FileSystem`:扩展 `fs-local`,并按每次调用的模式与工作区根政策约束写入/编辑(只读模式拒绝,工作区写入模式限制在会话工作区与临时根目录内);读取直接通过 | (注册 `ctx.fs` |
| `fs-policy/` | 政策门禁插件:通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑 | (无服务,仅有 `fs/*` 监听器) |
| `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升级字段 | 注册到 `ctx.tools` |
| `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具;当 `rg` 位于 bash 执行器 `PATH` 上时注册,通过 `ctx.bash` 运行固定 ripgrep 命令,而不是使用 `ctx.fs` 提供方方法 | 注册到 `ctx.tools` |
后端可在 `ctx.fs` 后互相替换;策略和工具独立消费该 seam。发现功能仍由进程提供不扩展提供方契约。子 README 负责围堵、修改、schema 和超时细节。
接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、政策门禁或面向模型的工具 schema`fs-sandbox` 基于共享沙箱模式提供进程内路径围栏([决策](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)),而 `fs-e2b` 则把文件状态置于与 E2B 进程管理提供方共享的远程执行世界中([决策](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md))。政策(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去政策,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam搜索是在 bash 执行器上运行 `rg`、由进程支持的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。
## 文件 I/O 不设超时
`read`/`write`/`edit` **不** 接受 `timeoutMs`,提供方 seam 也不启动 deadline。这与 bash 和 web两者使用 [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md))及基于 bash 的 `glob`/`grep` 不同(它们声明的 `timeoutMs``@deepseek-ai/dsh-timeout-policy` 强制执行这些工作由进程支持deadline 可以实际终止工作。本地系统调用至多只能尽力中止:超时无法强制正在进行的 `fsync`/`rename` 停止,因此这里的 deadline 会成为无法兑现承诺的配置项。在此添加 deadline 还会在「显式优于隐式」明确禁止的地方引入隐式默认值。两个参考 agentClaude Code、Codex出于同一原因都不为文件 I/O 计时;取消仍通过工具执行信号传播,在系统调用边界尽力中止。

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/fs/fs-local/README.md
README.md: 6d344fa3fef7f6bda6c0daa50184661156a925a7
README.zh.md: 4c94de64561d805684f91f02d5b0dfc375f0d04f
README.md: 2e934298ceff75440357b0742770010c8b1c3904
README.zh.md: 14f4867ce0f9eaae2a1dea98dbd7d401c98d4535

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eight `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eleven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
```ts ignore-check
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
@@ -15,8 +15,9 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
## Behavior
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `fileUrl` encodes that path through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`.
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing.
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
@@ -35,7 +36,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)).
- **An overwrite reads the whole prior file into memory** — solely as the UI diff basis; bounding that pre-read above a size threshold is deferred (`TODO(overwrite-diff-bound)`).
- **Version tokens are `mtimeMs:size`** — an external change that preserves both within the filesystem's timestamp granularity defeats the stale guard.
- **Version tokens depend on filesystem metadata** — they combine device, inode, size, nanosecond mtime, and nanosecond ctime; a storage layer that cannot update any of those facts for a rewrite can still defeat the stale guard.
- **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path.
- **Binary detection is asymmetric** — reads NUL-sample only the first 8192 bytes while edits scan the whole buffer, so a file with a late NUL reads fine but rejects edits.
- **The per-target mutation lock is in-process only** — a writer in another process is caught only by the optional version guard, never serialized.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
`ctx.fs` 提供方 seam[`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持`FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`
`ctx.fs` 提供方 seam[`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十一`FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`
```ts ignore-check
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
@@ -15,27 +15,28 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
## 行为
- **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent智能体的会话 cwd见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。
- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo``version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token`type` 为 `file`/`directory`/`other``size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此异步探测进行期间发生的中止会报告 `FS_ABORTED`,而非已失效的「不存在」结果
- **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片流式读取(跨分片解码),因此超大文件无需整体保存在内存中。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)按大小决定调用哪个方法,并负责行窗口逻辑
- **执行世界坐标**`processPath` 公开目标的规范化宿主路径,`fileUrl` 通过 Node 的平台感知 URL 转换对该路径编码,`contains` 则使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`
- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo``version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token`type` 为 `file`/`directory`/`other``size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果
- **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。
- **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。
- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 mode 会保留,新文件默认为 `0o600`Windows 上的新文件继承目标目录的 DACL而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选OPTIONAL的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED``replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。
- **`editText`**:在同一原语之上执行原子的字面量读取-修改-写入,并通过变更锁按目标串行化。`expected` 防护是可选OPTIONAL的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。
- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 mode 会保留,新文件默认为 `0o600`Windows 上的新文件继承目标目录的 DACL而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED``replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。
- **`editText`**:在同一原语之上依次执行原子的字面量读取修改写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。
包根目录的 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis单独进行单元测试`src/index.ts` 是轻量服务接线。
包根 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis单独进行单元测试`src/index.ts` 是轻量服务接线。
## 模型体验
通过 [`dsh-tool-fs`](../tool-fs/README.md) 间接产生影响;该消费方在有上限的保留结果中渲染本提供方带行窗口的 UTF-8 内容、变更确认和精确提供方消息,而版本、原子写入机制和目录元数据仍属内部实现
通过 [`dsh-tool-fs`](../tool-fs/README.md) 间接产生影响;该消费方本提供方带行窗口的 UTF-8 内容、变更确认和精确提供方消息渲染为有上限且保留的结果,而版本、原子写入机制和目录元数据保持内部可见
#### KV Cache 影响
不会直接使缓存失效;具名消费方负责请求前缀的任何变化。
## 已知限制与暂缓事项
## 已知限制与延期工作
- **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall瀑布式事件上的权限插件实施约束见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences))。
- **覆盖会把整个旧文件读入内存**:只用于 UI diff在大小阈值之上限制这次预读取的工作延期处理`TODO(overwrite-diff-bound)`)。
- **版本 token 是 `mtimeMs:size`**:如果外部变更在文件系统时间戳粒度内保持两者不变,就能绕过陈旧防护。
- **版本 token 依赖文件系统元数据**它们组合设备、inode、大小、纳秒级 mtime 和纳秒级 ctime如果存储层在重写时无法更新其中任何一项事实仍可能绕过陈旧防护。
- **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。
- **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。
- **每目标变更锁仅限进程内**:其他进程中的写入方只会被可选版本防护发现,绝不会被串行化。

View File

@@ -5,7 +5,8 @@
*/
import { Context } from 'cordis'
import { resolve } from 'node:path'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import z from 'schemastery'
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
@@ -90,6 +91,19 @@ export class LocalFileSystem extends FileSystem {
return { targetKey: local.targetKey, displayPath: local.displayPath }
}
override processPath(target: FsTarget): string {
return String(target.targetKey)
}
override fileUrl(target: FsTarget): string {
return pathToFileURL(this.processPath(target)).href
}
override contains(parent: FsTarget, child: FsTarget): boolean {
const path = relative(this.processPath(parent), this.processPath(child))
return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path))
}
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
const info = await probe(target.targetKey)

View File

@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
import { FsVersion } from '@deepseek-ai/dsh-fs'
@@ -85,6 +86,20 @@ describe('resolve', () => {
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
it('projects process paths, file URLs, and canonical containment', async () => {
await mkdir(join(dir, 'nested'))
await writeFile(join(dir, 'nested', 'file.txt'), 'text')
const root = await fs.resolve('.')
const child = await fs.resolve('nested/file.txt')
const outside = await fs.resolve('..')
expect(fs.processPath(child)).toBe(await realpath(join(dir, 'nested', 'file.txt')))
expect(fs.fileUrl(child)).toBe(pathToFileURL(await realpath(join(dir, 'nested', 'file.txt'))).href)
expect(fs.contains(root, root)).toBe(true)
expect(fs.contains(root, child)).toBe(true)
expect(fs.contains(root, outside)).toBe(false)
})
})
describe('stat', () => {

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/fs/fs/README.md
README.md: 6c80cc22f6f28e792c3458df3390b241b51d8202
README.zh.md: 4772d799221efbfff9667cbc8b9e1df6af07dcff
README.md: bf1dd1c1eb65146258cd64e450749845522e7057
README.zh.md: f3fcc0c3794b972233dc418e93bdd80b1cc8570a

View File

@@ -2,21 +2,33 @@
English | [中文](README.zh.md)
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, no-follow path metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
This package is the provider-seam layer of the [filesystem family](../README.md). The [tool](../tool-fs/README.md), [policy](../fs-policy/README.md), and [local](../fs-local/README.md) and [sandboxed](../fs-sandbox/README.md) backends remain separate consumers and implementations; the capability-seam decisions own the split ([foundation](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [filesystem seam](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [provider split](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), [event gate](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)).
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
| Layer | Package | Role |
|---|---|---|
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
| policy | `@deepseek-ai/dsh-fs-policy` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: execution-world paths, text IO, and atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
| provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation |
A future sandboxed, virtual, or remote backend implements this interface and the policy/tool layers don't change.
## Service API (`ctx.fs`)
A backend subclasses `FileSystem` and implements eight primitives.
A backend subclasses `FileSystem` and implements eleven primitives.
| Member | Semantics |
|---|---|
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default), while `opts.signal` aborts a backend round-trip. Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
| `processPath(target)` | Return the canonical absolute path that a subprocess in this provider's execution world can open. This is intentionally distinct from opaque `targetKey`. |
| `fileUrl(target)` | Return the canonical `file:` URI in the execution world's platform syntax. The backend, not the host process, owns encoding. |
| `contains(parent, child)` | Test canonical identity/descendant containment without exposing or parsing target keys. Both targets come from this provider. |
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
| `lstat(path, opts?, signal?)` | Return `FsPathInfo` metadata without following the final path component when it is a symlink. This is path-shaped so consumers can reject repository-owned symlinks before `resolve` follows them into a target. |
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here); consumers that need a byte ceiling enforce it while consuming the stream. |
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. |
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. |
| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. |
@@ -37,10 +49,6 @@ This package declares three events (see the generated [events catalog](../../../
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
## No IO deadline
Filesystem primitives accept an optional `AbortSignal` but arm no deadline. Local IO is only best-effort abortable: a timeout cannot force an in-progress `fsync` or `rename` to stop, so a fixed deadline would promise control the backend cannot provide. Process-backed discovery owns its separate timeout contract.
## Model Experience
Indirectly, through `dsh-tool-fs`, which renders provider text and errors as bounded, retained filesystem tool results.
@@ -52,6 +60,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md).
- **Eight primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **No IO deadline** — cancellation is best-effort at primitive boundaries.
- **Eleven primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)).
- **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend.

View File

@@ -2,56 +2,64 @@
[English](README.md) | 中文
**文件系统提供方 seam**:抽象 `FileSystem` 服务(`ctx.fs`),定义后端提供的存储原语包括路径解析、stat 元数据、不跟随链接的路径元数据、读取/流式读取文本、列出目录、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选**接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、策插件监听的 `fs/*`事件词汇。
**文件系统提供方 seam**:抽象 `FileSystem` 服务(`ctx.fs`),定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、检查列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、策插件监听的 `fs/*` 策事件词汇。
本包是[文件系统家族](../README.md)中的提供方 seam 层。[工具](../tool-fs/README.md)、[策略](../fs-policy/README.md)、[本地](../fs-local/README.md)与[沙箱化](../fs-sandbox/README.md)后端分别作为消费方与实现保持独立;能力 seam 决策负责该拆分([基础](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统 seam](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[提供方拆分](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)[事件门禁](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)
本包是四层文件系统栈中的提供方 seam 层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)
| 层 | 包 | 角色 |
|---|---|---|
| 工具/执行器 | `@deepseek-ai/dsh-tool-fs` | 面向模型的 `read`/`write`/`edit` schema、读取窗口和文本渲染通过 `ctx.fs` 读取/写入/编辑,并分派 `fs/*` 事件 |
| 政策 | `@deepseek-ai/dsh-fs-policy` | 已观察状态、编辑前读取和版本防护的写入/编辑,通过 `fs/*` 事件门禁贡献(无服务) |
| 提供方 seam | `@deepseek-ai/dsh-fs`(本包) | `ctx.fs`:执行世界路径、文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 事件词汇 |
| 提供方 | `@deepseek-ai/dsh-fs-local` | 宿主文件系统实现 |
未来的沙箱化、虚拟或远程后端只需实现该接口,政策层和工具层无需改变。
## 服务 API`ctx.fs`
后端继承 `FileSystem` 并实现个原语。
后端继承 `FileSystem` 并实现十一个原语。
| 成员 | 语义 |
|---|---|
| `resolve(path, opts?)` | 把路径解析为稳定的 `FsTarget`(不透明 `targetKey``displayPath`)。`opts.cwd` 是相对 `path` 解析所依据的基准(调用方提供其会话工作区;绝对路径忽略该值;省略时使用后端默认值),`opts.signal` 则中止后端往返。该方法是异步的,因为远程后端可能需要 I/O。经不同路径到达的同一文件必须产生相同 `targetKey`。 |
| `processPath(target)` | 返回该提供方执行世界中的子进程可以打开的规范化绝对路径。该路径有意与不透明的 `targetKey` 分离。 |
| `fileUrl(target)` | 返回采用执行世界平台语法的规范化 `file:` URI。编码由后端而非宿主进程负责。 |
| `contains(parent, child)` | 在不公开或解析目标 key 的情况下,检查规范化身份相等或后代包含关系。两个目标都来自该提供方。 |
| `stat(target, signal?)` | 返回 `FsInfo` 元数据(`version``type`、可选 `size`);目标不存在时返回 `undefined`。绝不返回内容。 |
| `lstat(path, opts?, signal?)` | 当最后一个路径组件是符号链接时,不跟随该组件,返回 `FsPathInfo` 元数据。该方法采用路径形态,使消费方能在 `resolve` 跟随仓库有的符号链接进入目标前拒绝它。 |
| `lstat(path, opts?, signal?)` | 当最后一个路径组件是符号链接时,不跟随该组件,返回 `FsPathInfo` 元数据。该方法采用路径形态,使消费方能在 `resolve` 跟随仓库有的符号链接进入目标前拒绝它。 |
| `readText(target, signal?)` | 把整个普通文本文件读取为一个解码后的字符串。负责普通文件检查、UTF-8 解码和二进制/NUL 拒绝(`FS_NOT_TEXT`)。 |
| `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责)。 |
| `listDir(target, signal?)` | 按稳定名称顺序列出直接子项。返回条目名称、条目类型、解析后的子目标和低成本元数据(若可用则包括 `version`/文件 `size`);绝不读取文件内容。缺失目标抛出 `FS_NOT_FOUND`,非目录抛出 `FS_NOT_DIRECTORY`,权限失败抛出 `FS_PERMISSION_DENIED`,其他后端 I/O 失败抛出 `FS_IO_ERROR`。损坏/消失的子项可以作为无元数据的 `other` 返回;子项权限/I/O 失败会使用相同结构化代码使整个列出操作失败。 |
| `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责);需要字节上限的消费方在消费流时执行该上限。 |
| `listDir(target, signal?)` | 按稳定名称顺序列出直接子项。返回条目名称、条目类型、解析后的子目标和低成本元数据(若可用则包括 `version`/文件 `size`);绝不读取文件内容。缺失目标抛出 `FS_NOT_FOUND`,非目录抛出 `FS_NOT_DIRECTORY`,权限失败抛出 `FS_PERMISSION_DENIED`,其他后端 I/O 失败抛出 `FS_IO_ERROR`。损坏/消失的子项可以作为无元数据的 `other` 返回;子项权限/I/O 失败会使用相同结构化代码使整个列失败。 |
| `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent``createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。 |
| `editText(target, edit, expected?, signal?)` | 字面量编辑。`expected` 是可选的:省略 ⇒ 无条件编辑当前内容;提供 `{ version }` ⇒ 添加防护,并在匹配之前校验。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。应用和写入以原子方式完成,使用同一个变更临界区。 |
无论是否有版本防护,变更都在后端的每目标锁内运行,因此无条件写入/编辑仍是原子的;「无条件」只移除*版本*前置条件,不移除原子性。
## `fs/*` 策事件
## `fs/*` 策事件
本包声明三个事件(见已生成的[事件目录](../../../docs/cordis-catalog/events.md)),使发出方(`@deepseek-ai/dsh-tool-fs`)和策监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖策插件。`fs/write-intent``fs/edit-intent` 是单槽决策 waterfall瀑布式事件)(监听器完整决策,绝不调用 `next()``fs/observed` 是发后即忘的记录事件。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent智能体/会话所有者结构。
本包声明三个事件(见已生成的[事件目录](../../../docs/cordis-catalog/events.md)),使发出方(`@deepseek-ai/dsh-tool-fs`)和策监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖策插件。`fs/write-intent``fs/edit-intent` 是单槽决策 waterfall监听器完整决策绝不调用 `next()``fs/observed` 是发后即忘的记录事件。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent智能体/会话所有者结构。
## 提供方 seam不是策
## 提供方 seam不是策层
`ctx.fs` 有意接近 fsspec 风格的存储原语,比字节级 `cat`/`open` 高半层,因为它会解码文本并拒绝二进制,使策层绝不接触原始字节。它负责 UTF-8 解码、二进制拒绝、原子写入和字面量编辑临界区。它**不**负责行窗口、编号行、渲染 footer 或已观察状态。已观察状态、编辑前读取和版本防护的写入/编辑属于插件(`@deepseek-ai/dsh-fs-policy`)通过提供可选防护而添加的策,并非提供方行为,因此沙箱化/远程后端不会继承任何面向模型的观察策
`ctx.fs` 有意接近 fsspec 风格的存储原语,比字节级 `cat`/`open` 高半层,因为它会解码文本并拒绝二进制,使策层绝不接触原始字节。它负责 UTF-8 解码、二进制拒绝、原子写入和字面量编辑临界区。它**不** 负责行窗口、编号行、渲染 footer 或已观察状态。已观察状态、编辑前读取和版本防护的写入/编辑属于插件(`@deepseek-ai/dsh-fs-policy`)通过提供可选防护而添加的策,并非提供方行为,因此沙箱化/远程后端不会继承任何面向模型的观察策。
`editText` 留在该 seam 上,不由策层通过读取加写入组合,因为版本防护、字面量匹配和原子重写必须处于同一临界区内,才能正确归因错误并实现一方胜出/一方陈旧的并发;远程后端也可以将其实现为原生比较并编辑操作。
`editText` 留在该 seam 上,不由策层通过读取加写入组合,因为版本防护、字面量匹配和原子重写必须处于同一临界区内,才能正确归因错误并实现一方胜出/一方陈旧的并发;远程后端也可以将其实现为原生比较并编辑操作。
## 词汇
`FsTargetKey` / `FsVersion` 是带品牌的不透明 id见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode``FS_NOT_FOUND``FS_NOT_DIRECTORY``FS_NOT_TEXT``FS_NOT_REGULAR_FILE``FS_PERMISSION_DENIED``FS_IO_ERROR``FS_STALE_VERSION``FS_NOT_OBSERVED``FS_AMBIGUOUS_EDIT``FS_EDIT_NOT_FOUND``FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整契约见 `src/types.ts`
## 无 I/O deadline
文件系统原语接受可选 `AbortSignal`,但不会启动 deadline。本地 I/O 只能尽力取消:超时无法强制进行中的 `fsync``rename` 停止,因此固定 deadline 会承诺后端无法提供的控制能力。基于进程的发现功能拥有独立的超时契约。
## 模型体验
通过 `dsh-tool-fs` 间接产生影响;该消费方把提供方文本和错误渲染为有界且保留的文件系统工具结果。
#### KV Cache 影响
不会直接使缓存失效;上述消费方负责请求前缀的任何变化。
不会直接使缓存失效;具名消费方负责请求前缀的任何变化。
## 已知限制与暂缓事项
## 已知限制与延期工作
- **契约只支持文本**:后端以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;二进制安全操作是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。
- **只有个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层递归、glob、分页和搜索不在范围内见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。
- **没有 I/O deadline**取消只能在原语边界尽力执行
- **只有十一个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层递归、glob、分页和搜索不在范围内见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。
- **没有 I/O deadline**该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.md)
- **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。

View File

@@ -1,8 +1,10 @@
/**
* Filesystem text-storage provider seam. Backends own stable target identity,
* text decoding, binary rejection, and atomic mutations. Read windows and
* observed-state policy stay in consumer and policy plugins; `editText` remains
* here so version check, literal match, and rewrite share one critical section.
* Filesystem provider seam for one execution world. Backends own stable target
* identity, process paths and file URIs, containment, text reads, decoding,
* binary rejection, and atomic mutations. Read windows and
* observed-state policy stay in consumer and policy plugins; `editText`
* remains here so version check, literal match, and rewrite share one critical
* section.
* @module @deepseek-ai/dsh-fs
*/
@@ -83,7 +85,6 @@ export abstract class FileSystem extends Service {
super(ctx, 'fs')
}
/**
/**
* The sandbox mode this backend enforces on mutations BY DEFAULT, or
* `undefined` when it does not confine at all — the capability fact the tool
@@ -111,6 +112,34 @@ export abstract class FileSystem extends Service {
*/
abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>
/**
* Return the canonical absolute path a subprocess in this filesystem's
* execution world can open. The path is deliberately separate from
* {@link FsTarget.targetKey}: consumers may pass this value to another OS
* capability, but must continue treating the target key as opaque.
* @param target - the resolved target whose process path is required.
* @returns an absolute path in the backend's execution world.
*/
abstract processPath(target: FsTarget): string
/**
* Return the canonical `file:` URI for a target in this filesystem's
* execution world. Backends own URI encoding because the host platform may
* differ from the execution platform.
* @param target - the resolved target to encode.
* @returns the target's canonical file URI.
*/
abstract fileUrl(target: FsTarget): string
/**
* Test canonical containment without exposing or parsing backend target
* keys. Both targets must come from this provider.
* @param parent - canonical directory target.
* @param child - canonical candidate target.
* @returns true when `child` is `parent` or a descendant of it.
*/
abstract contains(parent: FsTarget, child: FsTarget): boolean
/**
* Return target metadata, or `undefined` when the target does not exist.
* @param target - the resolved target to stat.

View File

@@ -19,13 +19,18 @@ import type {
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
/** A minimal in-memory fake implementing the eight provider primitives. */
/** A minimal in-memory fake implementing the provider primitives. */
class FakeFileSystem extends FileSystem {
files = new Map<string, string>()
override async resolve(path: string): Promise<FsTarget> {
return { targetKey: FsTargetKey(path), displayPath: path }
}
override processPath(target: FsTarget): string { return String(target.targetKey) }
override fileUrl(target: FsTarget): string { return `file:///${encodeURIComponent(String(target.targetKey))}` }
override contains(parent: FsTarget, child: FsTarget): boolean {
return child.targetKey === parent.targetKey || String(child.targetKey).startsWith(`${parent.targetKey}/`)
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
const content = this.files.get(target.targetKey)
if (content === undefined) return undefined
@@ -75,6 +80,7 @@ describe('FileSystem provider seam', () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
const fs = ctx.fs as FakeFileSystem
expect(fs.sandboxMode).toBeUndefined()
fs.files.set('a.txt', 'hi')
const target = await fs.resolve('a.txt')
expect((await fs.stat(target))?.type).toBe('file')

View File

@@ -148,6 +148,8 @@ class FakeHandle implements SubprocessHandle {
*/
class FakeSubprocess extends SubprocessService {
spawns: SubprocessSpawnSpec[] = []
override async resolveExecutable(command: string): Promise<string> { return command }
override spawnTerminal(): Promise<never> { throw new Error('search tools spawn pipes, never terminals') }
handles: FakeHandle[] = []
/** Arms the per-spawn script; a `{ reject }` return scripts a spawn-level failure. */
handler: (spec: SubprocessSpawnSpec) => ScriptedRun | { reject: Error } = () => runResult('')

View File

@@ -48,6 +48,11 @@ class FakeFs extends FileSystem {
override async resolve(path: string): Promise<FsTarget> {
return { targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
}
override processPath(target: FsTarget): string { return String(target.targetKey) }
override fileUrl(target: FsTarget): string { return `file://${target.targetKey}` }
override contains(parent: FsTarget, child: FsTarget): boolean {
return child.targetKey === parent.targetKey || String(child.targetKey).startsWith(`${parent.targetKey}/`)
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
this.throwIfArmed()
const content = this.files.get(target.targetKey)

View File

@@ -2013,6 +2013,31 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return subagentPromptError(request, error, signal)
}
},
// Deliberately no catalog, history, persistence, or parent Agent lookup:
// the core primitive alone authorizes the durable address against the
// live Activation, which is what keeps a live child interruptible while
// its parent Agent is offline. Absent targets are accepted no-ops there.
interrupt(request) {
const { parentSessionId, childSessionId } = request.payload
try {
ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId })
} catch (error: unknown) {
if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') {
return Promise.resolve(err(request, {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId },
}))
}
return Promise.resolve(err(request, {
code: 'internal',
message: 'subagent interrupt failed',
details: {},
}))
}
return Promise.resolve(ok(request, { accepted: true as const }))
},
},
workspace: {

View File

@@ -42,7 +42,8 @@ export type {
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry,
SubagentPromptReceipt, SubagentsApi,
} from './subagents.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'

View File

@@ -36,6 +36,7 @@ export interface RpcMethodMap {
'subagent.list': SubagentsApi['list']
'subagent.history': SubagentsApi['history']
'subagent.prompt': SubagentsApi['prompt']
'subagent.interrupt': SubagentsApi['interrupt']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']

View File

@@ -69,6 +69,18 @@ export const subagentPromptRequestSchema = z.object({
content: z.array(contentBlockSchema),
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
/** subagent.interrupt request payload. */
export const subagentInterruptRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
mode: z.literal('continuable'),
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.interrupt'>>>
/** subagent.interrupt response value. */
export const subagentInterruptValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.interrupt'>>>
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
/** subagent.prompt response value. */

View File

@@ -40,6 +40,11 @@ export interface SubagentPromptReceipt {
messageId: MessageId
}
/** Uniform acknowledgement that one interrupt request was admitted. */
export interface SubagentInterruptReceipt {
accepted: true
}
/** Durable parent/child address that selects subagent transport in the client. */
export type SubagentAddress =
& {
@@ -94,4 +99,17 @@ export interface SubagentsApi {
>,
signal: AbortSignal,
): Promise<RpcResponse<SubagentPromptReceipt>>
/**
* Interrupts a live continuable child's current turn under the address's
* durable direct-parent authority, without requiring a live parent Agent,
* consulting the catalog, or resuming anything. Fire-and-return: `accepted`
* acknowledges the admitted cancel signal, not target quiescence, so the
* child may remain visibly running briefly. Unclaimed queued follow-ups are
* kept and parked; an absent, idle, or already-completed target is likewise
* `accepted`.
*/
interrupt(
request: RpcRequest<Extract<SubagentAddress, { mode: 'continuable' }>>,
): Promise<RpcResponse<SubagentInterruptReceipt>>
}

View File

@@ -58,6 +58,7 @@ import {
import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
import {
subagentHistoryValueSchema,
subagentInterruptValueSchema,
subagentListValueSchema,
subagentPromptValueSchema,
} from '../api/subagents.schema.ts'
@@ -96,6 +97,7 @@ export interface IApiClient {
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.interrupt'>>>
}
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
@@ -171,6 +173,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'subagent.list': subagentListValueSchema,
'subagent.history': subagentHistoryValueSchema,
'subagent.prompt': subagentPromptValueSchema,
'subagent.interrupt': subagentInterruptValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
@@ -407,6 +410,7 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
history: (payload, signal) => this.callUnary('subagent.history', payload, signal),
prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal),
}
readonly host: IApiClient['host'] = {

View File

@@ -60,6 +60,7 @@ import {
import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
import {
subagentHistoryRequestSchema,
subagentInterruptRequestSchema,
subagentListRequestSchema,
subagentPromptRequestSchema,
} from '../api/subagents.schema.ts'
@@ -95,6 +96,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) },
'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },

View File

@@ -19,6 +19,7 @@ function bench(options: {
childStatus?: 'idle' | 'running'
entries?: object[]
followupError?: Error
interruptError?: Error
listError?: Error
/** Persistence forgets the child entirely (the vanished-mid-read race). */
storedChild?: false
@@ -53,6 +54,12 @@ function bench(options: {
) => options.followupError === undefined
? Promise.resolve('message-1')
: Promise.reject(options.followupError))
const interrupt = vi.fn((
_targetSessionId: SessionId,
_authority: { kind: 'user'; parentSessionId: SessionId },
) => {
if (options.interruptError !== undefined) throw options.interruptError
})
const childHeader = {
version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
} satisfies SessionHeader
@@ -72,7 +79,7 @@ function bench(options: {
})
const ctx = new Context()
ctx.provide('agents', { get: getAgent })
ctx.provide('subagents', { listChildren, followup })
ctx.provide('subagents', { listChildren, followup, interrupt })
ctx.provide('sessions', {
get: (id: SessionId) => options.liveChild === true && id === CHILD
? { id: CHILD, header: childHeader, events: childEvents }
@@ -90,7 +97,7 @@ function bench(options: {
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
})
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent }
}
describe('subagent gateway', () => {
@@ -309,4 +316,48 @@ describe('subagent gateway', () => {
error: { code: 'internal', message: 'subagent prompt failed' },
})
})
it('interrupts through the core primitive alone while the parent Agent is offline', async () => {
const { api, interrupt, getAgent, listChildren, inspect } = bench({ parentLive: false })
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.rpcId).toBe('subagent-rpc')
expect(response.result).toEqual({ ok: true, value: { accepted: true } })
expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT })
// No parent-registry, catalog, or history dependency: this is what keeps a
// live child interruptible after its parent Agent went offline.
expect(getAgent).not.toHaveBeenCalled()
expect(listChildren).not.toHaveBeenCalled()
expect(inspect).not.toHaveBeenCalled()
})
it('maps interrupt authorization rejection without touching other services', async () => {
const { api, listChildren } = bench({
interruptError: new SubagentError('secret lineage', 'UNAUTHORIZED'),
})
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.result).toEqual({
ok: false,
error: {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId: CHILD },
},
})
expect(listChildren).not.toHaveBeenCalled()
})
it('hides unexpected interrupt failures behind the internal code', async () => {
const { api } = bench({ interruptError: new Error('secret activation state') })
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.result).toEqual({
ok: false,
error: { code: 'internal', message: 'subagent interrupt failed', details: {} },
})
})
})

View File

@@ -63,6 +63,7 @@ function scriptedApi(overrides: {
list: r => ok(r, { entries: [], parentAvailable: false }),
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { messageId: 'message-1' as never }),
interrupt: r => ok(r, { accepted: true as const }),
...overrides.subagents,
},
host: {
@@ -248,6 +249,32 @@ describe('unary round trip', () => {
}
})
it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => {
const interrupt = vi.fn((r: RpcRequest<unknown>) => ok(r, { accepted: true as const }))
const api = scriptedApi({ subagents: { interrupt } })
const c = client(api)
const accepted = await c.subagents.interrupt({
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable',
})
expect(accepted.result).toEqual({ ok: true, value: { accepted: true } })
expect(interrupt).toHaveBeenCalledTimes(1)
// The wire schema owns the mode fence: a one-shot address never reaches the impl.
const oneShot = await c.subagents.interrupt({
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot',
} as never)
expect(oneShot.result.ok).toBe(false)
if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request')
const incomplete = await c.subagents.interrupt({
parentSessionId: sid('parent'), mode: 'continuable',
} as never)
expect(incomplete.result.ok).toBe(false)
if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request')
expect(interrupt).toHaveBeenCalledTimes(1)
})
it('rejects a method/path mismatch as bad-request', async () => {
const handler = toFetchHandler(scriptedApi())
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }

View File

@@ -128,6 +128,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: true, value: { messageId: 'message-1' as never } },
}
},
async interrupt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
},
host: {
async describe(request) {
@@ -433,6 +436,11 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
mode: 'continuable',
content: [],
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
expect((await c.subagents.interrupt({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'continuable',
})).result).toEqual({ ok: true, value: { accepted: true } })
})
it('keeps caller and connection aborts on command.execute', async () => {

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

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.

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 主机为何使用共享的文件系统/子进程执行环境,以及扩展名归属为何在同一运行时内互斥。

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: 661c27d3326adfc3408b33550a63fe3ebe183a39
README.zh.md: 83f0eaa1fb4caccc381a1623b791355b2f5c5b65

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.
- 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.
- 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 byte-bound the source while streaming it 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. Provider disposal aborts filesystem and protocol work, awaits workspace lookups that have not entered a queue, then drains every queue and server.
- 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 streamed text validation, but emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
## Configuration
@@ -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, regular-file streaming, 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. Containment is evaluated before the stream opens and does not promise stable-handle identity across concurrent path replacement. 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.
- **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.
- **A hard-killed harness orphans language servers** — `initialize.processId: null` removes server-side client-PID monitoring, so servers are cleaned only by graceful service disposal; a SIGKILL'd harness leaves them running until they exit on their own.

View File

@@ -2,18 +2,19 @@
[English](README.md) | 中文
`ctx.lsp` 的**通用本地 stdio 语言服务器后端**。一个插件实例接受一张命名服务器表,并逐配置项注册一个隔离的提供方。这是通用主机,而不是语言服务器目录或安装器:部署需要显式配置命令与映射,预设应放在 `cordis.yml` overlay 中。
`ctx.lsp` 的**通用 stdio 语言服务器后端**。一个插件实例接受一张命名服务器表,并逐配置项注册一个隔离的提供方。它通过 `ctx.fs` 读取,并通过 `ctx.subprocess` 启动,因此服务器与源文件始终位于已挂载的执行世界中。这是通用主机,而不是语言服务器目录或安装器:部署需要显式配置命令与映射,预设应放在 `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。
- 通过一条逐 Workspace、可中止的队列串行执行每个源读取打开查询关闭生命周期因此排队调用只会在轮到自身时读取当前源不同 Workspace 并行运行。
- 每个 `(server id, canonical workspace target)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其 dispose资源释放完成并在新进程上重试该查询一次。
- 每次查询都使用兼容性优先的**临时打开**序列:通过 `ctx.fs` 流式读取源文件,同时解析并限制其字节数;随后执行 `textDocument/didOpen`(版本 1、完整文本、所请求操作再执行位于 `finally` 中的 `textDocument/didClose`。写入 `didOpen` 失败或取消时,会在池复用该实例前将其终止。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。
- 通过一条逐 Workspace、可中止的队列串行执行每个源读取打开查询关闭生命周期因此排队调用只会在轮到自身时读取当前源不同 Workspace 并行运行。提供方 dispose 会中止文件系统与协议工作,等待尚未进入队列的 Workspace 查找完成,随后排空每条队列与每个服务器。
- 协议 shutdown 失败后,经由子进程 seam 终止服务器后代树POSIX 进程组信号Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。
- 通过子进程 host namespace 中的 Node 文件系统 API 读取源文件,绝不使用 `ctx.fs`,也不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略
- 通过 `ctx.subprocess` 解析服务器可执行文件、cwd、进程和协议流`initialize.processId``null`,因为另一台机器或 PID namespace 不得监视 harness 进程
- 使用 `ctx.fs` 提供的规范化包含关系、文件 URI 与流式文本验证,但不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。
## 配置
@@ -41,7 +42,7 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
## 安全边界
提供方信任其配置的服务器,不提供任何沙箱隔离。它通过 Node API 规范化并读取源文件,拒绝缺失、非普通文件、非 UTF-8、过大或规范路径位于规范 Workspace 外部的源文件(符号链接别名共享一个实例)。结果位置可以在外部,但外部路径不能成为查询源。因此,第一版要求可信的主机本地部署;受限、远程或虚拟 Workspace 需要另一个提供方
提供方信任其配置的服务器,不提供任何沙箱隔离。它把规范化身份、包含关系、普通文件流式读取、UTF-8 验证和文件 URI 编码委托给 `ctx.fs`;并在服务器启动前拒绝缺失、非普通文件、非 UTF-8、过大或规范化后位于 Workspace 外部的查询源。包含关系在打开流之前评估,不承诺在并发路径替换期间保持稳定句柄身份。结果位置可以在外部,但外部路径不能成为查询源。部署必须挂载描述同一执行世界的文件系统与进程管理提供方;分裂世界组合无效
## 模型体验
@@ -53,6 +54,7 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
## 已知限制与暂缓事项
- **仅限可信主机本地环境**:没有沙箱隔离,也没有私有 cachetemp 写入契约;支持不受信任 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 服务器兼容,并不表示支持其他语言
- **不提供隔离策略**本包package信任所配置的服务器不对其进程实施沙箱受限部署必须提供适当的进程文件系统提供方或使用同一执行世界的沙箱包装层
- **临时打开兼容性下限**:同步能力省略打开/关闭(或声明 `None`)的服务器不受支持,即使关闭文档查询能够工作;固定的 TypeScript e2e 只建立一项兼容性下限,不代表跨语言承诺
- **逐服务器Workspace 串行化延迟**:共享同一个服务器与 Workspace 的并行 agent智能体会在一个进程后排队长生命周期 Workspace 进程会占用内存直到 dispose。
- **被强制杀死的 harness 会遗留语言服务器**`initialize.processId: null` 取消了服务器侧的客户端 PID 监视,因此服务器只能由服务的优雅 dispose 清理;被 SIGKILL 的 harness 会让它们继续运行,直到自行退出。

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:^",

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. */

View File

@@ -1,154 +1,124 @@
/**
* 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 { Buffer } from 'node:buffer'
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).catch((error: unknown) => {
throwIfAborted(signal)
throw error
})
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 read one byte-bounded query source through `ctx.fs`.
* This layer owns the LSP-specific complete-document cap while the filesystem
* provider owns streaming, regular-file checks, and UTF-8 validation.
* @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)
const chunks: string[] = []
let bytes = 0
try {
throwIfAborted(signal)
const info = await handle.stat()
throwIfAborted(signal)
if (!info.isFile()) {
throw new Error(`source "${filePath}" is not a regular file`)
// XXX(lsp-source-replacement): Revisit stable-handle identity only if a real query observes
// replacement between canonical containment and the provider opening this stream.
const stream = await fs.streamText(target, signal)
for await (const chunk of stream) {
throwIfAborted(signal)
bytes += Buffer.byteLength(chunk)
if (bytes > maxDocumentBytes) break
chunks.push(chunk)
}
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)
} catch (error: unknown) {
throwIfAborted(signal)
return { canonicalPath, text }
} finally {
await handle.close()
throw new Error(`source "${filePath}" could not be read: ${messageOf(error)}`, { cause: error })
}
if (bytes > maxDocumentBytes) {
throw new Error(
`source "${filePath}" exceeds the ${maxDocumentBytes}-byte limit; reading stopped after ${bytes} bytes`,
)
}
throwIfAborted(signal)
return {
fileUrl: fs.fileUrl(target),
text: chunks.join(''),
}
}
/** 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)
}

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(),
@@ -110,27 +106,67 @@ export const Config: z<Config> = z.object({
servers: z.dict(LspLocalServerConfig).required(),
})
/** Propagate teardown failures only after every sibling has settled. */
function throwTeardownFailures(results: readonly PromiseSettledResult<void>[], message: string): void {
const failures: unknown[] = []
for (const result of results) {
if (result.status === 'rejected') failures.push(result.reason)
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, message)
}
/**
* 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')
const setupAbort = new AbortController()
const stopSetupCancellation = ctx.on('internal/plugin', (fiber) => {
// An async plugin callback must observe its own disposal before Cordis can
// run effect cleanup, because unload otherwise waits for this callback.
if (fiber === ctx.fiber && fiber.uid === null) {
setupAbort.abort(new Error('lsp-local setup disposed'))
}
})
// 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]) => {
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 providers = await (async () => {
const lookups = 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 executable = await ctx.subprocess.resolveExecutable(
resolved.command,
resolved.env,
setupAbort.signal,
)
setupAbort.signal.throwIfAborted()
return new LocalLspProvider(
providerId,
ctx.fs,
resolved,
executable,
spec => ctx.subprocess.spawn(spec),
)
})
try {
return await Promise.all(lookups)
} catch (error: unknown) {
setupAbort.abort(error)
await Promise.allSettled(lookups)
throw error
} finally {
stopSetupCancellation()
}
})()
ctx.effect(() => {
const disposers: Array<() => void> = []
@@ -143,7 +179,8 @@ export function apply(ctx: Context, config: Config): void {
return async () => {
// Remove every route before process teardown so no new query can enter a draining provider.
for (const dispose of disposers.reverse()) dispose()
await Promise.all(providers.map(provider => provider.disposeAll()))
const results = await Promise.allSettled(providers.map(provider => provider.disposeAll()))
throwTeardownFailures(results, 'lsp-local provider teardown failed')
}
}, 'lsp-local.registerProviders')
}
@@ -180,16 +217,19 @@ 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>>()
/** Workspace canonicalizations that have not entered a provider-owned queue yet. */
private readonly workspaceLookups = new Set<Promise<void>>()
private readonly lifetime = new AbortController()
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,
) {
@@ -210,43 +250,60 @@ class LocalLspProvider implements LspProvider {
if (signal?.aborted) throw abortError(signal)
}
/** Fuse caller cancellation with provider disposal for every filesystem and protocol await. */
private querySignal(signal?: AbortSignal): AbortSignal {
return signal === undefined
? this.lifetime.signal
: AbortSignal.any([signal, this.lifetime.signal])
}
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)
this.assertActive(signal)
return this.enqueue(workspace, signal, async () => {
this.assertActive(signal)
const querySignal = this.querySignal(signal)
const workspaceResult = canonicalizeWorkspace(this.fs, request.workspaceRoot, querySignal)
const workspaceLookup = workspaceResult.then(() => undefined, () => undefined)
this.workspaceLookups.add(workspaceLookup)
let workspace: HostWorkspace
try {
workspace = await workspaceResult
} finally {
this.workspaceLookups.delete(workspaceLookup)
}
this.assertActive(querySignal)
const workspaceKey = workspace.target.targetKey
return this.enqueue(workspaceKey, querySignal, async () => {
this.assertActive(querySignal)
// 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, querySignal)
// 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)
this.assertActive(querySignal)
let instance = this.instanceFor(workspaceKey, workspace)
try {
return await instance.query(request, source, signal)
return await instance.query(request, source, querySignal)
} catch (error) {
// A selected child can have died while idle or fail during the next write. Queries are
// read-only, so replace that transport once and retry transparently.
if (!instance.isTransportFailure(error)) throw error
await instance.dispose()
this.evictIfCurrent(workspace, instance)
this.assertActive(signal)
instance = this.instanceFor(workspace)
return await instance.query(request, source, signal)
this.evictIfCurrent(workspaceKey, instance)
this.assertActive(querySignal)
instance = this.instanceFor(workspaceKey, workspace)
return await instance.query(request, source, querySignal)
} 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 +317,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,
@@ -294,51 +352,18 @@ class LocalLspProvider implements LspProvider {
/** Dispose every live instance and block further queries. */
async disposeAll(): Promise<void> {
this.disposed = true
this.lifetime.abort(new LspError('lsp-local provider is disposed', 'LSP_DISPOSED'))
const live = [...this.instances.values()]
const draining = [...this.queues.values()]
const resolving = [...this.workspaceLookups]
this.instances.clear()
await Promise.all([
const results = await Promise.allSettled([
...live.map(instance => instance.dispose()),
...draining,
...resolving,
])
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
this.workspaceLookups.clear()
throwTeardownFailures(results, 'lsp-local instance teardown failed')
}
}

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. */
@@ -241,10 +244,9 @@ export class LspInstance {
if (operation === 'hover') {
return { kind: 'hover', hover: normalizeHover(payload) }
}
// `spec.cwd` is the canonical workspace realpath (the provider canonicalizes before spawning),
// and every `file:` location URI is relative to it — so it is the root a caller must relativize
// display paths against, not the request's possibly-symlinked workspaceRoot.
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceRoot: this.spec.cwd }
// The filesystem provider owns URI syntax for the execution platform, which may differ from the
// harness host. Preserve that coordinate through rendering instead of reparsing `spec.cwd` there.
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceUri: this.spec.workspaceUri }
}
private answerServerRequest(method: string, params: unknown): Promise<unknown> {

View File

@@ -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: {

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,125 @@ 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('wraps a provider failure while resolving the workspace', async () => {
fs.resolve = async () => { throw 'raw workspace resolve failure' }
await expect(canonicalizeWorkspace(fs, ws))
.rejects.toThrow(`workspace root "${ws}" cannot be resolved: raw workspace resolve failure`)
})
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/)
})
it('normalizes workspace metadata cancellation and preserves other provider failures', async () => {
const providerFailure = new Error('workspace metadata failed')
fs.stat = async () => { throw providerFailure }
await expect(canonicalizeWorkspace(fs, ws)).rejects.toBe(providerFailure)
const controller = new AbortController()
fs.stat = async () => {
controller.abort(new Error('workspace metadata cancelled'))
throw providerFailure
}
await expect(canonicalizeWorkspace(fs, ws, controller.signal))
.rejects.toThrow('workspace metadata cancelled')
})
})
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 +141,44 @@ 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 () => {
it('rejects an oversized source and reports the observed lower bound', 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.toMatchObject({
message: 'source "big.ts" exceeds the 10-byte limit; reading stopped after 100 bytes',
})
})
it('counts the complete UTF-8 byte length at the configured boundary', async () => {
await writeFile(join(ws, 'multibyte.ts'), '€abc')
await expect(readSource('multibyte.ts', 6)).resolves.toMatchObject({ text: '€abc' })
await expect(readSource('multibyte.ts', 5)).rejects.toThrow(/5-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 = "<22>"\n')
const source = await readHostSource('repl.ts', ws, BIG)
const source = await readSource('repl.ts')
expect(source.text).toBe('const s = "<22>"\n')
})
})

View File

@@ -1,8 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
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 +18,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 +27,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 +48,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 +68,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 +83,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,
@@ -102,17 +118,17 @@ describe('LspInstance server-request handling', () => {
it('accepts a lifecycle client/registerCapability request', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
})
it('rejects a workspace/applyEdit request but keeps serving', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
})
it('rejects an unknown server request but keeps serving', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
})
})
@@ -248,7 +264,7 @@ describe('LspInstance query and abort', () => {
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
kind: 'locations',
locations: [],
resolvedWorkspaceRoot: ws,
resolvedWorkspaceUri: pathToFileURL(ws).href,
})
expect(instance.dead).toBe(true)
})
@@ -331,14 +347,22 @@ describe('LspInstance disposal', () => {
function processAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false
throw error
}
if (process.platform !== 'linux') return true
try {
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
const state = stat.slice(stat.lastIndexOf(')') + 2).split(/\s+/, 1)[0]
return !/^[ZXx]$/.test(state ?? '')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
throw error
}
}
/** Wait until a process id disappears so temporary-workspace cleanup cannot race handle release. */
/** Wait until a process can no longer execute so temporary-workspace cleanup cannot race handle release. */
async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise<void> {
const started = Date.now()
while (processAlive(pid)) {

View File

@@ -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' }) }),
@@ -99,7 +102,7 @@ describe('lsp-local end to end over a fake server', () => {
expect(result).toEqual<LspQueryResult>({
kind: 'locations',
locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }],
resolvedWorkspaceRoot: ws,
resolvedWorkspaceUri: pathToFileURL(ws).href,
})
await ctx.fiber.dispose()
})
@@ -130,7 +133,7 @@ describe('lsp-local end to end over a fake server', () => {
it('returns an empty locations result for a null definition', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
await ctx.fiber.dispose()
})
@@ -170,7 +173,7 @@ describe('lsp-local end to end over a fake server', () => {
it('accepts openClose options sync', async () => {
const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
await ctx.fiber.dispose()
})
@@ -279,8 +282,9 @@ describe('lsp-local end to end over a fake server', () => {
readonly instances: ReadonlyMap<string, { readonly dead: boolean }>
}).instances
const instance = [...instances.values()][0]
if (instance === undefined) throw new Error('expected one pooled LSP instance')
await waitFor(async () => instance.dead)
// The query's finally may already have observed the exit and evicted the dead slot. When the
// slot remains, synchronize with its close before proving the next query replaces it.
if (instance !== undefined) await waitFor(async () => instance.dead)
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
await ctx.fiber.dispose()
})
@@ -294,7 +298,128 @@ describe('lsp-local end to end over a fake server', () => {
controller.abort(new Error('mid-read cancel'))
await expect(pending).rejects.toThrow(/mid-read cancel/)
// A subsequent live query still works, proving no half-created instance poisoned the pool.
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
await ctx.fiber.dispose()
})
it('aborts and awaits a workspace lookup when the provider is disposed', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const fs = ctx.fs
const resolve = fs.resolve.bind(fs)
const started = Promise.withResolvers<AbortSignal>()
const release = Promise.withResolvers<undefined>()
vi.spyOn(fs, 'resolve').mockImplementation(async (path, options) => {
if (path !== ws) return await resolve(path, options)
const signal = options?.signal
if (signal === undefined) throw new Error('workspace lookup missing provider lifetime signal')
started.resolve(signal)
return await rejectWhenAborted(signal, release.promise)
})
const pending = ctx.lsp.query(query('goToDefinition'))
const signal = await started.promise
let disposed = false
const disposing = ctx.fiber.dispose().then(() => { disposed = true })
await new Promise<void>(resolve => setImmediate(resolve))
expect(signal.aborted).toBe(true)
expect(disposed).toBe(false)
release.resolve(undefined)
await expect(pending).rejects.toThrow('provider is disposed')
await expect(disposing).resolves.toBeUndefined()
})
it('aborts a queued source stream when the provider is disposed', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const fs = ctx.fs
const started = Promise.withResolvers<AbortSignal>()
vi.spyOn(fs, 'streamText').mockImplementation(async (_target, signal) => {
if (signal === undefined) throw new Error('source read missing provider lifetime signal')
started.resolve(signal)
return (async function* () {
await rejectWhenAborted(signal)
yield ''
})()
})
const pending = ctx.lsp.query(query('goToDefinition'))
const signal = await started.promise
const disposing = ctx.fiber.dispose()
await expect(pending).rejects.toThrow('provider is disposed')
await expect(disposing).resolves.toBeUndefined()
expect(signal.aborted).toBe(true)
})
it('waits for every owned teardown before aggregating instance failures', async () => {
let provider: LspProvider | undefined
const ctx = await mount({ LSP_FAKE_DEF: 'null' }, {}, (registered) => { provider = registered })
if (provider === undefined) throw new Error('expected lsp-local to register a provider')
const internals = provider as unknown as {
readonly instances: Map<string, { dispose(): Promise<void> }>
readonly queues: Map<string, Promise<void>>
readonly workspaceLookups: Set<Promise<void>>
disposeAll(): Promise<void>
}
const firstFailure = new Error('first instance cleanup failed')
const secondFailure = new Error('second instance cleanup failed')
const release = Promise.withResolvers<undefined>()
internals.instances.set('first', { dispose: async () => { throw firstFailure } })
internals.instances.set('second', { dispose: async () => { throw secondFailure } })
internals.queues.set('pending', release.promise)
internals.workspaceLookups.add(Promise.resolve())
let settled = false
const disposing = internals.disposeAll().finally(() => { settled = true })
await new Promise<void>(resolve => setImmediate(resolve))
expect(settled).toBe(false)
release.resolve(undefined)
await expect(disposing).rejects.toMatchObject({
errors: [firstFailure, secondFailure],
message: 'lsp-local instance teardown failed',
})
expect(internals.instances.size).toBe(0)
expect(internals.queues.size).toBe(0)
expect(internals.workspaceLookups.size).toBe(0)
await ctx.fiber.dispose()
})
it('waits for every provider before reporting plugin teardown failure', async () => {
const ctx = new Context()
const disposalErrors: unknown[] = []
ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
const providers: LspProvider[] = []
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
const registrationSpy = vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => {
providers.push(provider)
return register(provider)
})
const fiber = await ctx.plugin(LspLocal, {
servers: {
first: fakeServer(),
second: fakeServer({}, { extensionToLanguage: { '.js': 'javascript' } }),
},
})
registrationSpy.mockRestore()
expect(providers).toHaveLength(2)
const failure = new Error('provider cleanup failed')
const release = Promise.withResolvers<undefined>()
const first = providers[0] as LspProvider & { disposeAll(): Promise<void> }
const second = providers[1] as LspProvider & { disposeAll(): Promise<void> }
first.disposeAll = async () => { throw failure }
second.disposeAll = async () => { await release.promise }
let disposed = false
const disposing = fiber.dispose().then(() => { disposed = true })
await new Promise<void>(resolve => setImmediate(resolve))
expect(disposed).toBe(false)
expect(disposalErrors).toEqual([])
release.resolve(undefined)
await disposing
expect(disposalErrors).toEqual([failure])
await ctx.fiber.dispose()
})
@@ -322,6 +447,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: {
@@ -354,3 +480,16 @@ async function waitFor(condition: () => Promise<boolean>, timeoutMs = 3000): Pro
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}
/** Hold one fake provider operation until cancellation, optionally behind a cleanup gate. */
function rejectWhenAborted<T>(signal: AbortSignal, release: Promise<unknown> = Promise.resolve()): Promise<T> {
return new Promise((_resolve, reject) => {
const onAbort = (): void => {
void release.then(() => {
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
})
}
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) onAbort()
})
}

View File

@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
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' } },
@@ -183,10 +195,90 @@ describe('lsp-local provider resolution', () => {
await ctx.fiber.dispose()
})
it('waits for aborted sibling executable lookups before setup rejects', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
const slowStarted = Promise.withResolvers<undefined>()
const slowAborted = Promise.withResolvers<undefined>()
const releaseCleanup = Promise.withResolvers<undefined>()
vi.spyOn(ctx.subprocess, 'resolveExecutable').mockImplementation(async (command, _env, signal) => {
if (signal === undefined) throw new Error('missing setup signal')
if (command === 'slow-lsp') {
return await new Promise<string>((_resolve, reject) => {
const onAbort = (): void => {
slowAborted.resolve(undefined)
void releaseCleanup.promise.then(() => {
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
})
}
signal.addEventListener('abort', onAbort, { once: true })
slowStarted.resolve(undefined)
if (signal.aborted) onAbort()
})
}
await slowStarted.promise
throw new Error('lookup failed')
})
const loading = ctx.plugin(LspLocal, {
servers: {
slow: { command: 'slow-lsp', extensionToLanguage: { '.ts': 'typescript' } },
failing: { command: 'failing-lsp', extensionToLanguage: { '.js': 'javascript' } },
},
})
await slowAborted.promise
let settled = false
void loading.then(() => { settled = true }, () => { settled = true })
await new Promise<void>((resolve) => { setImmediate(resolve) })
expect(settled).toBe(false)
releaseCleanup.resolve(undefined)
await expect(loading).rejects.toThrow('lookup failed')
await ctx.fiber.dispose()
})
it('aborts executable resolution when disposed during setup', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
const subprocess = ctx.subprocess
const lookupStarted = Promise.withResolvers<AbortSignal>()
vi.spyOn(subprocess, 'resolveExecutable').mockImplementation(async (_command, _env, signal) => {
if (signal === undefined) throw new Error('missing setup signal')
lookupStarted.resolve(signal)
return await new Promise<string>((_resolve, reject) => {
const onAbort = (): void => {
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
}
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) onAbort()
})
})
const loading = ctx.plugin(LspLocal, config('pending', {
command: 'pending-lsp',
extensionToLanguage: { '.ts': 'typescript' },
}))
const signal = await lookupStarted.promise
const unrelated = await ctx.plugin(() => {})
await unrelated.dispose()
expect(signal.aborted).toBe(false)
const disposing = loading.dispose()
await expect(loading).rejects.toThrow('lsp-local setup disposed')
await expect(disposing).resolves.toBeUndefined()
expect(signal.aborted).toBe(true)
await ctx.fiber.dispose()
})
it('rolls back earlier registrations when a later server conflicts', async () => {
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' } },

View File

@@ -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: {

Some files were not shown because too many files have changed in this diff Show More