Cloud relay chat: give the machine the person's harness login before the send
cmux cloud start-chat tries the relay first (#35829), but cloud relay-chat (CloudStartSession.bindRelayChat) sent the prompt without the login step that cloud start-session runs before its start. So a chat started from the CLI reached a guest with no Claude login, and production answered "Claude is not signed in here".
Leo: "we have the daemon, we have the token, we have the thing that can just send it over the authentication. So why are you still getting stuck on this? ... just fix it"
Before relay.chat (the person's palette run) and relay.answer (CLI and MCP runs), the relay path now does what start-session does, in the same order:
- It brings up the machine's app link with the session's own connect. A cold machine that no start-session touched therefore gets an owner answer.
- It runs the gated, owned
passHarnessLoginon theCloudMachineSessionthe relay already resolved. - Only then does it send.
The account that asked is checked before the link, again after the link and before the login, after the login, and right before the send. The step reuses the existing credential route, and no field or log line holds a token. The decision log line has verdicts only: link state, owner answer, guest kind, and whether the cache was queried.
If anything other than a usable login comes out of these steps, nothing is sent. A CLI or MCP run gets back a typed answer with delivered: false and the start-session command that recovers.
| After the steps | Code | Sent |
|---|---|---|
| login copied, the machine's own login, or no login to pass (pi) | (relay answer) | yes |
| machine paused or resuming (this entry never resumes) | cloud.relay.machine_not_ready | no; start-chat runs start-session, which resumes |
| the link did not come up | cloud.relay.not_connected | no; start-chat runs start-session, which connects |
a teammate's machine (link answer team) | cloud.relay.login_team_machine | no |
owner answer not read (unknown: no link, or the app server could not read it) | cloud.relay.login_unverified | no |
| any other Claude skip | cloud.relay.login_skipped | no |
the rules refuse it (a headless run with no login choice, --login machine with none there, Release consent) | cloud.relay.login_refused + login_reason; with no choice, the command adds --login none (a bare rerun refuses again), whose chat's Sign in to Claude card signs in | no |
| Claude with no login on the machine, and none cached on this Mac (or the Keychain didn't answer) | cloud.relay.login_missing | no |
| the person declined the copy | cloud.relay.login_declined | no |
| the copy failed | cloud.relay.login_failed, with the copy's fixed text only | no |
| the account changed during the link or login | cloud.relay.login_account_changed | no |
| link plus login still running after 15 s (CLI/MCP only) | cloud.relay.login_pending | no |
Ownership has three states end to end. The app server's cloud.machine.connect answers vm_kind from the Worker's own word: owner and team only on those exact values. It answers unknown for an absent or other value, or for a transient owner read, and that answer is never cached. A machine that is gone or out of reach still refuses the connect. The catalog and the generated app contract gain unknown. On the Mac, CloudLinkSession.callerOwnership reads owner, team or unknown, and only owner grants anything. A team machine the caller made (link owner, guest team) goes the owner way: its own working login sends, and none gives login_missing.
Rust cmux cloud start-chat answers any undelivered cloud.relay.login_* as that typed error with its recovery command (exit 75 for login_pending, else 1), and runs nothing more. Any other undelivered answer (machine_not_ready, not_connected, an app or machine without the relay) still falls back to start-session on that machine.
An honest limit: a cold machine whose link comes up and whose owner answer is owner now gets an owned login and a reply over the relay. If the link can't come up or the owner answer can't be read, the run gets the typed recovery above, not a signed-in reply.
The binding moves out of CloudStartSession.swift (over its file cap) into CloudStartSession+RelayChat.swift. The visible composer (CloudStartChat.runHome) and the daemon_closed failure after 102.7 s are owned elsewhere and aren't touched here.
Changelog
Fixed: A chat that cmux cloud start-chat sends over the Cloud relay now connects to the machine and gives it your Claude login first, or says why it can't and sends nothing, instead of reaching a Claude that isn't signed in.
Testing
The regression (test only, bf5eca799e0) is CloudRelayChatLoginTests/aCLIRelayChatRunsTheHarnessLoginStepFirst. It drives the app's real cloudRelayChat binding and real login step on a fresh running machine. It was red at bf5eca799e0 in cmux-ci step c74bca804c818c3a70835cda.
At the head 614ec929 (tree 9810f479, one merge of feat-cmux-next):
- Mac: cmux-ci step c06d69ce0d3c906899c6719c. CloudRelayChatLoginTests, CloudStartSessionTests, CloudStartChatLoginTests, CloudCredentialTransferTests, CloudRelayChatTests and CloudAppLinkOwnerAnswerTests all passed.
- Linux: run 42153020137. Server
connect_ownerpassed 16/16,cmux-cloud-start-core vm_kindpassed 6/6, andcmux-tui cloud_start_chat::testspassed 39/39. The fullcheck-app-platform.shpassed, including the generated contract and the runtime sync.
CloudRelayChatLoginTests binds the real action with fixture stand-ins for the login step and for the relay's HTTPS transport, and records the order of the steps:
- a cold running machine runs connect, then login (as owner, through the app's own ownership check), then send;
- a cold machine whose connect fails runs connect only, then
not_connected; - cold paused, resuming or unknown-status machines connect nothing and send nothing;
- an account switch while the link waits starts no login and sends nothing;
- a link that answers past the deadline starts no login (
login_pending); - each login code and each ownership state is covered, including the team cases;
- every refusal carries its recovery command;
- no answer field carries the fixture bearer.
Reviews over the merge-base range found and fixed:
- the 40 s deadline;
- a declined copy that still sent;
- the start-chat fallback sending on a login refusal;
- a skipped login that still sent;
- unknown ownership read as team;
- the generated contract;
- start-chat --wait on an auto-paused machine;
- account checks around the link and the send;
- a cached unknown connect_info.
Two reviews (at 7e77e415 and 14c5ed65) blocked on a claim that CloudCallerOwnership lacks Equatable. A raw-value enum with no associated values gets it synthesized, and the exact trees compiled and passed (answered in this PR's comments).
Review (codex gpt-6.1-sol xhigh): SHIP at 614ec929434fa37a041fdbce0b996948f88b5fab.
Observed / Not run
Observed: the fleet runs above. Not run: a live Cloud machine. The real ownership and copy calls are stood in for in these tests; their own suites cover them.
Agent session: agent session
š¤ Generated with Claude Code