cmux daemon: bind a link share's guest to the shared chat

Merged
TE
9d ago
+724-124

Part 2 of 2. Part 1, #26750, landed as 382bbd6e. This branch is rebased onto it, so the diff here is only the guest binding (+724 / āˆ’124).

Leo (2026-10-28), on T3 Code's "Share with guest": "we want to be having, be able to do this kind of flow for CMux multiplayer ... we can work on the agent chat multiplayer."

The flow: the owner creates a link that works once and expires in 24 h. A guest opens it in a browser, types a name and watches the chat live. Every message the guest sends waits for the owner's approval. Inventory and plan: manaflow-ai/cmuxterm-hq#4766. Design: chat-multiplayer.md section 4.2. This is part 2 of 2: the guest binding.

What it does

  • Bind. agent-session-proxy-bind {link_guest: {share_id, guest_id}} uses the same checks as a teammate's bind: a same-user unix connection, not a remote relay, the connection's first line only, no _host_ markers. A bind may name author or link_guest, never both.
    • The guest must belong to a live share and still be active.
    • The daemon binds author {id: "guest:<share_id>.<guest_id>", name, approval: "owner"}.
    • The binding is stored in the same proxy_authors map as a teammate's, so everything that filters a teammate filters a guest the same way: transcript kinds only, no composer drafts, no descendants, the cut session summary, no presence events, no permission answers.
    • After registering the connection, the bind checks the guest again. If the share or guest ended in between, the bind unregisters it and answers share_ended.
  • Verbs. A bound guest may send agent-session-attach, -observe, -events, -prompt, -typing, -view and -detach. Anything else, including every non-agent-session command, gets agent_session.scope_not_granted.
    • attach is allowed because observe makes a read-only attachment, and a read-only attachment refuses prompt and typing (agent_session.read_only).
    • cancel is not allowed: a guest never cancels the owner's turn.
  • Every frame is re-checked: the share is still live, the guest is still active, any _host_author or _host_session matches. The daemon then pins the frame to the share's session and to the surface that shows it now, whatever surface the frame named.
    • After a restart renumbers surfaces, it finds the surface that shows the chat.
    • A detach ends this connection's attachment to the shared chat, on whatever surface the attach resolved to.
  • Prompt. A guest prompt carries client_id (1 to 128 bytes). The daemon sends it to acpmux with prompt id g- + base64url(SHA-256(share_id, guest_id, client_id))[..22], so acpmux's prompt ledger runs a retried prompt only once.
    • The prompt carries the author with approval: "owner". acpmux holds it for the owner once the mp-hold PR lands. Until then acpmux refuses a guest: id, so this fails closed.
    • A guest's typing and view now carry its author, so acpmux counts them as the guest's presence. A teammate's typing and view stay refused, as before.
  • Ending. Whatever ends a share or a guest closes its connections at once: a revoke, a kick, the expiry timer, or a verb, bind or frame that notices the expiry first.
    • Every registry change records what stopped being live, and settle() closes those connections right after.
    • A guest's live stream also checks its share and guest on every record, and ends with share_ended.
    • One one-shot timer (apps::timer::Timers) is set for the next active share's expiry. The expiry is read and the timer replaced under the shares lock, so a racing revoke can't cancel a timer a create just set. There is no polling, and the timer thread stops when the registry is dropped.
  • God-file ratchet. media() moved out of agent_session_attach.rs into agent_session_media.rs unchanged, so the file still shrinks.

Risk

risk:high: socket policy. This adds a new kind of bound connection, so a guest's whole reach is the seven verbs above on one chat, and the tests pin that list. Fail-closed points:

  • a guest id that does not parse is refused;
  • an ended share or a kicked guest gets agent_session.share_ended;
  • a frame naming another chat or author gets not_trusted;
  • a chat no longer shown on any surface gets not_shared.

Not done here:

  • persisting closed when the chat closes. Today a closed chat refuses guest frames (not_shared), but the share is not marked closed; I found no clean close hook.
  • the CLI (design PR 6).

Tests

cargo test -p cmux-tui-core --lib shares::tests (11 tests):

  • a guest's attach, prompt, typing and view reach acpmux carrying its author, its attach asks for transcript kinds only, and its prompt id is the derived one;
  • every other verb, agent-session-of, the share verbs and plain daemon commands get scope_not_granted; a forged _host_author or _host_session gets not_trusted;
  • kicking one guest closes only that guest's connection; a revoke closes the rest;
  • the expiry job closes a bound guest and leaves no timer armed;
  • an expiry first noticed by a list closes the guests, and a guest's stream stops at its next record;
  • concurrent creates and revokes leave the timer on the earliest live expiry;
  • a revoke run between a bind's check and its registration leaves nothing bound;
  • a detach on a renumbered surface ends the attachment;
  • a kicked guest's bind and a late bind get share_ended;
  • a bind from a remote relay or WebSocket binds nothing; a bind naming both an author and a link guest gets bad_request.

Results from cmux-ci are in the comments.

Changelog

none

Agent session: agent session

šŸ¤– Generated with Claude Code