cmux host share: one agent chat for a teammate, through a filtered proxy (chat multiplayer)
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],unshareandshares(remote_host/shares.rs).- Sharing needs a consented
agentsgrant, 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
allowlist cannot be shared (full_access): that login is the owner's own trust, not a teammate's.
- Sharing needs a consented
- The
agentsscope connects. A grant withoutshellis never spliced raw. It runs throughremote_host/agent_proxy.rs:- It binds the connection with an
agent-session-proxy-bindpreface 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
surfacemust be shared with that login in the current generation (agent_session.not_sharedotherwise). - A guest gets only observe, events and detach.
- It strips any client
authoror_metaand adds a_host_authormarker. The daemon checks the marker against the bound author, so an unbound connection cannot claim one.
- It binds the connection with an
- Admission: a login that has a share always goes through its grant, even if it is also on
allow. Shares are checked beforeallow, so the author is always stamped from WhoIs. - A share is pinned to the chat it was given for.
cmux host shareasks the daemon which agent session the surface shows (agent-session-of, owner only) and stores it in the share. The proxy stamps_host_sessionwith 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-childrenstream, and no child session changes. - It never sends
composerrecords (anyone's draft text), neither in the attach replay,eventspages nor live records.
- A bound attachment stamps
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,-eventsand-detachon that surface, plus-promptand-cancel. - Commands, as a guest: observe, events and detach only.
- Data: the chat's transcript records only (whatever
kindsit 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'stailnet:<login>author, the attach and the session watch included.
A teammate cannot reach any of these:
- the host's permission asks:
agent-session-permissionis 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 haspermission.owner_only(design section 7); - other chats, including the shared chat's descendants:
agent-session-childrenis 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-typingandagent-session-vieware 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:
composerrecords are dropped from the replay,eventspages and live records; - terminals, panes, workspaces and layout;
- files and resource or stream envelopes;
- daemon commands outside the six forwarded
agent-session-*verbs, such assubscribe,list-workspaces, shell and browser. The attach client'sidentifyandset-client-infoare 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,seqand that summary (no top-levelpeer); - a client-supplied author: the proxy strips
authorand_meta, the daemon accepts only the bound author, and an unbound_host_authoris 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
allowlogin (refused), andcmux host allowfor a login that has a share of this boot or an older one (refused, namingcmux 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, whichconfig::saverequires as an argument. The lock isstd::fs::File::lock(flock on Unix, LockFileEx on Windows), so it holds on every host; - a connection that keeps an old grant:
cmux host grantreplacing a live grant with other scopes restarts the listener, asrevokedoes, so ashellsplice or anagentsproxy 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
allowlogins 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_authoris refused; -
a shared login on
allowstill goes through its grant; -
cmux host allowis refused while the login has a share, a stale one included, and allowed again afterunshare(full_access_is_refused_while_the_login_has_a_share); -
teammate pages drop
composerrecords, 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
allowwhile 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