acpmux: the app may lend the daemon its cmux sign-in for GitHub App tokens
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
First half of the GitHub App token source for the GitHub broker, split out of #26909 to keep each PR under about 800 lines. It builds on the broker (#24797, #26784, #26785, all merged), and sits below #26909 (the mint) and #26910 (the Swift lender).
This PR adds only the lease. The cmux app lends the Mac daemon its cmux sign-in. The mint that uses the sign-in is #26909, and the Swift lender is #26910. On its own, this PR stores a sign-in that nothing reads yet.
- The call:
_acpmux/github_app_lease {seq, origin, bearer, team, expiresAtMs}lends the sign-in.{seq, clear: true}takes it back. The reply is only{"lent": bool}, or{"lent", "stale": true, "seq"}for a stale call. It never contains the bearer. - Who may call it:
- Only the app's own unix-socket connection, after it has proved the person (
hub/person.rs). Another process on the socket getsperson_required. - Web, LocalApp and Peer are refused in
remote_guard. - Any non-Local origin is refused again in the dispatcher.
peer_forwardnever forwards the call.
- Only the app's own unix-socket connection, after it has proved the person (
- What it may name:
- The origin must be cmux Cloud's API:
https://cloud-api.cmux.devorhttps://cloud-api-staging.cmux.dev, the app'sownerAPIBaseURL, with no path. Loopback is allowed only for local development and tests. - The bearer must be printable ASCII.
- The team must be a cmux team id.
- The origin must be cmux Cloud's API:
- Ordering: each call carries a
seqthat only grows. Aseqmore than a day ahead is refused. An older call changes nothing and getsstale: truewith the newestseq, so a lend that arrives after the sign-out that outdated it never restores the sign-in. At the newestseq, only a clear or a renewal of the unexpired sign-in held now (same API and team) is taken, so another team needs a newerseqand a cleared or expired sign-in never comes back under its own. - Held while used:
still(seq)returns the lease locked while that sign-in is still lent, so #26909 startsghwith no clear or replacement landing in between. - Lifetime: the sign-in is held in memory only. It lasts until it expires or until the app replaces or clears it. Every call (a lend, a clear, a read) takes the lock, then reads the clock, then drops an expired sign-in, so no wait lets an expired one through and a stale reply after expiry says none is lent.
Threat model
- The lent bearer is a Mac install token. It stays in daemon memory only: it is not written to disk or a log, and no reply carries it. In this PR it is sent nowhere. In #26909 it goes only to
{cmux Cloud API}/v1/integrations/github/token. - Who can change it: only the person's own app connection can set, replace or clear it. So another local process cannot point it at another origin or drop it.
- No new trust: no GitHub App, secret, Worker secret, workflow or App permission is added.
Tests (cmux-ci, Linux)
tests/github_app_token.rsonly_the_apps_own_connection_lends_its_sign_in_and_no_answer_holds_itchecks:- the app lends and gets
{"lent": true}; - an unproven local client gets
person_required; - LocalApp, Web and Peer are refused for both a lend and a clear;
- the app's lend still holds afterwards;
- an older call gets
staleand changes nothing, and no reply holds the bearer; - a clear takes the sign-in back, and a late lend does not restore it.
- the app lends and gets
- Unit tests in
src/github_app_token.rscheck:- which origins, bearers and teams are taken;
- stale lends and clears;
- that a lend at the newest
seqonly renews the sign-in held (a_lend_at_the_newest_seq_only_renews_the_sign_in_held_now); - that an expired sign-in is dropped from memory, for every call, with the clock read under the lock (
an_expired_sign_in_is_gone_for_every_call_and_the_clock_is_read_under_the_lock).
- Before (#26785's head
b17af6cwith this PR's test file): the test FAILED, because the daemon answeredMethod not found: _acpmux/github_app_lease. Run: cmux-ci fleet jobacba664a65cc1d43e580a18a. - Before, for review round 8 of #26909 (same
seq): this PR's first commit0895793with the new unit test.a_lend_at_the_newest_seq_only_renews_the_sign_in_held_nowFAILED: a lend for another team at the heldseqwas taken. Run: cmux-ci fleet job23268b6e29e620303bf74a1f. - After:
cargo fmt --check, clippy-D warnings, the broker unit tests and the integration tests (includinggithub_app_token) passed at0fdead6. Run: cmux-ci fleet jobe4d8531b7f943fc671c5cb77. - Before, for review round 1:
837cd57(0fdead6rebased) with the stale-after-expiry test. It FAILED: the stale reply saidlent: true. Run: cmux-ci fleet job0595376b1a4267546cae6867. - After, at
0bb52d0: the same checks passed. Run: cmux-ci fleet job49c3866631c26e9981c73b52. - Before, for review round 2:
0bb52d0with this head's unit test.a_lend_that_expired_on_its_way_moves_seq_on_and_is_not_keptFAILED: a lend already expired on arrival was kept and answeredlent: true. Run: cmux-ci fleet job0dc310319a7d1ef30e239fda. - After, at
1e7442b: the same checks passed. Run: cmux-ci fleet job7135233dcb1ec475d3639777. - After, at this head
edd10f7(feat-cmux-next with the broker merged in, plus5237347: the initialize reply's extension list is built outsidejson!, because one more name put the macro past its recursion limit):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/40804534438
Changelog
- None on its own. With #26909 and #26910, 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 (credential handling and socket policy). This code was reviewed inside #26909 through eight Codex gpt-6-astra rounds; round 8 found the same-seq replacement fixed here. Round 1 on its own, at 0fdead6: BLOCK (the clock read before the lock, and a stale reply after expiry); both fixed. Round 2, at 0bb52d0: BLOCK (a lend already expired on arrival was kept); fixed. Round 3 at 1e7442b could not read the mirror; round 4 there: SHIP. Round 5, at fc60631 (the extension-list fix): SHIP. Round 6, at this head edd10f7:
Review (codex gpt-6-astra, high, fast): SHIP at edd10f7c5b6134a09c4c308e64e1959040e56ce9. "No findings."
Agent session: agent session
š¤ Generated with Claude Code