Skip to content

Enroll a remote Host from Settings, not the devtools console - #423

Open
nedtwigg wants to merge 9 commits into
mainfrom
settings-remote-control
Open

Enroll a remote Host from Settings, not the devtools console#423
nedtwigg wants to merge 9 commits into
mainfrom
settings-remote-control

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Connecting a machine to a coordinating server was a window.dormouseRemoteHost.enroll(...) console call. That is a fine scripting seam, but it is the one step a self-hoster cannot skip — and finding it means knowing that VS Code webviews are nested iframes and that the hook lives in a lazily-loaded chunk.

Adds a Remote control section to the app-global Settings dialog over the same enroll / status / reconnect / clearEnrollment commands. The console hook is unchanged and stays.

Un-enrolled it is a three-field form (server, setup password, name for this machine). Enrolled it shows the server URL, the relay connection state, and the paired-device count, with Disconnect and — only on displacedReconnect.

That last one closes a gap the spec admitted to: "Nothing surfaces displaced in the UI yet." It is the one connection state whose recovery is an explicit user act, so it is the only one that gets a button.

Security notes

It handles a bearer credential, so this is the part worth reviewing closely:

  • The setup password goes straight through to the service — the party that actually talks to the server — and is cleared on success. It is never stored in the store or persisted.
  • hostToken never enters the webview realm: enroll answers { hostId, serverUrl }.
  • An origin outside the build's baked relay allowlist is refused before the password leaves the machine, and that refusal is what the form renders — so a wrong origin reads as "this build will not talk to that server" rather than as a bad password.
  • Disconnect confirms, because clearing the enrollment forces every paired phone to pair again.
  • Pairing approval is deliberately not here — it stays a modal, because it must interrupt.

Two implementation notes

  • The store is independent of the pairing chunk. alert-push.ts and the console hook live inside the lazily-imported RemotePairingModalHost chunk; Settings is in the main chunk. Importing the pairing module from Settings would pull the whole remote-host stack into every host's main bundle, including the website's. The build confirms the chunk is still split.
  • The connection is polled every 2s while the section is mounted. The service's status event fires only when enrolled changes, so connecting -> connected arrives with no event at all — without the poll the dialog reads as permanently "Connecting…". Polling only while subscribed keeps it to the seconds the dialog is open, not a standing timer per window.

The section renders nothing where getPlatform().remoteHost is absent, so the website and lib dev server are unaffected. That is the same seam the push-devices line already keys on, which is why its no-host copy can now point at this section.

Also adds a shared TextInput to design.tsx beside NumericInput, rather than restyling a bare <input>.

Verification

typecheck clean; 1678 tests passing; pnpm lint:specs OK. 9 new tests cover the hidden-when-unsupported case, field validation, trimming, refusal surfacing, the displaced-only button, the disconnect confirmation, the status-event re-read, and that polling stops on unmount.

Specs updated: server.md, alert.md, DESIGN.md, and the SELF_HOST.md enrollment step.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GDNJHHA95nvRdo4Cv3rAoi

Connecting a machine to a coordinating server was a `window.dormouseRemoteHost`
console call — fine as a POC seam, but it is the one step a self-hoster cannot
skip. Add a Remote control section to the app-global Settings dialog over the
same enroll/status/reconnect/clearEnrollment commands.

Un-enrolled it is a three-field form; enrolled it shows the server, the relay
connection state and the paired-device count, with Disconnect and — only on
`displaced` — Reconnect. That also fills a gap the spec admitted to: nothing
surfaced `displaced`, the one connection state whose recovery is an explicit
user act.

It renders nothing where `getPlatform().remoteHost` is absent, so the website
and lib dev server are unchanged. The store is deliberately independent of the
lazily-imported RemotePairingModalHost chunk — Settings is in the main chunk,
and importing the pairing module would pull the whole remote-host stack into
every host's main bundle.

Handling a bearer credential, so: the setup password goes straight through to
the service and is cleared on success, `hostToken` never enters the webview
realm, an origin outside the build's baked allowlist is refused before the
password leaves the machine and that refusal is what the form renders, and
Disconnect confirms because it forces every paired phone to re-pair.

The connection is polled every 2s while the section is mounted. The service's
`status` event fires only when `enrolled` changes, so `connecting -> connected`
arrives with no event at all and the dialog would otherwise read as permanently
"Connecting…".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GDNJHHA95nvRdo4Cv3rAoi
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 20, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: f0d205e
Status: ✅  Deploy successful!
Preview URL: https://24a9d961.mouseterm.pages.dev
Branch Preview URL: https://settings-remote-control.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.

