feat: shell run_in_background — bg-process lifecycle + output spill + offset polling (Dim 4)#112
Conversation
…put spill + offset polling
Dim 4 of the claude-code parity program. Adds a detached background mode
to the builtin run_shell tool plus two companion tools to poll and kill
long-running commands, matching claude-code's Bash(run_in_background) +
BashOutput/KillShell surface. Sandbox isolation is a separate workstream
and out of scope; there is no auto-rewake on completion — discovery is via
polling, matching claude-code (the journal-notification seam is unbuilt).
New module coding/shell_tasks.py:
- ShellTask dataclass (task_id, command, session_key, started_at_ms,
status running|completed|failed|killed|expired, exit_code, log_path,
process + pump handles).
- ShellTaskRegistry: process-wide singleton (get_registry) that spawns a
detached child with the SAME confinement as foreground run_shell
(workspace cwd, POSIX rlimits + setsid, env whitelist), streams combined
stdout+stderr to <ws>/.corlinman/shell_task_<id>.log (append, flush per
chunk), and stamps status/exit_code on exit. Enforces a concurrency cap
(CORLINMAN_SHELL_TASKS_MAX, default 8), retains a bounded window of
terminal records (cap 64, oldest evicted), runs a per-task max-lifetime
watchdog (CORLINMAN_SHELL_TASK_MAX_LIFETIME_S, default 1800s → expired),
and exposes read(offset)/kill()/shutdown(). Env knobs are read live at
spawn so operators/tests can tune without a restart; shutdown() is
exported for future lifecycle wiring, with an atexit best-effort fallback.
run_shell:
- schema gains optional boolean run_in_background (+ doc on the new tools
in the descriptions).
- dispatch_run_shell runs the existing destructive-command guards BEFORE
spawn in bg mode, then registers with session_key and returns
{task_id, status:"running", log_path, note} immediately; bg tasks are
exempt from the 60s foreground cap (watchdog governs). Foreground path
behaviour is unchanged — the inner _kill_process_group closure was
lifted to a shared module-level kill_process_group() reused by the
registry.
Two new tools (schemas + dispatchers, JSON envelopes, never raise):
- shell_task_output(task_id, offset) → {task_id, status, exit_code?,
output, new_offset, log_path}; unknown id → {"error":"task_not_found"}.
- shell_task_kill(task_id) → {task_id, status}; killpg reaps the whole
process group.
Wiring: names folded into CODING_TOOLS (→ BUILTIN_TOOLS automatically) and
coding_tool_schemas(); dispatch branches added next to run_shell in
agent_servicer, with session_key threaded into dispatch_run_shell mirroring
execute_code.
Tests (TDD): tests/test_shell_tasks.py (spawn returns fast; offset
semantics with disjoint advancing chunks; completed/failed exit stamping;
killpg termination; concurrency cap raise + clean dispatcher envelope;
terminal-retention eviction; lifetime watchdog → expired; both new
dispatchers incl. task_not_found / args_invalid) + test_coding_tools.py
bg-mode additions (fast return + foreground unaffected; destructive
refusal fires before spawn; schema count 10→12) + a servicer dispatch-seam
smoke in test_agent_servicer.py. Timing tests use single fork-free
`python3 -c` commands so the inherited RLIMIT_NPROC can't fail a mid-shell
fork on a busy host, mirroring the existing foreground shell tests.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a727397932
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| return dispatch_shell_task_output(args_json=event.args_json) | ||
| if event.tool == SHELL_TASK_KILL_TOOL: | ||
| # Terminate a background shell task's process group (Dim 4). | ||
| return await dispatch_shell_task_kill(args_json=event.args_json) |
There was a problem hiding this comment.
Enforce session ownership when polling tasks
When multiple sessions share the same servicer process, spawn records session_key on the ShellTask, but these poll/kill branches dispatch only the caller-supplied task_id and never compare it with start.session_key. If a task id is copied or leaked between conversations, another session can read that task's shell output or terminate its process, so the new per-session tagging does not actually isolate background tasks; pass the caller session through and reject mismatches.
Useful? React with 👍 / 👎.
| chunk = await proc.stdout.read(_PUMP_CHUNK_BYTES) | ||
| if not chunk: | ||
| break | ||
| fh.write(chunk) |
There was a problem hiding this comment.
Cap background task log growth
For a background command that emits unbounded output, this pump writes every chunk to the workspace log without any size cap or truncation. The child RLIMIT_FSIZE does not protect this file because the parent process is doing the write, and the default watchdog allows the task to run for up to 30 minutes, so a single noisy task can fill the workspace or host disk; enforce a maximum log size or stop/truncate the task when the spill file grows too large.
Useful? React with 👍 / 👎.
| proc = task._proc | ||
| if proc is None or proc.stdout is None or task._log_abs is None: | ||
| return | ||
| with open(task._log_abs, "ab") as fh: |
There was a problem hiding this comment.
Kill the child when spill setup fails
If the workspace log cannot be opened here (for example .corlinman is a file or the directory is not writable), _pump catches the exception and marks the task failed without killing or waiting for task._proc. A silent long-running command such as sleep 9999 would then continue outside the concurrency cap, and later shell_task_kill returns early because the task is already terminal; kill the process group before retiring a pump-failed task.
Useful? React with 👍 / 👎.
| return dispatch_shell_task_output(args_json=event.args_json) | ||
| if event.tool == SHELL_TASK_KILL_TOOL: | ||
| # Terminate a background shell task's process group (Dim 4). | ||
| return await dispatch_shell_task_kill(args_json=event.args_json) |
There was a problem hiding this comment.
Add shell_task_kill to mutating permissions
This new termination branch is reachable after the permission gate, but the gate's MUTATING_TOOLS set in permission.py only includes run_shell and not shell_task_kill. In plan mode or strict mode, a model can still kill an already-running background process even though those modes deny other side-effecting tools, so classify the new kill tool as mutating before dispatching it.
Useful? React with 👍 / 👎.
…l tasks PR #112 (Dim 4, background run_shell) review — four confirmed real issues: 1. Session ownership on poll/kill (agent_servicer dispatch + shell_tasks): the shell_task_output / shell_task_kill dispatchers only took the caller-supplied task_id, so a leaked id let another session read or kill a task it doesn't own. ShellTaskRegistry.read()/kill() now take an optional expected_session_key: when it is non-empty AND the task's recorded session_key is non-empty AND they differ, the call behaves exactly as task_not_found (returns None — no existence leak). Tasks recorded with an empty session_key (direct library callers) stay accessible to any caller. The two dispatchers thread session_key, and the servicer's SHELL_TASK_* branches pass start.session_key (mirroring the run_shell spawn path). 2. Cap background log growth (shell_tasks._pump_body): the child's RLIMIT_FSIZE does not bound the spill file because the PARENT pump writes it, so an unbounded chatty child could fill the disk. New live env knob CORLINMAN_SHELL_TASK_MAX_LOG_BYTES (default 16 MiB, floor 4096). The pump counts bytes written; on breach it appends a marker line, kills the process group, awaits the child, and stamps a new terminal status "log_capped" (added to the status literals + docstring; surfaced by shell_task_output like any other terminal state). 3. Kill the child when spill setup fails (shell_tasks._pump): if opening the spill file raised (e.g. .corlinman is a file), the pump stamped the task failed but left the child running outside the concurrency cap — and a later shell_task_kill no-op'd because the row was already terminal. The setup-failure path now kill_process_group(proc) + await proc.wait() (best-effort, suppressed) BEFORE stamping failed. 4. shell_task_kill missing from MUTATING_TOOLS (permission.py): in plan/strict mode the model could still kill a running bg process. Added "shell_task_kill" to MUTATING_TOOLS; shell_task_output stays read-only (not added). Tests: TDD failing-first for all four. Added registry-level session ownership (read/kill/empty-session), dispatcher ownership, log-cap (status + bounded file + dead process), spill-setup-failure reap, a servicer-level cross-session poll/kill test, and a plan-mode shell_task_kill deny / shell_task_output allow test. All four target suites green (196 passed); ruff + mypy clean.
Follow-up hardening to the Codex #112 batch (flagged during orchestrator review): registry.read() caught only FileNotFoundError, so a .corlinman dir replaced by a file (NotADirectoryError) or a permissions change (PermissionError) escaped through shell_task_output, violating the tools' never-raise envelope contract. Broadened to OSError — no output surfaces but the returned status still tells the caller the task's real state. Extended the spill-setup-failure test to pin the read() posture.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e85e1f6c69
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if ( | ||
| expected_session_key | ||
| and task.session_key | ||
| and task.session_key != expected_session_key | ||
| ): |
There was a problem hiding this comment.
Reject empty-session polling of owned tasks
When a tool call reaches the servicer with start.session_key == "" (the gateway still normalizes requests with no session key to an empty string), expected_session_key is falsy here and the ownership check is skipped. That lets a no-session caller with a known/leaked task_id read output from a task spawned under a non-empty session; the same guard is repeated in kill(), so termination has the same bypass. Fresh evidence beyond the prior ownership comment is that the new registry explicitly treats an empty expected key as a bypass while production callers can still be empty-session.
Useful? React with 👍 / 👎.
| envelope: dict[str, Any] = { | ||
| "task_id": task_id, | ||
| "status": status, | ||
| "output": text, | ||
| "new_offset": new_offset, |
There was a problem hiding this comment.
Bound shell task output responses
When a chatty background task reaches the default 16 MiB log cap and the model polls from offset=0, this puts the entire unread log into one tool result. The servicer journals result_json verbatim before the reasoning-loop spill/truncation path, so a single poll can push a multi-megabyte gRPC/tool-result payload and database row; foreground run_shell avoids this with a 16k inline cap, so shell_task_output should page or cap each response while still advancing new_offset.
Useful? React with 👍 / 👎.
| task.status = "completed" if rc == 0 else "failed" | ||
| task.exit_code = rc | ||
| self._retire(task) |
There was a problem hiding this comment.
Reap process group before marking shell tasks complete
For commands that explicitly daemonize/background their real work and close the inherited pipe, such as sleep 600 >/dev/null 2>&1 &, the shell exits with code 0 and this path marks the task completed even though a child in the same process group is still running. Once the row is terminal, the lifetime watchdog and later shell_task_kill will not reap that process, so these commands escape the new concurrency/lifetime controls; check for or kill the remaining process group before retiring the task.
Useful? React with 👍 / 👎.
The round-2 agent hit a session limit mid-task; orchestrator completed these directly. 1. Empty-session bypass: an OWNED task (non-empty recorded session_key) now requires an EXACT session match — an empty expected key (the gateway normalizes no-session requests to '') is a mismatch, so a no-session caller with a leaked task_id can't read/kill an owned task. Same gate in read() and kill(). Unowned tasks stay open. 2. Bounded reads: read() caps each response at CORLINMAN_SHELL_TASK_READ_MAX_BYTES (default 64 KiB) and returns a has_more paging flag (threaded into the shell_task_output envelope + schema) — a chatty task at the 16 MiB log cap can no longer stuff megabytes into one journaled tool result. 3. Daemonize reap: a command that backgrounds its real work (sleep 600 &) exits the shell leader 0 while a child lives on in the group — it escaped the watchdog/kill controls once the row went terminal. The pump now reaps the group on natural exit via a new reap_orphan_group() that killpg's by pid directly (getpgid fails — asyncio already reaped the leader zombie by EOF; under setsid pgid == leader pid). Lifecycle tests spawn unowned tasks (ownership has dedicated gate tests which now also cover the empty-session case); read() unpack sites take the new has_more element; roundtrip threads the owning session. New tests: empty-session rejection, read cap + paging, reap mechanism + pump-wiring spy (deterministic, no host-fork dependency). 200 passed; ruff+mypy clean.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9a2f806ad0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| # and return a task_id immediately. The safety guards above have | ||
| # already run. Import lazily so :mod:`.shell_tasks` (which imports from | ||
| # this module) doesn't create an import cycle. | ||
| if bool(raw.get("run_in_background")): |
There was a problem hiding this comment.
Require a real boolean for background mode
With providers or direct clients that do not strictly enforce the tool schema, run_in_background can arrive as the string "false" or another truthy non-boolean value. This truthiness check then takes the background path and returns only a task_id/status instead of the foreground output and exit code the caller requested; the nearby subagent parser already rejects non-boolean run_in_background, so this should either branch only on is True or return args_invalid for non-bool values before spawning.
Useful? React with 👍 / 👎.
| cap = _env_read_max_bytes() | ||
| try: | ||
| with open(log_abs, "rb") as fh: | ||
| fh.seek(new_offset) |
There was a problem hiding this comment.
Clamp oversized shell-task offsets
If a model or client sends an offset larger than the platform file-offset type, this seek() raises ValueError instead of OSError, so the new shell_task_output path escapes the dispatcher’s never-raise envelope and the servicer reports a generic builtin_tool_failed for a bad paging argument. Clamp or reject excessively large offsets before seeking, or catch ValueError here and return an args_invalid/safe empty read response.
Useful? React with 👍 / 👎.
| break | ||
| if capped: | ||
| # Log cap tripped — reap the whole group and stamp log_capped. | ||
| kill_process_group(proc) |
There was a problem hiding this comment.
Reap orphaned writers when the log cap trips
When a command backgrounds a chatty child and the shell wrapper exits before the log cap trips, kill_process_group(proc) can no longer resolve the wrapper pid's process group and falls back to killing only the already-dead wrapper. This capped branch then marks the task terminal and returns before the natural-exit reap_orphan_group(proc) below can run, so that writer can survive or block outside the watchdog and later shell_task_kill is a no-op; use the same direct pgid reap here before retiring the task.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b445eebbe1
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| running = [t for t in self._tasks.values() if t.status == "running"] | ||
| for t in running: | ||
| if t._proc is not None: | ||
| kill_process_group(t._proc) |
There was a problem hiding this comment.
Reap orphaned process groups during shutdown
When shutdown runs after a task whose shell wrapper has already exited but a background child still holds the stdout pipe open (for example sleep 600 &), kill_process_group() can no longer resolve the wrapper pid's process group and only falls back to killing the dead wrapper. The task is then marked terminal and shutdown() awaits the pump, which remains blocked on the still-open pipe until the child exits naturally; use the same _reap() path used by kill/watchdog/natural-exit before retiring shutdown tasks.
Useful? React with 👍 / 👎.
| offset = max(0, int(offset)) | ||
| except (TypeError, ValueError): |
There was a problem hiding this comment.
Catch OverflowError while parsing shell-task offsets
If a client sends an offset as a JSON number that Python parses as a non-finite float, such as 1e10000, int(offset) raises OverflowError, which is not caught here; the dispatcher then escapes its JSON-envelope contract and the servicer reports a generic builtin_tool_failed for a bad paging argument. Fresh evidence beyond the prior oversized-offset comment is that this exception occurs before ShellTaskRegistry.read(), so the new seek-side OverflowError catch does not cover it.
Useful? React with 👍 / 👎.
| "with a task_id you then poll with shell_task_output and " | ||
| "terminate with shell_task_kill." |
There was a problem hiding this comment.
Extend subagent run_shell allowlists to task tools
When a subagent is spawned with a narrow tool_allowlist or card allowance containing only shell.run/run_shell, the unchanged subagent runner still filters that to just run_shell and rejects later shell_task_output/shell_task_kill as outside allowed_tools. Fresh evidence beyond the prior skill-allowlist comment is that only _skill_allowed_tools_block got the run_shell⇒task-tools implication, so this schema can hand narrowed subagents a background task_id they are forbidden to poll or terminate.
Useful? React with 👍 / 👎.
…kground # Conflicts: # python/packages/corlinman-server/src/corlinman_server/agent_servicer.py # python/packages/corlinman-server/tests/test_fix_LANE_A_servicer.py
…parse/subagent allowlist) Each is a DIFFERENT code path than the earlier same-class fixes: 1. shutdown() now reaps each running task's whole group via the shared _reap — a daemonized child that outlived the wrapper would otherwise keep the pipe open and block the awaited pump indefinitely. 2. The shell_task_output dispatcher's own int(offset) parse catches OverflowError too — a non-finite JSON offset (1e10000 → inf) raises there BEFORE registry.read()'s seek, so the round-3 seek-side catch didn't cover it; now degrades to offset 0 instead of builtin_tool_failed. 3. The subagent runner's _filter_tools_for_child applies the same run_shell⇒shell_task_output/kill implication the skill gate got in r4 (only for task tools the parent actually holds, so the child never exceeds its ceiling) — a subagent narrowed to run_shell can now poll and terminate its bg tasks. Also merges origin/main (both #113's alias-collision-warn and this PR's run_shell skill implication live in _skill_allowed_tools_block — kept both). 4 new tests. 220 passed; ruff+mypy clean.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 77be2a38cc
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| for task in list(reg._tasks.values()): | ||
| if task.status == "running" and task._proc is not None: | ||
| try: | ||
| kill_process_group(task._proc) |
There was a problem hiding this comment.
Reap orphaned groups in the atexit path
For a background task such as sleep 600 &, the shell wrapper can exit while the child keeps the stdout pipe open, leaving the task running until shutdown. In this atexit-only path, kill_process_group() cannot resolve the process group from the already-exited wrapper PID and falls back to killing only that dead wrapper, so the child can survive interpreter exit; use the same _reap/reap_orphan_group logic used by the async shutdown paths before returning.
Useful? React with 👍 / 👎.
| # ``shell_task_kill`` terminates a running background shell process | ||
| # group — a real side effect, so plan/strict mode must deny it. | ||
| # (``shell_task_output`` is read-only and stays allowed.) | ||
| "shell_task_kill", |
There was a problem hiding this comment.
Allow task kills with explicit run_shell grants
In strict or plan mode, an explicit permission rule can allow run_shell, but this separate mutating entry means the follow-up shell_task_kill still falls through to the mode/strict default and is denied. Because the run_shell schema now tells models to use run_in_background=true, that configuration can start a long-running background task but cannot terminate it except by waiting for the watchdog; mirror the run_shell ⇒ task-tool implication used by the skill/subagent allowlists or require the spawn path to reject background mode when kill is not permitted.
Useful? React with 👍 / 👎.
| return await dispatch_run_shell( | ||
| args_json=event.args_json, | ||
| session_key=start.session_key, | ||
| ) |
There was a problem hiding this comment.
Bind child background shells to subagent lifetimes
When this branch is reached from the subagent child executor, start is the parent's ChatStart, so a child run_shell(run_in_background=true) is registered under the parent session and returns immediately. If that child then hits max_wall_seconds or is cancelled, the subagent runner has no record to clean up and the process can keep running until the global shell-task watchdog, bypassing the child wall-clock cap; track/reap background tasks per child or reject background shell mode inside child execution.
Useful? React with 👍 / 👎.
…n alias/child bg) The last-mile lifecycle + permission gaps for run_in_background: 1. atexit fallback reap: the synchronous interpreter-exit path used kill_process_group alone, missing a daemonized child that outlived the wrapper. Now routes through the same _reap so no bg child survives exit. 2. Permission gate: shell_task_kill now inherits run_shell's full verdict (rules + mode + strict) via _PERMISSION_TOOL_ALIAS — an explicit run_shell allow rule lets the model terminate the tasks it started, even in plan/strict mode where the kill would otherwise be denied. With no run_shell grant it's still denied by default. shell_task_output is read-only (unaliased) and stays allowed everywhere. Mirrors the run_shell⇒task-tools implication the skill/subagent allowlists carry. 3. Subagent child executor refuses run_shell(run_in_background=true): a detached task would register under the PARENT session and outlive the child's wall-clock cap with nothing to reap it on cancel. The child runs the command in the foreground instead (clean lifecycle). 4 new tests (atexit reap; shell_task_kill inherits run_shell grant in plan+strict; child bg rejection). 163 passed; ruff+mypy clean.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 40b6577be6
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| # A shell-task tool inherits run_shell's full verdict (rules + mode | ||
| # + strict): the whole decision below runs under run_shell's | ||
| # identity so the poll/kill surface tracks the run_shell grant. | ||
| tool = _PERMISSION_TOOL_ALIAS.get(tool, tool) |
There was a problem hiding this comment.
Honor arg-scoped shell grants for task kills
When strict/plan deployments allow only specific shell commands, e.g. run_shell(npm:*), a background call that matches the command rule can still start, but shell_task_kill is re-evaluated as run_shell against kill args that contain only task_id, so extract_arg_candidates("run_shell", args) returns no command and the arg-scoped allow never matches. This is separate from the prior broad-allow case: the new alias fixes tool="run_shell" rules, but arg-scoped grants can still create a background task that the model cannot terminate until the watchdog expires.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in b4e1ed3. Root cause was that the argless kill call carries no command, so the scoped run_shell(npm:*) pattern can never match it. New _can_start_shell_tasks(ctx) mirrors the resolver's rule/mode/strict/default ordering but honours ANY run_shell allow (scoped or unscoped), ignoring arg scope — a scoped grant proves the session can start that command's task, so it may control it. shell_task_kill denied only by strict/default is now rescued when the session can start tasks. Confined by the registry's session-ownership gate. New test test_task_control_honours_arg_scoped_run_shell_grant.
| if _RUN_SHELL_TOOL in effective: | ||
| for _implied in (_SHELL_TASK_OUTPUT_TOOL, _SHELL_TASK_KILL_TOOL): | ||
| if _implied in parent_by_canon: | ||
| effective.add(parent_by_canon[_implied]) |
There was a problem hiding this comment.
Avoid granting task controls to foreground-only children
When a child is given a narrow run_shell allowance, this still advertises shell_task_output and shell_task_kill, but _make_child_tool_executor now rejects run_in_background=true for children, so the child cannot legitimately create a background task of its own. Because child tool calls are dispatched with the parent start.session_key, a child that receives or is prompted with a parent task id can poll or kill the parent's background job despite only being granted run_shell; drop this implication for subagents while background shell mode is refused there.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in b4e1ed3. Dropped the run_shell⇒{output,kill} implication for children entirely: a child is refused run_in_background (can't make a bg task) and dispatches tool calls under the PARENT session_key, so implying the controls let any child with plain run_shell reach the parent's jobs. A child now only gets a control tool if its allowlist names it explicitly. Inverted test_subagent_run_shell_does_not_imply_task_tools + added test_subagent_task_tools_only_when_explicitly_requested.
| _PERMISSION_TOOL_ALIAS: dict[str, str] = { | ||
| "shell_task_kill": "run_shell", | ||
| } |
There was a problem hiding this comment.
Let shell polling inherit run_shell allow rules
In allowlist-style permission configs, e.g. [{"tool":"run_shell","action":"allow"},{"tool":"*","action":"deny"}], run_shell(run_in_background=true) is permitted and returns a task_id, but the required follow-up shell_task_output call is not aliased here and falls through to the wildcard deny. The kill tool inherits the run_shell grant, but polling remains blocked, so either make shell_task_output inherit the same grant or reject background mode when the caller will not be allowed to poll it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in b4e1ed3. shell_task_output now participates in the same grant-tracking rescue as shell_task_kill: a control tool denied ONLY by a wildcard (*) rule is the catch-all-swallow case and is rescued iff _can_start_shell_tasks(ctx) — so [run_shell allow, * deny] lets the model poll the task it was allowed to start. Kept output UN-aliased so it stays read-only-allowed in plan mode (the round-1 contract). New test test_task_control_rescued_from_wildcard_deny; test_task_control_denied_when_run_shell_denied proves it's grant-tracking, not a blanket allow.
…acking)
The round-6 alias + child-bg-rejection opened three second-order edges in the
task-control surface (shell_task_output / shell_task_kill):
- Arg-scoped grants: a strict deployment allowing only run_shell(npm:*) can
START a bg npm task (rule beats mode) but the argless kill call carries no
command, so the npm:* pattern never matches and the kill falls to strict
deny — an untouchable task until the watchdog fires. (3523181651)
- Polling asymmetry: shell_task_kill was aliased to run_shell but
shell_task_output was not, so an allowlist [run_shell allow, * deny] let the
model start a task it could never poll. (3523181653)
- Child cross-session reach: run_shell⇒{output,kill} was implied for subagent
children, but children are refused run_in_background and dispatch tool calls
under the PARENT session_key — so a child granted plain run_shell could poll
or kill the PARENT's background jobs. (3523181652)
Coherent fix: the task-control surface tracks the *grant to start* tasks, not
run_shell's per-command scoping. New _can_start_shell_tasks(ctx) mirrors the
resolver's rule/mode/strict/default ordering but honours ANY run_shell allow
(scoped or unscoped) and ignores arg scope; a control tool denied only by a
wildcard rule or a mode/strict/default default is rescued when the session can
start tasks. The registry's session-ownership gate is what confines control to
the caller's own tasks. The child implication is dropped entirely — a child
only gets a control tool if its allowlist names it explicitly.
Tests: 4 new permission cases (arg-scoped kill+poll, wildcard-deny rescue,
run_shell-denied stays denied, default-deny gate) + inverted subagent tests.
Full agent suite 953 passed/1 skipped; ruff + mypy (agent 70 / server 296) clean.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b4e1ed30eb
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| # dropped for children (Codex #112 r7). A child only gets the control | ||
| # tools if its card explicitly lists them. |
There was a problem hiding this comment.
Drop task controls from inherited child tool sets
Fresh evidence beyond the earlier narrow-allowlist concern is that the default/wildcard inherit path still copies the parent's whole tool set before this note, and the parent set now always includes shell_task_output/shell_task_kill. For a general-purpose child with tools_allowed=["*"] and no tool_allowlist, those controls are still advertised even though the child cannot create background tasks and its tool calls run under the parent session key, so a child given a parent task_id can poll or kill the parent's job without an explicit task-control grant.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in ec93be8. Correct — r7 only removed the implication; the wildcard/legacy-card inherit path (tools_allowed=["*"] or empty card → effective = set(parent)) still copied the controls wholesale. Child filtering now ACTIVELY strips shell_task_output/shell_task_kill for every child (child_depth>=1) unless the per-spawn allowlist NAMES them — covers both the inherit path and the narrow-allowlist path. New test test_subagent_wildcard_child_strips_task_controls (legacy card + explicit ["*"]), and test_subagent_task_tools_only_when_explicitly_requested confirms an explicit request still grants.
| if rule.action in (ALLOW, LOG): | ||
| # A scoped OR unscoped allow proves at least one command runs. | ||
| return True |
There was a problem hiding this comment.
Treat ask rules as shell-task start grants
With a permission setup such as run_shell set to ask plus default_action=deny or a * deny catch-all, an approved run_shell(run_in_background=true) can start and return a task_id, but _can_start_shell_tasks() returns false because ASK is not considered a start capability here. The task-control rescue therefore does not apply, leaving shell_task_output denied (and scoped-ask kills denied) for a task the user just approved; include ASK in this predicate or otherwise carry the approval through to task controls.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in ec93be8. ASK now counts as a start capability in _can_start_shell_tasks. Behaviour: read-only shell_task_output is rescued to allow (never left denied by the catch-all); a scoped-ask kill is rescued to allow (the argless call can't be tied to the scoped command, and the task was already approved at start); a broad run_shell=ask kill inherits ask via the alias (prompt-before-kill, consistent with an ask-before-shell policy). Nothing stays denied. New test test_task_control_carries_ask_approval_through.
| # can still run the command in the foreground. | ||
| if child_event.tool == RUN_SHELL_TOOL: | ||
| _child_args = self._parse_args_dict(child_event.args_json) | ||
| if _child_args.get("run_in_background") is True: |
There was a problem hiding this comment.
Re-check child background mode after hook mutations
Fresh evidence beyond the earlier child-lifetime issue is that this guard runs before _dispatch_builtin, but builtin pre-tool hooks later may replace event.args_json with mutated_args before the final dispatch. If a hook normalizes or rewrites a child run_shell call to add run_in_background=true, this check has already passed and the task is spawned under the parent session, again escaping the child's wall-clock/cancel lifecycle; enforce the child background ban after hook mutation or in the dispatch path with child context.
Useful? React with 👍 / 👎.
| task.status = "killed" | ||
| task.exit_code = None | ||
| self._retire(task) |
There was a problem hiding this comment.
Wait for the pump before publishing killed state
When shell_task_kill is called while the pump still has buffered pipe data to append, this marks the task terminal before the pump has finished draining and writing the log. A caller that kills and then immediately polls can observe status: killed with has_more: false at the current EOF, then stop polling even though the pump may append the final output moments later; wait for the pump (or otherwise delay the terminal stamp) so a terminal status means the spill file is complete.
Useful? React with 👍 / 👎.
…+ ask grant)
Two paths the r7 fix didn't cover:
- Wildcard-inherit child: r7 removed the run_shell⇒{output,kill} *implication*,
but the default/wildcard inherit path (tools_allowed=["*"] or a legacy card)
copies the parent's WHOLE set — which carries the controls. So a
general-purpose child with no explicit allowlist still got shell_task_output/
kill and could poll/kill the PARENT's jobs under the shared session_key. Now
child filtering ACTIVELY strips the task-control tools (child_depth>=1) unless
the per-spawn allowlist NAMES them, covering both the inherit and narrow
paths. (3523226974)
- ask grants: _can_start_shell_tasks counted allow/log but not ASK, so an
approved run_shell(run_in_background) under a run_shell=ask rule (+ default
deny) started a task the model couldn't poll (shell_task_output denied) or
kill (scoped-ask). ASK now counts as a start capability; the read-only poll
is rescued to allow and a scoped-ask kill likewise, while a broad run_shell=ask
kill inherits ask (prompt-before-kill) via the alias. Nothing stays denied.
(3523226977)
Tests: wildcard/legacy-card child strips controls (kept when explicitly
requested); ask carries through (broad→kill prompts, output allow; scoped→both
rescued). Full agent suite 955 passed/1 skipped; ruff + mypy (366 files) clean.
…ollow-up) Self-review of the r8 wildcard-strip caught a latent over-strip: it only consulted the per-spawn allowlist, so a card that DELIBERATELY lists shell_task_output (a non-wildcard operator grant) would be stripped when the spawn passed no allowlist. Codex's principle is 'no controls WITHOUT an explicit grant' — a card naming the tool IS explicit. Strip now spares a control tool named in either the card's own (non-wildcard) list OR the per-spawn allowlist; only wildcard/legacy blanket inherit loses it. Test: card=[run_shell, shell_task_output] + no allowlist keeps output, still strips the un-named kill.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0de1dc582a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| #: participate in the task-control rescue below so a ``*``-deny catch-all | ||
| #: can't strand it.) | ||
| _PERMISSION_TOOL_ALIAS: dict[str, str] = { | ||
| "shell_task_kill": "run_shell", |
There was a problem hiding this comment.
Honor explicit shell_task_kill permission rules
When a deployment adds an explicit shell_task_kill rule, this alias rewrites the tool to run_shell before rule matching, so the kill-specific rule is never considered. For example, in strict mode [{"tool":"shell_task_kill","action":"allow"}] still denies kills, and [{"tool":"shell_task_kill","action":"deny"},{"tool":"run_shell","action":"allow"}] still allows them; check rules for the real control tool before falling back to run_shell inheritance.
Useful? React with 👍 / 👎.
| "Set run_in_background=true for a long-running command " | ||
| "(dev server, watcher, slow build): it returns immediately " | ||
| "with a task_id you then poll with shell_task_output and " | ||
| "terminate with shell_task_kill." |
There was a problem hiding this comment.
Hide background shell mode from child schemas
When run_shell is granted to a subagent, the runner projects the parent's tool schema unchanged, but the child executor rejects run_in_background=true with background_not_allowed_in_subagent. In that context this description tells the child to use a mode it cannot execute (and often cannot poll/kill), so long-running child tasks such as dev servers or watchers are likely to fail immediately; strip this property/wording from child-projected schemas or make the child executor actually own background tasks.
Useful? React with 👍 / 👎.
| # Shutdown | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| async def shutdown(self) -> None: |
There was a problem hiding this comment.
Wire shell-task shutdown into server close
This process-wide registry now owns long-running child processes, but I found no caller for ShellTaskRegistry.shutdown() in the server lifecycle (CorlinmanAgentServicer.aclose only closes journal/memory/blackboard/persona/hook resources). In a graceful stop or hot reload where the Python process continues, a task spawned via run_shell(run_in_background=true) keeps running until the watchdog with no live RPC surface left to poll or kill it; call this shutdown hook from the servicer/gateway close path.
Useful? React with 👍 / 👎.
| and self._can_start_shell_tasks(ctx) | ||
| ): | ||
| return ALLOW, None | ||
| return self._default, None |
There was a problem hiding this comment.
Carry default ask approvals into task polling
With default_action=ask and no explicit run_shell rule, an approved run_shell(run_in_background=true) can start a task, but shell_task_output has no matching rule and falls through to this default ASK on every poll. Because polling is the only way to observe completion/output, that setup forces repeated human approvals for each output page (or strands the task if a poll is denied); treat the default ask start capability like the explicit ask-rule path and allow read-only polling for the approved task.
Useful? React with 👍 / 👎.
Summary
claude-code parity Dim 4 core:
run_shellgainsrun_in_background, with two new polling tools. Sandbox abstraction is the separate XL workstream — out of scope. No auto-rewake on completion in v1: research verified the journal notification seam is unbuilt for ALL producers (the subagent dispatcher's_inject_notificationdepends on a journal helper that doesn't exist) — discovery is via polling, matching claude-code's own BashOutput model.Orchestrated build: Opus research (16 claims, 0 refuted) → Opus implementation in an isolated worktree → independently re-validated before push.
What shipped
coding/shell_tasks.py(new):ShellTaskRegistrysingleton — spawns detached children with the SAME confinement as foregroundrun_shell(workspace cwd, POSIX rlimits + setsid, env whitelist); a pump task streams combined stdout+stderr to<ws>/.corlinman/shell_task_<id>.log; stamps status/exit_code on exit. Concurrency cap (CORLINMAN_SHELL_TASKS_MAX, default 8), bounded terminal-record retention (64), max-lifetime watchdog (CORLINMAN_SHELL_TASK_MAX_LIFETIME_S, default 1800s →expired),shutdown()+ atexit best-effort kill.run_shell: optionalrun_in_background— destructive-command guards run BEFORE spawn (verified in review); returns{task_id, status:"running", log_path, note}immediately; exempt from the 60s foreground cap (watchdog governs). Foreground path byte-for-byte semantics preserved (_kill_process_groupextracted to module level for reuse — shared by the registry).shell_task_output(task_id, offset)→{status, exit_code?, output, new_offset, log_path};shell_task_kill(task_id)→ killpg reaps the whole group. JSON envelopes, never raise.CODING_TOOLS(→BUILTIN_TOOLSautomatic), schemas intocoding_tool_schemas(), dispatch branches besiderun_shell;session_keynow threaded intodispatch_run_shell(mirrorsexecute_code).Testing
15 new registry/lifecycle tests + servicer dispatch smoke (bg spawn returns <1s during a running sleep; offset polling returns disjoint advancing chunks; kill reaps forked children via killpg; guard-before-spawn; concurrency cap envelope; terminal eviction; lifetime watchdog). 169 passed across shell/coding/servicer suites; agent+server mypy (366 files) + ruff clean — independently re-verified.