mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(bash): generalize managed shell environment
This commit is contained in:
@@ -26,6 +26,7 @@ Composition is preferred over inheritance. `packages/core/` is a repository grou
|
||||
|---|---|---|
|
||||
| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls |
|
||||
| `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution |
|
||||
| `ctx.bashEnv` | [`dsh-tool-bash`](../packages/bash/tool-bash/README.md) | declared, per-execution `DSH_*` environment facts for model bash |
|
||||
| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) |
|
||||
| `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution |
|
||||
| `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events |
|
||||
@@ -144,6 +145,7 @@ New behavior should attach to a documented extension point; changing the shipped
|
||||
| Add a model provider | register an adapter on `ctx.llm` |
|
||||
| Add a model-facing capability | register a tool on `ctx.tools`; schemas flow into prompt assembly |
|
||||
| Add command execution | implement and register a `ctx.bash` backend |
|
||||
| Expose a Harness fact to model bash | register a declared `DSH_*` contributor on `ctx.bashEnv` |
|
||||
| Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events |
|
||||
| Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning |
|
||||
| Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall |
|
||||
|
||||
@@ -51,6 +51,7 @@ flowchart LR
|
||||
pkg_bash_sandbox["bash-sandbox"]
|
||||
pkg_hooks_claude["hooks-claude"]
|
||||
pkg_hooks_codex["hooks-codex"]
|
||||
svc_bashEnv["ctx.bashEnv<br/>Managed bash environment registry"]
|
||||
pkg_sandbox["sandbox"]
|
||||
svc_sandbox["ctx.sandbox<br/>Process-sandbox seam"]
|
||||
pkg_sandbox_local["sandbox-local"]
|
||||
@@ -114,6 +115,7 @@ flowchart LR
|
||||
pkg_subagent_mock --> svc_subagents
|
||||
pkg_subagent_spawn --> svc_subagents
|
||||
pkg_system_prompt --> svc_systemPrompt
|
||||
pkg_tool_bash --> svc_bashEnv
|
||||
pkg_tools --> svc_tools
|
||||
pkg_user_interaction --> svc_userInteraction
|
||||
pkg_web --> svc_web
|
||||
@@ -183,6 +185,7 @@ flowchart LR
|
||||
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
|
||||
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
|
||||
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
|
||||
| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. |
|
||||
| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. |
|
||||
| `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. |
|
||||
| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). |
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface Config {
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
@@ -74,7 +76,8 @@ Source: [`packages/ui/acp-agent/src/index.ts:52`](../packages/ui/acp-agent/src/i
|
||||
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
|
||||
* plugin (the deployment's persona section and the explicit model-facing tool
|
||||
* order), the `tools` object to the tool registry (its presentation `mode`),
|
||||
* and `skills` to the skill registry/local provider/tool consumer. Every field
|
||||
* `dshHome` to the bash environment registry and local skill provider, and
|
||||
* `skills` to the skill registry/local provider/tool consumer. Every field
|
||||
* is optional INPUT here because each owner's schema supplies the default;
|
||||
* the schema is the INTERSECTION of the owners' own schemas (with registry
|
||||
* schemas nested under their bundle keys), so validation and defaulting can
|
||||
@@ -89,6 +92,8 @@ export interface Config {
|
||||
toolOrder?: SystemPromptConfig['toolOrder']
|
||||
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory shared by shell context and local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Skill registry, local provider, and model-facing consumer config. */
|
||||
skills?: SkillConfig
|
||||
}
|
||||
@@ -106,7 +111,7 @@ export interface SkillConfig {
|
||||
|
||||
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts)
|
||||
|
||||
Source: [`packages/core/agent-core/src/index.ts:87`](../packages/core/agent-core/src/index.ts)
|
||||
Source: [`packages/core/agent-core/src/index.ts:89`](../packages/core/agent-core/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-agent-loop`
|
||||
|
||||
@@ -625,6 +630,8 @@ export interface Config {
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||
@@ -807,6 +814,20 @@ export interface Config {
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:179`](../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-bash`
|
||||
|
||||
Requires: `tools` · `bash` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the bash tool and its managed child environment. */
|
||||
export interface Config {
|
||||
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/bash/tool-bash/src/index.ts:86`](../packages/bash/tool-bash/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-cordis`
|
||||
|
||||
Requires: `tools`
|
||||
@@ -1141,7 +1162,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
|
||||
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-bash` — requires `tools` · `bash` · `systemPrompt` ([`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
|
||||
- `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts))
|
||||
|
||||
|
||||
@@ -79,7 +79,21 @@ onTaskDone(listener: BashTaskListener): () => void
|
||||
|
||||
Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md)
|
||||
|
||||
Source: [`packages/bash/bash/src/index.ts:62`](../../packages/bash/bash/src/index.ts)
|
||||
Source: [`packages/bash/bash/src/index.ts:63`](../../packages/bash/bash/src/index.ts)
|
||||
|
||||
## `ctx.bashEnv` — `BashEnvRegistry`
|
||||
|
||||
Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal.
|
||||
|
||||
```ts cordis-catalog
|
||||
register(contributor: BashEnvContributor): () => void
|
||||
collect(execution: ToolExecution): DshEnvironment
|
||||
list(): BashEnvVariableInfo[]
|
||||
```
|
||||
|
||||
Types: [ToolExecution](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/bash/tool-bash/src/index.ts:143`](../../packages/bash/tool-bash/src/index.ts)
|
||||
|
||||
## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ interface BashExecRequest {
|
||||
* uses shell syntax like `FOO=bar cmd`).
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/**
|
||||
* Trusted DeepSeek Harness variables for this execution. Keys are restricted
|
||||
* to `DSH_*`; implementations remove inherited `DSH_*` before merging this
|
||||
* overlay so unavailable current facts never fall back to stale ambient ones.
|
||||
*/
|
||||
dshEnv?: DshEnvironment | undefined
|
||||
/**
|
||||
* Opaque OWNER token for a background task — the consumer's isolation key
|
||||
* (the tool layer passes the owning agent's `session.header.id`). The
|
||||
@@ -84,6 +90,8 @@ interface BashExecSpec {
|
||||
* config default, absent means "no extra env".
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/** Trusted managed variables carried through from the request. */
|
||||
dshEnv?: DshEnvironment | undefined
|
||||
/**
|
||||
* Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs`
|
||||
* being required on the resolved spec): {@link BashExecutor.resolve} carries
|
||||
@@ -108,7 +116,7 @@ interface BashExecSpec {
|
||||
|
||||
The `owner` token is the isolation key: the executor stores it but never interprets it (access policy is the consumer's job), so a background task started by one agent isn't readable cross-session. A required-but-nullable field makes a forgotten owner a visible `undefined` rather than a silently-unowned task.
|
||||
|
||||
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only — because a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in `dsh-bash-local` is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model `env`/`stdin` — to catch a future `...args` spread, not to defend a trust wall. `env` is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its payload and non-Harness environment. `dshEnv` is the distinct trusted channel for a current `DSH_*` snapshot collected by model-facing tool-bash. The tool does not expose any of them as parameters; model-provided extras are ignored. `dsh-bash-local` removes ambient credentials and all ambient `DSH_*`, merges terminal defaults and ordinary `env`, then applies `dshEnv`; ordinary `env` containing `DSH_*` is rejected. This makes secret scrubbing and Harness namespace ownership separate explicit contracts. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment RFC](../rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path.
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ Add `stdin?: string` and `env?: Record<string, string>` to **both** `BashExecReq
|
||||
|
||||
Three deliberate choices:
|
||||
|
||||
1. **The model-facing `bash` tool does NOT expose `stdin`/`env` as parameters** — not as a security wall, but because bash syntax already covers the model's needs, so duplicating them as tool params would be redundant surface. [dsh-tool-bash](../../../../packages/bash/tool-bash)'s `bash` tool builds its `BashExecRequest` from named schema fields and may add harness-owned environment such as the current [session identity and JSONL location](../feature/2026-07-10-agent-session-identity-and-log-location.md); a model that includes `env`/`stdin` keys in its tool-call arguments has them ignored and cannot replace that overlay. Regression guards drive the real tool with extra args and assert no model-provided field enters the request. In-process plugins (the hooks bridges, native plugins) that construct a `BashExecRequest` directly set the fields; the seam imposes no access policy (consistent with how `owner` works — the executor stores but never interprets it).
|
||||
1. **The model-facing `bash` tool does NOT expose `stdin`/`env` as parameters** — not as a security wall, but because bash syntax already covers the model's needs, so duplicating them as tool params would be redundant surface. [dsh-tool-bash](../../../../packages/bash/tool-bash)'s `bash` tool builds its `BashExecRequest` from named schema fields; a model that includes `env`/`stdin` keys has them ignored. Harness-owned variables use the distinct `dshEnv` channel added by the [session environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them. In-process plugins such as hook bridges construct requests directly and set ordinary `stdin`/`env`; the seam otherwise imposes no access policy.
|
||||
|
||||
2. **`env` merges AFTER the credential scrub, so an explicit caller entry always wins** — even a credential-shaped name. This is correct because the scrub's job is narrow: stop the harness's *ambient* `process.env` credentials from leaking into a spawned command. A caller that explicitly sets a var has named a value it already holds (not the ambient secret), so the scrub is not a constraint on it. `childEnv(extra?)` layers `scrub(process.env)` → `ENV_OVERRIDES` (the model-friendly `TERM=dumb` etc.) → `extra`, last-wins.
|
||||
2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`.
|
||||
|
||||
3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`.
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
An agent can identify its workspace through `session.header.cwd`, but a model using the bash tool cannot identify the session that owns the call or the durable JSONL file that records it. The default apps happen to use `./.sessions`, yet that is deployment config rather than a contract: `persistenceRoot` can point elsewhere, the JSONL backend hashes `cwd` into a bucket, and arbitrary session ids are path-encoded. Asking the agent to run `find` therefore makes the model guess backend layout and can select the wrong log under concurrent, resumed, forked, or subagent sessions.
|
||||
An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call or the durable transcript that records it. Searching `./.sessions` guesses deployment config and JSONL layout; custom roots, alternate persistence backends, resume, forks, and concurrent parent/child agents make that guess unreliable. Hooks have the same need for transcript location, while future plugins may need to expose other harness-owned environment facts to shell commands.
|
||||
|
||||
The same missing ownership boundary appears in the hook bridges. The Codex bridge emits `session_id` but fixes `transcript_path` to `null`; the Claude Code bridge emits `session_id` and `cwd` but no transcript path. Teaching each consumer to reconstruct the JSONL layout would duplicate backend policy and couple model tools and protocol adapters to one persistence implementation.
|
||||
|
||||
The feature needs two distinct facts: a stable session identity that exists even without persistence, and an optional physical location owned by the active persistence backend. They must be resolved per agent invocation rather than written to global `process.env`, because one harness process can run multiple agents and in-process subagents concurrently.
|
||||
The boundary must preserve two properties: the owner of a fact decides how to resolve it, and every child receives a per-execution snapshot rather than process-global mutable state. In particular, a nested harness must not leak its ambient `DSH_*` values into a child whose current agent, persistence backend, or configuration differs.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -17,70 +15,71 @@ Extend the [`SessionPersistence`](../../implemented/architecture/2026-06-14-sess
|
||||
```ts
|
||||
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export interface SessionLocation {
|
||||
interface SessionLocation {
|
||||
readonly kind: string
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
export abstract class SessionPersistence {
|
||||
abstract locate(meta: SessionHeader): SessionLocation | undefined
|
||||
interface SessionPersistence {
|
||||
locate(meta: SessionHeader): SessionLocation | undefined
|
||||
}
|
||||
```
|
||||
|
||||
`path` is an absolute local path to the backend's dedicated log for `meta`; `kind` identifies the representation. The JSONL backend returns `{ kind: 'jsonl', path }` using its already-resolved absolute root and existing cwd-bucket/id-encoding helpers. The SQLite backend returns `undefined` because a session is rows inside a shared database, not a dedicated transcript file. A backend with no honest local per-session path also returns `undefined`.
|
||||
`path` is an absolute local path to the backend's dedicated log for `meta`; `kind` identifies the representation. JSONL returns `{ kind: 'jsonl', path }` using its resolved root and path helpers. SQLite and any backend without an honest local per-session artifact return `undefined`. The query creates and flushes nothing, so it can report a lazy target path before that file exists.
|
||||
|
||||
`locate` performs no filesystem I/O, creates nothing, flushes nothing, and never searches by convention. It reports where this backend would materialize the session, so callers can receive a path before the file exists. Making the query synchronous and local-path-only keeps it usable while constructing tool and hook invocation context; a future remote/object-store locator is a separate capability rather than a blocking network call hidden inside prompt or tool assembly.
|
||||
The model-facing bash package owns a `ctx.bashEnv` registry. A contributor declares its stable name, every `DSH_*` key it may return, a description for each key, and `resolve(execution: ToolExecution)`. Duplicate contributor names, duplicate key ownership, reserved keys, malformed declarations, undeclared runtime output, and non-string output fail loudly. Registration is a Cordis effect and is removed with the contributing plugin fiber. `list()` exposes declarations without running resolvers, keeping the environment surface enumerable for diagnostics and future prompt/UI consumers.
|
||||
|
||||
The model-facing bash consumer derives a trusted environment overlay for each `ToolExecution` with an agent:
|
||||
The registry rebuilds a trusted overlay for every foreground and background bash `ToolExecution`:
|
||||
|
||||
- `DSH_SESSION_ID` is always the current `agent.session.header.id`, including when persistence is absent or non-file-backed.
|
||||
- `DSH_SESSION_JSONL` is present only when the active `ctx.sessionPersistence.locate(header)` returns `kind: 'jsonl'`; its value is that location's absolute path.
|
||||
- A call without an agent receives neither variable.
|
||||
- `DSH_HOME` is always the absolute configured Harness home, resolved from tool-bash/agent-core `dshHome`, then ambient `$DSH_HOME`, then `~/.dsh`.
|
||||
- `DSH_SHELL=1` is always present and identifies a model bash child managed by DeepSeek Harness.
|
||||
- `DSH_SESSION_ID` is present when the execution has an agent and equals `agent.session.header.id`.
|
||||
- The built-in persistence translator contributes `DSH_SESSION_JSONL` only when `ctx.sessionPersistence.locate(header)` returns `kind: 'jsonl'`.
|
||||
|
||||
The overlay is passed through the existing `BashExecRequest.env` surface from the [trusted stdin/env decision](../../implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). It applies to foreground and background starts, and `dsh-bash-local` merges it after its ambient credential scrub and terminal overrides. The model-facing tool continues to build the request from named schema fields: model-supplied `env`/`stdin` keys are ignored and cannot replace the overlay. A shell command can still overwrite its own variables (`DSH_SESSION_ID=x command`); these values are correlation metadata, never authority.
|
||||
Session persistence remains the fact owner: JSONL does not depend on tool-bash or register shell variables itself, and hooks continue to consume `locate()` directly. Tool-bash is the translation layer from the persistence fact into a shell convention. Other plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`.
|
||||
|
||||
The bash tool description tells the model that the current session id is available as `$DSH_SESSION_ID` and that JSONL deployments additionally expose `$DSH_SESSION_JSONL`. This guidance belongs with the tool that provides the variables, not in a permanent system-prompt section. The schema is already recorded in the request header under the [reconstructable-request contract](../../implemented/architecture/2026-07-05-reconstructable-requests.md), and every resulting tool output is a durable `tool/result`, so no new session event is needed.
|
||||
The bash seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`. Ordinary `env` remains the general in-process plugin surface used by hooks, but cannot contain `DSH_*`; the local executor rejects that wrong channel, removes every inherited ambient `DSH_*`, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments.
|
||||
|
||||
The [Claude Code and Codex hook bridges](../../implemented/feature/2026-06-30-hook-bridges.md) resolve transcript location from the same seam at payload construction time. Codex payloads use `transcript_path: string | null`; Claude Code payloads keep their string-shaped dialect field and use `transcript_path: string`, falling back to `''` when no local per-session file exists. Hook lookup is the same side-effect-free snapshot as bash lookup: it does not force materialization or make a pre-turn hook create an otherwise abandoned session artifact.
|
||||
The bash tool description teaches only the durable convention: current harness environment facts are available through managed `$DSH_*` variables and may be inspected when needed. It does not enumerate persistence-specific keys or add a permanent system-prompt section. Tool schemas are already logged in request headers and tool output is logged as `tool/result`, so no new session event is required.
|
||||
|
||||
The [Claude Code and Codex hook bridges](../../implemented/feature/2026-06-30-hook-bridges.md) resolve transcript location from the same persistence seam when constructing payloads. Codex uses `transcript_path: string | null`; Claude Code preserves its string field and falls back to `''`. Hook lookup neither materializes nor flushes a session.
|
||||
|
||||
## Peer product findings
|
||||
|
||||
Peer products separate stable identity from physical storage rather than treating an absolute path as the only session key. Codex injects `CODEX_THREAD_ID` into each spawned shell environment after its environment policy has run, while its rollout recorder owns the exact path and exposes it separately to client events and hooks. Claude Code supplies `session_id` and `transcript_path` as structured hook/status-line input rather than a general Bash transcript environment contract. OpenCode carries session identity in structured tool execution context; Kimi Code expands a session-id placeholder in skill content; Reasonix keeps the active session path on its controller and rebinds it on branch/resume.
|
||||
|
||||
The reusable principles are narrower than any one product's API: inject identity at the invocation boundary, let persistence resolve storage, do not mutate process-global environment for concurrent agents, and do not promise that a precomputed path is already materialized. DeepSeek Harness adds the optional JSONL path to bash because its requested user behavior is explicitly “ask the agent for this session's log,” while retaining the stable id as the primary identity.
|
||||
Peer products separate stable identity from physical storage. Codex injects stable `CODEX_THREAD_ID` into spawned shells while recorder and hook surfaces own transcript paths. Claude Code supplies `session_id` and `transcript_path` as structured hook/status input. OpenCode carries identity in structured tool context; Kimi Code expands a session placeholder; Reasonix keeps the active session path on its controller. The portable rule is to inject identity at the invocation boundary, let storage resolve location, and never use a process-global current-session variable in a concurrent harness.
|
||||
|
||||
## Lifecycle and persistence semantics
|
||||
|
||||
A fresh session receives its id before any turn. Its bash environment can therefore carry both values during the first turn, but JSONL lazy materialization remains unchanged: before the first successful turn-end `session/flush`, `$DSH_SESSION_JSONL` can name a file that does not yet exist. During an open later turn, the file contains only the last durably flushed prefix, not the current buffered events. Consumers that need a readable up-to-date transcript require a separate explicit checkpoint/materialization API; this decision deliberately does not add one.
|
||||
A fresh session receives its id before the first turn, so its first bash call can read `DSH_SESSION_ID` and a JSONL target. The JSONL file may still be absent until the first successful turn-end checkpoint, and during an open turn it contains only the last flushed prefix. `DSH_SESSION_JSONL` is a location hint, not an authorization credential or freshness guarantee.
|
||||
|
||||
Resume reuses the loaded session header, so it exposes the same id and backend location. Fork and in-process spawn create a new session id; the JSONL backend derives a new file while preserving the existing `parentSession` lineage and inherited cwd rules. Concurrent parent/child agents compute overlays from their own `ToolExecution.agent`, so neither can inherit or overwrite the other's identity.
|
||||
Resume reuses the loaded header and therefore the same id and location. Fork and spawn create new session ids and locations. Parent and child calls resolve from their own `ToolExecution.agent`; each command receives an immutable snapshot even when calls overlap. A persistence service replacement affects later collections because the translator queries `ctx.get('sessionPersistence')` at execution time; the registry itself is effect-scoped and HMR-safe.
|
||||
|
||||
Consumers resolve the active service through the Cordis context at invocation time and do not cache a concrete JSONL backend instance. This keeps HMR/reload behavior aligned with the service store: a replacement backend controls subsequent locations, and an absent/inactive backend removes only `DSH_SESSION_JSONL`, never the session id.
|
||||
`dshHome` is session-independent deployment context. Agent-core routes one value to both tool-bash and local skill discovery; if top-level `dshHome` and `skills.local.dshHome` are both supplied and resolve differently, composition fails instead of exposing contradictory homes. Persistence may change independently without freezing its facts into the session prefix.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage pins each boundary. The persistence seam contract asserts JSONL returns an absolute encoded path under a custom root while SQLite returns `undefined`; JSONL tests cover cwd/no-cwd buckets and ids requiring escaping. Tool-bash request-recording tests cover foreground/background overlays, no-agent calls, absent/SQLite persistence, ignored model `env` keys, and separate parent/child identities. Both hook bridge suites assert their exact available/unavailable `transcript_path` dialect shapes.
|
||||
Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, absent/JSONL persistence, ignored model `env`, and parent/child isolation. JSONL/SQLite locator contract tests and both hook bridge suites pin available and unavailable transcript dialects.
|
||||
|
||||
A keyless full-loop integration uses the real agent loop, JSONL persistence, `dsh-tool-bash`, and `dsh-bash-local` with only the model scripted. On the first turn the model runs a command that prints both variables and reports whether the path exists; the test verifies the values against the live session header and locator, verifies the file can be absent inside the tool call, then waits for idle and confirms the materialized file's header carries the same session id. Request-recording tests prove parent/child calls receive different overlays, while locator tests prove resume keeps the path and fork changes it.
|
||||
|
||||
Snapshot coverage updates the existing request-header pin for the bash description and the hook payload scenarios affected by `transcript_path`. No with-key e2e is required: model choice is not the contract, and the deterministic behavior is exercised through the real local executor, persistence backend, loader composition, and snapshot replay without depending on a provider credential.
|
||||
A keyless full-loop integration drives the real agent loop, JSONL persistence, tool-bash, and bash-local on the first turn. The child prints `DSH_HOME`, `DSH_SHELL`, session id, JSONL target, and an inherited stale sentinel; the test verifies current values, absence of the stale variable, pre-flush file absence, and the eventual persisted header. Snapshot coverage pins the generic bash description in the recorded request header. No with-key test is required because the contract is deterministic local execution rather than model choice.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Expose only `DSH_SESSION_ID` and make the agent search.** This copies Codex's shell surface but not its separate persistence resolver. A recursive `find` knows neither a custom root nor a non-JSONL backend, duplicates layout rules, and can race or mis-select under multiple sessions. Stable id remains necessary, but it is insufficient for the requested direct-path behavior.
|
||||
**Only an id plus `find`.** Search cannot know a custom root or backend layout and races under multiple sessions.
|
||||
|
||||
**Expose only the absolute path.** A path can be unavailable for non-file persistence and can name a not-yet-created lazy artifact; it is not the stable identity other APIs use for resume, lineage, or ownership. Keeping id and optional location separate makes those semantics explicit.
|
||||
**Only an absolute path.** A path can be unavailable, lazy, or representation-specific and is not stable session identity.
|
||||
|
||||
**Write the current session into global `process.env`.** One process can drive multiple ACP sessions and in-process subagents concurrently, so a global assignment is last-writer-wins shared mutable state. Per-`ToolExecution` request env gives every child process an immutable snapshot of the correct agent instead.
|
||||
**Global `process.env`.** Concurrent agents would overwrite one another and nested harnesses would inherit stale current-session values.
|
||||
|
||||
**Add a model-facing `session_info` tool.** A dedicated tool would add schema and another call when bash already supplies the requested query surface. It would also need the same persistence resolver, so it does not remove the seam work; the environment variables are smaller and compose with ordinary shell scripts.
|
||||
**Put persistence instructions in the session prefix.** A session prefix is frozen while the active service can change across HMR or future backend switching; persistence-specific guidance would become stale.
|
||||
|
||||
**Make tool-bash depend directly on the JSONL backend.** Reading backend config or importing `logPath` from the implementation would violate the interface/implementation/consumer split and leave hooks to invent another route. The persistence service is the only layer that can state whether a physical per-session path exists.
|
||||
**A typed waterfall event.** Listeners cannot declare ownership without running, and later listeners can silently overwrite keys. A registry detects key conflicts at registration and remains enumerable.
|
||||
|
||||
**Have each persistence backend register bash env directly.** That reverses the dependency from storage into one consumer and forces bash into deployments that do not use it. `locate()` is also still required by hooks.
|
||||
|
||||
**A model-facing `session_info` tool.** It adds schema and another call while bash already supplies the query surface; the registry generalizes to future environment facts without one tool per fact.
|
||||
|
||||
## Consequences
|
||||
|
||||
Foreground and background bash calls now expose the current agent's stable session id, while only JSONL-backed sessions expose a file path. No-agent calls receive neither variable; absent and SQLite persistence still leave `DSH_SESSION_ID` available. Resume retains identity and location, while forks, spawns, and concurrent child agents derive new values from their own immutable headers. Model-supplied `env`/`stdin` fields remain ignored, and both hook bridges consume the same locator with their dialect-specific unavailable value.
|
||||
Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The complete `DSH_*` namespace inside these children is managed by the harness: ambient values are removed, current trusted values are re-added, and ordinary callers cannot use `env` to bypass ownership checks.
|
||||
|
||||
The path reveals the configured persistence root to the model and hooks. The bash tool already runs with the executor's filesystem authority, so this adds discoverability rather than permission; deployments needing isolation use a sandboxing executor or omit local-file persistence. A valid location can be absent or stale relative to an open turn because durability checkpoints happen at turn end.
|
||||
|
||||
Commands can overwrite either variable inside their own shell syntax. The values are debugging/correlation facts rather than credentials, so external consumers still verify the file header before attributing a transcript. `DSH_SESSION_JSONL` remains representation-specific, and backends without a dedicated per-session file return `undefined` instead of squeezing database coordinates into a path contract. The pre-release seam extension intentionally requires every persistence backend to make that supported/unsupported choice without a compatibility shim.
|
||||
The namespace is discoverable but not secret. Paths can reveal configured roots, lazy targets can be absent or stale, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts, verify transcript metadata when attribution matters, and rely on sandbox/filesystem policy rather than variable secrecy for authorization.
|
||||
|
||||
@@ -125,7 +125,7 @@ Registered by the tool registry itself under `mode: code` / `mode: both` (see th
|
||||
|
||||
### `bash`
|
||||
|
||||
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]`. The current agent session id is available as `$DSH_SESSION_ID`; when JSONL persistence is configured, `$DSH_SESSION_JSONL` is its absolute target path and may not exist or contain the current unflushed turn yet. 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). 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`.
|
||||
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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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). 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`.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -28,7 +28,7 @@ The available tools:
|
||||
|
||||
```ts
|
||||
declare const tools: {
|
||||
/** 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]`. The current agent session id is available as `$DSH_SESSION_ID`; when JSONL persistence is configured, `$DSH_SESSION_JSONL` is its absolute target path and may not exist or contain the current unflushed turn yet. 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). 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`. */
|
||||
/** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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). 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`. */
|
||||
bash(args: {
|
||||
/** The bash command to execute. */
|
||||
command: string;
|
||||
|
||||
@@ -28,7 +28,7 @@ The available tools:
|
||||
|
||||
```ts
|
||||
declare const tools: {
|
||||
/** 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]`. The current agent session id is available as `$DSH_SESSION_ID`; when JSONL persistence is configured, `$DSH_SESSION_JSONL` is its absolute target path and may not exist or contain the current unflushed turn yet. 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). 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`. */
|
||||
/** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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). 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`. */
|
||||
bash(args: {
|
||||
/** The bash command to execute. */
|
||||
command: string;
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
{"type":"turn/start","seq":0,"time":1783613224997,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1783613224997,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: cat notes.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":1783613224997,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":3,"time":1783613224997,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"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]`. The current agent session id is available as `$DSH_SESSION_ID`; when JSONL persistence is configured, `$DSH_SESSION_JSONL` is its absolute target path and may not exist or contain the current unflushed turn yet. 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). 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`. 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 succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"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."},"sandbox_permissions":{"type":"string","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.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"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":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}}]},"reason":"initial"}}
|
||||
{"type":"request/header","seq":3,"time":1783613224997,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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). 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`. 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 succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"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."},"sandbox_permissions":{"type":"string","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.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"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":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}}]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":4,"time":1783613225437,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1783613225438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1783613225658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
|
||||
|
||||
@@ -22,7 +22,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
|
||||
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
|
||||
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
|
||||
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
- **Model-friendly env + credential/namespace scrub** — start with `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and every `DSH_*`, then apply `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`. Ordinary spec `env` is merged next and may restore caller-held credential-shaped values, but is rejected if it tries to set reserved `DSH_*`; the trusted spec `dshEnv` snapshot is merged last. This keeps ambient secrets out and prevents a nested/previous harness identity from surviving when the current registry omits it. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null`. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload.
|
||||
|
||||
## Sandboxing
|
||||
|
||||
@@ -126,10 +126,11 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
|
||||
timeoutMs,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
// Carry stdin/env through verbatim — optional, no config default (absent
|
||||
// means none). env merges AFTER the scrub in run.ts.
|
||||
// Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
|
||||
// no config default. run.ts owns the scrub and merge order.
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
// Carry the owner through verbatim (required-but-nullable on the spec):
|
||||
// the executor never interprets it — the consumer's access policy does.
|
||||
owner: request.owner,
|
||||
@@ -153,6 +154,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
signal: d.signal,
|
||||
stdin: spec.stdin,
|
||||
env: spec.env,
|
||||
dshEnv: spec.dshEnv,
|
||||
}, this.internals).done
|
||||
// Classify the FIRST abort reason: a BASH_TIMEOUT TimeoutReason means our
|
||||
// timeout cut the command short; any other abort — an upstream cancel, or a
|
||||
@@ -179,6 +181,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
signal: spec.signal,
|
||||
stdin: spec.stdin,
|
||||
env: spec.env,
|
||||
dshEnv: spec.dshEnv,
|
||||
}, this.internals)
|
||||
|
||||
const id = BashTaskId(`bash-${this.nextTaskId++}`)
|
||||
|
||||
@@ -27,7 +27,7 @@ import { randomBytes } from 'node:crypto'
|
||||
import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/**
|
||||
* Model-friendly environment overrides: disable colors, pagers, and
|
||||
@@ -50,27 +50,33 @@ export const ENV_OVERRIDES = {
|
||||
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
|
||||
/**
|
||||
* `process.env` minus credential-shaped vars, plus the model-friendly
|
||||
* overrides, plus any caller-supplied `extra` entries.
|
||||
* Build a child environment from scrubbed ambient values, terminal overrides,
|
||||
* ordinary caller entries, and a trusted managed `DSH_*` snapshot.
|
||||
*
|
||||
* Layering matters: the scrub drops `process.env` credentials, then
|
||||
* `ENV_OVERRIDES` forces the model-friendly terminal vars, then `extra` is
|
||||
* merged LAST so an explicit caller entry wins even when its name matches the
|
||||
* scrub pattern (the scrub is the control that stops the HARNESS's ambient
|
||||
* credentials leaking into a spawned command; a caller that explicitly sets a
|
||||
* var named a value it already holds, not that ambient secret). `extra` is set
|
||||
* by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash`
|
||||
* builds its request from named fields only and does not forward model input
|
||||
* here (see its README, § "The tool builds its request from named args only").
|
||||
* @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides.
|
||||
* Ambient credentials and all ambient `DSH_*` are removed first;
|
||||
* `ENV_OVERRIDES` then forces model-friendly terminal values, ordinary `extra`
|
||||
* follows, and `dshEnv` merges last. Ordinary `extra` may restore a
|
||||
* credential-shaped name whose value the caller already holds, but cannot set
|
||||
* the managed namespace. `dsh-tool-bash` builds both channels from trusted
|
||||
* named fields and never forwards model-provided environment objects.
|
||||
* @param extra - ordinary caller-supplied entries; `DSH_*` names are rejected.
|
||||
* @param dshEnv - trusted managed `DSH_*` entries for the current execution.
|
||||
* @returns the environment to hand to `spawn` for the child process.
|
||||
*/
|
||||
export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
|
||||
export function childEnv(
|
||||
extra?: Readonly<Record<string, string>>,
|
||||
dshEnv?: DshEnvironment,
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
|
||||
if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith('DSH_')) env[key] = value
|
||||
}
|
||||
return { ...env, ...ENV_OVERRIDES, ...extra }
|
||||
for (const key of Object.keys(extra ?? {})) {
|
||||
if (key.startsWith('DSH_')) {
|
||||
throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`)
|
||||
}
|
||||
}
|
||||
return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv }
|
||||
}
|
||||
|
||||
/** What to run and under which limits (resolved — no defaults in here). */
|
||||
@@ -96,12 +102,12 @@ export interface SpawnSpec {
|
||||
*/
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
* Extra environment entries, merged onto the scrubbed env AFTER the
|
||||
* credential scrub and the model-friendly overrides (so an explicit entry
|
||||
* wins). Set by in-process plugins; the model-facing tool does not forward
|
||||
* model input here.
|
||||
* Ordinary environment entries merged after the credential scrub and
|
||||
* terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`.
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/** Harness-owned `DSH_*` entries merged after ambient `DSH_*` removal. */
|
||||
dshEnv?: DshEnvironment | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,7 +352,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
|
||||
// typed `spawn` overload infer non-null stdout/stderr, which the
|
||||
// `ChildProcessByStdio` annotation captures (stdin `Writable | null`; stdout/
|
||||
// stderr the non-null `Readable` the collectors attach to without a cast).
|
||||
const env = childEnv(spec.env)
|
||||
const env = childEnv(spec.env, spec.dshEnv)
|
||||
const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
|
||||
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
|
||||
: spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
|
||||
|
||||
@@ -136,21 +136,28 @@ describe('LocalBashExecutor.run', () => {
|
||||
await expect(bash.run(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('resolve() carries stdin/env onto the spec, and run() threads them to the command', async () => {
|
||||
it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => {
|
||||
const { bash } = await setup()
|
||||
const spec = bash.resolve({ command: 'cat; echo "[$DSH_SEAM_VAR]"', stdin: 'piped\n', env: { DSH_SEAM_VAR: 'env-ok' } })
|
||||
// resolve() keeps the stdin/env fields verbatim (optional, no default).
|
||||
const spec = bash.resolve({
|
||||
command: 'cat; echo "[$SEAM_VAR][$DSH_SEAM_VAR]"',
|
||||
stdin: 'piped\n',
|
||||
env: { SEAM_VAR: 'env-ok' },
|
||||
dshEnv: { DSH_SEAM_VAR: 'dsh-ok' },
|
||||
})
|
||||
// resolve() keeps the optional input/environment fields verbatim.
|
||||
expect(spec.stdin).toBe('piped\n')
|
||||
expect(spec.env).toEqual({ DSH_SEAM_VAR: 'env-ok' })
|
||||
expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' })
|
||||
expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' })
|
||||
const result = await bash.run(spec)
|
||||
expect(result.stdout.text).toBe('piped\n[env-ok]\n')
|
||||
expect(result.stdout.text).toBe('piped\n[env-ok][dsh-ok]\n')
|
||||
})
|
||||
|
||||
it('resolve() omits stdin/env when the request supplies neither', async () => {
|
||||
it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => {
|
||||
const { bash } = await setup()
|
||||
const spec = bash.resolve({ command: 'true' })
|
||||
expect('stdin' in spec).toBe(false)
|
||||
expect('env' in spec).toBe(false)
|
||||
expect('dshEnv' in spec).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -177,14 +184,15 @@ describe('LocalBashExecutor background tasks', () => {
|
||||
await Promise.all([first.done, second.done])
|
||||
})
|
||||
|
||||
it('threads stdin and extra env into a background task', async () => {
|
||||
it('threads stdin, ordinary env, and managed env into a background task', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({
|
||||
command: 'cat; echo "[$DSH_BG_VAR]"',
|
||||
command: 'cat; echo "[$BG_VAR][$DSH_BG_VAR]"',
|
||||
stdin: 'bg-stdin\n',
|
||||
env: { DSH_BG_VAR: 'bg-env' },
|
||||
env: { BG_VAR: 'bg-env' },
|
||||
dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
|
||||
}))
|
||||
const read = await readUntil(bash, task.id, '[bg-env]')
|
||||
const read = await readUntil(bash, task.id, '[bg-env][bg-dsh-env]')
|
||||
expect(read.delta).toContain('bg-stdin')
|
||||
await task.done
|
||||
expect(task.exitCode).toBe(0)
|
||||
|
||||
@@ -201,19 +201,19 @@ describe('stdin and extra env (set by in-process plugins)', () => {
|
||||
expect(piped.stdout.text).toBe('socket\n')
|
||||
})
|
||||
|
||||
it('merges extra env entries onto the scrubbed environment', async () => {
|
||||
const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', {
|
||||
env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' },
|
||||
it('merges ordinary extra env entries onto the scrubbed environment', async () => {
|
||||
const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
|
||||
env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' },
|
||||
})).done
|
||||
expect(result.stdout.text).toBe('alpha/beta\n')
|
||||
})
|
||||
|
||||
it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => {
|
||||
// TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins.
|
||||
// DSH_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
|
||||
// EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
|
||||
// entry is still honored — the scrub only drops AMBIENT process.env creds.
|
||||
const result = await runBash(spec('echo "$TERM/$DSH_OVERRIDE_KEY"', {
|
||||
env: { TERM: 'xterm-256color', DSH_OVERRIDE_KEY: 'explicit-wins' },
|
||||
const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', {
|
||||
env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
|
||||
})).done
|
||||
expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n')
|
||||
})
|
||||
@@ -361,13 +361,13 @@ describe('abort edge cases', () => {
|
||||
})
|
||||
|
||||
describe('review fixes: env scrubbing and spill hardening', () => {
|
||||
it('scrubs credential-shaped env vars from child processes', async () => {
|
||||
it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => {
|
||||
process.env.DSH_TEST_API_KEY = 'super-secret'
|
||||
process.env.DSH_TEST_TOKEN = 'also-secret'
|
||||
process.env.DSH_TEST_PLAIN = 'visible'
|
||||
try {
|
||||
const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
|
||||
expect(result.stdout.text.trim()).toBe('[absent|absent|visible]')
|
||||
expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
|
||||
} finally {
|
||||
delete process.env.DSH_TEST_API_KEY
|
||||
delete process.env.DSH_TEST_TOKEN
|
||||
@@ -375,6 +375,23 @@ describe('review fixes: env scrubbing and spill hardening', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('injects only the current trusted DSH environment after scrubbing ambient values', async () => {
|
||||
process.env.DSH_STALE = 'old-value'
|
||||
try {
|
||||
const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
|
||||
dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' },
|
||||
})).done
|
||||
expect(result.stdout.text.trim()).toBe('[absent|1|current-session]')
|
||||
} finally {
|
||||
delete process.env.DSH_STALE
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects DSH variables on the ordinary env channel', () => {
|
||||
expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
|
||||
.toThrow(/DSH_WRONG_CHANNEL.*dshEnv/)
|
||||
})
|
||||
|
||||
it('creates spill files with owner-only permissions and random names', async () => {
|
||||
const result = await runBash(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
|
||||
|
||||
@@ -30,8 +30,8 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner, sandboxMode) before execution; `owner` and `sandboxMode` are optional on the request and **required-but-nullable** on the resolved spec, so a forgotten one is a visible `undefined` rather than a silently-absent property. `sandboxMode` is the explicit per-call sandbox-policy input: an escalation grant a human just issued ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), which outranks) or the session's standing override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)); a sandboxing executor's `resolve()` stamps its configured default when the request carries none, and a non-sandboxing executor carries the field verbatim and confines nothing.
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, dshEnv?, owner?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, dshEnv?, owner, sandboxMode) before execution; `owner` and `sandboxMode` are optional on the request and **required-but-nullable** on the resolved spec, so a forgotten one is a visible `undefined` rather than a silently-absent property. `sandboxMode` is the explicit per-call sandbox-policy input: an escalation grant a human just issued ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), which outranks) or the session's standing override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)); a sandboxing executor's `resolve()` stamps its configured default when the request carries none, and a non-sandboxing executor carries the field verbatim and confines nothing.
|
||||
|
||||
The seam also owns the per-session mode override vocabulary (the sandbox RFC § Per-session mode switching): the log-only `'bash/sandbox-mode'` session event, the pure fold `effectiveSandboxMode(events)` (last event wins; `undefined` means "apply the executor default"), and THE write path `setSandboxMode(session, mode)` — the session log is the store, so an override survives restart by replay and two sessions can never see each other's mode. Writers must respect turn-enclosure: the ACP bridge anchors an idle switch at the next turn rather than appending between turns. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. A sandboxing executor additionally stamps `sandbox` result facts on results and settled tasks (`BashSandboxInfo`: the mode it executed under, the conservative `denied` classification, and — for confined modes — the backend's `enforcement` completeness); the mode/enforcement vocabulary is owned by the [`dsh-sandbox`](../../sandbox/sandbox/) seam, and the facts are documented in [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). See `src/types.ts` for the full contracts.
|
||||
|
||||
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to `DSH_*` keys; model bash uses it for the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited `DSH_*`, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
@@ -30,6 +30,7 @@ export type {
|
||||
BashTaskRead,
|
||||
BashTaskStatus,
|
||||
CollectedOutput,
|
||||
DshEnvironment,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
|
||||
@@ -12,6 +12,9 @@ import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
/** Identifies one background task within an executor (generated `bash-N`). */
|
||||
export type BashTaskId = Branded<'BashTaskId'>
|
||||
|
||||
/** Trusted DeepSeek Harness variables for one bash execution. */
|
||||
export type DshEnvironment = Readonly<Record<`DSH_${string}`, string>>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link BashTaskId}.
|
||||
* @param id - the raw task-id string (the executor generates `bash-N`).
|
||||
@@ -106,15 +109,19 @@ export interface BashExecRequest {
|
||||
*/
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
* Extra environment entries for the command, merged AFTER the
|
||||
* implementation's credential scrub (so an explicit entry here is honored even
|
||||
* when its name matches the scrub pattern — the caller named a value it holds,
|
||||
* not the harness's ambient secret). Set by in-process plugins (the hooks
|
||||
* bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing
|
||||
* bash tool does not expose it as a parameter (a model that needs an env var
|
||||
* uses shell syntax like `FOO=bar cmd`).
|
||||
* Ordinary environment entries for the command, merged after the credential
|
||||
* scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it
|
||||
* here. Set by in-process plugins (the hooks bridges set
|
||||
* `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool
|
||||
* does not expose it as a parameter.
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/**
|
||||
* Harness-owned `DSH_*` variables for this execution. Executors discard
|
||||
* ambient `DSH_*` entries before merging this snapshot, so an unavailable
|
||||
* current fact cannot inherit a stale value from the harness process.
|
||||
*/
|
||||
dshEnv?: DshEnvironment | undefined
|
||||
/**
|
||||
* Opaque OWNER token for a background task — the consumer's isolation key
|
||||
* (the tool layer passes the owning agent's `session.header.id`). The
|
||||
@@ -163,13 +170,14 @@ export interface BashExecSpec {
|
||||
*/
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
* Extra environment entries, carried through verbatim from
|
||||
* {@link BashExecRequest.env} and merged by the implementation AFTER its
|
||||
* credential scrub (an explicit entry wins even when its name matches the
|
||||
* scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no
|
||||
* config default, absent means "no extra env".
|
||||
* Ordinary environment entries carried through from
|
||||
* {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}.
|
||||
* OPTIONAL on the spec for the same reason as `stdin`: absent means no
|
||||
* ordinary extra environment.
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/** Trusted `DSH_*` snapshot carried through from {@link BashExecRequest.dshEnv}. */
|
||||
dshEnv?: DshEnvironment | undefined
|
||||
/**
|
||||
* Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs`
|
||||
* being required on the resolved spec): {@link BashExecutor.resolve} carries
|
||||
|
||||
@@ -22,11 +22,26 @@ 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()`.
|
||||
|
||||
### Session identity environment
|
||||
### Managed shell environment
|
||||
|
||||
Every foreground and background call made for an agent receives `DSH_SESSION_ID=agent.session.header.id`. When the active persistence backend locates a JSONL artifact, the call also receives `DSH_SESSION_JSONL=<absolute target path>`; absent persistence and non-file backends still provide the id but omit the JSONL variable. The path is a location hint: lazy materialization means it may not exist on the first turn, and during an open turn it can omit buffered events that have not reached `session/flush`. Neither value is an authorization credential.
|
||||
Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
|
||||
|
||||
The overlay is computed from `ToolExecution.agent` for each call and passed through `BashExecRequest.env`; `process.env` is never modified, so concurrent parent/child agents keep separate values. The tool description names both variables so the model can inspect them without a permanent system-prompt section.
|
||||
`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.bashEnv.register({
|
||||
name: 'deployment-region',
|
||||
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
|
||||
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
|
||||
|
||||
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.
|
||||
|
||||
@@ -52,7 +67,7 @@ When a background task finishes, a short notice is injected into the owning agen
|
||||
|
||||
## The tool builds its request from named args only
|
||||
|
||||
The `BashExecRequest` seam carries optional `stdin` and `env`, used by trusted consumers. This tool does **not** expose them as model parameters: it builds the request from named schema fields and adds only the session overlay above, so model-supplied `env`/`stdin` keys are ignored and cannot replace the trusted values. 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 ambient secrets is `dsh-bash-local`'s credential scrub. Regression guards assert extra model fields never enter the request while the trusted overlay still does. 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 ordinary `env` for in-process consumers plus the harness-owned `dshEnv` channel above. This tool does **not** expose any of them as model parameters: it builds the request from named schema fields, so model-supplied `env`/`stdin` keys are ignored and cannot replace the managed values. A model already has equivalent command-local power through shell syntax (`FOO=bar cmd`, a heredoc); ambient-secret protection comes from `dsh-bash-local`'s credential scrub, while `dshEnv` ownership prevents stale or spoofed Harness context. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
## Permissions and escalation
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
|
||||
@@ -55,8 +55,10 @@
|
||||
* @module @deepseek-ai/dsh-tool-bash
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { Service, type Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { homedir } from 'node:os'
|
||||
import { isAbsolute, join, 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'
|
||||
@@ -69,11 +71,178 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
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 { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, BashTask, CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
bashEnv: BashEnvRegistry
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'tool-bash'
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
|
||||
/** Configuration for the bash tool and its managed child environment. */
|
||||
export interface Config {
|
||||
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
}
|
||||
|
||||
/** Runtime configuration schema for the bash tool plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
})
|
||||
|
||||
/** Model-visible metadata for one managed `DSH_*` environment variable. */
|
||||
export interface BashEnvVariable {
|
||||
/** Concise description of the environment fact represented by the variable. */
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin contribution to the managed environment of each model bash call.
|
||||
* Declared keys make ownership conflicts detectable before the first command;
|
||||
* `resolve` computes only the values available for the current execution.
|
||||
*/
|
||||
export interface BashEnvContributor {
|
||||
/** Stable contributor name used in diagnostics and duplicate detection. */
|
||||
name: string
|
||||
/** Complete set of `DSH_*` keys this contributor may return. */
|
||||
variables: Readonly<Record<`DSH_${string}`, BashEnvVariable>>
|
||||
/**
|
||||
* Resolve this contributor's available values for one tool execution.
|
||||
* @param execution - the bash tool execution and its optional calling agent.
|
||||
* @returns a partial map containing only keys declared in {@link variables}.
|
||||
*/
|
||||
resolve(execution: ToolExecution): Readonly<Partial<Record<`DSH_${string}`, string>>>
|
||||
}
|
||||
|
||||
/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */
|
||||
export interface BashEnvVariableInfo extends BashEnvVariable {
|
||||
/** Contributor that owns the variable. */
|
||||
contributor: string
|
||||
/** Declared `DSH_*` environment variable name. */
|
||||
key: `DSH_${string}`
|
||||
}
|
||||
|
||||
const RESERVED_BASH_ENV_KEYS = new Set<`DSH_${string}`>([
|
||||
'DSH_HOME',
|
||||
'DSH_SHELL',
|
||||
'DSH_SESSION_ID',
|
||||
])
|
||||
const BASH_ENV_KEY = /^DSH_[A-Z][A-Z0-9_]*$/
|
||||
|
||||
/**
|
||||
* Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.
|
||||
* The namespace is rebuilt for every model bash call: ambient `DSH_*` values
|
||||
* are discarded by the executor, then the registry's current snapshot is
|
||||
* injected. Built-in shell facts remain owned by the registry itself while
|
||||
* plugins can register additional, enumerable facts with effect-scoped
|
||||
* disposal.
|
||||
*/
|
||||
export class BashEnvRegistry extends Service {
|
||||
private readonly contributors = new Map<string, BashEnvContributor>()
|
||||
private readonly keyOwners = new Map<`DSH_${string}`, string>()
|
||||
private readonly dshHome: string
|
||||
|
||||
/**
|
||||
* Create and install the `ctx.bashEnv` service.
|
||||
* @param ctx - Cordis context that owns the service and registrations.
|
||||
* @param config - home-directory configuration for the built-in variables.
|
||||
*/
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'bashEnv')
|
||||
this.dshHome = resolvePath(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one environment contributor. Names and keys are unique; built-in
|
||||
* keys are reserved. Registration is disposed with the calling plugin fiber.
|
||||
* @param contributor - declared key ownership and per-execution resolver.
|
||||
* @returns the disposer that unregisters the contribution.
|
||||
*/
|
||||
register(contributor: BashEnvContributor): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: BashEnvRegistry) {
|
||||
if (contributor.name.trim().length === 0) {
|
||||
throw new Error('bash env contributor name must be non-empty')
|
||||
}
|
||||
if (this.contributors.has(contributor.name)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" is already registered`)
|
||||
}
|
||||
|
||||
const variables = Object.entries(contributor.variables) as [`DSH_${string}`, BashEnvVariable][]
|
||||
for (const [key, variable] of variables) {
|
||||
if (!BASH_ENV_KEY.test(key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`)
|
||||
}
|
||||
if (RESERVED_BASH_ENV_KEYS.has(key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`)
|
||||
}
|
||||
if (variable.description.trim().length === 0) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`)
|
||||
}
|
||||
const owner = this.keyOwners.get(key)
|
||||
if (owner !== undefined) {
|
||||
throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`)
|
||||
}
|
||||
}
|
||||
|
||||
this.contributors.set(contributor.name, contributor)
|
||||
for (const [key] of variables) this.keyOwners.set(key, contributor.name)
|
||||
yield () => {
|
||||
this.contributors.delete(contributor.name)
|
||||
for (const [key] of variables) this.keyOwners.delete(key)
|
||||
}
|
||||
}.bind(this), 'bashEnv.register()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the trusted `DSH_*` snapshot for one bash tool execution.
|
||||
* @param execution - the current tool execution.
|
||||
* @returns an immutable environment overlay containing built-ins and current contributions.
|
||||
*/
|
||||
collect(execution: ToolExecution): DshEnvironment {
|
||||
const values: Record<`DSH_${string}`, string> = {
|
||||
DSH_HOME: this.dshHome,
|
||||
DSH_SHELL: '1',
|
||||
}
|
||||
if (execution.agent !== undefined) {
|
||||
values.DSH_SESSION_ID = execution.agent.session.header.id
|
||||
}
|
||||
|
||||
for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const resolved = contributor.resolve(execution)
|
||||
for (const [rawKey, value] of Object.entries(resolved)) {
|
||||
const key = rawKey as `DSH_${string}`
|
||||
if (!Object.hasOwn(contributor.variables, key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`)
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`)
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate plugin-contributed variables without executing their resolvers.
|
||||
* @returns declarations sorted by environment variable name.
|
||||
*/
|
||||
list(): BashEnvVariableInfo[] {
|
||||
return [...this.contributors.values()]
|
||||
.flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({
|
||||
contributor: contributor.name,
|
||||
description: variable.description,
|
||||
key: key as `DSH_${string}`,
|
||||
})))
|
||||
.sort((left, right) => left.key.localeCompare(right.key))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the constraints the SchemaSpec can't express. `defineTool` now
|
||||
* validates parsed args against the SchemaSpec before `execute` runs (the
|
||||
@@ -168,8 +337,7 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string {
|
||||
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]`. '
|
||||
+ 'The current agent session id is available as `$DSH_SESSION_ID`; when JSONL persistence is configured, '
|
||||
+ '`$DSH_SESSION_JSONL` is its absolute target path and may not exist or contain the current unflushed turn yet. '
|
||||
+ 'Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. '
|
||||
+ '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). '
|
||||
+ '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; '
|
||||
@@ -397,22 +565,6 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
|
||||
return modelWorkdir
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the trusted per-execution session environment. Identity always comes
|
||||
* from the calling agent's immutable session header; an optional JSONL path
|
||||
* comes from the active persistence backend's side-effect-free locator. A
|
||||
* non-agent caller has no current session, so it receives neither variable.
|
||||
*/
|
||||
function sessionEnvironment(ctx: Context, exec: { agent?: Agent }): Record<string, string> | undefined {
|
||||
const agent = exec.agent
|
||||
if (agent === undefined) return undefined
|
||||
|
||||
const env: Record<string, string> = { DSH_SESSION_ID: agent.session.header.id }
|
||||
const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
|
||||
if (location?.kind === 'jsonl') env.DSH_SESSION_JSONL = location.path
|
||||
return env
|
||||
}
|
||||
|
||||
/** Status line for background task reads. */
|
||||
function statusLine(task: BashTask): string {
|
||||
switch (task.status) {
|
||||
@@ -422,7 +574,23 @@ function statusLine(task: BashTask): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const bashEnv = new BashEnvRegistry(ctx, config)
|
||||
bashEnv.register({
|
||||
name: 'session-persistence',
|
||||
variables: {
|
||||
DSH_SESSION_JSONL: {
|
||||
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
|
||||
},
|
||||
},
|
||||
resolve(execution) {
|
||||
const agent = execution.agent
|
||||
if (agent === undefined) return {}
|
||||
const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
|
||||
return location?.kind === 'jsonl' ? { DSH_SESSION_JSONL: location.path } : {}
|
||||
},
|
||||
})
|
||||
|
||||
// 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.
|
||||
@@ -614,13 +782,13 @@ export function apply(ctx: Context): void {
|
||||
// session runs in its own workspace (see resolveWorkdir); an explicit
|
||||
// model workdir still wins.
|
||||
const workdir = resolveWorkdir(args.workdir, exec)
|
||||
const env = sessionEnvironment(ctx, exec)
|
||||
const dshEnv = bashEnv.collect(exec)
|
||||
const request = {
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
...env !== undefined ? { env } : {},
|
||||
dshEnv,
|
||||
...sandboxMode !== undefined ? { sandboxMode } : {},
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
|
||||
189
packages/bash/tool-bash/tests/bash-env.spec.ts
Normal file
189
packages/bash/tool-bash/tests/bash-env.spec.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
function execution(sessionId?: string): ToolExecution {
|
||||
return {
|
||||
callId: CallId('bash-env-call'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true' },
|
||||
...(sessionId === undefined
|
||||
? {}
|
||||
: { agent: { session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as Agent }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('BashEnvRegistry', () => {
|
||||
it('collects unconditional shell facts and the current agent session id', () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
|
||||
expect(registry.collect(execution())).toEqual({
|
||||
DSH_HOME: resolve('./test-dsh-home'),
|
||||
DSH_SHELL: '1',
|
||||
})
|
||||
expect(registry.collect(execution('session-a'))).toEqual({
|
||||
DSH_HOME: resolve('./test-dsh-home'),
|
||||
DSH_SESSION_ID: 'session-a',
|
||||
DSH_SHELL: '1',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves DSH_HOME from the ambient override or the user-home default', () => {
|
||||
vi.stubEnv('DSH_HOME', './ambient-dsh-home')
|
||||
const fromEnvironment = new BashEnvRegistry(new Context())
|
||||
expect(fromEnvironment.collect(execution()).DSH_HOME).toBe(resolve('./ambient-dsh-home'))
|
||||
|
||||
vi.stubEnv('DSH_HOME', undefined)
|
||||
const fromDefault = new BashEnvRegistry(new Context())
|
||||
expect(fromDefault.collect(execution()).DSH_HOME).toBe(join(homedir(), '.dsh'))
|
||||
})
|
||||
|
||||
it('collects declared contributor variables and omits unavailable values', () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'optional-session-fact',
|
||||
variables: {
|
||||
DSH_SESSION_OPTIONAL: { description: 'Optional session-scoped test fact.' },
|
||||
},
|
||||
resolve: exec => exec.agent === undefined ? {} : { DSH_SESSION_OPTIONAL: exec.agent.session.header.id },
|
||||
})
|
||||
registry.register({
|
||||
name: 'always-available-fact',
|
||||
variables: {
|
||||
DSH_ALWAYS_AVAILABLE: { description: 'Always-available test fact.' },
|
||||
},
|
||||
resolve: () => ({ DSH_ALWAYS_AVAILABLE: 'yes' }),
|
||||
})
|
||||
|
||||
expect(registry.collect(execution())).not.toHaveProperty('DSH_SESSION_OPTIONAL')
|
||||
expect(registry.collect(execution()).DSH_ALWAYS_AVAILABLE).toBe('yes')
|
||||
expect(registry.collect(execution('session-b')).DSH_SESSION_OPTIONAL).toBe('session-b')
|
||||
expect(registry.list()).toEqual([
|
||||
{
|
||||
contributor: 'always-available-fact',
|
||||
description: 'Always-available test fact.',
|
||||
key: 'DSH_ALWAYS_AVAILABLE',
|
||||
},
|
||||
{
|
||||
contributor: 'optional-session-fact',
|
||||
description: 'Optional session-scoped test fact.',
|
||||
key: 'DSH_SESSION_OPTIONAL',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects duplicate variable ownership at registration time', () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'first',
|
||||
variables: { DSH_SHARED: { description: 'First owner.' } },
|
||||
resolve: () => ({ DSH_SHARED: 'first' }),
|
||||
})
|
||||
|
||||
expect(() => registry.register({
|
||||
name: 'second',
|
||||
variables: { DSH_SHARED: { description: 'Second owner.' } },
|
||||
resolve: () => ({ DSH_SHARED: 'second' }),
|
||||
})).toThrow(/DSH_SHARED.*first.*second|DSH_SHARED.*second.*first/)
|
||||
})
|
||||
|
||||
it('rejects duplicate contributor names and malformed declarations', () => {
|
||||
const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'declared',
|
||||
variables: { DSH_DECLARED: { description: 'Declared fact.' } },
|
||||
resolve: () => ({}),
|
||||
})
|
||||
|
||||
expect(() => registry.register({
|
||||
name: 'declared',
|
||||
variables: { DSH_ANOTHER: { description: 'Another fact.' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/already registered/)
|
||||
expect(() => registry.register({
|
||||
name: ' ',
|
||||
variables: { DSH_BLANK_NAME: { description: 'Blank owner.' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/name must be non-empty/)
|
||||
expect(() => registry.register({
|
||||
name: 'invalid-key',
|
||||
variables: { dsh_invalid: { description: 'Invalid key.' } } as unknown as Record<'DSH_INVALID', { description: string }>,
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/invalid key/)
|
||||
expect(() => registry.register({
|
||||
name: 'reserved-key',
|
||||
variables: { DSH_HOME: { description: 'Reserved key.' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/reserved key/)
|
||||
expect(() => registry.register({
|
||||
name: 'blank-description',
|
||||
variables: { DSH_BLANK_DESCRIPTION: { description: ' ' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/must describe/)
|
||||
})
|
||||
|
||||
it('rejects undeclared variables returned by a contributor', () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'drifted-provider',
|
||||
variables: { DSH_DECLARED: { description: 'Declared fact.' } },
|
||||
resolve: () => ({ DSH_UNDECLARED: 'bad' }),
|
||||
})
|
||||
|
||||
expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/)
|
||||
})
|
||||
|
||||
it('rejects non-string values returned by a contributor', () => {
|
||||
const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'wrong-value-type',
|
||||
variables: { DSH_STRING: { description: 'String fact.' } },
|
||||
resolve: () => ({ DSH_STRING: 42 }) as unknown as Record<'DSH_STRING', string>,
|
||||
})
|
||||
|
||||
expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/)
|
||||
})
|
||||
|
||||
it('removes an effect-scoped contributor when its plugin is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
const fiber = await ctx.plugin({
|
||||
inject: ['bashEnv'],
|
||||
apply(inner: Context) {
|
||||
inner.bashEnv.register({
|
||||
name: 'temporary',
|
||||
variables: { DSH_TEMPORARY: { description: 'Temporary fact.' } },
|
||||
resolve: () => ({ DSH_TEMPORARY: 'present' }),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(registry.collect(execution()).DSH_TEMPORARY).toBe('present')
|
||||
await fiber.dispose()
|
||||
expect(registry.collect(execution())).not.toHaveProperty('DSH_TEMPORARY')
|
||||
})
|
||||
|
||||
it('returns an explicit contributor disposer', () => {
|
||||
const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
|
||||
const dispose = registry.register({
|
||||
name: 'explicit-disposal',
|
||||
variables: { DSH_EXPLICIT_DISPOSAL: { description: 'Explicitly disposed fact.' } },
|
||||
resolve: () => ({ DSH_EXPLICIT_DISPOSAL: 'present' }),
|
||||
})
|
||||
|
||||
expect(registry.collect(execution()).DSH_EXPLICIT_DISPOSAL).toBe('present')
|
||||
dispose()
|
||||
expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -21,7 +21,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
|
||||
* through the agent loop, exercising the same seams a live model would
|
||||
* (tool/call + tool/result session events, agent.inject notifications).
|
||||
*/
|
||||
async function harness(adapter: MockAdapter, sessionRoot?: string) {
|
||||
async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -31,13 +31,16 @@ async function harness(adapter: MockAdapter, sessionRoot?: string) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) })
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -79,14 +82,16 @@ describe('bash tool through the agent loop', () => {
|
||||
it('first-turn bash receives session identity before the lazy JSONL file materializes', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-bash-session-env-'))
|
||||
dirs.push(root)
|
||||
const dshHome = join(root, 'dsh-home')
|
||||
vi.stubEnv('DSH_STALE_PARENT', 'stale')
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-1', 'bash', {
|
||||
command: 'printf \'%s\\n%s\\n\' "$DSH_SESSION_ID" "$DSH_SESSION_JSONL"; if [ -e "$DSH_SESSION_JSONL" ]; then printf \'present\\n\'; else printf \'absent\\n\'; fi',
|
||||
command: 'printf \'%s\\n%s\\n%s\\n%s\\n%s\\n\' "$DSH_HOME" "$DSH_SHELL" "$DSH_SESSION_ID" "$DSH_SESSION_JSONL" "${DSH_STALE_PARENT-unset}"; if [ -e "$DSH_SESSION_JSONL" ]; then printf \'present\\n\'; else printf \'absent\\n\'; fi',
|
||||
description: 'inspect session environment',
|
||||
}),
|
||||
textResponse('Session environment inspected.'),
|
||||
])
|
||||
const ctx = await harness(adapter, root)
|
||||
const ctx = await harness(adapter, root, dshHome)
|
||||
const handle = ctx.agents.create({
|
||||
agentId: AgentId('session-env'),
|
||||
sessionId: SessionId('session-env-id'),
|
||||
@@ -100,7 +105,7 @@ describe('bash tool through the agent loop', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const result = findEvent(events(agent), 'tool/result')
|
||||
expect(resultText(result)).toBe(`session-env-id\n${location?.path}\nabsent\n`)
|
||||
expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`)
|
||||
expect(existsSync(location!.path)).toBe(true)
|
||||
const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string }
|
||||
expect(header).toMatchObject({ type: 'session', id: 'session-env-id' })
|
||||
|
||||
@@ -892,6 +892,8 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
})
|
||||
|
||||
describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => {
|
||||
const recordingDshHome = join(spillDir, 'dsh-home')
|
||||
|
||||
/**
|
||||
* Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a
|
||||
* test can assert what the model-facing tool DID and DID NOT forward. The `bash`
|
||||
@@ -899,7 +901,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
* model that power), so it must build its request from named args only and
|
||||
* never spread unknown tool-call keys into it. This guard's job is to catch a
|
||||
* future refactor that blindly forwards `...args` — which would silently thread
|
||||
* model input into the post-scrub `env` merge — NOT to defend a trust boundary
|
||||
* model input into the ordinary `env` channel — NOT to defend a trust boundary
|
||||
* (the credential scrub in dsh-bash-local is the security control; see the
|
||||
* bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()` is
|
||||
* unused here.
|
||||
@@ -915,6 +917,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
owner: request.owner,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
@@ -943,15 +946,15 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: join(spillDir, 'jsonl') })
|
||||
}
|
||||
await ctx.plugin(RecordingBashExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
await ctx.plugin(ToolBash, { dshHome: recordingDshHome })
|
||||
return { ctx, bash: ctx.bash as RecordingBashExecutor }
|
||||
}
|
||||
|
||||
it('describes the trusted session variables to the model', async () => {
|
||||
it('describes the managed harness environment namespace to the model', async () => {
|
||||
const { ctx } = await setupRecording()
|
||||
const description = ctx.tools.get('bash')?.description ?? ''
|
||||
expect(description).toContain('DSH_SESSION_ID')
|
||||
expect(description).toContain('DSH_SESSION_JSONL')
|
||||
expect(description).toContain('$DSH_*')
|
||||
expect(description).not.toContain('DSH_SESSION_JSONL')
|
||||
})
|
||||
|
||||
it('injects the session id and JSONL target path into a foreground request', async () => {
|
||||
@@ -966,9 +969,11 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
agent,
|
||||
})
|
||||
|
||||
expect(bash.requests[0]?.env).toEqual({
|
||||
expect(bash.requests[0]?.dshEnv).toEqual({
|
||||
DSH_HOME: recordingDshHome,
|
||||
DSH_SESSION_ID: 'request-fg',
|
||||
DSH_SESSION_JSONL: path,
|
||||
DSH_SHELL: '1',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -989,13 +994,16 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
agent,
|
||||
})
|
||||
|
||||
expect(bash.requests[0]?.env).toEqual({
|
||||
expect(bash.requests[0]?.env).toBeUndefined()
|
||||
expect(bash.requests[0]?.dshEnv).toEqual({
|
||||
DSH_HOME: recordingDshHome,
|
||||
DSH_SESSION_ID: 'request-bg',
|
||||
DSH_SESSION_JSONL: path,
|
||||
DSH_SHELL: '1',
|
||||
})
|
||||
})
|
||||
|
||||
it('injects only the stable session id when no JSONL locator is available', async () => {
|
||||
it('injects built-ins and the stable session id when no JSONL locator is available', async () => {
|
||||
const { ctx, bash } = await setupRecording()
|
||||
const agent = registerFakeAgent(ctx, 'request-id-only', () => undefined)
|
||||
const ambient = process.env.DSH_SESSION_ID
|
||||
@@ -1007,7 +1015,11 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
agent,
|
||||
})
|
||||
|
||||
expect(bash.requests[0]?.env).toEqual({ DSH_SESSION_ID: 'request-id-only' })
|
||||
expect(bash.requests[0]?.dshEnv).toEqual({
|
||||
DSH_HOME: recordingDshHome,
|
||||
DSH_SESSION_ID: 'request-id-only',
|
||||
DSH_SHELL: '1',
|
||||
})
|
||||
expect(process.env.DSH_SESSION_ID).toBe(ambient)
|
||||
})
|
||||
|
||||
@@ -1025,17 +1037,21 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
})
|
||||
}
|
||||
|
||||
expect(bash.requests.map(request => request.env)).toEqual([
|
||||
expect(bash.requests.map(request => request.dshEnv)).toEqual([
|
||||
{
|
||||
DSH_HOME: recordingDshHome,
|
||||
DSH_SESSION_ID: 'request-parent',
|
||||
DSH_SESSION_JSONL: ctx.sessionPersistence.locate(parent.session.header)?.path,
|
||||
DSH_SHELL: '1',
|
||||
},
|
||||
{
|
||||
DSH_HOME: recordingDshHome,
|
||||
DSH_SESSION_ID: 'request-child',
|
||||
DSH_SESSION_JSONL: ctx.sessionPersistence.locate(child.session.header)?.path,
|
||||
DSH_SHELL: '1',
|
||||
},
|
||||
])
|
||||
expect(bash.requests[0]?.env?.DSH_SESSION_JSONL).not.toBe(bash.requests[1]?.env?.DSH_SESSION_JSONL)
|
||||
expect(bash.requests[0]?.dshEnv?.DSH_SESSION_JSONL).not.toBe(bash.requests[1]?.dshEnv?.DSH_SESSION_JSONL)
|
||||
})
|
||||
|
||||
it('does not forward env/stdin even when the model includes them as extra arguments', async () => {
|
||||
|
||||
@@ -95,6 +95,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
'onTaskDone(listener: BashTaskListener): () => void',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'bashEnv',
|
||||
summary: 'Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.',
|
||||
methods: [
|
||||
'register(contributor: BashEnvContributor): () => void',
|
||||
'collect(execution: ToolExecution): DshEnvironment',
|
||||
'list(): BashEnvVariableInfo[]',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'codeRuntime',
|
||||
summary: 'Abstract code-execution service.',
|
||||
@@ -524,13 +533,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'AssembledSection',
|
||||
declaration: 'export interface AssembledSection {\n name: string;\n order: number;\n text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashEnvContributor',
|
||||
declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly<Record<`DSH_${string}`, BashEnvVariable>>;\n resolve(execution: ToolExecution): Readonly<Partial<Record<`DSH_${string}`, string>>>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashEnvVariable',
|
||||
declaration: 'export interface BashEnvVariable {\n description: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashEnvVariableInfo',
|
||||
declaration: 'export interface BashEnvVariableInfo extends BashEnvVariable {\n contributor: string;\n key: `DSH_${string}`;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashExecRequest',
|
||||
declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n owner?: OwnerToken | undefined;\n sandboxMode?: SandboxMode | undefined;\n}',
|
||||
declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n owner?: OwnerToken | undefined;\n sandboxMode?: SandboxMode | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashExecSpec',
|
||||
declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n owner: OwnerToken | undefined;\n sandboxMode: SandboxMode | undefined;\n}',
|
||||
declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n owner: OwnerToken | undefined;\n sandboxMode: SandboxMode | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashRunResult',
|
||||
@@ -636,6 +657,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'DiffResultView',
|
||||
declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'DshEnvironment',
|
||||
declaration: 'export type DshEnvironment = Readonly<Record<`DSH_${string}`, string>>;',
|
||||
},
|
||||
{
|
||||
name: 'FileDiff',
|
||||
declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}',
|
||||
|
||||
@@ -39,11 +39,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-core'
|
||||
// { agents?, persona?, toolOrder?, tools?, skills? } — the schema intersects the owner schemas,
|
||||
// { agents?, persona?, toolOrder?, tools?, dshHome?, skills? } — the schema intersects the owner schemas,
|
||||
// so validation and defaulting can never drift from the owners.
|
||||
```
|
||||
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry; `dshHome` to tool-bash's managed environment and the local skill provider; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly.
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { resolve as resolvePath } from 'node:path'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import z from 'schemastery'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
@@ -78,7 +79,8 @@ export interface SkillConfig {
|
||||
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
|
||||
* plugin (the deployment's persona section and the explicit model-facing tool
|
||||
* order), the `tools` object to the tool registry (its presentation `mode`),
|
||||
* and `skills` to the skill registry/local provider/tool consumer. Every field
|
||||
* `dshHome` to the bash environment registry and local skill provider, and
|
||||
* `skills` to the skill registry/local provider/tool consumer. Every field
|
||||
* is optional INPUT here because each owner's schema supplies the default;
|
||||
* the schema is the INTERSECTION of the owners' own schemas (with registry
|
||||
* schemas nested under their bundle keys), so validation and defaulting can
|
||||
@@ -93,6 +95,8 @@ export interface Config {
|
||||
toolOrder?: SystemPromptConfig['toolOrder']
|
||||
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory shared by shell context and local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Skill registry, local provider, and model-facing consumer config. */
|
||||
skills?: SkillConfig
|
||||
}
|
||||
@@ -108,7 +112,7 @@ export const SkillConfigSchema: z<SkillConfig> = z.object({
|
||||
export const Config = z.intersect([
|
||||
AgentLoop.Config,
|
||||
SystemPrompt.Config,
|
||||
z.object({ tools: ToolRegistry.Config, skills: SkillConfigSchema }),
|
||||
z.object({ tools: ToolRegistry.Config, dshHome: z.string(), skills: SkillConfigSchema }),
|
||||
]) as unknown as z<Config>
|
||||
|
||||
/**
|
||||
@@ -121,6 +125,13 @@ export const Config = z.intersect([
|
||||
* then the loop that drives them.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const nestedDshHome = config.skills?.local?.dshHome
|
||||
if (config.dshHome !== undefined && nestedDshHome !== undefined
|
||||
&& resolvePath(config.dshHome) !== resolvePath(nestedDshHome)) {
|
||||
throw new Error('agent-core: dshHome and skills.local.dshHome must resolve to the same directory')
|
||||
}
|
||||
const dshHome = config.dshHome ?? nestedDshHome
|
||||
|
||||
ctx.plugin(Timer)
|
||||
ctx.plugin(LlmService)
|
||||
ctx.plugin(SessionStore)
|
||||
@@ -136,10 +147,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
ctx.plugin(ToolRegistry, config.tools ?? {})
|
||||
ctx.plugin(SkillService, config.skills?.registry ?? {})
|
||||
ctx.plugin(SkillLocal, config.skills?.local ?? {})
|
||||
ctx.plugin(SkillLocal, Object.assign(
|
||||
{},
|
||||
config.skills?.local,
|
||||
dshHome === undefined ? {} : { dshHome },
|
||||
))
|
||||
ctx.plugin(AgentRegistry)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(toolBash)
|
||||
ctx.plugin(toolBash, dshHome === undefined ? {} : { dshHome })
|
||||
ctx.plugin(toolSkill, config.skills?.tool ?? {})
|
||||
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
|
||||
}
|
||||
|
||||
@@ -2,12 +2,24 @@ import { describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import { Context, Service } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as agentCore from '../src/index.ts'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Minimal service that lets the executor-less bundle activate tool-bash in config-forwarding tests. */
|
||||
class StubBashService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'bash')
|
||||
}
|
||||
|
||||
onTaskDone(): () => void {
|
||||
return () => undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
@@ -152,6 +164,39 @@ describe('dsh-agent-core bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('shares top-level dshHome between local skills and the managed bash environment', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-home-'))
|
||||
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-agents-'))
|
||||
await mkdir(join(home, 'skills'), { recursive: true })
|
||||
await writeFile(join(home, 'skills', 'shared-skill.md'), '---\nname: shared-skill\ndescription: Shared home skill\n---\n\nShared body.\n')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubBashService)
|
||||
await ctx.plugin(agentCore, {
|
||||
dshHome: home,
|
||||
skills: { local: { agentsHome } },
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['shared-skill'])
|
||||
const execution: ToolExecution = {
|
||||
callId: CallId('agent-core-dsh-home'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true' },
|
||||
}
|
||||
expect(ctx.bashEnv.collect(execution)).toMatchObject({ DSH_HOME: home, DSH_SHELL: '1' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects conflicting global and nested DSH home directories', () => {
|
||||
expect(() => {
|
||||
agentCore.apply(new Context(), {
|
||||
dshHome: '/global-dsh-home',
|
||||
skills: { local: { dshHome: '/nested-dsh-home' } },
|
||||
})
|
||||
}).toThrow(/must resolve to the same directory/)
|
||||
})
|
||||
|
||||
it('uses the default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -27,6 +27,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
|
||||
| `model` | (required) | the per-session agent template the bridge creates agents from |
|
||||
| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
|
||||
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
|
||||
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`).
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface Config {
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
@@ -72,6 +74,7 @@ export const Config: z<Config> = z.object({
|
||||
// schemastery's native [] default would read as an invalid configured list.
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
tools: ToolRegistry.Config,
|
||||
dshHome: z.string(),
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
})
|
||||
@@ -88,6 +91,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||
...config.tools !== undefined ? { tools: config.tools } : {},
|
||||
...config.dshHome !== undefined ? { dshHome: config.dshHome } : {},
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
})
|
||||
ctx.plugin(UserInteractionService)
|
||||
|
||||
@@ -104,7 +104,8 @@ describe('dsh-acp-agent composition', () => {
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
|
||||
const skills = await isolatedSkillsConfig(6)
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills })
|
||||
ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...')
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -28,6 +28,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
|
||||
| `model` | (required) | the pre-created `main` agent's model |
|
||||
| `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` |
|
||||
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
| `welcome` | `ready.` | the stdin-chat banner |
|
||||
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface Config {
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||
@@ -93,6 +95,7 @@ export const Config: z<Config> = z.object({
|
||||
// schemastery's native [] default would read as an invalid configured list.
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
tools: ToolRegistry.Config,
|
||||
dshHome: z.string(),
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
welcome: z.string().default('ready.'),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
@@ -112,6 +115,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||
...config.tools !== undefined ? { tools: config.tools } : {},
|
||||
...config.dshHome !== undefined ? { dshHome: config.dshHome } : {},
|
||||
agents: [{
|
||||
id: AgentId('main'),
|
||||
model: config.model,
|
||||
|
||||
@@ -129,7 +129,8 @@ describe('dsh-stdio-agent app', () => {
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
|
||||
const skills = await isolatedSkillsConfig(6)
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills })
|
||||
ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
72
pnpm-lock.yaml
generated
72
pnpm-lock.yaml
generated
@@ -75,31 +75,6 @@ importers:
|
||||
specifier: ^4.1.8
|
||||
version: 4.1.8(@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))
|
||||
|
||||
packages/ui/user-approval:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
|
||||
packages/bash/bash:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-brand':
|
||||
@@ -157,6 +132,10 @@ importers:
|
||||
version: 0.0.0-test.0
|
||||
|
||||
packages/bash/tool-bash:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
@@ -164,9 +143,6 @@ importers:
|
||||
'@deepseek-ai/dsh-agent-loop':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent-loop
|
||||
'@deepseek-ai/dsh-user-approval':
|
||||
specifier: workspace:^
|
||||
version: link:../../ui/user-approval
|
||||
'@deepseek-ai/dsh-bash':
|
||||
specifier: workspace:^
|
||||
version: link:../bash
|
||||
@@ -200,6 +176,9 @@ importers:
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
'@deepseek-ai/dsh-user-approval':
|
||||
specifier: workspace:^
|
||||
version: link:../../ui/user-approval
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
@@ -431,9 +410,6 @@ importers:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../agent
|
||||
'@deepseek-ai/dsh-user-approval':
|
||||
specifier: workspace:^
|
||||
version: link:../../ui/user-approval
|
||||
'@deepseek-ai/dsh-code-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../code-runtime/code-runtime
|
||||
@@ -446,6 +422,9 @@ importers:
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../system-prompt
|
||||
'@deepseek-ai/dsh-user-approval':
|
||||
specifier: workspace:^
|
||||
version: link:../../ui/user-approval
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
@@ -1153,9 +1132,6 @@ importers:
|
||||
'@deepseek-ai/dsh-agent-loop':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent-loop
|
||||
'@deepseek-ai/dsh-user-approval':
|
||||
specifier: workspace:^
|
||||
version: link:../user-approval
|
||||
'@deepseek-ai/dsh-bash':
|
||||
specifier: workspace:^
|
||||
version: link:../../bash/bash
|
||||
@@ -1204,6 +1180,9 @@ importers:
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
'@deepseek-ai/dsh-user-approval':
|
||||
specifier: workspace:^
|
||||
version: link:../user-approval
|
||||
'@deepseek-ai/dsh-user-interaction':
|
||||
specifier: workspace:^
|
||||
version: link:../user-interaction
|
||||
@@ -1328,6 +1307,31 @@ importers:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
|
||||
packages/ui/user-approval:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
|
||||
packages/ui/user-interaction:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
|
||||
@@ -170,6 +170,13 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
|
||||
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.',
|
||||
},
|
||||
{
|
||||
key: 'bashEnv',
|
||||
pkg: 'tool-bash',
|
||||
title: 'Managed bash environment registry',
|
||||
mode: 'core',
|
||||
note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.',
|
||||
},
|
||||
{
|
||||
key: 'sandbox',
|
||||
pkg: 'sandbox',
|
||||
|
||||
Reference in New Issue
Block a user