Read the whole change. The security shape is the part I looked hardest at and it holds up: the password lives only in EnrollForm's local state and is passed through to the service, enroll answering { hostId, serverUrl } keeps hostToken out of the webview realm, and surfacing the allowlist refusal as the form's own error is the right failure to render. describeConnection covers all six RemoteHostStatus members, and the generation counter in refreshRemoteHostStatus does guard the enroll/poll/status-event overlap. Three observations, none of them blocking.

The no-host copy points "below" at a section that renders nothing on exactly the builds that report no-host most often. The PR description says the push line and the new section key on the same seam, but they don't: no-host covers both "there is a remoteHost link but this machine hasn't enrolled" and "this build has no Host service at all" — push-devices.ts says as much ("a build without a Host leaves it at no-host forever"), and PlatformAdapter.remoteHost's own doc comment says "Adapters that omit it have no Host anywhere — the website". RemoteControlSection returns null in the second case, so the website playground (PlaygroundDesktop.tsx calls initPlatform("fake")) and every Modals/SettingsDialog story render "…server below to send push" with nothing below. Inline suggestion gates the word on the same seam the section does; dropping "below" outright would also be fine.

Nothing stories the new section, and the green Visual Regression Tests check is the evidence. lib/.storybook/preview.ts boots initPlatform('fake'), which has no remoteHost, so the section is null in all 15 SettingsDialog stories — a Chromatic run that finds no visual change on a PR adding a whole dialog section is the tell. That leaves four new visual states (enroll form, enrolled, the displaced warning, the disconnect confirm) with no snapshot coverage, in a dialog that otherwise stories every branch down to PushNoDevices vs PushNoHost. A primedRemoteHostStatus parameter in preview.ts, mirroring how primedPushDevices is threaded, would be enough to reach them — and it would have caught the copy issue above at the same time. Happy to push that if you'd like it in this PR.

Minor: setState in host-status-store.ts publishes a fresh { kind: 'ready', status } on every 2 s tick even when nothing changed, so useSyncExternalStore re-renders the section twice a minute for nothing. The sibling store the same dialog reads guards exactly this — setPushDevices is "Identity-guarded so a repeat write does not churn React". A field-wise comparison of the five primitives in RemoteHostConsoleStatus before the assignment would match it. Costs nothing today; noting it because the two stores otherwise read as a matched pair.

One thing I checked and liked: host-status-store.ts imports only types from service-protocol and platform/types, so the main-chunk/pairing-chunk split the description claims really is preserved.

Comment thread lib/src/components/SettingsDialog.tsx Outdated
nedtwigg and others added 5 commits August 20, 2026 16:30
…he question

Serializing the reads fixed the poll superseding a slow read's own timeout, but
made every caller join whatever was already in flight — including two that must
not.

A lifecycle command is one: `enroll` / `reconnect` / `clearEnrollment` each
finish by re-reading, and a `status` issued before the command answers the
question as it stood beforehand. Joining it reports the old enrollment as though
the command had not run, which is the inverse of the delete-first ordering the
service uses so a failed delete never claims to have succeeded — here a
successful one claims not to have.

Losing the last subscriber is the other. `refreshInFlight` outlived the
unsubscribe, so closing the dialog during a wedged read and reopening it issued
no read at all: the new mount coalesced onto the abandoned promise and sat on
"Checking…" until it settled, up to the link's whole 15-second timeout.

Both are the same act — drop the read in flight — so both call it. The
abandoned read was already neutralized twice over: `generation` moves, so it
cannot commit, and its completion callback sees a different in-flight promise,
so it cannot clear whatever replaced it.

Also points `server.md` at the pairing walkthrough committed alongside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VoYG8YjdVgHwhPd5TGEgB
Wires up the MDX page and the stories it embeds.

MDX was not supported: `@storybook/addon-docs` was not installed and the stories
glob matched only `.stories.tsx` under `lib/src`. Because the walkthrough is a
doc about the product rather than about `lib`, it lives at the repo root, which
puts it outside Node's resolution path to `lib/node_modules` — hence the exact
`@storybook/addon-docs/blocks` alias, resolved from where the package actually
is. A root dependency would be worse, not simpler: the root declares none today,
and a second copy would render the page's blocks from a different instance than
the addon rendering the page.

`RemoteControlSection` reads its whole world from `getPlatform().remoteHost`, so
the fake adapter gains the field the way it already carries `hostOwnsTheme` and
`hostOwnsShells`, and a `primedRemoteHost` parameter installs the shared stub.
Its stories render in their own frames: the store behind that section is a
module singleton whose link is captured only at `listeners.size === 1`, so
sections sharing one realm share one status however many links exist.

