Skip to content

One-click Host enrollment from the installer's offer - #507

Open
nedtwigg wants to merge 11 commits into
mainfrom
enroll-offer
Open

One-click Host enrollment from the installer's offer#507
nedtwigg wants to merge 11 commits into
mainfrom
enroll-offer

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Sep 1, 2026

Copy link
Copy Markdown
Member

What

Item 1 of the selfhost-onboarding scope (docs/specs/server.md ## Future), continuing #495. Run the installer → open Settings → one click, and the machine is enrolled: no origin typed, no 64-hex setup password ferried across surfaces.

Implementation

  • Installers (all three) mint a one-time 64-hex CSPRNG token on every run and leave {origin, token, mintedAt} at <install root>/run/enroll-offer.json, locked to the installing user before the token is written, only after the post-switch health check passes. The run-server wrapper exports DORMOUSE_ENROLL_TOKEN_FILE.
  • Server: POST /api/host/enroll takes exactly one of the setup password or enrollToken. The offer is read fresh per attempt, expires after 7 days, and is unlinked before the enrollment is minted — single-use even under races; every failure mode answers the same delayed 401.
  • Host: the service reads the platform well-known path while un-enrolled and reports {origin, suggestedLabel} — the token never enters a webview. Settings → Remote control leads with the offer card (name prefilled from the hostname, one Enroll button, origin echoed back so a swapped offer refuses); the three-field form folds behind a disclosure as the remote-server fallback. Works in both hosts — VS Code's idle answer reads the offer in-process and enrollOffer bootstraps the service contention like enroll.
  • Audit chain: SECURITY.md gains the fifth at-rest credential and four FAIL IF lines; deploy-lint enforces them (self-test green) and now also pins the password's CSPRNG assignment and server.env lock-ordering that the refactor made spoofable; CI's Linux test-mode install asserts the offer's shape, mode, and that the token rotates across an update while server.env stays byte-identical.
  • Spec promotion: Future item 1 deleted, behavior stated above the fold in server.md / SELF_HOST.md checkpoint 4 / pairing.mdx §3; the install-root paths are pinned by mirrored-constants.test.ts against all three installers.

Review passes

Each chunk (server, installers, host) got a /simplify and a /code-review pass, findings fixed and mutation-verified. Highlights the reviews caught: Windows culture-sensitive timestamps silently defeating the 7-day expiry (th-TH mints year 2569); manage purge leaving a live redeemable credential; the ENOENT unlink race answering an oracle 500; the async status read racing the enrolled-gate seed; the offer card unmounting mid-enroll and swallowing the failure after the single-use token was spent.

Testing

  • server 169 · server-lib-common 126 · lib 1857 · vscode-ext 113 · full root pnpm test green (deploy-lint self-test 50/50 load-bearing)
  • macOS installer exercised twice in test mode: token rotates, 0600/0700 modes held, purge removes the offer, manage verify covers offer + run/

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF

nedtwigg and others added 9 commits August 31, 2026 15:02
…nroll

First slice of selfhost-onboarding item 1 (server.md ## Future): the shared
EnrollmentOffer shape in server-lib-common, DORMOUSE_ENROLL_TOKEN_FILE (read
fresh per attempt, unlinked before the enrollment so the token is single-use),
and an enroll route that takes exactly one of the setup password or the offer's
token. Installers and the Host-side offer come next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF
One secretEquals shared by password, host-token and enroll-token compares;
redemption extracted to enroll-token.ts mirroring runtime-file.ts; the URL
ambient moved to the package globals.d.ts seam; say-it-once trims across the
spec row, config doc and tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF
ENOENT on the unlink is a lost race (401, not 500); the not-invalidated 500
waits the credential delay so a valid token cannot be confirmed unspent;
offers expire after 7 days; exactly-one is presence, not typedness, and the
wire type is now a union; unusable offer files warn while spent ones stay
silent; junk-format tokens reject before any I/O; secretEquals hashes utf16le.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF
All three installers write run/enroll-offer.json — fresh 64-hex CSPRNG token
per run, locked to the installing user before the token lands — and the
run-server wrapper exports DORMOUSE_ENROLL_TOKEN_FILE. SECURITY.md gains the
fifth at-rest credential and three FAIL IF lines, deploy-lint enforces them
(self-test green), CI's Linux test-mode install asserts the offer's shape and
that the token rotates across an update, and the SELF_HOST.md installer
contract records the invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF
Mint moved after the release switch so a failed install cannot burn an unspent
offer; run/ named, grouped and pinned 0700; the password CSPRNG rule anchored
on its assignment (the helper extraction had left it spoofable) plus a
lock-before-write rule for server.env on all three platforms; the run/
placement rationale stated once; CI folds the mode check into one reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF
Windows timestamps are culture-invariant (fi-FI's '.' separator and th-TH's
Buddhist calendar silently defeated the 7-day expiry; BUILT_AT/RELEASE_ID had
the same class of bug); the mint runs only after the health check so a rolled-
back update keeps its previous offer; purge deletes run/; run/ is owner-only,
verified, and pinned by lint on every platform; lock-ordering lint rules bind
their operands; deploy-lint survives CRLF checkouts; CI validates the offer
with the server's own isEnrollmentOffer; threat-model prose says possession,
not local identity, is what redemption checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF
The Host service reads run/enroll-offer.json at the platform well-known path
while un-enrolled and surfaces {origin, suggestedLabel} — never the token —
in its status; a new enrollOffer command re-reads the file and enrolls with
the one-time token through the same gated flow as the typed form. Settings →
Remote control leads with the offer card when one is present, with the
three-field form folded behind a disclosure as the remote-server fallback.
Future item 1 promoted above the fold; SELF_HOST.md checkpoint 4 and the
pairing walkthrough follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF
The offer now works in VS Code — enrollOffer bootstraps the contention and
the idle status reads the offer file in-process; the status snapshot moved
off the lifecycle lease; suggestedLabel is flat status (hostname is not part
of the installer's offer) and prefills the typed form too; STATUS_FIELDS
carries one comparator per field; one busy-action hook and one name field in
the section; the install-root path table is pinned by mirrored-constants
against all three installers; say-once sweep across specs and code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF
Status reads snapshot after their last suspension so a seed cannot lose the
enrolled-gate race; the offer card survives its file being spent mid-enroll
and the typed form stays mounted across offer flips; enrollOffer echoes the
reviewed origin and refuses a swapped one; one parseEnrollmentOffer and one
unenrolledStatus shared across server, service and the VS Code idle answer;
the mirror test pins the full offer path against all three installers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF
Comment thread lib/src/remote/host/host-status-store.ts Outdated
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 1, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 01e1bfc
Status: ✅  Deploy successful!
Preview URL: https://286609b2.mouseterm.pages.dev
Branch Preview URL: https://enroll-offer.mouseterm.pages.dev

View logs

Single-use rested on unlink-ENOENT losing the race, but on macOS/APFS two
concurrent unlinks of one path both report success (measured 188/200 double
redemptions at N=8). The winner is now decided by rename to a unique claim
path — exactly one concurrent redemption can win — and the claimed file is
re-verified so an installer re-mint sliding into the window is restored, not
consumed. Stressed 300/300 single-winner through the built module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF
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