Phase 9: authentication — pairing, device tokens, a per-launch token for the desktop app #17

Merged
alex merged 121 commits from refactor/phase-9-auth into refactor/phase-8d-workspaces 2026-10-29 18:46:10 +01:00
Owner

Phase 9 of the DDD/CQRS refactor: authentication. A device pairs with a one-time code, every request proves who it is, and the desktop app protects itself with a token made at each launch. The change was security-reviewed adversarially, and every finding was fixed or documented as an accepted limit before this PR. The review also surfaced a real race in workflow runs, fixed here too. Stacked on #16.

What changes

Who may ask what (the rule is decideAccess; server/middleware/auth.ts runs before every page and route):

Request Needs
From another machine a paired device's token (kanbot_device, an HttpOnly SameSite=Strict cookie, or Bearer). Pages redirect to /pair?redirect=; the API answers 401.
From this machine, packaged app the per-launch token. Electron makes it and hands it over the utility process's message port, never the environment (which ps -E would show to any of your processes, agents included). The window and its cookie use a per-install kanbot-<hex>.localhost name, so the cookie never reaches other servers on 127.0.0.1.
From this machine, no token (dev, tests, node .output/…) trusted, behind three checks: a loopback Host on the listening port (no DNS rebinding), a same-origin Origin/Sec-Fetch-Site (no cross-site posts), and no proxy headers. A request through a proxy counts as another machine.
Under nuxt dev the dev listener stamps each request with its real client address, signed with a per-process key. Nitro's dev proxy passes a client's own X-Forwarded-For through, so that header is never believed.
Anyone /pair, POST /api/auth/pair (body capped at 4 KB, 10 tries per 10 minutes per address), static assets, icons, the browser handoff link.

Pairing. Settings › Devices offers a six-digit code: crypto.randomInt, five minutes, single use, burnt after five wrong tries, compared in constant time. Every failure gives the same answer. The pairing window shows the code with a countdown, a QR code (uqr, inline SVG) and the hub's certificate fingerprint. /pair works with no token, reads no data, and follows the browser's language. A device's 32-byte token is returned once and stored only as its sha256.

Roles. Only this machine's own app (or a tokenless loopback session) may offer codes or revoke other devices; a paired device may only revoke itself. Revoking closes the device's open streams.

The hub's certificate. An ECDSA P-256 certificate is generated once into the data dir with pkijs (pure JS); its common name is the machine id. Its fingerprint is the hub's identity. HTTPS is switched on with hub mode in Phase 11.

