cmux-next: lend acpmux the cmux sign-in for GitHub App tokens, named by the team's cmux id

Merged
TE
1d ago
+545-2

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

This is the app half of the GitHub App token source for the GitHub broker. It builds on #26909 (the mint) and #26914 (the lease the daemon takes), both merged: when the Mac's gh has no keyring login, the Mac daemon mints a GitHub App token for one action with a cmux sign-in the app lends it. This PR is the lender (Cloud/GitHubAppLease.swift), driven from the cloud account in AppServices+Cloud.swift.

  • What it lends (_acpmux/github_app_lease {seq, origin, bearer, team, expiresAtMs}):
    • team is the cmux backend's id of the team. That is the active Stack team's derived id (stackTeamIdFor in backend domains/user.ts), or with no active team the user's personal team (personalTeamIdFor). It is never the Stack team's own id, which the API refuses as a team id.
    • The backend derives these ids so that routing needs no lookup, and the app already derives the user's id the same way (CloudIdentity.workerUserID).
    • With no team, a managed (organization) account lends nothing, because cmux is off for it there.
    • bearer is a Mac install token for that team. The Worker lets an install token act only in the team it was minted for (team-select.ts). So the app sends its own install token when that token's team claim is this team, and otherwise mints one with the existing MacInstallIdentity.installToken(team:).
  • When it lends: on sign-in and on a switch of the team.
    • It lends again 90 s before the token's exp, and at least every 5 minutes, so a restarted daemon gets it back soon.
    • After a failure, it retries after 5, 15 and 60 s, then every 300 s.
  • Clearing:
    • When a sign-out starts (CloudService.willSignOut), before its install revoke, which may fail or take a while.
    • On sign-out, or with no team to name, the app sends {seq, clear: true}. It sends it again on the retry schedule until the daemon takes it or the account changes again, so no earlier sign-in or team stays usable.
    • The first account state the app sees always counts (GitHubAppLease.Account), even when it is signed out. So a launch that finds no session clears a lease the daemon kept from the app's last run.
  • Ordering: the lends and clears of one account change go one at a time, in order. A new change cancels the earlier change's calls still in flight, its token fetch included (the team install-token mint now runs in the caller's task, InstallAuthClient.installTokenInCallerTask). A renewal or retry timer of an earlier change that fires late queues nothing. Each carries a seq that only grows, and the daemon refuses an older one. A later change's clear does not wait for an earlier change's calls (a token fetch may hang): its newer seq already outranks them, and an earlier change's stale reply is never retried past it.
  • Reused pieces: the lender uses the existing checked person line (AcpmuxPersonLine), MacInstallIdentity and the HomeCloudLease helpers.

Threat model

  • The lent bearer is a Mac install token, held only in the daemon's memory (#26909). The daemon sends it nowhere but cmux Cloud's token route.
    • The app sends it over the unix socket line only once acpmux took the app's person proof on it. A line whose proof was refused or never asked for (an older daemon, or another one at the socket path) gets nothing, not even a call to refuse. It logs only the error's description, never the token.
    • A sign-out or a team switch replaces or clears it, and a stale lend is refused by seq.
  • The team the lease names is the one the user has active in the app. Cloud checks membership on every mint (selectTeam), and mints only from that team's GitHub connection.
  • No new trust: this PR uses the existing install identity and the existing cmux GitHub App. It adds no GitHub App, secret or permission.

