agent pane: a question answered in the pane for a chat on another machine carries the person's proof (re-cut of #23869, pane side)
Re-cut of #23869 (by the cosmic-gray lane 01a15bdd, now gone), rebased onto feat-cmux-next. The stale-pr-reconciler gave it to hb-acpmux; Leo, 2026-10-25: "these should never have been this stale for this long". Credit for the work goes to #23869. It is split in two to stay under 800 lines: this PR is the pane side (Swift, web), and #25424 is the daemon side (acpmux, cmux-tui). Each lands on its own: until both are in, an answer relayed from the pane is still refused, the same as today.
Answering an agent's question in the Mac app's chat pane failed for a chat on an ssh peer (Leo hit it live on huge-blue). The peer's acpmux refused it with "approve this on the Mac app: allowing a permission is accepted only from the cmux app". The cause is in #25424: the person's decision proof never covered answers.
Fix (pane side)
signRelayedAllowsigns a relayed answer withAcpmuxSecretKey.decisionProof(..., answers:, annotations:), the same bytes as acpmux'sacpmux-person-decision-answers-v1. Its canonical JSON matches acpmux's byte for byte, and the Rust and Swift tests check shared vectors, including annotations and a character outside the BMP.- A question's answer takes the person's gesture ticket, as an option pick does (
direct.ts). The ticket binds the session, prompt, option, answers and annotations. The answers and annotations are compared by the sha256 of their canonical JSON bytes, never by SwiftStringequality, which counts a composed é and a decomposed é as equal. So a ticket the page took for one answer never redeems, and never gets signed for, another. - The answers and annotations reach that machine:
RemoteAcpmuxWire,AgentSessionRemoteClient,AgentTabRemoteHostandAgentSessionAttachClientused to drop them.
Tests
- Swift (this PR's CI):
swift test --package-path Packages/macOS/CmuxNext --filter AgentPaneRelayedAllowTests:anAnswerToAnotherMachinesQuestionCarriesTheProofOverItsAnswers.theAnswersProofMatchesAcpmuxsVector(with annotations).aTicketIsForExactlyItsAnswersAndAnnotations: nested Codex answers, key order, differing notes, and composed vs decomposed Unicode.aTicketForOneAnswerIsRefusedForAnother: at the transport level, a swapped answer and a decomposed é are refused with gesture_required, and nothing goes out.- Also
RemoteAcpmuxWireAnswerTests(the answers and annotations reach the daemon client, under the proof over both).
- Web:
bun test src/agent-session/acpmux/questionAnswerTicket.test.ts:
Review
Codex gpt-6-astra xhigh, through the bare mirror:
- Round 1 BLOCKed with two findings: the ticket did not bind the answers, and annotations were dropped.
- Round 2 BLOCKed on the ticket comparing by
Stringequality (Unicode canonical equivalence).
All three are fixed with tests. The final verdict at this head is in a comment.
No new timers or observers.
No capture: the question card looks the same. The change is in the ticket, the proof and the wire.
Changelog
Fixed: Answering an agent's question in a chat on another machine no longer fails with "accepted only from the cmux app" (with #25424).
risk:high: this changes what the person's decision proof covers (auth).
Agent session: big-red
🤖 Generated with Claude Code