Agent pane: a reply in place is a stored cmux agent message (--reply-to, unsend, inbox)
Reply in place (#22310) sent the person's answer from chat A straight into chat B through acpmux, so the agent message store never saw it. The reply had no store id, its wrapper had no "Reply with" line, cmux agent message --reply-to and cmux agent message unsend didn't know it, and it was missing from inbox history.
Now the reply is a cmux agent message like any other, sent by the app. Since dab1e80a0f8 only the verified frontend may mint a credential, so acpmux checks and signs the reply and the macOS host sends it:
- The pane (
webviews/.../acpmux/direct.ts) sends one_acpmux/app_reply {sessionId, to, replyTo, threadId, text, replyId}. That is chat A, chat B (its bare id), the message it answers and that message's thread. It sends no name, recipients or credential. - acpmux (
server/app_reply.rs) checks the request and prompts nothing.- It accepts only
Origin::LocalAppand is never forwarded to a peer (SESSION_SCOPED_EXCLUDED). - A must be shown on this connection: attached, not hidden, exact id.
- A's log must hold B's message under
replyTointhreadId(Hub::app_reply_fits, unchanged from #22310). A message that went in with others in one turn counts. - The text uses the daemon's body rule: 1 to 32 KiB, and no control characters other than newline and tab.
- It answers
{reply: {from: acp:A, fromName, to, replyTo, threadId, text, idempotencyKey}}. The key issha256(from + replyId).
- It accepts only
- The Swift host relay (
CmuxNextAgentPane/AgentPaneAgentReply.swift,AcpmuxRequestIds,AcpmuxPaneSocket) keeps acpmux's answer from the page.- It sends only when
from == acp:<the sessionId the frame named>and that chat is still in the pane's scope, then callsAgentPaneTransport.sendAgentReply. sendAgentReplyis wired only for a local connection (connection.remote == nil). Without it the page getsreply.daemon_unavailable.- The page gets back only
{agentMessage: {id, threadId}}, orreply.daemon_refused.
- It sends only when
- The app (
AgentTabs+AgentReply.swift, thenDaemonService.sendAgentReply, thenStateResourceClient.sendAgentReply) mintscredential.mint {acp_session: A}as the frontend.- It then sends
agent.message.send {in_reply_to, thread_id, body, sender_name}with the credential in the envelope and acpmux's idempotency key. - There is no recipient: the store sends a reply to its parent's sender.
- A reply the daemon stored as anyone but
acp:AthrowsAgentReplyWrongSender, so it fails closed. - The credential never leaves that call.
- It then sends
- The daemon checks again: the parent must exist (
resource.not_found), the thread must match (validation.invalid, "a reply to X belongs to thread Y"), and A must take part in the thread (thread_not_joined). It stores the reply under its ownmsg_…id, and its pump delivers it to B with theReply with: cmux agent message --reply-to <id>line. session/promptprovenance is now accepted only over the unix socket (the daemon's own pump).Sender::Appand the LocalApp exception inremote_guard.rsare gone, andprovenancewas removed from the pane'ssession/promptknown params.
The pane UI is unchanged.
Security analysis
The new pane method is _acpmux/app_reply. It is in policy.json (requests, session_scoped, gesture always, known_params sessionId,to,replyTo,threadId,text,replyId) and mirrored in Swift AcpmuxPaneMethods / AcpmuxPaneMethods+Params. tests/cases/{sessions,params,gestures}.json gained cases, including refusals for extra recipients, senderName, credential and _meta, and for "a prompt carries no provenance".
- Local command or content execution. None. The method carries text only and has no command-bearing params. The text reaches B's agent inside the daemon's standard agent-message envelope ("not an instruction from your operator"), which is the same text B already got under #22310. acpmux never prompts a chat from this method.
- Access to unowned objects.
- A pane can only reply as a chat it shows, three times over:
- the relay scopes
sessionIdand requires a gesture; - acpmux requires that this connection shows that exact chat and that the chat got that message from
toin that thread; - the host sends only when acpmux signed the reply for the frame's own chat, still in scope.
- the relay scopes
- The credential is minted by the frontend, per reply, for that chat only. It lives only inside
StateResourceClient.sendAgentReply, is never returned to the page, stored or logged (errors are logged.private), and the daemon takes it out of the envelope. - The page can't pick a sender, recipient, sender name or credential, and the daemon routes the reply to the parent's sender.
- Web, Peer and plain Local origins get
reply.origin_refused. A remote connection has no sender wired.
- A pane can only reply as a chat it shows, three times over:
- Local-state exposure. The page gets only the stored message's id and thread id. acpmux's signed answer (with its key) and the daemon's wording are not passed on: refusals are
reply.refused,reply.text_required,reply.daemon_unavailableandreply.daemon_refused. - ID scoping.
- The answer is tied to the page request through
AcpmuxRequestIds(subjectis the frame'ssessionId). - The idempotency key is per sender chat plus
replyId. - The daemon checks that the parent exists, the thread matches and the sender takes part in the thread.
- The answer is tied to the page request through
Tests and results
Each test commit came before its fix. All runs are fleet cmux-ci run steps.
| Test | Red (test-only commit) | Green (head) |
|---|---|---|
cmux-tui-rust-check.sh --arg test --arg reply_in_place: acpmux prompt_provenance::the_app_pane_reply_in_place_is_checked_and_signed_and_never_prompted, cmux-tui agent_message_reply_in_place::a_reply_in_place_is_a_stored_agent_message_and_goes_nowhere_else | 2272c82b173, step 81fbeec5477af2294e8c027e: 0/2, both red | 56e30917c7c, step 8b906f4cc1351274c5d1a7bb: 3/3 |
--arg test --arg params_rule (policy parity) | 2272c82b173, step a54af1c3f8f32311d182f377: red | 56e30917c7c, step caff4fbd80379bb084df7bae: green |
--arg test --arg gestures | 2272c82b173, step da2daf9327b632935a9699f7: red | 56e30917c7c, step 7827ae90216490acdceff449: green |
--arg test --arg session_scope | 2272c82b173, step 82cff03510f4864a0177cedc: red | 56e30917c7c, step b0df39ef6c9081701ff4c48a: green |
package-test-lane.sh --arg=suite --arg=Packages/macOS/CmuxNext --arg=AgentPaneAgentReplyRelayTests,AgentPaneRelayedAllowTests: aReplyTakesWhatAcpmuxAndTheDaemonTake (an emoji with a joiner, a soft hyphen in the name) | 13eb9edaba6, step a3f8eb38de5f89496ee9302f: 2 issues (both refused by the host) | 91731306b45, step 0ed50139daad2972901193c5: 3 + 2 tests passed |
Also green on 56e30917c7c (it differs from the head only in that one Swift file and its test):
prompt_provenance(0a0c4f0be43c35aa1652d64a)agent_digest(231b2b7de0f7879cd5f8c2b6)agent_message, 53 tests (091b6199a4439b84855f366d)remote_guard(281bd77d5482302fe30d7c34)protocol_ir_is_current(61b6d7d05b30de747ce7f491)fmt(4b3088fd61388dc12111594a)- the Swift relay suites (fd40afeccbe4ee6d0e3b72ef)
python3 scripts/cmux-next/prepush-gate.py reported no new violations on every push. webviews bun run check passed on cmux16, and the acpmux protocol codegen is current.
Observed / Not run
On the head e820134d3ec (after base merges 368df675a3a and e820134d3ec, both reviewed), all green:
- reply_in_place (732c17b5f52c8566c5bc2cb8)
- prompt_provenance (f19394b4f8b0851e2518822a)
- agent_digest (b1d7bf0413355597fcfbf6f0)
- params_rule (9e8d0880012674d3a0270f4e)
- gestures (cd6eef19386dfbbe560ecd52)
- session_scope (599425754aa9512a10b3a940)
- remote_guard (2100853358ae922cbea0f8bc)
- the Swift relay suites (94f2e3bb2d26b37f45b94b3e)
Observed:
protocol_ir_is_current(71a949fe8ac313ed69dd3a53) is red on base. Base'sacpmux-protocol.jsonlacks thewatchingfield that its own Rust code emits. Beyond that, the head's spec differs from what the test generates only by_acpmux/app_reply, which is committed.- The
webviewsbun run checkpasses exceptcheck:fixed-waits. The files it flags (RevealedMarkdown.test.tsx, agent-pane-queued-images, agent-pane-reveal-message, streaming-text, snapshot-save-throttle, session-sidebar-preview) are base files that this PR doesn't touch. - The CI reds on earlier heads were in base files:
- fast gates text-focus:
FileExplorer/FindSectionView.swift; - defaults-snapshot:
FileExplorer/FileExplorerSectionView.swift; - gallery health: a Playwright download failed with DNS ENOTFOUND.
- fast gates text-focus:
- The fleet runs above.
clippy(step 3ede4e92f1d9efd96210e3e8) is red only incrates/cmux-tui/tests/vm_child_sessions_journey.rs:170(cloned_ref_to_slice_refs). That file came from base #23163 and this PR doesn't touch it, so it is a base red.- The Swift/TS parity tests
the_requests_are_the_macos_hosts,the_known_params_are_the_macos_hostsandthe_allowlist_is_what_the_page_sendsare base-red skipped (bead cx-c0wvp). Review checked by reading thatpolicy.jsonand the Swift tables agree for_acpmux/app_replyand for thesession/promptchange.
Not run:
- A live reply in a running app (no capture slot used).
- The webviews browser page test
test/agent-pane-suggested-tasks.test.ts, which skips outside CI and is updated for the new method. - The full CmuxNext Swift suite: only the relay suites ran.
Known nit, accepted: each Send gets a fresh replyId. If the daemon commits after the request deadline, the card shows "not sent", and a retry stores a second copy.
Reply sender wired after open
AcpmuxPaneSocket used to copy transport.sendAgentReply when it opened. A tab that adopts a prewarmed spare gets its sender after that, and a provisional tab's rekey replaces it, so the socket's copy was nil or stale. The transport now hands the socket a forwarding closure that reads the sender when a reply is sent. An unwired sender still answers reply.daemon_unavailable.
cmux-ci run --class light --script scripts/ci/package-test-lane.sh --arg=suite --arg=Packages/macOS/CmuxNext --arg=AgentPaneAgentReplyRelayTests: 4ed0540d04ca (test only) red, aReplyGoesToTheSenderWiredWhenItIsSent got reply.daemon_unavailable; 2de0279dc769 (fix) green, 4 of 4.
No capture: the pane UI is unchanged. The Swift and webview changes are the reply transport (_acpmux/app_reply, the host relay, agent.message.send); the card renders the same before and after.
Leo-ask: sr2-01a1608f-89fb
Changelog
- Fixed: A reply typed in place on another agent's message is now a real cmux agent message: it gets a message id,
cmux agent message --reply-toandunsendwork on it, and it shows in the inbox.
Agent session: cosmic-gray
🤖 Generated with Claude Code