Repository navigation
fix(run-claude-cli): reap every leg-1 process before the next leg starts - #801
Closed
kyle-sexton wants to merge 1 commit into
Closed
kyle-sexton wants to merge 1 commit into
kyle-sexton wants to merge 1 commit into
Conversation
After each leg, list every process whose real uid is the runner user's and that started after the launcher (excluding the launcher and its ancestors), stop and kill them, and start the next leg only once a listing comes back empty. A survivor fails the run. The listing does not depend on process group, session or parent, so it covers &, nohup, setsid and double forks. Closes #799 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q6B1MWhz73WFRYyyrKjtjY
This was referenced Oct 20, 2026
Member
Author
|
Superseded by #803, which runs the subscription leg in its own job instead of reaping processes within one job (decision: #799 (comment)). |
kyle-sexton
added a commit
that referenced
this pull request
Oct 20, 2026
Closes #799 ## Summary Runs the API-key leg and the subscription leg as separate jobs instead of two legs of one step, per the design decision on #799. `run-claude-cli` now runs one leg per invocation, and `intake-triage` splits into a key job and a subscription job. ## Fix - `run-claude-cli`: exactly one of `api-key` and `subscription-token` must be set (fails closed on none or both); the in-step fallback loop is removed. A key leg that ends in a credit stop sets `lane-class` to `credit-stop`, still written after the launcher cuts back the step files. - `verifyManagedSettings` also requires `allowManagedHooksOnly: true` or `disableAllHooks: true`; `apply-claude-managed-settings` (whose default already sets `allowManagedHooksOnly`) refuses settings that set neither. - `intake-triage.yml`: - `claude-intake-triage` (caller's runner) runs the key leg only. - `claude-intake-triage-subscription` runs only when that job's `lane-class` is exactly `credit-stop` (or its key was empty), on a fixed `ubuntu-24.04` runner, with a fresh checkout and the same managed-settings, sudo/docker removal and bubblewrap steps. It takes no artifact or cache from the first job, and it is the only job that references the OAuth secret. - The job that triaged applies and reports. `claude-intake-triage-status` reads both jobs and reports one outcome. - Caller inputs, secrets and workflow outputs are unchanged. - README: documents single-leg use and the two-job pattern for callers. - Review follow-ups: - The step's file-command files are bound read-only in the leg's bwrap namespace, including for any process the leg leaves behind. After the leg they are restored to their exact content, and only then does the launcher write its outputs. - The CLI version defaults to 2.1.304, the version `npm view` returned on 2026-10-20, instead of `latest`. - The intake header notes that rerunning only the subscription job does not rerun the author gate. This is accepted because a rerun needs write access. **Breaking for direct `run-claude-cli` callers:** passing both credentials now fails, and a key credit stop reports `credit-stop` instead of `rate-limit`. Versioning: this repository keeps no changelog file and cuts SemVer tags with `release-tag.yml`; the `!` title marks the break, and the release after merge should take the breaking bump (on `v0.x`, a minor bump per the README). The pr-verify migration in claude-code-plugins follows that release and pins to it. ## Verification - `node --test .github/scripts/*.test.cjs`: 471 pass, 0 fail - `node --test .github/actions/report-lane-outcome/*.test.cjs .github/actions/run-claude-cli/*.test.cjs`: 88 pass, 0 fail, 0 skipped, including a background process that tries to append `lane-class=credit-stop` after the leg ends (it gets EROFS and the output keeps the launcher's value) and a same-size rewrite that is put back byte for byte - New tests cover: - exactly-one-credential enforcement (none, both, `run` refuses both before any leg starts) - no second leg after a credit stop, and the `credit-stop` lane-class - the managed-settings hook requirement, and the action default passing the launcher's check - workflow structure: only the subscription job references the OAuth secret, its fixed `runs-on`, its exact gate, no artifact or cache, steps equal to the key job's but for the credential, and the status job's outcome selection - Deliberately breaking the fixed `runs-on`, the status class mapping and the exactly-one rule each made the new tests fail. - `actionlint`, `zizmor --config .github/zizmor.yml .` (no findings), `biome ci`, `markdownlint-cli2 README.md`, `typos`, `editorconfig-checker` and composite-run shellcheck: all clean. ## Related - Follow-up (out of scope here): #804, give the model a read-only token and move label writes to a model-free job. - Supersedes #801 (same-job process reap); that draft can be closed. - Design: the last comment on #799. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Q6B1MWhz73WFRYyyrKjtjY --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #799
Summary
Leg isolation hardening for
run-claude-cli. A process a leg starts and detaches (&,nohup,setsid, a double fork) could keep running after the leg ended, including while a later leg ran. Each leg now ends only after every process it left is killed and confirmed gone. If any process survives, the next leg does not start.Fix
After each leg,
runLegscallsreapLeg, which repeats these steps:legProcesseslists every live process whose real uid is the runner user's and whose/proc/<pid>/statstart time is no earlier than the launcher's own. The launcher and its ancestors are left out.reapLegsendsSIGSTOPand thenSIGKILLto each listed process. A stopped process cannot fork again before it is killed.The next leg starts only when a listing comes back empty. If processes remain,
runLegsthrowsleg N left K process(es) running after it ended; no further leg runs, and the step fails closed. The same check also runs after the last leg, so nothing outlives the step's legs.Why this mechanism rather than the others considered:
setsidchanges the session and process group, andnohup,&and a double fork change the parent, but none of them changes the real uid or the start time. Changing the real uid takes privilege, which the runner user does not have after runner isolation removes sudo. Zombies (Z/X) count as dead.setsid.bwrap --unshare-pidwould put the whole leg in a PID namespace, but it breaks the CLI's nested bwrap sandbox (open /proc/3/ns/ns failed, see run-claude-cli: a process a leg detaches outlives the leg's timeout #799).PR_SET_CHILD_SUBREAPERhas no Node binding, so it would need a native helper.The
/procsweep needs neither new privilege nor a new binary.runLegsandruntake optionallistLegProcessesandkillparameters. The tests use them to narrow the sweep to their own processes, so a local test run never signals a process the developer started.Verification
node --test .github/actions/run-claude-cli/*.test.cjs: 42 pass, 0 fail. The 3 new tests:legProcesseslists the processes left by&,nohup,setsid -fand a double fork, matched by the pid each one wrote for itself, and never lists the test process or its parent. This runs the unscoped production listing and only reads it.killthat does nothing: the run throws before leg 2, and only the key leg ran (fail closed).reapLegshort-circuited to report no survivors, the two leg tests fail.node --test .github/actions/report-lane-outcome/*.test.cjs: 45 pass.run-claude-cli-ro-binds-probeshape). It should check that the sweep finds no unrelated runner-user process started during the step, and that a detached child is gone before leg 2.Related
🤖 Generated with Claude Code
https://claude.ai/code/session_01Q6B1MWhz73WFRYyyrKjtjY