| index 9297e26..689ae54 100644 |
| --- a/docs/instance-router.md |
| +++ b/docs/instance-router.md |
| @@ -181,44 +181,76 @@ later, if it earns it. |
| |
| ## Bots (sidecars) |
| |
| -An instance can have a **sidecar** hosting the default agents (see `agent/`). It's |
| -a **Node** process, not part of the Go binary, and it connects to its instance as |
| -a **WebSocket client** over the instance's public host — authenticating with a |
| -one-time agent invite and persisting its session to disk. It serves no inbound |
| -traffic, so it never needs a socket or a cert, and the router never routes *to* it. |
| - |
| -**Why a separate process, and why Node?** Two different reasons. It's a *separate |
| -process* because it's a full E2E client — it holds its own keypair, and that |
| -private key must never live in the server process (the core rule), so a bot is a |
| -member that runs on a server, not a server feature (a separate *Go* process would |
| -satisfy this too). It's *Node* specifically because it imports `web/static/ |
| -crypto.js` directly — the same crypto the browser runs, on Node's WebCrypto — so |
| -its ciphertext can't drift from what clients expect. A Go rewrite would keep the |
| -separate-process property but give up that shared implementation: you'd |
| -re-implement crypto.js's exact wire format and keep it byte-compatible forever |
| -(correctness-critical, silent on failure). Cheaper ways to drop the Node install: |
| -bundle the sidecar into one executable (Node SEA), or run it off-box. |
| - |
| -That makes it the simplest thing to supervise: **one `lchat-bot@<host>.service` |
| -per instance**, restarted on deploy. Because the sidecar backfills on reconnect, a |
| -bot deploy is a plain restart — no socket-swap, no drain. Two notes: |
| - |
| -- The sidecar is Node, so its host needs a **Node runtime** (the Go server is a |
| - static binary; the bot is not); its `agent/` deps install with `npm`. |
| -- It reaches its instance at the public URL through the router — a normal client |
| - connection that happens to originate on the same box. One wrinkle: the box must |
| - resolve that public hostname **to itself**. Pin every hostname the box hosts to |
| - loopback in `/etc/hosts` (`127.0.0.1 chat.example.com …`) so on-box clients — |
| - sidecars, and an instance's own calls to a sibling service like the push relay — |
| - reach the local router directly (still over TLS+SNI, so the right cert is served) |
| - instead of hair-pinning out to the public IP. This also sidesteps a migration |
| - trap: after you move onto this box and flip DNS, an external resolver may still |
| - cache the *old* box's IP for these hosts, so an on-box client that resolves them |
| - externally can silently talk to the old box — or fail once it's gone. (On cloud |
| - images that regenerate `/etc/hosts` on boot, disable that so the pins persist.) |
| - |
| -Its secrets (an API key, the persisted agent sessions) live in its own env + disk, |
| -the same way the messenger's admin token lives in the instance DB. |
| +An instance can have a **sidecar** hosting the default agents. It connects to its |
| +instance as a **WebSocket client** over the instance's public host — |
| +authenticating with a one-time agent invite and persisting its session to disk. |
| +It serves no inbound traffic, so it never needs a socket or a cert, and the |
| +router never routes *to* it. |
| + |
| +**The sidecar is the same `lchat` binary** — `lchat bot run` (`botcmd.go`, |
| +`internal/bot/`). No separate runtime: the Go port mirrors `crypto.js`'s wire |
| +format byte-for-byte (`internal/e2ee`, pinned by cross-language vectors generated |
| +from `crypto.js` itself), so its ciphertext can't drift from what clients expect, |
| +and the box that runs the server can run its bots with **no extra install**. The |
| +Node reference (`agent/`) stays in the repo as the functional example for |
| +self-hosters who prefer it, but the default deployment is Go. |
| + |
| +**Why still a separate process?** Because a bot is a full E2E client — it holds |
| +its own keypair, and that private key must never live in the server process (the |
| +core rule). So a bot is a *member that runs on a server*, not a server feature — |
| +run wherever the operator wants the keys to live (same box, off-box, a Raspberry |
| +Pi), since it only ever dials out. |
| + |
| +**One process, the whole trio.** `lchat bot run -base https://<host> |
| +<claude>.session.json <eleven>.session.json <announcer>.session.json` runs all |
| +three identities as goroutines in one process, each its own connection. The |
| +standard trio is **zero-config**: with no recipe file, an identity named |
| +`Announcer` gets the write-only changelog recipe (badges BOT), everyone else |
| +gets the conversational default (command floor + model; badges AI when |
| +`ANTHROPIC_API_KEY` is set). A custom bot drops a `<session>.recipe.json` |
| +alongside its session (`docs/bot-recipes.md`) — behaviour as data, no code. |
| + |
| +Supervise it as **one `lchat-bot@<host>.service` per instance**, restarted on |
| +deploy. Because the sidecar backfills on reconnect, a bot deploy is a plain |
| +restart — no socket-swap, no drain. One wrinkle: it reaches its instance at the |
| +public URL through the router, so the box must resolve that public hostname **to |
| +itself**. Pin every hostname the box hosts to loopback in `/etc/hosts` |
| +(`127.0.0.1 chat.example.com …`) so on-box clients — sidecars, and an instance's |
| +own calls to a sibling service like the push relay — reach the local router |
| +directly (still over TLS+SNI, so the right cert is served) instead of |
| +hair-pinning out to the public IP. This also sidesteps a migration trap: after |
| +you move onto this box and flip DNS, an external resolver may still cache the |
| +*old* box's IP for these hosts, so an on-box client that resolves them externally |
| +can silently talk to the old box — or fail once it's gone. (On cloud images that |
| +regenerate `/etc/hosts` on boot, disable that so the pins persist.) |
| + |
| +Its secrets (an API key, the persisted agent sessions) live in its own env + |
| +disk, the same way the messenger's admin token lives in the instance DB. |
| + |
| +### Migrating a Node sidecar to Go |
| + |
| +The session files are **byte-compatible** (`internal/bot/session.go` matches the |
| +Node `session.json` shape), so a running Node sidecar swaps to Go in place with |
| +no re-provisioning and no lost identity: |
| + |
| +1. Point the unit's `ExecStart` at the Go sidecar, keeping the same session |
| + directory and env file: |
| + ```ini |
| + ExecStart=/opt/lchat/lchat bot run -base https://%i \ |
| + /data/%i-bot/session.json \ |
| + /data/%i-bot/eleven-session.json \ |
| + /data/%i-bot/announcer-session.json |
| + ``` |
| + (The env file still carries `ANTHROPIC_API_KEY` and, if the bot does |
| + connected-services, `LCHAT_CONNECT_BROKER_URL`/`_TOKEN`.) |
| +2. `systemctl daemon-reload && systemctl restart lchat-bot@<host>`. The bot |
| + reconnects, re-declares its kind, and backfills — members see no gap. |
| +3. The Go sidecar keeps the Node sidecar's `.state.json` (announce memory), so |
| + the Announcer replays nothing. |
| + |
| +Once **no** unit on the box still `ExecStart`s Node, the Node runtime and the |
| +`agent/node_modules` install can be removed entirely — the box becomes a pure |
| +Go deployment. |
| |
| ## The registry (a small control DB) |
| |
| @@ -282,13 +314,12 @@ That's the whole dependency story for the Go side — nothing to `apt install`, |
| version to keep patched, no runtime to manage. A bare box with *no* Go on it is |
| not a concern; it's the point. |
| |
| -**The one runtime dependency is Node**, and only on a box that runs bot sidecars — |
| -the sidecar (`agent/`) is Node, not a static binary. Install a pinned Node and |
| -`npm ci` in `agent/`. If you'd rather keep the box to static binaries only, the |
| -sidecar can be bundled into a single executable (Node SEA / `pkg`) or run |
| -**off-box** entirely — it's just a WebSocket client to the public URL, so it need |
| -not live on the router box at all. For the common case, installing Node is the |
| -pragmatic choice. |
| +**There is no runtime dependency — not even for bots.** The sidecar is now the |
| +same `lchat` binary (`lchat bot run`; see *Bots*), so a box that runs the server |
| +can run its bots with nothing else installed. The Node reference (`agent/`) |
| +remains for self-hosters who want it, but it's optional; a stock box is pure |
| +static Go. (A bot can still run **off-box** — it's just a WebSocket client to the |
| +public URL — if you'd rather keep its keys on a different machine.) |
| |
| ## add-instance |
| |
| @@ -378,8 +409,8 @@ zero-downtime via `SO_REUSEPORT` / systemd socket activation. |
| - `lchat@.service` — a template unit, one per instance. `MemoryMax=` + `CPUWeight=` |
| give resource isolation; `Restart=always` gives crash isolation. Bump |
| `LimitNOFILE` (instances × WS connections × SQLite files). |
| -- `lchat-bot@.service` — the optional per-instance sidecar (Node; see *Bots*). |
| - `Restart=always`; a deploy is just a restart (it backfills on reconnect). |
| +- `lchat-bot@.service` — the optional per-instance sidecar (`lchat bot run`; see |
| + *Bots*). `Restart=always`; a deploy is just a restart (it backfills on reconnect). |
| |
| ## Hardening |
| |
| @@ -401,10 +432,12 @@ else binds a unix socket and needs no capabilities (`CapabilityBoundingSet=`). |
| `SystemCallFilter=@system-service`, and `MemoryMax`. `systemd-analyze security |
| <unit>` should score each in the low single digits. |
| |
| -> **Go vs Node caveat.** The Go units add `MemoryDenyWriteExecute=yes` (the static |
| -> binary never maps W+X memory). The Node **sidecar must not** set it — V8's JIT |
| -> maps writable+executable pages, so it would crash on boot. That's the one |
| -> directive that differs between the two. |
| +> **`MemoryDenyWriteExecute` note.** Every unit — including the Go sidecar — |
| +> can set `MemoryDenyWriteExecute=yes`, since the static Go binary never maps |
| +> W+X memory. (The legacy Node sidecar was the one exception — V8's JIT maps |
| +> writable+executable pages, so it crashed on boot with that directive; a Go |
| +> sidecar has no such caveat and gets the same hardening as the router and |
| +> instances.) |
| |
| **OS level** (one-time): |
| |