Skip to content

feat: the bridge's web page, signed in from the phone - #143

Merged
deymosh merged 30 commits into
masterfrom
feat/bridge-web
Oct 17, 2026
Merged

deymosh merged 30 commits into
masterfrom
feat/bridge-web

Conversation

@deymosh

@deymosh deymosh commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

The bridge's web page, signed in from a paired phone, and the phone's list of the browsers signed in. Rebased onto the terminal (#142).

What

  • Protocol — web-sign-in {code} (phone → bridge) and web-sign-in-result {code, signedIn}; web-browsers-request and web-browser-sign-out {id} (phone → bridge) answered with web-browsers {browsers} to the asking client only (a sign-out is answered with the fresh list). A browser's id is the page's short public id, never its key or cookie. Corpus fixtures for all.
  • Bridge — a web page on the direct link's wss:// port: the same listener and certificate, with a WebSocket upgrade still going to the direct link. Plain http:// redirects to https://; the onion listener serves no page. Sign-in uses a code plus a QR (codedeck://web-sign-in?machine=<npub>&code=) confirmed from a paired phone. The engine (engine/web.rs) matches each code once, within 5 minutes; the page withdraws a code it stops showing (WebSignInWithdrawn).
  • Tabs:
    • Overview — the heartbeat: bridge, direct link, agents and credential status, sessions.
    • Usage — per agent: sessions, what runs or waits, transcript size, models; then each session's context fill. All of it comes from the heartbeat, with no new request.
    • Browsers — the signed-in browsers, each with Sign out.
    • Devices — the paired phones, read-only.
    • Settings — the safe set the user chose (machine name, relays, direct link addresses, transcripts kept, admin tokens write-only). Everything else is read-only: workspace roots, Tor proxy, node and agent-host paths, listeners. A stolen cookie must not be able to run a program, drop Tor or widen file access.
  • Hardening — a Host allowlist; an Origin check on every POST; CSP, no-store, nosniff and frame-deny on every response; a __Host- cookie (Secure, HttpOnly, SameSite=Strict, 30 days) whose SHA-256, never the token itself, is what web-sessions.json (0600) keeps across restarts; sign-in starts rate-limited per address (IPv6 per /64); caps on pending codes (the engine's own count), on sessions (64) and on request sizes.
  • No cost to the phones' links — the bridge runs on one thread shared with the relays, the engine and the direct links. Page file writes run on their own thread, and a config write on the blocking pool. At most 16 page requests are served at once (503 past that), and one host may hold at most 8 of the listener's 64 connections.
  • Phone — the WebSignIn / ClearWebSignIn / RequestWebBrowsers / SignOutWebBrowser intents and the UI view's webSignIn and webBrowsers. A machine's page gets a "Web page" group (its https addresses) and a "Browsers" page: scan the QR in the app's own scanner, or type the code; then the browsers signed in now, each signed out after a confirmation. A QR naming another machine is refused, and there is deliberately no deep link. It is wired in the shell and in Settings, with snapshots.
  • Docs — PROTOCOL.md (the wire), BRIDGE.md (the page, its hardening, its files), CLIENT.md (the phone page), README.

Traffic cost

Two small command-class events per sign-in, and nothing while no one signs in. The browser list costs one request and one answer when the phone's Browsers page opens (and after a sign-in from it), one sign-out and one answer per sign-out; never polled. The page polls the bridge over HTTPS: every 2 s while a code is pending, every 5 s on the dashboard. None of that is Nostr traffic.

Breaking

New protocol messages, no migration.

Verified

  • ./codedeck check green; cargo doc --workspace --no-deps and clippy with -D warnings; bindings regenerated with no drift.
  • Android test verifyPaparazziDebug lintDebug: 203 tests, "No issues found". New snapshots: the Browsers page with its list, while asking, and with none; the machine page with the Terminal and the Web page groups together.
  • The development switch CODEDECK_WEB_NO_SIGN_IN is removed (bridge, page banner, compose file).
  • Earlier bug hunts' findings all fixed; one more runs on the browsers list. Still owed: a sign-in and the tabs on the maintainer's own device and browser.

🤖 Generated with Claude Code

deymosh and others added 22 commits October 17, 2026 17:57
… page

The bridge's web page has no password: it shows a short code, and a paired
phone sending that code as a signed command (`web-sign-in`) is what lets
the browser in. The engine keeps the codes the page is showing (told by
the runtime as each is shown), matches one once and only before it lapses,
answers the phone with `web-sign-in-result`, and tells the runtime which
browser to let in. A code read off a screen is worthless without a paired
phone, and a typed one is read in any case with or without its dash.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…e phone

The direct link's `wss://` listener now also serves a web page: a request
that is not a WebSocket upgrade is a page request, the bytes read to tell
them apart are replayed to the WebSocket handshake, and the onion listener
serves no page. Same port and certificate, so a bridge that has a direct
link has the page with no new setting, over TLS on the LAN or a VPN.

A browser signs in with the owner's phone: the page shows a code and its
QR, and once a paired phone sends it the browser gets a cookie that only
the server reads, only HTTPS carries, and no other site sends (`__Host-`,
`Secure`, `HttpOnly`, `SameSite=Strict`), for 30 days. Requests must name an
IP address, localhost or a direct endpoint's host; a POST must come from
the page's own origin; every response forbids framing, sniffing, referrers,
caching and any resource not from the page; sign-in starts are rate-limited
per address and pending codes and sessions are capped. The first page shows
the bridge, its direct link, its agents with their credential status and
its sessions — the heartbeat, which never carries a secret.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…the bind address

In a container the bind address is the container's own, which no browser
on the host can reach; the page is on the direct link's listener, so it is
wherever the bridge tells phones to dial.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… development switch skips sign-in

A browser that types `http://` reached the TLS listener and got TLS bytes
it could not read ("invalid response"). The listener now peeks at the first
byte: a TLS handshake opens with a handshake record, anything else is
answered with a redirect to the same page over HTTPS — and only for a host
the page would serve, so it redirects nowhere else.

`CODEDECK_WEB_NO_SIGN_IN=1` lets a browser on a loopback or private address
in without a phone sign-in, to work on the page before the phone side
exists. It is off unless set, logs a warning at start, and the page shows a
banner while it is on. It is for development and goes before the page
ships.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The wire already carries `web-sign-in` and its result; the phone now has the
core plumbing behind them, in the shape the provider-profile ack already set.

`UniffiIntent::WebSignIn` trims the code — whitespace is not a code, and an
empty one is refused as an intent error rather than published — and the
runtime sends the command on the ordinary signed path, recording `{code,
Sending}` for that machine in the ui store. The bridge's answer moves that
entry to `SignedIn` or `NotMatched` only when it names the code recorded now:
an answer to an older code, from a retyped one or a screen that was reopened,
is not about the attempt on screen, so it is dropped instead of reported. An
unanswered `Sending` stays as it is — the screen showing the code times
itself out, so silence is never turned into a verdict here.

`ClearWebSignIn` forgets the entry when the screen closes, so a late answer
cannot move something nothing is showing any more. `UniffiUiView.web_sign_in`
carries the entry per machine; it is UI state only, never persisted, like the
other ack statuses.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The engine reads a typed code in any case, with or without its dash, but
answered with the page's spelling, and the phone matches an answer to the
code it sent — so a code typed in lower case signed the browser in while
the phone kept waiting. The answer now carries the phone's own spelling.
Engine tests cover the single use, the any-case match, and an unknown or
lapsed code.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A machine's page gets a "Web page" group: the https address of the page on
each wss:// direct endpoint (onion and plain ws:// ones serve none), each
copyable, and a "Sign in a browser" row. That page scans the QR the web page
shows, through the pairing scanner (which now takes the line it prompts
with), or takes the typed code, and sends it with WebSignIn; it shows
"Signing in", then the bridge's answer, or that none came within 15 s.

A scanned code is sent only when the QR names this machine: a code shown by
one bridge handed to another would sign in the wrong page. There is no
deep link for the QR on purpose, so a link opened elsewhere cannot sign a
browser in without the user scanning it on this page.

Closing the page clears the machine's sign-in in the core, so a late answer
is not kept for a screen that is gone; the clear is dispatched on the core
host's own scope, since the composition's scope is already cancelled in
onDispose.

The page is wired in the shell and in Settings, each going back to the
machine's page. The full-page snapshots get a taller canvas: the machine's
page outgrew the old one.

Co-Authored-By: Claude Code <noreply@anthropic.com>
PROTOCOL.md gets the sign-in messages (the code's format, that it works
once for 5 minutes, that the answer echoes the code as the phone sent it,
and that a phone sends a scanned code only to the machine its QR names),
with their traffic cost. BRIDGE.md says where the page is served, how a
browser signs in and for how long, what it shows, and how it is hardened
for a network that is not trusted. CLIENT.md describes the phone's "Web
page" group and sign-in page.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… out

The bridge keeps signed-in browsers in memory only, so a restart ends
every web session before its 30 days; the phone's confirmation and
BRIDGE.md now say so rather than promise the whole 30 days.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… of linking a private const

Public rustdoc may not link a private item (cargo doc with -D warnings
fails on it), so the module doc says what the constant means: 30 days,
ended early by a restart since signed-in browsers live in memory only.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The engine honoured every code the page had shown until it lapsed, so a
code whose browser had signed out, or had been dropped to make room, still
answered a phone "signed in" for a browser that no longer existed.
Input::WebSignInWithdrawn removes the code, and the engine's cap is public
(MAX_WEB_SIGN_IN_CODES) so the page keeps exactly as many pending.

Co-Authored-By: Claude Code <noreply@anthropic.com>
- Signed-in browsers are capped at 64 as documented, dropping the longest
  signed in; before, the only cap counted pending and signed-in browsers
  together (up to 96) and could drop a browser still showing a live code.
- Pending sign-ins are capped at the engine's own count, and every pending
  browser dropped or signed out withdraws its code from the engine.
- The caps drop by an order counter rather than a millisecond clock, which
  gives two browsers the same age.
- Sign-in starts are counted per IPv6 /64 (IPv4-mapped addresses as IPv4):
  one host owns a whole /64 and could otherwise start without limit. The
  table of counts is bounded.
- The sign-in steps name the phone's row as it is: "Sign in a browser",
  under "Web page".

Co-Authored-By: Claude Code <noreply@anthropic.com>
A code works once. When the first answer was late and the phone sent the
code again, the first answer signed the browser in and the second, for the
same code, was a refusal that turned the screen to "did not match". A
refusal of a code that already signed in now leaves the entry as it is.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ery send waits anew

"No answer" was set after 15 s and cleared only by a new send, so a refusal
arriving later stayed hidden behind it; any change of the answer now clears
it. A resend of the same code changed none of the wait's keys, so it got no
wait of its own and could spin forever; the wait is keyed on the attempt.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The dashboard is now tabs (Overview, Usage) on the hash, rendered from the
status the page already polls, so switching tabs costs no request. Usage
shows, per agent, its sessions, what runs or waits now, transcript size
and the models in use, then every session's context fill, fullest first.
All of it is what the heartbeat already carries: nothing new is asked of
the bridge or the agents.

Session states are read as the heartbeat names them: a session waiting
for a permission or an answer showed its raw state in grey, since the page
looked for a "waiting" state that does not exist.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…config set

Signed-in browsers now survive a restart: the page keeps them in
`<home>/web-sessions.json`, owner-only (0600) and written atomically, keyed by
the cookie's SHA-256 — never the token, so a copy of the file cannot be
replayed as a cookie. Pending sign-ins are not written, a missing or corrupt
file starts empty (with a warning), and `lastSeenMs` moves in memory on every
authenticated request while reaching the disk at most once a minute. Each
browser also keeps the request's User-Agent (control characters stripped, at
most 120 characters) so the owner can tell one from another.

Three JSON APIs follow, all needing a signed-in browser (401 otherwise) and,
for a POST, the page's own Origin as before:

- `GET /api/browsers` lists the signed-in browsers and
  `POST /api/browsers/sign-out` ends one by the first 16 hex characters of its
  key (404 when unknown). Any signed-in browser may end any other: they are
  all the owner's.
- `GET /api/devices` shows the paired identities and their clients (labels and
  key expiries). The runtime hands the page the snapshot alongside the
  heartbeat and on every pairing change, and it carries nothing secret — not
  even a session key's public half.
- `GET /api/config` shows the config as it is in effect and `POST /api/config`
  changes only the safe set — the machine name, the relays, the direct
  endpoints, the transcript retention and the relay/Blossom registration
  tokens. Each value is validated, then written back keeping every other key
  (including ones this bridge does not know). A secret is shown only as
  set/not set, its value never reaches a response or a log line, and every
  field says whether a flag or environment variable overrides the file.

The safe set is deliberately small: the other keys name paths, listeners or
process settings whose change could break a running bridge, so the page has no
business writing them.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The bridge runs on one thread, shared by the relays, the engine, the
phones' direct links and the web page, so anything the page does there
is time every phone's traffic waits:

- The browsers file is written by a thread of its own, in order, and a
  config write runs on the blocking pool with only its own request
  waiting for it: each write ends in an fsync, which a slow disk or a
  Docker Desktop volume can hold for tens of milliseconds.
- At most 16 page requests are served at once; past that one is answered
  503 rather than let it queue work in front of the phones'.
- One host may hold at most 8 of the listener's 64 connections (an IPv6
  host counted by its /64), so a flood of page requests, or handshakes
  left hanging until their timeout, cannot take the slots phones need.

