acpmux: a question answered in the pane counts on another machine under the person's proof (re-cut of #23869, daemon 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 daemon side (acpmux, cmux-tui), and #25440 is the pane side (Swift, web). 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".
Cause
A chat on another machine gets its answers over a connection that is never the person, so the peer counts an allow only when it carries the person's decision proof (cx-0q7o). That proof never covered question answers: person_decided and respond_permission refused any proof when answers was present.
Fix
- acpmux: a new proof,
acpmux-person-decision-answers-v1(hub/person/decision_answers.rs), signs the allow's five parts plus the sha256 of the canonical answers JSON (approvers::answers_digest). When the answer has annotations (Claude Code's chosen preview and notes, #24356), the sha256 of the canonical annotations is a seventh part. - An answer from a connection that is not the person counts only under that proof, so a relay can neither swap the answers nor add annotations. Annotations from such a connection count only on the proof path: never for an ancestor's answer, and never with a phone's signed answer, whose signature covers the answers alone. Each nonce is still spent once, under the prompt's lock.
- cmux-tui
agent-session-permissioncarriesanswersandannotations(objects of at most 64 KiB each, annotations only beside answers) to acpmux unchanged (spec/commands.md).
Tests
cargo test -p acpmux --test person_allow:a_relayed_answer_counts_with_the_persons_proof_over_exactly_its_answers, red at 2f320427 (a compile error: the proof does not exist yet).a_relayed_answers_annotations_count_only_under_the_persons_proof_over_them, red at 9aab5bf0 (run: the annotated answer is refused).the_answers_decision_proof_matches_the_apps_vector, with annotations and a character outside the BMP. The vectors were computed independently in Python, and the Swift side checks the same vectors in #25440.- All 25 green at b7ab6351 (run).
cargo test -p cmux-tui-core:cargo test -p acpmux --test hub_integration questions: 8 of 8 pass. clippy-D warningsis clean, and rustfmt is clean at this head.agent_tools::tests::own_servers_run_under_the_daemon_prefixalso fails on base 41e4d6bb (run). It is not this PR's.
Review
Codex gpt-6-astra xhigh, through the bare mirror. Round 1 BLOCKed with two findings: the ticket did not bind the answers, and the remote wire dropped annotations. Both are fixed with tests. Round 2 BLOCKed on Unicode equality in the Swift ticket, which is fixed in #25440. Round 2 confirmed the daemon side by inspection. The final verdict at this head is in a comment.
No new timers or observers.
No capture: the change is in the proof and the daemon verb, and nothing in the UI changes.
Changelog
Fixed: Answering an agent's question in a chat on another machine no longer fails with "accepted only from the cmux app" (with #25440).
risk:high: this changes what the person's decision proof covers (auth).
Agent session: big-red
š¤ Generated with Claude Code