Agent pane: a reply in place is a stored cmux agent message (--reply-to, unsend, inbox)

Merged
TE
7d ago
+1229-279

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::LocalApp and 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 replyTo in threadId (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 is sha256(from + replyId).
  • 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 calls AgentPaneTransport.sendAgentReply.
    • sendAgentReply is wired only for a local connection (connection.remote == nil). Without it the page gets reply.daemon_unavailable.
    • The page gets back only {agentMessage: {id, threadId}}, or reply.daemon_refused.
  • The app (AgentTabs+AgentReply.swift, then DaemonService.sendAgentReply, then StateResourceClient.sendAgentReply) mints credential.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:A throws AgentReplyWrongSender, so it fails closed.
    • The credential never leaves that call.
  • 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 own msg_… id, and its pump delivers it to B with the Reply with: cmux agent message --reply-to <id> line.
  • session/prompt provenance is now accepted only over the unix socket (the daemon's own pump). Sender::App and the LocalApp exception in remote_guard.rs are gone, and provenance was removed from the pane's session/prompt known 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 sessionId and requires a gesture;
      • acpmux requires that this connection shows that exact chat and that the chat got that message from to in that thread;
      • the host sends only when acpmux signed the reply for the frame's own chat, still in scope.
    • 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.
  • 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_unavailable and reply.daemon_refused.
  • ID scoping.
    • The answer is tied to the page request through AcpmuxRequestIds (subject is the frame's sessionId).
    • 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.

Tests and results

Each test commit came before its fix. All runs are fleet cmux-ci run steps.

TestRed (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_else2272c82b173, step 81fbeec5477af2294e8c027e: 0/2, both red56e30917c7c, step 8b906f4cc1351274c5d1a7bb: 3/3
--arg test --arg params_rule (policy parity)2272c82b173, step a54af1c3f8f32311d182f377: red56e30917c7c, step caff4fbd80379bb084df7bae: green
--arg test --arg gestures2272c82b173, step da2daf9327b632935a9699f7: red56e30917c7c, step 7827ae90216490acdceff449: green
--arg test --arg session_scope2272c82b173, step 82cff03510f4864a0177cedc: red56e30917c7c, 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):

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:

Observed:

  • protocol_ir_is_current (71a949fe8ac313ed69dd3a53) is red on base. Base's acpmux-protocol.json lacks the watching field 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 webviews bun run check passes except check: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.
  • The fleet runs above.
  • clippy (step 3ede4e92f1d9efd96210e3e8) is red only in crates/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_hosts and the_allowlist_is_what_the_page_sends are base-red skipped (bead cx-c0wvp). Review checked by reading that policy.json and the Swift tables agree for _acpmux/app_reply and for the session/prompt change.

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-to and unsend work on it, and it shows in the inbox.

Agent session: cosmic-gray

🤖 Generated with Claude Code