eleven / ocho Public

server: make lchat bot run the drop-in sidecar for the standard trio (#322)

Two changes so one `lchat bot run` faithfully hosts Claude + Eleven +
Announcer with no Node:

- DefaultRecipeFor(name): the built-in recipe when no recipe file is present,
  keyed by identity name — Announcer gets the write-only changelog recipe,
  everyone else the conversational default. The standard trio is zero-config.
- Badge from the recipe, not the key: the Engine gives the Runtime the AI
  client only when the recipe actually has a model rule, so a write-only
  Announcer sharing the process's ANTHROPIC_API_KEY still badges BOT (was
  wrongly badging AI). Recipe.usesModel() drives it.

Also rewrites docs/instance-router.md's Bots section: the sidecar is now the
same lchat binary (no Node runtime), documents the in-place Node->Go unit
migration (session files are byte-compatible), and corrects the 'one runtime
dependency is Node' and MemoryDenyWriteExecute notes. Genericised a luan.chat
example in botcmd.go help.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5ce6b8b1857cb6ce5f3203c2ce6a319408bc8117 parent 82013db
5 files changed, +193 −55
  • bot_run_test.go +39 −0
  • botcmd.go +4 −2
  • docs/instance-router.md +84 −51
  • internal/bot/recipe.go +36 −2
  • internal/bot/recipe_test.go +30 −0
