Sfoglia il codice sorgente

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

# Conflicts:
#	docs/cordis-catalog/events-and-services.md
#	packages/core/agent/src/types.ts
Tianyi Cui 3 mesi fa
parent
commit
3422f8fa5c

+ 1 - 1
docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md

@@ -22,7 +22,7 @@ Three deliberate choices:
 
 3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`.
 
-`dsh-bash-local` now ALWAYS spawns stdin as a `'pipe'` and closes it immediately — with the supplied bytes when a caller set `stdin`, empty otherwise. A closed empty pipe gives a reading child EOF exactly as the previous `'ignore'` (`/dev/null`) did, so the no-stdin path is behavior-equivalent; keeping the `stdio` tuple a literal `['pipe','pipe','pipe']` also preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. A child that exits without reading makes the stdin write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`.
+`dsh-bash-local` spawns stdin as a `'pipe'` (writing the supplied bytes, then closing) ONLY when a caller set `stdin`; with none supplied it uses `'ignore'` — fd 0 → `/dev/null` — the exact pre-seam default. This distinction is observable and deliberate: a closed empty pipe and `/dev/null` are NOT the same file type (node's spawn pipe is an `AF_UNIX` socket, so `test -c /dev/stdin` holds for `/dev/null` but not for an empty pipe), so the no-stdin path — every model-driven call — must keep `/dev/null` rather than regress to an always-open pipe. Each branch's `stdio` tuple is a literal, which preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. When stdin IS written, a child that exits without reading makes the write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`.
 
 ## Scope: configurable scrub pattern is NOT included
 

+ 1 - 1
packages/bash/bash-local/README.md

@@ -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. 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).
+- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
 - **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

+ 26 - 18
packages/bash/bash-local/src/run.ts

@@ -15,7 +15,8 @@
  * @module dsh-bash-local/run
  */
 
-import { spawn } from 'node:child_process'
+import { type ChildProcessByStdio, spawn } from 'node:child_process'
+import type { Readable, Writable } from 'node:stream'
 import { randomBytes } from 'node:crypto'
 import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
 import { tmpdir } from 'node:os'
@@ -298,17 +299,20 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
     throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
   }
 
-  // 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 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),
-    stdio: ['pipe', 'pipe', 'pipe'],
-    detached: true,
-  })
+  // stdin is a pipe ONLY when the caller supplied bytes; with none it is `ignore`
+  // (fd 0 → /dev/null) — the exact pre-seam default. This matters: a spawn pipe
+  // and /dev/null are NOT observationally identical (node's pipe is an AF_UNIX
+  // socket, so a command that probes stdin's type — `test -c /dev/stdin`, `stat
+  // /proc/self/fd/0` — sees a char device vs a socket), so the no-stdin path
+  // (every model-driven call) must keep /dev/null rather than regress to a socket.
+  // Two LITERAL `stdio` tuples (not one variable tuple): only a literal lets the
+  // typed `spawn` overload infer non-null stdout/stderr, which the
+  // `ChildProcessByStdio` annotation captures (stdin `Writable | null`; stdout/
+  // stderr the non-null `Readable` the collectors attach to without a cast).
+  const env = childEnv(spec.env)
+  const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
+    ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
+    : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
 
   const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir)
   const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir)
@@ -343,10 +347,12 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
   }
   spec.signal?.addEventListener('abort', onAbort, { once: true })
 
-  // Write stdin and close it. This handler must exist: an unhandled 'error' on
-  // the stream would throw and crash the host. We swallow the error rather than
-  // reject `done`, and that is correct for ANY stdin-write error, not just the
-  // common one — the stdin write is BEST-EFFORT, while the command's authoritative
+  // Write stdin and close it, but ONLY when the caller supplied bytes — with no
+  // stdin, fd 0 is `ignore` (/dev/null) and `child.stdin` is null. The error
+  // handler must exist whenever we write: an unhandled 'error' on the stream
+  // would throw and crash the host. We swallow the error rather than reject
+  // `done`, and that is correct for ANY stdin-write error, not just the common
+  // one — the stdin write is BEST-EFFORT, while the command's authoritative
   // outcome is its exit code + captured output, which the `close` handler reports
   // regardless of whether the write landed. The expected case is EPIPE (the child
   // exited without reading, so closing our end of a still-full pipe fails); a rare
@@ -354,8 +360,10 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
   // surfaces that itself through its own exit/output (e.g. a hook that gets
   // truncated JSON errors out) — rejecting here would instead discard that real
   // output and turn it into an opaque infrastructure error, which is worse.
