diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index ffb3afa51a..4e105ef598 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -18,7 +18,7 @@ Persistence is an abstract **capability seam** ([capability seams](2026-06-13-ca Key choices recorded here because they are durable, contested, and surprising: - **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. -- **Append-only; a crashed turn is closed, never truncated.** Events through a flushed `turn/end` are never rewritten, and the loop flushes only at turn end. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends error results for unanswered tool calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. +- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. - **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) - **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. diff --git a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md index ece39654ea..0a6d9b85b1 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md +++ b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md @@ -8,13 +8,17 @@ The ACP bridge gives every session its own workspace: `session/new` records the Filesystem resolution used one plugin-load cwd while bash used the session project directory. Relative paths therefore disagreed whenever the editor project differed from the server launch directory; snapshots hid the bug by making those paths identical. +A valid absolute cwd can itself have two apparent parents: when it contains `symlink/..`, filesystem lookup follows the symlink before applying `..`, while `path.resolve()` erases both components lexically. Resolving sandbox policy lexically while launching bash from the raw cwd granted the unrelated lexical parent, denied writes in the real workspace, and let filesystem tools resolve relative paths into the wrong directory. + +An ordinary symlink cwd exposes the same distinction when the requested relative path contains `..`: a process traverses from the symlink's physical target, while `path.resolve(cwd, path)` traverses from its lexical spelling. Reads would therefore select a different file than bash or a sandboxed mutation for the same model-supplied path. + ## Decision -Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent. +Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. When either the cwd or the requested path contains a parent segment, resolve the cwd to its native filesystem identity before any lexical join; ordinary cwd spellings stay stable for display when no traversal makes their identity observable. Reuse the resolved sandbox-policy root for mutations and sandboxed bash calls so one call has one workspace identity. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent. - `FileSystem.resolve` accepts `resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. `opts.signal` cancels resolution when the backend performs I/O. The options object keeps both caller-owned resolution controls together without positional growth. - `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies none (non-ACP / no-session use, and the single-session stdio demo where `process.cwd()` IS the workspace). -- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. A non-agent / headerless caller yields `undefined`, so the backend applies its default. +- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec, requestedPath)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. The helper uses native realpath semantics when a parent segment in either value could cross a symlink while retaining ordinary spellings otherwise; a sandboxed mutation reuses the complete policy's `workspaceRoot`; a non-agent / headerless caller yields `undefined`, so the backend applies its default. ## Alternatives considered @@ -27,6 +31,7 @@ The default lives in ONE place — the provider's `config.cwd`. `sessionCwd` ret ## Consequences - In the ACP demo the fs tools and bash now agree on each session's workspace; an editor can open any project folder and both tool families act on it. +- A session cwd containing `symlink/..`, or an ordinary symlink cwd paired with a parent-traversing relative path, resolves from the same physical workspace for bash, filesystem tools, and the sandbox grant; the lexical parent receives no grant. - No change to `FsTarget` identity: `targetKey` is still the realpath of the resolved absolute path, so observed-state keying and symlink identity are unaffected — a correct per-session cwd produces the same key bash targets. - Backward compatible: every existing `resolve(path)` call (all in tests) keeps working; the new argument is optional. - The single-session stdio demo is unaffected: it supplies no session cwd (its agent's session has no `cwd`), so resolution falls back to `config.cwd = process.cwd()`, which is the workspace. diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml new file mode 100644 index 0000000000..5556ed5fa1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml @@ -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 +2026-07-21-semantic-session-checkpoints.md: 4bca02fe3893ac39621ed79a000ca8f86db4ff67 +2026-07-21-semantic-session-checkpoints.zh.md: 1f187eb6448a3c9ca6784ec2bddd7295be2706d7 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md new file mode 100644 index 0000000000..4bca02fe38 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md @@ -0,0 +1,29 @@ +# Agent Note: Semantic session checkpoints + +Status: implemented + +English | [中文](2026-07-21-semantic-session-checkpoints.zh.md) + +## Problem + +Persistence buffered every synchronous `session/event` until the loop's final turn checkpoint. A turn is the correct conversational transaction, but it is too coarse as the only crash-recovery point: a hard crash during a long model request or tool call could discard the whole in-flight turn, including the request envelope needed to identify what had been attempted. A tool call with no result was also repaired with one undifferentiated interruption error, so the resumed model could not tell whether execution had started and could retry a side effect blindly. + +## Decision + +`dsh-session-checkpoint-policy` owns semantic durability barriers as a zero-config plugin beside a persistence backend. It wraps `llm/stream` lazily and flushes the live session after `request/header` is logged but before the adapter stream is constructed. It wraps top-level `tools/execute` after ordered pre-execute policy and flushes the recorded `tool/call` before the tool body; nested dispatches reuse the outer model-visible call. It flushes at `agent/post-step` after the assistant message and ordered results are recorded. The loop's existing final `turn/end` checkpoint remains the closing boundary. + +Persistence and checkpoint scheduling remain separate Cordis plugins. A backend makes requested `session/flush` boundaries durable but does not choose them; loading it without this policy is valid and retains the loop's coarser checkpoints. First-party persisted apps and runtimes explicitly mount both, while a specialized deployment may intentionally omit or replace the policy. Registration order governs whether events appended by other `agent/post-step` listeners join this checkpoint; the loop-owned assistant message and ordered results always precede the event. + +Checkpoint failure and cancellation are fail-closed at effect boundaries. A rejected request checkpoint prevents adapter dispatch; a rejected tool checkpoint becomes an error result without invoking the tool body. If cancellation lands while the tool checkpoint is pending, the policy rechecks the signal and returns the canonical `ABORTED_BEFORE_DISPATCH` result. A rejected post-step checkpoint stops continuation before another model request. Persistence serialization continues to belong to the coordinator, so concurrent tool checkpoints cannot duplicate event sequences. + +The ACP app owns its bridge, checkpoint policy, and persistence backend in one ordered Cordis effect. Cordis unloads sibling plugin effects concurrently, so independent mounts would let persistence detach while bridge teardown was still closing an interrupted turn. The composite lifecycle unloads the bridge first, waits for its agents to quiesce and flush the real `step/end` and `turn/end`, then removes checkpoint scheduling and persistence. + +Crash repair distinguishes durable evidence. An assistant tool request without a `tool/call` becomes `TOOL_NOT_STARTED` and may be retried if still needed. A durable `tool/call` without a result becomes `TOOL_OUTCOME_UNKNOWN`; its model-visible result permits retry only for read-only or idempotent operations and directs the model to verify external state or ask the user before deciding about side-effecting work. A provider that supports idempotency keys can receive the stable `callId`, but the Harness does not claim generic exactly-once effects. + +## Alternatives considered + +Flushing every event or streaming chunk minimizes loss but turns local append and `fsync` latency into the hot path and destabilizes streaming throughput. Moving the barriers into `agent-loop` prevents omission for that loop but hides checkpoint policy inside the mechanism and removes Cordis-level replacement and ordering. Keeping turn-only flush preserves throughput but loses the request and execution intent needed for safe recovery. Automatically retrying every unmatched call is safe only for a subset of tools and can duplicate irreversible effects. + +## Consequences + +Hard-crash recovery retains the complete model request, durable tool intent, and complete settled step at the nearest semantic boundary while allowing partial streaming chunks since the previous boundary to remain lossy. Default CLI, TUI, ACP, Python SDK runtime, headless persistence tests, and JSON-RPC compositions mount the policy with their persistence backend. Unit tests cover ordering, cancellation during a checkpoint, fail-closed behavior, nested dispatch, disposal, and Loader shape; a real child process killed with `SIGKILL` proves request and tool-intent recovery through JSONL, and the shared persistence contract proves both recovery classifications across backends. Keyless ACP snapshots prove both that retry-risk guidance reaches resumed history and the next model turn and that graceful cancellation persists the loop's real closing boundaries. diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md new file mode 100644 index 0000000000..1f187eb644 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 语义会话检查点 + +Status: implemented + +[English](2026-07-21-semantic-session-checkpoints.md) | 中文 + +## 问题 + +持久化机制会缓冲所有同步 `session/event`,直到 agent loop(智能体循环)执行最后的轮次检查点才写入。一个轮次是正确的对话事务,但作为唯一的崩溃恢复点过于粗粒度:如果在耗时的模型请求或工具调用期间发生硬崩溃,整个进行中的轮次都可能丢失,其中包括识别已尝试操作所需的请求封套。系统还会使用同一种不作区分的中断错误,修复没有结果的工具调用,因此恢复运行的模型无法判断调用是否已经开始,可能会盲目重试带有副作用的操作。 + +## 决策 + +`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新活动会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。它还会在 `agent/post-step` 时刷新会话,此时模型消息与按序结果都已记录。现有的最终 `turn/end` 检查点仍是轮次的收尾边界。 + +持久化与检查点调度仍是相互独立的 Cordis 插件。后端使请求的 `session/flush` 边界持久化,但不选择边界;只加载后端而不加载本策略仍是有效组合,并保留循环提供的较粗检查点。第一方持久化应用与运行时会显式加载两者,专用部署则可以有意省略或替换本策略。注册顺序决定其他 `agent/post-step` 监听器追加的事件是否会纳入本检查点;循环自身记录的助手消息与有序结果始终先于该事件。 + +检查点失败与取消在副作用边界上采取失败关闭策略。请求检查点被拒绝时,系统不会分发给适配器;工具检查点被拒绝时,系统会返回错误结果,不调用工具主体。如果在工具检查点等待期间收到取消,策略会重新检查信号,并返回标准的 `ABORTED_BEFORE_DISPATCH` 结果。步骤后检查点被拒绝时,系统会在发起下一个模型请求前停止继续执行。持久化写入的串行化仍由协调器负责,因此并发的工具检查点不会产生重复的事件序号。 + +ACP(Agent Client Protocol)应用在一个有序 Cordis effect 中统一持有其桥接层、检查点策略与持久化后端。Cordis 会并发卸载同级插件的 effect;如果分别加载,桥接层仍在为被中断的轮次收尾时,持久化后端就可能已经卸载。组合生命周期会先卸载桥接层,等待其各 agent 达到静止,并刷新真实的 `step/end` 与 `turn/end`,再移除检查点调度与持久化。 + +崩溃修复会区分持久化证据。如果模型发出了工具请求,却没有 `tool/call`,系统会将其标记为 `TOOL_NOT_STARTED`;如果仍有需要,可以重试。如果持久化的 `tool/call` 没有结果,系统会将其标记为 `TOOL_OUTCOME_UNKNOWN`;对应的模型可见结果只允许重试只读或幂等操作,并指示模型在决定是否重试有副作用的工作前,先验证外部状态或询问用户。支持幂等键的模型提供方可以获取稳定的 `callId`,但 Harness 不承诺通用的副作用恰好执行一次保证。 + +## 考虑过的替代方案 + +刷新每个事件或流式分片虽能尽可能减少丢失,但会把本地追加与 `fsync` 延迟带入热路径,破坏流式输出的吞吐稳定性。将这些屏障放入 `agent-loop`,虽能防止该循环漏装,却会将检查点策略隐藏在机制中,并失去 Cordis 层的替换与排序能力。仅保留轮次刷新可以维持吞吐量,但会丢失安全恢复所需的请求与执行意图。自动重试所有未匹配调用只对部分工具安全,可能会重复不可逆的副作用。 + +## 后果 + +发生硬崩溃时,崩溃恢复会在最近的语义边界保留完整的模型请求、持久化的工具意图与完整且已结束的步骤,但允许上一个边界之后的部分流式分片仍可能丢失。默认的 CLI(命令行界面)、TUI、ACP、Python SDK 运行时、headless 持久化测试与 JSON-RPC 组合都会在持久化后端旁加载该策略。单元测试覆盖顺序、检查点期间的取消、失败关闭行为、嵌套分发、dispose(资源释放)与 Loader 形状;一个被 `SIGKILL` 终止的真实子进程通过 JSONL 证明系统可以恢复请求与工具意图,共享持久化契约则证明各后端都支持这两种恢复分类。无密钥 ACP 快照既证明重试风险指引会进入恢复后的历史记录与下一个模型轮次,也证明取消流程正常收尾时,系统会持久化由循环实际生成的闭合边界。 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md index def0604df9..91d85aeded 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md @@ -18,6 +18,14 @@ Background bash tasks carry an opaque owner token equal to the owning session id Connection teardown clears the live map, settles each pending prompt as cancelled, and disposes all `AgentHandle`s in parallel. Each handle stops and awaits its loop, flushes the session while attached, unregisters the agent, and removes the session. Teardown is memoized and shared by client disconnect and plugin disposal. +## Protocol and workspace scope + +[ACP v1 expressly permits several concurrent sessions on one connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/get-started/architecture.mdx#L16-L24), and each new session carries its own primary `cwd`. This bridge implements that session-level multiplexing, including different primary workspaces as recorded by the [per-session cwd decision](../architecture/2026-07-02-fs-per-session-cwd.md); it does not create one agent subprocess per session. + +A multi-root project inside one session is a separate optional capability: ACP defines the [effective roots as the primary `cwd` plus `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367). [Zed sends the remaining project work directories only when the agent advertises that capability](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1139-L1145), otherwise it [drops them from the session request](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1454-L1472). The bridge does not advertise this capability and rejects non-empty values, as recorded in its [known limitations](../../../../packages/ui/acp/README.md#known-limitations-and-deferred-work), so a current Zed multi-root project reaches it with only the first work directory. + +[The standard transport is one editor-launched agent subprocess per stdio connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42); multiple editor connections therefore require multiple subprocesses or a custom transport, while this decision guarantees multiple sessions within one connection. Within that connection, `ctx.sandboxPolicy` resolves every session's `cwd` as its own `workspace-write` root, so the shared bash and filesystem services can serve concurrent projects without granting cross-project writes. This does not add ACP `additionalDirectories`; it removes the process-wide root limit from the already-supported one-primary-root-per-session path. + ## Alternatives considered **One live session per connection** — rejected. It adds process overhead and contradicts the target client's multi-session shape without removing multiplexing needs from the editor. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 2cc6516523..d5d0c6d5aa 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -12,7 +12,7 @@ Confinement alone leaves two gaps. A denial with no escalation path is terminal ## Decision -One seam, one per-platform chain of local backends, one consumer, and two levers on top: a per-call escalation path and per-session runtime modes. Everything below composes from the leaf `cordis.yml`; nothing touches `agent-loop`. The scope is deliberately bounded: the phases this Agent Note names but does not design — per-session workspace root, cross-family fs enforcement, the `subagent-acp` consumer, more environments, a Windows chain — are listed under § Deferred phases, each a follow-up design, not a config knob. +One seam, one per-platform chain of local backends, one consumer, and two levers on top: a per-call escalation path and per-session runtime modes. Everything below composes from the leaf `cordis.yml`; nothing touches `agent-loop`. Cross-family fs enforcement and per-session workspace roots landed as follow-ups on the same policy carrier; the remaining phases — the `subagent-acp` consumer, more environments, and a Windows chain — stay under § Deferred phases. ### How a deployment uses it @@ -48,7 +48,7 @@ OS subprocess confinement applies to the bash executor, including hook commands, #### The seam: `ctx.sandbox` -`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxPolicy` (mode + workspace root). +`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxExecutionPolicy` (the complete per-capability-call mode + workspace root), and `SandboxPolicy` (the confined provider subset). Policy rides each CALL, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is a new call with a wider policy — inexpressible under a config-fixed provider mode. @@ -74,9 +74,9 @@ The model's view is result facts only: the static tool description explains the #### Escalation: one approved wider retry after a denial -`BashExecRequest.sandboxMode` is an optional per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` advertises whether the mounted executor can honor it, so only a confining composition exposes escalation. The seam accepts any explicit mode; the tool owns the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined. +`BashExecRequest.sandboxPolicy` is an optional complete per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` remains the capability fact advertising whether the mounted executor can honor that policy, so only a confining composition exposes escalation. The seam accepts any explicit policy; the tool owns session resolution and the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined. -`SandboxBashExecutor.resolve()` stamps the effective mode — escalation grant > session override > configured default — so `run()`/`start()` read the spec, never the config. Per-process wrap facts are keyed by the returned `BashProcess`; `onProcessDone()` classifies stderr and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects. +`ctx.sandboxPolicy.resolve()` stamps the complete execution policy — explicit escalation mode > session override > configured default, with `SessionHeader.cwd` > configured fallback root — before the executor runs. `SandboxBashExecutor.resolve()` retains that policy on the spec, or supplies the deployment fallback for a direct agentless caller, so `run()`/`start()` never read mutable session state. Per-process wrap facts are keyed by the returned `BashProcess`; `onProcessDone()` classifies stderr and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects. When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted. @@ -115,16 +115,15 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s ### Testing -- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes. -- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip. +- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes. +- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip. - **With-key:** drive a real model, runner, bridge answerer, and disk effect through granted and rejected escalation; unavailable credentials or runners self-skip. -- **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. Snapshot mode starts unconfined so unrelated fixtures remain platform-independent; policy scenarios switch explicitly. Real denial stderr stays on platform tests because its dialect is runner-specific. +- **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins a successful workspace-write mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent, and policy scenarios switch explicitly. ## Deferred phases Each phase gets its full design when picked up, validated against the code at that time, and lands with unit, real-API e2e, and snapshot coverage at the tiers it touches. -- **Per-session workspace root** — the executor's write boundary stays config-fixed for its lifetime while each ACP session has its own cwd; a per-session root rides the same per-call policy carrier once designed. Centralizing the root on `ctx.sandboxPolicy` (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)) is the groundwork. - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). - **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. @@ -163,6 +162,7 @@ What shipped pins — the tiers in Testing hold each: - N idle-time flips produce at most one anchored event per knob (a net-zero sequence anchors none — a no-op push from a client echoing current selections records nothing); an approval-policy switch is narrated in at most one coalesced notice; a mid-turn sandbox switch is honored by the next call's stamp. - A resumed session's overrides apply and are reported to the editor with no special-casing; a default changed while the process was down is narrated before the session's first new request, attributed to the operator. - Two concurrent sessions never see each other's state, notices, or config options. +- Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd. - `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/pre-step`, `agent/prompt-submit`, and the ACP handler surface. Costs and accepted limits: @@ -199,7 +199,7 @@ Costs and accepted limits: In-repo precedents this design copies or contrasts with: - [The capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md) — the interface/implementation/consumer split and the "don't split preemptively" timing rule the second consumer satisfied. -- The `dsh-bash` request/spec split ([the bash vocabulary catalog](../../../../docs/core-data-structures/bash.md)) — the per-call carrier template `sandboxMode` rides, and the explicit-`resolve()` defaulting convention. +- The `dsh-bash` request/spec split ([the bash vocabulary catalog](../../../../docs/core-data-structures/bash.md)) — the complete `sandboxPolicy` rides its per-call carrier, and the explicit-`resolve()` defaulting convention. - [The approval seam Agent Note](2026-07-06-approval-seam.md) — the channel escalation asks through; its answerer waterfall, audit pair, and one-package rationale are recorded there. - [Event-sourced sessions](../architecture/2026-06-11-event-sourced-sessions.md) and [the turn-enclosure invariant](../architecture/2026-06-15-turn-enclosure-invariant.md) — the log-as-store foundation the per-session modes fold over, and the commit boundary the anchoring design obeys. - [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` vocabulary the escalation gate deliberately does not reuse (an escalating call has no pre-execute moment of its own). diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml index 41246ca3b3..74ad64601b 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-14-cross-family-fs-sandbox.md: 0897695cc14b7573ebb53f3ffa6a460652882b37 -2026-07-14-cross-family-fs-sandbox.zh.md: 15de061a0d2b18392f839c927e9b0f5d0cacf28b +2026-07-14-cross-family-fs-sandbox.md: e8a59be345b52f7684c574134b37f48bc49843fc +2026-07-14-cross-family-fs-sandbox.zh.md: 92bc5a495a7c20a08bc85ef9dbf1a1beffe6264f diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md index 0897695cc1..e8a59be345 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md @@ -22,9 +22,10 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching - `Config`: `mode` (the closed `SandboxMode` union, default `read-only`) and `workspaceRoot` (default the process cwd, resolved absolute). Misconfiguration fails loud at load. - The per-session override event `sandbox/mode`, with its pure fold (`effectiveSandboxMode(events)`), its write path (`setSandboxMode(session, mode)`), and `SANDBOX_MODES`. The event is policy state — consumed by two families — so it lives here, not in either capability's seam. Its shape and log-only semantics match the `approval/*` precedent. -- `defaultMode` / `workspaceRoot` accessors the enforcing implementations read for their resolve fallback and boundary. +- `resolve({ session?, mode? })`, which returns a complete per-call `SandboxExecutionPolicy`: explicit approved mode > the session fold > `defaultMode`, and the session's immutable cwd > configured `workspaceRoot` fallback. +- `defaultMode` / `workspaceRoot` accessors retained as deployment fallbacks and the capability-advertisement fact. -`dsh-bash-sandbox` carries no sandbox config of its own — it injects `sandboxPolicy` and reads the default from it; its `resolve()` precedence is unchanged (escalation grant > per-call stamp > default). `dsh-tool-bash` and `dsh-tool-fs` fold the session's `sandbox/mode` with `effectiveSandboxMode` to stamp each call; `dsh-permission` presets and the ACP bridge write through the relocated setter. The seam that owns bash execution no longer depends on `dsh-session` at all — the session dependency moved to the policy package with the fold. +`dsh-bash-sandbox` carries no sandbox config of its own — it injects `sandboxPolicy` and uses its deployment fallback only for direct calls. `dsh-tool-bash` and `dsh-tool-fs` pass the active session to `ctx.sandboxPolicy.resolve()`, so both receive the same effective mode and cwd root on every call; `dsh-permission` presets and the ACP bridge write through the relocated setter. The seams that own bash and fs execution remain session-free — the session dependency lives in the policy package and tool consumers. ### `dsh-fs-sandbox` — enforcement inside the provider @@ -34,13 +35,13 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching - `workspace-write` fences the canonicalized target against the writable-root set — `writableRoots(policy)` in `dsh-sandbox`: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), each realpathed — the SAME set the Seatbelt profile grants, so the fs fence is the fourth dialect of one mode meaning alongside the bwrap/Landlock/Seatbelt profiles, and "the write tool cannot write `/tmp` but bash can" asymmetries cannot arise. Canonical spellings take a lexical containment fast path; when Windows exposes one directory through different casing or long-name/8.3 spellings, an ancestor walk compares filesystem identity rather than weakening the boundary to textual prefix guesses. The target is re-canonicalized (`resolve` realpaths the deepest existing ancestor) immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. - `danger-full-access` delegates unfenced. -A denial is the structured `FS_SANDBOX_DENIED` carrying the effective mode — distinct from `FS_PERMISSION_DENIED` (a host EACCES is the world refusing; this is policy refusing). No text inference: an in-process fence knows exactly what it denied. The per-call carrier is a trailing optional `sandboxMode` on `writeText`/`editText` (the filesystem twin of `BashExecRequest.sandboxMode`); the seam stays session-free (the caller stamps, exactly as `resolve` takes a cwd), and the bare local backend carries-and-ignores it. `FileSystem.sandboxMode` is the capability fact (`undefined` on the base and `fs-local`, the default on `SandboxedFileSystem`), so the tool layer advertises escalation from composition truth. +A denial is the structured `FS_SANDBOX_DENIED` carrying the effective mode — distinct from `FS_PERMISSION_DENIED` (a host EACCES is the world refusing; this is policy refusing). No text inference: an in-process fence knows exactly what it denied. The per-call carrier is a trailing optional `SandboxExecutionPolicy` on `writeText`/`editText` (the filesystem twin of `BashExecRequest.sandboxPolicy`); the seam stays session-free, and the bare local backend ignores it. `FileSystem.sandboxMode` is the capability fact (`undefined` on the base and `fs-local`, the default on `SandboxedFileSystem`), so the tool layer advertises escalation from composition truth. The threat model is stated in the package README: a policy fence in trusted code over model-controlled paths, not a kernel boundary — the operations are the seam's own, only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface (the `code-runtime` "containment, not a security boundary" precedent). Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job. The residual resolve-to-syscall race is narrowed by the in-place re-canonicalization and eliminated only by platform primitives (`openat2` `RESOLVE_BENEATH`) not worth their portability cost here. ### Tool parity — one denial marker, one escalation flow -`dsh-tool-fs` stamps the effective mode onto each mutation and maps `FS_SANDBOX_DENIED` to the marker the model already knows from bash: `[sandbox: file access denied under mode]`. When `ctx.fs.sandboxMode` reports a confining mode at registration, `write` and `edit` advertise the same `sandbox_permissions` + `justification` fields, teach the same same-turn retry, and resolve the same `ctx.approval` request before executing — the four outcomes and their verbatim fail-closed texts carried over from [the sandbox Agent Note](2026-07-06-sandbox.md) § Escalation (strict widening checked at execution against the call's effective mode; a grant consumed by the one call that asked; no new session events). +`dsh-tool-fs` resolves the active session's complete policy onto each mutation and maps `FS_SANDBOX_DENIED` to the marker the model already knows from bash: `[sandbox: file access denied under mode]`. When `ctx.fs.sandboxMode` reports a confining mode at registration, `write` and `edit` advertise the same `sandbox_permissions` + `justification` fields, teach the same same-turn retry, and resolve the same `ctx.approval` request before executing — the four outcomes and their verbatim fail-closed texts carried over from [the sandbox Agent Note](2026-07-06-sandbox.md) § Escalation (strict widening checked at execution against the call's effective mode; a grant changes only that call's mode and retains its session root; no new session events). The shared pieces live in `dsh-sandbox`, which owns the mode types: `WIDER_MODES`, the escalation-target enum, the argument-pairing validation, the denial/hint marker builders, and `approveEscalation` — the ordered fail-closed choreography. `approveEscalation` takes a minimal STRUCTURAL approver (`EscalationApprover`, generic over the agent and call-id types), not the approval service type, so `dsh-sandbox` gains no dependency on the approval or agent packages: each tool passes its own `ctx.approval`, agent, call id, and tool name as ingredients. `dsh-tool-bash` and `dsh-tool-fs` both use these; the cross-file duplication gate holds the single-sourcing honest. @@ -53,7 +54,8 @@ The sandbox Agent Note's original cross-family sketch put fs enforcement on the ### Out of scope - **Network policy for `ctx.web`** — `SandboxMode` claims file effects only; a web-only network knob while bash `curl` runs free would be a false boundary. Revisit when a bash backend enforces network (bwrap `--unshare-net`, Landlock ABI v4+). -- **The `subagent-acp` consumer** and **per-session workspace root** — unchanged deferred phases of the sandbox RFC; centralizing the root in `ctx.sandboxPolicy` is groundwork for the latter, not its design. +- **The `subagent-acp` consumer** — unchanged deferred phase of the sandbox RFC. +- **Additional writable roots inside one session** — the resolved policy carries one primary `SessionHeader.cwd`; ACP `additionalDirectories` remains a separate bridge and policy design. - **A uniform per-tool sandbox runtime** — remains rejected for the reasons in the sandbox RFC. ## Alternatives considered @@ -66,7 +68,7 @@ The sandbox Agent Note's original cross-family sketch put fs enforcement on the - **Per-family policy config with a load-time consistency check** — rejected: two homes for one fact, patched by a check that must enumerate every future enforcing family; the policy service makes drift inexpressible instead of detected. - **Keep the override event in `dsh-bash` as `bash/sandbox-mode`** — rejected: the event is policy state consumed by two families; leaving it bash-named forces `dsh-fs-sandbox` to depend on bash vocabulary. Pre-release, the rename is a same-change move with snapshot re-records, no shims. - **Escalation choreography imported from the approval/agent packages into `dsh-sandbox`** — rejected: it would invert the layering (a base vocabulary package depending on UI/agent packages). The structural approver keeps the logic single-sourced in `dsh-sandbox` while the dependencies stay in the tool layer that already holds them. -- **A consolidated mutation-options object on the fs seam** (the shape first sketched for the per-call carrier) — rejected on friction: it churns every `writeText`/`editText` caller and splits `signal` across an options bag for mutations while reads keep it positional. A trailing optional `sandboxMode` matches bash's carry-and-ignore pattern and keeps `signal` symmetric across the seam. +- **A consolidated mutation-options object on the fs seam** (the shape first sketched for the per-call carrier) — rejected on friction: it splits `signal` across an options bag for mutations while reads keep it positional. A trailing optional `SandboxExecutionPolicy` matches bash's carry-and-ignore pattern and keeps `signal` symmetric across the seam. - **Extra writable-root grants on `SandboxPolicy` now** — deferred unchanged: `writableRoots()` derives from the mode meaning today; ad-hoc grants are an escalation-scope question the sandbox RFC left open. ## Consequences @@ -77,6 +79,7 @@ What shipped — the tiers in § Testing hold each: - Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, a new file created under such a symlink, and alias-equivalent root spellings — denies every escape while admitting the same directory identity on real disks. - A denied fs mutation retried once with `sandbox_permissions` + `justification` prompts through the composed approval chain; a grant runs exactly that call under the wider mode and the write lands; rejected/cancelled/unavailable each produce their verbatim fail-closed text and mutate nothing. - One `permission` preset switch governs both families: after a session switches modes, the next bash call and the next fs mutation both honor the new mode from the same `sandbox/mode` fold. +- Concurrent sessions with different cwd roots carry different policies through the same service instances; neither family caches one session's root for the next call. - A direct `ctx.fs.writeText` with no per-call stamp is confined at the deployment default. - The escalation fields on `write`/`edit` exist exactly when the mounted `ctx.fs` confines, absent under `dsh-fs-local`. - `agent-loop` is untouched — everything rides `ctx.sandboxPolicy`, the `ctx.fs` seam, `SessionEventMap` merging, and the tool-execution pipeline. @@ -90,5 +93,6 @@ Costs and accepted limits: ## Testing -- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins the default accessors, the fold/setter, the load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-mode fence and the containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, and alias-equivalent spelling) on a real filesystem, plus the per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, the mode stamp, the fold, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` migrate to the relocated policy/kit. +- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins deployment fallback, session mode/root resolution, explicit-mode precedence, the fold/setter, load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-policy fence and containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, root-ending-in-separator, and alias-equivalent spelling) on a real filesystem, plus per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, complete policy resolution, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` consume the same policy kit. +- Keyless e2e: one real Cordis context creates two agents with different session cwd roots, runs the shipped bash and fs tools concurrently, and world-verifies that own-project writes land while both cross-project writes are denied. - Snapshot: the acp-agent example composes `dsh-sandbox-policy` + `dsh-fs-sandbox`; the pinned header carries the fs escalation fields and the `sandbox/mode` event name, re-recorded once. diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md index 15de061a0d..92bc5a495a 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md @@ -22,9 +22,10 @@ Status: implemented - `Config`:`mode`(封闭的 `SandboxMode` 联合,默认 `read-only`)与 `workspaceRoot`(默认进程 cwd,解析为绝对路径)。配置错误在加载时高声失败。 - per-session 覆盖事件 `sandbox/mode`,连同它的纯折叠(`effectiveSandboxMode(events)`)、写入路径(`setSandboxMode(session, mode)`)与 `SANDBOX_MODES`。该事件是策略状态——被两个家族消费——所以它住在这里,而不在任一能力的 seam 里。它的形状与仅日志(log-only)语义遵循 `approval/*` 的先例。 -- `defaultMode` / `workspaceRoot` 访问器,供执行实现读取其 resolve 回退值与边界。 +- `resolve({ session?, mode? })` 返回完整的单次调用 `SandboxExecutionPolicy`:显式批准的模式 > 会话折叠结果 > `defaultMode`,而会话中不可变的 cwd > 配置的 `workspaceRoot` 回退值。 +- 保留 `defaultMode` / `workspaceRoot` 访问器,作为部署回退值与能力宣告依据。 -`dsh-bash-sandbox` 自身不再携带任何沙箱配置——它注入 `sandboxPolicy` 并从中读取默认值;其 `resolve()` 优先级不变(升级授权 > per-call 盖章 > 默认)。`dsh-tool-bash` 与 `dsh-tool-fs` 用 `effectiveSandboxMode` 折叠会话的 `sandbox/mode` 以对每次调用盖章;`dsh-permission` 预设与 ACP bridge 经由迁移后的 setter 写入。拥有 bash 执行的那个 seam 不再依赖 `dsh-session`——会话依赖随折叠一起迁到了策略包。 +`dsh-bash-sandbox` 自身不再携带任何沙箱配置——它注入 `sandboxPolicy`,仅在直接调用时使用其中的部署回退值。`dsh-tool-bash` 与 `dsh-tool-fs` 把当前会话传给 `ctx.sandboxPolicy.resolve()`,因此两者每次调用都会取得相同的生效模式与 cwd 根目录;`dsh-permission` 预设与 ACP bridge 经由迁移后的 setter 写入。拥有 bash 与 fs 执行的 seam 仍不依赖会话——会话依赖归策略包与工具消费方所有。 ### `dsh-fs-sandbox`——在提供方内部执行 @@ -34,13 +35,13 @@ Status: implemented - `workspace-write` 把规范化后的目标围栏于可写根集合——`dsh-sandbox` 中的 `writableRoots(policy)`:工作区根加上平台临时目录(`/tmp`、`os.tmpdir()`),各自 realpath——与 Seatbelt profile 授予的是同一个集合,所以 fs 围栏是这一个模式含义在 bwrap/Landlock/Seatbelt profile 之外的第四种方言,因此不会出现「write 工具不能写 `/tmp` 而 bash 能」的不对称。规范化路径写法采用词法包含的快速路径;当 Windows 以大小写不同的路径、长文件名或 8.3 短文件名表示同一目录时,系统会逐级遍历祖先目录并比较文件系统身份,而不会把边界弱化为依据文本前缀猜测包含关系。目标在委托前被立即重新规范化(`resolve` 对最深的既有祖先做 realpath),因此自工具解析该目标以来被换出的祖先符号链接会被捕获。 - `danger-full-access` 不加围栏地委托。 -拒绝是结构化的 `FS_SANDBOX_DENIED`,携带生效模式——区别于 `FS_PERMISSION_DENIED`(宿主 EACCES 是世界在拒绝;这里是策略在拒绝)。无文本推断:进程内围栏确切知道它拒绝了什么。per-call 载体是 `writeText`/`editText` 上一个末尾可选的 `sandboxMode`(文件系统侧对应 `BashExecRequest.sandboxMode`);该 seam 保持无会话依赖(由调用方盖章,正如 `resolve` 接收一个 cwd),而裸的本地后端携带并忽略它。`FileSystem.sandboxMode` 是能力事实(在基类与 `fs-local` 上为 `undefined`,在 `SandboxedFileSystem` 上为默认值),所以工具层按组合真相来宣告升级。 +拒绝是结构化的 `FS_SANDBOX_DENIED`,携带生效模式——区别于 `FS_PERMISSION_DENIED`(宿主 EACCES 是世界在拒绝;这里是策略在拒绝)。无文本推断:进程内围栏确切知道它拒绝了什么。per-call 载体是 `writeText`/`editText` 上一个末尾可选的 `SandboxExecutionPolicy`(文件系统侧对应 `BashExecRequest.sandboxPolicy`);该 seam 保持无会话依赖,而裸的本地后端会忽略它。`FileSystem.sandboxMode` 是能力事实(在基类与 `fs-local` 上为 `undefined`,在 `SandboxedFileSystem` 上为默认值),所以工具层按组合真相来宣告升级。 威胁模型写在包 README 里:一道位于可信代码中、针对模型可控路径的策略围栏,而非内核边界——操作是 seam 自身的,只有目标路径不可信,所以「先规范化再判包含」是对这个面的完整答案(`code-runtime` 的「containment, not a security boundary」先例)。对不可信代码的内核级隔离仍是 `ctx.bash` 的职责。resolve 到系统调用之间残留的竞态被就地重新规范化收窄,只有平台原语(`openat2` `RESOLVE_BENEATH`)能彻底消除它,而那在此不值其可移植性代价。 ### 工具对等——一个拒绝标记、一条升级流程 -`dsh-tool-fs` 把生效模式盖章到每次变更上,并将 `FS_SANDBOX_DENIED` 映射为模型已从 bash 认识的标记:`[sandbox: file access denied under mode]`。当 `ctx.fs.sandboxMode` 在注册时报告一个受限模式,`write` 与 `edit` 宣告相同的 `sandbox_permissions` + `justification` 字段,教授相同的同回合重试,并在执行前解析相同的 `ctx.approval` 请求——四种结果及其逐字的 fail-closed 文案沿用自[沙箱 RFC](2026-07-06-sandbox.md) § Escalation(严格加宽在执行时针对调用的生效模式检查;授权由发起它的那一次调用消费;无任何新会话事件)。 +`dsh-tool-fs` 把当前会话解析成完整策略,并传给每次变更,同时将 `FS_SANDBOX_DENIED` 映射为模型已从 bash 认识的标记:`[sandbox: file access denied under mode]`。当 `ctx.fs.sandboxMode` 在注册时报告一个受限模式,`write` 与 `edit` 宣告相同的 `sandbox_permissions` + `justification` 字段,教授相同的同回合重试,并在执行前解析相同的 `ctx.approval` 请求——四种结果及其逐字的 fail-closed 文案沿用自[沙箱 RFC](2026-07-06-sandbox.md) § Escalation(执行时根据调用的生效模式检查是否严格加宽;授权只改变当前调用的模式,并保留其会话根目录;不产生任何新会话事件)。 共享部分住在 `dsh-sandbox`,它拥有模式类型:`WIDER_MODES`、升级目标枚举、参数配对校验、拒绝/提示标记构造器,以及 `approveEscalation`——有序的 fail-closed 编排。`approveEscalation` 接收一个最小的结构化 approver(`EscalationApprover`,对 agent 与 call-id 类型泛型化),而非审批服务类型,所以 `dsh-sandbox` 不获得对 approval 或 agent 包的依赖:每个工具把自己的 `ctx.approval`、agent、call id 与工具名作为原料传入。`dsh-tool-bash` 与 `dsh-tool-fs` 都使用它们;跨文件重复检测门禁确保单一来源不走样。 @@ -53,7 +54,8 @@ Status: implemented ### 范围之外 - **`ctx.web` 的网络策略**——`SandboxMode` 只声明文件效果;在 bash `curl` 畅通时给一个仅限 web 的网络旋钮会是一道假边界。待某个 bash 后端能执行网络(bwrap `--unshare-net`、Landlock ABI v4+)时再议。 -- **`subagent-acp` 消费者** 与 **per-session 工作区根**——沙箱 RFC 未变的延后阶段;把根集中到 `ctx.sandboxPolicy` 是后者的铺垫,而非其设计。 +- **`subagent-acp` 消费者**——沙箱 RFC 中未变的延后阶段。 +- **单个会话中的额外可写根目录**——解析后的策略携带一个主要 `SessionHeader.cwd`;ACP `additionalDirectories` 仍是独立的 bridge 与策略设计问题。 - **统一的 per-tool 沙箱运行时**——因沙箱 RFC 中的理由继续否决。 ## Alternatives considered @@ -66,7 +68,7 @@ Status: implemented - **带加载期一致性校验的 per-family 策略配置**——否决:一个事实两个归属,靠一个必须枚举每个未来执行家族的校验来打补丁;策略服务让漂移不可表达,而非被检测到。 - **把覆盖事件留在 `dsh-bash` 里作 `bash/sandbox-mode`**——否决:该事件是被两个家族消费的策略状态;保留 bash 命名会迫使 `dsh-fs-sandbox` 依赖 bash 词汇。预发布阶段,该改名是同一变更内的迁移,附带快照重录,无任何 shim。 - **把升级编排从 approval/agent 包导入 `dsh-sandbox`**——否决:那会倒置分层(一个基础词汇包依赖 UI/agent 包)。结构化 approver 让逻辑单一来源于 `dsh-sandbox`,而依赖留在本就持有它们的工具层。 -- **fs seam 上一个合并的 mutation-options 对象**(per-call 载体最初草拟的形状)——因摩擦被否决:它会搅动每一个 `writeText`/`editText` 调用方,并把 `signal` 拆进变更专用的选项包,而读取仍保持位置参数。一个末尾可选的 `sandboxMode` 匹配 bash 的携带并忽略模式,并使 `signal` 在整个 seam 上保持对称。 +- **fs seam 上一个合并的 mutation-options 对象**(per-call 载体最初草拟的形状)——因摩擦被否决:它会把 `signal` 拆进变更专用的选项包,而读取仍保持位置参数。一个末尾可选的 `SandboxExecutionPolicy` 匹配 bash 的携带并忽略模式,并使 `signal` 在整个 seam 上保持对称。 - **现在就在 `SandboxPolicy` 上加额外的可写根授权**——照旧延后:`writableRoots()` 如今由模式含义推导;临时授权是沙箱 RFC 留下的升级作用域问题。 ## Consequences @@ -77,6 +79,7 @@ Status: implemented - 在 `workspace-write` 下,变更落在工作区根与临时目录下,其外被拒;包含矩阵——`..` 穿越、指向外部的绝对路径、一个既有的、指向外部的工作区内符号链接目录、在这样一个符号链接下新建的文件,以及根路径的等价别名形式——在真实磁盘上拒绝每一种逃逸,同时允许文件系统认定为同一目录的路径。 - 一个被拒的 fs 变更,携带 `sandbox_permissions` + `justification` 重试一次,会经组合的审批链提示;一次授权让恰好那一次调用在更宽的模式下运行且写入落盘;rejected/cancelled/unavailable 各自产生其逐字的 fail-closed 文案且不做任何变更。 - 一次 `permission` 预设切换同时管辖两个家族:会话切换模式后,下一次 bash 调用与下一次 fs 变更都从同一个 `sandbox/mode` 折叠遵循新模式。 +- cwd 根目录不同的并发会话通过同一组服务实例携带不同策略;两个家族都不会缓存某个会话的根目录供下一次调用使用。 - 一次无 per-call 盖章的直连 `ctx.fs.writeText` 会被围栏于部署默认值。 - `write`/`edit` 上的升级字段恰好在被挂载的 `ctx.fs` 受限时存在,在 `dsh-fs-local` 下不存在。 - `agent-loop` 未被触动——一切都骑在 `ctx.sandboxPolicy`、`ctx.fs` seam、`SessionEventMap` 合并,以及工具执行管线之上。 @@ -90,5 +93,6 @@ Status: implemented ## Testing -- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住默认访问器、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住 per-mode 围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、模式盖章、折叠、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 迁移到迁移后的策略/工具集。 +- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住部署回退、会话模式/根目录解析、显式模式优先级、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住按策略执行的围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、以分隔符结尾的根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、完整策略解析、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 使用同一套策略工具集。 +- 无密钥 e2e:一个真实 Cordis 上下文创建两个 agent,其会话的 cwd 根目录各不相同;系统并发运行正式发布的 bash 与 fs 工具,再通过外部可观察结果验证各自在所属项目中的写入成功,而两次跨项目写入都被拒绝。 - 快照:acp-agent 示例组合 `dsh-sandbox-policy` + `dsh-fs-sandbox`;被钉住的 header 携带 fs 升级字段与 `sandbox/mode` 事件名,一次性重录。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml new file mode 100644 index 0000000000..10eddece3c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml @@ -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 +2026-07-22-web-bind-address.md: 3332176c0cee940648ad334a44edd30879225503 +2026-07-22-web-bind-address.zh.md: f539fff93628205bf0099d8f23dfd13d14e55ca5 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md new file mode 100644 index 0000000000..3332176c0c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md @@ -0,0 +1,29 @@ +# Agent Note: Explicit web bind address + +Status: implemented + +English | [中文](2026-07-22-web-bind-address.zh.md) + +## Problem + +`dsh web` binds every network interface even when its browser runs on the same machine. Local use therefore exposes an unauthenticated development server without an explicit operator choice, while remote-container and LAN-browser use still needs a supported way to accept non-loopback connections. + +The HTTP carrier also hides the bind address inside `startWebServer()`, so alternate shells cannot state their own network policy at the package boundary. + +## Decision + +`dsh web` binds `127.0.0.1` by default. The CLI accepts `--host 0.0.0.0` as the explicit all-interface mode and rejects other values so its network modes remain a small, deliberate contract. All-interface mode keeps printing the loopback URL and, when available, the first external IPv4 URL. + +`WebServerOptions.host` is required. The HTTP carrier passes that value to `node:http` without supplying a fallback, leaving each shell responsible for its bind policy. Programmatic carrier consumers may select another hostname or address directly. + +## Alternatives considered + +**Keep `0.0.0.0` as the default.** Rejected because ordinary same-machine use does not need network-wide reachability and should not acquire it implicitly. + +**Use a boolean exposure flag.** Rejected because `--host 0.0.0.0` names the resulting socket behavior directly and matches the underlying server option without introducing a second term. + +**Default inside `startWebServer()`.** Rejected because the carrier has multiple possible shells and no basis for choosing their deployment policy. Requiring `host` makes the choice visible at every assembly call. + +## Consequences + +Local `dsh web` starts remain reachable at `http://127.0.0.1:3080`; a browser on another machine must opt in with `dsh web --host 0.0.0.0`. The CLI does not yet expose custom interface addresses or IPv6 modes, while programmatic carrier consumers retain that flexibility. Server tests pin both loopback and all-interface forwarding into the Node listen boundary, and the web smoke continues to exercise the default CLI path. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md new file mode 100644 index 0000000000..f539fff936 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md @@ -0,0 +1,29 @@ +# Agent Note:显式指定 Web 绑定地址 + +Status: implemented + +[English](2026-07-22-web-bind-address.md) | 中文 + +## 问题 + +即便浏览器与服务器运行在同一台机器上,`dsh web` 也会绑定所有网络接口。因此,本地使用会在操作者未明确选择的情况下暴露一个未经身份验证的开发服务器;另一方面,远程容器和局域网浏览器场景仍需要一种受支持的方式来接受非环回连接。 + +HTTP 承载层还把绑定地址隐藏在 `startWebServer()` 内部,导致其他壳层无法在包(package)边界明确表达自己的网络策略。 + +## 决策 + +`dsh web` 默认绑定 `127.0.0.1`。CLI(命令行界面)接受 `--host 0.0.0.0` 作为显式启用的全接口模式,并拒绝其他取值,使网络模式保持为一份规模小、经过审慎限定的契约。全接口模式仍然输出本机环回 URL,并在可用时输出第一个外部 IPv4 URL。 + +`WebServerOptions.host` 为必填项。HTTP 承载层将该值直接传给 `node:http`,不提供回退值,因此每个壳层负责制定自己的绑定策略。以编程方式使用承载层的消费方可以直接选择其他主机名或地址。 + +## 曾考虑的替代方案 + +**保留以 `0.0.0.0` 作为默认值。** 不予采纳,因为普通的同机使用不需要在全网范围内可达,也不应隐式获得这种可达性。 + +**使用布尔型暴露标志。** 不予采纳,因为 `--host 0.0.0.0` 直接说明最终的套接字行为,并与底层服务器选项一致,无需再引入第二套术语。 + +**在 `startWebServer()` 内设置默认值。** 不予采纳,因为承载层可能由多种壳层调用,没有依据替它们选择部署策略。要求传入 `host`,可使每次装配调用都明确作出这一选择。 + +## 后果 + +`dsh web` 的本地启动仍可通过 `http://127.0.0.1:3080` 访问;其他机器上的浏览器必须使用 `dsh web --host 0.0.0.0` 显式启用。CLI 尚未开放自定义接口地址或 IPv6 模式,而以编程方式使用承载层的消费方仍保留这种灵活性。服务器测试将环回模式和全接口模式向 Node 监听边界的传递固定为契约,Web 冒烟测试继续覆盖默认 CLI 路径。 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index a46b729a4d..87b1c0847b 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -10,7 +10,7 @@ Aggregate jobs such as documentation synchronization hide long sequential chains ## Decision -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI and `doc-sync`. It expands named modes into leaf gates, respects artifact dependencies, buffers attributable output, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, respects artifact dependencies, buffers attributable output, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml index a21afc8e6a..361f7a0bd0 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-fast-local-git-hooks.md: bab47c6479f1a2c01cbfa7152b1d610917fb6175 -2026-07-22-fast-local-git-hooks.zh.md: 7b279b1a9ad86e09ed5cf7d2470cb61ff17e09b7 +2026-07-22-fast-local-git-hooks.md: a07af1cd424c86f7fa80ea946cd5012362cc66eb +2026-07-22-fast-local-git-hooks.zh.md: 78d4ea8980476609a9140737a75152eba123b308 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md index bab47c6479..a07af1cd42 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md @@ -14,7 +14,7 @@ Fast hooks still need to reject cheap, high-confidence defects before work leave [lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: ESLint fixes and re-stages changed JavaScript and TypeScript, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode. -Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The `check:pre-push` package script and `pre-push` scheduler mode do not exist; [scripts/run-gates.ts](../../../../scripts/run-gates.ts) continues to own CI and `doc-sync` scheduling. +Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The opt-in `check:all` package script selects the `check-all` scheduler inventory in [scripts/run-gates.ts](../../../../scripts/run-gates.ts) independently of the hooks; it is a contributor command, not an agent instruction. Agents inspect the outgoing diff and run the narrowest tests and checks that cover its behavior once. CI owns exhaustive coverage, built-artifact checks, and the platform matrix. A complete local rehearsal is reserved for an explicit request, CI diagnosis, or a repository-wide change that cannot be validated credibly by narrower evidence. @@ -31,6 +31,6 @@ This decision supersedes the local-hook portion of [Parallel pre-push gates](202 ## Consequences -Normal commits take the staged-file lint critical path, and warm pushes take the incremental typecheck critical path. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state. +Normal commits take the staged-file lint critical path, and warm pushes take the incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state. Local publication no longer proves the exhaustive repository matrix. Agents must select relevant behavioral evidence, reviewers must evaluate whether that selection matches the diff, and CI supplies the comprehensive signal once per pushed revision. diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md index 7b279b1a9a..78d4ea8980 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md @@ -14,7 +14,7 @@ agent(智能体)已经会运行能够覆盖自身改动的测试和检查, [lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:ESLint 修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。 -两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。`check:pre-push` 包脚本与调度器的 `pre-push` 模式不存在;[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 继续负责 CI 和 `doc-sync` 调度。 +两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。可选运行的 `check:all` 包脚本独立于这些钩子,从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `check-all` 调度器清单;它是贡献者命令,而非对 agent 的指令。 agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小范围测试和检查。CI 负责全量覆盖率门禁、构建产物检查与平台矩阵。只有在明确要求、诊断 CI,或涉及全仓库的改动无法由范围更窄的证据得到可信验证时,才完整运行一遍本地检查矩阵。 @@ -31,6 +31,6 @@ agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小 ## 结果 -普通提交的关键路径是暂存文件 lint,缓存已预热时推送的关键路径是增量类型检查。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 +普通提交的关键路径是暂存文件 lint,缓存已预热时推送的关键路径是增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 从本地推送成功不再能证明仓库完整矩阵已通过。agent 必须选择相关的行为证据,评审人必须判断该选择是否与 diff 相符,CI 则对每个推送版本提供一次全面信号。 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index f400701fcd..1e20d26464 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -57,7 +57,7 @@ Normalization replaces session, cwd, protocol-id, timestamp, path, and process v ### Isolation: normalization now, sandbox later -Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-login shell, constrained commands, and normalization. Concurrent replay runs own separate cwd, persistence, and fixed-length scenario-keyed spill roots, so one scenario's teardown cannot delete another's in-flight full-output recovery while real-path preview budgets remain stable. This tier does not claim OS confinement. A sandboxed executor can replace the local backend through the existing [capability seam](../architecture/2026-06-13-capability-seams.md) if a stronger tier is needed. +Tool determinism comes from a generated cwd, scrubbed environment, fresh non-login shell, constrained commands, and normalization. The cwd defaults to the platform temp directory; a scenario can instead supply its parent when temp is an always-writable policy root and the behavior needs an independent project location. Concurrent replay runs own separate cwd, persistence, and fixed-length scenario-keyed spill roots, so one scenario's teardown cannot delete another's in-flight full-output recovery while real-path preview budgets remain stable. This tier does not claim OS confinement. A sandboxed executor can replace the local backend through the existing [capability seam](../architecture/2026-06-13-capability-seams.md) if a stronger tier is needed. ### The replay plugin is its own package @@ -75,6 +75,6 @@ Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-log ## Consequences -The new tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures. Workspace seeds are copied into the temporary cwd for both record and replay. In return the tier provides deterministic keyless transcript coverage through the real Loader and tool composition. The subprocess, input, workspace, normalization, and replay harness can support examples beyond ACP. +The new tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless transcript coverage through the real Loader and tool composition. The subprocess, input, workspace, normalization, and replay harness can support examples beyond ACP. This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas snapshot tests pin the *external protocol output*. They are complementary — one guards the event-sourcing invariant, the other guards the editor-facing contract. diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 5e03f2bb19..02e98e78b5 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -10,14 +10,27 @@ import { createRequire } from 'node:module' import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime' import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver' +const LOOPBACK_HOST = '127.0.0.1' +const ALL_INTERFACES_HOST = '0.0.0.0' + export async function runWeb(argv: string[]): Promise { const { values } = parseArgs({ args: argv, - options: { port: { type: 'string', default: '3080' } }, + options: { + host: { type: 'string', default: LOOPBACK_HOST }, + port: { type: 'string', default: '3080' }, + }, allowPositionals: false, }) + if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) { + process.stderr.write( + `dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`, + ) + process.exit(1) + } + const hostAddress = values.host const port = Number(values.port) - if (!Number.isInteger(port) || port <= 0 || port > 65535) { + if (!Number.isInteger(port) || port < 0 || port > 65535) { process.stderr.write(`dsh web: invalid --port ${values.port}\n`) process.exit(1) } @@ -65,7 +78,7 @@ export async function runWeb(argv: string[]): Promise { let server: Awaited> try { server = await startWebServer( - { port, distIndex, apiHandler: host.handler, webPlugins }, + { host: hostAddress, port, distIndex, apiHandler: host.handler, webPlugins }, (err: Error) => { process.stderr.write(`dsh web: ${String(err)}\n`) void shutdown(1) @@ -78,11 +91,12 @@ export async function runWeb(argv: string[]): Promise { process.exit(1) } - // The server binds 0.0.0.0 (remote-container + LAN-browser is the primary scenario); - // print the LAN address alongside loopback so the printed URL is copy-usable from outside. - const lan = Object.values(networkInterfaces()).flat() - .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - console.log(`dsh web: http://127.0.0.1:${server.port}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`) + const lan = hostAddress === ALL_INTERFACES_HOST + ? Object.values(networkInterfaces()).flat() + .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + : undefined + const localUrl = `http://${LOOPBACK_HOST}:${server.port}` + console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`) process.on('SIGTERM', () => { void shutdown(0) }) process.on('SIGINT', () => { void shutdown(130) }) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 291e871e63..b75716c615 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -44,6 +44,7 @@ describe('web boot chain (keyless, real carrier)', () => { const port = await probeFreePort() const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) } server = await startWebServer({ + host: '127.0.0.1', port, distIndex: DIST_INDEX, apiHandler, @@ -110,6 +111,7 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( // ?fixture never opens HTTP streams; /api is a tripwire like the first describe. const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) } server = await startWebServer({ + host: '127.0.0.1', port, distIndex: DIST_INDEX, apiHandler, diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 95b293d0e7..a51e0c9e56 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -85,6 +85,39 @@ const notReady = UI_PLUGIN_DIRS.filter((dir) => { }) if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`) +describe('dsh web keyless CLI smoke', () => { + it('listens on 127.0.0.1 by default', async () => { + requireDist() + const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-keyless-')) + const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href + const child = spawn( + process.execPath, + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + { + cwd: sessionsDir, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-web-no-call', + TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + try { + const readyUrl = await waitForReadyLine(child) + expect(readyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) + expect((await fetch(readyUrl)).status).toBe(200) + } finally { + const closed = child.exitCode === null + ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) + : Promise.resolve() + if (child.exitCode === null) child.kill('SIGTERM') + await closed + rmSync(sessionsDir, { recursive: true, force: true }) + } + }) +}) + describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => { let child: ChildProcess let sessionsDir: string diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 333d90b910..01d7f139d5 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: 1db749ec8b6c6a8872bdc1280cd50dbf0583e159 -architecture.zh.md: 8d05765eb94853076cf8f13c390cef01da07281f +architecture.md: 905cb4a202f7278bbb2694b90b498c85fee3217b +architecture.zh.md: 451ae1a048c1c5f7d5100192980bdfc0d5275798 diff --git a/docs/architecture.md b/docs/architecture.md index 1db749ec8b..905cb4a202 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,7 +63,7 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop drains prompt-to-checkpoint work through plugin-visible services and events. +The shipped loop runs prompt-to-checkpoint work through plugin services and events. A **session** is append-only. Each ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits the preceding claimed turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it; a **step** is one model request plus tools. In the [sequence below](agent-lifecycle.md), quotes mark durable events. @@ -91,7 +91,7 @@ forever: agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> log request/header -> llm/stream (frozen) + agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen) on final adapter-path or terminal in-band failure: 'step/end' agent/request-error(original error, failure facts, immutable prior failures, signal) @@ -103,10 +103,10 @@ forever: schedule tool calls by ctx.tools.executionMode: exclusive -> one-call barrier parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start - each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute each model-order result -> ordered tools/post-execute -> 'tool/result' append accepted tool-batch context after all recorded results, then steering - agent/post-step + agent/post-step -> checkpoint complete response/results 'step/end' agent/turn-continuation agent/turn-stop (terminal policy) @@ -127,7 +127,7 @@ The turn contains failures. Adapter failures close the step before `agent/reques Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tool calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The turn signal retires before `turn/end`. Effective `cancel()` emits its typed cause before clearing queues and aborting; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). -Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. +Session events are turn-enclosed. Reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures report only through `agent/error`; no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. ### Agent Handles @@ -145,7 +145,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw **Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern. Backends buffer synchronous `session/event` notifications; the loop awaits a turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract. +Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). `ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 8d05765eb9..451ae1a048 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -63,7 +63,7 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 ` ## 默认循环生命周期 -已交付的循环通过插件可见的服务和事件,持续处理从提示词到检查点的工作。 +已交付的循环通过插件服务和事件,处理从提示词到检查点的工作。 **会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 @@ -91,7 +91,7 @@ forever: agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> log request/header -> llm/stream (frozen) + agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen) on final adapter-path or terminal in-band failure: 'step/end' agent/request-error(original error, failure facts, immutable prior failures, signal) @@ -103,10 +103,10 @@ forever: schedule tool calls by ctx.tools.executionMode: exclusive -> one-call barrier parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start - each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute each model-order result -> ordered tools/post-execute -> 'tool/result' append accepted tool-batch context after all recorded results, then steering - agent/post-step + agent/post-step -> checkpoint complete response/results 'step/end' agent/turn-continuation agent/turn-stop (terminal policy) @@ -127,7 +127,7 @@ forever: 其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具调用会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。轮次信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会在清空队列和中止前发出类型化原因;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 -每个会话事件都包围在轮次内。重新加载会保留中断的日志尾部,并用合成的 `interrupted` 轮次结束事件将其闭合。持久轮次关闭后的故障只通过 `agent/error` 报告,因为此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。 +会话事件均位于轮次边界内。重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障只通过 `agent/error` 报告;此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。 ### Agent 句柄 @@ -145,7 +145,7 @@ forever: **模型可见 ⟺ 已记录**:日志可以重建每个请求,包括由请求头会话前缀置于开头的 `step/start` 时消息,以及通过折叠 `request/header` 得到的请求头;开发期不变量会断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责。后端会缓冲同步的 `session/event` 通知;循环等待轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约。 +持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 `ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 76afd7d68a..fd67bef0b8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -79,7 +79,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:38`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:41`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -218,11 +218,10 @@ Requires: `sandbox` · `sandboxPolicy` ```ts config-catalog /** * 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 ``` @@ -271,7 +270,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/cli-demo/src/index.ts:25`](../packages/examples/cli-demo/src/index.ts) +Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts) ## `@deepseek-ai/dsh-code-runtime-worker` @@ -389,8 +388,8 @@ Requires: `sandboxPolicy` /** * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve * base for relative paths). The sandbox default (mode + `workspace-write` - * boundary root) is NOT here — it lives on `ctx.sandboxPolicy`, the one home - * both enforcing families share. + * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling + * session for both enforcing families. */ export type Config = LocalConfig ``` @@ -912,8 +911,8 @@ export interface Config { /** File-sandbox mode a session starts from (default: `read-only`). */ mode?: SandboxMode /** - * Absolute root directory `workspace-write` may write under (default: - * `process.cwd()`). Both enforcing families fence against this SAME root. + * Fallback root for agentless calls and sessions without a cwd (default: + * `process.cwd()`). Normal agent calls use their session cwd instead. */ workspaceRoot?: string } @@ -1615,7 +1614,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/tui-demo/src/index.ts:32`](../packages/examples/tui-demo/src/index.ts) +Source: [`packages/examples/tui-demo/src/index.ts:33`](../packages/examples/tui-demo/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -1836,6 +1835,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) +- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d7ca6cb602..9b358d8b21 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -499,12 +499,12 @@ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call sandbox mode this write runs under; a - * sandboxing backend fences the write by it, the bare backend ignores it. - * Omit to leave the backend its own default. + * @param sandboxPolicy - the per-call mode and workspace root this write + * runs under; a sandboxing backend fences the write by it, the bare backend + * ignores it. Omit to leave the backend its own default. * @returns the outcome, including the version the write produced. */ -abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise +abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise /** * Atomically edit literal text. When supplied, the version guard is checked @@ -514,15 +514,15 @@ abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call sandbox mode this edit runs under; a - * sandboxing backend fences the edit by it, the bare backend ignores it. - * Omit to leave the backend its own default. + * @param sandboxPolicy - the per-call mode and workspace root this edit runs + * under; a sandboxing backend fences the edit by it, the bare backend + * ignores it. Omit to leave the backend its own default. * @returns the outcome, including the version the edit produced. */ -abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise +abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise ``` -Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsPathInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) · [SandboxMode](../core-data-structures/sandbox.md) +Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsPathInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) · [SandboxExecutionPolicy](../core-data-structures/sandbox.md) Source: [`packages/fs/fs/src/index.ts:81`](../../packages/fs/fs/src/index.ts) @@ -856,13 +856,28 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:122`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:131`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` -The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode and workspace root; enforcing implementations read defaultMode and workspaceRoot, and the tool layers fold each session's `sandbox/mode` override with effectiveSandboxMode on top. +The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode and fallback workspace root. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. -Source: [`packages/sandbox/sandbox-policy/src/index.ts:60`](../../packages/sandbox/sandbox-policy/src/index.ts) +```ts cordis-catalog +/** + * Resolve the complete policy for one capability call. An approved explicit + * mode outranks the session's last `sandbox/mode` event, which outranks the + * deployment default. A session cwd is its workspace-write boundary; the + * configured root is the fallback for agentless calls and sessions without a + * cwd. + * @param request - optional session and approved mode override. + * @returns the fully resolved per-call mode and absolute workspace root. + */ +resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy +``` + +Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) + +Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../../packages/sandbox/sandbox-policy/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 2070c464cf..b639e55927 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -67,8 +67,8 @@ interface BashExecRequest { * reject non-`DSH_*` names supplied through this managed channel. */ 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 } ``` @@ -100,8 +100,8 @@ interface BashExecSpec { env?: Record | undefined /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ dshEnv?: DshEnvironment | undefined - /** Resolved sandbox mode; ignored by executors that do not confine. */ - sandboxMode: SandboxMode | undefined + /** Resolved sandbox policy; ignored by executors that do not confine. */ + sandboxPolicy: SandboxExecutionPolicy | undefined } ``` @@ -159,7 +159,7 @@ interface CollectedOutput { ## File sandbox: `BashSandboxInfo` -A sandbox-consuming executor exposes its configured fallback through `BashExecutor.sandboxMode`. The tool layer folds each session's durable `sandbox/mode` override (owned by [`@deepseek-ai/dsh-sandbox-policy`](../../packages/sandbox/sandbox-policy/README.md)) and may replace it for one user-approved strictly wider call. The mode/enforcement vocabulary is owned by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md); modes govern file effects only. +A sandbox-consuming executor exposes its configured mode fallback through `BashExecutor.sandboxMode`. The tool layer asks [`@deepseek-ai/dsh-sandbox-policy`](../../packages/sandbox/sandbox-policy/README.md) to resolve each calling session's durable `sandbox/mode` override and immutable cwd into `BashExecRequest.sandboxPolicy`; a user-approved strictly wider call replaces only the mode. The mode/root/enforcement vocabulary is owned by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md); modes govern file effects only. A sandboxed run reports its mode, conservative denial classification, and enforcement completeness. `runnerFailed` marks a sandbox runner failure before the command ran; foreground execution throws `SANDBOX_UNAVAILABLE`, while a settled background process has only its facts channel. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index e3ed506fa7..9194c73d98 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -30,7 +30,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles | | [pty.md](pty.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots | -| [sandbox.md](sandbox.md) | the process-confinement seam: file-effect modes, `SandboxPolicy`, `ConfinedArgv`, enforcement and fail-closed errors | +| [sandbox.md](sandbox.md) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `ConfinedArgv`, enforcement and fail-closed errors | | [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | | [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` | | [lsp.md](lsp.md) | the LSP navigation seam: `LspQueryRequest`/`Result`, `LspProvider`/`Service`, four operations, `LspError` | diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 5b20febc3e..bdc86287fb 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -38,7 +38,35 @@ type SandboxEnforcement = 'full' | 'partial' ## Per-call policy -The policy is fully resolved and carried per call. This permits concurrent consumers and one-shot escalated retries to ask the same provider for different boundaries without mutating provider state. +The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. The root is canonicalized with filesystem semantics before lexical normalization, so a cwd containing `symlink/..` identifies the directory where a spawned process actually runs. + +```ts type-equiv +/** + * The complete file-effect policy resolved for one capability call. The root + * is carried even under modes that do not consume it so callers can resolve + * policy once before choosing the enforcement path. + */ +interface SandboxExecutionPolicy { + /** The file-effect mode this execution runs under. */ + mode: SandboxMode + /** Absolute root directory `workspace-write` may write under. */ + workspaceRoot: string +} +``` + +`ctx.sandboxPolicy.resolve()` accepts the active session and, for an approved retry, an explicit mode. The service owns precedence and root fallback so bash and fs do not repeat it. + +```ts type-equiv +/** Inputs that select the sandbox policy for one capability call. */ +interface SandboxPolicyRequest { + /** Calling session; its immutable cwd becomes the workspace boundary. */ + session?: Session + /** Explicit approved mode override, which outranks session policy. */ + mode?: SandboxMode +} +``` + +Only a confined execution reaches `ctx.sandbox`; its provider policy narrows the mode while retaining the same root. This permits concurrent sessions, consumers, and one-shot escalated retries to ask the same provider for different boundaries without mutating provider state. ```ts type-equiv /** @@ -46,15 +74,12 @@ The policy is fully resolved and carried per call. This permits concurrent consu * fixed on the provider: two consumers may confine under different policies * at the same instant (bash under `read-only` while a confined child agent * needs its state directory writable), and an approved escalated retry is a - * new call with a wider policy. Defaulting/resolution is the consumer's - * explicit step (its config owns the fallback chain); the provider treats - * the policy as fully specified. + * new call with a wider policy. Defaulting/resolution is an explicit step at + * the consumer boundary; the provider treats the policy as fully specified. */ -interface SandboxPolicy { +interface SandboxPolicy extends SandboxExecutionPolicy { /** The file-effect mode this execution runs under. */ mode: ConfinedSandboxMode - /** Absolute root directory `workspace-write` may write under. */ - workspaceRoot: string } ``` diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 20eb3b4ca1..4ea0bafef3 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: 10406cebae1bf83fff663903b1478c9acb8476a1 -development.zh.md: 50051ffd631518b37c3ad96f5fd3830cf6893ec9 +development.md: 3559b09d86395707f222aad0a281c9db1246c24f +development.zh.md: 8664a3291c04a5338fdadbce8f24b160cc9ec0a8 diff --git a/docs/development.md b/docs/development.md index 10406cebae..3559b09d86 100644 --- a/docs/development.md +++ b/docs/development.md @@ -65,6 +65,8 @@ The vendor manifest guard checks that changes under `vendor/*/src` are staged wi The hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix. +Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction. + ## CI gates The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. @@ -77,6 +79,7 @@ Use these from the repo root: pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY +pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix diff --git a/docs/development.zh.md b/docs/development.zh.md index 50051ffd63..8664a3291c 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -65,6 +65,8 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v 这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。 +贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。 + ## CI 门禁 keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 @@ -77,6 +79,7 @@ keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若 pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY +pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d7f4123b91..afbc4c210b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -12,7 +12,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:172`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-title`](../packages/session-title/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:90`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | @@ -42,7 +42,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:123`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:105`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.md b/docs/module-graph.md index ca4848477f..2577e468a6 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -98,6 +98,7 @@ flowchart TD pkg_hooks_codex["hooks-codex"] end subgraph group_session_persistence["packages/session-persistence"] + pkg_session_checkpoint_policy["session-checkpoint-policy"] pkg_session_persistence["session-persistence"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] @@ -530,6 +531,12 @@ flowchart TD pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools + pkg_session_checkpoint_policy --> pkg_agent + pkg_session_checkpoint_policy --> pkg_invariants + pkg_session_checkpoint_policy --> pkg_llm + pkg_session_checkpoint_policy --> pkg_session + pkg_session_checkpoint_policy --> pkg_session_persistence + pkg_session_checkpoint_policy --> pkg_tools pkg_agent_loop_testkit --> pkg_agent pkg_agent_loop_testkit --> pkg_invariants pkg_agent_loop_testkit --> pkg_llm @@ -691,6 +698,7 @@ flowchart TD pkg_acp_demo --> pkg_command_goal pkg_acp_demo --> pkg_commands pkg_acp_demo --> pkg_invariants + pkg_acp_demo --> pkg_session_checkpoint_policy pkg_acp_demo --> pkg_session_persistence_jsonl pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_user_interaction @@ -701,6 +709,7 @@ flowchart TD pkg_cli_demo --> pkg_invariants pkg_cli_demo --> pkg_llm pkg_cli_demo --> pkg_session + pkg_cli_demo --> pkg_session_checkpoint_policy pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context @@ -713,6 +722,7 @@ flowchart TD pkg_tui_demo --> pkg_invariants pkg_tui_demo --> pkg_llm pkg_tui_demo --> pkg_session + pkg_tui_demo --> pkg_session_checkpoint_policy pkg_tui_demo --> pkg_session_persistence_jsonl pkg_tui_demo --> pkg_tool_ask_user pkg_tui_demo --> pkg_tools @@ -823,6 +833,7 @@ flowchart TD | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | `session-persistence` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | @@ -844,6 +855,6 @@ flowchart TD | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | -| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index c96cf0109c..d803da3d0e 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -29,7 +29,7 @@ Add to your Zed `settings.json` under `agent_servers`: } ``` -The editor sets each session's `cwd` to the project it opens, and bash uses that directory as its workdir. The current sandbox write boundary is nevertheless fixed when the server starts (`workspaceRoot: process.cwd()`), so launch the server from the workspace it should be allowed to modify; making that root session-scoped is deferred in the [sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). The filesystem tools ride the same sandbox policy through [`@deepseek-ai/dsh-fs-sandbox`](../../packages/fs/fs-sandbox/), so `read`/`write`/`edit` remain available regardless of plan state and confined to the same `workspaceRoot`. +The editor sets each session's `cwd` to the project it opens. That directory is both bash's default workdir and the session's primary `workspace-write` boundary: every bash or filesystem mutation carries one policy resolved from the calling session, so a single server process may serve concurrent projects. Projects outside the platform temporary areas do not grant either session writes into the other; `/tmp` and `os.tmpdir()` remain shared writable scratch roots under `workspace-write`, so projects placed there are not mutually isolated ([writable-root contract](../../packages/sandbox/sandbox/README.md)). The configured `workspaceRoot: process.cwd()` remains the fallback for calls without a session cwd. The filesystem tools ride the same policy through [`@deepseek-ai/dsh-fs-sandbox`](../../packages/fs/fs-sandbox/), so `read`/`write`/`edit` are available under every mode and confined to the same policy. ## Plan mode @@ -47,9 +47,9 @@ The default tree composes [`@deepseek-ai/dsh-sandbox-local`](../../packages/sand - **One session config option is live**: a capable client shows one `Permissions` select. `workspace-write` means workspace-confined bash plus `ask`; `danger-full-access` means unconfined bash plus `never`. Switching writes one `permission/preset` event through to the sandbox-mode and approval-policy events, and `session/load` reports the resumed value. - **Every approval is one-shot**: the choices are `Allow once` and `Reject`; a dismissal, rejection, missing editor, or unavailable runner fails closed. -- **The boundary spans bash and the filesystem tools, and is config-fixed today**: bash confines through the OS runner and the `read`/`write`/`edit` tools through an in-process path fence ([`dsh-fs-sandbox`](../../packages/fs/fs-sandbox/)), both keyed to the same `workspaceRoot` — which remains the server's launch directory (a per-session root is deferred). +- **The boundary spans bash and the filesystem tools per session**: bash confines through the OS runner and the `read`/`write`/`edit` tools through an in-process path fence ([`dsh-fs-sandbox`](../../packages/fs/fs-sandbox/)); both receive the calling session's cwd as `workspaceRoot`. -`tests/escalation.e2e.ts` boots this default tree keyless, drives the permission select, and—with a key and usable runner—proves both approval outcomes against the filesystem. Most snapshots use that tree and start at `danger-full-access` so bash fixtures remain runner-independent; scenarios that call `read`, `write`, or `edit` use the fixed full-access fs overlay and a separate request-header pin. The permission-switching and escalation inputs select `workspace-write` before exercising the bash policy path. No fixture pins a real denial because kernel error text is backend-specific; real confinement remains covered by the sandbox packages' kernel e2e suites. +`tests/escalation.e2e.ts` boots this default tree keyless, drives the permission select, and—with a key and usable runner—proves both approval outcomes against the filesystem. The agent-spine e2e independently boots one context with two home-directory project sessions and world-verifies concurrent own-root success plus sibling-root denial through both shipped tool families. The keyless `session-sandbox-root` ACP snapshot places its generated project under the user home while an overlay points the deployment fallback at `/tmp`; its successful `workspace-write` call proves the assembled app used the session cwd. Most snapshots start at `danger-full-access` so bash fixtures remain runner-independent. No fixture pins real runner denial text because its dialect is platform-specific. ## MVP limitations diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 4122867228..ffde5b0e3e 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -22,8 +22,8 @@ # workspace and asks before a wider retry. Snapshot runs select # danger-full-access so the established scenarios remain runner-independent; # DSH_PERMISSION_MODE provides the same explicit deployment/test override -# outside the snapshot harness. The sandbox mode + workspace root live on -# ctx.sandboxPolicy — the one home both enforcing families (bash, fs) read. +# outside the snapshot harness. The sandbox default + fallback root live on +# ctx.sandboxPolicy; agent calls resolve both families against the session cwd. - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' diff --git a/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml b/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml new file mode 100644 index 0000000000..f1261dc294 --- /dev/null +++ b/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml @@ -0,0 +1,50 @@ +# Keyless replay counterpart of session-sandbox-root.cordis.yml. Patches do not +# compose across nested includes, so the replay swap, the recorded model pin, +# and the deliberately distinct sandbox fallback are applied together to the +# live tree. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: /tmp + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/session-sandbox-root.cordis.yml b/examples/acp-agent/session-sandbox-root.cordis.yml new file mode 100644 index 0000000000..f27732fd68 --- /dev/null +++ b/examples/acp-agent/session-sandbox-root.cordis.yml @@ -0,0 +1,14 @@ +# Session-root sandbox snapshot overlay. The generated ACP session cwd lives +# under the user's home, while this deployment fallback deliberately points at +# /tmp. A workspace-write mutation can therefore succeed only when the calling +# session's cwd replaces the process-level fallback root. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')" + workspaceRoot: /tmp diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 297a36da5f..eb02832089 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' +import { homedir } from 'node:os' import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot' /** @@ -32,6 +33,7 @@ const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.m const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) +const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { @@ -222,6 +224,19 @@ const SCENARIOS: Scenario[] = [ { name: 'escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, { name: 'escalation-rejected', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, { name: 'fs-escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, + // Unlike ordinary snapshots, this session cwd is outside the platform temp + // roots that workspace-write always grants. The overlay points the + // deployment fallback at /tmp, so a successful relative write proves the + // assembled app replaced that process-level fallback with SessionHeader.cwd. + { + name: 'session-sandbox-root', + hasModelTurn: true, + recorded: false, + overridden: true, + headerClass: 'sandbox', + configPath: SESSION_SANDBOX_ROOT_CONFIG, + workspaceParent: homedir(), + }, ] defineAcpSnapshotSuite({ diff --git a/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.jsonl b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.jsonl new file mode 100644 index 0000000000..16d72490a8 --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"semantic-checkpoint-replay","createdAt":1,"delegationDepth":0} diff --git a/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.override.json b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.override.json new file mode 100644 index 0000000000..192212820e --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.override.json @@ -0,0 +1,11 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "I will verify the external state before deciding whether to retry the side-effecting operation." }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "I will verify the external state before deciding whether to retry the side-effecting operation." } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl new file mode 100644 index 0000000000..fa003415b7 --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl @@ -0,0 +1,21 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Perform one side-effecting remote mutation."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"assistant/message","seq":3,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"}},"surfaceOp":"append"} +{"type":"tool/call","seq":4,"time":0,"data":{"turn":1,"step":1,"callId":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}} +{"type":"tool/result","seq":5,"time":0,"data":{"turn":1,"step":1,"callId":"unknown-outcome-call","content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}],"isError":true,"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[4]} +{"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"interrupted"}}} +{"type":"turn/start","seq":8,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":10,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":11,"time":0,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":12,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":17,"time":0,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"}},"sourceEventSeqs":[13,14,15,16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":0,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":19,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/stdout.expected.jsonl b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/stdout.expected.jsonl new file mode 100644 index 0000000000..454edf63cd --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/stdout.expected.jsonl @@ -0,0 +1,9 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Perform one side-effecting remote mutation."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"unknown-outcome-call","title":"write_remote","kind":"other","status":"in_progress","rawInput":{"value":1}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"unknown-outcome-call","status":"failed","content":[{"type":"content","content":{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} +{"jsonrpc":"2.0","id":2,"result":{"modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Perform one side-effecting remote mutati","updatedAt":"{{updatedAt}}"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/semantic-checkpoint.snapshot.ts b/examples/acp-agent/tests/semantic-checkpoint.snapshot.ts new file mode 100644 index 0000000000..e43d2ca4d0 --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint.snapshot.ts @@ -0,0 +1,129 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { + launchAcpTestAgent, + normalizeSessionLog, + normalizeStdout, + scrubRequestHeaders, + type AgentUnderTest, + type NormalizeContext, +} from '@deepseek-ai/dsh-acp-snapshot' +import { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { describe, expect, it } from 'vitest' + +const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'semantic-checkpoint-snapshots/tool-outcome-unknown') +const replayFixture = join(fixtureDir, 'replay.jsonl') +const replayOverride = join(fixtureDir, 'replay.override.json') +const stdoutExpected = join(fixtureDir, 'stdout.expected.jsonl') +const sessionExpected = join(fixtureDir, 'session.expected.jsonl') +const sessionId = SessionId('semantic-checkpoint-unknown-outcome') +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' + +const agent: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), +} + +async function seedInterruptedSession(root: string, cwd: string): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + const meta: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: sessionId, + createdAt: 1, + cwd, + delegationDepth: 0, + } + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: 11, data: { content: [{ type: 'text', text: 'Perform one side-effecting remote mutation.' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: 12, data: { turn: 1, step: 1 } }, + { + type: 'assistant/message', + seq: 3, + time: 13, + data: { + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: CallId('unknown-outcome-call'), name: 'write_remote', arguments: '{"value":1}' }], + provenance: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + }, + surfaceOp: 'append', + }, + { + type: 'tool/call', + seq: 4, + time: 14, + data: { + turn: 1, + step: 1, + callId: CallId('unknown-outcome-call'), + name: 'write_remote', + arguments: '{"value":1}', + }, + }, + ] + try { + await ctx.sessionPersistence.create(meta) + await ctx.sessionPersistence.append(sessionId, events) + const location = ctx.sessionPersistence.locate(meta) + if (location === undefined) throw new Error('JSONL backend did not locate the seeded session') + return location.path + } finally { + await ctx.fiber.dispose() + } +} + +describe('semantic checkpoint recovery snapshot', () => { + it('loads an unknown tool outcome and carries retry-risk guidance into the next model turn', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-semantic-snapshot-cwd-')) + const sessionsRoot = await mkdtemp(join(tmpdir(), 'dsh-semantic-snapshot-sessions-')) + let launched: ReturnType | undefined + try { + const sessionPath = await seedInterruptedSession(sessionsRoot, cwd) + launched = launchAcpTestAgent({ + agent, + cwd, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: replayFixture, + DSH_SNAPSHOT_OVERRIDE: replayOverride, + DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, + }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await launched.client.loadSession({ sessionId, cwd, mcpServers: [] }) + await launched.client.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'Continue safely from the interrupted operation.' }], + }) + await launched.close() + + const normalization: NormalizeContext = { sessionIds: [sessionId], cwd } + const stdout = normalizeStdout(launched.rawStdout(), normalization) + const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization)) + if (refreshing) { + await writeFile(stdoutExpected, stdout) + await writeFile(sessionExpected, session) + } + expect(stdout).toBe(await readFile(stdoutExpected, 'utf8')) + expect(session).toBe(await readFile(sessionExpected, 'utf8')) + expect(session).toContain('TOOL_OUTCOME_UNKNOWN') + expect(session).toContain('Do not retry blindly.') + } finally { + await launched?.close('SIGKILL').catch(() => undefined) + await Promise.all([ + rm(cwd, { recursive: true, force: true }), + rm(sessionsRoot, { recursive: true, force: true }), + ]) + } + }) +}) diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json index 0f40e9d8b6..7024820966 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json @@ -6,7 +6,9 @@ "op": "promptAndCancel", "text": "Run two shell commands: wait for cancellation, then write skipped.txt.", "afterUpdate": "tool_call", + "waitForFile": { "path": "started.txt" }, "waitForToolCallUpdate": "call_skipped" - } + }, + { "op": "waitForTurnEnd" } ] } diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json b/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json index c0aa7730d7..ec47a5cd1d 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json @@ -3,8 +3,8 @@ "kind": "chunks", "chunks": [ { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "call_wait", "name": "bash", "argumentsDelta": "{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_wait", "name": "bash", "arguments": "{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" } }, + { "type": "tool-call-delta", "index": 0, "id": "call_wait", "name": "bash", "argumentsDelta": "{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_wait", "name": "bash", "arguments": "{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" } }, { "type": "block-start", "index": 1, "blockType": "tool-call" }, { "type": "tool-call-delta", "index": 1, "id": "call_skipped", "name": "bash", "argumentsDelta": "{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}" }, { "type": "block-end", "index": 1, "block": { "type": "tool-call", "id": "call_skipped", "name": "bash", "arguments": "{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}" } }, diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index 25a6c1aabf..16d218f778 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -5,15 +5,15 @@ {"type":"step/start","seq":3,"time":1784437195076,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1784437195076,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_wait","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}} -{"type":"assistant/chunk","seq":7,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}} +{"type":"assistant/chunk","seq":6,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_wait","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}} {"type":"assistant/chunk","seq":8,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skipped","name":"bash","argumentsDelta":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}} {"type":"assistant/chunk","seq":10,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":10}}}} {"type":"assistant/chunk","seq":12,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} -{"type":"tool/call","seq":14,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} +{"type":"assistant/message","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} {"type":"tool/result","seq":15,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} {"type":"tool/result","seq":17,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true,"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"sourceEventSeqs":[16],"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl index 63d5bc0927..cc04c4d105 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl @@ -2,7 +2,7 @@ {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Run two shell commands: wait","updatedAt":"{{updatedAt}}"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: command aborted\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skipped","title":"printf skipped > skipped.txt","kind":"execute","status":"in_progress","rawInput":"printf skipped > skipped.txt","content":[{"type":"content","content":{"type":"text","text":"Write skipped marker"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/input.json b/examples/acp-agent/tests/snapshots/cancel/input.json index 0bc989ed10..a2e2fdc5f0 100644 --- a/examples/acp-agent/tests/snapshots/cancel/input.json +++ b/examples/acp-agent/tests/snapshots/cancel/input.json @@ -2,6 +2,7 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "promptAndCancel", "text": "Start a long task; this turn will be cancelled mid-stream." } + { "op": "promptAndCancel", "text": "Start a long task; this turn will be cancelled mid-stream." }, + { "op": "waitForTurnEnd" } ] } diff --git a/examples/acp-agent/tests/snapshots/session-sandbox-root/input.json b/examples/acp-agent/tests/snapshots/session-sandbox-root/input.json new file mode 100644 index 0000000000..9cef40f51d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-sandbox-root/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "setConfigOption", "configId": "permission", "value": "workspace-write" }, + { "op": "prompt", "text": "Use the write tool (NOT bash) to create session-root.txt in the current directory containing exactly: session root. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/session-sandbox-root/replay.override.json b/examples/acp-agent/tests/snapshots/session-sandbox-root/replay.override.json new file mode 100644 index 0000000000..511613b441 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-sandbox-root/replay.override.json @@ -0,0 +1,22 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_session_root", "name": "write", "argumentsDelta": "{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_session_root", "name": "write", "arguments": "{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "DONE" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "DONE" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl b/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl new file mode 100644 index 0000000000..1abfd3ba7d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl @@ -0,0 +1,27 @@ +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0,"cwd":"/Users/cty/acp-snap-cwd-MABAjO","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1784567324138,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"permission/preset","seq":1,"time":1784567324138,"data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","seq":2,"time":1784567324138,"data":{"mode":"workspace-write"}} +{"type":"approval/policy","seq":3,"time":1784567324138,"data":{"policy":"ask"}} +{"type":"user/message","seq":4,"time":1784567324138,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create session-root.txt in the current directory containing exactly: session root. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1784567324138,"data":{"title":"Use the write tool (NOT","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":6,"time":1784567324142,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":7,"time":1784567324142,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":8,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_session_root","name":"write","argumentsDelta":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}} +{"type":"assistant/chunk","seq":10,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":12,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1784567324144,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1784567324145,"data":{"turn":1,"step":1,"callId":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}} +{"type":"tool/result","seq":15,"time":1784567324155,"data":{"turn":1,"step":1,"callId":"call_session_root","content":[{"type":"text","text":"/Users/cty/acp-snap-cwd-MABAjO/session-root.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1784567324157,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1784567324157,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1784567324157,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":1784567324157,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":20,"time":1784567324158,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":21,"time":1784567324158,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":22,"time":1784567324158,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":23,"time":1784567324158,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"step/end","seq":24,"time":1784567324158,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":25,"time":1784567324158,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/session-sandbox-root/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-sandbox-root/stdout.expected.jsonl new file mode 100644 index 0000000000..f0e789892a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-sandbox-root/stdout.expected.jsonl @@ -0,0 +1,9 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the write tool (NOT","updatedAt":"{{updatedAt}}"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_session_root","title":"Write session-root.txt","kind":"edit","status":"in_progress","locations":[{"path":"session-root.txt"}],"content":[{"type":"diff","path":"session-root.txt","oldText":null,"newText":"session root"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_session_root","status":"completed","content":[{"type":"diff","path":"session-root.txt","oldText":null,"newText":"session root"}],"title":"Write session-root.txt"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index 6cfa31a750..e1edc1dadd 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -10,6 +10,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as SessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' @@ -70,7 +71,10 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio // Durable JSONL persistence is opt-in: only the resume e2e needs it, and the // other suites stay file-free. Loaded last so a resume's deferred // `ctx.inject(['sessionPersistence'])` resolves once this is present. - if (options.persistenceRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) + if (options.persistenceRoot !== undefined) { + await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) + await ctx.plugin(SessionCheckpointPolicy) + } return ctx } diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 2ff464de7b..f0eb7ea6d8 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -39,6 +39,9 @@ config: root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' +- id: session-checkpoints + name: '@deepseek-ai/dsh-session-checkpoint-policy' + - id: subagent name: '@deepseek-ai/dsh-subagent' diff --git a/examples/package.json b/examples/package.json index da2c8a52fa..db8cc142eb 100644 --- a/examples/package.json +++ b/examples/package.json @@ -37,6 +37,7 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-tui-demo": "workspace:*", diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index c5d6fa36f3..624041854e 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -99,7 +99,10 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { actions: [ ...SELECT_PRO_MODEL, { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/plan exercise the TUI\r' }, - { waitFor: 'How should the scripted run proceed?', send: '\r' }, + // The question text first appears in the streamed tool-call card. Wait + // for the dialog's input legend so Enter cannot arrive before it owns + // terminal input when pre-dispatch policy yields. + { waitFor: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '' }, // Session title: the first user message drives the first-message-llm // provider's tool-less title call; the scripted adapter answers it, the diff --git a/knip.json b/knip.json index baab7e1d7b..dbc1e585de 100644 --- a/knip.json +++ b/knip.json @@ -317,6 +317,10 @@ "tests/**/*.ts" ] }, + "packages/session-persistence/session-checkpoint-policy": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/util/paths": { "entry": [ "tests/**/*.spec.ts" @@ -376,6 +380,10 @@ "tests/**/*.ts" ] }, + "packages/examples/agent-spine-demo": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/ui/jsonrpc": { "entry": [ "tests/**/*.spec.ts", diff --git a/package.json b/package.json index cab2592fea..611e1d49e2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "test:web": "npm run build:web && vitest run --config vitest.web.config.ts", "test:gui": "vitest run packages/client packages/host", + "check:all": "tsx scripts/run-gates.ts check-all", "check:ci": "tsx scripts/run-gates.ts ci-primary", "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 6428cd7ac8..2c25701fb9 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -109,10 +109,10 @@ export class LocalBashExecutor extends BashExecutor { ...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, } } diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 97419b45d4..93e0c9e6f2 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -16,7 +16,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 +29,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 diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 67b850916c..b889692f3c 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -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,21 +19,19 @@ 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'] @@ -42,7 +41,6 @@ export class SandboxBashExecutor extends LocalBashExecutor { // 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 { - // 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 }) @@ -138,13 +132,13 @@ export class SandboxBashExecutor extends LocalBashExecutor { * `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, diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index 32a246e42e..87bcffe9df 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -89,7 +89,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') diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index b8c86d95b2..3ce944b07c 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -94,7 +94,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') diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 5b2b3ba16a..6e8f2229a0 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -12,7 +12,7 @@ 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 { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' @@ -72,6 +72,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 +151,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 +186,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 +196,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') diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 7e08ea0365..6c212ee546 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -91,7 +91,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') diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index ec5005ec70..73fbb4fb3e 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -27,7 +27,7 @@ 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). diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 55beccacea..a504513417 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -4,7 +4,7 @@ * @module dsh-bash/types */ -import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' /** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ export const DSH_ENV_PREFIX = 'DSH_' as const @@ -75,8 +75,8 @@ export interface BashExecRequest { * reject non-`DSH_*` names supplied through this managed channel. */ 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 } /** @@ -106,8 +106,8 @@ export interface BashExecSpec { env?: Record | undefined /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ dshEnv?: DshEnvironment | undefined - /** Resolved sandbox mode; ignored by executors that do not confine. */ - sandboxMode: SandboxMode | undefined + /** Resolved sandbox policy; ignored by executors that do not confine. */ + sandboxPolicy: SandboxExecutionPolicy | undefined } /** One captured stream: the (possibly truncated) text plus recovery info. */ diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index 63d9533410..cacfe85eca 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -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) diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 5e0ceb3826..f83df567f4 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -17,12 +17,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 diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 8426e770ba..094e594661 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -18,9 +18,9 @@ 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 { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' @@ -299,11 +299,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) @@ -330,9 +337,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 +354,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 => { + const approveBashEscalation = ( + mode: string, + justification: string, + exec: ToolExecution, + standingPolicy: SandboxExecutionPolicy | undefined, + ): Promise => { 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' }, { @@ -401,17 +418,21 @@ export function apply(ctx: Context, config: Config = {}): void { 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. diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 9c9e06bb31..f430ca07a1 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -17,6 +17,7 @@ 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 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' @@ -107,12 +108,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 { - this.modes.push(spec.sandboxMode) + this.modes.push(spec.sandboxPolicy?.mode) return Promise.resolve({ exitCode: 0, signal: null, @@ -121,18 +122,18 @@ 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 }, }) } 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 +150,7 @@ class CountingStartExecutor extends BashExecutor { workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } @@ -175,6 +176,7 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(AgentRegistry) await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) await ctx.plugin(ToolBash) @@ -532,6 +534,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')! @@ -993,7 +1003,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 { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7a8e8abdf3..8080f4ceec 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -263,12 +263,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * List direct children of a directory in stable name order. Returns resolved\n * child targets plus cheap metadata only; never reads file contents.\n * @param target - the resolved directory target.\n * @param signal - aborts the listing.\n * @returns one entry per direct child, in stable name order.\n */', }, { - signature: 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise', - jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxMode - the per-call sandbox mode this write runs under; a\n * sandboxing backend fences the write by it, the bare backend ignores it.\n * Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */', + signature: 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise', + jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this write\n * runs under; a sandboxing backend fences the write by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */', }, { - signature: 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise', - jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxMode - the per-call sandbox mode this edit runs under; a\n * sandboxing backend fences the edit by it, the bare backend ignores it.\n * Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */', + signature: 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise', + jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this edit runs\n * under; a sandboxing backend fences the edit by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */', }, ], }, @@ -437,7 +437,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sandboxPolicy', summary: 'The sandbox-policy service (`ctx.sandboxPolicy`).', - methods: [], + methods: [ + { + signature: 'resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy', + jsDoc: '/**\n * Resolve the complete policy for one capability call. An approved explicit\n * mode outranks the session\'s last `sandbox/mode` event, which outranks the\n * deployment default. A session cwd is its workspace-write boundary; the\n * configured root is the fallback for agentless calls and sessions without a\n * cwd.\n * @param request - optional session and approved mode override.\n * @returns the fully resolved per-call mode and absolute workspace root.\n */', + }, + ], }, { key: 'sessionPersistence', @@ -1186,11 +1191,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BashExecRequest', - declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxMode?: SandboxMode | undefined;\n}', + declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy?: SandboxExecutionPolicy | undefined;\n}', }, { name: 'BashExecSpec', - declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxMode: SandboxMode | undefined;\n}', + declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy: SandboxExecutionPolicy | undefined;\n}', }, { name: 'BashProcess', @@ -1596,13 +1601,21 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SandboxEnforcement', declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';', }, + { + name: 'SandboxExecutionPolicy', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n}', + }, { name: 'SandboxMode', declaration: 'export type SandboxMode = \'read-only\' | \'workspace-write\' | \'danger-full-access\';', }, { name: 'SandboxPolicy', - declaration: 'export interface SandboxPolicy {\n mode: ConfinedSandboxMode;\n workspaceRoot: string;\n}', + declaration: 'export interface SandboxPolicy extends SandboxExecutionPolicy {\n mode: ConfinedSandboxMode;\n}', + }, + { + name: 'SandboxPolicyRequest', + declaration: 'export interface SandboxPolicyRequest {\n session?: Session;\n mode?: SandboxMode;\n}', }, { name: 'SaveTextSpill', diff --git a/packages/core/session/README.md b/packages/core/session/README.md index bba2a21e06..490c54dcdb 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -107,11 +107,11 @@ Appended surface entries preserve reusable prefixes. A `replace` operation inval #### What the model sees -If a persisted turn ended with unanswered tool calls, each synthetic error result contains exactly `Tool call interrupted by a crash; no result was recorded.` +If recovery finds an assistant tool request with no durable `tool/call`, its synthetic `TOOL_NOT_STARTED` result says `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.` If a durable `tool/call` has no result, its `TOOL_OUTCOME_UNKNOWN` result says `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.` #### Token effect -Zero tokens in an intact session. Each repaired call adds this retained error text on resume. +Zero tokens in an intact session. Each repaired call adds its retained risk-specific error text on resume. #### KV Cache effect diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 0d804ec977..7a8ac1b094 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -22,7 +22,7 @@ import { foldRequestHeader } from './request-header.ts' export * from './types.ts' export { isJsonValue, snapshotJsonValue } from './json.ts' export type { JsonValue } from './json.ts' -export { interruptedTurnClosers } from './repair.ts' +export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index aa8aa91531..52ac9247c1 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -10,6 +10,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm' import type { CallId } from '@deepseek-ai/dsh-llm' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { TOOL_NOT_STARTED } from './repair.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-session' @@ -133,8 +134,8 @@ function validateEvent( break } requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail) - const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted' - if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) { + const syntheticNotStarted = event.data.isError && event.data.error?.code === TOOL_NOT_STARTED + if (!trace.pendingCalls.has(event.data.callId) && !syntheticNotStarted) { fail(`tool/result for ${event.data.callId} with no prior tool/call in this step`) } pendingCalls = { kind: 'delete', callId: event.data.callId } diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index efbb3d2004..6d2de49c75 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -8,6 +8,12 @@ import type { CallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from './types.ts' +/** Recovery code for an assistant tool request that never reached a recorded call start. */ +export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED' + +/** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */ +export const TOOL_OUTCOME_UNKNOWN = 'TOOL_OUTCOME_UNKNOWN' + /** * Return deterministic synthetic events that close an open tail turn. Unmatched * calls receive error results first, followed by an open `step/end` and an @@ -82,6 +88,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session // Close calls before their step: providers reject dangling assistant calls, // and Map insertion order preserves their transcript order. for (const [callId, { step, callSeq }] of pendingCalls) { + const started = callSeq !== undefined closers.push({ type: 'tool/result', seq: seq++, @@ -90,12 +97,19 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session turn: openTurn, step, callId, - content: [{ type: 'text', text: 'Tool call interrupted by a crash; no result was recorded.' }], + content: [{ + type: 'text', + text: started + ? 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.' + : 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.', + }], isError: true, - error: { name: 'InterruptedError', code: 'interrupted' }, + error: started + ? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN } + : { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED }, }, surfaceOp: 'append', - ...callSeq !== undefined ? { sourceEventSeqs: [callSeq] } : {}, + ...started ? { sourceEventSeqs: [callSeq] } : {}, }) } diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index bc0a79759d..7b1b671737 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' @@ -256,7 +256,7 @@ describe('session-log invariants', () => { })).toThrow(/outside any open turn/) }) - it('allows interrupted repair results and unresolved calls at step end', async () => { + it('allows not-started repair results and unresolved calls at step end', async () => { const repaired = (await setup()).ctx.sessions.create() expect(() => { repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -267,7 +267,7 @@ describe('session-log invariants', () => { callId: CallId('crashed'), content: [], isError: true, - error: { name: 'InterruptedError', code: 'interrupted' }, + error: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED }, }, { surfaceOp: 'append' }) repaired.append('step/end', { turn: 1, step: 1 }) repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index 765502b8ce..1edda36cfb 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' -import { interruptedTurnClosers } from '../src/index.ts' +import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts' import type { SessionEvent, SurfaceEvent } from '../src/index.ts' /** @@ -47,9 +47,7 @@ describe('interruptedTurnClosers', () => { expect(closers.map(e => e.seq)).toEqual([2, 3]) }) - it('synthesizes an error tool/result for a tool-call the crash left unanswered', () => { - // A step issued one tool call (in the assistant message) but crashed before - // the tool/result was logged — the classic mid-tool crash. + it('marks an assistant tool request with no recorded call as not started', () => { const events: SessionEvent[] = [ userTurnStart(2, 0), { type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } }, @@ -64,8 +62,11 @@ describe('interruptedTurnClosers', () => { expect(closers.map(e => e.seq)).toEqual([3, 4, 5]) const result = closers[0]! expect(result.type === 'tool/result' && result.data).toMatchObject({ - turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: 'interrupted' }, + turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: TOOL_NOT_STARTED }, }) + expect(result.type === 'tool/result' && result.data.content).toEqual([{ + type: 'text', text: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.', + }]) }) it('does NOT synthesize a result for a tool-call that already has one', () => { @@ -152,6 +153,14 @@ describe('interruptedTurnClosers', () => { const result = closers[0]! expect((result as SurfaceEvent).surfaceOp).toBe('append') expect((result as SurfaceEvent).sourceEventSeqs).toEqual([3]) + expect(result.type === 'tool/result' && result.data.error).toEqual({ + name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN, + }) + if (result.type !== 'tool/result' || result.data.content[0]?.type !== 'text') { + throw new Error('expected a text tool result') + } + expect(result.data.content[0].text).toContain('retry only if the operation is read-only or idempotent') + expect(result.data.content[0].text).toContain('first verify external state or ask the user') }) it('handles tool/call without a matching assistant/message entry gracefully', () => { diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index ffd46521cb..7038ad5525 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -15,13 +15,14 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | +| `@deepseek-ai/dsh-session-checkpoint-policy` | semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints | | `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool | | ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately | | ~~`@deepseek-ai/dsh-user-approval`~~ | **omitted by default** — permission policy is deployment-specific; sandbox/approval leaves opt in and the ACP bridge then supplies the answerer | | ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../../ui/acp/README.md)) | | ~~`hmr`~~ | **omitted** — the editor owns the subprocess | -Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead. +The app owns this cluster through one ordered Cordis effect. Teardown drains the ACP bridge before removing the checkpoint policy or persistence backend, so a graceful disconnect persists the real closing `step/end` and `turn/end` events rather than leaving crash recovery to synthesize them. Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead. ## Config diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 370dd39fbf..27148c36b1 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", @@ -60,6 +61,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index eb88880fd2..2f621aaa69 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -1,7 +1,9 @@ /** * The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}), * human-command registry, JSONL session persistence, and the - * {@link @deepseek-ai/dsh-acp} bridge. It writes nothing to stdout. + * {@link @deepseek-ai/dsh-acp} bridge. The app owns those plugins through one + * ordered lifecycle so ACP sessions quiesce before persistence detaches. It + * writes nothing to stdout. * It pre-creates no agents and leaves adapters, executors, and optional tools to * the leaf, which must likewise avoid stdout loggers. Named exports are * required so Loader retains this plugin's `Config` schema (see @@ -21,6 +23,7 @@ import SessionPersistenceJsonl, { JsonlCompressionSchema, type JsonlCompression, } from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' export const name = 'acp-demo' @@ -101,17 +104,22 @@ export const Config: z = z.object({ * NO agents (its `agents` list defaults to `[]`) and carries the deployment * `persona`; the JSONL backend persists under `persistenceRoot`; the ACP * bridge owns stdout for JSON-RPC and creates one agent per `session/new` - * from the provider/model pair. No logger, no `hmr` — stdout stays pure. + * from the provider/model pair. The composite effect unloads in reverse order, + * keeping checkpoint and persistence listeners attached until ACP agents have + * flushed their closing events. No logger, no `hmr` — stdout stays pure. */ export function apply(ctx: Context, config: Config): void { const goals = config.goals ?? {} - ctx.plugin(CommandService) - if (goals !== false) ctx.plugin(commandGoal) - ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }) - ctx.plugin(UserInteractionService) - ctx.plugin(SessionPersistenceJsonl, { - root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, - ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), - }) - ctx.plugin(acp, { provider: config.provider, model: config.model }) + ctx.effect(function* () { + yield ctx.plugin(CommandService).dispose + if (goals !== false) yield ctx.plugin(commandGoal).dispose + yield ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }).dispose + yield ctx.plugin(UserInteractionService).dispose + yield ctx.plugin(SessionPersistenceJsonl, { + root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, + ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), + }).dispose + yield ctx.plugin(sessionCheckpointPolicy).dispose + yield ctx.plugin(acp, { provider: config.provider, model: config.model }).dispose + }, 'acp-demo.composition') } diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index fb6662291e..98a0d7f727 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -35,7 +35,8 @@ const dshPackages = [ 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', - 'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', 'util/paths', + 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', + 'ui/acp', 'examples/acp-demo', 'util/paths', ] const vendorPackages = [ 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', diff --git a/packages/examples/acp-demo/tsconfig.json b/packages/examples/acp-demo/tsconfig.json index 5bd1627345..0e2a6248c9 100644 --- a/packages/examples/acp-demo/tsconfig.json +++ b/packages/examples/acp-demo/tsconfig.json @@ -44,6 +44,9 @@ { "path": "../../ui/tool-ask-user" }, + { + "path": "../../session-persistence/session-checkpoint-policy" + }, { "path": "../../session-persistence/session-persistence-jsonl" }, diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index c30496c62c..bf69e27787 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -55,13 +55,18 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", @@ -70,11 +75,13 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", + "node-addon-landlock-run": "0.0.0-test.0", "cordis": "^4.0.0-rc.7" }, "dependencies": { diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts new file mode 100644 index 0000000000..11f5421bf9 --- /dev/null +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -0,0 +1,243 @@ +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' +import { CallId } from '@deepseek-ai/dsh-llm' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import type { ToolResult } from '@deepseek-ai/dsh-tools' +import { launcherPath } from 'node-addon-landlock-run' +import * as agentSpine from '../src/index.ts' + +const bwrapUsable = spawnSync('bwrap', [ + '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true', +], { timeout: 5_000, stdio: 'ignore' }).status === 0 +const landlockUsable = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, stdio: 'ignore' }).status === 0 +const seatbeltUsable = process.platform === 'darwin' + && spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'workspace-write', workspaceRoot: homedir() }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }).status === 0 +const processSandboxUsable = bwrapUsable || landlockUsable || seatbeltUsable + +let ctx: Context | undefined +let projectA: string +let projectB: string +const tempDirs: string[] = [] + +async function projectDir(label: string): Promise { + const dir = await mkdtemp(join(homedir(), `dsh-${label}-`)) + tempDirs.push(dir) + return dir +} + +async function expectMissing(path: string): Promise { + await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) +} + +function resultText(result: ToolResult): string { + return result.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n') +} + +beforeEach(async () => { + projectA = await projectDir('project-a') + projectB = await projectDir('project-b') + const fallbackRoot = await projectDir('fallback') + + ctx = new Context() + await ctx.plugin(LocalSandboxProvider, {}) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot }) + await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 }) + await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot }) + await ctx.plugin(agentSpine, { + workspaceContext: false, + skills: { enabled: false }, + toolBash: { enableRunInBackground: false }, + toolTasks: false, + }) + await new Promise(resolve => setTimeout(resolve, 50)) + await ctx.plugin(FsPolicy) + await ctx.plugin(ToolFs) +}) + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +async function agents() { + const active = ctx as Context + const [a, b] = await Promise.all([ + active.agents.create({ sessionId: SessionId('project-a-session'), meta: { cwd: projectA } }), + active.agents.create({ sessionId: SessionId('project-b-session'), meta: { cwd: projectB } }), + ]) + return { active, agentA: a.agent, agentB: b.agent } +} + +describe('one-context multi-project sandbox', () => { + it.skipIf(!processSandboxUsable)('confines concurrent bash calls to each calling session workspace', async () => { + const { active, agentA, agentB } = await agents() + const [aOwn, bOwn, aCross, bCross] = await Promise.all([ + active.tools.execute({ + callId: CallId('bash-a-own'), name: 'bash', agent: agentA, + signal: new AbortController().signal, + arguments: { command: 'printf a > a-owned.txt', description: 'Write project A marker' }, + }), + active.tools.execute({ + callId: CallId('bash-b-own'), name: 'bash', agent: agentB, + signal: new AbortController().signal, + arguments: { command: 'printf b > b-owned.txt', description: 'Write project B marker' }, + }), + active.tools.execute({ + callId: CallId('bash-a-cross'), name: 'bash', agent: agentA, + signal: new AbortController().signal, + arguments: { command: `printf cross > ../${basename(projectB)}/from-a.txt`, description: 'Attempt project B write' }, + }), + active.tools.execute({ + callId: CallId('bash-b-cross'), name: 'bash', agent: agentB, + signal: new AbortController().signal, + arguments: { command: `printf cross > ../${basename(projectA)}/from-b.txt`, description: 'Attempt project A write' }, + }), + ]) + + expect(aOwn.isError).toBe(false) + expect(bOwn.isError).toBe(false) + expect(aCross.isError).toBe(false) + expect(bCross.isError).toBe(false) + expect(resultText(aCross)).toContain('[sandbox: file access denied under workspace-write mode]') + expect(resultText(bCross)).toContain('[sandbox: file access denied under workspace-write mode]') + expect(await readFile(join(projectA, 'a-owned.txt'), 'utf8')).toBe('a') + expect(await readFile(join(projectB, 'b-owned.txt'), 'utf8')).toBe('b') + await expectMissing(join(projectB, 'from-a.txt')) + await expectMissing(join(projectA, 'from-b.txt')) + }) + + it('confines concurrent filesystem writes to each calling session workspace', async () => { + const { active, agentA, agentB } = await agents() + const [aOwn, bOwn, aCross, bCross] = await Promise.all([ + active.tools.execute({ + callId: CallId('fs-a-own'), name: 'write', agent: agentA, + signal: new AbortController().signal, + arguments: { file_path: 'a-owned.txt', content: 'a' }, + }), + active.tools.execute({ + callId: CallId('fs-b-own'), name: 'write', agent: agentB, + signal: new AbortController().signal, + arguments: { file_path: 'b-owned.txt', content: 'b' }, + }), + active.tools.execute({ + callId: CallId('fs-a-cross'), name: 'write', agent: agentA, + signal: new AbortController().signal, + arguments: { file_path: join(projectB, 'from-a.txt'), content: 'cross' }, + }), + active.tools.execute({ + callId: CallId('fs-b-cross'), name: 'write', agent: agentB, + signal: new AbortController().signal, + arguments: { file_path: join(projectA, 'from-b.txt'), content: 'cross' }, + }), + ]) + + expect(aOwn.isError).toBe(false) + expect(bOwn.isError).toBe(false) + expect(aCross.isError).toBe(true) + expect(bCross.isError).toBe(true) + expect(resultText(aCross)).toContain('[sandbox: file access denied under workspace-write mode]') + expect(resultText(bCross)).toContain('[sandbox: file access denied under workspace-write mode]') + expect(await readFile(join(projectA, 'a-owned.txt'), 'utf8')).toBe('a') + expect(await readFile(join(projectB, 'b-owned.txt'), 'utf8')).toBe('b') + await expectMissing(join(projectB, 'from-a.txt')) + await expectMissing(join(projectA, 'from-b.txt')) + }) + + it.skipIf(!processSandboxUsable)('keeps symlink-sensitive session cwd semantics aligned across bash, fs, and policy', async () => { + const active = ctx as Context + const lexicalRoot = await projectDir('lexical-workspace') + const physicalRoot = await projectDir('physical-workspace') + const physicalChild = join(physicalRoot, 'child') + await mkdir(physicalChild) + const link = join(lexicalRoot, 'link') + await symlink(physicalChild, link, process.platform === 'win32' ? 'junction' : 'dir') + const sessionCwd = `${link}/..` + const handle = await active.agents.create({ + sessionId: SessionId('symlink-parent-session'), + meta: { cwd: sessionCwd }, + }) + + const [bashOwn, bashLexical, fsOwn, fsLexical] = await Promise.all([ + active.tools.execute({ + callId: CallId('bash-symlink-own'), name: 'bash', agent: handle.agent, + signal: new AbortController().signal, + arguments: { command: 'printf bash > bash-owned.txt', description: 'Write physical workspace marker' }, + }), + active.tools.execute({ + callId: CallId('bash-symlink-lexical'), name: 'bash', agent: handle.agent, + signal: new AbortController().signal, + arguments: { command: `printf escaped > ${join(lexicalRoot, 'bash-escaped.txt')}`, description: 'Attempt lexical workspace write' }, + }), + active.tools.execute({ + callId: CallId('fs-symlink-own'), name: 'write', agent: handle.agent, + signal: new AbortController().signal, + arguments: { file_path: 'fs-owned.txt', content: 'fs' }, + }), + active.tools.execute({ + callId: CallId('fs-symlink-lexical'), name: 'write', agent: handle.agent, + signal: new AbortController().signal, + arguments: { file_path: join(lexicalRoot, 'fs-escaped.txt'), content: 'escaped' }, + }), + ]) + + expect(bashOwn.isError).toBe(false) + expect(resultText(bashOwn)).not.toContain('[sandbox:') + expect(bashLexical.isError).toBe(false) + expect(resultText(bashLexical)).toContain('[sandbox: file access denied under workspace-write mode]') + expect(fsOwn.isError).toBe(false) + expect(fsLexical.isError).toBe(true) + expect(resultText(fsLexical)).toContain('[sandbox: file access denied under workspace-write mode]') + expect(await readFile(join(physicalRoot, 'bash-owned.txt'), 'utf8')).toBe('bash') + expect(await readFile(join(physicalRoot, 'fs-owned.txt'), 'utf8')).toBe('fs') + await expectMissing(join(lexicalRoot, 'bash-escaped.txt')) + await expectMissing(join(lexicalRoot, 'fs-escaped.txt')) + }) + + it.skipIf(!processSandboxUsable)('resolves parent traversal from a symlinked session root consistently', async () => { + const active = ctx as Context + const lexicalRoot = await projectDir('lexical-parent') + const physicalRoot = await projectDir('physical-parent') + const physicalChild = join(physicalRoot, 'child') + await mkdir(physicalChild) + const link = join(lexicalRoot, 'link') + await symlink(physicalChild, link, process.platform === 'win32' ? 'junction' : 'dir') + await writeFile(join(lexicalRoot, 'shared.txt'), 'from-lexical-parent') + await writeFile(join(physicalRoot, 'shared.txt'), 'from-physical-parent') + const handle = await active.agents.create({ + sessionId: SessionId('symlink-root-parent-path-session'), + meta: { cwd: link }, + }) + + const [bashRead, fsRead] = await Promise.all([ + active.tools.execute({ + callId: CallId('bash-symlink-parent-read'), name: 'bash', agent: handle.agent, + signal: new AbortController().signal, + arguments: { command: 'cat ../shared.txt', description: 'Read through the physical parent' }, + }), + active.tools.execute({ + callId: CallId('fs-symlink-parent-read'), name: 'read', agent: handle.agent, + signal: new AbortController().signal, + arguments: { file_path: '../shared.txt' }, + }), + ]) + + expect(bashRead.isError).toBe(false) + expect(fsRead.isError).toBe(false) + expect(resultText(bashRead)).toContain('from-physical-parent') + expect(resultText(fsRead)).toContain('from-physical-parent') + expect(resultText(bashRead)).not.toContain('from-lexical-parent') + expect(resultText(fsRead)).not.toContain('from-lexical-parent') + }) +}) diff --git a/packages/examples/cli-demo/package.json b/packages/examples/cli-demo/package.json index 1c00a32891..f977bc5986 100644 --- a/packages/examples/cli-demo/package.json +++ b/packages/examples/cli-demo/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index 82884a2a6e..f7d543c3fe 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -15,6 +15,7 @@ import SessionPersistenceJsonl, { JsonlCompressionSchema, type JsonlCompression, } from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' const DEFAULT_PERSISTENCE_ROOT = './.sessions' @@ -94,4 +95,5 @@ export function apply(ctx: Context, config: Config): void { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), }) + ctx.plugin(sessionCheckpointPolicy) } diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index b6227d9702..e002be43e0 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -24,7 +24,8 @@ const dshPackages = [ 'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot', - 'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl', + 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy', + 'session-persistence/session-persistence-jsonl', 'context/workspace-context', 'spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention', ] diff --git a/packages/examples/cli-demo/tsconfig.json b/packages/examples/cli-demo/tsconfig.json index c7e3aed914..df5758b7b8 100644 --- a/packages/examples/cli-demo/tsconfig.json +++ b/packages/examples/cli-demo/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../agent-spine-demo" }, + { + "path": "../../session-persistence/session-checkpoint-policy" + }, { "path": "../../session-persistence/session-persistence-jsonl" }, diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index bcaa64c984..76152e84bd 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", @@ -67,6 +68,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index d602a233e3..1bc37abc4b 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -21,6 +21,7 @@ import SessionPersistenceJsonl, { JsonlCompressionSchema, type JsonlCompression, } from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as uiTui from '@deepseek-ai/dsh-tui' @@ -119,6 +120,7 @@ export function composeTuiApp(ctx: Context, config: Config): void { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), }) + ctx.plugin(sessionCheckpointPolicy) ctx.plugin(UserInteractionService) ctx.plugin(uiTui, { ...config.ui, diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index 433eb5c0b7..784657ec69 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -45,6 +45,7 @@ describe('dsh-tui-demo app', () => { 'CommandService', 'command-goal', 'SessionPersistenceJsonl', + 'session-checkpoint-policy', 'UserInteractionService', 'ui-tui', 'agent-spine-demo', @@ -52,7 +53,7 @@ describe('dsh-tui-demo app', () => { ]) expect(calls[0]?.config).toBeUndefined() expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) - const tuiConfig = calls[4]?.config as { sessionId: string } + const tuiConfig = calls[5]?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', resumeCommand: 'dsh --resume {session}', @@ -60,7 +61,7 @@ describe('dsh-tui-demo app', () => { maxToolOutputLines: 3, }) expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - const spineConfig = calls[5]?.config as { + const spineConfig = calls[6]?.config as { readonly agents: Array> readonly goals: Record readonly maxParallelToolCalls: number @@ -95,8 +96,8 @@ describe('dsh-tui-demo app', () => { expect(calls[2]?.config).toEqual({ root: './.sessions' }) // No configured welcome forwards none: the TUI banner sweeps in without a subtitle. - expect(calls[4]?.config).toEqual({ sessionId: 'persisted-session' }) - expect((calls[5]?.config as { agents: Array> }).agents[0]).toMatchObject({ + expect(calls[5]?.config).toEqual({ sessionId: 'persisted-session' }) + expect((calls[6]?.config as { agents: Array> }).agents[0]).toMatchObject({ id: 'main', resumeSessionId: 'persisted-session', }) @@ -112,12 +113,12 @@ describe('dsh-tui-demo app', () => { workspaceContext: false, }) - const tuiConfig = calls[3]?.config as { sessionId: string } + const tuiConfig = calls[4]?.config as { sessionId: string } expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - expect((calls[4]?.config as { agents: Array> }).agents[0]) + expect((calls[5]?.config as { agents: Array> }).agents[0]) .toMatchObject({ sessionId: tuiConfig.sessionId }) expect(calls.map(call => call.name)).not.toContain('command-goal') - expect(calls[4]?.config).toMatchObject({ goals: false }) + expect(calls[5]?.config).toMatchObject({ goals: false }) }) it('has the namespace-plugin export shape so the Loader keeps its schema', () => { diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json index 21e6eff01f..cf0d1d87c4 100644 --- a/packages/examples/tui-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../../ui/tool-ask-user" }, + { + "path": "../../session-persistence/session-checkpoint-policy" + }, { "path": "../../session-persistence/session-persistence-jsonl" }, diff --git a/packages/fs/README.md b/packages/fs/README.md index f1025879ac..161387160e 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -6,9 +6,9 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona |---|---|---| | `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` | | `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | -| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call sandbox mode (read-only denies, workspace-write contains to the workspace + temp roots), reads pass through | (registers `ctx.fs`) | +| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call mode + workspace root policy (read-only denies, workspace-write contains to the session workspace + temp roots), reads pass through | (registers `ctx.fs`) | | `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | -| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); advertises the sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) | +| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) | | `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) | The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents). diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md index c7043e2e70..53fb4324ce 100644 --- a/packages/fs/fs-sandbox/README.md +++ b/packages/fs/fs-sandbox/README.md @@ -2,11 +2,11 @@ `SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading. -Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. Injects `sandboxPolicy` for the default mode and the `workspace-write` boundary root — the SAME policy home bash reads, so the two families never confine to different roots. +Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. The tool layer resolves the calling session's mode and cwd into the SAME per-call policy bash receives, so the two families never confine to different roots. ## The fence -The per-call mode is the tool-stamped effective mode (session override or escalation grant), falling back to the deployment default: +The per-call policy carries the effective mode (session override or escalation grant) together with the calling session's immutable cwd root, falling back to deployment policy only for calls without one: - `read-only` — denies every mutation with the structured `FS_SANDBOX_DENIED`. - `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. Canonical spellings use a lexical fast path; an identity-based ancestor fallback recognizes alias-equivalent roots such as Windows long names and 8.3 names without treating unrelated prefixes as contained. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. @@ -30,4 +30,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **A policy fence, not a kernel boundary** — the check is trusted code over a model-controlled path, so the residual resolve-to-syscall TOCTOU is narrowed (by the in-place re-canonicalization) but not eliminated; adversarial host processes are out of scope. Kernel-grade isolation of untrusted code stays `ctx.bash`'s. - **Fence-vs-runner parity is derived, not asserted** — the writable set comes from `writableRoots`, shared with the Seatbelt profile and pinned by a parity test; a runner profile that changed its writable set without that function would drift. -- **Requires `ctx.sandboxPolicy`** — the backend reads the default mode and workspace root from it and does not confine without it composed. +- **Requires `ctx.sandboxPolicy`** — tools use it to resolve each session policy and the backend uses it for agentless-call fallbacks; the backend does not confine without it composed. diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts index 5268412955..796b65f192 100644 --- a/packages/fs/fs-sandbox/src/index.ts +++ b/packages/fs/fs-sandbox/src/index.ts @@ -3,7 +3,7 @@ * `@deepseek-ai/dsh-fs` provider seam. It extends `LocalFileSystem` so all * text-storage mechanics — resolve, stat, read/stream, list, the atomic * write and the read-match-write edit critical section — are the local - * implementation's, verbatim; this package adds only the per-call MODE fence + * implementation's, verbatim; this package adds only the per-call POLICY fence * on the two mutations. Reads pass through untouched: every mode permits * reading. * @@ -17,9 +17,9 @@ * syscall) is narrowed by re-canonicalizing immediately before delegating and * is accepted for this threat model. * - * Per-call mode: `read-only` denies every mutation; `workspace-write` allows a - * mutation only when the target canonicalizes under the workspace root or a - * platform temp area (the SAME writable-root set the Seatbelt profile grants, + * Per-call policy: `read-only` denies every mutation; `workspace-write` allows + * a mutation only when the target canonicalizes under the policy's workspace + * root or a platform temp area (the SAME writable-root set Seatbelt grants, * derived from the one `writableRoots` function so bash and fs cannot drift); * `danger-full-access` delegates unfenced. A denial throws the structured * `FS_SANDBOX_DENIED` — no text inference is needed (unlike bash's kernel @@ -36,15 +36,15 @@ import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local' import { FsError } from '@deepseek-ai/dsh-fs' import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs' import { writableRoots } from '@deepseek-ai/dsh-sandbox' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' import { isPathUnder } from './containment.ts' /** * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve * base for relative paths). The sandbox default (mode + `workspace-write` - * boundary root) is NOT here — it lives on `ctx.sandboxPolicy`, the one home - * both enforcing families share. + * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling + * session for both enforcing families. */ export type Config = LocalConfig @@ -52,26 +52,17 @@ export type Config = LocalConfig * Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it * INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole * swap — the model-facing tools are untouched). Its configured default mode is - * the fallback exposed by {@link sandboxMode}; `dsh-tool-fs` folds a session's - * `sandbox/mode` override and stamps the effective mode onto each mutation, - * while an approved escalation may stamp a strictly wider mode for one call. + * the capability fact exposed by {@link sandboxMode}; `dsh-tool-fs` resolves + * each session's mode and cwd into a policy for every mutation, while an + * approved escalation may stamp a strictly wider mode for one call. */ export class SandboxedFileSystem extends LocalFileSystem { static inject = ['sandboxPolicy'] private readonly defaultMode: SandboxMode - /** - * The canonical roots a `workspace-write` mutation may land under, computed - * once (the workspace root and platform temp areas are fixed for the - * provider's lifetime): the same set {@link writableRoots} gives every - * enforcement dialect, so the fs fence and the bash runner agree. - */ - private readonly writableRoots: string[] - constructor(ctx: Context, config: Config) { super(ctx, config) this.defaultMode = ctx.sandboxPolicy.defaultMode - this.writableRoots = writableRoots({ mode: 'workspace-write', workspaceRoot: ctx.sandboxPolicy.workspaceRoot }) } /** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */ @@ -80,13 +71,14 @@ export class SandboxedFileSystem extends LocalFileSystem { } /** - * Fence the write by the per-call mode, then delegate to the inherited + * Fence the write by the per-call policy, then delegate to the inherited * atomic write. See {@link checkedTarget}. * @param target - the resolved target to write. * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call mode; omit to use the deployment default. + * @param sandboxPolicy - the per-call mode and workspace root; omit to use + * the deployment fallback. * @returns the write outcome from the inherited backend. */ override async writeText( @@ -94,19 +86,20 @@ export class SandboxedFileSystem extends LocalFileSystem { content: string, expected?: FsWriteIntent, signal?: AbortSignal, - sandboxMode?: SandboxMode, + sandboxPolicy?: SandboxExecutionPolicy, ): Promise { - return super.writeText(await this.checkedTarget(target, sandboxMode), content, expected, signal) + return super.writeText(await this.checkedTarget(target, sandboxPolicy), content, expected, signal) } /** - * Fence the edit by the per-call mode, then delegate to the inherited + * Fence the edit by the per-call policy, then delegate to the inherited * atomic edit. See {@link checkedTarget}. * @param target - the resolved target to edit. * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call mode; omit to use the deployment default. + * @param sandboxPolicy - the per-call mode and workspace root; omit to use + * the deployment fallback. * @returns the edit outcome from the inherited backend. */ override async editText( @@ -114,13 +107,13 @@ export class SandboxedFileSystem extends LocalFileSystem { edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, - sandboxMode?: SandboxMode, + sandboxPolicy?: SandboxExecutionPolicy, ): Promise { - return super.editText(await this.checkedTarget(target, sandboxMode), edit, expected, signal) + return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal) } /** - * Enforce the per-call mode against `target` and return the EXACT target the + * Enforce the per-call policy against `target` and return the EXACT target the * mutation must use, so the checked identity is the mutated one (no * check-here-write-there TOCTOU). `read-only` denies; `workspace-write` * re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor, @@ -130,8 +123,9 @@ export class SandboxedFileSystem extends LocalFileSystem { * refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker * and the escalation hint. */ - private async checkedTarget(target: FsTarget, sandboxMode?: SandboxMode): Promise { - const mode = sandboxMode ?? this.defaultMode + private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise { + const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() + const { mode } = policy if (mode === 'danger-full-access') return target if (mode === 'read-only') { throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED') @@ -141,7 +135,7 @@ export class SandboxedFileSystem extends LocalFileSystem { // mutation delegates with THIS fresh target — never the stale one. const fresh = await this.resolve(target.displayPath) let contained = false - for (const root of this.writableRoots) { + for (const root of writableRoots(policy)) { if (await isPathUnder(fresh.targetKey, root)) { contained = true break diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 65472f2ece..62648e1362 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -1,5 +1,5 @@ /** - * Tests for the sandbox-enforcing filesystem backend: the per-call mode fence + * Tests for the sandbox-enforcing filesystem backend: the per-call policy fence * on write/edit (read-only denies, workspace-write contains, danger-full-access * passes through), reads always passing through, the capability fact, and the * containment matrix — `..` traversal, absolute paths outside, and symlink @@ -194,12 +194,12 @@ describe('danger-full-access', () => { }) }) -describe('the per-call mode override (escalation)', () => { +describe('the per-call policy override (escalation)', () => { it('a workspace-write stamp on a read-only default lets a contained write land for that call only', async () => { await boot('read-only') const path = join(workspace, 'escalated.txt') - // Default read-only would deny; the per-call workspace-write stamp allows it (contained). - await fs.writeText(await target(path), 'granted', undefined, undefined, 'workspace-write') + // Default read-only would deny; the per-call workspace-write policy allows it (contained). + await fs.writeText(await target(path), 'granted', undefined, undefined, { mode: 'workspace-write', workspaceRoot: workspace }) expect(await readFile(path, 'utf8')).toBe('granted') // A neighboring plain call still runs under the read-only default. await expect(fs.writeText(await target(join(workspace, 'plain.txt')), 'x')) @@ -209,7 +209,7 @@ describe('the per-call mode override (escalation)', () => { it('a danger-full-access stamp bypasses the fence for that call', async () => { await boot('read-only') const path = join(outside, 'granted-full.txt') - await fs.writeText(await target(path), 'full', undefined, undefined, 'danger-full-access') + await fs.writeText(await target(path), 'full', undefined, undefined, { mode: 'danger-full-access', workspaceRoot: workspace }) expect(await readFile(path, 'utf8')).toBe('full') }) }) diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 0279273d40..b43fa48c4b 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -7,7 +7,7 @@ */ import { Context, Service } from 'cordis' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { FsDirEntry, FsEditOutcome, @@ -170,9 +170,9 @@ export abstract class FileSystem extends Service { * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call sandbox mode this write runs under; a - * sandboxing backend fences the write by it, the bare backend ignores it. - * Omit to leave the backend its own default. + * @param sandboxPolicy - the per-call mode and workspace root this write + * runs under; a sandboxing backend fences the write by it, the bare backend + * ignores it. Omit to leave the backend its own default. * @returns the outcome, including the version the write produced. */ abstract writeText( @@ -180,7 +180,7 @@ export abstract class FileSystem extends Service { content: string, expected?: FsWriteIntent, signal?: AbortSignal, - sandboxMode?: SandboxMode, + sandboxPolicy?: SandboxExecutionPolicy, ): Promise /** @@ -191,9 +191,9 @@ export abstract class FileSystem extends Service { * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call sandbox mode this edit runs under; a - * sandboxing backend fences the edit by it, the bare backend ignores it. - * Omit to leave the backend its own default. + * @param sandboxPolicy - the per-call mode and workspace root this edit runs + * under; a sandboxing backend fences the edit by it, the bare backend + * ignores it. Omit to leave the backend its own default. * @returns the outcome, including the version the edit produced. */ abstract editText( @@ -201,7 +201,7 @@ export abstract class FileSystem extends Service { edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, - sandboxMode?: SandboxMode, + sandboxPolicy?: SandboxExecutionPolicy, ): Promise } diff --git a/packages/fs/tool-fs-search/tests/load-path.spec.ts b/packages/fs/tool-fs-search/tests/load-path.spec.ts index 2eeffe65e6..0f72529567 100644 --- a/packages/fs/tool-fs-search/tests/load-path.spec.ts +++ b/packages/fs/tool-fs-search/tests/load-path.spec.ts @@ -36,7 +36,7 @@ class ProbeSuccessBashExecutor extends BashExecutor { timeoutMs: request.timeoutMs ?? 60_000, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, signal: request.signal, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 95ccaef4fe..9e4aa6483e 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -75,7 +75,7 @@ class FakeBash extends BashExecutor { timeoutMs: request.timeoutMs ?? 60_000, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, ...this.forwardSignal ? { signal: request.signal } : {}, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } override async run(spec: BashExecSpec): Promise { diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 46655f80b7..d3bb9a2803 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -92,10 +92,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { }, async execute(args: EditToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { const input = parseEditArgs(args) - // Resolve the per-call sandbox mode (escalation grant > session override - // > backend default) BEFORE anything executes. - const sandboxMode = await sandbox.stampMode('edit', args, exec) - const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec)) + // Resolve the per-call sandbox policy (approved mode > session override + // > backend default, plus the session cwd root) BEFORE anything executes. + const sandboxPolicy = await sandbox.resolvePolicy('edit', args, exec) + const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot)) // Single-slot decision: the policy plugin returns { version: vObserved } or // throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit). // No stat — the bare default never manufactures a version basis. @@ -107,11 +107,11 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, intent, exec.signal, - sandboxMode, + sandboxPolicy, ) } catch (error: unknown) { // A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through. - throw sandbox.mapError(error, sandboxMode) + throw sandbox.mapError(error, sandboxPolicy) } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index c7c217b609..a4c96d606b 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -64,7 +64,7 @@ export function apply(ctx: Context, config: Config): void { streamMinSize: resolved.readStreamMinSize, }) // One escalation surface shared by both mutating tools: advertisement gating, - // per-call mode stamping, and denial-marker mapping, all keyed off whether + // per-call policy resolution, and denial-marker mapping, all keyed off whether // the mounted ctx.fs confines (ctx.fs.sandboxMode). const sandbox = new FsSandboxSurface(ctx) applyWriteTool(ctx, sandbox) diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index a19b073514..cb1409987d 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -88,7 +88,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { isConcurrencySafe: () => true, async execute(args, exec): Promise { const input = parseReadArgs(args, caps.limit) - const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec)) + const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath)) // One stat: type check + size routing + the version recorded as observed. // A concurrent write can only make a later guarded mutation fail stale and require reread. diff --git a/packages/fs/tool-fs/src/sandbox.ts b/packages/fs/tool-fs/src/sandbox.ts index e6cc0a61cd..ca824ceea5 100644 --- a/packages/fs/tool-fs/src/sandbox.ts +++ b/packages/fs/tool-fs/src/sandbox.ts @@ -1,6 +1,6 @@ /** * The sandbox-escalation surface shared by the `write` and `edit` tools: the - * per-call mode stamp, the advertised escalation fields, and the denial-marker + * per-call policy resolution, the advertised escalation fields, and the denial-marker * mapping — all delegating the vocabulary and the fail-closed approval * sequence to `@deepseek-ai/dsh-sandbox` (the same pieces `@deepseek-ai/dsh-tool-bash` * uses), so bash and fs escalate identically. Built ONCE per plugin from @@ -12,9 +12,9 @@ import type { Context } from 'cordis' import type { ToolExecution } from '@deepseek-ai/dsh-tools' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' -import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { FsError } from '@deepseek-ai/dsh-fs' /** The two escalation arguments a mutating tool may carry (advertised only under a confining backend). */ @@ -30,20 +30,23 @@ export interface EscalationSchemaFields { } /** - * The filesystem escalation surface: advertisement gating, per-call mode - * stamping (folding the session's `sandbox/mode` override), the one-approved - * wider retry, and denial-marker mapping. A pure product of `ctx` at plugin - * apply time. + * The filesystem escalation surface: advertisement gating, per-call policy + * resolution, the one-approved wider retry, and denial-marker mapping. A pure + * product of `ctx` at plugin apply time. */ export class FsSandboxSurface { /** The escalation targets this composition advertises (`[]` when no confining backend is mounted). */ readonly escalationModes: readonly SandboxMode[] - /** The backend's default mode, or `undefined` when `ctx.fs` does not confine. */ - private readonly defaultMode: SandboxMode | undefined + /** Shared per-session policy resolver, required by a confining backend. */ + private readonly policy: SandboxPolicyService | undefined constructor(private readonly ctx: Context) { - this.defaultMode = ctx.fs.sandboxMode - this.escalationModes = this.defaultMode === undefined ? [] : ESCALATION_TARGETS + const defaultMode = ctx.fs.sandboxMode + this.escalationModes = defaultMode === undefined ? [] : ESCALATION_TARGETS + this.policy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') + if (defaultMode !== undefined && this.policy === undefined) { + throw new Error('tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing') + } } /** @@ -70,37 +73,29 @@ export class FsSandboxSurface { } /** - * The session's standing mode override for an ordinary (non-escalating) - * call — the `sandbox/mode` fold of the calling agent's log. Undefined for a - * non-confining backend and for agent-less callers. - */ - private sessionOverride(exec: ToolExecution): SandboxMode | undefined { - if (this.defaultMode === undefined || exec.agent === undefined) return undefined - return effectiveSandboxMode(exec.agent.session.events) - } - - /** - * The mode to STAMP onto this mutation: an approved escalation grant (a + * The policy to stamp onto this mutation: an approved escalation grant (a * strictly wider retry resolved through `ctx.approval` before anything - * executes), else the session's standing override, else `undefined` (the - * backend applies its own default). Validates the escalation argument + * executes), else the session's standing mode. The calling session's cwd is + * always carried as the workspace root. Validates the escalation argument * pairing first. * @param toolName - the mutating tool's name, for the approval audit trail. * @param args - the call's escalation arguments. * @param exec - the tool-execution context (agent, callId, signal). - * @returns the mode to pass to the mutation, or undefined for the backend default. + * @returns the policy to pass to the mutation, or undefined for an + * unsandboxed backend. */ - async stampMode(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise { + async resolvePolicy(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise { validateEscalationArgs(args.sandbox_permissions, args.justification) + const standingPolicy = this.policy?.resolve({ ...exec.agent ? { session: exec.agent.session } : {} }) if (args.sandbox_permissions === undefined || args.justification === undefined) { - return this.sessionOverride(exec) + return standingPolicy } if (this.escalationModes.length === 0) { throw new Error('sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)') } - const effectiveMode = (this.sessionOverride(exec) ?? this.defaultMode) as SandboxMode - return approveEscalation( - { requestedMode: args.sandbox_permissions, justification: args.justification, effectiveMode, subject: 'operation' }, + const policy = standingPolicy as SandboxExecutionPolicy + const approvedMode = await approveEscalation( + { requestedMode: args.sandbox_permissions, justification: args.justification, effectiveMode: policy.mode, subject: 'operation' }, { approver: this.ctx.get('approval'), agent: exec.agent, @@ -109,6 +104,7 @@ export class FsSandboxSurface { signal: exec.signal, }, ) + return { ...policy, mode: approvedMode } } /** @@ -122,14 +118,14 @@ export class FsSandboxSurface { * confining backend, which always advertises the escalation fields, so the * hint always applies here. * @param error - the error thrown by the mutation. - * @param stampedMode - the mode stamped onto the call (names the mode in the marker). + * @param policy - the policy stamped onto the call (names the mode in the marker). * @returns the error to throw — the marker `FsError` for a sandbox denial, else the original. */ - mapError(error: unknown, stampedMode: SandboxMode | undefined): unknown { + mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown { if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error - // A FS_SANDBOX_DENIED only arises under a confining backend, so defaultMode - // (hence the resolved mode) is defined here. - const mode = (stampedMode ?? this.defaultMode) as SandboxMode + // A FS_SANDBOX_DENIED only arises under a confining backend, whose tool + // path always resolves a policy before mutation. + const mode = (policy as SandboxExecutionPolicy).mode return new FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`, 'FS_SANDBOX_DENIED', { cause: error }) } } diff --git a/packages/fs/tool-fs/src/session-cwd.ts b/packages/fs/tool-fs/src/session-cwd.ts index 65a22bbc06..841769fb4d 100644 --- a/packages/fs/tool-fs/src/session-cwd.ts +++ b/packages/fs/tool-fs/src/session-cwd.ts @@ -9,23 +9,36 @@ */ import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { canonicalPath } from '@deepseek-ai/dsh-sandbox' + +const PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/ /** * The session workspace cwd for this call, or `undefined` when none applies. * @param exec - the tool-execution context; only its optional `agent` is read. + * @param requestedPath - the path the provider will resolve; parent traversal + * makes a symlinked cwd's filesystem identity observable. * @returns the calling agent's session cwd, or undefined for a non-agent caller (the backend then applies its own default). */ -export function sessionCwd(exec: ToolExecution): string | undefined { - return exec.agent?.session.header.cwd +export function sessionCwd(exec: ToolExecution, requestedPath: string): string | undefined { + const cwd = exec.agent?.session.header.cwd + if (cwd === undefined || (!PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath))) return cwd + return canonicalPath(cwd) } /** * Resolution options shared by all model-facing filesystem tools. * @param exec - the tool-execution context supplying session cwd and cancellation. + * @param requestedPath - the path the provider will resolve. + * @param policyWorkspaceRoot - resolved per-call root, when a mutation carries sandbox policy. * @returns provider resolution options for the current tool call. */ -export function sessionResolveOptions(exec: ToolExecution): { cwd?: string; signal?: AbortSignal } { - const cwd = sessionCwd(exec) +export function sessionResolveOptions( + exec: ToolExecution, + requestedPath: string, + policyWorkspaceRoot?: string, +): { cwd?: string; signal?: AbortSignal } { + const cwd = policyWorkspaceRoot ?? sessionCwd(exec, requestedPath) return { ...cwd !== undefined ? { cwd } : {}, signal: exec.signal, diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 3f23e9b5ea..1e92b66612 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -76,21 +76,21 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { }, async execute(args: WriteToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { const input = parseWriteArgs(args) - // Resolve the per-call sandbox mode (escalation grant > session override - // > backend default) BEFORE anything executes; an escalating call - // resolves approval here and throws its distinct text on any non-grant. - const sandboxMode = await sandbox.stampMode('write', args, exec) - const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec)) + // Resolve the per-call sandbox policy (approved mode > session override + // > backend default, plus the session cwd root) BEFORE anything executes; + // an escalating call throws its distinct text on any non-grant. + const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec) + const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot)) // Single-slot decision: the policy plugin produces createIfAbsent/ // replaceIfVersion; the bare default is undefined (unconditional). No stat. const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined) let outcome: FsWriteOutcome try { - outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxMode) + outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy) } catch (error: unknown) { // A sandbox denial becomes the shared [sandbox: …] marker (the model // recognizes it from bash); any other error passes through. - throw sandbox.mapError(error, sandboxMode) + throw sandbox.mapError(error, sandboxPolicy) } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 5de343abe3..df55bb99f4 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -5,6 +5,9 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -24,8 +27,10 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { STREAM_MIN_SIZE } from '../src/read.ts' import { formatReadOutput } from '../src/read-render.ts' import type { FileReadOutcome } from '../src/read-render.ts' +import { sessionCwd } from '../src/session-cwd.ts' import ApprovalService from '@deepseek-ai/dsh-user-approval' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' const testToolSignal = new AbortController().signal @@ -107,6 +112,32 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } +describe('session cwd resolution', () => { + const execution = (cwd?: string) => cwd === undefined + ? {} + : { agent: { session: { header: { cwd } } } } + + it('retains ordinary spelling but resolves the cwd before parent traversal', () => { + const cwd = process.cwd() + const throughParent = `${cwd}${sep}..` + expect(sessionCwd(execution() as never, 'file.txt')).toBeUndefined() + expect(sessionCwd(execution(cwd) as never, 'file.txt')).toBe(cwd) + expect(sessionCwd(execution(throughParent) as never, 'file.txt')).toBe(realpathSync.native(throughParent)) + + const root = mkdtempSync(join(tmpdir(), 'dsh-tool-fs-session-cwd-')) + const physical = join(root, 'physical') + const link = join(root, 'link') + try { + mkdirSync(physical) + symlinkSync(physical, link, process.platform === 'win32' ? 'junction' : 'dir') + expect(sessionCwd(execution(link) as never, 'child.txt')).toBe(link) + expect(sessionCwd(execution(link) as never, `..${sep}parent.txt`)).toBe(realpathSync.native(link)) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) + describe('registration', () => { it('registers read, write, and edit', async () => { const { ctx } = await setup() @@ -587,9 +618,9 @@ describe('read caps are plugin config', () => { }) describe('sandbox escalation surface (write/edit)', () => { - /** A confining fake `ctx.fs`: reports a default mode, records the per-call mode stamped, and can arm a sandbox denial. */ + /** A confining fake `ctx.fs`: reports a default mode, records each per-call policy, and can arm a sandbox denial. */ class SandboxingFakeFs extends FakeFs { - stamped: (SandboxMode | undefined)[] = [] + stamped: (SandboxExecutionPolicy | undefined)[] = [] override get sandboxMode(): SandboxMode { return 'workspace-write' } @@ -598,9 +629,9 @@ describe('sandbox escalation surface (write/edit)', () => { content: string, expected?: FsWriteIntent, _signal?: AbortSignal, - sandboxMode?: SandboxMode, + sandboxPolicy?: SandboxExecutionPolicy, ): Promise { - this.stamped.push(sandboxMode) + this.stamped.push(sandboxPolicy) return super.writeText(target, content, expected) } override async editText( @@ -608,9 +639,9 @@ describe('sandbox escalation surface (write/edit)', () => { edit: FsEditRequest, expected?: { version: FsVersion }, _signal?: AbortSignal, - sandboxMode?: SandboxMode, + sandboxPolicy?: SandboxExecutionPolicy, ): Promise { - this.stamped.push(sandboxMode) + this.stamped.push(sandboxPolicy) return super.editText(target, edit, expected) } } @@ -619,6 +650,7 @@ describe('sandbox escalation surface (write/edit)', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' }) await ctx.plugin(SandboxingFakeFs) await ctx.plugin(FsPolicy) if (opts.approval === true) await ctx.plugin(ApprovalService) @@ -631,7 +663,7 @@ describe('sandbox escalation surface (write/edit)', () => { return { id: 'agent-fs-esc', session: { - header: { version: 0, id: 'sess-fs-esc', createdAt: 0 }, + header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' }, events: [{ type: 'turn/start' }, ...events], append: (type: string, data: Record) => { events.push({ type, data }) }, }, @@ -644,6 +676,14 @@ describe('sandbox escalation surface (write/edit)', () => { return schema as unknown as { parameters: { properties: Record } } } + it('fails load when a confining filesystem has no shared sandbox-policy resolver', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SandboxingFakeFs) + await expect(ctx.plugin(ToolFs)).rejects.toThrow('tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing') + }) + it('advertises no escalation fields under a non-confining backend', async () => { const { ctx } = await setup() expect(ctx.fs.sandboxMode).toBeUndefined() @@ -663,16 +703,16 @@ describe('sandbox escalation surface (write/edit)', () => { } }) - it('a plain write stamps nothing (backend default) and no session override folds without one', async () => { + it('a plain write stamps the default mode with the calling session root', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent()) - expect(fs.stamped).toEqual([undefined]) + expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: '/session-project' }]) }) it('a standing session override folds onto the stamp', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }])) - expect(fs.stamped).toEqual(['read-only']) + expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: '/session-project' }]) }) it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => { @@ -705,7 +745,7 @@ describe('sandbox escalation surface (write/edit)', () => { agent: escalationAgent() as never, signal: new AbortController().signal, }) - expect(fs.stamped).toEqual(['danger-full-access']) + expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: '/session-project' }]) }) it('a rejected escalation fails closed with its own text and never mutates', async () => { diff --git a/packages/hooks/hook-protocol/tests/runner.spec.ts b/packages/hooks/hook-protocol/tests/runner.spec.ts index 09e0e65275..cda3a31bd4 100644 --- a/packages/hooks/hook-protocol/tests/runner.spec.ts +++ b/packages/hooks/hook-protocol/tests/runner.spec.ts @@ -27,7 +27,7 @@ function recordingBash(run: (spec: BashExecSpec) => Promise): { ...request.signal ? { signal: request.signal } : {}, ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } }, async run(spec: BashExecSpec): Promise { diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 28f7ccd49c..9e59699bd6 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,7 +2,7 @@ Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. -The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. +The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own. @@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No TLS, auth, or origin policy** — the server binds `0.0.0.0` and trusts its network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1. +- **No TLS, auth, or origin policy** — callers that bind a non-loopback address expose the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1. - **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships. -- **`port` is the only listen knob** — bind address and socket options are fixed until a deployment needs them. +- **Socket options are fixed** — callers select the bind host and port, while backlog and other socket settings remain internal until a deployment needs them. diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 3d7531caed..074bfe395c 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -10,6 +10,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { readFile } from 'node:fs/promises' +import type { AddressInfo } from 'node:net' import { dirname } from 'node:path' import { serveStatic } from './static.ts' import type { HostWebPluginRegistry } from './web-plugins.ts' @@ -21,7 +22,9 @@ export type { /** Options for startWebServer. */ export interface WebServerOptions { - /** Port to listen on (0.0.0.0). */ + /** Address or hostname to listen on. */ + host: string + /** Port to listen on; zero requests an OS-assigned port. */ port: number /** * Absolute path of index.html inside the static root — the caller resolves @@ -40,7 +43,7 @@ export interface WebServerOptions { /** Listening web server handle. */ export interface RunningWebServer { - /** The listening port (for the shell's URL line; equals options.port). */ + /** The listening port, including the OS-assigned value when options.port is zero. */ port: number /** * Shutdown: close + closeAllConnections (SSE connections never end on their @@ -50,7 +53,7 @@ export interface RunningWebServer { } /** - * Start the web-shape HTTP server: listen(port, '0.0.0.0'). + * Start the web-shape HTTP server on the caller-selected host and port. * Routing: /api/* → apiHandler bridge; non-GET/HEAD → 405; everything else → * static with the step1-locked semantics (403 traversal, SPA fallback 200). * A listen failure (EADDRINUSE…) rejects — the shell decides how to exit; a @@ -63,7 +66,7 @@ export interface RunningWebServer { * @returns the running server handle once listening. */ export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise { - const { port, distIndex, apiHandler, webPlugins } = options + const { host, port, distIndex, apiHandler, webPlugins } = options const distRoot = dirname(distIndex) const renderIndex = webPlugins === undefined ? undefined : async (): Promise => { const html = await readFile(distIndex, 'utf8') @@ -113,10 +116,10 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error) return new Promise((resolveListen, rejectListen) => { server.once('error', rejectListen) - server.listen(port, '0.0.0.0', () => { + server.listen(port, host, () => { server.off('error', rejectListen) server.on('error', onError) - resolveListen({ port, close }) + resolveListen({ port: (server.address() as AddressInfo).port, close }) }) }) } diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 9c7613ee88..0b9d1a8978 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -1,16 +1,16 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' -import { createServer as createNetServer, type AddressInfo } from 'node:net' +import { createServer as createNetServer, Server as NetServer, type AddressInfo } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { startWebServer, type RunningWebServer } from '../src/index.ts' -/** RunningWebServer.port echoes options.port, so tests must pick a concrete free port up front. */ +/** Reserve a loopback port for tests that need to address a second server. */ function freePort(): Promise { return new Promise((resolve, reject) => { const probe = createNetServer() probe.once('error', reject) - probe.listen(0, () => { + probe.listen(0, '127.0.0.1', () => { const port = (probe.address() as AddressInfo).port probe.close(() => { resolve(port) }) }) @@ -107,16 +107,15 @@ afterEach(async () => { async function boot(onError: (err: Error) => void = () => undefined): Promise { const { distIndex } = makeDist() const port = await freePort() - server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, onError) + server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, onError) return `http://127.0.0.1:${String(server.port)}` } describe('startWebServer', () => { it('reports the listening port and closes idempotently', async () => { const { distIndex } = makeDist() - const port = await freePort() - server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined) - expect(server.port).toBe(port) + server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined) + expect(server.port).toBeGreaterThan(0) const first = server.close() const second = server.close() expect(second).toBe(first) @@ -124,11 +123,33 @@ describe('startWebServer', () => { server = undefined }) + it.each(['127.0.0.1', '0.0.0.0'])('forwards bind address %s without opening a socket', async (host) => { + const { distIndex } = makeDist() + const port = 3080 + const listen = vi.spyOn(NetServer.prototype, 'listen').mockImplementation(function ( + this: NetServer, ...args: unknown[] + ): NetServer { + const callback = args.at(-1) + if (typeof callback !== 'function') throw new TypeError('listen callback missing') + queueMicrotask(callback as () => void) + return this + }) + const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port }) + try { + const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined) + expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function)) + await inertServer.close() + } finally { + address.mockRestore() + listen.mockRestore() + } + }) + it('rejects when the port is already taken', async () => { const { distIndex } = makeDist() const port = await freePort() - server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined) - await expect(startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined)) + server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined) + await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)) .rejects.toMatchObject({ code: 'EADDRINUSE' }) }) }) @@ -185,7 +206,9 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti clientPath: (id: string) => id === rows[0]?.id ? join(distRoot, 'bundle.js') : undefined, } const port = await freePort() - server = await startWebServer({ port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined) + server = await startWebServer( + { host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined, + ) return `http://127.0.0.1:${String(server.port)}` } @@ -221,7 +244,9 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti clientPath: () => '/nonexistent/lib/client.js', } const port = await freePort() - server = await startWebServer({ port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined) + server = await startWebServer( + { host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined, + ) const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`) expect(res.status).toBe(404) }) diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 0ff5aebf35..b2d6a38255 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -90,7 +90,7 @@ describe('pty-local real shell', () => { const created = await ctx.pty.spawn(agent, { type: 'shell' }) expect(sandbox.calls).toEqual([{ argv: ['/bin/bash', '--noprofile', '--norc', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: root }, + policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root) }, }]) await fiber.dispose() expect(ctx.pty.listBackends()).toEqual([]) diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index 990da84d1a..1ab5fbcc07 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -1,12 +1,12 @@ # sandbox/ — process-sandbox capability family -The confinement half of the [capability-seam split](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface, platform backends, and the shared policy home. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; policy (`SandboxPolicy`: mode + workspace root) rides each call, so different consumers confine under different policies at the same instant. All **product** packages. +The confinement half of the [capability-seam split](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface, platform backends, and the shared policy home. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; a complete `SandboxExecutionPolicy` (mode + workspace root) rides each capability call, and its confined subset becomes the provider's `SandboxPolicy`. Different sessions and consumers can therefore confine under different policies at the same instant. All **product** packages. | Package | Role | ctx key | |---|---|---| | `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) plus the shared ESCALATION kit (`approveEscalation`, the strictly-wider ladder, the denial/hint markers) and the `writableRoots` derivation every enforcement dialect shares | `ctx.sandbox` | | `sandbox-local/` | Local backends by platform chain: Linux `bwrap` else the `landlock-run` launcher (the npm-distributed [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) family, built and released from its own repository), darwin `sandbox-exec`/Seatbelt — multi-candidate chains functionally probed, sole candidates selected directly, verdict cached, fail-closed | (registers `ctx.sandbox`) | -| `sandbox-policy/` | The policy home: the deployment default (mode + `workspace-write` boundary root) and the per-session `sandbox/mode` override (event + fold + write path). Both enforcing families read it, so bash and fs can never confine to different roots | `ctx.sandboxPolicy` | +| `sandbox-policy/` | The policy resolver: deployment fallbacks plus each session's durable mode and immutable cwd root. Both enforcing families consume its complete per-call result, so bash and fs cannot confine to different roots | `ctx.sandboxPolicy` | The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 5f2d748bc9..783b338acf 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -1,20 +1,21 @@ # dsh-sandbox-policy — the sandbox policy home (`ctx.sandboxPolicy`) -The single owner of the deployment's sandbox policy: the file-effect [`SandboxMode`](../sandbox/README.md) a session starts from, the `workspace-write` boundary root, and the per-session `sandbox/mode` override every enforcing capability family reads. +The single owner of sandbox-policy resolution: the deployment's default [`SandboxMode`](../sandbox/README.md) and fallback root, plus each session's durable mode override and immutable workspace root. Every enforcing capability family receives one resolved mode-and-root policy per call. ## Why a shared home -Two families enforce the same mode vocabulary: the sandboxed bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem provider (`@deepseek-ai/dsh-fs-sandbox`). If each held its own `mode` + `workspaceRoot` config, the two could drift into a split world — bash confined to one root while fs fences another, exactly what [the sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) warns against. Both inject `ctx.sandboxPolicy` and read the SAME default instead. The [cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the decision. +Two families enforce the same mode vocabulary: the sandboxed bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem provider (`@deepseek-ai/dsh-fs-sandbox`). If each resolved its own `mode` + `workspaceRoot`, the two could drift into a split world — bash confined to one root while fs fences another, exactly what [the sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) warns against. Both tool layers resolve policy through `ctx.sandboxPolicy`, and both enforcing backends consume that complete per-call result. The [cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the shared-policy decision. ## Config - `mode` — the deployment default `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`), validated at load. Default `read-only` (fail-safe). -- `workspaceRoot` — the absolute directory `workspace-write` may write under. Default `process.cwd()`, resolved absolute either way. +- `workspaceRoot` — the fallback directory `workspace-write` may write under for agentless calls or sessions without a cwd. Default `process.cwd()`, resolved to its absolute filesystem identity either way. A normal agent call uses its session header's immutable `cwd` instead. ## Surface -- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default the enforcing implementations read for their resolve fallback and boundary. -- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`). The tool layers apply it to stamp each call, so neither the executor nor the provider depends on session events. +- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution. +- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`. +- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`. - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. @@ -22,7 +23,7 @@ The optional `./invariant` companion rejects a forged durable `sandbox/mode` eve ## The per-session store -A runtime switch (an ACP `session/set_config_option`, a test scenario) is one log-only `sandbox/mode` event on the session it applies to. `effective = fold(events) ?? the deployment default`, so an override survives restart by replay, two sessions never see each other's state, and there is no external config store. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. Execution honors the fold in each tool layer, weakest-precedence beneath an escalation grant. +A runtime switch (an ACP `session/set_config_option`, a test scenario) is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. ## Model Experience @@ -34,5 +35,5 @@ No direct invalidation; the named consumers own any request-prefix changes, and ## Known Limitations and Deferred Work -- **`workspaceRoot` is process-wide and fixed for the service's lifetime** — a per-session workspace root is a deferred phase of the sandbox RFC; this package centralizing the root is its groundwork, not its design. +- **One primary workspace root per session** — policy resolves `SessionHeader.cwd`; extra writable roots are not part of `SandboxExecutionPolicy`. - **File-effect modes only** — `SandboxMode` governs file effects; network and process policy are outside its vocabulary, so no knob here restricts them. diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index f8a7235247..d5f9270ed1 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", - "description": "Sandbox policy home (ctx.sandboxPolicy) for the DeepSeek Harness: the deployment default mode + workspace root and the per-session sandbox/mode override, shared by every enforcing capability family", + "description": "Per-call sandbox policy resolver (ctx.sandboxPolicy): deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index cd7a1545a8..23a205e60c 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -1,33 +1,33 @@ /** * The sandbox POLICY home (`ctx.sandboxPolicy`): the single owner of the - * deployment's sandbox default — the file-effect {@link SandboxMode} a session - * starts from and the `workspace-write` boundary root — plus the per-session - * override kit (the `sandbox/mode` event, its fold, and its write path, from - * `./session-mode.ts`). + * deployment's sandbox fallbacks plus per-session resolution: the file-effect + * {@link SandboxMode}, the `workspace-write` root, and the override kit (the + * `sandbox/mode` event, its fold, and its write path, from `./session-mode.ts`). * * Both enforcing capability families read the SAME policy here: the sandboxed * bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem - * provider (`@deepseek-ai/dsh-fs-sandbox`) inject `ctx.sandboxPolicy` for the - * default mode and workspace root, so bash and fs can never confine to - * different roots — the split world the sandbox RFC warns about. The default - * lives here rather than on either executor's config precisely because it is - * one fact two families share. - * - * This service holds only the DEFAULT; the per-session fold - * ({@link effectiveSandboxMode}) is a pure function the tool layers apply to - * stamp each call, so neither the executor nor the provider depends on session - * events. + * provider (`@deepseek-ai/dsh-fs-sandbox`) consume the SAME resolved per-call + * policy, so bash and fs can never confine to different roots — the split + * world the sandbox RFC warns about. The service reads session state once at + * the tool boundary; executors and providers remain session-free. * * @module @deepseek-ai/dsh-sandbox-policy */ -import { resolve } from 'node:path' +import { resolve as resolvePath } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { Session } from '@deepseek-ai/dsh-session' +import { effectiveSandboxMode } from './session-mode.ts' export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' +/** Resolve filesystem identity before lexical normalization can erase symlink-sensitive components. */ +function resolveWorkspaceRoot(path: string): string { + return resolvePath(canonicalPath(path)) +} + declare module 'cordis' { interface Context { sandboxPolicy: SandboxPolicyService @@ -45,17 +45,25 @@ export interface Config { /** File-sandbox mode a session starts from (default: `read-only`). */ mode?: SandboxMode /** - * Absolute root directory `workspace-write` may write under (default: - * `process.cwd()`). Both enforcing families fence against this SAME root. + * Fallback root for agentless calls and sessions without a cwd (default: + * `process.cwd()`). Normal agent calls use their session cwd instead. */ workspaceRoot?: string } +/** Inputs that select the sandbox policy for one capability call. */ +export interface SandboxPolicyRequest { + /** Calling session; its immutable cwd becomes the workspace boundary. */ + session?: Session + /** Explicit approved mode override, which outranks session policy. */ + mode?: SandboxMode +} + /** * The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment - * default mode and workspace root; enforcing implementations read - * {@link defaultMode} and {@link workspaceRoot}, and the tool layers fold each - * session's `sandbox/mode` override with {@link effectiveSandboxMode} on top. + * default mode and fallback workspace root. Tool layers call {@link resolve} + * for each execution so a session's mode log and immutable cwd travel together + * to every enforcing capability. */ export class SandboxPolicyService extends Service { // Inline schema call: the config catalog walks `static Config` statically. @@ -68,7 +76,7 @@ export class SandboxPolicyService extends Service { /** The deployment default mode — the fallback beneath a session override. */ readonly defaultMode: SandboxMode - /** The absolute `workspace-write` boundary root both families fence against. */ + /** The absolute `workspace-write` fallback root for calls without a session cwd. */ readonly workspaceRoot: string constructor(ctx: Context, config: Config) { @@ -77,7 +85,24 @@ export class SandboxPolicyService extends Service { // runtime fact. `workspaceRoot` has NO schema default, so its fallback to // the process cwd is real branching, resolved absolute either way. this.defaultMode = config.mode as SandboxMode - this.workspaceRoot = resolve(config.workspaceRoot ?? process.cwd()) + this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd()) + } + + /** + * Resolve the complete policy for one capability call. An approved explicit + * mode outranks the session's last `sandbox/mode` event, which outranks the + * deployment default. A session cwd is its workspace-write boundary; the + * configured root is the fallback for agentless calls and sessions without a + * cwd. + * @param request - optional session and approved mode override. + * @returns the fully resolved per-call mode and absolute workspace root. + */ + resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy { + const { session } = request + return { + mode: request.mode ?? (session === undefined ? undefined : effectiveSandboxMode(session.events)) ?? this.defaultMode, + workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), + } } } diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index 62be36501f..ad7fe0ef29 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -7,9 +7,9 @@ * and there is no external config store. The event is log-only (the * `approval/*` precedent): the model learns the mode from the boundary * markers in the enforcing tools, never from the event itself. EXECUTION - * honors the fold in each tool layer — it stamps the effective mode onto the - * per-call policy carrier (a bash request's `sandboxMode`, an fs mutation's - * `sandboxMode`), weakest-precedence beneath an escalation grant. + * honors the fold through `ctx.sandboxPolicy.resolve()` — it stamps the mode + * together with the calling session's workspace root onto each capability + * call, weakest-precedence beneath an escalation grant. * * The override is policy state shared by every enforcing family (bash and * filesystem alike), so it lives here in the policy package rather than in any diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 52476fdece..cd81caa6b4 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -4,7 +4,9 @@ * override kit (fold + write path) both enforcing families read. */ -import { resolve } from 'node:path' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -16,6 +18,16 @@ async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'dange return ctx } +function session(id: string, cwd?: string): Session { + const sessionId = SessionId(id) + return new Session(sessionId, undefined, { + version: 0, + id: sessionId, + createdAt: 0, + ...cwd === undefined ? {} : { cwd }, + }) +} + describe('SandboxPolicyService', () => { it('defaults to read-only under the process cwd', async () => { const ctx = await mounted() @@ -29,6 +41,71 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub')) }) + it('resolves the deployment policy for an agentless call', async () => { + const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' }) + expect(ctx.sandboxPolicy.resolve()).toEqual({ + mode: 'workspace-write', + workspaceRoot: resolve('/fallback'), + }) + }) + + it('resolves each session mode and cwd together without changing the fallback', async () => { + const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' }) + const first = session('sess-first', '/projects/first') + const second = session('sess-second', '/projects/second') + setSandboxMode(second, 'read-only') + + expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({ + mode: 'workspace-write', + workspaceRoot: resolve('/projects/first'), + }) + expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({ + mode: 'read-only', + workspaceRoot: resolve('/projects/second'), + }) + expect(ctx.sandboxPolicy.resolve()).toEqual({ + mode: 'workspace-write', + workspaceRoot: resolve('/fallback'), + }) + }) + + it('resolves a symlink-sensitive session cwd with filesystem semantics', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-policy-cwd-')) + try { + const lexical = join(root, 'lexical') + const physical = join(root, 'physical') + const child = join(physical, 'child') + mkdirSync(lexical) + mkdirSync(child, { recursive: true }) + const link = join(lexical, 'link') + symlinkSync(child, link, process.platform === 'win32' ? 'junction' : 'dir') + const cwd = `${link}${sep}..` + const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' }) + + expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({ + mode: 'workspace-write', + workspaceRoot: realpathSync.native(physical), + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('lets an approved mode outrank the session mode while retaining its root', async () => { + const ctx = await mounted({ workspaceRoot: '/fallback' }) + const active = session('sess-approved', '/projects/approved') + setSandboxMode(active, 'read-only') + expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({ + mode: 'danger-full-access', + workspaceRoot: resolve('/projects/approved'), + }) + }) + + it('uses the configured root when a session has no cwd', async () => { + const ctx = await mounted({ workspaceRoot: '/fallback' }) + expect(ctx.sandboxPolicy.resolve({ session: session('sess-no-cwd') }).workspaceRoot).toBe(resolve('/fallback')) + }) + it('rejects a mode outside the closed vocabulary at load', async () => { const ctx = new Context() // schemastery rejects the union violation when the plugin loads. diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index fccb08c18f..2b2d6e8df7 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -1,12 +1,12 @@ # @deepseek-ai/dsh-sandbox -Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxPolicy` (per-CALL policy — mode + workspace root), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend. +Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxExecutionPolicy` (the complete per-call mode + workspace root), `SandboxPolicy` (its confined subset), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend. The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus two facts about the selected backend: the enforcement completeness it achieves and its denial dialect (`denialSignatures`, the stderr substrings its kernel prints on a denied file effect — what stderr-inferring consumers match instead of a cross-backend union); when no backend is usable it throws rather than passing the argv through unconfined. Policy rides the call, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is just a new call with a wider policy. -**Same-world confinement only.** A backend shares the host's filesystem and kernel (`bwrap`, Landlock, Seatbelt); `workspaceRoot` names a real host path. Containers, microVMs, and remote executors are NOT backends of this seam — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups. The boundary and its rationale: [the sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). +**Same-world confinement only.** A backend shares the host's filesystem and kernel (`bwrap`, Landlock, Seatbelt); `workspaceRoot` names the filesystem-canonical real host directory. Workspace identity is resolved before lexical normalization, so a valid cwd containing `symlink/..` grants the directory where `chdir` actually lands rather than an unrelated lexical parent. Containers, microVMs, and remote executors are NOT backends of this seam — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups. The boundary and its rationale: [the sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). Implementations: [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) (Linux: `bwrap`, else the per-platform Landlock launcher; macOS: `sandbox-exec`/Seatbelt). Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/) (wraps `['bash', '-c', command]`). diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index e4120efedd..781227f411 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -30,6 +30,18 @@ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' /** A confining (non-`danger-full-access`) mode — the modes a {@link SandboxPolicy} can carry. */ export type ConfinedSandboxMode = Exclude +/** + * The complete file-effect policy resolved for one capability call. The root + * is carried even under modes that do not consume it so callers can resolve + * policy once before choosing the enforcement path. + */ +export interface SandboxExecutionPolicy { + /** The file-effect mode this execution runs under. */ + mode: SandboxMode + /** Absolute root directory `workspace-write` may write under. */ + workspaceRoot: string +} + /** * Enforcement completeness for this host. `partial` means an active backend or * older kernel ABI cannot govern every promised file effect; callers requiring @@ -42,15 +54,12 @@ export type SandboxEnforcement = 'full' | 'partial' * fixed on the provider: two consumers may confine under different policies * at the same instant (bash under `read-only` while a confined child agent * needs its state directory writable), and an approved escalated retry is a - * new call with a wider policy. Defaulting/resolution is the consumer's - * explicit step (its config owns the fallback chain); the provider treats - * the policy as fully specified. + * new call with a wider policy. Defaulting/resolution is an explicit step at + * the consumer boundary; the provider treats the policy as fully specified. */ -export interface SandboxPolicy { +export interface SandboxPolicy extends SandboxExecutionPolicy { /** The file-effect mode this execution runs under. */ mode: ConfinedSandboxMode - /** Absolute root directory `workspace-write` may write under. */ - workspaceRoot: string } /** diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts index 2d70148cdf..1215f3dac1 100644 --- a/packages/sandbox/sandbox/src/roots.ts +++ b/packages/sandbox/sandbox/src/roots.ts @@ -15,7 +15,7 @@ import { realpathSync } from 'node:fs' import { tmpdir } from 'node:os' -import type { SandboxPolicy } from './index.ts' +import type { SandboxExecutionPolicy } from './index.ts' /** * Resolve a granted root to the path the enforcement layer actually compares: @@ -29,9 +29,13 @@ import type { SandboxPolicy } from './index.ts' */ export function canonicalPath(path: string): string { try { - return realpathSync(path) + // Node's JavaScript realpath implementation lexically collapses `..` + // before resolving a preceding symlink on some platforms. The native + // implementation follows the filesystem's component-by-component lookup, + // matching chdir/spawn and the enforcement layers this identity feeds. + return realpathSync.native(path) } catch { - // realpathSync failed: the path (or a prefix) is missing or unreadable. + // realpathSync.native failed: the path (or a prefix) is missing or unreadable. return path } } @@ -45,7 +49,7 @@ export function canonicalPath(path: string): string { * @param policy - the file-effect policy to derive the allow-list from. * @returns the canonical writable roots; empty exactly under `read-only`. */ -export function writableRoots(policy: SandboxPolicy): string[] { +export function writableRoots(policy: SandboxExecutionPolicy): string[] { if (policy.mode !== 'workspace-write') return [] return [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))] } diff --git a/packages/session-persistence/README.md b/packages/session-persistence/README.md index 6435a4bea7..d1e4b2286e 100644 --- a/packages/session-persistence/README.md +++ b/packages/session-persistence/README.md @@ -5,6 +5,7 @@ The durable session-persistence seam and its storage backends. The interface pac | Package | Role | ctx key | |---|---|---| | `session-persistence/` | Persistence seam + shared write coordinator | `ctx.sessionPersistence` | +| `session-checkpoint-policy/` | Semantic durability barriers for agent requests and tool execution | (wraps `ctx.llm` / `ctx.tools`, listens on agent events) | | `session-persistence-jsonl/` | JSONL-sidecar persistence backend | (registers `ctx.sessionPersistence`) | | `session-persistence-sqlite/` | SQLite persistence backend | (registers `ctx.sessionPersistence`) | diff --git a/packages/session-persistence/session-checkpoint-policy/README.md b/packages/session-persistence/session-checkpoint-policy/README.md new file mode 100644 index 0000000000..004c49c5cb --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/README.md @@ -0,0 +1,45 @@ +# dsh-session-checkpoint-policy + +Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and after a step has recorded its complete assistant message and ordered tool results. The final `turn/end` checkpoint remains owned by `dsh-agent-loop`. + +## Plugin (namespace: `session-checkpoint-policy`) + +This zero-config function plugin consumes `ctx.sessions`, `ctx.llm`, `ctx.tools`, and the presence of `ctx.sessionPersistence`. Load it beside one persistence backend: + +```yaml +- id: session-persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + +- id: session-checkpoints + name: '@deepseek-ai/dsh-session-checkpoint-policy' +``` + +Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend makes each requested `session/flush` durable; this policy chooses the request, tool-dispatch, and completed-step checkpoints. Loading a backend without this policy is valid and retains checkpoints requested by the loop, including final `turn/end`, but crash recovery may lose the rest of an in-flight turn. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the policy. + +The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/post-step` persists the complete response/result batch before continuation work. + +The loop records its assistant message and ordered tool results before dispatching `agent/post-step`, so the policy always captures that core batch. An event appended by another `agent/post-step` listener is captured at this checkpoint only when that listener is registered before the policy; Cordis registration order is the explicit composition rule for such extensions. + +Checkpoint rejection is fail-closed at the model and tool boundaries: neither the adapter nor the top-level tool body runs. A post-step rejection fails the turn before another request starts. Concurrent tool checkpoints share the session store's serialized persistence drain and cannot duplicate sequence numbers. + +## Model Experience + +### Interrupted calls + +#### What the model sees + +The plugin adds no prompt or tool schema. A hard crash after a tool checkpoint but before its result leaves a durable unmatched call; session recovery supplies the model-visible `TOOL_OUTCOME_UNKNOWN` result owned by `dsh-session`. The message permits retry for read-only or idempotent work and requires state verification or user confirmation for calls that may have side effects. + +#### Token effect + +Successful checkpoints add no tokens and do not change the request. Recovery adds one short tool-result message to balance the interrupted transcript. + +#### KV Cache effect + +The repair result is appended after the reusable prefix, so it does not invalidate earlier cache entries. + +## Known Limitations and Deferred Work + +- The policy durably records execution intent, not generic exactly-once effects. Side-effecting tools should forward `exec.callId` as an idempotency key when their provider supports one. +- Streaming `assistant/chunk` events have no per-chunk checkpoint. They reach storage with the next semantic checkpoint, so a hard crash may lose the current partial response. +- A persisted call without a result cannot prove whether its external effect completed. Recovery therefore records an unknown outcome instead of retrying automatically. diff --git a/packages/session-persistence/session-checkpoint-policy/package.json b/packages/session-persistence/session-checkpoint-policy/package.json new file mode 100644 index 0000000000..5d0fe5f465 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/package.json @@ -0,0 +1,52 @@ +{ + "name": "@deepseek-ai/dsh-session-checkpoint-policy", + "description": "Semantic session durability checkpoints before model requests and tool side effects", + "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", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@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-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts new file mode 100644 index 0000000000..138e45db2d --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -0,0 +1,75 @@ +/** + * Semantic durability checkpoints for model requests, top-level tool dispatch, + * and completed agent steps. + * @module @deepseek-ai/dsh-session-checkpoint-policy + */ + +import type { Context } from 'cordis' +import type { Session } from '@deepseek-ai/dsh-session' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session-persistence' + +/** Cordis plugin name used by Loader diagnostics. */ +export const name = 'session-checkpoint-policy' + +/** Services whose request, tool, session, and persistence boundaries this policy joins. */ +export const inject = ['llm', 'sessionPersistence', 'sessions', 'tools'] + +/** + * Delay construction of the downstream model stream until the complete logged + * request prefix is durable. A checkpoint rejection prevents adapter dispatch. + * + * @param ctx - plugin context that owns the session store. + * @param session - live session named by the model request. + * @param next - downstream `llm/stream` chain. + * @returns a stream that checkpoints before requesting its first chunk. + */ +function afterCheckpoint( + ctx: Context, + session: Session, + next: () => AsyncIterable, +): AsyncIterable { + return (async function* (): AsyncIterable { + await ctx.sessions.flush(session) + yield* next() + })() +} + +/** Materialize the canonical result for a call cancelled before tool dispatch. */ +function abortedBeforeDispatchResult(): ToolExecutionResult { + return { + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + } +} + +/** + * Install semantic checkpoint listeners. Loop-built model calls checkpoint the + * logged request before adapter dispatch; top-level tool calls checkpoint their + * recorded call before the tool body; post-step checkpoints retain the complete + * response/result batch. Nested tool dispatches reuse the durable outer call. + * + * Checkpoint failures are fail-closed at the model and tool side-effect + * boundaries: the downstream adapter or tool body is not invoked. + * + * @param ctx - plugin context that owns the listeners. + */ +export function apply(ctx: Context): void { + ctx.on('llm/stream', (options, next): AsyncIterable => { + if (options.sessionId === undefined) return next() + const session = ctx.sessions.get(options.sessionId) + return session === undefined ? next() : afterCheckpoint(ctx, session, next) + }) + + ctx.on('tools/execute', async (exec, next): Promise => { + if (exec.agent === undefined || exec.parent !== undefined) return next() + await ctx.sessions.flush(exec.agent.session) + if (exec.signal.aborted) return abortedBeforeDispatchResult() + return next() + }) + + ctx.on('agent/post-step', (agent): Promise => ctx.sessions.flush(agent.session)) +} diff --git a/packages/session-persistence/session-checkpoint-policy/src/invariant.ts b/packages/session-persistence/session-checkpoint-policy/src/invariant.ts new file mode 100644 index 0000000000..f6baece911 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-checkpoint-policy`. + * @module @deepseek-ai/dsh-session-checkpoint-policy/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-checkpoint-policy' + +/** Cordis companion plugin name. */ +export const name = 'session-checkpoint-policy-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: checkpoint ordering is enforced at the intercepted waterfall and + * persistence seams; this stateless policy owns no independent mutable relation. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts new file mode 100644 index 0000000000..411e374833 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -0,0 +1,106 @@ +import { spawn } from 'node:child_process' +import { access, mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { afterEach, describe, expect, it } from 'vitest' +import SessionStore, { + SessionId, TOOL_OUTCOME_UNKNOWN, + type SessionEvent, +} from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const childScript = fileURLToPath(new URL('./fixtures/crash-child.ts', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const sessionId = SessionId('semantic-checkpoint-crash') +const roots: string[] = [] +const CHILD_FAILPOINT_TIMEOUT_MS = 30_000 + +async function waitForFile(path: string): Promise { + const deadline = Date.now() + CHILD_FAILPOINT_TIMEOUT_MS + for (;;) { + try { + await access(path) + return + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (Date.now() >= deadline) throw new Error(`crash child did not reach failpoint ${path}`) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> { + const root = await mkdtemp(join(tmpdir(), `dsh-semantic-${mode}-`)) + roots.push(root) + const marker = join(root, 'failpoint') + const child = spawn(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], { + cwd: repoRoot, + env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, + stdio: ['ignore', 'ignore', 'pipe'], + }) + let stderr = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + try { + await waitForFile(marker) + const markerText = await readFile(marker, 'utf8') + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.once('close', (code, signal) => { resolve({ code, signal }) }) + }) + child.kill('SIGKILL') + const exit = await closed + expect(exit).toEqual({ code: null, signal: 'SIGKILL' }) + return { root, markerText } + } catch (error: unknown) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') + throw new Error(`crash child failed: ${stderr}`, { cause: error }) + } +} + +async function load(root: string): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + try { + return (await ctx.sessionPersistence.load(sessionId)).events + } finally { + await ctx.fiber.dispose() + } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe.skipIf(process.platform === 'win32')('semantic checkpoint hard-crash recovery', () => { + it('persists the complete request before model dispatch', async () => { + const crashed = await crashAt('request') + expect(crashed.markerText).toBe('request-dispatched') + const events = await load(crashed.root) + expect(events.map(event => event.type)).toEqual([ + 'turn/start', 'user/message', 'step/start', 'request/header', 'step/end', 'turn/end', + ]) + expect(events.at(-1)).toMatchObject({ + type: 'turn/end', data: { reason: { kind: 'interrupted' } }, + }) + }) + + it('persists tool intent before a side effect and repairs its missing result as unknown', async () => { + const crashed = await crashAt('tool') + expect(crashed.markerText).toBe('tool-side-effect') + const events = await load(crashed.root) + expect(events.some(event => event.type === 'assistant/message')).toBe(true) + expect(events.some(event => event.type === 'tool/call')).toBe(true) + const result = events.find(event => event.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.error).toEqual({ + name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN, + }) + if (result?.type !== 'tool/result' || result.data.content[0]?.type !== 'text') { + throw new Error('expected a text tool result') + } + expect(result.data.content[0].text).toContain('Do not retry blindly.') + }) +}) diff --git a/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts new file mode 100644 index 0000000000..17a9aec997 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts @@ -0,0 +1,59 @@ +import { writeFile } from 'node:fs/promises' +import { Context } from 'cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as checkpointPolicy from '../../src/index.ts' + +function waitForCrash(): Promise { + return new Promise(() => { setInterval(() => {}, 60_000) }) +} + +const [mode, root, marker] = process.argv.slice(2) +if ((mode !== 'request' && mode !== 'tool') || root === undefined || marker === undefined) { + throw new Error('usage: crash-child.ts ') +} +const persistenceRoot = root +const failpoint = marker + +class CrashAdapter extends LlmAdapter { + async * stream(_options: GenerateOptions): AsyncIterable { + if (mode === 'request') { + await writeFile(failpoint, 'request-dispatched') + await waitForCrash() + return + } + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { + type: 'block-end', + index: 0, + block: { type: 'tool-call', id: CallId('crash-call'), name: 'crash_tool', arguments: '{}' }, + } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + } +} + +const ctx = new Context() +await mountAgentLoopTestDependencies(ctx) +await ctx.plugin(AgentLoop, { agents: [] }) +await ctx.plugin(SessionPersistenceJsonl, { root: persistenceRoot, compression: 'none' }) +await ctx.plugin(checkpointPolicy) +ctx.llm.registerAdapter(['crash'], new CrashAdapter()) +ctx.tools.register({ + name: 'crash_tool', + description: 'records an external effect and never returns', + parameters: {}, + async execute() { + await writeFile(failpoint, 'tool-side-effect') + return waitForCrash() + }, +}) + +const handle = await ctx.agents.create({ + sessionId: SessionId('semantic-checkpoint-crash'), + agentOptions: { provider: 'crash', model: 'crash' }, +}) +handle.agent.send([{ type: 'text', text: 'exercise the crash boundary' }]) +await waitForCrash() diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts new file mode 100644 index 0000000000..2056d30729 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -0,0 +1,249 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import LlmService, { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' +import * as checkpointPolicy from '../src/index.ts' + +const contexts: Context[] = [] + +class TestPersistence extends SessionPersistence { + locate(_meta: SessionHeader): undefined { return undefined } + create(_meta: SessionHeader): Promise { return Promise.resolve() } + append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } + load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return Promise.reject(new Error('not used')) + } + list(): Promise { return Promise.resolve([]) } +} + +class RecordingAdapter extends LlmAdapter { + constructor(private readonly order: string[]) { super() } + async * stream(_options: GenerateOptions): AsyncIterable { + this.order.push('adapter') + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +async function setup(): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(LlmService) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(TestPersistence) + await ctx.plugin(checkpointPolicy) + return ctx +} + +async function drain(stream: AsyncIterable): Promise { + for await (const _chunk of stream) { /* drain */ } +} + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +describe('session-checkpoint-policy request boundary', () => { + it('awaits the live session checkpoint before constructing the downstream model stream', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('request-checkpoint')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const gate = Promise.withResolvers() + const order: string[] = [] + ctx.on('session/flush', async () => { + order.push('flush:start') + await gate.promise + order.push('flush:end') + }) + ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order)) + + const pending = drain(ctx.llm.stream({ + provider: 'mock', model: 'mock', messages: [], sessionId: session.id, + })) + await Promise.resolve() + expect(order).toEqual(['flush:start']) + gate.resolve(undefined) + await pending + expect(order).toEqual(['flush:start', 'flush:end', 'adapter']) + }) + + it('delegates a request without a live session without checkpointing', async () => { + const ctx = await setup() + const order: string[] = [] + ctx.on('session/flush', () => { order.push('flush') }) + ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order)) + await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [] })) + expect(order).toEqual(['adapter']) + }) + + it('delegates an already-detached session id without checkpointing', async () => { + const ctx = await setup() + const order: string[] = [] + ctx.on('session/flush', () => { order.push('flush') }) + ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order)) + await drain(ctx.llm.stream({ + provider: 'mock', model: 'mock', messages: [], sessionId: SessionId('detached'), + })) + expect(order).toEqual(['adapter']) + }) + + it('does not dispatch the adapter when the checkpoint rejects', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('request-failure')) + const order: string[] = [] + ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable'))) + ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order)) + await expect(drain(ctx.llm.stream({ + provider: 'mock', model: 'mock', messages: [], sessionId: session.id, + }))).rejects.toThrow('disk unavailable') + expect(order).toEqual([]) + }) +}) + +describe('session-checkpoint-policy tool and step boundaries', () => { + it('awaits the checkpoint before a top-level tool body', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('tool-checkpoint')) + const agent = { session } as Agent + const gate = Promise.withResolvers() + const order: string[] = [] + ctx.on('session/flush', async () => { + order.push('flush:start') + await gate.promise + order.push('flush:end') + }) + ctx.tools.register({ + name: 'write', description: 'side effect', parameters: {}, + execute: async () => { order.push('tool'); return [] }, + }) + + const pending = ctx.tools.execute({ + callId: CallId('write-1'), name: 'write', arguments: {}, agent, + signal: new AbortController().signal, + }) + await Promise.resolve() + expect(order).toEqual(['flush:start']) + gate.resolve(undefined) + await expect(pending).resolves.toMatchObject({ isError: false }) + expect(order).toEqual(['flush:start', 'flush:end', 'tool']) + }) + + it('does not dispatch when cancellation lands during the tool checkpoint', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('tool-checkpoint-cancel')) + const agent = { session } as Agent + const controller = new AbortController() + const gate = Promise.withResolvers() + const order: string[] = [] + ctx.on('session/flush', async () => { + order.push('flush:start') + await gate.promise + order.push('flush:end') + }) + ctx.tools.register({ + name: 'write', description: 'side effect', parameters: {}, + execute: async () => { order.push('tool'); return [] }, + }) + + const pending = ctx.tools.execute({ + callId: CallId('write-cancelled'), name: 'write', arguments: {}, agent, + signal: controller.signal, + }) + await Promise.resolve() + expect(order).toEqual(['flush:start']) + controller.abort('cancelled during checkpoint') + gate.resolve(undefined) + + await expect(pending).resolves.toEqual({ + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(order).toEqual(['flush:start', 'flush:end']) + }) + + it('turns a rejected checkpoint into an error result without running the tool body', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('tool-failure')) + const agent = { session } as Agent + let ran = false + ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable'))) + ctx.tools.register({ + name: 'write', description: 'side effect', parameters: {}, + execute: async () => { ran = true; return [] }, + }) + const result = await ctx.tools.execute({ + callId: CallId('write-2'), name: 'write', arguments: {}, agent, + signal: new AbortController().signal, + }) + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ type: 'text', text: 'Error: disk unavailable' }]) + expect(ran).toBe(false) + }) + + it('reuses the outer checkpoint for a nested tool dispatch', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('nested-tool')) + const agent = { session } as Agent + let flushes = 0 + ctx.on('session/flush', () => { flushes += 1 }) + ctx.tools.register({ name: 'nested', description: 'nested', parameters: {}, execute: async () => [] }) + await ctx.tools.execute({ + callId: CallId('nested-1'), name: 'nested', arguments: {}, agent, + parent: Symbol('outer') as never, + signal: new AbortController().signal, + }) + expect(flushes).toBe(0) + }) + + it('checkpoints the complete recorded step at agent/post-step', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('post-step')) + const agent = { session } as Agent + const flushed: string[] = [] + ctx.on('session/flush', (current) => { flushed.push(current.id) }) + await agentEvents(ctx, agent).serial( + 'agent/post-step', 1, 1, new AbortController().signal, + ) + expect(flushed).toEqual([session.id]) + }) +}) + +describe('session-checkpoint-policy lifecycle', () => { + it('removes its wrappers when the owning fiber is disposed', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(LlmService) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(TestPersistence) + const session = ctx.sessions.create(SessionId('disposed-policy')) + let flushes = 0 + ctx.on('session/flush', () => { flushes += 1 }) + ctx.llm.registerAdapter(['mock'], new RecordingAdapter([])) + const fiber = await ctx.plugin(checkpointPolicy) + await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [], sessionId: session.id })) + expect(flushes).toBe(1) + await fiber.dispose() + await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [], sessionId: session.id })) + expect(flushes).toBe(1) + }) + + it('keeps the Loader-safe namespace plugin shape', () => { + expect('default' in checkpointPolicy).toBe(false) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(checkpointPolicy) as Record + expect(unwrapped).toBe(checkpointPolicy) + expect(unwrapped.name).toBe('session-checkpoint-policy') + expect(unwrapped.inject).toEqual(['llm', 'sessionPersistence', 'sessions', 'tools']) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/session-persistence/session-checkpoint-policy/tsconfig.json b/packages/session-persistence/session-checkpoint-policy/tsconfig.json new file mode 100644 index 0000000000..1b81e05951 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../core/tools" + } + ] +} diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index cf766d2057..0362d49a96 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -32,7 +32,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the ## Durability and crash semantics - **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`. -- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length. +- **Append-only.** Flushed events are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length. - **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects. - **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. @@ -46,7 +46,7 @@ The plugin buffers frozen session events and drains them on flush or disposal. A #### What the model sees -JSONL storage contributes no live prompt or schema. Loading restores stored surface history and preserves prior request headers for reconstruction; the new loop composes its current envelope. Each unanswered call in an interrupted tail is balanced with the exact error text `Tool call interrupted by a crash; no result was recorded.` Raw `assistant/chunk` records do not duplicate messages. +JSONL storage contributes no live prompt or schema. Loading restores stored surface history and preserves prior request headers for reconstruction; the new loop composes its current envelope. Recovery balances an assistant request without a durable call with `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, which tells the model to retry only read-only or idempotent work and to verify possible side effects or ask the user. Raw `assistant/chunk` records do not duplicate messages. #### Token effect diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 4c346390a9..3c676bb47d 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -211,8 +211,8 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE } }) - // The last index (into eventEntries) that is a valid `turn/end` — the last - // fully-committed boundary (the loop flushes only at turn/end). + // The last index (into eventEntries) that is a valid `turn/end` — holes + // through a closed turn are always committed corruption. let lastTurnEnd = -1 for (let i = parsed.length - 1; i >= 0; i--) { const p = parsed[i] diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 8099aaf9cc..39619ff60a 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -39,7 +39,7 @@ Like the JSONL backend, the plugin also installs the `session/event` → buffer #### What the model sees -SQLite storage contributes no live prompt or schema. Loading restores the same surface history as JSONL and preserves prior headers for reconstruction; the new loop composes its current envelope. Each unanswered call in interrupted rows is balanced with the exact error text `Tool call interrupted by a crash; no result was recorded.` Row metadata and raw chunks are not messages. +SQLite storage contributes no live prompt or schema. Loading restores the same surface history as JSONL and preserves prior headers for reconstruction; the new loop composes its current envelope. Recovery balances an assistant request without a durable call with `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, which tells the model to retry only read-only or idempotent work and to verify possible side effects or ask the user. Row metadata and raw chunks are not messages. #### Token effect diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 4a7f12e759..da29c73b5d 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -166,8 +166,8 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[] } }) - // The last index that is a valid `turn/end` — the last fully-committed - // boundary (the loop flushes only at turn/end). + // The last index that is a valid `turn/end` — holes through a closed turn + // are always committed corruption. let lastTurnEnd = -1 for (let i = parsed.length - 1; i >= 0; i--) { if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break } diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 6d9e1392fe..045686ba5b 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -16,7 +16,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l ## Invariants every backend must honor -- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded. +- **Append-only; a crashed turn is closed, not truncated.** Flushed events are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (a risk-classified error `tool/result` per unanswered assistant call, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded. - **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq. - **JSON-serializable data.** `append` materializes each direct/replay batch through the shared one-pass lossless-JSON boundary. Live `Session` events are already deep-frozen, but the write coordinator still copies each event into a persistence-owned buffer. - **Durability.** `append` returns only once the batch is durable. @@ -25,6 +25,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l `PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event` → `session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). +The coordinator implements durability at checkpoints but does not select their schedule. Persisted deployments explicitly compose [`dsh-session-checkpoint-policy`](../session-checkpoint-policy) when they want request-, tool-dispatch-, and completed-step recovery boundaries; omitting it leaves the loop's coarser checkpoints intact. + When a live session emits `session/disposed`, the coordinator waits for its initialization, serializes a final buffer drain, then releases every map entry owned by that exact `Session` object. A failed final drain keeps the pending buffer for backend teardown to retry. Backend teardown stops event admission first, awaits all in-flight session retirements and remaining per-id operations, drains any retained buffers, and only then closes the storage handle. The side-effect-free `locate` query remains backend-owned because it describes storage topology rather than write orchestration. @@ -59,7 +61,7 @@ Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `ve #### What the model sees -This seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair inserts exactly `Tool call interrupted by a crash; no result was recorded.` as the error result for each unanswered tool call. +This seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair marks an assistant request without a durable call as `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, whose text lets the model retry read-only or idempotent work but directs it to verify side effects or ask the user instead of retrying blindly. #### Token effect diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 84386c016b..aaea0ddfff 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -9,8 +9,8 @@ */ import { describe, expect, it } from 'vitest' -import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' import type { SessionPersistence } from '../src/index.ts' @@ -122,7 +122,7 @@ export function runPersistenceContract(name: string, make: () => Promise { + it('crash recovery: an unstarted assistant tool request gets a retryable synthetic result', async () => { const { persistence, dispose } = await make() try { const m = meta('interrupted-toolcall') @@ -149,7 +149,7 @@ export function runPersistenceContract(name: string, make: () => Promise e.type === 'tool/result') expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({ - callId: CallId('call-x'), isError: true, error: { code: 'interrupted' }, + callId: CallId('call-x'), isError: true, error: { code: TOOL_NOT_STARTED }, }) // The synthetic result carries the SAME callId as the orphaned tool-call, // so deriveMessages() pairs them — no provider-invalid dangling call. @@ -162,6 +162,40 @@ export function runPersistenceContract(name: string, make: () => Promise { + const { persistence, dispose } = await make() + try { + const m = meta('unknown-tool-outcome') + await persistence.create(m) + await persistence.append(m.id, [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, content: [ + { type: 'tool-call', id: CallId('call-risk'), name: 'write', arguments: '{}' }, + ], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' }, + { type: 'tool/call', seq: 3, time: 4, data: { turn: 1, step: 1, callId: CallId('call-risk'), name: 'write', arguments: '{}' } }, + ]) + + const loaded = await persistence.load(m.id) + const synthetic = loaded.events.find(e => e.type === 'tool/result') + expect(synthetic?.type === 'tool/result' && synthetic.data.error).toEqual({ + name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN, + }) + if (synthetic?.type !== 'tool/result' || synthetic.data.content[0]?.type !== 'text') { + throw new Error('expected a text tool result') + } + expect(synthetic.data.content[0].text).toContain('retry only if the operation is read-only or idempotent') + expect(synthetic.data.content[0].text).toContain('if it may have side effects, first verify external state or ask the user') + const resumed = new Session(m.id, loaded.events, loaded.meta) + const resumedResult = resumed.deriveMessages().find(message => message.content.some(block => block.type === 'tool-result')) + expect(resumedResult?.content[0]).toMatchObject({ + type: 'tool-result', toolCallId: CallId('call-risk'), isError: true, + }) + } finally { + await dispose() + } + }) + it('list() excludes a created-but-never-appended (zero-event) session', async () => { const { persistence, dispose } = await make() try { diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 6e37534e73..029bf0f657 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -4,8 +4,8 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie Four layers, importable separately: -- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a temp cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. -- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo. Startup failures preserve captured agent stderr in the rejected diagnostic. +- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. +- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt` → `{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh preserves existing volatile fields by event position and gives a newly inserted `session/title` its preceding event's time, so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. @@ -36,7 +36,7 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index c29886fdbb..2821969457 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -21,6 +21,7 @@ import { existsSync } from 'node:fs' import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { basename, dirname, join, delimiter } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' import { ClientSideConnection, PROTOCOL_VERSION, @@ -34,6 +35,9 @@ import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } fr export type { AgentUnderTest } from './launcher.ts' +const DEFAULT_WAIT_TIMEOUT_MS = 10_000 +const WAIT_POLL_INTERVAL_MS = 10 + /** * One step of a scenario's deterministic input script (`input.json`). The * harness interprets these in order. `newSession` captures the server-issued @@ -42,10 +46,13 @@ export type { AgentUnderTest } from './launcher.ts' * * `promptAndCancel` starts a prompt without awaiting completion, waits until * the client observes the selected update (`agent_message_chunk` by default), - * then cancels and awaits completion. A named `waitForToolCallUpdate` keeps the - * step open for a terminal tool update that may follow the prompt response. + * then cancels and awaits completion. An optional `waitForFile` first observes + * a cwd-relative readiness marker, and a named `waitForToolCallUpdate` keeps + * the step open for a terminal tool update that may follow the prompt response. * `promptAndWaitForAgentMessage` arms an exact text-chunk waiter before sending * the prompt, then keeps the application live until that later update arrives. + * `waitForTurnEnd` holds the subprocess open until the selected session's latest + * complete raw-JSONL turn boundary is `turn/end`; its timeout defaults to 10s. */ export type InputStep = | { op: 'initialize'; terminalOutput?: boolean } @@ -58,8 +65,10 @@ export type InputStep = op: 'promptAndCancel' text: string afterUpdate?: 'agent_message_chunk' | 'tool_call' + waitForFile?: { path: string; timeoutMs?: number } waitForToolCallUpdate?: string } + | { op: 'waitForTurnEnd'; timeoutMs?: number } | { op: 'cancel' } | { op: 'setMode'; modeId: string } | { op: 'setModeExpectError'; modeId: string } @@ -130,7 +139,7 @@ export interface RunResult { stderr: string /** The session id the server issued (undefined if no session was created). */ sessionId?: string - /** The temp cwd the session ran in (the bash workspace). */ + /** The generated cwd the session ran in (the bash workspace). */ cwd: string /** * Every persisted session log harvested after the run, ordered primary-first: @@ -161,11 +170,19 @@ export interface RunOptions { childFiles?: string[] /** * Optional `/workspace/` directory whose contents are copied into - * the temp cwd BEFORE the run — the standard way to seed files the agent + * the generated cwd BEFORE the run — the standard way to seed files the agent * operates on (a file to read, edit, or grep). Absent for scenarios that * start from an empty workspace. */ workspaceDir?: string + /** + * Parent directory for the generated session cwd. Defaults to + * `os.tmpdir()`. A scenario that must distinguish its workspace from the + * sandbox's always-writable temporary roots can place the generated child + * under `os.homedir()` instead. The harness removes only that generated + * child, never the supplied parent. + */ + workspaceParent?: string /** * Alternate LIVE config path for the boot (absolute), overriding * {@link AgentUnderTest.configPath} for this run. A scenario needing a @@ -196,15 +213,15 @@ export function snapshotSpillRoot( /** * Run a scenario end-to-end against a freshly-spawned subprocess. Owns the - * child and its temp dirs; always tears them down. Returns the captured stdout + * child and its generated dirs; always tears them down. Returns the captured stdout * and (record mode) the harvested session-log path. * * @param input The scenario's input script (steps + optional permission answers). * @param opts The agent to boot, the mode, and the fixture wiring. - * @returns The captured stdout/stderr, session id, temp cwd, and harvested logs. + * @returns The captured stdout/stderr, session id, generated cwd, and harvested logs. */ export async function runScenario(input: InputScript, opts: RunOptions): Promise { - const cwd = await mkdtemp(join(tmpdir(), 'acp-snap-cwd-')) + const cwd = await mkdtemp(join(opts.workspaceParent ?? tmpdir(), 'acp-snap-cwd-')) const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-')) // Fixed path length: spill-policy budgets the preview against the REAL path // before stdout normalization, so tmpdir() length differences churn expected outputs. @@ -218,7 +235,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise let sessionLogs: HarvestedLog[] = [] const outcome = await (async (): Promise => { // Seed the workspace if the scenario ships one (a file the agent reads/edits). - // Copied into the temp cwd so the agent's bash tools see it; the expected outputs + // Copied into the generated cwd so the agent's bash tools see it; the expected outputs // normalize the cwd, so the seeded paths stay stable across runs. if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) { await cp(opts.workspaceDir, cwd, { recursive: true }) @@ -287,7 +304,15 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise const { client } = active for (const step of input.steps) { - await runStep(client, step, cwd, match => active.waitForUpdate(match), () => sessionId, (id) => { sessionId = id }) + await runStep( + client, + step, + cwd, + match => active.waitForUpdate(match), + () => sessionId, + (id) => { sessionId = id }, + (id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs), + ) // A permission exchange happens while a step's request is in flight, so // by the time the step settles any script bug it exposed is captured — // fail the run HERE, as a harness error, rather than hoping the agent's @@ -298,7 +323,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // persistence) and exits. Then await exit so the harvested log is complete. await active.close() // Harvest EVERY persisted log (parent + any subagent children) while the - // temp dirs still exist, ordered primary-first. + // generated dirs still exist, ordered primary-first. sessionLogs = await harvestSessionLogs(sessionsRoot) return { rawStdout: launched.rawStdout(), @@ -357,6 +382,7 @@ async function runStep( waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise, getSessionId: () => string | undefined, setSessionId: (id: string) => void, + waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise, ): Promise { switch (step.op) { case 'initialize': @@ -421,6 +447,9 @@ async function runStep( const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) const afterUpdate = step.afterUpdate ?? 'agent_message_chunk' await waitForUpdate(u => u.sessionUpdate === afterUpdate) + if (step.waitForFile !== undefined) { + await waitForWorkspaceFile(cwd, step.waitForFile.path, step.waitForFile.timeoutMs) + } // Arm this before cancellation so a fast tool drain cannot outrun the waiter. const toolCallUpdateDone = step.waitForToolCallUpdate === undefined ? undefined @@ -430,6 +459,12 @@ async function runStep( if (toolCallUpdateDone !== undefined) await toolCallUpdateDone return } + case 'waitForTurnEnd': { + const sessionId = getSessionId() + if (sessionId === undefined) throw new Error('snapshot-harness: waitForTurnEnd before newSession') + await waitForTurnEnd(sessionId, step.timeoutMs) + return + } case 'cancel': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: cancel before newSession') @@ -477,6 +512,51 @@ async function runStep( } } +/** + * Wait until the raw JSONL backend exposes one complete closing turn boundary. + * The ACP cancel notification settles its prompt before the agent necessarily + * reaches quiescence, so cancellation snapshots use this external boundary to + * keep subprocess disposal from changing an `aborted` turn into `disposed`. + */ +async function waitForPersistedTurnEnd( + root: string, + sessionId: string, + timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, +): Promise { + const deadline = Date.now() + timeoutMs + while (true) { + const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) + if (log !== undefined && latestTurnIsClosed(log.content)) return + if (Date.now() >= deadline) { + throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`) + } + await delay(WAIT_POLL_INTERVAL_MS) + } +} + +/** Wait for a cwd-relative marker proving an external action reached readiness. */ +async function waitForWorkspaceFile( + cwd: string, + path: string, + timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, +): Promise { + const target = join(cwd, path) + const deadline = Date.now() + timeoutMs + while (!existsSync(target)) { + if (Date.now() >= deadline) { + throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`) + } + await delay(WAIT_POLL_INTERVAL_MS) + } +} + +/** Return whether the last complete raw-JSONL turn boundary closes its turn. */ +function latestTurnIsClosed(content: string): boolean { + const complete = content.slice(0, content.lastIndexOf('\n') + 1) + return complete.lastIndexOf('\n{"type":"turn/end",') + > complete.lastIndexOf('\n{"type":"turn/start",') +} + /** * Harvest EVERY persisted `.jsonl` session log under a sessions root, parse each * header line, and return them ordered primary-first: the top-level session (no diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 24e068e6b7..673aebb331 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -1,5 +1,5 @@ /** - * Pure ACP transcript and session-log normalizers. They scrub session ids, temp cwd, RPC ids, + * Pure ACP transcript and session-log normalizers. They scrub session ids, run cwd, RPC ids, * timestamps, and hook duration while preserving deterministic event sequence numbers. * Request-header scrubbers stay composable so one scenario per header class can pin prompt and * tool-schema sidecars while retaining any model-visible prefix in the session log. @@ -44,7 +44,7 @@ function canonicalizeEmbeddedPaths(value: string): string { export interface NormalizeContext { /** The session id(s) the run issued — replaced with `{{sessionId}}`. */ sessionIds: string[] - /** The temp cwd the run used — replaced with `{{cwd}}`. */ + /** The generated cwd the run used — replaced with `{{cwd}}`. */ cwd: string } diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index ab6008913c..509021346e 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -104,6 +104,12 @@ export interface Scenario { * {@link headerClass}. */ configPath?: string + /** + * Parent directory for the generated session cwd. Defaults to the platform + * temp directory; set this when temp is itself part of the behavior under + * test and the scenario needs an independent project location. + */ + workspaceParent?: string /** * Whether Windows additionally compares stdout with native separators against * `stdout.expected.windows.jsonl`. The shared canonical stdout expected output is still @@ -237,7 +243,7 @@ export function fixtureContext(fixture: string): NormalizeContext { * The `data.header` payload of every `request/header` event in a session * JSONL, in log order, with the log's volatile values scrubbed first * ({@link normalizeSessionLog}) so headers harvested from different runs — - * each embedding its own temp cwd in the composed prompt — compare on equal + * each embedding its own generated cwd in the composed prompt — compare on equal * footing. * * @param rawLog The session `.jsonl` content to extract headers from. @@ -561,6 +567,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // replays from its own script. In RECORD they are harvested, not read. ...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, + ...scenario.workspaceParent !== undefined ? { workspaceParent: scenario.workspaceParent } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. ...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {}, diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index 26d2c41638..df3bb0b970 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -49,6 +49,8 @@ interface Behavior { cancelAtToolCall?: boolean /** Emit the parked tool call's terminal update after answering cancellation. */ cancelToolCallUpdate?: boolean + /** Persist the scripted logs while handling cancellation, before stdin EOF. */ + persistLogsOnCancel?: boolean /** Before responding to a prompt, send a `session/request_permission` request and echo its outcome as a chunk. */ permissionProbe?: boolean /** Before responding to a prompt, send an `elicitation/create` request and echo its response as a chunk. */ @@ -63,7 +65,7 @@ interface Behavior { stderrNote?: string /** Let a short-lived descendant retain stdio and emit one final ACP update plus stderr line after this parent exits. */ lateInheritedOutput?: boolean - /** Session logs to persist on stdin EOF. */ + /** Session logs to persist on stdin EOF and, when selected, on cancellation. */ logs?: ScriptedLog[] /** Leave a stray FILE directly under the sessions root (harvest must skip it). */ strayRootFile?: boolean @@ -304,6 +306,7 @@ function handleFrame(frame: Record): void { }, }) } + if (behavior.persistLogsOnCancel === true) writeLogs() } return default: @@ -313,12 +316,16 @@ function handleFrame(frame: Record): void { } } -function flushLogsAndExit(): void { +function writeLogs(): void { for (const log of behavior.logs ?? []) { const target = join(sessionsRoot, log.file) mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, log.lines.map(l => JSON.stringify(instantiate(l))).join('\n') + '\n') } +} + +function flushLogsAndExit(): void { + writeLogs() if (behavior.strayRootFile === true) writeFileSync(join(sessionsRoot, 'stray.txt'), 'not a bucket\n') if (behavior.strayBucketFile === true) { mkdirSync(join(sessionsRoot, 'bucket-noise'), { recursive: true }) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 7363ecff16..a87e72d3d4 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { once } from 'node:events' import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' +import { delimiter, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' @@ -466,6 +466,22 @@ describe('runScenario', () => { expect(result.rawStdout).toContain('workspace:seeded.txt') }) + it('creates the generated workspace under an explicit parent', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({}) + const workspaceParent = await mkdtemp(join(tmpdir(), 'acp-snap-parent-')) + tempDirs.push(workspaceParent) + + const result = await runScenario( + { steps: boot }, + { agent: AGENT, mode: 'replay', fixtureFile, workspaceParent }, + ) + + const child = relative(workspaceParent, result.cwd) + expect(child).not.toBe('') + expect(child).not.toBe('..') + expect(child.startsWith(`..${sep}`)).toBe(false) + }) + it('promptAndCancel waits for the streamed chunk, cancels, and settles the prompt', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel' }) const result = await runScenario( @@ -477,6 +493,37 @@ describe('runScenario', () => { expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled')) }) + it('promptAndCancel can wait for cwd-relative readiness before cancelling', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ prompt: 'hang-until-cancel' }) + const workspaceDir = join(dir, 'workspace') + const { mkdir } = await import('node:fs/promises') + await mkdir(workspaceDir, { recursive: true }) + await writeFile(join(workspaceDir, 'started.txt'), 'started') + const result = await runScenario( + { + steps: [...boot, { + op: 'promptAndCancel', + text: 'hang', + waitForFile: { path: 'started.txt' }, + }], + }, + { agent: AGENT, mode: 'replay', fixtureFile, workspaceDir }, + ) + expect(result.rawStdout).toContain('"stopReason":"cancelled"') + + const missing = await scenario({ prompt: 'hang-until-cancel' }) + await expect(runScenario( + { + steps: [...boot, { + op: 'promptAndCancel', + text: 'hang', + waitForFile: { path: 'never.txt', timeoutMs: 20 }, + }], + }, + { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile }, + )).rejects.toThrow(/workspace file "never\.txt" did not appear within 20ms/) + }) + it('promptAndWaitForAgentMessage keeps the app live through a matching later update', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'respond' }) const result = await runScenario( @@ -514,6 +561,55 @@ describe('runScenario', () => { expect(result.rawStdout.indexOf('cancelled')).toBeLessThan(result.rawStdout.indexOf('"sessionUpdate":"tool_call_update"')) }) + it('waitForTurnEnd holds cancellation open through the persisted closing boundary', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ + prompt: 'hang-until-cancel', + persistLogsOnCancel: true, + logs: [{ + file: 'bucket/session.jsonl', + lines: [ + { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } }, + ], + }], + }) + const result = await runScenario( + { steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForTurnEnd' }] }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ) + expect(result.sessionLogs[0]?.content).toContain('"type":"turn/end"') + }) + + it('waitForTurnEnd times out for a missing log and an open logged turn', { timeout: 20_000 }, async () => { + const missing = await scenario({}) + await expect(runScenario( + { steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 20 }] }, + { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile }, + )).rejects.toThrow(/did not persist turn\/end within 20ms/) + + const open = await scenario({ + prompt: 'hang-until-cancel', + persistLogsOnCancel: true, + logs: [{ + file: 'bucket/session.jsonl', + lines: [ + { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + ], + }], + }) + await expect(runScenario( + { + steps: [ + ...boot, + { op: 'promptAndCancel', text: 'hang' }, + { op: 'waitForTurnEnd', timeoutMs: 20 }, + ], + }, + { agent: AGENT, mode: 'replay', fixtureFile: open.fixtureFile }, + )).rejects.toThrow(/did not persist turn\/end within 20ms/) + }) + it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'error' }) const result = await runScenario( @@ -604,6 +700,7 @@ describe('runScenario', () => { [{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/], [{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/], [{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/], + [{ op: 'waitForTurnEnd' }, /waitForTurnEnd before newSession/], [{ op: 'cancel' }, /cancel before newSession/], [{ op: 'setConfigOption', configId: 'sandbox-mode', value: 'read-only' }, /setConfigOption before newSession/], [{ op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'yolo' }, /setConfigOptionExpectError before newSession/], diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index b80b5a50d2..d0d290e070 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -48,7 +48,14 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta. // Replay pins explicit header classes; recording covers the default fallback. const REPLAY_SCENARIOS: Scenario[] = [ { name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' }, - { name: 'plain-turn', hasModelTurn: true, recorded: true, headerClass: 'main', configPath: AGENT.configPath }, + { + name: 'plain-turn', + hasModelTurn: true, + recorded: true, + headerClass: 'main', + configPath: AGENT.configPath, + workspaceParent: tmpdir(), + }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, { name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true, headerClass: 'main' }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 0c8450b04c..4afef81c22 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -663,13 +663,24 @@ describe('pi-tui chat lifecycle and transcript', () => { }) it('refreshes the running status elapsed time on its own timer', async () => { - const result = await setup({ status: 'running' }) - result.terminal.output = '' - // The loader repaints "0s" until the controller's own interval fires; a - // non-zero elapsed proves the refresh, not just the loader's animation. - await new Promise(resolve => setTimeout(resolve, 1_300)) - expect(result.terminal.output).toMatch(/Waiting for the first token [1-9]s/) - await dispose(result) + let now = 0 + const intervals = vi.spyOn(globalThis, 'setInterval') + let result: Awaited> | undefined + try { + result = await setup({ status: 'running', now: () => now }) + const refresh = intervals.mock.calls.find(([, interval]) => interval === 1_000)?.[0] + if (typeof refresh !== 'function') throw new Error('TUI did not register its elapsed-status refresh interval') + result.terminal.output = '' + // The loader repaints "0s" until the controller's own interval fires; a + // non-zero elapsed proves the refresh, not just the loader's animation. + now = 1_000 + refresh() + await tick() + expect(result.terminal.output).toContain('Waiting for the first token 1s') + } finally { + if (result !== undefined) await dispose(result) + intervals.mockRestore() + } }) it('shows minutes and seconds once a step passes a minute', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f60725e0a..be885c5432 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,6 +270,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../packages/sandbox/sandbox-policy + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:* + version: link:../packages/session-persistence/session-checkpoint-policy '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:* version: link:../packages/session-persistence/session-persistence-jsonl @@ -1195,6 +1198,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:^ + version: link:../../session-persistence/session-checkpoint-policy '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -1232,9 +1238,18 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:^ + version: link:../../bash/bash-sandbox '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../../fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../fs/fs-sandbox '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal @@ -1253,6 +1268,12 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -1277,6 +1298,9 @@ importers: '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../goal/tool-goal @@ -1295,6 +1319,9 @@ importers: cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + node-addon-landlock-run: + specifier: 0.0.0-test.0 + version: 0.0.0-test.0 packages/examples/cli-demo: devDependencies: @@ -1322,6 +1349,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:^ + version: link:../../session-persistence/session-checkpoint-policy '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -1389,6 +1419,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:^ + version: link:../../session-persistence/session-checkpoint-policy '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -2572,6 +2605,45 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-persistence/session-checkpoint-policy: + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../session-persistence-jsonl + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/session-persistence/session-persistence: devDependencies: '@deepseek-ai/dsh-invariants': @@ -4121,6 +4193,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-checkpoint-policy '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../packages/session-persistence/session-persistence @@ -14035,35 +14110,6 @@ snapshots: - typescript - universal-cookie - vitest@4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.3 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 22.20.0 - '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) - jsdom: 29.1.1 - transitivePeerDependencies: - - msw - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 @@ -14124,6 +14170,35 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.0 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + vscode-jsonrpc@5.0.1: {} vscode-jsonrpc@9.0.1: {} diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 00f0fe0035..90433c4f5f 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: cdf38d4474a0e0148a4804e14c12971c76b38e27 -README.zh.md: 99d57c6f900371a46b94c5ea80b2a1665fd5e8d1 +README.md: f2ccd8939e497d10359aafe8b1bd8b364875ed98 +README.zh.md: 30bdf46fee03c38a1f4b6e8b2b39d87e8174a3e0 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index cdf38d4474..f2ccd8939e 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -26,4 +26,4 @@ Each wheel contains exactly one executable. The fixed tags are `py3-none-manylin ## Zero-config design -The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, local bash, and a local filesystem provider for bounded workspace-instruction loading. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. +The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, the explicitly composed semantic checkpoint policy, local bash, and a local filesystem provider for bounded workspace-instruction loading. The persistence backend owns durable storage while the separate policy selects request-, tool-dispatch-, and completed-step checkpoints. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 99d57c6f90..30bdf46fee 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -26,4 +26,4 @@ exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deep ## 零配置设计 -运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。`bin`(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent 就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、`agent-core`、预载的 DeepSeek 适配器、JSONL 持久化、本地 bash,以及用于有界加载工作区指令的本地文件系统 provider。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统 provider 则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 +运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。`bin`(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent 就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、`agent-core`、预载的 DeepSeek 适配器、JSONL 持久化、显式组合的语义检查点策略、本地 bash,以及用于有界加载工作区指令的本地文件系统 provider。持久化后端负责持久存储,独立的策略则选择请求、工具分发和已完成步骤的检查点。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统 provider 则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index b794f84368..19225b0697 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index a0eccdf483..824aa03e7b 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -27,6 +27,11 @@ config: root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' +# Persistence owns durable storage; this separate policy explicitly selects +# the request, tool-dispatch, and completed-step durability checkpoints. +- id: session-checkpoints + name: '@deepseek-ai/dsh-session-checkpoint-policy' + # Local bash executor; $DSH_CWD wins over the process cwd. - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 181df4986e..956d6f8ff8 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 5fd1bc7cd89152a28d3da17100fd62eed4f8cb14 -README.zh.md: 247a2ca5ea5c1c3afc19335a6bbcba356c823211 +README.md: 23d15d617b3d295a6cc2d8d20c6d03abc226834b +README.zh.md: 4f6aef13833af937babc2e5a92bfd14c12170534 diff --git a/python/sdk/README.md b/python/sdk/README.md index 5fd1bc7cd8..23d15d617b 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -19,7 +19,7 @@ with DeepSeekHarness() as harness: `DeepSeekHarness` keeps its lazily started runtime subprocess for reuse across calls. Use it as a context manager, as above, or call `close()` explicitly when finished. -By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-jsonrpc` entry in the config and pass the Cordis config path. +By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence with an explicitly composed semantic checkpoint policy, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-jsonrpc` entry in the config and pass the Cordis config path. ```py from deepseek_harness import DeepSeekHarness diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 247a2ca5ea..4f6aef1383 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -15,7 +15,7 @@ with DeepSeekHarness() as harness: `DeepSeekHarness` 会保留延迟启动的运行时子进程,以供多次调用复用。请像上例一样将其用作上下文管理器,或在用完后显式调用 `close()`。 -默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、`agent-core`、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash)。要运行自己的插件组合,请在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。 +默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、`agent-core`、预载的 DeepSeek 适配器、配有显式组合语义检查点策略的 JSONL 会话持久化、本地 bash)。要运行自己的插件组合,请在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。 ```py from deepseek_harness import DeepSeekHarness diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index bc1da849b2..6ff14fa266 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -28,6 +28,8 @@ _CORDIS_YML = """\ name: '@deepseek-ai/dsh-session-persistence-jsonl' config: root: './sessions' +- id: session-checkpoints + name: '@deepseek-ai/dsh-session-checkpoint-policy' - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 4858686191..400394ae4e 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -15,7 +15,10 @@ from deepseek_harness_runtime import ( def test_default_config_is_shipped_with_the_package() -> None: path = bundled_default_config_path() assert path == bundled_package_dir() / "runtime" / "cordis.yml" - assert "@deepseek-ai/dsh-agent-spine-demo" in path.read_text() + config = path.read_text() + assert "@deepseek-ai/dsh-agent-spine-demo" in config + assert "@deepseek-ai/dsh-session-persistence-jsonl" in config + assert "@deepseek-ai/dsh-session-checkpoint-policy" in config def test_unknown_explicit_mode_fails_loud() -> None: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a28a5df9c8..da2438b8d4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -90,6 +90,7 @@ export const LINK_MAP: Record = { SessionHeader: 'persistence.md', SessionLocation: 'persistence.md', ConfinedArgv: 'sandbox.md', + SandboxExecutionPolicy: 'sandbox.md', SandboxMode: 'sandbox.md', SandboxPolicy: 'sandbox.md', PtyBackend: 'pty.md', @@ -103,6 +104,7 @@ export const LINK_MAP: Record = { PtySignalResult: 'pty.md', PtySpawnRequest: 'pty.md', PtySpawnResult: 'pty.md', + SandboxPolicyRequest: 'sandbox.md', ScopeKey: 'scope.md', Scoped: 'scope.md', EpochHeader: 'session.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index db18132859..cc85d48ba5 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -64,7 +64,7 @@ class CatalogSearchBashExecutor extends BashExecutor { timeoutMs: request.timeoutMs ?? 60_000, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, signal: request.signal, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 59ee171572..1c3958434c 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -21,7 +21,7 @@ type Mode = | 'ci-windows-observational' | 'node-compat' | 'pre-push' - | 'manual-push' + | 'check-all' | 'doc-sync' type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' @@ -98,12 +98,12 @@ function parseMode(raw: string | undefined): Mode { case 'ci-windows-observational': case 'node-compat': case 'pre-push': - case 'manual-push': + case 'check-all': case 'doc-sync': return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | manual-push | doc-sync, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | check-all | doc-sync, got ${JSON.stringify(raw)}.`, ) } } @@ -112,7 +112,7 @@ function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefau const available = availableParallelism() // Local modes cap workers: several doc gates each build a full ts.Program, // so an uncapped default on a large host trades wall clock for memory blowups. - const localCap = selectedMode === 'pre-push' || selectedMode === 'doc-sync' + const localCap = selectedMode === 'pre-push' || selectedMode === 'check-all' || selectedMode === 'doc-sync' const modeLimit = localCap ? Math.min(4, available) : available return { workers: Math.min(total, modeLimit), @@ -191,7 +191,7 @@ function gatesForMode(selected: Mode): Gate[] { case 'node-compat': return nodeCompatGates() case 'pre-push': return [] - case 'manual-push': + case 'check-all': return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), @@ -383,7 +383,7 @@ function coverageGate(): Gate { } // The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node, -// plugins via real exports) — CI and pre-push already build, so they exercise what ships rather +// plugins via real exports) — CI and check-all already build, so they exercise what ships rather // than the tsx/source path dev uses. It therefore waits on `build`. function snapshotGate(): Gate { return pnpmScript('snapshot', 'test:snapshot', { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 11c76939f0..2da7b2310e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -688,6 +688,11 @@ "symbol": "ConfinedSandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "SandboxExecutionPolicy", + "source": "packages/sandbox/sandbox/src/index.ts" + }, { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxEnforcement", @@ -698,6 +703,11 @@ "symbol": "SandboxPolicy", "source": "packages/sandbox/sandbox/src/index.ts" }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "SandboxPolicyRequest", + "source": "packages/sandbox/sandbox-policy/src/index.ts" + }, { "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedArgv", diff --git a/tsconfig.build.json b/tsconfig.build.json index 4e22d615a2..fc0050f34b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -19,6 +19,7 @@ { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, { "path": "./packages/session-persistence/session-persistence" }, + { "path": "./packages/session-persistence/session-checkpoint-policy" }, { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-query/session-query" }, diff --git a/tsconfig.json b/tsconfig.json index f5c65c396f..8def2a8aeb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,7 @@ { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, { "path": "./packages/session-persistence/session-persistence" }, + { "path": "./packages/session-persistence/session-checkpoint-policy" }, { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-query/session-query" },