Merge remote-tracking branch 'origin/master' into worktree/provider-routed-llm-adapters

# Conflicts:
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/session/README.md
#	packages/core/session/src/types.ts
#	packages/core/session/tests/surface.spec.ts
#	packages/examples/acp-demo/tests/acp-agent.spec.ts
#	packages/examples/stdio-demo/tests/stdio-agent.spec.ts
#	packages/hooks/hooks-claude/tests/coverage.spec.ts
#	packages/hooks/hooks-codex/tests/coverage.spec.ts
#	packages/session-query/session-query/tests/session-query.spec.ts
#	packages/support/invariants/tests/invariants.spec.ts
#	packages/ui/acp/tests/harness.ts
This commit is contained in:
Tianyi Cui
2026-07-17 21:56:10 +08:00
358 changed files with 18578 additions and 2877 deletions

View File

@@ -23,8 +23,8 @@ 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).
- **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. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. 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*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
## Model Experience

View File

@@ -92,15 +92,22 @@ export class LocalBashExecutor extends BashExecutor {
this.config.maxTimeoutMs,
'bash-local: request.timeoutMs',
)
const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
return {
command: request.command,
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
timeoutMs,
stdoutMaxBytes,
...request.signal ? { signal: request.signal } : {},
// Explicit environment values are merged after credential scrubbing 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 } : {},
// Local execution carries this override for sandboxing subclasses.
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
// Carry a sandbox-mode override through verbatim: this executor never
// confines, so the field is inert here (the seam contract) — a
// sandboxing subclass overrides resolve() to stamp its default instead.
sandboxMode: request.sandboxMode,
}
}
@@ -111,11 +118,13 @@ export class LocalBashExecutor extends BashExecutor {
const outcome = await runBash({
command: spec.command,
cwd: spec.workdir,
maxOutputBytes: this.config.maxOutputBytes,
stdoutMaxBytes: spec.stdoutMaxBytes,
stderrMaxBytes: this.config.maxOutputBytes,
graceMs: this.config.graceMs,
signal: d.signal,
stdin: spec.stdin,
env: spec.env,
dshEnv: spec.dshEnv,
}, this.internals).done
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
@@ -128,11 +137,13 @@ export class LocalBashExecutor extends BashExecutor {
const running = runBash({
command: spec.command,
cwd: spec.workdir,
maxOutputBytes: this.config.maxOutputBytes,
stdoutMaxBytes: this.config.maxOutputBytes,
stderrMaxBytes: this.config.maxOutputBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
stdin: spec.stdin,
env: spec.env,
dshEnv: spec.dshEnv,
}, this.internals)
let stdoutOffset = 0

View File

@@ -11,7 +11,8 @@ 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 { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash'
/**
* Model-friendly environment overrides: disable colors, pagers, and
@@ -34,26 +35,43 @@ export const ENV_OVERRIDES = {
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/**
* Build a child environment by scrubbing credential-shaped ambient variables,
* applying model-friendly overrides, then merging trusted caller entries last.
*
* @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides.
* Build a child environment from scrubbed ambient values, terminal overrides,
* ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed
* names are removed; ordinary and managed entries reject the other channel's
* namespace before `dshEnv` merges last.
* @param extra - caller entries; `DSH_*` names are rejected.
* @param dshEnv - managed entries; non-`DSH_*` names are rejected.
* @returns the environment to hand to `spawn` for the child process.
*/
export function childEnv(extra?: 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_PREFIX)) env[key] = value
}
return { ...env, ...ENV_OVERRIDES, ...extra }
for (const key of Object.keys(extra ?? {})) {
if (key.startsWith(DSH_ENV_PREFIX)) {
throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`)
}
}
for (const key of Object.keys(dshEnv ?? {})) {
if (!key.startsWith(DSH_ENV_PREFIX)) {
throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`)
}
}
return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv }
}
/** What to run and under which limits (resolved — no defaults in here). */
export interface SpawnSpec {
command: string
cwd: string
/** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */
maxOutputBytes: number
/** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */
stdoutMaxBytes: number
/** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
stderrMaxBytes: number
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
graceMs: number
/**
@@ -71,12 +89,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 entries; non-`DSH_*` names are rejected before spawn. */
dshEnv?: DshEnvironment | undefined
}
/**
@@ -278,13 +296,13 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
}
// Keep absent stdin as /dev/null; literal tuples preserve non-null output types.
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 })
const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir)
const stdout = new OutputCollector(spec.stdoutMaxBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.stderrMaxBytes, 'stderr', spillDir)
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })

View File

@@ -71,6 +71,23 @@ describe('LocalBashExecutor.run', () => {
const { bash } = await setup()
expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
expect(() => bash.resolve({ command: 'true', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/)
expect(() => bash.resolve({ command: 'true', stdoutMaxBytes: -1 })).toThrow(/request\.stdoutMaxBytes/)
})
it('defaults stdoutMaxBytes to maxOutputBytes and lets foreground callers raise stdout only', async () => {
const { bash } = await setup({ maxOutputBytes: 100 })
expect(bash.resolve({ command: 'true' }).stdoutMaxBytes).toBe(100)
const result = await bash.run(bash.resolve({
command: 'printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2',
stdoutMaxBytes: 500,
}))
expect(result.stdout.truncated).toBe(false)
expect(result.stdout.text).toBe('x'.repeat(500))
expect(result.stderr.truncated).toBe(true)
expect(result.stderr.text.length).toBeLessThanOrEqual(100)
})
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
@@ -110,21 +127,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)
})
})
@@ -143,11 +167,12 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('threads stdin and extra env into a background process', async () => {
const { bash } = await setup()
const proc = 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 output = await readUntil(proc, '[bg-env]')
const output = await readUntil(proc, '[bg-env][bg-dsh-env]')
expect(output).toContain('bg-stdin')
await proc.done
expect(proc.exitCode).toBe(0)

View File

@@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { DshEnvironment } from '@deepseek-ai/dsh-bash'
import { killGroup, OutputCollector, runBash } from '../src/run.ts'
import type { RunningBash } from '../src/run.ts'
@@ -26,7 +27,8 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]>
return {
command,
cwd: process.cwd(),
maxOutputBytes: 64_000,
stdoutMaxBytes: 64_000,
stderrMaxBytes: 64_000,
graceMs: 3_000,
...overrides,
}
@@ -197,19 +199,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')
})
@@ -224,10 +226,24 @@ describe('stdin and extra env (set by in-process plugins)', () => {
})
describe('output truncation and spill', () => {
it('applies stdout and stderr caps independently', async () => {
const result = await runBash(
spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', {
stdoutMaxBytes: 500,
stderrMaxBytes: 100,
}),
{ spillDir },
).done
expect(result.stdout.truncated).toBe(false)
expect(result.stdout.text).toBe('x'.repeat(500))
expect(result.stderr.truncated).toBe(true)
expect(result.stderr.text.length).toBeLessThanOrEqual(100)
})
it('keeps the tail and spills the full stream to disk', async () => {
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
expect(result.stdout.truncated).toBe(true)
@@ -242,7 +258,7 @@ describe('output truncation and spill', () => {
it('does not truncate output exactly at the cap', async () => {
const result = await runBash(
spec('printf "%.0sx" $(seq 1 500)', { maxOutputBytes: 500 }),
spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
expect(result.stdout.truncated).toBe(false)
@@ -253,7 +269,7 @@ describe('output truncation and spill', () => {
it('settles with the tail and no spill path when final spill close fails', async () => {
failNextClose.value = true
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
expect(failNextClose.value).toBe(false)
@@ -348,13 +364,13 @@ describe('abort edge cases', () => {
})
describe('environment and spill-file 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
@@ -362,9 +378,32 @@ describe('environment and spill-file 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('rejects ordinary variables on the managed env channel', () => {
const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment
expect(() => runBash(spec('true', { dshEnv: invalid })))
.toThrow(/managed bash env.*PATH.*use env/)
})
it('creates spill files with owner-only permissions and random names', async () => {
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
const path = result.stdout.spillPath!
@@ -375,7 +414,7 @@ describe('environment and spill-file hardening', () => {
it('defaults spills into a private per-process directory', async () => {
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
).done
const dir = dirname(result.stdout.spillPath!)
expect(dir).toMatch(/dsh-bash-/)

View File

@@ -27,11 +27,11 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
## Vocabulary
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, sandboxMode) before execution. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path. `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
`stdin` and `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; a missing value means "none". 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 managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env 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).
## Model Experience

View File

@@ -9,6 +9,7 @@ import { Context, Service } from 'cordis'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
export { DSH_ENV_PREFIX } from './types.ts'
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
export type {
BashExecRequest,
@@ -19,6 +20,8 @@ export type {
BashRunResult,
BashSandboxInfo,
CollectedOutput,
DshEnvironment,
DshEnvironmentKey,
} from './types.ts'
declare module 'cordis' {

View File

@@ -6,6 +6,15 @@
import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
export const DSH_ENV_PREFIX = 'DSH_' as const
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
/** Trusted DeepSeek Harness variables for one bash execution. */
export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
/**
* Sandbox facts for one run, present iff a sandboxing executor handled it.
* Facts are reported independently of process exit status so callers can
@@ -34,6 +43,13 @@ export interface BashExecRequest {
workdir?: string | undefined
/** Timeout override in milliseconds (implementations cap it). */
timeoutMs?: number | undefined
/**
* Foreground stdout capture budget in bytes. Absent uses the executor's
* default output cap. Trusted in-process consumers use this when they must
* parse complete stdout up to their own bounded limit; the model-facing bash
* tool does not expose it as a parameter.
*/
stdoutMaxBytes?: number | undefined
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/**
@@ -45,15 +61,20 @@ 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, and
* reject non-`DSH_*` names supplied through this managed channel.
*/
dshEnv?: DshEnvironment | undefined
/** Explicit per-call sandbox mode override. */
sandboxMode?: SandboxMode | undefined
}
@@ -67,15 +88,24 @@ export interface BashExecSpec {
command: string
workdir: string
timeoutMs: number
/**
* Resolved foreground stdout capture budget in bytes. `run()` uses it for
* stdout; background tasks and stderr keep the executor's own output cap.
*/
stdoutMaxBytes: number
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/** Bytes to write to stdin before closing it; absent means no stdin. */
stdin?: string | undefined
/**
* Extra environment entries, merged after credential scrubbing so explicit
* values win; absent means no extra entries.
* 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
/** Managed `DSH_*` snapshot; implementations reject ordinary names. */
dshEnv?: DshEnvironment | undefined
/** Resolved sandbox mode; ignored by executors that do not confine. */
sandboxMode: SandboxMode | undefined
}
@@ -96,9 +126,19 @@ export interface BashRunResult {
exitCode: number | null
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
signal: NodeJS.Signals | null
/** True when the executor's own timeout killed the command. */
/**
* True when the executor's own timeout was the FIRST cause to cut the command
* short. Mutually exclusive with {@link aborted}: one fused deadline drives
* both the timeout and the caller's cancellation, so a timeout and an abort
* racing before process close report the single first-abort cause, not both
* (see the [timeout-library RFC](../../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
*/
timedOut: boolean
/** True when the caller's AbortSignal killed the command. */
/**
* True when the caller's `AbortSignal` was the FIRST cause to kill the command
* (and it was not the executor's own timeout). Mutually exclusive with
* {@link timedOut} — see there for the first-cause classification.
*/
aborted: boolean
/** The effective timeout applied to this run (after defaulting/capping). */
timeoutMs: number

View File

@@ -15,6 +15,7 @@ class StubExecutor extends BashExecutor {
command: request.command,
workdir: request.workdir ?? '/stub',
timeoutMs: request.timeoutMs ?? 1000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
...request.signal ? { signal: request.signal } : {},
sandboxMode: request.sandboxMode,
}
@@ -54,7 +55,7 @@ describe('BashExecutor service seam', () => {
const ctx = new Context()
await ctx.plugin(StubExecutor)
const spec = ctx.bash.resolve({ command: 'echo hi' })
expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, sandboxMode: undefined })
expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, stdoutMaxBytes: 64_000, sandboxMode: undefined })
const result = await ctx.bash.run(spec)
expect(result.exitCode).toBe(0)

