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>
6 files changed,
+365
−1
bot_provision_test.go+142 −0botcmd.go+65 −0docs/bots.md+6 −1internal/bot/provision.go+97 −0internal/bot/session.go+52 −0main.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 |