cmux-host: hold the relay-first chat socket from bind on (hq#7086)

Merged
TE
20h ago
+995-1

Lane (a) of manaflow-ai/cmuxterm-hq#7086, the relay-first chat path, machine half. Leo: "it should take <30 seconds from starting a thing to having a response on cloud". Asked "So what's the best way?", he approved this design: "OBVIOUSLY".

The Cloud agent in cmux-host now opens one outbound WebSocket to the platform's MachineRelay as soon as the machine is bound, and keeps it open: GET {platform}/v1/cloud/machines/{machine}/relay as this machine's own VM install. A person's chat then reaches the machine over HTTPS and this socket, and never waits for the app's link to the machine. That link is what fails on prod today ("Machine didn't connect", hq#7060).

  • New cloud/relay.rs, a thread with its own current-thread tokio runtime.
    • It mints the bearer with the machine's install key, the same way the GitHub minter does, before each dial. Reconnect backoff is 1 s doubling to 30 s. It pings every 20 s, and a socket silent for 60 s is dropped and dialed again.
    • A chat with no session first runs cmux-tui acp --json new -d --reuse -m AGENT -n relay-<id> --cwd DIR.
    • Then acp --json send --wait --no-steer --prompt-id ID SESSION - runs, with the prompt on stdin. Both run as the work user with a clean environment, the same way the trajectory importer runs acpmux.
    • Events go back up: started {session, prompt_id}, text (agent message chunks), record (raw _acpmux/event with its seq), permission, then done {stop_reason} or error {code, message}.
    • cancel aborts the chat. That drops the child, and the child is killed.
  • Frame checks: agent, ids, session and cwd are checked before anything runs, so no value can reach a flag or a shell. The prompt is never an argument. Nothing logs a prompt or a reply.
  • Lifecycle: role.rs starts the relay in start_live and replaces it on a rebind.
  • Lane (c) handoff: prompt_id and seq let lane (c)'s direct link resume the same acpmux session without a gap or a second turn.

Depends on manaflow-ai/cmux-platform#820, which adds the routes. Until that is deployed, the dial is refused and retried with backoff, so nothing else changes. The app/CLI caller comes in a separate PR.

Tests

cargo test -p cmux-host --lib cloud::relay: 7 pass, covering frame parsing and refusals, command building, event mapping, and final events. cargo clippy -p cmux-host --tests -- -D warnings is clean.

Observed / Not run

Run: the unit tests and clippy above. Not run yet: a live machine holding the socket. That is next, once platform#820 is on staging, using a staging image built from this branch.

Changelog

none

Agent session: agent session

🤖 Generated with Claude Code


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


Summary by cubic

The Cloud agent opens one outbound WebSocket to the platform's MachineRelay as soon as the machine is bound and holds it open, so a person's first chat reaches the machine and never waits on the app's link to the machine (which fails on prod today with "Machine didn't connect"). A chat sent down the socket runs in the machine's own acpmux as the work user; a tail on the session streams its text, records, and permission asks back up, and done carries the send's whole reply, which stands over the streamed text. cancel aborts a chat; dropping a socket ends all of its chats, a dropped chat also runs acp cancel so the daemon doesn't keep the turn going, and kill_on_drop kills the acpmux children.

  • The socket reconnects with backoff (1 s to 30 s), pings every 20 s, and is dropped if silent for 60 s.
  • Frame fields are validated before anything runs, so no value can reach a flag or a shell; the prompt is never an argument and nothing logs a prompt or a reply.
  • Failures carry acpmux's exit codes, naming timeouts, missing sessions, and permission denials.
  • role.rs starts the relay on bind and replaces it on a rebind, closing the old socket's chats at once.
  • A repeated chat id is refused instead of running a second send.
  • A chat's output is drained past its cap so a verbose child never blocks; a reply past the cap ends as done with reply_cut and no reply.
  • Chat processes run with the work user's own supplementary groups, each session is named after its whole chat id, and a socket's frames wait in a bounded queue so a slow socket holds acpmux back instead of queueing.
  • The relay refuses to start on kernels without pidfd_open: the agent blocks SIGCHLD and the thread inherits that mask, so a chat's exit could never be seen.
  • Needs the platform routes from manaflow-ai/cmux-platform#820; until deployed, the dial is refused and retried with backoff.
  • prompt_id and seq let a follow-up direct link resume the same acpmux session without a gap or a second turn.