View File

@@ -24,6 +24,29 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
`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()`.
### Managed shell environment
Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-home`](../../util/home/README.md) (`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.
`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 const inject = ['bashEnv']
export function apply(ctx: Context): void {
ctx.bashEnv.register({
name: 'deployment-region',
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
})
}
```
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 contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`.
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
@@ -34,7 +57,7 @@ The tool owns its `presentCall`/`presentResult` render intent. A foreground call
## The tool builds its request from named args only
The `BashExecRequest` seam carries optional `stdin` and `env`, used by trusted in-process plugins. This tool does **not** expose or forward them: it builds requests from named command/workdir/timeout/signal/sandbox fields only. This is not a trust boundary; the local executor's ambient credential scrub is the security control.
The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
## Permissions and escalation

View File

@@ -25,7 +25,9 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
@@ -38,12 +40,16 @@
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",

View File

@@ -8,34 +8,203 @@
* @module @deepseek-ai/dsh-tool-bash
*/
import type { Context } from 'cordis'
import { Service, type Context } from 'cordis'
import z from 'schemastery'
import { isAbsolute, resolve as resolvePath } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-persistence'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import { DSH_ENV_PREFIX, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home'
import { processOutcome } from './background.ts'
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
declare module 'cordis' {
interface Context {
bashEnv: BashEnvRegistry
}
}
export const name = 'tool-bash'
export const inject = ['tools', 'bash', 'systemPrompt']
/** Configures whether the model may background commands. */
/** Configuration for the bash tool and its managed child environment. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
/** 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({
enableRunInBackground: z.boolean().default(true),
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<DshEnvironmentKey, 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<DshEnvironmentKey, 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: DshEnvironmentKey
}
const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const
const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const
const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const
const RESERVED_BASH_ENV_KEYS = new Set<DshEnvironmentKey>([
DSH_HOME_ENV,
DSH_SHELL_KEY,
DSH_SESSION_ID_KEY,
])
const BASH_ENV_KEY_SUFFIX = /^[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<DshEnvironmentKey, 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 = resolveDshHome(config.dshHome)
}
/**
* 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 [DshEnvironmentKey, BashEnvVariable][]
for (const [key, variable] of variables) {
if (!key.startsWith(DSH_ENV_PREFIX)
|| !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) {
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<DshEnvironmentKey, string> = {
[DSH_HOME_ENV]: this.dshHome,
[DSH_SHELL_KEY]: '1',
}
if (execution.agent !== undefined) {
values[DSH_SESSION_ID_KEY] = 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 DshEnvironmentKey
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))))
}
// TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics,
// prompt, or UI code treats list() as an exhaustive environment catalog.
/**
* 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 DshEnvironmentKey,
})))
.sort((left, right) => left.key.localeCompare(right.key))
}
}
/** Parsed tool args; execute validates value constraints absent from SchemaSpec. */
interface BashToolArgs {
command: string
@@ -82,6 +251,7 @@ function bashDescription(backgroundEnabled: boolean, escalationModes: readonly S
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]`. '
+ `Current harness environment facts are exposed through managed \`$${DSH_ENV_PREFIX}*\` 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. '
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
+ background
@@ -153,7 +323,22 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
return modelWorkdir
}
export function apply(ctx: Context, config: Config): void {
export function apply(ctx: Context, config: Config = {}): void {
const bashEnv = new BashEnvRegistry(ctx, config)
bashEnv.register({
name: 'session-persistence',
variables: {
[DSH_SESSION_JSONL_KEY]: {
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_KEY]: location.path } : {}
},
})
const backgroundEnabled = config.enableRunInBackground ?? true
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
@@ -235,10 +420,12 @@ export function apply(ctx: Context, config: Config): void {
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
: sessionOverride(exec)
const workdir = resolveWorkdir(args.workdir, exec)
const dshEnv = bashEnv.collect(exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
dshEnv,
...sandboxMode !== undefined ? { sandboxMode } : {},
}
if (args.run_in_background === true) {

View File

@@ -0,0 +1,190 @@
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 {
token: Symbol('bash-env-test') as ToolExecution['token'],
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')
})
})

View File

@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
@@ -19,22 +20,25 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
* (tool/call + tool/result session events, the generic `ctx.tasks` runtime,
* agent.inject completion notices).
*/
async function harness(adapter: MockAdapter) {
async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await mountAgentLoopTestDependencies(ctx)
if (sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
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(() => {
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) => {
const dispose = ctx.on('agent/status', (subject, status) => {
@@ -82,6 +86,39 @@ async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise<v
}
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%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, dshHome)
const handle = await ctx.agents.create({
agentId: AgentId('session-env'),
sessionId: SessionId('session-env-id'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent = handle.agent as ReactLoopAgent
const location = ctx.sessionPersistence.locate(agent.session.header)
expect(location?.kind).toBe('jsonl')
agent.send([{ type: 'text', text: 'inspect the current session' }])
await waitForIdle(ctx, agent)
const result = findEvent(events(agent), 'tool/result')
expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`)
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' })
await handle.dispose()
})
it('foreground: model calls bash, sees the result, replies', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'bash', { command: 'echo integration-ok', description: 'test command' }, 'Running it.'),