Tests

  • Tests/CmuxNextAppTests/GitHubAppLeaseTests.swift:
    • aLendNamesTheTeamByItsCmuxIDNeverItsStackID checks the derived ids against values computed with the backend's own sha256 derivation, for both a shared and a personal team. It also covers a managed account with no team, and a signed-out user.
    • theFirstAccountSeenCountsEvenSignedOut checks that the first signed-out state counts, and that a team change counts.
    • aSwitchClearsTheEarlierSignInBeforeItFetchesTheNext, aTokenOfAnotherAccountOrTeamIsNeverLent and aClearTheDaemonCallsOlderGoesAgainPastItsSeq check the fence, the account check and a stale reply.
    • aSignOutClearsTheLeaseWhenItStarts checks that a sign-out's start sends a newer clear.
    • aSignOutsClearNeverWaitsForATokenFetch checks that the clear goes while a token fetch hangs, that the superseded fetch is cancelled and its failure not counted, and that nothing is lent once it ends.
    • theSignInGoesOnlyToADaemonThatTookThePersonProof checks that a line without the person proof gets no call and is closed.
    • aTimerOfTheAccountBeforeNeverCancelsTheClearAfterIt checks that a late timer of the account before cannot cancel a sign-out's clear.
  • Tests/CmuxNextAppTests/MacInstallTeamAuthTests.swift: cancellingATeamMintReachesItsRequest runs the real MacInstallIdentity and InstallAuthClient against a stand-in owner that holds the team token request, and checks that cancelling the caller cancels that request.
  • Before: these tests do not compile at #26909's head, because the derivation and Account are new. There, the app lent cloud.auth.teamID, the Stack UUID, which the daemon's own check (team must be a cmux team id) and the Worker both refuse. The first-state rule compared leaseAccount?.user != user, which is nil != nil (false) on a signed-out launch, so no clear went out.
  • Before, for review rounds 2 and 3: on df7ca0f the clear came only once isSignedIn turned false, and on b1a1eb7 it queued behind the hung fetch, so aSignOutsClearNeverWaitsForATokenFetch would wait until its 1-minute limit there. The same holds for rounds 5, 8 and 10: on 9ea8b17 the team mint ran in a task of its own (there cancellingATeamMintReachesItsRequest would find the held request not cancelled), on ac6b681 the bearer went to any line, and on 4d7b091 a late timer's lend cancelled the clear; the round-8 and round-10 tests need send(on:) and an internal lend, which the fixes add. The Swift tests run only in macOS PR CI, so these before-runs have no separate run.
  • After, at this head ff39d5a: a fleet SwiftPM Release compile of the app passed (at b23e9c8, the code head; ff39d5a changes only a test). In PR CI's macOS cmux-next swift test (https://github.com/manaflow-ai/cmux/actions/runs/40820032720), the changed suites ran first: GitHubAppLeaseTests and MacInstallTeamAuthTests passed, 13 tests.
  • The rest of that job is red, as on base. By name: settingsSetWritesARouterModelAndRefusesAnyOther and theAccountAndSearchShareOneLineAboveTheBottomMargin fail on base too (tip Swift suite runs 40817472717 and 40825282427). noViewDrawsTheStockTooltip names HomeChatColorsView.swift from base's 27d2053. countsByDomainMatchInventory, exportIsFresh and reportIsFresh are action-catalog checks; this PR adds no action. anArmedWatcherEndsAStuckProcessWithSIGKILLAfterItsSeconds is a 30 s crash-watcher timing test. This PR touches none of those files. The broad pass ended with no summary line: the test helper exited on its own, with no signal and no crash report. Every recent base tip Swift-suite run ends the same way.

Changelog

  • None on its own. With #26909, 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. This is a background lender with no UI.

Risk

risk:high (credential lending). An independent Codex gpt-6-astra review (high, fast) covers this head. Its first review was of this code inside #26909 at 535d77a, where it found two issues. Round 1 at 862f252 found three (the fence, the account check, a stale reply), and round 2 at df7ca0f found that the lease was cleared only at the end of a sign-out; all are fixed here. Round 3, at b1a1eb7: BLOCK (a sign-out's clear queued behind a hung token fetch); fixed. Round 4, at b935c71: BLOCK (superseded token fetches went on, and their failures counted); fixed. Round 5, at 9ea8b17: BLOCK (the team install-token mint ran in an unstructured task, so cancelling the lend did not reach it); fixed. Rounds 6 and 7, at dd74aa3 and cf257c6: SHIP. Round 8, at ac6b681 (feat-cmux-next merged in): BLOCK (the bearer went to a line acpmux had not taken the person proof on); fixed. Round 9, at 6e392a6: SHIP. Round 10, at 4d7b091 (feat-cmux-next made QuitAgents.environment main-actor, so the lender's environment closure is now @MainActor; a fleet SwiftPM Release compile passes): BLOCK (a late timer of the account before could cancel a sign-out's clear); fixed. Round 11, at this head b23e9c8:

Review (codex gpt-6-astra, high, fast): SHIP at b23e9c880aee3332651f7e0efb9488f69f5a8ac2. "No findings."

Agent session: agent session

šŸ¤– Generated with Claude Code