Chromatic gains `docs/stories/**` — the page would otherwise not rebuild when it
changes — but skips the page itself, whose every pixel is one of thirteen
stories it already snapshots, and whose capture would otherwise race six lazy
iframes.

AGENTS.md names the walkthrough so it is not a second docs tree nothing points
at; the path is backticked, so spec-lint fails the build if it is ever renamed
without updating the pointer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VoYG8YjdVgHwhPd5TGEgB

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

The storybook coverage lands the four states I said were unsnapshotted, plus five more, and test-remote-host-link.ts being the single typed fixture for both the unit test and the stories is the right shape. I traced the coalescing rewrite in host-status-store.ts against the three tests: the abandoned-read neutralization really is doubled (generation moves and the completion callback's refreshInFlight !== refresh guard), refreshAgain can't strand because the only path to listeners.size === 0 runs dropInFlightRead first, and the recursion in refreshRemoteHostStatus is synchronous through readRemoteHostStatus's first await, so nothing can interleave between clearing refreshInFlight and setting it again. I also spot-checked the walkthrough's factual claims — PAIRING_PRESENCE_WINDOW_MS = 30_000, the 8-character fingerprint in RemotePairingModal.tsx, manage verify / show-password, dogfood:standalone / dogfood:vscode, the 13 embedded stories and the six inline: false frames — and they hold.

One real finding, on the Chromatic escape hatch rather than on the page itself.

parameters on an unattached <Meta> is silently ignored in Storybook 10, so disableSnapshot isn't in effect. The Meta doc block's documented props are of, name, title, and isTemplate only; its source types props as BaseAnnotations & { of?, title? }, which is why parameters typechecks, but the component body never reads it. parameters on <Meta> was CSF-in-MDX, removed in Storybook 7 — every example still showing it (Chromatic's docs included) predates that. So the walkthrough page is snapshot-eligible, and the comment above it asserts the opposite, which is the part that will cost someone time later: whoever sees a Pairing/Self-hosted walkthrough diff will read that comment and conclude the page can't be the source.

If Chromatic is indexing docs entries here, this page is the worst candidate the repo has for a stable snapshot — six lazy iframes plus two autoplay stories, one of which paints a terminal. The UI Tests build on this commit is the cheap way to settle it: if a Pairing/Self-hosted walkthrough entry shows up in the changes list, the parameter is confirmed dead and the page needs a mechanism Chromatic actually reads. The one I know works in Storybook 10 is attaching the page — a CSF meta carrying parameters: { chromatic: { disableSnapshot: true } } and <Meta of={…} /> instead of title — since an attached docs entry inherits component-level parameters. Happy to push whichever form you prefer once the build says which way it went.

Comment thread docs/stories/pairing.mdx Outdated
Comment thread lib/src/remote/host/host-status-store.ts Outdated
nedtwigg and others added 3 commits August 20, 2026 20:02
`describePushTargets` said "Connect this machine to a Dormouse server **below**
to send push" for every `no-host`, but `no-host` is a superset of the seam the
Remote control section gates on. It covers a Host service that has not enrolled
*and* a build with no Host service at all — the website leaves it there forever
(`push-devices.ts`) — and in the second case `RemoteControlSection` renders
`null`, so the word pointed the reader at nothing.

The word now keys on `getPlatform().remoteHost`, the same seam the section
itself uses, read beside the two `getPlatform()` reads already in this render
and passed in so the copy function stays pure.

`PushNoHost` and the new `PushNotEnrolled` are the two cases side by side: same
push status, different builds, and only the second has anything below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VoYG8YjdVgHwhPd5TGEgB
The store polls every 2 s while the dialog is open and the service answers with
a fresh object each time, so `setState` notified on every tick and
`useSyncExternalStore` re-rendered the section twice a minute to paint identical
text.

Compared field-wise before storing, which is the whole of it: the five members
of `RemoteHostConsoleStatus` are primitives, and `loading` / `unsupported` are
singletons where matching kinds is the answer. This is what the sibling store
the same dialog reads already does — `setPushDevices` is commented
"Identity-guarded so a repeat write does not churn React" — and the two now read
as the matched pair they otherwise looked like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VoYG8YjdVgHwhPd5TGEgB
Storybook 10's `Meta` doc block is `({ of }) => …` — it destructures nothing
else, so the `parameters` prop compiled (its type is `BaseAnnotations & { of?,
title? }`) and was then never read. `parameters` on `<Meta>` was CSF-in-MDX,
removed in Storybook 7; the examples still showing it, Chromatic's own included,
predate that.

The dead prop cost nothing. The comment above it did: it told the next reader
this page cannot be the source of a Chromatic diff, which would have sent them
looking anywhere else first. Both are gone rather than replaced, because an
unattached docs page has no per-entry opt-out to replace them with — attaching
it to the `RemoteControlSection` meta would inherit the parameter but also
disable snapshots for the eight states that meta exists to cover.

Whether Chromatic captures docs entries here is still open. The story counts
lean toward no — CI published "234 stories" for a build whose index held 234
stories and one docs entry — and the next build's changes list settles it.

Also widens the `generation` comment: the mutation path drops an in-flight read
for the same reason unsubscribe does, so scoping it to unsubscribe read as
though enroll and disconnect no longer invalidated one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VoYG8YjdVgHwhPd5TGEgB

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

All three of the previous findings are closed. I re-traced the ones that needed it rather than taking the commit messages for it: hasHostService reads getPlatform().remoteHost !== undefined, which is the same expression RemoteControlSection's unsupported branch resolves to on every build that reaches production (#status() in service.ts returns a literal, never null, so the store's other route to unsupported is stub-only) — so "below" and the section now genuinely share a seam, and PushNoHost / PushNotEnrolled pin both sides of it. The dedup guard is right too: sameState covers all four kinds, the two singletons fall through to the matching-kind return by way of the a.kind !== b.kind early exit, and unsubscribe writes state = LOADING directly rather than through setState, so a reopened dialog is never deduped against the previous open's answer. The new test drives a real poll and asserts both directions.

One new thing, non-blocking.

sameState's five-field compare is the one place in this package that isn't spelled as a compile-time checklist. Two files over, sameRequest in activation.ts solves the identical problem — a fresh object off the bridge every event, compared field by field — and guards it with PAIRING_REQUEST_FIELDS ... satisfies Record<keyof PairingRequest, true>, whose comment is explicit about why: "a field added to the wire type and forgotten in a hand-written compare would silently leave the user…". The same failure exists here and is quieter, because it lands in the guard rather than in a renderer: add a field to RemoteHostConsoleStatus, render it in EnrolledView, and the poll fetches it while sameState never looks at it, so setState suppresses the publish and the section paints that field's value from whenever one of the other five last changed — stale for as long as the dialog stays open. Nothing catches it. test-remote-host-link.ts breaks on the addition (its own docstring says that is the point), which sends you to the fixture and the stories and past this function; sameState stays green because keyof is never mentioned. Inline suggestions port the activation.ts idiom over.

Minor, outside the diff: describePushTargets's docstring still opens with "no-host is the ordinary case for a build with no remote Host at all" — the one-build reading the fix exists to correct, now sitting seven lines above a comment that says no-host covers two. Whoever reads the docstring and stops there is set up to make the same edit again. Happy to push a one-sentence rewrite if you want it in this PR.

for (const listener of listeners) listener();
}

