Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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`.

Expand Down
52 changes: 52 additions & 0 deletions src/exec/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,62 @@ 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,
});
});

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", () => {
test("explorer overlay narrows advertised tools to the package allow list", () => {
const overlay = resolveExecDirectorOverlay("explorer");
Expand Down
23 changes: 22 additions & 1 deletion src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -597,7 +612,13 @@ export async function runExec(config: Config): Promise<ExecResult> {
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(
Expand Down
6 changes: 3 additions & 3 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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 };
}
Expand Down
31 changes: 31 additions & 0 deletions src/permission/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading