Skip to content

feat: shell run_in_background — bg-process lifecycle + output spill + offset polling (Dim 4)#112

Merged
sweetcornna merged 12 commits into
mainfrom
feat/shell-run-in-background
Jul 4, 2026
Merged

feat: shell run_in_background — bg-process lifecycle + output spill + offset polling (Dim 4)#112
sweetcornna merged 12 commits into
mainfrom
feat/shell-run-in-background

Conversation

@sweetcornna

Copy link
Copy Markdown
Owner

Summary

claude-code parity Dim 4 core: run_shell gains run_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_notification depends 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): ShellTaskRegistry singleton — spawns detached children with the SAME confinement as foreground run_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: optional run_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_group extracted to module level for reuse — shared by the registry).
  • New tools: 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.
  • Wiring: names into CODING_TOOLS (→ BUILTIN_TOOLS automatic), schemas into coding_tool_schemas(), dispatch branches beside run_shell; session_key now threaded into dispatch_run_shell (mirrors execute_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.

…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.
@github-actions github-actions Bot added codex:needs-review A Codex review is needed for the current PR head. codex:review-requested A Codex review was requested or should be running automatically. status: 🔁 re-review loop A fresh Codex review was requested after the latest change or comment. labels Jul 3, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +3253 to +3256
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@github-actions github-actions Bot added codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result. and removed codex:review-requested A Codex review was requested or should be running automatically. codex:needs-review A Codex review is needed for the current PR head. status: 🔁 re-review loop A fresh Codex review was requested after the latest change or comment. labels Jul 3, 2026
…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.
@github-actions github-actions Bot added codex:needs-rerun A new push or stale result means @codex review should be requested again. codex:review-requested A Codex review was requested or should be running automatically. status: 🔁 re-review loop A fresh Codex review was requested after the latest change or comment. and removed codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result. labels Jul 3, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +440 to +444
if (
expected_session_key
and task.session_key
and task.session_key != expected_session_key
):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +691 to +695
envelope: dict[str, Any] = {
"task_id": task_id,
"status": status,
"output": text,
"new_offset": new_offset,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +397 to +399
task.status = "completed" if rc == 0 else "failed"
task.exit_code = rc
self._retire(task)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@github-actions github-actions Bot added codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result. and removed codex:review-requested A Codex review was requested or should be running automatically. codex:needs-rerun A new push or stale result means @codex review should be requested again. status: 🔁 re-review loop A fresh Codex review was requested after the latest change or comment. labels Jul 3, 2026
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.
@github-actions github-actions Bot added codex:needs-rerun A new push or stale result means @codex review should be requested again. codex:review-requested A Codex review was requested or should be running automatically. status: 🔁 re-review loop A fresh Codex review was requested after the latest change or comment. and removed codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result. labels Jul 4, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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")):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +772 to +773
offset = max(0, int(offset))
except (TypeError, ValueError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +350 to +351
"with a task_id you then poll with shell_task_output and "
"terminate with shell_task_kill."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +3248 to +3251
return await dispatch_run_shell(
args_json=event.args_json,
session_key=start.session_key,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@github-actions github-actions Bot added codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result. and removed codex:review-requested A Codex review was requested or should be running automatically. codex:needs-rerun A new push or stale result means @codex review should be requested again. status: 🔁 re-review loop A fresh Codex review was requested after the latest change or comment. labels Jul 4, 2026
…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.
@github-actions github-actions Bot added codex:needs-rerun A new push or stale result means @codex review should be requested again. codex:review-requested A Codex review was requested or should be running automatically. status: 🔁 re-review loop A fresh Codex review was requested after the latest change or comment. and removed codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result. labels Jul 4, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +929 to +932
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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +164 to +166
_PERMISSION_TOOL_ALIAS: dict[str, str] = {
"shell_task_kill": "run_shell",
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions github-actions Bot added codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result. and removed codex:review-requested A Codex review was requested or should be running automatically. codex:needs-rerun A new push or stale result means @codex review should be requested again. status: 🔁 re-review loop A fresh Codex review was requested after the latest change or comment. labels Jul 4, 2026
…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.
@github-actions github-actions Bot added codex:needs-rerun A new push or stale result means @codex review should be requested again. codex:review-requested A Codex review was requested or should be running automatically. and removed codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result. labels Jul 4, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +924 to +925
# dropped for children (Codex #112 r7). A child only gets the control
# tools if its card explicitly lists them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +584 to +586
if rule.action in (ALLOW, LOG):
# A scoped OR unscoped allow proves at least one command runs.
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +590 to +592
task.status = "killed"
task.exit_code = None
self._retire(task)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +348 to +351
"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."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: ✅ merge-ready Evidence and review are clear; normal merge gates may proceed. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant