Merge origin/master into goal-ui: adopt the rewritten client core and apiproxy carrier
Conflict rulings follow the projection-reattach plan: - host/runtime package (deleted on master): take master; the PR's boot composition moves to the cordis.yml roster and its goals handlers will be re-landed in dsh-host-apiproxy; the session.prompt slash interception and its spec are dropped entirely (superseded by command.execute + command/run logging). - client core (rewritten on master): take master; the PR's Session goal fields/methods, ConversationSnapshot.goal, goalActions injection, and the hard-mounted GoalBar are all superseded by the 'goal' session projection (useProjection) and will return as the ui-goal plugin. - wire contract: union of master's workspace/command/skill domains and the PR's goal domain, minus goal.get (the read side is the projection block + session/projection frames; six mutation RPCs stay). - GoalBar component and spec leave ui-conversation (they re-land in the new ui-goal package); IconSparkle16 stays in ui-conversation chat. - The web-slash-command-dispatch note documents the dropped interception and is removed; the goal-bar note will be rewritten for the projection model. - pnpm-lock.yaml taken from master (reinstall recomputes).
This commit is contained in:
@@ -7,7 +7,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
|
||||
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
|
||||
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
|
||||
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
|
||||
- **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service.
|
||||
- **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice.
|
||||
- **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage.
|
||||
@@ -19,6 +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)).
|
||||
- `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.
|
||||
|
||||
6
packages/README.i18n.yaml
Normal file
6
packages/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/README.md
|
||||
README.md: f5420b6f2f30837b030a0e832a438c34674a6f23
|
||||
README.zh.md: 7beeaadf380a742cbdb6447553f42692a97fad10
|
||||
@@ -1,5 +1,7 @@
|
||||
# Packages
|
||||
|
||||
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).
|
||||
|
||||
## Hierarchy
|
||||
@@ -11,7 +13,9 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | 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 |
|
||||
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface |
|
||||
@@ -24,18 +28,23 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`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 |
|
||||
| [`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) | Todo/planning family: the model-facing `todo_write` tool | 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) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 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 capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface |
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | 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 |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
|
||||
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |
|
||||
| [`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 |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/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 |
|
||||
|
||||
|
||||
59
packages/README.zh.md
Normal file
59
packages/README.zh.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 包
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有包都使用 `@deepseek-ai/dsh-*` scope。每个包都是 Cordis `Service` 子类或函数插件;所有贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
|
||||
|
||||
## 层级结构
|
||||
|
||||
包位于 `packages/<group>/<pkg>/`;组是容器,包名仍为 `@deepseek-ai/dsh-<pkg>`。**每个组 README 是规范的包/ctx 键映射。**
|
||||
|
||||
| 组 | 职责 | 发布预期 |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 |
|
||||
| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 |
|
||||
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 |
|
||||
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
|
||||
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 |
|
||||
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 |
|
||||
| [`sandbox/`](sandbox/README.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | 产品:稳定表面 |
|
||||
| [`fs/`](fs/README.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、bash 后端发现工具 | 产品:稳定表面 |
|
||||
| [`lsp/`](lsp/README.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定表面 |
|
||||
| [`skill/`](skill/README.md) | Skill(技能)能力系列:提供方注册表、本地提供方和面向模型的目录/加载器 | 产品:稳定表面 |
|
||||
| [`compact/`](compact/README.md) | 压缩(compaction)能力系列:抽象 seam + 基础后端(工具延后) | 产品:稳定表面 |
|
||||
| [`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` 工具 | 产品:稳定表面 |
|
||||
| [`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) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定表面 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 |
|
||||
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
|
||||
| [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
|
||||
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
|
||||
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
|
||||
| [`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、用户问答工具 | 产品:稳定表面 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
|
||||
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |
|
||||
|
||||
组用于区分产品 API 与支持基础设施。新包加入现有组;新组则更新其 README 和此表。
|
||||
|
||||
## 依赖
|
||||
|
||||
依赖图由工具生成:[docs/module-graph.md](../docs/module-graph.md)(`pnpm run gen-module-graph`,CI 中有新鲜度门禁)。
|
||||
|
||||
**扩展插件依赖接口,绝不依赖具体循环。** `dsh-agent-loop` 可替换;UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力拆分为接口/实现/消费方包;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。
|
||||
|
||||
包 README 覆盖用途、API、扩展点和[模型体验](../docs/cookbook/adding-a-package.md#4-write-the-package-readme);列入模型无关[省略允许清单](../scripts/verify-package-readme-model-experience.ts)的包除外。它们还要包含 `## Known Limitations and Deferred Work`,或使用其[允许清单](../scripts/verify-package-readme-limitations.ts)。
|
||||
6
packages/acp/README.i18n.yaml
Normal file
6
packages/acp/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
|
||||
README.md: 326615210e5cfc39004fc5ab7462623089ac4126
|
||||
README.zh.md: 9999ecdd019501c3f501a6c69fab5e0ccfaf555c
|
||||
11
packages/acp/README.md
Normal file
11
packages/acp/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# acp/ — Agent Client Protocol automation
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The ACP group exposes harness agents to programmatic clients. It is an interoperability transport, not a presentation or human-interaction layer.
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | Automation-only ACP server: fresh text sessions, committed assistant output, machine permission policy, cancellation, and connection-owned teardown. |
|
||||
|
||||
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.
|
||||
11
packages/acp/README.zh.md
Normal file
11
packages/acp/README.zh.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# acp/:Agent Client Protocol 自动化
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开给程序化客户端。它是互操作传输层,而非展示层或人机交互层。
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接拥有的清理。 |
|
||||
|
||||
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以驱动同一服务器契约。
|
||||
6
packages/acp/acp/README.i18n.yaml
Normal file
6
packages/acp/acp/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
|
||||
README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88
|
||||
README.zh.md: f8abe9e45a5efffa436513f7d4a931c69c624b84
|
||||
79
packages/acp/acp/README.md
Normal file
79
packages/acp/acp/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# @deepseek-ai/dsh-acp
|
||||
|
||||
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.
|
||||
|
||||
## Plugin
|
||||
|
||||
`apply(ctx, config)` opens an `AgentSideConnection` on stdin/stdout and drives `ctx.agents`. Stdout is reserved for protocol frames.
|
||||
|
||||
| Config | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `provider` | — | Initial provider route for every created agent. |
|
||||
| `model` | — | Initial model for every created agent. |
|
||||
|
||||
Both fields are optional so another agent/request listener may supply the target. The runnable ACP composition requires both.
|
||||
|
||||
## Protocol contract
|
||||
|
||||
| Method | Behavior |
|
||||
|---|---|
|
||||
| `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/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. |
|
||||
|
||||
One connection may own several sessions. The bridge keys records by branded session id and checks exact agent identity before routing events or permission requests. Each session has an independent prompt slot, workspace, cancellation path, and disposer.
|
||||
|
||||
Committed-message output intentionally trades token-by-token latency for a clean automation result. Uncommitted provider chunks and retry attempts cannot leak partial text; reasoning and tool activity remain in the session log for observability through other interfaces.
|
||||
|
||||
## 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.
|
||||
|
||||
## Running
|
||||
|
||||
`pnpm --dir /path/to/deepseek-harness run demo:acp` boots the repository's automation server composition. A parent harness can spawn it through [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md); other ACP clients need only the core methods above.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Prompt text
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`session/prompt` text blocks are concatenated verbatim into one user message; a baseline resource link appears in that message as a bracketed `[resource_link name=… uri=…]` reference the model may open with its own tools. Protocol metadata, client capabilities, permission choices, and session ids never enter the model request.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions retain independent contexts.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; the new user message follows the reusable request prefix and does not invalidate prior cache entries.
|
||||
|
||||
### Permission decisions
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing directly. The owning tool records its allowed, rejected, cancelled, or unavailable outcome through the normal tool-result path.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Only the owning tool result contributes tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only through the owning tool result.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Fresh sessions only** — load, list, resume, delete, and fork are unsupported.
|
||||
- **Baseline prompts and one workspace only** — images, audio, embedded resources, non-empty additional directories, and MCP servers reject; resource links flatten to textual references rather than fetched content.
|
||||
- **Committed answers only** — live progress, reasoning, tool activity, plans, titles, and usage stay off the wire.
|
||||
- **Connection-owned lifetime** — one connection releases all of its sessions; per-session close is not implemented.
|
||||
79
packages/acp/acp/README.zh.md
Normal file
79
packages/acp/acp/README.zh.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# @deepseek-ai/dsh-acp
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
通过 JSON-RPC stdio 提供的仅面向自动化的 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、通过策略解决一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
|
||||
|
||||
此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、mode、配置选择器、信息征集、推理、计划、标题或工具展示。交互渲染与人类问题属于 Web 和 TUI 模块。
|
||||
|
||||
## 插件
|
||||
|
||||
`apply(ctx, config)` 在 stdin/stdout 上打开 `AgentSideConnection` 并驱动 `ctx.agents`。Stdout 专用于协议帧。
|
||||
|
||||
| 配置 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `provider` | 无 | 每个已创建 agent 的初始提供方路由。 |
|
||||
| `model` | 无 | 每个已创建 agent 的初始模型。 |
|
||||
|
||||
两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行 ACP 组合同时要求两者。
|
||||
|
||||
## 协议契约
|
||||
|
||||
| 方法 | 行为 |
|
||||
|---|---|
|
||||
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
|
||||
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
|
||||
| `session/new` | 使用绝对主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
|
||||
| `session/prompt` | 连接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并从该请求拥有的持久 `turn/end` 结算。 |
|
||||
| `session/cancel` | 仅取消被定址的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
|
||||
| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
|
||||
| `session/request_permission` | 为携带工具调用 id 的桥接层所有批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
|
||||
|
||||
一个连接可以拥有多个会话。桥接层使用带品牌的 session id 为记录建键,并在路由事件或权限请求前检查精确的 agent 标识。每个会话都有独立的提示词槽位、workspace、取消路径和 disposer。
|
||||
|
||||
已提交消息输出有意以逐 token 延迟换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。
|
||||
|
||||
## 生命周期
|
||||
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行释放所有已拥有的 agent handle,并等待它们的循环/会话清理完成。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
|
||||
## 运行
|
||||
|
||||
`pnpm --dir /path/to/deepseek-harness run demo:acp` 启动仓库的自动化服务器组合。父 harness 可以通过 [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md) spawn 它;其他 ACP 客户端只需上述核心方法。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 提示词文本
|
||||
|
||||
#### 模型所见内容
|
||||
|
||||
`session/prompt` 文本块会原样连接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
提示词 token 取决于数据,并保留在该会话的历史中直到压缩。并发 ACP 会话保留独立上下文。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新用户消息位于可复用请求前缀之后,不会使先前缓存条目失效。
|
||||
|
||||
### 权限决策
|
||||
|
||||
#### 模型所见内容
|
||||
|
||||
没有直接内容。拥有该决策的工具通过常规工具结果路径记录允许、拒绝、取消或不可用结果。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
只有拥有该决策的工具结果会贡献 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
通过所属工具结果仅追加。
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
- **仅新会话**:不支持加载、列出、恢复、删除和 fork。
|
||||
- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接会被展平为文本引用,而不是已获取内容。
|
||||
- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不上线。
|
||||
- **连接拥有的生命期**:一个连接会释放其所有会话;尚未实现每会话关闭。
|
||||
51
packages/acp/acp/package.json
Normal file
51
packages/acp/acp/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-acp",
|
||||
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
"schemastery": "^3.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
62
packages/acp/acp/src/codec.ts
Normal file
62
packages/acp/acp/src/codec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Pure translation between the harness lifecycle and the automation-only ACP wire.
|
||||
* @module @deepseek-ai/dsh-acp/codec
|
||||
*/
|
||||
|
||||
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Map a harness turn ending to ACP's terminal reason vocabulary.
|
||||
* @param reason - harness turn outcome.
|
||||
* @returns the closest legal ACP stop reason.
|
||||
*/
|
||||
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
switch (reason.kind) {
|
||||
case 'completed':
|
||||
return 'end_turn'
|
||||
case 'max-tokens':
|
||||
return 'max_tokens'
|
||||
case 'aborted':
|
||||
case 'disposed':
|
||||
case 'interrupted':
|
||||
return 'cancelled'
|
||||
case 'error':
|
||||
return 'end_turn'
|
||||
// TurnEndReason is merge-extensible; future variants still need a legal wire value.
|
||||
default:
|
||||
return 'end_turn'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten an ACP prompt's baseline blocks to text. Text blocks concatenate
|
||||
* verbatim; resource links become explicit textual references so a baseline
|
||||
* client can point at files without the bridge silently dropping that context.
|
||||
* @param prompt - supported ACP prompt blocks.
|
||||
* @returns text in wire order, with resource links rendered as bracketed references.
|
||||
*/
|
||||
export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
|
||||
return prompt.flatMap((block): string[] => {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return [block.text]
|
||||
case 'resource_link':
|
||||
return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a prompt carries content beyond the ACP baseline. The spec requires
|
||||
* every agent to accept `text` and `resource_link`; richer inline payloads
|
||||
* (image, audio, embedded resource) are optional capabilities this bridge does
|
||||
* not advertise, so they are rejected rather than silently dropped.
|
||||
* @param prompt - ACP prompt blocks to inspect.
|
||||
* @returns `true` when any block is neither `text` nor `resource_link`.
|
||||
*/
|
||||
export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
|
||||
return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link')
|
||||
}
|
||||
369
packages/acp/acp/src/index.ts
Normal file
369
packages/acp/acp/src/index.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Automation-only Agent Client Protocol server over JSON-RPC stdio.
|
||||
*
|
||||
* The bridge exposes fresh harness sessions to trusted programmatic clients. It
|
||||
* carries prompt text, committed assistant text, cancellation, and one-shot
|
||||
* permission decisions; presentation and human-interaction features stay with
|
||||
* the harness's UI modules.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-acp
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
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 {
|
||||
AgentSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
RequestError,
|
||||
type Agent as AcpAgent,
|
||||
type AuthenticateRequest,
|
||||
type CancelNotification,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse,
|
||||
type NewSessionRequest,
|
||||
type NewSessionResponse,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type SessionNotification,
|
||||
type StopReason,
|
||||
type Stream,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
// Side-effect type import: declaration-merges the approval waterfall answered below.
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from './codec.ts'
|
||||
|
||||
export const name = 'acp'
|
||||
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
|
||||
export const inject = ['agents']
|
||||
|
||||
/** Preserve invalid-parameter detail in the SDK wire error message. */
|
||||
function invalidParams(detail: string): RequestError {
|
||||
return RequestError.invalidParams(undefined, detail)
|
||||
}
|
||||
|
||||
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
|
||||
function internalError(detail: string): RequestError {
|
||||
return RequestError.internalError(undefined, detail)
|
||||
}
|
||||
|
||||
/** Plugin config: the provider/model target used for each ACP-created agent. */
|
||||
export interface AcpConfig {
|
||||
/** Provider route for created agents. */
|
||||
provider?: string
|
||||
/** Model name for created agents. */
|
||||
model?: string
|
||||
/** Runtime-only transport override; production uses stdio. */
|
||||
stream?: Stream
|
||||
}
|
||||
|
||||
export const Config: Schema<AcpConfig> = Schema.object({
|
||||
provider: Schema.string(),
|
||||
model: Schema.string(),
|
||||
})
|
||||
|
||||
/** Per-session protocol state. */
|
||||
interface SessionRecord {
|
||||
agent: Agent
|
||||
/** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */
|
||||
dispose: () => Promise<void>
|
||||
/** In-flight prompt and its captured turn number for exact settlement. */
|
||||
inflight: {
|
||||
resolve: (reason: StopReason) => void
|
||||
reject: (error: Error) => void
|
||||
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
|
||||
} | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the automation-only ACP server.
|
||||
* @param ctx - Cordis context carrying the agent factory and session events.
|
||||
* @param config - Initial provider/model target and optional test transport.
|
||||
*/
|
||||
export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// ACP handlers execute outside this plugin's injection scope, so capture the
|
||||
// injected service during apply rather than reading it lazily in a callback.
|
||||
const agents = ctx.agents
|
||||
const logger = ctx.logger
|
||||
const sessions = new Map<SessionId, SessionRecord>()
|
||||
let closed = false
|
||||
let conn: AgentSideConnection
|
||||
|
||||
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
|
||||
const ownedRecord = (agent: Agent): SessionRecord | undefined => {
|
||||
const record = sessions.get(agent.session.id)
|
||||
return record?.agent === agent ? record : undefined
|
||||
}
|
||||
|
||||
const assertOpen = (): void => {
|
||||
if (closed) throw internalError('the ACP bridge has been disposed')
|
||||
}
|
||||
|
||||
const requireSession = (sessionId: SessionId): SessionRecord => {
|
||||
const record = sessions.get(sessionId)
|
||||
if (record === undefined) throw invalidParams(`unknown session: ${sessionId}`)
|
||||
return record
|
||||
}
|
||||
|
||||
/** Send a protocol update without letting a disconnected client fail an agent turn. */
|
||||
const notify = (notification: SessionNotification): void => {
|
||||
/* v8 ignore next 3 -- only a transport write failure reaches this guard. */
|
||||
void conn.sessionUpdate(notification).catch((error: unknown) => {
|
||||
logger.warn(`acp: session/update failed: ${String(error)}`)
|
||||
})
|
||||
}
|
||||
|
||||
const settlePrompt = (record: SessionRecord, reason: StopReason): void => {
|
||||
const inflight = record.inflight
|
||||
if (inflight === undefined) return
|
||||
record.inflight = undefined
|
||||
inflight.resolve(reason)
|
||||
}
|
||||
|
||||
const rejectFromError = (
|
||||
inflight: NonNullable<SessionRecord['inflight']>,
|
||||
reason: Extract<TurnEndReason, { kind: 'error' }>,
|
||||
): void => {
|
||||
inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`))
|
||||
}
|
||||
|
||||
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
|
||||
// titles, and retry markers are presentation or trace data and stay off the
|
||||
// automation wire.
|
||||
ctx.on('session/event', (session, event: SessionEvent) => {
|
||||
const record = sessions.get(session.header.id)
|
||||
if (record === undefined || record.agent.session !== session) return
|
||||
try {
|
||||
if (event.type === 'assistant/message') {
|
||||
for (const block of event.data.message.content) {
|
||||
if (block.type === 'text' && block.text.length > 0) {
|
||||
notify({
|
||||
sessionId: record.agent.session.id,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: block.text },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 (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 {
|
||||
record.inflight = undefined
|
||||
inflight.resolve(turnEndToStopReason(event.data.reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 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.
|
||||
ctx.on('approval/request', (request, next) => {
|
||||
const record = ownedRecord(request.agent)
|
||||
if (record === undefined || request.callId === undefined) return next()
|
||||
return conn.requestPermission({
|
||||
sessionId: record.agent.session.id,
|
||||
toolCall: { toolCallId: request.callId },
|
||||
options: [
|
||||
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
|
||||
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
|
||||
],
|
||||
}).then(({ outcome }) => {
|
||||
if (outcome.outcome === 'cancelled') return 'cancelled'
|
||||
return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'
|
||||
})
|
||||
})
|
||||
|
||||
const makeAgent = (connection: AgentSideConnection): AcpAgent => {
|
||||
conn = connection
|
||||
return {
|
||||
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
// Single-version agent: the spec's "same version if supported, else
|
||||
// the latest supported" both resolve to this server's one version.
|
||||
return Promise.resolve({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
|
||||
agentCapabilities: {
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
||||
},
|
||||
authMethods: [],
|
||||
})
|
||||
},
|
||||
|
||||
authenticate(_params: AuthenticateRequest): Promise<void> {
|
||||
return Promise.resolve()
|
||||
},
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
assertOpen()
|
||||
validateSessionParams(params)
|
||||
const sessionId = SessionId(randomUUID())
|
||||
const handle = await agents.create({
|
||||
sessionId,
|
||||
meta: { cwd: params.cwd },
|
||||
agentOptions: agentOptions(config),
|
||||
})
|
||||
/* v8 ignore next 4 -- a real stdio close can race an in-flight create. */
|
||||
if (closed) {
|
||||
await handle.dispose()
|
||||
throw internalError('connection closed during session/new')
|
||||
}
|
||||
sessions.set(sessionId, {
|
||||
agent: handle.agent,
|
||||
dispose: () => handle.dispose(),
|
||||
inflight: undefined,
|
||||
})
|
||||
return { sessionId }
|
||||
},
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
assertOpen()
|
||||
const record = requireSession(SessionId(params.sessionId))
|
||||
if (record.inflight !== undefined) {
|
||||
throw invalidParams('a prompt is already in flight for this session')
|
||||
}
|
||||
if (promptHasUnsupportedContent(params.prompt)) {
|
||||
throw invalidParams('only text and resource_link prompt content is supported')
|
||||
}
|
||||
const text = acpPromptToText(params.prompt)
|
||||
if (text.trim().length === 0) throw invalidParams('empty prompt')
|
||||
|
||||
// Not driving a retired agent is this bridge's contract: an
|
||||
// agent-loop-only reload disposes the loop's agents while the bridge
|
||||
// record survives, so validate the record against the live registry
|
||||
// before sending — a disposed machine would accept the item silently.
|
||||
if (ctx.agents.get(record.agent.id) !== record.agent) {
|
||||
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
|
||||
}
|
||||
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,
|
||||
}
|
||||
record.inflight = inflight
|
||||
try {
|
||||
record.agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
|
||||
// The machine's send() contains listener failures and accepts
|
||||
// any typed input; this guards a future synchronous throw so the
|
||||
// slot cannot wedge.
|
||||
/* v8 ignore start -- future-proofing guard, see above */
|
||||
} catch (error: unknown) {
|
||||
record.inflight = undefined
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
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.
|
||||
void record.agent.whenIdle().then(() => {
|
||||
if (record.inflight !== inflight || inflight.turn !== undefined) return
|
||||
record.inflight = undefined
|
||||
if (inflight.pendingError !== undefined) {
|
||||
rejectFromError(inflight, inflight.pendingError)
|
||||
return
|
||||
}
|
||||
inflight.resolve('cancelled')
|
||||
})
|
||||
})
|
||||
return { stopReason }
|
||||
},
|
||||
|
||||
cancel(params: CancelNotification): Promise<void> {
|
||||
const record = sessions.get(SessionId(params.sessionId))
|
||||
if (record === undefined) return Promise.resolve()
|
||||
record.agent.cancel({ kind: 'user' })
|
||||
settlePrompt(record, 'cancelled')
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* v8 ignore next 4 -- production stdio wiring; tests inject config.stream. */
|
||||
const stream: Stream = config.stream ?? ndJsonStream(
|
||||
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
|
||||
)
|
||||
conn = new AgentSideConnection(makeAgent, stream)
|
||||
|
||||
let quiescing: Promise<void> | undefined
|
||||
const quiesce = (): Promise<void> => {
|
||||
if (quiescing !== undefined) return quiescing
|
||||
closed = true
|
||||
const records = [...sessions.values()]
|
||||
sessions.clear()
|
||||
quiescing = Promise.all(records.map(async (record) => {
|
||||
settlePrompt(record, 'cancelled')
|
||||
await record.dispose()
|
||||
})).then(() => {})
|
||||
return quiescing
|
||||
}
|
||||
|
||||
/* v8 ignore start -- production transport rejection and teardown failure. */
|
||||
void conn.closed
|
||||
.catch((error: unknown) => {
|
||||
logger.warn(`acp: connection closed with an error: ${String(error)}`)
|
||||
})
|
||||
.then(quiesce)
|
||||
.catch((error: unknown) => {
|
||||
logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
|
||||
})
|
||||
/* v8 ignore stop */
|
||||
|
||||
ctx.effect(() => quiesce, 'acp.connection')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build per-agent options from plugin config without assigning absent optional fields.
|
||||
* @param config - ACP provider/model configuration.
|
||||
* @returns the configured fields only.
|
||||
*/
|
||||
function agentOptions(config: AcpConfig): { provider?: string; model?: string } {
|
||||
return {
|
||||
...config.provider !== undefined ? { provider: config.provider } : {},
|
||||
...config.model !== undefined ? { model: config.model } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject session features outside the automation contract. */
|
||||
function validateSessionParams(params: NewSessionRequest): void {
|
||||
if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
|
||||
if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
|
||||
throw invalidParams('additionalDirectories is not supported')
|
||||
}
|
||||
if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported')
|
||||
}
|
||||
@@ -15,8 +15,8 @@ export const name = 'acp-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
|
||||
* boundary and replay tests cover its protocol mapping.
|
||||
* No runtime invariant: this transport owns no durable package-local event stream;
|
||||
* protocol and lifecycle tests cover its mapping.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
78
packages/acp/acp/tests/approval.spec.ts
Normal file
78
packages/acp/acp/tests/approval.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
|
||||
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
|
||||
|
||||
describe('ACP machine permission policy', () => {
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.dispose()
|
||||
harness = undefined
|
||||
})
|
||||
|
||||
async function ownedRequest(overrides: Partial<ApprovalRequest> = {}): Promise<ApprovalRequest> {
|
||||
if (harness === undefined) throw new Error('missing harness')
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
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' } } })
|
||||
return { agent, toolName: 'bash', callId: CallId('call-9'), ...overrides }
|
||||
}
|
||||
|
||||
it('maps the two advertised one-shot choices', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
|
||||
const request = await ownedRequest()
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('allowed-once')
|
||||
expect(harness.permissionRequests[0]).toMatchObject({
|
||||
sessionId: request.agent.session.id,
|
||||
toolCall: { toolCallId: 'call-9' },
|
||||
options: [
|
||||
{ optionId: 'allow-once', kind: 'allow_once' },
|
||||
{ optionId: 'reject-once', kind: 'reject_once' },
|
||||
],
|
||||
})
|
||||
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } })
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('maps cancellation and unknown choices without granting access', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const request = await ownedRequest()
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('cancelled')
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'unknown-grant' } })
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('fails closed when the client errors the permission request', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const request = await ownedRequest()
|
||||
harness.onPermission = () => { throw new Error('client gone') }
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('delegates a same-id foreign agent', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const request = await ownedRequest()
|
||||
const foreign = {
|
||||
session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) },
|
||||
} as unknown as Agent
|
||||
await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: CallId('call') }))
|
||||
.resolves.toBe('unavailable')
|
||||
expect(harness.permissionRequests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('delegates requests that have no protocol tool-call identity', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const request = await ownedRequest()
|
||||
await expect(harness.ctx.approval.request({ agent: request.agent, toolName: request.toolName }))
|
||||
.resolves.toBe('unavailable')
|
||||
expect(harness.permissionRequests).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
148
packages/acp/acp/tests/bridge.spec.ts
Normal file
148
packages/acp/acp/tests/bridge.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
describe('automation-only ACP bridge', () => {
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.dispose()
|
||||
harness = undefined
|
||||
})
|
||||
|
||||
it('advertises only fresh text sessions', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const response = await harness.client.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: { _meta: { terminal_output: true } },
|
||||
})
|
||||
|
||||
expect(response).toEqual({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
|
||||
agentCapabilities: {
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
||||
},
|
||||
authMethods: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('negotiates an unsupported version and accepts the required no-op authentication call', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const response = await harness.client.initialize({ protocolVersion: 0, clientCapabilities: {} })
|
||||
expect(response.protocolVersion).toBe(PROTOCOL_VERSION)
|
||||
await expect(harness.client.authenticate({ methodId: 'unused' })).resolves.toEqual({})
|
||||
})
|
||||
|
||||
it('creates a session, emits one committed answer, and settles the prompt', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('hello there')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const result = await harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'say hello' }],
|
||||
})
|
||||
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
await vi.waitFor(() => { expect(harness!.updates).toHaveLength(1) })
|
||||
expect(harness.updates).toEqual([{
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'hello there' },
|
||||
}])
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.header.cwd).toBe(process.cwd())
|
||||
expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'say hello' }])
|
||||
})
|
||||
|
||||
it('leaves absent agent targets for request listeners to supply', async () => {
|
||||
harness = await makeBridgeHarness({ config: { provider: undefined, model: undefined } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.options).toEqual({})
|
||||
})
|
||||
|
||||
it('concatenates text blocks without exposing protocol framing to the model', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('done')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{ type: 'text', text: 'first' },
|
||||
{ type: 'text', text: ' second' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'first second' }])
|
||||
})
|
||||
|
||||
it('renders the deployment persona for an ACP-created agent', async () => {
|
||||
harness = await makeBridgeHarness({ persona: 'Automation persona for {{model}} in {{cwd}}.', script: [textResponse('ok')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(harness.adapter.requests[0]?.system).toContain(`Automation persona for mock in ${process.cwd()}.`)
|
||||
})
|
||||
|
||||
it('requires one absolute workspace and no MCP servers', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
|
||||
await expect(harness.client.newSession({ cwd: 'relative', mcpServers: [] })).rejects.toThrow(/absolute path/)
|
||||
await expect(harness.client.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
additionalDirectories: ['/tmp/other'],
|
||||
})).rejects.toThrow(/additionalDirectories/)
|
||||
await expect(harness.client.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [{ name: 'fs', command: 'node', args: [], env: [] }],
|
||||
})).rejects.toThrow(/mcpServers/)
|
||||
|
||||
await expect(harness.client.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
additionalDirectories: [],
|
||||
})).resolves.toHaveProperty('sessionId')
|
||||
})
|
||||
|
||||
it('rejects empty and beyond-baseline prompts before a turn starts', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: ' ' }] }))
|
||||
.rejects.toThrow(/empty prompt/)
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'image', data: '', mimeType: 'image/png' }],
|
||||
})).rejects.toThrow(/only text and resource_link/)
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('renders baseline resource links as textual references in the user message', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('done')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{ type: 'text', text: 'summarize' },
|
||||
{ type: 'resource_link', name: 'notes.txt', uri: 'file:///tmp/notes.txt' },
|
||||
],
|
||||
})
|
||||
expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'summarize\n[resource_link name="notes.txt" uri="file:///tmp/notes.txt"]\n',
|
||||
}])
|
||||
})
|
||||
|
||||
it('rejects prompts for unknown sessions and ignores unknown cancellation', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(harness.client.prompt({ sessionId: 'missing', prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/unknown session/)
|
||||
await expect(harness.client.cancel({ sessionId: 'missing' })).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
37
packages/acp/acp/tests/codec.spec.ts
Normal file
37
packages/acp/acp/tests/codec.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { acpPromptToText, promptHasUnsupportedContent, 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)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
85
packages/acp/acp/tests/dispose.spec.ts
Normal file
85
packages/acp/acp/tests/dispose.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
|
||||
|
||||
describe('ACP connection ownership', () => {
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.dispose()
|
||||
harness = undefined
|
||||
})
|
||||
|
||||
it('disposal cancels a running prompt and awaits agent teardown', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
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))!
|
||||
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
await vi.waitFor(() => { expect(agent.status).toBe('running') })
|
||||
|
||||
await harness.acpFiber.dispose()
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('an ACP-only reload rejects new sessions before creating an orphan', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await harness.acpFiber.dispose()
|
||||
await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/disposed/)
|
||||
expect(harness.ctx.agents.list()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a client disconnect disposes every owned session without root-context disposal', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
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') })
|
||||
|
||||
await harness.closeClientTransport()
|
||||
await harness.acpFiber.dispose()
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a failed client transport still disposes every owned session', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
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') })
|
||||
|
||||
await harness.abortClientTransport()
|
||||
await vi.waitFor(() => {
|
||||
expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true)
|
||||
})
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('disconnect and plugin disposal share one quiescence boundary', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
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') })
|
||||
|
||||
await Promise.all([harness.closeClientTransport(), harness.acpFiber.dispose()])
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disposing a session-less bridge is idempotent', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
await Promise.all([harness.acpFiber.dispose(), harness.acpFiber.dispose()])
|
||||
expect(harness.ctx.agents.list()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
69
packages/acp/acp/tests/edges.spec.ts
Normal file
69
packages/acp/acp/tests/edges.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { createUserMessage, CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
function toolCallResponse(): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: CallId('call-1'), name: 'echo', argumentsDelta: '{}' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-1'), name: 'echo', arguments: '{}' } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]
|
||||
}
|
||||
|
||||
describe('ACP automation output boundary', () => {
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.dispose()
|
||||
harness = undefined
|
||||
})
|
||||
|
||||
it('does not emit tool, terminal, plan, title, or reasoning presentation updates', async () => {
|
||||
harness = await makeBridgeHarness({ script: [toolCallResponse(), textResponse('done')] })
|
||||
harness.ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: 'Return a deterministic result.',
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve([{ type: 'text', text: 'tool result' }]),
|
||||
}))
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
|
||||
await vi.waitFor(() => { expect(harness!.updates).toHaveLength(1) })
|
||||
expect(harness.updates).toEqual([{
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'done' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('ignores events from agents the bridge does not own', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('foreign')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const { agent } = await harness.ctx.agents.create({
|
||||
sessionId: SessionId('foreign'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await agent.whenIdle()
|
||||
expect(harness.updates).toHaveLength(0)
|
||||
})
|
||||
|
||||
// `session/update` is a JSON-RPC notification, so a client-side handler
|
||||
// failure never reaches the bridge; this pins that the prompt still settles
|
||||
// normally with such a client. The bridge's own write-failure guard is
|
||||
// transport-level and documented untestable at `notify`.
|
||||
it('settles the prompt normally when the client rejects update notifications', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
harness.onSessionUpdateError = () => {}
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
})
|
||||
})
|
||||
174
packages/acp/acp/tests/harness.ts
Normal file
174
packages/acp/acp/tests/harness.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/** In-memory ACP transport fixture over the real agent factory and loop. */
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
type Stream,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as AcpPlugin from '../src/index.ts'
|
||||
import type { AcpConfig } from '../src/index.ts'
|
||||
|
||||
/** Scripted adapter for protocol tests. */
|
||||
class MockAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
constructor(private readonly script: (StreamChunk[] | 'hang')[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string) {
|
||||
if (provider !== 'mock') throw new Error(`MockAdapter: unknown provider ${provider}`)
|
||||
return { id: 'mock', name: 'Mock' }
|
||||
}
|
||||
|
||||
override listModels(provider: string) {
|
||||
return Promise.resolve(provider === 'mock' ? [{ provider: 'mock', id: 'mock', name: 'Mock' }] : [])
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
if (entry === undefined) throw new Error('MockAdapter: script exhausted')
|
||||
if (entry === 'hang') {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'partial' }
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(new Error('aborted'))
|
||||
return
|
||||
}
|
||||
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
for (const chunk of entry) {
|
||||
if (options.signal?.aborted) throw new Error('aborted')
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Scripted text response ending in a clean stop. */
|
||||
export function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 5, outputTokens: text.length } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
/** Scripted response ending at the output-token ceiling. */
|
||||
export function maxTokensResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
]
|
||||
}
|
||||
|
||||
/** Scripted response that fails after publishing an uncommitted partial chunk. */
|
||||
export function errorResponse(message: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'partial' },
|
||||
{ type: 'finish', reason: { kind: 'error', failure: { message, code: 'PROVIDER_ERROR' } } },
|
||||
]
|
||||
}
|
||||
|
||||
export type CapturedUpdate = SessionNotification['update']
|
||||
|
||||
export interface BridgeHarness {
|
||||
ctx: Context
|
||||
client: ClientSideConnection
|
||||
adapter: MockAdapter
|
||||
updates: CapturedUpdate[]
|
||||
sessionUpdates: { sessionId: string; update: CapturedUpdate }[]
|
||||
permissionRequests: RequestPermissionRequest[]
|
||||
onPermission: (request: RequestPermissionRequest) => RequestPermissionResponse
|
||||
onSessionUpdateError: (() => void) | undefined
|
||||
closeClientTransport: () => Promise<void>
|
||||
abortClientTransport: () => Promise<void>
|
||||
acpFiber: Awaited<ReturnType<Context['plugin']>>
|
||||
/** The AgentLoop fiber, so a test can reload the loop out from under the bridge. */
|
||||
loopFiber: Awaited<ReturnType<Context['plugin']>>
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
type AcpConfigOverrides = { [K in keyof AcpConfig]?: AcpConfig[K] | undefined }
|
||||
|
||||
/** Build the bridge and a connected SDK client over cross-wired byte streams. */
|
||||
export async function makeBridgeHarness(options: {
|
||||
script?: (StreamChunk[] | 'hang')[]
|
||||
config?: AcpConfigOverrides
|
||||
persona?: string
|
||||
} = {}): Promise<BridgeHarness> {
|
||||
const adapter = new MockAdapter(options.script ?? [])
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } })
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const agentToClient = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const clientToAgentWriter = clientToAgent.writable.getWriter()
|
||||
const clientOutput = new WritableStream<Uint8Array>({
|
||||
write: chunk => clientToAgentWriter.write(chunk),
|
||||
})
|
||||
const agentStream: Stream = ndJsonStream(agentToClient.writable, clientToAgent.readable)
|
||||
const clientStream: Stream = ndJsonStream(clientOutput, agentToClient.readable)
|
||||
|
||||
const updates: CapturedUpdate[] = []
|
||||
const sessionUpdates: { sessionId: string; update: CapturedUpdate }[] = []
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const harness: BridgeHarness = {
|
||||
ctx,
|
||||
adapter,
|
||||
updates,
|
||||
sessionUpdates,
|
||||
permissionRequests,
|
||||
onPermission: () => ({ outcome: { outcome: 'cancelled' } }),
|
||||
onSessionUpdateError: undefined,
|
||||
client: undefined as unknown as ClientSideConnection,
|
||||
acpFiber: undefined as unknown as BridgeHarness['acpFiber'],
|
||||
loopFiber,
|
||||
closeClientTransport: async () => { await clientToAgentWriter.close() },
|
||||
abortClientTransport: async () => { await clientToAgentWriter.abort(new Error('client transport failed')) },
|
||||
dispose: async () => { await ctx.fiber.dispose() },
|
||||
}
|
||||
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
updates.push(params.update)
|
||||
sessionUpdates.push({ sessionId: params.sessionId, update: params.update })
|
||||
if (harness.onSessionUpdateError !== undefined) return Promise.reject(new Error('client update rejected'))
|
||||
return Promise.resolve()
|
||||
},
|
||||
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
permissionRequests.push(params)
|
||||
return Promise.resolve(harness.onPermission(params))
|
||||
},
|
||||
})
|
||||
|
||||
const config = { stream: agentStream, ...options.config } as AcpConfig
|
||||
if (!(options.config && 'provider' in options.config)) config.provider = 'mock'
|
||||
if (!(options.config && 'model' in options.config)) config.model = 'mock'
|
||||
harness.acpFiber = await ctx.plugin({
|
||||
name: 'acp-test',
|
||||
inject: [...AcpPlugin.inject],
|
||||
apply: (inner: Context) => { AcpPlugin.apply(inner, config) },
|
||||
})
|
||||
harness.client = new ClientSideConnection(makeClient, clientStream)
|
||||
return harness
|
||||
}
|
||||
95
packages/acp/acp/tests/multi-session.spec.ts
Normal file
95
packages/acp/acp/tests/multi-session.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
|
||||
function messageTextFor(
|
||||
updates: { sessionId: string; update: CapturedUpdate }[],
|
||||
sessionId: string,
|
||||
): string {
|
||||
return updates.flatMap(({ sessionId: owner, update }) => (
|
||||
owner === sessionId && update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text'
|
||||
? [update.content.text]
|
||||
: []
|
||||
)).join('')
|
||||
}
|
||||
|
||||
describe('ACP multi-session isolation', () => {
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.dispose()
|
||||
harness = undefined
|
||||
})
|
||||
|
||||
it('demultiplexes concurrent answers by session id', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('answer-A'), textResponse('answer-B')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'go A' }] }),
|
||||
harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] }),
|
||||
])
|
||||
expect(resultA.stopReason).toBe('end_turn')
|
||||
expect(resultB.stopReason).toBe('end_turn')
|
||||
await vi.waitFor(() => {
|
||||
expect(messageTextFor(harness!.sessionUpdates, a)).toBe('answer-A')
|
||||
expect(messageTextFor(harness!.sessionUpdates, b)).toBe('answer-B')
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels one session without affecting another', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang', textResponse('B done')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
|
||||
const pendingA = harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'hang A' }] })
|
||||
await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(a))?.status).toBe('running') })
|
||||
await harness.client.cancel({ sessionId: a })
|
||||
await expect(pendingA).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
await expect(harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
await vi.waitFor(() => { expect(messageTextFor(harness!.sessionUpdates, b)).toBe('B done') })
|
||||
})
|
||||
|
||||
it('enforces one in-flight prompt independently for each session', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang', 'hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const pendingA = harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'A' }] })
|
||||
const pendingB = harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'B' }] })
|
||||
await vi.waitFor(() => {
|
||||
expect(harness!.ctx.agents.get(SessionId(a))?.status).toBe('running')
|
||||
expect(harness!.ctx.agents.get(SessionId(b))?.status).toBe('running')
|
||||
})
|
||||
|
||||
await expect(harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'again' }] }))
|
||||
.rejects.toThrow(/already in flight/)
|
||||
await Promise.all([harness.client.cancel({ sessionId: a }), harness.client.cancel({ sessionId: b })])
|
||||
await expect(pendingA).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
await expect(pendingB).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
})
|
||||
|
||||
it('drains every live session on bridge disposal', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang', 'hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const agentA = harness.ctx.agents.get(SessionId(a))!
|
||||
const agentB = harness.ctx.agents.get(SessionId(b))!
|
||||
void harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'A' }] }).catch(() => {})
|
||||
void harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'B' }] }).catch(() => {})
|
||||
await vi.waitFor(() => {
|
||||
expect(agentA.status).toBe('running')
|
||||
expect(agentB.status).toBe('running')
|
||||
})
|
||||
|
||||
await harness.acpFiber.dispose()
|
||||
expect(harness.ctx.agents.get(SessionId(a))).toBeUndefined()
|
||||
expect(harness.ctx.agents.get(SessionId(b))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
206
packages/acp/acp/tests/turns.spec.ts
Normal file
206
packages/acp/acp/tests/turns.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
errorResponse,
|
||||
makeBridgeHarness,
|
||||
maxTokensResponse,
|
||||
textResponse,
|
||||
type BridgeHarness,
|
||||
} from './harness.ts'
|
||||
|
||||
async function newSession(harness: BridgeHarness): Promise<string> {
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
return (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
}
|
||||
|
||||
function messageText(harness: BridgeHarness): string {
|
||||
return harness.updates.flatMap(update => (
|
||||
update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text'
|
||||
? [update.content.text]
|
||||
: []
|
||||
)).join('')
|
||||
}
|
||||
|
||||
describe('ACP prompt lifecycle', () => {
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.dispose()
|
||||
harness = undefined
|
||||
})
|
||||
|
||||
it('maps a max-token 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')
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
|
||||
})
|
||||
|
||||
it('rejects a failed turn and never publishes its partial chunks', async () => {
|
||||
harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: provider boom/)
|
||||
expect(messageText(harness)).toBe('')
|
||||
})
|
||||
|
||||
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') })
|
||||
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('settles even when an earlier turn observer throws', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
|
||||
harness.ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'turn/start' || event.type === 'turn/end') throw new Error('peer listener boom')
|
||||
}, { prepend: true })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
})
|
||||
|
||||
it('ignores an injection turn while correlating the owning message turn', 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) {
|
||||
injected = true
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
}
|
||||
})
|
||||
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('real answer') })
|
||||
})
|
||||
|
||||
it('ignores an autonomous message turn while correlating the client turn', async () => {
|
||||
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, message) => {
|
||||
if (subject !== agent || 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 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(settled).toBe(false)
|
||||
await harness.client.cancel({ sessionId })
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
})
|
||||
|
||||
it('frees the prompt slot when the agent rejects the send synchronously', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
const sessionId = await newSession(harness)
|
||||
// Reload the loop out from under the bridge: its agents dispose while the
|
||||
// bridge record survives, so the next send() throws synchronously.
|
||||
await harness.loopFiber.dispose()
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] }))
|
||||
.rejects.toThrow(/prompt was not queued/)
|
||||
// The failed prompt must not wedge the session's single prompt slot.
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] }))
|
||||
.rejects.toThrow(/prompt was not queued/)
|
||||
})
|
||||
|
||||
it('permits only one in-flight prompt per session', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
const sessionId = await newSession(harness)
|
||||
const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] })
|
||||
await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId))?.status).toBe('running') })
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] }))
|
||||
.rejects.toThrow(/already in flight/)
|
||||
await harness.client.cancel({ sessionId })
|
||||
await expect(first).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
})
|
||||
|
||||
it('cancels a running turn and records the aborted outcome', 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') })
|
||||
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' })
|
||||
})
|
||||
|
||||
it('an idle cancel does not affect the following prompt', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
await harness.client.cancel({ sessionId })
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('answer') })
|
||||
})
|
||||
|
||||
it('a late end from a cancelled turn cannot settle the next prompt', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang', textResponse('next')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] })
|
||||
await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId))?.status).toBe('running') })
|
||||
await harness.client.cancel({ sessionId })
|
||||
await expect(first).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') })
|
||||
})
|
||||
|
||||
it('a retry turn adopts the prompt instead of rejecting at the failed turn end', async () => {
|
||||
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) => {
|
||||
if (!retried) {
|
||||
retried = true
|
||||
return { kind: 'retry' }
|
||||
}
|
||||
})
|
||||
const sessionId = await newSession(harness)
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('recovered') })
|
||||
})
|
||||
|
||||
it('a failed turn with no retry still rejects, at quiescence', async () => {
|
||||
harness = await makeBridgeHarness({ script: [errorResponse('terminal boom')] })
|
||||
let offered = 0
|
||||
harness.ctx.on('agent/request-error', async () => { offered += 1 })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: terminal boom/)
|
||||
expect(offered).toBe(1)
|
||||
})
|
||||
|
||||
it('an admission-blocked prompt settles cancelled instead of hanging', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
harness.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy said no' }))
|
||||
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.
|
||||
expect(messageText(harness)).toBe('')
|
||||
})
|
||||
})
|
||||
33
packages/acp/acp/tsconfig.json
Normal file
33
packages/acp/acp/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/bash/README.i18n.yaml
Normal file
6
packages/bash/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
|
||||
README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b
|
||||
README.zh.md: 57c28b45cf713aeaac725edb70d1fc24912c35db
|
||||
@@ -1,11 +1,13 @@
|
||||
# bash/ — bash capability family
|
||||
|
||||
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.
|
||||
|
||||
| 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) | `ctx.bash` |
|
||||
| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
|
||||
| `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`) |
|
||||
|
||||
|
||||
14
packages/bash/README.zh.md
Normal file
14
packages/bash/README.zh.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# bash/:bash 能力家族
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品** 包。
|
||||
|
||||
| 包 | 职责 | 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/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器配置项;受限实现还需选择一个 `ctx.sandbox` 提供方配置项(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。
|
||||
6
packages/bash/bash-local/README.i18n.yaml
Normal file
6
packages/bash/bash-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
|
||||
README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706
|
||||
README.zh.md: aa6de87df48ee943ccdd2c6227ad977f596b5516
|
||||
@@ -1,8 +1,10 @@
|
||||
# @deepseek-ai/dsh-bash-local
|
||||
|
||||
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
|
||||
Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — 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 package root exports the default and named `LocalBashExecutor` plugin plus its `Config`.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -22,11 +24,11 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
|
||||
|
||||
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/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
|
||||
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
|
||||
- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file.
|
||||
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. 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), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. 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.
|
||||
- **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`.
|
||||
- **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 Experience
|
||||
|
||||
@@ -40,8 +42,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`.
|
||||
- **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them.
|
||||
- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
|
||||
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
|
||||
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
|
||||
- **POSIX-only** — the `bash` binary is hardcoded, and the underlying service's group semantics are POSIX; Windows is unsupported.
|
||||
- **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.
|
||||
|
||||
The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring.
|
||||
Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.
|
||||
|
||||
48
packages/bash/bash-local/README.zh.md
Normal file
48
packages/bash/bash-local/README.zh.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# @deepseek-ai/dsh-bash-local
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c <command>` 作为受管进程组 spawn,并拥有所有 bash 形态的职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级、dispose(资源释放))归进程管理器服务所有。
|
||||
|
||||
包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
cwd: /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
|
||||
```
|
||||
|
||||
## 行为(以及设计来源)
|
||||
|
||||
设计时调研了 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 读取合并为一条带标记分节的增量,由一个消费游标驱动。仍在运行的进程归进程管理器服务所有,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-bash` 间接影响;该工具会渲染此执行器有界的 stdout/stderr 尾部、后台进程增量、spill 文件路径与基础设施失败。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;请求前缀变更由具名消费方负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。
|
||||
- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。
|
||||
- **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。
|
||||
- **后台 spawn 失败提示只交付一次**:进程管理器不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。
|
||||
|
||||
凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 记录;这些机制归它所有。
|
||||
@@ -29,6 +29,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -38,6 +39,8 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
/**
|
||||
* Local-subprocess implementation of the bash executor seam. Each command runs
|
||||
* as `bash -c` in its own process group; disposal kills and joins live groups.
|
||||
* Execution policy belongs in `tools/pre-execute` or a sandboxing executor.
|
||||
* 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.
|
||||
* @module @deepseek-ai/dsh-bash-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } 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 { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts'
|
||||
import type { RunInternals, RunningBash } from './run.ts'
|
||||
|
||||
/**
|
||||
* Model-friendly environment overrides: disable colors, pagers, and
|
||||
* interactive terminal features that would garble tool output (the same set
|
||||
* Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy —
|
||||
* merged first into the spawn's explicit env, so a trusted caller's own entry
|
||||
* still wins; the subprocess service applies its credential scrub independently.
|
||||
*/
|
||||
export const ENV_OVERRIDES = {
|
||||
NO_COLOR: '1',
|
||||
TERM: 'dumb',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
} as const
|
||||
|
||||
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
|
||||
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 {
|
||||
@@ -32,6 +54,16 @@ export interface Config {
|
||||
/** The shape after schemastery applied the defaults (cwd has none). */
|
||||
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
||||
|
||||
/** 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(`bash-local: ${name} must be a positive finite number`)
|
||||
@@ -39,10 +71,15 @@ function assertPositiveFinite(name: string, value: number): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Local bash executor with bounded output, spill files, and process-group
|
||||
* `SIGTERM` to `SIGKILL` escalation.
|
||||
* Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
|
||||
* process-group SIGTERM→SIGKILL escalation are the subprocess service's
|
||||
* mechanics; this executor supplies their configured budgets per spawn, so a
|
||||
* still-running background process stays managed (killed and joined at
|
||||
* composition teardown) even across an executor reload.
|
||||
*/
|
||||
export class LocalBashExecutor extends BashExecutor {
|
||||
static inject = ['subprocess']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string(),
|
||||
timeoutMs: z.number().default(120_000),
|
||||
@@ -52,11 +89,6 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
})
|
||||
|
||||
/** Live processes retained only so disposal can kill and join them. */
|
||||
private live = new Map<BashProcess, RunningBash>()
|
||||
/** Test seam: spill knobs forwarded to runBash. */
|
||||
internals: RunInternals = {}
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
@@ -69,17 +101,6 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
ctx.effect(() => async () => {
|
||||
// Await closure so even a TERM-trapping child cannot outlive the fiber.
|
||||
const pending: Promise<void>[] = []
|
||||
for (const [proc, running] of this.live) {
|
||||
proc.status = 'killed'
|
||||
running.kill()
|
||||
pending.push(proc.done)
|
||||
}
|
||||
this.live.clear()
|
||||
await Promise.all(pending)
|
||||
}, 'local bash teardown')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,52 +126,82 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
stdoutMaxBytes,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
// Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
|
||||
// no config default. run.ts owns the scrub and merge order.
|
||||
// no config default. The subprocess service owns the scrub and merge order.
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
// Carry a sandbox-mode override through verbatim: this executor never
|
||||
// Carry a sandbox policy through verbatim: this executor never
|
||||
// confines, so the field is inert here (the seam contract) — a
|
||||
// sandboxing subclass overrides resolve() to stamp its default instead.
|
||||
sandboxMode: request.sandboxMode,
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one resolved bash spec 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 {
|
||||
const collect = (maxBytes: number): SubprocessCollect =>
|
||||
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
||||
return {
|
||||
argv: ['bash', '-c', 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,
|
||||
// One explicit env map for the seam, layered so the trusted dshEnv
|
||||
// snapshot beats both the caller's env and the terminal overrides; the
|
||||
// subprocess service merges the whole map after its ambient scrub.
|
||||
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('bash-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 outcome = await runBash({
|
||||
command: spec.command,
|
||||
cwd: spec.workdir,
|
||||
stdoutMaxBytes: spec.stdoutMaxBytes,
|
||||
stderrMaxBytes: this.config.maxOutputBytes,
|
||||
maxSpillBytes: this.config.maxSpillBytes,
|
||||
graceMs: this.config.graceMs,
|
||||
signal: d.signal,
|
||||
stdin: spec.stdin,
|
||||
env: spec.env,
|
||||
dshEnv: spec.dshEnv,
|
||||
}, this.internals).done
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, 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.
|
||||
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
|
||||
const aborted = d.signal.aborted && !timedOut
|
||||
return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs }
|
||||
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 = runBash({
|
||||
command: spec.command,
|
||||
cwd: spec.workdir,
|
||||
stdoutMaxBytes: this.config.maxOutputBytes,
|
||||
stderrMaxBytes: this.config.maxOutputBytes,
|
||||
maxSpillBytes: this.config.maxSpillBytes,
|
||||
graceMs: this.config.graceMs,
|
||||
signal: spec.signal,
|
||||
stdin: spec.stdin,
|
||||
env: spec.env,
|
||||
dshEnv: spec.dshEnv,
|
||||
}, this.internals)
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
const collected = LocalBashExecutor.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
|
||||
@@ -165,26 +216,27 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.onProcessDone(proc, running.stderr.readFrom(0).text)
|
||||
this.live.delete(proc)
|
||||
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'
|
||||
running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
|
||||
this.onProcessDone(proc, running.stderr.readFrom(0).text)
|
||||
this.live.delete(proc)
|
||||
spawnFailureNote = `spawn failed: ${String(error)}`
|
||||
this.onProcessDone(proc, spawnFailureNote)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = running.stdout.readFrom(stdoutOffset)
|
||||
const err = running.stderr.readFrom(stderrOffset)
|
||||
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
|
||||
+ (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '')
|
||||
+ (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
|
||||
return {
|
||||
delta,
|
||||
lossy: out.lossy || err.lossy,
|
||||
@@ -195,11 +247,10 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
kill: (): boolean => {
|
||||
if (proc.status !== 'running') return false
|
||||
proc.status = 'killed'
|
||||
running.kill()
|
||||
running.terminate()
|
||||
return true
|
||||
},
|
||||
}
|
||||
this.live.set(proc, running)
|
||||
return proc
|
||||
}
|
||||
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
/**
|
||||
* Process plumbing for the local bash executor: detached process-group spawn,
|
||||
* tail-keep output with spill files, and SIGTERM→SIGKILL escalation. This layer
|
||||
* reacts to an abort signal; the executor owns deadlines and classifies causes.
|
||||
* @module dsh-bash-local/run
|
||||
*/
|
||||
|
||||
import { type ChildProcessByStdio, spawn } from 'node:child_process'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/**
|
||||
* Model-friendly environment overrides: disable colors, pagers, and
|
||||
* interactive terminal features that would garble tool output (the same set
|
||||
* Codex hardcodes; Claude Code achieves it via TERM=dumb).
|
||||
*/
|
||||
export const ENV_OVERRIDES = {
|
||||
NO_COLOR: '1',
|
||||
TERM: 'dumb',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Credential-shaped env vars are NOT forwarded to commands (the harness's
|
||||
* own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or
|
||||
* spill files). Same default pattern as Codex's env policy; a future config
|
||||
* can whitelist specific vars when a workflow genuinely needs one.
|
||||
*/
|
||||
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
|
||||
/**
|
||||
* Build a child environment from scrubbed ambient values, terminal overrides,
|
||||
* ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed
|
||||
* names are removed; ordinary and managed entries reject the other channel's
|
||||
* namespace before `dshEnv` merges last.
|
||||
* @param extra - caller entries; `DSH_*` names are rejected.
|
||||
* @param dshEnv - managed entries; non-`DSH_*` names are rejected.
|
||||
* @returns the environment to hand to `spawn` for the child process.
|
||||
*/
|
||||
export function childEnv(
|
||||
extra?: Readonly<Record<string, string>>,
|
||||
dshEnv?: DshEnvironment,
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value
|
||||
}
|
||||
for (const key of Object.keys(extra ?? {})) {
|
||||
if (key.startsWith(DSH_ENV_PREFIX)) {
|
||||
throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`)
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(dshEnv ?? {})) {
|
||||
if (!key.startsWith(DSH_ENV_PREFIX)) {
|
||||
throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`)
|
||||
}
|
||||
}
|
||||
return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv }
|
||||
}
|
||||
|
||||
/** What to run and under which limits (resolved — no defaults in here). */
|
||||
export interface SpawnSpec {
|
||||
command: string
|
||||
cwd: string
|
||||
/** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */
|
||||
stdoutMaxBytes: number
|
||||
/** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
|
||||
stderrMaxBytes: 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. */
|
||||
graceMs: number
|
||||
/**
|
||||
* Abort signal — kills the process group when it fires. The executor owns
|
||||
* timing: `run()` passes a fused timeout/cancel deadline signal (see
|
||||
* `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal.
|
||||
* runBash only listens and kills; it does NOT classify why (the executor
|
||||
* reads the signal's reason afterward).
|
||||
*/
|
||||
signal?: AbortSignal | undefined
|
||||
/**
|
||||
* Bytes to write to the child's stdin, then close it. Absent (or empty)
|
||||
* leaves stdin closed/empty. Set by in-process plugins (the hooks bridges);
|
||||
* the model-facing `dsh-tool-bash` tool does not thread model input here.
|
||||
*/
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
* Ordinary environment entries merged after the credential scrub and
|
||||
* terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`.
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */
|
||||
dshEnv?: DshEnvironment | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw outcome of one closed process (before result shaping). Deliberately
|
||||
* carries NO timeout/cancel classification: runBash kills on abort but does not
|
||||
* decide why — the executor's `run()`/`start()` reads the deadline signal it
|
||||
* owns to classify `timedOut`/`aborted` (see the package README).
|
||||
*/
|
||||
export interface SpawnOutcome {
|
||||
exitCode: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
stdout: CollectedOutput
|
||||
stderr: CollectedOutput
|
||||
}
|
||||
|
||||
/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */
|
||||
export interface RunInternals {
|
||||
/** Directory for spill files (defaults to the OS temp dir). */
|
||||
spillDir?: string
|
||||
}
|
||||
|
||||
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
|
||||
export const DEFAULT_GRACE_MS = 3_000
|
||||
|
||||
/** Default per-stream spill cap (the `maxSpillBytes` config). */
|
||||
export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
|
||||
|
||||
let spillCounter = 0
|
||||
let defaultSpillDir: string | undefined
|
||||
|
||||
/**
|
||||
* The default spill location: a private (0700) per-process directory under
|
||||
* the OS tmpdir, created lazily. Predictable world-readable paths would let
|
||||
* other local users read command output or pre-create symlinks.
|
||||
*/
|
||||
function privateSpillDir(): string {
|
||||
defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-bash-'))
|
||||
return defaultSpillDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects one stream with a bounded in-memory tail. On first overflow a
|
||||
* spill file is created and every chunk (including those already collected)
|
||||
* is appended there while the full stream remains within `maxSpillBytes`.
|
||||
*
|
||||
* Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
|
||||
* end of command output; the spill file covers the head.
|
||||
*/
|
||||
export class OutputCollector {
|
||||
private chunks: Buffer[] = []
|
||||
private bytes = 0
|
||||
private dropped = false
|
||||
private spillFd: number | undefined
|
||||
private spillFile: string | undefined
|
||||
private spillDisabled = false
|
||||
/** Total bytes ever pushed (not just retained). */
|
||||
private total = 0
|
||||
|
||||
constructor(
|
||||
private readonly maxBytes: number,
|
||||
private readonly maxSpillBytes: number,
|
||||
private readonly label: string,
|
||||
private readonly spillDir: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ingest one stream chunk, counting it toward the whole-stream total. On
|
||||
* first overflow of the in-memory cap a spill file is opened and every chunk
|
||||
* (already-collected ones included) is appended there from then on; the
|
||||
* in-memory tail then drops whole chunks from its head (or the head of a
|
||||
* single over-cap chunk) until it fits the cap again.
|
||||
* @param chunk - the raw bytes from one stream 'data' event.
|
||||
*/
|
||||
push(chunk: Buffer): void {
|
||||
this.total += chunk.length
|
||||
const overflows = this.bytes + chunk.length > this.maxBytes
|
||||
if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk)
|
||||
this.chunks.push(chunk)
|
||||
this.bytes += chunk.length
|
||||
while (this.bytes > this.maxBytes && this.chunks.length > 1) {
|
||||
// Drop whole chunks from the head; pipe chunks are small (≤64KiB), so
|
||||
// the retained tail tracks the cap closely enough for a model-facing
|
||||
// truncation boundary. (length > 1 was just checked — shift() returns.)
|
||||
const head = this.chunks.shift() as Buffer
|
||||
this.bytes -= head.length
|
||||
this.dropped = true
|
||||
}
|
||||
if (this.bytes > this.maxBytes && this.chunks.length === 1) {
|
||||
// A single chunk larger than the cap: keep its tail.
|
||||
const only = this.chunks[0] as Buffer
|
||||
this.chunks[0] = only.subarray(only.length - this.maxBytes)
|
||||
this.bytes = this.maxBytes
|
||||
this.dropped = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the spill file lazily and append `chunk` (and any prior chunks once). */
|
||||
private spillAll(chunk: Buffer): void {
|
||||
if (this.total > this.maxSpillBytes) {
|
||||
this.discardSpill()
|
||||
return
|
||||
}
|
||||
if (this.spillFd === undefined) {
|
||||
// Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
|
||||
// existing path, symlink or not) + owner-only mode: defeats spill-path
|
||||
// prediction and symlink planting in shared tmp dirs.
|
||||
this.spillFile = join(
|
||||
this.spillDir,
|
||||
`dsh-bash-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
|
||||
)
|
||||
this.spillFd = openSync(this.spillFile, 'wx', 0o600)
|
||||
for (const prior of this.chunks) writeSync(this.spillFd, prior)
|
||||
}
|
||||
writeSync(this.spillFd, chunk)
|
||||
}
|
||||
|
||||
/** Stop spilling and remove the file once it can no longer hold the complete stream. */
|
||||
private discardSpill(): void {
|
||||
const fd = this.spillFd
|
||||
const file = this.spillFile
|
||||
this.spillFd = undefined
|
||||
this.spillFile = undefined
|
||||
this.spillDisabled = true
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
closeSync(fd)
|
||||
} catch {
|
||||
// Retain the descriptor so finalize can retry the failed close.
|
||||
this.spillFd = fd
|
||||
}
|
||||
}
|
||||
if (file !== undefined) {
|
||||
try {
|
||||
unlinkSync(file)
|
||||
} catch {
|
||||
// A failed unlink leaves at most maxSpillBytes behind, never an unbounded file.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental read in whole-stream byte coordinates: returns everything
|
||||
* pushed since `fromByte`. When `fromByte` has already slid out of the
|
||||
* in-memory tail window, the read is `lossy` — it returns the whole
|
||||
* retained tail and the gap is only recoverable from the spill file.
|
||||
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
|
||||
* @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
|
||||
*/
|
||||
readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } {
|
||||
const windowStart = this.total - this.bytes
|
||||
const buffer = Buffer.concat(this.chunks)
|
||||
const lossy = fromByte < windowStart
|
||||
const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart)
|
||||
return {
|
||||
text: slice.toString('utf8'),
|
||||
nextOffset: this.total,
|
||||
lossy,
|
||||
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the spill file (if any) and return the final output. A failed close
|
||||
* (delayed writeback fault) stops advertising the spill path — the file may
|
||||
* be missing its tail — but still returns the in-memory result.
|
||||
* @returns the final collected output: tail text, truncation flag, and the spill path when intact.
|
||||
*/
|
||||
finalize(): CollectedOutput {
|
||||
if (this.spillFd !== undefined) {
|
||||
try {
|
||||
closeSync(this.spillFd)
|
||||
} catch {
|
||||
// A delayed writeback failure makes the spill unreliable; keep finalize
|
||||
// total but stop advertising that file.
|
||||
this.spillFile = undefined
|
||||
}
|
||||
this.spillFd = undefined
|
||||
}
|
||||
return {
|
||||
text: Buffer.concat(this.chunks).toString('utf8'),
|
||||
truncated: this.dropped,
|
||||
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send `sig` to a detached process group. Never throws: delivery races process
|
||||
* exit and may run in a timer callback, so failures are contained and a
|
||||
* non-positive pid is a no-op.
|
||||
* @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op.
|
||||
* @param sig - the signal to deliver to the whole group.
|
||||
*/
|
||||
export function killGroup(pid: number, sig: NodeJS.Signals): void {
|
||||
if (pid <= 0) return
|
||||
try {
|
||||
process.kill(-pid, sig)
|
||||
} catch {
|
||||
// Swallow: see contract above.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A live bash child process: the promise resolves when the process closes;
|
||||
* `kill()` starts the SIGTERM→grace→SIGKILL escalation on its group.
|
||||
*/
|
||||
export interface RunningBash {
|
||||
/** Process id (group leader); -1 when the spawn itself failed. */
|
||||
readonly pid: number
|
||||
/** stdout/stderr collectors (live — background polling reads incrementally). */
|
||||
readonly stdout: OutputCollector
|
||||
readonly stderr: OutputCollector
|
||||
/** Resolves when the process closes; rejects only for spawn-level failures. */
|
||||
readonly done: Promise<SpawnOutcome>
|
||||
/** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
|
||||
kill(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn one isolated `bash -c` process group and collect its output.
|
||||
* Runtime exits resolve as {@link SpawnOutcome}; only spawn failures reject.
|
||||
* @param spec - fully resolved command, cwd, limits, and cancellation.
|
||||
* @param internals - test-only process and spill-directory overrides.
|
||||
* @returns live process handle and outcome promise.
|
||||
*/
|
||||
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
|
||||
export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
|
||||
const spillDir = internals.spillDir ?? privateSpillDir()
|
||||
|
||||
if (spec.signal?.aborted) {
|
||||
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
|
||||
}
|
||||
|
||||
// Keep absent stdin as /dev/null; literal tuples preserve non-null output types.
|
||||
const env = childEnv(spec.env, spec.dshEnv)
|
||||
const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
|
||||
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
|
||||
: spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
|
||||
|
||||
const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir)
|
||||
const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir)
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
|
||||
|
||||
let graceTimer: NodeJS.Timeout | undefined
|
||||
|
||||
// Failed spawns use pid -1 so kill remains a no-op.
|
||||
const pid = child.pid ?? -1
|
||||
|
||||
const kill = (): void => {
|
||||
if (graceTimer !== undefined) return // escalation already in flight
|
||||
killGroup(pid, 'SIGTERM')
|
||||
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
|
||||
}
|
||||
|
||||
// The executor owns timeout classification; this layer only reacts to abort.
|
||||
const onAbort = (): void => { kill() }
|
||||
spec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
// Stdin writes are best-effort; process exit and captured output remain authoritative.
|
||||
if (child.stdin !== null) {
|
||||
child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
|
||||
child.stdin.end(spec.stdin)
|
||||
}
|
||||
|
||||
const done = new Promise<SpawnOutcome>((resolve, reject) => {
|
||||
let settled = false
|
||||
let pipeDrainTimer: NodeJS.Timeout | undefined
|
||||
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
child.stdout.destroy()
|
||||
child.stderr.destroy()
|
||||
cleanup()
|
||||
resolve({
|
||||
exitCode,
|
||||
signal,
|
||||
stdout: stdout.finalize(),
|
||||
stderr: stderr.finalize(),
|
||||
})
|
||||
}
|
||||
child.on('error', (error) => {
|
||||
// No meaningful close outcome follows a spawn failure.
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
child.on('exit', (exitCode, signal) => {
|
||||
pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs)
|
||||
})
|
||||
child.on('close', settle)
|
||||
function cleanup(): void {
|
||||
if (graceTimer !== undefined) clearTimeout(graceTimer)
|
||||
if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer)
|
||||
spec.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})
|
||||
|
||||
return { pid, stdout, stderr, done, kill }
|
||||
}
|
||||
@@ -4,16 +4,18 @@ import { join } from 'node:path'
|
||||
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 type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
||||
|
||||
async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[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(LocalBashExecutor, { graceMs: 200, ...config })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
@@ -293,44 +295,52 @@ describe('LocalBashExecutor.start (background process handles)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBashExecutor disposal', () => {
|
||||
it('disposing the fiber kills running processes and AWAITS their exit (no orphans, SIGKILL escalation included)', async () => {
|
||||
describe('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 fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const managerFiber = await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
|
||||
// The child prints its own pid ($$ = the detached bash group leader) so
|
||||
// the test can probe liveness through the public read surface alone.
|
||||
const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo $$; sleep 60' }))
|
||||
const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 60' }))
|
||||
const pid = Number((await readUntil(proc, '\n')).trim())
|
||||
expect(Number.isInteger(pid) && pid > 0).toBe(true)
|
||||
|
||||
await fiber.dispose()
|
||||
// Disposal itself waited: the pid must already be gone, no grace left —
|
||||
// even for a TERM-trapping child held until the SIGKILL escalation landed.
|
||||
// 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()
|
||||
expect(proc.status).toBe('killed')
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
})
|
||||
|
||||
it('settled processes already left the live map: dispose does not touch them', async () => {
|
||||
it('service disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const managerFiber = await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
|
||||
const finished = bash.start(bash.resolve({ command: 'echo done' }))
|
||||
await finished.done
|
||||
expect(finished.status).toBe('completed')
|
||||
const running = bash.start(bash.resolve({ command: 'sleep 60' }))
|
||||
const trapping = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
|
||||
await readUntil(trapping, 'armed')
|
||||
|
||||
await fiber.dispose()
|
||||
// The teardown marks every LIVE entry killed; a settled process had
|
||||
// already left the map, so its status stays completed.
|
||||
await managerFiber.dispose()
|
||||
// A settled process was untouched; the live one died by escalation.
|
||||
expect(finished.status).toBe('completed')
|
||||
expect(running.status).toBe('killed')
|
||||
await running.done
|
||||
expect(running.signal).toBe('SIGTERM')
|
||||
await trapping.done
|
||||
expect(trapping.status).toBe('killed')
|
||||
expect(trapping.signal).toBe('SIGKILL')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,510 +0,0 @@
|
||||
import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DshEnvironment } from '@deepseek-ai/dsh-bash'
|
||||
import { killGroup, OutputCollector, runBash } from '../src/run.ts'
|
||||
import type { RunningBash } from '../src/run.ts'
|
||||
|
||||
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
|
||||
failNextClose: { value: false },
|
||||
failNextUnlink: { value: false },
|
||||
}))
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
return {
|
||||
...actual,
|
||||
closeSync(fd: number): void {
|
||||
if (failNextClose.value) {
|
||||
failNextClose.value = false
|
||||
throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' })
|
||||
}
|
||||
actual.closeSync(fd)
|
||||
},
|
||||
unlinkSync(path: Parameters<typeof actual.unlinkSync>[0]): void {
|
||||
if (failNextUnlink.value) {
|
||||
failNextUnlink.value = false
|
||||
throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' })
|
||||
}
|
||||
actual.unlinkSync(path)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-'))
|
||||
|
||||
function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]> = {}) {
|
||||
return {
|
||||
command,
|
||||
cwd: process.cwd(),
|
||||
stdoutMaxBytes: 64_000,
|
||||
stderrMaxBytes: 64_000,
|
||||
maxSpillBytes: 64 * 1024 * 1024,
|
||||
graceMs: 3_000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll until a pid no longer exists (kill(pid, 0) throws ESRCH). */
|
||||
async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (running.stdout.readFrom(0).text.includes(expected)) return
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const pid = Number(readFileSync(path, 'utf8').trim())
|
||||
if (Number.isSafeInteger(pid) && pid > 0) return pid
|
||||
} catch {
|
||||
// The child shell has not written the pid file yet.
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
describe('runBash', () => {
|
||||
it('captures stdout on success', async () => {
|
||||
const result = await runBash(spec('echo hello')).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.signal).toBeNull()
|
||||
expect(result.stdout.text).toBe('hello\n')
|
||||
expect(result.stdout.truncated).toBe(false)
|
||||
expect(result.stderr.text).toBe('')
|
||||
})
|
||||
|
||||
it('captures stderr separately', async () => {
|
||||
const result = await runBash(spec('echo oops >&2')).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('')
|
||||
expect(result.stderr.text).toBe('oops\n')
|
||||
})
|
||||
|
||||
it('captures both streams', async () => {
|
||||
const result = await runBash(spec('echo out; echo err >&2')).done
|
||||
expect(result.stdout.text).toBe('out\n')
|
||||
expect(result.stderr.text).toBe('err\n')
|
||||
})
|
||||
|
||||
it('reports non-zero exit codes', async () => {
|
||||
const result = await runBash(spec('exit 42')).done
|
||||
expect(result.exitCode).toBe(42)
|
||||
expect(result.signal).toBeNull()
|
||||
})
|
||||
|
||||
it('applies model-friendly env overrides', async () => {
|
||||
const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done
|
||||
expect(result.stdout.text).toBe('1/dumb/cat\n')
|
||||
})
|
||||
|
||||
it('runs in the requested cwd', async () => {
|
||||
const result = await runBash(spec('pwd', { cwd: '/tmp' })).done
|
||||
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
|
||||
})
|
||||
|
||||
it('kills the process group with SIGTERM when the signal fires', async () => {
|
||||
// runBash owns no timer: it kills on abort. The executor drives the timeout
|
||||
// by firing this signal via a deadline (see executor.spec.ts); here we
|
||||
// assert the kill itself lands as SIGTERM.
|
||||
const controller = new AbortController()
|
||||
const start = Date.now()
|
||||
const running = runBash(spec('sleep 60', { signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort('deadline') }, 100)
|
||||
const result = await running.done
|
||||
expect(Date.now() - start).toBeLessThan(5_000)
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
expect(result.exitCode).toBeNull()
|
||||
})
|
||||
|
||||
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
|
||||
const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
|
||||
await waitForStdout(running, 'ready\n')
|
||||
running.kill()
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGKILL')
|
||||
})
|
||||
|
||||
it('kills the whole process group (grandchildren die too)', async () => {
|
||||
// The subshell writes the sleep's pid then waits on it; killing the
|
||||
// group must take the sleep down with bash.
|
||||
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
|
||||
const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
|
||||
const grandchild = await waitForPidFile(pidFile)
|
||||
expect(grandchild).toBeGreaterThan(0)
|
||||
|
||||
running.kill()
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
await waitGone(grandchild)
|
||||
})
|
||||
|
||||
it('aborts via AbortSignal mid-run', async () => {
|
||||
const controller = new AbortController()
|
||||
const running = runBash(spec('sleep 60', { signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort('user cancelled') }, 50)
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('throws when the signal is already aborted before spawn', () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort('too late')
|
||||
expect(() => runBash(spec('echo hi', { signal: controller.signal })))
|
||||
.toThrow(/aborted before spawn: too late/)
|
||||
})
|
||||
|
||||
it('rejects with a spawn error for a nonexistent cwd', async () => {
|
||||
await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
|
||||
.rejects.toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('kill() is idempotent (second call does not restart escalation)', async () => {
|
||||
const running = runBash(spec('sleep 60'))
|
||||
running.kill()
|
||||
running.kill()
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('bounds inherited-pipe draining after the shell exits', async () => {
|
||||
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
|
||||
const started = Date.now()
|
||||
const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
|
||||
const descendant = await waitForPidFile(pidFile)
|
||||
try {
|
||||
const result = await running.done
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('shell-done\n')
|
||||
} finally {
|
||||
process.kill(descendant, 'SIGKILL')
|
||||
await waitGone(descendant)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('stdin and extra env (set by in-process plugins)', () => {
|
||||
it('writes stdin to the command and closes it', async () => {
|
||||
const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('hello from stdin\n')
|
||||
})
|
||||
|
||||
it('a command that reads stdin sees EOF when none is supplied', async () => {
|
||||
// No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
|
||||
// output (it does NOT block).
|
||||
const result = await runBash(spec('cat')).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('')
|
||||
})
|
||||
|
||||
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
|
||||
// With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device).
|
||||
// Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO.
|
||||
const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
|
||||
expect(none.stdout.text).toBe('char\n')
|
||||
const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
|
||||
expect(piped.stdout.text).toBe('socket\n')
|
||||
})
|
||||
|
||||
it('merges ordinary extra env entries onto the scrubbed environment', async () => {
|
||||
const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
|
||||
env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' },
|
||||
})).done
|
||||
expect(result.stdout.text).toBe('alpha/beta\n')
|
||||
})
|
||||
|
||||
it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => {
|
||||
// TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins.
|
||||
// EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
|
||||
// entry is still honored — the scrub only drops AMBIENT process.env creds.
|
||||
const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', {
|
||||
env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
|
||||
})).done
|
||||
expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n')
|
||||
})
|
||||
|
||||
it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
|
||||
// The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE.
|
||||
// The handler swallows that write error and `done` reports the child's real exit.
|
||||
const big = 'x'.repeat(1024 * 1024)
|
||||
const result = await runBash(spec('exit 7', { stdin: big })).done
|
||||
expect(result.exitCode).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('output truncation and spill', () => {
|
||||
it('applies stdout and stderr caps independently', async () => {
|
||||
const result = await runBash(
|
||||
spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', {
|
||||
stdoutMaxBytes: 500,
|
||||
stderrMaxBytes: 100,
|
||||
}),
|
||||
{ spillDir },
|
||||
).done
|
||||
expect(result.stdout.truncated).toBe(false)
|
||||
expect(result.stdout.text).toBe('x'.repeat(500))
|
||||
expect(result.stderr.truncated).toBe(true)
|
||||
expect(result.stderr.text.length).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('keeps the tail and spills the full stream to disk', async () => {
|
||||
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
|
||||
const result = await runBash(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
{ spillDir },
|
||||
).done
|
||||
expect(result.stdout.truncated).toBe(true)
|
||||
expect(result.stdout.text.length).toBeLessThanOrEqual(500)
|
||||
expect(result.stdout.text).toContain('line-0200')
|
||||
expect(result.stdout.text).not.toContain('line-0001')
|
||||
expect(result.stdout.spillPath).toBeDefined()
|
||||
const full = readFileSync(result.stdout.spillPath!, 'utf8')
|
||||
expect(full).toContain('line-0001')
|
||||
expect(full).toContain('line-0200')
|
||||
})
|
||||
|
||||
it('does not truncate output exactly at the cap', async () => {
|
||||
const result = await runBash(
|
||||
spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
{ spillDir },
|
||||
).done
|
||||
expect(result.stdout.truncated).toBe(false)
|
||||
expect(result.stdout.text.length).toBe(500)
|
||||
expect(result.stdout.spillPath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('settles with the tail and no spill path when final spill close fails', async () => {
|
||||
failNextClose.value = true
|
||||
const result = await runBash(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
{ spillDir },
|
||||
).done
|
||||
expect(failNextClose.value).toBe(false)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.truncated).toBe(true)
|
||||
expect(result.stdout.text).toContain('line-0200')
|
||||
expect(result.stdout.spillPath).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OutputCollector', () => {
|
||||
it('keeps the tail of a single oversized chunk', () => {
|
||||
const collector = new OutputCollector(10, 100, 'test', spillDir)
|
||||
collector.push(Buffer.from('0123456789abcdef'))
|
||||
const out = collector.finalize()
|
||||
expect(out.text).toBe('6789abcdef')
|
||||
expect(out.truncated).toBe(true)
|
||||
expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef')
|
||||
})
|
||||
|
||||
it('readFrom returns increments and flags lossy reads', () => {
|
||||
const collector = new OutputCollector(10, 100, 'test', spillDir)
|
||||
collector.push(Buffer.from('aaaaa'))
|
||||
const first = collector.readFrom(0)
|
||||
expect(first.text).toBe('aaaaa')
|
||||
expect(first.lossy).toBe(false)
|
||||
expect(first.nextOffset).toBe(5)
|
||||
|
||||
collector.push(Buffer.from('bbbbb'))
|
||||
const second = collector.readFrom(first.nextOffset)
|
||||
expect(second.text).toBe('bbbbb')
|
||||
expect(second.lossy).toBe(false)
|
||||
|
||||
// Push enough to slide the window past the last offset.
|
||||
collector.push(Buffer.from('c'.repeat(20)))
|
||||
const third = collector.readFrom(second.nextOffset)
|
||||
expect(third.lossy).toBe(true)
|
||||
expect(third.text).toBe('c'.repeat(10))
|
||||
expect(third.spillPath).toBeDefined()
|
||||
})
|
||||
|
||||
it('contains close failures and drops the spill path', () => {
|
||||
const collector = new OutputCollector(4, 100, 'closefail', spillDir)
|
||||
collector.push(Buffer.from('aaaa'))
|
||||
collector.push(Buffer.from('bbbb'))
|
||||
expect(collector.readFrom(0).spillPath).toBeDefined()
|
||||
|
||||
failNextClose.value = true
|
||||
let out: ReturnType<typeof collector.finalize>
|
||||
expect(() => { out = collector.finalize() }).not.toThrow()
|
||||
|
||||
expect(failNextClose.value).toBe(false)
|
||||
expect(out!.text).toBe('bbbb')
|
||||
expect(out!.truncated).toBe(true)
|
||||
expect(out!.spillPath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('discards a spill that exceeds its configured cap', () => {
|
||||
const collector = new OutputCollector(4, 8, 'bounded', spillDir)
|
||||
collector.push(Buffer.from('aaaa'))
|
||||
collector.push(Buffer.from('bbbb'))
|
||||
const spillPath = collector.readFrom(0).spillPath!
|
||||
expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb')
|
||||
|
||||
collector.push(Buffer.from('c'))
|
||||
collector.push(Buffer.from('dddd'))
|
||||
const out = collector.finalize()
|
||||
expect(out.text).toBe('dddd')
|
||||
expect(out.truncated).toBe(true)
|
||||
expect(out.spillPath).toBeUndefined()
|
||||
expect(() => readFileSync(spillPath)).toThrow()
|
||||
})
|
||||
|
||||
it('does not create a spill when the first overflowing chunk exceeds the cap', () => {
|
||||
const collector = new OutputCollector(4, 4, 'no-spill', spillDir)
|
||||
collector.push(Buffer.from('abcdefgh'))
|
||||
const out = collector.finalize()
|
||||
expect(out.text).toBe('efgh')
|
||||
expect(out.truncated).toBe(true)
|
||||
expect(out.spillPath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('contains cleanup failures while disabling an oversize spill', () => {
|
||||
const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir)
|
||||
collector.push(Buffer.from('aaaa'))
|
||||
collector.push(Buffer.from('bbbb'))
|
||||
const spillPath = collector.readFrom(0).spillPath!
|
||||
|
||||
failNextClose.value = true
|
||||
failNextUnlink.value = true
|
||||
expect(() => { collector.push(Buffer.from('c')) }).not.toThrow()
|
||||
expect(failNextClose.value).toBe(false)
|
||||
expect(failNextUnlink.value).toBe(false)
|
||||
expect(collector.finalize().spillPath).toBeUndefined()
|
||||
unlinkSync(spillPath)
|
||||
})
|
||||
})
|
||||
|
||||
describe('killGroup', () => {
|
||||
it('ignores non-positive pids', () => {
|
||||
expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow()
|
||||
expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('swallows ESRCH for vanished groups', async () => {
|
||||
const running = runBash(spec('true'))
|
||||
await running.done
|
||||
expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('abort edge cases', () => {
|
||||
it('reports a fallback reason for reason-less pre-aborted signals', () => {
|
||||
// Real AbortControllers always set a DOMException reason; signal-like
|
||||
// objects from other libraries may not — the fallback covers them.
|
||||
const bare = {
|
||||
aborted: true,
|
||||
reason: undefined,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
} as unknown as AbortSignal
|
||||
expect(() => runBash(spec('echo hi', { signal: bare })))
|
||||
.toThrow(/aborted before spawn: aborted/)
|
||||
})
|
||||
|
||||
it('reports the terminating signal of an externally self-killed command', async () => {
|
||||
// runBash reports the raw signal; whether it counts as timeout/cancel is the
|
||||
// executor's classification (a self-kill is neither) — see executor.spec.ts.
|
||||
const result = await runBash(spec('kill -TERM $$')).done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
})
|
||||
})
|
||||
|
||||
describe('environment and spill-file hardening', () => {
|
||||
it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => {
|
||||
process.env.DSH_TEST_API_KEY = 'super-secret'
|
||||
process.env.DSH_TEST_TOKEN = 'also-secret'
|
||||
process.env.DSH_TEST_PLAIN = 'visible'
|
||||
try {
|
||||
const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
|
||||
expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
|
||||
} finally {
|
||||
delete process.env.DSH_TEST_API_KEY
|
||||
delete process.env.DSH_TEST_TOKEN
|
||||
delete process.env.DSH_TEST_PLAIN
|
||||
}
|
||||
})
|
||||
|
||||
it('injects only the current trusted DSH environment after scrubbing ambient values', async () => {
|
||||
process.env.DSH_STALE = 'old-value'
|
||||
try {
|
||||
const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
|
||||
dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' },
|
||||
})).done
|
||||
expect(result.stdout.text.trim()).toBe('[absent|1|current-session]')
|
||||
} finally {
|
||||
delete process.env.DSH_STALE
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects DSH variables on the ordinary env channel', () => {
|
||||
expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
|
||||
.toThrow(/DSH_WRONG_CHANNEL.*dshEnv/)
|
||||
})
|
||||
|
||||
it('rejects ordinary variables on the managed env channel', () => {
|
||||
const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment
|
||||
expect(() => runBash(spec('true', { dshEnv: invalid })))
|
||||
.toThrow(/managed bash env.*PATH.*use env/)
|
||||
})
|
||||
|
||||
it('creates spill files with owner-only permissions and random names', async () => {
|
||||
const result = await runBash(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
{ spillDir },
|
||||
).done
|
||||
const path = result.stdout.spillPath!
|
||||
expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
|
||||
const mode = statSync(path).mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
})
|
||||
|
||||
it('defaults spills into a private per-process directory', async () => {
|
||||
const result = await runBash(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
).done
|
||||
const dir = dirname(result.stdout.spillPath!)
|
||||
expect(dir).toMatch(/dsh-bash-/)
|
||||
const mode = statSync(dir).mode & 0o777
|
||||
expect(mode).toBe(0o700)
|
||||
})
|
||||
|
||||
it('killGroup never throws, even for EPERM-style failures', () => {
|
||||
const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw Object.assign(new Error('EPERM'), { code: 'EPERM' })
|
||||
})
|
||||
try {
|
||||
expect(() => { killGroup(12345, 'SIGTERM') }).not.toThrow()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('honors AbortSignal on background-style runs (no timeout)', async () => {
|
||||
const controller = new AbortController()
|
||||
const running = runBash(spec('sleep 60', { signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
6
packages/bash/bash-sandbox/README.i18n.yaml
Normal file
6
packages/bash/bash-sandbox/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
|
||||
README.md: ca77a9c626784b29145712535d69de4afbd3a697
|
||||
README.zh.md: c1a65ead539ef3930d70d27f2b176a5346daded3
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-bash-sandbox
|
||||
|
||||
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.
|
||||
@@ -16,7 +18,7 @@ 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.
|
||||
- **Deployment default, per-call policy.** The DEFAULT mode + workspace root are owned by [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (one home both enforcing families read), not this executor's config; `resolve()` stamps the default onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox Agent Note § Escalation](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
|
||||
- **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 model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
|
||||
- **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/).
|
||||
|
||||
@@ -29,12 +31,12 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: read-only
|
||||
workspaceRoot: !!js process.cwd()
|
||||
workspaceRoot: !!js process.cwd() # fallback for calls without a session cwd
|
||||
- id: bash
|
||||
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); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.
|
||||
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
|
||||
|
||||
|
||||
90
packages/bash/bash-sandbox/README.zh.md
Normal file
90
packages/bash/bash-sandbox/README.zh.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# @deepseek-ai/dsh-bash-sandbox
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
消费 [`@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 保留在内部。
|
||||
|
||||
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用(必须快速失败并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默无约束运行),属于提供方职责;本包只拥有 bash 侧。
|
||||
|
||||
| 模式 | 文件影响 |
|
||||
|---|---|
|
||||
| `read-only`(默认) | 任何位置都不可写(在 `/dev` 中只有 `/dev/null` 节点可写,因此 `>/dev/null` 仍可正常工作) |
|
||||
| `workspace-write` | 只能写入 `workspaceRoot` + `/tmp`(在 bwrap 下为临时目录,在 Landlock 下为宿主 `/tmp`,在 Seatbelt 下为 `/private/tmp` 加每用户临时目录) |
|
||||
| `danger-full-access` | 不作限制;绝不咨询提供方。前台结果携带 `sandbox: { mode, denied: false }`;后台进程句柄不携带沙箱事实。 |
|
||||
|
||||
语义:
|
||||
|
||||
- **拒绝是结果事实。** 如果一次失败运行的 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 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **部署回退,每次调用策略。** [`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/)。
|
||||
|
||||
seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它驱动本包遵守的覆盖。
|
||||
|
||||
```yaml
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: read-only
|
||||
workspaceRoot: !!js process.cwd() # fallback for calls without a session cwd
|
||||
- id: bash
|
||||
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
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布一个执行限制的 `sandboxMode`,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
在 `bash` 可见的请求上增加少量固定 schema;模式切换不增加上下文 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
执行器持续公布相同沙箱能力时,前缀保持稳定。更改这些能力会改变 `bash` schema,可能使从该定义起的复用失效;每会话模式切换不会导致失效。
|
||||
|
||||
### 间接的 Bash 工具结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
在普通有界输出之后,被拒绝的调用会精确追加 `[sandbox: file access denied under <mode> mode]`。当升权可用时,接下来精确追加 `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`。已结算的后台 runner 失败则追加 `[sandbox: the sandbox runner itself failed under <mode> mode — the command did not run; this is a sandbox problem, not a command failure]`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记,并保留到压缩。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
|
||||
|
||||
### 间接的 Bash 工具错误
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误;它由 `dsh-sandbox` 持有](../../sandbox/sandbox/README.md#confinement-error-indirectly)。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
该次调用可见的是有条件错误文本,并保留在历史记录中直到压缩。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
|
||||
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但匹配的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
|
||||
- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。
|
||||
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。
|
||||
@@ -38,6 +38,7 @@
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
* `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 per-call modes.
|
||||
* processes carry `runnerFailed`. 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, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedSandboxMode, 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'
|
||||
@@ -18,31 +19,28 @@ import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } f
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
|
||||
* the default mode and the `workspace-write` boundary root — is NOT here: it
|
||||
* lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one
|
||||
* home both enforcing families read, so bash and fs can never confine to
|
||||
* different roots. The runner choice is likewise the `ctx.sandbox` provider's
|
||||
* config, not this executor's.
|
||||
* the default mode and fallback `workspace-write` root — is NOT here: it lives
|
||||
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
|
||||
* each calling session's mode and cwd for both enforcing families. The runner
|
||||
* choice is likewise the `ctx.sandbox` provider's config, not this executor's.
|
||||
*/
|
||||
export type Config = LocalConfig
|
||||
|
||||
/**
|
||||
* Registers as `ctx.bash` in place of the local executor and requires a
|
||||
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
|
||||
* unchanged. The policy default (mode + workspace root) is the fallback,
|
||||
* while a session override or approved one-shot escalation may select each
|
||||
* call's mode. The prompt does not state the standing mode; `result.sandbox`
|
||||
* reports the mode and enforcement actually used.
|
||||
* unchanged. Tool calls pass the calling session's resolved policy; direct
|
||||
* calls fall back to deployment policy. The prompt does not state the standing
|
||||
* mode; `result.sandbox` reports the mode and enforcement actually used.
|
||||
*/
|
||||
export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
static inject = ['sandbox', 'sandboxPolicy']
|
||||
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
|
||||
|
||||
// No own Config: the sandbox default (mode + workspaceRoot) moved to
|
||||
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
|
||||
// verbatim (the config catalog walks the inherited static).
|
||||
|
||||
private readonly mode: SandboxMode
|
||||
private readonly workspaceRoot: string
|
||||
/**
|
||||
* Per-process confinement facts retained until settlement. Providers may
|
||||
* vary enforcement and diagnostic dialect between overlapping calls, so a
|
||||
@@ -58,11 +56,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, config)
|
||||
// The sandbox default (mode + workspaceRoot) is the one shared policy home
|
||||
// both enforcing families read; injecting sandboxPolicy guarantees it is
|
||||
// constructed first. workspaceRoot arrives already resolved absolute.
|
||||
// The default mode is the capability fact used for schema advertisement;
|
||||
// actual tool executions carry their resolved per-call policy.
|
||||
this.mode = ctx.sandboxPolicy.defaultMode
|
||||
this.workspaceRoot = ctx.sandboxPolicy.workspaceRoot
|
||||
}
|
||||
|
||||
/** The configured default mode — the capability fact the tool layer reads. */
|
||||
@@ -71,24 +67,22 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the effective mode onto the spec — the request's explicit override
|
||||
* (an approved escalation), else this executor's configured default — so
|
||||
* defaulting stays an explicit resolve step and `run()`/`start()` read the
|
||||
* spec, never the config.
|
||||
* Stamp a complete per-call policy onto the spec. Tool calls supply the
|
||||
* calling session's resolved mode and root; lower-level callers fall back to
|
||||
* the deployment policy.
|
||||
*/
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode }
|
||||
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
|
||||
}
|
||||
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
// resolve() always stamps the mode; the cast records that invariant
|
||||
// (mirrors the constructor's config casts).
|
||||
const mode = spec.sandboxMode as SandboxMode
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') {
|
||||
const result = await super.run(spec)
|
||||
return { ...result, sandbox: { mode, denied: false } }
|
||||
}
|
||||
const confined = this.confine(spec.command, mode)
|
||||
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.
|
||||
@@ -99,11 +93,11 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
}
|
||||
|
||||
override start(spec: BashExecSpec): BashProcess {
|
||||
// Same stamped-by-resolve invariant as run().
|
||||
const mode = spec.sandboxMode as SandboxMode
|
||||
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.
|
||||
const confined = this.confine(spec.command, mode)
|
||||
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 })
|
||||
@@ -134,17 +128,17 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
* 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` that `runBash` spawns
|
||||
* 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.
|
||||
*/
|
||||
private confine(command: string, mode: ConfinedSandboxMode): {
|
||||
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], { mode, workspaceRoot: this.workspaceRoot })
|
||||
const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy)
|
||||
return {
|
||||
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
|
||||
enforcement: confined.enforcement,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
/**
|
||||
* Keyless integration of the real provider and executor through public run/start paths. With
|
||||
@@ -42,6 +43,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
@@ -89,7 +91,7 @@ describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.
|
||||
expect(strict.exitCode).not.toBe(0)
|
||||
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
|
||||
expect(retried.exitCode).toBe(0)
|
||||
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
|
||||
|
||||
@@ -9,6 +9,7 @@ import { launcherPath } from 'node-addon-landlock-run'
|
||||
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'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
|
||||
@@ -47,6 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
@@ -94,7 +96,7 @@ describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement throug
|
||||
expect(strict.exitCode).not.toBe(0)
|
||||
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: enforcement })
|
||||
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
|
||||
expect(retried.exitCode).toBe(0)
|
||||
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: enforcement })
|
||||
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
|
||||
|
||||
@@ -12,9 +12,10 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
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 { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
@@ -58,9 +59,10 @@ async function setup(
|
||||
...mode !== undefined ? { mode } : {},
|
||||
...workspaceRoot !== undefined ? { workspaceRoot } : {},
|
||||
})
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
return { ctx, bash, calls }
|
||||
}
|
||||
|
||||
@@ -72,6 +74,10 @@ function runResult(exitCode: number | null, stderr: string): BashRunResult {
|
||||
return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) }
|
||||
}
|
||||
|
||||
function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd())): SandboxExecutionPolicy {
|
||||
return { mode, workspaceRoot }
|
||||
}
|
||||
|
||||
describe('the provider hand-off', () => {
|
||||
it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
@@ -147,30 +153,31 @@ describe('danger-full-access', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-call sandboxMode override (the escalation mechanism)', () => {
|
||||
describe('per-call sandbox policy (the session and escalation carrier)', () => {
|
||||
it('exposes the configured default as the capability fact, and resolve() stamps it', async () => {
|
||||
const { bash } = await setup()
|
||||
expect(bash.sandboxMode).toBe('read-only')
|
||||
expect(bash.resolve({ command: 'true' }).sandboxMode).toBe('read-only')
|
||||
expect(bash.resolve({ command: 'true' }).sandboxPolicy).toEqual(executionPolicy('read-only'))
|
||||
})
|
||||
|
||||
it('an explicit override outranks the default at resolve(), and the wrap policy follows it', async () => {
|
||||
it('an explicit policy outranks the default at resolve(), and the wrap follows its mode and root', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
expect(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }).sandboxMode).toBe('workspace-write')
|
||||
await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
|
||||
const explicit = executionPolicy('workspace-write', '/session/project')
|
||||
expect(bash.resolve({ command: 'true', sandboxPolicy: explicit }).sandboxPolicy).toEqual(explicit)
|
||||
await bash.run(bash.resolve({ command: 'true', sandboxPolicy: explicit }))
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(calls.map(call => call.policy.mode)).toEqual(['workspace-write', 'read-only'])
|
||||
expect(calls.map(call => call.policy)).toEqual([explicit, executionPolicy('read-only')])
|
||||
})
|
||||
|
||||
it('an escalated run reports the mode it ACTUALLY ran under', async () => {
|
||||
const { bash } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
|
||||
const result = await bash.run(bash.resolve({ command: 'true', sandboxPolicy: executionPolicy('workspace-write') }))
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('escalating to danger-full-access bypasses the provider entirely — the grant, not a probe, is the authority there', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'echo free', sandboxMode: 'danger-full-access' }))
|
||||
const result = await bash.run(bash.resolve({ command: 'echo free', sandboxPolicy: executionPolicy('danger-full-access') }))
|
||||
expect(result.stdout.text).toBe('free\n')
|
||||
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
|
||||
expect(calls).toHaveLength(0)
|
||||
@@ -181,7 +188,7 @@ describe('per-call sandboxMode override (the escalation mechanism)', () => {
|
||||
// once — anything keyed off the configured default would misreport the
|
||||
// escalated one at its settle stamp.
|
||||
const { bash } = await setup()
|
||||
const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxMode: 'workspace-write' }))
|
||||
const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxPolicy: executionPolicy('workspace-write') }))
|
||||
const plain = bash.start(bash.resolve({ command: 'true' }))
|
||||
await plain.done
|
||||
await escalated.done
|
||||
@@ -191,7 +198,7 @@ describe('per-call sandboxMode override (the escalation mechanism)', () => {
|
||||
|
||||
it('an escalated danger-full-access background task carries no facts (nothing confined it)', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxMode: 'danger-full-access' }))
|
||||
const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxPolicy: executionPolicy('danger-full-access') }))
|
||||
await task.done
|
||||
expect(task.sandbox).toBeUndefined()
|
||||
expect(task.readOutput().delta).toContain('bg-free')
|
||||
|
||||
@@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
/**
|
||||
* Keyless macOS integration of the real provider and executor through public run/start paths.
|
||||
@@ -41,6 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
@@ -91,7 +93,7 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
|
||||
expect(strict.exitCode).not.toBe(0)
|
||||
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
|
||||
expect(retried.exitCode).toBe(0)
|
||||
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
|
||||
|
||||
6
packages/bash/bash/README.i18n.yaml
Normal file
6
packages/bash/bash/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
|
||||
README.md: d7bf746969f52000fe298b65b995b7c631d8001c
|
||||
README.zh.md: 14476b770397e4ef850c7c867e3058e25085c339
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-bash
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime.
|
||||
|
||||
This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently:
|
||||
@@ -27,11 +29,11 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.
|
||||
|
||||
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
|
||||
|
||||
`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, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. 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).
|
||||
`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).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
49
packages/bash/bash/README.zh.md
Normal file
49
packages/bash/bash/README.zh.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# @deepseek-ai/dsh-bash
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
|
||||
|
||||
本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-bash`(本包) | 接口:抽象服务 + 词汇类型 |
|
||||
| `@deepseek-ai/dsh-bash-local` | 实现:本地子进程 |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | 实现:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 |
|
||||
| `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.bash`、面向模型的工具 schema |
|
||||
|
||||
该拆分与 LLM seam(`LlmService`/`LlmAdapter`)及 agent 工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。
|
||||
|
||||
## 服务 API(`ctx.bash`)
|
||||
|
||||
| 成员 | 语义 |
|
||||
|---|---|
|
||||
| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**(工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止终止都会 resolve 为描述性 `BashRunResult`。 |
|
||||
| `start(spec)` | 后台执行。立即返回不含任务语义的 `BashProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.tasks`。 |
|
||||
| `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 |
|
||||
| `BashProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区边界丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 |
|
||||
| `BashProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 |
|
||||
|
||||
实现会继承 `BashExecutor` 并实现抽象方法。dispose 必须终止每个运行中的进程并等待其退出,详见 HMR 安全测试。
|
||||
|
||||
## 词汇
|
||||
|
||||
`BashExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。
|
||||
|
||||
每会话沙箱模式覆盖词汇(`'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](../../../.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)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-bash` 间接影响;该工具会将执行器输出与沙箱事实转为指引和保留的工具结果 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;请求前缀变更由具名消费方负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有交互式输入词汇**:`stdin` 只会在 spawn 时写入一次并关闭;seam 不提供向运行中任务继续输入的通道,也没有 PTY 会话概念。
|
||||
- **前台超时始终由执行器拥有**:seam 上的调用方拥有 deadline 模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。
|
||||
@@ -28,11 +28,13 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ declare module 'cordis' {
|
||||
* failures settle as `killed` with the error on stderr.
|
||||
* - {@link BashProcess.readOutput} is incremental: consecutive reads never
|
||||
* repeat output. Lossy reads report truncation and available spill files.
|
||||
* - Disposal kills all running background processes and awaits their exit.
|
||||
* - A still-running background process is stopped and awaited when its
|
||||
* owning composition tears down. With the subprocess seam that
|
||||
* boundary is `ctx.subprocess` disposal, so a background process survives
|
||||
* an executor-only reload.
|
||||
*/
|
||||
export abstract class BashExecutor extends Service {
|
||||
constructor(ctx: Context) {
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
/**
|
||||
* Execution types for the bash executor seam. Background task semantics belong
|
||||
* to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles.
|
||||
* to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. The
|
||||
* managed-environment and captured-output vocabulary is owned by the
|
||||
* subprocess seam and re-exported here so bash consumers keep one import
|
||||
* root.
|
||||
* @module dsh-bash/types
|
||||
*/
|
||||
|
||||
import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
|
||||
export const DSH_ENV_PREFIX = 'DSH_' as const
|
||||
|
||||
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
|
||||
export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
|
||||
|
||||
/** Trusted DeepSeek Harness variables for one bash execution. */
|
||||
export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
|
||||
export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess'
|
||||
export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/**
|
||||
* Sandbox facts for one run, present iff a sandboxing executor handled it.
|
||||
@@ -62,21 +60,22 @@ export interface BashExecRequest {
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
* Ordinary environment entries for the command, merged after the credential
|
||||
* scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it
|
||||
* here. Set by in-process plugins (the hooks bridges set
|
||||
* `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool
|
||||
* does not expose it as a parameter.
|
||||
* scrub. Managed facts belong in {@link dshEnv}, which merges after this
|
||||
* map, so an entry here can never displace one. Set by in-process plugins
|
||||
* (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the
|
||||
* model-facing bash tool does not expose it as a parameter.
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/**
|
||||
* Harness-owned `DSH_*` variables for this execution. Executors discard
|
||||
* ambient `DSH_*` entries before merging this snapshot, so an unavailable
|
||||
* current fact cannot inherit a stale value from the harness process, and
|
||||
* reject non-`DSH_*` names supplied through this managed channel.
|
||||
* Harness-owned `DSH_*` variables for this execution (typed to managed
|
||||
* keys). Executors discard ambient `DSH_*` entries before merging this
|
||||
* snapshot last, so an unavailable current fact cannot inherit a stale
|
||||
* value from the harness process and a caller {@link env} entry cannot
|
||||
* displace a managed one.
|
||||
*/
|
||||
dshEnv?: DshEnvironment | undefined
|
||||
/** Explicit per-call sandbox mode override. */
|
||||
sandboxMode?: SandboxMode | undefined
|
||||
/** Fully resolved per-call sandbox policy; sandboxing executors default it. */
|
||||
sandboxPolicy?: SandboxExecutionPolicy | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,25 +98,15 @@ export interface BashExecSpec {
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
* Ordinary environment entries carried through from
|
||||
* {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}.
|
||||
* {@link BashExecRequest.env}; {@link dshEnv} still merges after them.
|
||||
* OPTIONAL on the spec for the same reason as `stdin`: absent means no
|
||||
* ordinary extra environment.
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/** Managed `DSH_*` snapshot; implementations reject ordinary names. */
|
||||
/** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */
|
||||
dshEnv?: DshEnvironment | undefined
|
||||
/** Resolved sandbox mode; ignored by executors that do not confine. */
|
||||
sandboxMode: SandboxMode | undefined
|
||||
}
|
||||
|
||||
/** One captured stream: the (possibly truncated) text plus recovery info. */
|
||||
export interface CollectedOutput {
|
||||
/** Collected text — the TAIL of the stream when truncated. */
|
||||
text: string
|
||||
/** True when bytes were dropped from `text`. */
|
||||
truncated: boolean
|
||||
/** Path to a file holding the COMPLETE stream, when truncated and available. */
|
||||
spillPath?: string
|
||||
/** Resolved sandbox policy; ignored by executors that do not confine. */
|
||||
sandboxPolicy: SandboxExecutionPolicy | undefined
|
||||
}
|
||||
|
||||
/** The outcome of one completed (or killed) foreground run. */
|
||||
@@ -165,8 +154,9 @@ export interface BashProcessRead {
|
||||
|
||||
/**
|
||||
* A background process handle returned by {@link BashExecutor.start}. It is the
|
||||
* only access path; buffered output remains readable after exit. Executor
|
||||
* disposal kills running processes and awaits {@link done}.
|
||||
* only access path; buffered output remains readable after exit. Composition
|
||||
* teardown (the subprocess service's disposal) kills running processes and
|
||||
* awaits {@link done}; an executor-only reload leaves them running.
|
||||
*/
|
||||
export interface BashProcess {
|
||||
/** Process lifecycle state (settled exactly once). */
|
||||
|
||||
@@ -17,7 +17,7 @@ class StubExecutor extends BashExecutor {
|
||||
timeoutMs: request.timeoutMs ?? 1000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
sandboxMode: request.sandboxMode,
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('BashExecutor service seam', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubExecutor)
|
||||
const spec = ctx.bash.resolve({ command: 'echo hi' })
|
||||
expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, stdoutMaxBytes: 64_000, sandboxMode: undefined })
|
||||
expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, stdoutMaxBytes: 64_000, sandboxPolicy: undefined })
|
||||
|
||||
const result = await ctx.bash.run(spec)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
|
||||
6
packages/bash/tool-bash/README.i18n.yaml
Normal file
6
packages/bash/tool-bash/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
|
||||
README.md: 965ae25a5e29a4f767adfcb73e4a77f1060e4b46
|
||||
README.zh.md: 60be5c5ca5624719f5ca651a78b6ba56f3f3df06
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-tool-bash
|
||||
|
||||
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']`).
|
||||
@@ -17,12 +19,12 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
|
||||
| `command` | string (required) | Run via `bash -c`. 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 session cwd. |
|
||||
| `workdir` | string | Working directory for this call. Defaults to the filesystem identity of 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. |
|
||||
| `sandbox_permissions` | string enum | ADVERTISED ONLY when the mounted executor sandboxes (`ctx.bash.sandboxMode` reports a confining default): the wider mode a denied command needs, from the closed target vocabulary `workspace-write`/`danger-full-access` (never cut down to the executor's default — the effective mode is per-session; strict widening is checked at execution against it, and a non-widening request fails without prompting anyone). |
|
||||
| `justification` | string | Required together with `sandbox_permissions` (each without the other is a validation error): one sentence for the user explaining why this exact command needs the wider access. |
|
||||
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. 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()`.
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. 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()`. When sandbox policy is present, the tool reuses its already-canonical `workspaceRoot` as the workdir base so confinement and process launch cannot resolve the same session spelling differently.
|
||||
|
||||
### Managed shell environment
|
||||
|
||||
@@ -49,6 +51,8 @@ The overlay is computed from the current `ToolExecution` and passed through the
|
||||
|
||||
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`.
|
||||
|
||||
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The Native renderer preserves the text above, including exactly `started background task <id>`; programmatic consumers use the typed fields without parsing those strings. Executor stream caps remain acquisition limits on `BashRunResult` and carry their spill paths.
|
||||
|
||||
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 bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
|
||||
|
||||
## UI presentation
|
||||
|
||||
158
packages/bash/tool-bash/README.zh.md
Normal file
158
packages/bash/tool-bash/README.zh.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# @deepseek-ai/dsh-tool-bash
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型侧 `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']`)。
|
||||
|
||||
包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。
|
||||
|
||||
插件还会提供 `tool:bash` 提示词段落(顺序 105):检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。
|
||||
|
||||
## 工具
|
||||
|
||||
### `bash`
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `command` | string(必填) | 通过 `bash -c` 运行。调用之间不保留状态;请使用 `workdir`,不要使用 `cd`。 |
|
||||
| `description` | string(必填) | 用一行主动语态概述命令(5~10 个词),仅用于 UI/日志显示,不影响执行。 |
|
||||
| `timeoutMs` | number | 以毫秒为单位覆盖超时时间。执行器会应用其配置的默认值和上限。 |
|
||||
| `workdir` | string | 本次调用的工作目录。默认为调用方 agent(智能体)会话 cwd 的文件系统标识(`session.header.cwd`),使每个会话都在自己的工作区中运行;相对 `workdir` 也以同一标识为基准解析。 |
|
||||
| `run_in_background` | boolean | 立即返回 task id;不应用超时。 |
|
||||
| `sandbox_permissions` | string enum | 仅当已挂载的执行器启用沙箱时才会公开(`ctx.bash.sandboxMode` 报告一个具有限制作用的默认值):被拒命令所需的更宽模式,取自封闭的目标词汇 `workspace-write`/`danger-full-access`(绝不能缩减为执行器默认值;有效模式按会话确定,执行时会基于它检查是否严格拓宽,未拓宽的请求直接失败,不会向任何人发起提示)。 |
|
||||
| `justification` | string | 必须与 `sandbox_permissions` 一同提供(缺少任一项都会产生验证错误):用一句话向用户解释此命令为何需要这项更宽权限。 |
|
||||
|
||||
执行前,`command`、`workdir` 和 `timeoutMs` 会通过 `ctx.bash.resolve()` 依据执行器配置默认值完成解析,因此执行器 seam(`BashExecSpec`)收到显式的 `workdir`/`timeoutMs` 值。工具层会根据调用方 agent 的 `session.header.cwd` 应用工作目录默认值,然后才调用 `resolve()`:由于 N 个会话共享一个执行器,逐会话 cwd 必须来自 `exec.agent`;只有无法取得会话 cwd 时,执行器才回退到自身配置/`process.cwd()`。存在沙箱策略时,工具会复用已经规范化的 `workspaceRoot` 作为工作目录基准,防止限制逻辑与进程启动过程对同一个会话路径拼写产生不同解析结果。
|
||||
|
||||
### 托管 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_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
|
||||
|
||||
结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。
|
||||
|
||||
已完成前台进程的规范成功值为 `{ kind: 'foreground', ...BashRunResult }`,已发布任务则为 `{ kind: 'background', taskId }`。Native renderer 保留上述文本,包括精确的 `started background task <id>`;程序化消费方使用带类型字段,无需解析这些字符串。执行器的流上限仍是 `BashRunResult` 的采集限制,并携带其 spill 路径。
|
||||
|
||||
当 `run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.tasks.start()`,把调用方 agent 注册为持有者,并将返回的 `BashProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时持有 id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。
|
||||
|
||||
## UI 展示
|
||||
|
||||
工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片,包含命令、说明、cwd、原始输出和解析后的退出状态。后台启动只返回 task id,因此使用通用执行卡片;通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。
|
||||
|
||||
## 工具仅使用具名参数构建请求
|
||||
|
||||
`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)。
|
||||
|
||||
## 权限与升权
|
||||
|
||||
除非启用沙箱的执行器([`dsh-bash-sandbox`](../bash-sandbox/))限制命令,否则命令以执行器的完整权限运行。仅拒绝型沙箱会把拒绝作为结果事实报告,并在此渲染为拒绝标记;逐调用的允许/拒绝/询问策略由 `tools/pre-execute` waterfall(瀑布式事件)负责(参见 docs/architecture.md)。
|
||||
|
||||
需要升权的 bash 调用会在执行前解析 `ctx.approval`。`allowed-once` 只对该次调用应用请求模式;审批被拒、取消、不可用或缺少审批上下文时,命令完全不会执行,并返回不同的错误。发生真实拒绝后,模型可以在同一轮次中使用满足需要的最窄模式和理由重试同一命令一次;审批提示本身就是征求同意的步骤。升权绝不能预先推测,禁用或拒绝审批即为最终结果。其理由由 [沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) 持有。
|
||||
|
||||
## 逐会话模式切换
|
||||
|
||||
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。提示词和切换通知均不公布当前常驻模式;拒绝结果会在边界相关时报告有效模式。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换契约](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 系统提示词
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
此插件注册作用域内的每个请求都包含下方 bash 指引。启用沙箱的执行器不会添加模式声明或切换通知。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。
|
||||
|
||||
##### Bash 指引
|
||||
|
||||
```markdown
|
||||
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
插件活跃期间,每个请求都会产生少量固定输入开销,不受沙箱模式或模式切换影响。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要注册作用域和提示词文本不变,前缀即可稳定复用。插件激活或 dispose 可能从此提示词段落开始使复用失效;沙箱模式切换不会。
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
模型会看到生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。仅当此生产方启用 `run_in_background` 时,该字段才会出现;仅当已挂载执行器声明支持沙箱时,`sandbox_permissions` 和 `justification` 才会出现。Agent 作用域的工具限制可以移除该 agent 的定义。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
工具可见的每个请求都会产生固定 schema 开销;沙箱支持会增加升权字段及其条件说明段落。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要可见性、后台支持和执行器沙箱功能保持不变,前缀即可稳定复用。限制、配置或执行器发生变化时,可能从首个变化的工具定义开始使复用失效。
|
||||
|
||||
### 前台结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` 和 stderr 尾部。没有输出时,它会精确输出 `(no output)`。条件行精确为 `[output truncated; full output: <path-or-(unavailable)>]`、`[sandbox: file access denied under <mode> mode]`、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 和 `[exit code: <exitCode>]`;沙箱升权与 runner 故障行原文列于 [`dsh-bash-sandbox`](../bash-sandbox/README.md)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
调用前结果 token 为零。每条流的输出有界,每个已输出行则会保留在历史中,直至压缩(compaction)。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 后台任务上下文与结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
启动会精确返回 `started background task <taskId>`。此生产方会向通用任务运行时提供增量进程输出、可选的 `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`、沙箱事实,以及 `exit code: <exitCode>` 或 `signal: <signal>` 等终止详情。[`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) 持有模型可见的状态行、完成通知、列表和取消响应。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
启动确认很短并会保留;收集到的输出依数据而定,并受执行器流缓冲区限制。消费式读取不会重复先前输出。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 工具错误
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
验证和策略失败统一为 `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`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
只有失败调用会增加这些保留 token;升权被拒时命令不会运行,因此不会添加命令输出。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill;这是仅影响展示的已知残留问题。
|
||||
- **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。
|
||||
- **后台进程没有执行器超时**:工作不再需要时,调用方必须使用 `task_kill`,或依赖持有者/服务的 dispose。
|
||||
@@ -52,6 +52,7 @@
|
||||
"@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:^",
|
||||
@@ -60,6 +61,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
|
||||
@@ -11,18 +11,19 @@
|
||||
import { Service, type Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
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 { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
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 { DshEnvironment, DshEnvironmentKey } 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 { processOutcome } from './background.ts'
|
||||
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
|
||||
@@ -206,7 +207,7 @@ export class BashEnvRegistry extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Parsed tool args; execute validates value constraints absent from SchemaSpec. */
|
||||
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
|
||||
interface BashToolArgs {
|
||||
command: string
|
||||
description: string
|
||||
@@ -299,11 +300,18 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an explicit workdir first, making a relative one session-cwd-relative;
|
||||
* otherwise use the session cwd and leave executor defaulting as the fallback.
|
||||
* Resolve an explicit workdir first, making a relative one session-workspace-relative;
|
||||
* otherwise use the filesystem identity of the session cwd and leave executor
|
||||
* defaulting as the fallback. A resolved sandbox-policy root wins so workdir
|
||||
* and confinement use the exact same per-call identity.
|
||||
*/
|
||||
function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
|
||||
const sessionCwd = exec.agent?.session.header.cwd
|
||||
function resolveWorkdir(
|
||||
modelWorkdir: string | undefined,
|
||||
exec: { agent?: Agent },
|
||||
policyWorkspaceRoot?: string,
|
||||
): string | undefined {
|
||||
const headerCwd = exec.agent?.session.header.cwd
|
||||
const sessionCwd = policyWorkspaceRoot ?? (headerCwd === undefined ? undefined : canonicalPath(headerCwd))
|
||||
if (modelWorkdir === undefined) return sessionCwd
|
||||
if (sessionCwd !== undefined && !isAbsolute(modelWorkdir)) {
|
||||
return resolvePath(sessionCwd, modelWorkdir)
|
||||
@@ -311,6 +319,38 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
|
||||
return modelWorkdir
|
||||
}
|
||||
|
||||
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
|
||||
function canonicalBashResult(result: BashRunResult) {
|
||||
const output = (stream: BashRunResult['stdout']) => ({
|
||||
text: stream.text,
|
||||
truncated: stream.truncated,
|
||||
...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {},
|
||||
})
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
aborted: result.aborted,
|
||||
timeoutMs: result.timeoutMs,
|
||||
stdout: output(result.stdout),
|
||||
stderr: output(result.stderr),
|
||||
...result.sandbox !== undefined ? {
|
||||
sandbox: {
|
||||
mode: result.sandbox.mode,
|
||||
denied: result.sandbox.denied,
|
||||
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
|
||||
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
|
||||
},
|
||||
} : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical background-handle properties shared by the bash output union. */
|
||||
const BACKGROUND_OUTPUT_PROPERTIES = {
|
||||
kind: { type: 'string', required: true, const: 'background' },
|
||||
taskId: { type: 'string', required: true },
|
||||
} as const
|
||||
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const bashEnv = new BashEnvRegistry(ctx, config)
|
||||
bashEnv.register({
|
||||
@@ -330,9 +370,14 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
const backgroundEnabled = config.enableRunInBackground ?? true
|
||||
const defaultMode = ctx.bash.sandboxMode
|
||||
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
|
||||
const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
|
||||
if (defaultMode !== undefined && sandboxPolicy === undefined) {
|
||||
throw new Error('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing')
|
||||
}
|
||||
|
||||
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
|
||||
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
|
||||
/** Resolve the complete standing policy for this call when a confining executor is mounted. */
|
||||
const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined =>
|
||||
sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
|
||||
|
||||
/**
|
||||
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
|
||||
@@ -342,14 +387,19 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
* guard (the fields are unadvertised without a sandboxing executor, yet
|
||||
* schema validation checks advertised keys only, so an unadvertised
|
||||
* `sandbox_permissions` still reaches execute) and the approval ingredients
|
||||
* — the seam is consumed opportunistically (`ctx.get`) so a deployment
|
||||
* without it degrades per call.
|
||||
* The shared policy resolver is required whenever the executor advertises
|
||||
* confinement, so a split composition fails at tool-plugin load.
|
||||
*/
|
||||
const approveBashEscalation = (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
|
||||
const approveBashEscalation = (
|
||||
mode: string,
|
||||
justification: string,
|
||||
exec: ToolExecution,
|
||||
standingPolicy: SandboxExecutionPolicy | undefined,
|
||||
): Promise<SandboxMode> => {
|
||||
if (escalationModes.length === 0) {
|
||||
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
||||
}
|
||||
const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode
|
||||
const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode
|
||||
return approveEscalation(
|
||||
{ requestedMode: mode, justification, effectiveMode, subject: 'command' },
|
||||
{
|
||||
@@ -398,20 +448,83 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: BACKGROUND_OUTPUT_PROPERTIES,
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'foreground' },
|
||||
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
||||
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
timedOut: { type: 'boolean', required: true },
|
||||
aborted: { type: 'boolean', required: true },
|
||||
timeoutMs: { type: 'number', required: true },
|
||||
stdout: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
text: { type: 'string', required: true },
|
||||
truncated: { type: 'boolean', required: true },
|
||||
spillPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
text: { type: 'string', required: true },
|
||||
truncated: { type: 'boolean', required: true },
|
||||
spillPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
sandbox: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
mode: { type: 'string', required: true },
|
||||
denied: { type: 'boolean', required: true },
|
||||
enforcement: { type: 'string' },
|
||||
runnerFailed: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: value.kind === 'background'
|
||||
? `started background task ${value.taskId}`
|
||||
: renderResult(value as { kind: 'foreground' } & BashRunResult, escalationModes),
|
||||
}],
|
||||
},
|
||||
async execute(args: BashToolArgs, exec) {
|
||||
validateBashArgs(args)
|
||||
// Description is display metadata; workdir defaults to the caller's session.
|
||||
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
||||
? await approveBashEscalation(args.sandbox_permissions, args.justification, exec)
|
||||
: sessionOverride(exec)
|
||||
const workdir = resolveWorkdir(args.workdir, exec)
|
||||
const standingPolicy = resolveSandboxPolicy(exec)
|
||||
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
||||
? await approveBashEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
|
||||
: undefined
|
||||
const policy = approvedMode === undefined
|
||||
? standingPolicy
|
||||
: { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode }
|
||||
const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot)
|
||||
const dshEnv = bashEnv.collect(exec)
|
||||
const request = {
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
dshEnv,
|
||||
...sandboxMode !== undefined ? { sandboxMode } : {},
|
||||
...policy !== undefined ? { sandboxPolicy: policy } : {},
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// Undeclared keys are allowed, so schema omission also needs enforcement.
|
||||
@@ -422,8 +535,12 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
if (tasks === undefined) {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// The caller owns cancellation until TaskService commits detached ownership.
|
||||
if (exec.signal.aborted) return []
|
||||
// The caller owns cancellation until ctx.tasks commits detached ownership.
|
||||
if (exec.signal.aborted) {
|
||||
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
// Task preflight finishes before the starter can spawn a process.
|
||||
const id = tasks.start({
|
||||
kind: 'bash',
|
||||
@@ -438,14 +555,14 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
}
|
||||
},
|
||||
})
|
||||
return [{ type: 'text', text: `started background task ${id}` }]
|
||||
return { kind: 'background' as const, taskId: id }
|
||||
}
|
||||
const result = await ctx.bash.run(ctx.bash.resolve({
|
||||
...request,
|
||||
signal: exec.signal,
|
||||
}))
|
||||
if (result.aborted) throw new Error('command aborted')
|
||||
return [{ type: 'text', text: renderResult(result, escalationModes) }]
|
||||
return { kind: 'foreground' as const, ...canonicalBashResult(result) }
|
||||
},
|
||||
presentCall: presentBashCall,
|
||||
presentResult: presentBashResult,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
@@ -8,9 +9,10 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
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 { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -27,8 +29,9 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot, compression: 'none' })
|
||||
}
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -71,7 +74,7 @@ function findEvent<T extends SessionEvent['type']>(
|
||||
|
||||
function resultText(event: SessionEvent): string {
|
||||
if (event.type !== 'tool/result') return ''
|
||||
return event.data.content
|
||||
return event.data.message.content[0].content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
@@ -109,11 +112,12 @@ describe('bash tool through the agent loop', () => {
|
||||
const location = ctx.sessionPersistence.locate(agent.session.header)
|
||||
expect(location?.kind).toBe('jsonl')
|
||||
|
||||
agent.send([{ type: 'text', text: 'inspect the current session' }])
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the current session' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const result = findEvent(events(agent), 'tool/result')
|
||||
expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`)
|
||||
await ctx.sessions.flush(agent.session)
|
||||
expect(existsSync(location!.path)).toBe(true)
|
||||
const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string }
|
||||
expect(header).toMatchObject({ type: 'session', id: 'session-env-id' })
|
||||
@@ -128,7 +132,7 @@ describe('bash tool through the agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'run echo integration-ok' }])
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run echo integration-ok' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
@@ -136,7 +140,7 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(toolCall.data.name).toBe('bash')
|
||||
|
||||
const toolResult = findEvent(log, 'tool/result')
|
||||
expect(toolResult.data.isError).toBe(false)
|
||||
expect(toolResult.data.message.content[0].isError).toBe(false)
|
||||
expect(resultText(toolResult)).toBe('integration-ok\n')
|
||||
|
||||
// The second model call saw the tool result in its derived history.
|
||||
@@ -147,7 +151,7 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(toolResultBlocks).toHaveLength(1)
|
||||
|
||||
const finalMessage = findEvent(log, 'assistant/message', 'last')
|
||||
expect(finalMessage.data.content.some(
|
||||
expect(finalMessage.data.message.content.some(
|
||||
block => block.type === 'text' && block.text.includes('integration-ok'),
|
||||
)).toBe(true)
|
||||
})
|
||||
@@ -160,16 +164,16 @@ describe('bash tool through the agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'run exit 9' }])
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run exit 9' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const toolResult = findEvent(events(agent), 'tool/result')
|
||||
expect(toolResult.data.isError).toBe(false)
|
||||
expect(toolResult.data.message.content[0].isError).toBe(false)
|
||||
expect(resultText(toolResult)).toContain('[exit code: 9]')
|
||||
})
|
||||
|
||||
it('background: start ack → completion notice as context/message → task_output collects it', async () => {
|
||||
// The task id is deterministic (a fresh TaskService counts per kind from 1),
|
||||
it('background: start ack → completion notice as user/message → 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([
|
||||
toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),
|
||||
@@ -180,28 +184,30 @@ describe('bash tool through the agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run echo bg-ok in the background' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const firstResult = findEvent(events(agent), 'tool/result')
|
||||
expect(firstResult.data.isError).toBe(false)
|
||||
expect(firstResult.data.message.content[0].isError).toBe(false)
|
||||
expect(resultText(firstResult)).toBe('started background task bash-1')
|
||||
|
||||
// The task settles on its own; the tool-tasks notice listener injects a
|
||||
// durable context/message into the owning agent's session (settlement may
|
||||
// race turn end, so poll for it).
|
||||
await pollUntil(() => events(agent).some(event => event.type === 'context/message'))
|
||||
const notice = findEvent(events(agent), 'context/message')
|
||||
// durable plugin-sourced user/message into the owning agent's session
|
||||
// (settlement may race turn end, so poll for it).
|
||||
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(
|
||||
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' })
|
||||
|
||||
// The next turn collects the output through the generic task tool.
|
||||
agent.send([{ type: 'text', text: 'collect it' }])
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const readResult = findEvent(events(agent), 'tool/result', 'last')
|
||||
expect(readResult.data.isError).toBe(false)
|
||||
expect(readResult.data.message.content[0].isError).toBe(false)
|
||||
expect(resultText(readResult)).toContain('bg-ok')
|
||||
expect(resultText(readResult)).toContain('[status: completed, exit code: 0]')
|
||||
})
|
||||
|
||||
@@ -12,11 +12,13 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
|
||||
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 { processOutcome } from '../src/background.ts'
|
||||
import { renderProcessRead, renderResult } from '../src/render.ts'
|
||||
@@ -31,8 +33,9 @@ async function setup() {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
}
|
||||
@@ -43,10 +46,11 @@ async function setupWithTasks() {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
}
|
||||
@@ -107,12 +111,12 @@ class RecordingSandboxExecutor extends BashExecutor {
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
timeoutMs: request.timeoutMs ?? 1000,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
sandboxMode: request.sandboxMode ?? 'read-only',
|
||||
sandboxPolicy: request.sandboxPolicy ?? { mode: 'read-only', workspaceRoot: process.cwd() },
|
||||
}
|
||||
}
|
||||
|
||||
run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
this.modes.push(spec.sandboxMode)
|
||||
this.modes.push(spec.sandboxPolicy?.mode)
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
@@ -121,18 +125,24 @@ class RecordingSandboxExecutor extends BashExecutor {
|
||||
timeoutMs: spec.timeoutMs,
|
||||
stdout: { text: 'ok', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false },
|
||||
sandbox: {
|
||||
mode: spec.sandboxPolicy?.mode ?? 'read-only',
|
||||
denied: false,
|
||||
...spec.command === 'without optional sandbox facts'
|
||||
? {}
|
||||
: { enforcement: 'full' as const, runnerFailed: false },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
this.modes.push(spec.sandboxMode)
|
||||
this.modes.push(spec.sandboxPolicy?.mode)
|
||||
return {
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false },
|
||||
sandbox: { mode: spec.sandboxPolicy?.mode ?? 'read-only', denied: false },
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => false,
|
||||
}
|
||||
@@ -149,7 +159,7 @@ class CountingStartExecutor extends BashExecutor {
|
||||
workdir: request.workdir ?? '/x',
|
||||
timeoutMs: request.timeoutMs ?? 0,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
sandboxMode: request.sandboxMode,
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,8 +183,9 @@ async function setupSandboxed(withApproval = false) {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(SandboxPolicyService, {})
|
||||
await ctx.plugin(RecordingSandboxExecutor)
|
||||
if (withApproval) await ctx.plugin(ApprovalService)
|
||||
await ctx.plugin(ToolBash)
|
||||
@@ -211,6 +222,16 @@ describe('bash tool', () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'bash', { command: 'echo hello', description: 'test command' })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected bash success')
|
||||
expect(result.value).toMatchObject({
|
||||
kind: 'foreground',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
stdout: { text: 'hello\n', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
})
|
||||
expect(text(result)).toBe('hello\n')
|
||||
})
|
||||
|
||||
@@ -257,8 +278,9 @@ describe('bash tool', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
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: ')
|
||||
@@ -294,7 +316,7 @@ describe('bash tool', () => {
|
||||
})
|
||||
|
||||
// Type and required-key violations are rejected by the harness
|
||||
// (defineTool validates against the SchemaSpec — the arg-validation Agent Note) before execute.
|
||||
// (defineTool validates against the ParameterSchemaSpec — the arg-validation Agent Note) before execute.
|
||||
it.each([
|
||||
[{}, /missing required property "command"/],
|
||||
[{ command: 42, description: 'd' }, /"command" must be a string/],
|
||||
@@ -310,7 +332,7 @@ describe('bash tool', () => {
|
||||
expect(text(result)).toMatch(pattern)
|
||||
})
|
||||
|
||||
// Value constraints the SchemaSpec can't express stay in the tool body.
|
||||
// Value constraints the ParameterSchemaSpec can't express stay in the tool body.
|
||||
it.each([
|
||||
[{ command: ' ', description: 'd' }, /invalid command/],
|
||||
[{ command: 'x', description: ' ' }, /invalid description/],
|
||||
@@ -365,6 +387,7 @@ describe('bash tool', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
const fiber = await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
@@ -382,6 +405,7 @@ describe('bash tool', () => {
|
||||
// inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
|
||||
await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
@@ -393,6 +417,7 @@ describe('bash tool', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
ToolBash.apply(ctx, {})
|
||||
const schema = ctx.tools.schemas()[0]!
|
||||
@@ -406,6 +431,8 @@ describe('background execution through the task runtime', () => {
|
||||
const ctx = await setupWithTasks()
|
||||
const started = await call(ctx, 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true })
|
||||
expect(started.isError).toBe(false)
|
||||
if (started.isError) throw new Error('expected background bash success')
|
||||
expect(started.value).toEqual({ kind: 'background', taskId: 'bash-1' })
|
||||
expect(text(started)).toBe('started background task bash-1')
|
||||
|
||||
const read = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, 'bg-ok')
|
||||
@@ -452,7 +479,7 @@ describe('background execution through the task runtime', () => {
|
||||
})
|
||||
|
||||
it('fails loud when the task runtime is not loaded', async () => {
|
||||
const ctx = await setup() // no TaskService / ToolTasks
|
||||
const ctx = await setup() // no LocalTaskService / ToolTasks
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
@@ -463,7 +490,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(CountingStartExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
@@ -477,7 +504,10 @@ describe('background execution through the task runtime', () => {
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
|
||||
expect(result.error).toEqual({
|
||||
message: 'tool call aborted before dispatch',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
})
|
||||
expect(text(result)).toBe('Error: tool call aborted before dispatch')
|
||||
expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
|
||||
})
|
||||
@@ -488,7 +518,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(CountingStartExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
@@ -503,6 +533,7 @@ describe('background execution through the task runtime', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(ToolBash, { enableRunInBackground: false })
|
||||
|
||||
@@ -532,6 +563,14 @@ describe('sandbox escalation through the generic task producer', () => {
|
||||
justification: 'the command needs workspace writes',
|
||||
}
|
||||
|
||||
it('fails load when a confining executor has no shared sandbox-policy resolver', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(RecordingSandboxExecutor)
|
||||
await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing')
|
||||
})
|
||||
|
||||
it('advertises the sandbox fields and validates their pairing', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
const schema = ctx.tools.schemas().find(item => item.name === 'bash')!
|
||||
@@ -623,7 +662,10 @@ describe('sandbox escalation through the generic task producer', () => {
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED })
|
||||
expect(result.error).toEqual({
|
||||
message: 'tool call aborted',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED },
|
||||
})
|
||||
expect(text(result)).toBe('Error: tool call aborted')
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -637,6 +679,22 @@ describe('sandbox escalation through the generic task producer', () => {
|
||||
expect(bash.modes).toEqual(['workspace-write', 'danger-full-access'])
|
||||
})
|
||||
|
||||
it('omits sandbox facts the executor did not acquire from the canonical result', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
const result = await call(ctx, 'bash', {
|
||||
command: 'without optional sandbox facts',
|
||||
description: 'exercise optional sandbox facts',
|
||||
})
|
||||
|
||||
if (result.isError) throw new Error('expected foreground bash success')
|
||||
expect(result.value).toMatchObject({
|
||||
kind: 'foreground',
|
||||
sandbox: { mode: 'read-only', denied: false },
|
||||
})
|
||||
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement')
|
||||
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed')
|
||||
})
|
||||
|
||||
it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => {
|
||||
const { ctx } = await setupSandboxed(true)
|
||||
ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)
|
||||
@@ -993,7 +1051,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
sandboxMode: request.sandboxMode,
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
run(): Promise<BashRunResult> {
|
||||
@@ -1023,7 +1081,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: join(spillDir, 'jsonl') })
|
||||
}
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(RecordingBashExecutor)
|
||||
await ctx.plugin(ToolBash, { dshHome: recordingDshHome })
|
||||
|
||||
@@ -1,42 +1,62 @@
|
||||
# AGENTS.md — Web client stack
|
||||
|
||||
Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md); read the two architecture notes linked below before structural changes.
|
||||
Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md). Before touching slots, component props, stores, or plugin structure, read the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) (the definitive composition model) and the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) (loading chain, object layer, services).
|
||||
|
||||
Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<name>`.
|
||||
|
||||
## Slot and props discipline
|
||||
|
||||
The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) owns the full design; these are the rules you must not violate when writing or reviewing client code:
|
||||
|
||||
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
|
||||
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
|
||||
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
|
||||
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
|
||||
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
|
||||
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
|
||||
|
||||
## Reactive read and contract-currency discipline
|
||||
|
||||
How live data reaches render code, and what may cross a business boundary:
|
||||
|
||||
1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
|
||||
2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
|
||||
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration.
|
||||
4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively).
|
||||
5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
|
||||
6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
|
||||
|
||||
## Export discipline (client plugin packages)
|
||||
|
||||
The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments):
|
||||
|
||||
1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Types are the extra allowance: contract types (owner shares, injected shapes, composed props aliases) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
|
||||
2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile.
|
||||
3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself.
|
||||
|
||||
## ctx discipline (components never see ctx)
|
||||
|
||||
`ctx` belongs to the apply world only: the plugin body and the inject factories closed over it. Components — every `.tsx` under a feature domain — receive all data and callbacks **through the four props shares**; they never call a hook that reaches ctx, never import a service class to poke it, never read a React context (business components see zero contexts — `BindingContext` and its kin are renderer-internal). If a component needs something new, the answer is a prop threaded from its share's source (owner site, store declaration, or inject face), not a hook.
|
||||
|
||||
## Layering red lines
|
||||
|
||||
The stack is three layers with one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
|
||||
The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
|
||||
|
||||
1. **Data object layer** (`web-runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine). Zero React imports — grep-assertable.
|
||||
2. **Hooks layer** (`web-ui/src/hooks`, pure data): subscribes to object snapshots via `useSyncExternalStore`, exposes plain-data handles. No JSX, no DOM.
|
||||
3. **Presentation components** (`web-ui`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; they receive data and callbacks through props only.
|
||||
1. **Data object layer** (`runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable.
|
||||
2. **Render machinery** (`web-react`, shell-only glue): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all.
|
||||
3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares.
|
||||
|
||||
Non-negotiables across the layers:
|
||||
|
||||
- **No business objects in the store.** zustand carries cross-view presentation state only (`rpcLog`, `ui`, `connection` slices). Sessions, frames, and connections live in the object layer. View-local facts (selection, expansion) stay in component state, not the store.
|
||||
- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
|
||||
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
|
||||
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `web-runtime/src/session/notifier.ts`.
|
||||
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `runtime/src/client/sessions/notifier.ts`.
|
||||
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
|
||||
|
||||
## Directory regime (`web-ui/src`)
|
||||
## Directory regime (plugin packages)
|
||||
|
||||
> Shell restructure in progress: the tree is converging to this layout (today's `components/{conversation,sessions,panels}` migrate into it); the regime below is the target every new feature follows now.
|
||||
|
||||
Two-level feature directories, one contributor per directory — physical conflict avoidance:
|
||||
|
||||
```
|
||||
web-ui/src/
|
||||
shell/ # AppShell + the three slot registries + builtins
|
||||
leftmenu/<bar>/ # one directory per left-nav bar (sessions, rpclog, …)
|
||||
sessiontabs/<tab>/ # one directory per session tab (conversation, gantt, …)
|
||||
components/ # shared leaves (MessageText, JsonBlock, …)
|
||||
hooks/ utils/ style/ # cross-cutting; not feature-owned
|
||||
```
|
||||
|
||||
- `leftmenu/<a>` must not import `leftmenu/<b>` or `sessiontabs/*` (and vice versa). Anything two features need sinks into `components/`.
|
||||
- Bars, tabs, and detail blocks register through the `shell/` registries (module-level map, `register*()` returns the disposer — same shape as `toolCardRegistry`). v1 registration is static in `shell/builtins.ts`; plugin-driven registration later calls the same functions.
|
||||
- **Claiming a placeholder slot**: pick a `placeholder: true` tab (or add a bar) in `shell/builtins.ts`, create your feature directory, and replace the placeholder component with your container. Don't build features outside this regime.
|
||||
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
|
||||
|
||||
## Styling
|
||||
|
||||
@@ -56,16 +76,26 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy
|
||||
Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
|
||||
|
||||
1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.
|
||||
2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`).
|
||||
2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=refresh` rewrites their aria goldens after an intentional conversation-UI change; `DSH_SNAPSHOT=record` re-records fixtures with a key).
|
||||
3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit.
|
||||
|
||||
If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.
|
||||
|
||||
## New plugin package checklist
|
||||
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
|
||||
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
|
||||
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
|
||||
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
|
||||
|
||||
## New component checklist
|
||||
|
||||
1. Claim the slot (see the directory regime above): one feature, one directory.
|
||||
2. Build the container in your feature directory; keep leaves pure-props. Wire data through the hooks layer, not by importing business objects into components.
|
||||
3. Copy a neighbouring jsdom spec into `web-ui/tests/`, keep it behavior-shaped: start from the happy path and the edge states, then widen until the component's branches are covered — the coverage gate applies; only the assertion style stays behavior-level.
|
||||
1. Compose through register: merge the slot contract into `SlotMap`, declare the slot in its parent entry's `children`, register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists.
|
||||
2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
|
||||
3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery.
|
||||
4. Tokens only in CSS; Chinese product copy; English comments.
|
||||
5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface).
|
||||
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the three GUI notes above are the precedents to extend.
|
||||
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.
|
||||
|
||||
6
packages/client/connection/README.i18n.yaml
Normal file
6
packages/client/connection/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
|
||||
README.md: 80228a180faba0c556ff720e999b29b5bb1635b6
|
||||
README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819
|
||||
@@ -1,6 +1,12 @@
|
||||
# @deepseek-ai/dsh-client-connection
|
||||
|
||||
Wire consumer layer (moved verbatim from web-runtime): IApiClient family (WebApiClient/FixtureApiClient), ConnectionController (SSE dual-stream + backoff reconnect), WEB_EVENTS. Contract: api-contracts v3 §3, export inventory in §3.2.
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
|
||||
## Keyless fixture
|
||||
|
||||
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
22
packages/client/connection/README.zh.md
Normal file
22
packages/client/connection/README.zh.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# @deepseek-ai/dsh-client-connection
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
|
||||
## 无密钥 fixture
|
||||
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。协议消费层只在浏览器与主机之间搬运已经组合好的消息;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **history 的隐式恢复存在争议**:在未附加的会话上打开 history,会在主机侧拉起 agent;纯持久化读取的替代方案记录在 rt-core 协调账本中,P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。
|
||||
- **计划移除 `ToolEventView`/`ToolCallView`/`ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时,它们会一并移除(呈现属于客户端);在此之前,fixture 保留一份局部 `viewFor` 镜像。
|
||||
@@ -30,6 +30,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
@@ -43,10 +44,12 @@
|
||||
"src"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
8
packages/client/connection/src/api-path.ts
Normal file
8
packages/client/connection/src/api-path.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The /api URL prefix — single source for both halves of the web transport.
|
||||
* The node half registers this prefix on the web server; browser-side path
|
||||
* literals currently live in the apiproxy client layer (out of scope here).
|
||||
*/
|
||||
|
||||
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
|
||||
export const API_PATH = '/api'
|
||||
@@ -8,14 +8,21 @@
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
GoalsApi, GoalView, GoalRef, GoalPhase, GoalBlockReason,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
// transportError moved down to the apiproxy api layer (it belongs beside
|
||||
// RpcResult, its subject); re-exported here so connection consumers keep one
|
||||
// contract entry point.
|
||||
export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -32,16 +39,3 @@ import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export function resultOf<T>(response: RpcResponse<T>): RpcResult<T> {
|
||||
return response.result
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a transport exception into the RpcResult error branch (unified error
|
||||
* surface; 'internal' as the catch-all code).
|
||||
* @param error - the thrown value from the carrier.
|
||||
* @returns the error branch of an RpcResult.
|
||||
*/
|
||||
export function transportError<T>(error: unknown): RpcResult<T> {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,34 @@
|
||||
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
|
||||
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
|
||||
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
|
||||
// prompt triggers a chunked streaming replay; cancel stops the replay; one resident pending
|
||||
// approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse).
|
||||
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
|
||||
// approval/question requests exercise replay and composer takeover with stable rpcIds.
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
createAssistantMessage,
|
||||
createToolResultMessage,
|
||||
createUserMessage,
|
||||
} from '@deepseek-ai/dsh-llm/message'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
ContentBlock,
|
||||
MessageSource,
|
||||
ToolResultMessage,
|
||||
UserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
TodoItem,
|
||||
} from '@deepseek-ai/dsh-session/types'
|
||||
// Type-only: the brand constructor is host-side; the fixture casts at its
|
||||
// wire-fabrication boundary (the schema layer's one-cast-point posture).
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
||||
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
ToolCallView, ToolEventView, ToolResultView, GoalView,
|
||||
ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { AbstractApiClient, RpcId } from './api.ts'
|
||||
@@ -24,6 +43,62 @@ function text(t: string): ContentBlock[] {
|
||||
return [{ type: 'text', text: t }]
|
||||
}
|
||||
|
||||
function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'user' }): UserMessage {
|
||||
return createUserMessage({ content, source })
|
||||
}
|
||||
|
||||
function assistantMessage(content: ContentBlock[]): AssistantMessage {
|
||||
return createAssistantMessage({
|
||||
content,
|
||||
source: { provider: 'fixture', model: 'fx-1' },
|
||||
})
|
||||
}
|
||||
|
||||
function toolResultMessage(callId: string, content: ContentBlock[], isError: boolean): ToolResultMessage {
|
||||
return createToolResultMessage({ callId: CallId(callId), content, isError })
|
||||
}
|
||||
|
||||
const MARKDOWN_FIXTURE = [
|
||||
'# Markdown fixture',
|
||||
'',
|
||||
'Assistant output renders **strong text**, *emphasis*, and `inline code`.',
|
||||
'',
|
||||
'- first item',
|
||||
' - nested item',
|
||||
'',
|
||||
'| Surface | State |',
|
||||
'| --- | --- |',
|
||||
'| history | rendered |',
|
||||
'| streaming | stable |',
|
||||
'',
|
||||
'[DeepSeek](https://www.deepseek.com)',
|
||||
'',
|
||||
'```ts',
|
||||
'const markdown = true',
|
||||
'```',
|
||||
].join('\n')
|
||||
|
||||
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
|
||||
|
||||
const DEEPSEEK_REASONING = {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
{ id: 'high', name: 'High' },
|
||||
{ id: 'max', name: 'Max' },
|
||||
],
|
||||
defaultEffort: 'high',
|
||||
}
|
||||
|
||||
const OPENAI_REASONING = {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
{ id: 'medium', name: 'Medium' },
|
||||
{ id: 'high', name: 'High' },
|
||||
{ id: 'max', name: 'Max' },
|
||||
],
|
||||
defaultEffort: 'medium',
|
||||
}
|
||||
|
||||
function sid(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
@@ -40,54 +115,124 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
}
|
||||
for (let turn = 0; turn < 60; turn++) {
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } })
|
||||
const userSeq = push({
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: userMessage(text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`)),
|
||||
})
|
||||
if (turn === 0) {
|
||||
push({
|
||||
type: 'session/title',
|
||||
data: { title: 'Fixture 历史会话', messageSeqs: [userSeq], source: { kind: 'fallback' } },
|
||||
})
|
||||
}
|
||||
if (turn % 9 === 4) {
|
||||
push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`[fixture] 上下文注入(turn ${turn})`), { kind: 'plugin', plugin: 'fixture' }) })
|
||||
}
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
const withTool = turn % 5 === 2
|
||||
const withReasoning = turn % 3 === 1
|
||||
const blocks: ContentBlock[] = []
|
||||
if (withReasoning) blocks.push({ type: 'reasoning', text: `思考过程 ${turn}:这是一段可折叠的 reasoning 内容。` })
|
||||
blocks.push({ type: 'text', text: `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
|
||||
blocks.push({ type: 'text', text: turn === 59 ? MARKDOWN_FIXTURE : `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
|
||||
if (withTool) {
|
||||
const callId = `fx-call-${turn}`
|
||||
blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock)
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } })
|
||||
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } })
|
||||
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(`ECHO: TURN ${turn}`), isError: turn % 25 === 12 } })
|
||||
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(`ECHO: TURN ${turn}`), turn % 25 === 12) } })
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'step/start', data: { turn, step: 1 } })
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 1, content: text(`工具结果已消化(turn ${turn})。`), provenance: { provider: 'fixture', model: 'fx-1' } } })
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 1, message: assistantMessage(text(`工具结果已消化(turn ${turn})。`)) } })
|
||||
push({ type: 'step/end', data: { turn, step: 1 } })
|
||||
} else {
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } })
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
}
|
||||
if (turn % 13 === 6) {
|
||||
push({ type: 'steering/message', surfaceOp: 'append', data: { turn, content: text(`插话 ${turn}:fixture steering 消息。`), source: { kind: 'user' } } })
|
||||
push({ type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(text(`插话 ${turn}:fixture steering 消息。`)) } })
|
||||
}
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Three view-sample turns (60-62) for the tool-card wire acceptance: one per built-in card
|
||||
// type. `echo` above stays presenter-less on purpose — it is the no-view fallback sample.
|
||||
// Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
|
||||
// turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above
|
||||
// stays presenter-less as the unknown fallback.
|
||||
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
|
||||
const callId = `fx-call-${turn}`
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:${name} 样本。`), source: { kind: 'user' } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
type: 'assistant/message', surfaceOp: 'append',
|
||||
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
|
||||
data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock]) },
|
||||
})
|
||||
push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } })
|
||||
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(resultText), isError: false } })
|
||||
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(resultText), false) } })
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
|
||||
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
|
||||
toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录')
|
||||
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
|
||||
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
|
||||
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
|
||||
// Mode acceptance surface (parent code row + nested native-identical rows,
|
||||
// including an isError sub-call and a bash sub-call that must hit the same
|
||||
// keyed registration a top-level bash row uses).
|
||||
{
|
||||
const turn = 64
|
||||
const callId = `fx-call-${turn}`
|
||||
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
|
||||
+ 'const demo = await tools.read({ path: "notes/demo.txt" })\n'
|
||||
+ 'await tools.read({ path: "notes/missing.txt" }).catch(() => "tolerated")\n'
|
||||
+ 'return { listing, demo }'
|
||||
const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:run_code 样本。`)) })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
type: 'assistant/message', surfaceOp: 'append',
|
||||
data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock]) },
|
||||
})
|
||||
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'run_code', arguments: args } })
|
||||
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
|
||||
push({
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
|
||||
})
|
||||
push({
|
||||
type: 'tool/code-dispatch',
|
||||
data: {
|
||||
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
|
||||
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
|
||||
},
|
||||
})
|
||||
}
|
||||
dispatchPair(1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt\nnew-demo.txt')
|
||||
dispatchPair(2, 'read', { path: 'notes/demo.txt' }, 'hello fixture\n')
|
||||
dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
|
||||
push({
|
||||
type: 'tool/result', surfaceOp: 'append',
|
||||
data: { turn, step: 0, message: toolResultMessage(callId, text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), false) },
|
||||
})
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Turn 65: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip.
|
||||
const fixtureTodos = [
|
||||
{ content: '梳理需求', status: 'completed' },
|
||||
{ content: '实现 fixture 样本', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
const callIndex = events.length - 4
|
||||
const callTime = events[callIndex]?.time as number
|
||||
events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } })
|
||||
events.forEach((e, i) => { e.seq = i })
|
||||
return events as unknown as SessionEvent[]
|
||||
}
|
||||
|
||||
@@ -112,8 +257,10 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
card: 'diff', title: `Write ${str(args.path)}`,
|
||||
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
|
||||
}
|
||||
case 'fx-note':
|
||||
return { card: 'generic', title: '记录笔记', kind: 'edit', rawInput: args }
|
||||
case 'edit':
|
||||
return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
|
||||
case 'write':
|
||||
return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
|
||||
default:
|
||||
return undefined // echo et al: the documented no-view fallback path
|
||||
}
|
||||
@@ -139,13 +286,13 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
|
||||
return view === undefined ? undefined : { for: 'call', view }
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
const callId = String(event.data.callId)
|
||||
const callId = String(event.data.message.source.callId)
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const candidate = log[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within [0, log.length),
|
||||
so the undefined arm needs a sparse log no code path builds. */
|
||||
if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
|
||||
const resultText = event.data.content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
const view = presentResult(candidate.data.name, candidate.data.arguments, resultText)
|
||||
return view === undefined ? undefined : { for: 'result', view }
|
||||
}
|
||||
@@ -155,6 +302,40 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Fixture parallel of the host's projection units: whole current values per key over the full log. */
|
||||
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {}
|
||||
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
|
||||
if (titleEvent !== undefined) {
|
||||
values['title'] = (titleEvent as unknown as { data: { title: string } }).data.title
|
||||
}
|
||||
// Always present (tool-todo unit composed): null when no plan stands.
|
||||
values['todos'] = backscanTodos(log) ?? null
|
||||
return values
|
||||
}
|
||||
|
||||
/** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
|
||||
function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
|
||||
const type = (event as { type: string }).type
|
||||
if (type === 'session/title') {
|
||||
const values = projectionValuesOf(log)
|
||||
/* v8 ignore next -- the advancing title event is in the log, so the key is present. */
|
||||
if (!Object.hasOwn(values, 'title')) return []
|
||||
return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
|
||||
}
|
||||
// Standing-plan fold: writes replace the list; turn/start clears it (null).
|
||||
if (type === 'todo/write' || type === 'turn/start') {
|
||||
return [{
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
key: 'todos',
|
||||
value: backscanTodos(log) ?? null,
|
||||
seq: event.seq,
|
||||
}]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Message-boundary paging (mirrors the host's paging contract): count
|
||||
* maxMessages messages
|
||||
@@ -186,10 +367,38 @@ function pageOf(
|
||||
return { events, hasMore: start > 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Current plan projection over the full log (host parallel: latest todo/write
|
||||
* with no later turn/start; a new turn retires the previous plan).
|
||||
*/
|
||||
function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined {
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const event = log[i]
|
||||
if (event === undefined) continue
|
||||
if (event.type === 'turn/start') return undefined
|
||||
if (event.type === 'todo/write') return event.data.todos
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface StreamConn<F> {
|
||||
push(envelope: RpcRequest<F>): void
|
||||
}
|
||||
|
||||
/** Deterministic fixture branches used by keyless Web assembly tests. */
|
||||
export interface FixtureOptions {
|
||||
/** Start with no real Workspace or Session. */
|
||||
empty?: boolean
|
||||
/** Reject every prompt before appending its user event. */
|
||||
rejectPrompt?: boolean
|
||||
/** Publish the Session but fail its Workspace account write. */
|
||||
failWorkspaceAttach?: boolean
|
||||
/** Publish and frame the Session, then throw instead of returning create. */
|
||||
dropSessionCreateResponse?: boolean
|
||||
/** Order of the two successful create frames. */
|
||||
createFrameOrder?: 'session-first' | 'workspace-first'
|
||||
}
|
||||
|
||||
/** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung
|
||||
* outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and
|
||||
* piles up for the stream's lifetime, audit C5). breakNow force-ends the stream without the
|
||||
@@ -234,26 +443,76 @@ class FxInbox<F> implements StreamConn<F> {
|
||||
|
||||
/**
|
||||
* In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material).
|
||||
* @param options - fixture branches for empty state and failure timing.
|
||||
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
|
||||
*/
|
||||
export function createFixtureApi(): ApiProxy {
|
||||
const sessions: SessionSummary[] = [
|
||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
|
||||
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
// The resident fixture sessions all carry history, so none of them is blank.
|
||||
const sessions: SessionSummary[] = options.empty ? [] : [
|
||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
|
||||
]
|
||||
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
|
||||
const fixtureGoal: GoalView = {
|
||||
id: 'fx-goal-1' as GoalView['id'], revision: 1, objective: 'Ship the fixture goal bar',
|
||||
phase: 'active', maxGoalRounds: 4, roundsStarted: 1, createdAt: 1, updatedAt: 2,
|
||||
activation: 'armed',
|
||||
}
|
||||
const modelTargets = new Map<SessionId, ModelTarget>(sessions.map(session => [
|
||||
session.sessionId,
|
||||
{ provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
]))
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
|
||||
let nextSession = 1
|
||||
let nextRpc = 1
|
||||
let attachedSessions = options.empty ? 0 : 1
|
||||
// Workspace entities mirroring the host registry: the fixture sessions all
|
||||
// live under one workspace, whose account carries them in attach order.
|
||||
const wid = (raw: string): WorkspaceId => raw as WorkspaceId
|
||||
const fixtureEpoch = new Date(Date.now() - 300_000).toISOString()
|
||||
const workspaces: WorkspaceView[] = options.empty ? [] : [{
|
||||
workspaceId: wid('fx-ws-fixture'),
|
||||
path: '/tmp/fixture',
|
||||
title: 'fixture',
|
||||
sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')],
|
||||
createdAt: fixtureEpoch,
|
||||
updatedAt: fixtureEpoch,
|
||||
}]
|
||||
let nextWorkspace = 1
|
||||
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
|
||||
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
|
||||
const pendingApprovalRpcId = mint()
|
||||
const pendingQuestionRpcId = mint()
|
||||
let questionPending = true
|
||||
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
|
||||
{
|
||||
id: 'harness-profile',
|
||||
header: '偏好',
|
||||
question: '你现在更想招哪类 Agent/Harness 候选人?',
|
||||
options: [
|
||||
{ label: '工程落地型 (Recommended)', description: '更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。' },
|
||||
{ label: '研究潜力型', description: '更看重 Agent 理解、训练评测思路和长期成长空间。' },
|
||||
{ label: '均衡型', description: '同时要求工程能力和 Agent 认知,但可能筛选门槛更高。' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'work-mode',
|
||||
header: '方式',
|
||||
question: '你希望候选人优先展示哪种工作方式?',
|
||||
options: [
|
||||
{ label: '先做小型原型 (Recommended)', description: '用可运行结果尽快验证关键假设。' },
|
||||
{ label: '先写完整设计', description: '先收敛边界、协议和风险,再开始实现。' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'signals',
|
||||
header: '信号',
|
||||
question: '哪些面试信号最重要?',
|
||||
detail: '按当前招聘目标选择;跳过则视为不设偏好。',
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ label: '系统设计' },
|
||||
{ label: '代码质量' },
|
||||
{ label: 'Agent 产品判断' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const muxConns = new Set<StreamConn<MuxFrame>>()
|
||||
const hostConns = new Set<StreamConn<HostFrame>>()
|
||||
@@ -273,6 +532,16 @@ export function createFixtureApi(): ApiProxy {
|
||||
}
|
||||
|
||||
const summaryOf = (id: SessionId): SessionSummary | undefined => sessions.find(s => s.sessionId === id)
|
||||
/** Shared session guard for sessionId-addressed catalog routes: the error
|
||||
* response when the session is unknown, undefined when it exists. */
|
||||
const requireSession = (request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<never>> | undefined => {
|
||||
if (summaryOf(request.payload.sessionId) !== undefined) return undefined
|
||||
return err<{ sessionId: SessionId }, never>(request, {
|
||||
code: 'session-not-found',
|
||||
message: `no session ${request.payload.sessionId}`,
|
||||
details: { sessionId: request.payload.sessionId },
|
||||
})
|
||||
}
|
||||
const setRunning = (id: SessionId, running: boolean): void => {
|
||||
const summary = summaryOf(id)
|
||||
if (summary === undefined || summary.running === running) return
|
||||
@@ -299,6 +568,8 @@ export function createFixtureApi(): ApiProxy {
|
||||
emitMux(view === undefined
|
||||
? { type: 'session/event', sessionId: id, event }
|
||||
: { type: 'session/event', sessionId: id, event, view })
|
||||
// Host eager-drive parallel: a unit-advancing event pushes its finished value.
|
||||
for (const frame of projectionFramesOf(id, log, event)) emitMux(frame)
|
||||
}
|
||||
|
||||
/** At most one in-flight replay per session; cancel clears it. */
|
||||
@@ -325,12 +596,18 @@ export function createFixtureApi(): ApiProxy {
|
||||
},
|
||||
/** Log append + mux emit (the normal live path). */
|
||||
appendUser(id: string, msg: string): void {
|
||||
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: { content: text(msg), source: { kind: 'user' } } })
|
||||
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: userMessage(text(msg)) })
|
||||
},
|
||||
/** Append a later durable title revision through the normal raw-event + control-frame path. */
|
||||
appendTitle(id: string, title: string): void {
|
||||
const log = logOf(sid(id))
|
||||
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
|
||||
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
|
||||
},
|
||||
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
|
||||
appendSilent(id: string, msg: string): void {
|
||||
const log = logOf(sid(id))
|
||||
log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: { content: text(msg), source: { kind: 'user' } } } as unknown as SessionEvent)
|
||||
log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: userMessage(text(msg)) } as unknown as SessionEvent)
|
||||
},
|
||||
/** End every open stream generator (client sees both streams close -> reconnect + resync path). */
|
||||
breakStreams(): void {
|
||||
@@ -344,14 +621,14 @@ export function createFixtureApi(): ApiProxy {
|
||||
const step = 0
|
||||
append(id, { type: 'step/start', data: { turn, step } })
|
||||
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
|
||||
/* v8 ignore next -- the ?? arm needs a null match, but replyText is never empty (prompt always prefixes 回声). */
|
||||
const pieces = replyText.match(/.{1,6}/gu) ?? [replyText]
|
||||
/* v8 ignore next -- the ?? arm needs a null match, but every fixture reply is non-empty. */
|
||||
const pieces = replyText.match(/[\s\S]{1,6}/gu) ?? [replyText]
|
||||
let i = 0
|
||||
const finish = (aborted: boolean): void => {
|
||||
replays.delete(id)
|
||||
const done = pieces.slice(0, i).join('')
|
||||
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } })
|
||||
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(aborted ? `${done}(已中断)` : done), provenance: { provider: 'fixture', model: 'fx-1' } } })
|
||||
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, message: assistantMessage(text(aborted ? `${done}(已中断)` : done)) } })
|
||||
append(id, { type: 'step/end', data: { turn, step } })
|
||||
append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } })
|
||||
setRunning(id, false)
|
||||
@@ -372,24 +649,132 @@ export function createFixtureApi(): ApiProxy {
|
||||
return {
|
||||
sessions: {
|
||||
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
|
||||
create: (request) => {
|
||||
create: async (request) => {
|
||||
const workspace = request.payload.workspaceId === undefined
|
||||
? undefined
|
||||
: workspaces.find(w => w.workspaceId === request.payload.workspaceId)
|
||||
if (request.payload.workspaceId !== undefined && workspace === undefined) {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `no workspace ${request.payload.workspaceId}`,
|
||||
details: { workspaceId: request.payload.workspaceId },
|
||||
})
|
||||
}
|
||||
const cwd = workspace?.path ?? request.payload.cwd ?? '/tmp/fixture'
|
||||
const requestedId = request.payload.sessionId
|
||||
const attachWorkspace = (sessionId: SessionId): void => {
|
||||
/* v8 ignore next -- callers enter only when a target Workspace exists. */
|
||||
if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return
|
||||
workspace.sessionIds = [sessionId, ...workspace.sessionIds]
|
||||
workspace.updatedAt = new Date().toISOString()
|
||||
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
|
||||
}
|
||||
const attachFailure = (
|
||||
sessionId: SessionId,
|
||||
workspaceId: WorkspaceId,
|
||||
): Promise<RpcResponse<{ sessionId: SessionId }>> => err(request, {
|
||||
code: 'workspace-attach-failed' as const,
|
||||
message: `fixture rejected Workspace attachment for ${sessionId}`,
|
||||
details: { sessionId, workspaceId },
|
||||
})
|
||||
if (requestedId !== undefined) {
|
||||
const existing = summaryOf(requestedId)
|
||||
if (existing !== undefined) {
|
||||
if (existing.cwd !== cwd) {
|
||||
return err(request, {
|
||||
code: 'session-conflict',
|
||||
message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`,
|
||||
details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } },
|
||||
})
|
||||
}
|
||||
if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) {
|
||||
if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId)
|
||||
attachWorkspace(requestedId)
|
||||
}
|
||||
return ok(request, { sessionId: requestedId })
|
||||
}
|
||||
}
|
||||
const created: SessionSummary = {
|
||||
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture',
|
||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
|
||||
}
|
||||
sessions.push(created)
|
||||
emitHost({ type: 'host/session-added', sessionId: created.sessionId })
|
||||
modelTargets.set(created.sessionId, { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
attachedSessions += 1
|
||||
const emitSession = (): void => {
|
||||
// Mirrors the host: the frame fires at creation, so blank is constantly true.
|
||||
emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd })
|
||||
}
|
||||
if (workspace !== undefined && options.failWorkspaceAttach) {
|
||||
emitSession()
|
||||
return attachFailure(created.sessionId, workspace.workspaceId)
|
||||
}
|
||||
if (workspace !== undefined && options.createFrameOrder === 'workspace-first') {
|
||||
attachWorkspace(created.sessionId)
|
||||
emitSession()
|
||||
} else {
|
||||
emitSession()
|
||||
if (workspace !== undefined) attachWorkspace(created.sessionId)
|
||||
}
|
||||
if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication')
|
||||
return ok(request, { sessionId: created.sessionId })
|
||||
},
|
||||
history: async (request) => {
|
||||
const log = logs.get(request.payload.sessionId) ?? []
|
||||
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
|
||||
const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50)
|
||||
// Tail page carries the projections block (host parallel: one consistent
|
||||
// cut over the registered units; asOfSeq = window tail seq, -1 on an
|
||||
// empty log — the host's session.seq-1 convention).
|
||||
const projections = request.payload.beforeSeq === undefined
|
||||
? { asOfSeq: log.length - 1, values: projectionValuesOf(log) }
|
||||
: undefined
|
||||
const doomed = failNextHistory
|
||||
failNextHistory = false
|
||||
const delay = historyDelayMs
|
||||
if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
|
||||
if (doomed) throw new Error('fixture: simulated history transport failure')
|
||||
return ok(request, page)
|
||||
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
||||
},
|
||||
models: request => ok(request, {
|
||||
current: modelTargets.get(request.payload.sessionId)
|
||||
?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek-V4-Flash',
|
||||
description: '快速响应',
|
||||
reasoning: DEEPSEEK_REASONING,
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v4-pro',
|
||||
name: 'DeepSeek-V4-Pro',
|
||||
description: '复杂任务',
|
||||
reasoning: DEEPSEEK_REASONING,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
models: [{ id: 'gpt-5', name: 'GPT-5', reasoning: OPENAI_REASONING }],
|
||||
},
|
||||
],
|
||||
failures: [],
|
||||
}),
|
||||
selectModel: (request) => {
|
||||
const selected: ModelTarget = {
|
||||
provider: request.payload.provider,
|
||||
model: request.payload.model,
|
||||
...request.payload.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: request.payload.reasoningEffort },
|
||||
}
|
||||
modelTargets.set(request.payload.sessionId, selected)
|
||||
return ok(request, { selected })
|
||||
},
|
||||
prompt: (request) => {
|
||||
const { sessionId: id, mode, content } = request.payload
|
||||
@@ -397,21 +782,42 @@ export function createFixtureApi(): ApiProxy {
|
||||
if (summary === undefined) {
|
||||
return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
|
||||
}
|
||||
if (options.rejectPrompt) {
|
||||
return err(request, {
|
||||
code: 'agent-busy',
|
||||
message: 'fixture: prompt rejected before acceptance',
|
||||
details: { reason: 'fixture-prompt-rejection' },
|
||||
})
|
||||
}
|
||||
summary.updatedAt = Date.now()
|
||||
// First accepted prompt appends events: the summary stops being blank.
|
||||
summary.blank = false
|
||||
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
if (mode === 'steer' && replays.has(id)) {
|
||||
// Steering: insert a steering message into the current turn; the replay continues.
|
||||
/* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */
|
||||
const turn = (nextTurn.get(id) ?? 1) - 1
|
||||
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, content, source: { kind: 'user' } } })
|
||||
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(content) } })
|
||||
return ok(request, { accepted: true as const })
|
||||
}
|
||||
const turn = nextTurn.get(id) ?? 0
|
||||
nextTurn.set(id, turn + 1)
|
||||
setRunning(id, true)
|
||||
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
|
||||
startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`)
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
|
||||
startReply(
|
||||
id,
|
||||
turn,
|
||||
userText === 'render markdown'
|
||||
? MARKDOWN_FIXTURE
|
||||
: userText === 'report model'
|
||||
? (() => {
|
||||
const target = modelTargets.get(id)
|
||||
return `当前模型:${target?.provider ?? 'unknown'}/${target?.model ?? 'unknown'}`
|
||||
+ (target?.reasoningEffort === undefined ? '' : ` · 推理等级:${target.reasoningEffort}`)
|
||||
})()
|
||||
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
|
||||
)
|
||||
return ok(request, { accepted: true as const })
|
||||
},
|
||||
cancel: (request) => {
|
||||
@@ -426,10 +832,149 @@ export function createFixtureApi(): ApiProxy {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }),
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
pickDirectory: request => ok(request, { path: null }),
|
||||
openPath: request => ok(request, { opened: true as const }),
|
||||
},
|
||||
workspace: {
|
||||
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
|
||||
create: (request) => {
|
||||
const { path, name } = request.payload
|
||||
const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}`
|
||||
const existing = workspaces.find(w => w.path === target)
|
||||
if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false })
|
||||
const now = new Date().toISOString()
|
||||
const created: WorkspaceView = {
|
||||
workspaceId: wid(`fx-ws-${nextWorkspace++}`),
|
||||
path: target,
|
||||
title: name ?? target.split('/').filter(Boolean).at(-1) ?? target,
|
||||
sessionIds: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
workspaces.unshift(created)
|
||||
emitHost({ type: 'host/workspace-changed', workspace: { ...created } })
|
||||
return ok(request, { workspace: { ...created }, created: true })
|
||||
},
|
||||
rename: (request) => {
|
||||
const { workspaceId, title } = request.payload
|
||||
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
|
||||
if (workspace === undefined) {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `no workspace ${workspaceId}`,
|
||||
details: { workspaceId },
|
||||
})
|
||||
}
|
||||
const trimmed = title.trim()
|
||||
if (trimmed !== workspace.title) {
|
||||
if (workspaces.some(w => w.workspaceId !== workspaceId && w.title === trimmed)) {
|
||||
return err(request, {
|
||||
code: 'workspace-name-conflict',
|
||||
message: `workspace name '${trimmed}' is already in use`,
|
||||
details: { name: trimmed },
|
||||
})
|
||||
}
|
||||
workspace.title = trimmed
|
||||
workspace.updatedAt = new Date().toISOString()
|
||||
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
|
||||
}
|
||||
return ok(request, { workspace: { ...workspace } })
|
||||
},
|
||||
delete: (request) => {
|
||||
const { workspaceId } = request.payload
|
||||
const index = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
|
||||
if (index === -1) {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `no workspace ${workspaceId}`,
|
||||
details: { workspaceId },
|
||||
})
|
||||
}
|
||||
workspaces.splice(index, 1)
|
||||
emitHost({ type: 'host/workspace-removed', workspaceId })
|
||||
return ok(request, { deleted: true as const })
|
||||
},
|
||||
insertSessionBefore: (request) => {
|
||||
const { workspaceId, sessionId, beforeSessionId } = request.payload
|
||||
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
|
||||
if (workspace === undefined) {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `no workspace ${workspaceId}`,
|
||||
details: { workspaceId },
|
||||
})
|
||||
}
|
||||
if (!workspace.sessionIds.includes(sessionId)
|
||||
|| (beforeSessionId !== undefined && !workspace.sessionIds.includes(beforeSessionId))) {
|
||||
return err(request, {
|
||||
code: 'workspace-move-invalid',
|
||||
message: `session or anchor is not accounted by workspace ${workspaceId}`,
|
||||
details: { workspaceId, sessionId, ...beforeSessionId === undefined ? {} : { beforeSessionId } },
|
||||
})
|
||||
}
|
||||
const without = workspace.sessionIds.filter(id => id !== sessionId)
|
||||
const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId)
|
||||
const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)]
|
||||
if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) {
|
||||
workspace.sessionIds = sessionIds
|
||||
workspace.updatedAt = new Date().toISOString()
|
||||
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
|
||||
}
|
||||
return ok(request, { workspace: { ...workspace } })
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
// The catalog mirrors one session's effective view (every fixture
|
||||
// session has an agent, like the real host).
|
||||
list: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
return ok(request, {
|
||||
commands: [
|
||||
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
|
||||
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
|
||||
{ name: 'goal-fixture', description: 'fixture:目标样本命令', input: { hint: '<objective>' } },
|
||||
],
|
||||
})
|
||||
},
|
||||
// Pure admission, mirroring the host: an admitted command logs the
|
||||
// command/run + command/done lifecycle pair (mux-broadcast by append),
|
||||
// and the response only reports resolution.
|
||||
execute: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
// Structured split mirroring the host parser: name + verbatim rawInput
|
||||
// (separator whitespace included) — the run payload carries no line.
|
||||
const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
|
||||
const name = match?.[1]
|
||||
const args = match?.[2] ?? ''
|
||||
const outcomes: Record<string, string> = {
|
||||
compact: 'fixture:已压缩(假动作)',
|
||||
echo: args.trim(),
|
||||
'goal-fixture': `fixture:goal 已设置(${id})`,
|
||||
}
|
||||
const text = name === undefined ? undefined : outcomes[name]
|
||||
if (name === undefined || text === undefined) return ok(request, { matched: false as const })
|
||||
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
|
||||
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
|
||||
append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } })
|
||||
return ok(request, { matched: true as const, commandId })
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
list: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
return ok(request, {
|
||||
skills: [
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' },
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
get: request => ok(request, { goal: request.payload.sessionId === sid('fx-alpha') ? fixtureGoal : null }),
|
||||
create: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
|
||||
edit: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
|
||||
pause: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
|
||||
@@ -443,10 +988,16 @@ export function createFixtureApi(): ApiProxy {
|
||||
muxConns.add(conn)
|
||||
const breakNow = (): void => { conn.breakNow() }
|
||||
streamBreakers.add(breakNow)
|
||||
// Open baseline: subscribed for attached (running) sessions + pending approval replay (stable rpcId).
|
||||
// Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds.
|
||||
for (const s of sessions) {
|
||||
if (!s.running) continue
|
||||
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
|
||||
const log = logs.get(s.sessionId) ?? []
|
||||
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: log.length - 1 } })
|
||||
// Post-subscribe projection baseline (host parallel: recomputed unit values ride push frames).
|
||||
const values = projectionValuesOf(log)
|
||||
for (const key of Object.keys(values)) {
|
||||
conn.push({ rpcId: mint(), payload: { type: 'session/projection', sessionId: s.sessionId, key, value: values[key], seq: log.length - 1 } })
|
||||
}
|
||||
}
|
||||
conn.push({
|
||||
rpcId: pendingApprovalRpcId,
|
||||
@@ -456,6 +1007,14 @@ export function createFixtureApi(): ApiProxy {
|
||||
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
|
||||
},
|
||||
})
|
||||
if (questionPending) {
|
||||
conn.push({
|
||||
rpcId: pendingQuestionRpcId,
|
||||
payload: {
|
||||
type: 'question/requested', sessionId: sid('fx-alpha'), questions: fixtureQuestions,
|
||||
},
|
||||
})
|
||||
}
|
||||
try {
|
||||
yield* conn.drain(signal)
|
||||
} finally {
|
||||
@@ -485,9 +1044,16 @@ export function createFixtureApi(): ApiProxy {
|
||||
},
|
||||
},
|
||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
// The v1 UI never answers (PendingCard is visible but not answerable); implemented for type completeness, always not-pending.
|
||||
void message
|
||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||
if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
|
||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||
}
|
||||
questionPending = false
|
||||
emitMux({
|
||||
type: 'question/resolved', sessionId: sid('fx-alpha'),
|
||||
questionRpcId: pendingQuestionRpcId,
|
||||
outcome: message.result.ok ? 'answered' : 'cancelled',
|
||||
})
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -500,7 +1066,12 @@ export function createFixtureApi(): ApiProxy {
|
||||
* to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
|
||||
*/
|
||||
export class FixtureApiClient extends AbstractApiClient {
|
||||
private readonly api = createFixtureApi()
|
||||
private readonly api: ApiProxy
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.api = createFixtureApi(fixtureOptionsFromLocation())
|
||||
}
|
||||
|
||||
protected doFetch(): Promise<Response> {
|
||||
throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable')
|
||||
@@ -525,10 +1096,22 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.list': return this.api.sessions.list(request)
|
||||
case 'session.create': return this.api.sessions.create(request)
|
||||
case 'session.history': return this.api.sessions.history(request)
|
||||
case 'session.models': return this.api.sessions.models(request)
|
||||
case 'session.selectModel': return this.api.sessions.selectModel(request)
|
||||
case 'session.prompt': return this.api.sessions.prompt(request)
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'goal.get': return this.api.goals.get(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
|
||||
case 'workspace.list': return this.api.workspace.list(request)
|
||||
case 'workspace.create': return this.api.workspace.create(request)
|
||||
case 'workspace.rename': return this.api.workspace.rename(request)
|
||||
case 'workspace.delete': return this.api.workspace.delete(request)
|
||||
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
|
||||
case 'command.list': return this.api.commands.list(request)
|
||||
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
|
||||
case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
|
||||
case 'skill.list': return this.api.skills.list(request)
|
||||
case 'goal.create': return this.api.goals.create(request)
|
||||
case 'goal.edit': return this.api.goals.edit(request)
|
||||
case 'goal.pause': return this.api.goals.pause(request)
|
||||
@@ -579,3 +1162,16 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
return this.api.respond(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */
|
||||
function fixtureOptionsFromLocation(): FixtureOptions {
|
||||
if (typeof location === 'undefined') return {}
|
||||
const query = new URLSearchParams(location.search)
|
||||
return {
|
||||
empty: query.get('fixture') === 'empty',
|
||||
rejectPrompt: query.get('fixturePrompt') === 'reject',
|
||||
failWorkspaceAttach: query.get('fixtureAttach') === 'fail',
|
||||
dropSessionCreateResponse: query.get('fixtureSessionCreate') === 'drop-response',
|
||||
createFrameOrder: query.get('fixtureFrames') === 'workspace-first' ? 'workspace-first' : 'session-first',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Browser half of the wire consumer layer (contract: api-contracts v3
|
||||
* section 3; export inventory = v3 §3.2). The wire is this package's client
|
||||
* half in its entirety — apply mounts ctx.connection: the shared api client
|
||||
* plus the connection controller handle. Mode selection (?fixture) happens
|
||||
* here so the rest of the client tree is mode-blind; the controller's sinks
|
||||
* are wired by the runtime plugin (object layer), which injects this service.
|
||||
* Browser wire client. The plugin selects fixture or HTTP transport, provides
|
||||
* the shared API client, and lets the runtime object layer start the stream
|
||||
* controller with its sinks.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { IApiClient } from './api.ts'
|
||||
@@ -16,22 +13,21 @@ import { WebApiClient } from './web-api-client.ts'
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
ToolCallView, ToolResultView,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
GoalsApi, GoalView, GoalRef, GoalPhase, GoalBlockReason,
|
||||
GoalsApi, GoalRef,
|
||||
} from './api.ts'
|
||||
export { RpcId, AbstractApiClient, resultOf, transportError } from './api.ts'
|
||||
export { RpcId, AbstractApiClient, transportError } from './api.ts'
|
||||
|
||||
// ---- Connection loop ----
|
||||
export { ConnectionController } from './connection.ts'
|
||||
// Connection loop types are public through ConnectionHandle.start; the
|
||||
// controller remains package-internal.
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
|
||||
// ---- Platform client subclasses ----
|
||||
export { WebApiClient } from './web-api-client.ts'
|
||||
export { FixtureApiClient, createFixtureApi } from './fixture.ts'
|
||||
|
||||
|
||||
/** Required services (none — this is the wire root). */
|
||||
export const inject: string[] = []
|
||||
|
||||
59
packages/client/connection/src/http-bridge.ts
Normal file
59
packages/client/connection/src/http-bridge.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
|
||||
* web carrier; the fetch-shaped handler itself is transport-agnostic).
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
|
||||
/**
|
||||
* Bridge one node:http request to the fetch-shaped handler (client close
|
||||
* aborts; SSE bodies stream out chunk by chunk).
|
||||
* @param req - incoming node:http request (fully read before dispatch).
|
||||
* @param res - node:http response the bridge writes and owns to completion.
|
||||
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
|
||||
*/
|
||||
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
|
||||
const abort = new AbortController()
|
||||
// Client-disconnect detection MUST hang off the response, not the request:
|
||||
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
|
||||
// fully consumed (immediately for a bodyless GET), which would abort every SSE
|
||||
// stream right after open. ServerResponse 'close' fires on connection teardown;
|
||||
// writableEnded distinguishes a normal end() from the client going away.
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) abort.abort()
|
||||
})
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer)
|
||||
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
|
||||
requests; the fields are only optional on the client-side IncomingMessage type */
|
||||
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
|
||||
method: req.method ?? 'GET',
|
||||
headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]),
|
||||
...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
|
||||
signal: abort.signal,
|
||||
})
|
||||
const response = await apiHandler.fetch(request)
|
||||
res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
|
||||
if (response.body === null) {
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
for await (const chunk of response.body) {
|
||||
// Backpressure: a false return means the socket buffer is full — wait for drain
|
||||
// instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also
|
||||
// resolves so a mid-wait disconnect can't park this loop forever; the close
|
||||
// handler above aborts the handler stream, which then ends the iteration.
|
||||
if (!res.write(chunk)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const done = (): void => {
|
||||
res.off('drain', done)
|
||||
res.off('close', done)
|
||||
resolve()
|
||||
}
|
||||
res.once('drain', done)
|
||||
res.once('close', done)
|
||||
})
|
||||
}
|
||||
}
|
||||
res.end()
|
||||
}
|
||||
@@ -1,10 +1,40 @@
|
||||
/**
|
||||
* Connection plugin, node half. The package IS a dshClient plugin: the wire
|
||||
* consumer layer lives in its client half in full (src/client/ — contract:
|
||||
* api-contracts v3 section 3, inventory §3.2); consumers import the /client
|
||||
* subpath. The empty apply exists so the plugin appears in the host Loader
|
||||
* (lifecycle governance + dshClient discovery).
|
||||
*/
|
||||
/** Host HTTP bridge for browser-client RPC. */
|
||||
import type { Context } from 'cordis'
|
||||
// Activates the httpServer Context merge used below.
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { isTrustedNativeDialogRequest } from './native-dialog-request.ts'
|
||||
|
||||
/** Host plugin body — no host-side behavior for the connection plugin. */
|
||||
export function apply(_ctx: unknown): void {}
|
||||
export { API_PATH } from './api-path.ts'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
|
||||
/**
|
||||
* Mounts the API gateway under the browser transport prefix.
|
||||
* @param ctx - Host plugin context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
if ((pathname === `${API_PATH}/host.pickDirectory`
|
||||
|| pathname === `${API_PATH}/host.openPath`)
|
||||
&& !isTrustedNativeDialogRequest(req)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, apiHandler)
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ export const name = 'client-connection-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the pure wire layer emits no cordis events and owns no
|
||||
* No runtime invariant: the wire layer emits no cordis events and owns no
|
||||
* mutable cross-plugin relation — stream/reconnect sequencing is exercised
|
||||
* directly by its behavior specs, and rpcId round-trip discipline is owned by
|
||||
* the apiproxy contract layer.
|
||||
* directly by its behavior specs, rpcId round-trip discipline is owned by the
|
||||
* apiproxy contract layer, and the node half's single route registration's
|
||||
* register/dispose symmetry is audited by the webserver package's invariant.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
52
packages/client/connection/src/native-dialog-request.ts
Normal file
52
packages/client/connection/src/native-dialog-request.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/** Trust check for browser requests that can invoke privileged native host actions. */
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
|
||||
interface NativeDialogRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
socket: { remoteAddress?: string | undefined }
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function isLoopback(address: string | undefined): boolean {
|
||||
if (address === undefined) return false
|
||||
if (address === '::1') return true
|
||||
const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address
|
||||
const first = ipv4.split('.')[0]
|
||||
return first === '127'
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a local socket plus browser-controlled same-origin metadata.
|
||||
* @param request - the node HTTP request facts used by the carrier guard.
|
||||
* @returns true only for a same-origin browser request whose peer and URL are loopback.
|
||||
*/
|
||||
export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean {
|
||||
if (!isLoopback(request.socket.remoteAddress)) return false
|
||||
if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false
|
||||
const origin = header(request.headers, 'origin')
|
||||
const host = header(request.headers, 'host')
|
||||
if (origin === undefined || host === undefined) return false
|
||||
try {
|
||||
const parsed = new URL(origin)
|
||||
const hostUrl = new URL(`http://${host}`)
|
||||
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
||||
&& parsed.host === host
|
||||
&& isLoopbackHostname(parsed.hostname)
|
||||
&& isLoopbackHostname(hostUrl.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
HostFrame, IApiClient, MuxFrame, RpcRequest, RpcResponse, SessionId,
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
|
||||
@@ -44,13 +46,29 @@ export class FakeApiClient implements IApiClient {
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
() => Promise.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
|
||||
}))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: { provider: 'deepseek', model: 'deepseek-chat' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
}))
|
||||
onSelectModel: (payload: ModelTarget & { sessionId: SessionId })
|
||||
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
@@ -63,16 +81,53 @@ export class FakeApiClient implements IApiClient {
|
||||
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
|
||||
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))),
|
||||
create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
created: true,
|
||||
}))),
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))),
|
||||
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
}
|
||||
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
// wire shapes so cases can program catalogs and skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
|
||||
= () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
get: payload => this.record('goal.get', payload, Promise.resolve(ok({ goal: null }))),
|
||||
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
|
||||
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
|
||||
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
|
||||
|
||||
109
packages/client/connection/tests/fixture-commands.spec.ts
Normal file
109
packages/client/connection/tests/fixture-commands.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Fixture commands/skills domains: contract-shape conformance for the two
|
||||
* domains added to ApiProxy — rpcId echo, session-addressed catalogs, execute
|
||||
* parse/dispatch, skill.list session resolution, and the FixtureApiClient
|
||||
* dispatch rows.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
import type { RpcRequest } from '../src/client/api.ts'
|
||||
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
let reqCount = 0
|
||||
const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${reqCount++}`), payload })
|
||||
const signal = new AbortController().signal
|
||||
|
||||
describe('createFixtureApi commands/skills', () => {
|
||||
it('serves the addressed session catalog with rpcId echo', async () => {
|
||||
const api = createFixtureApi()
|
||||
const request = req({ sessionId: sid('fx-alpha') })
|
||||
const response = await api.commands.list(request)
|
||||
expect(response.rpcId).toBe(request.rpcId)
|
||||
if (!response.result.ok) throw new Error('list failed')
|
||||
const commands = response.result.value.commands
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture'])
|
||||
// input hint rides only the commands declaring it.
|
||||
const echo = commands.find(c => c.name === 'echo')
|
||||
expect(echo?.input?.hint).toBeTruthy()
|
||||
expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a catalog request for an unknown session', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.commands.list(req({ sessionId: sid('fx-nope') }))
|
||||
expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => {
|
||||
const api = createFixtureApi()
|
||||
const frames: unknown[] = []
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux(req({}), abort.signal)
|
||||
const pump = (async () => {
|
||||
for await (const frame of stream) {
|
||||
frames.push(frame.payload)
|
||||
if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort()
|
||||
}
|
||||
})()
|
||||
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
|
||||
if (!response.result.ok) throw new Error('execute failed')
|
||||
expect(response.result.value).toMatchObject({ matched: true })
|
||||
expect(response.result.value.commandId).toBeTruthy()
|
||||
await pump
|
||||
const events = frames
|
||||
.filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event')
|
||||
.map(f => f.event)
|
||||
expect(events).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'echo', args: ' hello world', source: { kind: 'user' } } },
|
||||
{ type: 'command/done', data: { kind: 'success', text: 'hello world' } },
|
||||
])
|
||||
expect(events[0]?.data.commandId).toBe(events[1]?.data.commandId)
|
||||
})
|
||||
|
||||
it('addresses execute to the session; an unknown session errs', async () => {
|
||||
const api = createFixtureApi()
|
||||
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal)
|
||||
if (!hit.result.ok) throw new Error('execute failed')
|
||||
expect(hit.result.value.matched).toBe(true)
|
||||
|
||||
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal)
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
it('falls to matched:false on unknown names and non-command lines', async () => {
|
||||
const api = createFixtureApi()
|
||||
for (const line of ['/nope', 'plain text', '/']) {
|
||||
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal)
|
||||
if (!response.result.ok) throw new Error('execute failed')
|
||||
// Pure admission value: the matched bit is the whole response shape.
|
||||
expect(response.result.value).toEqual({ matched: false })
|
||||
}
|
||||
})
|
||||
|
||||
it('serves the skill catalog for the addressed session and rejects unknown sessions', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.skills.list(req({ sessionId: sid('fx-alpha') }))
|
||||
if (!response.result.ok) throw new Error('skill list failed')
|
||||
expect(response.result.value.skills[0]?.name).toBe('fixture-demo')
|
||||
|
||||
const missingSession = await api.skills.list(req({ sessionId: sid('fx-nope') }))
|
||||
expect(missingSession.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('FixtureApiClient command/skill dispatch', () => {
|
||||
it('routes the three method keys through the in-memory dispatch table', async () => {
|
||||
const client = new FixtureApiClient()
|
||||
const list = await client.commands.list({ sessionId: sid('fx-alpha') })
|
||||
if (!list.result.ok) throw new Error('command.list failed')
|
||||
expect(list.result.value.commands.length).toBeGreaterThan(0)
|
||||
const executed = await client.commands.execute({ sessionId: sid('fx-alpha'), line: '/compact' })
|
||||
if (!executed.result.ok) throw new Error('command.execute failed')
|
||||
expect(executed.result.value.matched).toBe(true)
|
||||
const skills = await client.skills.list({ sessionId: sid('fx-alpha') })
|
||||
if (!skills.result.ok) throw new Error('skill.list failed')
|
||||
expect(skills.result.value.skills.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
* the hand-written fixture/host parallel implementations.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '../src/client/api.ts'
|
||||
import type { SessionId, WorkspaceId } from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts'
|
||||
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
|
||||
@@ -18,6 +18,7 @@ interface TimingHooks {
|
||||
setHistoryDelay(ms: number): void
|
||||
failNextHistory(): void
|
||||
appendUser(id: string, msg: string): void
|
||||
appendTitle(id: string, title: string): void
|
||||
appendSilent(id: string, msg: string): void
|
||||
breakStreams(): void
|
||||
}
|
||||
@@ -64,10 +65,60 @@ describe('createFixtureApi', () => {
|
||||
const clamped = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: -5, maxMessages: 10 }))
|
||||
if (!clamped.result.ok) throw new Error('clamped failed')
|
||||
expect(clamped.result.value.events).toEqual([])
|
||||
// Unknown session: empty page, not an error (history of a bare id).
|
||||
// Unknown session: empty page, not an error (history of a bare id). The
|
||||
// tail block still rides it — empty-log cut at -1, the host convention.
|
||||
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
|
||||
if (!empty.result.ok) throw new Error('empty failed')
|
||||
expect(empty.result.value).toEqual({ events: [], hasMore: false })
|
||||
// Fixture composes the todos unit (host parallel when tool-todo is mounted): null before any write.
|
||||
expect(empty.result.value).toEqual({
|
||||
events: [], hasMore: false, projections: { asOfSeq: -1, values: { todos: null } },
|
||||
})
|
||||
})
|
||||
|
||||
it('serves grouped models and keeps a selected target for later history and fixture requests', async () => {
|
||||
const api = createFixtureApi()
|
||||
const sessionId = sid('fx-alpha')
|
||||
const catalog = await api.sessions.models(req({ sessionId }))
|
||||
if (!catalog.result.ok) throw new Error('models failed')
|
||||
expect(catalog.result.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI'])
|
||||
expect(catalog.result.value.groups[0]?.models.map(model => model.id))
|
||||
.toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
|
||||
const selected = await api.sessions.selectModel(req({
|
||||
sessionId,
|
||||
provider: 'openai',
|
||||
model: 'gpt-5',
|
||||
}))
|
||||
if (!selected.result.ok) throw new Error('selection failed')
|
||||
expect(selected.result.value.selected).toEqual({ provider: 'openai', model: 'gpt-5' })
|
||||
const history = await api.sessions.history(req({ sessionId }))
|
||||
if (!history.result.ok) throw new Error('history failed')
|
||||
|
||||
const prompt = await api.sessions.prompt(req({
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'report model' }],
|
||||
}))
|
||||
expect(prompt.result.ok).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 600))
|
||||
const after = await api.sessions.history(req({ sessionId }))
|
||||
if (!after.result.ok) throw new Error('history failed')
|
||||
expect(JSON.stringify(after.result.value.events)).toContain('openai/gpt-5')
|
||||
})
|
||||
|
||||
it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => {
|
||||
const api = createFixtureApi()
|
||||
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
|
||||
if (!tail.result.ok) throw new Error('history failed')
|
||||
const events = tail.result.value.events.map(e => e.event)
|
||||
const todoAt = events.findIndex(e => e.type === 'todo/write')
|
||||
expect(todoAt).toBeGreaterThan(0)
|
||||
// Production ordering (the tool appends mid-execution): call → snapshot → result.
|
||||
expect(events[todoAt - 1]?.type).toBe('tool/call')
|
||||
expect(events[todoAt + 1]?.type).toBe('tool/result')
|
||||
const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time)
|
||||
expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0)
|
||||
expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0)
|
||||
})
|
||||
|
||||
it('create adds a session and pushes host/session-added to open host streams', async () => {
|
||||
@@ -86,7 +137,7 @@ describe('createFixtureApi', () => {
|
||||
await consuming
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const createdId = created.result.value.sessionId
|
||||
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId }])
|
||||
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture' }])
|
||||
const list = await api.sessions.list(req({}))
|
||||
if (!list.result.ok) throw new Error('list failed')
|
||||
expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
|
||||
@@ -113,7 +164,7 @@ describe('createFixtureApi', () => {
|
||||
const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } })
|
||||
// Real prompt: replay starts (running flips true), cancel freezes it.
|
||||
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '取消我' }] }))
|
||||
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] }))
|
||||
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks
|
||||
await api.sessions.cancel(req({ sessionId: id }))
|
||||
@@ -148,14 +199,14 @@ describe('createFixtureApi', () => {
|
||||
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
|
||||
})
|
||||
|
||||
it('mux open replays the baseline: subscribed for running sessions + the resident approval with a stable rpcId', async () => {
|
||||
it('mux open replays subscribed sessions and resident interactions with stable rpcIds', async () => {
|
||||
const api = createFixtureApi()
|
||||
const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
|
||||
const abort = new AbortController()
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
envelopes.push(envelope)
|
||||
if (envelopes.length >= 2) abort.abort()
|
||||
if (envelopes.length >= 4) abort.abort()
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
@@ -163,8 +214,13 @@ describe('createFixtureApi', () => {
|
||||
const second = await openOnce()
|
||||
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
|
||||
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
||||
expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
// Projection baseline frames follow the subscribed frame (title + todos units).
|
||||
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
|
||||
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
|
||||
expect(first[3]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[3]?.rpcId).toBe(first[3]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[4]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[4]?.rpcId).toBe(first[4]?.rpcId)
|
||||
})
|
||||
|
||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||
@@ -217,15 +273,330 @@ describe('createFixtureApi', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('respond is a typed stub: always not-pending', async () => {
|
||||
it('respond resolves the resident question once and rejects duplicate or unrelated ids', async () => {
|
||||
const api = createFixtureApi()
|
||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
|
||||
const abort = new AbortController()
|
||||
let question: RpcRequest<MuxFrame> | undefined
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
if (envelope.payload.type !== 'question/requested') continue
|
||||
question = envelope
|
||||
abort.abort()
|
||||
}
|
||||
if (question === undefined) throw new Error('fixture question missing')
|
||||
const response = { type: 'client-response' as const, rpcId: question.rpcId, result: { ok: true as const, value: {} } }
|
||||
expect(await api.respond(response)).toEqual({ accepted: true })
|
||||
expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
|
||||
|
||||
const replayAbort = new AbortController()
|
||||
const replayed = await collect(api.events.mux(req({}), replayAbort.signal), replayAbort, frames => frames.length === 2)
|
||||
expect(replayed.every(frame => frame.type !== 'question/requested')).toBe(true)
|
||||
|
||||
const cancelledApi = createFixtureApi()
|
||||
const cancelAbort = new AbortController()
|
||||
let cancelQuestion: RpcRequest<MuxFrame> | undefined
|
||||
for await (const envelope of cancelledApi.events.mux(req({}), cancelAbort.signal)) {
|
||||
if (envelope.payload.type !== 'question/requested') continue
|
||||
cancelQuestion = envelope
|
||||
cancelAbort.abort()
|
||||
}
|
||||
if (cancelQuestion === undefined) throw new Error('fixture cancellation question missing')
|
||||
expect(await cancelledApi.respond({
|
||||
type: 'client-response', rpcId: cancelQuestion.rpcId,
|
||||
result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
|
||||
})).toEqual({ accepted: true })
|
||||
})
|
||||
|
||||
it('describe answers the fixture identity', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.host.describe(req({}))
|
||||
expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } })
|
||||
const empty = await createFixtureApi({ empty: true }).host.describe(req({}))
|
||||
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
|
||||
})
|
||||
|
||||
it('workspace.list serves the resident account and create reuses on path collision', async () => {
|
||||
const api = createFixtureApi()
|
||||
const listed = await api.workspace.list(req({}))
|
||||
if (!listed.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.items).toEqual([expect.objectContaining({
|
||||
workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture',
|
||||
sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'],
|
||||
})])
|
||||
// path collision → the existing entity comes back, created:false, no frame.
|
||||
const reused = await api.workspace.create(req({ path: '/tmp/fixture' }))
|
||||
if (!reused.result.ok) throw new Error('reuse failed')
|
||||
expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } })
|
||||
})
|
||||
|
||||
it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const created = await api.workspace.create(req({ name: 'nova' }))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
expect(created.result.value.created).toBe(true)
|
||||
expect(created.result.value.workspace).toMatchObject({
|
||||
path: '/tmp/fixture-workspaces/nova', title: 'nova', sessionIds: [],
|
||||
})
|
||||
await consuming
|
||||
expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }])
|
||||
// path spelling falls back to the basename when no title/name rides along.
|
||||
const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' }))
|
||||
if (!pathOnly.result.ok) throw new Error('pathOnly failed')
|
||||
expect(pathOnly.result.value.workspace.title).toBe('base')
|
||||
// Degenerate spellings reach the impl unfiltered (the fixture carrier has
|
||||
// no schema gate): both-absent falls back to the bucket dir, and a
|
||||
// basename-less path serves as its own title.
|
||||
const bare = await api.workspace.create(req({}))
|
||||
if (!bare.result.ok) throw new Error('bare failed')
|
||||
expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' })
|
||||
const rootPath = await api.workspace.create(req({ path: '/' }))
|
||||
if (!rootPath.result.ok) throw new Error('rootPath failed')
|
||||
expect(rootPath.result.value.workspace.title).toBe('/')
|
||||
})
|
||||
|
||||
it('workspace.rename covers not-found, conflict, no-op, and the changed frame', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
if (seen.length >= 2) abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const wsid = 'fx-ws-fixture' as WorkspaceId
|
||||
const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
|
||||
|
||||
await api.workspace.create(req({ name: 'occupied' }))
|
||||
const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' }))
|
||||
expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } })
|
||||
|
||||
const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' }))
|
||||
if (!noop.result.ok) throw new Error('no-op rename failed')
|
||||
expect(noop.result.value.workspace.title).toBe('fixture')
|
||||
|
||||
const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' }))
|
||||
if (!renamed.result.ok) throw new Error('rename failed')
|
||||
expect(renamed.result.value.workspace.title).toBe('renamed')
|
||||
await consuming
|
||||
// Only the create and the effective rename emit frames; the no-op stays silent.
|
||||
expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed'])
|
||||
})
|
||||
|
||||
it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => {
|
||||
const api = createFixtureApi()
|
||||
const wsid = 'fx-ws-fixture' as WorkspaceId
|
||||
const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
|
||||
const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') }))
|
||||
expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } })
|
||||
const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') }))
|
||||
expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } })
|
||||
|
||||
const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') }))
|
||||
if (!moved.result.ok) throw new Error('move failed')
|
||||
expect(moved.result.value.workspace.sessionIds).toEqual(['fx-alpha', 'fx-gamma', 'fx-beta'])
|
||||
const appended = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') }))
|
||||
if (!appended.result.ok) throw new Error('append failed')
|
||||
expect(appended.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha'])
|
||||
const before = appended.result.value.workspace.updatedAt
|
||||
const noop = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') }))
|
||||
if (!noop.result.ok) throw new Error('no-op move failed')
|
||||
expect(noop.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha'])
|
||||
expect(noop.result.value.workspace.updatedAt).toBe(before)
|
||||
})
|
||||
|
||||
it('workspace.delete removes only the Workspace row and emits the removal frame', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
|
||||
const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
|
||||
expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
|
||||
await consuming
|
||||
expect(seen).toEqual([{ type: 'host/workspace-removed', workspaceId: 'fx-ws-fixture' }])
|
||||
const list = await api.workspace.list(req({}))
|
||||
if (!list.result.ok) throw new Error('workspace list failed')
|
||||
expect(list.result.value.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false)
|
||||
const sessions = await api.sessions.list(req({}))
|
||||
if (!sessions.result.ok) throw new Error('session list failed')
|
||||
expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha')
|
||||
})
|
||||
|
||||
it('session.create({workspaceId}) lands on the account and unknown ids error', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
if (seen.length >= 2) abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
|
||||
const created = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const id = created.result.value.sessionId
|
||||
await consuming
|
||||
// The session lands with the workspace's path as cwd, and the account
|
||||
// write pushes the fresh workspace snapshot after session-added.
|
||||
expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture' })
|
||||
expect(seen[1]).toMatchObject({
|
||||
type: 'host/workspace-changed',
|
||||
workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('supports an empty baseline, preallocated ids, workspace-first frames, and idempotent retry', async () => {
|
||||
const api = createFixtureApi({ empty: true, createFrameOrder: 'workspace-first' })
|
||||
const initialSessions = await api.sessions.list(req({}))
|
||||
const initialWorkspaces = await api.workspace.list(req({}))
|
||||
expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } })
|
||||
expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } })
|
||||
|
||||
const made = await api.workspace.create(req({ name: 'nova' }))
|
||||
if (!made.result.ok) throw new Error('workspace create failed')
|
||||
const abort = new AbortController()
|
||||
const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const preallocated = sid('fx-preallocated')
|
||||
const created = await api.sessions.create(req({
|
||||
workspaceId: made.result.value.workspace.workspaceId,
|
||||
sessionId: preallocated,
|
||||
}))
|
||||
expect(created.result).toEqual({ ok: true, value: { sessionId: preallocated } })
|
||||
const frames = await framesPromise
|
||||
expect(frames[0]).toMatchObject({
|
||||
type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] },
|
||||
})
|
||||
expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, blank: true, cwd: made.result.value.workspace.path })
|
||||
|
||||
const retried = await api.sessions.create(req({
|
||||
workspaceId: made.result.value.workspace.workspaceId,
|
||||
sessionId: preallocated,
|
||||
}))
|
||||
expect(retried.result).toEqual({ ok: true, value: { sessionId: preallocated } })
|
||||
const listed = await api.sessions.list(req({}))
|
||||
if (!listed.result.ok) throw new Error('session list failed')
|
||||
expect(listed.result.value.items.filter(item => item.sessionId === preallocated)).toHaveLength(1)
|
||||
|
||||
const conflict = await api.sessions.create(req({ sessionId: preallocated, cwd: '/elsewhere' }))
|
||||
expect(conflict.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'session-conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches an existing ungrouped Session to a matching Workspace', async () => {
|
||||
const api = createFixtureApi()
|
||||
const sessionId = sid('fx-existing-ungrouped')
|
||||
await expect(api.sessions.create(req({ sessionId, cwd: '/tmp/fixture' }))).resolves.toMatchObject({
|
||||
result: { ok: true, value: { sessionId } },
|
||||
})
|
||||
|
||||
await expect(api.sessions.create(req({
|
||||
sessionId,
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
}))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } })
|
||||
|
||||
const workspaces = await api.workspace.list(req({}))
|
||||
if (!workspaces.result.ok) throw new Error('workspace list failed')
|
||||
expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId)
|
||||
})
|
||||
|
||||
it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => {
|
||||
const api = createFixtureApi()
|
||||
const listed = await api.sessions.list(req({}))
|
||||
if (!listed.result.ok) throw new Error('session list failed')
|
||||
const existing = listed.result.value.items.find(item => item.sessionId === sid('fx-alpha'))
|
||||
if (existing === undefined) throw new Error('fixture Session missing')
|
||||
delete existing.cwd
|
||||
|
||||
const conflict = await api.sessions.create(req({ sessionId: existing.sessionId }))
|
||||
expect(conflict.result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'session-conflict',
|
||||
message: `session ${existing.sessionId} already uses no cwd`,
|
||||
details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes an ungrouped Session when Workspace attachment fails', async () => {
|
||||
const api = createFixtureApi({ failWorkspaceAttach: true })
|
||||
const sessionId = sid('fx-partial')
|
||||
const created = await api.sessions.create(req({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId,
|
||||
}))
|
||||
expect(created.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } },
|
||||
})
|
||||
const listed = await api.sessions.list(req({}))
|
||||
const workspaces = await api.workspace.list(req({}))
|
||||
if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
|
||||
expect(workspaces.result.value.items[0]?.sessionIds).not.toContain(sessionId)
|
||||
|
||||
const retried = await api.sessions.create(req({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId,
|
||||
}))
|
||||
expect(retried.result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
const afterRetry = await api.sessions.list(req({}))
|
||||
if (!afterRetry.result.ok) throw new Error('list failed')
|
||||
expect(afterRetry.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reconciles a dropped create response and can reject a prompt before acceptance', async () => {
|
||||
const sessionId = sid('fx-lost-response')
|
||||
const dropped = createFixtureApi({ dropSessionCreateResponse: true })
|
||||
await expect(Promise.resolve().then(() => dropped.sessions.create(req({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId,
|
||||
})))).rejects.toThrow(/dropped session\.create response/)
|
||||
const listed = await dropped.sessions.list(req({}))
|
||||
const workspaces = await dropped.workspace.list(req({}))
|
||||
if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.items.some(item => item.sessionId === sessionId)).toBe(true)
|
||||
expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId)
|
||||
await expect(dropped.sessions.create(req({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId,
|
||||
}))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } })
|
||||
|
||||
const rejecting = createFixtureApi({ empty: true, rejectPrompt: true })
|
||||
const real = await rejecting.sessions.create(req({ sessionId: sid('fx-rejected') }))
|
||||
if (!real.result.ok) throw new Error('session create failed')
|
||||
const prompt = await rejecting.sessions.prompt(req({
|
||||
sessionId: real.result.value.sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'keep me' }],
|
||||
}))
|
||||
expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
|
||||
})
|
||||
|
||||
it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => {
|
||||
@@ -248,10 +619,15 @@ describe('createFixtureApi', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
hooks.appendSilent('fx-alpha', '静默丢帧')
|
||||
hooks.appendUser('fx-alpha', '正常直播')
|
||||
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
|
||||
await vi.waitFor(() => {
|
||||
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
|
||||
expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true)
|
||||
})
|
||||
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
|
||||
const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title')
|
||||
const titleControlIndex = seen.findIndex(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')
|
||||
expect(titleControlIndex).toBe(rawTitleIndex + 1)
|
||||
// But history serves the silent event (the client's repull finds it).
|
||||
const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
|
||||
if (!repull.result.ok) throw new Error('repull failed')
|
||||
@@ -273,6 +649,7 @@ describe('createFixtureApi', () => {
|
||||
describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => {
|
||||
@@ -308,9 +685,66 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
|
||||
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
|
||||
expect((await client.host.describe({})).result.ok).toBe(true)
|
||||
const goal = await client.goals.get({ sessionId: sid('fx-alpha') })
|
||||
expect(goal.result).toMatchObject({ ok: true, value: { goal: { objective: 'Ship the fixture goal bar' } } })
|
||||
expect((await client.goals.get({ sessionId: id })).result).toEqual({ ok: true, value: { goal: null } })
|
||||
expect((await client.workspace.list({})).result.ok).toBe(true)
|
||||
const workspace = await client.workspace.create({ name: 'via-client' })
|
||||
if (!workspace.result.ok) throw new Error('workspace create failed')
|
||||
expect(workspace.result.value.workspace.title).toBe('via-client')
|
||||
const wsid = workspace.result.value.workspace.workspaceId
|
||||
const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' })
|
||||
if (!renamed.result.ok) throw new Error('workspace rename failed')
|
||||
expect(renamed.result.value.workspace.title).toBe('via-client-2')
|
||||
const attached = await client.sessions.create({ workspaceId: wsid })
|
||||
if (!attached.result.ok) throw new Error('attached create failed')
|
||||
const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId })
|
||||
if (!moved.result.ok) throw new Error('workspace move failed')
|
||||
expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId])
|
||||
})
|
||||
|
||||
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
|
||||
vi.stubGlobal('location', {
|
||||
search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first',
|
||||
})
|
||||
const client = new FixtureApiClient()
|
||||
await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } })
|
||||
const made = await client.workspace.create({ name: 'query-workspace' })
|
||||
if (!made.result.ok) throw new Error('workspace create failed')
|
||||
const abort = new AbortController()
|
||||
const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const sessionId = sid('fx-query-session')
|
||||
const created = await client.sessions.create({
|
||||
workspaceId: made.result.value.workspace.workspaceId,
|
||||
sessionId,
|
||||
})
|
||||
expect(created.result).toMatchObject({ ok: true, value: { sessionId } })
|
||||
const frames = await framesPromise
|
||||
expect(frames.map(frame => frame.type)).toEqual(['host/workspace-changed', 'host/session-added'])
|
||||
const rejected = await client.sessions.prompt({
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'retain' }],
|
||||
})
|
||||
expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
|
||||
})
|
||||
|
||||
it('maps attach-failure and dropped-response query scenarios', async () => {
|
||||
vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' })
|
||||
const partial = new FixtureApiClient()
|
||||
const partialResult = await partial.sessions.create({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId: sid('fx-query-partial'),
|
||||
})
|
||||
expect(partialResult.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-attach-failed', details: { sessionId: 'fx-query-partial' } },
|
||||
})
|
||||
|
||||
vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' })
|
||||
const dropped = new FixtureApiClient()
|
||||
await expect(dropped.sessions.create({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId: sid('fx-query-dropped'),
|
||||
})).rejects.toThrow(/dropped session\.create response/)
|
||||
const ref = { id: 'fx-goal-1' as never, revision: 1 }
|
||||
expect((await client.goals.create({ sessionId: id, objective: 'x' })).result.ok).toBe(false)
|
||||
expect((await client.goals.edit({ sessionId: id, ref, objective: 'x' })).result.ok).toBe(false)
|
||||
|
||||
47
packages/client/connection/tests/http-bridge.spec.ts
Normal file
47
packages/client/connection/tests/http-bridge.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { Readable } from 'node:stream'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bridge } from '../src/http-bridge.ts'
|
||||
|
||||
describe('HTTP bridge abort', () => {
|
||||
it('aborts a pending native picker request when the browser disconnects', async () => {
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {},
|
||||
})
|
||||
const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
|
||||
Object.assign(request, {
|
||||
url: '/api/host.pickDirectory',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead() { return this },
|
||||
write() { return true },
|
||||
end() { this.writableEnded = true; return this },
|
||||
}) as unknown as ServerResponse
|
||||
|
||||
let resolveStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => { resolveStarted = resolve })
|
||||
let carrierSignal: AbortSignal | undefined
|
||||
const pending = bridge(request, response, {
|
||||
fetch: async (input) => {
|
||||
const fetchRequest = input as Request
|
||||
carrierSignal = fetchRequest.signal
|
||||
resolveStarted()
|
||||
if (!fetchRequest.signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
fetchRequest.signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
return Response.json({ aborted: fetchRequest.signal.aborted })
|
||||
},
|
||||
})
|
||||
await started
|
||||
response.emit('close')
|
||||
await pending
|
||||
expect(carrierSignal?.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts'
|
||||
|
||||
function request(
|
||||
remoteAddress: string | undefined,
|
||||
headers: IncomingHttpHeaders = {
|
||||
host: '127.0.0.1:3080',
|
||||
origin: 'http://127.0.0.1:3080',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
) {
|
||||
return { socket: { remoteAddress }, headers }
|
||||
}
|
||||
|
||||
describe('native dialog request trust', () => {
|
||||
it('accepts loopback same-origin browser requests', () => {
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('::1', {
|
||||
host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.2', {
|
||||
host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects remote sockets and requests without matching browser metadata', () => {
|
||||
expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,53 @@
|
||||
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply } from '../src/index.ts'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, inject } from '../src/index.ts'
|
||||
|
||||
describe('node half', () => {
|
||||
it('apply is a no-op host placeholder', () => {
|
||||
apply(undefined)
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
describe('connection node half', () => {
|
||||
it('registers the /api prefix route and removes it with the fiber', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
// Structural fake: the plugin only touches register(); the service class
|
||||
// carries private state a literal cannot (and need not) reproduce.
|
||||
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
ctx.provide('httpServer', httpServer as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) {
|
||||
let status: number | undefined
|
||||
let body: unknown
|
||||
const deniedRequest = {
|
||||
url,
|
||||
headers: {
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
socket: { remoteAddress: '192.168.1.8' },
|
||||
} as unknown as IncomingMessage
|
||||
const deniedResponse = {
|
||||
writeHead(value: number) { status = value; return this },
|
||||
end(value?: unknown) { body = value; return this },
|
||||
} as unknown as ServerResponse
|
||||
await routes[0]!.handler(deniedRequest, deniedResponse)
|
||||
expect(status).toBe(403)
|
||||
expect(body).toBe('forbidden')
|
||||
}
|
||||
|
||||
await fiber.dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
@@ -20,12 +15,18 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
|
||||
6
packages/client/hmr/README.i18n.yaml
Normal file
6
packages/client/hmr/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
|
||||
README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
|
||||
README.zh.md: 6d94ca4a5e91f390e58575aa4ddf64fc18a509de
|
||||
21
packages/client/hmr/README.md
Normal file
21
packages/client/hmr/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-client-hmr
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
|
||||
|
||||
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the reload driver is browser-side machinery; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
|
||||
- **No failure rollback** — a reload that fails leaves the entry FAILED and loud in the loader status projection; restoring the previous bundle automatically is deferred until a real need shows.
|
||||
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless (the bundle endpoint serves no-cache); rev refresh lands with the reconnect-handshake mechanism.
|
||||
21
packages/client/hmr/README.zh.md
Normal file
21
packages/client/hmr/README.zh.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-client-hmr
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为通过 fetch 到达的客户端插件提供热重载。该静态到达配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略此行,因此外壳打包的代码保持不活动。
|
||||
|
||||
浏览器侧订阅系统 SSE 通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber 之前执行:只释放 fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、以 `fiber.await()` 将启动失败高声重新抛出。依赖方由 cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR,无需 builder→host 通道。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。重载驱动器属于浏览器侧机制;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(connection/runtime fiber、Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中高声报告;自动恢复先前组合包会等到实际需要出现后再实现。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新会随重新连接握手机制落地。
|
||||
56
packages/client/hmr/package.json
Normal file
56
packages/client/hmr/package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-hmr",
|
||||
"description": "Dev-only hot-reload driver for fetch-arrival client entries: SSE rebuilt frames → prefetch/invalidate → fiber swap through the vendored Loader entry",
|
||||
"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": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-client-modules": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
182
packages/client/hmr/src/client/index.ts
Normal file
182
packages/client/hmr/src/client/index.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* client-hmr, browser half: hot-reload driver for client plugin entries.
|
||||
*
|
||||
* Listens on the host's system SSE channel (`GET /plugins/events`); on a
|
||||
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
|
||||
* fiber in place. Every graph entry is a plugin bundle under the web2 model
|
||||
* — `immediately` rows differ only in stage-one prefetch (a boot
|
||||
* optimization), so all rostered plugin packages share these reload semantics;
|
||||
* normal packages (react family, cordis, shell, pure libs) are not entries
|
||||
* and shell changes still mean a page reload. Cascade is zero-touch:
|
||||
* downstream fibers key their activation epoch on provider fiber uids
|
||||
* (vendor/cordis/src/fiber.ts `_refresh`), so replacing a provider fiber
|
||||
* re-cascades natively — reloading a data-layer plugin (connection/runtime)
|
||||
* cascades into its UI dependents with no HMR-side bookkeeping.
|
||||
*
|
||||
* Reload order (lazy CJS table): invalidate (drop the stale factory and
|
||||
* materialized record) → prefetch (fetch + execute + register the fresh
|
||||
* factory) → registry-first teardown → drain old fiber unload → remove
|
||||
* owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
|
||||
* factory. Invalidate MUST precede prefetch: a live factory makes prefetch
|
||||
* a no-op, and re-executing a bundle over an undeleted registration is a
|
||||
* loud duplicate. The swap is safe because execution is pure registration
|
||||
* under the lazy model — every module side effect (CSS injection included)
|
||||
* lives in the factory closure and runs at materialization, inside
|
||||
* refresh(). That also keeps the CSS ordering guarantee: owned styles are
|
||||
* removed after the old fiber's disposers drained (SlotCore one-owner
|
||||
* unregister) and before materialization re-injects tags under the same
|
||||
* stable tag ids.
|
||||
*
|
||||
* Failure window: if prefetch rejects after invalidate, the module is left
|
||||
* unregistered while the OLD fiber keeps running untouched (teardown never
|
||||
* started) — degraded but recoverable, the next rebuilt frame retries from
|
||||
* scratch. Consistent with the v1 no-rollback policy below. Known dev-only
|
||||
* race: a rebuilt frame overlapping a still-in-flight boot arrival shares
|
||||
* that arrival's task and may materialize the pre-rebuild bytes; the next
|
||||
* rebuilt frame self-heals.
|
||||
*
|
||||
* Why not the naive `entry.fiber.dispose()` → `entry.refresh()` path —
|
||||
* confirmed against vendor sources:
|
||||
* 1. `Entry.fiber` is never cleared on dispose (vendor/loader/src/config/
|
||||
* entry.ts assigns it only in `_init`), so `refresh()` hits its
|
||||
* `if (this.fiber) return` guard and no-ops.
|
||||
* 2. A bare `fiber.dispose()` lands in Loader's self-dispose branch
|
||||
* (vendor/loader/src/index.ts `internal/plugin` case 4: the registry
|
||||
* still holds the runtime at emit time), which flags the entry
|
||||
* `disabled: true` — permanently.
|
||||
* vendor/hmr's reload skeleton documents the fix: delete the runtime record
|
||||
* FIRST (`registry.delete` → case 4 returns early, the entry stays enabled),
|
||||
* then rebuild. We additionally clear `entry.fiber` ourselves so
|
||||
* `entry.refresh()` re-imports and re-plugins through the Loader's own
|
||||
* `_init` (entry-resolved config, automatic `fiber.entry` rebinding) instead
|
||||
* of hand-rolling `registry.plugin`. Client entries have exactly one fiber
|
||||
* per runtime, so `registry.delete` never collaterally disposes siblings.
|
||||
*
|
||||
* Self-reload: this plugin is itself a graph entry, so a rebuilt frame may
|
||||
* name it. The in-flight reload keeps running in the old bundle's closure
|
||||
* (its EventSource closes with the old fiber's effects); the new bundle's
|
||||
* apply opens a fresh channel. Frames arriving during the gap are lost —
|
||||
* acceptable for the dev channel, the next rebuild renotifies.
|
||||
*
|
||||
* Failure policy (v1): no rollback. An import failure leaves the entry
|
||||
* fiberless (the next rebuilt frame retries from scratch); an apply failure
|
||||
* leaves a FAILED fiber for the shell's status projection. Both log loudly.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { Entry, Loader } from '@cordisjs/plugin-loader'
|
||||
import type { PluginsEventFrame } from '../events.ts'
|
||||
import { EVENTS_ENDPOINT } from '../events.ts'
|
||||
|
||||
export type { PluginsEventFrame } from '../events.ts'
|
||||
export { EVENTS_ENDPOINT } from '../events.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'client-hmr'
|
||||
|
||||
/** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */
|
||||
export const inject = ['loader', 'modules']
|
||||
|
||||
/** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */
|
||||
function findEntry(loader: Loader, id: string): Entry | undefined {
|
||||
for (const entry of loader.entries()) {
|
||||
if (entry.options.name === id) return entry
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Remove every `<style data-plugin>` tag owned by `id` (attribute compared verbatim — no CSS-selector escaping pitfalls). */
|
||||
function removeOwnedStyles(id: string): void {
|
||||
for (const el of document.querySelectorAll('style[data-plugin]')) {
|
||||
if (el.getAttribute('data-plugin') === id) el.remove()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the HMR driver: subscribe to the system SSE channel and hot-swap
|
||||
* rebuilt entries.
|
||||
* @param ctx - plugin context with `loader` and `modules` available.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// Both are declared injections (typed Context merges: `modules` from the
|
||||
// client module loader package, `loader` from the vendored Loader).
|
||||
const modLoader = ctx.modules
|
||||
const loader: Loader = ctx.loader
|
||||
|
||||
async function reload(id: string): Promise<void> {
|
||||
const entry = findEntry(loader, id)
|
||||
if (entry === undefined) {
|
||||
ctx.logger.warn(`client-hmr: rebuilt frame for unknown entry "${id}" (not in the loader tree)`)
|
||||
return
|
||||
}
|
||||
// Invalidate first (drop stale factory + record — a live factory makes
|
||||
// prefetch a no-op and re-registration a loud duplicate), then run the
|
||||
// async half while the old fiber still serves: fetch + execute registers
|
||||
// the fresh factory with zero side effects (lazy CJS — module bodies run
|
||||
// at materialization, not execution).
|
||||
modLoader.invalidate(id)
|
||||
await modLoader.prefetch(id)
|
||||
|
||||
const oldFiber = entry.fiber
|
||||
if (oldFiber !== undefined) {
|
||||
// Registry-first teardown (see module comment): the runtime record must
|
||||
// be gone before the fiber's disposer emits internal/plugin, or the
|
||||
// Loader flags the entry disabled.
|
||||
const runtime = oldFiber.runtime
|
||||
if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
|
||||
// Drain the unload: effect disposers (slots, subscriptions) must finish
|
||||
// before the new bundle executes and the new apply re-registers.
|
||||
while (oldFiber.inertia !== undefined) await oldFiber.inertia
|
||||
delete entry.fiber
|
||||
}
|
||||
// Old owned styles go before materialization re-injects them (the CSS
|
||||
// idempotency guard keys on stable tag ids).
|
||||
removeOwnedStyles(id)
|
||||
// Re-init through the entry: fiber cleared above, so refresh() re-imports
|
||||
// — materializing the prefetched factory (CSS injects here) — and
|
||||
// re-plugins under the entry context. Import failures are logged by
|
||||
// Entry._init and leave the entry fiberless (retryable).
|
||||
await entry.refresh()
|
||||
// Surface apply failures loudly (v1: no rollback, FAILED state stays).
|
||||
await entry.fiber?.await()
|
||||
}
|
||||
|
||||
// Serialize reloads: frames can arrive faster than a swap completes, and
|
||||
// interleaved dispose/execute chains would corrupt the single-slot handoff.
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
const handle = (frame: PluginsEventFrame): void => {
|
||||
switch (frame.type) {
|
||||
case 'rebuilt':
|
||||
queue = queue.then(() => reload(frame.id)).catch((error: unknown) => {
|
||||
ctx.logger.error(`client-hmr: reload of "${frame.id}" failed`)
|
||||
ctx.logger.error(error)
|
||||
})
|
||||
break
|
||||
case 'graph':
|
||||
// Connect-time snapshot, unused in v1. The loader's cached graph rev
|
||||
// goes stale after rebuilds — harmless, since prefetch hits the
|
||||
// network anyway (host serves bundles no-cache); graph rev refresh
|
||||
// lands with the reconnect-handshake mechanism.
|
||||
break
|
||||
default:
|
||||
// Merge-extensible frame union: unknown frame types from newer hosts
|
||||
// are ignored by design.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
const source = new EventSource(EVENTS_ENDPOINT)
|
||||
source.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
let frame: PluginsEventFrame
|
||||
try {
|
||||
frame = JSON.parse(event.data) as PluginsEventFrame
|
||||
} catch {
|
||||
// Wire boundary: a malformed dev-channel frame is dropped loudly.
|
||||
ctx.logger.warn(`client-hmr: unparseable event frame: ${event.data}`)
|
||||
return
|
||||
}
|
||||
handle(frame)
|
||||
})
|
||||
return () => { source.close() }
|
||||
}, 'client-hmr: event source')
|
||||
}
|
||||
16
packages/client/hmr/src/events.ts
Normal file
16
packages/client/hmr/src/events.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Wire protocol of the `/plugins/events` dev SSE channel — single source for
|
||||
* both halves of this package. Frames still cross a wire boundary: the
|
||||
* browser half validates them at its JSON parse point; sharing the type keeps
|
||||
* the two ends from drifting, not from parsing.
|
||||
*/
|
||||
|
||||
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
/** One SSE frame: the full graph on connect, or one rebuilt bundle notice. */
|
||||
export type PluginsEventFrame =
|
||||
| { type: 'graph'; graph: WebBootGraph }
|
||||
| { type: 'rebuilt'; id: string; rev: string }
|
||||
|
||||
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
|
||||
export const EVENTS_ENDPOINT = '/plugins/events'
|
||||
189
packages/client/hmr/src/index.ts
Normal file
189
packages/client/hmr/src/index.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* HMR plugin, node half: the host end of the dev reload chain. One interval
|
||||
* stat-polls every graph row's client bundle (polling by design: network
|
||||
* mounts deliver no inotify events), reports content changes through
|
||||
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
|
||||
* broadcasting graph/rebuilt frames to the browser half (src/client/).
|
||||
* Dev-only row: prod compositions never mount this plugin.
|
||||
*/
|
||||
import { statSync } from 'node:fs'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
// Empty type imports carry the clientModuleHost/httpServer Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-client-modules'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { PluginsEventFrame } from './events.ts'
|
||||
import { EVENTS_ENDPOINT } from './events.ts'
|
||||
|
||||
export type { PluginsEventFrame } from './events.ts'
|
||||
export { EVENTS_ENDPOINT } from './events.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'client-hmr'
|
||||
|
||||
/** Required services: the web plugin table and the route registry. */
|
||||
export const inject = ['clientModuleHost', 'httpServer']
|
||||
|
||||
/** Plugin config, validated by the same-named schemastery schema. */
|
||||
export interface Config {
|
||||
/** Bundle stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */
|
||||
pollIntervalMs?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
pollIntervalMs: z.number().step(1).min(1).default(500),
|
||||
})
|
||||
|
||||
/** Serialize one frame as an SSE data line. */
|
||||
function sseData(frame: PluginsEventFrame): string {
|
||||
return `data: ${JSON.stringify(frame)}\n\n`
|
||||
}
|
||||
|
||||
interface WatchedBundle {
|
||||
path: string
|
||||
mtimeMs: number
|
||||
size: number
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
|
||||
* @param ctx - host plugin context carrying clientModuleHost and httpServer.
|
||||
* @param config - validated {@link Config}.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery's .default() guarantees the field is set after validation.
|
||||
const pollIntervalMs = config.pollIntervalMs as number
|
||||
|
||||
// --- bundle watch: one HMR-owned stat poll ------------------------------
|
||||
const watched = new Map<string, WatchedBundle>()
|
||||
|
||||
const rehash = (id: string, watch: WatchedBundle, current: { mtimeMs: number; size: number }): void => {
|
||||
try {
|
||||
// rebuilt() re-hashes; an unchanged hash stays silent (clientModuleHost
|
||||
// fires onRebuilt only on a real rev change).
|
||||
ctx.clientModuleHost.rebuilt(id)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') {
|
||||
watch.dirty = true
|
||||
return
|
||||
}
|
||||
ctx.logger.warn(error)
|
||||
}
|
||||
watch.mtimeMs = current.mtimeMs
|
||||
watch.size = current.size
|
||||
watch.dirty = false
|
||||
}
|
||||
|
||||
const watchRow = (id: string, path: string): void => {
|
||||
let baseline: { mtimeMs: number; size: number }
|
||||
try {
|
||||
baseline = statSync(path)
|
||||
} catch (error) {
|
||||
watched.set(id, { path, mtimeMs: 0, size: 0, dirty: true })
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
|
||||
return
|
||||
}
|
||||
const watch = { path, mtimeMs: baseline.mtimeMs, size: baseline.size, dirty: false }
|
||||
watched.set(id, watch)
|
||||
// The module host hashed before publishing the graph. Re-hash immediately
|
||||
// after capturing this baseline so a write in between cannot become an
|
||||
// already-current baseline paired with a stale graph rev.
|
||||
rehash(id, watch, baseline)
|
||||
}
|
||||
|
||||
const pollWatches = (): void => {
|
||||
for (const [id, watch] of watched) {
|
||||
let current: { mtimeMs: number; size: number }
|
||||
try {
|
||||
current = statSync(watch.path)
|
||||
} catch (error) {
|
||||
watch.dirty = true
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
|
||||
continue
|
||||
}
|
||||
if (!watch.dirty && current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue
|
||||
// Stat-before-hash preserves a detectable older baseline for writes that
|
||||
// land during hashing. Repeated stat changes heal a torn read.
|
||||
rehash(id, watch, current)
|
||||
}
|
||||
}
|
||||
|
||||
// Diff the watch set against the current graph: drop watches for removed
|
||||
// rows (or rows whose bundle path moved), add watches for new rows.
|
||||
const syncWatches = (): void => {
|
||||
const rows = new Map<string, string>()
|
||||
for (const row of ctx.clientModuleHost.graph().entries) {
|
||||
const path = ctx.clientModuleHost.clientPath(row.id)
|
||||
if (path !== undefined) rows.set(row.id, path)
|
||||
}
|
||||
for (const [id, watch] of watched) {
|
||||
if (rows.get(id) === watch.path) continue
|
||||
watched.delete(id)
|
||||
}
|
||||
for (const [id, path] of rows) {
|
||||
if (!watched.has(id)) watchRow(id, path)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
// Initial sync covers rows already in the graph; the subscription covers
|
||||
// rows arriving later (boot-window activations, including this plugin's
|
||||
// own row — no self-exemption, a modules/hmr rebuild rides the same chain).
|
||||
syncWatches()
|
||||
const unsubscribe = ctx.clientModuleHost.onGraphChanged(syncWatches)
|
||||
const timer = setInterval(pollWatches, pollIntervalMs)
|
||||
timer.unref()
|
||||
return () => {
|
||||
unsubscribe()
|
||||
clearInterval(timer)
|
||||
watched.clear()
|
||||
}
|
||||
}, 'client-hmr: bundle watches')
|
||||
|
||||
// --- /plugins/events SSE channel ----------------------------------------
|
||||
const connections = new Set<ServerResponse>()
|
||||
|
||||
const connect = (res: ServerResponse): void => {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
'connection': 'keep-alive',
|
||||
})
|
||||
// Comment line on open so clients/proxies see a live channel even when
|
||||
// no rebuild ever happens; EventSource frame parsing skips it naturally.
|
||||
res.write(': connected\n\n')
|
||||
res.write(sseData({ type: 'graph', graph: ctx.clientModuleHost.graph() }))
|
||||
connections.add(res)
|
||||
res.on('close', () => { connections.delete(res) })
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
const disposeRoute = ctx.httpServer.register({
|
||||
kind: 'exact',
|
||||
path: EVENTS_ENDPOINT,
|
||||
handler: (req, res) => {
|
||||
// Named routes match ahead of the carrier's method gate; keep the old
|
||||
// global 405 semantics for non-GET hits on this endpoint.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
connect(res)
|
||||
},
|
||||
})
|
||||
const unsubscribe = ctx.clientModuleHost.onRebuilt((id, rev) => {
|
||||
const line = sseData({ type: 'rebuilt', id, rev })
|
||||
for (const res of connections) res.write(line)
|
||||
})
|
||||
return () => {
|
||||
unsubscribe()
|
||||
disposeRoute()
|
||||
for (const res of connections) res.destroy()
|
||||
connections.clear()
|
||||
}
|
||||
}, 'client-hmr: /plugins/events channel')
|
||||
}
|
||||
59
packages/client/hmr/src/invariant.ts
Normal file
59
packages/client/hmr/src/invariant.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-hmr`.
|
||||
* @module @deepseek-ai/dsh-client-hmr/invariant
|
||||
*/
|
||||
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-hmr-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Live fs.watchFile pollers (this package is the composition's only stat-poll user). */
|
||||
function statWatchers(): number {
|
||||
return process.getActiveResourcesInfo().filter(kind => kind === 'StatWatcher').length
|
||||
}
|
||||
|
||||
/**
|
||||
* Owned relation: every bundle stat watcher the node half starts must die
|
||||
* with its fiber — a surviving poller would keep re-hashing bundles for a
|
||||
* torn-down dev chain forever. Checked as a baseline delta: the StatWatcher
|
||||
* count observed at fiber creation must be restored once disposal has drained
|
||||
* the fiber's effects (`internal/plugin` fires at dispose start; the microtask
|
||||
* hop lets the disposer queue its unload before `fiber.await()` joins it).
|
||||
* SSE-connection and listener teardown live inside the same ctx.effect
|
||||
* disposers, so the watcher count is the relation's observable proxy.
|
||||
*/
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
const baselines = new WeakMap<Fiber, number>()
|
||||
// Async listener by design: emitPluginDisposed awaits-and-logs returned
|
||||
// promises, so a violation surfaces loudly instead of unhandled.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
ctx.on('internal/plugin', async (fiber) => {
|
||||
if (fiber.name !== 'client-hmr') return
|
||||
if (fiber.uid !== null) {
|
||||
baselines.set(fiber, statWatchers())
|
||||
return
|
||||
}
|
||||
const baseline = baselines.get(fiber)
|
||||
if (baseline === undefined) return
|
||||
await Promise.resolve()
|
||||
await fiber.await()
|
||||
const remaining = statWatchers()
|
||||
if (remaining > baseline) {
|
||||
fail(`client-hmr fiber disposed but ${remaining - baseline} bundle stat watcher(s) survived teardown`)
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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))
|
||||
204
packages/client/hmr/tests/node-half.spec.ts
Normal file
204
packages/client/hmr/tests/node-half.spec.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Node half of the HMR plugin: bundle watches follow the graph, stat changes
|
||||
* report through clientModuleHost.rebuilt, and everything dies with the fiber.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules'
|
||||
import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts'
|
||||
|
||||
const POLL_MS = 20
|
||||
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) })
|
||||
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
||||
|
||||
/**
|
||||
* Controllable clientModuleHost fake over a mutable id → bundle-path table.
|
||||
* Structural (Pick+cast): the plugin only touches the read/notify surface;
|
||||
* the service class carries private scan state a literal need not reproduce.
|
||||
*/
|
||||
type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void }
|
||||
interface FakeHostOptions {
|
||||
beforeGraphRead?: () => void
|
||||
rebuilt?: (id: string) => string | undefined
|
||||
}
|
||||
|
||||
function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
|
||||
const graphListeners = new Set<() => void>()
|
||||
const rebuiltCalls: string[] = []
|
||||
const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
|
||||
rebuiltCalls,
|
||||
fireGraphChanged: () => { for (const l of graphListeners) l() },
|
||||
graph: (): WebBootGraph => {
|
||||
options.beforeGraphRead?.()
|
||||
return {
|
||||
rev: 'r',
|
||||
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
|
||||
}
|
||||
},
|
||||
clientPath: id => rows.get(id),
|
||||
rebuilt: (id) => {
|
||||
rebuiltCalls.push(id)
|
||||
return options.rebuilt?.(id) ?? 'r2'
|
||||
},
|
||||
onRebuilt: () => () => {},
|
||||
onGraphChanged: (listener) => {
|
||||
graphListeners.add(listener)
|
||||
return () => { graphListeners.delete(listener) }
|
||||
},
|
||||
}
|
||||
return fake as FakeHost
|
||||
}
|
||||
|
||||
// Structural fake: the plugin only touches register(); the service class
|
||||
// carries private state a literal cannot (and need not) reproduce.
|
||||
function fakeHttpServer(routes: WebRoute[]): HttpServerService {
|
||||
const fake: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
return fake as HttpServerService
|
||||
}
|
||||
|
||||
async function mount(clientModuleHost: FakeHost, httpServer: HttpServerService) {
|
||||
const ctx = new Context()
|
||||
ctx.provide('clientModuleHost', clientModuleHost)
|
||||
ctx.provide('httpServer', httpServer)
|
||||
const fiber = ctx.plugin(
|
||||
{ inject: [...inject], Config, apply },
|
||||
{ pollIntervalMs: POLL_MS },
|
||||
)
|
||||
await fiber.await()
|
||||
return fiber
|
||||
}
|
||||
|
||||
describe('hmr node half', () => {
|
||||
it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
|
||||
const bundle = join(dir, 'a.js')
|
||||
writeFileSync(bundle, 'v1')
|
||||
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
|
||||
const routes: WebRoute[] = []
|
||||
const fiber = await mount(clientModuleHost, fakeHttpServer(routes))
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
|
||||
expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
|
||||
// Nudge mtime past stat granularity so the poller sees a content signal.
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
||||
writeFileSync(bundle, 'v2-longer')
|
||||
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
|
||||
|
||||
await fiber.dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
// Watcher gone: further file changes report nothing.
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
writeFileSync(bundle, 'v3-even-longer')
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
|
||||
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('follows graph changes: rows added after activation get watched', async () => {
|
||||
const early = join(dir, 'early.js')
|
||||
const late = join(dir, 'late.js')
|
||||
writeFileSync(early, 'v1')
|
||||
const rows = new Map([['pkg-early', early]])
|
||||
const clientModuleHost = fakeClientModuleHost(rows)
|
||||
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
|
||||
writeFileSync(late, 'v1')
|
||||
rows.set('pkg-late', late)
|
||||
clientModuleHost.fireGraphChanged()
|
||||
expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-late'])
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
||||
writeFileSync(late, 'v2-longer')
|
||||
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
|
||||
|
||||
rows.delete('pkg-late')
|
||||
clientModuleHost.fireGraphChanged()
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
writeFileSync(late, 'v3-even-longer')
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
|
||||
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('rehashes after baseline capture so a construction-window write cannot become the baseline', async () => {
|
||||
const bundle = join(dir, 'construction.js')
|
||||
writeFileSync(bundle, 'v1')
|
||||
let rewrite = true
|
||||
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
|
||||
beforeGraphRead: () => {
|
||||
if (!rewrite) return
|
||||
rewrite = false
|
||||
// The graph carries the hash from before this write. The old
|
||||
// fs.watchFile registration asynchronously captured the new file as
|
||||
// its first baseline and never requested a re-hash.
|
||||
writeFileSync(bundle, 'v2-written-during-watch-construction')
|
||||
},
|
||||
})
|
||||
|
||||
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
||||
|
||||
expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
|
||||
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('marks a vanished bundle dirty so identical metadata still re-hashes after it reappears', async () => {
|
||||
const bundle = join(dir, 'replace.js')
|
||||
writeFileSync(bundle, 'seed')
|
||||
const fixedTime = new Date(1_600_000_000_000)
|
||||
utimesSync(bundle, fixedTime, fixedTime)
|
||||
const baseline = statSync(bundle)
|
||||
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
|
||||
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
||||
clientModuleHost.rebuiltCalls.length = 0
|
||||
|
||||
unlinkSync(bundle)
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
|
||||
writeFileSync(bundle, 'x'.repeat(baseline.size))
|
||||
utimesSync(bundle, fixedTime, fixedTime)
|
||||
const restored = statSync(bundle)
|
||||
expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({
|
||||
mtimeMs: baseline.mtimeMs,
|
||||
size: baseline.size,
|
||||
})
|
||||
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a']) }, { timeout: 3_000 })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('retains a dirty baseline when the immediate re-hash races a rename', async () => {
|
||||
const bundle = join(dir, 'rename.js')
|
||||
writeFileSync(bundle, 'v1')
|
||||
let first = true
|
||||
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
|
||||
rebuilt: () => {
|
||||
if (!first) return 'r2'
|
||||
first = false
|
||||
throw Object.assign(new Error('bundle renamed'), { code: 'ENOENT' })
|
||||
},
|
||||
})
|
||||
|
||||
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
|
||||
|
||||
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a', 'pkg-a']) }, { timeout: 3_000 })
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
36
packages/client/hmr/tsconfig.json
Normal file
36
packages/client/hmr/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../modules"
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/hmr/tsdown.config.ts
Normal file
3
packages/client/hmr/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-hmr', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -1,16 +0,0 @@
|
||||
# @deepseek-ai/dsh-client-i18n
|
||||
|
||||
i18n plugin: I18nService (ns×locale dictionaries, bind(ns)→t with a stable function identity, locale store). Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the i18n registry serves browser UI copy; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **zh/en ship as empty structures** — the existing UI copy is inline Chinese; extraction into dictionaries is deferred repo-wide work, so `bind(ns)` consumers today mostly receive key-echo fallbacks.
|
||||
- **Locale switching re-renders the whole tree** — accepted as a low-frequency operation; no per-namespace subscription granularity.
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* i18n plugin, browser half: namespace x locale dictionary registry with a
|
||||
* bound translate function whose reference is stable (safe for inject
|
||||
* surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries.
|
||||
* Contract: api-contracts v3 section 8.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { en } from '../locales/en.ts'
|
||||
import { zh } from '../locales/zh.ts'
|
||||
|
||||
/** Translate a key with optional params. */
|
||||
export type Translate = (key: string, params?: Record<string, unknown>) => string
|
||||
|
||||
/** Locale dictionary: flat key to template string ({name} placeholders). */
|
||||
export type LocaleDict = Record<string, string>
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
i18n: I18nService
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback locale consulted after the active locale misses. */
|
||||
export const FALLBACK_LOCALE = 'zh'
|
||||
|
||||
/** Shared namespace for shell-level texts. */
|
||||
export const COMMON_NS = 'common'
|
||||
|
||||
/**
|
||||
* Dictionary registry plus locale switch. Lookup chain per key: active locale
|
||||
* -> zh fallback -> the key itself (missing text stays visible, fail loud in
|
||||
* the UI rather than blank).
|
||||
*/
|
||||
export class I18nService {
|
||||
private dicts = new Map<string, Map<string, LocaleDict>>()
|
||||
private bound = new Map<string, Translate>()
|
||||
private localeStore = createSnapshotStore<string>(FALLBACK_LOCALE)
|
||||
|
||||
/**
|
||||
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
|
||||
* throws (single occupant; a namespace's texts have one owner).
|
||||
* @param ns - namespace.
|
||||
* @param locale - locale tag (zh/en to start).
|
||||
* @param dict - dictionary.
|
||||
* @returns disposer (idempotent).
|
||||
*/
|
||||
register(ns: string, locale: string, dict: LocaleDict): () => void {
|
||||
let locales = this.dicts.get(ns)
|
||||
if (!locales) {
|
||||
locales = new Map()
|
||||
this.dicts.set(ns, locales)
|
||||
}
|
||||
if (locales.has(locale)) throw new Error(`i18n namespace "${ns}" already has locale "${locale}"`)
|
||||
locales.set(locale, dict)
|
||||
return () => {
|
||||
const owner = this.dicts.get(ns)
|
||||
if (owner?.get(locale) === dict) owner.delete(locale)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a namespace to a translate function. The returned reference is
|
||||
* stable per namespace (repeat binds return the same function), so it can
|
||||
* ride inject surfaces without breaking memoization.
|
||||
* @param ns - namespace.
|
||||
* @returns the translate function (reads the locale store at call time).
|
||||
*/
|
||||
bind(ns: string): Translate {
|
||||
let t = this.bound.get(ns)
|
||||
if (!t) {
|
||||
t = (key, params) => this.translate(ns, key, params)
|
||||
this.bound.set(ns, t)
|
||||
return t
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/** Active locale store (switching re-renders the tree; low frequency). */
|
||||
get locale(): SnapshotStore<string> {
|
||||
return this.localeStore
|
||||
}
|
||||
|
||||
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
|
||||
const locales = this.dicts.get(ns)
|
||||
const template = locales?.get(this.localeStore.getSnapshot())?.[key]
|
||||
?? locales?.get(FALLBACK_LOCALE)?.[key]
|
||||
?? key
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in params ? String(params[name]) : match)
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services (none; the loader passes the export surface as an object plugin). */
|
||||
export const inject: string[] = []
|
||||
|
||||
/**
|
||||
* Client plugin body: provide the i18n service with base dictionaries.
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const i18n = new I18nService()
|
||||
i18n.register(COMMON_NS, 'zh', zh)
|
||||
i18n.register(COMMON_NS, 'en', en)
|
||||
ctx.provide('i18n', i18n)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* i18n plugin, node half. Pure UI plugin: the empty apply exists so the
|
||||
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
|
||||
* the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration). Everything else —
|
||||
* I18nService, Translate, LocaleDict — lives in the client half; consumers
|
||||
* import the /client subpath. Contract: api-contracts v3 section 8.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the i18n plugin. */
|
||||
export function apply(): void {}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
|
||||
|
||||
describe('I18nService', () => {
|
||||
it('translates from the active locale with zh fallback then key passthrough', () => {
|
||||
const i18n = new I18nService()
|
||||
i18n.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
|
||||
i18n.register('ns', 'en', { hello: 'Hello' })
|
||||
const t = i18n.bind('ns')
|
||||
expect(i18n.locale.getSnapshot()).toBe('zh')
|
||||
expect(t('hello')).toBe('你好')
|
||||
i18n.locale.set('en')
|
||||
expect(t('hello')).toBe('Hello')
|
||||
expect(t('onlyZh')).toBe('仅中文')
|
||||
expect(t('missing.key')).toBe('missing.key')
|
||||
})
|
||||
|
||||
it('interpolates {name} params and leaves unknown placeholders intact', () => {
|
||||
const i18n = new I18nService()
|
||||
i18n.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
|
||||
const t = i18n.bind('ns')
|
||||
expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次')
|
||||
expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}')
|
||||
expect(t('greet')).toBe('你好,{name}!第 {n} 次')
|
||||
})
|
||||
|
||||
it('bind returns a stable reference per namespace', () => {
|
||||
const i18n = new I18nService()
|
||||
expect(i18n.bind('a')).toBe(i18n.bind('a'))
|
||||
expect(i18n.bind('a')).not.toBe(i18n.bind('b'))
|
||||
})
|
||||
|
||||
it('duplicate (ns, locale) throws; disposer unregisters and is idempotent', () => {
|
||||
const i18n = new I18nService()
|
||||
const dispose = i18n.register('ns', 'zh', { k: 'v1' })
|
||||
expect(() => i18n.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
|
||||
dispose()
|
||||
dispose()
|
||||
const t = i18n.bind('ns')
|
||||
expect(t('k')).toBe('k')
|
||||
i18n.register('ns', 'zh', { k: 'v2' })
|
||||
expect(t('k')).toBe('v2')
|
||||
})
|
||||
|
||||
it('locale store is subscribable (snapshot store contract)', () => {
|
||||
const i18n = new I18nService()
|
||||
let notified = 0
|
||||
i18n.locale.subscribe(() => { notified += 1 })
|
||||
i18n.locale.set('en')
|
||||
expect(i18n.locale.getSnapshot()).toBe('en')
|
||||
expect(notified).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-i18n'
|
||||
import { apply as clientApply, COMMON_NS, I18nService, inject } from '@deepseek-ai/dsh-client-i18n/client'
|
||||
import * as I18nInvariant from '@deepseek-ai/dsh-client-i18n/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(I18nInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', () => {
|
||||
nodeApply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
|
||||
it('client apply provides ctx.i18n seeded with the zh/en common namespace', async () => {
|
||||
expect(inject).toEqual([])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
const i18n = ctx.get('i18n')
|
||||
expect(i18n).toBeInstanceOf(I18nService)
|
||||
// Seeded dictionaries occupy the (ns, locale) seats even while empty.
|
||||
expect(() => (i18n as I18nService).register(COMMON_NS, 'zh', {})).toThrow('already has locale')
|
||||
expect(() => (i18n as I18nService).register(COMMON_NS, 'en', {})).toThrow('already has locale')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-i18n', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
6
packages/client/locale/README.i18n.yaml
Normal file
6
packages/client/locale/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
|
||||
README.md: 9015af2b44a33771b06863ace139fe97695df616
|
||||
README.zh.md: 6b129bcabbef5b5a00c5073ebc9142a0e406ddba
|
||||
18
packages/client/locale/README.md
Normal file
18
packages/client/locale/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @deepseek-ai/dsh-client-locale
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the locale registry serves browser UI copy; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred.
|
||||
- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount.
|
||||
18
packages/client/locale/README.zh.md
Normal file
18
packages/client/locale/README.zh.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @deepseek-ai/dsh-client-locale
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 为键持久化;提供 getter/setter,并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。locale 注册表为浏览器 UI 文案提供服务;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。
|
||||
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的分区会保留已渲染文本,直到重新挂载。
|
||||
62
packages/client/locale/package.json
Normal file
62
packages/client/locale/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-locale",
|
||||
"description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row",
|
||||
"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-client-runtime"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user