acpmux: the app may lend the daemon its cmux sign-in for GitHub App tokens

Merged
TE
8d ago
+459-10

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 gets person_required.
    • Web, LocalApp and Peer are refused in remote_guard.
    • Any non-Local origin is refused again in the dispatcher.
    • peer_forward never forwards the call.
  • What it may name:
    • The origin must be cmux Cloud's API: https://cloud-api.cmux.dev or https://cloud-api-staging.cmux.dev, the app's ownerAPIBaseURL, 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.
  • Ordering: each call carries a seq that only grows. A seq more than a day ahead is refused. An older call changes nothing and gets stale: true with the newest seq, so a lend that arrives after the sign-out that outdated it never restores the sign-in. At the newest seq, only a clear or a renewal of the unexpired sign-in held now (same API and team) is taken, so another team needs a newer seq and 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 starts gh with 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.rs only_the_apps_own_connection_lends_its_sign_in_and_no_answer_holds_it checks:
    • 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 stale and changes nothing, and no reply holds the bearer;
    • a clear takes the sign-in back, and a late lend does not restore it.
  • Unit tests in src/github_app_token.rs check:
    • which origins, bearers and teams are taken;
    • stale lends and clears;
    • that a lend at the newest seq only 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 b17af6c with this PR's test file): the test FAILED, because the daemon answered Method not found: _acpmux/github_app_lease. Run: cmux-ci fleet job acba664a65cc1d43e580a18a.
  • Before, for review round 8 of #26909 (same seq): this PR's first commit 0895793 with the new unit test. a_lend_at_the_newest_seq_only_renews_the_sign_in_held_now FAILED: a lend for another team at the held seq was taken. Run: cmux-ci fleet job 23268b6e29e620303bf74a1f.
  • After: cargo fmt --check, clippy -D warnings, the broker unit tests and the integration tests (including github_app_token) passed at 0fdead6. Run: cmux-ci fleet job e4d8531b7f943fc671c5cb77.
  • Before, for review round 1: 837cd57 (0fdead6 rebased) with the stale-after-expiry test. It FAILED: the stale reply said lent: true. Run: cmux-ci fleet job 0595376b1a4267546cae6867.
  • After, at 0bb52d0: the same checks passed. Run: cmux-ci fleet job 49c3866631c26e9981c73b52.
  • Before, for review round 2: 0bb52d0 with this head's unit test. a_lend_that_expired_on_its_way_moves_seq_on_and_is_not_kept FAILED: a lend already expired on arrival was kept and answered lent: true. Run: cmux-ci fleet job 0dc310319a7d1ef30e239fda.
  • After, at 1e7442b: the same checks passed. Run: cmux-ci fleet job 7135233dcb1ec475d3639777.
  • After, at this head edd10f7 (feat-cmux-next with the broker merged in, plus 5237347: the initialize reply's extension list is built outside json!, because one more name put the macro past its recursion limit): 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/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 gh login.

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