From 1aaf9e5003707f4489ea7a63486c4c6c7b938567 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 07:32:51 -0700 Subject: [PATCH 1/2] feat(exec): gate interactivity on stdin so piped stdout still prompts --- src/exec/runner.test.ts | 31 +++++++++++++++++++++++++++++++ src/exec/runner.ts | 23 ++++++++++++++++++++++- src/permission/gate.ts | 6 +++--- src/permission/permission.test.ts | 31 +++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/exec/runner.test.ts b/src/exec/runner.test.ts index 7edd905d9..342908591 100644 --- a/src/exec/runner.test.ts +++ b/src/exec/runner.test.ts @@ -23,10 +23,41 @@ import { refreshSelectedProviderCredential, resolveExecDirectorOverlay, resolveExecDirectorOverlayForPackage, + resolveExecInteractivity, } from "./runner.js"; const OUTSIDE_ALLOW = "mcp__linear__create_issue"; +describe("exec piped-stdout interactivity (CL-9002 Option A)", () => { + test("stdout piped plus stdin TTY stays interactive", () => { + expect(resolveExecInteractivity(true, false)).toEqual({ + interactive: true, + outputPiped: true, + }); + }); + + test("stdin piped denies even when stdout is a TTY", () => { + expect(resolveExecInteractivity(false, true)).toEqual({ + interactive: false, + outputPiped: false, + }); + }); + + test("fully attached stdio stays interactive and unpiped", () => { + expect(resolveExecInteractivity(true, true)).toEqual({ + interactive: true, + outputPiped: false, + }); + }); + + test("fully piped stdio is headless", () => { + expect(resolveExecInteractivity(false, false)).toEqual({ + interactive: false, + outputPiped: true, + }); + }); +}); + describe("exec director allowlist", () => { test("explorer overlay narrows advertised tools to the package allow list", () => { const overlay = resolveExecDirectorOverlay("explorer"); diff --git a/src/exec/runner.ts b/src/exec/runner.ts index ecbd598fe..8063ccfbd 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -271,6 +271,21 @@ export function isExecOverlayToolAllowed( ); } +/** + * CL-9002 Option A: stdin-present means an operator is present. A piped + * stdout must not disable prompts — it carries only command output while + * prompts stay on stderr. `outputPiped` is a diagnostic flag only. + */ +export function resolveExecInteractivity( + stdinIsTTY: boolean | undefined, + stdoutIsTTY: boolean | undefined, +): { interactive: boolean; outputPiped: boolean } { + return { + interactive: stdinIsTTY === true, + outputPiped: stdoutIsTTY !== true, + }; +} + export function resolveExecDirectorOverlayForPackage( pkg: DirectorPackage, ): ExecDirectorOverlay { @@ -597,7 +612,13 @@ export async function runExec(config: Config): Promise { resolveSessionMode(config.settings, localSettingsForMode) ?? "orchestrator"; - const interactive = input.isTTY === true && output.isTTY === true; + const { interactive, outputPiped } = resolveExecInteractivity( + input.isTTY, + output.isTTY, + ); + if (outputPiped) { + logger.debug("exec stdout is piped; prompts stay on stderr"); + } if (config.skipPermissionsFromSettings) { stderr.write( diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 028598739..b858ccd80 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -1002,8 +1002,8 @@ export function createPermissionGate( "deny", ); const reason = anySecret - ? `${request.action} references a sensitive path and requires operator approval, which is unavailable in a non-interactive run.` - : `${request.action} requires operator approval, which is unavailable in a non-interactive run. Re-run with --dangerously-skip-permissions to bypass, or narrow the action.`; + ? `${request.action} references a sensitive path and requires operator approval, which is unavailable because stdin is not a TTY (no operator attached).` + : `${request.action} requires operator approval, which is unavailable because stdin is not a TTY (no operator attached). Re-run with --dangerously-skip-permissions to bypass, or narrow the action.`; denialMemory.record(stableId, reason); return { kind: "deny", reason }; } @@ -1047,7 +1047,7 @@ export function createPermissionGate( if (!interactive || requestApproval === undefined) { recordAutoDecision(request.tool, "non-interactive", "deny"); - const reason = `${request.action} requires operator approval, which is unavailable in a non-interactive run. Re-run with --dangerously-skip-permissions to bypass, or narrow the action.`; + const reason = `${request.action} requires operator approval, which is unavailable because stdin is not a TTY (no operator attached). Re-run with --dangerously-skip-permissions to bypass, or narrow the action.`; denialMemory.record(stableId, reason); return { kind: "deny", reason }; } diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 7bb19ca06..52e5abb44 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -1335,6 +1335,37 @@ describe("createPermissionGate", () => { expect(verdict.allowed).toBe(false); }); + test("headless deny names stdin and keeps the non-interactive rule", async () => { + const gate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: false, + reactorGated: false, + }); + const verdict = await gate.evaluate(shellCall("curl x")); + expect(verdict.allowed).toBe(false); + if (verdict.allowed === false) { + expect(verdict.reason).toMatch(/stdin is not a TTY/); + } + }); + + test("interactive headless-shaped request resolves via the approval mock", async () => { + let asked = 0; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => { + asked++; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + reactorGated: false, + }); + const verdict = await gate.evaluate(shellCall("curl x")); + expect(verdict.allowed).toBe(true); + expect(asked).toBe(1); + }); + test("pre-approved patterns pass without asking", async () => { let asked = 0; const gate = createPermissionGate({ From fb9af7a06b747f83d22346e94fcbeea8983e1273 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 08:00:35 -0700 Subject: [PATCH 2/2] test(exec): pin undefined-stdio shapes and sync Option A docs --- docs/ARCHITECTURE.md | 4 ++-- docs/PRODUCT.md | 4 ++-- src/exec/runner.test.ts | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index da16a4412..aada8f7b1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -126,8 +126,8 @@ The interactive TUI may offer an explicit alternate-provider/model selector only - Product non-TUI agent path that **shares** the TUI stack (session mode, ChatDirector, toolset, permission gate, MCP, plugins, hooks, run-sink) without the OpenTUI shell - Bootstrap consumes the shared session assembly (`src/session/assemble-runtime.ts`, layered over `src/session/runtime-assembly.ts`) instead of hand-wiring a second copy. Intentional deltas vs TUI: - No workflow controller (`isWorkflowActive` is always false) - - Non-interactive permission gate by default - - `ask_operator` is unmounted when stdin/stdout are not TTYs (no cancel stub on the wire); TTY exec still prompts on stdin + - Non-interactive permission gate when stdin is not a TTY (piped stdout with TTY stdin stays interactive; prompts go to stderr) + - `ask_operator` is unmounted when stdin is not a TTY (no cancel stub on the wire); TTY-stdin exec still prompts on stdin, with prompts on stderr when stdout is piped - MCP connect is awaited before workflow resume and first inference, abort-capped at 15s so a hung handshake cannot block the run. A rejected batch does not disarm that abort while sibling dials are still in flight. A 1s log fires if connect is still in progress; remaining dials keep running until settle or abort. The TUI still fire-and-forgets connect (no rewrite). `tool_search` does not treat an empty catalog as a definitive miss while servers are still connecting. - Entry: `corbits exec "prompt"` (alias `corbits run`); `loadConfig` sets `command: "exec"` - Streams assistant text deltas to stdout; lifecycle errors to stderr diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 6ae4b42af..8bf7ec7c4 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -40,7 +40,7 @@ The evidence is in how the product fails today: the personas already produce exc 4. **Safe by default** — Consequential actions (writes, edits, shell) pass a permission gate; secret files and catastrophic commands are denied outright, regardless of intent. 5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption. 6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why. -7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI). Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not TTYs. TTY exec still reads a single line from stdin. +7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI). Headless `corbits exec` unmounts `ask_operator` when stdin is not a TTY. TTY-stdin exec still reads a single line from stdin, with prompts on stderr when stdout is piped. 8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` is parent-busy, so Enter is a queued steer, not a new turn. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges, and held messages list in a pending column stacked on the prompt box — `↑`/`↓` select, `Enter` force-pushes one now, `Ctrl+X` drops it — instead of echoing labelled transcript rows; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop). 9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators collect through mailbox mail the same way. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored. @@ -66,7 +66,7 @@ $ corbits exec "Add JWT auth to the API" $ corbits run "Add JWT auth to the API" ``` -Same directors, tools, permissions, MCP, plugins, and hooks as the TUI — without the OpenTUI shell. Bootstrap shares `src/session/assemble-runtime.ts` with the TUI; see `docs/ARCHITECTURE.md` “Exec Runner” for intentional deltas (no workflow controller; single primary send; non-interactive permission gate; `ask_operator` unmounted when non-TTY). Compaction continuation matches TUI so long runs do not stall after compact. Streams assistant text to stdout for scripts and CI. Non-interactive by default: actions that need operator approval are denied unless `--dangerously-skip-permissions` or its `--yolo` alias is set, the active settings file has persisted yolo on, or auto mode covers them. Both CLI flags force skip-permissions for this process only; skip-permissions wins when combined with auto, so `--auto --yolo` runs in yolo mode. Secret-guard and authz still apply. `ask_operator` is not advertised on non-TTY exec; TTY exec still reads a single line from stdin. +Same directors, tools, permissions, MCP, plugins, and hooks as the TUI — without the OpenTUI shell. Bootstrap shares `src/session/assemble-runtime.ts` with the TUI; see `docs/ARCHITECTURE.md` “Exec Runner” for intentional deltas (no workflow controller; single primary send; non-interactive permission gate when stdin is not a TTY; `ask_operator` unmounted when stdin is not a TTY). Compaction continuation matches TUI so long runs do not stall after compact. Streams assistant text to stdout for scripts and CI. Piped stdout with TTY stdin stays interactive, with prompts on stderr. Non-interactive when stdin is not a TTY: actions that need operator approval are denied unless `--dangerously-skip-permissions` or its `--yolo` alias is set, the active settings file has persisted yolo on, or auto mode covers them. Both CLI flags force skip-permissions for this process only; skip-permissions wins when combined with auto, so `--auto --yolo` runs in yolo mode. Secret-guard and authz still apply. `ask_operator` is not advertised when stdin is not a TTY; TTY-stdin exec still reads a single line from stdin, with prompts on stderr when stdout is piped. Local multi-model capability checks use this path (`bun run eval:capability`); see `evals/capability/README.md`. diff --git a/src/exec/runner.test.ts b/src/exec/runner.test.ts index 342908591..b2baf370b 100644 --- a/src/exec/runner.test.ts +++ b/src/exec/runner.test.ts @@ -56,6 +56,27 @@ describe("exec piped-stdout interactivity (CL-9002 Option A)", () => { outputPiped: true, }); }); + + test("absent stdio shape is headless and piped", () => { + expect(resolveExecInteractivity(undefined, undefined)).toEqual({ + interactive: false, + outputPiped: true, + }); + }); + + test("absent stdin denies even when stdout is a TTY", () => { + expect(resolveExecInteractivity(undefined, true)).toEqual({ + interactive: false, + outputPiped: false, + }); + }); + + test("stdin TTY with absent stdout stays interactive and piped", () => { + expect(resolveExecInteractivity(true, undefined)).toEqual({ + interactive: true, + outputPiped: true, + }); + }); }); describe("exec director allowlist", () => {