Skip to content

Hosted enrollment from the desktop, and paired phones under Local networks and Anywhere - #872

Merged
nedtwigg merged 15 commits into
mainfrom
remote-network-hosted-client
Oct 1, 2026
Merged

nedtwigg merged 15 commits into
mainfrom
remote-network-hosted-client

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Oct 1, 2026

Copy link
Copy Markdown
Member

Fifth and last of the Hosted persistent stack, on #870. It is the desktop half; with it, docs/specs/remote-network.md has no ## Future Hosted persistent scope left.

Enroll a Hosted build (Settings → Network → Phones):

  • "Enroll with hosted.dormouse.sh" replaces the disabled button. The service begins the device-code enrollment and polls it itself; the device code never enters a webview.
  • The panel shows the code, a link to approve it, and the time left. The link is composed by the Burrow at the fixed https://hosted.dormouse.sh (a dev Hosted build follows the Relay's checked link).
  • Once enrolled, it shows the self-host enrolled view plus "Manage computers".
  • Edge cases handled:
    • redemption completes before the panel leaves its waiting state;
    • a late redemption (after Cancel) is adopted;
    • a lost poll answer reads as "Remove Burrow X from your account", using a marker the Relay keeps until expiry;
    • a second VS Code window joins the waiting code rather than replacing it.

Paired phones:

  • Network levels: a Hosted build runs its persistent Burrow under Local networks and Anywhere (runsBurrow is every level but Nothing), and restarts it on any path change.
  • Local networks is direct-only:
    • the encrypted connection outcome says so, and Pocket waits for the direct switch before sending protocol-v1;
    • the Burrow ends the session unread on an application message over the Relay, a given-up attempt, or a deadline of the phone's setup plus handoff (30 s).
    • One mechanism in EstablishedE2eSession now serves both this rule and the one-time session's.
  • Anywhere may fall back to relaying through Hosted.
  • Pocket served by Hosted gathers through Cloudflare STUN. It learns its deployment from a deployment.json that Hosted stages; anything else reads as self-host.
  • Settings → Network lists the paired-phone connections. Push follows from the running Burrow.

Changed shared bound: the direct-only deadline went from 15 s to 30 s, so ONE_TIME_EXPIRY_GRACE_MS went from 30 s to 45 s. A room must outlive a session confirmed in a link's last second. This affects the one-time room on Hosted too, so deploy the relay Worker with or before desktop builds carrying this.

Test plan

  • Root pnpm test green; Hosted Docker suite 154/154, including an end-to-end device-code enroll → pair → connect through the account's Durable Object, ended unread on a relayed application message; Storybook play for the changed stories.
  • Manual: see the stack's manual test plan (top comment on Split Hosted into account, relay, and voice origins #867).

🤖 Generated with Claude Code

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7ad70b3
Status: ✅  Deploy successful!
Preview URL: https://8a68066f.mouseterm.pages.dev
Branch Preview URL: https://remote-network-hosted-client.mouseterm.pages.dev

View logs

@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.

Three enrollment edge cases are inline. Some text outside the diff still describes Hosted's STUN as one-time-only, but after this PR, Pocket served by Hosted always gathers through Cloudflare STUN, Local networks included (deploymentDirectPeer → hostedDirectPeer):

  • docs/specs/security.md, the "Traffic analysis" bullet: "Cloudflare's STUN server sees the one-time phone's public address, and under Anywhere this computer's". That page is published at /security, so it now understates whose addresses Cloudflare sees. A paired phone in Hosted-served Pocket belongs there too.
  • lib/src/remote/client/browser-direct-peer.ts: the doc comments on hostedDirectPeer ("The one-time page's, which Hosted serves.") and selfHostDirectPeer ("Pocket's, which a self-host Relay serves.") no longer hold.
  • docs/specs/remote-api.md, the ## Future sentence "A paired phone's network levels follow the remote-network scope". This PR builds those levels, so the pointer reads as if they were still unbuilt.

I can push these doc fixes as a commit if you'd like.