diff --git a/bot_run_test.go b/bot_run_test.go
index 9c00d1e..47fa8ab 100644
--- a/bot_run_test.go
+++ b/bot_run_test.go
@@ -398,3 +398,42 @@ func TestBotEngineModelReply(t *testing.T) {
t.Error("system block not marked cacheable")
}
}
+
+// TestBotEngineAnnouncerBadge pins the recipe-driven badge: an Announcer run
+// with the shared ANTHROPIC_API_KEY present must still badge BOT (write-only,
+// no model rule), not AI — the bug that would appear when the standard trio
+// runs from one `lchat bot run` with one key.
+func TestBotEngineAnnouncerBadge(t *testing.T) {
+ ts, s := newTestServer(t)
+ owner := provisionNamed(t, ts.URL, "Owner", false)
+ announcer := provisionNamed(t, ts.URL, "Announcer", true)
+ createGroupWith(t, ts.URL, s, owner, announcer)
+
+ dir := t.TempDir()
+ sessionPath := dir + "/announcer.session.json"
+ if err := announcer.Save(sessionPath); err != nil {
+ t.Fatal(err)
+ }
+ ready := make(chan struct{})
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ eng := &bot.Engine{Base: ts.URL, Session: announcer, SessionPath: sessionPath,
+ Recipe: bot.DefaultRecipeFor("Announcer"), AIKey: "a-key-that-should-be-ignored",
+ Ready: func() { close(ready) }, Logf: t.Logf}
+ go eng.Run(ctx)
+ awaitReady(t, ready)
+
+ uid, _ := s.db.deviceTokenUser(announcer.Token)
+ waitFor(t, "announcer badge", func() bool {
+ var kind string
+ s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind)
+ return kind == "bot"
+ })
+ // Give it a beat to (not) declare ai — if the badge ever became "ai" the
+ // wait above would have to have seen "bot" first anyway; assert it stays bot.
+ var kind string
+ s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind)
+ if kind != "bot" {
+ t.Fatalf("announcer with a key present should badge bot, got %q", kind)
+ }
+}
diff --git a/botcmd.go b/botcmd.go
index a1bb380..1e1cb79 100644
--- a/botcmd.go
+++ b/botcmd.go
@@ -127,7 +127,9 @@ func runBotRun(args []string) {
die("bot run: %v", err)
}
if recipe == nil {
- recipe = bot.DefaultRecipe()
+ // No recipe file → the built-in for this identity, so the standard
+ // trio (Claude / Eleven / Announcer) deploys with zero config.
+ recipe = bot.DefaultRecipeFor(sess.Name)
} else {
log.Printf("bot run: %s follows %s%s", sess.Name, bot.RecipePathFor(path),
map[bool]string{true: " (watches every message)", false: ""}[recipe.Watches()])
@@ -154,7 +156,7 @@ func runBotRun(args []string) {
func runBotProvision(args []string) {
fs := flag.NewFlagSet("bot provision", flag.ExitOnError)
- base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL, e.g. https://test.luan.chat (env: LCHAT_BASE)")
+ base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL, e.g. https://chat.example.com (env: LCHAT_BASE)")
session := fs.String("session", envOr("LCHAT_SESSION", "session.json"), "session file to write (env: LCHAT_SESSION)")
name := fs.String("name", envOr("LCHAT_AGENT_NAME", "Claude"), "bot name for the session file (env: LCHAT_AGENT_NAME)")
_ = fs.Parse(reorderFlagsFirst(fs, args))
diff --git a/docs/instance-router.md b/docs/instance-router.md
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):
diff --git a/internal/bot/recipe.go b/internal/bot/recipe.go
index 84df5e5..6df0bb2 100644
--- a/internal/bot/recipe.go
+++ b/internal/bot/recipe.go
@@ -69,6 +69,33 @@ func DefaultRecipe() *Recipe {
}}
}
+// DefaultRecipeFor is the built-in recipe for a named identity when no recipe
+// file is present, so the standard trio deploys with zero config (mirroring
+// how the Node sidecar hard-wires these three). The Announcer is write-only —
+// it polls the changelog and never answers messages — so it gets an announce
+// recipe (and badges BOT, since it uses no model); everyone else gets the
+// conversational default. An explicit <session>.recipe.json always overrides.
+func DefaultRecipeFor(name string) *Recipe {
+ if name == "Announcer" {
+ r := &Recipe{Rules: []Rule{{On: "changelog", Action: "announce"}}}
+ _ = r.validate() // populates the default poll interval
+ return r
+ }
+ return DefaultRecipe()
+}
+
+// usesModel reports whether any rule can produce a model-generated reply — the
+// signal for the AI badge (a bot with a key but no model rule, like the
+// Announcer or a template-only watcher, is deterministic and badges BOT).
+func (r *Recipe) usesModel() bool {
+ for _, rule := range r.Rules {
+ if rule.On == "message" && rule.Action == "model" {
+ return true
+ }
+ }
+ return false
+}
+
// LoadRecipe reads and validates a recipe file. A missing file returns
// (nil, nil) — "no recipe" is a normal state (the caller falls back to
// DefaultRecipe). Anything else fails closed: a recipe that doesn't validate
@@ -236,11 +263,18 @@ func (e *Engine) Run(ctx context.Context) error {
a := &Announcer{Base: e.Base, Session: e.Session, StatePath: StatePathFor(e.SessionPath), Logf: e.Logf}
go a.Run(ctx, rule.every)
}
+ // The AI brain is live only when the recipe can actually use it: a bot with
+ // a key but no model rule (the Announcer, a template-only watcher) is
+ // deterministic and must badge BOT, not AI.
+ aiKey := ""
+ if e.Recipe.usesModel() {
+ aiKey = e.AIKey
+ }
rt := &Runtime{
Base: e.Base,
Session: e.Session,
- Handler: e.Recipe.handler(e.Session.Name, e.AIKey != ""),
- AIKey: e.AIKey,
+ Handler: e.Recipe.handler(e.Session.Name, aiKey != ""),
+ AIKey: aiKey,
Ready: e.Ready,
Logf: e.Logf,
}
diff --git a/internal/bot/recipe_test.go b/internal/bot/recipe_test.go
index e424ac2..d0d1734 100644
--- a/internal/bot/recipe_test.go
+++ b/internal/bot/recipe_test.go
@@ -118,3 +118,33 @@ func TestRecipeHandler(t *testing.T) {
t.Fatal("default recipe must ignore unaddressed messages")
}
}
+
+func TestDefaultRecipeFor(t *testing.T) {
+ // The Announcer is write-only: an announce recipe, no model, badges BOT.
+ ann := DefaultRecipeFor("Announcer")
+ if err := ann.validate(); err != nil {
+ t.Fatalf("announcer default recipe invalid: %v", err)
+ }
+ if ann.usesModel() {
+ t.Error("announcer must not use the model")
+ }
+ var hasChangelog bool
+ for _, r := range ann.Rules {
+ if r.On == "changelog" && r.Action == "announce" {
+ hasChangelog = true
+ if r.every <= 0 {
+ t.Error("announce recipe has no poll interval")
+ }
+ }
+ }
+ if !hasChangelog {
+ t.Error("announcer default recipe missing the changelog rule")
+ }
+
+ // Everyone else gets the conversational default (uses the model).
+ for _, name := range []string{"Claude", "Eleven", "Helper"} {
+ if !DefaultRecipeFor(name).usesModel() {
+ t.Errorf("%s default recipe should use the model", name)
+ }
+ }
+}