cmux host share: one agent chat for a teammate, through a filtered proxy (chat multiplayer)

Merged
TE
10d ago
+1021-25

Leo, 2026-10-26: "we can still do the regular chat multiplayer". Earlier: "also chief chats we need multiplayer". Relayed: "for the agent chat side, we should be able to just... do stuff. unless lawrence is super actively workin on stuf rn"

Design: https://github.com/manaflow-ai/cmuxterm-hq/pull/4389, section 2 (who can join, enforcement) and section 5 (drafts). This is teammate-access slice 3: the agents scope through a filtered proxy, plus a per-chat share.

Split: the daemon half (bind, the per-frame author and pinned-chat check, stamping, the teammate filters, agent-session-of, and the spec text) landed as #25456 (squash 3a06bbbf). This PR is the host proxy, the share list, the CLI and their tests: +1020/āˆ’25 across 12 files.

What

  • cmux host share <surface> <login> [--role member|guest], unshare and shares (remote_host/shares.rs).
    • Sharing needs a consented agents grant, confirmed by a person at a terminal (the grant rule).
    • A share is bound to the daemon's boot generation, so a share expires when the daemon restarts.
    • Changing a share restarts the listener, which closes existing links.
    • A login on the full-access allow list cannot be shared (full_access): that login is the owner's own trust, not a teammate's.
  • The agents scope connects. A grant without shell is never spliced raw. It runs through remote_host/agent_proxy.rs:
    • It binds the connection with an agent-session-proxy-bind preface on the same-user socket. The daemon accepts that preface only from a same-user unix client, once per client, before any attach.
    • It passes only agent-session-attach|observe|events|prompt|cancel|detach.
    • Each command's surface must be shared with that login in the current generation (agent_session.not_shared otherwise).
    • A guest gets only observe, events and detach.
    • It strips any client author or _meta and adds a _host_author marker. The daemon checks the marker against the bound author, so an unbound connection cannot claim one.
  • Admission: a login that has a share always goes through its grant, even if it is also on allow. Shares are checked before allow, so the author is always stamped from WhoIs.
  • A share is pinned to the chat it was given for. cmux host share asks the daemon which agent session the surface shows (agent-session-of, owner only) and stores it in the share. The proxy stamps _host_session with that session on every forwarded frame, overwriting anything the client sent. The daemon refuses the frame (agent_session.not_shared) unless the surface shows that chat now, so a tab rebound to another chat is not shared, and an existing attachment ends when the tab's session changes. The consent prompt names the chat. A share written before this change has no session and never matches: share it again.
  • The daemon (cmux-tui-core, in #25456):
    • A bound attachment stamps _meta.acpmux.author {id: "tailnet:<login>", name, login, node} on every acpmux call. These are the names in hb-acpmux's #25268.
    • It follows no descendant chats: no agent-session-children stream, and no child session changes.
    • It never sends composer records (anyone's draft text), neither in the attach replay, events pages nor live records.

What a teammate can reach, exactly

A teammate is a tailnet login with a consented agents grant (made at a terminal on the host) and at least one share. Through cmux host serve, that login can reach:

  • Sessions: only the agent chat a share names: one tab surface in the current daemon boot, only for that login, and only while that surface still shows the chat the owner consented to.
  • Commands, as a member: agent-session-attach, -observe, -events and -detach on that surface, plus -prompt and -cancel.
  • Commands, as a guest: observe, events and detach only.
  • Data: the chat's transcript records only (whatever kinds it asks for, the daemon pages and streams ["transcript"]: no raw wire records), its session summary and its pending asks, as the attach page and live records carry them. Every acpmux call is stamped with the login's tailnet:<login> author, the attach and the session watch included.

A teammate cannot reach any of these:

  • the host's permission asks: agent-session-permission is refused by the proxy and by the daemon for a bound author, so a teammate can neither allow nor deny a tool. Answers come once acpmux has permission.owner_only (design section 7);
  • other chats, including the shared chat's descendants: agent-session-children is refused and no child changes are streamed;
  • another chat on the shared surface: the share is pinned to the consented chat's session, and a surface rebound to another chat is refused (agent_session.not_shared);
  • typing, view or presence: agent-session-typing and agent-session-view are not forwarded by the proxy and refused by the daemon for a teammate, and a teammate gets no presence set or event. Teammate typing and view come in a follow-up;
  • anyone's drafts: composer records are dropped from the replay, events pages and live records;
  • terminals, panes, workspaces and layout;
  • files and resource or stream envelopes;
  • daemon commands outside the six forwarded agent-session-* verbs, such as subscribe, list-workspaces, shell and browser. The attach client's identify and set-client-info are answered by the proxy itself, from the bind reply alone (protocol and boot), and never reach the daemon;
  • other users' sessions;
  • the shared chat's folder, parent and child links, tags, tools, rules, policy, MCP servers, peer or pull requests: a teammate's attach page and live changes carry the chat's summary cut to what the chat shows (name, title, status, turn, queue, asks, model), and a live change keeps only sessionId, kind, recordKind, seq and that summary (no top-level peer);
  • a client-supplied author: the proxy strips author and _meta, the daemon accepts only the bound author, and an unbound _host_author is refused;
  • binding an identity: only a same-user unix client can bind, once, before any attach. WebSocket and remote clients are refused;
  • a share that outlives its daemon boot;
  • a raw daemon splice: a login with any share always takes the proxy, even with shell;
  • a share for a full-access allow login (refused), and cmux host allow for a login that has a share of this boot or an older one (refused, naming cmux host unshare): a shared login is always admitted through its grant, so full access would otherwise silently not apply;
  • a share that comes back after it was revoked: consent applies under the config lock to the config as it is then, and every writer of remote.json (enable, disable, allow/deny, grant, revoke, share/unshare) holds that same lock, which config::save requires as an argument. The lock is std::fs::File::lock (flock on Unix, LockFileEx on Windows), so it holds on every host;
  • a connection that keeps an old grant: cmux host grant replacing a live grant with other scopes restarts the listener, as revoke does, so a shell splice or an agents proxy admitted under the old grant is closed;
  • a share confirmed across a daemon restart: the boot is read before the prompt and compared after it, so a surface number from the old boot is refused;
  • anything the daemon serves beyond the six verbs, even if a proxy bug forwarded it: the daemon refuses every other frame of a bound link (#25456). The bind is the link's first line, so the link carries no earlier subscription.

Size

+1020/āˆ’25, about 370 of them tests in teammate_tests.rs. That is over the ~800 cap but under ~1,200. What's left is the proxy, the share list and its consent under the config lock, which together decide who reaches what. Moving their tests out would land the boundary untested. The localization (67 lines) is the proxy's and the share CLI's own errors and prompt, and the boundary's error paths need it.

Risk

Medium: this is a new remote surface. These keep it contained:

  • the proxy is deny-by-default (an allowlist of 6 verbs, each surface checked against shares on every frame and pinned to the consented chat);
  • the author comes from WhoIs through a same-user socket preface, never from network JSON;
  • the owner's local panes and full-access allow logins behave exactly as before;
  • a share needs a person at the terminal;
  • shares end on daemon restart.

Not here: steer, queue edit/remove, view, typing and presence for a teammate. Those come in a follow-up.

Tests

Run through cmux-ci test --setup rust (Linux):

  • cargo test -p cmux-tui --lib remote_host: 28 passed;
  • cargo test -p cmux-tui --lib localization: 14 passed;
  • the daemon side's tests are in #25456;
  • cargo clippy -p cmux-tui --all-targets: clean. Base's only warnings are in the acpmux crate.

New tests cover:

  • a guest only observes;

  • shares expire on a daemon change;

  • the proxy rewrites the author;

  • an unbound _host_author is refused;

  • a shared login on allow still goes through its grant;

  • cmux host allow is refused while the login has a share, a stale one included, and allowed again after unshare (full_access_is_refused_while_the_login_has_a_share);

  • teammate pages drop composer records, both in the replayed attach page and live;

  • a teammate hears no descendant's change, neither one the session watch named nor one started later, while an owner on the same fake answers hears both (a control showing the fixture is effective);

  • a WebSocket connection cannot bind a proxy identity, through handle_connection_frame, the dispatch the WebSocket listener runs;

  • a teammate's history page (agent-session-events) drops drafts an owner's keeps;

  • a teammate's view of the chat's own summary is the allowlist only;

  • a teammate that disconnects while acpmux answers the attach still has its author on the session watch (a gated fake attach answer);

  • a confirmed share is refused when the daemon boot changed, the grant was revoked, or the login was put on allow while the prompt waited (shares::applied).

  • a changed live grant closes open connections and admits with the new scopes; the same scopes, a first grant or a grant after a revoke do not (a_changed_grant_closes_the_open_connections_and_admits_with_the_new_scopes).

CLI only, so there are no screenshots.

Agent session: agent session

šŸ¤– Generated with Claude Code