Add bash execution: dsh-bash seam, dsh-bash-local impl, dsh-tool-bash tools
Three packages following the new capability-seam pattern (interface /
implementation / consumer, now documented in docs/architecture.md):
- dsh-bash: abstract BashExecutor service (ctx.bash) + vocabulary types.
- dsh-bash-local: local subprocesses — bash -c per call in a detached
process group, SIGTERM→SIGKILL group kills, tail-keep truncation with
full-stream spill files, model-friendly env, background task registry.
- dsh-tool-bash: the bash / bash_output / bash_kill tool schemas with
runtime arg validation and background completion notices via
agent.inject(). Non-zero exits are reported, not errored.
Design surveyed against the bash tools of Claude Code, OpenCode, Codex,
and pi (notes in the package READMEs). Permissions/sandbox stay TODO on
the tools/execute waterfall seam; stateful-shell alternatives recorded
in run.ts.
2026-06-12 23:28:44 +08:00
|
|
|
import { mkdtempSync } from 'node:fs'
|
|
|
|
|
import { tmpdir } from 'node:os'
|
|
|
|
|
import { join } from 'node:path'
|
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { Context } from 'cordis'
|
|
|
|
|
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
2026-06-23 16:27:49 +08:00
|
|
|
import type { BashTaskRead } from '@deepseek-ai/dsh-bash'
|
Add bash execution: dsh-bash seam, dsh-bash-local impl, dsh-tool-bash tools
Three packages following the new capability-seam pattern (interface /
implementation / consumer, now documented in docs/architecture.md):
- dsh-bash: abstract BashExecutor service (ctx.bash) + vocabulary types.
- dsh-bash-local: local subprocesses — bash -c per call in a detached
process group, SIGTERM→SIGKILL group kills, tail-keep truncation with
full-stream spill files, model-friendly env, background task registry.
- dsh-tool-bash: the bash / bash_output / bash_kill tool schemas with
runtime arg validation and background completion notices via
agent.inject(). Non-zero exits are reported, not errored.
Design surveyed against the bash tools of Claude Code, OpenCode, Codex,
and pi (notes in the package READMEs). Permissions/sandbox stay TODO on
the tools/execute waterfall seam; stateful-shell alternatives recorded
in run.ts.
2026-06-12 23:28:44 +08:00
|
|
|
|
|
|
|
|
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
|
|
|
|
|
|
|
|
|
async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LocalBashExecutor, config)
|
|
|
|
|
const bash = ctx.bash as LocalBashExecutor
|
|
|
|
|
bash.internals = { spillDir, graceMs: 200 }
|
|
|
|
|
return { ctx, bash }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Poll until a pid no longer exists. */
|
|
|
|
|
async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
|
|
|
|
|
const deadline = Date.now() + timeoutMs
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
|
|
try {
|
|
|
|
|
process.kill(pid, 0)
|
|
|
|
|
} catch {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 16:27:49 +08:00
|
|
|
async function readUntil(
|
|
|
|
|
bash: LocalBashExecutor,
|
|
|
|
|
id: BashTaskId,
|
|
|
|
|
expected: string,
|
|
|
|
|
timeoutMs = 5_000,
|
|
|
|
|
): Promise<BashTaskRead> {
|
|
|
|
|
const deadline = Date.now() + timeoutMs
|
|
|
|
|
let last: BashTaskRead | undefined
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
|
|
last = bash.readOutput(id)
|
|
|
|
|
if (last.delta.includes(expected)) return last
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; last delta was ${JSON.stringify(last?.delta ?? '')}`)
|
|
|
|
|
}
|
|
|
|
|
|
Add bash execution: dsh-bash seam, dsh-bash-local impl, dsh-tool-bash tools
Three packages following the new capability-seam pattern (interface /
implementation / consumer, now documented in docs/architecture.md):
- dsh-bash: abstract BashExecutor service (ctx.bash) + vocabulary types.
- dsh-bash-local: local subprocesses — bash -c per call in a detached
process group, SIGTERM→SIGKILL group kills, tail-keep truncation with
full-stream spill files, model-friendly env, background task registry.
- dsh-tool-bash: the bash / bash_output / bash_kill tool schemas with
runtime arg validation and background completion notices via
agent.inject(). Non-zero exits are reported, not errored.
Design surveyed against the bash tools of Claude Code, OpenCode, Codex,
and pi (notes in the package READMEs). Permissions/sandbox stay TODO on
the tools/execute waterfall seam; stateful-shell alternatives recorded
in run.ts.
2026-06-12 23:28:44 +08:00
|
|
|
describe('LocalBashExecutor.run', () => {
|
|
|
|
|
it('resolves with output and the effective timeout', async () => {
|
|
|
|
|
const { bash } = await setup({ timeoutMs: 5_000 })
|
|
|
|
|
const result = await bash.run(bash.resolve({ command: 'echo hi' }))
|
|
|
|
|
expect(result.exitCode).toBe(0)
|
|
|
|
|
expect(result.stdout.text).toBe('hi\n')
|
|
|
|
|
expect(result.timeoutMs).toBe(5_000)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses config cwd, overridable per call', async () => {
|
|
|
|
|
const { bash } = await setup({ cwd: '/tmp' })
|
|
|
|
|
const fromConfig = await bash.run(bash.resolve({ command: 'pwd' }))
|
|
|
|
|
expect(fromConfig.stdout.text.trim()).toMatch(/\/tmp$/)
|
|
|
|
|
const fromCall = await bash.run(bash.resolve({ command: 'pwd', workdir: '/' }))
|
|
|
|
|
expect(fromCall.stdout.text.trim()).toBe('/')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('defaults cwd to process.cwd()', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const result = await bash.run(bash.resolve({ command: 'pwd' }))
|
|
|
|
|
expect(result.stdout.text.trim()).toBe(process.cwd())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('caps per-call timeouts at maxTimeoutMs', async () => {
|
|
|
|
|
const { bash } = await setup({ timeoutMs: 1_000, maxTimeoutMs: 2_000 })
|
|
|
|
|
const result = await bash.run(bash.resolve({ command: 'true', timeoutMs: 99_999 }))
|
|
|
|
|
expect(result.timeoutMs).toBe(2_000)
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-17 21:26:44 +08:00
|
|
|
it('rejects invalid numeric config and timeout overrides', async () => {
|
|
|
|
|
await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
|
|
|
|
|
await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
|
|
|
|
|
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
|
|
|
|
|
|
|
|
|
|
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/)
|
|
|
|
|
})
|
|
|
|
|
|
Add bash execution: dsh-bash seam, dsh-bash-local impl, dsh-tool-bash tools
Three packages following the new capability-seam pattern (interface /
implementation / consumer, now documented in docs/architecture.md):
- dsh-bash: abstract BashExecutor service (ctx.bash) + vocabulary types.
- dsh-bash-local: local subprocesses — bash -c per call in a detached
process group, SIGTERM→SIGKILL group kills, tail-keep truncation with
full-stream spill files, model-friendly env, background task registry.
- dsh-tool-bash: the bash / bash_output / bash_kill tool schemas with
runtime arg validation and background completion notices via
agent.inject(). Non-zero exits are reported, not errored.
Design surveyed against the bash tools of Claude Code, OpenCode, Codex,
and pi (notes in the package READMEs). Permissions/sandbox stay TODO on
the tools/execute waterfall seam; stateful-shell alternatives recorded
in run.ts.
2026-06-12 23:28:44 +08:00
|
|
|
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
|
|
|
|
|
const { bash } = await setup({ timeoutMs: 60_000 })
|
|
|
|
|
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
|
|
|
|
|
expect(result.timedOut).toBe(true)
|
|
|
|
|
expect(result.timeoutMs).toBe(100)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('propagates abort signals', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = bash.run(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
|
|
|
|
|
setTimeout(() => { controller.abort() }, 50)
|
|
|
|
|
const result = await pending
|
|
|
|
|
expect(result.aborted).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects on spawn failure (bad workdir)', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
await expect(bash.run(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
|
|
|
|
|
})
|
feat(bash): add stdin + extra env to the executor seam as a trusted-plugin surface
The hooks subsystem runs external hook commands the Claude Code / Codex way:
JSON payload on stdin, context in CLAUDE_PROJECT_DIR / CLAUDE_PLUGIN_ROOT env.
Reusing the ctx.bash seam for that needs two new inputs — but stdin and arbitrary
env are exactly what dsh-bash-local's credential scrub exists to keep away from
model-driven commands. So this adds them as a TRUSTED-PLUGIN surface:
- BashExecRequest + BashExecSpec gain optional `stdin` and `env`. They are plain
optionals on the resolved spec (not required-but-nullable like `owner`): a
missing one means "none", the safe default, not a security footgun.
- dsh-bash-local threads them through resolve/run/start. `env` merges AFTER the
credential scrub, so a trusted caller's explicit entry wins even on a
credential-shaped name — the scrub guards the harness's OWN ambient creds from
model-driven commands, not a trusted plugin. stdin is always a pipe, closed
immediately (with bytes when supplied, empty otherwise — EOF as before); an
EPIPE from a child that exits without reading is swallowed.
- The model-facing dsh-tool-bash NEVER forwards model input into stdin/env (its
request is command/workdir/timeoutMs/signal/owner only). A regression guard
drives the real tool with adversarial args and asserts the request carries
neither field — proven to go red if the consumer ever forwards them.
Configurable scrub (in an earlier sketch) is dropped as speculative: the explicit
`env` field already gives a trusted caller full control, and no caller needs to
broaden the ambient scrub. Documented in a new architecture RFC, the bash.md
type-equiv blocks, and the three bash READMEs.
2026-06-30 13:52:25 +08:00
|
|
|
|
|
|
|
|
it('resolve() carries stdin/env 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' } })
|
2026-07-02 04:30:40 +08:00
|
|
|
// resolve() keeps the stdin/env fields verbatim (optional, no default).
|
feat(bash): add stdin + extra env to the executor seam as a trusted-plugin surface
The hooks subsystem runs external hook commands the Claude Code / Codex way:
JSON payload on stdin, context in CLAUDE_PROJECT_DIR / CLAUDE_PLUGIN_ROOT env.
Reusing the ctx.bash seam for that needs two new inputs — but stdin and arbitrary
env are exactly what dsh-bash-local's credential scrub exists to keep away from
model-driven commands. So this adds them as a TRUSTED-PLUGIN surface:
- BashExecRequest + BashExecSpec gain optional `stdin` and `env`. They are plain
optionals on the resolved spec (not required-but-nullable like `owner`): a
missing one means "none", the safe default, not a security footgun.
- dsh-bash-local threads them through resolve/run/start. `env` merges AFTER the
credential scrub, so a trusted caller's explicit entry wins even on a
credential-shaped name — the scrub guards the harness's OWN ambient creds from
model-driven commands, not a trusted plugin. stdin is always a pipe, closed
immediately (with bytes when supplied, empty otherwise — EOF as before); an
EPIPE from a child that exits without reading is swallowed.
- The model-facing dsh-tool-bash NEVER forwards model input into stdin/env (its
request is command/workdir/timeoutMs/signal/owner only). A regression guard
drives the real tool with adversarial args and asserts the request carries
neither field — proven to go red if the consumer ever forwards them.
Configurable scrub (in an earlier sketch) is dropped as speculative: the explicit
`env` field already gives a trusted caller full control, and no caller needs to
broaden the ambient scrub. Documented in a new architecture RFC, the bash.md
type-equiv blocks, and the three bash READMEs.
2026-06-30 13:52:25 +08:00
|
|
|
expect(spec.stdin).toBe('piped\n')
|
|
|
|
|
expect(spec.env).toEqual({ DSH_SEAM_VAR: 'env-ok' })
|
|
|
|
|
const result = await bash.run(spec)
|
|
|
|
|
expect(result.stdout.text).toBe('piped\n[env-ok]\n')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('resolve() omits stdin/env when the request supplies neither', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const spec = bash.resolve({ command: 'true' })
|
|
|
|
|
expect('stdin' in spec).toBe(false)
|
|
|
|
|
expect('env' in spec).toBe(false)
|
|
|
|
|
})
|
Add bash execution: dsh-bash seam, dsh-bash-local impl, dsh-tool-bash tools
Three packages following the new capability-seam pattern (interface /
implementation / consumer, now documented in docs/architecture.md):
- dsh-bash: abstract BashExecutor service (ctx.bash) + vocabulary types.
- dsh-bash-local: local subprocesses — bash -c per call in a detached
process group, SIGTERM→SIGKILL group kills, tail-keep truncation with
full-stream spill files, model-friendly env, background task registry.
- dsh-tool-bash: the bash / bash_output / bash_kill tool schemas with
runtime arg validation and background completion notices via
agent.inject(). Non-zero exits are reported, not errored.
Design surveyed against the bash tools of Claude Code, OpenCode, Codex,
and pi (notes in the package READMEs). Permissions/sandbox stay TODO on
the tools/execute waterfall seam; stateful-shell alternatives recorded
in run.ts.
2026-06-12 23:28:44 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('LocalBashExecutor background tasks', () => {
|
|
|
|
|
it('start returns immediately with a registered running task', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const before = Date.now()
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' }))
|
|
|
|
|
expect(Date.now() - before).toBeLessThan(150)
|
|
|
|
|
expect(task.status).toBe('running')
|
|
|
|
|
expect(bash.get(task.id)).toBe(task)
|
|
|
|
|
expect(bash.list()).toContain(task)
|
|
|
|
|
await task.done
|
|
|
|
|
expect(task.status).toBe('completed')
|
|
|
|
|
expect(task.exitCode).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('assigns sequential ids', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const first = bash.start(bash.resolve({ command: 'true' }))
|
|
|
|
|
const second = bash.start(bash.resolve({ command: 'true' }))
|
|
|
|
|
expect(first.id).toBe('bash-1')
|
|
|
|
|
expect(second.id).toBe('bash-2')
|
|
|
|
|
await Promise.all([first.done, second.done])
|
|
|
|
|
})
|
|
|
|
|
|
feat(bash): add stdin + extra env to the executor seam as a trusted-plugin surface
The hooks subsystem runs external hook commands the Claude Code / Codex way:
JSON payload on stdin, context in CLAUDE_PROJECT_DIR / CLAUDE_PLUGIN_ROOT env.
Reusing the ctx.bash seam for that needs two new inputs — but stdin and arbitrary
env are exactly what dsh-bash-local's credential scrub exists to keep away from
model-driven commands. So this adds them as a TRUSTED-PLUGIN surface:
- BashExecRequest + BashExecSpec gain optional `stdin` and `env`. They are plain
optionals on the resolved spec (not required-but-nullable like `owner`): a
missing one means "none", the safe default, not a security footgun.
- dsh-bash-local threads them through resolve/run/start. `env` merges AFTER the
credential scrub, so a trusted caller's explicit entry wins even on a
credential-shaped name — the scrub guards the harness's OWN ambient creds from
model-driven commands, not a trusted plugin. stdin is always a pipe, closed
immediately (with bytes when supplied, empty otherwise — EOF as before); an
EPIPE from a child that exits without reading is swallowed.
- The model-facing dsh-tool-bash NEVER forwards model input into stdin/env (its
request is command/workdir/timeoutMs/signal/owner only). A regression guard
drives the real tool with adversarial args and asserts the request carries
neither field — proven to go red if the consumer ever forwards them.
Configurable scrub (in an earlier sketch) is dropped as speculative: the explicit
`env` field already gives a trusted caller full control, and no caller needs to
broaden the ambient scrub. Documented in a new architecture RFC, the bash.md
type-equiv blocks, and the three bash READMEs.
2026-06-30 13:52:25 +08:00
|
|
|
it('threads stdin and extra env into a background task', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const task = bash.start(bash.resolve({
|
|
|
|
|
command: 'cat; echo "[$DSH_BG_VAR]"',
|
|
|
|
|
stdin: 'bg-stdin\n',
|
|
|
|
|
env: { DSH_BG_VAR: 'bg-env' },
|
|
|
|
|
}))
|
|
|
|
|
const read = await readUntil(bash, task.id, '[bg-env]')
|
|
|
|
|
expect(read.delta).toContain('bg-stdin')
|
|
|
|
|
await task.done
|
|
|
|
|
expect(task.exitCode).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
Add bash execution: dsh-bash seam, dsh-bash-local impl, dsh-tool-bash tools
Three packages following the new capability-seam pattern (interface /
implementation / consumer, now documented in docs/architecture.md):
- dsh-bash: abstract BashExecutor service (ctx.bash) + vocabulary types.
- dsh-bash-local: local subprocesses — bash -c per call in a detached
process group, SIGTERM→SIGKILL group kills, tail-keep truncation with
full-stream spill files, model-friendly env, background task registry.
- dsh-tool-bash: the bash / bash_output / bash_kill tool schemas with
runtime arg validation and background completion notices via
agent.inject(). Non-zero exits are reported, not errored.
Design surveyed against the bash tools of Claude Code, OpenCode, Codex,
and pi (notes in the package READMEs). Permissions/sandbox stay TODO on
the tools/execute waterfall seam; stateful-shell alternatives recorded
in run.ts.
2026-06-12 23:28:44 +08:00
|
|
|
it('readOutput returns increments without re-delivery', async () => {
|
|
|
|
|
const { bash } = await setup()
|
2026-06-23 16:27:49 +08:00
|
|
|
const task = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
|
|
|
|
|
const first = await readUntil(bash, task.id, 'first\n')
|
Add bash execution: dsh-bash seam, dsh-bash-local impl, dsh-tool-bash tools
Three packages following the new capability-seam pattern (interface /
implementation / consumer, now documented in docs/architecture.md):
- dsh-bash: abstract BashExecutor service (ctx.bash) + vocabulary types.
- dsh-bash-local: local subprocesses — bash -c per call in a detached
process group, SIGTERM→SIGKILL group kills, tail-keep truncation with
full-stream spill files, model-friendly env, background task registry.
- dsh-tool-bash: the bash / bash_output / bash_kill tool schemas with
runtime arg validation and background completion notices via
agent.inject(). Non-zero exits are reported, not errored.
Design surveyed against the bash tools of Claude Code, OpenCode, Codex,
and pi (notes in the package READMEs). Permissions/sandbox stay TODO on
the tools/execute waterfall seam; stateful-shell alternatives recorded
in run.ts.
2026-06-12 23:28:44 +08:00
|
|
|
expect(first.delta).toBe('first\n')
|
|
|
|
|
expect(first.lossy).toBe(false)
|
|
|
|
|
await task.done
|
|
|
|
|
const second = bash.readOutput(task.id)
|
|
|
|
|
expect(second.delta).toBe('second\n')
|
|
|
|
|
const third = bash.readOutput(task.id)
|
|
|
|
|
expect(third.delta).toBe('')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('readOutput marks stderr sections', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'echo out; echo err >&2' }))
|
|
|
|
|
await task.done
|
|
|
|
|
const read = bash.readOutput(task.id)
|
|
|
|
|
expect(read.delta).toBe('out\n[stderr]\nerr\n')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('readOutput reports stderr-only deltas without a leading newline', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'echo err >&2' }))
|
|
|
|
|
await task.done
|
|
|
|
|
expect(bash.readOutput(task.id).delta).toBe('[stderr]\nerr\n')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('readOutput flags lossy reads and reports spill paths', async () => {
|
|
|
|
|
const { bash } = await setup({ maxOutputBytes: 100 })
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' }))
|
|
|
|
|
await task.done
|
|
|
|
|
const read = bash.readOutput(task.id)
|
|
|
|
|
// Window slid past offset 0 → lossy, spill path points at the full stream.
|
|
|
|
|
expect(read.lossy).toBe(true)
|
|
|
|
|
expect(read.stdoutSpillPath).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('readOutput throws for unknown ids', async () => {
|
|
|
|
|
const { bash } = await setup()
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
expect(() => bash.readOutput(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
|
Add bash execution: dsh-bash seam, dsh-bash-local impl, dsh-tool-bash tools
Three packages following the new capability-seam pattern (interface /
implementation / consumer, now documented in docs/architecture.md):
- dsh-bash: abstract BashExecutor service (ctx.bash) + vocabulary types.
- dsh-bash-local: local subprocesses — bash -c per call in a detached
process group, SIGTERM→SIGKILL group kills, tail-keep truncation with
full-stream spill files, model-friendly env, background task registry.
- dsh-tool-bash: the bash / bash_output / bash_kill tool schemas with
runtime arg validation and background completion notices via
agent.inject(). Non-zero exits are reported, not errored.
Design surveyed against the bash tools of Claude Code, OpenCode, Codex,
and pi (notes in the package READMEs). Permissions/sandbox stay TODO on
the tools/execute waterfall seam; stateful-shell alternatives recorded
in run.ts.
2026-06-12 23:28:44 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('kill terminates the process group and reports status killed', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
|
|
|
|
|
expect(bash.kill(task.id)).toBe(true)
|
|
|
|
|
await task.done
|
|
|
|
|
expect(task.status).toBe('killed')
|
|
|
|
|
expect(task.signal).toBe('SIGTERM')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('kill returns false for finished tasks and throws for unknown ids', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'true' }))
|
|
|
|
|
await task.done
|
|
|
|
|
expect(bash.kill(task.id)).toBe(false)
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
expect(() => bash.kill(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
|
Add bash execution: dsh-bash seam, dsh-bash-local impl, dsh-tool-bash tools
Three packages following the new capability-seam pattern (interface /
implementation / consumer, now documented in docs/architecture.md):
- dsh-bash: abstract BashExecutor service (ctx.bash) + vocabulary types.
- dsh-bash-local: local subprocesses — bash -c per call in a detached
process group, SIGTERM→SIGKILL group kills, tail-keep truncation with
full-stream spill files, model-friendly env, background task registry.
- dsh-tool-bash: the bash / bash_output / bash_kill tool schemas with
runtime arg validation and background completion notices via
agent.inject(). Non-zero exits are reported, not errored.
Design surveyed against the bash tools of Claude Code, OpenCode, Codex,
and pi (notes in the package READMEs). Permissions/sandbox stay TODO on
the tools/execute waterfall seam; stateful-shell alternatives recorded
in run.ts.
2026-06-12 23:28:44 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('notifies onTaskDone listeners on completion', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const seen: [string, string][] = []
|
|
|
|
|
bash.onTaskDone(task => void seen.push([task.id, task.status]))
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'true' }))
|
|
|
|
|
await task.done
|
|
|
|
|
expect(seen).toEqual([[task.id, 'completed']])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('notifies onTaskDone for killed tasks too', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const listener = vi.fn()
|
|
|
|
|
bash.onTaskDone(listener)
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
|
|
|
|
|
bash.kill(task.id)
|
|
|
|
|
await task.done
|
|
|
|
|
expect(listener).toHaveBeenCalledWith(task)
|
|
|
|
|
expect(task.status).toBe('killed')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('marks tasks killed when the background spawn itself fails', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const listener = vi.fn()
|
|
|
|
|
bash.onTaskDone(listener)
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))
|
|
|
|
|
await task.done
|
|
|
|
|
expect(task.status).toBe('killed')
|
|
|
|
|
expect(listener).toHaveBeenCalledWith(task)
|
|
|
|
|
expect(bash.readOutput(task.id).delta).toContain('spawn failed')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'printf out; echo err >&2' }))
|
|
|
|
|
await task.done
|
|
|
|
|
expect(bash.readOutput(task.id).delta).toBe('out\n[stderr]\nerr\n')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('readOutput reports stderr spill paths', async () => {
|
|
|
|
|
const { bash } = await setup({ maxOutputBytes: 100 })
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' }))
|
|
|
|
|
await task.done
|
|
|
|
|
const read = bash.readOutput(task.id)
|
|
|
|
|
expect(read.lossy).toBe(true)
|
|
|
|
|
expect(read.stderrSpillPath).toBeDefined()
|
|
|
|
|
expect(read.delta).toContain('[stderr]')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('disposing with already-finished tasks only kills the running ones', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
const fiber = await ctx.plugin(LocalBashExecutor, {})
|
|
|
|
|
const bash = ctx.bash as LocalBashExecutor
|
|
|
|
|
bash.internals = { spillDir, graceMs: 200 }
|
|
|
|
|
|
|
|
|
|
const finished = bash.start(bash.resolve({ command: 'true' }))
|
|
|
|
|
await finished.done
|
|
|
|
|
const running = bash.start(bash.resolve({ command: 'sleep 60' }))
|
|
|
|
|
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
await running.done
|
|
|
|
|
expect(finished.status).toBe('completed')
|
|
|
|
|
expect(running.signal).toBe('SIGTERM')
|
|
|
|
|
expect(bash.list()).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('disposing the executor fiber kills running tasks (no orphans)', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
const fiber = await ctx.plugin(LocalBashExecutor, {})
|
|
|
|
|
const bash = ctx.bash as LocalBashExecutor
|
|
|
|
|
bash.internals = { spillDir, graceMs: 200 }
|
|
|
|
|
const listener = vi.fn()
|
|
|
|
|
bash.onTaskDone(listener)
|
|
|
|
|
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
|
|
|
|
|
const running = bash.get(task.id)!
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
|
|
|
|
|
|
// Grab the pid before dispose clears the registry.
|
|
|
|
|
const pid = (running as unknown as { running: { pid: number } }).running.pid
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
await waitGone(pid)
|
|
|
|
|
expect(bash.list()).toEqual([])
|
|
|
|
|
// Listener silenced by base-class teardown — no late notifications.
|
|
|
|
|
expect(listener).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('review fixes: lifecycle hardening', () => {
|
|
|
|
|
it('start honors a pre-aborted or later-aborted AbortSignal', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
|
|
|
|
|
controller.abort()
|
|
|
|
|
await task.done
|
|
|
|
|
expect(task.status).toBe('killed')
|
|
|
|
|
expect(task.signal).toBe('SIGTERM')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a throwing onTaskDone listener does not reject task.done or starve later listeners', async () => {
|
|
|
|
|
const { bash } = await setup()
|
|
|
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
|
|
|
const second = vi.fn()
|
|
|
|
|
try {
|
|
|
|
|
bash.onTaskDone(() => { throw new Error('listener bug') })
|
|
|
|
|
bash.onTaskDone(second)
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'true' }))
|
|
|
|
|
await expect(task.done).resolves.toBeUndefined()
|
|
|
|
|
expect(second).toHaveBeenCalledWith(task)
|
|
|
|
|
expect(errorSpy).toHaveBeenCalled()
|
|
|
|
|
} finally {
|
|
|
|
|
errorSpy.mockRestore()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('dispose AWAITS a TERM-trapping process (SIGKILL escalation included)', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
const fiber = await ctx.plugin(LocalBashExecutor, {})
|
|
|
|
|
const bash = ctx.bash as LocalBashExecutor
|
|
|
|
|
bash.internals = { spillDir, graceMs: 200 }
|
|
|
|
|
|
|
|
|
|
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
|
|
|
const pid = (task as unknown as { running: { pid: number } }).running.pid
|
|
|
|
|
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
// Disposal itself waited: the pid must already be gone, no grace left.
|
|
|
|
|
expect(() => process.kill(pid, 0)).toThrow()
|
|
|
|
|
expect(task.status).toBe('killed')
|
|
|
|
|
})
|
|
|
|
|
})
|