function sameState(a: RemoteHostStatusState, b: RemoteHostStatusState): boolean {

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.

The checklist half of the activation.ts idiom. RemoteHostConsoleStatus is already imported as a type, and satisfies is what turns a forgotten field into a compile error instead of a silently-suppressed publish.

Suggested change
function sameState(a: RemoteHostStatusState, b: RemoteHostStatusState): boolean {
/**
* Every field of a {@link RemoteHostConsoleStatus}, as a compile-time checklist.
*
* The same guard `sameRequest` uses in `activation.ts`, for the same reason: a
* field added to the interface and forgotten in this compare would be polled
* but never published, so the section would paint a stale value for as long as
* the dialog stays open.
*/
const STATUS_FIELDS = {
enrolled: true,
serverUrl: true,
hostId: true,
connection: true,
pairedClients: true,
} satisfies Record<keyof RemoteHostConsoleStatus, true>;
function sameState(a: RemoteHostStatusState, b: RemoteHostStatusState): boolean {

Comment on lines +83 to +91
if (a.kind === 'ready' && b.kind === 'ready') {
return (
a.status.enrolled === b.status.enrolled &&
a.status.serverUrl === b.status.serverUrl &&
a.status.hostId === b.status.hostId &&
a.status.connection === b.status.connection &&
a.status.pairedClients === b.status.pairedClients
);
}

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.

…and the compare that reads from it, so the field list has exactly one home.

Suggested change
if (a.kind === 'ready' && b.kind === 'ready') {
return (
a.status.enrolled === b.status.enrolled &&
a.status.serverUrl === b.status.serverUrl &&
a.status.hostId === b.status.hostId &&
a.status.connection === b.status.connection &&
a.status.pairedClients === b.status.pairedClients
);
}
if (a.kind === 'ready' && b.kind === 'ready') {
return (Object.keys(STATUS_FIELDS) as Array<keyof RemoteHostConsoleStatus>).every(
(field) => a.status[field] === b.status[field],
);
}

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