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)

Merged
TE
11d ago
+554-28

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)

  • signRelayedAllow signs a relayed answer with AcpmuxSecretKey.decisionProof(..., answers:, annotations:), the same bytes as acpmux's acpmux-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 Swift String equality, 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, AgentTabRemoteHost and AgentSessionAttachClient used 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 String equality (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