eleven / ocho Public

server: lchat bot provision — headless Go provisioning, Node-compatible session (#263)

Second slice of the Go bot sidecar (docs/bots.md). internal/bot mirrors
agent/provision.mjs: derive the invite id/claim from the #fragment (which
never reaches the server), generate a keypair, claim a device token, register
the pubkey. The session file is byte-compatible with the Node sidecar's
session.json ({token,name,userPub,userPriv}), so Go and Node bots can take
over each other's identity in place. The invite secret is env-only
(LCHAT_INVITE), matching add-bot's ANTHROPIC_API_KEY rule.

End-to-end test provisions against the real router via newTestServer: mints
the invite through the admin API, verifies token auth + is_agent + roster
pubkey, wraps a thread key to the roster key and unwraps it with the session
key, and confirms invites stay single-use.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell fc6938f0c19389fdd7f8d20c68f3bcee426c951f parent 926dacd
6 files changed, +365 −1
  • bot_provision_test.go +142 −0
  • botcmd.go +65 −0
  • docs/bots.md +6 −1
  • internal/bot/provision.go +97 −0
  • internal/bot/session.go +52 −0
  • main.go +3 −0
diff --git a/bot_provision_test.go b/bot_provision_test.go
new file mode 100644
index 0000000..948e6ab
--- /dev/null
+++ b/bot_provision_test.go
@@ -0,0 +1,142 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "os"
+ "testing"
+
+ "lchat/internal/bot"
+ "lchat/internal/e2ee"
+)
+
+// mintAgentInviteForTest does what the admin console does when "This is an
+// agent" is ticked: derive id + claim_hash from a fresh secret T client-side,
+// then store them via the admin API. Returns T (the link #fragment).
+func mintAgentInviteForTest(t *testing.T, tsURL string) string {
+ t.Helper()
+ secret, err := e2ee.RandomSecret()
+ if err != nil {
+ t.Fatal(err)
+ }
+ inv, err := e2ee.DeriveInvite(secret)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := json.Marshal(map[string]any{
+ "label": "TestBot",
+ "id": inv.ID,
+ "claim_hash": inv.ClaimHash,
+ "agent": true,
+ })
+ req, _ := http.NewRequest("POST", tsURL+"/api/admin/key-invites", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer admintok")
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var out struct {
+ OK bool `json:"ok"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || !out.OK {
+ t.Fatalf("mint invite: status %d ok=%v err=%v", resp.StatusCode, out.OK, err)
+ }
+ return secret
+}
+
+func TestBotProvisionAgainstRealServer(t *testing.T) {
+ ts, s := newTestServer(t)
+ secret := mintAgentInviteForTest(t, ts.URL)
+
+ sess, err := bot.Provision(ts.URL, secret, "TestBot")
+ if err != nil {
+ t.Fatalf("Provision: %v", err)
+ }
+ if sess.Token == "" || sess.UserPub == "" || sess.UserPriv == "" {
+ t.Fatal("session is missing fields")
+ }
+
+ // The minted device token must authenticate like any member's.
+ if !s.tokenOK(sess.Token) {
+ t.Error("provisioned token doesn't authenticate")
+ }
+ uid, ok := s.db.deviceTokenUser(sess.Token)
+ if !ok {
+ t.Fatal("token maps to no user")
+ }
+ if !s.db.isAgent(uid) {
+ t.Error("provisioned user is not marked as an agent")
+ }
+
+ // The registered public key must be the session's — and usable: wrap a
+ // thread key to it (as an inviter's browser would) and unwrap with the
+ // session's private key.
+ registered := s.db.userPublicKey(uid)
+ if registered != sess.UserPub {
+ t.Fatalf("roster pubkey %q != session pubkey %q", registered, sess.UserPub)
+ }
+ threadKey, err := e2ee.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(threadKey), registered)
+ if err != nil {
+ t.Fatal(err)
+ }
+ back, err := e2ee.UnwrapThreadKey(wrapped, sess.UserPriv)
+ if err != nil {
+ t.Fatalf("unwrap with session key: %v", err)
+ }
+ if back != e2ee.B64uEncode(threadKey) {
+ t.Error("thread key wrapped to the roster pubkey doesn't unwrap with the session key")
+ }
+
+ // Single-use: a second claim of the same invite must fail.
+ if _, err := bot.Provision(ts.URL, secret, "TestBot2"); err == nil {
+ t.Error("re-provisioning a used invite succeeded")
+ }
+}
+
+func TestBotSessionFileNodeCompat(t *testing.T) {
+ // The Go bot must read the Node sidecar's session.json as-is (same identity,
+ // no re-provisioning) and write files Node can read back.
+ path := t.TempDir() + "/session.json"
+ nodeJSON := `{
+ "token": "tok123",
+ "name": "Eleven",
+ "userPub": "BPub",
+ "userPriv": "Priv"
+}`
+ if err := os.WriteFile(path, []byte(nodeJSON), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ s, err := bot.LoadSession(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s == nil || s.Token != "tok123" || s.Name != "Eleven" || s.UserPub != "BPub" || s.UserPriv != "Priv" {
+ t.Fatalf("bad session: %+v", s)
+ }
+
+ if err := s.Save(path); err != nil {
+ t.Fatal(err)
+ }
+ var round map[string]string
+ raw, _ := os.ReadFile(path)
+ if err := json.Unmarshal(raw, &round); err != nil {
+ t.Fatal(err)
+ }
+ for _, k := range []string{"token", "name", "userPub", "userPriv"} {
+ if round[k] == "" {
+ t.Errorf("saved session is missing Node-compat key %q", k)
+ }
+ }
+
+ missing, err := bot.LoadSession(t.TempDir() + "/nope.json")
+ if err != nil || missing != nil {
+ t.Errorf("missing session file should be (nil, nil), got (%v, %v)", missing, err)
+ }
+}
diff --git a/botcmd.go b/botcmd.go
new file mode 100644
index 0000000..c3857bf
--- /dev/null
+++ b/botcmd.go
@@ -0,0 +1,65 @@
+// The `lchat bot` subcommand family: the Go bot sidecar (docs/bots.md §
+// Direction: a Go sidecar). Distinct from `add-bot` (a router-box control
+// command that stands up the Node sidecar): `lchat bot ...` IS the sidecar,
+// run wherever the operator wants the bot's keys to live — which need not be
+// the server box, since it only ever dials out.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+
+ "lchat/internal/bot"
+)
+
+func runBotCmd(args []string) {
+ if len(args) < 1 {
+ fmt.Fprintln(os.Stderr, `usage: lchat bot <command>
+
+commands:
+ provision claim an agent invite and write the bot's session file`)
+ os.Exit(2)
+ }
+ switch args[0] {
+ case "provision":
+ runBotProvision(args[1:])
+ default:
+ die("bot: unknown command %q", args[0])
+ }
+}
+
+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)")
+ 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))
+
+ if *base == "" {
+ die("bot provision: set -base (or LCHAT_BASE) to your instance URL")
+ }
+ // The invite fragment is a secret, so read it from the environment only (a
+ // flag would leak it into the process table + shell history) — same rule as
+ // ANTHROPIC_API_KEY in add-bot.
+ fragment := strings.TrimSpace(os.Getenv("LCHAT_INVITE"))
+ if fragment == "" {
+ die("bot provision: set LCHAT_INVITE to the #fragment of a /join/... link (mint an agent invite in the admin console)")
+ }
+ if existing, err := bot.LoadSession(*session); err != nil {
+ die("bot provision: %v", err)
+ } else if existing != nil {
+ die("bot provision: already provisioned (%s) — delete it to re-provision", *session)
+ }
+
+ s, err := bot.Provision(*base, fragment, *name)
+ if err != nil {
+ die("bot provision: %v", err)
+ }
+ if err := s.Save(*session); err != nil {
+ die("bot provision: %v", err)
+ }
+ fmt.Printf("Provisioned %q. Session written to %s — it holds the bot's private key; keep it secret, back it up.\n", *name, *session)
+ fmt.Println("Now add the bot to a group, or start a DM with it from the app.")
+}
diff --git a/docs/bots.md b/docs/bots.md
index 2b08c02..e093a04 100644
--- a/docs/bots.md
+++ b/docs/bots.md
@@ -106,7 +106,12 @@ then the shared runtime, then Eleven's persona layer.
raw/pkcs8 keypairs), pinned by `internal/e2ee/testdata/vectors.json` — fixtures
generated from `crypto.js` itself by `test/gen-e2ee-vectors.mjs` (regenerate
them after any deliberate `crypto.js` change and re-run `go test
-./internal/e2ee`). Next slices: headless provisioning, then the Announcer.
+./internal/e2ee`). **Provisioning is landed too**: `lchat bot provision`
+(`internal/bot` + `botcmd.go`) claims an agent invite headlessly — the invite
+secret comes from `LCHAT_INVITE` in the env, never argv — and writes a session
+file byte-compatible with the Node sidecar's `session.json`, so either
+implementation can take over the other's identity in place. Next slice: the
+Announcer.
## What exists today (`agent/`)
diff --git a/internal/bot/provision.go b/internal/bot/provision.go
new file mode 100644
index 0000000..132284f
--- /dev/null
+++ b/internal/bot/provision.go
@@ -0,0 +1,97 @@
+package bot
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "lchat/internal/e2ee"
+)
+
+// Provision turns an admin agent-invite into a bot identity — agent/
+// provision.mjs (web/static/join.js run headlessly) in Go. The invite's
+// #fragment secret T never reaches the server: we derive the invite id from
+// it, generate our own keypair, claim a device token by presenting the derived
+// claim secret, and register the public key in the roster. The screen name is
+// never sent — an agent's label was set server-side when the invite was minted.
+func Provision(base, fragment, name string) (*Session, error) {
+ base = strings.TrimRight(base, "/")
+ client := &http.Client{Timeout: 30 * time.Second}
+
+ inv, err := e2ee.DeriveInvite(fragment)
+ if err != nil {
+ return nil, fmt.Errorf("bot: bad invite fragment: %w", err)
+ }
+
+ var info struct {
+ OK bool `json:"ok"`
+ Wrapped string `json:"wrapped"`
+ }
+ if err := getJSON(client, base+"/api/key-invite/"+url.PathEscape(inv.ID), &info); err != nil {
+ return nil, err
+ }
+ if !info.OK {
+ return nil, fmt.Errorf("bot: invite is used or invalid — mint a fresh one")
+ }
+ if info.Wrapped != "" {
+ return nil, fmt.Errorf("bot: that's an add-device link; use a fresh-person agent invite")
+ }
+
+ kp, err := e2ee.GenerateUserKeypair()
+ if err != nil {
+ return nil, err
+ }
+
+ var claim struct {
+ OK bool `json:"ok"`
+ Token string `json:"token"`
+ }
+ err = postJSON(client, base+"/api/key-invite/"+url.PathEscape(inv.ID)+"/claim",
+ map[string]string{"claim_secret": inv.ClaimSecret}, &claim)
+ if err != nil {
+ return nil, err
+ }
+ if !claim.OK || claim.Token == "" {
+ return nil, fmt.Errorf("bot: couldn't claim the invite (it may have just been used)")
+ }
+
+ err = postJSON(client, base+"/api/pubkey",
+ map[string]string{"token": claim.Token, "public_key": kp.PublicKey}, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Session{Token: claim.Token, Name: name, UserPub: kp.PublicKey, UserPriv: kp.PrivateKey}, nil
+}
+
+func getJSON(client *http.Client, url string, out any) error {
+ resp, err := client.Get(url)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ return json.NewDecoder(resp.Body).Decode(out)
+}
+
+func postJSON(client *http.Client, url string, body, out any) error {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ resp, err := client.Post(url, "application/json", bytes.NewReader(raw))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if out == nil {
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("bot: %s: HTTP %d", url, resp.StatusCode)
+ }
+ return nil
+ }
+ return json.NewDecoder(resp.Body).Decode(out)
+}
diff --git a/internal/bot/session.go b/internal/bot/session.go
new file mode 100644
index 0000000..aa5fc25
--- /dev/null
+++ b/internal/bot/session.go
@@ -0,0 +1,52 @@
+// Package bot is the Go bot sidecar (docs/bots.md § Direction: a Go sidecar) —
+// a headless lchat member, being ported from agent/ so bots need no Node
+// runtime. It runs as its own process, never inside the server: the session
+// file below holds a private key that decrypts the bot's threads, and the
+// server must never be able to read it (CLAUDE.md, the one rule).
+package bot
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+)
+
+// Session is the bot's identity: its device token and ECDH keypair. The JSON
+// shape is exactly the Node sidecar's session.json (agent/config.mjs), so the
+// Go bot can take over an existing bot's session file in place — same identity,
+// same keys, no re-provisioning.
+type Session struct {
+ Token string `json:"token"`
+ Name string `json:"name"`
+ UserPub string `json:"userPub"`
+ UserPriv string `json:"userPriv"`
+}
+
+// LoadSession reads a session file. A missing file returns (nil, nil) — "not
+// provisioned yet" is a normal state, not an error.
+func LoadSession(path string) (*Session, error) {
+ raw, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var s Session
+ if err := json.Unmarshal(raw, &s); err != nil {
+ return nil, fmt.Errorf("bot: bad session file %s: %w", path, err)
+ }
+ if s.Token == "" || s.UserPriv == "" {
+ return nil, fmt.Errorf("bot: session file %s is missing token or key", path)
+ }
+ return &s, nil
+}
+
+// Save persists the session. 0600: the file is the bot's private key.
+func (s *Session) Save(path string) error {
+ raw, err := json.MarshalIndent(s, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, append(raw, '\n'), 0o600)
+}
diff --git a/main.go b/main.go
index b1bb8d7..013fb9e 100644
--- a/main.go
+++ b/main.go
@@ -110,6 +110,9 @@ func main() {
case "add-bot":
runAddBot(os.Args[2:])
return
+ case "bot":
+ runBotCmd(os.Args[2:])
+ return
case "remove":
runRemove(os.Args[2:])
return