Skip to content

cloud: derive trigger identity from trusted scope - #31133

Merged
teamleaderleo merged 4 commits into
feat-cmux-nextfrom
lane/cloud-trigger-identity-fallback
Nov 1, 2026
Merged

teamleaderleo merged 4 commits into
feat-cmux-nextfrom
lane/cloud-trigger-identity-fallback

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

Cloud platform workspace machines receive a root-owned trusted turn scope but may not have the legacy Cloud bind record that normally creates an identity receipt. In that state cmux host cloud identity returned identity_unavailable, so trigger delivery could never authorize.

When the trusted scope exists and no bind is in progress, the host now creates a per-scope install key and receipt, then serves the resulting identity over the existing root-owned socket. Existing bind enrollment remains authoritative and unchanged. This lets the trigger principal bind to the platform scope while preserving key freshness across cloned scopes.

Follow-up probe: trigger-preflight must read back a delivered turn on a real Cloud machine.

Agent session: agent session


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Cloud machines with a trusted turn scope but no legacy bind record previously returned identity_unavailable from cmux host cloud identity, so trigger delivery could never authorize. The host now derives identity from the trusted scope when no bind is in progress.

  • The turn-scope file must be a root-owned 0600 regular file in a root-owned protected directory; malformed or untrusted files fail startup, while a missing file leaves existing machines unchanged.
  • Scoped enrollment keeps its install key and receipt in separate files, so the key and receipt from normal bind enrollment are never rotated; bind-in-progress machines refuse the scoped path until normal enrollment completes.
  • Only the three non-secret CMUX_PLATFORM_* fields are handed to the daemon; secret platform values are never inherited.

Written for commit d4438ac. Summary will update on new commits.

View guided diff Turn on auto-fix

@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review d4438ac: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr31133 -f ref=lane/cloud-trigger-identity-fallback -f platform=macos -f backend_mode=local

red: cmux-next checks (god files, concurrency, crash safety, l10n); no finished base run on fe90490 to compare; do not merge until green; see lane-rules

Shipped in:

The feat-cmux-next push run https://github.com/manaflow-ai/cmux/actions/runs/41330433296 at e0fa06f failed: cmux-next crash ends the app.
Those jobs last passed at 45b916e. This pull request is one of 5 merged since: #31133, #30400, #31145, #31066, #31069.
If the failure is in your change, fix forward on feat-cmux-next. Pull requests run only the tiers their paths reach (docs/ci/cmux-next-tiers.md); label a batch PR full-ci to run them all before merging.

@github-actions github-actions Bot added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Nov 1, 2026
@blacksmith-sh

blacksmith-sh Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Found 6 test failures on Blacksmith runners:

Failures

Test View Logs
/tmp/tmp7nsyaf48/summary-missing.json (live):/summary-missing.json (live): View Logs
/tmp/tmps5eh91co/summary-fail.json (live):/summary-fail.json (live): View Logs
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows/
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows
View Logs
[new_workspaces] the window draws no frames/[new_workspaces] the window draws no frames View Logs
[tabs] debug.tab_switch timed 0 of 40 switches/
[tabs] debug.tab_switch timed 0 of 40 switches
View Logs
[tabs] the window draws no frames/[tabs] the window draws no frames View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (claude-opus-5-5 medium, cloud-reviewer): BLOCK at 4793f86

I read the PR's own commit, 4793f86 ("cloud: derive trigger identity from trusted scope", +27/-1 in identity.rs and linux/mod.rs). The two commits under it are #31011's turn-scope loader, which I already reviewed as a SHIP at d424758. I also read the rest of identity.rs, ensure_install_key in cloud/client.rs, FileStore and the two install-key paths in cloud/role.rs, and the platform code that consumes the identity (readGuestIdentity, then mintGuestTriggerToken bound to guestKey).

[blocking] On an already-bound machine, the fallback overwrites the install key the server knows.

  • Where the fallback runs. reply_identity calls scoped whenever enrolled() fails, which includes every transient case: no settled instance yet at boot, a metadata miss, or a receipt mismatch. scoped refuses only when bind.json exists. That is the pending bind token, which is removed once the bind completes. It doesn't check bound.json or the existing receipt, so an ordinarily bound machine that also has a turn-scope file takes this path.
  • What it overwrites. scoped calls ensure_install_key(store, machine, …) with the logical machine id, against the shared INSTALL_KEY_FILE (/var/lib/cmux/install/key.json). On a bound machine that file is keyed by the provider instance id, so the ids differ, and ensure_install_key generates a new key and replaces the file. record then rewrites the shared RECEIPT with instance_id = machine.
  • Effect. The enrolled key is gone. After that:
    • enrolled() fails permanently on the instance mismatch;
    • on the next agent restart, resume_bound sees rotated and stops reporting ("not reporting with a key the server does not know");
    • bind_now and resume_bound would rotate the file back by instance id whenever they run, so the two paths keep replacing each other's key.
      This loses enrollment state and leaves the machine unreported until it is re-bound.
  • Fix.
    • Refuse the fallback when bound.json or a valid enrolled receipt exists, not only when bind.json does.
    • Keep the scoped key in its own file (for example /var/lib/cmux/install/scope-key.json and its own receipt), never in INSTALL_KEY_FILE or RECEIPT. The PR description says "per-scope install key/receipt", but the code reuses the shared ones.
  • Test. Add one where a bound machine with a scope file, during a failed enrolled(), keeps its key file byte-for-byte.

