feat(bash): generalize managed shell environment

This commit is contained in:
Yichen Jiang
2026-07-12 15:41:42 +08:00
parent acdbe7b828
commit df9617aaff
40 changed files with 790 additions and 197 deletions

View File

@@ -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 |

View File

@@ -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). |

View File

@@ -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))

View File

@@ -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)

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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
{