Skip to content

fix(run-claude-cli)!: run the subscription leg as its own job - #803

Merged
kyle-sexton merged 2 commits into
mainfrom
fix/799-split-legs
Oct 20, 2026
Merged

kyle-sexton merged 2 commits into
mainfrom
fix/799-split-legs

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

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

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q6B1MWhz73WFRYyyrKjtjY

run-claude-cli now runs one leg per invocation: exactly one of api-key and
subscription-token must be set, and the in-step fallback is gone. A key leg
that ends in a credit stop sets lane-class `credit-stop`, written after the
step files are cut back.

intake-triage runs the key in the claude job and the token in a new
claude-intake-triage-subscription job on ubuntu-24.04, gated on an exact
`credit-stop` (or an empty key), with a fresh checkout and the same
hardening, and no artifact or cache from the first job. Only the job that
triaged applies and reports; claude-intake-triage-status reports the one
outcome. Caller inputs and secrets are unchanged.

verifyManagedSettings also requires allowManagedHooksOnly or
disableAllHooks, and apply-claude-managed-settings refuses settings that
set neither.

BREAKING CHANGE: run-claude-cli fails closed when both api-key and
subscription-token are set, and reports a key-leg credit stop as
lane-class `credit-stop` instead of `rate-limit`. Callers that relied on
the in-step fallback run the subscription leg as a second job (README,
two-job pattern).

Closes #799

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6B1MWhz73WFRYyyrKjtjY
…rsion

The step's file-command files are now bound read-only in the leg's bwrap
mount namespace, so a process the leg leaves behind cannot write them after
the leg ends. After the leg they are restored to their exact content, not
just their size, before the launcher writes its own outputs.

The CLI version defaults to 2.1.304 in run-claude-cli and intake-triage
instead of latest. The intake header also notes that rerunning only the
subscription job does not rerun the author gate.

Refs #799

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6B1MWhz73WFRYyyrKjtjY
@kyle-sexton
kyle-sexton marked this pull request as ready for review October 20, 2026 05:32
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T05:33:57.104615Z 9ce02f5 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 9m 54s —— View job


Security review

  • Gather context (PR diff, changed files)
  • Review run-claude-cli/lane.cjs credential handling (api-key vs subscription-token, "fails closed" logic)
  • Review apply-claude-managed-settings hook requirement changes
  • Review intake-triage.yml job split (secret scoping between key job and subscription job)
  • Review actions-expression.cjs changes
  • Adversarially validate findings (independent hunter agent, no production-code findings)
  • Post final review

No security findings. This review (and an independent adversarial hunter pass) found no issue meeting the high-signal, concrete-exploit-path bar. GitHub Actions hardening concerns (dangerous triggers, expression injection, permission widening, action pins) are zizmor's lane and out of scope here.

What I checked:

  1. Exactly-one-credential invariant — readConfig filters to non-empty credentials and throws unless exactly one is supplied; legEnv unconditionally strips every CREDENTIAL_VARIABLES entry before setting the one selected. No path found where both or neither reach the CLI.
  2. lane-class/credential-present forgery by the model — credential-present is a pure secrets.X != '' boolean the model never touches; lane-class is written by run() only after runLeg's finally has already restored the step's file-command files to their pre-leg content, so the leg cannot set it directly, and credit-stop classification depends on SDK-authored structured fields the model's own output can't forge. The subscription job's if: has no status function, so an implicit success() on needs: claude applies too.
  3. File-command read-only binding/restore ordering in runLeg (snapshot → chmod-lock → bwrap ro-bind → spawn → restore in finally) — the new lane.test.cjs straggler-process test exercises exactly the race this design targets (a process the leg leaves running still gets EROFS after the host-side restore), consistent with the bwrap mount-namespace guarantee described in the action's contract comments.
  4. intake-triage.yml job wiring — the subscription job repeats every hardening step on a fixed ubuntu-24.04 runner, takes no artifact/cache from claude, and holds the same permissions: (no elevation). The status job's shell script closes every needs.*.outputs value against a fixed case allowlist before printing/writing, so a planted ::error::/newline payload in an output doesn't reach stdout (exercised by intake-triage-legs.test.cjs's "prints and writes only closed values" test). The two risks the PR description itself discloses and accepts (the breaking change for direct callers, and a subscription-only job rerun skipping the claude job's author gate — mitigated by reruns requiring write access) are not re-raised here; I didn't find that stated mitigation insufficient.
  5. Credential leakage — legEnv strips credential/file-command vars before spawn; the JSONL log and job summary are only written after holdsCredential scans clean. Nothing in this diff opens a new leak path.

One sub-finding-grade observation, not raised as a finding (no attacker-reachable path on a standard GitHub-hosted runner): snapshotFiles silently skips (and therefore leaves unprotected) any file-command file that doesn't already exist when the leg starts; GitHub always creates these files before a step runs, so this is defense-in-depth only.

@claude

claude Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 30s —— View job


Code review

No blocking or high-signal findings. This is a careful, narrowly-scoped
refactor (one credential per run-claude-cli invocation, the subscription
fallback moved to its own job) and the implementation is internally
consistent:

  • lane.cjs fails closed on zero or two credentials
    (readConfig),
    and only the key leg can reach credit-stop
    (judgeLeg).
  • The file-command restore-by-content logic
    (snapshotFiles)
    runs before lockTrees, and runLeg's finally restores modes before
    content, before run() appends its own outputs — ordering that the
    straggler-process and same-size-rewrite tests in lane.test.cjs exercise
    directly.
  • intake-triage.yml's subscription job gates on an exact
    lane-class == 'credit-stop' compare (or an empty key), runs on a fixed
    ubuntu-24.04, and takes no artifact/cache from claude; the status
    job's shell logic correctly falls back to the key job's outcome only when
    subscription was skipped, and closes the gap where both secrets are
    empty (failure-class=other) distinctly from a credit-stop with no token
    (failure-class=rate-limit) — matching intake-triage-legs.test.cjs's
    assertions.
  • apply-claude-managed-settings's default input and lane.cjs's
    verifyManagedSettings agree on the new allowManagedHooksOnly /
    disableAllHooks requirement.

Two notes, neither blocking:

  • Closing issue run-claude-cli: a process a leg detaches outlives the leg's timeout #799 unread. gh issue view required network access
    this sandbox didn't grant, so I could not check the PR against run-claude-cli: a process a leg detaches outlives the leg's timeout #799's
    acceptance criteria or its design-decision comment. Worth a human
    double-check against the issue before merge.
  • Verification section is author-claimed, not independently confirmed.
    Running node --test in this sandbox also required approval that wasn't
    available, so the "471 pass" / "88 pass" counts and the actionlint /
    zizmor / biome / markdownlint-cli2 results are as reported by the
    author, not verified here. Static reading of lane.test.cjs and
    intake-triage-legs.test.cjs shows tests that do exercise the specific
    claims in the PR body (straggler process, same-size rewrite, exact
    runs-on/gate/outcome-selection checks).

@github-actions

Copy link
Copy Markdown

claude-review has reviewed this pull request through 9ce02f5; a later push is reviewed from there.

@github-actions

Copy link
Copy Markdown

claude-security-review has reviewed this pull request through 9ce02f5; a later push is reviewed from there.

@kyle-sexton
kyle-sexton merged commit 7b36242 into main Oct 20, 2026
115 of 133 checks passed
@kyle-sexton
kyle-sexton deleted the fix/799-split-legs branch October 20, 2026 08:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

run-claude-cli: a process a leg detaches outlives the leg's timeout

1 participant