cmux-host: the Cloud agent mints GitHub tokens for its own machine

Merged
TE
11d ago
+931-9

Leo, 2026-10-24, on the GitHub App for our lanes: "can this be productionized?" This is the VM half of PR 3 in teamleaderleo/leo-workspace research/2026-10-24-github-app-broker-product.md: a cmux Cloud VM gets short-lived, repo-scoped GitHub tokens from the Cloud API as its own vm install, with no gh auth login on the machine.

What changes

The Cloud agent role (cmux host run, root) already holds the VM install's key and mints its bearer through /v1/auth/challenge and /v1/auth/token. Its agent socket, /run/cmux-vm-agent/agent.sock (root:cmux, 0660, so only the work user cmux and root can connect), now takes one more line:

{"github_token": {"repo": "owner/name", "permissions": {"contents": "write"}, "label": "git-credential"}}

and answers on the same connection with one line: {"ok": true, "token", "expires_at", "repo", "permissions"} or {"ok": false, "status", "error": {"code", "message"}, "retry_after"?}. The agent:

  • checks the ask against the endpoint contract (repo owner/name; permission names from {contents, pull_requests, issues, workflows, checks, actions, statuses, metadata}, each read or write; label at most 64 of [A-Za-z0-9._-]), else request.invalid;
  • answers vm.unbound before a bind;
  • mints on its own thread (cloud/github_minter.rs), so the poll loop never waits on an API call. The worker hands each asking connection to the minter (one ask per connection), which answers one line and closes it. At most 8 asks wait; more get busy at once. The minter has its own CloudClient for the bound machine (the same install key, its own bearer cache) from each bind on. Stop condition: dropping it (the worker ending) sets a stop flag, so queued asks get vm.agent_stopping, and joins the thread, which waits for the one mint in flight. The stop flag is shared with the role, which sets it before it asks the worker to stop. The final check and the reply write hold its lock, so a token minted after that is never handed out (the reply is vm.agent_stopping; the API keeps it in its ledger), even when the worker is stuck in a request past the role's deadline. Before each mint the minter checks the asker is still connected (one gone while queued costs no token) and that it waited at most 60 s (older asks get busy, so the CLI's 240 s timeout always sees a reply). A minter that cannot start fails the worker instead of leaving askers with EOF. With the socket's 16 connections taken, a new one gets a busy line (written within 200 ms) instead of a silent close;
  • calls POST /v1/integrations/github/token with {team: <bound team>, repo, permissions, label} and the VM install's bearer. It retries once with a fresh bearer only after a 401 or an auth.* 403, never after policy.denied;
  • accepts a 200 only with a GitHub App installation token (ghs_…, so nothing else, such as a Cloud bearer, can be handed out), an expiry, the asked repo, every asked permission at the asked level (write covers read) and a 16-hex token_id; anything less is integration.bad_answer;
  • keeps nothing. The bearer never leaves the agent. The token goes only into the reply line: it is never cached, written to disk or logged. The journal line is github token owner/name contents:write: minted|refused <code>.

The client side comes in the next two PRs, split off to keep each one under 800 lines: cmux github credential|gh (branch vm-github-cli) and the opt-in cmux github setup (branch vm-github-setup). This PR changes no behaviour until something sends the new line. setup-github's default (CloudGitHubSetup.swift) and the VM image are unchanged.

Contract and backend

This PR codes against the endpoint contract that PR 1 implements, now landed as https://github.com/manaflow-ai/cmux/pull/25044. Its request body (team, repo, permissions, label) and response fields match that route. The API's refusals pass through as they are: policy.denied (403, also a GitHub-side downgrade), integration.unavailable (503: the team, SSO or install check unreachable; retryable) and rate_limited (429, burst 60 then 1/s per principal, with a top-level retry_after). The CLI in the next PR retries the retryable ones once.

Tests (fake API, no network)

  • cloud/github_tests.rs: the route, the bearer and the exact body sent; the token in the reply and not in the journal note; policy.denied passed through with no bearer retry; a 401 or an auth.* 403 drops the bearer and retries once; 429 retry_after, transport errors, an unknown 5xx, and a 200 missing each field or naming another repo; ask validation and the line round-trip; the minter thread (unbound, invalid, then bound; a client gone before its reply does not stop it; with a gated fake, the ask over the limit gets busy at once and the held ones all get their token).
  • cloud/role_tests.rs: the real worker poll loop under a temp root answers vm.unbound and request.invalid on the asking connection and still stops cleanly.

Size: about 890 changed lines, about 390 of them tests; the code is about 500.

Risk: low. The new path runs only for a github_token line, and its connection leaves the worker's poll set. Other socket lines go to the session as before.

Remaining step: the live end-to-end check (a real VM pushes a branch with no gh login). It needs the product GitHub App, which doesn't exist yet.

Agent session: cosmic-gray

šŸ¤– Generated with Claude Code