Skip to content

Commit aa4c877

Browse files
committed
fix(shell): bound collect wait and fail closed without collect
A grandchild holding stdout parked collect after the child had already exited, and intern persist could leak background children because shell_collect is not mounted. Collect now settles on process exit with a short stdio drain, wait_ms is capped, and surfaces without collect hide background and unwire the registry.
1 parent 445886d commit aa4c877

13 files changed

Lines changed: 184 additions & 22 deletions

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ tool call
394394
- **Secret Guard** (`secret-guard-plugin.ts`) — Hard-denies path-keyed tool calls (`read_file`, `write_file`, …) that would put a sensitive file into (or write it from) the model context. Runs before the permission plugin, so the path-arg deny holds even under `--dangerously-skip-permissions`. Shell commands that _reference_ a sensitive path (tokenized so `cat .env`, `bun --env-file=.env run …`, and quote/env-assignment forms are detected) are not hard-denied here: they require operator approval via the permission gate, and auto mode forces an ask through the auto-shell policy (`sensitive-path` rule). Once the operator approves, the command runs. Shell detection is best-effort: token matching defeats quoting and env-assignment/redirection forms but not dynamic path construction (variable indirection, `printf` assembly). Tool-result secret scrub still redacts credential-shaped output.
395395
- **Authorization** (`run-shell-authz.ts`, enforced by the permission gate) — Denies catastrophic shell command patterns by regex, and hard-blocks shell `find`, head-position `rg`, and recursive `grep -r` (they can walk huge trees and OOM the host). Bounded `grep`/`search_files` tools remain practical alternatives (timeout + output caps); the patterns match those three command shapes only — an `ls -R`, `fd`, or scripted `os.walk` is just as unbounded and is not caught, so the block message tells the model not to substitute one. The gate hard-denies these at the top of its verdict path — before auto-allow, prompting, grants, and skipPermissions — so no mode or stored grant can admit them.
396396
- **Permission** (`permission-plugin.ts`) — Delegates consequential calls to the permission gate.
397-
- **Shell Guard** (`shell-guard-plugin.ts`) — Corbits Code-only replacement for stock `run_shell` (interchange stays unpatched): 120s foreground default (`settings.shell.timeoutMs` overrides that default only; a positive per-call `timeout` is the bound with no ceiling — `maxTimeoutMs` does not clamp it), background `run_shell` has no default (only a per-call timeout arms a timer), 512KB display cap with head+tail retention (the process keeps running when the cap is hit), process-group kill on timeout, abort, and plugin dispose (live children tracked in the plugin and reaped by `posixTools.dispose`), and `background: true` — the call returns a `shell_id` at once (registry in `src/shell/background-shell.ts`), the process group keeps running past the turn, completion is delivered on a later turn via `buildShellBackgroundMessage`, and `shell_collect` retrieves or cancels (schema advertised by `advertiseShellGuardTimeout`; evaluated by the permission chain at start time like any shell call). Foreground expiry is exit 124 + `timedOut:true` plus a nudge to retry with `background:true`. Also applies a 10s wall-clock budget to `grep`/`search_files`. Ripgrep detached spawns are not tracked.
397+
- **Shell Guard** (`shell-guard-plugin.ts`) — Corbits Code-only replacement for stock `run_shell` (interchange stays unpatched): 120s foreground default (`settings.shell.timeoutMs` overrides that default only; a positive per-call `timeout` is the bound with no ceiling — `maxTimeoutMs` does not clamp it), background `run_shell` has no default (only a per-call timeout arms a timer), 512KB display cap with head+tail retention (the process keeps running when the cap is hit), process-group kill on timeout, abort, and plugin dispose (live children tracked in the plugin and reaped by `posixTools.dispose`), and `background: true` — the call returns a `shell_id` at once (registry in `src/shell/background-shell.ts`), the process group keeps running past the turn, completion is process-exit (stdio-close is not required), delivered on a later turn via `buildShellBackgroundMessage`, and `shell_collect` retrieves or cancels with a 300s wait cap (schema advertised by `advertiseShellGuardTimeout` only when `shell_collect` is mounted; evaluated by the permission chain at start time like any shell call). Foreground expiry is exit 124 + `timedOut:true` plus a nudge to retry with `background:true`. Also applies a 10s wall-clock budget to `grep`/`search_files`. Ripgrep detached spawns are not tracked.
398398
- **Read File Guard** (`read-file-guard-plugin.ts`) — Corbits Code-only short-circuit for `read_file` on real filesystem paths and configured `tool-output://` URIs (interchange stays unpatched): streaming reads that never decode the whole file in one pass, caps model-facing output at 50KB, defaults to 2000 lines, truncates long lines with recovery hints, samples the first chunk to reject binary, and stops at an 8MB scan ceiling. Emits `offset` continuation notices so the model can page without losing file or spill content on disk.
399399
- **Verify** (`verify-plugin.ts`) — Re-reads after `write_file` / `edit_file` and errors on mismatch. Per-path serialization (`file-mutation-lock.ts`) prevents parallel edits on one file from tripping verification.
400400
- **Edit file line range** (`edit-file-line-range-plugin.ts`) — Corbits Code-only short-circuit for `edit_file` mode B (`start_line`/`end_line`/`new_string`), same pattern as shell-guard; schema advertised via `advertiseEditFileLineRange`. Modes are mutually exclusive: a call supplying both `old_string` and `start_line`/`end_line` is rejected with a recoverable error naming which fields to omit (no file-content disambiguation).