Co-Authored-By: Claude Code <noreply@anthropic.com>
- Browsers: every signed-in browser (which browser and system, its
  address, when it signed in and was last seen, which identity let it in),
  this one marked, each with Sign out.
- Devices: the paired phones, with their key, when they paired, how many
  copies of the app each has, whether one is here now and until when
  their keys are valid. Read-only: pairing and unpairing stay with the
  phone and the machine.
- Settings: the safe set as a form (machine name, relays, direct link
  addresses, transcripts kept, and an admin token beside its endpoint,
  write-only: shown as set or not, typed to replace, ticked to clear).
  A field a flag or an environment variable overrides is shown disabled,
  saying so. Everything else is listed read-only under "Set on the
  machine". Saving says the bridge needs a restart to apply it.

The form is built once per visit to the tab, so the page's poll never
wipes what is being typed.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…he browser out

The bridge now keeps signed-in browsers across restarts, so the
confirmation promises what is true: 30 days, or until it is signed out
(by itself, or from another browser's Browsers tab).

Co-Authored-By: Claude Code <noreply@anthropic.com>
…start

- A direct endpoint is accepted only when the bridge runs the listener it
  needs (wss:// the direct listener, ws:// the onion one): load refuses
  to start otherwise, so a page edit could have stopped the bridge until
  config.json was mended by hand. As a last guard, the changed file must
  still read as the bridge's config before it is written.
- An admin endpoint shows, and its token is offered, before the token is
  set: the endpoint was read from the pair that exists only once both are.
- A field counts as overridden only when load would take the flag or
  environment value; an empty or unparsable one leaves the file in effect.
- The Usage tab's meters show the context window's size, not the
  window object.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The development switch that let a browser on a private address see the
page without signing in is gone, with its banner and its compose entry:
the page shows the machine's sessions and agents, and a LAN or VPN is not
a trusted place, so a phone's sign-in is the only way in.

Co-Authored-By: Claude Code <noreply@anthropic.com>
deymosh and others added 6 commits October 17, 2026 18:04
…eb page groups

Both groups landed on the page from separate branches; the golden now
renders them together.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…b page

The bridge's web page already lets a signed-in browser read which browsers
are signed in (`GET /api/browsers`) and end one (`POST /api/browsers/sign-out`),
but a paired phone had no way to do either. It now does, so the owner can
manage the page's browsers from the phone without opening the page itself.

Three messages, v12 with no bump (a new variant is additive within a
version):

- `web-browsers-request` (phone to bridge, no fields) asks for the list. The
  phone sends it when the screen opens, never on a timer, so the list is
  fetched only when someone is looking at it.
- `web-browsers {browsers}` (bridge to phone, to the asking client alone)
  answers with one entry per signed-in browser. Each entry carries the page's
  short public id — the first 16 hex of the stored key's SHA-256, never the
  key and never the session token — the npub that signed it in, the sign-in,
  last-seen and expiry times, the address and the cleaned User-Agent.
- `web-browser-sign-out {id}` (phone to bridge) ends one by that id, and is
  answered with the same `web-browsers`, the fresh list, so a sign-out is
  confirmed by the list it produced.

Corpus fixtures (valid and rejected) and the conformance test's type lists
cover the three, so the wire shape is pinned.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The engine holds no browser list — the page does — so both new commands
become effects the runtime answers from its own `Web`: `web-browsers` for a
request, and for a sign-out the browser is revoked and the fresh list sent,
so the answer is always the state that resulted. Both go to the asking
client alone, like the other per-client replies.

`Web::browsers()` and `GET /api/browsers` now read one shared helper, so the
phone's list and the page's own JSON can never disagree about which browsers
are signed in or in what order; only the page's rows carry the `current`
mark, which only its own request can know. A bridge with no web page (no
direct link) answers an empty list rather than staying silent.

`revoke` persists through the page's writer thread, so ending a browser never
blocks the bridge's one thread.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…e phone

The phone can now show the browsers signed in to a machine's web page and
end one, the same thing the page's own APIs do. The list lives in the ui
store — transient, so it is never persisted or backed up — as
`UiState.web_browsers`, keyed by machine.

Two intents: `RequestWebBrowsers` sends the bare request when the screen
opens (never on a timer, so an unopened screen costs nothing), and
`SignOutWebBrowser` drops the row optimistically before sending, so a browser
the user ended does not linger while the answer is in flight. The bridge's
`web-browsers` replaces the whole list for its machine, which is what
confirms a sign-out.

The view carries the list as `UiView.web_browsers`, projected through
client-ffi as the `UniffiWebBrowser` record (its `id` is the page's short
public id, never the session token). Regenerated the Kotlin bindings.

Co-Authored-By: Claude Code <noreply@anthropic.com>
PROTOCOL.md gains the three messages in the web page sign-in section, with
the request's send-on-open rule and what each field of a browser entry is;
BRIDGE.md and CLIENT.md say in one place each that the phone reads and ends
the page's browsers through the same list the page's own APIs serve.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ns one out

The machine's "Sign in a browser" page becomes "Browsers": signing a
browser in as before, then the browsers signed in now, each named as the
web page names it ("Firefox on Linux"), with its address, when it was last
seen and, when another identity signed it in, which one. Sign out asks
first, as the page does. The list is asked for when the page opens and
again after a sign-in from it, never polled.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The web page's own instructions, BRIDGE.md and CLIENT.md name the page as
the phone now calls it, and CLIENT.md says when the list is asked for.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@deymosh deymosh changed the title feat: the bridge's web page, signed in from a paired phone feat: the bridge's web page, signed in from the phone Oct 17, 2026
A lapsed browser is only reaped when it next asks for something, so until
then the page's list, and now the phone's, showed it signed in. The list
leaves it out.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@deymosh
deymosh marked this pull request as ready for review October 17, 2026 16:48
@deymosh
deymosh merged commit c2d9e92 into master Oct 17, 2026
6 checks passed
@deymosh deymosh mentioned this pull request Oct 17, 2026
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