Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
owns idle rebuild; delivery generation owns session identity, so interrupt,
/clear, and /new abort the outstanding overlay, skip minting a grant, and
notify the operator instead of delivering into a rebuilt agent.
- TUI quit, crash, and process signals await once-only runtime shutdown so live
shell-guard children are reaped. Teardown failure after a completed session
exits 1; SIGINT, SIGTERM, and SIGHUP still exit 128+n.
- Persist close_agent surfaces leftover-child dispose failure so a worker
that survives reap is not reported as a successful shutdown.
- Leftover exec dispose is reported as a failed run (stderr + status failed), and
parent toolset dispose finishes remaining workers and posix teardown before
surfacing leftover-child failure.

### Changed

Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ tool call
- **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.
- **Authorization** (`run-shell-authz.ts`, wired by `authz-plugin.ts`) — 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 permission gate’s shell auto-allow path consults the same policy so it never pre-approves a command authz would reject.
- **Permission** (`permission-plugin.ts`) — Delegates consequential calls to the permission gate.
- **Shell Guard** (`shell-guard-plugin.ts`) — Corbits Code-only replacement for stock `run_shell` (interchange stays unpatched): no built-in default timeout (optional per-call or `settings.shell.timeoutMs`; `maxTimeoutMs` clamps only a resolved timeout), 512KB display cap with head+tail retention (the process keeps running when the cap is hit), process-group kill on timeout/abort only, 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). Also applies a 10s wall-clock budget to `grep`/`search_files`.
- **Shell Guard** (`shell-guard-plugin.ts`) — Corbits Code-only replacement for stock `run_shell` (interchange stays unpatched): no built-in default timeout (optional per-call or `settings.shell.timeoutMs`; `maxTimeoutMs` clamps only a resolved timeout), 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). Also applies a 10s wall-clock budget to `grep`/`search_files`. Ripgrep detached spawns are not tracked.
- **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.
- **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.
- **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).
Expand Down
158 changes: 158 additions & 0 deletions src/agent/fleet-verbs-mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,164 @@ describe("primary fleet verb mount", () => {
await toolset.dispose();
});

test("createAgentToolset dispose rejects when a fleet closeOne throws leftover children", async () => {
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
const { createAgentToolset } = await import("./tools.js");
const permissionGate = {
check: async () => ({ allowed: true }),
getSkipPermissions: () => false,
} as never;
const sessions = createSubAgentSessionStore();
const worker = sessions.start({ description: "d", agentId: "a", brief: "b" });
sessions.markRunning(worker.id);
sessions.registerClose(worker.id, async () => {
throw new Error("1 shell child process still live after 2000ms reap");
});

const toolset = await createAgentToolset({
cwd,
permissionGate,
onOperatorGate: async () => ({ kind: "option", index: 0 }),
subAgent: {
provider: {
providerName: "test",
baseURL: "http://127.0.0.1:0",
model: "test-model",
},
getWorkdirBase: () => cwd,
sessions,
},
});

await expect(toolset.dispose()).rejects.toThrow(/still live after 2000ms reap/);
});

test("createAgentToolset dispose rejects when a retained completed persist worker leaves children", async () => {
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
const { createAgentToolset } = await import("./tools.js");
const permissionGate = {
check: async () => ({ allowed: true }),
getSkipPermissions: () => false,
} as never;
const sessions = createSubAgentSessionStore();
const worker = sessions.start({
description: "d",
agentId: "a",
brief: "b",
retained: true,
});
sessions.registerClose(worker.id, async () => {
throw new Error("1 shell child process still live after 2000ms reap");
});
sessions.complete(worker.id, "done", { agentRetained: true });

const toolset = await createAgentToolset({
cwd,
permissionGate,
onOperatorGate: async () => ({ kind: "option", index: 0 }),
subAgent: {
provider: {
providerName: "test",
baseURL: "http://127.0.0.1:0",
model: "test-model",
},
getWorkdirBase: () => cwd,
sessions,
},
});

await expect(toolset.dispose()).rejects.toThrow(/still live after 2000ms reap/);
});

