Merge remote-tracking branch 'origin/master' into worktree/fix-pr1145-revised-guard-20260808
# Conflicts: # packages/ui/tool-ask-user/README.i18n.yaml # packages/ui/user-interaction/README.i18n.yaml
This commit is contained in:
@@ -19,7 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
|
||||
|
||||
Naming notes:
|
||||
|
||||
- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)).
|
||||
- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `support/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)).
|
||||
- `src/types.ts` contains only types — no runtime code.
|
||||
- Tests live at package level under `tests/`, not `src/__tests__/`.
|
||||
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code.
|
||||
|
||||
@@ -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: c8984bfa652a0ad7e12bc1f2001618df452bc863
|
||||
README.zh.md: 2a59a63d22cdb2e5c0de53cd1dcfce1296882c01
|
||||
README.md: 229feae568ba6e40a9c633696097eff46fd5bc95
|
||||
README.zh.md: b84aef020a7e3edf305df709d399fbc7b093b6a3
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
|
||||
Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
|
||||
|
||||
## Hierarchy
|
||||
|
||||
@@ -11,8 +11,10 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable surface |
|
||||
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
|
||||
| [`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 |
|
||||
| [`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 |
|
||||
@@ -26,14 +28,15 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Script seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
|
||||
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection/model-written temporary Plugins and restricted repository Plugin loading | Product — stable surface |
|
||||
| [`timeout/`](timeout/README.md) | Tool-call `tools/execute` deadline enforcement | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene advisory repeat-call reminders | Product — stable surface |
|
||||
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection, temporary Plugins, restricted repository Plugin loading | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
|
||||
@@ -46,11 +49,11 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | TUI and JSON-RPC integrations, approval/interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | JSON-RPC integration, approval/interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
|
||||
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
|
||||
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有包都使用 `@deepseek-ai/dsh-*` scope。每个包都是 Cordis `Service` 子类或函数插件;所有贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
|
||||
所有包都使用 `@deepseek-ai/dsh-*` scope。Cordis `Service` 子类和函数插件的贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
|
||||
|
||||
## 层级结构
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
| 组 | 职责 | 发布预期 |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 |
|
||||
| [`api/`](api/README.md) | Remote BFF 装配与 TypeRT RPC Gateway | 产品:稳定表面 |
|
||||
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 |
|
||||
| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 |
|
||||
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定表面 |
|
||||
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定表面 |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 |
|
||||
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 |
|
||||
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
|
||||
@@ -26,14 +28,15 @@
|
||||
| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定表面 |
|
||||
| [`subagent/`](subagent/README.md) | Subagent 能力系列:提供方注册表 seam 和面向模型的委托工具 | 产品:稳定表面 |
|
||||
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定表面 |
|
||||
| [`workflow/`](workflow/README.md) | 工作流能力系列:脚本引擎 seam、worker 线程引擎、面向模型的 `workflow` 与新 agent `ralph` 工具 | 产品:稳定表面 |
|
||||
| [`workflow/`](workflow/README.md) | 脚本 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定表面 |
|
||||
| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定表面 |
|
||||
| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
|
||||
| [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 |
|
||||
| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检/模型编写的临时 Plugin,以及受限 repository Plugin 加载 | 产品:稳定表面 |
|
||||
| [`timeout/`](timeout/README.md) | 工具调用 `tools/execute` 截止时间强制执行 | 产品:稳定表面 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生建议性重复调用提醒 | 产品:稳定表面 |
|
||||
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定表面 |
|
||||
| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检、临时 Plugin、受限 repository Plugin 加载 | 产品:稳定表面 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 |
|
||||
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
|
||||
| [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 |
|
||||
@@ -46,11 +49,11 @@
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
|
||||
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
|
||||
| [`ui/`](ui/README.md) | TUI 与 JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
|
||||
| [`ui/`](ui/README.md) | JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定表面 |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 |
|
||||
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
|
||||
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |
|
||||
|
||||
|
||||
@@ -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/acp/README.md
|
||||
README.md: 326615210e5cfc39004fc5ab7462623089ac4126
|
||||
README.zh.md: 8679f2428a9e82a81de69d7d1413132a946fcafa
|
||||
README.md: 3ba247598f29f2244456061fe7f9a3282086148f
|
||||
README.zh.md: c13bc05b12fff2ef97b4d547936aa14a6556430a
|
||||
|
||||
@@ -6,6 +6,6 @@ The ACP group exposes harness agents to programmatic clients. It is an interoper
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | Automation-only ACP server: fresh text sessions, committed assistant output, machine permission policy, cancellation, and connection-owned teardown. |
|
||||
| [`acp/`](acp/README.md) | Automation-only ACP server. |
|
||||
|
||||
The matching out-of-process subagent client remains in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface; arbitrary ACP clients may drive the same server contract.
|
||||
|
||||
@@ -6,6 +6,6 @@ ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接负责的清理。 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器。 |
|
||||
|
||||
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器。
|
||||
|
||||
@@ -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/acp/acp/README.md
|
||||
README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88
|
||||
README.zh.md: c1e7d045b55119b62ad44d81071188e1ed6110d5
|
||||
README.md: 9cc4a5e271c7200f6ad8799a4b8fa9e64b2ca893
|
||||
README.zh.md: 82aa5df2c7d87312d4b619a09582cc0c2d884398
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text prompts, collect committed assistant text, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md).
|
||||
|
||||
This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the web and TUI modules.
|
||||
This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the Web host and client modules.
|
||||
|
||||
## Plugin
|
||||
|
||||
@@ -24,7 +24,7 @@ Both fields are optional so another agent/request listener may supply the target
|
||||
| `initialize` | Negotiates the supported version and advertises baseline-only prompts (no image, audio, or embedded-context capability). No session, editor, terminal, filesystem, or MCP capability is advertised. |
|
||||
| `authenticate` | No-op because the server advertises no authentication methods. |
|
||||
| `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. |
|
||||
| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and settles from that request's owning durable `turn/end`. |
|
||||
| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and waits for the whole agent to become idle. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. |
|
||||
| `session/cancel` | Cancels only the addressed agent and settles its pending prompt as `cancelled`; unknown ids are no-ops. |
|
||||
| `session/update` | Emits one `agent_message_chunk` per non-empty text block in a committed `assistant/message`. Raw deltas and non-message events are omitted. |
|
||||
| `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. |
|
||||
@@ -35,7 +35,9 @@ Committed-message output intentionally trades token-by-token latency for a clean
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then disposes all owned agent handles in parallel and awaits their loop/session cleanup. An ACP-only plugin reload therefore leaves no orphan agent.
|
||||
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
|
||||
|
||||
ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit turn endings therefore do not become prompt-level ACP stop reasons (they settle as `end_turn`); a model error on the correlated turn rejects the prompt immediately.
|
||||
|
||||
## Running
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
|
||||
|
||||
此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 和 TUI 模块。
|
||||
此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理(reasoning)、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 宿主和客户端模块。
|
||||
|
||||
## 插件
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
|
||||
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
|
||||
| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
|
||||
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并根据该请求所属的持久 `turn/end` 结算。 |
|
||||
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入 idle。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(turnless 槽位)时报告 `cancelled`。 |
|
||||
| `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
|
||||
| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
|
||||
| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
|
||||
@@ -35,7 +35,9 @@
|
||||
|
||||
## 生命周期
|
||||
|
||||
客户端断开连接与 Cordis 的 dispose(资源释放)共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose,并等待它们的循环/会话清理完成。因此,单独重载 ACP 插件不会遗留孤儿 agent。
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
|
||||
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入 idle 前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即 reject 提示词。
|
||||
|
||||
## 运行
|
||||
|
||||
@@ -47,7 +49,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。
|
||||
`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和会话 id 绝不进入模型请求。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -65,11 +67,11 @@
|
||||
|
||||
#### Token 影响
|
||||
|
||||
只有该工具的结果会贡献 token。
|
||||
只有所属工具的结果会贡献 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
随该工具的结果仅追加。
|
||||
仅通过所属工具的结果追加。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -17,13 +17,17 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
return 'end_turn'
|
||||
case 'max-tokens':
|
||||
return 'max_tokens'
|
||||
// `cancelled` is reserved for explicit client cancellation (`session/cancel`)
|
||||
// and disposal, both settled out of band; a turn aborted by a hook or
|
||||
// another owner is ordinary quiescence and reports `end_turn`.
|
||||
case 'aborted':
|
||||
case 'disposed':
|
||||
return 'end_turn'
|
||||
case 'interrupted':
|
||||
return 'cancelled'
|
||||
case 'blocked':
|
||||
case 'error':
|
||||
return 'end_turn'
|
||||
// TurnEndReason is merge-extensible; future variants still need a legal wire value.
|
||||
/* v8 ignore next 2 -- TurnEndReason is closed and every member is handled above */
|
||||
default:
|
||||
return 'end_turn'
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import Schema from 'schemastery'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
AgentSideConnection,
|
||||
ndJsonStream,
|
||||
@@ -43,6 +43,19 @@ export const name = 'acp'
|
||||
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
|
||||
export const inject = ['agents']
|
||||
|
||||
/**
|
||||
* The single continuable-subagent teardown the bridge needs. Declared
|
||||
* structurally so this package does not depend on the subagent seam for one
|
||||
* shutdown hook; an absent service means nothing continuable was materialized.
|
||||
*/
|
||||
interface ContinuableDrain {
|
||||
/**
|
||||
* Close admission below exact host-owned parents, then dispose only their
|
||||
* continuable descendants child-first.
|
||||
*/
|
||||
drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
|
||||
}
|
||||
|
||||
/** Preserve invalid-parameter detail in the SDK wire error message. */
|
||||
function invalidParams(detail: string): RequestError {
|
||||
return RequestError.invalidParams(undefined, detail)
|
||||
@@ -77,13 +90,10 @@ interface SessionRecord {
|
||||
inflight: {
|
||||
resolve: (reason: StopReason) => void
|
||||
reject: (error: Error) => void
|
||||
messageId: string
|
||||
turn: number | undefined
|
||||
/**
|
||||
* A failed turn's terminal reason, held until quiescence: a retry action
|
||||
* closes the failed turn and opens a successor that adopts the prompt, so
|
||||
* rejecting at `turn/end` would race the recovery.
|
||||
*/
|
||||
pendingError: Extract<TurnEndReason, { kind: 'error' }> | undefined
|
||||
/** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */
|
||||
endReason: TurnEndReason | undefined
|
||||
} | undefined
|
||||
}
|
||||
|
||||
@@ -136,7 +146,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
inflight: NonNullable<SessionRecord['inflight']>,
|
||||
reason: Extract<TurnEndReason, { kind: 'error' }>,
|
||||
): void => {
|
||||
inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`))
|
||||
inflight.reject(internalError(`turn failed: ${reason.error.message}`))
|
||||
}
|
||||
|
||||
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
|
||||
@@ -161,30 +171,33 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
}
|
||||
} finally {
|
||||
const inflight = record.inflight
|
||||
if (inflight !== undefined && event.type === 'turn/start') {
|
||||
if (inflight.turn === undefined && event.data.trigger.kind === 'message'
|
||||
&& event.data.trigger.source.kind === 'user') {
|
||||
inflight.turn = event.data.turn
|
||||
} else if (inflight.pendingError !== undefined && event.data.trigger.kind === 'retry') {
|
||||
// A recovery policy opened a retry turn on the failed history: the
|
||||
// prompt rides it instead of rejecting on the failed turn's end.
|
||||
inflight.turn = event.data.turn
|
||||
inflight.pendingError = undefined
|
||||
}
|
||||
} else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
|
||||
if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
|
||||
if (event.data.reason.kind === 'error') {
|
||||
// Hold the rejection: request recovery may adopt the prompt with a
|
||||
// successor turn; quiescence without one delivers this error.
|
||||
inflight.turn = undefined
|
||||
inflight.pendingError = event.data.reason
|
||||
} else {
|
||||
// Model failures surface immediately as prompt errors; ordinary
|
||||
// endings wait for whole-agent idle below.
|
||||
record.inflight = undefined
|
||||
inflight.resolve(turnEndToStopReason(event.data.reason))
|
||||
rejectFromError(inflight, event.data.reason)
|
||||
} else {
|
||||
inflight.endReason = event.data.reason
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
|
||||
const record = ownedRecord(agent)
|
||||
const inflight = record?.inflight
|
||||
if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn
|
||||
})
|
||||
|
||||
ctx.on('agent/error', ({ agent, turn, error }) => {
|
||||
const record = ownedRecord(agent)
|
||||
const inflight = record?.inflight
|
||||
if (record === undefined || inflight === undefined || inflight.turn === turn) return
|
||||
record.inflight = undefined
|
||||
inflight.reject(internalError(`turn failed: ${errorChain(error)}`))
|
||||
})
|
||||
|
||||
// Permission requests are a machine policy channel for ACP clients such as
|
||||
// dsh-subagent-acp. The bridge offers one-shot choices only and never infers a
|
||||
// durable grant from an unknown client response.
|
||||
@@ -265,17 +278,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
if (ctx.agents.get(record.agent.id) !== record.agent) {
|
||||
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
|
||||
}
|
||||
const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
const stopReason = await new Promise<StopReason>((resolve, reject) => {
|
||||
// Arm the slot before followup() so a listener-driven synchronous
|
||||
// turn cannot slip past correlation; a synchronous followup()
|
||||
// failure (invalid input) must free the slot again or the session
|
||||
// would reject every later prompt as already in flight.
|
||||
const inflight: NonNullable<SessionRecord['inflight']> = {
|
||||
resolve, reject, turn: undefined, pendingError: undefined,
|
||||
resolve, reject, messageId: message.id, turn: undefined, endReason: undefined,
|
||||
}
|
||||
record.inflight = inflight
|
||||
try {
|
||||
record.agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
|
||||
record.agent.followup(message)
|
||||
// The machine's send() contains listener failures and accepts
|
||||
// any typed input; this guards a future synchronous throw so the
|
||||
// slot cannot wedge.
|
||||
@@ -286,18 +300,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
throw internalError(`prompt was not queued: ${detail}`)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
// Admission is pre-turn and retries outlive their failed turn, so a
|
||||
// turnless slot settles only at quiescence: a held failure rejects
|
||||
// (no retry adopted the prompt); no turn at all means admission
|
||||
// discarded the prompt — report cancelled.
|
||||
// Settlement waits for whole-agent idle: a correlated turn/end arms
|
||||
// `endReason`, while a turnless slot (admission discarded the
|
||||
// prompt) stays cancelled. Other producers may run further turns
|
||||
// before quiescence; the prompt settles only when the agent stops.
|
||||
void record.agent.whenIdle().then(() => {
|
||||
if (record.inflight !== inflight || inflight.turn !== undefined) return
|
||||
if (record.inflight !== inflight) return
|
||||
record.inflight = undefined
|
||||
if (inflight.pendingError !== undefined) {
|
||||
rejectFromError(inflight, inflight.pendingError)
|
||||
return
|
||||
const end = inflight.endReason
|
||||
if (end === undefined) {
|
||||
inflight.resolve('cancelled')
|
||||
} else {
|
||||
// Token-limit and other non-terminal endings are not prompt-level
|
||||
// stop reasons (see README); only normal quiescence reports end_turn.
|
||||
inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end))
|
||||
}
|
||||
inflight.resolve('cancelled')
|
||||
})
|
||||
})
|
||||
return { stopReason }
|
||||
@@ -326,10 +343,45 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
closed = true
|
||||
const records = [...sessions.values()]
|
||||
sessions.clear()
|
||||
quiescing = Promise.all(records.map(async (record) => {
|
||||
// Stop the bridge's own work before any await: a descendant drain can block
|
||||
// on persistence or scoped cleanup, and the top-level agents must not keep
|
||||
// running model and tool calls for its whole duration.
|
||||
for (const record of records) {
|
||||
record.agent.cancel({ kind: 'user' })
|
||||
settlePrompt(record, 'cancelled')
|
||||
await record.dispose()
|
||||
})).then(() => {})
|
||||
}
|
||||
quiescing = (async () => {
|
||||
// Continuable subagents outlive the turn that started them, and their
|
||||
// Activations own descendant teardown. Drain only these sessions' forests
|
||||
// child-first BEFORE disposing the top-level agents, so no descendant is
|
||||
// left holding a runtime its owner already released and another frontend
|
||||
// sharing this Context remains live.
|
||||
// Read the one teardown method structurally: the bridge needs no other
|
||||
// part of the subagent seam, so it does not depend on that package.
|
||||
const subagents = ctx.get('subagents') as ContinuableDrain | undefined
|
||||
if (subagents !== undefined) {
|
||||
try {
|
||||
await subagents.drainContinuableDescendants(records.map(record => record.agent))
|
||||
} catch (error: unknown) {
|
||||
logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
const disposals = await Promise.allSettled(records.map(record => record.dispose()))
|
||||
const failures: unknown[] = []
|
||||
for (const result of disposals) {
|
||||
if (result.status === 'rejected') failures.push(result.reason as unknown)
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
// The production consumer logs this AggregateError through `String`,
|
||||
// which renders only its message. Embed every per-session diagnostic,
|
||||
// including nested causes and aggregate members, in that message.
|
||||
const detail = failures.map(failure => errorChain(failure)).join('; ')
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
`ACP agent teardown failed for ${failures.length} session(s): ${detail}`,
|
||||
)
|
||||
}
|
||||
})()
|
||||
return quiescing
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('ACP machine permission policy', () => {
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
return { agent, toolName: 'bash', callId: CallId('call-9'), ...overrides }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from '../src/codec.ts'
|
||||
import { acpPromptToText, turnEndToStopReason } from '../src/codec.ts'
|
||||
|
||||
describe('ACP automation codec', () => {
|
||||
it('maps every known turn outcome to a legal stop reason', () => {
|
||||
const cases: [TurnEndReason, string][] = [
|
||||
[{ kind: 'completed' }, 'end_turn'],
|
||||
[{ kind: 'max-tokens' }, 'max_tokens'],
|
||||
[{ kind: 'aborted' }, 'cancelled'],
|
||||
[{ kind: 'disposed' }, 'cancelled'],
|
||||
[{ kind: 'interrupted' }, 'cancelled'],
|
||||
[{ kind: 'error', step: 1, message: 'boom' }, 'end_turn'],
|
||||
]
|
||||
for (const [reason, expected] of cases) expect(turnEndToStopReason(reason)).toBe(expected)
|
||||
describe('ACP codec', () => {
|
||||
it.each([
|
||||
[{ kind: 'completed' }, 'end_turn'],
|
||||
[{ kind: 'max-tokens' }, 'max_tokens'],
|
||||
[{ kind: 'aborted', reason: { kind: 'user' } }, 'end_turn'],
|
||||
[{ kind: 'interrupted' }, 'cancelled'],
|
||||
[{ kind: 'blocked' }, 'end_turn'],
|
||||
[{ kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } }, 'end_turn'],
|
||||
] satisfies Array<[TurnEndReason, string]>)('maps %o to %s', (reason, expected) => {
|
||||
expect(turnEndToStopReason(reason)).toBe(expected)
|
||||
})
|
||||
|
||||
it('uses a legal fallback for merge-extensible future outcomes', () => {
|
||||
expect(turnEndToStopReason({ kind: 'future' } as unknown as TurnEndReason)).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('flattens baseline blocks and rejects everything richer', () => {
|
||||
expect(acpPromptToText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab')
|
||||
expect(acpPromptToText([
|
||||
{ type: 'text', text: 'see' },
|
||||
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
|
||||
])).toBe('see\n[resource_link name="x" uri="file:///x"]\n')
|
||||
expect(acpPromptToText([{ type: 'image', data: '', mimeType: 'image/png' }])).toBe('')
|
||||
expect(promptHasUnsupportedContent([
|
||||
{ type: 'text', text: 'ok' },
|
||||
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
|
||||
])).toBe(false)
|
||||
expect(promptHasUnsupportedContent([
|
||||
{ type: 'image', data: '', mimeType: 'image/png' },
|
||||
])).toBe(true)
|
||||
it('drops unsupported blocks from baseline text conversion', () => {
|
||||
expect(acpPromptToText([{
|
||||
type: 'image',
|
||||
data: '',
|
||||
mimeType: 'image/png',
|
||||
}])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
|
||||
|
||||
@@ -25,6 +26,137 @@ describe('ACP connection ownership', () => {
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drains continuable subagents before disposing its own sessions', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const order: string[] = []
|
||||
let drainedParents: readonly Agent[] = []
|
||||
// A continuable Activation outlives the turn that started it, so the bridge
|
||||
// must release that forest before the agents whose runtime it depends on.
|
||||
harness.ctx.provide('subagents', {
|
||||
drainContinuableDescendants: (parents: readonly Agent[]) => {
|
||||
drainedParents = parents
|
||||
order.push('drained')
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as never)
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
harness.ctx.on('agent/disposed', () => { order.push('agent disposed') })
|
||||
|
||||
await harness.acpFiber.dispose()
|
||||
|
||||
expect(order).toEqual(['drained', 'agent disposed'])
|
||||
expect(drainedParents).toEqual([agent])
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cancels its own prompt before awaiting the descendant drain', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
const order: string[] = []
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
harness.ctx.provide('subagents', {
|
||||
drainContinuableDescendants: async () => {
|
||||
order.push('drain started')
|
||||
await release.promise
|
||||
order.push('drain finished')
|
||||
},
|
||||
} as never)
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||
await vi.waitFor(() => { expect(agent.status).toBe('running') })
|
||||
const cancel = agent.cancel.bind(agent)
|
||||
let cancelObserved = false
|
||||
vi.spyOn(agent, 'cancel').mockImplementation((...args) => {
|
||||
if (!cancelObserved) {
|
||||
cancelObserved = true
|
||||
order.push('parent cancelled')
|
||||
}
|
||||
cancel(...args)
|
||||
})
|
||||
|
||||
const disposal = harness.acpFiber.dispose()
|
||||
// A drain can block on persistence, so the bridge's own turn must already be
|
||||
// cancelled rather than running for its whole duration.
|
||||
await vi.waitFor(() => { expect(order).toContain('drain started') })
|
||||
expect(order).toEqual(['parent cancelled', 'drain started'])
|
||||
release.resolve(undefined)
|
||||
await disposal
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a failed continuable drain and still disposes its sessions', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const warnings: string[] = []
|
||||
harness.ctx.logger.warn = (message: string) => { warnings.push(message) }
|
||||
harness.ctx.provide('subagents', {
|
||||
drainContinuableDescendants: () => Promise.reject(new Error('activation teardown failed')),
|
||||
} as never)
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await harness.acpFiber.dispose()
|
||||
|
||||
// A stuck descendant must not strand the bridge's own teardown.
|
||||
expect(warnings.some(warning => warning.includes('continuable subagent teardown failed'))).toBe(true)
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('awaits every owned session disposal and reports nested failure reasons', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const create = harness.ctx.agents.create.bind(harness.ctx.agents)
|
||||
const releaseSecond = Promise.withResolvers<undefined>()
|
||||
const warnings: string[] = []
|
||||
let created = 0
|
||||
let secondStarted = false
|
||||
harness.ctx.logger.warn = (message: string) => { warnings.push(message) }
|
||||
const createSpy = vi.spyOn(harness.ctx.agents, 'create').mockImplementation(async (options) => {
|
||||
const handle = await create(options)
|
||||
const originalDispose = handle.dispose.bind(handle)
|
||||
if (created++ === 0) {
|
||||
handle.dispose = async () => {
|
||||
await originalDispose()
|
||||
throw new AggregateError([
|
||||
new Error('scope cleanup failed', { cause: new Error('sqlite busy') }),
|
||||
new Error('hook cleanup failed'),
|
||||
], 'first session cleanup failed')
|
||||
}
|
||||
} else {
|
||||
handle.dispose = async () => {
|
||||
secondStarted = true
|
||||
await releaseSecond.promise
|
||||
await originalDispose()
|
||||
}
|
||||
}
|
||||
return handle
|
||||
})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await harness.closeClientTransport()
|
||||
await vi.waitFor(() => { expect(secondStarted).toBe(true) })
|
||||
expect(warnings.some(warning => warning.includes('connection-close teardown failed'))).toBe(false)
|
||||
|
||||
releaseSecond.resolve(undefined)
|
||||
await vi.waitFor(() => {
|
||||
expect(warnings.some(warning =>
|
||||
warning.includes(
|
||||
'ACP agent teardown failed for 1 session(s): '
|
||||
+ 'first session cleanup failed [scope cleanup failed: sqlite busy; hook cleanup failed]',
|
||||
))).toBe(true)
|
||||
expect(harness!.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined()
|
||||
expect(harness!.ctx.agents.get(SessionId(second.sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
createSpy.mockRestore()
|
||||
const disposed = harness
|
||||
harness = undefined
|
||||
await disposed.dispose().catch(() => undefined)
|
||||
})
|
||||
|
||||
it('an ACP-only reload rejects new sessions before creating an orphan', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
|
||||
@@ -31,11 +31,13 @@ describe('ACP prompt lifecycle', () => {
|
||||
harness = undefined
|
||||
})
|
||||
|
||||
it('maps a max-token turn without losing its committed text', async () => {
|
||||
it('maps a max-token turn to end_turn without losing its committed text', async () => {
|
||||
harness = await makeBridgeHarness({ script: [maxTokensResponse('cut off')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(result.stopReason).toBe('max_tokens')
|
||||
// A token-limit turn ending is not a prompt-level stop reason (README):
|
||||
// the prompt settles at whole-agent idle with end_turn.
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
|
||||
})
|
||||
|
||||
@@ -49,12 +51,27 @@ describe('ACP prompt lifecycle', () => {
|
||||
|
||||
it('rejects an ordinary plugin failure through the same prompt boundary', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('must not run')] })
|
||||
harness.ctx.on('agent/step', () => { throw new Error('plugin pre-step failed') })
|
||||
harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: plugin pre-step failed/)
|
||||
})
|
||||
|
||||
it('rejects a turn-start failure before the prompt is claimed', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('must not run')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
const append = agent.session.append.bind(agent.session)
|
||||
vi.spyOn(agent.session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
|
||||
if (type === 'turn/start') throw new Error('turn start unavailable')
|
||||
return (append as (...args: never[]) => unknown)(type as never, ...rest)
|
||||
}) as never)
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: turn start unavailable/)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('settles even when an earlier turn observer throws', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
|
||||
harness.ctx.on('session/event', (_session, event) => {
|
||||
@@ -65,13 +82,13 @@ describe('ACP prompt lifecycle', () => {
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
})
|
||||
|
||||
it('ignores an injection turn while correlating the owning message turn', async () => {
|
||||
it('correlates the owning prompt when a synchronous injection joins its first step', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('real answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
let injected = false
|
||||
harness.ctx.on('agent/inbox/enqueue', (subject) => {
|
||||
if (subject === agent && !injected) {
|
||||
harness.ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => {
|
||||
if (subject === agent && message.source.kind === 'user' && !injected) {
|
||||
injected = true
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
}
|
||||
@@ -86,30 +103,44 @@ describe('ACP prompt lifecycle', () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
let inserted = false
|
||||
harness.ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject !== agent || item.message.source.kind !== 'user' || inserted) return
|
||||
inserted = true
|
||||
const source = { kind: 'plugin', plugin: 'test' } as const
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } })
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'autonomous work' }],
|
||||
source,
|
||||
}), { surfaceOp: 'append' })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
let autonomousStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => { autonomousStarted = resolve })
|
||||
harness.ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'assistant/chunk') autonomousStarted()
|
||||
})
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'autonomous work' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
await started
|
||||
|
||||
let settled = false
|
||||
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
.finally(() => { settled = true })
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'agent/inbox/spliced'
|
||||
&& event.data.inserted.length > 0)).toHaveLength(2)
|
||||
})
|
||||
expect(settled).toBe(false)
|
||||
await harness.client.cancel({ sessionId })
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
})
|
||||
|
||||
it('correlates a prompt whose step history is replaced', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('rewritten answer')] })
|
||||
harness.ctx.on('agent/pre-step', async () => ({
|
||||
kind: 'enter',
|
||||
messages: [createUserMessage({
|
||||
content: [{ type: 'text', text: 'rewritten prompt' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})],
|
||||
}))
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'original' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
})
|
||||
|
||||
it('frees the prompt slot when the agent rejects the send synchronously', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
const sessionId = await newSession(harness)
|
||||
@@ -143,7 +174,39 @@ describe('ACP prompt lifecycle', () => {
|
||||
await harness.client.cancel({ sessionId })
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
await agent.whenIdle()
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason).toEqual({ kind: 'aborted' })
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
|
||||
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
})
|
||||
|
||||
it('settles a hook-cancelled turn as end_turn, not cancelled', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
const sessionId = await newSession(harness)
|
||||
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
await vi.waitFor(() => { expect(agent.status).toBe('running') })
|
||||
// A hook or another owner cancels the agent: the ACP client never called
|
||||
// session/cancel, so this is ordinary quiescence and reports end_turn.
|
||||
agent.cancel({ kind: 'hook', reason: 'owner intervention' })
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' })
|
||||
})
|
||||
|
||||
it('cancels autonomous running work without an in-flight prompt', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'autonomous work' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
|
||||
})
|
||||
|
||||
await harness.client.cancel({ sessionId })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
|
||||
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
})
|
||||
|
||||
it('an idle cancel does not affect the following prompt', async () => {
|
||||
@@ -172,7 +235,7 @@ describe('ACP prompt lifecycle', () => {
|
||||
harness = await makeBridgeHarness({ script: [errorResponse('transient boom'), textResponse('recovered')] })
|
||||
// A recovery policy: schedule one retry for the failed request.
|
||||
let retried = false
|
||||
harness.ctx.on('agent/request-error', async (_subject) => {
|
||||
harness.ctx.on('agent/request-error', async () => {
|
||||
if (!retried) {
|
||||
retried = true
|
||||
return { kind: 'retry' }
|
||||
@@ -184,7 +247,7 @@ describe('ACP prompt lifecycle', () => {
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('recovered') })
|
||||
})
|
||||
|
||||
it('a failed turn with no retry still rejects, at quiescence', async () => {
|
||||
it('a failed turn with no retry still rejects', async () => {
|
||||
harness = await makeBridgeHarness({ script: [errorResponse('terminal boom')] })
|
||||
let offered = 0
|
||||
harness.ctx.on('agent/request-error', async () => { offered += 1 })
|
||||
@@ -194,13 +257,36 @@ describe('ACP prompt lifecycle', () => {
|
||||
expect(offered).toBe(1)
|
||||
})
|
||||
|
||||
it('an admission-blocked prompt settles cancelled instead of hanging', async () => {
|
||||
it('a pre-step-rejected prompt settles instead of hanging', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
harness.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy said no' }))
|
||||
harness.ctx.on('agent/pre-step', async () => ({
|
||||
kind: 'reject' as const,
|
||||
}))
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'cancelled' })
|
||||
// The blocked prompt opened no turn and streamed nothing.
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
// The rejected prompt closed a blocked turn without streaming anything.
|
||||
expect(messageText(harness)).toBe('')
|
||||
})
|
||||
|
||||
it('cancels a prompt removed before its turn claims it', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
const sessionId = await newSession(harness)
|
||||
const dispose = harness.ctx.on('agent/inbox/inserted', ({ agent, message }) => {
|
||||
if (message.source.kind === 'user') agent.inbox.remove(message.id)
|
||||
})
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'cancelled' })
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('rejects a prompt when pre-step fails inside its open turn', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
harness.ctx.on('agent/pre-step', async () => { throw new Error('pre-step exploded') })
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: pre-step exploded/)
|
||||
})
|
||||
})
|
||||
|
||||
6
packages/api/README.i18n.yaml
Normal file
6
packages/api/README.i18n.yaml
Normal 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/api/README.md
|
||||
README.md: 7c75e8012459266e0ce09c97416d140e5ac777e1
|
||||
README.zh.md: 87bd15fc4e5ad23ef785f7c9ee805a4aa1a35e46
|
||||
17
packages/api/README.md
Normal file
17
packages/api/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# api/ — Remote API layers
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The application-facing Remote stack. `remotes` owns BFF policy and the selected business API, while `gateway` implements the TypeRT unary RPC endpoints shared by Host and Client environments.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.remote` |
|
||||
| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` |
|
||||
|
||||
The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientRemote` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Connection and WebServer remain at [`client/connection`](../client/connection/README.md) and [`host/webserver`](../host/webserver/README.md); a later package-only move can place them under `api/connection` and `api/webserver` without changing their service contracts.
|
||||
- The legacy API Proxy remains at [`host/apiproxy`](../host/apiproxy/README.md) as the fallback for methods not yet migrated to Remote. It consumes the Host resolver owned by `api-remotes` so migrated and legacy methods retain one Agent/Session identity policy.
|
||||
17
packages/api/README.zh.md
Normal file
17
packages/api/README.zh.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# api/:Remote API 层
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向应用的 Remote 技术栈。`remotes` 负责 BFF 策略和选定的业务 API,`gateway` 则实现 Host 与 Client 环境共用的 TypeRT 一元 RPC endpoint。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.remote` |
|
||||
| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` |
|
||||
|
||||
运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientRemote` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection` 和 `api/webserver` 下,而无需改变服务契约。
|
||||
- 旧 API Proxy 仍位于 [`host/apiproxy`](../host/apiproxy/README.md),作为尚未迁移到 Remote 的方法的回退路径。它使用由 `api-remotes` 持有的 Host resolver,使已迁移与旧方法共用同一套 Agent/Session 身份策略。
|
||||
6
packages/api/gateway/README.i18n.yaml
Normal file
6
packages/api/gateway/README.i18n.yaml
Normal 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/api/gateway/README.md
|
||||
README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502
|
||||
README.zh.md: 6b5ccff2340405cc0045147239c5bd4f3eead7da
|
||||
39
packages/api/gateway/README.md
Normal file
39
packages/api/gateway/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-api-gateway
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection.
|
||||
|
||||
## Host service: `TypertGatewayService` (ctx key: `typertGateway`)
|
||||
|
||||
`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance.
|
||||
|
||||
Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation.
|
||||
|
||||
The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypeRTLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences.
|
||||
|
||||
A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type.
|
||||
|
||||
## Client service: `ClientRemote` (ctx key: `remote`)
|
||||
|
||||
`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.<namespace>` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
|
||||
|
||||
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
|
||||
|
||||
Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package dispatches application calls and registers no prompt, tool, or session event.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct effect; invoked business Services own any model-visible result.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypeRTLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers.
|
||||
- SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields.
|
||||
- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection.
|
||||
- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection.
|
||||
- Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key.
|
||||
39
packages/api/gateway/README.zh.md
Normal file
39
packages/api/gateway/README.zh.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-api-gateway
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。
|
||||
|
||||
## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`)
|
||||
|
||||
每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。
|
||||
|
||||
严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
|
||||
|
||||
Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypeRTLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。
|
||||
|
||||
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
|
||||
|
||||
## Client 服务:`ClientRemote`(ctx key:`remote`)
|
||||
|
||||
`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.<namespace>` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
|
||||
|
||||
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
|
||||
|
||||
生成的声明合并通过共享的 `TypeRTClientRemote` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为该包分发应用调用,不注册任何提示词、工具或会话事件。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;被调用的业务服务负责产生任何模型可见结果。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypeRTLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。
|
||||
- SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。
|
||||
- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。
|
||||
- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。
|
||||
- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。
|
||||
66
packages/api/gateway/package.json
Normal file
66
packages/api/gateway/package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-gateway",
|
||||
"description": "TypeRT Remote Host dispatcher and Client API endpoint",
|
||||
"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"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
498
packages/api/gateway/src/client/index.ts
Normal file
498
packages/api/gateway/src/client/index.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Client projection of generated TypeRT Remote descriptors. Contributions
|
||||
* install traced `remote.<namespace>` services; no JavaScript Proxy
|
||||
* participates in method lookup, invocation, or type exposure.
|
||||
*/
|
||||
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTClientRemote,
|
||||
TypeRTCodec,
|
||||
TypeRTDisposer,
|
||||
TypeRTRemoteContribution,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
interface MountToken {
|
||||
active: boolean
|
||||
readonly abort: AbortController
|
||||
}
|
||||
|
||||
interface ScopedProjection {
|
||||
readonly context: string
|
||||
readonly wire: string
|
||||
readonly codec: TypeRTCodec
|
||||
readonly parameterIndex?: number
|
||||
}
|
||||
|
||||
interface DirectMethod {
|
||||
readonly descriptor: InvocationDescriptor
|
||||
readonly token: MountToken
|
||||
}
|
||||
|
||||
interface ScopedMethod extends DirectMethod {
|
||||
readonly projection: ScopedProjection
|
||||
}
|
||||
|
||||
interface RemoteMethodRecord {
|
||||
direct?: DirectMethod
|
||||
scoped?: ScopedMethod
|
||||
}
|
||||
|
||||
interface BoundContextIdentity {
|
||||
readonly value: unknown
|
||||
}
|
||||
|
||||
interface RemoteNamespaceHandle {
|
||||
readonly service: RemoteNamespaceService
|
||||
readonly dispose: TypeRTDisposer
|
||||
}
|
||||
|
||||
/** Typed Remote service augmented by generated direct namespaces. */
|
||||
export type ClientRemote = TypeRTClientRemote
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Generated Remote namespaces selected by the Client assembly. */
|
||||
remote: ClientRemote
|
||||
}
|
||||
}
|
||||
|
||||
/** Required Client services: the TypeRT registry and the existing Connection carrier. */
|
||||
export const inject = ['typert', 'connection']
|
||||
|
||||
/**
|
||||
* Install the typed Client Remote service.
|
||||
* @param ctx - Client Cordis root.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
new ClientRemoteService(ctx)
|
||||
}
|
||||
|
||||
class ClientRemoteService extends Service implements TypeRTClientRemote {
|
||||
private readonly ownerCtx: Context
|
||||
private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
|
||||
private mutations = Promise.resolve()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'remote')
|
||||
this.ownerCtx = ctx
|
||||
}
|
||||
|
||||
async $mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientRemote['$mount']> {
|
||||
const callerCtx = this.ctx
|
||||
const owned = callerCtx.effect(async () => {
|
||||
const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution))
|
||||
return () => this.enqueue(dispose)
|
||||
}, `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`)
|
||||
await owned
|
||||
return async () => { await owned() }
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
const result = this.mutations.then(operation, operation)
|
||||
this.mutations = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
private async mountContribution(
|
||||
callerCtx: Context,
|
||||
contribution: TypeRTRemoteContribution,
|
||||
): Promise<TypeRTDisposer> {
|
||||
this.validateContribution(contribution)
|
||||
const disposeRemote = callerCtx.typert.remotes.register(contribution)
|
||||
const installed: TypeRTDisposer[] = []
|
||||
try {
|
||||
for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor))
|
||||
} catch (error) {
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
await disposeRemote()
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
await disposeRemote()
|
||||
}
|
||||
}
|
||||
|
||||
private validateContribution(contribution: TypeRTRemoteContribution): void {
|
||||
const direct = new Map<string, Set<string>>()
|
||||
const scoped = new Map<string, Set<string>>()
|
||||
const add = (
|
||||
table: Map<string, Set<string>>,
|
||||
descriptor: InvocationDescriptor,
|
||||
kind: 'direct' | 'scoped',
|
||||
): void => {
|
||||
const methods = table.get(descriptor.namespace) ?? new Set<string>()
|
||||
if (methods.has(descriptor.method)) {
|
||||
throw new Error(`client api: contribution repeats ${kind} method ${endpointOf(descriptor)}`)
|
||||
}
|
||||
methods.add(descriptor.method)
|
||||
table.set(descriptor.namespace, methods)
|
||||
const namespace = this.namespaces.get(descriptor.namespace)?.service
|
||||
if (namespace?.has(kind, descriptor.method) === true) {
|
||||
throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`)
|
||||
}
|
||||
}
|
||||
for (const descriptor of contribution.descriptors) {
|
||||
requireStrictDescriptor(descriptor)
|
||||
if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct')
|
||||
if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped')
|
||||
}
|
||||
const namespaces = new Set([...direct.keys(), ...scoped.keys()])
|
||||
for (const namespace of namespaces) {
|
||||
const service = this.namespaces.get(namespace)?.service
|
||||
if (service === undefined) {
|
||||
if (namespace in this) {
|
||||
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`)
|
||||
}
|
||||
const serviceKey = remoteServiceKey(namespace)
|
||||
const property = this.ownerCtx.reflect.props[serviceKey]
|
||||
if (property?.type === 'accessor' || this.ownerCtx.get(serviceKey) !== undefined) {
|
||||
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`)
|
||||
}
|
||||
}
|
||||
for (const method of new Set([...(direct.get(namespace) ?? []), ...(scoped.get(namespace) ?? [])])) {
|
||||
if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method)
|
||||
else service.assertMethodAvailable(method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async install(descriptor: InvocationDescriptor): Promise<TypeRTDisposer> {
|
||||
const token: MountToken = { active: true, abort: new AbortController() }
|
||||
const installed: TypeRTDisposer[] = []
|
||||
try {
|
||||
if (descriptor.invocation.kind === 'direct') {
|
||||
installed.push(await this.installDirect(descriptor, token))
|
||||
}
|
||||
const projection = scopedProjection(descriptor)
|
||||
if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token))
|
||||
} catch (error) {
|
||||
token.active = false
|
||||
token.abort.abort()
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
|
||||
if (!token.active) return
|
||||
token.active = false
|
||||
token.abort.abort()
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise<TypeRTDisposer> {
|
||||
const namespace = await this.namespace(descriptor.namespace)
|
||||
try {
|
||||
namespace.service.installDirect(descriptor, token)
|
||||
} catch (error) {
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
namespace.service.remove('direct', descriptor.method, token)
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
}
|
||||
}
|
||||
|
||||
private async installScoped(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection,
|
||||
token: MountToken,
|
||||
): Promise<TypeRTDisposer> {
|
||||
const namespace = await this.namespace(descriptor.namespace)
|
||||
try {
|
||||
namespace.service.installScoped(descriptor, projection, token)
|
||||
} catch (error) {
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
namespace.service.remove('scoped', descriptor.method, token)
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
}
|
||||
}
|
||||
|
||||
private async namespace(name: string): Promise<RemoteNamespaceHandle> {
|
||||
let namespace = this.namespaces.get(name)
|
||||
if (namespace !== undefined) return namespace
|
||||
let service: RemoteNamespaceService | undefined
|
||||
const fiber = this.ownerCtx.plugin({
|
||||
name: remoteServiceKey(name),
|
||||
apply: (ctx: Context) => {
|
||||
service = new RemoteNamespaceService(
|
||||
ctx,
|
||||
name,
|
||||
(direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args),
|
||||
)
|
||||
},
|
||||
})
|
||||
try {
|
||||
await fiber
|
||||
} catch (error) {
|
||||
await fiber.dispose()
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */
|
||||
if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
|
||||
namespace = { service, dispose: fiber.dispose }
|
||||
this.namespaces.set(name, namespace)
|
||||
return namespace
|
||||
}
|
||||
|
||||
private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise<void> {
|
||||
if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return
|
||||
this.namespaces.delete(name)
|
||||
await namespace.dispose()
|
||||
}
|
||||
|
||||
private invokeMethod(
|
||||
direct: DirectMethod | undefined,
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
): Promise<unknown> {
|
||||
if (scoped !== undefined) {
|
||||
const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
|
||||
const identity = binder?.identity(callerCtx)
|
||||
if (identity !== undefined) {
|
||||
return this.invoke(
|
||||
scoped.descriptor,
|
||||
scoped.projection,
|
||||
scoped.token,
|
||||
callerCtx,
|
||||
values,
|
||||
{ value: identity },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (direct !== undefined) {
|
||||
return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values)
|
||||
}
|
||||
if (scoped !== undefined) {
|
||||
return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values)
|
||||
}
|
||||
throw new Error('client api: Remote method is no longer mounted')
|
||||
}
|
||||
|
||||
private async invoke(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection | undefined,
|
||||
token: MountToken,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
boundIdentity?: BoundContextIdentity,
|
||||
): Promise<unknown> {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`)
|
||||
const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
|
||||
const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
|
||||
if (values.length !== expected && !hasCallerSignal) {
|
||||
const contract = descriptor.cancellation === undefined
|
||||
? `${String(expected)} argument(s)`
|
||||
: `${String(expected)} business argument(s) plus an optional AbortSignal`
|
||||
throw new Error(
|
||||
`client api: ${endpoint} expected ${contract}, got ${String(values.length)}`,
|
||||
)
|
||||
}
|
||||
const args = Object.create(null) as Record<string, unknown>
|
||||
if (projection !== undefined) {
|
||||
const binder = boundIdentity === undefined
|
||||
? this.ownerCtx.typert.contexts.getClient(projection.context)
|
||||
: undefined
|
||||
if (boundIdentity === undefined && binder === undefined) {
|
||||
throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`)
|
||||
}
|
||||
const identity = boundIdentity === undefined
|
||||
? binder?.identity(callerCtx)
|
||||
: boundIdentity.value
|
||||
if (identity === undefined) {
|
||||
throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`)
|
||||
}
|
||||
args[projection.wire] = parse(projection.codec, identity, endpoint, projection.wire)
|
||||
}
|
||||
let valueIndex = 0
|
||||
descriptor.parameters.forEach((parameter, parameterIndex) => {
|
||||
if (parameterIndex === projection?.parameterIndex) return
|
||||
args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire)
|
||||
valueIndex += 1
|
||||
})
|
||||
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
|
||||
const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined
|
||||
const signal = callerSignal === undefined
|
||||
? token.abort.signal
|
||||
: AbortSignal.any([token.abort.signal, callerSignal])
|
||||
const result = await connection.rpc.call('/api', endpoint, { args }, signal)
|
||||
if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`)
|
||||
if (!result.ok) throw remoteFailure(endpoint, result.error)
|
||||
return parse(descriptor.result, result.value, endpoint, 'result')
|
||||
}
|
||||
}
|
||||
|
||||
type InvokeRemote = (
|
||||
direct: DirectMethod | undefined,
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
args: readonly unknown[],
|
||||
) => Promise<unknown>
|
||||
|
||||
class RemoteNamespaceService extends Service {
|
||||
private readonly methods = new Map<string, RemoteMethodRecord>()
|
||||
private readonly namespace: string
|
||||
|
||||
static assertMethodAvailable(namespace: string, method: string): void {
|
||||
if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) {
|
||||
throw new Error(`client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
private readonly invokeRemote: InvokeRemote,
|
||||
) {
|
||||
super(ctx, remoteServiceKey(name))
|
||||
this.namespace = name
|
||||
}
|
||||
|
||||
assertMethodAvailable(method: string): void {
|
||||
RemoteNamespaceService.assertMethodAvailable(this.namespace, method)
|
||||
if (method in this && !this.methods.has(method)) {
|
||||
throw new Error(`client api: method ${JSON.stringify(`${this.namespace}/${method}`)} conflicts with its namespace service`)
|
||||
}
|
||||
}
|
||||
|
||||
get empty(): boolean {
|
||||
return this.methods.size === 0
|
||||
}
|
||||
|
||||
has(kind: 'direct' | 'scoped', method: string): boolean {
|
||||
return this.methods.get(method)?.[kind] !== undefined
|
||||
}
|
||||
|
||||
installDirect(descriptor: InvocationDescriptor, token: MountToken): void {
|
||||
this.install(descriptor.method, 'direct', { descriptor, token })
|
||||
}
|
||||
|
||||
installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void {
|
||||
this.install(descriptor.method, 'scoped', { descriptor, projection, token })
|
||||
}
|
||||
|
||||
private install(method: string, kind: 'direct', value: DirectMethod): void
|
||||
private install(method: string, kind: 'scoped', value: ScopedMethod): void
|
||||
private install(method: string, kind: 'direct' | 'scoped', value: DirectMethod | ScopedMethod): void {
|
||||
this.assertMethodAvailable(method)
|
||||
let record = this.methods.get(method)
|
||||
const fresh = record === undefined
|
||||
record ??= {}
|
||||
if (fresh) {
|
||||
Object.defineProperty(this, method, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
|
||||
const callerCtx = this.ctx
|
||||
const current = this.methods.get(method)
|
||||
const direct = current?.direct
|
||||
const scoped = current?.scoped
|
||||
return (...args: unknown[]) => {
|
||||
return this.invokeRemote(direct, scoped, callerCtx, args)
|
||||
}
|
||||
},
|
||||
})
|
||||
this.methods.set(method, record)
|
||||
}
|
||||
if (kind === 'direct') record.direct = value
|
||||
else record.scoped = value as ScopedMethod
|
||||
}
|
||||
|
||||
remove(kind: 'direct' | 'scoped', method: string, token: MountToken): void {
|
||||
const record = this.methods.get(method)
|
||||
const current = record?.[kind]
|
||||
/* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */
|
||||
if (record === undefined || current?.token !== token) return
|
||||
if (kind === 'direct') delete record.direct
|
||||
else delete record.scoped
|
||||
if (record.direct !== undefined || record.scoped !== undefined) return
|
||||
this.methods.delete(method)
|
||||
Reflect.deleteProperty(this, method)
|
||||
}
|
||||
}
|
||||
|
||||
const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace'])
|
||||
|
||||
function remoteServiceKey(namespace: string): string {
|
||||
return `remote.${namespace}`
|
||||
}
|
||||
|
||||
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
|
||||
return `${descriptor.namespace}/${descriptor.method}`
|
||||
}
|
||||
|
||||
function mountActive(token: MountToken): boolean {
|
||||
return token.active
|
||||
}
|
||||
|
||||
function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | undefined {
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
return {
|
||||
context: descriptor.invocation.context,
|
||||
wire: descriptor.invocation.wire,
|
||||
codec: descriptor.invocation.codec,
|
||||
}
|
||||
}
|
||||
if (descriptor.scope === undefined) return undefined
|
||||
const lookupParameters = descriptor.parameters
|
||||
.map((parameter, index) => ({ parameter, index }))
|
||||
.filter(candidate => candidate.parameter.source === 'lookup')
|
||||
const selected = lookupParameters.length === 1 ? lookupParameters[0] : undefined
|
||||
if (selected === undefined
|
||||
|| selected.parameter.wire !== descriptor.scope.wire
|
||||
|| selected.parameter.lookup !== descriptor.scope.context) {
|
||||
throw new Error(
|
||||
`client api: generated Remote ${endpointOf(descriptor)} scope must select its only lookup parameter`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
context: descriptor.scope.context,
|
||||
wire: descriptor.scope.wire,
|
||||
codec: selected.parameter.codec,
|
||||
parameterIndex: selected.index,
|
||||
}
|
||||
}
|
||||
|
||||
function requireStrictDescriptor(descriptor: InvocationDescriptor): void {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
requireStrictCodec(descriptor.result, endpoint, 'result')
|
||||
for (const parameter of descriptor.parameters) {
|
||||
requireStrictCodec(parameter.codec, endpoint, parameter.wire)
|
||||
}
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire)
|
||||
}
|
||||
}
|
||||
|
||||
function requireStrictCodec(codec: TypeRTCodec, endpoint: string, field: string): void {
|
||||
if (codec.mode !== 'strict') {
|
||||
throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
|
||||
}
|
||||
}
|
||||
|
||||
function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: string): unknown {
|
||||
if (codec.mode !== 'strict') {
|
||||
throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
|
||||
}
|
||||
try {
|
||||
return codec.schema.parse(value)
|
||||
} catch (cause) {
|
||||
throw new Error(`client api: ${endpoint} rejected ${JSON.stringify(field)}`, { cause })
|
||||
}
|
||||
}
|
||||
|
||||
function remoteFailure(endpoint: string, error: RpcError): Error {
|
||||
return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error })
|
||||
}
|
||||
638
packages/api/gateway/src/index.ts
Normal file
638
packages/api/gateway/src/index.ts
Normal file
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* Live TypeRT Remote dispatch over Cordis Services and registered providers.
|
||||
* Transport, request correlation, and response envelopes belong to Connection.
|
||||
* @module @deepseek-ai/dsh-api-gateway
|
||||
*/
|
||||
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
||||
import {
|
||||
remoteMethods,
|
||||
TypeRTLookupFailure,
|
||||
type InvocationDescriptor,
|
||||
type InvocationParameterDescriptor,
|
||||
type TypeRTCodec,
|
||||
type TypeRTGatewayBinding,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import type {
|
||||
InvokeRemoteRequest,
|
||||
TypertGateway,
|
||||
TypertGatewayErrorCode,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
InvokeRemoteRequest,
|
||||
TypertGateway,
|
||||
TypertGatewayErrorCode,
|
||||
} from './types.ts'
|
||||
|
||||
interface GatewayErrorOptions {
|
||||
readonly cause?: unknown
|
||||
readonly field?: string
|
||||
}
|
||||
|
||||
interface ResolvedBinding {
|
||||
readonly binding: TypeRTGatewayBinding
|
||||
readonly original: object
|
||||
}
|
||||
|
||||
type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
|
||||
type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
|
||||
const NEVER_ABORTED_SIGNAL = new AbortController().signal
|
||||
|
||||
/** Dispatch failure produced outside the invoked business method. */
|
||||
export class TypertGatewayError extends Error {
|
||||
/** Machine-readable failure category. */
|
||||
readonly code: TypertGatewayErrorCode
|
||||
/** Canonical `<namespace>/<method>` endpoint. */
|
||||
readonly endpoint: string
|
||||
/** Affected wire field when the failure is field-specific. */
|
||||
readonly field: string | undefined
|
||||
|
||||
/**
|
||||
* Construct a Gateway failure without embedding boundary values in its message.
|
||||
* @param code - stable failure category.
|
||||
* @param endpoint - canonical Remote endpoint.
|
||||
* @param message - correction-oriented diagnostic without sensitive values.
|
||||
* @param options - optional field and contained cause.
|
||||
*/
|
||||
constructor(
|
||||
code: TypertGatewayErrorCode,
|
||||
endpoint: string,
|
||||
message: string,
|
||||
options: GatewayErrorOptions = {},
|
||||
) {
|
||||
super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause })
|
||||
this.name = 'TypertGatewayError'
|
||||
this.code = code
|
||||
this.endpoint = endpoint
|
||||
this.field = options.field
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve strict generated definitions or conservative SRC markers against
|
||||
* current Cordis Services and TypeRT providers.
|
||||
* @typert service typertGateway
|
||||
*/
|
||||
export class TypertGatewayService extends Service implements TypertGateway {
|
||||
static inject = ['typert']
|
||||
|
||||
private srcClaims: ReadonlySet<string> | undefined
|
||||
|
||||
/**
|
||||
* Register the Gateway against the active TypeRT registry.
|
||||
* @param ctx - owning Host Context with TypeRT registry access.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'typertGateway')
|
||||
ctx.on('internal/service', () => {
|
||||
this.srcClaims = undefined
|
||||
})
|
||||
ctx.inject(['connection'], (connectionCtx) => {
|
||||
connectionCtx.connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => this.claimsEndpoint(endpoint),
|
||||
(endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal),
|
||||
{ authority: 'trusted-host' },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private claimsEndpoint(endpoint: string): boolean {
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false
|
||||
if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true
|
||||
this.srcClaims ??= this.collectSrcClaims()
|
||||
return this.srcClaims.has(endpoint)
|
||||
}
|
||||
|
||||
private collectSrcClaims(): ReadonlySet<string> {
|
||||
const claims = new Set<string>()
|
||||
for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
|
||||
if (definition.type !== 'service') continue
|
||||
const receiver = this.ctx.get(serviceKey) as unknown
|
||||
if (!isObject(receiver)) continue
|
||||
const original = originalOf(receiver)
|
||||
const binding = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (!isObject(binding) || typeof Reflect.get(binding, 'namespace') !== 'string') continue
|
||||
const namespace = Reflect.get(binding, 'namespace') as string
|
||||
for (const candidate of remoteMethods(original)) {
|
||||
claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method))
|
||||
}
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke one live Remote method through strict generated reflection or SRC markers.
|
||||
* @param request - decoded endpoint and exact named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
async invoke(request: InvokeRemoteRequest): Promise<unknown> {
|
||||
const endpoint = endpointOf(request.namespace, request.method)
|
||||
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
|
||||
assertExactArguments(request.args, descriptor, endpoint)
|
||||
const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)
|
||||
const receiver = receiverContext.get(descriptor.service) as unknown
|
||||
if (!isObject(receiver)) {
|
||||
throw new TypertGatewayError(
|
||||
'service-unavailable',
|
||||
endpoint,
|
||||
`active Service ${JSON.stringify(descriptor.service)} is unavailable`,
|
||||
)
|
||||
}
|
||||
validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
|
||||
const args = await Promise.all(descriptor.parameters.map(parameter =>
|
||||
this.resolveParameter(parameter, request.args, endpoint)))
|
||||
if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
|
||||
const implementation = descriptor.implementation ?? descriptor.method
|
||||
const method = Reflect.get(receiver, implementation) as unknown
|
||||
if (typeof method !== 'function') {
|
||||
throw new TypertGatewayError(
|
||||
'method-unavailable',
|
||||
endpoint,
|
||||
`active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result = await Reflect.apply(method, receiver, args) as unknown
|
||||
return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
|
||||
}
|
||||
|
||||
private async dispatchRpc(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
): Promise<ConnectionRpcResult> {
|
||||
return this.invokeRpc(endpoint, payload, signal)
|
||||
}
|
||||
|
||||
private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
|
||||
try {
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
|
||||
throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
|
||||
}
|
||||
const [namespace, method] = segments as [string, string]
|
||||
if (!isObject(payload)
|
||||
|| !isPlainObject(payload)
|
||||
|| Reflect.ownKeys(payload).length !== 1
|
||||
|| !Object.hasOwn(payload, 'args')
|
||||
|| !isObject(payload.args)
|
||||
|| !isPlainObject(payload.args)) {
|
||||
throw new Error('Remote payload must contain exactly one plain-object args field')
|
||||
}
|
||||
const value = await this.invoke({
|
||||
namespace,
|
||||
method,
|
||||
args: payload.args,
|
||||
signal,
|
||||
})
|
||||
return { ok: true, value }
|
||||
} catch (error) {
|
||||
return rpcFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
|
||||
const strict = this.ctx.typert.local.get(endpoint)
|
||||
if (strict !== undefined) return strict
|
||||
if (this.ctx.typert.local.hasSeen(endpoint)) {
|
||||
throw new TypertGatewayError(
|
||||
'definition-unavailable',
|
||||
endpoint,
|
||||
'its strict definition was withdrawn and SRC fallback is forbidden',
|
||||
)
|
||||
}
|
||||
return this.resolveSrcDescriptor(namespace, method, endpoint)
|
||||
}
|
||||
|
||||
private resolveSrcDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
|
||||
const candidates: InvocationDescriptor[] = []
|
||||
for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
|
||||
if (definition.type !== 'service') continue
|
||||
const receiver = this.ctx.get(serviceKey) as unknown
|
||||
if (!isObject(receiver)) continue
|
||||
const original = originalOf(receiver)
|
||||
const value = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (value === undefined) continue
|
||||
const binding = readBinding(value, original, serviceKey, endpoint)
|
||||
if (binding.namespace !== namespace) continue
|
||||
const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method)
|
||||
if (marker === undefined) continue
|
||||
candidates.push(this.srcDescriptor(binding, marker, method, endpoint))
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint')
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
throw new TypertGatewayError(
|
||||
'ambiguous-endpoint',
|
||||
endpoint,
|
||||
`multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`,
|
||||
)
|
||||
}
|
||||
return candidates[0] as InvocationDescriptor
|
||||
}
|
||||
|
||||
private srcDescriptor(
|
||||
binding: TypeRTGatewayBinding,
|
||||
marker: ReturnType<typeof remoteMethods>[number],
|
||||
method: string,
|
||||
endpoint: string,
|
||||
): InvocationDescriptor {
|
||||
const names = methodParameterNames(binding.service, marker.method, endpoint)
|
||||
const signalIndex = names.indexOf('signal')
|
||||
if (signalIndex >= 0 && signalIndex !== names.length - 1) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
'SRC cancellation parameter signal must be the final parameter',
|
||||
{ field: 'signal' },
|
||||
)
|
||||
}
|
||||
const cancellation = signalIndex >= 0
|
||||
? { parameter: 'signal' as const }
|
||||
: undefined
|
||||
const businessNames = cancellation === undefined ? names : names.slice(0, -1)
|
||||
const parameters: InvocationParameterDescriptor[] = []
|
||||
const wires = new Set<string>()
|
||||
for (const name of businessNames) {
|
||||
const matches = this.ctx.typert.lookups.definitions()
|
||||
.filter(definition => definition.parameter === name)
|
||||
if (matches.length > 1) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`parameter ${JSON.stringify(name)} matches multiple lookup providers`,
|
||||
{ field: name },
|
||||
)
|
||||
}
|
||||
const match = matches[0]
|
||||
const parameter: InvocationParameterDescriptor = match === undefined
|
||||
? { name, wire: name, source: 'json', codec: { mode: 'src-json' } }
|
||||
: {
|
||||
name,
|
||||
wire: match.wire,
|
||||
source: 'lookup',
|
||||
lookup: match.key,
|
||||
codec: { mode: 'src-json' },
|
||||
}
|
||||
if (wires.has(parameter.wire)) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`multiple parameters use wire field ${JSON.stringify(parameter.wire)}`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
wires.add(parameter.wire)
|
||||
parameters.push(parameter)
|
||||
}
|
||||
|
||||
let receiver: InvocationDescriptor['invocation'] = { kind: 'direct' }
|
||||
if (marker.invocation.kind === 'context') {
|
||||
const provider = this.ctx.typert.contexts.getHost(marker.invocation.context)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-unavailable',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`,
|
||||
)
|
||||
}
|
||||
if (wires.has(provider.wire)) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`,
|
||||
{ field: provider.wire },
|
||||
)
|
||||
}
|
||||
receiver = {
|
||||
kind: 'context',
|
||||
context: marker.invocation.context,
|
||||
wire: provider.wire,
|
||||
codec: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `src:${binding.serviceKey}#${endpoint}`,
|
||||
service: binding.serviceKey,
|
||||
namespace: binding.namespace,
|
||||
method,
|
||||
...(marker.method === method ? {} : { implementation: marker.method }),
|
||||
invocation: receiver,
|
||||
parameters,
|
||||
...(cancellation === undefined ? {} : { cancellation }),
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveReceiverContext(
|
||||
descriptor: InvocationDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): Promise<Context> {
|
||||
if (descriptor.invocation.kind === 'direct') return this.ctx
|
||||
const invocation = descriptor.invocation
|
||||
const provider = this.ctx.typert.contexts.getHost(invocation.context)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-unavailable',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} is unavailable`,
|
||||
)
|
||||
}
|
||||
if (provider.wire !== invocation.wire
|
||||
|| (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) {
|
||||
throw new TypertGatewayError(
|
||||
'provider-mismatch',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`,
|
||||
{ field: invocation.wire },
|
||||
)
|
||||
}
|
||||
const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire)
|
||||
let context: Context | undefined
|
||||
try {
|
||||
context = await provider.resolve(identity)
|
||||
} catch (cause) {
|
||||
if (cause instanceof TypeRTLookupFailure) throw cause
|
||||
throw new TypertGatewayError(
|
||||
'context-failed',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} failed`,
|
||||
{ cause, field: invocation.wire },
|
||||
)
|
||||
}
|
||||
if (context === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-not-found',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`,
|
||||
{ field: invocation.wire },
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
private async resolveParameter(
|
||||
parameter: InvocationParameterDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): Promise<unknown> {
|
||||
const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)
|
||||
if (parameter.source === 'json') return value
|
||||
const key = parameter.lookup
|
||||
/* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */
|
||||
if (key === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-unavailable',
|
||||
endpoint,
|
||||
`lookup parameter ${JSON.stringify(parameter.name)} has no provider key`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
const provider = this.ctx.typert.lookups.get(key)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-unavailable',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} is unavailable`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
if (provider.wire !== parameter.wire
|
||||
|| (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) {
|
||||
throw new TypertGatewayError(
|
||||
'provider-mismatch',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} does not match its strict definition`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
let resolved: unknown
|
||||
try {
|
||||
resolved = await provider.resolve(value)
|
||||
} catch (cause) {
|
||||
if (cause instanceof TypeRTLookupFailure) throw cause
|
||||
throw new TypertGatewayError(
|
||||
'lookup-failed',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} failed`,
|
||||
{ cause, field: parameter.wire },
|
||||
)
|
||||
}
|
||||
if (resolved === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-not-found',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} did not resolve the requested identity`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
function rpcFailure(error: unknown): ConnectionRpcResult {
|
||||
if (error instanceof TypeRTLookupFailure) {
|
||||
return { ok: false, error: error.failure as ConnectionRpcError }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function endpointOf(namespace: string, method: string): string {
|
||||
return `${namespace}/${method}`
|
||||
}
|
||||
|
||||
function validateBinding(
|
||||
receiver: object,
|
||||
serviceKey: string,
|
||||
namespace: string,
|
||||
endpoint: string,
|
||||
): ResolvedBinding {
|
||||
const original = originalOf(receiver)
|
||||
const value = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (value === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'binding-invalid',
|
||||
endpoint,
|
||||
`Service ${JSON.stringify(serviceKey)} has no visible typertGateway binding`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
binding: readBinding(value, original, serviceKey, endpoint, namespace),
|
||||
original,
|
||||
}
|
||||
}
|
||||
|
||||
function readBinding(
|
||||
value: unknown,
|
||||
original: object,
|
||||
serviceKey: string,
|
||||
endpoint: string,
|
||||
namespace?: string,
|
||||
): TypeRTGatewayBinding {
|
||||
if (!isObject(value)
|
||||
|| Reflect.get(value, 'service') !== original
|
||||
|| Reflect.get(value, 'serviceKey') !== serviceKey
|
||||
|| typeof Reflect.get(value, 'namespace') !== 'string'
|
||||
|| (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) {
|
||||
throw new TypertGatewayError(
|
||||
'binding-invalid',
|
||||
endpoint,
|
||||
`Service ${JSON.stringify(serviceKey)} has an inconsistent typertGateway binding`,
|
||||
)
|
||||
}
|
||||
return value as unknown as TypeRTGatewayBinding
|
||||
}
|
||||
|
||||
function originalOf(receiver: object): object {
|
||||
const original = Reflect.get(receiver, symbols.original) as unknown
|
||||
return isObject(original) ? original : receiver
|
||||
}
|
||||
|
||||
function methodParameterNames(service: object, method: string, endpoint: string): readonly string[] {
|
||||
let prototype: object | null = Object.getPrototypeOf(service) as object | null
|
||||
let implementation: ((this: object, ...args: never[]) => unknown) | undefined
|
||||
while (prototype !== null) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, method)
|
||||
if (descriptor !== undefined) {
|
||||
if ('value' in descriptor && typeof descriptor.value === 'function') {
|
||||
implementation = descriptor.value as (this: object, ...args: never[]) => unknown
|
||||
}
|
||||
break
|
||||
}
|
||||
prototype = Object.getPrototypeOf(prototype) as object | null
|
||||
}
|
||||
if (implementation === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'method-unavailable',
|
||||
endpoint,
|
||||
`Remote marker has no prototype method ${JSON.stringify(method)}`,
|
||||
)
|
||||
}
|
||||
const source = Function.prototype.toString.call(implementation)
|
||||
const open = source.indexOf('(')
|
||||
const close = source.indexOf(')', open + 1)
|
||||
/* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */
|
||||
if (open < 0 || close < 0) return invalidSignature(endpoint, method)
|
||||
const body = source.slice(open + 1, close).trim()
|
||||
if (body.length === 0) return []
|
||||
const parts = body.split(',').map(part => part.trim())
|
||||
const names = new Set<string>()
|
||||
for (const part of parts) {
|
||||
if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method)
|
||||
names.add(part)
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
|
||||
function invalidSignature(endpoint: string, method: string): never {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`,
|
||||
)
|
||||
}
|
||||
|
||||
function assertExactArguments(
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
descriptor: InvocationDescriptor,
|
||||
endpoint: string,
|
||||
): void {
|
||||
if (!isPlainObject(args)) {
|
||||
throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object')
|
||||
}
|
||||
const expected = new Set(descriptor.parameters.map(parameter => parameter.wire))
|
||||
if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
|
||||
const actual = Reflect.ownKeys(args)
|
||||
const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key))
|
||||
const missing = [...expected].filter(key => !Object.hasOwn(args, key))
|
||||
if (extra.length === 0 && missing.length === 0) return
|
||||
const clauses: string[] = []
|
||||
if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
|
||||
if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`)
|
||||
throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`)
|
||||
}
|
||||
|
||||
function decode(
|
||||
codec: TypeRTCodec,
|
||||
value: unknown,
|
||||
code: 'input-invalid' | 'result-invalid',
|
||||
endpoint: string,
|
||||
field: string,
|
||||
): unknown {
|
||||
try {
|
||||
if (codec.mode === 'strict') value = codec.schema.parse(value)
|
||||
assertJsonValue(value, new Set())
|
||||
return value
|
||||
} catch (cause) {
|
||||
throw new TypertGatewayError(
|
||||
code,
|
||||
endpoint,
|
||||
code === 'input-invalid'
|
||||
? `wire field ${JSON.stringify(field)} failed boundary validation`
|
||||
: 'business result failed boundary validation',
|
||||
{ cause, field },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertJsonValue(value: unknown, ancestors: Set<object>): void {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isFinite(value)) return
|
||||
throw new TypeError('non-finite number is not JSON-safe')
|
||||
}
|
||||
if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`)
|
||||
if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe')
|
||||
ancestors.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) {
|
||||
throw new TypeError('sparse or decorated array is not JSON-safe')
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (!Object.hasOwn(value, index)) throw new TypeError('sparse array is not JSON-safe')
|
||||
assertJsonValue(value[index], ancestors)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe')
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe')
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
||||
/* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */
|
||||
if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError('non-data property is not JSON-safe')
|
||||
}
|
||||
assertJsonValue(descriptor.value, ancestors)
|
||||
}
|
||||
} finally {
|
||||
ancestors.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: object): value is Record<string, unknown> {
|
||||
if (Array.isArray(value)) return false
|
||||
const prototype = Object.getPrototypeOf(value) as object | null
|
||||
return prototype === null || prototype === Object.prototype
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is object {
|
||||
return (typeof value === 'object' && value !== null) || typeof value === 'function'
|
||||
}
|
||||
|
||||
export default TypertGatewayService
|
||||
30
packages/api/gateway/src/invariant.ts
Normal file
30
packages/api/gateway/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-api-gateway`.
|
||||
* @module @deepseek-ai/dsh-api-gateway/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-gateway'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'api-gateway-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: Host calls re-read authoritative Cordis and TypeRT
|
||||
* state, while Client methods and descriptors mutate in one owned effect.
|
||||
*/
|
||||
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 */
|
||||
54
packages/api/gateway/src/types.ts
Normal file
54
packages/api/gateway/src/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Carrier-independent TypeRT Gateway request, service, and error contracts.
|
||||
* @module @deepseek-ai/dsh-api-gateway/types
|
||||
*/
|
||||
|
||||
/** One Remote method request after a carrier has decoded its envelope. */
|
||||
export interface InvokeRemoteRequest {
|
||||
/** Remote namespace selected by the generated descriptor. */
|
||||
readonly namespace: string
|
||||
/** Exported Service method name. */
|
||||
readonly method: string
|
||||
/** Named wire values; fields must exactly match the descriptor. */
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Stable infrastructure and boundary failures emitted before or after business execution. */
|
||||
export type TypertGatewayErrorCode =
|
||||
| 'ambiguous-endpoint'
|
||||
| 'arguments-invalid'
|
||||
| 'binding-invalid'
|
||||
| 'context-failed'
|
||||
| 'context-not-found'
|
||||
| 'context-unavailable'
|
||||
| 'definition-unavailable'
|
||||
| 'input-invalid'
|
||||
| 'invocation-unavailable'
|
||||
| 'lookup-failed'
|
||||
| 'lookup-not-found'
|
||||
| 'lookup-unavailable'
|
||||
| 'method-unavailable'
|
||||
| 'provider-mismatch'
|
||||
| 'result-invalid'
|
||||
| 'service-unavailable'
|
||||
| 'signature-invalid'
|
||||
|
||||
/** Host dispatcher consumed by Connection adapters. */
|
||||
export interface TypertGateway {
|
||||
/**
|
||||
* Invoke one live Remote method without assuming a carrier or response envelope.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Host dispatcher for TypeRT Remote calls. */
|
||||
typertGateway: TypertGateway
|
||||
}
|
||||
}
|
||||
573
packages/api/gateway/tests/client.spec.ts
Normal file
573
packages/api/gateway/tests/client.spec.ts
Normal file
@@ -0,0 +1,573 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTClientRemote,
|
||||
TypeRTContext,
|
||||
TypeRTRemoteScopeApi,
|
||||
TypeRTRemoteNamespace,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
fixture: TypeRTContext<string>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteMap {
|
||||
'goals/create': (
|
||||
agentId: string,
|
||||
request: { readonly objective: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ readonly ref: string }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteScopeMap {
|
||||
'fixture:goals/create': (
|
||||
request: { readonly objective: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ readonly ref: string }>
|
||||
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteNamespaceMap {
|
||||
goals: TypeRTRemoteNamespace<'goals'>
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type FixtureContext = Omit<Context, 'remote'> & {
|
||||
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'>
|
||||
}
|
||||
|
||||
const idSchema = z.string().min(1)
|
||||
const requestSchema = z.object({ objective: z.string().min(1) })
|
||||
const createResultSchema = z.object({ ref: z.string().min(1) })
|
||||
const renameResultSchema = z.object({ renamed: z.boolean() })
|
||||
|
||||
function directDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
scope: { context: 'fixture', wire: 'agentId' },
|
||||
parameters: [{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'fixture',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
}, {
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
|
||||
}],
|
||||
cancellation: { parameter: 'signal' },
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
|
||||
}
|
||||
}
|
||||
|
||||
function contextDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/rename',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'fixture',
|
||||
wire: 'agentId',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
},
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema },
|
||||
}],
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema },
|
||||
}
|
||||
}
|
||||
|
||||
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
|
||||
await ctx.plugin({ inject, apply })
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('Client TypeRT API', () => {
|
||||
it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const businessGoals = { owner: 'host business service' }
|
||||
const disposeBusinessGoals = ctx.provide('goals', businessGoals)
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
const retained = ctx.remote.goals.create
|
||||
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/create',
|
||||
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
const callerAbort = new AbortController()
|
||||
await expect(ctx.remote.goals.create(
|
||||
'agent-1',
|
||||
{ objective: 'cancel me' },
|
||||
callerAbort.signal,
|
||||
)).resolves.toEqual({ ref: 'goal-1' })
|
||||
const combinedSignal = call.mock.calls.at(-1)?.[3]
|
||||
expect(combinedSignal).toBeInstanceOf(AbortSignal)
|
||||
expect(combinedSignal).not.toBe(callerAbort.signal)
|
||||
const cancellation = new Error('caller cancelled')
|
||||
callerAbort.abort(cancellation)
|
||||
expect(combinedSignal?.aborted).toBe(true)
|
||||
expect(combinedSignal?.reason).toBe(cancellation)
|
||||
await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
|
||||
|
||||
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBe(businessGoals)
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
|
||||
disposeBusinessGoals()
|
||||
})
|
||||
|
||||
it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-2' } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/create',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' }))
|
||||
.rejects.toThrow('expected 2 business argument(s)')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the caller Context identity for scoped namespace methods', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { renamed: true } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/rename',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'land' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' }))
|
||||
.rejects.toThrow('requires a "fixture" Context')
|
||||
|
||||
await assembly.dispose()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects weak descriptors and namespace collisions before registration', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const weak: InvocationDescriptor = {
|
||||
...directDescriptor(),
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] }))
|
||||
.rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/conflict',
|
||||
descriptors: [{ ...directDescriptor(), namespace: '$mount' }],
|
||||
})).rejects.toThrow('conflicts with the Remote service')
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { renamed: true } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-duplicates',
|
||||
descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }],
|
||||
})).rejects.toThrow('repeats direct method')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/scoped-duplicates',
|
||||
descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }],
|
||||
})).rejects.toThrow('repeats scoped method')
|
||||
|
||||
const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }],
|
||||
})).rejects.toThrow('direct method goals/create is already mounted')
|
||||
await disposeDirect()
|
||||
|
||||
const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }],
|
||||
})).rejects.toThrow('scoped method goals/rename is already mounted')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/service-method-conflict',
|
||||
descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }],
|
||||
})).rejects.toThrow('conflicts with its namespace service')
|
||||
const scopedService = ctx.get('remote.goals') as unknown as object
|
||||
Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/service-own-property-conflict',
|
||||
descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }],
|
||||
})).rejects.toThrow('conflicts with its namespace service')
|
||||
Reflect.deleteProperty(scopedService, 'custom')
|
||||
await disposeScoped()
|
||||
|
||||
const disposeRemoteTypert = ctx.reflect.provide('remote.typert', { owner: 'fixture' })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/context-property-conflict',
|
||||
descriptors: [{ ...context, namespace: 'typert' }],
|
||||
})).rejects.toThrow('conflicts with an existing Remote namespace')
|
||||
await disposeRemoteTypert()
|
||||
|
||||
const disposeMultipleScoped = await ctx.remote.$mount({
|
||||
package: '@fixture/multiple-scoped',
|
||||
descriptors: [directDescriptor(), contextDescriptor()],
|
||||
})
|
||||
await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'/api',
|
||||
'goals/rename',
|
||||
{ args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await disposeMultipleScoped()
|
||||
})
|
||||
|
||||
it('rolls back earlier descriptors when a later descriptor fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const { scope: _scope, ...first } = directDescriptor()
|
||||
const second: InvocationDescriptor = {
|
||||
...first,
|
||||
id: '@fixture/goals#goals/archive',
|
||||
method: 'archive',
|
||||
}
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'archive') throw new Error('fixture later-descriptor failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/failing-batch', descriptors: [first, second] }))
|
||||
.rejects.toThrow('fixture later-descriptor failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
expect((ctx.remote.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('rolls back a direct projection when its scoped projection fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const disposeContext = await ctx.remote.$mount({
|
||||
package: '@fixture/context-anchor',
|
||||
descriptors: [contextDescriptor()],
|
||||
})
|
||||
const namespace = ctx.get('remote.goals') as unknown as {
|
||||
installScoped: (...args: unknown[]) => void
|
||||
readonly create?: unknown
|
||||
}
|
||||
const installScoped = vi.spyOn(namespace, 'installScoped').mockImplementation(() => {
|
||||
throw new Error('fixture scoped projection failure')
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-projection-failure',
|
||||
descriptors: [directDescriptor()],
|
||||
})).rejects.toThrow('fixture scoped projection failure')
|
||||
} finally {
|
||||
installScoped.mockRestore()
|
||||
}
|
||||
|
||||
expect(namespace.create).toBeUndefined()
|
||||
await disposeContext()
|
||||
})
|
||||
|
||||
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/weak-parameter',
|
||||
descriptors: [{
|
||||
...direct,
|
||||
parameters: direct.parameters.map((parameter, index) => index === 0
|
||||
? { ...parameter, codec: { mode: 'src-json' } }
|
||||
: parameter),
|
||||
}],
|
||||
})).rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/weak-context',
|
||||
descriptors: [{
|
||||
...context,
|
||||
invocation: { ...context.invocation, codec: { mode: 'src-json' } },
|
||||
} as InvocationDescriptor],
|
||||
})).rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/malformed-scope',
|
||||
descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }],
|
||||
})).rejects.toThrow('scope must select its only lookup parameter')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/ambiguous-scope',
|
||||
descriptors: [{
|
||||
...direct,
|
||||
parameters: [...direct.parameters, {
|
||||
name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
}],
|
||||
}],
|
||||
})).rejects.toThrow('scope must select its only lookup parameter')
|
||||
})
|
||||
|
||||
it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const descriptor = directDescriptor()
|
||||
const dispose = await ctx.remote.$mount({
|
||||
package: '@fixture/goals',
|
||||
descriptors: [descriptor, contextDescriptor()],
|
||||
})
|
||||
const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
const goals = (ctx as FixtureContext).remote.goals
|
||||
const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
|
||||
await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1')
|
||||
await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra'))
|
||||
.rejects.toThrow('got 4')
|
||||
await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0')
|
||||
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' }))
|
||||
.rejects.toThrow('expected 2 business argument(s)')
|
||||
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' }))
|
||||
.rejects.toThrow('no Client Context binder')
|
||||
|
||||
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
|
||||
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
|
||||
|
||||
ctx.set('connection', undefined)
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('withdraws a pending invocation and preserves a direct namespace until its last method leaves', async () => {
|
||||
let resolveCall!: (result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>) => void
|
||||
const pending = new Promise<Awaited<ReturnType<ConnectionHandle['rpc']['call']>>>((resolve) => {
|
||||
resolveCall = resolve
|
||||
})
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>().mockReturnValue(pending)
|
||||
const ctx = await bench(call)
|
||||
const { scope: _scope, ...first } = directDescriptor()
|
||||
const second: InvocationDescriptor = {
|
||||
...first,
|
||||
id: '@fixture/goals#goals/archive',
|
||||
method: 'archive',
|
||||
}
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] })
|
||||
const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' })
|
||||
await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
|
||||
await dispose()
|
||||
resolveCall({ ok: true, value: { ref: 'goal-1' } })
|
||||
|
||||
await expect(invocation).rejects.toThrow('withdrawn during invocation')
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails a method obtained from a withdrawn namespace getter', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
const namespace = ctx.get('remote.goals') as unknown as object
|
||||
const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace)
|
||||
|
||||
await dispose()
|
||||
|
||||
expect(getWithdrawn).toBeTypeOf('function')
|
||||
const withdrawn = getWithdrawn?.() as (...args: unknown[]) => Promise<unknown>
|
||||
expect(() => withdrawn('agent-1', { objective: 'ship' }))
|
||||
.toThrow('Remote method is no longer mounted')
|
||||
})
|
||||
|
||||
it('preserves a __proto__ wire parameter as an own named argument', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const { scope: _scope, ...base } = directDescriptor()
|
||||
const descriptor: InvocationDescriptor = {
|
||||
...base,
|
||||
id: '@fixture/goals#goals/prototype',
|
||||
method: 'prototype',
|
||||
parameters: [{
|
||||
name: 'value',
|
||||
wire: '__proto__',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() },
|
||||
}],
|
||||
}
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] })
|
||||
|
||||
const method = (ctx.remote.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
|
||||
await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' })
|
||||
const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> }
|
||||
expect(Object.getPrototypeOf(payload.args)).toBeNull()
|
||||
expect(Object.hasOwn(payload.args, '__proto__')).toBe(true)
|
||||
expect(payload.args.__proto__).toBe('wire-value')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('rolls back Remote registration when namespace Service startup fails', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === Service.tracker) throw new Error('fixture namespace startup failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
|
||||
.rejects.toThrow('fixture namespace startup failure')
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] })
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('withdraws a fresh direct namespace when its first method fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'create') throw new Error('fixture direct method installation failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-method-failure',
|
||||
descriptors: [directDescriptor()],
|
||||
})).rejects.toThrow('fixture direct method installation failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = await ctx.remote.$mount({
|
||||
package: '@fixture/direct-method-retry',
|
||||
descriptors: [directDescriptor()],
|
||||
})
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('withdraws a fresh scoped Service when its first method fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'rename') throw new Error('fixture scoped installation failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] }))
|
||||
.rejects.toThrow('fixture scoped installation failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] })
|
||||
expect((ctx.get('remote.goals') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
|
||||
expect(ctx.get('remote.goals')).toBeDefined()
|
||||
|
||||
await dispose()
|
||||
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
const replacement = { owner: 'replacement' }
|
||||
const disposeReplacement = ctx.reflect.provide('remote.goals', replacement)
|
||||
expect(ctx.get('remote.goals')).toBe(replacement)
|
||||
await disposeReplacement()
|
||||
})
|
||||
|
||||
it('throws RPC failures with the structured error as its cause', async () => {
|
||||
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
|
||||
await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
await ctx.remote.goals.create('agent-1', { objective: 'ship' })
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail')
|
||||
expect(failure.message).toContain('internal: host failed')
|
||||
expect(failure.cause).toBe(rpcError)
|
||||
})
|
||||
})
|
||||
1317
packages/api/gateway/tests/gateway.spec.ts
Normal file
1317
packages/api/gateway/tests/gateway.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
27
packages/api/gateway/tsconfig.json
Normal file
27
packages/api/gateway/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/api/gateway/tsdown.config.ts
Normal file
3
packages/api/gateway/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
6
packages/api/remotes/README.i18n.yaml
Normal file
6
packages/api/remotes/README.i18n.yaml
Normal 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/api/remotes/README.md
|
||||
README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a
|
||||
README.zh.md: 7490d68781d3a7b0002b73fe06056ec86c144575
|
||||
33
packages/api/remotes/README.md
Normal file
33
packages/api/remotes/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-api-remotes
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.
|
||||
|
||||
`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation.
|
||||
|
||||
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway.
|
||||
|
||||
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract.
|
||||
|
||||
## Build boundary
|
||||
|
||||
An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations.
|
||||
|
||||
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory.
|
||||
|
||||
The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this BFF selects Remote application methods and identity policy but registers no model surface.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct effect; mounted Host capabilities own any model-visible behavior they trigger.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime.
|
||||
- Additional capabilities require an explicit `/remote` value import and mount in this assembly.
|
||||
- The standard Web Host supplies resume defaults and Agent-scope setup from the legacy API Proxy until that remaining BFF configuration moves into `api-remotes`.
|
||||
33
packages/api/remotes/README.zh.md
Normal file
33
packages/api/remotes/README.zh.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-api-remotes
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
|
||||
|
||||
`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。
|
||||
|
||||
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。
|
||||
|
||||
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。
|
||||
|
||||
## 构建边界
|
||||
|
||||
仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。
|
||||
|
||||
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。
|
||||
|
||||
包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。
|
||||
- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。
|
||||
- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。
|
||||
64
packages/api/remotes/package.json
Normal file
64
packages/api/remotes/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remotes",
|
||||
"description": "Remote BFF assembly and Host Agent/Session lookup policy",
|
||||
"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"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
194
packages/api/remotes/src/agent-lookup.ts
Normal file
194
packages/api/remotes/src/agent-lookup.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/** Host BFF policy for resolving Remote Agent and Session identities. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
import type {} from '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
/** Caller-facing failures preserved by the Gateway's RPC adapter. */
|
||||
export type ApiRemoteLookupError =
|
||||
| { readonly code: 'agent-busy'; readonly message: string; readonly details: { readonly reason: string } }
|
||||
| { readonly code: 'session-not-found'; readonly message: string; readonly details: { readonly sessionId: SessionId } }
|
||||
| { readonly code: 'internal'; readonly message: string; readonly details: Record<never, never> }
|
||||
|
||||
/** Result of resolving one session identity to its live Agent. */
|
||||
export type ApiRemoteAgentResult =
|
||||
| { readonly agent: Agent }
|
||||
| { readonly error: ApiRemoteLookupError }
|
||||
|
||||
/** Resume configuration supplied by the owning Host composition. */
|
||||
export interface ApiRemoteAgentOptions {
|
||||
/** Read the per-Agent defaults when a cold identity must resume. */
|
||||
readonly agentOptions?: () => AgentOptions
|
||||
/** Host-specific Agent-scope composition completed before publication. */
|
||||
readonly setup?: AgentSetup
|
||||
}
|
||||
|
||||
/** Cold identity absent from the durable session store. */
|
||||
export class ApiRemoteSessionNotFound extends Error {}
|
||||
|
||||
/** Session identity whose lifecycle belongs to subagent routing. */
|
||||
export class ApiRemoteSubagentSessionOwnership extends Error {
|
||||
/**
|
||||
* Construct the ownership fence.
|
||||
* @param sessionId - identity reserved to subagent routing.
|
||||
*/
|
||||
constructor(readonly sessionId: SessionId) {
|
||||
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether generic Host routing must leave an identity to subagent routing.
|
||||
* @param ctx - Host Context carrying the live Agent registry.
|
||||
* @param session - attached or live Session metadata.
|
||||
* @param agent - live Agent when one is registered.
|
||||
* @returns whether generic Remote and legacy API calls must reject the identity.
|
||||
*/
|
||||
export function hasApiRemoteSubagentOwner(
|
||||
ctx: Context,
|
||||
session: Pick<Session, 'header'>,
|
||||
agent: Agent | undefined,
|
||||
): boolean {
|
||||
if (session.header.origin === 'subagent') return true
|
||||
const parentId = session.header.parentSession
|
||||
if (parentId === undefined || agent === undefined) return false
|
||||
const parent = ctx.agents.get(parentId)
|
||||
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stable caller-facing ownership rejection.
|
||||
* @param sessionId - identity reserved to subagent routing.
|
||||
* @returns the existing `agent-busy` RPC shape.
|
||||
*/
|
||||
export function apiRemoteSubagentOwnershipError(sessionId: SessionId): ApiRemoteLookupError {
|
||||
return {
|
||||
code: 'agent-busy',
|
||||
message: `session "${sessionId}" is owned by subagent routing`,
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect one cold served session without repairing, resuming, or publishing it.
|
||||
* @param ctx - Host Context carrying the optional persistence provider.
|
||||
* @param sessionId - durable identity to inspect.
|
||||
* @returns detached metadata and events for a servable session.
|
||||
* @throws {@link ApiRemoteSessionNotFound} when the identity has no project-backed session.
|
||||
*/
|
||||
export async function inspectApiRemoteSession(
|
||||
ctx: Context,
|
||||
sessionId: SessionId,
|
||||
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
|
||||
}
|
||||
const meta = (await persistence.list()).find(candidate => candidate.id === sessionId)
|
||||
if (meta === undefined || meta.cwd === undefined) {
|
||||
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
const inspected = await persistence.inspect(sessionId)
|
||||
if (inspected.meta.cwd === undefined) {
|
||||
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
return { meta: inspected.meta, events: [...inspected.events] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Host's shared Agent resolver and configure Agent/Session TypeRT lookups.
|
||||
* Live Agents are reused, ordinary cold sessions resume once per identity, and
|
||||
* subagent-owned identities retain the legacy `agent-busy` fence.
|
||||
* @param ctx - owning Host Context.
|
||||
* @param options - defaults and Agent-scope setup used only for cold resume.
|
||||
* @returns resolver shared by legacy API Proxy methods and TypeRT lookups.
|
||||
*/
|
||||
export function createApiRemoteAgentResolver(
|
||||
ctx: Context,
|
||||
options: ApiRemoteAgentOptions,
|
||||
): (sessionId: SessionId) => Promise<ApiRemoteAgentResult> {
|
||||
const resumes = new Map<SessionId, Promise<Agent>>()
|
||||
|
||||
const fencedLiveAgent = (sessionId: SessionId): ApiRemoteAgentResult | undefined => {
|
||||
const live = ctx.agents.get(sessionId)
|
||||
if (live === undefined) return undefined
|
||||
if (hasApiRemoteSubagentOwner(ctx, live.session, live)) {
|
||||
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
return { agent: live }
|
||||
}
|
||||
|
||||
const agentFor = async (sessionId: SessionId): Promise<ApiRemoteAgentResult> => {
|
||||
const fenced = fencedLiveAgent(sessionId)
|
||||
if (fenced !== undefined) return fenced
|
||||
const attached = ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
|
||||
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
let resume = resumes.get(sessionId)
|
||||
if (resume === undefined) {
|
||||
resume = (async () => {
|
||||
try {
|
||||
const inspected = await inspectApiRemoteSession(ctx, sessionId)
|
||||
if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) {
|
||||
throw new ApiRemoteSubagentSessionOwnership(sessionId)
|
||||
}
|
||||
const publishedSession = ctx.sessions.get(sessionId)
|
||||
const publishedAgent = ctx.agents.get(sessionId)
|
||||
if (publishedSession !== undefined
|
||||
&& hasApiRemoteSubagentOwner(ctx, publishedSession, publishedAgent)) {
|
||||
throw new ApiRemoteSubagentSessionOwnership(sessionId)
|
||||
}
|
||||
const handle = await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() },
|
||||
...options.setup === undefined ? {} : { setup: options.setup },
|
||||
})
|
||||
return handle.agent
|
||||
} finally {
|
||||
resumes.delete(sessionId)
|
||||
}
|
||||
})()
|
||||
resumes.set(sessionId, resume)
|
||||
}
|
||||
try {
|
||||
return { agent: await resume }
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiRemoteSessionNotFound) {
|
||||
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
|
||||
}
|
||||
if (error instanceof ApiRemoteSubagentSessionOwnership) {
|
||||
return { error: apiRemoteSubagentOwnershipError(error.sessionId) }
|
||||
}
|
||||
const fenced = fencedLiveAgent(sessionId)
|
||||
if (fenced !== undefined) return fenced
|
||||
const attached = ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
|
||||
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
return {
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: `resume failed for session "${sessionId}": ${String(error)}`,
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.inject(['typert'], (typeCtx) => {
|
||||
const resolveAgent = async (sessionId: SessionId): Promise<Agent> => {
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) throw new TypeRTLookupFailure(found.error)
|
||||
return found.agent
|
||||
}
|
||||
typeCtx.typert.lookups.configure('agent', resolveAgent)
|
||||
typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session)
|
||||
typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx)
|
||||
})
|
||||
|
||||
return agentFor
|
||||
}
|
||||
27
packages/api/remotes/src/client/index.ts
Normal file
27
packages/api/remotes/src/client/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** Platform-neutral assembly of generated Host Remote contributions. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
|
||||
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
export type {} from '@deepseek-ai/dsh-goal/remote'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Generated Remote namespaces selected by this Client assembly. */
|
||||
remote: TypeRTClientRemote
|
||||
}
|
||||
}
|
||||
|
||||
/** Required service: the typed Client Remote contribution mount. */
|
||||
export const inject = ['remote']
|
||||
|
||||
/**
|
||||
* Mount the Host capabilities explicitly selected for this Client assembly.
|
||||
* @param ctx - Client Cordis root carrying the typed API service.
|
||||
* @returns disposer after every selected Remote namespace is ready.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
return await ctx.remote.$mount(goalsRemote)
|
||||
}
|
||||
18
packages/api/remotes/src/index.ts
Normal file
18
packages/api/remotes/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
|
||||
|
||||
export {
|
||||
ApiRemoteSessionNotFound,
|
||||
ApiRemoteSubagentSessionOwnership,
|
||||
apiRemoteSubagentOwnershipError,
|
||||
createApiRemoteAgentResolver,
|
||||
hasApiRemoteSubagentOwner,
|
||||
inspectApiRemoteSession,
|
||||
} from './agent-lookup.ts'
|
||||
export type {
|
||||
ApiRemoteAgentOptions,
|
||||
ApiRemoteAgentResult,
|
||||
ApiRemoteLookupError,
|
||||
} from './agent-lookup.ts'
|
||||
|
||||
/** Host plugin body; the selected contributions mount only in Client environments. */
|
||||
export function apply(): void {}
|
||||
24
packages/api/remotes/src/invariant.ts
Normal file
24
packages/api/remotes/src/invariant.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'api-remotes-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */
|
||||
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 */
|
||||
154
packages/api/remotes/tests/agent-lookup.spec.ts
Normal file
154
packages/api/remotes/tests/agent-lookup.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
|
||||
function header(id: SessionId): SessionHeader {
|
||||
return { version: 0, id, createdAt: 1, cwd: '/proj' }
|
||||
}
|
||||
|
||||
async function createContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function provideSession(
|
||||
ctx: Context,
|
||||
meta: SessionHeader,
|
||||
inspect: () => Promise<{ meta: SessionHeader; events: SessionEvent[] }>,
|
||||
): void {
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
}
|
||||
|
||||
function stubAgent(ctx: Context, session: Session): Agent {
|
||||
return { id: session.id, session, status: 'idle', ctx } as Agent
|
||||
}
|
||||
|
||||
describe('API Remote Agent resolver races', () => {
|
||||
it('maps an inspected session without a cwd to session-not-found', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('missing-after-inspect')
|
||||
const meta = header(sessionId)
|
||||
provideSession(ctx, meta, () => Promise.resolve({
|
||||
meta: { ...meta, cwd: undefined } as unknown as SessionHeader,
|
||||
events: [],
|
||||
}))
|
||||
|
||||
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
|
||||
|
||||
expect(result).toMatchObject({ error: { code: 'session-not-found', details: { sessionId } } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resumes through a concurrently attached ordinary Session without optional defaults', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('ordinary-attach-race')
|
||||
const meta = header(sessionId)
|
||||
let published: Session | undefined
|
||||
provideSession(ctx, meta, () => {
|
||||
published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } })
|
||||
return Promise.resolve({ meta, events: [] })
|
||||
})
|
||||
const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
|
||||
if (published === undefined) throw new Error('Session was not published')
|
||||
return { agent: stubAgent(ctx, published), dispose: () => Promise.resolve() }
|
||||
})
|
||||
|
||||
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
|
||||
|
||||
expect(result).toMatchObject({ agent: { id: sessionId } })
|
||||
expect(resume).toHaveBeenCalledWith({ resumeSessionId: sessionId })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a subagent Session published after durable inspection', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('owned-attach-race')
|
||||
const meta = header(sessionId)
|
||||
provideSession(ctx, meta, () => {
|
||||
ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
|
||||
return Promise.resolve({ meta, events: [] })
|
||||
})
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
|
||||
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
|
||||
|
||||
expect(result).toMatchObject({ error: { code: 'agent-busy' } })
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reclassifies failed resumes after a live or attached subagent wins publication', async () => {
|
||||
for (const winner of ['agent', 'session'] as const) {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid(`owned-${winner}-resume-race`)
|
||||
const meta = header(sessionId)
|
||||
provideSession(ctx, meta, () => Promise.resolve({ meta, events: [] }))
|
||||
vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
|
||||
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
|
||||
if (winner === 'agent') ctx.agents.register(stubAgent(ctx, session))
|
||||
throw new Error('session id already published')
|
||||
})
|
||||
|
||||
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
|
||||
|
||||
expect(result).toMatchObject({ error: { code: 'agent-busy' } })
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the shared cold-resume policy for the Agent Host Context', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('context-cold-resume')
|
||||
const meta = header(sessionId)
|
||||
let published: Session | undefined
|
||||
provideSession(ctx, meta, () => {
|
||||
published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } })
|
||||
return Promise.resolve({ meta, events: [] })
|
||||
})
|
||||
const agentCtx = ctx.extend()
|
||||
vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
|
||||
if (published === undefined) throw new Error('Session was not published')
|
||||
return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() }
|
||||
})
|
||||
const defaultProvider = ctx.typert.contexts.getHost('agent')
|
||||
createApiRemoteAgentResolver(ctx, {})
|
||||
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
|
||||
const provider = ctx.typert.contexts.getHost('agent')
|
||||
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
|
||||
|
||||
await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('applies the subagent ownership fence to the Agent Host Context', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('context-owned-subagent')
|
||||
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
|
||||
ctx.agents.register(stubAgent(ctx.extend(), session))
|
||||
const defaultProvider = ctx.typert.contexts.getHost('agent')
|
||||
createApiRemoteAgentResolver(ctx, {})
|
||||
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
|
||||
const provider = ctx.typert.contexts.getHost('agent')
|
||||
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
|
||||
|
||||
const resolution = provider.resolve(sessionId)
|
||||
await expect(resolution).rejects.toBeInstanceOf(TypeRTLookupFailure)
|
||||
await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
224
packages/api/remotes/tests/built-lib.e2e.ts
Normal file
224
packages/api/remotes/tests/built-lib.e2e.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Built-artifact smoke for the first generated Remote: plain Node boots the
|
||||
* Host and Browser bundle handoffs, then crosses the shared `/api` HTTP route.
|
||||
*/
|
||||
|
||||
const packageDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const root = resolve(packageDir, '../../..')
|
||||
const artifact = (path: string): string => join(root, path)
|
||||
const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href
|
||||
|
||||
const requiredArtifacts = [
|
||||
'packages/client/connection/lib/client.js',
|
||||
'packages/client/connection/lib/index.js',
|
||||
'packages/api/remotes/lib/client.js',
|
||||
'packages/core/agent/lib/index.js',
|
||||
'packages/core/session/lib/index.js',
|
||||
'packages/goal/goal/lib/index.js',
|
||||
'packages/goal/goal/lib/typert.host.js',
|
||||
'packages/api/gateway/lib/client.js',
|
||||
'packages/api/gateway/lib/index.js',
|
||||
'packages/typert/registry/lib/client.js',
|
||||
'packages/typert/registry/lib/index.js',
|
||||
].every(path => existsSync(artifact(path)))
|
||||
|
||||
describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
|
||||
it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => {
|
||||
const urls = Object.fromEntries(Object.entries({
|
||||
agent: 'packages/core/agent/lib/index.js',
|
||||
apiGatewayClient: 'packages/api/gateway/lib/client.js',
|
||||
apiGatewayHost: 'packages/api/gateway/lib/index.js',
|
||||
connectionClient: 'packages/client/connection/lib/client.js',
|
||||
connectionHost: 'packages/client/connection/lib/index.js',
|
||||
goal: 'packages/goal/goal/lib/index.js',
|
||||
goalTypert: 'packages/goal/goal/lib/typert.host.js',
|
||||
registryClient: 'packages/typert/registry/lib/client.js',
|
||||
registryHost: 'packages/typert/registry/lib/index.js',
|
||||
remotesClient: 'packages/api/remotes/lib/client.js',
|
||||
session: 'packages/core/session/lib/index.js',
|
||||
}).map(([key, path]) => [key, artifactUrl(path)]))
|
||||
const script = `
|
||||
import { createServer } from 'node:http'
|
||||
import * as cordis from 'cordis'
|
||||
|
||||
const urls = ${JSON.stringify(urls)}
|
||||
const { Context } = cordis
|
||||
const { default: AgentRegistry } = await import(urls.agent)
|
||||
const connectionHost = await import(urls.connectionHost)
|
||||
const { default: TypertGatewayService } = await import(urls.apiGatewayHost)
|
||||
const { default: GoalService } = await import(urls.goal)
|
||||
const { TYPERT } = await import(urls.goalTypert)
|
||||
const { default: TypertRegistry } = await import(urls.registryHost)
|
||||
const { Session, SessionId } = await import(urls.session)
|
||||
|
||||
const routes = []
|
||||
const host = new Context()
|
||||
host.provide('httpServer', {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex() { return () => {} },
|
||||
port: 0,
|
||||
})
|
||||
await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply })
|
||||
await host.plugin(TypertRegistry)
|
||||
await host.plugin(AgentRegistry)
|
||||
await host.plugin(TypertGatewayService)
|
||||
await host.plugin(GoalService)
|
||||
host.typert.register(TYPERT)
|
||||
|
||||
const makeAgent = rawId => {
|
||||
const session = new Session(SessionId(rawId))
|
||||
return {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
ctx: host.extend(),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
send() {},
|
||||
updateInbox() { return 'not-found' },
|
||||
followup() {},
|
||||
steer() { return { outcome: Promise.resolve({ status: 'rejected' }) } },
|
||||
inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) },
|
||||
reserveTurnAdmission() {},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
}
|
||||
const rootAgent = makeAgent('built-root-agent')
|
||||
const scopedAgent = makeAgent('built-scoped-agent')
|
||||
host.agents.register(rootAgent)
|
||||
host.agents.register(scopedAgent)
|
||||
|
||||
if (routes.length !== 1 || routes[0].path !== '/api') {
|
||||
throw new Error('Connection did not register exactly one /api route')
|
||||
}
|
||||
const server = createServer((request, response) => { void routes[0].handler(request, response) })
|
||||
await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address')
|
||||
const origin = 'http://127.0.0.1:' + String(address.port)
|
||||
|
||||
const handoffs = new Map()
|
||||
globalThis.window = {
|
||||
__ModuleLoader__: {
|
||||
load(handoff) { handoffs.set(handoff.id, handoff) },
|
||||
},
|
||||
}
|
||||
globalThis.location = { hostname: '127.0.0.1', origin, search: '' }
|
||||
await import(urls.registryClient)
|
||||
await import(urls.connectionClient)
|
||||
await import(urls.apiGatewayClient)
|
||||
await import(urls.remotesClient)
|
||||
|
||||
const instantiate = id => {
|
||||
const handoff = handoffs.get(id)
|
||||
if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id)
|
||||
return handoff.factory(specifier => {
|
||||
if (specifier === 'cordis') return cordis
|
||||
throw new Error('unexpected Client external ' + specifier)
|
||||
})
|
||||
}
|
||||
const client = new Context()
|
||||
for (const id of [
|
||||
'@deepseek-ai/dsh-typert-registry',
|
||||
'@deepseek-ai/dsh-client-connection',
|
||||
'@deepseek-ai/dsh-api-gateway',
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
]) {
|
||||
const plugin = instantiate(id)
|
||||
await client.plugin({ inject: plugin.inject, apply: plugin.apply })
|
||||
}
|
||||
client.typert.contexts.registerClient('agent', {
|
||||
identity: candidate => candidate.builtAgentId,
|
||||
})
|
||||
|
||||
let invalidRejected = false
|
||||
try {
|
||||
await client.remote.goals.create(rootAgent.id, { objective: 1 })
|
||||
} catch {
|
||||
invalidRejected = true
|
||||
}
|
||||
const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
|
||||
const rootEdit = await client.remote.goals.edit(
|
||||
rootAgent.id,
|
||||
rootResult.ref,
|
||||
{ objective: 'edited root goal' },
|
||||
)
|
||||
const agentContext = client.extend({ builtAgentId: scopedAgent.id })
|
||||
const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
|
||||
const result = {
|
||||
invalidRejected,
|
||||
rootResult,
|
||||
rootEdit,
|
||||
scopedResult,
|
||||
rootGoal: host.goals.get(rootAgent)?.objective,
|
||||
scopedGoal: host.goals.get(scopedAgent)?.objective,
|
||||
rootEvents: rootAgent.session.events.length,
|
||||
scopedEvents: scopedAgent.session.events.length,
|
||||
}
|
||||
|
||||
await client.fiber.dispose()
|
||||
await new Promise((resolveClose, rejectClose) => server.close(error => {
|
||||
if (error === undefined) resolveClose()
|
||||
else rejectClose(error)
|
||||
}))
|
||||
await host.fiber.dispose()
|
||||
console.log(JSON.stringify(result))
|
||||
`
|
||||
|
||||
const result = await runPlainNode(script)
|
||||
expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0)
|
||||
const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as {
|
||||
invalidRejected: boolean
|
||||
rootResult: { ref: { id: string; revision: number } }
|
||||
rootEdit: { objective: string; revision: number }
|
||||
scopedResult: { ref: { id: string; revision: number } }
|
||||
rootGoal: string
|
||||
scopedGoal: string
|
||||
rootEvents: number
|
||||
scopedEvents: number
|
||||
}
|
||||
expect(output).toMatchObject({
|
||||
invalidRejected: true,
|
||||
rootResult: { ref: { revision: 1 } },
|
||||
rootEdit: { objective: 'edited root goal', revision: 2 },
|
||||
scopedResult: { ref: { revision: 1 } },
|
||||
rootGoal: 'edited root goal',
|
||||
scopedGoal: 'scoped goal',
|
||||
rootEvents: 2,
|
||||
scopedEvents: 1,
|
||||
})
|
||||
expect(output.rootResult.ref.id).toMatch(/^goal-/)
|
||||
expect(output.scopedResult.ref.id).toMatch(/^goal-/)
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
/** Execute one ESM script without tsx or a TypeScript loader. */
|
||||
function runPlainNode(script: string): Promise<{
|
||||
readonly exitCode: number | null
|
||||
readonly stdout: string
|
||||
readonly stderr: string
|
||||
}> {
|
||||
return new Promise((resolveRun) => {
|
||||
execFile(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: packageDir,
|
||||
encoding: 'utf8',
|
||||
timeout: 55_000,
|
||||
}, (error, stdout, stderr) => {
|
||||
resolveRun({
|
||||
exitCode: error === null ? 0 : typeof error.code === 'number' ? error.code : null,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
22
packages/api/remotes/tsconfig.client.json
Normal file
22
packages/api/remotes/tsconfig.client.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/client/index.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
36
packages/api/remotes/tsconfig.host.json
Normal file
36
packages/api/remotes/tsconfig.host.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/agent-lookup.ts",
|
||||
"src/index.ts",
|
||||
"src/invariant.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/registry"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
11
packages/api/remotes/tsconfig.json
Normal file
11
packages/api/remotes/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
packages/api/remotes/tsdown.config.ts
Normal file
7
packages/api/remotes/tsdown.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle(
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
{ hostPhase: true },
|
||||
)
|
||||
@@ -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/bash/README.md
|
||||
README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b
|
||||
README.zh.md: deb23ea820de40c99f0affd3726d9a49857039ea
|
||||
README.md: 601782caad24d3555a206365a3f1954d81af1cf0
|
||||
README.zh.md: 8b96c4f80ba8776bfd8bdde178178cea946ff36c
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The canonical three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages.
|
||||
The capability family spans the canonical executor seam, its implementations, the shared shell environment, and the model-facing tools. All are **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` |
|
||||
| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) |
|
||||
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
|
||||
| `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
|
||||
| [`bash/`](bash/README.md) | Defines the executor contract shared by implementations and consumers. | `ctx.bash` |
|
||||
| [`bash-local/`](bash-local/README.md) | Executes commands through the local [`subprocess`](../subprocess/README.md) service. | (registers `ctx.bash`) |
|
||||
| [`bash-sandbox/`](bash-sandbox/README.md) | Applies the configured [`sandbox`](../sandbox/README.md) backend before local execution. | (registers `ctx.bash`) |
|
||||
| [`pwsh-local/`](pwsh-local/README.md) | Executes PowerShell commands with Windows-specific process behavior. | (registers `ctx.bash`) |
|
||||
| [`bash-env/`](bash-env/README.md) | Provides the managed `DSH_*` environment shared by shell tools. | `ctx.bashEnv` |
|
||||
| [`tool-bash/`](tool-bash/README.md) | Exposes Bash execution and background-task integration to the model. | (registers on `ctx.tools`) |
|
||||
| [`tool-pwsh/`](tool-pwsh/README.md) | Exposes PowerShell execution to the model. | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)).
|
||||
A leaf `cordis.yml` selects one executor implementation and the model-facing tools it needs. A sandboxed composition also selects a `ctx.sandbox` provider; the [ACP example](../../examples/acp-agent/) shows one complete wiring.
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# bash/:bash 能力家族
|
||||
# bash/ — bash 能力家族
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品**包。
|
||||
该能力家族涵盖规范执行器 seam、其实现、共享 shell 环境和面向模型的工具。这些全是**产品**包。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` |
|
||||
| `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现(命令默认值补全、deadline、终端环境、后台读取合并) | (注册 `ctx.bash`) |
|
||||
| `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) |
|
||||
| `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) |
|
||||
| [`bash/`](bash/README.md) | 定义实现与消费方共享的执行器契约。 | `ctx.bash` |
|
||||
| [`bash-local/`](bash-local/README.md) | 通过本地 [`subprocess`](../subprocess/README.md) 服务执行命令。 | (注册 `ctx.bash`) |
|
||||
| [`bash-sandbox/`](bash-sandbox/README.md) | 在本地执行前应用已配置的 [`sandbox`](../sandbox/README.md) 后端。 | (注册 `ctx.bash`) |
|
||||
| [`pwsh-local/`](pwsh-local/README.md) | 以 Windows 专用进程行为执行 PowerShell 命令。 | (注册 `ctx.bash`) |
|
||||
| [`bash-env/`](bash-env/README.md) | 提供 shell 工具共享的托管 `DSH_*` 环境。 | `ctx.bashEnv` |
|
||||
| [`tool-bash/`](tool-bash/README.md) | 向模型公开 Bash 执行和后台任务集成。 | (注册到 `ctx.tools`) |
|
||||
| [`tool-pwsh/`](tool-pwsh/README.md) | 向模型公开 PowerShell 执行。 | (注册到 `ctx.tools`) |
|
||||
|
||||
接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。
|
||||
叶节点 `cordis.yml` 选择一个执行器实现和所需的面向模型工具。沙箱化组合还会选择一个 `ctx.sandbox` 提供方;[ACP(Agent Client Protocol)示例](../../examples/acp-agent/)展示一套完整接线。
|
||||
|
||||
6
packages/bash/bash-env/README.i18n.yaml
Normal file
6
packages/bash/bash-env/README.i18n.yaml
Normal 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/bash/bash-env/README.md
|
||||
README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f
|
||||
README.zh.md: b6f3aca41771f1ca990a4b708cd53119b8e8ad78
|
||||
51
packages/bash/bash-env/README.md
Normal file
51
packages/bash/bash-env/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# @deepseek-ai/dsh-bash-env
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of trusted, per-execution `DSH_*` variables that the model-facing shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`) collect into every shell call's environment. Built-in shell facts (`DSH_HOME`, `DSH_SHELL=1`, `DSH_SESSION_ID`) are owned by the registry itself; other plugins register additional enumerable facts with effect-scoped disposal, and duplicate ownership or undeclared runtime keys fail loudly.
|
||||
|
||||
The package root exports the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the `BashEnvRegistry` service class and its contributor types; consumers use `ctx.bashEnv` after loading this plugin.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: bash-env
|
||||
name: '@deepseek-ai/dsh-bash-env'
|
||||
config:
|
||||
dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh
|
||||
```
|
||||
|
||||
## Managed environment
|
||||
|
||||
Every foreground and background model shell call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
|
||||
|
||||
`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; this plugin's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.bashEnv.register({
|
||||
name: 'deployment-region',
|
||||
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
|
||||
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executors remove all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The shell tools' descriptions teach the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumers own any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`list()` enumerates contributor-declared variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog.
|
||||
51
packages/bash/bash-env/README.zh.md
Normal file
51
packages/bash/bash-env/README.zh.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# @deepseek-ai/dsh-bash-env
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
工具无关的 shell 环境插件:拥有 `ctx.bashEnv` 注册表,管理受信任的、每次执行收集的 `DSH_*` 变量,供模型可见的 shell 工具(`dsh-tool-bash`、`dsh-tool-pwsh`)收集进每次 shell 调用的环境。内置 shell 事实(`DSH_HOME`、`DSH_SHELL=1`、`DSH_SESSION_ID`)归注册表自身所有;其他插件可以注册额外的可枚举事实,注册随插件纤维(fiber)释放,重复所有权或未声明的运行时键会响亮失败。
|
||||
|
||||
包根导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费者在加载本插件后使用 `ctx.bashEnv`。
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: bash-env
|
||||
name: '@deepseek-ai/dsh-bash-env'
|
||||
config:
|
||||
dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh
|
||||
```
|
||||
|
||||
## Managed environment
|
||||
|
||||
每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh`),`DSH_SHELL=1` 标识受管理的子进程。带 agent(智能体)的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。
|
||||
|
||||
`ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor,带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.bashEnv.register({
|
||||
name: 'deployment-region',
|
||||
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
|
||||
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
覆盖层根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器在合并该快照前移除所有继承的 `DSH_*`,因此嵌套 harness 与并发的父子 agent 无法泄漏过期的身份。`process.env` 永不被修改。shell 工具的描述只教授通用的 `$DSH_*` 约定,而不是点名持久化相关的变量或添加常驻的 system-prompt 段落。
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumers own any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`list()` 只枚举 contributor 声明的变量** — 注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`)不包含在内,因此诊断、prompt 或 UI 代码不得把 `list()` 当作完整的环境目录。
|
||||
48
packages/bash/bash-env/package.json
Normal file
48
packages/bash/bash-env/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash-env",
|
||||
"description": "Tool-independent managed DSH_* shell environment registry",
|
||||
"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-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
217
packages/bash/bash-env/src/index.ts
Normal file
217
packages/bash/bash-env/src/index.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of
|
||||
* trusted, per-execution `DSH_*` variables consumed by the model-facing shell
|
||||
* tools (`dsh-tool-bash`, `dsh-tool-pwsh`). Built-in shell facts are owned by
|
||||
* the registry itself while plugins can register additional, enumerable facts
|
||||
* with effect-scoped disposal.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-env
|
||||
*/
|
||||
|
||||
import { Service, type Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
|
||||
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
bashEnv: BashEnvRegistry
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'bash-env'
|
||||
export const inject: string[] = []
|
||||
|
||||
/** Plugin config (all optional — the built-in facts resolve without defaults). */
|
||||
export interface Config {
|
||||
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
}
|
||||
|
||||
/** Runtime configuration schema for the bash-env plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
})
|
||||
|
||||
/** Model-visible metadata for one managed `DSH_*` environment variable. */
|
||||
export interface BashEnvVariable {
|
||||
/** Concise description of the environment fact represented by the variable. */
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin contribution to the managed environment of each model shell call.
|
||||
* Declared keys make ownership conflicts detectable before the first command;
|
||||
* `resolve` computes only the values available for the current execution.
|
||||
*/
|
||||
export interface BashEnvContributor {
|
||||
/** Stable contributor name used in diagnostics and duplicate detection. */
|
||||
name: string
|
||||
/** Complete set of `DSH_*` keys this contributor may return. */
|
||||
variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>
|
||||
/**
|
||||
* Resolve this contributor's available values for one tool execution.
|
||||
* @param execution - the shell tool execution and its optional calling agent.
|
||||
* @returns a partial map containing only keys declared in {@link variables}.
|
||||
*/
|
||||
resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>
|
||||
}
|
||||
|
||||
/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */
|
||||
export interface BashEnvVariableInfo extends BashEnvVariable {
|
||||
/** Contributor that owns the variable. */
|
||||
contributor: string
|
||||
/** Declared `DSH_*` environment variable name. */
|
||||
key: DshEnvironmentKey
|
||||
}
|
||||
|
||||
const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const
|
||||
const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const
|
||||
const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const
|
||||
const RESERVED_BASH_ENV_KEYS = new Set<DshEnvironmentKey>([
|
||||
DSH_HOME_ENV,
|
||||
DSH_SHELL_KEY,
|
||||
DSH_SESSION_ID_KEY,
|
||||
])
|
||||
const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/
|
||||
|
||||
/**
|
||||
* Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.
|
||||
* The namespace is rebuilt for every model shell call: ambient `DSH_*` values
|
||||
* are discarded by the executor, then the registry's current snapshot is
|
||||
* injected. Built-in shell facts remain owned by the registry itself while
|
||||
* plugins can register additional, enumerable facts with effect-scoped
|
||||
* disposal.
|
||||
*/
|
||||
export class BashEnvRegistry extends Service {
|
||||
private readonly contributors = new Map<string, BashEnvContributor>()
|
||||
private readonly keyOwners = new Map<DshEnvironmentKey, string>()
|
||||
private readonly dshHome: string
|
||||
|
||||
/**
|
||||
* Create and install the `ctx.bashEnv` service.
|
||||
* @param ctx - Cordis context that owns the service and registrations.
|
||||
* @param config - home-directory configuration for the built-in variables.
|
||||
*/
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'bashEnv')
|
||||
this.dshHome = resolveDshHome(config.dshHome)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one environment contributor. Names and keys are unique; built-in
|
||||
* keys are reserved. Registration is disposed with the calling plugin fiber.
|
||||
* @param contributor - declared key ownership and per-execution resolver.
|
||||
* @returns the disposer that unregisters the contribution.
|
||||
*/
|
||||
register(contributor: BashEnvContributor): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: BashEnvRegistry) {
|
||||
if (contributor.name.trim().length === 0) {
|
||||
throw new Error('bash env contributor name must be non-empty')
|
||||
}
|
||||
if (this.contributors.has(contributor.name)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" is already registered`)
|
||||
}
|
||||
|
||||
const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][]
|
||||
for (const [key, variable] of variables) {
|
||||
if (!key.startsWith(DSH_ENV_PREFIX)
|
||||
|| !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`)
|
||||
}
|
||||
if (RESERVED_BASH_ENV_KEYS.has(key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`)
|
||||
}
|
||||
if (variable.description.trim().length === 0) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`)
|
||||
}
|
||||
const owner = this.keyOwners.get(key)
|
||||
if (owner !== undefined) {
|
||||
throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`)
|
||||
}
|
||||
}
|
||||
|
||||
this.contributors.set(contributor.name, contributor)
|
||||
for (const [key] of variables) this.keyOwners.set(key, contributor.name)
|
||||
yield () => {
|
||||
this.contributors.delete(contributor.name)
|
||||
for (const [key] of variables) this.keyOwners.delete(key)
|
||||
}
|
||||
}.bind(this), 'bashEnv.register()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the trusted `DSH_*` snapshot for one shell tool execution.
|
||||
* @param execution - the current tool execution.
|
||||
* @returns an immutable environment overlay containing built-ins and current contributions.
|
||||
*/
|
||||
collect(execution: ToolExecution): DshEnvironment {
|
||||
const values: Record<DshEnvironmentKey, string> = {
|
||||
[DSH_HOME_ENV]: this.dshHome,
|
||||
[DSH_SHELL_KEY]: '1',
|
||||
}
|
||||
if (execution.agent !== undefined) {
|
||||
values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id
|
||||
}
|
||||
|
||||
for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const resolved = contributor.resolve(execution)
|
||||
for (const [rawKey, value] of Object.entries(resolved)) {
|
||||
const key = rawKey as DshEnvironmentKey
|
||||
if (!Object.hasOwn(contributor.variables, key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`)
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`)
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))))
|
||||
}
|
||||
|
||||
// TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics,
|
||||
// prompt, or UI code treats list() as an exhaustive environment catalog.
|
||||
/**
|
||||
* Enumerate plugin-contributed variables without executing their resolvers.
|
||||
* @returns declarations sorted by environment variable name.
|
||||
*/
|
||||
list(): BashEnvVariableInfo[] {
|
||||
return [...this.contributors.values()]
|
||||
.flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({
|
||||
contributor: contributor.name,
|
||||
description: variable.description,
|
||||
key: key as DshEnvironmentKey,
|
||||
})))
|
||||
.sort((left, right) => left.key.localeCompare(right.key))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the bash-env plugin: register the `ctx.bashEnv` service and the
|
||||
* shell-agnostic persistence contributor (`DSH_SESSION_JSONL`).
|
||||
* @param ctx - Cordis context that owns the service and registrations.
|
||||
* @param config - home-directory configuration for the built-in variables.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const registry = new BashEnvRegistry(ctx, config)
|
||||
registry.register({
|
||||
name: 'session-persistence',
|
||||
variables: {
|
||||
[DSH_SESSION_JSONL_KEY]: {
|
||||
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
|
||||
},
|
||||
},
|
||||
resolve(execution) {
|
||||
const agent = execution.agent
|
||||
if (agent === undefined) return {}
|
||||
const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
|
||||
return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {}
|
||||
},
|
||||
})
|
||||
}
|
||||
30
packages/bash/bash-env/src/invariant.ts
Normal file
30
packages/bash/bash-env/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-bash-env`.
|
||||
* @module @deepseek-ai/dsh-bash-env/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'bash-env-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the environment registry validates ownership and collected values at each
|
||||
* registration/collection; it publishes no independent snapshot that a companion could 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 */
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Registry tests for `@deepseek-ai/dsh-bash-env`: built-in facts, contributor
|
||||
* ownership and validation, collection ordering, effect-scoped disposal, and
|
||||
* the explicit disposer contract.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -5,7 +11,8 @@ import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash'
|
||||
import { BashEnvRegistry } from '@deepseek-ai/dsh-bash-env'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
@@ -190,4 +197,41 @@ describe('BashEnvRegistry', () => {
|
||||
dispose()
|
||||
expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL')
|
||||
})
|
||||
|
||||
it('the plugin registers the service and the persistence contributor on load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
expect(ctx.bashEnv).toBeInstanceOf(BashEnvRegistry)
|
||||
expect(ctx.bashEnv.list()).toEqual([
|
||||
{
|
||||
contributor: 'session-persistence',
|
||||
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
|
||||
key: 'DSH_SESSION_JSONL',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('the persistence contributor resolves DSH_SESSION_JSONL only for a jsonl backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
ctx.provide('sessionPersistence', {
|
||||
locate: () => ({ kind: 'jsonl' as const, path: 'C:\\sessions\\s.jsonl' }),
|
||||
})
|
||||
expect(ctx.bashEnv.collect(execution('sess-p')).DSH_SESSION_JSONL).toBe('C:\\sessions\\s.jsonl')
|
||||
})
|
||||
|
||||
it('the persistence contributor omits the variable for a non-jsonl backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
ctx.provide('sessionPersistence', {
|
||||
locate: () => ({ kind: 'sqlite' as const, path: 'C:\\sessions\\s.db' }),
|
||||
})
|
||||
expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL')
|
||||
})
|
||||
|
||||
it('the persistence contributor omits the variable without a persistence backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL')
|
||||
})
|
||||
})
|
||||
36
packages/bash/bash-env/tsconfig.json
Normal file
36
packages/bash/bash-env/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/bash/bash-local/README.md
|
||||
README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706
|
||||
README.zh.md: c56543f26965effebaf020dd8d9d4ba130cd9b17
|
||||
README.md: cb40cb8fa40d95d5b4589b7c804f450a2bf38c8e
|
||||
README.zh.md: bd4f73babdb47ff92e87e20eb7d60657ed515ec4
|
||||
|
||||
@@ -20,15 +20,13 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
|
||||
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
|
||||
```
|
||||
|
||||
## Behavior (and where it came from)
|
||||
## Behavior
|
||||
|
||||
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Task ids, ownership, polling, and notices belong to the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -20,15 +20,13 @@
|
||||
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
|
||||
```
|
||||
|
||||
## 行为(以及设计来源)
|
||||
## 行为
|
||||
|
||||
设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下:
|
||||
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流需要时采用。
|
||||
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
|
||||
- **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
|
||||
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
|
||||
- **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。task id、所有权、轮询和通知属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Local implementation of the bash executor seam over the subprocess
|
||||
* seam. Each command runs as `bash -c` in a managed process group spawned
|
||||
* through `ctx.subprocess`; this executor owns command defaulting, deadlines
|
||||
* and cause classification, the model-friendly terminal environment, and the
|
||||
* model-facing stdout/stderr merge for background reads. Execution policy
|
||||
* belongs in `tools/pre-execute` or a sandboxing executor.
|
||||
* seam. Public commands run as `bash -c` in a managed process group spawned
|
||||
* through `ctx.subprocess`; subclasses may reuse the same mechanics with an
|
||||
* explicit argv. This executor owns command defaulting, deadlines and cause
|
||||
* classification, the model-friendly terminal environment, and the model-facing
|
||||
* stdout/stderr merge for background reads. Execution policy belongs in
|
||||
* `tools/pre-execute` or a sandboxing executor.
|
||||
* @module @deepseek-ai/dsh-bash-local
|
||||
*/
|
||||
|
||||
@@ -13,7 +14,7 @@ import z from 'schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
/**
|
||||
* Model-friendly environment overrides: disable colors, pagers, and
|
||||
@@ -47,7 +48,7 @@ export interface Config {
|
||||
maxOutputBytes?: number
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes?: number
|
||||
/** Grace period for kill escalation and for inherited pipes after shell exit. */
|
||||
/** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
|
||||
graceMs?: number
|
||||
}
|
||||
|
||||
@@ -101,6 +102,9 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,13 +141,18 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
|
||||
/** Map one resolved bash spec and explicit argv onto a fully-specified subprocess spawn. */
|
||||
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
|
||||
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
|
||||
private spawnSpec(
|
||||
spec: BashExecSpec,
|
||||
argv: readonly string[],
|
||||
stdoutMaxBytes: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): SubprocessSpawnSpec {
|
||||
const collect = (maxBytes: number): SubprocessCollect =>
|
||||
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
||||
return {
|
||||
argv: ['bash', '-c', spec.command],
|
||||
argv,
|
||||
cwd: spec.workdir,
|
||||
stdio: {
|
||||
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
|
||||
@@ -171,9 +180,21 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
return this.runArgv(spec, ['bash', '-c', spec.command])
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an explicit argv with the foreground lifecycle, environment, output,
|
||||
* timeout, and cancellation semantics of this executor. Subclasses use this
|
||||
* after replacing the public command's shell argv at an execution boundary.
|
||||
* @param spec - resolved execution settings and caller-owned command metadata.
|
||||
* @param argv - exact executable and arguments to hand to `ctx.subprocess`.
|
||||
* @returns the settled foreground result with collected output and cause facts.
|
||||
*/
|
||||
protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, spec.stdoutMaxBytes, d.signal))
|
||||
const outcome = await handle.done
|
||||
const collected = LocalBashExecutor.collected(handle)
|
||||
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
|
||||
@@ -190,8 +211,21 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
return this.startArgv(spec, ['bash', '-c', spec.command])
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an explicit argv with the background lifecycle, environment, output,
|
||||
* cancellation, and process-tree ownership semantics of this executor.
|
||||
* Subclasses use this after replacing the public command's shell argv at an
|
||||
* execution boundary.
|
||||
* @param spec - resolved execution settings and caller-owned command metadata.
|
||||
* @param argv - exact executable and arguments to hand to `ctx.subprocess`.
|
||||
* @returns the live background handle; spawn rejection settles it as killed.
|
||||
*/
|
||||
protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess {
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal))
|
||||
const collected = LocalBashExecutor.collected(running)
|
||||
|
||||
// A spawn failure produces no process output, so the subprocess service has nothing
|
||||
@@ -216,12 +250,12 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
|
||||
}, (error: unknown) => {
|
||||
// Background spawn failures settle as killed and surface through the read path.
|
||||
proc.status = 'killed'
|
||||
spawnFailureNote = `spawn failed: ${String(error)}`
|
||||
this.onProcessDone(proc, spawnFailureNote)
|
||||
this.onProcessDone(proc, spawnFailureNote, true, error)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = collected.stdout.readFrom(stdoutOffset)
|
||||
@@ -261,8 +295,10 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
* empty.
|
||||
* @param _proc - the settled process handle.
|
||||
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
|
||||
* @param _spawnFailed - whether the subprocess promise rejected before a process started.
|
||||
* @param _spawnError - the original spawn rejection reason, which may itself be undefined.
|
||||
*/
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
|
||||
}
|
||||
|
||||
export default LocalBashExecutor
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
||||
@@ -70,6 +71,8 @@ describe('LocalBashExecutor.run', () => {
|
||||
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
|
||||
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
|
||||
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
|
||||
await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 }))
|
||||
.rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
|
||||
const { bash } = await setup()
|
||||
expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
|
||||
|
||||
@@ -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/bash/bash-sandbox/README.md
|
||||
README.md: 035a8ad2401ca608d264049d454359eda7b2b9a7
|
||||
README.zh.md: cee27a9baaa539ba07eb1d730ea9bef2004fbeeb
|
||||
README.md: 74c1e28f76db35603bb9f72e0522e16ece5589f7
|
||||
README.zh.md: 2593049ce09c7bc1ae0a996321bb27cbac449977
|
||||
|
||||
@@ -4,9 +4,9 @@ English | [中文](README.zh.md)
|
||||
|
||||
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
|
||||
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal.
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; result-classification helpers stay internal.
|
||||
|
||||
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
|
||||
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned argv directly. With the shipped native runners, the inner Bash retains shell semantics and evaluates `BASH_ENV` only after the runner establishes confinement. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
|
||||
|
||||
| Mode | File effects |
|
||||
|---|---|
|
||||
@@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm
|
||||
Semantics:
|
||||
|
||||
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
|
||||
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
|
||||
- **Runner attribution is conservative.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with positive provenance for provider argv[0]. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessService` synchronously throws the same provenanced `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
|
||||
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
|
||||
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
|
||||
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
|
||||
@@ -36,8 +36,6 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
```
|
||||
|
||||
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent). The agent-spine e2e additionally drives two concurrent sessions in one Cordis context and proves each real bash tool call can write only its own project. See [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Bash tool schema, indirectly
|
||||
@@ -72,7 +70,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail.
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` argv[0] evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -86,5 +84,5 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
|
||||
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
|
||||
- **A background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`.
|
||||
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`; a provenanced synchronous `SubprocessService` throw instead fails `start()` immediately.
|
||||
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
|
||||
|
||||
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。
|
||||
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;结果分类 helper 保留在内部。
|
||||
|
||||
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
|
||||
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,并直接 spawn 返回的 argv。使用随附的原生 runner 时,内层 Bash 保留 shell 语义,并且只在 runner 建立约束后才求值 `BASH_ENV`。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
|
||||
|
||||
| 模式 | 文件影响 |
|
||||
|---|---|
|
||||
@@ -17,7 +17,7 @@
|
||||
语义:
|
||||
|
||||
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。
|
||||
- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **Runner 归因是保守的。** 进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且 Node 报告 `ENOENT` 或 `EACCES`,且带有明确指向提供方 argv[0] 的来源信息时,才会将拒绝归因于 runner。这样可以识别缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true` 和 `denied: false`。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导。
|
||||
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
|
||||
- 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
|
||||
@@ -36,8 +36,6 @@
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
```
|
||||
|
||||
无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,从外部验证实际文件效果,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 间接的 Bash 工具 schema
|
||||
@@ -72,7 +70,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 `ENOENT`/`EACCES` argv[0] 证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -86,5 +84,5 @@
|
||||
|
||||
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
|
||||
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
|
||||
- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。
|
||||
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;同步 `SubprocessService` 抛出带有来源信息的 `ENOENT`/`EACCES` 时,则会使 `start()` 立即失败。
|
||||
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,18 +1,61 @@
|
||||
/**
|
||||
* Internal shell-quoting and sandbox-result classification helpers.
|
||||
* Internal sandbox-result classification helpers.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-sandbox/helpers
|
||||
*/
|
||||
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
|
||||
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
|
||||
|
||||
/** Whether the caller-owned spawn cwd can be entered. */
|
||||
function isUsableWorkdir(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isDirectory()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote one string as a single-quoted POSIX shell word.
|
||||
* @param text - raw argv element to preserve through the outer shell parse.
|
||||
* @returns the quoted shell word.
|
||||
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
|
||||
* after independently ruling out the caller-owned cwd. A supplied error path
|
||||
* must exactly identify the runner; without one, the syscall must. With a
|
||||
* usable cwd, these codes describe resolution or execute permission for that
|
||||
* argv[0] or its shebang interpreter.
|
||||
* The workdir is checked at classification time, not atomically with spawn;
|
||||
* concurrent path replacement may change attribution but cannot permit an
|
||||
* unconfined execution.
|
||||
* @param error - the original spawn rejection.
|
||||
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
|
||||
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
|
||||
* @returns whether the rejection has executable-specific runner evidence.
|
||||
*/
|
||||
export function shellQuote(text: string): string {
|
||||
return `'${text.replaceAll("'", String.raw`'\''`)}'`
|
||||
export function isRunnerSpawnFailure(
|
||||
error: unknown,
|
||||
runnerProgram: string | undefined,
|
||||
workdir: string,
|
||||
): boolean {
|
||||
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
|
||||
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
|
||||
if (typeof syscall !== 'string') return false
|
||||
const exactSyscall = `spawn ${runnerProgram}`
|
||||
if (path === undefined) return syscall === exactSyscall
|
||||
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
|
||||
return syscall === 'spawn' || syscall === exactSyscall
|
||||
}
|
||||
|
||||
/** Fatal runner evidence retained for infrastructure-error detail. */
|
||||
interface RunnerFailureMatch {
|
||||
/** The original stderr line that matched a fatal signature. */
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,13 +69,37 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a failed run against the selected backend's runner-failure dialect.
|
||||
* @param result - settled foreground run.
|
||||
* @param signatures - case-insensitive runner-failure substrings from the active wrap.
|
||||
* @returns whether the failed run matches that runner-failure dialect.
|
||||
* Classify one settled process against the selected backend's structured
|
||||
* runner-failure rules. Each rule requires a nonzero exit, its optional
|
||||
* exit-code gate, and a fatal signature on one stderr line after exact
|
||||
* informational lines are excluded.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text, left unchanged.
|
||||
* @param rules - structured runner-failure rules from the active wrap.
|
||||
* @returns the first matching fatal line, or undefined when evidence is insufficient.
|
||||
*/
|
||||
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
export function classifyRunnerFailure(
|
||||
exitCode: number | null,
|
||||
stderr: string,
|
||||
rules: readonly RunnerFailureRule[],
|
||||
): RunnerFailureMatch | undefined {
|
||||
if (exitCode === null || exitCode === 0) return undefined
|
||||
const lines = stderr.split(/\r?\n/)
|
||||
for (const rule of rules) {
|
||||
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
|
||||
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
|
||||
// An empty or whitespace-only substring is not meaningful runner evidence.
|
||||
// Ignore it while keeping any valid signatures beside it active.
|
||||
const fatalSignatures = rule.fatalSignatures
|
||||
.filter(signature => signature.trim().length > 0)
|
||||
.map(signature => signature.toLowerCase())
|
||||
for (const line of lines) {
|
||||
const lowered = line.toLowerCase()
|
||||
if (informationalLines.has(lowered)) continue
|
||||
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
/**
|
||||
* Sandbox-consuming bash executor. It wraps the exact local bash argv through
|
||||
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
|
||||
* mode, enforcement, and denial facts. Runner failure means the command never
|
||||
* ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background
|
||||
* processes carry `runnerFailed`. The tool owns approval and passes a complete
|
||||
* per-call policy.
|
||||
* mode, enforcement, and denial facts. Positive runner-launch evidence means
|
||||
* the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
|
||||
* background processes carry `runnerFailed`; other spawn rejections retain
|
||||
* local-executor semantics. The tool owns approval and passes a complete per-call policy.
|
||||
* @module @deepseek-ai/dsh-bash-sandbox
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {
|
||||
ConfinedArgv,
|
||||
ConfinedSandboxMode,
|
||||
RunnerFailureRule,
|
||||
SandboxEnforcement,
|
||||
SandboxExecutionPolicy,
|
||||
SandboxMode,
|
||||
SandboxPolicy,
|
||||
} from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
||||
import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
|
||||
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
|
||||
@@ -51,7 +59,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
mode: ConfinedSandboxMode
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
runnerFailureRules: readonly RunnerFailureRule[]
|
||||
runnerProgram: string | undefined
|
||||
workdir: string
|
||||
}>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
@@ -83,11 +93,22 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
return { ...result, sandbox: { mode, denied: false } }
|
||||
}
|
||||
const confined = this.confine(spec.command, { ...policy, mode })
|
||||
const result = await super.run({ ...spec, command: confined.command })
|
||||
// Runner failure outranks denial because the command did not run. Throw the
|
||||
// same fail-closed error as confine-time discovery with the first stderr line.
|
||||
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
|
||||
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
|
||||
let result: BashRunResult
|
||||
try {
|
||||
result = await this.runArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
// An upstream abort remains cancellation even when it prevents spawn.
|
||||
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// Runner failure outranks denial because the command did not run. Carry
|
||||
// the matched fatal line, not an informational line that preceded it.
|
||||
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
|
||||
if (runnerFailure !== undefined) {
|
||||
throw new SandboxUnavailableError(mode, runnerFailure.detail)
|
||||
}
|
||||
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
|
||||
}
|
||||
@@ -96,11 +117,29 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Install facts synchronously; promise settlement cannot run before start() returns.
|
||||
// Once startArgv returns, install facts synchronously; promise settlement
|
||||
// cannot run before start() returns.
|
||||
const confined = this.confine(spec.command, { ...policy, mode })
|
||||
const proc = super.start({ ...spec, command: confined.command })
|
||||
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
|
||||
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
|
||||
let proc: BashProcess
|
||||
try {
|
||||
proc = this.startArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
// LocalSubprocessService reports provenanced ENOENT/EACCES through async
|
||||
// `done` rejection; this covers alternatives that throw that shape synchronously.
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const { enforcement, denialSignatures, runnerFailureRules } = confined
|
||||
this.processFacts.set(proc, {
|
||||
mode,
|
||||
enforcement,
|
||||
denialSignatures,
|
||||
runnerFailureRules,
|
||||
runnerProgram: confined.argv[0],
|
||||
workdir: spec.workdir,
|
||||
})
|
||||
return proc
|
||||
}
|
||||
|
||||
@@ -108,12 +147,15 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
* Stamp per-process sandbox facts before `done` settles. Full-access processes
|
||||
* have no facts; signal deaths are not denials.
|
||||
*/
|
||||
protected override onProcessDone(proc: BashProcess, stderr: string): void {
|
||||
protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
|
||||
const facts = this.processFacts.get(proc)
|
||||
if (facts !== undefined) {
|
||||
this.processFacts.delete(proc)
|
||||
// Runner failure outranks denial because its diagnostics may contain denial terms.
|
||||
const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
|
||||
// A rejected spawn never started the confined launch. Otherwise runner
|
||||
// failure outranks denial because its diagnostics may contain denial terms.
|
||||
const runnerFailed = spawnFailed
|
||||
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
|
||||
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
|
||||
proc.sandbox = {
|
||||
mode: facts.mode,
|
||||
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
|
||||
@@ -121,30 +163,19 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
...(runnerFailed ? { runnerFailed } : {}),
|
||||
}
|
||||
}
|
||||
super.onProcessDone(proc, stderr)
|
||||
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap one shell command via the `ctx.sandbox` provider: hand over the
|
||||
* exact `['bash', '-c', command]` argv this executor would spawn, get back
|
||||
* the confined argv, and re-assemble it into the `exec …` command string
|
||||
* the inherited spawn path runs (the outer `bash -c` the subprocess service spawns
|
||||
* `exec`s into the runner, so no extra shell lingers). Provider errors
|
||||
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
|
||||
* Wrap one shell command via the `ctx.sandbox` provider. Provider errors
|
||||
* propagate unchanged; the returned argv is handed directly to the local
|
||||
* executor's subprocess path.
|
||||
* @param command - shell source for the confined inner `bash -c`.
|
||||
* @param policy - resolved confined execution policy.
|
||||
* @returns the provider's exact argv and settlement-classification facts.
|
||||
*/
|
||||
private confine(command: string, policy: SandboxPolicy): {
|
||||
command: string
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
} {
|
||||
const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy)
|
||||
return {
|
||||
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
|
||||
enforcement: confined.enforcement,
|
||||
denialSignatures: confined.denialSignatures,
|
||||
runnerFailureSignatures: confined.runnerFailureSignatures,
|
||||
}
|
||||
private confine(command: string, policy: SandboxPolicy): ConfinedArgv {
|
||||
return this.ctx.sandbox.confine(['bash', '-c', command], policy)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
270
packages/bash/bash-sandbox/tests/partial-landlock.spec.ts
Normal file
270
packages/bash/bash-sandbox/tests/partial-landlock.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Deterministic real-process proofs for runner classification: the real local
|
||||
* provider and sandbox bash executor exercise direct runner-spawn failures
|
||||
* and a POSIX fake Landlock launcher that prints its notice before exec.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const FATAL_PREFIX = 'landlock-run: '
|
||||
const FATAL = `${FATAL_PREFIX}landlock ruleset error: Invalid argument`
|
||||
|
||||
const contexts: Context[] = []
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
/** Write a fake native launcher that reports partial enforcement, then execs or fails. */
|
||||
async function fakeLauncher(fatalExit?: number): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-'))
|
||||
tempDirs.push(dir)
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
const fatalBranch = fatalExit === undefined ? '' : `printf '%s\\n' '${FATAL}' >&2\nexit ${fatalExit}\n`
|
||||
await writeFile(launcher, `#!/bin/sh
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ro|--rw) shift 2 ;;
|
||||
--) shift; break ;;
|
||||
*) printf '%s\\n' '${FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\\n' '${NOTICE}' >&2
|
||||
${fatalBranch}exec "$@"
|
||||
`, { mode: 0o755 })
|
||||
return launcher
|
||||
}
|
||||
|
||||
async function setup(fatalExit?: number): Promise<SandboxBashExecutor> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = ctx.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = {
|
||||
platform: 'linux',
|
||||
probeBwrap: () => false,
|
||||
probeLandlock: () => 'partial',
|
||||
landlockLauncher: await fakeLauncher(fatalExit),
|
||||
}
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
async function setupConfiguredRunner(runner: string): Promise<SandboxBashExecutor> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LocalSandboxProvider, {
|
||||
runnerCommand: [runner],
|
||||
runnerFailureSignatures: ['configured-runner: fatal'],
|
||||
})
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe('partial Landlock runner-failure classification', () => {
|
||||
it.each(['missing', 'unexecutable', 'missing-interpreter'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-unusable-sandbox-runner-'))
|
||||
tempDirs.push(dir)
|
||||
const runner = join(dir, `${kind}-runner`)
|
||||
if (kind === 'unexecutable') await writeFile(runner, '#!/bin/sh\nexit 0\n', { mode: 0o644 })
|
||||
if (kind === 'missing-interpreter') {
|
||||
await writeFile(runner, '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
|
||||
}
|
||||
const bash = await setupConfiguredRunner(runner)
|
||||
|
||||
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
|
||||
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(runner)
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`)
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
runnerFailed: true,
|
||||
})
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
})
|
||||
|
||||
it.each(['bare-name', 'relative'] as const)(
|
||||
'classifies a %s runner whose shebang interpreter is missing',
|
||||
async (form) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-argv-form-sandbox-runner-'))
|
||||
tempDirs.push(dir)
|
||||
const filename = 'missing-interpreter-runner'
|
||||
const runner = form === 'bare-name' ? filename : `./${filename}`
|
||||
await writeFile(join(dir, filename), '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
|
||||
const bash = await setupConfiguredRunner(runner)
|
||||
const request = form === 'bare-name'
|
||||
? { command: 'true', env: { PATH: dir } }
|
||||
: { command: 'true', workdir: dir }
|
||||
|
||||
const error = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
|
||||
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
// Empirically, Darwin and Linux Node 24 preserve the passed bare/relative
|
||||
// argv[0] in this spawn error rather than resolving it to an absolute path.
|
||||
expect((error as Error).message).toContain(`spawn ${runner} ENOENT`)
|
||||
|
||||
const task = bash.start(bash.resolve(request))
|
||||
await task.done
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`)
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
runnerFailed: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps a real malformed executable ordinary across no-shebang spawn behavior', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-malformed-sandbox-runner-'))
|
||||
tempDirs.push(dir)
|
||||
const runner = join(dir, 'malformed-runner')
|
||||
await writeFile(runner, 'not a native executable or shebang script\n', { mode: 0o755 })
|
||||
const bash = await setupConfiguredRunner(runner)
|
||||
const request = { command: 'true' }
|
||||
|
||||
// Node/libuv may expose execve's ENOEXEC directly (Darwin) or retry a
|
||||
// no-shebang executable through /bin/sh (Linux). Neither path supplies the
|
||||
// provenanced ENOENT/EACCES evidence required for runner attribution.
|
||||
const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
|
||||
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
|
||||
if (foreground instanceof Error) {
|
||||
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
|
||||
expect((foreground as { path?: unknown }).path).toBeUndefined()
|
||||
|
||||
let background: unknown
|
||||
try {
|
||||
bash.start(bash.resolve(request))
|
||||
} catch (error) {
|
||||
background = error
|
||||
}
|
||||
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
|
||||
expect((background as { path?: unknown }).path).toBeUndefined()
|
||||
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} else {
|
||||
expect(foreground).toMatchObject({
|
||||
exitCode: 127,
|
||||
signal: null,
|
||||
sandbox: { mode: 'read-only', denied: false, enforcement: 'full' },
|
||||
})
|
||||
expect((foreground as { stderr: { text: string } }).stderr.text.length).toBeGreaterThan(0)
|
||||
|
||||
const background = bash.start(bash.resolve(request))
|
||||
await background.done
|
||||
expect(background.status).toBe('completed')
|
||||
expect(background.exitCode).toBe(127)
|
||||
expect(background.signal).toBeNull()
|
||||
expect(background.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
const output = background.readOutput().delta
|
||||
expect(output.startsWith('[stderr]\n')).toBe(true)
|
||||
expect(output.length).toBeGreaterThan('[stderr]\n'.length)
|
||||
expect(output).not.toContain('spawn failed:')
|
||||
}
|
||||
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
})
|
||||
|
||||
it.each([0, 1, 2, LAUNCHER_FAILURE_EXIT])(
|
||||
'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line',
|
||||
async (exitCode) => {
|
||||
const bash = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
},
|
||||
)
|
||||
|
||||
it.each([126, 127])('keeps a successfully launched Landlock child exit %i as an ordinary outcome', async (exitCode) => {
|
||||
const bash = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it.each([1, 2])('keeps a Landlock fatal line at exit %i as insufficient runner-failure evidence', async (exitCode) => {
|
||||
const bash = await setup(exitCode)
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\n${FATAL}\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => {
|
||||
const bash = await setup(LAUNCHER_FAILURE_EXIT)
|
||||
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
|
||||
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(`Runner failure: ${FATAL}`)
|
||||
expect((error as Error).message).not.toContain(NOTICE)
|
||||
})
|
||||
|
||||
it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => {
|
||||
const bash = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it('applies the same evidence rule to notice-only background exits', async () => {
|
||||
const bash = await setup()
|
||||
for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) {
|
||||
const task = bash.start(bash.resolve({ command }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
expect(task.readOutput().delta).toContain(NOTICE)
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies a background notice plus child Permission denied as denial', async () => {
|
||||
const bash = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
expect(task.readOutput().delta).toContain(NOTICE)
|
||||
})
|
||||
|
||||
it('makes a background fatal line outrank denial text after the notice', async () => {
|
||||
const bash = await setup(LAUNCHER_FAILURE_EXIT)
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'partial',
|
||||
runnerFailed: true,
|
||||
})
|
||||
const output = task.readOutput().delta
|
||||
expect(output).toContain(NOTICE)
|
||||
expect(output).toContain(FATAL)
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
* the Unix denial signature used by the classifier without requiring a real sandbox runner.
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -16,7 +16,8 @@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy }
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
|
||||
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
|
||||
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from '../src/helpers.ts'
|
||||
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
|
||||
@@ -30,12 +31,19 @@ interface ConfineCall {
|
||||
/** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
|
||||
const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
|
||||
|
||||
/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
|
||||
const RUNNER_FAILURE = ['fake-runner: '] as const
|
||||
/** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
|
||||
const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const
|
||||
|
||||
/** Provider argv[0] forms that all share the caller-owned cwd spawn precondition. */
|
||||
const RUNNER_FORMS = [
|
||||
['absolute', process.execPath],
|
||||
['bare', 'node'],
|
||||
['relative', './sandbox-runner'],
|
||||
] as const
|
||||
|
||||
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
|
||||
const passthrough = (argv: readonly string[]): ConfinedArgv =>
|
||||
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })
|
||||
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })
|
||||
|
||||
/**
|
||||
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
|
||||
@@ -90,15 +98,49 @@ describe('the provider hand-off', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => {
|
||||
// The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix:
|
||||
// the sentinel only prints if the executor spawned the WRAPPED argv.
|
||||
const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
it('hands the provider\'s returned argv directly to ctx.subprocess.spawn', async () => {
|
||||
const returnedArgv = ['env', 'DSH_WRAP=1', 'bash', '-c', 'printf "%s" "$DSH_WRAP"']
|
||||
const { ctx, bash } = await setup({}, () => ({ argv: returnedArgv, enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
|
||||
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
|
||||
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
|
||||
expect(result.stdout.text).toBe('1')
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
expect(spawn.mock.calls[0]?.[0].argv).toEqual(returnedArgv)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('starts a non-Bash runner before the confined inner Bash evaluates BASH_ENV', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-bash-env-order-'))
|
||||
const hook = join(dir, 'hook.sh')
|
||||
const order = join(dir, 'order.txt')
|
||||
writeFileSync(hook, 'printf "hook\\n" >> "$DSH_ORDER_FILE"\n')
|
||||
const runnerScript = [
|
||||
'const { appendFileSync } = require("node:fs");',
|
||||
'const { spawnSync } = require("node:child_process");',
|
||||
'appendFileSync(process.env.DSH_ORDER_FILE, "runner\\n");',
|
||||
'const child = spawnSync(process.argv[1], process.argv.slice(2), { env: process.env, stdio: "inherit" });',
|
||||
'process.exit(child.status ?? 125);',
|
||||
].join('')
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: [process.execPath, '-e', runnerScript, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await bash.run(bash.resolve({
|
||||
command: 'true',
|
||||
env: { BASH_ENV: hook },
|
||||
dshEnv: { DSH_ORDER_FILE: order },
|
||||
}))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(readFileSync(order, 'utf8')).toBe('runner\nhook\n')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'workspace-write' })
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
@@ -120,9 +162,6 @@ describe('the provider hand-off', () => {
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => {
|
||||
expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fail closed', () => {
|
||||
@@ -132,6 +171,120 @@ describe('fail closed', () => {
|
||||
await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
|
||||
})
|
||||
|
||||
it('preserves an already-aborted foreground call as cancellation', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('caller cancelled before spawn')
|
||||
controller.abort(reason)
|
||||
await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it.each(RUNNER_FORMS)(
|
||||
'keeps an invalid workdir ordinary with the %s provider-runner form',
|
||||
async (_form, runner) => {
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
try {
|
||||
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ code: 'ENOENT' })
|
||||
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps an invalid workdir ordinary when danger-full-access bypasses the provider', async () => {
|
||||
const { bash } = await setup({ mode: 'danger-full-access' })
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
try {
|
||||
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ code: 'ENOENT' })
|
||||
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Node-shaped synchronous ENOEXEC ordinary in run() and start()', async () => {
|
||||
const runner = join(spillDir, 'malformed-runner')
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
|
||||
throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn' })
|
||||
})
|
||||
|
||||
const foreground = await bash.run(bash.resolve({ command: 'true' })).catch((error: unknown) => error)
|
||||
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
|
||||
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
|
||||
let background: unknown
|
||||
try {
|
||||
bash.start(bash.resolve({ command: 'true' }))
|
||||
} catch (error) {
|
||||
background = error
|
||||
}
|
||||
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
|
||||
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
})
|
||||
|
||||
it('classifies a synchronous SubprocessService EACCES with exact runner provenance', async () => {
|
||||
const runner = join(spillDir, 'unexecutable-runner')
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
// This pins an alternative SubprocessService's synchronous seam, not the
|
||||
// shipped local behavior.
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
|
||||
throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES', syscall: 'spawn', path: runner })
|
||||
})
|
||||
|
||||
await expect(bash.run(bash.resolve({ command: 'true' })))
|
||||
.rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(() => bash.start(bash.resolve({ command: 'true' })))
|
||||
.toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
|
||||
it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => {
|
||||
const runner = './sandbox-runner'
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
const workdir = join(parent, 'missing')
|
||||
const failure = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { throw failure })
|
||||
try {
|
||||
let thrown: unknown
|
||||
try {
|
||||
bash.start(bash.resolve({ command: 'true', workdir }))
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBe(failure)
|
||||
expect(thrown).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('danger-full-access', () => {
|
||||
@@ -233,15 +386,134 @@ describe('classifyDenial', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRunnerSpawnFailure', () => {
|
||||
it.each(['EACCES', 'ENOENT'])(
|
||||
'attributes executable-class spawn code %s to argv[0] once cwd ambiguity is eliminated',
|
||||
(code) => {
|
||||
const runner = join(spillDir, 'runner')
|
||||
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
|
||||
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ENOEXEC', 'ENOTDIR', 'EPERM'])(
|
||||
'keeps unproven executable code %s ordinary despite synthetic argv[0] fields',
|
||||
(code) => {
|
||||
const runner = join(spillDir, 'runner')
|
||||
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
|
||||
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it('requires a usable caller cwd before classifying absolute, bare, or relative runners', () => {
|
||||
const missingWorkdir = join(spillDir, 'missing-workdir')
|
||||
for (const [, runner] of RUNNER_FORMS) {
|
||||
const error = Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
|
||||
expect(isRunnerSpawnFailure(error, runner, missingWorkdir)).toBe(false)
|
||||
}
|
||||
const fileWorkdir = join(spillDir, 'not-a-workdir')
|
||||
writeFileSync(fileWorkdir, '')
|
||||
const error = Object.assign(new Error('spawn failed'), { code: 'ENOTDIR', syscall: 'spawn node', path: 'node' })
|
||||
expect(isRunnerSpawnFailure(error, 'node', fileWorkdir)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects resource, non-spawn, mismatched-program, and unstructured failures', () => {
|
||||
const missingRunner = join(spillDir, 'definitely-missing-runner')
|
||||
const spawnError = (code: unknown, syscall: unknown = `spawn ${missingRunner}`, path: unknown = missingRunner) =>
|
||||
Object.assign(new Error('spawn failed'), { code, syscall, path })
|
||||
const spawnErrorWithoutPath = (syscall: string) =>
|
||||
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall })
|
||||
|
||||
expect(isRunnerSpawnFailure(spawnError('EMFILE'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOMEM'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError(2), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'open'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 1), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', process.execPath), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', 1), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn other-runner'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(undefined, missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(null, missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts only syscall provenance compatible with the exact runner program', () => {
|
||||
const runner = join(spillDir, 'runner with spaces')
|
||||
const spawnError = (syscall: string, path?: string) =>
|
||||
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall, path })
|
||||
|
||||
expect(isRunnerSpawnFailure(spawnError('spawn', runner), runner, process.cwd())).toBe(true)
|
||||
expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`, runner), runner, process.cwd())).toBe(true)
|
||||
expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`), runner, process.cwd())).toBe(true)
|
||||
expect(isRunnerSpawnFailure(spawnError('spawn other-runner', runner), runner, process.cwd())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyRunnerFailure', () => {
|
||||
it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => {
|
||||
const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory']
|
||||
expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true)
|
||||
expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true)
|
||||
it('ignores empty and whitespace-only fatal signatures instead of treating exit status or notice text as evidence', () => {
|
||||
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: ['', ' ', '\t'] }]
|
||||
expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps valid fatal signatures active beside an ignored empty entry', () => {
|
||||
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const fatal = 'landlock-run: ruleset creation failed'
|
||||
const rules = [{
|
||||
allowedExitCodes: [125],
|
||||
fatalSignatures: ['', ' ', 'landlock-run: '],
|
||||
informationalLines: [notice],
|
||||
}]
|
||||
expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal })
|
||||
})
|
||||
|
||||
it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => {
|
||||
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }]
|
||||
expect(classifyRunnerFailure(1, notice, rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(2, notice, rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(125, notice, rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(125, notice.toUpperCase(), rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(125, `${notice}: extra detail`, rules))
|
||||
.toEqual({ detail: `${notice}: extra detail` })
|
||||
expect(classifyRunnerFailure(125, `${notice}\nlandlock-run: exec failed: No such file or directory`, rules))
|
||||
.toEqual({ detail: 'landlock-run: exec failed: No such file or directory' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'landlock-run: usage error: missing `-- <argv>...` command',
|
||||
'landlock-run: landlock is not enforced by this kernel (ABI unsupported or disabled)',
|
||||
'landlock-run: cannot open rule path: /gone: No such file or directory',
|
||||
'landlock-run: landlock ruleset error: Invalid argument',
|
||||
'landlock-run: exec failed: Permission denied',
|
||||
'landlock-run: out of memory',
|
||||
'landlock-run: future fatal diagnostic',
|
||||
])('keeps known and future Landlock fatal diagnostics fail-closed: %s', (fatal) => {
|
||||
const rules = [{
|
||||
allowedExitCodes: [125],
|
||||
fatalSignatures: ['landlock-run: '],
|
||||
informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'],
|
||||
}]
|
||||
expect(classifyRunnerFailure(125, fatal, rules)).toEqual({ detail: fatal })
|
||||
})
|
||||
})
|
||||
|
||||
describe('result facts', () => {
|
||||
it.each([126, 127])('keeps a successfully launched wrapped child exit %i as an ordinary outcome', async (exitCode) => {
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: ['env', ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
|
||||
const { bash } = await setup()
|
||||
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
|
||||
@@ -253,25 +525,66 @@ describe('result facts', () => {
|
||||
})
|
||||
|
||||
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
|
||||
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('background sandbox facts', () => {
|
||||
it('stamps facts and releases accounting when background spawn fails', async () => {
|
||||
const { bash } = await setup()
|
||||
const missingWorkdir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')), 'missing')
|
||||
const task = bash.start(bash.resolve({ command: 'true', workdir: missingWorkdir }))
|
||||
it.each(RUNNER_FORMS)('keeps an invalid-workdir rejection ordinary for the %s provider-runner form', async (_form, runner) => {
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
try {
|
||||
const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
await task.done
|
||||
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain('spawn failed:')
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
})
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not invent runner evidence when a spawn rejection has no structured reason', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const emptyReader: SubprocessOutputReader = {
|
||||
readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
|
||||
}
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({
|
||||
pid: -1,
|
||||
stdin: undefined,
|
||||
stdout: undefined,
|
||||
stderr: undefined,
|
||||
collected: { stdout: emptyReader, stderr: emptyReader },
|
||||
// Arbitrary subprocess providers can reject without a value; that edge is the point of this test.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
done: Promise.reject(undefined),
|
||||
terminate: vi.fn(),
|
||||
waitForExit: async () => true,
|
||||
} satisfies SubprocessHandle)
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain('spawn failed:')
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
expect(task.readOutput().delta).toContain('spawn failed: undefined')
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
|
||||
@@ -284,7 +597,7 @@ describe('background sandbox facts', () => {
|
||||
it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
|
||||
// The wrap's runner prefix on a failed run means the SANDBOX broke and
|
||||
// the command never ran — the late twin of the confine-time throw, with
|
||||
// the runner's own first stderr line carried as the cause.
|
||||
// the matched fatal stderr line carried as the cause.
|
||||
const { bash } = await setup()
|
||||
const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
|
||||
await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
@@ -315,7 +628,7 @@ describe('background sandbox facts', () => {
|
||||
let call = 0
|
||||
const { bash } = await setup({}, (argv) => {
|
||||
const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
|
||||
return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE }
|
||||
return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE }
|
||||
})
|
||||
const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
|
||||
const quick = bash.start(bash.resolve({ command: 'true' }))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -76,6 +76,32 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('evaluates BASH_ENV only after Seatbelt confines the inner Bash', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const hook = join(workdir, 'bash-env-hook.sh')
|
||||
const insideProbe = join(workdir, 'hook-ran.txt')
|
||||
const outsideProbe = join(outside, 'escaped.txt')
|
||||
await writeFile(hook, [
|
||||
'printf hook > "$DSH_BASH_ENV_INSIDE"',
|
||||
'printf escaped > "$DSH_BASH_ENV_OUTSIDE"',
|
||||
'',
|
||||
].join('\n'))
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
await bash.run(bash.resolve({
|
||||
command: 'true',
|
||||
env: { BASH_ENV: hook },
|
||||
dshEnv: {
|
||||
DSH_BASH_ENV_INSIDE: insideProbe,
|
||||
DSH_BASH_ENV_OUTSIDE: outsideProbe,
|
||||
},
|
||||
}))
|
||||
|
||||
expect(readFileSync(insideProbe, 'utf8')).toBe('hook')
|
||||
expect(existsSync(outsideProbe)).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
|
||||
@@ -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/bash/bash/README.md
|
||||
README.md: d7bf746969f52000fe298b65b995b7c631d8001c
|
||||
README.zh.md: a7c0cac0bce2154362c822c213a44f3c507d541c
|
||||
README.md: 88f519a21a0889d6b7649502c51077940c23709f
|
||||
README.zh.md: 294044692133da8baa57583146352e84c1ff9946
|
||||
|
||||
@@ -25,7 +25,7 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
|
||||
| `BashProcess.readOutput()` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. |
|
||||
| `BashProcess.kill()` | Kill the process group. Returns `false` when it already finished. |
|
||||
|
||||
Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit — see the HMR-safety tests.
|
||||
Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
@@ -35,6 +35,8 @@ The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, th
|
||||
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives on the seam so the two tools never drift on the marker contract.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
|
||||
|
||||
本包(package)是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
|
||||
本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
@@ -25,7 +25,7 @@
|
||||
| `BashProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区边界丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 |
|
||||
| `BashProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 |
|
||||
|
||||
实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出,详见 HMR(热模块替换)安全测试。
|
||||
实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出。
|
||||
|
||||
## 词汇
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
|
||||
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。
|
||||
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染契约的另一半:`dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 追加的 `[exit code: N]`/`[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill;它放在 seam 上,两个工具便永远不会在 marker 契约上漂移。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -22,6 +22,8 @@ export type {
|
||||
DshEnvironment,
|
||||
DshEnvironmentKey,
|
||||
} from './types.ts'
|
||||
export { parseExitStatus } from './render.ts'
|
||||
export type { ParsedExitStatus } from './render.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
|
||||
42
packages/bash/bash/src/render.ts
Normal file
42
packages/bash/bash/src/render.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Shared rendering helpers for the shell tools (`dsh-tool-bash`,
|
||||
* `dsh-tool-pwsh`): the exit-status marker contract the tools' renderers
|
||||
* emit and the presentation layer parses back.
|
||||
* @module @deepseek-ai/dsh-bash/render
|
||||
*/
|
||||
|
||||
/**
|
||||
* The exit status recovered from a rendered result, with the output body that
|
||||
* status was split off from.
|
||||
*/
|
||||
export type ParsedExitStatus =
|
||||
& { body: string }
|
||||
& ({ exitCode: number } | { signal: string })
|
||||
|
||||
/**
|
||||
* Split a rendered shell-tool result string into its output body and the
|
||||
* structured exit status — the inverse of the `[exit code: N]` /
|
||||
* `[killed by signal: X]` markers the shell tools' renderers append. A killed
|
||||
* marker yields `signal`; otherwise a non-zero marker yields `exitCode`;
|
||||
* absent both means a clean exit 0.
|
||||
*
|
||||
* The consumed marker is removed from `body` because a terminal presentation
|
||||
* shows the exit status as its own pill: leaving the marker in the output
|
||||
* would render the exit twice. Other markers (timeout, sandbox denial) carry
|
||||
* facts no pill shows, so they stay in the body.
|
||||
*
|
||||
* Replay only retains the rendered content text, not the original
|
||||
* `BashRunResult`, so terminal presentation must recover the exit pill here.
|
||||
* Requiring a leading newline and the end of the string keeps ordinary output
|
||||
* that merely ends with marker-like text from matching unless the final line
|
||||
* is indistinguishable from a real marker.
|
||||
* @param text - rendered model-facing shell-tool result.
|
||||
* @returns the marker-free body plus the recovered terminal exit code or signal.
|
||||
*/
|
||||
export function parseExitStatus(text: string): ParsedExitStatus {
|
||||
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
|
||||
if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
|
||||
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
|
||||
if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
|
||||
return { body: text, exitCode: 0 }
|
||||
}
|
||||
36
packages/bash/bash/tests/render.spec.ts
Normal file
36
packages/bash/bash/tests/render.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared exit-status parse contract: the inverse of the `[exit code: N]` /
|
||||
* `[killed by signal: X]` markers `dsh-tool-bash` and `dsh-tool-pwsh` append.
|
||||
* Both tools' presenter suites round-trip their own renderers through this
|
||||
* parse; this spec pins the parse's own edges (marker-like output, body
|
||||
* slicing) once, at the seam that owns it.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseExitStatus } from '../src/render.ts'
|
||||
|
||||
describe('parseExitStatus', () => {
|
||||
it('recovers a clean exit 0 with the body verbatim when no marker is present', () => {
|
||||
expect(parseExitStatus('hi\n\n')).toEqual({ body: 'hi\n\n', exitCode: 0 })
|
||||
expect(parseExitStatus('')).toEqual({ body: '', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('recovers a non-zero exit and strips only its marker from the body', () => {
|
||||
expect(parseExitStatus('oops\n[exit code: 3]')).toEqual({ body: 'oops', exitCode: 3 })
|
||||
// The marker needs the leading newline and the end of the string, so a
|
||||
// clean result whose output merely ENDS in marker-like text is not read
|
||||
// as a failure and the text stays in the body.
|
||||
expect(parseExitStatus('[exit code: 5]')).toEqual({ body: '[exit code: 5]', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('recovers a signal kill ahead of any non-zero exit marker', () => {
|
||||
expect(parseExitStatus('gone\n[killed by signal: SIGKILL]')).toEqual({ body: 'gone', signal: 'SIGKILL' })
|
||||
// A fake signal marker with no leading newline is output, not a kill.
|
||||
expect(parseExitStatus('[killed by signal: SIGKILL]')).toEqual({ body: '[killed by signal: SIGKILL]', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('keeps markers no pill shows (timeout) in the body', () => {
|
||||
expect(parseExitStatus('slow\n[timed out after 100ms]\n[exit code: 143]'))
|
||||
.toEqual({ body: 'slow\n[timed out after 100ms]', exitCode: 143 })
|
||||
})
|
||||
})
|
||||
6
packages/bash/pwsh-local/README.i18n.yaml
Normal file
6
packages/bash/pwsh-local/README.i18n.yaml
Normal 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/bash/pwsh-local/README.md
|
||||
README.md: 3e38ea3830cb651a80eaee744a42f68891767358
|
||||
README.zh.md: 8d32ce865d299bac37704e3e8730a7faa63ee108
|
||||
56
packages/bash/pwsh-local/README.md
Normal file
56
packages/bash/pwsh-local/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# @deepseek-ai/dsh-pwsh-local
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Local PowerShell implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
|
||||
|
||||
The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
|
||||
|
||||
The package root exports the default and named `PwshLocalExecutor` plugin, its `Config`, the pure `resolvePwshPath`/`candidatePwshPaths` helpers, and the `ENV_OVERRIDES`/`ENCODING_PREAMBLE` constants the executor injects into every spawn.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-pwsh-local'
|
||||
config:
|
||||
cwd: C:\path\to\workspace # default: process.cwd()
|
||||
timeoutMs: 120000 # default foreground timeout
|
||||
maxTimeoutMs: 600000 # cap for per-call overrides
|
||||
maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk
|
||||
maxSpillBytes: 67108864 # per-stream full-output spill cap
|
||||
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
|
||||
pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH
|
||||
```
|
||||
|
||||
## Behavior (and where it came from)
|
||||
|
||||
The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call:
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
|
||||
- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
|
||||
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-pwsh`, which renders this executor's bounded stdout/stderr tails, background-process deltas (through the generic task runtime), spill-file paths, and infrastructure failures.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead.
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`.
|
||||
- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures.
|
||||
- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
|
||||
- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly.
|
||||
- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead.
|
||||
- **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws. pwsh 7 defaults to UTF-8 and is unaffected.
|
||||
|
||||
Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.
|
||||
56
packages/bash/pwsh-local/README.zh.md
Normal file
56
packages/bash/pwsh-local/README.zh.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# @deepseek-ai/dsh-pwsh-local
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell 实现,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>`,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。
|
||||
|
||||
命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(`bash -c` 字符串域在这里没有对应物)。原生 Win32 路径(`C:\...`)原样通过。
|
||||
|
||||
包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`、纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数,以及执行器注入每次 spawn 的 `ENV_OVERRIDES`/`ENCODING_PREAMBLE` 常量。
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-pwsh-local'
|
||||
config:
|
||||
cwd: C:\path\to\workspace # default: process.cwd()
|
||||
timeoutMs: 120000 # default foreground timeout
|
||||
maxTimeoutMs: 600000 # cap for per-call overrides
|
||||
maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk
|
||||
maxSpillBytes: 67108864 # per-stream full-output spill cap
|
||||
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
|
||||
pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH
|
||||
```
|
||||
|
||||
## 行为(及其由来)
|
||||
|
||||
作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义:
|
||||
|
||||
- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
|
||||
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。
|
||||
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
|
||||
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
|
||||
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
|
||||
- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接地,经由 `dsh-tool-pwsh` 呈现本执行器的有界 stdout/stderr 尾部、后台进程增量(经通用任务运行时)、spill 文件路径与基础设施失败。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接失效;具名消费方拥有请求前缀的任何变更。
|
||||
|
||||
## 已知局限与延期工作
|
||||
|
||||
- **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要约束的部署应组合沙箱化 bash 执行器或策略。
|
||||
- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`。
|
||||
- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。
|
||||
- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。
|
||||
- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。
|
||||
- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。
|
||||
- **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8,不受影响。
|
||||
|
||||
清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。
|
||||
45
packages/bash/pwsh-local/package.json
Normal file
45
packages/bash/pwsh-local/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-pwsh-local",
|
||||
"description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam",
|
||||
"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-bash": "^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-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
291
packages/bash/pwsh-local/src/index.ts
Normal file
291
packages/bash/pwsh-local/src/index.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Local PowerShell implementation of the bash executor seam. Each command runs
|
||||
* as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` in a managed
|
||||
* process spawned through `ctx.subprocess`; the executor owns command
|
||||
* defaulting, deadlines and cause classification, the model-friendly terminal
|
||||
* environment, and the model-facing stdout/stderr merge for background reads.
|
||||
*
|
||||
* The command string is passed as ONE argv element to `-Command`: PowerShell
|
||||
* itself parses the text, and no intermediate shell exists, so there is no
|
||||
* shell-quoting layer to escape (the `bash -c` string domain has no
|
||||
* equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-pwsh-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { resolvePwshPath } from './resolve.ts'
|
||||
|
||||
/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */
|
||||
/**
|
||||
* Model-friendly environment overrides for PowerShell: disable colors and
|
||||
* pagers that would garble tool output. `TERM=dumb` is a POSIX concept and is
|
||||
* deliberately absent; `NO_COLOR` is honored by modern pwsh renderers.
|
||||
*/
|
||||
export const ENV_OVERRIDES = {
|
||||
NO_COLOR: '1',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* UTF-8 output pinning prepended to every command. The subprocess collector
|
||||
* decodes output bytes as UTF-8, but Windows PowerShell 5.1 (the last-resort
|
||||
* executable fallback) writes the console/OEM code page by default, which
|
||||
* garbles non-ASCII output; pwsh 7 defaults to UTF-8 and is unaffected. The
|
||||
* statements ride on line 1 after `; ` separators so PowerShell error line
|
||||
* numbers stay accurate.
|
||||
*/
|
||||
export const ENCODING_PREAMBLE =
|
||||
'[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); '
|
||||
|
||||
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */
|
||||
const DEFAULT_GRACE_MS = 3_000
|
||||
|
||||
/** Default per-stream spill cap (the `maxSpillBytes` config). */
|
||||
const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
|
||||
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Default working directory for commands (default: process.cwd()). */
|
||||
cwd?: string
|
||||
/** Default foreground timeout in milliseconds. */
|
||||
timeoutMs?: number
|
||||
/** Upper bound for per-call timeout overrides. */
|
||||
maxTimeoutMs?: number
|
||||
/** Per-stream in-memory output cap; overflow spills to a temp file. */
|
||||
maxOutputBytes?: number
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes?: number
|
||||
/** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
|
||||
graceMs?: number
|
||||
/**
|
||||
* Explicit pwsh executable. When omitted, well-known Windows install
|
||||
* locations and PATH entries are probed in order (PowerShell 7 install,
|
||||
* PATH entries such as the Microsoft Store install, then Windows
|
||||
* PowerShell 5.1), falling back to a bare `pwsh` resolved through PATH.
|
||||
*/
|
||||
pwshPath?: string
|
||||
}
|
||||
|
||||
/** The shape after schemastery applied the defaults (cwd/pwshPath have none). */
|
||||
type ResolvedConfig = Required<Omit<Config, 'cwd' | 'pwshPath'>> & Pick<Config, 'cwd' | 'pwshPath'>
|
||||
|
||||
// Resolution lives in its own dependency-free module so the repository's
|
||||
// coverage-gate probe shares the exact definition the suites use.
|
||||
export { candidatePwshPaths, resolvePwshPath } from './resolve.ts'
|
||||
|
||||
/** Project a settled collect-mode reader into the final CollectedOutput shape. */
|
||||
function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
|
||||
const read = reader.readFrom(0)
|
||||
return {
|
||||
text: read.text,
|
||||
truncated: read.lossy,
|
||||
...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`pwsh-local: ${name} must be a positive finite number`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local PowerShell executor over `ctx.subprocess`. Bounded output, spill
|
||||
* files, and process-tree termination are the subprocess service's mechanics;
|
||||
* this executor supplies their configured budgets per spawn.
|
||||
*/
|
||||
export class PwshLocalExecutor extends BashExecutor {
|
||||
static inject = ['subprocess']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string(),
|
||||
timeoutMs: z.number().default(120_000),
|
||||
maxTimeoutMs: z.number().default(600_000),
|
||||
maxOutputBytes: z.number().default(64_000),
|
||||
maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
pwshPath: z.string(),
|
||||
})
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
/** The pwsh executable resolved once at construction. */
|
||||
readonly pwshPath: string
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// Schemastery fills these fields before construction; the type does not encode that step.
|
||||
this.config = config as ResolvedConfig
|
||||
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
this.pwshPath = resolvePwshPath(this.config.pwshPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a request into a fully-specified spec: fill `workdir` from
|
||||
* `config.cwd` (else `process.cwd()`), and `timeoutMs` from
|
||||
* `config.timeoutMs`, capped at `config.maxTimeoutMs`.
|
||||
*/
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
const timeoutMs = clampTimeout(
|
||||
request.timeoutMs,
|
||||
this.config.timeoutMs,
|
||||
this.config.maxTimeoutMs,
|
||||
'pwsh-local: request.timeoutMs',
|
||||
)
|
||||
const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
|
||||
assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
|
||||
timeoutMs,
|
||||
stdoutMaxBytes,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
|
||||
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
|
||||
const collect = (maxBytes: number): SubprocessCollect =>
|
||||
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
||||
return {
|
||||
argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`],
|
||||
cwd: spec.workdir,
|
||||
stdio: {
|
||||
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
|
||||
stdout: collect(stdoutMaxBytes),
|
||||
stderr: collect(this.config.maxOutputBytes),
|
||||
},
|
||||
graceMs: this.config.graceMs,
|
||||
signal,
|
||||
env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv },
|
||||
}
|
||||
}
|
||||
|
||||
/** The collect-mode readers the executor itself requested (present by construction). */
|
||||
private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } {
|
||||
const { stdout, stderr } = handle.collected
|
||||
/* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */
|
||||
if (stdout === undefined || stderr === undefined) {
|
||||
throw new Error('pwsh-local: subprocess implementation dropped a requested collect stream')
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
|
||||
const outcome = await handle.done
|
||||
const collected = PwshLocalExecutor.collected(handle)
|
||||
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
|
||||
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
|
||||
const aborted = d.signal.aborted && !timedOut
|
||||
return {
|
||||
...outcome,
|
||||
timedOut,
|
||||
aborted,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
stdout: finalOutput(collected.stdout),
|
||||
stderr: finalOutput(collected.stderr),
|
||||
}
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
const collected = PwshLocalExecutor.collected(running)
|
||||
|
||||
// A spawn failure produces no process output, so the subprocess service has nothing
|
||||
// to buffer; the note is delivered exactly once through the read path.
|
||||
let spawnFailureNote: string | undefined
|
||||
const consumeSpawnFailure = (): string => {
|
||||
const note = spawnFailureNote ?? ''
|
||||
spawnFailureNote = undefined
|
||||
return note
|
||||
}
|
||||
|
||||
let stdoutOffset = 0
|
||||
let stderrOffset = 0
|
||||
const proc: BashProcess = {
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done: running.done.then((outcome) => {
|
||||
// Any signal termination is killed, including a command signaling itself.
|
||||
if (proc.status === 'running') {
|
||||
proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
|
||||
}
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
|
||||
}, (error: unknown) => {
|
||||
// Background spawn failures settle as killed and surface through the read path.
|
||||
proc.status = 'killed'
|
||||
spawnFailureNote = `spawn failed: ${String(error)}`
|
||||
this.onProcessDone(proc, spawnFailureNote)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = collected.stdout.readFrom(stdoutOffset)
|
||||
const err = collected.stderr.readFrom(stderrOffset)
|
||||
stdoutOffset = out.nextOffset
|
||||
stderrOffset = err.nextOffset
|
||||
|
||||
// A failed spawn never produced process output, so the note and real
|
||||
// stderr text are mutually exclusive.
|
||||
const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
|
||||
// Single newline between sections: stdout chunks usually end with one
|
||||
// already; add it only when missing.
|
||||
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
|
||||
const delta = out.text
|
||||
+ (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
|
||||
return {
|
||||
delta,
|
||||
lossy: out.lossy || err.lossy,
|
||||
...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
|
||||
...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
|
||||
}
|
||||
},
|
||||
kill: (): boolean => {
|
||||
if (proc.status !== 'running') return false
|
||||
proc.status = 'killed'
|
||||
running.terminate()
|
||||
return true
|
||||
},
|
||||
}
|
||||
return proc
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement hook for subclasses that attach execution facts to a process.
|
||||
* The base implementation is intentionally empty. Mirrored from
|
||||
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is
|
||||
* the declared seam for a future pwsh-confining subclass and has no consumer
|
||||
* in this package yet.
|
||||
* @param _proc - the settled process handle.
|
||||
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
|
||||
*/
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
export default PwshLocalExecutor
|
||||
30
packages/bash/pwsh-local/src/invariant.ts
Normal file
30
packages/bash/pwsh-local/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-local`.
|
||||
* @module @deepseek-ai/dsh-pwsh-local/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'pwsh-local-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
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 */
|
||||
60
packages/bash/pwsh-local/src/resolve.ts
Normal file
60
packages/bash/pwsh-local/src/resolve.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* PowerShell executable resolution, dependency-free so non-package consumers
|
||||
* (the repository's coverage-gate probe in `vitest.config.ts`) can share the
|
||||
* ONE resolution definition with the executor and its suites — a probe that
|
||||
* resolved differently from the code under test could exempt a file whose
|
||||
* suites actually run.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-pwsh-local/resolve
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Well-known Windows PowerShell install locations plus PATH entries, newest
|
||||
* first. Explicitly parameterized (env) so resolution is a pure function of
|
||||
* its inputs on every platform.
|
||||
* @param env - the environment to probe; defaults to the process environment.
|
||||
* @returns candidate `pwsh` executable paths in resolution order.
|
||||
*/
|
||||
export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
|
||||
const systemRoot = env.SystemRoot ?? 'C:\\Windows'
|
||||
const candidates = [
|
||||
join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
|
||||
]
|
||||
// Microsoft Store installs (and any user-added location) live on PATH;
|
||||
// entries may carry surrounding quotes from `setx`-style definitions.
|
||||
for (const entry of (env.PATH ?? '').split(';')) {
|
||||
const trimmed = entry.trim().replace(/^"|"$/g, '')
|
||||
if (trimmed.length === 0) continue
|
||||
candidates.push(join(trimmed, 'pwsh.exe'))
|
||||
}
|
||||
// Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts.
|
||||
candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'))
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the pwsh executable this executor spawns.
|
||||
* @param configured - an explicit `pwshPath` config value, trusted as-is.
|
||||
* @param env - the environment to probe on Windows; defaults to the process environment.
|
||||
* @param platform - the platform to resolve for; defaults to the process platform.
|
||||
* @returns the first existing well-known location on Windows (PowerShell 7
|
||||
* install, a PATH entry such as the Microsoft Store install, then Windows
|
||||
* PowerShell 5.1), else `pwsh` for PATH resolution.
|
||||
*/
|
||||
export function resolvePwshPath(
|
||||
configured?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
if (configured !== undefined && configured.length > 0) return configured
|
||||
if (platform === 'win32') {
|
||||
for (const candidate of candidatePwshPaths(env)) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
return 'pwsh'
|
||||
}
|
||||
457
packages/bash/pwsh-local/tests/executor.spec.ts
Normal file
457
packages/bash/pwsh-local/tests/executor.spec.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Real-process tests for `@deepseek-ai/dsh-pwsh-local`: the LOCAL subprocess
|
||||
* service plus a REAL pwsh executable, exercised through the executor seam
|
||||
* (`resolve` → `run`/`start`). These verify the world — actual PowerShell
|
||||
* runs, output capture, truncation and spill, deadlines, kill escalation, and
|
||||
* the background-handle contract. The suite self-skips when no usable `pwsh`
|
||||
* resolves (a CI accommodation for hosts without PowerShell); the pure unit tests
|
||||
* (config validation, executable resolution) run on every platform. PowerShell
|
||||
* writes CRLF on Windows, so exact text assertions normalize line endings.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SubprocessService from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-'))
|
||||
|
||||
// The probe follows the executor's own resolution (Program Files installs on
|
||||
// Windows are found even when bare `pwsh` is not on PATH).
|
||||
const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
|
||||
/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
|
||||
const lf = (text: string): string => text.replace(/\r\n/g, '\n')
|
||||
|
||||
/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */
|
||||
function samePath(actual: string, expected: string): boolean {
|
||||
const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value)
|
||||
return norm(actual) === norm(expected)
|
||||
}
|
||||
|
||||
async function setup(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
// A short kill grace via the REAL config path, so escalation tests stay fast.
|
||||
await ctx.plugin(PwshLocalExecutor, { graceMs: 200, ...config })
|
||||
const bash = ctx.bash as PwshLocalExecutor
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a handle's consuming readOutput until the ACCUMULATED delta contains
|
||||
* `expected`; returns the accumulation (reads never re-deliver, so the caller
|
||||
* gets everything produced up to the match).
|
||||
*/
|
||||
async function readUntil(proc: BashProcess, expected: string, timeoutMs = 5_000): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let all = ''
|
||||
while (Date.now() < deadline) {
|
||||
all += proc.readOutput().delta
|
||||
if (lf(all).includes(expected)) return lf(all)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(lf(all))}`)
|
||||
}
|
||||
|
||||
describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => {
|
||||
it('trusts an explicit configured path verbatim', () => {
|
||||
expect(resolvePwshPath('C:\\custom\\pwsh.exe')).toBe('C:\\custom\\pwsh.exe')
|
||||
expect(resolvePwshPath('pwsh')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('falls through an empty configured path to platform resolution', () => {
|
||||
// SystemRoot points at a non-existent tree so the Windows PowerShell 5.1
|
||||
// fallback candidate cannot exist either.
|
||||
expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('returns pwsh on non-Windows platforms regardless of the environment', () => {
|
||||
expect(resolvePwshPath(undefined, { ProgramFiles: 'P:\\Program Files' }, 'linux')).toBe('pwsh')
|
||||
expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => {
|
||||
const candidates = candidatePwshPaths({
|
||||
ProgramFiles: 'P:\\Program Files',
|
||||
SystemRoot: 'S:\\Windows',
|
||||
PATH: ';"Q:\\quoted store";' + ';',
|
||||
})
|
||||
expect(candidates).toEqual([
|
||||
join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
|
||||
join('Q:\\quoted store', 'pwsh.exe'),
|
||||
join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
])
|
||||
// A missing PATH contributes no entries (the empty-string fallback).
|
||||
expect(candidatePwshPaths({ ProgramFiles: 'P:\\Program Files', SystemRoot: 'S:\\Windows' }))
|
||||
.toEqual([
|
||||
join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
|
||||
join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the first EXISTING win32 candidate, else pwsh', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-'))
|
||||
const store = join(dir, 'store')
|
||||
mkdirSync(store, { recursive: true })
|
||||
writeFileSync(join(store, 'pwsh.exe'), '')
|
||||
// The existing PATH entry wins over the non-existent Program Files install.
|
||||
expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32'))
|
||||
.toBe(join(store, 'pwsh.exe'))
|
||||
// No candidate exists anywhere (SystemRoot points at a non-existent tree,
|
||||
// so even the Windows PowerShell 5.1 fallback cannot exist) → the
|
||||
// PATH-resolution fallback.
|
||||
expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32'))
|
||||
.toBe('pwsh')
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawn construction (pure, every platform)', () => {
|
||||
/** A subprocess service that records spawn specs and settles instantly. */
|
||||
class CapturingSubprocessService extends SubprocessService {
|
||||
specs: SubprocessSpawnSpec[] = []
|
||||
private readonly reader: SubprocessOutputReader = {
|
||||
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
|
||||
}
|
||||
override spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
|
||||
this.specs.push(spec)
|
||||
return {
|
||||
pid: -1,
|
||||
stdin: undefined,
|
||||
stdout: undefined,
|
||||
stderr: undefined,
|
||||
collected: { stdout: this.reader, stderr: this.reader },
|
||||
done: Promise.resolve({ exitCode: 0, signal: null }),
|
||||
terminate: () => {},
|
||||
waitForExit: async () => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => {
|
||||
const ctx = new Context()
|
||||
const subprocess = new CapturingSubprocessService(ctx)
|
||||
await ctx.plugin(PwshLocalExecutor)
|
||||
await ctx.bash.run(ctx.bash.resolve({ command: 'Write-Output 你好' }))
|
||||
expect(subprocess.specs).toHaveLength(1)
|
||||
const { argv } = subprocess.specs[0]!
|
||||
expect(argv.slice(0, 5)).toEqual([expect.any(String), '-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
||||
expect(argv[5]).toBe(`${ENCODING_PREAMBLE}Write-Output 你好`)
|
||||
expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding')
|
||||
expect(ENCODING_PREAMBLE).toContain('$OutputEncoding')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
|
||||
it('resolves with output and the effective timeout', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 5_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Write-Output hi' }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(lf(result.stdout.text)).toBe('hi\n')
|
||||
expect(result.timeoutMs).toBe(5_000)
|
||||
})
|
||||
|
||||
it('uses config cwd, overridable per call', async () => {
|
||||
const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-'))
|
||||
const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-'))
|
||||
const { bash } = await setup({ cwd: first })
|
||||
const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' }))
|
||||
expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true)
|
||||
const fromCall = await bash.run(bash.resolve({ command: '(Get-Location).Path', workdir: second }))
|
||||
expect(samePath(fromCall.stdout.text.trim(), second)).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults cwd to process.cwd()', async () => {
|
||||
const { bash } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: '(Get-Location).Path' }))
|
||||
expect(samePath(result.stdout.text.trim(), process.cwd())).toBe(true)
|
||||
})
|
||||
|
||||
it('caps per-call timeouts at maxTimeoutMs', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 1_000, maxTimeoutMs: 2_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Write-Output ok', timeoutMs: 99_999 }))
|
||||
expect(result.timeoutMs).toBe(2_000)
|
||||
})
|
||||
|
||||
it('rejects invalid numeric config and timeout overrides', async () => {
|
||||
await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
|
||||
await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
|
||||
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
|
||||
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
|
||||
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
|
||||
await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 }))
|
||||
.rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
|
||||
const { bash } = await setup()
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/)
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: -1 })).toThrow(/request\.stdoutMaxBytes/)
|
||||
})
|
||||
|
||||
it('defaults stdoutMaxBytes to maxOutputBytes and lets foreground callers raise stdout only', async () => {
|
||||
const { bash } = await setup({ maxOutputBytes: 100 })
|
||||
expect(bash.resolve({ command: 'Write-Output ok' }).stdoutMaxBytes).toBe(100)
|
||||
|
||||
// Raw Console writes avoid PowerShell's own line-ending and formatting
|
||||
// layers, so the byte counts are exact on every platform.
|
||||
const result = await bash.run(bash.resolve({
|
||||
command: '[Console]::Out.Write("x" * 500); [Console]::Error.WriteLine("e" * 500)',
|
||||
stdoutMaxBytes: 500,
|
||||
}))
|
||||
|
||||
expect(result.stdout.text).toBe('x'.repeat(500))
|
||||
expect(result.stdout.truncated).toBe(false)
|
||||
expect(result.stderr.truncated).toBe(true)
|
||||
expect(result.stderr.text.length).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 60_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', timeoutMs: 100 }))
|
||||
expect(result.timedOut).toBe(true)
|
||||
// Mutually exclusive: a timeout classifies as timedOut, never also aborted.
|
||||
expect(result.aborted).toBe(false)
|
||||
expect(result.timeoutMs).toBe(100)
|
||||
})
|
||||
|
||||
it('propagates abort signals', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await pending
|
||||
expect(result.aborted).toBe(true)
|
||||
// Mutually exclusive: an upstream cancel classifies as aborted, never also timedOut.
|
||||
expect(result.timedOut).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a self-killed command as neither timed out nor aborted', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 60_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Stop-Process -Id $PID' }))
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.aborted).toBe(false)
|
||||
// Windows reports a forced termination without a signal; POSIX reports the
|
||||
// terminating signal PowerShell chose (SIGTERM, or SIGKILL for the hard kill).
|
||||
if (process.platform === 'win32') {
|
||||
expect(result.signal).toBeNull()
|
||||
} else {
|
||||
expect(['SIGTERM', 'SIGKILL']).toContain(result.signal)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects on spawn failure (bad workdir)', async () => {
|
||||
const { bash } = await setup()
|
||||
await expect(bash.run(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => {
|
||||
const { bash } = await setup()
|
||||
const spec = bash.resolve({
|
||||
command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:SEAM_VAR][$env:DSH_SEAM_VAR]"',
|
||||
stdin: 'piped\n',
|
||||
env: { SEAM_VAR: 'env-ok' },
|
||||
dshEnv: { DSH_SEAM_VAR: 'dsh-ok' },
|
||||
})
|
||||
// resolve() keeps the optional input/environment fields verbatim.
|
||||
expect(spec.stdin).toBe('piped\n')
|
||||
expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' })
|
||||
expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' })
|
||||
const result = await bash.run(spec)
|
||||
expect(lf(result.stdout.text)).toBe('piped\n[env-ok][dsh-ok]\n')
|
||||
})
|
||||
|
||||
it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => {
|
||||
const { bash } = await setup()
|
||||
const spec = bash.resolve({ command: 'Write-Output ok' })
|
||||
expect('stdin' in spec).toBe(false)
|
||||
expect('env' in spec).toBe(false)
|
||||
expect('dshEnv' in spec).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)', () => {
|
||||
it('start returns immediately with a running handle that settles as completed', async () => {
|
||||
const { bash } = await setup()
|
||||
const before = Date.now()
|
||||
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' }))
|
||||
expect(Date.now() - before).toBeLessThan(150)
|
||||
expect(proc.status).toBe('running')
|
||||
await proc.done
|
||||
expect(proc.status).toBe('completed')
|
||||
expect(proc.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('threads stdin and extra env into a background process', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({
|
||||
command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:BG_VAR][$env:DSH_BG_VAR]"',
|
||||
stdin: 'bg-stdin\n',
|
||||
env: { BG_VAR: 'bg-env' },
|
||||
dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
|
||||
}))
|
||||
const output = await readUntil(proc, '[bg-env][bg-dsh-env]')
|
||||
expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
|
||||
await proc.done
|
||||
expect(proc.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output first; Start-Sleep -Seconds 1; Write-Output second' }))
|
||||
const first = await readUntil(proc, 'first\n')
|
||||
expect(lf(first)).toBe('first\n')
|
||||
await proc.done
|
||||
// Read-after-exit returns the remaining buffered output — once.
|
||||
const second = proc.readOutput()
|
||||
expect(lf(second.delta)).toBe('second\n')
|
||||
expect(second.lossy).toBe(false)
|
||||
expect(proc.readOutput().delta).toBe('')
|
||||
})
|
||||
|
||||
it('readOutput marks stderr sections', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output out; [Console]::Error.WriteLine("err")' }))
|
||||
await proc.done
|
||||
expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput reports stderr-only deltas without a leading newline', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: '[Console]::Error.WriteLine("err")' }))
|
||||
await proc.done
|
||||
expect(lf(proc.readOutput().delta)).toBe('[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: '[Console]::Out.Write("out"); [Console]::Error.WriteLine("err")' }))
|
||||
await proc.done
|
||||
expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput flags lossy reads and reports stdout spill paths', async () => {
|
||||
const { bash } = await setup({ maxOutputBytes: 100 })
|
||||
const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { "line-$_" }' }))
|
||||
await proc.done
|
||||
const read = proc.readOutput()
|
||||
// Window slid past offset 0 → lossy, spill path points at the full stream.
|
||||
expect(read.lossy).toBe(true)
|
||||
expect(read.stdoutSpillPath).toBeDefined()
|
||||
})
|
||||
|
||||
it('readOutput reports stderr spill paths', async () => {
|
||||
const { bash } = await setup({ maxOutputBytes: 100 })
|
||||
const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { [Console]::Error.WriteLine("line-$_") }' }))
|
||||
await proc.done
|
||||
const read = proc.readOutput()
|
||||
expect(read.lossy).toBe(true)
|
||||
expect(read.stderrSpillPath).toBeDefined()
|
||||
expect(lf(read.delta)).toContain('[stderr]')
|
||||
})
|
||||
|
||||
it('kill() terminates the process tree: true once, false after settlement', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
|
||||
expect(proc.kill()).toBe(true)
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.kill()).toBe(false)
|
||||
})
|
||||
|
||||
it('kill() returns false for a naturally completed process', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output ok' }))
|
||||
await proc.done
|
||||
expect(proc.status).toBe('completed')
|
||||
expect(proc.kill()).toBe(false)
|
||||
})
|
||||
|
||||
it('a spec.signal abort settles the handle as killed, not completed', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal }))
|
||||
controller.abort()
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('a self-signal exit settles the handle as killed, not completed (POSIX)', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Stop-Process -Id $PID' }))
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.exitCode).toBeNull()
|
||||
// PowerShell picks SIGTERM for Stop-Process, SIGKILL for the hard kill.
|
||||
expect(['SIGTERM', 'SIGKILL']).toContain(proc.signal)
|
||||
})
|
||||
|
||||
it('a background spawn failure settles as killed with the error readable on stderr', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))
|
||||
// done resolves (never rejects) even though the process never ran.
|
||||
await expect(proc.done).resolves.toBeUndefined()
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.readOutput().delta).toContain('spawn failed:')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service, not the executor)', () => {
|
||||
it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => {
|
||||
const ctx = new Context()
|
||||
const managerFiber = await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
const executorFiber = await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as PwshLocalExecutor
|
||||
|
||||
// The child prints its own pid so the test can probe liveness through the
|
||||
// public read surface alone.
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output $PID; Start-Sleep -Seconds 60' }))
|
||||
const pid = Number((await readUntil(proc, '\n')).trim())
|
||||
expect(Number.isInteger(pid) && pid > 0).toBe(true)
|
||||
|
||||
// Executor reload/disposal leaves background work running — the
|
||||
// handle stays live and readable, mirroring the task runtime's
|
||||
// registrations-outlive-producer-fibers contract.
|
||||
await executorFiber.dispose()
|
||||
expect(proc.status).toBe('running')
|
||||
expect(() => process.kill(pid, 0)).not.toThrow()
|
||||
|
||||
// Service disposal kills the group and AWAITS its exit (no orphans).
|
||||
await managerFiber.dispose()
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
await proc.done
|
||||
// POSIX reports the kill as a signal; Windows reports a forced
|
||||
// termination as exit 1 with no signal (indistinguishable from a crash),
|
||||
// so the status stamp follows the platform's exit facts.
|
||||
expect(proc.status).toBe(process.platform === 'win32' ? 'completed' : 'killed')
|
||||
})
|
||||
|
||||
it('service disposal settles running handles and leaves settled ones untouched', async () => {
|
||||
const ctx = new Context()
|
||||
const managerFiber = await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as PwshLocalExecutor
|
||||
|
||||
const finished = bash.start(bash.resolve({ command: 'Write-Output done' }))
|
||||
await finished.done
|
||||
expect(finished.status).toBe('completed')
|
||||
const running = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
|
||||
|
||||
await managerFiber.dispose()
|
||||
// A settled process was untouched; the live one was terminated and joined.
|
||||
expect(finished.status).toBe('completed')
|
||||
await running.done
|
||||
expect(running.status).toBe(process.platform === 'win32' ? 'completed' : 'killed')
|
||||
})
|
||||
})
|
||||
36
packages/bash/pwsh-local/tsconfig.json
Normal file
36
packages/bash/pwsh-local/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/bash/tool-bash/README.md
|
||||
README.md: 29b9fba369e1fc6a4b8bb7bdd6543b7678df627d
|
||||
README.zh.md: 31f691f7bfb8d2cb905751663151c3f6a6bc6c57
|
||||
README.md: 47101e1198d13518c3d82877df8726c1fbf26b82
|
||||
README.zh.md: d60ac4b3826838e875f7d43bc314f62e1453c1d9
|
||||
|
||||
@@ -4,9 +4,9 @@ English | [中文](README.zh.md)
|
||||
|
||||
The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`.
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor.
|
||||
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests.
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain package-internal.
|
||||
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on.
|
||||
|
||||
@@ -28,26 +28,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
|
||||
|
||||
### Managed shell environment
|
||||
|
||||
Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
|
||||
|
||||
`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.bashEnv.register({
|
||||
name: 'deployment-region',
|
||||
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
|
||||
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
|
||||
Every foreground and background model bash call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/README.md) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. The registry contract — contributor registration, loud duplicate/undeclared-key failure, the built-in reservations, and the contributor example — lives in that package's README. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; the local executor removes all inherited `DSH_*` before merging it, so nested harnesses and concurrent parent/child agents cannot leak stale identities, and `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
|
||||
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`.
|
||||
|
||||
@@ -141,7 +122,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
|
||||
Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `tool call aborted`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output`、`task_list` 和 `task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。
|
||||
|
||||
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`);在 `ctx.bash` 可用之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt']`)。
|
||||
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具契约是 bash 方言——请挂载能解析 bash 的执行器。
|
||||
|
||||
包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。
|
||||
包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍保留在包内部。
|
||||
|
||||
插件还会提供 `tool:bash` 提示词段落(顺序 105):检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。
|
||||
|
||||
@@ -28,26 +28,7 @@
|
||||
|
||||
### 托管 shell 环境
|
||||
|
||||
每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME`、`~/.dsh`),`DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`;当活跃的持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=<absolute target path>`。JSONL 路径只是位置提示:首次 flush 前它可能尚不存在,也可能不包含当前缓冲的轮次,并且它不是授权凭据。
|
||||
|
||||
`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME`、`DSH_SHELL` 和 `DSH_SESSION_ID`;tool-bash 的持久化转换器持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.bashEnv.register({
|
||||
name: 'deployment-region',
|
||||
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
|
||||
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
|
||||
每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`(Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表契约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
|
||||
|
||||
结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。
|
||||
|
||||
@@ -61,7 +42,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques
|
||||
|
||||
## 工具仅使用具名参数构建请求
|
||||
|
||||
`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
|
||||
`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
|
||||
|
||||
## 权限与升权
|
||||
|
||||
@@ -141,7 +122,7 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
验证和策略失败统一为 `Error: <message>`。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`、审批不可用/拒绝/取消变体,以及 `command aborted`。
|
||||
验证和策略失败统一为 `Error: <message>`。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -21,20 +21,17 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash-env": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -49,15 +46,14 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-env": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
|
||||
@@ -16,10 +16,10 @@ import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
*/
|
||||
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
|
||||
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
|
||||
// infrastructure-failure outcome, then map spawn failures and
|
||||
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
|
||||
// failure with a signal-less kill and a runner failure with an ordinary
|
||||
// wrapper exit; real nonzero command exits must remain `completed`.
|
||||
// infrastructure-failure outcome, then map it to task `failed`. Restricted
|
||||
// runner failures expose sandbox.runnerFailed, but unconfined spawn failures
|
||||
// still alias a signal-less kill; real nonzero command exits must remain
|
||||
// `completed`.
|
||||
if (proc.status === 'killed') {
|
||||
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
|
||||
}
|
||||
|
||||
@@ -8,205 +8,39 @@
|
||||
* @module @deepseek-ai/dsh-tool-bash
|
||||
*/
|
||||
|
||||
import { Service, type Context } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
|
||||
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from './background.ts'
|
||||
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
bashEnv: BashEnvRegistry
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'tool-bash'
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv']
|
||||
|
||||
/** Configuration for the bash tool and its managed child environment. */
|
||||
/** Configuration for the bash tool. */
|
||||
export interface Config {
|
||||
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
|
||||
enableRunInBackground?: boolean
|
||||
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
}
|
||||
|
||||
/** Runtime configuration schema for the bash tool plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
dshHome: z.string(),
|
||||
})
|
||||
|
||||
/** Model-visible metadata for one managed `DSH_*` environment variable. */
|
||||
export interface BashEnvVariable {
|
||||
/** Concise description of the environment fact represented by the variable. */
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin contribution to the managed environment of each model bash call.
|
||||
* Declared keys make ownership conflicts detectable before the first command;
|
||||
* `resolve` computes only the values available for the current execution.
|
||||
*/
|
||||
export interface BashEnvContributor {
|
||||
/** Stable contributor name used in diagnostics and duplicate detection. */
|
||||
name: string
|
||||
/** Complete set of `DSH_*` keys this contributor may return. */
|
||||
variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>
|
||||
/**
|
||||
* Resolve this contributor's available values for one tool execution.
|
||||
* @param execution - the bash tool execution and its optional calling agent.
|
||||
* @returns a partial map containing only keys declared in {@link variables}.
|
||||
*/
|
||||
resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>
|
||||
}
|
||||
|
||||
/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */
|
||||
export interface BashEnvVariableInfo extends BashEnvVariable {
|
||||
/** Contributor that owns the variable. */
|
||||
contributor: string
|
||||
/** Declared `DSH_*` environment variable name. */
|
||||
key: DshEnvironmentKey
|
||||
}
|
||||
|
||||
const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const
|
||||
const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const
|
||||
const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const
|
||||
const RESERVED_BASH_ENV_KEYS = new Set<DshEnvironmentKey>([
|
||||
DSH_HOME_ENV,
|
||||
DSH_SHELL_KEY,
|
||||
DSH_SESSION_ID_KEY,
|
||||
])
|
||||
const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/
|
||||
|
||||
/**
|
||||
* Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.
|
||||
* The namespace is rebuilt for every model bash call: ambient `DSH_*` values
|
||||
* are discarded by the executor, then the registry's current snapshot is
|
||||
* injected. Built-in shell facts remain owned by the registry itself while
|
||||
* plugins can register additional, enumerable facts with effect-scoped
|
||||
* disposal.
|
||||
*/
|
||||
export class BashEnvRegistry extends Service {
|
||||
private readonly contributors = new Map<string, BashEnvContributor>()
|
||||
private readonly keyOwners = new Map<DshEnvironmentKey, string>()
|
||||
private readonly dshHome: string
|
||||
|
||||
/**
|
||||
* Create and install the `ctx.bashEnv` service.
|
||||
* @param ctx - Cordis context that owns the service and registrations.
|
||||
* @param config - home-directory configuration for the built-in variables.
|
||||
*/
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'bashEnv')
|
||||
this.dshHome = resolveDshHome(config.dshHome)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one environment contributor. Names and keys are unique; built-in
|
||||
* keys are reserved. Registration is disposed with the calling plugin fiber.
|
||||
* @param contributor - declared key ownership and per-execution resolver.
|
||||
* @returns the disposer that unregisters the contribution.
|
||||
*/
|
||||
register(contributor: BashEnvContributor): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: BashEnvRegistry) {
|
||||
if (contributor.name.trim().length === 0) {
|
||||
throw new Error('bash env contributor name must be non-empty')
|
||||
}
|
||||
if (this.contributors.has(contributor.name)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" is already registered`)
|
||||
}
|
||||
|
||||
const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][]
|
||||
for (const [key, variable] of variables) {
|
||||
if (!key.startsWith(DSH_ENV_PREFIX)
|
||||
|| !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`)
|
||||
}
|
||||
if (RESERVED_BASH_ENV_KEYS.has(key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`)
|
||||
}
|
||||
if (variable.description.trim().length === 0) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`)
|
||||
}
|
||||
const owner = this.keyOwners.get(key)
|
||||
if (owner !== undefined) {
|
||||
throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`)
|
||||
}
|
||||
}
|
||||
|
||||
this.contributors.set(contributor.name, contributor)
|
||||
for (const [key] of variables) this.keyOwners.set(key, contributor.name)
|
||||
yield () => {
|
||||
this.contributors.delete(contributor.name)
|
||||
for (const [key] of variables) this.keyOwners.delete(key)
|
||||
}
|
||||
}.bind(this), 'bashEnv.register()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the trusted `DSH_*` snapshot for one bash tool execution.
|
||||
* @param execution - the current tool execution.
|
||||
* @returns an immutable environment overlay containing built-ins and current contributions.
|
||||
*/
|
||||
collect(execution: ToolExecution): DshEnvironment {
|
||||
const values: Record<DshEnvironmentKey, string> = {
|
||||
[DSH_HOME_ENV]: this.dshHome,
|
||||
[DSH_SHELL_KEY]: '1',
|
||||
}
|
||||
if (execution.agent !== undefined) {
|
||||
values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id
|
||||
}
|
||||
|
||||
for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const resolved = contributor.resolve(execution)
|
||||
for (const [rawKey, value] of Object.entries(resolved)) {
|
||||
const key = rawKey as DshEnvironmentKey
|
||||
if (!Object.hasOwn(contributor.variables, key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`)
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`)
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))))
|
||||
}
|
||||
|
||||
// TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics,
|
||||
// prompt, or UI code treats list() as an exhaustive environment catalog.
|
||||
/**
|
||||
* Enumerate plugin-contributed variables without executing their resolvers.
|
||||
* @returns declarations sorted by environment variable name.
|
||||
*/
|
||||
list(): BashEnvVariableInfo[] {
|
||||
return [...this.contributors.values()]
|
||||
.flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({
|
||||
contributor: contributor.name,
|
||||
description: variable.description,
|
||||
key: key as DshEnvironmentKey,
|
||||
})))
|
||||
.sort((left, right) => left.key.localeCompare(right.key))
|
||||
}
|
||||
}
|
||||
|
||||
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
|
||||
interface BashToolArgs {
|
||||
command: string
|
||||
@@ -354,24 +188,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
|
||||
} as const
|
||||
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
// FIXME(bash-env-ownership): Move ctx.bashEnv to a tool-independent shell
|
||||
// environment plugin; replacing this tool with persistent Bash must not
|
||||
// remove the managed DSH_* contributor seam.
|
||||
const bashEnv = new BashEnvRegistry(ctx, config)
|
||||
bashEnv.register({
|
||||
name: 'session-persistence',
|
||||
variables: {
|
||||
[DSH_SESSION_JSONL_KEY]: {
|
||||
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
|
||||
},
|
||||
},
|
||||
resolve(execution) {
|
||||
const agent = execution.agent
|
||||
if (agent === undefined) return {}
|
||||
const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
|
||||
return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {}
|
||||
},
|
||||
})
|
||||
const backgroundEnabled = config.enableRunInBackground ?? true
|
||||
const defaultMode = ctx.bash.sandboxMode
|
||||
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
|
||||
@@ -522,7 +338,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
? standingPolicy
|
||||
: { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode }
|
||||
const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot)
|
||||
const dshEnv = bashEnv.collect(exec)
|
||||
const dshEnv = ctx.bashEnv.collect(exec)
|
||||
const request = {
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
@@ -565,7 +381,11 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
...request,
|
||||
signal: exec.signal,
|
||||
}))
|
||||
if (result.aborted) throw new Error('command aborted')
|
||||
if (result.aborted) {
|
||||
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
return { kind: 'foreground' as const, ...canonicalBashResult(result) }
|
||||
},
|
||||
presentCall: presentBashCall,
|
||||
|
||||
@@ -95,36 +95,9 @@ export function renderProcessRead(
|
||||
}
|
||||
|
||||
/**
|
||||
* The exit status recovered from a rendered result, with the output body that
|
||||
* status was split off from.
|
||||
* The exit-status parse is the shared marker-contract half of the shell-tool
|
||||
* rendering story, owned by `@deepseek-ai/dsh-bash` so `dsh-tool-pwsh` reuses
|
||||
* it (its renderer emits the same markers). Re-exported here to keep
|
||||
* `../src/render.ts` a single import root for bash-tool consumers.
|
||||
*/
|
||||
export type ParsedExitStatus =
|
||||
& { body: string }
|
||||
& ({ exitCode: number } | { signal: string })
|
||||
|
||||
/**
|
||||
* Split a rendered {@link renderResult} string into its output body and the
|
||||
* structured exit status — the inverse of the status markers it appends. A
|
||||
* killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`;
|
||||
* absent both means a clean exit 0.
|
||||
*
|
||||
* The consumed marker is removed from `body` because a terminal presentation
|
||||
* shows the exit status as its own pill: leaving the marker in the output would
|
||||
* render the exit twice. Other markers (timeout, sandbox denial) carry facts no
|
||||
* pill shows, so they stay in the body.
|
||||
*
|
||||
* Replay only retains the rendered content text, not the original
|
||||
* `BashRunResult`, so terminal presentation must recover the exit pill here.
|
||||
* Requiring a leading newline and the end of the string keeps ordinary output
|
||||
* that merely ends with marker-like text from matching unless the final line
|
||||
* is indistinguishable from a real marker.
|
||||
* @param text - rendered model-facing bash result.
|
||||
* @returns the marker-free body plus the recovered terminal exit code or signal.
|
||||
*/
|
||||
export function parseExitStatus(text: string): ParsedExitStatus {
|
||||
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
|
||||
if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
|
||||
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
|
||||
if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
|
||||
return { body: text, exitCode: 0 }
|
||||
}
|
||||
export { parseExitStatus, type ParsedExitStatus } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
@@ -32,8 +33,9 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
|
||||
await ctx.plugin(ToolBash)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
@@ -46,7 +48,7 @@ afterEach(() => {
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
@@ -172,7 +174,7 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(toolResult)).toContain('[exit code: 9]')
|
||||
})
|
||||
|
||||
it('background: start ack → completion notice as user/message → task_output collects it', async () => {
|
||||
it('background: start ack → pending completion notice → task_output collects it', async () => {
|
||||
// The task id is deterministic (a fresh LocalTaskService counts per kind from 1),
|
||||
// so the script can name `bash-1` without threading a generated id.
|
||||
const adapter = new MockAdapter([
|
||||
@@ -192,20 +194,27 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(firstResult)).toBe('started background task bash-1')
|
||||
|
||||
// The task settles on its own; the tool-tasks notice listener injects a
|
||||
// durable plugin-sourced user/message into the owning agent's session
|
||||
// (settlement may race turn end, so poll for it).
|
||||
// pending next-step message without waking the idle agent.
|
||||
const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> =>
|
||||
e.type === 'user/message' && e.data.source.kind === 'plugin'
|
||||
await pollUntil(() => events(agent).some(isNotice))
|
||||
const notice = events(agent).find(isNotice)!
|
||||
expect(notice.data.content.some(
|
||||
await pollUntil(() => agent.inbox.nextStep.some(message => message.source.kind === 'plugin'))
|
||||
const pendingNotice = agent.inbox.nextStep.find(message => message.source.kind === 'plugin')!
|
||||
expect(pendingNotice.content.some(
|
||||
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
|
||||
)).toBe(true)
|
||||
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
|
||||
expect(pendingNotice.source).toEqual({
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-tasks',
|
||||
form: 'notice',
|
||||
summary: 'bash echo bg-ok [status: completed, exit code: 0]',
|
||||
})
|
||||
|
||||
// The next turn collects the output through the generic task tool.
|
||||
// The next turn first admits that notice as user/message, then collects
|
||||
// the output through the generic task tool.
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const notice = events(agent).find(isNotice)!
|
||||
expect(notice.data).toEqual(pendingNotice)
|
||||
const readResult = findEvent(events(agent), 'tool/result', 'last')
|
||||
expect(readResult.data.message.content[0].isError).toBe(false)
|
||||
expect(resultText(readResult)).toContain('bg-ok')
|
||||
|
||||
@@ -20,6 +20,7 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import { processOutcome } from '../src/background.ts'
|
||||
import { renderProcessRead, renderResult } from '../src/render.ts'
|
||||
|
||||
@@ -35,6 +36,7 @@ async function setup() {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
@@ -50,6 +52,7 @@ async function setupWithTasks() {
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
@@ -188,6 +191,7 @@ async function setupSandboxed(withApproval = false) {
|
||||
await ctx.plugin(SandboxPolicyService, {})
|
||||
await ctx.plugin(RecordingSandboxExecutor)
|
||||
if (withApproval) await ctx.plugin(ApprovalService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
return { ctx, bash: ctx.bash as RecordingSandboxExecutor }
|
||||
}
|
||||
@@ -281,6 +285,7 @@ describe('bash tool', () => {
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
|
||||
expect(text(result)).toContain('[output truncated; full output: ')
|
||||
@@ -300,7 +305,7 @@ describe('bash tool', () => {
|
||||
expect(text(result)).toMatch(/ENOENT/)
|
||||
})
|
||||
|
||||
it('surfaces foreground aborts as isError', async () => {
|
||||
it('surfaces foreground aborts as the structured TOOL_ABORTED error', async () => {
|
||||
const ctx = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.tools.execute({
|
||||
@@ -312,7 +317,10 @@ describe('bash tool', () => {
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toMatch(/aborted/)
|
||||
expect(result.error).toMatchObject({
|
||||
message: 'tool call aborted',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED },
|
||||
})
|
||||
})
|
||||
|
||||
// Type and required-key violations are rejected by the harness
|
||||
@@ -389,6 +397,7 @@ describe('bash tool', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
const fiber = await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash'])
|
||||
@@ -403,6 +412,7 @@ describe('bash tool', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
// inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
@@ -493,6 +503,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(CountingStartExecutor)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const controller = new AbortController()
|
||||
@@ -520,6 +531,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(CountingStartExecutor)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
@@ -534,6 +546,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(ToolBash, { enableRunInBackground: false })
|
||||
|
||||
@@ -568,6 +581,7 @@ describe('sandbox escalation through the generic task producer', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(RecordingSandboxExecutor)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing')
|
||||
})
|
||||
|
||||
@@ -1003,9 +1017,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
// not renderResult output, so a generic fenced card, no terminal output/exit.
|
||||
const out = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'x', description: 'x' },
|
||||
{ content: [{ type: 'text', text: 'command aborted' }], isError: true },
|
||||
{ content: [{ type: 'text', text: 'tool call aborted' }], isError: true },
|
||||
)
|
||||
expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] })
|
||||
expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] })
|
||||
})
|
||||
|
||||
it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
|
||||
@@ -1097,8 +1111,9 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
}
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(BashEnvPlugin, { dshHome: recordingDshHome })
|
||||
await ctx.plugin(RecordingBashExecutor)
|
||||
await ctx.plugin(ToolBash, { dshHome: recordingDshHome })
|
||||
await ctx.plugin(ToolBash)
|
||||
return { ctx, bash: ctx.bash as RecordingBashExecutor }
|
||||
}
|
||||
|
||||
|
||||
@@ -26,21 +26,18 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-env"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
|
||||
6
packages/bash/tool-pwsh/README.i18n.yaml
Normal file
6
packages/bash/tool-pwsh/README.i18n.yaml
Normal 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/bash/tool-pwsh/README.md
|
||||
README.md: 78eb161f77b9524bc577b273abe59db6b931727c
|
||||
README.zh.md: 17696fe6d908838aaaca12e8179f2ad9cb780210
|
||||
124
packages/bash/tool-pwsh/README.md
Normal file
124
packages/bash/tool-pwsh/README.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# @deepseek-ai/dsh-tool-pwsh
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker).
|
||||
|
||||
Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`).
|
||||
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-task adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export.
|
||||
|
||||
The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero exits are reported as `[exit code: N]` markers, and Windows interruption settles as exit 1 without a signal marker.
|
||||
|
||||
## Tools
|
||||
|
||||
### `pwsh`
|
||||
|
||||
| Arg | Type | Notes |
|
||||
|---|---|---|
|
||||
| `command` | string (required) | Run via `pwsh -Command`. No state persists between calls — use `workdir`, not `cd`. |
|
||||
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
|
||||
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
|
||||
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. |
|
||||
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
|
||||
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
|
||||
|
||||
### Managed shell environment
|
||||
|
||||
Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables.
|
||||
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
|
||||
|
||||
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text.
|
||||
|
||||
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
|
||||
|
||||
## UI presentation
|
||||
|
||||
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed foreground result is a `terminal` card too: the exit marker becomes the card's exit-status pill (`exitCode`/`signal`), and the marker-free body is the card's output — exactly the bash tool's terminal-card story, via the shared exit-status parse from `@deepseek-ai/dsh-bash`. Background acks and execution errors stay `generic` cards with the rendered output in a `console` fence. These presenters are pure and replay-safe.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Every request in this plugin's registration scope contains the pwsh guidance below. Scoped tool restrictions can hide the schema without removing this independently registered section.
|
||||
|
||||
##### Pwsh guidance
|
||||
|
||||
```markdown
|
||||
Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Small fixed input cost per request while the plugin is active.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section.
|
||||
|
||||
### Tool schemas
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model sees the generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh). Agent-scoped tool restrictions can remove the definition for that agent.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost on every request where the tool is visible.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while visibility and the tool definition are unchanged. A restriction or config change may invalidate reuse from the first changed token.
|
||||
|
||||
### Foreground result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path>]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Background result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A background start renders exactly `started background task <id>`; subsequent reads and status flow through the generic `task_output`/`task_kill` tools, including the lossy-read spill notice when in-memory truncation dropped unread bytes.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The ack is a fixed short line; task output is bounded per read.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Tool errors
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Only the failing call adds these retained tokens; an aborted call adds no command output.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored).
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
|
||||
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user