View File

@@ -10,6 +10,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
@@ -101,6 +103,7 @@ class RecordingSandboxExecutor extends BashExecutor {
return {
command: request.command,
workdir: request.workdir ?? process.cwd(),
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
timeoutMs: request.timeoutMs ?? 1000,
...request.signal ? { signal: request.signal } : {},
sandboxMode: request.sandboxMode ?? 'read-only',
@@ -140,7 +143,13 @@ class CountingStartExecutor extends BashExecutor {
starts = 0
resolve(request: BashExecRequest): BashExecSpec {
return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0, sandboxMode: request.sandboxMode }
return {
command: request.command,
workdir: request.workdir ?? '/x',
timeoutMs: request.timeoutMs ?? 0,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
sandboxMode: request.sandboxMode,
}
}
run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
@@ -924,14 +933,17 @@ 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`
* tool does not expose `stdin`/`env` as parameters (bash syntax already gives a
* model that power), so it must build its request from named args only and
* tool does not expose trusted-plugin fields (`stdoutMaxBytes`, `stdin`, or
* `env`) as parameters, 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 post-scrub `env` merge or per-run capture budget — 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()`
* hands back an already-settled fake handle so the task registration completes.
@@ -944,9 +956,11 @@ describe('the model-facing bash tool builds its request from named args only (no
command: request.command,
workdir: request.workdir ?? process.cwd(),
timeoutMs: request.timeoutMs ?? 0,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
sandboxMode: request.sandboxMode,
}
}
@@ -968,19 +982,127 @@ describe('the model-facing bash tool builds its request from named args only (no
}
}
async function setupRecording() {
async function setupRecording(withJsonl = false) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
if (withJsonl) {
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root: join(spillDir, 'jsonl') })
}
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(RecordingBashExecutor)
await ctx.plugin(ToolBash)
await ctx.plugin(ToolBash, { dshHome: recordingDshHome })
return { ctx, bash: ctx.bash as RecordingBashExecutor }
}
it('does not forward env/stdin even when the model includes them as extra arguments', 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_*')
expect(description).not.toContain('DSH_SESSION_JSONL')
})
it('injects the session id and JSONL target path into a foreground request', async () => {
const { ctx, bash } = await setupRecording(true)
const agent = registerFakeAgent(ctx, 'request-fg', () => undefined)
const path = ctx.sessionPersistence.locate(agent.session.header)?.path
await ctx.tools.execute({
callId: CallId('session-env-fg'),
name: 'bash',
arguments: { command: 'true', description: 'run command' },
agent,
})
expect(bash.requests[0]?.dshEnv).toEqual({
DSH_HOME: recordingDshHome,
DSH_SESSION_ID: 'request-fg',
DSH_SESSION_JSONL: path,
DSH_SHELL: '1',
})
})
it('injects the same trusted variables into a background request without forwarding model env', async () => {
const { ctx, bash } = await setupRecording(true)
const agent = registerFakeAgent(ctx, 'request-bg', () => undefined)
const path = ctx.sessionPersistence.locate(agent.session.header)?.path
await ctx.tools.execute({
callId: CallId('session-env-bg'),
name: 'bash',
arguments: {
command: 'sleep 1',
description: 'run command',
run_in_background: true,
env: { DSH_SESSION_ID: 'spoofed', DSH_SESSION_JSONL: '/tmp/spoofed' },
},
agent,
})
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 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
await ctx.tools.execute({
callId: CallId('session-env-id-only'),
name: 'bash',
arguments: { command: 'true', description: 'run command' },
agent,
})
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)
})
it('keeps parent and child agent session environments isolated', async () => {
const { ctx, bash } = await setupRecording(true)
const parent = registerFakeAgent(ctx, 'request-parent', () => undefined)
const child = registerFakeAgent(ctx, 'request-child', () => undefined)
for (const [callId, agent] of [['parent', parent], ['child', child]] as const) {
await ctx.tools.execute({
callId: CallId(`session-env-${callId}`),
name: 'bash',
arguments: { command: 'true', description: 'run command' },
agent,
})
}
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]?.dshEnv?.DSH_SESSION_JSONL).not.toBe(bash.requests[1]?.dshEnv?.DSH_SESSION_JSONL)
})
it('does not forward trusted-only fields even when the model includes them as extra arguments', async () => {
const { ctx, bash } = await setupRecording()
// Unknown `env` and `stdin` keys are ignored by the schema and named request construction.
// This preserves the request shape; it is not a security boundary because shell syntax can
@@ -993,6 +1115,7 @@ describe('the model-facing bash tool builds its request from named args only (no
description: 'echo',
env: { SNEAKY_API_KEY: 'leak' },
stdin: 'malicious payload',
stdoutMaxBytes: 999_999,
},
})
expect(bash.requests).toHaveLength(1)
@@ -1000,9 +1123,10 @@ describe('the model-facing bash tool builds its request from named args only (no
expect(request.command).toBe('echo hi')
expect('env' in request).toBe(false)
expect('stdin' in request).toBe(false)
expect('stdoutMaxBytes' in request).toBe(false)
})
it('a background bash call likewise carries no env/stdin', async () => {
it('a background bash call likewise carries no trusted-only fields', async () => {
const { ctx, bash } = await setupRecording()
const result = await ctx.tools.execute({
callId: CallId('no-forward-2'),
@@ -1013,6 +1137,7 @@ describe('the model-facing bash tool builds its request from named args only (no
run_in_background: true,
env: { TOKEN: 'leak' },
stdin: 'x',
stdoutMaxBytes: 999_999,
},
})
// The call really went down the background path (the recorder sees the real
@@ -1024,5 +1149,6 @@ describe('the model-facing bash tool builds its request from named args only (no
expect(request.command).toBe('sleep 1')
expect('env' in request).toBe(false)
expect('stdin' in request).toBe(false)
expect('stdoutMaxBytes' in request).toBe(false)
})
})

View File

@@ -26,9 +26,15 @@
{
"path": "../../core/agent"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../bash/bash"
},
{
"path": "../../util/home"
},
{
"path": "../../tasks/tasks"
},