test("createAgentToolset dispose closes remaining retained workers after the first leftover", async () => {
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
const { createAgentToolset } = await import("./tools.js");
const permissionGate = {
check: async () => ({ allowed: true }),
getSkipPermissions: () => false,
} as never;
const sessions = createSubAgentSessionStore();
const first = sessions.start({
description: "d1",
agentId: "a",
brief: "b",
retained: true,
});
const second = sessions.start({
description: "d2",
agentId: "a",
brief: "b",
retained: true,
});
let firstCloseCalls = 0;
let secondCloseCalls = 0;
sessions.registerClose(first.id, async () => {
firstCloseCalls += 1;
throw new Error("1 shell child process still live after 2000ms reap");
});
sessions.registerClose(second.id, async () => {
secondCloseCalls += 1;
});
sessions.complete(first.id, "done", { agentRetained: true });
sessions.complete(second.id, "done", { agentRetained: true });

const toolset = await createAgentToolset({
cwd,
permissionGate,
onOperatorGate: async () => ({ kind: "option", index: 0 }),
subAgent: {
provider: {
providerName: "test",
baseURL: "http://127.0.0.1:0",
model: "test-model",
},
getWorkdirBase: () => cwd,
sessions,
},
});

await expect(toolset.dispose()).rejects.toThrow(/still live after 2000ms reap/);
expect(firstCloseCalls).toBe(1);
expect(secondCloseCalls).toBe(1);
});

test("createAgentToolset dispose rejects when a retained running persist worker leaves children", async () => {
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
const { createAgentToolset } = await import("./tools.js");
const permissionGate = {
check: async () => ({ allowed: true }),
getSkipPermissions: () => false,
} as never;
const sessions = createSubAgentSessionStore();
const worker = sessions.start({
description: "d",
agentId: "a",
brief: "b",
retained: true,
});
sessions.markRunning(worker.id);
sessions.registerClose(worker.id, async () => {
throw new Error("1 shell child process still live after 2000ms reap");
});

const toolset = await createAgentToolset({
cwd,
permissionGate,
onOperatorGate: async () => ({ kind: "option", index: 0 }),
subAgent: {
provider: {
providerName: "test",
baseURL: "http://127.0.0.1:0",
model: "test-model",
},
getWorkdirBase: () => cwd,
sessions,
},
});

await expect(toolset.dispose()).rejects.toThrow(/still live after 2000ms reap/);
});

test("createAgentToolset omits fleet verbs when subAgent is not set", async () => {
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
const { createAgentToolset } = await import("./tools.js");
Expand Down
33 changes: 27 additions & 6 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ export const ASK_OPERATOR_OPTION_MAX_CHARS = 48;
/** Cap on the ask_operator question (UTF-16 code units). */
export const ASK_OPERATOR_QUESTION_MAX_CHARS = 160;

function rethrowToolsetDisposeFailures(failures: unknown[]): void {
const first = failures[0];
if (first === undefined) return;
if (failures.length === 1) throw first;
throw new AggregateError(failures, "toolset leftover dispose failed");
}

const SubmitOutputArgs = type({
"summary?": "string",
"step?": "string",
Expand Down Expand Up @@ -982,11 +989,28 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
disposed = true;
mcpAbortController.abort(new Error("MCP toolset disposed"));
disposal = (async () => {
const failures: unknown[] = [];
// Kill every live background process group before the posix teardown so
// /clear, interrupt, and reload cannot leave orphans behind.
backgroundShells.disposeAll("session closed");
try {
await posixTools.dispose();
} catch (err: unknown) {
failures.push(err);
}
const fleetSessions = fleetSessionsForDispose;
if (fleetSessions !== undefined) {
fleetSessions.cancelAll("parent session closed");
try {
await fleetSessions.cancelAll("parent session closed");
} catch (err: unknown) {
failures.push(err);
}
for (const session of [...fleetSessions.list()].reverse()) {
await fleetSessions.closeOne(session.id, DEFAULT_CLOSE_DEADLINE_MS);
try {
await fleetSessions.closeOne(session.id, DEFAULT_CLOSE_DEADLINE_MS);
} catch (err: unknown) {
failures.push(err);
}
}
}
await Promise.allSettled([...inFlightConnections.values()]);
Expand All @@ -997,11 +1021,8 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
[...connectedClients.values()].map((client) => client.close().catch(() => undefined)),
);
connectedClients.clear();
// Kill every live background process group before the posix teardown so
// /clear, interrupt, and reload cannot leave orphans behind.
backgroundShells.disposeAll("session closed");
await posixTools.dispose();
await disposeWebSearchClients();
rethrowToolsetDisposeFailures(failures);
})();
return disposal;
};
Expand Down
Loading
Loading