Repository navigation
Machine join E2E: dispatch-only dev harness (mint, join on stdin, heartbeat, revoke) - #27453
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
|
cmux status (one comment, edited in place; each line is updated by the job that owns it) Review App of this head (app.zip, kept 1 day)
red: cmux-next checks (god files, concurrency, crash safety, l10n); no finished base run on facadd8 to compare; do not merge until green; see lane-rules |
|
Owned by an agent session · session |
|
Found 7 test failures on Blacksmith runners: Failures
|
|
Bead |
…ser's processes as that user; 3-day evidence retention Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
…-flight join and find a late-listed machine before revoking; mask the stack email and password; require joinTokenId and a valid credential record; cleanup outputs before useradd Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
…nd awaits them, then revokes on short timeouts; sweep this account's stale CI machines; escape masks; fail an incomplete home scan Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
…ed when a cancel aborts cleanup's request; the mint label is always the run's Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
…ed clock, with abort-aware fakes for the deadline tests Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
Bugbot needs on-demand usage enabledBugbot uses usage-based billing for this team and requires on-demand usage to be enabled. A team admin can enable on-demand usage in the Cursor dashboard. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
…or checked; real acpmux joins with malformed stdin as that user before the secrets (exit 2, never 5) acpmux refuses a state directory with an ancestor owned by another user (config.rs validate_private_state_ancestors), so a runner-owned mktemp base failed the join before enrollment. A join takes its lock, and so creates and checks its state directory, before it reads stdin: malformed non-secret stdin then exercises that check with no request and no secret. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
…contents scanned; no file name in any argv; held values too); sign-in stops between stages and cleanup waits for it before revoking the install; heartbeats count only inside the daemon's run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
Bugbot needs on-demand usage enabledBugbot uses usage-based billing for this team and requires on-demand usage to be enabled. A team admin can enable on-demand usage in the Cursor dashboard. |
…ome runner images), so the scan reads only what acpmux writes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
…er's own state directory (not a link); the flow stops before the sweep once cleanup has begun Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
…est allows the state directory's stat Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
…ld value masked and matched in both forms; the scan fails on any tar warning (a skipped socket); each heartbeat read gets only the remaining budget and a beat counts only within it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
…ly what is left of it, no 1 s floor, and no retry once it is spent Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
…pes; a status answer after its deadline does not count; the heartbeat pause ends by the deadline Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
…the daemon's heartbeat rewrites it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
… matched JSON-escaped too; install.register survives a cancel so its install is revoked; dot-only ids refused Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6
|
Review (codex gpt-6-astra high, next-review): SHIP at fa07b29 No blocking findings. The 12 workflow guard tests passed. Live acpmux/API compatibility remains unverified: repository MCP reads required approval, which this session disallows. |
|
The feat-cmux-next push run https://github.com/manaflow-ai/cmux/actions/runs/40959656345 at b844975 failed: cmux-next checks (god files, concurrency, crash safety, l10n). |
|
Shipped in:
|
![Fix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/fix-with-codesmith-light-3x.png)
Adds
machine-join-e2e.yml, a workflow you can only start by hand. It runs one bounded end-to-end check of the one-line machine join against the development platform: mint a join token, pipe it toacpmux host joinon stdin, wait for a heartbeat, check status, the platform list, consent and the credential file mode, revoke, and scan for secrets. All of it lives in one bun script,scripts/cmux-next/machine-join-e2e.ts, with its tests.Leo: "just have this sort of thing for, I guess, general onboarding of adding remote machines and whatnot and whatever, which includes, I guess, screen sharing potentially."
The ask from the acpmux join owner: "Please provide the CI-safe harness invocation for mint+join (where the signed-in CLI bearer is injected transiently by the CI job, never argv/env/log/disk) and the script's expected redacted evidence fields. I will run one bounded cmux-ci job only; it must mint in-process, pipe cmj_ directly to acpmux host join stdin, then emit only machineId/teamId/name/hubOrigin/credentialPresent/heartbeat/revoked and join list correlation by joinTokenId, with no secrets. Also confirm which CI origin/development route and whether the job may use an ephemeral temporary OS home that is destroyed by the job. Do not send any token/credential to this lane."
The secret rules, as given: "DEVELOPMENT platform only (https://cmux-platform-development.debussy.workers.dev), minted with the DEV stack UI-test account (CMUX_UITEST_STACK_* in CI) inside a CI job that already holds those secrets. Never put a CMUX_UITEST_STACK_* value, cmj_ or cmm_ on a lane disk, in argv, in an env file or in a log. The production mint is an [outward] item for Leo, not this."
Scope
on: workflow_dispatchand nothing else (nopull_request,pull_request_targetorworkflow_run). The job'sif:and its first step both refuse any ref butrefs/heads/feat-cmux-next. Inputs:name(defaultci-join-<run id>) andacpmux_ref, which accepts only the run's own commit (the feat-cmux-next tip). acpmux is always built from the run's commit, because the build runs as the runner user that later holds the secrets, so no other code may build there.permissions: contents: readand nothing else; no job-level grants.cmux-next-vm-image-devenvironment. GitHub's branch policy on that environment admitsfeat-cmux-nextonly, so the branch limit is enforced by GitHub as well as by the job's own checks.tests/test_supplychain_security.pyrequires every dispatchable job that reads secrets to run in an environment. That environment's own secret (FREESTYLE_API_KEY) is never referenced;tests/test_machine_join_e2e_workflow.pypins the job to exactly the two dogfood secret references.CMUX_DOGFOOD_STACK_EMAIL/CMUX_DOGFOOD_STACK_PASSWORDthat already exist. No new secret, environment or variable. They reach one step, the mint step, throughenvonly, mapped toCMUX_UITEST_STACK_*. ios-e2e.yml (lines 353-367) uses the same pair the same way.HUB_ORIGIN), and the script refuses any other origin before it makes a request. The Stack project is the development one that cmux-api-development trusts (454ecd03-…,backend/apps/api/wrangler.jsoncenv.development). The access token comes fromhttps://cmux-api-development.debussy.workers.dev(issuerhttps://cmux-api/development, the issuer cmux-platform development verifies). A token from that issuer cannot sign in to staging or production.LINUX_RUNNER, which may name a persistent host, because this job adds an OS user and holds the secrets. 45-minute job bound and 15 minutes for the mint step. No concurrency group, so it cancels nothing.How the secrets are held
process.envbefore anything starts. It then holds these values in memory and never anywhere else: the Stack session, a throwaway cmuxcliinstall key (non-extractable), the cmux access token, thecmj_and thecmm_.::add-mask::the moment it exists: the Stack tokens on sign-in, the access token on mint, thecmj_on mint, and thecmm_once it has been read from the credential file.{PATH}as its environment, and argv carries no secret. acpmux runs as the disposable user throughsudo -n -u USER -- env -i HOME=… PATH=… acpmux ….statand the home scan. So a path that user controls, such as a symlink, reaches nothing beyond what that user could read anyway. The credential path must be exactly$HOME/.acpmux/machine-credential.json, andstatwithout-Lmust show, before the read and again after the heartbeat, that~/.acpmuxis the user's own directory (not a link) and the credential is the user's own regular file with exactly one link (so no hard link to it exists elsewhere) and mode 600, checked right after the join as well as after the heartbeat. Ids from the platform and from acpmux (machine, join token, team, label) must be plain[A-Za-z0-9._-], not only dots (so.and..can never step up a revoke URL), and never token-shaped (cmj_,cmm_,eyJ,bearer), or they are dropped before they reach a URL, a log line or the evidence. Every line the script prints, apart from its own::add-mask::and::warning::commands, is made plain: CR and LF become a space,::is split, and##[is broken. So an id, a server code or acpmux's output can never start a workflow command (::stop-commands::would turn later masks into plain text). The evidence check prints values from the evidence only as plain characters, and the smoke step prints acpmux's output only throughsedthat splits::and breaks##[, never raw.host join --hub <dev> --name <name> --jsongetscmj_…\non stdin and then stdin is closed.cmj_/cmm_token and JWT redacted.evidenceLeaks(prefixes, JWT shape, bearer/authorization, any held value both as written and in its JSON-escaped form), and the workflow's "Check the evidence" step runs a second grep (cmj_|cmm_|eyJ…\.…\.|bearer|authorization). Either check fails the job and blocks the upload.Cleanup (every path)
One cleanup pass. It runs in the script's
finally; on SIGINT or SIGTERM (a cancel or a step timeout; the stepexecs bun); and at the script's own 12-minute deadline (the step allows 15). The order:kill -TERM -1, then-KILL -1, run as that user). Each step is confirmed before the next. A process that outlives its SIGKILL by 500 ms has its pipes closed and counts as ended, so a child holding them open cannot stall cleanup. A cancel ends a long wait that is already running but never skips the confirmation (after a cancel, at most 3 s in all). A join still running after that is reported, so no join can still redeem once the search for its machine begins;joinTokenId(6 list reads). After a cancel, everything works to one deadline, 7.5 s after the cancel: the search for the machine stops in time to leave the revoke request 2.5 s, and the process exits at 9 s, inside the runner's grace (SIGINT, then SIGTERM 7.5 s later, then SIGKILL). If a cancel lands during cleanup and aborts the revoke, the revoke is tried again;A mint without a
joinTokenIdnever joins.Sweep. The mint label is always
ci-join-<run id>, whatevernameis. Each run starts by revoking this account's own CI machines (that label, minted by the same user) that are still joined 30 minutes after they were made. This covers a run killed before its revoke (a lost runner or SIGKILL); a machine credential otherwise lives 365 days.Secret scan of the home. One process with a fixed argv,
tar -C HOME --one-file-system -cf - ., runs as the disposable user. The harness reads the archive in memory and countscmj_/cmm_in every file name, link target and body, and looks for every value it holds. No file name reaches an argv. tar must read everything: any warning on its stderr fails the scan, because tar skips a socket with a warning and still exits 0. Child text output is read as UTF-8 and the archive as bytes, and every held value is matched both as text and as its UTF-8 bytes, so a non-ASCII value is caught in a file name, a file body or child output.Sign-in is undone even mid-way. The sign-in, install and token stage stops between steps once cleanup has begun. A cancel never aborts the install registration (it keeps its own bounded timeout), and cleanup waits for that stage, with what is left of the cancel budget less 1.5 s, before it revokes the install and signs out. So an install the server committed just as the cancel came is still revoked.
Heartbeats are checked against the daemon's run. Status's heartbeat (ms) must fall within the daemon's first 90 s, with no future times. Each status read gets only what is left of that budget. The list's (RFC 3339, or epoch seconds or ms) must fall within a minute of it.
"Check the evidence" (
always()) fails the job when no evidence was written, or when a joined machine is not shown revoked.The disposable user's base is root's. The base directory is
root:root0755 beforeuseradd. The home is made empty (no/etc/skelcopy, which is hundreds of MB on some runner images), so it holds only what acpmux writes. Every ancestor of the home and of the installed binary is then checked: it must be owned by root or the user, with no group or other write unless sticky, and no extended ACL. acpmux refuses a state directory with an ancestor that another user owns (config.rsvalidate_private_state_ancestors), so a runner-owned base would fail the join before enrollment. A root-owned base also keeps the disposable user from replacing the binary.A real-binary smoke check before the secrets. "Smoke-test acpmux as the disposable user" runs the built acpmux as that user with no secret in its env:
host join --hub <dev> --name ci-join-smoke --jsonwithnot-a-join-tokenon stdin. The join takes its lock first, so it creates and checks its state directory and every ancestor before it reads stdin. The step requires exit 2 (join.malformed, before any request), fails on exit 5 (join.store, the directory refusal), and checks that~/.acpmuxis the user's, mode 700. Rehearsed on a Linux runner with a real build: root-owned base, exit 2 and a 700 state directory; the old runner-owned base, exit 5."Remove the disposable user" (
always()) starts from step outputs written beforeuseradd, so it runs even if user creation fails partway. It runskill -KILL -1as that user, which signals exactly that user's processes and nothing else. Then it runsuserdel --removeand deletes the user's base and socket directories. The VM is then discarded.Evidence (the run summary and the only artifact, kept 3 days)
run{id,sha},hubOrigin,name,machineId,teamId,joinTokenId,status{credentialPresent,joinIncomplete,revoked,hubReachable,heartbeatAgeSeconds},consent{screen,control}(onlynoneorgranted; anything else is dropped and fails the check),credentialFileMode,list{machineId,label,revoked,lastHeartbeatPresent},secretScan{home{cmj,cmm,credentialFileCmm},log{cmj,cmm},evidence{cmj,cmm}},revoke{revokedListed,statusRevoked},result,reason.Tests
scripts/cmux-next/machine-join-e2e.test.ts(bun) runs against a fake fetch, a fake sudo and a fake acpmux, with no network and no secret, on virtual time: the clock moves only when nothing else can run. It covers:cmj_,cmm_, JWT, bearer or authorization in the evidence; every child env exactly{PATH}; no secret in argv, URLs or printed lines (masks aside);joinTokenId(never joins);ci-join-*machines.%, CR, LF) and set line by line, and the stack email and password are masked before the sign-in.tests/test_machine_join_e2e_workflow.pypins the scope above: dispatch-only, the feat-cmux-next refusal, the token permissions, the secrets reaching one step through env, no xtrace, the dev origin, the ephemeral runner, thealways()cleanup and evidence check, the root-owned base beforeuseraddwith its ancestor check, the smoke step's place before the mint, its exit handling and its plain-text output, and a single artifact with no cache.ci-guards.yml, the bun test in thepreflight-agent-chatgroup (which sets up bun) and the Python test in thecigroup. The workflow runs the bun test itself too, before it reaches the secrets.Depends on
The acpmux
host joinstdin path and--status --json(already on feat-cmux-next), and the platform's join-token routes (on development). Nothing here waits onmachine-join.sh; a second pass through that script comes later.🤖 Generated with Claude Code
Agent session: agent session