acpmux: with no gh keyring login, the Mac runs a lent GitHub action with a cmux GitHub App token

Merged
TE
8d ago
+751-39

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): if gh auth status --hostname github.com succeeds, 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/token with {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 comment posts 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 one gh, as GH_TOKEN (with GH_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_TOKEN and GITHUB_ENTERPRISE_TOKEN removed. So keyring means the keyring, and the App token is the only token an App action sees.
  • Nothing gh prints leaks the token: with an App token, every ghs_ run in gh'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, seq ordering) 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 time gh would start is not used either: the action fails with -32045 github.app_token, code lease_changed, and nothing runs. That check and the start of gh happen 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 (keyring or app) goes on the Mac's log line, on the reply's source, in the error data, and on the added machine's github_broker_action event. 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 with gh 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's code is in data.code.
  • Any other mint failure: -32045, data.reason = "github.app_token", with code. Only codes on an allowlist are passed on (policy.denied, rate_limited, unreachable, invalid_reply); any other code becomes unknown. A sign-in that changed during the mint gives lease_changed. Each has fixed words, and nothing Cloud wrote is passed on.
  • Every error once a login was tried carries data.source (keyring or app), a failed mint included.

Threat model

  • The minted token lives in the Mac daemon's memory for one action. It goes to that one gh child as GH_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_repo asserts that neither the token nor the bearer appears in what goes back. a_token_gh_prints_never_goes_back uses a gh that prints $GH_TOKEN on 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 deadlineMs is checked on arrival and again after the login is chosen (gh auth status takes up to 10 s, a mint up to 20 s). So gh starts 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 status fails 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 push is still not brokered.

Tests (cmux-ci, Linux)

  • Before: #26785's head with only this PR's tests/github_app_token.rs, at a51f607 with the first two tests. Run: https://github.com/manaflow-ai/cmux/actions/runs/40664769931
    • with_no_keyring_login_the_mac_runs_a_lent_action_with_an_app_token_for_that_repo FAILED with Method not found: _acpmux/github_app_lease.
    • with_neither_login_the_action_fails_closed_and_says_to_connect_github FAILED because the Mac ran the action instead of failing closed.
  • Before, for review round 1: this PR's first head d24ccca with that round's test file. an_action_whose_login_took_past_its_deadline_starts_no_gh FAILED: a 3 s mint against a 62 s deadline still ran gh. Run: https://github.com/manaflow-ai/cmux/actions/runs/40670440390
  • Before, for review round 2: the round-2 head 926bfb4 with this head's test file. All 4 FAILED. Run: https://github.com/manaflow-ai/cmux/actions/runs/40676604288
    • The decoy GITHUB_TOKEN in the daemon's env passed the keyring check, so the actions ran as source: keyring (the test gh passes auth status only 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 a gh that printed $GH_TOKEN sent it back (a_token_gh_prints_never_goes_back). Run: https://github.com/manaflow-ai/cmux/actions/runs/40675854530
  • Before, for review round 3: a57885c with this head's test file. a_failed_mint_says_it_tried_the_app_and_passes_on_none_of_clouds_words FAILED, because a Cloud refusal that echoed a ghs_ token and the bearer went on in the error with no source. Run: https://github.com/manaflow-ai/cmux/actions/runs/40693360082
  • Before, for review round 4: d3f3103 with this head's unit test. cloud_errors_go_on_in_fixed_words_only FAILED: a token-shaped code ghs_minted0123 went on as the code. Run: https://github.com/manaflow-ai/cmux/actions/runs/40706153738
  • Before, for review round 5: 1f49357 with this head's test file. a_token_broader_than_its_action_is_never_used FAILED: a reply listing contents: write for a comment was used to run gh. Run: https://github.com/manaflow-ai/cmux/actions/runs/40718963089
  • After, at 535d77a: clippy -D warnings was clean, and the same tests passed. Run: https://github.com/manaflow-ai/cmux/actions/runs/40718984924
  • Before, for review round 7: 15a905b with this head's test file. a_token_minted_across_a_sign_out_is_never_used FAILED: 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 gh started): no_sign_out_lands_between_the_check_and_the_start cannot run on the round-8 head, since while_lent is new; there still_lent returned before the spawn, which is what the test pins.
  • After, split from the lease (#26914; 7232e81): cargo fmt --check and clippy -D warnings were clean. Unit tests github_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_tests passed, and the integration tests github_app_token (8), github_broker, remote_guard, remote_guard_peer, peer_integration, web_token_exposure, pairing, peer_lease and start_mode passed. Run: cmux-ci fleet job 9e74569957a99292116405e3 (at fb5a847, the head after the rebase; 7232e81 before it passed too, job 99e55dedcf9a688b21e0729c).
  • 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 at 611e6df and 6cb26db, 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 (including github_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 gh with 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 gh prints 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_connected and 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, no gh).
  • Lease ordering is enforced on the daemon side by seq, whatever order calls arrive in (unit test a_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 gh login.

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