Merge worktree-hooks-b-bash-seam into worktree-hooks-c-interception

Bring the interception-seams branch onto current master (via A→B). The
substantive reconciliation is master's compaction `agent/pre-step` serial seam
meeting C's interception seams:

- types.ts: keep BOTH master's `agent/pre-step` AND C's new interception events
  (`agent/prompt-submit`, `agent/session-start`, `agent/turn-continuation`→
  `ContinuationDecision`); drop the turn-mirror declarations (removed on A).
- loop.ts: the merged per-turn order is `turn/start` → per queued msg
  `agent/prompt-submit` (rewrite/inject/block) → (fully-blocked ⇒ zero-step
  `rejected`) → per step: drain steering → assemble system prompt →
  `agent/pre-step` (compaction, OUTSIDE the step) → `step/start` → single
  `deriveMessages()` → model → tools/pre-execute·dispatch·post-execute. No
  turn-mirror emits; `closeTurn()` is the A-simplified single-call form.
- Docs (architecture, core.md, agent/agent-loop READMEs, catalog) reconciled to
  show C's interception seams alongside `agent/pre-step`, no turn/step mirrors.
- rfc/README: dropped the stale `proposed/` compaction row (master moved that RFC
  to implemented/); kept C's new `pre-tool-input-rewrite` proposed row.
- interception.spec.ts: migrated its two `agent/turn-end` reason collectors to
  the `turn/end` session event, and ADDED a cross-test proving a
  `prompt-submit` rewrite + additionalContext is VISIBLE to an `agent/pre-step`
  listener on the same turn — pinning the merged seam ordering (compaction sees
  the post-prompt-submit surface, not stale history).
This commit is contained in:
Tianyi Cui
2026-07-02 04:50:14 +08:00
75 changed files with 4794 additions and 687 deletions

View File

@@ -21,7 +21,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after a 3s grace (OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's **trusted-plugin** `env` is merged LAST (after the scrub), so an in-process plugin's explicit entry wins even on a credential-shaped name — the scrub guards the harness's *ambient* credentials from *model-driven* commands, not a trusted caller. The spec's `stdin` (also trusted-plugin) is written to the child and closed; with none supplied, stdin is an immediately-closed empty pipe (EOF, as before). See [the trusted-plugin RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin` is written to the child and closed; with none supplied, stdin is an immediately-closed empty pipe (EOF, as before). Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
- **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload.
## Sandboxing

View File

@@ -116,8 +116,8 @@ export class LocalBashExecutor extends BashExecutor {
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
timeoutMs,
...request.signal ? { signal: request.signal } : {},
// Carry the trusted-plugin stdin/env through verbatim — optional, no
// config default (absent means none). env merges AFTER the scrub in run.ts.
// Carry stdin/env through verbatim — optional, no config default (absent
// means none). env merges AFTER the scrub in run.ts.
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
// Carry the owner through verbatim (required-but-nullable on the spec):

View File

@@ -48,12 +48,13 @@ export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
*
* Layering matters: the scrub drops `process.env` credentials, then
* `ENV_OVERRIDES` forces the model-friendly terminal vars, then `extra` is
* merged LAST so a TRUSTED-PLUGIN entry wins even when its name matches the
* scrub pattern (the scrub guards against leaking the HARNESS's ambient
* credentials into model-driven commands; an in-process plugin that explicitly
* sets a var has taken responsibility for it). `extra` is NEVER model-supplied
* — `dsh-tool-bash` does not forward model input here (see its README, §
* "Trusted-plugin boundary").
* merged LAST so an explicit caller entry wins even when its name matches the
* scrub pattern (the scrub is the control that stops the HARNESS's ambient
* credentials leaking into a spawned command; a caller that explicitly sets a
* var named a value it already holds, not that ambient secret). `extra` is set
* by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash`
* builds its request from named fields only and does not forward model input
* here (see its README, § "The tool builds its request from named args only").
*/
export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
@@ -75,14 +76,15 @@ export interface SpawnSpec {
signal?: AbortSignal | undefined
/**
* Bytes to write to the child's stdin, then close it. Absent (or empty)
* leaves stdin closed/empty. A TRUSTED-PLUGIN surface (see {@link SpawnSpec}'s
* consumer `dsh-bash`); never carries model input.
* leaves stdin closed/empty. Set by in-process plugins (the hooks bridges);
* the model-facing `dsh-tool-bash` tool does not thread model input here.
*/
stdin?: string | undefined
/**
* Extra environment entries, merged onto the scrubbed env AFTER the
* credential scrub and the model-friendly overrides (so an explicit entry
* wins). A TRUSTED-PLUGIN surface; never carries model input.
* wins). Set by in-process plugins; the model-facing tool does not forward
* model input here.
*/
env?: Record<string, string> | undefined
}
@@ -297,10 +299,10 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
}
// stdin is ALWAYS a pipe (kept literal so the typed spawn overload guarantees
// non-null stdout/stderr) and is closed immediately: with bytes when a
// trusted plugin supplied stdin, empty otherwise. A closed empty pipe gives a
// reading child EOF exactly as `/dev/null` would, so the no-stdin path (every
// model-driven call) is unchanged.
// non-null stdout/stderr) and is closed immediately: with bytes when a caller
// supplied stdin, empty otherwise. A closed empty pipe gives a reading child
// EOF exactly as `/dev/null` would, so the no-stdin path (every model-driven
// call) is unchanged.
const child = spawn('bash', ['-c', spec.command], {
cwd: spec.cwd,
env: childEnv(spec.env),

View File

@@ -110,7 +110,7 @@ describe('LocalBashExecutor.run', () => {
it('resolve() carries stdin/env onto the spec, and run() threads them to the command', async () => {
const { bash } = await setup()
const spec = bash.resolve({ command: 'cat; echo "[$DSH_SEAM_VAR]"', stdin: 'piped\n', env: { DSH_SEAM_VAR: 'env-ok' } })
// resolve() keeps the trusted-plugin fields verbatim (optional, no default).
// resolve() keeps the stdin/env fields verbatim (optional, no default).
expect(spec.stdin).toBe('piped\n')
expect(spec.env).toEqual({ DSH_SEAM_VAR: 'env-ok' })
const result = await bash.run(spec)

View File

@@ -158,7 +158,7 @@ describe('runBash', () => {
})
})
describe('stdin and extra env (trusted-plugin surface)', () => {
describe('stdin and extra env (set by in-process plugins)', () => {
it('writes stdin to the command and closes it', async () => {
const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done
expect(result.exitCode).toBe(0)