Hardening from the security review:

  • Attachments are served with nosniff and a sandboxing CSP, and as downloads unless they are raster images, PDF, plain text or video. SVG and HTML are always downloads (an SVG served inline ran script as Kanbot).
  • Every response carries X-Frame-Options, nosniff and Referrer-Policy.
  • RPC handlers learn the caller from the HTTP request, never from message headers (which a client controls).
  • The desktop window allows navigation by exact origin (http://127.0.0.1:PORT@evil.com used to load evil.com inside it).
  • The tray's live updates now carry the token. Without it they would have been refused forever.

Bugs fixed along the way

  • Closed tabs leaked their live updates: a closed tab's live-updates subscription kept running on the server, with its bus listener and an unbounded queue. h3 never cancelled streamed responses, so the RPC route now cancels them when the socket closes.
  • Workflow runs started out of order: a run's id came from a crypto.subtle digest on libuv's thread pool, so runs reached the engine late and out of order. That is where the suite's intermittent failures came from, and under load a wake at a gate could be lost. Runs now start under the id the task already records.
  • A stopped run could act once more: a stop landing between recording a step and running it still ran the step. A task finished by hand at its gate went back to the gate with a "waiting" notification, or an agent started for a dead run. Each node action now checks that its run is still the task's.

Checks

  • 1,128 tests (988 before), passing in five consecutive full runs; lint, typecheck, Storybook and the Nuxt build.
  • Each review fix and each race fix has a test that fails before the fix and passes after. The races are forced with latches, not timing.
  • Production build on 127.0.0.1, with curl:
    • spoofed Host/Origin, cookie and Bearer, pairing (wrong code 401, right code returns a token), roles;
    • the 4 KB cap (a 400 MB body is refused at once), proxy headers, attachment headers;
    • revoking closes the device's live-updates stream.
  • Bound to 0.0.0.0 temporarily, through the LAN IP:
    • no token → 401, and a page → 302 to /pair;
    • a browser paired over the LAN rendered /settings with its data;
    • after revoking, its cookie got 401.
  • Packaged app: a windowless Electron harness ran the bundled server through utilityProcess.
    • The token arrived over the port: 200 with it, 401 without it.
    • The app's own host name was accepted, and evil.localhost got 403.
    • ps -E shows no token.

Accepted limits (documented in CLAUDE.md › Auth)

  • Plain HTTP until Phase 11. Codes and tokens cross the LAN in clear, and the fingerprint on /pair proves nothing yet.
  • Anyone who can reach the hub can burn the current code with five wrong tries; that is the price of making guessing hopeless.
  • The "try again" page lets another site open a Kanbot tab with a dialog prefilled. Nothing runs by itself.
  • With no per-launch token (npm run dev, npm start), any local process is trusted.
  • The pairing offer, the limiter and the handoff nonces live in memory, so a restart withdraws the code.

Not exercised

The real desktop window was not opened: its cookie on the kanbot-… name, the tray, notifications, and "Open in browser" in Safari. That Safari resolves *.localhost is inferred from the macOS resolver. Not run on Windows. No Forgejo runner.

🤖 Generated with Claude Code

Phase 9 of the DDD/CQRS refactor: authentication. A device pairs with a one-time code, every request proves who it is, and the desktop app protects itself with a token made at each launch. The change was security-reviewed adversarially, and every finding was fixed or documented as an accepted limit before this PR. The review also surfaced a real race in workflow runs, fixed here too. Stacked on #16. ## What changes **Who may ask what** (the rule is `decideAccess`; `server/middleware/auth.ts` runs before every page and route): | Request | Needs | |---|---| | From another machine | a paired device's token (`kanbot_device`, an HttpOnly SameSite=Strict cookie, or Bearer). Pages redirect to `/pair?redirect=`; the API answers 401. | | From this machine, packaged app | the per-launch token. Electron makes it and hands it over the utility process's message port, never the environment (which `ps -E` would show to any of your processes, agents included). The window and its cookie use a per-install `kanbot-<hex>.localhost` name, so the cookie never reaches other servers on 127.0.0.1. | | From this machine, no token (dev, tests, `node .output/…`) | trusted, behind three checks: a loopback `Host` on the listening port (no DNS rebinding), a same-origin `Origin`/`Sec-Fetch-Site` (no cross-site posts), and no proxy headers. A request through a proxy counts as another machine. | | Under `nuxt dev` | the dev listener stamps each request with its real client address, signed with a per-process key. Nitro's dev proxy passes a client's own `X-Forwarded-For` through, so that header is never believed. | | Anyone | `/pair`, `POST /api/auth/pair` (body capped at 4 KB, 10 tries per 10 minutes per address), static assets, icons, the browser handoff link. | **Pairing.** Settings › Devices offers a six-digit code: `crypto.randomInt`, five minutes, single use, burnt after five wrong tries, compared in constant time. Every failure gives the same answer. The pairing window shows the code with a countdown, a QR code (`uqr`, inline SVG) and the hub's certificate fingerprint. `/pair` works with no token, reads no data, and follows the browser's language. A device's 32-byte token is returned once and stored only as its sha256. **Roles.** Only this machine's own app (or a tokenless loopback session) may offer codes or revoke other devices; a paired device may only revoke itself. Revoking closes the device's open streams. **The hub's certificate.** An ECDSA P-256 certificate is generated once into the data dir with pkijs (pure JS); its common name is the machine id. Its fingerprint is the hub's identity. HTTPS is switched on with hub mode in Phase 11. **Hardening from the security review:** - Attachments are served with nosniff and a sandboxing CSP, and as downloads unless they are raster images, PDF, plain text or video. SVG and HTML are always downloads (an SVG served inline ran script as Kanbot). - Every response carries `X-Frame-Options`, `nosniff` and `Referrer-Policy`. - RPC handlers learn the caller from the HTTP request, never from message headers (which a client controls). - The desktop window allows navigation by exact origin (`http://127.0.0.1:PORT@evil.com` used to load evil.com inside it). - The tray's live updates now carry the token. Without it they would have been refused forever. **Bugs fixed along the way** - **Closed tabs leaked their live updates:** a closed tab's live-updates subscription kept running on the server, with its bus listener and an unbounded queue. h3 never cancelled streamed responses, so the RPC route now cancels them when the socket closes. - **Workflow runs started out of order:** a run's id came from a `crypto.subtle` digest on libuv's thread pool, so runs reached the engine late and out of order. That is where the suite's intermittent failures came from, and under load a wake at a gate could be lost. Runs now start under the id the task already records. - **A stopped run could act once more:** a stop landing between recording a step and running it still ran the step. A task finished by hand at its gate went back to the gate with a "waiting" notification, or an agent started for a dead run. Each node action now checks that its run is still the task's. ## Checks - 1,128 tests (988 before), passing in five consecutive full runs; lint, typecheck, Storybook and the Nuxt build. - Each review fix and each race fix has a test that fails before the fix and passes after. The races are forced with latches, not timing. - Production build on 127.0.0.1, with curl: - spoofed `Host`/`Origin`, cookie and Bearer, pairing (wrong code 401, right code returns a token), roles; - the 4 KB cap (a 400 MB body is refused at once), proxy headers, attachment headers; - revoking closes the device's live-updates stream. - Bound to 0.0.0.0 temporarily, through the LAN IP: - no token → 401, and a page → 302 to `/pair`; - a browser paired over the LAN rendered `/settings` with its data; - after revoking, its cookie got 401. - Packaged app: a windowless Electron harness ran the bundled server through `utilityProcess`. - The token arrived over the port: 200 with it, 401 without it. - The app's own host name was accepted, and `evil.localhost` got 403. - `ps -E` shows no token. ## Accepted limits (documented in CLAUDE.md › Auth) - Plain HTTP until Phase 11. Codes and tokens cross the LAN in clear, and the fingerprint on `/pair` proves nothing yet. - Anyone who can reach the hub can burn the current code with five wrong tries; that is the price of making guessing hopeless. - The "try again" page lets another site open a Kanbot tab with a dialog prefilled. Nothing runs by itself. - With no per-launch token (`npm run dev`, `npm start`), any local process is trusted. - The pairing offer, the limiter and the handoff nonces live in memory, so a restart withdraws the code. ## Not exercised The real desktop window was not opened: its cookie on the `kanbot-…` name, the tray, notifications, and "Open in browser" in Safari. That Safari resolves `*.localhost` is inferred from the macOS resolver. Not run on Windows. No Forgejo runner. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
alex added 26 commits 2026-10-13 10:43:47 +02:00
The Devices context: what a paired device and a pairing offer are
Some checks failed
CI / check (push) Has been cancelled
4a2400c47c
The contract Phase 9 builds on: a Device (only its token's hash is stored),
a PairingOffer (one-time six-digit code, expiry, address, the hub's
certificate fingerprint), one PairingRefused for every way a code fails so a
guesser learns nothing, and the Devices RPC group Settings uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings › Devices needs the list, a pairing code and revoking, and the
/pair page needs the one call that arrives without a token. The RPC side
follows the other contexts: an api module (composables/api/devices), a
query composable refreshed on `devices.changed`, and a command composable
that re-reads the list after a revoke so the row greys without waiting for
the live message. usePairing is plain $fetch to POST /api/auth/pair: it is
the request that sets the HttpOnly cookie, so it cannot go through the RPC
client, and it maps the answers to what the page says (400/401 refused,
429 too many tries, anything else a failure to show).

The client merges `Devices` into KanbotRpcs on its own side until the
server serves the group (Phase 9 A adds it to KanbotRpcs with its
handlers; the merge goes then). DeviceNotFound and PairingRefused get
their catalogue keys, and the catalogues carry every string the pairing UI
uses, in English and Spanish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A protected Kanbot sends a browser it does not know to /pair, so the page
must render with nothing the auth would refuse: it reads no query (no
settings, no plugins, no live updates), uses a bare layout with no header,
tour or shortcuts, and the setup middleware leaves it alone. Its one
request is the pairing POST; on success it reloads the app at `redirect`,
a same-origin path only (safeRedirect refuses //host, /\host, schemes and
/pair itself), so everything afterwards goes out with the new cookie.

The code is one numeric field that keeps only digits, so a pasted
"123 456" works, and it pairs the moment the sixth digit arrives. A code
prefilled from the pairing address waits for the user, who should compare
the hub's fingerprint (`fp`, shown when the address carries it) first.
The device name defaults to the browser and system the User-Agent names.
Whoever opens /pair is not yet someone this Kanbot knows, so the page
speaks their browser's language rather than the hub owner's setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A section like the others (rail entry, searchable by its keywords) lists
the devices with their kind, when they were paired and last seen, and
"This device" on the one the page is open on; revoked ones are greyed and
hidden behind a toggle. Revoking asks first and warns when it is this
device, which the page then sends to /pair. "Pair a device" asks the hub
for a code when it opens and shows it big with its countdown, the address
and its QR code, and the hub's fingerprint to compare. Reopening keeps the
same code while it lasts, since asking again would end it; an expired one
offers a new one, and a device that pairs while the code is on screen
closes it with a toast.

The page passes the list and the commands as props, so every component
has a story with a fake hub. The QR code is uqr (pure JS, already in the
tree through Nuxt, now a direct dependency) drawn as one inline SVG path
on a white tile, which stays scannable in dark themes. The tour gains a
short step on the section. Component tests cover typing and pasting the
code, auto-submit, each outcome the /pair card shows, the countdown and
how usePairing reads the server's answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The address and its QR code carry the code, so after the countdown runs
out they only lead the other device to a refusal. They step aside until
"New code" brings a live one; a component test drives the expiry and the
renewal with fake timers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 9 gives the hub an identity a device can check when it paires,
Syncthing-style: a self-signed certificate whose SHA-256 fingerprint is shown
beside the pairing code. It is an ECDSA P-256 key from Node's WebCrypto, signed
by pkijs (pure JS, already in the tree through electron-builder): no native
module and no OpenSSL to shell out to, so the desktop bundle stays portable.

hub.crt and hub.key (0600) are written once, for this machine's id, valid ten
years, and read back on every boot; they are only remade when a file is
missing or the key is not the certificate's, since a new fingerprint is a new
identity to every paired device. HTTPS stays off until hub mode (Phase 11);
hubCertificatePaths is what it will read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Devices context's server half. A device is a row in `devices` (migration
0020) holding only the SHA-256 of its token: the token (32 random bytes,
base64url) exists in clear once, in pairDevice's answer, and a request's token
is checked by hashing it and finding that hash (compared again in constant
time). Revoking keeps the row and stops the token for good.

The pairing code lives in memory (PairingOffersMemory): it lasts five minutes,
so a restart withdrawing it only costs a new code, and nothing secret reaches
the disk. Six digits from crypto.randomInt, compared in constant time; a newer
offer replaces the older, a right code is spent, and five wrong tries burn it.
Every way a code fails is the one PairingRefused, so a guesser learns nothing.

DevicePaired and DeviceRevoked reach screens as `devices.changed`; pairing and
revoking are logged without tokens or codes. The 0013 migration moved verbatim
into its own file to keep the list under its size limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
server/middleware/auth.ts runs before every route and page (after Nitro's
static files, which are the public build), and decides with one pure rule,
decideAccess (packages/core/src/devices):
- From another machine, by the socket's address (never X-Forwarded-For): a
  paired, unrevoked device's token, as the kanbot_device cookie or a Bearer
  header. Otherwise a page goes to /pair?redirect=…, anything else is 401 JSON.
- From this machine, with KANBOT_LOCAL_TOKEN set (the packaged app): that
  token (kanbot_local cookie or Bearer, compared in constant time) or a device
  token. The variable leaves the environment when boot reads it, so agents and
  setup commands do not inherit it.
- From this machine without it (dev, tests, scripts): open as before, but the
  Host must name this machine and port (DNS rebinding), and a foreign Origin,
  a cross-site request other than a top-level navigation, or an unsafe one
  from another site is 403 (CSRF from web pages).
- Open to anyone: /pair, POST /api/auth/pair, /_nuxt and /_i18n assets, the
  icons, and the browser handoff link; dot segments and encoded separators
  never count as open.

Nuxt renders pages by calling /api/rpc in process with the page request's
headers. The middleware gives each let-through request a random caller id,
in a header only this process writes (a client's is removed), so those calls
carry the page's caller; the RPC handlers read it for Devices.list's
isCurrent, and offerPairing/revoke die without one.

POST /api/auth/pair is rate-limited per address (IPv6 by /64): the eleventh
try in ten minutes is 429 before the code is read. A browser gets only the
HttpOnly SameSite=Strict cookie; a node or script the token, once. A device
may revoke itself (signing out): the hub's own window is never a device, so
no one is locked out. A stale device cookie is dropped. "Open in browser"
trades a one-minute, single-use nonce the shell asks for (local token only)
for the per-launch cookie, on this machine only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the server now refusing this machine's requests unless they show
KANBOT_LOCAL_TOKEN, the shell makes one for each launch (32 random bytes)
and is the only holder: it passes it to the server it forks, sets it as the
HttpOnly, SameSite=Strict `kanbot_local` cookie on the window's session before
loading the board, and sends it as a Bearer header from its own REST calls,
the boot poll of /api/status and the SSE client. Other local programs and
web pages can no longer drive the app.

"Open in browser" (tray and Help menu) asks the server for a one-time link
that gives the system browser the cookie, since a plain URL would now be
refused. desktop:dev (ELECTRON_DEV_URL) has no token, as its dev server has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge the pairing UI (wip/p9-ui) onto the auth server side
Some checks failed
CI / check (push) Has been cancelled
ee0e0f7551
The app's client takes Devices from KanbotRpcs again, now that the server
serves it. Both tracks added the same error strings; the translation files
also carried a duplicate machineUnavailable key from Phase 7. JSON keeps the
last of each, so the shorter pairing message and the machine message that
names the machine were being overridden. One of each is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The packaged app's tray and notifications listen to /api/events, and
since Phase 9 every request from this machine must carry the per-launch
token. main.js subscribed without it: the stream was answered 401 and
retried forever, so the tray never refreshed on its own and no desktop
notification was ever shown.

All of the shell's requests (the boot poll, the REST calls, the live
feed) now go through one client, electron/shellClient.js, made with the
launch's headers, so no call can be the one that forgets them. A test
checks all three carry the token and that main.js makes no request of
its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main.js let the window follow a link or open a popup when the target
string started with the server's address. `http://127.0.0.1:PORT@evil.com/`
starts with it but is evil.com (the part before the @ is a user name), and
`http://127.0.0.1:PORT1` is another port: a link in a task's description,
an agent's output or a tracker issue could load another site inside the
Kanbot window, where it looks like Kanbot and can phish.

The window now compares parsed origins (electron/navigation.js), and
everything else opens in the system browser as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Devices handlers learnt who was calling from the `headers` the RPC
server hands them. Those are the HTTP request's headers with each
message's own headers concatenated after them, and the last value of a
name wins (RpcServer: requestHeaders.concat(message.headers)). The
message's headers are part of the NDJSON body the client writes, so a
client could put its own `x-kanbot-caller` there and override the id the
auth middleware set. Today that only lets a caller lose its identity (an
id it cannot know resolves to nobody), but any id it ever learnt would
let it act as that caller, and a device could make itself look like
someone else to list/revoke.

Handlers now read the caller from the HTTP request itself (rpcCaller):
the RPC server runs each handler with the request fiber's context, where
HttpServerRequest is the request server/api/rpc.ts passed, carrying only
the middleware's id. The headers-based helper is gone. The test sends a
paired device's Devices.list with a forged caller header in the message
and checks the handler still knows it is that device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Any paired device could call Devices.offerPairing and Devices.revoke on
any device. A phone left unlocked, a stolen laptop or a compromised
script token could therefore mint codes and pair more devices of the
attacker's choosing (persistence that outlives revoking the first one),
or revoke every other device to lock the owner's laptop and phone out.

mayManageDevices (a core rule) now lets the computer Kanbot runs on (the
packaged app's window or, with no per-launch token, loopback) do both,
and a paired device only revoke itself (signing out). Anything else is
NotAllowedFromDevice, a domain error in the Devices contract with its
catalogue key. Settings › Devices, opened on a paired device, hides
"Pair a device" and the other devices' Revoke and says where to do it.

Tests: over HTTP a paired script lists devices and signs itself out but
gets NotAllowedFromDevice for offering a code and revoking another, while
loopback and the per-launch token may; the section hides the buttons on
a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Revoking a device stopped its next request, but not the ones already
open: the SSE feed (/api/events) and the RPC LiveUpdates stream kept
delivering every task, question and notification to the revoked device
until it happened to reconnect, which a stolen laptop left open never
does.

The callers registry now remembers, for every open request, how to cut
it off, and Devices.revoke closes all of the device's requests except
the one asking (a device signing itself out still gets its answer).

Cutting the connection was not enough for the RPC stream: h3 pipes a
streamed Response into the socket and never cancels it when the socket
goes, so the RPC server went on feeding a subscription nobody read,
one bus listener and an unbounded queue per closed tab, revoked or not.
server/api/rpc.ts now relays the body and cancels it when the connection
closes, which ends the RPC's scope and the subscription behind it.

The test opens a revoked device's SSE feed and LiveUpdates stream and a
bystander's feed, revokes, and checks the first two end at once, the
third stays open, and no bus listener is left behind; and that a device
revoking itself gets its answer while its feed ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/auth/pair is the one request anybody may make, and it read the
whole body into memory before looking at it (h3's readBody buffers it),
whatever its size: the review sent 400 MB and the server's memory grew by
1.2 GB, and nothing stops a stranger on the network from doing that
again and again until the hub runs out.

The route now reads the body through readBodyWithin before decoding it: a
Content-Length over 4 KB is answered 413 unread, and a chunked body stops
being read the moment it passes 4 KB, with Connection: close so the rest
is never read either. Pairing is the only open route that reads a body
(the others are GET), so it is the only one that needs it.

The test sends a declared 5 KB body, a chunked one that passes 4 KB and
stops (413 straight away), and 64 MB in 64 KB chunks, which is cut off
long before it is all sent; a normal pairing still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A task's attachments come from whoever can comment on it, from agents,
from Discord and from trackers, and GET /api/attachments/:id served them
with the type their name implied and nothing else. An SVG with a
<script> in it, opened from the task page (its file link opens it in a
new tab), ran as Kanbot's own origin: stored XSS with the whole API in
reach, Devices.offerPairing included, so one uploaded file could pair the
attacker's device.

Every attachment is now served with:
- X-Content-Type-Options: nosniff, so the browser takes the type we give
  and never runs a "picture" that is really HTML;
- Content-Security-Policy: default-src 'none'; img-src 'self' data:;
  media-src 'self'; style-src 'unsafe-inline'; sandbox, so whatever does
  open has no script, no requests and an origin of its own;
- Content-Disposition: inline only for a closed list (png, jpeg, gif,
  webp, avif, pdf, plain text, mp4, webm), attachment with the file's
  name (RFC 6266) for everything else.

SVG is a download, still served as image/svg+xml: a picture in a page
(the gallery's <img>) ignores the disposition and never runs script, and
opened by itself it is saved, not rendered; if a browser rendered it
anyway, the sandbox stops its script (checked in headless Chrome: script
runs without the headers, is blocked with the CSP, the navigation
downloads with the disposition, and <img> still shows it).

A PDF keeps the CSP without `sandbox`: the browser's PDF viewer is a
plugin, which a sandboxed document may not load, and a PDF's own scripts
run inside that viewer, never as the page's origin.

Every answer also gets a baseline beside X-Frame-Options: nosniff and
Referrer-Policy: same-origin. No site-wide CSP: Nuxt's pages need inline
scripts and styles, so one strict enough to matter would break them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a per-launch token (npm run dev, npm start, a server someone
runs by hand), a request from this machine's socket is trusted. Put a
reverse proxy on the same machine in front of it (nginx, Caddy, a
tunnel, `tailscale serve`) and every client of the proxy arrives from
127.0.0.1 with a loopback Host: anyone who can reach the proxy had the
whole API.

A request from a loopback socket that carries X-Forwarded-For,
X-Forwarded-Host, X-Real-IP or Forwarded now counts as another machine
when no per-launch token is set: a paired device's token works, the
open routes (/pair and its POST, the assets) stay open so devices can
pair through the proxy, and anything else is refused 403 with its own
key, error.behindProxy, which says to pair the device or run Kanbot with
a per-launch token. The headers are only checked for presence, never
believed: who the client is stays unknown. With a per-launch token set
the token decides, as for any request from this machine.

Known limit: through the proxy every client shares one pairing rate
limit bucket (127.0.0.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under `nuxt dev` the app runs in a worker on a Unix socket, behind Nuxt's
listener and Nitro's dev proxy. The socket has no address, so the auth
middleware counted every request as this machine: with `nuxt dev --host`
(or NUXT_HOST/HOST set), anyone on the network who sent a loopback Host
had the whole API, no token needed.

The forwarding headers cannot fix that: Nitro's dev proxy passes a
client's own X-Forwarded-For through untouched and adds one only when
there is none (nitropack createHTTPProxy, `if (!hasHeader)`), so a client
that sends `X-Forwarded-For: 127.0.0.1` is believed. Reading the last
entry would have kept the hole.

Instead the dev listener itself says who the client is: nuxt.config's
`listen` hook (dev only, given the real listener, forked or not) adds a
request listener that runs before anything else and writes the socket's
address, its local port and whether the request carried forwarding
headers into x-kanbot-dev-peer, overwriting whatever the client sent,
signed with a random key put in the environment the Nitro workers
inherit (and taken out of the worker's at boot, so agents do not get it).
In dev the worker believes only a stamp with that key; a request without
one (someone talking to the socket directly) is another machine, never
this one. Outside dev the stamp is removed and ignored. The address also
feeds the pairing rate limit (event.context.clientAddress), which counted
every dev client as one 'unknown'.

The test puts a real listener with the stamp and a proxy that behaves
like Nitro's in front of the middleware on a Unix socket: this machine
gets in, another machine with a loopback Host, a forged X-Forwarded-For
or a forged stamp does not, it can pair and use its token, and a request
straight to the socket proves nothing. Checked against a real
`nuxt dev --host 127.0.0.1`: loopback 200, a forwarded request 403
behindProxy (the stamp's flag got through), a request straight to the
worker's socket 401.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Electron passed the per-launch token to its server as KANBOT_LOCAL_TOKEN,
and the server deleted it from process.env at boot. Deleting it changes
only the copy JavaScript sees: the process's initial environment, which
`ps -E` (macOS) and /proc/<pid>/environ (Linux) show to every program of
the same user, still has it. Those programs include the agents the
server runs on arbitrary repositories, so a prompt-injected agent could
read the token and drive the app (approve its own commands, offer pairing
codes) as if it were the window.

The shell now starts the server with only KANBOT_LOCAL_TOKEN_VIA=parentport,
which carries no secret, and answers the server's request for the token
over the utility process's message port (electron/localAuth.js). At boot
the server marks the token as pending before Nitro listens, so every
request from this machine is refused until it arrives (the shell's boot
poll simply retries), then asks for it on process.parentPort; the first
well-formed answer is the token and nothing changes it afterwards.
Started with the flag but without a parent port, it stays shut rather
than open. KANBOT_LOCAL_TOKEN is still read (tests, a server run by hand).

Chosen over a 0600 file in the data dir: a file is readable by the same
user's programs for as long as it exists and leaves the token on disk if
the server dies before unlinking it; the port is private to the parent
and the child and never touches disk or the environment.

Tests: the server asks over a fake parent port, refuses everything until
the token arrives, takes only the first well-formed one and the token is
in no environment variable; without a port it stays shut; the shell
answers the request on the port and main.js puts no token in the env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop app answers under its own name, kanbot-<random>.localhost
Some checks failed
CI / check (push) Has been cancelled
6765b4d88c
Cookies are not scoped by port. The packaged app set its per-launch
cookie (kanbot_local) on 127.0.0.1, and after the tray's "Open in
browser" the system browser had it too: it then sent it to every server
on 127.0.0.1, whatever the port, for instance a dev server an agent
started from a cloned repository's code, which could log it (and drive
Kanbot with it) or overwrite it with a Set-Cookie of its own.

The shell now makes a name for this machine once per install,
kanbot-<16 hex>.localhost (kept 0600 in the user-data folder), and the
window, its cookie and the browser handoff all use it; the shell's own
REST calls and boot poll stay on 127.0.0.1 with the Bearer header. The
name goes to the server with the token over the message port (it is not
secret, but must be known), and hostIsLoopback accepts exactly that name
besides the loopback names; the server takes no name the shell could not
have made (/^kanbot-[a-z0-9]{12,32}\.localhost$/). Nothing changes in
how the server listens: it still binds 127.0.0.1.

Browsers resolve *.localhost to this machine by themselves (Chromium,
Firefox), and Safari asks macOS's resolver, which does too (getaddrinfo
answers 127.0.0.1 and ::1 here), so the handoff needs no fallback to
127.0.0.1. Checked in headless Chrome against a server bound to
127.0.0.1: the name resolves, and a cookie set under it is not sent to
127.0.0.1 or localhost on another port.

Remaining: the cookie still goes to the same name on another port, so a
program that reads the name from the user-data folder and gets the
browser to open it on its own port could still read the cookie; it is
one more step than before, not a wall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: authentication, as built and as reviewed
Some checks failed
CI / check (push) Has been cancelled
3d34b19495
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
startRun went through TaskRun.execute, which computes the execution id by
hashing the idempotency key with crypto.subtle.digest. WebCrypto runs that
digest on libuv's thread pool, so the run was handed to the engine only when
the pool answered: after an unbounded number of event-loop turns, and for
runs started together, in whichever order their digests came back. The task's
state already names the execution (executionIdOf, the same hash done
synchronously with node:crypto), so the run now goes straight to
WorkflowEngine.execute under that id, as Workflow.execute would after its
hash.

That hop is what made the scheduler and gate tests flaky in full-suite runs,
where the forks contend for the pool: settle() only lets event-loop turns go
by, so a sweep's runs were sometimes not started yet when the test looked
(test/machines/scheduler.test.ts: "expected null to be 'slot'", "expected []
to deeply equal [1, 2]"; test/characterisation/scheduler.test.ts: enqueuePlan
"called 0 times"), started b before a, or were a stage behind at the gate
(test/rpc/workflow.test.ts: "expected 'agent' to be 'human'"). Delaying the
digest by a few milliseconds reproduces all of them on every run.

test/workflow/runStart.test.ts makes the digest never answer: two runs
started one after the other still reach their first stage within settle(),
in order, under the execution id their state names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RunHost.record writes the run's state only while its runId is still the
task's, so a stopped run can never put its state back (nothing else in the
run writes the read model, it has no finalizer or onInterrupt, and an
interrupt's replay answers every activity from the engine's record). But a
step is recorded in one activity and its action runs in the next, and the
action did not check: a stop landing between the two (the human finishing at
the gate, a card dragged away, a run dropped) still ran the node. At a gate
that moved the finished card back to the gate's column, set it waiting and
notified; at a stage it started an agent for a run nobody had any more.
parkOnCap and blockOnFailure, which follow a wait, had the same gap.

The node kinds now return Stop when the task's run is not theirs, and
parkOnCap and blockOnFailure take the runId and do nothing for a stale run,
as stopLooping already did.

test/workflow/staleRun.test.ts stops the run from inside the step's own
write (the task.workflow message saveState sends), which is exactly between
the two activities every time: finishing at the gate leaves the task
finished, not waiting, and dropping a run as it reaches Develop starts no
agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: how a run reaches the engine, and why its actions check their run
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
1b53a50242
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refuse to start on a Node older than 24
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
f400664f0b
On Node 22 node:sqlite has no DatabaseSync.isTransaction, so the dev worker
died at boot and every page answered a bare 500 with no reason given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No bare @ in the catalogues
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
1c2f11f08b
vue-i18n reads @ as a linked message, so plugins.problem.noMetadata and
undecorated failed to compile and took the dev page down with them. They are
reworded rather than escaped as {'@'}, which the server's translate() would
print verbatim. A test compiles every message, the plugins' included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Component tests read the catalogue's text, not its keys
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
1850fccc19
They were written while en.json failed to compile (a bare @), when every
message rendered as its key. Now the catalogue loads, so they look text up
through the app's own i18n (test/support/text.ts) and check what is shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test/support/text.ts typechecks under the test tsconfig
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
fc61c05f69
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alex added 91 commits 2026-10-29 18:46:04 +01:00
Phase 10 contract: what a node serves the hub
Some checks failed
CI / check (push) Has been cancelled
f48d583454
NodeRpcs (NodeHello, NodeSessions, NodeWorkspace) over the WebSocket a node
dials. The hub is the client; a session's messages are the stream of the
call that started it, numbered so a reconnect attaches after the last one
seen, and the SessionSpec's callbacks (tools, gate, ask) are messages the
hub answers with NodeSessions.reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NodeLinks: the hub's registry of connected nodes
Some checks failed
CI / check (push) Has been cancelled
3d1c540c06
The seam between the transport, which registers a link per open socket, and
everything that reaches a node through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The board, the host's notifications and the app's text read the Kanbot
database (settings, tasks), so the host took them in through pluginContext
and could not run where there is none. They are now handed in with the
host's options: appSurface on the hub; a node will hand in its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Auth: the node socket is its own route kind, open to a paired device only
Some checks failed
CI / check (push) Has been cancelled
b8a1072317
classifyRoute names GET /api/nodes/ws 'nodeSocket'; decideAccess lets
nothing but a valid device token through it (not loopback, not the
per-launch token). NodeLinks' device ids are strings, as devices' are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NodeHandlersLive serves NodeRpcs on a node. Sessions run as fibers of a
FiberMap in the layer's scope, detached from the call that started them;
their messages are numbered per session and journalled in memory until the
hub acks them (JOURNAL_LIMIT unacked messages ends the session). Tool calls,
gate requests and questions are messages the hub answers with reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test the node's handlers through an in-memory RPC client
Some checks failed
CI / check (push) Has been cancelled
aedc52690a
Sessions (events, callbacks, stop, drop and re-attach, concurrent
attaches, ack, errors, the journal bound), Node.hello, NodeWorkspace over
a real repository, the runtime contract through the handlers, and a guard
that the handlers' module graph reaches no database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: a node's handlers and its sessions
Some checks failed
CI / check (push) Has been cancelled
598ddbd6c2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge branch 'refactor/phase-9-auth' into refactor/phase-10-nodes
Some checks failed
CI / check (push) Has been cancelled
4ae242ad76
Merge branch 'refactor/phase-9-auth' into refactor/phase-10-nodes
Some checks failed
CI / check (push) Has been cancelled
3856ca5e29
A machine is online (this one always, a node while it has a link: read,
never stored); MachineConnected/MachineDisconnected with the Machines port's
registerNode and seen; machines.device_id (0021). A session can be
unreachable (it keeps its slot) or lost, and a task's agent unreachable.
MachineRuntimes chooses a session's runtime by its machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MachineWorkspaces and MachineRuntimes reach a connected node over its link;
RemoteRuntime drives NodeSessions, answers callbacks once per call id,
acknowledges what it passed on, waits a dropped link out for a lease and
re-attaches after the last message, else the session is lost and its task
resumes by external id when the node is back. Node links register their
machine (followLinks, in the background). A node away takes no work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Show nodes online or offline, and tasks whose node is unreachable
Some checks failed
CI / check (push) Has been cancelled
d11f35095a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nitro's websockets are on. The upgrade checks a node device's Bearer
token itself (upgrades never reach the auth middleware); the open peer
becomes an Effect Socket, and the accept runs on the app's runtime: an
RpcClient for NodeRpcs over it, Node.hello, the protocol and hub-id
checks, then NodeLinks.register until the socket closes. NodeLinksLive
keeps the links by machine, a newer one replacing the older, and revoking
a device closes its links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A SocketServer whose run dials the hub with the node's token and dials
again with backoff (500 ms doubling to 30 s, reset by a connection that
lasted 10 s), under RpcServer's socket protocol. The node's layer runs
the runtime plugins on an in-memory store with no board, settings or
HostOS, and the stub handlers until track N's NodeHandlersLive. main.ts
pairs (node.json, 0600) and runs; scripts/build-node.ts bundles it to
.output/node/index.mjs without the Nitro server or the database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tests: the node socket over real HTTP, and the node's entry
Some checks failed
CI / check (push) Has been cancelled
c5a781d947
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge refactor/phase-10-nodes
Some checks failed
CI / check (push) Has been cancelled
7341e4c061
CLAUDE.md: Nodes, the socket's transport and auth
Some checks failed
CI / check (push) Has been cancelled
4051fe6cfa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The node process serves the real handlers
Some checks failed
CI / check (push) Has been cancelled
2928c13ba3
nodeLayer builds NodeHandlersLive over the node's identity, its local
workspace and the runtime plugins, in place of the stub (kept for the
transport tests). One NodeIdentity: the plain type is the service's shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
linkedNode registers the node's real handlers into the app's NodeLinks and
cuts the link like a dead socket; the runtime contract passes through
RemoteRuntime; the scheduler's second machine is a connected node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merged base serves NodeLinksLive (string device ids), so the in-memory
stand-in goes and the hub's layers sit on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
End-to-end: a session from the hub to a node over the real socket
Some checks failed
CI / check (push) Has been cancelled
4e694f2f9f
nodeLayer takes a runtimes layer in place of the plugins' (tests only).
That exposed a bug: Node.hello read AgentRuntimes from the RPC server's
context at call time while the session host used the layer's, so hello
could announce runtimes other than the ones sessions start. Hello now
uses the runtimes its layer was built with.

The test pairs a node built from nodeLayer, runs a scripted session
(events, tools, the gate, a question answered over RPC) on a project on
the node, cuts the socket from the hub with the question pending and
from the node while the runtime keeps emitting, checks the timeline
line by line, and revokes the node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node's token opens the node socket and nothing else
Some checks failed
CI / check (push) Has been cancelled
bb33c48f76
decideAccess now sees the device's kind: a node device is refused
(Forbidden: nodeDevice) on every route but the node socket, from anywhere,
as a Bearer or a cookie, and the node socket lets in only node devices.
A node taken over is no longer the user on the hub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Projects.create can put a project on a connected node
Some checks failed
CI / check (push) Has been cancelled
a08d14adc9
ProjectInput takes an optional machineId; the core already checked the
folder on that machine and recorded it, but the contract dropped the field,
so there was no way to make a project on a node but by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node's hello names its machine id; the hub took its word, so another
paired node could say the same id and take its projects, sessions and
prompts. claimNodeMachine now decides, in one transaction before the link
is registered, whether the device may be that machine (decideNodeClaim):
a machine bound to another paired, unrevoked device is refused
(machineTaken), and a device is one machine for good (deviceTaken); the
row is bound in the same step. Revoking the device frees the machine for
the device paired next. A refused node is closed 4403 and logged.

NodeInfo is bounded: machine id-like and at most 64 characters, a name of
1-80, at most 32 runtimes and 1000 held sessions; a hello that does not
decode is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tool resolved any path the agent gave and read it on the hub, so a
node (or a prompt-injected agent) could attach the hub's secrets.key, its
database or ~/.ssh keys as a screenshot. MachineWorkspace.readImage now
reads it on the session's machine: the path must stay inside the working
folder once symlinks are followed (outside as written is refused without
looking), be a PNG, JPEG, GIF or WebP by name and bytes, and be at most
5 MB. A node serves it as NodeWorkspace.readImage (base64, bounded), so a
node's screenshots now work, and the hub checks the bytes again. The agent
is told why an image was refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WebSocket upgrades only on the node socket's path, frames capped at 8 MiB
Some checks failed
CI / check (push) Has been cancelled
bd98b6f610
With Nitro's websocket support on, crossws accepted an upgrade on any path
without hooks of its own, past the auth middleware, with ws's 100 MiB
frames: anyone on the LAN could hold sockets open on / or /api/rpc. A
Nitro plugin (server/plugins/websockets.ts) now sets crossws's global
upgrade hook (guardWebSockets) before the upgrade listener is made: 404 on
every path but /api/nodes/ws, whose own hook decides, and maxPayload 8 MiB
(a 5 MB screenshot as base64 fits). The test app uses the same guard.

Checked by hand against nuxt dev (the dev proxy reaches the worker's
crossws: node socket opens, other paths 404) and the production build
(also: an oversized frame closes the socket with 1009).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bound what one node can cost the hub
Some checks failed
CI / check (push) Has been cancelled
98b0fcea06
- Orphans a node's hello lists are stopped in the background, eight at a
  time, off followLinks' change loop: a node listing many fake sessions no
  longer stalls every other node's connect (each stop waited 5 s, in turn).
- The peer socket closes (1008) when more than 32 MiB or 4096 frames wait
  unread, instead of buffering without bound.
- A node device may upgrade 30 times a minute; past that, 429 with
  Retry-After before a socket opens (error.tooManyConnects).
- At most 64 callbacks per session wait on an answer; past that a call is
  Rejected at once. The once-per-call-id set is now the calls in flight
  (replays are already skipped by seq), so it no longer grows forever.
- NodeInfo's runtimes and sessions were bounded in the binding commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Markdown: links only to the web or mail, no remote images
Some checks failed
CI / check (push) Has been cancelled
b4ad20ddc5
Escaping < kept raw HTML out, but [x](javascript:alert(1)) still rendered
as a link (marked 18 outputs the href), and text from a tracker, an agent
or now a node is rendered in the app. renderMarkdown now uses its own
Marked instance whose renderer writes a link only for http(s) and mailto
hrefs (anything else is its text) and never an <img>: an image is a link
to it, so no remote image loads by itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A device revoked while its node's hello is in flight gets no link
Some checks failed
CI / check (push) Has been cancelled
33c2b092c5
Revoking closed the device's links, but a node whose hello was still on
its way registered right after and stayed up. NodeLinks now remembers the
devices it closed for good: a link of one registered later is closed at
once and never announced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node's AskRequest reaches the board only as its kind's payload
Some checks failed
CI / check (push) Has been cancelled
8807642a67
The payload crossed the wire as Schema.Unknown and was handed to spec.ask
as is, so a node could put any shape on the board as a question. It is now
decoded against its kind's Schema from the SDK (FreeQuestion,
ChoiceQuestions, PlanQuestion, ApprovalQuestion; extra keys dropped);
one that does not decode is Rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kanbot-node pair: http(s) hubs only, no redirects, a warning in clear
Some checks failed
CI / check (push) Has been cancelled
864445e9f3
fetch followed a 307/308 and re-POSTed the pairing code to wherever it
pointed. pairNode now refuses redirects (redirect: 'error') and any hub
address that is not http(s); the CLI warns that a plain-http hub sees the
code and the token cross the network in clear until Phase 11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
node.json is written atomically, 0600 from the start, in a 0700 data dir
Some checks failed
CI / check (push) Has been cancelled
24cf1dc5bb
writeNodeConfig wrote over an existing node.json and narrowed its mode
afterwards (readable by others for a moment if it was), and could leave it
half written. It now writes a new 0600 file beside it and renames it over;
the data dir it (or kanbot-node at start) creates is 0700. main.ts says to
prefer node.json to KANBOT_NODE_TOKEN, which same-user processes can read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pairing code pairs the kind of device it was offered for
Some checks failed
CI / check (push) Has been cancelled
bbd979b6ba
Any code from Settings › Devices could pair a node, so a code offered for
someone's phone could become a node the hub sends prompts to. An offer is
now for a node or, by default, for a browser or a script
(Devices.offerPairing takes an optional { kind }; no payload still works),
and redeeming it as the other kind fails like a wrong code (it counts
towards burning it; every failure is the same PairingRefused / 401).

Settings › Devices' pairing card has a "For a node" switch: it asks for a
node's code and shows the kanbot-node pair command to run there instead of
the address and QR code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node's project does not open on the hub's editor or read the hub's disk
Some checks failed
CI / check (push) Has been cancelled
45177f5b3c
Opening a project or task on a node ran the hub's editor or terminal on a
path that is the node's (it could name one of the hub's own folders), and
the project list read the launcher labels from the hub's file system at
that path. openFolder now refuses a project not on this machine with
HostActionFailed error.opensOnItsMachine (naming the machine) before
anything is read or launched, and the labels of a node's project are the
global ones until they are read through its workspace (Phase 11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: the node hardening, and what a node and the hub trust by design
Some checks failed
CI / check (push) Has been cancelled
5016c14fb9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
End-to-end test pairs its node with a node code
Some checks failed
CI / check (push) Has been cancelled
e04457cf78
A pairing code now pairs only the kind it was offered for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node's session never sends a frame over the hub's cap
Some checks failed
CI / check (push) Has been cancelled
3c38c97867
The node's session streams went out as whatever the RPC server took from
the queue at once, written as one WebSocket frame: a backlog replayed after
a reconnect, or one large event, passed the hub's 8 MiB cap, the hub closed
the link (1009), the node redialled and was attached after the same seq,
forever.

- followSession streams one message per chunk (Stream.rechunk(1)).
- clipEvent holds an event under 1 MiB (strings cut with a truncation
  marker, a tool's input as its cut JSON); a tool call, gate request or
  question over 4 MiB is settled on the node, failing closed.
- boundReply holds the hub's replies under the cap too.
- Backstop: the peer socket keeps its close code (ws reports the cap as an
  error before closing 1009); three links in a row closing 1009 with the
  session's messages pending and none through fail the session with
  error.nodeMessageTooLarge instead of re-attaching forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The node socket's upgrade guard compares the raw path
Some checks failed
CI / check (push) Has been cancelled
6a805c5a60
The guard compared `new URL(request.url).pathname`, which resolves `..`,
`.` and `%2e`, while h3 picks a route's websocket hooks from the path as
written. `/x/../api/nodes/ws`, `/api/nodes/./ws` and `/api/nodes/%2e/ws`
passed the guard, resolved to no route, and crossws accepted them with no
token (101). The guard now takes the path exactly as the request line wrote
it (and a plain Host), and lets through only `/api/nodes/ws` with an
optional query, so h3 always resolves what it lets through to the node
route and its token check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A screenshot may come from the temp folders or the task's attachments
Some checks failed
CI / check (push) Has been cancelled
b8f1178722
attach_screenshot read only inside the working folder, so a screenshot
saved where browser tools save them (Playwright MCP under the OS temp
folder, Chrome DevTools often /tmp) was refused, and so were the human's
files in the task's attachments folder. readImage now also accepts the
machine's temp folders (Platform.tempDirs: the OS's, /tmp, /private/tmp on
macOS) and, for a session on the hub's own machine, the task's attachments
folder, each checked after symlinks as the working folder is; the image
checks are unchanged. The tool's description says where images may be read
from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node's question is bounded before it reaches the board
Some checks failed
CI / check (push) Has been cancelled
efa98cede5
The hub decoded an AskRequest's payload against the SDK's question
Schemas, whose strings and arrays are unbounded, so a node could put a
question of megabytes on a task. The hub now decodes it against bounded
copies (askPayloads: text, context, plan and command up to 20,000
characters, headers, questions, labels, descriptions, tool and reason up to
500, at most 20 options and 10 questions); past them the call is Rejected.
The SDK's public Schemas are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing the hub forks for a node outlives its link or session uncounted
Some checks failed
CI / check (push) Has been cancelled
37097fbf8e
Orphan stops were forked detached, so they outlived the link that listed
them; they are now forked in the link's scope (NodeLink.scope: the accept's
scope, the socket's lifetime). Callbacks refused past
MAX_CALLBACKS_IN_FLIGHT were each forked uncounted, every one waiting for a
link to send its refusal; the refusals waiting are now counted
(RemoteState.refusing) and capped at as many again, past which a call is
dropped with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
readImage checks that the file it opened is the one its path leads to
Some checks failed
CI / check (push) Has been cancelled
4991c16027
readImage resolved the path, checked it was inside an allowed folder, then
opened the resolved path, following symlinks again: a folder on the path
swapped for a symlink out between the check and the open handed over a
file outside. It now opens with O_NOFOLLOW (where the OS has it) and, after
the open, resolves the path again, checks it is still inside, and refuses
unless the opened file and that one have the same device and inode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node may not claim a machine that was never a node
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
49948ef511
decideNodeClaim refused a machine bound to another live device, but a
machines row with no device that is not this hub's current id (the hub's
own row under an earlier machine id, with its projects) could be claimed by
any node device, which then took over its projects. The claim now also
reads the row's role and refuses one that is not `node` (`notANode`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 11 contract: hub settings, Discovery, Shell, NodeHost
Some checks failed
CI / check (push) Has been cancelled
fcd67bfc76
The hub section (off, port 7433) switches on the LAN listener and the
announcement; Discovery lists the hubs mDNS sees; Shell asks the desktop
shell to switch to Connect; NodeHost is a node's HostOS, for the desktop a
person sits at. The groups are wired by the tracks that implement them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add bonjour-service 1.4.4 for LAN discovery
Some checks failed
CI / check (push) Has been cancelled
3d534b496b
Pure JS (multicast-dns); announces and finds hubs as _kanbot._tcp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hub mode's HTTPS listener, opened and moved live from the hub settings
Some checks failed
CI / check (push) Has been cancelled
0f5c61efdd
A second listener on hub.port with the data dir's certificate serves the
same app (pages, RPC, upgrades through crossws behind the same guard);
every socket it accepts is another machine's to the auth check. Hub.status
and Discovery.hubs over RPC; the LAN behind a Lan port (bonjour-service).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code offered for a desktop (Devices.offerPairing({ kind: 'desktop' }))
pairs two devices in one transaction: the window's browser device (its
token is the kanbot_device cookie, Secure over HTTPS) and the node it
runs (its token in the answer: { deviceId, nodeDeviceId, nodeToken }).
devices.companion_id (migration 0022) points each at the other.
Revoking either revokes both and cuts both off. A code for another kind
fails like a wrong one. Settings › Devices offers the three kinds and
shows a desktop as one entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Host actions follow the person: NodeHost on the node, routing on the hub
Some checks failed
CI / check (push) Has been cancelled
a69d68ee22
A node serves NodeHost over its link, from a HostOS that reaches no
database (the local machine's, with English text); HostOSNone is gone.
On the hub, Host.* and openProject/openTask ask hostOfCaller: this
machine opens on its own desktop, a window paired together with a
linked node opens on that node's, anyone else is refused
(error.opensOnYourComputer). A project opens only from the machine it
is on. The test RPC client carries a caller (loopback by default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shell.openConnect asks the desktop shell for its connect screen
Some checks failed
CI / check (push) Has been cancelled
8e37b69758
Over the utility process's parent port ({ type: 'kanbot-shell',
action: 'open-connect' }), only from the desktop app's own window (a
Local caller) and only when the shell started the server to talk over
that port; anything else is ShellUnavailable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Desktop shell: connect to a hub
Some checks failed
CI / check (push) Has been cancelled
be25b37386
shell.json says whether this computer runs its own board or a hub's. The
connect screen (a local page) lists hubs mDNS finds, shows the certificate
fingerprint an address presents, and pairs as a desktop over a connection
pinned to it: the window's device cookie goes into the session, the node's
pairing into userData/node/node.json, and the app restarts in connect mode,
with no local server, the hub's board in a window whose session accepts only
the pinned certificate for the hub's host, and the bundled node forked as a
utility process. An offline page offers Retry and Use this computer only;
the tray says which hub this is and can disconnect (best-effort revoke).
desktop-prepare ships the node's external packages and a bundled
bonjour-service; desktop-verify checks they are in the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project picks its machine, and moves to another when idle
Some checks failed
CI / check (push) Has been cancelled
96d1cbbf59
The project form has a machine picker (reachable machines, this one by
default) for a new project, and Projects.update takes machineId: the
project moves only to a machine that is reachable (MachineUnavailable),
has its folder (NotADirectory, checked there), and while none of its
sessions is open (ProjectBusy, error.projectBusy). Its tasks forget their
resume ids (those were the old machine's). ProjectChanged with machineId
tells the screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: Desktop modes
Some checks failed
CI / check (push) Has been cancelled
4eb5af018a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nodes pin the hub's certificate; kanbot-node discover
Some checks failed
CI / check (push) Has been cancelled
f1401b65d9
kanbot-node pair reads the https hub's certificate first: --fingerprint must
match it, or a person confirms it at a terminal; the code goes over a
connection pinned to it and node.json keeps the pin. Every wss dial is
pinned (undici's WebSocket over an Agent whose connector checks
fingerprint256), KANBOT_HUB_FINGERPRINT overrides. Tests over real HTTPS:
remote treatment on the hub listener (dev too), Secure cookies, pairing,
the node over pinned wss end to end, discovery over a fake and real mDNS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One hub certificate for callers at the same moment; CLAUDE.md on hub mode
Some checks failed
CI / check (push) Has been cancelled
d4c72deed5
The identity and hub mode's listener both ensure the certificate as the
runtime starts: on a first boot each made a pair, and one went on with a
fingerprint the files no longer had. CLAUDE.md: hub mode, remote treatment
on its listener, LAN discovery, node pinning; the real-mDNS test is skipped
under CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Machines page: the computers, sharing this one as a hub, the hubs around
Some checks failed
CI / check (push) Has been cancelled
89532433fd
/machines lists the machines (role, reachable, last seen, agents
running from Machines.activity, each one's limit), switches the hub
sharing (hub.enabled, port) with where the listener stands, lists the
hubs on the network, and offers Connect to a hub through the desktop
shell (off, and saying why, outside the desktop app's own window).
Linked from Settings › General and the app menu.

Hub.status and Discovery.hubs are track A's: useHubStatus and
useDiscoveredHubs are stand-ins (settings only, no hubs) to swap at
the merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Setup step Machines: this computer alone, shared as a hub, or connected
Some checks failed
CI / check (push) Has been cancelled
da5c0abcd9
A core step (since 2, right after welcome), so a fresh install sees it
in order and an install set up before (version 1) sees it once with the
end. Three choices: this computer only (default; sharing off), share as
a hub (sharing on, with its addresses and fingerprint), connect to a hub
(the desktop shell's connect screen; off in a browser, saying why). The
summary at the end says which. The story fixture for a step no screen
knows is now 'backups'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ESLint leaves .worktrees alone
Some checks failed
CI / check (push) Has been cancelled
ad5b34b2e0
Agents' checkouts of the repo live there; linted from the main checkout,
their files missed the suppressions baseline and failed lint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Machines page and setup read the real Hub.status and Discovery.hubs
Some checks failed
CI / check (push) Has been cancelled
d1793d9e2c
The stand-ins go: useHubStatus reads Hub.status (refreshed on
settings.changed and every 3 s on screen, since the listener follows the
setting by itself) and shows its hubMode.error key; useDiscoveredHubs
polls Discovery.hubs. A test pins the desktop shell's Disconnect
(Devices.revoke as the window's Bearer, no Origin) revoking both devices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test/hub/desktopEndToEnd.test.ts runs it over real sockets with the shell's own
modules (discovery, hubPin, pairing, hubLink) and the node process's layer: a
desktop code over pinned HTTPS, the node over pinned wss, a project on the
node's machine in a real git repo, a scripted session answered from the window,
Host.openProject reaching the node's (recording) desktop and refused for a
browser paired alone, an impostor pin never linking, and Disconnect revoking
both devices. nodeLayer takes a host desktop for tests; startNode keeps its logs,
so nodeOverHub waits on the refusal instead of sleeping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hub mode no longer renames the Mac: announce under the hub's own host name
Some checks failed
CI / check (push) Has been cancelled
343d497b46
bonjour-service publishes the SRV target with A/AAAA records for
os.hostname() by default, a name macOS's mDNSResponder owns; answering for it
from a second responder is a conflict macOS settles by renaming the computer
(Alexs-Mac-mini-691 -> -746 on turning hub mode on, and again on every run of
the real-mDNS test). The announcement now points at kanbot-<machine id>.local,
which only this hub answers for; hubs are reached by address, so nothing
else changes. The real-mDNS test checks the heard host name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: the hub announces under its own host name
Some checks failed
CI / check (push) Has been cancelled
1e306a1608
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
desktopEndToEnd: type the shell's pairing outcome
Some checks failed
CI / check (push) Has been cancelled
f9bb98b9e5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Discovery keeps a hub's announcements apart from one posing as it
Some checks failed
CI / check (push) Has been cancelled
f8c5b0f3b9
Announcements are kept per service identity (fqdn, port, addresses), not
per the machine id they claim; one id announced with different
fingerprints is listed once per fingerprint, each marked conflict, on the
Machines card, the shell's connect list and kanbot-node discover. The
announced fingerprint is labelled unverified, and discover no longer says
to pair with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hub listener bounds handshakes and connections, and closes at once
Some checks failed
CI / check (push) Has been cancelled
6dfc18c6a7
A 10 s TLS handshake timeout, header and request timeouts, at most 256
connections and 32 per address; every TCP connection is tracked from
'connection', so closing the listener ends those still handshaking instead
of waiting up to 120 s for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Browse the LAN for hubs only while someone looks for them
Some checks failed
CI / check (push) Has been cancelled
96d983161c
Discovery.hubs starts browsing and it stops 60 s after the last ask, instead
of every server process browsing mDNS from boot; LanBonjour closes its
multicast socket once nothing announces or browses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge origin/refactor/phase-11-discovery into wip/p11-security
Some checks failed
CI / check (push) Has been cancelled
c75f195bb7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shell keeps at most 64 announced hubs and tells changes in batches
Some checks failed
CI / check (push) Has been cancelled
a7388e4336
electron/discovery.js drops the announcement heard longest ago past 64 (the
server's cap) and publishes a burst of changes as one list after 250 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Another desktop opens folders for the hub only with a preset
Some checks failed
CI / check (push) Has been cancelled
8d7da23a06
The hub's launcher settings are hub-wide and any paired device can change
them, so a custom command or picked app sent over NodeHost would run on
someone else's computer. remoteHost sends the node platform's default
preset instead, and NodeHost refuses a custom or app choice from the wire
(error.remoteLauncherRefused). This machine keeps its custom launchers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only this computer may change hub sharing and Open with
Some checks failed
CI / check (push) Has been cancelled
8165d9b93a
Settings.update refuses the hub and launchers sections to a paired device
(mayChangeSettings -> SettingOnlyHere, error.settingOnlyHere), so a device
cannot switch hub mode off, move its port, or choose the command folders
open with. The sharing card and Settings > Open with show locked, with the
reason, when the page is not on this computer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project move checks again for open sessions inside its transaction
Some checks failed
CI / check (push) Has been cancelled
c18e588bd2
The check before the target's networked folder call could go stale: the
scheduler's sweep may start a session meanwhile. The transaction that
moves the project now re-reads its open sessions (SQL only) and refuses
ProjectBusy, writing nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop's node never inherits KANBOT_HUB_FINGERPRINT
Some checks failed
CI / check (push) Has been cancelled
2521ef0814
It overrides node.json's pin, so nodeEnvironment drops it with the hub URL
and token variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The window hands only web and mail links to the system
Some checks failed
CI / check (push) Has been cancelled
51aa30e897
setWindowOpenHandler and will-navigate passed any scheme to
shell.openExternal; in connect mode the page is the hub's. They now go
through mayOpenExternally (electron/navigation.js): http, https, mailto,
and VS Code's file links in local mode only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The window's pin matches an IPv6 hub's host name
Some checks failed
CI / check (push) Has been cancelled
12f04da7ef
Chromium hands setCertificateVerifyProc the bare address (fd00::1) while
new URL(pin.url).hostname keeps the brackets, so an IPv6 hub's pinned
certificate was left to Chromium and refused. Both sides are compared
without brackets, case-insensitively.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Connect to a hub" looks disabled when it is
Some checks failed
CI / check (push) Has been cancelled
67604691c2
Outside the desktop app the Machines card's button was disabled but kept
the primary fill (Nuxt UI only dims it to 75%); it now turns neutral and
subtle. In the setup step a disabled choice's card is dimmed too, and
from a paired device the share / alone choices are disabled (the server
refuses the hub section to a device since the previous commits).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Connect mode leaves out "Open in browser"; CLAUDE.md says who trusts whom
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
60d4d12b10
In connect mode the system browser would reach a self-signed hub with no
pin, so the tray and the Help menu offer it in local mode only. CLAUDE.md
records the trust model: a connected hub runs code on the desktop, pairing
is trust on first use (the fingerprint on the hub itself is the only check
against a relay), all devices are equally trusted, a moved project sends
its task text to the node, and a LAN neighbour can burn the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KANBOT_HUB_ENABLED / KANBOT_HUB_PORT force the hub settings section (the
environment wins, Settings.update refuses it with SettingFromEnvironment,
Hub.status says setByEnvironment and the card shows it locked).
KANBOT_LOCAL_AGENTS=0 gives this machine a capacity of 0 (Machine.agentsOff).
kanbot-hub offer/status/health/backup talks to the server over loopback,
bundled on its own to .output/hub-cli (npm run build:hub-cli), sharing
desktop-prepare's externals vendoring (scripts/bundleExternals.mjs). The
log says how to get a first code while hub mode listens with no browser paired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Headless hub image: Dockerfile, compose example, docs
Some checks failed
CI / check (push) Has been cancelled
3c4391d718
Multi-stage build on node:24.19.0-bookworm (pinned by digest), runtime on
bookworm-slim with git, ca-certificates and tini, as uid 1000, data in the
/data volume, hub mode forced on 7433 (the one EXPOSE), the plain listener
on the container's loopback, no local agents, kanbot-hub on PATH and as the
HEALTHCHECK. docs/headless-hub.md covers building, pairing browsers, nodes
and desktops, backups (kanbot-hub backup), upgrades, limits and security.
test/hub/dockerImage.test.ts keeps the files consistent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: Headless hub
Some checks failed
CI / check (push) Has been cancelled
75a3072b3f
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KANBOT_HUB_PUBLIC_URL: where a containerised hub is reached
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
2387d3e94f
Inside a container the hub only sees its Docker address (172.17.0.x), which
it gave out in Hub.status, the pairing link, the first-pairing hint and
kanbot-hub offer. The variable (https origins, comma-separated) is given out
instead; the compose example reads it from .env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge pull request 'Phase 12: a headless hub in Docker' (#20) from refactor/phase-12-headless into refactor/phase-11-discovery
Some checks failed
CI / check (push) Failing after 2m18s
CI / check (pull_request) Failing after 2m18s
5079b2df8a
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/20
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/19
Merge pull request 'Phase 10: nodes connect to the hub' (#18) from refactor/phase-10-nodes into refactor/phase-9-auth
Some checks failed
CI / check (push) Failing after 2m3s
CI / check (pull_request) Failing after 2m10s
a76074d757
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/18
alex merged commit 37e66bb5de into refactor/phase-8d-workspaces 2026-10-29 18:46:10 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Overlight/App!17
No description provided.