Phase 10: nodes connect to the hub #18

Merged
alex merged 90 commits from refactor/phase-10-nodes into refactor/phase-9-auth 2026-10-29 18:46:02 +01:00
Owner

Phase 10: nodes connect to the hub

A second machine runs kanbot-node, connects to the hub, and runs agents on its own projects. The hub decides everything as before; the node runs the model and does the git and folder work where the files are.

How it works

  • The node dials, the hub calls. A node opens a WebSocket to /api/nodes/ws and serves NodeRpcs (packages/contracts/src/nodes) on it: Node.hello, NodeSessions.*, NodeWorkspace.*. The hub is the RPC client.
  • A session's messages are the stream of the call that started it, numbered per session. The SDK's callbacks (tools, gate, ask) travel as ToolCall / GateRequest / AskRequest messages the hub answers with NodeSessions.reply.
  • A dropped connection doesn't stop the agent. The node keeps unacknowledged messages in memory (at most 10,000 per session). On reconnect the hub attaches after the last seq it stored: nothing lost, nothing doubled. Meanwhile the session shows unreachable and keeps its slot. After a 10-minute lease it is lost, and resumes by its external id once the node is back.
  • Runtime by machine. MachineRuntimes.for(machineId, type) gives the local plugins on the hub and a RemoteRuntime on a node. MachineWorkspaces.for(node) is a remote adapter.
  • Nodes are machines. They are registered from their hello (role node, device id, last seen), online while linked, and shown with a dot on the machine badge. Projects.create takes an optional machineId.
  • The node is its own program (npm run build:node → .output/node/index.mjs), with no Kanbot database. kanbot-node pair <hub> <code> stores its token in node.json (0600, written atomically).

Security

Two adversarial review rounds; 17 findings, all fixed with a test each:

  • A node token opens only the node socket (403 everywhere else). The socket accepts only node devices, even from loopback.
  • A machine id is bound to the device that first claimed it, and a device is one machine.
  • Pairing codes are tied to a device kind. Settings › Devices has a "For a node" switch.
  • WebSocket upgrades: only the exact raw path /api/nodes/ws (dot-segment bypasses closed), 8 MiB frames.
  • One message per frame, big events clipped, oversized callbacks refused on the node, and a hub backstop after repeated 1009 closes.
  • Screenshots must be real images, at most 5 MB, inside the workdir, the temp folder or (local only) the task's attachments, opened with O_NOFOLLOW and an inode re-check.
  • Rendered Markdown links only http(s)/mailto and loads no images.
  • Bounds on what a node costs the hub: 30 connects a minute, 64 callbacks in flight, ask payloads, NodeInfo arrays, orphan stops off the change loop.
  • Inherent, documented in CLAUDE.md:
    • The hub runs code on its nodes (run kanbot-node as a dedicated user).
    • A node is as trusted as the model; the gate is advisory for remote sessions.
    • Plain ws until Phase 11.

Also in this branch

  • Refuse to start on Node < 24. The dev worker used to die with a bare 500.
  • No bare @ in the catalogues, with a compile test. Component tests read the catalogue's text.
  • The plugin host takes its board, notify and text as a surface, so it no longer reaches the database by itself.
  • Migration 0021_node_machines (machines.device_id).

Checks

  • npm run lint, npm run typecheck, npx vitest run: 1254 passed, 1 skipped, twice.
  • End to end over the real socket (test/nodes/endToEnd*.test.ts):
    • a session with gate, tool call and question;
    • the socket cut twice, from each side, with an exact timeline afterwards;
    • revoke;
    • a 9 MB tool result;
    • a replayed backlog of 3,000 messages.
  • Production builds: a hub and a node process pair through the CLI.
    • The node shows online, and workspace reads go through it.
    • kill -9 makes it unavailable, and a restart brings it back.
    • A dispatched session's failure comes back from the node to the task's timeline.
    • Revoke cuts it off, and the upgrade probe table is as expected.

Not done / next

  • Not run between two real Macs. That is Phase 11's verification, with discovery and the UI.
  • Opening a node project's folder from the hub is refused until Connect-to-hub (Phase 11).
  • Moving a project between machines and the Machines page are Phase 11.
  • nuxt dev can crash when a client resets a proxied WebSocket. This predates these changes.