Comment thread lib/src/host/remote/service.ts
Comment thread lib/src/remote/burrow/enrollment.ts Outdated
Comment thread lib/src/host/remote/service.ts
nedtwigg and others added 11 commits October 1, 2026 13:05
The service begins and polls the device-code enrollment itself, holding the
device code out of every webview, and composes the approval link at the fixed
hosted.dormouse.sh (a dev Hosted build follows the Relay's checked link). The
Phones section replaces the disabled Hosted button with the code, a link to
approve, and the enrolled view.

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

Review fixes: the run stays redeeming until its save lands, a late redemption
is adopted rather than dropped, a lost poll answer reads as redeemed from a
marker the Relay keeps until expiry, a second window joins the waiting code
instead of replacing it, a failed begin keeps the old code, and the account
links follow one origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Hosted build runs its persistent Burrow under both levels. Under Local
networks a paired phone's session is direct-only: the outcome says so, Pocket
waits for the switch before protocol-v1, and the Burrow ends the session unread
on an application message over the Relay, a given-up attempt, or the deadline.
Under Anywhere it may fall back to relaying through Hosted. Pocket served by
Hosted gathers through Cloudflare STUN, read from a deployment file Hosted
stages, and Settings lists the paired-phone connections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes: EstablishedE2eSession owns the direct-only rule both runtimes
use, with a deadline of the phone's setup plus handoff so it cannot end an
attempt the phone still counts live; Pocket's failure copy follows the cause
and reports an ending once; stop() leaves no flush timer; the redeemed answer
names the Burrow to remove; one deployment marker; and the built scope leaves
the specs' Future sections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t an unanswered request could not reach

When the path ends a direct-only session under Local networks — a refused
pair, a given-up attempt, or the deadline once the phone offered — the shared
EstablishedE2eSession records { at, kind, address?, addressSource }: the
refused pair's remote end (observed), else the first public address the
phone's offer reported (a diagnostic that decides nothing). The goodbye now
carries reason network-not-allowed with that address, so Pocket and the
one-time page show one sentence naming it; the laptop shows the same record
in Settings → Network, with Dismiss, and in the one-time ending. The Burrow
service's fetch now names the host and why a request got no answer instead
of a bare "fetch failed".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Burrow closed 4001 (removed) or 4002 (Hosted, owner no longer entitled) now
latches `removed` / `not-entitled` instead of reconnecting forever, and a
refused upgrade earns one standing probe of GET /api/push/devices per failure
streak. Settings names the state, offering Enroll again (Hosted removal) or
Reconnect (not entitled), and lists no relay connection while latched; Pocket
marks a record the Relay's list no longer names as removed, with Forget, and
says so for a Connect that went unanswered for that reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s to what was answered

A path refusal now records `end`: `local` where the policy refused this
machine's end (the pair's local end, checked first, or no candidate of this
end), naming only its own address and sending the phone no address, so the
phone reads the generic direct-failure copy; else `remote`, naming the phone's
observed address, or the first public address outside the allowed networks its
offer reported. The laptop blames the phone's network only for `remote`, words
a reported address as the phone's claim rather than as off-network, and dates
a refusal from another day. Dismiss clears only the refusal held when it
arrived.

Pocket marks removed only a record of the signed-in account its list no longer
names. Only a 2xx/401/403 answer spends the standing probe's streak; a 5xx or
other status counts as no answer. One `relayRefuses` predicate serves the
service and both panels; the described fetch keeps its cause; relay.md's
socket-policy pointer joins the section's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 2xx whose body stream breaks is a transport failure like one before the
headers, so the next poll can still hear the redemption it spent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every Connect and pairing awaits the deployment read, bounded by
POCKET_DEPLOYMENT_READ_TIMEOUT_MS. A complete answer is cached for the page;
a failed, timed-out, 5xx, or truncated read is not, and that Connect fails
retryably, so Hosted's Pocket never gathers through no ICE server because its
origin was slow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
002_relay.sql returns to exactly what #868 merged; 004 adds redeemedBurrowId,
redeemedAt, and their CHECK by ALTER TABLE, since a migrated database never
reruns a migration. A test pins every merged migration's content.

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

# Conflicts:
#	scripts/spec-word-budgets.json
@nedtwigg
nedtwigg force-pushed the remote-network-hosted-client branch from 4eabb83 to 10e26a7 Compare October 1, 2026 20:18
@nedtwigg

nedtwigg commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

@dormouse-bot Please review and approve the current head f12c502 if the findings are resolved. The cancelled-begin and saved-startup-failure fixes are pushed, the lost-body recovery fix is present, and all three stale documentation points from your review summary are corrected. Validation: 172 enrollment/service tests, lib typecheck, and spec lint/self-tests passed. The maintainer explicitly says to IGNORE ARGOS; all other CI must pass. Please report any remaining issue and do not merge.

