acpmux: with no gh keyring login, the Mac runs a lent GitHub action with a cmux GitHub App token
Leo's ask (cmuxterm-hq#4770): "you can just not choose a project ... you just get a little green dot ... as if you were working on your own computer, but now it's remote."
Summary
Follow-up to the GitHub broker (#24797, #26784, #26785). It builds on #26914 (merged), which adds the lease this PR mints with. The app's lender is #26910, on top of this. Those let an added machine's agent run a few gh actions with the Mac's own gh keyring login. This PR covers a Mac that has no gh login but whose user connected the cmux GitHub App in Settings: the Mac daemon gets a short-lived App token scoped to that one repository and runs the action with it.
- Choosing the login, per action (
src/github_app_token.rs,choose): ifgh auth status --hostname github.comsucceeds, the keyring login is used, as before. Otherwise, if the app has lent the daemon a cmux sign-in, the daemon asks cmux Cloud's existing token route,POST {origin}/v1/integrations/github/tokenwith{team, repo, permissions, label: "acpmux-github-broker"}, for an App installation token for that repository. This is the same route Cloud machines use (#25044). With neither, the action fails closed. - Permissions per action, no more:
read: metadata read and pull_requests read.issue: metadata read and issues write.comment: metadata read, issues write and pull_requests read (gh issue commentposts through the issues API, and reads a pull request to tell which it is).draft_pr: metadata read, contents read and pull_requests write.- No action asks for contents write, workflows or actions.
- The reply is checked before it is used: the token must be a
ghs_installation token, the repository it names must be the action's repository, and the permissions it lists must be ones this action asked for, at no higher level. The token reaches only that onegh, asGH_TOKEN(withGH_HOST=github.com). - No env token counts as a login: the keyring check and every action (keyring or App) run with
GH_TOKEN,GITHUB_TOKEN,GH_ENTERPRISE_TOKENandGITHUB_ENTERPRISE_TOKENremoved. Sokeyringmeans the keyring, and the App token is the only token an App action sees. - Nothing
ghprints leaks the token: with an App token, everyghs_run ingh's stdout and stderr (whole, or cut off at the output limit) is replaced with[redacted]before anything leaves the Mac. - The lent sign-in (
_acpmux/github_app_lease, who may set it, origin pinning,seqordering) is #26914. This PR reads it per action. An expired lease is not used. A token minted with a sign-in that was replaced, cleared or expired by the timeghwould start is not used either: the action fails with-32045 github.app_token, codelease_changed, and nothing runs. That check and the start ofghhappen under the lease's lock (while_lent), so no sign-out lands between them. - The app half (the Swift lender) is its own PR, stacked on this one: #26910.
- What is recorded: the source (
keyringorapp) goes on the Mac's log line, on the reply'ssource, in the error data, and on the added machine'sgithub_broker_actionevent. The token never does.
Errors (for Connect GitHub)
- Neither login:
-32044,data.reason = "github.not_connected", with the message "GitHub is not connected on this Mac: connect GitHub in cmux Settings (or sign in withgh auth login)". - The team has no GitHub connection (
integration.not_found):-32044 github.not_connected, "GitHub is not connected for this team: connect GitHub in cmux Settings". It needs linking again (needs_reauth):-32044 github.not_connected, "GitHub needs to be connected again: reconnect GitHub in cmux Settings". Cloud'scodeis indata.code. - Any other mint failure:
-32045,data.reason = "github.app_token", withcode. Only codes on an allowlist are passed on (policy.denied,rate_limited,unreachable,invalid_reply); any other code becomesunknown. A sign-in that changed during the mint giveslease_changed. Each has fixed words, and nothing Cloud wrote is passed on. - Every error once a login was tried carries
data.source(keyringorapp), a failed mint included.
Threat model
- The minted token lives in the Mac daemon's memory for one action. It goes to that one
ghchild asGH_TOKEN. The child has null stdin and is killed after 60 s (kill_on_drop). The token is not written to disk, logged, put in argv or put in a reply, and it is never sent to the added machine. The reply carries only the action, repo, bounded output and source. Tests:with_no_keyring_login_the_mac_runs_a_lent_action_with_an_app_token_for_that_repoasserts that neither the token nor the bearer appears in what goes back.a_token_gh_prints_never_goes_backuses aghthat prints$GH_TOKENon stdout, and on stderr when it fails. - The lent cmux sign-in (Mac install token) is held in daemon memory only. It goes nowhere but
{cmux Cloud API}/v1/integrations/github/token, with redirects off. Only the app's own connection, which proved the person, can set it, replace it or clear it. - What the added machine controls: only the action and the repo, both from the broker's fixed vocabulary. Each is already checked twice, on the machine and again on the Mac. The permissions come from the action on the Mac. The added machine cannot name permissions, a team or an origin. A token is minted for one repository, and that repository must be one the user's App installation covers.
- Fail closed: with no keyring login and no lease (or an expired one), nothing is minted and nothing runs. A sign-out or team switch during a mint means its token is never used.
- Cloud's reply is read only up to 16 KiB. Its error text never leaves the Mac: only a code of the API's own shape goes on, with fixed words.
- Deadline: the request's
deadlineMsis checked on arrival and again after the login is chosen (gh auth statustakes up to 10 s, a mint up to 20 s). Soghstarts only if it will end before the added machine stops waiting, and a slow mint cannot make a retried comment run twice. - No new trust: this PR uses the user's existing cmux account credential and the existing cmux GitHub App, through the existing Cloud token route. It adds no GitHub App, no secret, no Worker secret, and no workflow or App permission.
- Residual: while
gh auth statusfails and a lease is held, each action costs one mint. Actions are already capped at 4 at a time per peer name, across reconnects.git pushis still not brokered.
Tests (cmux-ci, Linux)
- Before: #26785's head with only this PR's
tests/github_app_token.rs, ata51f607with the first two tests. Run: https://github.com/manaflow-ai/cmux/actions/runs/40664769931with_no_keyring_login_the_mac_runs_a_lent_action_with_an_app_token_for_that_repoFAILED withMethod not found: _acpmux/github_app_lease.with_neither_login_the_action_fails_closed_and_says_to_connect_githubFAILED because the Mac ran the action instead of failing closed.
- Before, for review round 1: this PR's first head
d24cccawith that round's test file.an_action_whose_login_took_past_its_deadline_starts_no_ghFAILED: a 3 s mint against a 62 s deadline still rangh. Run: https://github.com/manaflow-ai/cmux/actions/runs/40670440390 - Before, for review round 2: the round-2 head
926bfb4with this head's test file. All 4 FAILED. Run: https://github.com/manaflow-ai/cmux/actions/runs/40676604288- The decoy
GITHUB_TOKENin the daemon's env passed the keyring check, so the actions ran assource: keyring(the testghpassesauth statusonly when a token env var reaches it). - An earlier run of the same head without that change isolates the other two. An unproven local client could set the lease (
with_neither_login...), and aghthat printed$GH_TOKENsent it back (a_token_gh_prints_never_goes_back). Run: https://github.com/manaflow-ai/cmux/actions/runs/40675854530
- The decoy
- Before, for review round 3:
a57885cwith this head's test file.a_failed_mint_says_it_tried_the_app_and_passes_on_none_of_clouds_wordsFAILED, because a Cloud refusal that echoed aghs_token and the bearer went on in the error with nosource. Run: https://github.com/manaflow-ai/cmux/actions/runs/40693360082 - Before, for review round 4:
d3f3103with this head's unit test.cloud_errors_go_on_in_fixed_words_onlyFAILED: a token-shaped codeghs_minted0123went on as the code. Run: https://github.com/manaflow-ai/cmux/actions/runs/40706153738 - Before, for review round 5:
1f49357with this head's test file.a_token_broader_than_its_action_is_never_usedFAILED: a reply listingcontents: writefor a comment was used to rungh. Run: https://github.com/manaflow-ai/cmux/actions/runs/40718963089 - After, at
535d77a: clippy-D warningswas clean, and the same tests passed. Run: https://github.com/manaflow-ai/cmux/actions/runs/40718984924 - Before, for review round 7:
15a905bwith this head's test file.a_token_minted_across_a_sign_out_is_never_usedFAILED: a clear that arrived during a 3 s mint did not stop the token from being used. Run: https://github.com/manaflow-ai/cmux/actions/runs/40733651679 - Before, for review round 8 (the check let go of the lease before
ghstarted):no_sign_out_lands_between_the_check_and_the_startcannot run on the round-8 head, sincewhile_lentis new; therestill_lentreturned before the spawn, which is what the test pins. - After, split from the lease (#26914;
7232e81):cargo fmt --checkand clippy-D warningswere clean. Unit testsgithub_broker github_app_token github_owner gh_shim host_peering client_request remote_guard peer_lease catalog_reload list_reply hub::peers peer::link_epoch_tests server::outbound_queue_testspassed, and the integration testsgithub_app_token(8),github_broker,remote_guard,remote_guard_peer,peer_integration,web_token_exposure,pairing,peer_leaseandstart_modepassed. Run: cmux-ci fleet job9e74569957a99292116405e3(atfb5a847, the head after the rebase;7232e81before it passed too, job99e55dedcf9a688b21e0729c). - After, at this head
3006f92(feat-cmux-next with the broker and #26914 merged in; this PR's own changed lines are the same as at611e6dfand6cb26db, which passed in https://github.com/manaflow-ai/cmux/actions/runs/40789542229):cargo fmt --check, clippy-D warnings, the unit tests and the integration tests (includinggithub_app_token) passed. Run: https://github.com/manaflow-ai/cmux/actions/runs/40807851796 - The integration tests run a real Mac daemon against a stand-in added machine (WebSocket), a stand-in
ghwith no keyring login, and a loopback stand-in for the cmux API. They check:- the action runs with the minted token and with no other token in the child's env;
- the source is
app; - exactly one mint is made, with the lent bearer, team and repo, and only the action's permissions;
- nothing secret is returned, even when
ghprints it or Cloud's refusal echoes it; - a failed mint says
source: app; - a token whose reply lists more than the action asked for is never used;
- with no lease, the action fails with
github.not_connectedand the Settings text; - neither a LocalApp origin nor an unproven local client can set a lease;
- a login that took past the deadline starts no
gh; - a token minted across a sign-out is never used (
lease_changed, one mint seen, nogh).
- Lease ordering is enforced on the daemon side by
seq, whatever order calls arrive in (unit testa_lend_or_clear_that_arrives_after_a_newer_one_changes_nothing).
Size: about 800 changed lines (756 added, 41 removed), about 460 of them tests. login_env moved into the core (#26917), and a redaction unit test the core already covers was dropped. The lease (#26914) and the Swift lender (#26910) are their own PRs.
Changelog
- Machines you added with Add Machine can use GitHub through the cmux GitHub App you connected in Settings when your Mac has no
ghlogin.
No capture: nothing is drawn.
Risk
risk:high (token handling and socket policy). An independent Codex gpt-6-astra review (high, fast) covers this head.
Review rounds 1 to 5 each blocked, and every finding is fixed above. Round 3 also caught that the app lent a personal token with no team. Round 6, at 535d77a, found only issues in the Swift half (fixed in #26910). Round 7, at 15a905b, found that an expired lease stayed and that a token minted across a sign-out or team switch was used; both are fixed here. Round 8, at e318f53: BLOCK. A lend at the same seq could replace the sign-in a token was minted with (fixed in #26914), and the lease was let go between the check and the start of gh (fixed here with while_lent). Round 9, at 7232e81: SHIP. Round 10, at fb5a847 (rebased and trimmed): SHIP. Round 11, at 611e6df: SHIP. Round 12, at 6cb26db: SHIP. Round 13, at this head 3006f92 (feat-cmux-next merged in):
Review (codex gpt-6-astra, high, fast): SHIP at 3006f9236e2d4bb708e457e3c2be0f6a304d6687. "No findings in the BASE..HEAD diff. The merge matches the original patch (identical stable patch ID and per-file changes), with no Swift changes or dropped/duplicated lines."
Agent session: agent session
š¤ Generated with Claude Code