cmux-next: lend acpmux the cmux sign-in for GitHub App tokens, named by the team's cmux id
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}):teamis the cmux backend's id of the team. That is the active Stack team's derived id (stackTeamIdForin backenddomains/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.
beareris 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 existingMacInstallIdentity.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.
- It lends again 90 s before the token'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.
- When a sign-out starts (
- 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 aseqthat 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 newerseqalready 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),MacInstallIdentityand theHomeCloudLeasehelpers.
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:aLendNamesTheTeamByItsCmuxIDNeverItsStackIDchecks 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.theFirstAccountSeenCountsEvenSignedOutchecks that the first signed-out state counts, and that a team change counts.aSwitchClearsTheEarlierSignInBeforeItFetchesTheNext,aTokenOfAnotherAccountOrTeamIsNeverLentandaClearTheDaemonCallsOlderGoesAgainPastItsSeqcheck the fence, the account check and a stale reply.aSignOutClearsTheLeaseWhenItStartschecks that a sign-out's start sends a newer clear.aSignOutsClearNeverWaitsForATokenFetchchecks 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.theSignInGoesOnlyToADaemonThatTookThePersonProofchecks that a line without the person proof gets no call and is closed.aTimerOfTheAccountBeforeNeverCancelsTheClearAfterItchecks that a late timer of the account before cannot cancel a sign-out's clear.
Tests/CmuxNextAppTests/MacInstallTeamAuthTests.swift:cancellingATeamMintReachesItsRequestruns the realMacInstallIdentityandInstallAuthClientagainst 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
Accountare new. There, the app lentcloud.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 comparedleaseAccount?.user != user, which isnil != nil(false) on a signed-out launch, so no clear went out. - Before, for review rounds 2 and 3: on
df7ca0fthe clear came only onceisSignedInturned false, and onb1a1eb7it queued behind the hung fetch, soaSignOutsClearNeverWaitsForATokenFetchwould wait until its 1-minute limit there. The same holds for rounds 5, 8 and 10: on9ea8b17the team mint ran in a task of its own (therecancellingATeamMintReachesItsRequestwould find the held request not cancelled), onac6b681the bearer went to any line, and on4d7b091a late timer's lend cancelled the clear; the round-8 and round-10 tests needsend(on:)and an internallend, 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 (atb23e9c8, the code head;ff39d5achanges only a test). In PR CI's macOScmux-next swift test(https://github.com/manaflow-ai/cmux/actions/runs/40820032720), the changed suites ran first:GitHubAppLeaseTestsandMacInstallTeamAuthTestspassed, 13 tests. - The rest of that job is red, as on base. By name:
settingsSetWritesARouterModelAndRefusesAnyOtherandtheAccountAndSearchShareOneLineAboveTheBottomMarginfail on base too (tip Swift suite runs 40817472717 and 40825282427).noViewDrawsTheStockTooltipnamesHomeChatColorsView.swiftfrom base's 27d2053.countsByDomainMatchInventory,exportIsFreshandreportIsFreshare action-catalog checks; this PR adds no action.anArmedWatcherEndsAStuckProcessWithSIGKILLAfterItsSecondsis 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
ghlogin.
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