One-click Host enrollment from the installer's offer - #507
Open
nedtwigg wants to merge 11 commits into
Open
Conversation
…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
dormouse-bot
approved these changes
Sep 1, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF
Deploying mouseterm with
|
| Latest commit: |
01e1bfc
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://286609b2.mouseterm.pages.dev |
| Branch Preview URL: | https://enroll-offer.mouseterm.pages.dev |
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
dormouse-bot
approved these changes
Sep 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Item 1 of the
selfhost-onboardingscope (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
{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. Therun-serverwrapper exportsDORMOUSE_ENROLL_TOKEN_FILE.POST /api/host/enrolltakes exactly one of the setup password orenrollToken. 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.{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 andenrollOfferbootstraps the service contention likeenroll.deploy-lintenforces them (self-test green) and now also pins the password's CSPRNG assignment andserver.envlock-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 whileserver.envstays byte-identical.server.md/SELF_HOST.mdcheckpoint 4 /pairing.mdx§3; the install-root paths are pinned bymirrored-constants.test.tsagainst all three installers.Review passes
Each chunk (server, installers, host) got a
/simplifyand a/code-reviewpass, 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 purgeleaving 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
pnpm testgreen (deploy-lint self-test 50/50 load-bearing)manage verifycovers offer +run/🤖 Generated with Claude Code
https://claude.ai/code/session_01MMqtLjAob4fS9CgdEtNkaF