[blocking] The new identity path has no tests. The commit adds no test, and nothing in src/identity/tests.rs or tests/ calls scoped. For a security-sensitive identity source it needs at least:

  • no scope file gives no identity;
  • an untrusted scope (wrong owner, mode or symlink) gives no identity. turn_scope::environment handles this, but nothing asserts the identity path depends on it;
  • an existing bind.json refuses;
  • an existing bound.json or enrolled receipt refuses and leaves the key unchanged (the issue above);
  • repeated queries return the same key and identity;
  • the private key never appears in the receipt.

What the platform does with it. The platform reads {machine, instanceId, guestKey} through an authorized provider exec on the physical VM. It mints a short-lived guest trigger token bound to guestKey, and the guest daemon accepts the token only if its own fresh identity has the same key. The receipt never leaves the machine, holds only the public-key hash, and grants nothing by itself. A reused key matters only if the same disk runs twice, which is the clone case below.

Other checks

  • Trusted scope only: OK. The machine id comes only from turn_scope::environment: a root-owned regular file, 0600, with no symlink followed and a strict field allowlist. A non-root user can't plant one. Any loader error refuses with a fixed message and no file content.
  • Files: OK. Writes go through FileStore::write, a temp file beside the target at 0600 then a rename, so they're atomic and at fixed paths. The private key goes only into the key file; it never reaches the receipt, logs, argv or the environment.
  • Concurrency: OK within the host. reply_identity runs on the host's single loop. The cross-path race with cloud/role.rs is the blocking issue above.
  • Swift: none touched.
  • CI: the PR's cargo fast tier, clippy, rustfmt and rust-workspace checks are green. I couldn't run cargo test -p cmux-host myself at this head, but the commit adds no test that would exercise the new path.

Follow-ups:

  • [follow-up] The module header says "Files alone never establish freshness: the host answers only after its main loop has observed provider metadata." scoped answers from files even when no instance has been observed, and sets instance_id to the logical machine id. A disk clone of a scoped machine therefore answers with the same identity and key, and accepts guest tokens minted for the original. If that's intended for platform-provisioned machines (where the logical id is the authority), update the header. Otherwise, require an observed instance and store it in the scoped key record, so a clone regenerates its key the way enrolled does.
  • [follow-up] Each identity query rewrites the receipt. Skip the write when the receipt is unchanged.
  • [follow-up] scoped returns instance_id = machine. The platform accepts any non-empty value, but a reader can't tell a scoped identity from an enrolled one. Consider a distinct marker.

🤖 Generated with Claude Code

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (claude-opus-5-5 medium, cloud-reviewer): SHIP at d4438ac

This is a delta review against my BLOCK at 4793f86. The new commit is d4438ac, "cloud: isolate scoped identity enrollment state", in identity.rs and identity/tests.rs. I read it along with every reader of the install directory: enrolled(), record(), ensure_install_key, bind_now and resume_bound in cloud/role.rs, and cloud/receiver_files.rs.

Prior blocker: fixed.

  • Every bound or enrolled state refuses. scoped() now refuses when any of bind.json, bound.json or the legacy identity.json receipt exists. It checks with present() (symlink_metadata), which tests only whether the path exists, so the content doesn't matter. A machine that is bound but whose receipt is missing still refuses on bound.json. One whose receipt is corrupt refuses on the receipt itself. A pending bind refuses on bind.json. The legacy path is therefore never reached.
  • The scoped state lives in its own files. The key goes in /var/lib/cmux/install/platform-scope-key.json and the receipt in platform-scope-identity.json, both private constants in identity.rs. ensure_install_key is no longer called. Nothing outside scoped() reads these files: enrolled(), record(), bind_now, resume_bound and receiver_files read only key.json, identity.json, bound.json and the WireGuard key. The receiver's hard-link and permission checks are per file, so the new files in the same directory don't affect them. The shared files can't be rotated or misread through this path.
  • Same file handling. Both new files are written through FileStore::write: a temp file beside the target at 0600, then a rename. That's atomic, and it replaces a path rather than following it. Reads use the same store.read as the legacy identity files. The private key goes only into the scoped key file; the receipt holds only the public-key hash. A scoped key file recorded for a different machine id fails closed and is never rotated.
  • CI. At this head the PR's cargo fast tier, clippy, rustfmt and rust-workspace checks are green.

Follow-ups:

  • [follow-up] The new test scoped_identity_refuses_bound_machine_without_rotating_legacy_key doesn't exercise the fix. It sets up no scope file, so turn_scope::environment returns an empty list and scoped() exits with "scope has no machine id" before it touches any key. It passes at 4793f86 too. Write a valid root-owned scope file into the test's Paths so the refusal comes from the bound check. Also add:
    • a scoped machine with no legacy state gets an identity and a stable key across queries;
    • a scope file that is missing or untrusted gives no identity;
    • a mismatched platform-scope-key.json refuses.
  • [follow-up] Recovery: once platform-scope-key.json names a different machine, the machine has no identity until someone removes the file. That fails closed, which is right, but it needs a documented recovery step or an automatic re-key when the scope's machine id legitimately changes.
  • [follow-up] Still open from 4793f86:
    • scoped() answers from files before any instance has been observed, so a disk clone shares the key; either update the module header's freshness rule or bind the key to an observed instance;
    • each query rewrites the receipt;
    • a scoped identity can't be told apart from an enrolled one.

🤖 Generated with Claude Code

This branch was previously deployed

3 inactive deployments
artifacts — d4438acb Deployed Nov 1, 2026 by teamleaderleo via add the Windows daemon to the tree #96914
sentry-dsyms — d4438acb Deployed Nov 1, 2026 by teamleaderleo via upload cmux-tui dSYMs to Sentry #96914
ci-routing-agent — d4438acb Deployed Nov 1, 2026 by teamleaderleo via cmux-tui tree preflight #96914
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant