Skip to content

Confirm Hosted approvals with two typed digits, one flow for remote control and Natural Voice - #1275

Merged
nedtwigg merged 10 commits into
mainfrom
voice-active-code
Oct 21, 2026
Merged

nedtwigg merged 10 commits into
mainfrom
voice-active-code

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

What changes for the user

Approving a computer at hosted.dormouse.sh/enroll now takes two digits typed on the account page, for both remote control and Natural Voice. Dormouse shows the two digits (it used to show an 8-character code, 7KQM-X4TD, to compare by eye). The account page asks for the digits before Approve. If the digits are wrong, nothing is approved, the code can't be approved again, Dormouse says the digits didn't match and offers a new code, and the account page says so too, within one 5-second re-check.

The digits work like pairing's digits: they make approval active. A link alone no longer approves anything. Before this change, someone who clicked a stranger's /enroll#CODE link and pressed Approve without comparing handed that stranger an enrollment or a voice token.

One flow for both purposes

Before this PR, the two purposes already shared the Hosted routes, the approvals table, the /enroll view and the code component. The desktop still had two copies of the run itself: one in BurrowService, one in managed-voice-host.ts, and they modeled outcomes differently. Now:

  • Hosted: begin and poll share one derivation and one digit check. The only per-purpose branch is what redemption mints: a Burrow, or a voice token.
  • Desktop host: a new HostedApproval in lib/src/host/remote/hosted-approval.ts owns begin, the code waiting, the polls with their retry and slow-down, the deadline, cancel, and redemption. BurrowService and the managed-voice host each pass only a flow:
    • what begin holds back (the Noise static and label, or nothing);
    • how a poll reads (pollHostedEnrollment / pollHostedVoiceApproval, which now return one answer shape);
    • what redemption keeps (save the Burrow; or save the voice token, deleting it at Hosted when it's discarded).
  • Desktop state and UI: voice status now carries the same HostedEnrollmentState with typed end reasons, in place of free-text messages. Both webview readers parse it with parseHostedEnrollmentState. Both sections render it through HostedApprovalProgress; only the wording differs by purpose.
  • Account page: the /enroll view asks for the digits through a new ConfirmDigits component. After Approve, the page's re-check from Hosted account page: refetch after every mutation, wait out approvals #1266 also reads the approval itself (GET /api/relay/enrollments/:userCode → { mismatched, redeemed }, the signed-in account's own live approval only). A mismatch ends the wait at once with an error notice. When nothing visible would change (Natural Voice already on, or no computers list read), the page waits on redeemed instead of showing a fixed notice.

What still differs, and why

  • What redemption mints: a Burrow under the account lock and cap, or a voice token.
  • Account full: only remote control polls on through a full account; voice mints no Burrow, so it has no cap.
  • Lost answers: a lost remote answer names a Burrow to remove; a lost voice answer has nothing to name.
  • Copy: "this computer was not enrolled" vs "Natural Voice was not turned on".
  • User-code key: the HMAC key still differs per purpose (:voice), so a code for one purpose can never redeem the other.
  • Where the digits are checked: at the relay's poll, not on the account page. The digits are derived from the user code under RELAY_ENROLL_SECRET, which only the relay Worker holds (S79 forbids giving it to the account Worker). So the account page stores the typed digits and the poll compares them. A mismatched poll stamps mismatchedAt on the approval, and the account page reads that stamp, so the page reports a typo once the desktop next polls, rather than as you type. The alternative was a new secret shared by both Workers, plus a production secret to set.
  • Cancel during a voice save: cancelling Natural Voice while its token is being saved now lets the save finish (remote control's rule: "a redeeming one is left to land"). A Disconnect then clears and deletes the token. Before, the voice host dropped the token mid-save.

Compatibility with released desktops (v1.3.0): a clean break

v1.3.0 desktops can no longer finish a Hosted approval, for remote control or Natural Voice, once this Hosted deploys.

  • v1.3.0 shows only the 8-character code and never the digits, so its user has nothing to type.
  • The poll answers mismatch, a status v1.3.0 doesn't know, so v1.3.0 ends with "could not finish".
  • In practice that happens only after someone guesses at the digits (one guess in 100 succeeds); with no approval the code just times out.
  • Already enrolled v1.3.0 desktops are unaffected: their Burrow token, sockets and voice tokens don't change.
  • There's deliberately no path without digits for old desktops: anyone could choose a downgrade like that.
  • Both features are admin-only today.

Migration 011 (renumbered after main's 010) drops approvals from before the columns existed, and adds confirmDigits and mismatchedAt. Approvals live 10 minutes, and none of them carried digits.

Within one VS Code install, a window still running the old webview beside a new broker (or the reverse) won't draw a waiting code until it reloads.

Specs

  • docs/specs/hosted.md "Burrow enrollment": the digits in the diagram, how they're derived, the rule that a poll redeems only on matching digits (one attempt per code), the mismatch stamp, the approve body, and the approval read. The rationale file has a new "Confirmation digits" entry covering why the check sits at the poll and the v1.3.0 break.
  • docs/specs/relay.md "Burrow side": the desktop shows the digits and stops polling on mismatch. Managed voice's approval is now the same run under these rules.
  • docs/specs/security-hosted.md: new FAIL IF S276 (next bumped to 277) for the digit check, the mismatch stamp (written only by a poll under a live, unredeemed approval), and the approval read (owner only). Migration 011 is added to the Relay boundary's file list, and the .github/audit/hosted.md enrollment question now asks about digits too.
  • docs/specs/security.md "What is not defended" and docs/specs/security-remote.md S184: what an approval takes now.
  • Word budgets ratcheted for hosted.md, relay.md and security-hosted.md.

Test plan

  • remote-lib-common: node --test (the digit derivation against an independent HMAC reading, the skipped draws, padding, the begin-response guard, mismatch in both poll guards)
  • hosted: pnpm run test (begin's digits, mismatch for both purposes even for an approver no longer entitled, the approval route's 400 for missing or malformed digits, the migration pin)
  • lib: full vitest run (the shared runner through the remote service and the voice host, mismatch endings, the voice port's parser, both sections' rendering)
  • hosted: the mismatch stamp's every condition, the approval read's owner scope, expiry and replacement reset, and pendingEnd/approvalShows in settle.test.ts
  • Every new or changed assertion mutation-checked: the guarded line broken, the test seen red, the line restored
  • tsc -b in lib; tsc --noEmit in standalone, hosted, vscode-ext
  • pnpm run lint:specs, pnpm run lint:md, public-docs-lint, outbound-lint, e2e-lint
  • Storybook: the waiting and mismatch stories in RemoteControlSection and ManagedVoiceSection run under vitest.argos.config.ts (Chromium; screenshots looked over)
  • Account page on the local dev server: signed in, /enroll#CODE asks for the digits, the field drops non-digits, and Approve stays disabled until two digits are typed (the dev account isn't entitled, so the approval itself answered 403 there)
  • A real desktop against production Hosted, after deploy: Settings → Remote control → Enroll shows two digits; typing them at /enroll enrolls; typing others ends with "were not the ones shown here" on the desktop, and the account page shows the wrong-digits notice within ~5 s
  • The same for Settings → Notifications → Turn on Natural Voice
  • Confirm the account page on a phone: the digit field is a numeric keypad and doesn't zoom
  • A v1.3.0 desktop against the new Hosted: its approval can't finish (the expected break)

🤖 Generated with Claude Code

nedtwigg and others added 6 commits October 20, 2026 23:52
Begin answers confirmDigits, the HMAC of the user code under
RELAY_ENROLL_SECRET; the account's approval stores the two digits the
person typed; a poll redeems an approval, for either purpose, only where
they match, and answers mismatch otherwise. A live approval is never
replaced, so each code takes one attempt. Migration 010 adds the column
and drops approvals from before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HostedApproval (lib/src/host/remote/hosted-approval.ts) holds the begin,
the code waiting, the polls, and the redemption for both purposes; the
Burrow service and the managed-voice host differ only in their flow:
what begin holds back, how a poll reads, and what redemption keeps (a
Burrow, or a voice token, deleted at Hosted when discarded). Voice's
status now carries the same HostedEnrollmentState, parsed by the same
reader, with typed end reasons in place of free-text messages.

The desktop shows the two confirmation digits instead of the user code,
through one view, HostedApprovalProgress, worded per purpose, and ends
on a mismatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The /enroll view asks for the digits in place of showing the user code
to compare, and sends them with the approval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hosted.md states the derivation and the poll's comparison, with the
rationale for checking at the poll and the v1.3.0 break; relay.md puts
managed voice's approval under Burrow side's rules; security-hosted.md
adds FAIL IF S276; security.md and security-remote.md say what an
approval now takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4f1fc9c
Status: ✅  Deploy successful!
Preview URL: https://87a1598d.mouseterm.pages.dev
Branch Preview URL: https://voice-active-code.mouseterm.pages.dev

View logs

@nedtwigg
nedtwigg marked this pull request as ready for review October 21, 2026 15:05
Renumbers the confirm-digits migration to 011 after main's 010.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dormouse-bot
dormouse-bot previously approved these changes Oct 21, 2026

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI is red at this head: Build & Test fails in run 39623281831. Both failures come from this PR's changes reaching files it doesn't touch, and main is green at the merge base (de92b3e). My earlier approval didn't account for this, so I'm withdrawing it.

1. vscode-ext/test/burrow.test.ts › "answers another window's Enroll with the code this one is showing". Its fetch stub returns a begin answer without confirmDigits. isBurrowEnrollBeginResponse now requires that field, so the begin is refused with "the Relay's answer was not an enrollment code." The test also expects userCode in the waiting result, which now carries confirmDigits instead. The fix adds the field to the stub and asserts on it:

             userCode: '23AB-YZ9K',
+            confirmDigits: '42',
             expiresAt: Date.now() + 10 * 60_000,
...
-      expect(results(bound.posted)[0]).toMatchObject({ result: { status: 'waiting', userCode: '23AB-YZ9K' } });
+      expect(results(bound.posted)[0]).toMatchObject({ result: { status: 'waiting', confirmDigits: '42' } });
...
-      expect(far.results[0]).toMatchObject({ result: { status: 'waiting', userCode: '23AB-YZ9K' } });
+      expect(far.results[0]).toMatchObject({ result: { status: 'waiting', confirmDigits: '42' } });

2. scripts/security-audit-canary.test.mjs. The seed .github/audit/canaries/passkey-removal-keeps-sessions.patch no longer applies. Its migrations.test.ts hunk has }; as trailing context right after the 010 pin, and the new 011_enrollment_confirm_digits.sql pin now sits there. The fix widens that hunk to @@ -32,9 +32,9 @@ and adds the two 011 lines as context before };. With that change the canary test passes locally (17/17). I couldn't run the vscode-ext suite here.

