The last mile: a loopback tab that sees its own fleet - #46
Merged
Conversation
A browser on http://127.0.0.1:7717 listed one machine on a laptop that was fully joined, and nothing on any screen said why. It was missing three records and one read: no fleet key, no device certificate, no machine records — and `GET /directory` on the relay is a cross-origin fetch the Worker answers without `Access-Control-Allow-Origin`, so the answer was discarded before readDirectory saw a byte. Every pairing link points at the relay's address, which is another origin and another storage partition, so no ceremony this tab could run would have fixed any of it. The daemon side landed in #45. This is the browser: - **Enrolment on boot, loopback only.** The fleet posts this browser's device public key at `POST /api/fleet/enrol`, and pins the fleet public key and the certificate the machine signs for it. Once per epoch, never retried: 409 (this machine holds no fleet key) leaves the tab exactly as it was, which is the tab loopback has always been. - **The exemption, written down.** crypto/keys.ts forbids learning a fleet key over the wire, and that rule is untouched. What is different here is that there is no intermediary to be: the "wire" is a socket to 127.0.0.1, routed to the one process that owns the key and served this page. Nor is any authority created — a caller that can open /ws on loopback can spawn a shell and read the fleet *seed* out of relay.json. fleet/enrol.ts carries the argument in full, beside the two checks the browser still makes (the certificate must verify under the key it arrived beside and must name this browser's own device key — consistency, not trust). - **The directory through the daemon.** fleetSources' directoryFetch seam now carries `/api/fleet/directory` on a loopback tab and stays the plain cross-origin read on a relay tab. The bytes are the relay's own, so every blob is still verified under the pinned fleet key. - **Live discovery.** The expansion was one-shot per epoch, so a machine that joined this afternoon never appeared in a tab opened this morning. It re-runs on an interval and on focus, additive only: an empty read, a 502 or a key store that will not open drops nothing. - The fleet gap band's two loopback-facing sentences told the reader to pair from the relay, which that tab cannot act on. Both now name something it can do. Tests: a loopback tab with nothing in its store ends up holding both machines and dialling B's relay slot sealed to B's key with the enrolled certificate; both orders of the enrolment/welcome race; idempotence across reloads; 409 leaves a working one-machine tab; 502 keeps the machine the tab is on; the directory read targets the proxy on loopback and the relay origin elsewhere; discovery adds a late machine and never drops one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploying a relay from the Remote screen left the tab that did it in a state it could not get out of, and nothing said so. Three facts about that page were settled when the daemon served it: - its Content-Security-Policy, built from relay.json at serve time (LocalCSPFor), so a page served without a relay names none and the browser blocks the relay socket and the directory read whatever the app tries; - the relay origin, which a loopback tab learns from the welcome — and relayInfo() reports nothing while the leg is off, with no broadcast when that changes; - this browser's fleet identity, enrolled once per load and answered 409 by a machine that held no fleet key at the time. No amount of polling gets out of the first one, so the deploy's own result now names the reload and offers it. A button rather than an automatic navigation: the steps above it are the reader's only account of what was just done to their Cloudflare account, and every other flow on this card is careful not to navigate away from its own receipt. Setup only. A tab looking at the configured card was served with the relay already in relay.json, so it has nothing to gain from a reload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ns one The rule in "The second delivery" excludes loopback by name — a session cookie authenticates no key — and that stays exactly as it was. What was missing is that the tab it excludes is also the tab no QR can reach: a pairing link lands on the relay's origin, another storage partition, so the ceremony would admit a browser that is not this one. So the machine's own daemon answers for it, and the section says what that buys and what it costs: enrolment grants nothing a caller who can spawn a shell does not already hold (relay.json holds the seed), it must never become a wire message for the same reason (a relay-origin device cannot read that file), the pin is replaced here because the certificate arrives with the key from the process that minted both, and the directory route is transport rather than trust — it exists because the relay serves no CORS header, and the browser still verifies every blob under the pinned key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rebuild after an enrolment is gated on records actually being gained, so the second load and every one after it costs one expansion rather than two. The comment beside the gate now also carries what it deliberately swallows: a development-only double-mount race where two enrolments overlap and the second is told "already there" about records the fleet has not seen. The tab it leaves is one machine short until the next discovery tick, and every predicate that would catch it is wrong in the ordinary case — at the moment the enrolment resumes the expansion is usually still in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two of the loopback cases read post.calls after waiting on something that does not depend on the enrolment — the expansion the welcome triggers, which runs whether or not the browser was ever enrolled. Both raced, and both failed about one in three runs on a busy machine. Also the header on fleet.ts: what a loopback tab has to do before any of the merging below means anything, and that the machine set is no longer fixed at page load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 10, 2026
karngyan
added a commit
that referenced
this pull request
Aug 10, 2026
The sessions screen's gap band tells a reader their browser holds no key for the fleet and that pairing again from any machine on it will fix that. On a machine with no relay every clause of that is false: there is no fleet, no others to be missing, and no machine to pair against — and it is advice that cannot be followed and would fix nothing if it were. The band is fed by FleetGaps, which only an expansion produces, so it could only ever appear on a tab that had learned a relay origin. What it never did was give that origin back. A tab open across a `flue relay leave`, or a relay.json deleted by hand and the daemon restarted under it, reconnects to a welcome naming no relay at all — and kept the last expansion's counts for the life of the tab, plus a discovery tick asking a directory through a machine that had left, once a minute and on every focus, for an answer that can only be its own daemon's 404. So the fleet reads the fact it was already sent and ignored: a welcome with no relay on it — absent, or `off`, which the protocol says are one statement — is this machine saying it is on none. The origin goes, the once-per-epoch expansion flag goes with it so a machine that joins again builds against the new origin, and the gaps snapshot goes because it describes a fleet that is not there. noteGaps refuses a late one for the same reason, so an expansion still in flight cannot put the band back up. The slots already built stay: this machine leaving a relay says nothing about whether the others are still on it, which is the same reason adoptRemotes is additive. What each state says now. No relay, this load or since: nothing — one machine and no relay is a whole state, and the invitation to set up remote access belongs to the Remote screen, which can carry it out. A relay and no fleet key with no ceremony to this browser's name: the enrolment story from #46, unchanged — enrolThisBrowser answers a bare boolean, so 409 (this machine holds no fleet key) and 502 (the relay would not answer) cannot be told apart here, and `check flue status on this machine` is the honest sentence for both. A relay, no fleet key, and a machine this browser paired with: that machine is about to hand one over, unchanged. A relay and machines with no certificate to present to: unchanged. Tests: two at the fleet, two at the screen. The screen's `takes the band down when its machine leaves the relay` is the one that reproduces the report — it fails on main with the band still up, its text quoted in the diff — and `stops reading once the machine has left the relay it named` fails at four directory reads where it wants one. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.
Install flue on Mac A, deploy the relay, pair a phone, install flue on Mac B, run the join line — and B's sessions are now in A's own tab, A's in B's, and clicking one attaches in place. This is the last mile: the browser. The relay-origin path healed in #44 and the daemon side landed in #45.
What a loopback tab could not do, and why
A tab on
http://127.0.0.1:7717was three records and one read short of being on a fleet at all:GET /directoryis cross-origin from loopback and the Worker answers withoutAccess-Control-Allow-Origin, so the browser discarded it beforereadDirectorysaw a byte — andreadDirectoryreports every fault as "no machines", so the tab quietly showed a fleet of one on a fully joined laptop.None of it was fixable by pairing: the ceremony's link lands on the relay's origin, a different storage partition, so it would have admitted a browser that is not this one.
Enrolment on boot
The fleet posts this browser's device public key at
POST /api/fleet/enrolon the machine that served the page, and keeps what comes back: the fleet public key, pinned, and the certificate this machine signs for that key. Once per fleet epoch, gated strictly to the loopback origin — the flag comes fromsrc/main.tsx, where "how was this page served" is already answered, through the router's context to the provider, so nothing below sniffslocationand no scripted client in a test posts a device key at whatever answers.The enrolment and the daemon's welcome race, and both orders end in one rebuild: with no relay origin yet there is nothing to expand into and the welcome finds the records already in the store; with the welcome first the expansion ran blind and the enrolment's own rebuild repairs it, through the same once-per-epoch gate the welcome re-supply path uses.
The exemption comment, and why this is not a regression
crypto/keys.tsforbids learning a fleet key over the wire, and that rule is untouched —adoptFleetKeyis exactly as strict as it was. The argument for the narrow exemption is written out at the top ofweb/src/fleet/enrol.ts, and it is three things:/wson loopback can spawn a shell, and a shell can read the fleet seed out ofrelay.json— so it could already mint a certificate for any key it liked, valid on every machine. The endpoint collapses three steps into one for the honest case and changes nothing for the dishonest one. For the same reason it must never become a wire message: a relay-origin device cannot read that file, so awire.Enrolwould be an escalation.The pin is replaced here, unlike on a welcome, and the comment says why: the certificate arrives with the key from the process that minted both, so the pair is coherent on arrival, and a loopback tab has no ceremony to be sent back to.
spec/fleet-trust.mdgains a "third delivery" section carrying the same argument, beside the second-delivery rule it deliberately leaves alone.The directory, through the daemon
fleetSources' existingdirectoryFetchseam now carries/api/fleet/directoryon a loopback tab and stays the plain cross-origin read on a relay tab. The daemon forwards the relay's bytes unchanged, so every blob is still verified under the pinned fleet key: transport, not trust.409(this machine holds no fleet key) is never retried — one attempt per epoch, because the cure is a change on the machine — and502(relay unreachable) costs machines this browser does not learn of and nothing else.Live discovery
The expansion was one-shot per epoch, so a machine that joined at lunchtime never appeared in a tab opened at breakfast. It re-runs every 60s and on window focus, with a 5s floor so alt-tabbing is not a directory read per switch. Additive only:
adoptRemotesadds ids it does not already hold and removes nothing, so an empty read, a 502, or a key store that will not open drops no machine, no rows and no client.The reload-after-deploy trap
A tab that deploys a relay from the Remote screen cannot use it, and no amount of polling gets out of that: the page's Content-Security-Policy is built from relay.json when the daemon serves the document (
LocalCSPFor), so a page served without a relay names none and the browser blocks both the relay socket and the directory read. Two more facts settle at the same moment —relayInfo()reports nothing while the leg is off and nothing broadcasts a change, and this browser's enrolment was answered 409 by a machine that then held no fleet key. One reload fixes all three, so the deploy's own result says so and offers the button. A button rather than an automatic navigation: the steps above it are the reader's only account of what was just done to their Cloudflare account, and every other flow on that card is careful not to navigate away from its own receipt. Setup only — a tab looking at the configured card was served with the relay already named.The gap band
Both of
FleetGapBand's loopback-facing sentences told that tab to pair from the relay, which it cannot act on. The no-fleet-key one now names the only local answer (this machine has none to give — checkflue status, then reload), and the uncertified one names both doors, including the one that works on a machine's own address. In the healthy case the band does not render on loopback at all, because enrolment closes both gaps. #44'spinnedcount is what tells those cases apart and is kept.Test evidence
cd web && pnpm vitest run→ 1165 passed (59 files), three consecutive clean runs.pnpm run lint(tsc) clean.make web relaybuilds.go test ./...andgo vet ./...clean — no Go changed.The one that is the point, in
fleet.test.ts: a loopback tab with an empty store, a scripted daemon answeringPOST /api/fleet/enrol, and a directory naming two machines ends up holding both — pinning the fleet key and the certificate, listing this machine once (the twin is dropped rather than dialled a second time), and diallingwss://relay.example/client/loft-9f9fwith message A sealed to the key B's certificate names and the enrolled certificate in the payload. That client is whatTerminalRouteresolves from/d/<machineId>/s/<id>, which is the hrefSessionTablewrites for every row.Alongside it: both orders of the enrolment/welcome race; enrolment idempotent across reloads, with no second directory read on a load that gains nothing;
409leaves a working one-machine tab (no directory read, one POST, this machine's rows unchanged);502keeps the machine the tab is on; the directory read targets the proxy on loopback andhttps://relay.example/directorywhen nothing supplies a fetch; discovery adds a late machine, never drops one on an empty or failed read, asks again on focus but not on every glance, and reads nothing on a tab whose daemon has named no relay.enrol.test.tscovers the refusals — a certificate that does not verify under the key beside it, one for another device, a machine certificate where a device one belongs, a key that is not 32 bytes, a body that is not JSON — and the stale-pin replacement.provider.test.tsxpins that only the loopback tab posts anything at all.Every new case was run against un-fixed code: removing the enrolment call fails the five loopback cases, removing the discovery interval fails the late-machine case, removing the focus listener fails the focus case.
🤖 Generated with Claude Code