-  child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
-  child.stdin.end(spec.stdin ?? '')
+  if (child.stdin !== null) {
+    child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
+    child.stdin.end(spec.stdin)
+  }
 
   const done = new Promise<SpawnOutcome>((resolve, reject) => {
     child.on('error', (error) => {

+ 15 - 2
packages/bash/bash-local/tests/run.spec.ts

@@ -166,13 +166,26 @@ describe('stdin and extra env (set by in-process plugins)', () => {
   })
 
   it('a command that reads stdin sees EOF when none is supplied', async () => {
-    // No stdin → the always-piped-but-empty stdin closes immediately, so `cat`
-    // reads EOF and exits 0 with no output (it does NOT block).
+    // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
+    // output (it does NOT block).
     const result = await runBash(spec('cat')).done
     expect(result.exitCode).toBe(0)
     expect(result.stdout.text).toBe('')
   })
 
+  it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
+    // The no-stdin path must stay observationally identical to the pre-seam
+    // `ignore` default: a command that probes stdin's file type sees a char
+    // device (/dev/null). Regressing to an always-open pipe would make fd 0 a
+    // socket (node's spawn pipe is an AF_UNIX socket, not a FIFO), flipping
+    // `test -c /dev/stdin` for every model-driven call. When bytes ARE supplied,
+    // fd 0 is that pipe (a socket), as it must be to carry them.
+    const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
+    expect(none.stdout.text).toBe('char\n')
+    const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
+    expect(piped.stdout.text).toBe('socket\n')
+  })
+
   it('merges extra env entries onto the scrubbed environment', async () => {
     const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', {
       env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' },

+ 1 - 1
packages/support/ui-stdio/README.md

@@ -25,7 +25,7 @@ This package consolidates what were two near-identical copies under `examples/ec
 Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.)
 
 - `agent/stream-chunk` — `text-delta` is written verbatim; `reasoning-delta` is wrapped in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer. Reasoning rendering is inert when no `reasoning-delta` chunks arrive (e.g. a mock model), so it is always on.
-- `session/event` — the durable transcript feed drives all boundary and content rendering: `turn/start` prints a `[<agent> turn N]` header (the short agent label comes from an `agent/created`→id map, since the turn event carries only the turn number), `turn/end` prints the trailing `> ` prompt, `tool/call` renders `[tool call] name(args)`, `tool/result` renders the joined text blocks as `[tool result] …`, and `todo/write` renders a glyphed checklist.
+- `session/event` — the durable transcript feed drives all boundary and content rendering: `turn/start` prints a `[<agent> turn N]` header (the short agent label comes from a session-id→agent-id map seeded from `ctx.agents.list()` at install and kept live via `agent/created`/`agent/disposed`, since the turn event carries only the turn number), `turn/end` prints the trailing `> ` prompt, `tool/call` renders `[tool call] name(args)`, `tool/result` renders the joined text blocks as `[tool result] …`, and `todo/write` renders a glyphed checklist.
 
 ## The I/O seam
 

+ 7 - 1
packages/support/ui-stdio/src/index.ts

@@ -80,8 +80,14 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
   // number, so to print the short agent id (`[main turn 1]`) we map the
   // session's id to its agent's id. The session id is not reliably the agent id
   // (a session can be created with an explicit/client-supplied id), so build the
-  // map from `agent/created` rather than parsing the id string.
+  // map from `agent/created` rather than parsing the id string. Seed from the
+  // registry's current agents first: an agent registered before this plugin
+  // installed (e.g. the pre-created `main` agent, or any agent surviving an HMR
+  // reload of just this fiber) already fired its `agent/created`, so the live
+  // listener alone would miss it and its turns would fall back to the raw
+  // session id.
   const labelBySession = new Map<string, string>()
+  for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id)
   ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) })
   ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) })
 

+ 3 - 0
packages/support/ui-stdio/tests/readline.spec.ts

@@ -16,6 +16,9 @@ function fakeContext(): Context {
   return {
     on: vi.fn(() => vi.fn()),
     effect: vi.fn((callback: () => () => void) => callback()),
+    // The UI seeds its label map from the registry at install; this suite only
+    // exercises readline terminal-mode selection, so an empty roster suffices.
+    agents: { list: vi.fn(() => []) },
   } as unknown as Context
 }
 

+ 20 - 0
packages/support/ui-stdio/tests/ui-stdio.spec.ts

@@ -149,6 +149,26 @@ describe('createStdioChat rendering', () => {
     expect(out.text()).toContain('[orphan-session turn 1] ')
   })
 
+  it('seeds labels for agents already registered before the UI installs', async () => {
+    // The pre-created `main` agent (and any agent surviving an HMR reload of just
+    // this fiber) fired its `agent/created` before the UI's listener existed, so
+    // the live listener alone would miss it. Seeding from `ctx.agents.list()` at
+    // install time is what keeps its turn header showing `[main turn N]` instead
+    // of the raw session id.
+    const ctx = new Context()
+    await ctx.plugin(AgentRegistry)
+    const agent = makeAgent('main')
+    ctx.agents.register(agent) // registered BEFORE the UI plugin below
+    const { runtime, out } = makeRuntime()
+    await ctx.plugin(Object.assign((inner: Context) => {
+      createStdioChat(inner, CONFIG, runtime)
+    }, { inject: ['agents'] }))
+    ctx.emit('session/event', makeSession('main'), {
+      type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
+    } as SessionEvent)
+    expect(out.text()).toContain('[main turn 5] ')
+  })
+
   it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => {
     const { ctx, out } = await setup()
     const agent = makeAgent('main')