Neither file is in this diff, so I can't attach suggestions. I can push both fixes if you'd like.

@dormouse-bot
dormouse-bot dismissed their stale review October 21, 2026 15:19

CI failed — vscode-ext burrow test and audit canary seed break on this PR's changes.

nedtwigg and others added 3 commits October 21, 2026 08:21
The poll's read now stamps "mismatchedAt" (added in migration 011,
unmerged, so no 012) on the approval it reads, in the same statement and
only where that approval is live, unredeemed, of the poll's purpose, typed
with other digits, and not stamped before. The redeeming statements keep
their digit check, so a stamped approval still redeems nothing. A
replaced expired approval starts unstamped.

A new cookie route, GET /api/relay/enrollments/:userCode, answers
{ mismatched } for the signed-in account's own live approval only;
another account's approval reads as none. While the account page waits
on an approval it reads that route with each re-check, and a mismatch
ends the wait with an error-toned notice, replacing the late notice too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng else shows it

With Natural Voice already on, or no computers list read, an approval's
redemption changes nothing on the page, so it showed a fixed notice and a
wrong-digits entry went unshown. The approval read now answers
{ mismatched, redeemed } (still the signed-in account's own live approval
only), and approvalShows falls back to that redemption where no change
could show it: the page waits as it does elsewhere, ending in done, the
mismatch error, or the late notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vscode-ext begin stub now answers confirmDigits, which the begin guard
requires, and the passkey-removal canary's migrations hunk takes the 011 pin
as context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nedtwigg
nedtwigg merged commit 8fd2bf6 into main Oct 21, 2026
21 of 22 checks passed
@nedtwigg
nedtwigg deleted the voice-active-code branch October 21, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants