Skip to content

The last mile: a loopback tab that sees its own fleet - #46

Merged
karngyan merged 5 commits into
mainfrom
feat/loopback-fleet-browser
Aug 10, 2026
Merged

The last mile: a loopback tab that sees its own fleet#46
karngyan merged 5 commits into
mainfrom
feat/loopback-fleet-browser

Conversation

@karngyan

Copy link
Copy Markdown
Contributor

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:7717 was three records and one read short of being on a fleet at all:

  • No fleet key. It never ran a ceremony, because it never needed one — the session cookie is its credential.
  • No device certificate. Which is the only thing a machine it has never met will admit it on (spec/fleet-trust.md, rule 2).
  • No machine records. Only the relay branch of the pairing page writes those.
  • And the directory read was blocked. GET /directory is cross-origin from loopback and the Worker answers without Access-Control-Allow-Origin, so the browser discarded it before readDirectory saw a byte — and readDirectory reports 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/enrol on 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 from src/main.tsx, where "how was this page served" is already answered, through the router's context to the provider, so nothing below sniffs location and 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.ts forbids learning a fleet key over the wire, and that rule is untouched — adoptFleetKey is exactly as strict as it was. The argument for the narrow exemption is written out at the top of web/src/fleet/enrol.ts, and it is three things:

  • There is no intermediary to be. The "wire" is a socket to 127.0.0.1, which the OS routes to one process on this computer: the process that owns the fleet key, wrote it to relay.json and served this page. The rule exists to stop an unknown party choosing the anchor every machine certificate hangs from; here there is no such party.
  • No authority is created. A caller that can open /ws on loopback can spawn a shell, and a shell can read the fleet seed out of relay.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 a wire.Enrol would be an escalation.
  • The browser still checks what it stores. The certificate must verify under the key it arrived beside and must name this browser's own device key. Consistency rather than trust — the trust is the origin — and it keeps the browser from storing a blob it could only ever be refused for presenting.

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.md gains a "third delivery" section carrying the same argument, beside the second-delivery rule it deliberately leaves alone.

The directory, through the daemon

fleetSources' existing directoryFetch seam now carries /api/fleet/directory on 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 — and 502 (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: adoptRemotes adds 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 — check flue 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's pinned count is what tells those cases apart and is kept.

Test evidence

cd web && pnpm vitest run1165 passed (59 files), three consecutive clean runs. pnpm run lint (tsc) clean. make web relay builds. go test ./... and go 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 answering POST /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 dialling wss://relay.example/client/loft-9f9f with message A sealed to the key B's certificate names and the enrolled certificate in the payload. That client is what TerminalRoute resolves from /d/<machineId>/s/<id>, which is the href SessionTable writes 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; 409 leaves a working one-machine tab (no directory read, one POST, this machine's rows unchanged); 502 keeps the machine the tab is on; the directory read targets the proxy on loopback and https://relay.example/directory when 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.ts covers 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.tsx pins 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

karngyan and others added 5 commits August 10, 2026 23:12
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>
@karngyan
karngyan merged commit 0cea608 into main Aug 10, 2026
1 check passed
@karngyan
karngyan deleted the feat/loopback-fleet-browser branch August 10, 2026 18:00
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>
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.

1 participant