Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop
# Conflicts: # packages/bash/tool-bash/src/index.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/ui/acp/README.md
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
# @deepseek-ai/dsh-tool-bash
|
||||
|
||||
The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). This package owns schema and text shaping while process concerns stay behind the seam. Executor facts can change rendered results, and a sandboxing executor activates the escalation fields, without moving those presentation rules into the backend.
|
||||
The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`.
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `apply`); result rendering remains an implementation detail covered by same-package tests.
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests.
|
||||
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. A sandboxing executor changes the `bash` schema and result markers but adds no mode statement or switch notice; see [Per-session mode](#per-session-mode-switching).
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -24,41 +24,27 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c
|
||||
|
||||
`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()`.
|
||||
|
||||
Result text: stdout, then a `[stderr]` section, then status markers — `[sandbox: file access denied under <mode> mode]` when a sandboxing executor classified the failure as a policy denial (reported first so `[exit code: N]` stays the last line; the static description tells the model a denial is policy, not a command bug, and forbids retrying around it), `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: <path>]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`.
|
||||
|
||||
### `bash_output`
|
||||
|
||||
`task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). A settled task classified as a sandbox denial carries the same `[sandbox: file access denied under <mode> mode]` marker on every read that sees it (denials are only classifiable once the whole stderr has been collected). Reads that lost data to buffer bounds say so and point at the full-output spill file when one is safely available, otherwise `(unavailable)`.
|
||||
|
||||
### `bash_kill`
|
||||
|
||||
`task_id` → ask the executor to kill the background task. The concrete executor decides how to signal or stop the process; killing an already-finished task is a reported no-op, and unknown ids are errors.
|
||||
|
||||
### Task ownership (cross-session isolation)
|
||||
|
||||
The owning agent's shared registry/session id (`agent.id`) is stamped onto the task at spawn — passed to the executor via `resolve({ …, owner })` and stored ON THE TASK inside the executor (the `dsh-bash` `ownerOf(id)` seam), **not** in a plugin-local map. `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's shared id with `!== undefined` semantics and reject a task owned by a *different* session with `task <id> belongs to another session` (a task started with no agent — a non-loop caller — has no owner token and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this token check is the fence that stops one session's agent from reading or killing another session's background task. Because ownership lives on the task in the executor (disposed with the `dsh-bash` fiber), it **survives an independent `tool-bash` HMR reload** — closing the old plugin-local-map gap where a reload orphaned pre-reload tasks. (The `onTaskDone` listener is still effect-scoped to this plugin's `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
|
||||
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
|
||||
|
||||
## UI presentation
|
||||
|
||||
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI while the tool keeps model-facing result text unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). `bash_output`/`bash_kill` return a `generic` card with a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") and the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
|
||||
|
||||
## Background completion notices
|
||||
|
||||
When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for that shared agent/session id (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`.
|
||||
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, raw output, and parsed exit status. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe.
|
||||
|
||||
## The tool builds its request from named args only
|
||||
|
||||
The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
The `BashExecRequest` seam carries optional `stdin` and `env`, used by trusted in-process plugins. This tool does **not** expose or forward them: it builds requests from named command/workdir/timeout/signal/sandbox fields only. This is not a trust boundary; the local executor's ambient credential scrub is the security control.
|
||||
|
||||
## Permissions and escalation
|
||||
|
||||
Commands run with the executor's full authority unless a sandboxing executor ([`dsh-bash-sandbox`](../bash-sandbox/)) confines them — the deny-only sandbox reports denials as result facts, rendered here as the denial marker; per-call allow/deny/ask policy is the `tools/pre-execute` waterfall (see docs/architecture.md).
|
||||
|
||||
On top of a denial sits the escalation gate ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): an escalating call (`sandbox_permissions` + `justification`) resolves [`ctx.approval`](../../ui/user-approval/README.md) BEFORE anything executes — `allowed-once` stamps the granted mode onto the bash request as the seam-level `sandboxMode` override (that one call runs, classifies, and reports under the wider mode; its neighbors keep the session's effective mode), while `rejected`/`cancelled`/`unavailable` and the no-service / no-agent paths each fail closed with their own error text and execute nothing. The seam is consumed opportunistically (`ctx.get('approval')`, the dsh-tools ask-routing pattern); the grant is consumed by the very call that asked, and nothing is stored. The static description teaches — and a denied result itself prompts, via the escalation-available marker appended exactly when the fields are advertised — the SAME-TURN flow: on a denial a wider mode would cure, retry the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification` immediately, without detouring through chat (the approval prompt IS the user's consent); never speculatively — an escalation is grounded in a real denial (up-front only when the session already denied the same access), a prompt-stated approvals-disabled policy turns the exception off entirely, and a rejected escalation is final for that command.
|
||||
Escalating bash calls resolve `ctx.approval` before execution. `allowed-once` applies the requested mode only to that call; rejection, cancellation, unavailability, or missing approval context executes nothing and returns a distinct error. On a real denial, the model may retry the same command once in the same turn with the narrowest sufficient mode and justification; the approval prompt itself is the consent step. Escalation is never speculative, and a disabled or rejected approval is final. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns the rationale.
|
||||
|
||||
## Per-session mode switching
|
||||
|
||||
Under a sandboxing executor this plugin makes the session's standing mode override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); the `bash/sandbox-mode` fold owned by [`dsh-bash`](../bash/README.md)) real at EXECUTION: every call is stamped `escalation grant > session override > undefined` onto `BashExecRequest.sandboxMode`; without either, the executor's `resolve()` applies its configured default. Nothing is stamped under a non-sandboxing executor (nothing would honor it) or for an agent-less caller (no session to fold). The prompt deliberately does NOT state the mode and a switch is not narrated: a standing declaration teaches the model to refuse preemptively, while the denial marker already names the mode the command ran under exactly when the boundary is hit — behavior, not belief, carries the state.
|
||||
For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. Neither the prompt nor a switch notice announces the standing mode; denial results report the effective mode when the boundary matters. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -76,7 +62,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor
|
||||
|
||||
### Tool schemas
|
||||
|
||||
**What the model sees**: The model sees the generated [`bash`, `bash_output`, and `bash_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `sandbox_permissions` and `justification` augment `bash` only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definitions for that agent.
|
||||
**What the model sees**: The model sees the generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `run_in_background` appears only when this producer enables it; `sandbox_permissions` and `justification` appear only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definition for that agent.
|
||||
|
||||
**Token effect**: Fixed schema cost on every request where the tools are visible; sandbox support adds the escalation fields and its conditional description paragraph.
|
||||
|
||||
@@ -88,19 +74,18 @@ Check the [exit code: N] marker on every bash result; investigate failures befor
|
||||
|
||||
### Background task context and results
|
||||
|
||||
**What the model sees**: Start returns exactly `started background task <taskId>`. Completion injects exactly `background bash task <taskId> finished <status>. Read its output with bash_output.` Reads return only the data-dependent delta or `(no new output)`, optionally `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`, then exactly one of `[status: running]`, `[status: killed]`, `[status: killed by <signal>]`, or `[status: completed, exit code: <exitCode>]`. Kill returns `killed background task <taskId>` or `task <taskId> had already finished`.
|
||||
**What the model sees**: Start returns exactly `started background task <taskId>`. This producer supplies incremental process output, optional `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`, sandbox facts, and terminal detail such as `exit code: <exitCode>` or `signal: <signal>` to the generic task runtime. [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) owns the visible status line, completion notice, listing, and cancellation response.
|
||||
|
||||
**Token effect**: Start and status text is small; deltas are data-dependent. The completion notice and every tool result are retained until compaction, but polling does not repeat already-delivered output.
|
||||
**Token effect**: The start acknowledgement is small and retained; collected output is data-dependent and bounded by the executor's stream buffers. Consuming reads do not repeat prior output.
|
||||
|
||||
### Tool errors
|
||||
|
||||
**What the model sees**: Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `invalid task_id: expected a string, got <value>`, `task <taskId> belongs to another session`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
|
||||
**What the model sees**: Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
|
||||
|
||||
**Token effect**: Only the failing call adds these retained tokens; a rejected escalation does not add command output because the command does not run.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual.
|
||||
- **The bash tools opt out of `timeout-policy` budgets** — `bash` keeps the executor-owned `BASH_TIMEOUT` path and `bash_output`/`bash_kill` declare no budget, per [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
|
||||
- **Completion notices do not wake an idle agent** — they become durable context for the next request; a caller needing progress now must poll `bash_output` or send another message.
|
||||
- **Tasks started outside an agent have no ownership fence** — their predictable ids are readable and killable by any caller; only agent-started tasks carry a session owner token.
|
||||
- **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
|
||||
- **Background processes have no executor timeout** — callers must use `task_kill`, or rely on owner/service disposal, when work no longer matters.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-bash",
|
||||
"description": "Model-facing bash tools (bash, bash_output, bash_kill) over the DeepSeek Harness bash executor seam",
|
||||
"description": "Model-facing bash tool with optional generic background-task and sandbox-escalation support",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -28,21 +28,25 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
27
packages/bash/tool-bash/src/background.ts
Normal file
27
packages/bash/tool-bash/src/background.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Generic-task adaptation for background bash process handles.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-bash/background
|
||||
*/
|
||||
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/**
|
||||
* Map a settled background process onto the generic task-outcome vocabulary:
|
||||
* `killed` stays `killed` (detail: the signal when one is known), everything
|
||||
* else is `completed` with the exit code as detail. A nonzero command exit is
|
||||
* reported, not failed, exactly like the foreground rendering.
|
||||
* @param proc - the settled process handle.
|
||||
* @returns the outcome for the `ctx.tasks` registration.
|
||||
*/
|
||||
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
|
||||
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
|
||||
// infrastructure-failure outcome, then map spawn failures and
|
||||
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
|
||||
// failure with a signal-less kill and a runner failure with an ordinary
|
||||
// wrapper exit; real nonzero command exits must remain `completed`.
|
||||
if (proc.status === 'killed') {
|
||||
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
|
||||
}
|
||||
return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
|
||||
}
|
||||
@@ -1,89 +1,52 @@
|
||||
/**
|
||||
* The model-facing bash tools: `bash`, `bash_output`, `bash_kill`. Pure
|
||||
* schema + text shaping — every process concern lives behind the `ctx.bash`
|
||||
* executor seam (`@deepseek-ai/dsh-bash`), so sandbox/permission/remote
|
||||
* executor implementations swap in without touching what the model sees.
|
||||
*
|
||||
* Background notifications: when a background task completes, a short notice
|
||||
* is injected into the owning agent's session (`agent.inject()` — the
|
||||
* documented context seam). Injection is durable context for the NEXT model
|
||||
* request, not a wake-up: an idle agent stays idle until something sends a
|
||||
* message, which is why the tool descriptions tell the model to poll with
|
||||
* `bash_output`.
|
||||
*
|
||||
* Task ownership: a background task's OWNER is an opaque token — the owning
|
||||
* agent's shared `id` — passed to the executor at spawn
|
||||
* (`resolve({ …, owner })`) and stored ON THE TASK inside the executor
|
||||
* (`@deepseek-ai/dsh-bash`'s `ownerOf(id)` seam), NOT in a plugin-local map.
|
||||
* `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token
|
||||
* and reject a task owned by a DIFFERENT session (`owner !== undefined && owner
|
||||
* !== caller`); an unowned task (no token — started by a non-agent caller) is
|
||||
* open to anyone. Task ids are global and predictable (`bash-1`, …); under
|
||||
* multi-session ACP (RFC 011) this token check is the fence that stops one
|
||||
* session's agent from reading or killing another session's background task.
|
||||
*
|
||||
* Storing the token on the task in the EXECUTOR (disposed with the `dsh-bash`
|
||||
* fiber), rather than in this plugin, is what makes ownership survive a
|
||||
* `tool-bash` HMR reload — a reload that reset a plugin-local map would orphan
|
||||
* a task spawned before it. (The `onTaskDone` listener is still effect-scoped
|
||||
* to this plugin's `apply`, so a
|
||||
* completion landing during the reload gap still drops its one notice — the
|
||||
* pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
|
||||
*
|
||||
* Commands run with the executor's full authority unless a sandboxing
|
||||
* executor (`@deepseek-ai/dsh-bash-sandbox`) confines them; per-call
|
||||
* allow/deny/ask policy is the `tools/pre-execute` waterfall — see
|
||||
* docs/architecture.md § Extension And Composition. Under a sandboxing
|
||||
* executor this plugin also advertises the ESCALATION surface
|
||||
* (`sandbox_permissions`/`justification` — the sandbox RFC § Escalation,
|
||||
* docs/rfc/implemented/feature/2026-07-06-sandbox.md): a command the
|
||||
* sandbox denied may be retried once under a strictly wider mode, resolved
|
||||
* through `ctx.approval` BEFORE anything executes and failing closed on every
|
||||
* unanswerable path. The fields exist only when the mounted executor reports
|
||||
* a confining default (`ctx.bash.sandboxMode`) — a lever is never advertised
|
||||
* that the composition cannot honor.
|
||||
*
|
||||
* Per-session mode switching (the sandbox RFC § Per-session mode switching): a session may carry a
|
||||
* standing sandbox-mode override — the `bash/sandbox-mode` event fold from
|
||||
* `@deepseek-ai/dsh-bash` — which this plugin makes real at EXECUTION: each
|
||||
* call is stamped `escalation grant > session override > executor default`.
|
||||
* The prompt deliberately does NOT state the mode and no switch is narrated:
|
||||
* the model learns the boundary from the denial marker (which names the mode
|
||||
* it ran under) exactly when it matters, instead of preemptively refusing
|
||||
* work a standing declaration would discourage.
|
||||
* Model-facing `bash` tool over the `ctx.bash` executor seam. Background calls
|
||||
* register process handles with `ctx.tasks`; their work uses task cancellation
|
||||
* rather than the tool-call signal after an id is returned.
|
||||
*
|
||||
* TODO(permissions): deployment policy belongs in `tools/pre-execute` and
|
||||
* sandboxing executors; see docs/architecture.md § Extending The Harness.
|
||||
* @module @deepseek-ai/dsh-tool-bash
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
// Side-effect type import: declaration-merges `ctx.approval`, consumed
|
||||
// opportunistically by the escalation gate (`ctx.get('approval')` — the seam
|
||||
// stays optional at runtime, same pattern as dsh-tools' ask routing).
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashTask } from '@deepseek-ai/dsh-bash'
|
||||
import { parseExitStatus, renderResult } from './render.ts'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from './background.ts'
|
||||
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
|
||||
|
||||
export const name = 'tool-bash'
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
|
||||
/**
|
||||
* Validate the constraints the SchemaSpec can't express. `defineTool` now
|
||||
* validates parsed args against the SchemaSpec before `execute` runs (the
|
||||
* arg-validation RFC), so type/required/enum checks are already done and `args`
|
||||
* is the validated `InferArgs` shape here. What remains are value constraints
|
||||
* the DSL has no vocabulary for: non-empty strings, a positive finite timeout,
|
||||
* and the escalation pairing (`sandbox_permissions` and `justification` travel
|
||||
* together — an approval prompt without a reason, or a reason driving nothing,
|
||||
* is a malformed ask).
|
||||
*/
|
||||
/** Configures whether the model may background commands. */
|
||||
export interface Config {
|
||||
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
|
||||
enableRunInBackground?: boolean
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
})
|
||||
|
||||
/** Parsed tool args; execute validates value constraints absent from SchemaSpec. */
|
||||
interface BashToolArgs {
|
||||
command: string
|
||||
description: string
|
||||
timeoutMs?: number
|
||||
workdir?: string
|
||||
run_in_background?: boolean
|
||||
sandbox_permissions?: string
|
||||
justification?: string
|
||||
}
|
||||
|
||||
function validateBashArgs(args: BashToolArgs): void {
|
||||
if (args.command.trim().length === 0) {
|
||||
throw new Error('invalid command: expected a non-empty string')
|
||||
@@ -105,73 +68,23 @@ function validateBashArgs(args: BashToolArgs): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an empty `task_id`. Type and presence are guaranteed by the
|
||||
* SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the
|
||||
* DSL can't express, is left to check here.
|
||||
*/
|
||||
function validateTaskId(value: string): BashTaskId {
|
||||
if (value.length === 0) {
|
||||
throw new Error(`invalid task_id: expected a string, got ${JSON.stringify(value)}`)
|
||||
}
|
||||
return BashTaskId(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bash tool's validated argument shape — the base parameters plus the two
|
||||
* escalation fields, which are ADVERTISED only when the mounted executor
|
||||
* reports a confining default mode (absent from the schema otherwise, so the
|
||||
* SchemaSpec validator rejects them before `execute` ever sees one).
|
||||
*/
|
||||
interface BashToolArgs {
|
||||
command: string
|
||||
description: string
|
||||
timeoutMs?: number
|
||||
workdir?: string
|
||||
run_in_background?: boolean
|
||||
sandbox_permissions?: string
|
||||
justification?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The strictly-wider table: what a call whose effective mode is the key may
|
||||
* escalate TO. Checked at EXECUTION, never baked into the schema — the
|
||||
* schema's enum is {@link ESCALATION_TARGETS}, because schemas are
|
||||
* registry-global while the effective mode is per-call truth.
|
||||
*/
|
||||
const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
|
||||
'read-only': ['workspace-write', 'danger-full-access'],
|
||||
'workspace-write': ['danger-full-access'],
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed escalation-target vocabulary — every mode a call could ever
|
||||
* escalate TO (`read-only` is the floor; nothing escalates to it). Advertised
|
||||
* whenever the mounted executor confines: cutting the enum down to the modes
|
||||
* wider than the executor's DEFAULT would strand a session whose effective
|
||||
* mode sits below it (a `danger-full-access` default would advertise nothing
|
||||
* while a narrower-switched session stays confined with no lever).
|
||||
*/
|
||||
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
* The bash tool's static description. The base text is byte-stable regardless
|
||||
* of composition (it is part of the pinned snapshot header); the escalation
|
||||
* teaching rides only when the mounted executor actually honors the fields —
|
||||
* it names the ONE sanctioned exception to the base text's "do not retry
|
||||
* another way" rule. Its deference clause ("If the session states approval
|
||||
* prompts are disabled…") points at the approval plugin's never-policy prompt
|
||||
* sentence by meaning, not by parsed wording — a rendezvous kept working by
|
||||
* that sentence continuing to open with the approvals-disabled claim.
|
||||
*/
|
||||
function bashDescription(escalationModes: readonly SandboxMode[]): string {
|
||||
function bashDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string {
|
||||
const background = backgroundEnabled
|
||||
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
|
||||
: 'Background execution is not available; long-running commands must finish within the timeout.'
|
||||
const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
|
||||
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
|
||||
+ 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
|
||||
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). '
|
||||
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
||||
+ 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
|
||||
+ 'poll it with `bash_output` and stop it with `bash_kill`.'
|
||||
+ background
|
||||
if (escalationModes.length === 0) return base
|
||||
return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the '
|
||||
+ 'marker rather than assuming the denial. When a command is denied and a wider mode would let it '
|
||||
@@ -186,35 +99,14 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string {
|
||||
+ 'it — but it does not forbid attempting or escalating other commands later.'
|
||||
}
|
||||
|
||||
// Pure tool-owned presentation used for both live events and replay.
|
||||
|
||||
/**
|
||||
* Pending-state presentation for a `bash` call. The TITLE is the exact `command`
|
||||
* — a `kind: 'execute'` card is rendered as a terminal whose header label IS the
|
||||
* title, and an execute-kind card HIDES `rawInput` (Zed: `should_show_raw_input
|
||||
* = !is_terminal_tool`), so the command must BE the title to be seen. This
|
||||
* mirrors the reference ACP adapters (claude-agent-acp, codex-acp), which both
|
||||
* use the bare command as an execute tool's title. The model-written
|
||||
* `description` (a readable summary) rides as a `content` text block shown ABOVE
|
||||
* the card. (Note: claude-agent-acp DROPS the description in terminal mode and
|
||||
* shows only the card; surfacing it as a content block is a deliberate
|
||||
* divergence here — we keep the human summary visible alongside the card.)
|
||||
* `rawInput` still carries the bare command for non-execute UIs that DO render it.
|
||||
*
|
||||
* `terminal` marks the call so a capable UI renders a TERMINAL card — but ONLY a
|
||||
* FOREGROUND run is a terminal: a `run_in_background` call returns a task id
|
||||
* immediately (it never streams a terminal; its output is polled via
|
||||
* `bash_output`), so it is NOT marked terminal and renders as an ordinary
|
||||
* execute card. For a foreground run the `terminal.cwd` (header) is the model
|
||||
* `workdir` when given — ABSOLUTE as-is, RELATIVE for the UI bridge to resolve
|
||||
* against the session cwd; when omitted the bridge fills the session workspace
|
||||
* cwd (this PURE presenter, args only, can't see it).
|
||||
* Present foreground calls as terminals and background starts as generic cards.
|
||||
* The command remains the title on both paths; foreground cwd is passed through
|
||||
* for the bridge to resolve, while background descriptions remain card content.
|
||||
*/
|
||||
type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean }
|
||||
|
||||
function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView {
|
||||
// A background start is not an interactive terminal — a generic execute card
|
||||
// with the command as rawInput and the description as a content block.
|
||||
if (args.run_in_background === true) {
|
||||
return {
|
||||
card: 'generic',
|
||||
@@ -224,8 +116,6 @@ function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView
|
||||
content: [{ type: 'text', text: args.description }],
|
||||
}
|
||||
}
|
||||
// A foreground run IS a terminal: the command titles the card, the description
|
||||
// renders above it, and the cwd (when the model gave a workdir) heads it.
|
||||
return {
|
||||
card: 'terminal',
|
||||
title: args.command,
|
||||
@@ -235,57 +125,24 @@ function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed-state presentation for a `bash` call. Two parallel renderings of the
|
||||
* same output: `terminal.output` for a UI that shows a terminal card (the run's
|
||||
* stdout/stderr + status markers, exactly as the model sees them — the RAW text,
|
||||
* newlines preserved, since a terminal renderer relies on exact bytes), and a
|
||||
* fenced ```console `content` block as the fallback for a UI without terminal
|
||||
* support (the fences are a UI-only affordance, so they live here, not in the
|
||||
* model-facing result; the fenced body is trimmed of trailing blank lines for a
|
||||
* tidy block). A capable UI also gets an exit-status pill from `terminal.exitCode`
|
||||
* / `terminal.signal`, parsed from the status markers `renderResult` appended.
|
||||
*
|
||||
* Terminal output/exit is suppressed for results that are NOT a finished
|
||||
* foreground run: a `run_in_background` start (`isBackground` — the text is a
|
||||
* task-id ack, not a streamed run) and an `isError` result (a spawn failure or
|
||||
* abort — there is no real process exit to pill, and the body is an error
|
||||
* message, not `renderResult` output, so parsing it would be meaningless). Those
|
||||
* return a `generic` result whose content is the fenced ```console block. A
|
||||
* finished foreground run returns a `terminal` result carrying the RAW output
|
||||
* and the parsed exit status; the BRIDGE derives the fenced fallback from
|
||||
* `output` for a UI without terminal support, so the tool does not double-encode
|
||||
* it. A non-text result (unexpected for bash) falls through to `undefined`.
|
||||
* Present completed foreground output as a terminal; background acknowledgements
|
||||
* and execution errors use generic fenced output without an exit-status pill.
|
||||
*/
|
||||
function presentBashResult(args: unknown, result: ToolResult): ToolResultView | undefined {
|
||||
const block = result.content.length === 1 ? result.content[0] : undefined
|
||||
if (block === undefined || block.type !== 'text') return undefined
|
||||
const raw = block.text
|
||||
const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true
|
||||
// A background ack or an errored run is not a real terminal exit: render the
|
||||
// fenced ```console fallback as generic content (no exit pill).
|
||||
// Background acknowledgements and errors have no terminal exit status.
|
||||
if (isBackground || result.isError) {
|
||||
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
|
||||
}
|
||||
// A finished foreground run: RAW output + parsed exit for the terminal card.
|
||||
// The bridge derives the no-capability fenced fallback from `output`.
|
||||
return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
|
||||
}
|
||||
|
||||
/** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
|
||||
function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView {
|
||||
return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the working directory for a bash call. Precedence: an explicit model
|
||||
* `workdir` wins; otherwise default to the calling agent's session cwd
|
||||
* (`session.header.cwd`) so each ACP session's commands run in ITS workspace,
|
||||
* not the server's launch dir. A RELATIVE model `workdir` is resolved against
|
||||
* the session cwd (the tool tells the model to pass `workdir` instead of `cd`,
|
||||
* so a relative one should be relative to the session's root, not `process.cwd()`).
|
||||
* Returns `undefined` when neither is available (no agent / headerless session /
|
||||
* no session cwd) — the executor then applies its own config/`process.cwd()`
|
||||
* default, preserving today's non-ACP behavior.
|
||||
* Resolve an explicit workdir first, making a relative one session-cwd-relative;
|
||||
* otherwise use the session cwd and leave executor defaulting as the fallback.
|
||||
*/
|
||||
function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
|
||||
const sessionCwd = exec.agent?.session.header.cwd
|
||||
@@ -296,124 +153,18 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
|
||||
return modelWorkdir
|
||||
}
|
||||
|
||||
/** Status line for background task reads. */
|
||||
function statusLine(task: BashTask): string {
|
||||
switch (task.status) {
|
||||
case 'running': return '[status: running]'
|
||||
case 'killed': return `[status: killed${task.signal !== null ? ` by ${task.signal}` : ''}]`
|
||||
case 'completed': return `[status: completed, exit code: ${task.exitCode ?? 0}]`
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// The bash tools' cross-call HABIT, which the per-tool descriptions cannot
|
||||
// carry (they describe one call each): the exit-code marker is only useful
|
||||
// if the model actually checks it every time.
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:bash',
|
||||
order: 105,
|
||||
text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
|
||||
})
|
||||
|
||||
/**
|
||||
* The caller's owner TOKEN — the owning agent's shared registry/session id,
|
||||
* or `undefined` for a non-agent caller. Agent and Session deliberately have
|
||||
* one live identity; workdir remains separate session metadata.
|
||||
*/
|
||||
const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined =>
|
||||
exec.agent ? OwnerToken(exec.agent.id) : undefined
|
||||
|
||||
/**
|
||||
* Authorize a `bash_output`/`bash_kill` call against the task's stored owner
|
||||
* token. Rejects when the task HAS an owner and it differs from the caller's
|
||||
* token — using `!== undefined` semantics, NOT truthiness, so an empty-string
|
||||
* token is still a real owner (never treated as unowned). An unowned task
|
||||
* (`ownerOf` returns `undefined`) is allowed; a truly unknown id is also
|
||||
* `undefined` here and then fails loudly at the subsequent
|
||||
* `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller
|
||||
* (`callerToken` undefined) cannot match an owned task and is rejected.
|
||||
*/
|
||||
const assertTaskAccess = (taskId: BashTaskId, exec: { agent?: Agent }): void => {
|
||||
const owner = ctx.bash.ownerOf(taskId)
|
||||
if (owner !== undefined && owner !== callerToken(exec)) {
|
||||
throw new Error(`task ${taskId} belongs to another session`)
|
||||
}
|
||||
}
|
||||
|
||||
// Background completion → inject a notice into the owning agent's session.
|
||||
// Find the live agent by its shared registry/session token, read
|
||||
// opportunistically with `ctx.get('agents')` (NOT `ctx.agents`/static inject):
|
||||
// this listener runs from `task.done.then` on the bash fiber — a foreign
|
||||
// fiber — where the `ctx.agents` property proxy would throw through the
|
||||
// traceable shadow; `ctx.get(name)` is the topology-independent lookup. No
|
||||
// registry mounted (`undefined`) → drop the notice.
|
||||
ctx.bash.onTaskDone((task) => {
|
||||
const ownerToken = ctx.bash.ownerOf(task.id)
|
||||
if (ownerToken === undefined) return
|
||||
const agent = ctx.get('agents')?.list().find(a => OwnerToken(a.id) === ownerToken)
|
||||
if (!agent) return
|
||||
try {
|
||||
agent.inject(
|
||||
[{ type: 'text', text: `background bash task ${task.id} finished ${statusLine(task)}. Read its output with bash_output.` }],
|
||||
{ source: { kind: 'plugin', plugin: 'tool-bash' } },
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// The ONE expected failure: the agent was disposed between task
|
||||
// completion and this injection (Agent.inject throws
|
||||
// `agent "<id>" is disposed`). That race is benign — drop the notice.
|
||||
// Anything else is a real bug and must surface, not be swallowed.
|
||||
if (error instanceof Error && error.message.includes('is disposed')) return
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
// The escalation surface exists whenever the mounted executor confines.
|
||||
// Its enum is the closed target vocabulary, deliberately NOT cut down by
|
||||
// the configured default: a session may switch to a narrower effective mode
|
||||
// while sharing this globally registered schema. Strict widening therefore
|
||||
// belongs to the per-call check below. An executor swap restarts this fiber
|
||||
// (static inject) and re-registers the schema.
|
||||
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
|
||||
|
||||
/**
|
||||
* The session's standing mode override for an ordinary (non-escalating)
|
||||
* call: the `bash/sandbox-mode` fold of the calling agent's log, stamped
|
||||
* onto the request so execution follows the fold without stating it in the
|
||||
* prompt. Weakest precedence — an escalation grant (freshly approved for
|
||||
* exactly this call) outranks it, and without either the executor's
|
||||
* `resolve()` applies its configured default. Undefined for a non-sandboxing
|
||||
* executor (nothing honors it) and for agent-less callers (no session to
|
||||
* fold).
|
||||
*/
|
||||
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
|
||||
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
|
||||
|
||||
/**
|
||||
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
|
||||
* anything executes. Returns the granted mode to stamp onto the bash
|
||||
* request; throws the distinct fail-closed text for every other path (no
|
||||
* service composed, an agent-less execution, a rejection, a cancellation,
|
||||
* an unanswerable ask) — the registry turns the throw into this call's
|
||||
* isError result, and nothing has run. The seam is consumed
|
||||
* opportunistically (`ctx.get`, the dsh-tools ask-routing pattern), so a
|
||||
* deployment without it degrades per call, never at registration.
|
||||
*/
|
||||
const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
|
||||
// Schema validation only checks ADVERTISED keys, so an unadvertised
|
||||
// `sandbox_permissions` (no sandboxing executor) still reaches execute — reject it here so a
|
||||
// human is never prompted to "escalate" a sandbox that is not there. When
|
||||
// the fields ARE advertised, the registry's SchemaSpec enum has already
|
||||
// pinned `mode` to this ladder for every caller.
|
||||
if (escalationModes.length === 0) {
|
||||
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
||||
}
|
||||
// Strict widening is an EXECUTION check against the call's effective
|
||||
// mode — session override ?? executor default, the same fold ordinary
|
||||
// calls are stamped with — deliberately not a schema constraint (the
|
||||
// enum is the closed target vocabulary; the effective mode is per-call
|
||||
// truth). A non-widening request fails closed here and never prompts a
|
||||
// human.
|
||||
const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode
|
||||
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
|
||||
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
|
||||
@@ -429,14 +180,10 @@ export function apply(ctx: Context): void {
|
||||
agent: exec.agent,
|
||||
toolName: 'bash',
|
||||
callId: exec.callId,
|
||||
// Self-contained for the audit trail: approval/asked stores this
|
||||
// reason, and the target mode is part of the grant's identity.
|
||||
reason: `escalate sandbox to ${mode}: ${justification}`,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
})
|
||||
switch (outcome) {
|
||||
// The SchemaSpec enum already pinned `mode` to the closed target
|
||||
// vocabulary; the per-call check above proved it is strictly wider.
|
||||
case 'allowed-once': return mode as SandboxMode
|
||||
case 'rejected': throw new Error(`the user rejected escalating this command to "${mode}"`)
|
||||
case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
|
||||
@@ -445,9 +192,16 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-call guidance belongs in the prompt rather than one-call schema prose.
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:bash',
|
||||
order: 105,
|
||||
text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bash',
|
||||
description: bashDescription(escalationModes),
|
||||
description: bashDescription(backgroundEnabled, escalationModes),
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The bash command to execute.' },
|
||||
description: {
|
||||
@@ -459,117 +213,69 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
|
||||
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
|
||||
run_in_background: { type: 'boolean', description: 'Run in the background and return a task id immediately. No timeout applies.' },
|
||||
...backgroundEnabled ? {
|
||||
run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' },
|
||||
} : {},
|
||||
...escalationModes.length > 0 ? {
|
||||
sandbox_permissions: {
|
||||
type: 'string' as const,
|
||||
enum: [...escalationModes],
|
||||
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry '
|
||||
+ 'of a command the sandbox just denied; requires justification and user approval.',
|
||||
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.',
|
||||
},
|
||||
justification: {
|
||||
type: 'string' as const,
|
||||
description: 'Required with sandbox_permissions: one sentence for the user explaining '
|
||||
+ 'why this exact command needs the wider access.',
|
||||
description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.',
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
async execute(args: BashToolArgs, exec) {
|
||||
validateBashArgs(args)
|
||||
// `description` is display/logging metadata only (surfaced to UIs via
|
||||
// the tool/call session event); it is intentionally NOT forwarded to
|
||||
// ctx.bash and has no effect on execution.
|
||||
// An escalating call resolves approval BEFORE anything executes; every
|
||||
// non-grant outcome throws its distinct error text and runs nothing.
|
||||
// (validateBashArgs pinned the pairing, so the double narrow is exact.)
|
||||
// An ordinary call carries the session's standing override instead —
|
||||
// grant > session override > executor default (see sessionOverride).
|
||||
// Description is display metadata; workdir defaults to the caller's session.
|
||||
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
||||
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
|
||||
: sessionOverride(exec)
|
||||
// Default the workdir to the calling agent's session cwd so each ACP
|
||||
// session runs in its own workspace (see resolveWorkdir); an explicit
|
||||
// model workdir still wins.
|
||||
const workdir = resolveWorkdir(args.workdir, exec)
|
||||
const request = {
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
...sandboxMode !== undefined ? { sandboxMode } : {},
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// Stamp the owner token (the agent's session id) onto the spec so the
|
||||
// executor stores it on the task — the isolation fence for bash_output/
|
||||
// bash_kill. Foreground runs pass no owner (they finish inline; nothing
|
||||
// to fence).
|
||||
const task = ctx.bash.start(ctx.bash.resolve({ ...request, owner: callerToken(exec) }))
|
||||
return [{ type: 'text', text: `started background task ${task.id}` }]
|
||||
// Undeclared keys are allowed, so schema omission also needs enforcement.
|
||||
if (!backgroundEnabled) {
|
||||
throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)')
|
||||
}
|
||||
const tasks = ctx.get('tasks')
|
||||
if (tasks === undefined) {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// Reject pre-start cancellation; returned tasks use their own lifecycle.
|
||||
if (exec.signal?.aborted) throw new Error('command aborted')
|
||||
// Task preflight finishes before the starter can spawn a process.
|
||||
const id = tasks.start({
|
||||
kind: 'bash',
|
||||
label: args.command,
|
||||
...exec.agent ? { owner: exec.agent } : {},
|
||||
run: () => {
|
||||
const proc = ctx.bash.start(ctx.bash.resolve(request))
|
||||
return {
|
||||
cancel: () => void proc.kill(),
|
||||
done: proc.done.then(() => processOutcome(proc)),
|
||||
readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
|
||||
}
|
||||
},
|
||||
})
|
||||
return [{ type: 'text', text: `started background task ${id}` }]
|
||||
}
|
||||
const result = await ctx.bash.run(ctx.bash.resolve(request))
|
||||
const result = await ctx.bash.run(ctx.bash.resolve({
|
||||
...request,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
}))
|
||||
if (result.aborted) throw new Error('command aborted')
|
||||
return [{ type: 'text', text: renderResult(result, escalationModes) }]
|
||||
},
|
||||
presentCall: presentBashCall,
|
||||
presentResult: presentBashResult,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bash_output',
|
||||
description: 'Read new output from a background bash task started with `bash` + `run_in_background`. '
|
||||
+ 'Returns only output produced since the previous bash_output call, plus the task status. '
|
||||
+ 'Tasks keep running while you do other work; poll again later for more output.',
|
||||
parameters: {
|
||||
task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
|
||||
},
|
||||
// execute is synchronous (registry reads + string shaping) but the
|
||||
// ToolDefinition contract wants a Promise — hence resolve(), not async.
|
||||
execute(args, exec) {
|
||||
const id = validateTaskId(args.task_id)
|
||||
assertTaskAccess(id, exec)
|
||||
const read = ctx.bash.readOutput(id)
|
||||
let text = read.delta.length > 0 ? read.delta : '(no new output)'
|
||||
if (read.lossy) {
|
||||
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p): p is string => p !== undefined)
|
||||
const fullOutput = paths.length > 0 ? paths.join(', ') : '(unavailable)'
|
||||
text += `\n[some output was dropped from memory; full output: ${fullOutput}]`
|
||||
}
|
||||
text += `\n${statusLine(read.task)}`
|
||||
if (read.task.sandbox?.runnerFailed) {
|
||||
// The sandbox RUNNER itself failed — the command never ran. The
|
||||
// foreground path surfaces this as the structured SANDBOX_UNAVAILABLE
|
||||
// error; a settled task's read carries the marker instead.
|
||||
text += `\n[sandbox: the sandbox runner itself failed under ${read.task.sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`
|
||||
} else if (read.task.sandbox?.denied) {
|
||||
// Mirrors the foreground result marker (and its same-turn escalation
|
||||
// hint). Background denials are only classifiable once the task
|
||||
// settles (the classifier needs the whole stderr), so the marker
|
||||
// rides every read that sees the settled task.
|
||||
text += `\n[sandbox: file access denied under ${read.task.sandbox.mode} mode]`
|
||||
if (escalationModes.length > 0) {
|
||||
text += '\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]'
|
||||
}
|
||||
}
|
||||
return Promise.resolve([{ type: 'text', text }])
|
||||
},
|
||||
presentCall: args => presentTaskCall('Read output from', args),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bash_kill',
|
||||
description: 'Ask the executor to kill a running background bash task by task id.',
|
||||
parameters: {
|
||||
task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
|
||||
},
|
||||
execute(args, exec) {
|
||||
const id = validateTaskId(args.task_id)
|
||||
assertTaskAccess(id, exec)
|
||||
const killed = ctx.bash.kill(id)
|
||||
return Promise.resolve([{
|
||||
type: 'text',
|
||||
text: killed ? `killed background task ${id}` : `task ${id} had already finished`,
|
||||
}])
|
||||
},
|
||||
presentCall: args => presentTaskCall('Kill', args),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @module @deepseek-ai/dsh-tool-bash/render
|
||||
*/
|
||||
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashProcessRead, BashRunResult, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
||||
@@ -15,7 +15,7 @@ function streamText(output: CollectedOutput): string {
|
||||
|
||||
/**
|
||||
* Shape one finished run into the text the model sees: stdout, then a marked
|
||||
* stderr section, then exit-status markers. Non-zero exits are REPORTED, not
|
||||
* stderr section, then exit-status markers. Non-zero exits are reported, not
|
||||
* errored — the model decides how to react; only infrastructure failures
|
||||
* (spawn errors, aborts) surface as isError results.
|
||||
* @param result - the completed foreground run from the executor.
|
||||
@@ -40,23 +40,15 @@ export function renderResult(
|
||||
if (body.length === 0) body = '(no output)'
|
||||
|
||||
const markers: string[] = []
|
||||
// The sandbox marker precedes the exit-status markers so `[exit code: N]`
|
||||
// stays the LAST line (exitStatus() anchors its parse there). Denial is a
|
||||
// reported fact like timeout: the model decides how to react.
|
||||
// Keep the exit marker last because parseExitStatus anchors there.
|
||||
if (result.sandbox?.denied) {
|
||||
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
|
||||
// The same-turn nudge lives at the decision point: only when this
|
||||
// composition advertises the fields (a lever is never hinted that the
|
||||
// schema does not offer), and inside the sandbox marker family so the
|
||||
// exit-code marker stays the last line.
|
||||
// Hint only when the composition exposes escalation, before the final exit marker.
|
||||
if (escalationModes.length > 0) {
|
||||
markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
|
||||
}
|
||||
}
|
||||
// Timeout is reported independently of how the process actually ended: a
|
||||
// command can trap SIGTERM and exit 0 after our timer fired (e.g.
|
||||
// `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 /
|
||||
// signal:null — the model must still see that the command was cut short.
|
||||
// A command may trap SIGTERM and exit 0 after timeout; still report interruption.
|
||||
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
|
||||
if (result.signal !== null) {
|
||||
markers.push(`[killed by signal: ${result.signal}]`)
|
||||
@@ -69,6 +61,38 @@ export function renderResult(
|
||||
return body + markers.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape one background-process read into the `task_output` delta the model
|
||||
* sees: the incremental delta, plus the lossy-read notice (with full-stream
|
||||
* spill paths) when in-memory truncation dropped unread bytes. Empty-delta
|
||||
* rendering (`(no new output)`) is the generic control surface's job.
|
||||
* @param read - one incremental read from the process handle.
|
||||
* @param sandbox - settled sandbox facts, when this was a confined process.
|
||||
* @param escalationModes - escalation targets advertised by this composition.
|
||||
* @returns the delta text with any loss or sandbox notice appended.
|
||||
*/
|
||||
export function renderProcessRead(
|
||||
read: BashProcessRead,
|
||||
sandbox?: BashSandboxInfo,
|
||||
escalationModes: readonly SandboxMode[] = [],
|
||||
): string {
|
||||
const notices: string[] = []
|
||||
if (read.lossy) {
|
||||
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined)
|
||||
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
|
||||
}
|
||||
if (sandbox?.runnerFailed) {
|
||||
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
|
||||
} else if (sandbox?.denied) {
|
||||
notices.push(`[sandbox: file access denied under ${sandbox.mode} mode]`)
|
||||
if (escalationModes.length > 0) {
|
||||
notices.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
|
||||
}
|
||||
}
|
||||
if (notices.length === 0) return read.delta
|
||||
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the structured exit status from a rendered {@link renderResult}
|
||||
* string — the inverse of the status markers it appends. A killed marker
|
||||
|
||||
@@ -8,15 +8,17 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* Full-loop integration: a scripted mock model drives the REAL bash tool
|
||||
* through the agent loop, exercising the same seams a live model would
|
||||
* (tool/call + tool/result session events, agent.inject notifications).
|
||||
* (tool/call + tool/result session events, the generic `ctx.tasks` runtime,
|
||||
* agent.inject completion notices).
|
||||
*/
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
@@ -26,6 +28,8 @@ async function harness(adapter: MockAdapter) {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -68,6 +72,16 @@ function resultText(event: SessionEvent): string {
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Poll until `predicate` holds (background settlement races turn end). */
|
||||
async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`condition not met within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
describe('bash tool through the agent loop', () => {
|
||||
it('foreground: model calls bash, sees the result, replies', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
@@ -117,46 +131,41 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(toolResult)).toContain('[exit code: 9]')
|
||||
})
|
||||
|
||||
it('background: start → poll → completion notice lands as context/message', async () => {
|
||||
it('background: start ack → completion notice as context/message → task_output collects it', async () => {
|
||||
// The task id is deterministic (a fresh TaskService counts per kind from 1),
|
||||
// so the script can name `bash-1` without threading a generated id.
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),
|
||||
// Each harness owns a fresh BashLocal service, whose first task id is
|
||||
// deterministically bash-1. Keep the scripted call faithful to what the
|
||||
// model sent; tool arguments are immutable once execution policy begins.
|
||||
toolCallResponse('call-2', 'bash_output', { task_id: 'bash-1' }, undefined),
|
||||
textResponse('Started it in the background.'),
|
||||
toolCallResponse('call-2', 'task_output', { task_id: 'bash-1' }),
|
||||
textResponse('Background task finished.'),
|
||||
])
|
||||
let taskId = ''
|
||||
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-bg'), { model: 'mock' })
|
||||
|
||||
// Capture the generated id so the deterministic fixture is checked against
|
||||
// the real executor instead of silently assuming it.
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'tool/result' && taskId === '') {
|
||||
const match = /task (bash-\d+)/.exec(resultText(event))
|
||||
if (match) taskId = match[1]!
|
||||
}
|
||||
})
|
||||
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(taskId).toBe('bash-1')
|
||||
const firstResult = findEvent(events(agent), 'tool/result')
|
||||
expect(firstResult.data.isError).toBe(false)
|
||||
expect(resultText(firstResult)).toBe('started background task bash-1')
|
||||
|
||||
// Wait for the background task itself (completion may race turn end).
|
||||
const task = ctx.bash.get(BashTaskId(taskId))
|
||||
if (!task) throw new Error(`task ${taskId} not registered`)
|
||||
await task.done
|
||||
|
||||
const log = events(agent)
|
||||
const firstResult = findEvent(log, 'tool/result')
|
||||
expect(resultText(firstResult)).toBe(`started background task ${taskId}`)
|
||||
|
||||
const notice = findEvent(log, 'context/message')
|
||||
// The task settles on its own; the tool-tasks notice listener injects a
|
||||
// durable context/message into the owning agent's session (settlement may
|
||||
// race turn end, so poll for it).
|
||||
await pollUntil(() => events(agent).some(event => event.type === 'context/message'))
|
||||
const notice = findEvent(events(agent), 'context/message')
|
||||
expect(notice.data.content.some(
|
||||
block => block.type === 'text' && block.text.includes(`background bash task ${taskId} finished`),
|
||||
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
|
||||
)).toBe(true)
|
||||
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-bash' })
|
||||
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
|
||||
|
||||
// The next turn collects the output through the generic task tool.
|
||||
agent.send([{ type: 'text', text: 'collect it' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
const readResult = findEvent(events(agent), 'tool/result', 'last')
|
||||
expect(readResult.data.isError).toBe(false)
|
||||
expect(resultText(readResult)).toContain('bg-ok')
|
||||
expect(resultText(readResult)).toContain('[status: completed, exit code: 0]')
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
@@ -26,6 +29,9 @@
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user