@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.

One finding is inline, in Pocket's removed-marking.

Comment thread lib/src/remote/pocket-app/App.tsx Outdated
@dormouse-bot

Copy link
Copy Markdown
Collaborator

Not approving f12c502 yet: the review of this head (posted a minute after your comment) left one open finding. In loadBurrows in lib/src/remote/pocket-app/App.tsx, client.accountId is read after the await. If a sign-in to another account finishes while the list read is in flight, that account's records are compared against the previous account's list, and every one is marked removed. The inline suggestion moves the read above the Promise.all.

The three earlier findings are fixed at this head and their threads are resolved: cancel and disposal clear #enrollBegin, a body lost after a 2xx retries, and removal advice applies only when the save didn't land. The three doc points are fixed too. CI is still running (Build & Test, Webview Boot Smoketest). Once the Pocket fix is pushed, the review of the new head will approve it if nothing else turns up.

@nedtwigg

nedtwigg commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

@dormouse-bot Please review and approve latest head 837783d if clear. The account-switch removal race from your latest review is fixed with a regression; previous findings remain resolved. PR 870's latest fixes are integrated. Validation: 77 Pocket scan/view tests, 172 enrollment/service tests, 9 shared web-push tests, lib typecheck, spec lint/self-tests pass. Maintainer says IGNORE ARGOS; all other CI must pass. Please report any remaining issue and do not merge.

dormouse-bot
dormouse-bot previously approved these changes Oct 1, 2026
Base automatically changed from remote-network-hosted-push to main October 1, 2026 20:35

@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.

Build & Test is red at this head, and at f12c502 before it, on the new starts a fresh begin after %s while the old request is pending test. Each run failed a different variant: cancel at f12c502 (one of two begin requests had arrived), Nothing at this head (none had). That points to timing, not the fix. Approval withdrawn until it's green.

Comment on lines +2636 to +2640
async function drain(until: () => boolean): Promise<void> {
for (let turn = 0; turn < 500 && !until(); turn += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
}

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.

drain gives up silently after 500 setImmediate turns. Before its fetch, each begin awaits #networkPolicy() and mintNoiseStatic(), and WebCrypto key generation answers from the threadpool, not within a set number of turns. On a loaded runner the 500 turns run out first, so expect(settle).toHaveLength(2) sees 0 or 1. The begins left pending then fail after the test ends, which is the unhandled no result for beginHostedEnrollment in the same log. Only setTimeout and Date are faked here, so a wall-clock bound on performance.now() still works:

Suggested change
async function drain(until: () => boolean): Promise<void> {
for (let turn = 0; turn < 500 && !until(); turn += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
}
async function drain(until: () => boolean): Promise<void> {
const deadline = performance.now() + 5_000;
while (!until() && performance.now() < deadline) {
await new Promise((resolve) => setImmediate(resolve));
}
}

@dormouse-bot
dormouse-bot dismissed their stale review October 1, 2026 20:39

CI failed — Build & Test red on a timing-dependent enrollment test.

@nedtwigg

nedtwigg commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

@dormouse-bot Please review and approve latest head 7ad70b3 if clear. CI exposed a timing flaw in my new cancelled-begin regression: bounded event-loop spins could finish before real WebCrypto reached fetch under suite load. The test now awaits explicit first/second fetch-arrival promises, synchronizes the pending join through the cached-policy read, and fails any unexpected third begin immediately. Production code is unchanged. The full lib pnpm test passed: 286 files, 5190 tests; typecheck passed, 2 files/5 tests skipped. Prior review fixes remain present. Maintainer says IGNORE ARGOS; all other CI must pass. Please report any remaining issue and do not merge.

@dormouse-bot

Copy link
Copy Markdown
Collaborator

The 7ad70b3 diff is test-only. It replaces the event-loop spins with fetch-arrival promises and a third-begin tripwire, which removes the timing dependence behind the red runs on the cancel and Nothing variants. I see no new issues in it. The review run for this head is still going, and it posts the verdict: it approves once Build & Test and the other non-Argos checks are green. I'm leaving the approval to it so the PR doesn't get two verdicts on one head.

@nedtwigg
nedtwigg merged commit a98e796 into main Oct 1, 2026
9 checks passed
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