Base: refactor/phase-9-auth (PR #17).

🤖 Generated with Claude Code

## Phase 10: nodes connect to the hub A second machine runs `kanbot-node`, connects to the hub, and runs agents on its own projects. The hub decides everything as before; the node runs the model and does the git and folder work where the files are. ### How it works - **The node dials, the hub calls.** A node opens a WebSocket to `/api/nodes/ws` and serves `NodeRpcs` (packages/contracts/src/nodes) on it: `Node.hello`, `NodeSessions.*`, `NodeWorkspace.*`. The hub is the RPC client. - **A session's messages are the stream of the call that started it**, numbered per session. The SDK's callbacks (tools, gate, ask) travel as `ToolCall` / `GateRequest` / `AskRequest` messages the hub answers with `NodeSessions.reply`. - **A dropped connection doesn't stop the agent.** The node keeps unacknowledged messages in memory (at most 10,000 per session). On reconnect the hub attaches after the last seq it stored: nothing lost, nothing doubled. Meanwhile the session shows `unreachable` and keeps its slot. After a 10-minute lease it is `lost`, and resumes by its external id once the node is back. - **Runtime by machine.** `MachineRuntimes.for(machineId, type)` gives the local plugins on the hub and a `RemoteRuntime` on a node. `MachineWorkspaces.for(node)` is a remote adapter. - **Nodes are machines.** They are registered from their hello (role `node`, device id, last seen), `online` while linked, and shown with a dot on the machine badge. `Projects.create` takes an optional `machineId`. - **The node is its own program** (`npm run build:node` → `.output/node/index.mjs`), with no Kanbot database. `kanbot-node pair <hub> <code>` stores its token in `node.json` (0600, written atomically). ### Security Two adversarial review rounds; 17 findings, all fixed with a test each: - **A node token opens only the node socket** (403 everywhere else). The socket accepts only node devices, even from loopback. - **A machine id is bound to the device that first claimed it**, and a device is one machine. - **Pairing codes are tied to a device kind.** Settings › Devices has a "For a node" switch. - **WebSocket upgrades:** only the exact raw path `/api/nodes/ws` (dot-segment bypasses closed), 8 MiB frames. - **One message per frame,** big events clipped, oversized callbacks refused on the node, and a hub backstop after repeated 1009 closes. - **Screenshots must be real images,** at most 5 MB, inside the workdir, the temp folder or (local only) the task's attachments, opened with `O_NOFOLLOW` and an inode re-check. - **Rendered Markdown** links only http(s)/mailto and loads no images. - **Bounds on what a node costs the hub:** 30 connects a minute, 64 callbacks in flight, ask payloads, NodeInfo arrays, orphan stops off the change loop. - **Inherent, documented in CLAUDE.md:** - The hub runs code on its nodes (run `kanbot-node` as a dedicated user). - A node is as trusted as the model; the gate is advisory for remote sessions. - Plain ws until Phase 11. ### Also in this branch - Refuse to start on Node < 24. The dev worker used to die with a bare 500. - No bare `@` in the catalogues, with a compile test. Component tests read the catalogue's text. - The plugin host takes its board, notify and text as a `surface`, so it no longer reaches the database by itself. - Migration `0021_node_machines` (`machines.device_id`). ### Checks - `npm run lint`, `npm run typecheck`, `npx vitest run`: 1254 passed, 1 skipped, twice. - **End to end over the real socket** (test/nodes/endToEnd*.test.ts): - a session with gate, tool call and question; - the socket cut twice, from each side, with an exact timeline afterwards; - revoke; - a 9 MB tool result; - a replayed backlog of 3,000 messages. - **Production builds:** a hub and a node process pair through the CLI. - The node shows online, and workspace reads go through it. - `kill -9` makes it unavailable, and a restart brings it back. - A dispatched session's failure comes back from the node to the task's timeline. - Revoke cuts it off, and the upgrade probe table is as expected. ### Not done / next - **Not run between two real Macs.** That is Phase 11's verification, with discovery and the UI. - **Opening a node project's folder from the hub is refused** until Connect-to-hub (Phase 11). - **Moving a project between machines** and the Machines page are Phase 11. - **`nuxt dev` can crash** when a client resets a proxied WebSocket. This predates these changes. Base: `refactor/phase-9-auth` (PR #17). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
alex added 48 commits 2026-10-16 15:22:17 +02: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>
alex added 42 commits 2026-10-29 18:45:57 +01:00
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
alex merged commit a76074d757 into refactor/phase-9-auth 2026-10-29 18:46:02 +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!18
No description provided.