‎docs/IMPLEMENTATION.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,11 +206,11 @@ Listings are list-free, dumps are dump-locked: a bounded `ls`/`tree` prints name
206206

207207
#### Background shell mode
208208

209-
`run_shell` accepts `background: true` (shell-guard plugin, after the permission chain — a denied command spawns nothing). The starting call resolves `cwd` as usual and applies a per-call `timeout` only when passed (no 120s default on background), skips the pwd probe, spawns a detached process group via the registry in `src/shell/background-shell.ts`, and returns `{shell_id, status: "running"}` immediately; the retained shell cwd is never mutated by a background run. Limits: 8 running, 8 completed entries (ring; evicted ids collect as not-found — truncated output is spilled to a `tool-output:///bg-shell-<id>` blob named in the completion message). On process exit the host delivers `buildShellBackgroundMessage(exit)` (exit status, timed-out marker, ~2KB output preview, spill URI) through the same continuation channel as compaction — wired in all three loop hosts (TUI, exec, sub-agent). `shell_collect` (`{shell_id, action: "collect"|"cancel", wait_ms?}`, default non-blocking) retrieves status/output or kills the process group; it is ungated by design (cancel only kills the session's own child). Timeout keeps its meaning: expiry kills the group and reports exit code 124 with `timed_out: true`. The tool watchdog exempts background starts and `shell_collect` (same list as `spawn_agent`/`wait_agents`/`ask_director`). Toolset dispose calls `disposeAll("session closed")` before the posix teardown, so `/clear`, interrupt, and reload kill every live background process group.
209+
`run_shell` accepts `background: true` (shell-guard plugin, after the permission chain — a denied command spawns nothing). The starting call resolves `cwd` as usual and applies a per-call `timeout` only when passed (no 120s default on background), skips the pwd probe, spawns a detached process group via the registry in `src/shell/background-shell.ts`, and returns `{shell_id, status: "running"}` immediately; the retained shell cwd is never mutated by a background run. Limits: 8 running, 8 completed entries (ring; evicted ids collect as not-found — truncated output is spilled to a `tool-output:///bg-shell-<id>` blob named in the completion message). Completion is process-exit, not stdio-close: a grandchild holding the pipe does not park `shell_collect`. On process exit the host delivers `buildShellBackgroundMessage(exit)` (exit status, timed-out marker, ~2KB output preview, spill URI) through the same continuation channel as compaction — wired in all three loop hosts (TUI, exec, sub-agent). `shell_collect` (`{shell_id, action: "collect"|"cancel", wait_ms?}`, default non-blocking, `wait_ms` capped at 300s) retrieves status/output or kills the process group; it is ungated by design (cancel only kills the session's own child). Timeout keeps its meaning: expiry kills the group and reports exit code 124 with `timed_out: true`. The tool watchdog exempts background starts and `shell_collect` (same list as `spawn_agent`/`wait_agents`/`ask_director`) because collect's own wait cap bounds the poll. Surfaces that do not mount `shell_collect` (intern, migrator) do not advertise `background: true` and the registry is unwired — background starts fail closed. Parent abort, deadline, and `close_agent` release parked collect waiters and dispose live background groups; `interrupt_agent` only releases waiters so the session (and its children) stay resumable. Toolset dispose calls `disposeAll("session closed")` before the posix teardown, so `/clear`, interrupt, and reload kill every live background process group.
210210

211211
- **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only on **session-idle** — parent-idle **and** no live fleet lanes (`run` goes idle). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run.
212212

213-
Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count; the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. Skip that shot when a fleet-dry open-task continuation is latched. Exec-primary `wait_agents` mounts with no yield predicate (`createWaitAgentsTool({ sessions, fleetRecords })`) and blocks to ready/timeout/abort; `shouldYieldWait` remains a supported-but-unwired tool option (no production mount passes it).
213+
Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count; the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. Skip that shot when a fleet-dry open-task continuation is latched. Exec-primary `wait_agents` mounts with no yield predicate (`createWaitAgentsTool({ sessions, fleetRecords })`) and blocks to ready/timeout/abort; parent interrupt/abort cancels that parked waiter via the tool AbortSignal (`timed_out`, workers stay running) rather than a second waiter table. `interrupt_agent` on a child unblocks wait as interrupted through the mailbox overlay. `shouldYieldWait` remains a supported-but-unwired tool option (no production mount passes it).
214214

215215
`src/tui/stream-event-map.ts` maps reactor events onto the bridge's inbound events, and `src/tui/turn-state.ts` tracks the turn's status. `src/tui/turns-to-blocks.ts` hydrates a resumed session's stored turns into the same content blocks.
216216

‎src/agent/background-shell-tool.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type } from "arktype";
22
import type { ToolDefinition } from "@intx/types/runtime";
33
import {
44
createBackgroundShellRegistry,
5+
MAX_SHELL_COLLECT_WAIT_MS,
56
type BackgroundShellExit,
67
type BackgroundShellRegistry,
78
} from "../shell/background-shell.js";
@@ -41,7 +42,8 @@ export const shellCollectDefinition: ToolDefinition = {
4142
wait_ms: {
4243
type: "number",
4344
description:
44-
'For action="collect": milliseconds to wait for completion before returning "running" (default 0, non-blocking).',
45+
'For action="collect": milliseconds to wait for completion before returning "running" (default 0, non-blocking, capped at ' +
46+
`${MAX_SHELL_COLLECT_WAIT_MS}).`,
4547
},
4648
},
4749
required: ["shell_id", "action"],

‎src/agent/directors/intern/package.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ describe("internPackage", () => {
2828
expect(p).toMatch(/You do NOT:[\s\S]*Debug failures/);
2929
});
3030

31+
test("systemPrompt forbids background shells — shell_collect is not mounted", () => {
32+
const p = internPackage.systemPrompt;
33+
expect(p).toMatch(/Background shells are forbidden/);
34+
expect(p).toMatch(/background: true/);
35+
expect(p).toMatch(/shell_collect/);
36+
expect(p).toMatch(/foreground/);
37+
expect(p).toMatch(/timeouts only/);
38+
});
39+
3140
test("spawn.maySpawn is false", () => {
3241
expect(internPackage.spawn.maySpawn).toBe(false);
3342
});
@@ -40,6 +49,7 @@ describe("internPackage", () => {
4049
expect(allow).toContain("write_file");
4150
expect(allow).toContain("edit_file");
4251
expect(allow).toContain("delete_file");
52+
expect(allow).not.toContain("shell_collect");
4353
for (const name of [
4454
"grep",
4555
"search_files",

‎src/agent/directors/intern/package.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ You do NOT:
6060
- Do not overthink or get creative with the implementation
6161
- If you're given step-by-step instructions, follow them exactly as written
6262
- If instructions are ambiguous or unclear, STOP and report Blockers for the parent (Skywalker)
63+
- Background shells are forbidden (background: true starts are uncollectable without shell_collect, which is not mounted) — use foreground calls with timeouts only
6364
6465
**When to STOP and Report Blockers**
6566

‎src/agent/directors/migrator/package.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ describe("migratorPackage", () => {
9090
"list_agents",
9191
"search_agents",
9292
"wait_agents",
93+
"shell_collect",
9394
] as const) {
9495
expect(allow).not.toContain(verb);
9596
}

‎src/agent/directors/tool-sets.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ describe("REVIEW_TOOLS / INTERN_TOOLS", () => {
138138
expect(INTERN_TOOLS).toContain("run_shell");
139139
expect(INTERN_TOOLS).toContain("read_file");
140140
expect(INTERN_TOOLS).toContain("list_dir");
141+
expect(INTERN_TOOLS as readonly string[]).not.toContain("shell_collect");
141142
for (const name of [
142143
"grep",
143144
"search_files",

‎src/plugins/shell-guard-plugin.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,23 @@ describe("advertiseShellGuardTimeout", () => {
610610
/does not change the retained shell cwd/i,
611611
);
612612
});
613+
614+
test("omits background when collect is not mounted", () => {
615+
const rewritten = advertiseShellGuardTimeout(
616+
runShellDef(),
617+
undefined,
618+
undefined,
619+
false,
620+
);
621+
const properties = rewritten.inputSchema["properties"] as Record<
622+
string,
623+
unknown
624+
>;
625+
expect(properties["background"]).toBeUndefined();
626+
expect(timeoutSchema(rewritten)?.description).not.toMatch(
627+
/background:true/,
628+
);
629+
});
613630
});
614631

615632
describe("shellGuardPlugin", () => {

‎src/plugins/shell-guard-plugin.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,15 @@ export function formatShellTimeoutNotice(timeoutMs: number): string {
8989
* Schema `default` is the foreground omit path. Description says omit
9090
* timeout on background:true so the model does not copy 120000 onto
9191
* background calls (a copied value becomes a requested timeout and kills
92-
* the job).
92+
* the job). Surfaces that do not mount shell_collect pass
93+
* advertiseBackground=false so background is not advertised without a
94+
* collect path.
9395
*/
9496
export function advertiseShellGuardTimeout(
9597
definition: ToolDefinition,
9698
defaultMs?: number,
9799
maxMs?: number,
100+
advertiseBackground = true,
98101
): ToolDefinition {
99102
if (definition.name !== "run_shell") return definition;
100103
const schema = definition.inputSchema;
@@ -120,7 +123,9 @@ export function advertiseShellGuardTimeout(
120123
) {
121124
nextProperties["timeout"] = {
122125
...(timeout as Record<string, unknown>),
123-
description: `Timeout in milliseconds (foreground default: ${advertisedDefault}; omit on background:true for no timeout)`,
126+
description: advertiseBackground
127+
? `Timeout in milliseconds (foreground default: ${advertisedDefault}; omit on background:true for no timeout)`
128+
: `Timeout in milliseconds (foreground default: ${advertisedDefault})`,
124129
default: advertisedDefault,
125130
};
126131
}
@@ -131,14 +136,18 @@ export function advertiseShellGuardTimeout(
131136
"Optional working directory for this call only (does not change the session shell cwd retained across calls)",
132137
};
133138
}
134-
nextProperties["background"] = {
135-
type: "boolean",
136-
description:
137-
"Set true to run without holding the turn open (prefer this for builds, test suites, and dev servers). " +
138-
"Returns a shell_id immediately; the exit status and output are delivered when the process finishes. " +
139-
"Use shell_collect to collect or cancel. Omit timeout for no timeout. " +
140-
"Does not change the retained shell cwd.",
141-
};
139+
if (advertiseBackground) {
140+
nextProperties["background"] = {
141+
type: "boolean",
142+
description:
143+
"Set true to run without holding the turn open (prefer this for builds, test suites, and dev servers). " +
144+
"Returns a shell_id immediately; the exit status and output are delivered when the process finishes. " +
145+
"Use shell_collect to collect or cancel. Omit timeout for no timeout. " +
146+
"Does not change the retained shell cwd.",
147+
};
148+
} else {
149+
delete nextProperties["background"];
150+
}
142151
return {
143152
...definition,
144153
inputSchema: {

‎src/shell/background-shell.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,4 +245,68 @@ describe("background shell registry", () => {
245245
registry.disposeAll("test done");
246246
}
247247
});
248+
249+
test("collect wait is capped so a huge wait_ms cannot park unbounded", async () => {
250+
const registry = createBackgroundShellRegistry({ maxCollectWaitMs: 80 });
251+
const started = registry.start({
252+
command: "sleep 30",
253+
cwd: tmpCwd,
254+
});
255+
if ("error" in started) throw new Error(started.error);
256+
try {
257+
const t0 = Date.now();
258+
const snapshot = await registry.collect(started.id, 30_000);
259+
const elapsed = Date.now() - t0;
260+
expect(snapshot.state).toBe("running");
261+
expect(elapsed).toBeLessThan(2_000);
262+
} finally {
263+
registry.disposeAll("test done");
264+
}
265+
});
266+
267+
test("non-finite wait_ms is still capped", async () => {
268+
const registry = createBackgroundShellRegistry({ maxCollectWaitMs: 80 });
269+
const started = registry.start({
270+
command: "sleep 30",
271+
cwd: tmpCwd,
272+
});
273+
if ("error" in started) throw new Error(started.error);
274+
try {
275+
const t0 = Date.now();
276+
const snapshot = await registry.collect(
277+
started.id,
278+
Number.POSITIVE_INFINITY,
279+
);
280+
const elapsed = Date.now() - t0;
281+
expect(snapshot.state).toBe("running");
282+
expect(elapsed).toBeLessThan(2_000);
283+
} finally {
284+
registry.disposeAll("test done");
285+
}
286+
});
287+
288+
test("collect completes when the child exits even if stdio stays open", async () => {
289+
if (process.platform === "win32") return;
290+
const token = `ic_bg_stdio_hold_${randomUUID()}`;
291+
const registry = createBackgroundShellRegistry();
292+
// Grandchild inherits the piped stdout so Node's 'close' waits on it.
293+
// The shell itself exits immediately; collect must not require close.
294+
const started = registry.start({
295+
command: `bash -c 'exec -a ${token} sleep 600 & exit 0'`,
296+
cwd: tmpCwd,
297+
});
298+
if ("error" in started) throw new Error(started.error);
299+
try {
300+
const t0 = Date.now();
301+
const exited = await registry.collect(started.id, 5_000);
302+
expect(Date.now() - t0).toBeLessThan(2_000);
303+
expect(exited.state).toBe("completed");
304+
if (exited.state !== "completed") return;
305+
expect(exited.exit.exitCode).toBe(0);
306+
} finally {
307+
registry.disposeAll("test done");
308+
spawnSync("pkill", ["-f", token]);
309+
await waitUntilGone(token);
310+
}
311+
});
248312
});

0 commit comments

Comments
 (0)