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 { CallId } from '@deepseek-ai/dsh-llm'
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
|
|
|
|
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } 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
|
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
|
|
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
2026-06-20 08:14:27 +08:00
|
|
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
2026-07-18 12:21:15 +08:00
|
|
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
2026-07-10 20:52:27 +08:00
|
|
|
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
import TaskService from '@deepseek-ai/dsh-tasks'
|
|
|
|
|
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
2026-07-11 21:37:38 +08:00
|
|
|
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
|
|
|
|
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
|
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 { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
|
|
|
|
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
2026-07-15 13:38:17 +08:00
|
|
|
import { processOutcome } from '../src/background.ts'
|
|
|
|
|
import { renderProcessRead, renderResult } from '../src/render.ts'
|
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-tool-bash-spec-'))
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
/** Foreground-only harness: no task runtime (backgrounding fails loud here). */
|
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
|
|
|
async function setup() {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
2026-06-20 08:14:27 +08:00
|
|
|
await ctx.plugin(AgentRegistry)
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
|
|
|
|
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
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
|
|
|
await ctx.plugin(ToolBash)
|
|
|
|
|
return ctx
|
2026-07-11 21:37:38 +08:00
|
|
|
}
|
2026-07-09 16:05:44 +08:00
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
/** Full harness: the generic task runtime + its control surface, then the bash tool. */
|
|
|
|
|
async function setupWithTasks() {
|
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 ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
2026-06-20 08:14:27 +08:00
|
|
|
await ctx.plugin(AgentRegistry)
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
await ctx.plugin(TaskService)
|
|
|
|
|
await ctx.plugin(ToolTasks)
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
|
|
|
|
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
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
|
|
|
await ctx.plugin(ToolBash)
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 08:14:27 +08:00
|
|
|
/**
|
2026-07-15 23:29:47 +08:00
|
|
|
* Build a fake {@link Agent} with the shared agent/session identity, give it a
|
2026-07-13 15:57:17 +08:00
|
|
|
* dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`.
|
2026-06-20 08:14:27 +08:00
|
|
|
*/
|
2026-07-13 15:57:17 +08:00
|
|
|
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
|
|
|
|
|
const scopeFiber = ctx.plugin(() => {})
|
2026-07-14 07:26:46 +08:00
|
|
|
const id = SessionId(sessionId)
|
2026-07-13 15:57:17 +08:00
|
|
|
const agent = {
|
2026-07-15 23:29:47 +08:00
|
|
|
id,
|
2026-07-13 15:57:17 +08:00
|
|
|
ctx: scopeFiber.ctx,
|
|
|
|
|
inject,
|
2026-07-15 23:29:47 +08:00
|
|
|
session: { id, header: { version: 0, id, createdAt: 0 } },
|
2026-07-13 15:57:17 +08:00
|
|
|
} as unknown as Agent
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
ctx.agents.register(agent)
|
2026-06-20 08:14:27 +08:00
|
|
|
return agent
|
|
|
|
|
}
|
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
|
|
|
let callCounter = 0
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
|
|
|
|
|
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function text(result: { content: { type: string; text?: string }[] }): string {
|
|
|
|
|
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 16:27:49 +08:00
|
|
|
async function callUntilText(
|
|
|
|
|
ctx: Context,
|
|
|
|
|
name: string,
|
|
|
|
|
args: unknown,
|
|
|
|
|
expected: string,
|
|
|
|
|
timeoutMs = 5_000,
|
|
|
|
|
): Promise<Awaited<ReturnType<typeof call>>> {
|
|
|
|
|
const deadline = Date.now() + timeoutMs
|
|
|
|
|
let last: Awaited<ReturnType<typeof call>> | undefined
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
|
|
last = await call(ctx, name, args)
|
|
|
|
|
if (text(last).includes(expected)) return last
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`${name} output did not include ${JSON.stringify(expected)}; last text was ${JSON.stringify(last !== undefined ? text(last) : '')}`)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
class RecordingSandboxExecutor extends BashExecutor {
|
|
|
|
|
readonly modes: Array<string | undefined> = []
|
|
|
|
|
|
|
|
|
|
override get sandboxMode() {
|
|
|
|
|
return 'read-only' as const
|
2026-06-19 01:54:57 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resolve(request: BashExecRequest): BashExecSpec {
|
|
|
|
|
return {
|
|
|
|
|
command: request.command,
|
|
|
|
|
workdir: request.workdir ?? process.cwd(),
|
2026-07-10 11:53:02 +08:00
|
|
|
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
2026-07-11 23:04:27 +08:00
|
|
|
timeoutMs: request.timeoutMs ?? 1000,
|
2026-06-19 01:54:57 +08:00
|
|
|
...request.signal ? { signal: request.signal } : {},
|
2026-07-11 23:04:27 +08:00
|
|
|
sandboxMode: request.sandboxMode ?? 'read-only',
|
2026-06-19 01:54:57 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-13 23:42:54 +08:00
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
run(spec: BashExecSpec): Promise<BashRunResult> {
|
|
|
|
|
this.modes.push(spec.sandboxMode)
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
exitCode: 0,
|
|
|
|
|
signal: null,
|
|
|
|
|
timedOut: false,
|
|
|
|
|
aborted: false,
|
|
|
|
|
timeoutMs: spec.timeoutMs,
|
|
|
|
|
stdout: { text: 'ok', truncated: false },
|
|
|
|
|
stderr: { text: '', truncated: false },
|
|
|
|
|
sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false },
|
|
|
|
|
})
|
2026-07-13 23:42:54 +08:00
|
|
|
}
|
2026-06-19 01:54:57 +08:00
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
start(spec: BashExecSpec): BashProcess {
|
|
|
|
|
this.modes.push(spec.sandboxMode)
|
|
|
|
|
return {
|
|
|
|
|
status: 'completed',
|
|
|
|
|
exitCode: 0,
|
|
|
|
|
signal: null,
|
|
|
|
|
done: Promise.resolve(),
|
|
|
|
|
sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false },
|
|
|
|
|
readOutput: () => ({ delta: '', lossy: false }),
|
|
|
|
|
kill: () => false,
|
|
|
|
|
}
|
2026-06-19 01:54:57 +08:00
|
|
|
}
|
2026-07-11 23:04:27 +08:00
|
|
|
}
|
2026-06-19 01:54:57 +08:00
|
|
|
|
2026-07-14 09:59:21 +08:00
|
|
|
/** Test executor that records whether the background start boundary was crossed. */
|
|
|
|
|
class CountingStartExecutor extends BashExecutor {
|
|
|
|
|
starts = 0
|
2026-06-19 01:54:57 +08:00
|
|
|
|
2026-07-14 09:59:21 +08:00
|
|
|
resolve(request: BashExecRequest): BashExecSpec {
|
2026-07-17 18:21:54 +08:00
|
|
|
return {
|
|
|
|
|
command: request.command,
|
|
|
|
|
workdir: request.workdir ?? '/x',
|
|
|
|
|
timeoutMs: request.timeoutMs ?? 0,
|
|
|
|
|
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
|
|
|
|
sandboxMode: request.sandboxMode,
|
|
|
|
|
}
|
2026-06-19 01:54:57 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-14 09:59:21 +08:00
|
|
|
run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
|
2026-06-20 08:14:27 +08:00
|
|
|
|
2026-07-15 13:38:17 +08:00
|
|
|
start(): BashProcess {
|
2026-07-14 09:59:21 +08:00
|
|
|
this.starts += 1
|
|
|
|
|
return {
|
|
|
|
|
status: 'completed',
|
|
|
|
|
exitCode: 0,
|
|
|
|
|
signal: null,
|
|
|
|
|
done: Promise.resolve(),
|
|
|
|
|
readOutput: () => ({ delta: '', lossy: false }),
|
|
|
|
|
kill: () => false,
|
|
|
|
|
}
|
2026-06-19 01:54:57 +08:00
|
|
|
}
|
2026-07-14 09:59:21 +08:00
|
|
|
}
|
2026-06-19 01:54:57 +08:00
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
async function setupSandboxed(withApproval = false) {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
await ctx.plugin(TaskService)
|
|
|
|
|
await ctx.plugin(ToolTasks)
|
|
|
|
|
await ctx.plugin(RecordingSandboxExecutor)
|
|
|
|
|
if (withApproval) await ctx.plugin(ApprovalService)
|
|
|
|
|
await ctx.plugin(ToolBash)
|
|
|
|
|
return { ctx, bash: ctx.bash as RecordingSandboxExecutor }
|
|
|
|
|
}
|
2026-06-19 01:54:57 +08:00
|
|
|
|
2026-07-13 15:57:17 +08:00
|
|
|
function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', ctx?: Context): Agent {
|
2026-07-11 23:04:27 +08:00
|
|
|
const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }]
|
2026-07-16 23:31:51 +08:00
|
|
|
if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } })
|
2026-07-15 23:29:47 +08:00
|
|
|
const id = SessionId('sandbox-session')
|
2026-07-11 23:04:27 +08:00
|
|
|
return {
|
2026-07-15 23:29:47 +08:00
|
|
|
id,
|
2026-07-13 15:57:17 +08:00
|
|
|
...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx },
|
2026-07-11 23:04:27 +08:00
|
|
|
session: {
|
2026-07-15 23:29:47 +08:00
|
|
|
id,
|
|
|
|
|
header: { version: 0, id, createdAt: 0 },
|
2026-07-11 23:04:27 +08:00
|
|
|
events,
|
|
|
|
|
append: (type: string, data: Record<string, unknown>) => {
|
|
|
|
|
const event = { type, data }
|
|
|
|
|
events.push(event)
|
|
|
|
|
return event
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
} as unknown as Agent
|
2026-06-19 01:54:57 +08:00
|
|
|
}
|
|
|
|
|
|
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('bash tool', () => {
|
|
|
|
|
it('returns stdout for a successful command', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const result = await call(ctx, 'bash', { command: 'echo hello', description: 'test command' })
|
|
|
|
|
expect(result.isError).toBe(false)
|
|
|
|
|
expect(text(result)).toBe('hello\n')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports (no output) for silent commands', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const result = await call(ctx, 'bash', { command: 'true', description: 'test command' })
|
|
|
|
|
expect(text(result)).toBe('(no output)')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('marks stderr sections', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const result = await call(ctx, 'bash', { command: 'echo out; echo err >&2', description: 'test command' })
|
|
|
|
|
expect(text(result)).toBe('out\n[stderr]\nerr\n')
|
|
|
|
|
expect(result.isError).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports non-zero exits without isError', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const result = await call(ctx, 'bash', { command: 'echo failing; exit 3', description: 'test command' })
|
|
|
|
|
expect(result.isError).toBe(false)
|
|
|
|
|
expect(text(result)).toBe('failing\n[exit code: 3]')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports timeout kills with both markers (timeout first)', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', timeoutMs: 100 })
|
|
|
|
|
expect(result.isError).toBe(false)
|
|
|
|
|
expect(text(result)).toBe('(no output)\n[timed out after 100ms]\n[killed by signal: SIGTERM]')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports a timeout even when the command traps the signal and exits 0', async () => {
|
|
|
|
|
// The signal-independent timeout marker: a trapped SIGTERM that exits 0
|
|
|
|
|
// after our timer fired must NOT look like a clean success. (bash may
|
|
|
|
|
// print "Terminated" to stderr for the killed sleep — environment
|
|
|
|
|
// dependent — so assert the marker, not the exact body.)
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const result = await call(ctx, 'bash', { command: 'trap "exit 0" TERM; sleep 60', description: 'test command', timeoutMs: 100 })
|
|
|
|
|
expect(result.isError).toBe(false)
|
|
|
|
|
expect(text(result)).toContain('[timed out after 100ms]')
|
|
|
|
|
expect(text(result)).not.toContain('[exit code:')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports truncation with the spill path', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
|
|
|
|
|
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
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
|
|
|
await ctx.plugin(ToolBash)
|
|
|
|
|
const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
|
|
|
|
|
expect(text(result)).toContain('[output truncated; full output: ')
|
|
|
|
|
expect(text(result)).toContain('line-0100')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('honors workdir', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const result = await call(ctx, 'bash', { command: 'pwd', description: 'test command', workdir: '/tmp' })
|
|
|
|
|
expect(text(result).trim()).toMatch(/\/tmp$/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('surfaces spawn failures as isError', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const result = await call(ctx, 'bash', { command: 'true', description: 'test command', workdir: '/nonexistent-dsh' })
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(text(result)).toMatch(/ENOENT/)
|
|
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('surfaces foreground aborts as isError', async () => {
|
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 ctx = await setup()
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('call-abort'),
|
|
|
|
|
name: 'bash',
|
|
|
|
|
arguments: { command: 'sleep 60', description: 'test command' },
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
setTimeout(() => { controller.abort() }, 50)
|
|
|
|
|
const result = await pending
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(text(result)).toMatch(/aborted/)
|
|
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
// Type and required-key violations are rejected by the harness
|
2026-07-19 22:50:49 +08:00
|
|
|
// (defineTool validates against the SchemaSpec — the arg-validation Agent Note) before execute.
|
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.each([
|
2026-06-13 23:00:42 +08:00
|
|
|
[{}, /missing required property "command"/],
|
|
|
|
|
[{ command: 42, description: 'd' }, /"command" must be a string/],
|
|
|
|
|
[{ command: 'x' }, /missing required property "description"/],
|
|
|
|
|
[{ command: 'x', description: 7 }, /"description" must be a string/],
|
|
|
|
|
[{ command: 'x', description: 'd', timeoutMs: 'soon' }, /"timeoutMs" must be a number/],
|
|
|
|
|
[{ command: 'x', description: 'd', workdir: 7 }, /"workdir" must be a string/],
|
|
|
|
|
[{ command: 'x', description: 'd', run_in_background: 'yes' }, /"run_in_background" must be a boolean/],
|
|
|
|
|
])('rejects schema-invalid args %j', async (args, pattern) => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const result = await call(ctx, 'bash', args)
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(text(result)).toMatch(pattern)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Value constraints the SchemaSpec can't express stay in the tool body.
|
|
|
|
|
it.each([
|
|
|
|
|
[{ command: ' ', description: 'd' }, /invalid command/],
|
|
|
|
|
[{ command: 'x', description: ' ' }, /invalid description/],
|
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
|
|
|
[{ command: 'x', description: 'd', timeoutMs: -1 }, /invalid timeoutMs/],
|
2026-06-13 23:00:42 +08:00
|
|
|
])('rejects value-invalid args %j', async (args, pattern) => {
|
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 ctx = await setup()
|
|
|
|
|
const result = await call(ctx, 'bash', args)
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(text(result)).toMatch(pattern)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 22:55:26 +08:00
|
|
|
it('rejects a non-JSON numeric argument before tool-specific validation', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const result = await call(ctx, 'bash', {
|
|
|
|
|
command: 'x', description: 'd', timeoutMs: Number.NaN,
|
|
|
|
|
})
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(text(result)).toContain('tool execution arguments must be losslessly JSON-serializable')
|
|
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('registers the bash schema with run_in_background exposed by default', async () => {
|
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 ctx = await setup()
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
const schemas = ctx.tools.schemas()
|
|
|
|
|
expect(schemas.map(schema => schema.name)).toEqual(['bash'])
|
|
|
|
|
const bashSchema = schemas[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
|
|
|
expect(bashSchema.parameters).toMatchObject({
|
|
|
|
|
type: 'object',
|
|
|
|
|
required: ['command', 'description'],
|
|
|
|
|
})
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
expect(Object.keys(bashSchema.parameters.properties as Record<string, unknown>))
|
|
|
|
|
.toContain('run_in_background')
|
|
|
|
|
expect(bashSchema.description).toContain('task_output')
|
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
|
|
|
})
|
|
|
|
|
|
2026-07-05 01:54:46 +08:00
|
|
|
it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => {
|
|
|
|
|
const ctx = await setup()
|
2026-07-14 04:41:23 +08:00
|
|
|
ctx.systemPrompt.section({ name: 'test:before-bash', order: 104, text: 'before' })
|
|
|
|
|
ctx.systemPrompt.section({ name: 'test:after-bash', order: 106, text: 'after' })
|
2026-07-05 01:54:46 +08:00
|
|
|
const assembly = await ctx.systemPrompt.assemble()
|
|
|
|
|
const section = assembly.sections.find(s => s.name === 'tool:bash')
|
2026-07-14 04:41:23 +08:00
|
|
|
expect(assembly.sections.map(s => s.name)).toEqual([
|
|
|
|
|
'harness:identity',
|
|
|
|
|
'deployment:persona',
|
|
|
|
|
'test:before-bash',
|
|
|
|
|
'tool:bash',
|
|
|
|
|
'test:after-bash',
|
|
|
|
|
])
|
2026-07-05 01:54:46 +08:00
|
|
|
expect(section?.text).toContain('[exit code: 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
|
|
|
it('unregisters everything when the plugin fiber is disposed (HMR safety)', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
await ctx.plugin(LocalBashExecutor, {})
|
|
|
|
|
const fiber = await ctx.plugin(ToolBash)
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
expect(ctx.tools.schemas()).toHaveLength(1)
|
2026-07-05 23:23:46 +08:00
|
|
|
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool: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
|
|
|
await fiber.dispose()
|
|
|
|
|
expect(ctx.tools.schemas()).toHaveLength(0)
|
2026-07-05 23:23:46 +08:00
|
|
|
// Only the system-prompt plugin's own built-in sections remain.
|
|
|
|
|
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona'])
|
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('tools depend on the executor: no registration without ctx.bash', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
// inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
|
|
|
|
|
await ctx.plugin(ToolBash)
|
|
|
|
|
expect(ctx.tools.schemas()).toHaveLength(0)
|
|
|
|
|
await ctx.plugin(LocalBashExecutor, {})
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
expect(ctx.tools.schemas()).toHaveLength(1)
|
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
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('applies the built-in background default when apply() receives a bare config', async () => {
|
|
|
|
|
// Bypasses the schemastery defaults on purpose: apply() must stand on its
|
|
|
|
|
// own `?? true` fallback when embedded programmatically without the schema.
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
await ctx.plugin(LocalBashExecutor, {})
|
|
|
|
|
ToolBash.apply(ctx, {})
|
|
|
|
|
const schema = ctx.tools.schemas()[0]!
|
|
|
|
|
expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
|
|
|
|
|
.toContain('run_in_background')
|
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
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
describe('background execution through the task runtime', () => {
|
|
|
|
|
it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => {
|
|
|
|
|
const ctx = await setupWithTasks()
|
|
|
|
|
const started = await call(ctx, 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true })
|
|
|
|
|
expect(started.isError).toBe(false)
|
|
|
|
|
expect(text(started)).toBe('started background task bash-1')
|
|
|
|
|
|
|
|
|
|
const read = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, 'bg-ok')
|
|
|
|
|
expect(text(read)).toContain('bg-ok')
|
|
|
|
|
// A later read reports the terminal outcome in the generic status line.
|
|
|
|
|
const final = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, '[status: completed, exit code: 0]')
|
|
|
|
|
expect(final.isError).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
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('a running background task is killable through the REAL task_kill tool', async () => {
|
|
|
|
|
const ctx = await setupWithTasks()
|
|
|
|
|
await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
|
|
|
|
|
|
|
|
|
const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' })
|
|
|
|
|
expect(text(killed)).toBe('requested cancellation of task bash-1')
|
|
|
|
|
// The cancel reached the process handle; the task settles as killed with
|
|
|
|
|
// the signal detail mapped by processOutcome.
|
|
|
|
|
const final = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true })
|
|
|
|
|
expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-10 10:19:54 +08:00
|
|
|
it('a self-signal background exit is reported as killed through the REAL task_output tool', async () => {
|
|
|
|
|
const ctx = await setupWithTasks()
|
|
|
|
|
await call(ctx, 'bash', { command: 'kill -TERM $$', description: 'test command', run_in_background: true })
|
|
|
|
|
|
|
|
|
|
const final = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true })
|
|
|
|
|
expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
|
|
|
|
|
})
|
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
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('a background task started by an agent is registered with that agent as owner', async () => {
|
docs(tasks): condense background task prose
The background-task change repeated its lifecycle design across implemented RFCs, package READMEs, JSDoc, test commentary, and model-visible schemas. That repetition obscured the contracts that maintainers must preserve and added avoidable prompt tokens.
Rewrite the implemented RFCs around the current design, keep authorization, exact-owner cleanup, wait/abort ordering, producer quiescence, and teardown-failure guarantees at their owning surfaces, and remove peer surveys, review history, control-flow narration, and emphatic restatement.
Shorten the task and subagent schema wording, synchronize the bilingual tool cookbook, and regenerate the config, service, RFC, tool, and replay snapshot derivatives. Runtime behavior is unchanged; test edits update prose-only assertions and descriptions.
2026-07-15 21:08:58 +08:00
|
|
|
// The producer must forward exec.agent as the task owner.
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
const ctx = await setupWithTasks()
|
|
|
|
|
const agent = registerFakeAgent(ctx, 'sess-owner')
|
|
|
|
|
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }, agent)
|
|
|
|
|
expect(text(started)).toBe('started background task bash-1')
|
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
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
const anon = await call(ctx, 'task_output', { task_id: 'bash-1' })
|
|
|
|
|
expect(anon.isError).toBe(true)
|
|
|
|
|
expect(text(anon)).toMatch(/belongs to another session/)
|
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
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' }, agent)
|
|
|
|
|
expect(killed.isError).toBe(false)
|
|
|
|
|
await call(ctx, 'task_output', { task_id: 'bash-1', wait: true }, agent) // await settlement — no orphan
|
|
|
|
|
})
|
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
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('fails loud when the task runtime is not loaded', async () => {
|
|
|
|
|
const ctx = await setup() // no TaskService / ToolTasks
|
|
|
|
|
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
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
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('a pre-aborted call refuses to start: isError, no process spawned', async () => {
|
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 ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
await ctx.plugin(TaskService)
|
|
|
|
|
await ctx.plugin(ToolTasks)
|
|
|
|
|
await ctx.plugin(CountingStartExecutor)
|
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
|
|
|
await ctx.plugin(ToolBash)
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
const controller = new AbortController()
|
|
|
|
|
controller.abort()
|
|
|
|
|
const result = await ctx.tools.execute({
|
|
|
|
|
callId: CallId('call-pre-aborted'),
|
|
|
|
|
name: 'bash',
|
|
|
|
|
arguments: { command: 'sleep 60', description: 'test command', run_in_background: true },
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(text(result)).toContain('command aborted')
|
|
|
|
|
expect((ctx.bash as CountingStartExecutor).starts).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
|
|
|
})
|
|
|
|
|
|
refactor(tasks): declare-then-execute — ctx.tasks.start() replaces register()
start({ kind, label, owner, run }) preflights everything that can fail
(the attachSurface fence, validation, the owner-cleanup attach) BEFORE
invoking the producer's run() starter, then commits atomically —
'work started but never got a collectable id' is now structurally
impossible instead of a producer try/catch rollback obligation (the
P1 review fix, rebuilt on #185's declare/execute split). Producers
lose their catch-wraps; the leak tests now pin the stronger property
that a failed preflight never spawns anything. TaskRegistration splits
into TaskStart (identity + run) and TaskHooks (cancel/done/readOutput);
docs, type-equiv manifest, catalogs, and both RFCs move with it.
2026-07-09 21:53:48 +08:00
|
|
|
it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => {
|
docs(tasks): condense background task prose
The background-task change repeated its lifecycle design across implemented RFCs, package READMEs, JSDoc, test commentary, and model-visible schemas. That repetition obscured the contracts that maintainers must preserve and added avoidable prompt tokens.
Rewrite the implemented RFCs around the current design, keep authorization, exact-owner cleanup, wait/abort ordering, producer quiescence, and teardown-failure guarantees at their owning surfaces, and remove peer surveys, review history, control-flow narration, and emphatic restatement.
Shorten the task and subagent schema wording, synchronize the bilingual tool cookbook, and regenerate the config, service, RFC, tool, and replay snapshot derivatives. Runtime behavior is unchanged; test edits update prose-only assertions and descriptions.
2026-07-15 21:08:58 +08:00
|
|
|
// With no control surface, task preflight fails before the executor can spawn.
|
2026-06-19 01:54:57 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
await ctx.plugin(TaskService)
|
2026-07-14 09:59:21 +08:00
|
|
|
await ctx.plugin(CountingStartExecutor)
|
2026-06-19 01:54:57 +08:00
|
|
|
await ctx.plugin(ToolBash)
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(text(result)).toContain('no control surface is attached')
|
refactor(tasks): declare-then-execute — ctx.tasks.start() replaces register()
start({ kind, label, owner, run }) preflights everything that can fail
(the attachSurface fence, validation, the owner-cleanup attach) BEFORE
invoking the producer's run() starter, then commits atomically —
'work started but never got a collectable id' is now structurally
impossible instead of a producer try/catch rollback obligation (the
P1 review fix, rebuilt on #185's declare/execute split). Producers
lose their catch-wraps; the leak tests now pin the stronger property
that a failed preflight never spawns anything. TaskRegistration splits
into TaskStart (identity + run) and TaskHooks (cancel/done/readOutput);
docs, type-equiv manifest, catalogs, and both RFCs move with it.
2026-07-09 21:53:48 +08:00
|
|
|
// Declare-then-execute: the failed preflight means no process ever ran.
|
2026-07-14 09:59:21 +08:00
|
|
|
expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
|
2026-06-19 01:54:57 +08:00
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('enableRunInBackground: false removes the parameter and flips the description', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
await ctx.plugin(LocalBashExecutor, {})
|
|
|
|
|
await ctx.plugin(ToolBash, { enableRunInBackground: 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
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
const schema = ctx.tools.schemas().find(s => s.name === 'bash')!
|
|
|
|
|
expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
|
|
|
|
|
.toEqual(['command', 'description', 'timeoutMs', 'workdir'])
|
|
|
|
|
expect(schema.description).toContain('Background execution is not available')
|
|
|
|
|
expect(schema.description).not.toContain('run_in_background')
|
|
|
|
|
// The registry-held definition agrees (schema and capability never disagree).
|
|
|
|
|
const parameters = ctx.tools.get('bash')!.parameters as { properties: Record<string, unknown> }
|
|
|
|
|
expect('run_in_background' in parameters.properties).toBe(false)
|
2026-07-09 23:37:51 +08:00
|
|
|
|
docs(tasks): condense background task prose
The background-task change repeated its lifecycle design across implemented RFCs, package READMEs, JSDoc, test commentary, and model-visible schemas. That repetition obscured the contracts that maintainers must preserve and added avoidable prompt tokens.
Rewrite the implemented RFCs around the current design, keep authorization, exact-owner cleanup, wait/abort ordering, producer quiescence, and teardown-failure guarantees at their owning surfaces, and remove peer surveys, review history, control-flow narration, and emphatic restatement.
Shorten the task and subagent schema wording, synchronize the bilingual tool cookbook, and regenerate the config, service, RFC, tool, and replay snapshot derivatives. Runtime behavior is unchanged; test edits update prose-only assertions and descriptions.
2026-07-15 21:08:58 +08:00
|
|
|
// Schema omission is advertising; execution must also enforce the opt-out.
|
2026-07-09 23:37:51 +08:00
|
|
|
const forced = await call(ctx, 'bash', { command: 'echo hi', description: 'test command', run_in_background: true })
|
|
|
|
|
expect(forced.isError).toBe(true)
|
|
|
|
|
expect(text(forced)).toContain('run_in_background is disabled for this deployment')
|
|
|
|
|
const foreground = await call(ctx, 'bash', { command: 'echo hi', description: 'test command' })
|
|
|
|
|
expect(foreground.isError).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
|
|
|
})
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
})
|
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
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
describe('sandbox escalation through the generic task producer', () => {
|
|
|
|
|
const escalate = {
|
|
|
|
|
command: 'true',
|
|
|
|
|
description: 'test escalation',
|
|
|
|
|
sandbox_permissions: 'workspace-write',
|
|
|
|
|
justification: 'the command needs workspace writes',
|
|
|
|
|
}
|
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
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
it('advertises the sandbox fields and validates their pairing', async () => {
|
|
|
|
|
const { ctx } = await setupSandboxed()
|
|
|
|
|
const schema = ctx.tools.schemas().find(item => item.name === 'bash')!
|
|
|
|
|
const properties = schema.parameters.properties as Record<string, { enum?: string[] }>
|
|
|
|
|
expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
|
|
|
|
|
expect(schema.description).toContain('approval prompt')
|
|
|
|
|
|
|
|
|
|
for (const args of [
|
|
|
|
|
{ command: 'true', description: 'd', sandbox_permissions: 'workspace-write' },
|
|
|
|
|
{ command: 'true', description: 'd', justification: 'why' },
|
|
|
|
|
{ command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' },
|
|
|
|
|
]) {
|
|
|
|
|
expect((await call(ctx, 'bash', args)).isError).toBe(true)
|
|
|
|
|
}
|
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
|
|
|
})
|
|
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => {
|
|
|
|
|
const plain = await setup()
|
|
|
|
|
expect(text(await call(plain, 'bash', escalate))).toContain('not available in this composition')
|
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
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
const { ctx } = await setupSandboxed(true)
|
|
|
|
|
const prompted = vi.fn()
|
|
|
|
|
ctx.on('approval/request', () => { prompted(); return Promise.resolve<ApprovalOutcome>('allowed-once') })
|
|
|
|
|
const result = await call(ctx, 'bash', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write'))
|
|
|
|
|
expect(text(result)).toContain('not strictly wider')
|
|
|
|
|
expect(prompted).not.toHaveBeenCalled()
|
|
|
|
|
|
|
|
|
|
const malformed = sandboxAgent()
|
|
|
|
|
;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({
|
2026-07-16 23:31:51 +08:00
|
|
|
type: 'sandbox/mode',
|
2026-07-11 23:04:27 +08:00
|
|
|
data: { mode: 'unknown-mode' },
|
|
|
|
|
})
|
|
|
|
|
expect(text(await call(ctx, 'bash', escalate, malformed))).toContain('not strictly wider')
|
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
|
|
|
})
|
|
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
it('fails closed when approval cannot be routed', async () => {
|
|
|
|
|
const withoutService = await setupSandboxed()
|
|
|
|
|
expect(text(await call(withoutService.ctx, 'bash', escalate, sandboxAgent()))).toContain('no approval service')
|
|
|
|
|
|
|
|
|
|
const withService = await setupSandboxed(true)
|
|
|
|
|
expect(text(await call(withService.ctx, 'bash', escalate))).toContain('no agent to route')
|
|
|
|
|
expect(text(await call(withService.ctx, 'bash', escalate, sandboxAgent()))).toContain('no approval channel')
|
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
|
|
|
})
|
|
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
it.each([
|
|
|
|
|
['rejected', 'user rejected'],
|
|
|
|
|
['cancelled', 'was cancelled'],
|
|
|
|
|
] as const)('maps an approval %s to its distinct failure', async (outcome, message) => {
|
|
|
|
|
const { ctx, bash } = await setupSandboxed(true)
|
|
|
|
|
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>(outcome))
|
|
|
|
|
const result = await call(ctx, 'bash', escalate, sandboxAgent())
|
|
|
|
|
expect(text(result)).toContain(message)
|
|
|
|
|
expect(bash.modes).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('runs a granted foreground or background call under the approved mode', async () => {
|
|
|
|
|
const { ctx, bash } = await setupSandboxed(true)
|
|
|
|
|
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
2026-07-13 15:57:17 +08:00
|
|
|
const agent = sandboxAgent(undefined, ctx)
|
2026-07-11 23:04:27 +08:00
|
|
|
ctx.agents.register(agent)
|
|
|
|
|
const foreground = await ctx.tools.execute({
|
|
|
|
|
callId: CallId('sandbox-signal'),
|
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
|
|
|
name: 'bash',
|
2026-07-11 23:04:27 +08:00
|
|
|
arguments: escalate,
|
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
|
|
|
agent,
|
2026-07-11 23:04:27 +08:00
|
|
|
signal: new AbortController().signal,
|
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
|
|
|
})
|
2026-07-11 23:04:27 +08:00
|
|
|
expect(foreground.isError).toBe(false)
|
|
|
|
|
const background = await call(ctx, 'bash', { ...escalate, run_in_background: true }, agent)
|
|
|
|
|
expect(text(background)).toBe('started background task bash-1')
|
|
|
|
|
expect(bash.modes).toEqual(['workspace-write', 'workspace-write'])
|
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
|
|
|
})
|
|
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
it('uses the session override for ordinary calls and evaluates widening against it', async () => {
|
|
|
|
|
const { ctx, bash } = await setupSandboxed(true)
|
|
|
|
|
const agent = sandboxAgent('workspace-write')
|
|
|
|
|
await call(ctx, 'bash', { command: 'true', description: 'ordinary' }, agent)
|
|
|
|
|
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
|
|
|
|
await call(ctx, 'bash', { ...escalate, sandbox_permissions: 'danger-full-access' }, agent)
|
|
|
|
|
expect(bash.modes).toEqual(['workspace-write', 'danger-full-access'])
|
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
|
|
|
})
|
|
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => {
|
|
|
|
|
const { ctx } = await setupSandboxed(true)
|
|
|
|
|
ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)
|
|
|
|
|
const result = await call(ctx, 'bash', escalate, sandboxAgent())
|
2026-07-16 23:31:51 +08:00
|
|
|
expect(text(result)).toContain('unreachable variant in EscalationOutcome')
|
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
|
|
|
})
|
2026-07-11 23:04:27 +08:00
|
|
|
})
|
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
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
describe('renderProcessRead', () => {
|
|
|
|
|
const base: BashProcessRead = { delta: 'out\n', lossy: 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
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('returns the delta verbatim for a lossless read', () => {
|
|
|
|
|
expect(renderProcessRead(base)).toBe('out\n')
|
|
|
|
|
expect(renderProcessRead({ delta: '', lossy: false })).toBe('')
|
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
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('appends the loss notice with the available spill paths', () => {
|
|
|
|
|
expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log' }))
|
|
|
|
|
.toBe('out\n[some output was dropped from memory; full output: /spill/out.log]')
|
|
|
|
|
expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log', stderrSpillPath: '/spill/err.log' }))
|
|
|
|
|
.toBe('out\n[some output was dropped from memory; full output: /spill/out.log, /spill/err.log]')
|
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
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('reports (unavailable) when a lossy read has no safe spill path', () => {
|
|
|
|
|
expect(renderProcessRead({ ...base, lossy: true }))
|
|
|
|
|
.toBe('out\n[some output was dropped from memory; full output: (unavailable)]')
|
2026-06-20 08:14:27 +08:00
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('an empty lossy delta is the notice alone', () => {
|
|
|
|
|
expect(renderProcessRead({ delta: '', lossy: true, stderrSpillPath: '/spill/err.log' }))
|
|
|
|
|
.toBe('[some output was dropped from memory; full output: /spill/err.log]')
|
2026-06-20 08:14:27 +08:00
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('inserts the separating newline only when the delta lacks one', () => {
|
|
|
|
|
expect(renderProcessRead({ delta: 'tail', lossy: true }))
|
|
|
|
|
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
|
|
|
|
|
expect(renderProcessRead({ delta: 'tail\n', lossy: true }))
|
|
|
|
|
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
|
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
|
|
|
})
|
2026-06-16 19:23:21 +08:00
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
it('appends settled sandbox denial and runner-failure facts', () => {
|
|
|
|
|
expect(renderProcessRead(base, { mode: 'read-only', denied: true }, ['workspace-write']))
|
|
|
|
|
.toContain('[sandbox: escalation available')
|
|
|
|
|
expect(renderProcessRead({ delta: 'tail', lossy: false }, { mode: 'read-only', denied: true }))
|
|
|
|
|
.toBe('tail\n[sandbox: file access denied under read-only mode]')
|
|
|
|
|
const runner = renderProcessRead(
|
|
|
|
|
{ delta: '', lossy: false },
|
|
|
|
|
{ mode: 'workspace-write', denied: true, runnerFailed: true },
|
|
|
|
|
['danger-full-access'],
|
|
|
|
|
)
|
|
|
|
|
expect(runner).toContain('sandbox runner itself failed under workspace-write mode')
|
|
|
|
|
expect(runner).not.toContain('file access denied')
|
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
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
describe('processOutcome', () => {
|
|
|
|
|
function settled(over: Partial<BashProcess>): BashProcess {
|
|
|
|
|
return {
|
|
|
|
|
status: 'completed',
|
|
|
|
|
exitCode: 0,
|
|
|
|
|
signal: null,
|
|
|
|
|
done: Promise.resolve(),
|
|
|
|
|
readOutput: () => ({ delta: '', lossy: false }),
|
|
|
|
|
kill: () => false,
|
|
|
|
|
...over,
|
|
|
|
|
}
|
2026-06-16 19:23:21 +08:00
|
|
|
}
|
2026-06-20 08:14:27 +08:00
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('maps a signal-killed process to killed with the signal detail', () => {
|
|
|
|
|
expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' })))
|
|
|
|
|
.toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
|
2026-06-20 08:14:27 +08:00
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => {
|
|
|
|
|
expect(processOutcome(settled({ status: 'killed', exitCode: null })))
|
|
|
|
|
.toEqual({ status: 'killed', detail: 'killed before exit' })
|
2026-06-16 19:23:21 +08:00
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('maps a completed process to its exit code', () => {
|
|
|
|
|
expect(processOutcome(settled({ exitCode: 3 })))
|
|
|
|
|
.toEqual({ status: 'completed', detail: 'exit code: 3' })
|
2026-06-16 19:23:21 +08:00
|
|
|
})
|
|
|
|
|
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => {
|
|
|
|
|
expect(processOutcome(settled({ exitCode: null })))
|
|
|
|
|
.toEqual({ status: 'completed', detail: 'exit code: 0' })
|
2026-06-16 19:23:21 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-17 10:01:18 +08:00
|
|
|
describe('session-cwd routing (per-session workdir)', () => {
|
|
|
|
|
// An agent whose session header carries a cwd (what session/new records).
|
|
|
|
|
const agentInCwd = (cwd: string) =>
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as Agent
|
2026-06-17 10:01:18 +08:00
|
|
|
|
|
|
|
|
it('defaults bash to the agent\'s session cwd (not the server launch dir)', async () => {
|
|
|
|
|
const ctx = await setup()
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/tmp'))
|
2026-06-17 10:01:18 +08:00
|
|
|
expect(text(result).trim()).toMatch(/\/tmp$/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('an explicit absolute workdir overrides the session cwd', async () => {
|
|
|
|
|
const ctx = await setup()
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd', workdir: '/tmp' }, agentInCwd('/'))
|
2026-06-17 10:01:18 +08:00
|
|
|
expect(text(result).trim()).toMatch(/\/tmp$/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a relative workdir is resolved against the session cwd', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
// session cwd /usr + relative 'bin' → /usr/bin
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd', workdir: 'bin' }, agentInCwd('/usr'))
|
2026-06-17 10:01:18 +08:00
|
|
|
expect(text(result).trim()).toMatch(/\/usr\/bin$/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('two sessions with different cwds each run bash in their own dir', async () => {
|
|
|
|
|
const ctx = await setup()
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
const inUsr = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/usr'))
|
|
|
|
|
const inTmp = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/tmp'))
|
2026-06-17 10:01:18 +08:00
|
|
|
expect(text(inUsr).trim()).toMatch(/\/usr$/)
|
|
|
|
|
expect(text(inTmp).trim()).toMatch(/\/tmp$/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('falls back to the executor default when the agent has no session cwd', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
// No exec.agent at all → executor uses its config/process.cwd() default.
|
|
|
|
|
const result = await ctx.tools.execute({ callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } })
|
|
|
|
|
expect(result.isError).toBe(false)
|
|
|
|
|
expect(text(result).trim().length).toBeGreaterThan(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
|
|
|
describe('renderResult', () => {
|
|
|
|
|
const base = {
|
|
|
|
|
exitCode: 0 as number | null,
|
|
|
|
|
signal: null as NodeJS.Signals | null,
|
|
|
|
|
timedOut: false,
|
|
|
|
|
aborted: false,
|
|
|
|
|
timeoutMs: 1000,
|
|
|
|
|
stdout: { text: '', truncated: false },
|
|
|
|
|
stderr: { text: '', truncated: false },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('renders stderr-only output without a stdout prefix', () => {
|
|
|
|
|
expect(renderResult({ ...base, stderr: { text: 'err\n', truncated: false } }))
|
|
|
|
|
.toBe('[stderr]\nerr\n')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('adds a separator when stdout does not end with a newline', () => {
|
|
|
|
|
expect(renderResult({
|
|
|
|
|
...base,
|
|
|
|
|
stdout: { text: 'out', truncated: false },
|
|
|
|
|
stderr: { text: 'err', truncated: false },
|
|
|
|
|
})).toBe('out\n[stderr]\nerr')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('appends exit-code markers after a newline for unterminated output', () => {
|
|
|
|
|
expect(renderResult({ ...base, exitCode: 7, stdout: { text: 'x', truncated: false } }))
|
|
|
|
|
.toBe('x\n[exit code: 7]')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders signal kills without the timeout marker when not timed out', () => {
|
|
|
|
|
expect(renderResult({ ...base, exitCode: null, signal: 'SIGKILL' }))
|
|
|
|
|
.toBe('(no output)\n[killed by signal: SIGKILL]')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports a timeout that exited 0 (trapped signal) without a kill marker', () => {
|
|
|
|
|
expect(renderResult({ ...base, exitCode: 0, signal: null, timedOut: true }))
|
|
|
|
|
.toBe('(no output)\n[timed out after 1000ms]')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('orders the timeout marker before a kill marker', () => {
|
|
|
|
|
expect(renderResult({ ...base, exitCode: null, signal: 'SIGTERM', timedOut: true }))
|
|
|
|
|
.toBe('(no output)\n[timed out after 1000ms]\n[killed by signal: SIGTERM]')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('notes truncation with a fallback when the spill path is missing', () => {
|
|
|
|
|
expect(renderResult({ ...base, stdout: { text: 'tail', truncated: true } }))
|
|
|
|
|
.toBe('tail\n[output truncated; full output: (unavailable)]')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
it('reports sandbox denials before exit status and hints only when escalation is advertised', () => {
|
|
|
|
|
const result: BashRunResult = {
|
|
|
|
|
exitCode: 1,
|
|
|
|
|
signal: null,
|
|
|
|
|
timedOut: false,
|
|
|
|
|
aborted: false,
|
|
|
|
|
timeoutMs: 1000,
|
|
|
|
|
stdout: { text: '', truncated: false },
|
|
|
|
|
stderr: { text: 'denied', truncated: false },
|
|
|
|
|
sandbox: { mode: 'read-only', denied: true },
|
|
|
|
|
}
|
|
|
|
|
expect(renderResult(result)).toMatch(/denied under read-only mode\]\n\[exit code: 1\]$/)
|
|
|
|
|
expect(renderResult(result, ['workspace-write'])).toContain('[sandbox: escalation available')
|
|
|
|
|
expect(renderResult({ ...result, sandbox: { mode: 'read-only', denied: false } }, ['workspace-write']))
|
|
|
|
|
.not.toContain('[sandbox:')
|
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
|
|
|
})
|
|
|
|
|
})
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
|
|
|
|
|
describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
2026-07-03 02:04:03 +08:00
|
|
|
it('bash presentCall: a foreground run is a terminal card (command title, description, workdir → cwd absolute or relative)', async () => {
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
const ctx = await setup()
|
2026-07-03 02:04:03 +08:00
|
|
|
// No explicit workdir → a terminal card with no cwd (the UI bridge fills the
|
|
|
|
|
// session cwd it owns; the pure presenter can't see it).
|
2026-06-18 17:25:09 +08:00
|
|
|
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls -la src', description: 'List files in src' }))
|
2026-07-03 02:04:03 +08:00
|
|
|
.toEqual({ card: 'terminal', title: 'ls -la src', description: 'List files in src' })
|
2026-06-18 18:54:32 +08:00
|
|
|
// An ABSOLUTE workdir is surfaced verbatim as the terminal cwd header.
|
2026-06-18 17:25:09 +08:00
|
|
|
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: '/tmp/x' }))
|
2026-07-03 02:04:03 +08:00
|
|
|
.toEqual({ card: 'terminal', title: 'pwd', description: 'Print dir', cwd: '/tmp/x' })
|
2026-06-18 18:54:32 +08:00
|
|
|
// A RELATIVE workdir is passed through AS-IS (the bridge resolves it against
|
|
|
|
|
// the session cwd, matching where execution runs) — not dropped.
|
2026-06-18 17:25:09 +08:00
|
|
|
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: 'sub' }))
|
2026-07-03 02:04:03 +08:00
|
|
|
.toEqual({ card: 'terminal', title: 'pwd', description: 'Print dir', cwd: 'sub' })
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-03 02:04:03 +08:00
|
|
|
it('bash presentResult: a terminal result carries RAW output (newlines intact) + parsed exit code', async () => {
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
const present = ctx.tools.get('bash')!.presentResult!(
|
|
|
|
|
{ command: 'echo hi', description: 'echo' },
|
|
|
|
|
{ content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
|
|
|
|
|
)
|
2026-07-03 02:04:03 +08:00
|
|
|
// A terminal result keeps the RAW bytes (newlines intact) a terminal renderer
|
|
|
|
|
// needs; the bridge derives the fenced fallback. exitCode is parsed back from
|
|
|
|
|
// the [exit code: N] marker.
|
|
|
|
|
expect(present).toEqual({ card: 'terminal', output: 'hi\n[exit code: 0]\n\n', exitCode: 0 })
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
})
|
|
|
|
|
|
2026-06-18 18:54:32 +08:00
|
|
|
it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const args = { command: 'x', description: 'x' }
|
|
|
|
|
const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })
|
2026-07-03 02:04:03 +08:00
|
|
|
expect(nonzero).toEqual({ card: 'terminal', output: 'oops\n[exit code: 3]', exitCode: 3 })
|
2026-06-18 18:54:32 +08:00
|
|
|
const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })
|
2026-07-03 02:04:03 +08:00
|
|
|
expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
|
2026-06-18 18:54:32 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const present = ctx.tools.get('bash')!
|
|
|
|
|
// For each renderResult outcome, the rendered text fed back through
|
|
|
|
|
// presentResult recovers the matching structured exit — the parse and the
|
|
|
|
|
// marker emission co-evolve in one file, so this pins the pair.
|
|
|
|
|
const base = {
|
|
|
|
|
aborted: false,
|
|
|
|
|
timeoutMs: 1000,
|
|
|
|
|
stdout: { text: 'out', truncated: false },
|
|
|
|
|
stderr: { text: '', truncated: false },
|
|
|
|
|
}
|
|
|
|
|
const cases = [
|
|
|
|
|
{ result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } },
|
|
|
|
|
{ result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } },
|
|
|
|
|
{ result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } },
|
|
|
|
|
// A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0).
|
|
|
|
|
{ result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } },
|
|
|
|
|
]
|
|
|
|
|
for (const c of cases) {
|
|
|
|
|
const rendered = renderResult(c.result)
|
|
|
|
|
const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
|
2026-07-03 02:04:03 +08:00
|
|
|
// Drop card + output; the remaining fields are the parsed exit.
|
|
|
|
|
const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
|
2026-06-18 18:54:32 +08:00
|
|
|
expect(exit).toEqual(c.expect)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-18 19:35:15 +08:00
|
|
|
it('bash presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const args = { command: 'printf "[exit code: 5]"', description: 'print' }
|
2026-07-13 23:27:00 +08:00
|
|
|
// A successful command may print marker-like text. A clean result appends no marker or
|
|
|
|
|
// newline; parsing requires the leading newline emitted for real markers, so this stays exit 0.
|
2026-06-18 19:35:15 +08:00
|
|
|
const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
|
2026-07-03 02:04:03 +08:00
|
|
|
expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
|
2026-06-18 19:35:15 +08:00
|
|
|
// Same for a fake signal marker with no leading newline.
|
|
|
|
|
const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
|
2026-07-03 02:04:03 +08:00
|
|
|
expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 })
|
2026-06-18 19:35:15 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-03 02:04:03 +08:00
|
|
|
it('bash presentCall/presentResult: a run_in_background call is a generic card and its ack carries no exit pill', async () => {
|
2026-06-18 19:35:15 +08:00
|
|
|
const ctx = await setup()
|
2026-07-03 02:04:03 +08:00
|
|
|
// The background start returns a task-id ack, not a streamed run — a generic
|
|
|
|
|
// execute card with the command as rawInput and the description as content.
|
2026-06-18 19:35:15 +08:00
|
|
|
const call = ctx.tools.get('bash')!.presentCall!({ command: 'sleep 100', description: 'wait', run_in_background: true })
|
2026-07-03 02:04:03 +08:00
|
|
|
expect(call).toEqual({ card: 'generic', title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] })
|
|
|
|
|
// The ack result is a generic fenced-text card — no terminal output / exit pill.
|
2026-06-18 19:35:15 +08:00
|
|
|
const result = ctx.tools.get('bash')!.presentResult!(
|
|
|
|
|
{ command: 'sleep 100', description: 'wait', run_in_background: true },
|
|
|
|
|
{ content: [{ type: 'text', text: 'started background task bash-1' }], isError: false },
|
|
|
|
|
)
|
2026-07-03 02:04:03 +08:00
|
|
|
expect(result).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\nstarted background task bash-1\n```' }] })
|
2026-06-18 19:35:15 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-03 02:04:03 +08:00
|
|
|
it('bash presentResult: an isError result is a generic card (no real process exit to report)', async () => {
|
2026-06-18 19:35:15 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
// A spawn failure / abort has no process exit — the body is an error message,
|
2026-07-03 02:04:03 +08:00
|
|
|
// not renderResult output, so a generic fenced card, no terminal output/exit.
|
2026-06-18 19:35:15 +08:00
|
|
|
const out = ctx.tools.get('bash')!.presentResult!(
|
|
|
|
|
{ command: 'x', description: 'x' },
|
|
|
|
|
{ content: [{ type: 'text', text: 'command aborted' }], isError: true },
|
|
|
|
|
)
|
2026-07-03 02:04:03 +08:00
|
|
|
expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] })
|
2026-06-18 19:35:15 +08:00
|
|
|
})
|
|
|
|
|
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const present = ctx.tools.get('bash')!.presentResult!(
|
|
|
|
|
{ command: 'x', description: 'x' },
|
refactor(llm): drop the image content block until a path can honor it
ImageBlock had no production producer and every consumer dropped it:
the deepseek serializer skipped it, the pi-ai converter skipped it as
unrepresentable, the ACP bridge neither advertises image prompt
capability nor forwards image blocks, and compact-basic charged a flat
85-token estimate and rendered an [image] placeholder. A block
constructed today would silently vanish from the wire — the vocabulary
advertised a capability no path honors, the silent-data-loss shape the
defensive patterns warn against. The only constructors were tests
pinning the skip/estimate branches.
Remove ImageBlock and its ContentBlockMap entry (its cache?: CacheHint
field leaves with it; CacheHint itself and the other two cache? fields
are out of scope). compact-basic loses its explicit image estimate and
placeholder arms (the merge-extensible default arms absorb the case);
the deepseek serializer, pi-ai converter, and ACP codec already handled
image in their default arms, so only their image-naming comments
change. The codec's inbound rejection of ACP-protocol image prompt
content stays — that guards wire content a client can send regardless
of our vocabulary.
Tests that constructed harness image blocks to pin the removed branches
are dropped (the 85-token estimate pin) or retargeted onto plugin-added
block types / other non-text blocks, which the surviving default arms
own. Docs, the type-equiv pastes, and the content-block vocabulary
RFC's block list and multimodal-home consequence are updated in the
same change; the RFC moves to implemented/ and the index is
regenerated. A real multimodal feature reintroduces image via
declaration merging together with the adapter mapping, ACP
advertisement, and compaction pricing that honor it.
2026-07-04 17:21:13 +08:00
|
|
|
{ content: [{ type: 'reasoning', text: 'unexpected' }], isError: false },
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
)
|
|
|
|
|
expect(present).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('bash presentResult: a result that is not exactly one block → undefined (no single text to fence)', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const args = { command: 'x', description: 'x' }
|
|
|
|
|
// Empty content (no block) and multi-block content both fall through.
|
|
|
|
|
expect(ctx.tools.get('bash')!.presentResult!(args, { content: [], isError: false })).toBeUndefined()
|
|
|
|
|
expect(ctx.tools.get('bash')!.presentResult!(args, {
|
|
|
|
|
content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }],
|
|
|
|
|
isError: false,
|
|
|
|
|
})).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => {
|
|
|
|
|
const ctx = await setup()
|
2026-07-13 23:27:00 +08:00
|
|
|
// `defineTool` soft-validates replayed logged args before presentation. Invalid shapes return
|
|
|
|
|
// undefined for generic UI rendering rather than throwing; `presentCall` accepts `unknown`.
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
})
|
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
|
|
|
|
docs(bash): reframe stdin/env — the scrub is the security control, not a trust boundary
Address review: the "trusted-plugin surface" framing overstated the security
story. A model driving the `bash` tool already has equivalent power to set env
vars and feed stdin through ordinary shell syntax (`FOO=bar cmd`, heredocs), so
the `env`/`stdin` seam fields grant it no new capability — and they cannot
exfiltrate the harness's ambient credentials, because the credential SCRUB in
dsh-bash-local (which strips *KEY*/*SECRET*/*TOKEN* from process.env before the
child sees it) is the actual control, and it works regardless of these fields
(tool-call args are static JSON, never shell-evaluated).
So drop the "dangerous / trusted-plugin boundary" language across the RFC, the
three bash-package READMEs, the bash/src/types.ts JSDoc, and docs/bash.md (both
the type-equiv blocks — kept 1:1 with source — and the prose). The reality that
remains: the `bash` tool doesn't EXPOSE env/stdin as parameters because they'd
be redundant with shell syntax; the fields exist for in-process plugins (the
hooks bridges) to pass a JSON payload + CLAUDE_* vars cleanly. The guard test is
kept but reframed: it catches a future `...args` spread that would silently
forward model input into the post-scrub env merge, NOT a trust wall. No code or
behavior change.
2026-07-02 04:08:24 +08:00
|
|
|
describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => {
|
2026-07-12 15:41:42 +08:00
|
|
|
const recordingDshHome = join(spillDir, 'dsh-home')
|
|
|
|
|
|
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
|
|
|
/**
|
|
|
|
|
* Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a
|
docs(bash): reframe stdin/env — the scrub is the security control, not a trust boundary
Address review: the "trusted-plugin surface" framing overstated the security
story. A model driving the `bash` tool already has equivalent power to set env
vars and feed stdin through ordinary shell syntax (`FOO=bar cmd`, heredocs), so
the `env`/`stdin` seam fields grant it no new capability — and they cannot
exfiltrate the harness's ambient credentials, because the credential SCRUB in
dsh-bash-local (which strips *KEY*/*SECRET*/*TOKEN* from process.env before the
child sees it) is the actual control, and it works regardless of these fields
(tool-call args are static JSON, never shell-evaluated).
So drop the "dangerous / trusted-plugin boundary" language across the RFC, the
three bash-package READMEs, the bash/src/types.ts JSDoc, and docs/bash.md (both
the type-equiv blocks — kept 1:1 with source — and the prose). The reality that
remains: the `bash` tool doesn't EXPOSE env/stdin as parameters because they'd
be redundant with shell syntax; the fields exist for in-process plugins (the
hooks bridges) to pass a JSON payload + CLAUDE_* vars cleanly. The guard test is
kept but reframed: it catches a future `...args` spread that would silently
forward model input into the post-scrub env merge, NOT a trust wall. No code or
behavior change.
2026-07-02 04:08:24 +08:00
|
|
|
* test can assert what the model-facing tool DID and DID NOT forward. The `bash`
|
2026-07-10 11:53:02 +08:00
|
|
|
* tool does not expose trusted-plugin fields (`stdoutMaxBytes`, `stdin`, or
|
|
|
|
|
* `env`) as parameters, so it must build its request from named args only and
|
docs(bash): reframe stdin/env — the scrub is the security control, not a trust boundary
Address review: the "trusted-plugin surface" framing overstated the security
story. A model driving the `bash` tool already has equivalent power to set env
vars and feed stdin through ordinary shell syntax (`FOO=bar cmd`, heredocs), so
the `env`/`stdin` seam fields grant it no new capability — and they cannot
exfiltrate the harness's ambient credentials, because the credential SCRUB in
dsh-bash-local (which strips *KEY*/*SECRET*/*TOKEN* from process.env before the
child sees it) is the actual control, and it works regardless of these fields
(tool-call args are static JSON, never shell-evaluated).
So drop the "dangerous / trusted-plugin boundary" language across the RFC, the
three bash-package READMEs, the bash/src/types.ts JSDoc, and docs/bash.md (both
the type-equiv blocks — kept 1:1 with source — and the prose). The reality that
remains: the `bash` tool doesn't EXPOSE env/stdin as parameters because they'd
be redundant with shell syntax; the fields exist for in-process plugins (the
hooks bridges) to pass a JSON payload + CLAUDE_* vars cleanly. The guard test is
kept but reframed: it catches a future `...args` spread that would silently
forward model input into the post-scrub env merge, NOT a trust wall. No code or
behavior change.
2026-07-02 04:08:24 +08:00
|
|
|
* 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
|
2026-07-10 11:53:02 +08:00
|
|
|
* model input into the post-scrub `env` merge or per-run capture budget — NOT
|
|
|
|
|
* to defend a trust boundary
|
docs(bash): reframe stdin/env — the scrub is the security control, not a trust boundary
Address review: the "trusted-plugin surface" framing overstated the security
story. A model driving the `bash` tool already has equivalent power to set env
vars and feed stdin through ordinary shell syntax (`FOO=bar cmd`, heredocs), so
the `env`/`stdin` seam fields grant it no new capability — and they cannot
exfiltrate the harness's ambient credentials, because the credential SCRUB in
dsh-bash-local (which strips *KEY*/*SECRET*/*TOKEN* from process.env before the
child sees it) is the actual control, and it works regardless of these fields
(tool-call args are static JSON, never shell-evaluated).
So drop the "dangerous / trusted-plugin boundary" language across the RFC, the
three bash-package READMEs, the bash/src/types.ts JSDoc, and docs/bash.md (both
the type-equiv blocks — kept 1:1 with source — and the prose). The reality that
remains: the `bash` tool doesn't EXPOSE env/stdin as parameters because they'd
be redundant with shell syntax; the fields exist for in-process plugins (the
hooks bridges) to pass a JSON payload + CLAUDE_* vars cleanly. The guard test is
kept but reframed: it catches a future `...args` spread that would silently
forward model input into the post-scrub env merge, NOT a trust wall. No code or
behavior change.
2026-07-02 04:08:24 +08:00
|
|
|
* (the credential scrub in dsh-bash-local is the security control; see the
|
2026-07-19 22:50:49 +08:00
|
|
|
* bash-stdin-env Agent Note). Foreground `run()` returns a canned result; `start()`
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
* hands back an already-settled fake handle so the task registration completes.
|
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
|
|
|
*/
|
|
|
|
|
class RecordingBashExecutor extends BashExecutor {
|
|
|
|
|
readonly requests: BashExecRequest[] = []
|
|
|
|
|
resolve(request: BashExecRequest): BashExecSpec {
|
|
|
|
|
this.requests.push(request)
|
|
|
|
|
return {
|
|
|
|
|
command: request.command,
|
|
|
|
|
workdir: request.workdir ?? process.cwd(),
|
|
|
|
|
timeoutMs: request.timeoutMs ?? 0,
|
2026-07-10 11:53:02 +08:00
|
|
|
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
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
|
|
|
...request.signal ? { signal: request.signal } : {},
|
|
|
|
|
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
|
|
|
|
...request.env !== undefined ? { env: request.env } : {},
|
2026-07-12 15:41:42 +08:00
|
|
|
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
2026-07-09 16:05:44 +08:00
|
|
|
sandboxMode: request.sandboxMode,
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
run(): Promise<BashRunResult> {
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
exitCode: 0, signal: null, timedOut: false, aborted: false, timeoutMs: 0,
|
|
|
|
|
stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false },
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-07-15 13:38:17 +08:00
|
|
|
start(): BashProcess {
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
return {
|
|
|
|
|
status: 'completed',
|
|
|
|
|
exitCode: 0,
|
|
|
|
|
signal: null,
|
|
|
|
|
done: Promise.resolve(),
|
|
|
|
|
readOutput: () => ({ delta: '', lossy: false }),
|
|
|
|
|
kill: () => false,
|
|
|
|
|
}
|
|
|
|
|
}
|
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
|
|
|
}
|
|
|
|
|
|
2026-07-10 20:52:27 +08:00
|
|
|
async function setupRecording(withJsonl = false) {
|
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
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
2026-07-10 20:52:27 +08:00
|
|
|
if (withJsonl) {
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
await ctx.plugin(SessionPersistenceJsonl, { root: join(spillDir, 'jsonl') })
|
|
|
|
|
}
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
await ctx.plugin(TaskService)
|
|
|
|
|
await ctx.plugin(ToolTasks)
|
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
|
|
|
await ctx.plugin(RecordingBashExecutor)
|
2026-07-12 15:41:42 +08:00
|
|
|
await ctx.plugin(ToolBash, { dshHome: recordingDshHome })
|
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
|
|
|
return { ctx, bash: ctx.bash as RecordingBashExecutor }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 15:41:42 +08:00
|
|
|
it('describes the managed harness environment namespace to the model', async () => {
|
2026-07-10 20:52:27 +08:00
|
|
|
const { ctx } = await setupRecording()
|
|
|
|
|
const description = ctx.tools.get('bash')?.description ?? ''
|
2026-07-12 15:41:42 +08:00
|
|
|
expect(description).toContain('$DSH_*')
|
|
|
|
|
expect(description).not.toContain('DSH_SESSION_JSONL')
|
2026-07-10 20:52:27 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 15:41:42 +08:00
|
|
|
expect(bash.requests[0]?.dshEnv).toEqual({
|
|
|
|
|
DSH_HOME: recordingDshHome,
|
2026-07-10 20:52:27 +08:00
|
|
|
DSH_SESSION_ID: 'request-fg',
|
|
|
|
|
DSH_SESSION_JSONL: path,
|
2026-07-12 15:41:42 +08:00
|
|
|
DSH_SHELL: '1',
|
2026-07-10 20:52:27 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 15:41:42 +08:00
|
|
|
expect(bash.requests[0]?.env).toBeUndefined()
|
|
|
|
|
expect(bash.requests[0]?.dshEnv).toEqual({
|
|
|
|
|
DSH_HOME: recordingDshHome,
|
2026-07-10 20:52:27 +08:00
|
|
|
DSH_SESSION_ID: 'request-bg',
|
|
|
|
|
DSH_SESSION_JSONL: path,
|
2026-07-12 15:41:42 +08:00
|
|
|
DSH_SHELL: '1',
|
2026-07-10 20:52:27 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 15:41:42 +08:00
|
|
|
it('injects built-ins and the stable session id when no JSONL locator is available', async () => {
|
2026-07-10 20:52:27 +08:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 15:41:42 +08:00
|
|
|
expect(bash.requests[0]?.dshEnv).toEqual({
|
|
|
|
|
DSH_HOME: recordingDshHome,
|
|
|
|
|
DSH_SESSION_ID: 'request-id-only',
|
|
|
|
|
DSH_SHELL: '1',
|
|
|
|
|
})
|
2026-07-10 20:52:27 +08:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 15:41:42 +08:00
|
|
|
expect(bash.requests.map(request => request.dshEnv)).toEqual([
|
2026-07-10 20:52:27 +08:00
|
|
|
{
|
2026-07-12 15:41:42 +08:00
|
|
|
DSH_HOME: recordingDshHome,
|
2026-07-10 20:52:27 +08:00
|
|
|
DSH_SESSION_ID: 'request-parent',
|
|
|
|
|
DSH_SESSION_JSONL: ctx.sessionPersistence.locate(parent.session.header)?.path,
|
2026-07-12 15:41:42 +08:00
|
|
|
DSH_SHELL: '1',
|
2026-07-10 20:52:27 +08:00
|
|
|
},
|
|
|
|
|
{
|
2026-07-12 15:41:42 +08:00
|
|
|
DSH_HOME: recordingDshHome,
|
2026-07-10 20:52:27 +08:00
|
|
|
DSH_SESSION_ID: 'request-child',
|
|
|
|
|
DSH_SESSION_JSONL: ctx.sessionPersistence.locate(child.session.header)?.path,
|
2026-07-12 15:41:42 +08:00
|
|
|
DSH_SHELL: '1',
|
2026-07-10 20:52:27 +08:00
|
|
|
},
|
|
|
|
|
])
|
2026-07-12 15:41:42 +08:00
|
|
|
expect(bash.requests[0]?.dshEnv?.DSH_SESSION_JSONL).not.toBe(bash.requests[1]?.dshEnv?.DSH_SESSION_JSONL)
|
2026-07-10 20:52:27 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-10 11:53:02 +08:00
|
|
|
it('does not forward trusted-only fields even when the model includes them as extra arguments', async () => {
|
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
|
|
|
const { ctx, bash } = await setupRecording()
|
2026-07-13 23:27:00 +08:00
|
|
|
// 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
|
|
|
|
|
// already set environment variables or feed stdin.
|
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
|
|
|
await ctx.tools.execute({
|
2026-07-02 04:30:40 +08:00
|
|
|
callId: CallId('no-forward-1'),
|
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
|
|
|
name: 'bash',
|
|
|
|
|
arguments: {
|
|
|
|
|
command: 'echo hi',
|
|
|
|
|
description: 'echo',
|
|
|
|
|
env: { SNEAKY_API_KEY: 'leak' },
|
|
|
|
|
stdin: 'malicious payload',
|
2026-07-10 11:53:02 +08:00
|
|
|
stdoutMaxBytes: 999_999,
|
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(bash.requests).toHaveLength(1)
|
|
|
|
|
const request = bash.requests[0]!
|
|
|
|
|
expect(request.command).toBe('echo hi')
|
|
|
|
|
expect('env' in request).toBe(false)
|
|
|
|
|
expect('stdin' in request).toBe(false)
|
2026-07-10 11:53:02 +08:00
|
|
|
expect('stdoutMaxBytes' in request).toBe(false)
|
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
|
|
|
})
|
|
|
|
|
|
2026-07-10 11:53:02 +08:00
|
|
|
it('a background bash call likewise carries no trusted-only fields', async () => {
|
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
|
|
|
const { ctx, bash } = await setupRecording()
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
const result = await ctx.tools.execute({
|
2026-07-02 04:30:40 +08:00
|
|
|
callId: CallId('no-forward-2'),
|
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
|
|
|
name: 'bash',
|
|
|
|
|
arguments: {
|
|
|
|
|
command: 'sleep 1',
|
|
|
|
|
description: 'sleep',
|
|
|
|
|
run_in_background: true,
|
|
|
|
|
env: { TOKEN: 'leak' },
|
|
|
|
|
stdin: 'x',
|
2026-07-10 11:53:02 +08:00
|
|
|
stdoutMaxBytes: 999_999,
|
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
|
|
|
},
|
|
|
|
|
})
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
// The call really went down the background path (the recorder sees the real
|
|
|
|
|
// request the consumer built, so the absent env/stdin below is a real
|
|
|
|
|
// negative, not a recorder that drops everything).
|
|
|
|
|
expect(text(result)).toBe('started background task bash-1')
|
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(bash.requests).toHaveLength(1)
|
|
|
|
|
const request = bash.requests[0]!
|
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00
|
|
|
expect(request.command).toBe('sleep 1')
|
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('env' in request).toBe(false)
|
|
|
|
|
expect('stdin' in request).toBe(false)
|
2026-07-10 11:53:02 +08:00
|
|
|
expect('stdoutMaxBytes' in request).toBe(false)
|
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
|
|
|
})
|
|
|
|
|
})
|