eleven / ocho Public

server: add-bot mints agent invites in Go — Node no longer needed to provision (#856) (#865)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cbfa92e7f7b609b54e9b0563908889c4f8790b9c parent a0dd815
3 files changed, +125 −27
  • bot.go +62 −26
  • bot_test.go +61 −0
  • docs/instance-router.md +2 −1
diff --git a/bot.go b/bot.go
index ee39878..f0eac24 100644
--- a/bot.go
+++ b/bot.go
@@ -1,12 +1,19 @@
package main
import (
+ "bytes"
+ "crypto/rand"
+ "encoding/json"
"flag"
"fmt"
+ "io"
+ "net/http"
"os"
- "os/exec"
"path/filepath"
"strings"
+ "time"
+
+ "lchat/internal/e2ee"
)
// botEnvPath / botSessionDir: the per-instance sidecar convention — the
@@ -18,19 +25,17 @@ import (
func botEnvPath(host string) string { return filepath.Join(instanceDataDir, host+"-bot.env") }
func botSessionDir(host string) string { return filepath.Join(instanceDataDir, host+"-bot") }
-// runAddBot: `lchat add-bot <host>` — provision the Node sidecar (Claude +
-// Eleven) for an instance. It mints an agent invite per identity via the
-// instance's admin API (through agent/mint-invite.mjs, which reuses
-// web/static/crypto.js so the derived id/claim match what the sidecar computes),
-// writes the sidecar's EnvironmentFile, and starts lchat-bot@<host>. The instance
-// must be up first (Eleven baselines the roster on its first tick). See
-// docs/instance-router.md.
+// runAddBot: `lchat add-bot <host>` — provision the sidecar (Claude + Eleven)
+// for an instance. It mints an agent invite per identity via the instance's
+// admin API (pure Go since #856: internal/e2ee derives the invite id/claim
+// from the secret with the same HKDF walk the sidecar's Provision runs, so
+// the crypto can't diverge), writes the sidecar's EnvironmentFile, and starts
+// lchat-bot@<host>. The instance must be up first (Eleven baselines the
+// roster on its first tick). See docs/instance-router.md.
func runAddBot(args []string) {
fs := flag.NewFlagSet("add-bot", flag.ExitOnError)
registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
model := fs.String("model", "", "LCHAT_MODEL for the bots (optional; sidecar default otherwise)")
- agentDir := fs.String("agent-dir", "/opt/lchat/agent", "dir holding the Node sidecar (agent/)")
- nodeBin := fs.String("node", "/usr/bin/node", "path to the node binary")
botUnit := fs.String("unit", envOr("LCHAT_BOT_UNIT", "lchat-bot@"), "systemd bot unit template prefix")
_ = fs.Parse(reorderFlagsFirst(fs, args))
// The API key is a secret, so read it from the environment (a flag would leak it
@@ -65,7 +70,6 @@ func runAddBot(args []string) {
}
base := "https://" + host
- mintScript := filepath.Join(*agentDir, "mint-invite.mjs")
sessionDir := botSessionDir(host)
// One agent invite per identity. Both are catalog-available so Eleven's
@@ -88,7 +92,7 @@ func runAddBot(args []string) {
botEnv["LCHAT_MODEL"] = *model
}
for _, id := range identities {
- t, err := mintAgentInvite(*nodeBin, mintScript, base, admin, id.label, id.desc, *model)
+ t, err := mintAgentInvite(base, admin, id.label, id.desc, *model)
if err != nil {
die("add-bot: mint %s invite: %v", id.label, err)
}
@@ -130,20 +134,52 @@ func runAddBot(args []string) {
fmt.Printf(" logs: journalctl -u %s -f\n", unit)
}
-// mintAgentInvite runs the Node minter (which reuses crypto.js) for one identity
-// and returns the bare invite secret it prints.
-func mintAgentInvite(nodeBin, script, base, admin, label, desc, model string) (string, error) {
- cmd := exec.Command(nodeBin, script, "--label", label, "--description", desc, "--model", model, "--listen", "mention")
- cmd.Env = append(os.Environ(), "LCHAT_BASE="+base, "LCHAT_ADMIN_TOKEN="+admin)
- var out, errb strings.Builder
- cmd.Stdout = &out
- cmd.Stderr = &errb
- if err := cmd.Run(); err != nil {
- return "", fmt.Errorf("%v: %s", err, strings.TrimSpace(errb.String()))
- }
- t := strings.TrimSpace(out.String())
- if t == "" {
- return "", fmt.Errorf("minter returned no secret: %s", strings.TrimSpace(errb.String()))
+// mintAgentInvite mints one agent invite via the instance's admin API and
+// returns the bare invite secret T (the sidecar's LCHAT_*_INVITE). The secret
+// never reaches the server — only the id + claim hash derived from it do,
+// via the same HKDF walk (e2ee.DeriveInvite) the claiming sidecar runs, which
+// is what keeps the two sides' crypto from diverging now that no shared
+// crypto.js sits between them.
+func mintAgentInvite(base, admin, label, desc, model string) (string, error) {
+ raw := make([]byte, 16) // matches web/static/crypto.js randomSecret()
+ if _, err := rand.Read(raw); err != nil {
+ return "", err
+ }
+ t := e2ee.B64uEncode(raw)
+ inv, err := e2ee.DeriveInvite(t)
+ if err != nil {
+ return "", err
+ }
+ body, err := json.Marshal(map[string]any{
+ "label": label,
+ "id": inv.ID,
+ "wrapped": "", // fresh-identity invite: the sidecar generates its own keypair
+ "claim_hash": inv.ClaimHash,
+ "agent": true,
+ // In the Helpers catalog, so Eleven's roster baseline sees members.
+ "available_to_members": true,
+ "description": desc,
+ "model": model,
+ "listen": "mention",
+ })
+ if err != nil {
+ return "", err
+ }
+ req, err := http.NewRequest("POST", base+"/api/admin/key-invites", bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+admin)
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ detail, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return "", fmt.Errorf("POST /api/admin/key-invites: HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(detail)))
}
return t, nil
}
diff --git a/bot_test.go b/bot_test.go
new file mode 100644
index 0000000..ec52e0d
--- /dev/null
+++ b/bot_test.go
@@ -0,0 +1,61 @@
+package main
+
+import (
+ "path/filepath"
+ "testing"
+
+ "lchat/internal/bot"
+ "lchat/internal/e2ee"
+)
+
+// The zero-touch provisioning chain end-to-end against the real router:
+// mintAgentInvite (the Go port of agent/mint-invite.mjs, #856) mints an agent
+// invite via the admin API, and bot.EnsureSession (#806) redeems it and
+// persists a session — the exact pair add-bot and the activator co-spawn rely
+// on. If either side's HKDF walk drifted, the claim would fail here.
+func TestMintAgentInviteThenSelfProvision(t *testing.T) {
+ ts, s := newTestServer(t)
+
+ secret, err := mintAgentInvite(ts.URL, "admintok", "Eleven", "Your guide to the app.", "")
+ if err != nil {
+ t.Fatalf("mintAgentInvite: %v", err)
+ }
+ if secret == "" {
+ t.Fatal("mintAgentInvite returned an empty secret")
+ }
+ // The server must hold only the derived id, never the secret itself.
+ inv, err := e2ee.DeriveInvite(secret)
+ if err != nil {
+ t.Fatalf("derive: %v", err)
+ }
+ if _, found, err := s.db.getKeyInvite(inv.ID); err != nil || !found {
+ t.Fatalf("minted invite not on the server under its derived id: found=%v err=%v", found, err)
+ }
+
+ path := filepath.Join(t.TempDir(), "eleven-session.json")
+ sess, err := bot.EnsureSession(ts.URL, path, secret, "Eleven", t.Logf)
+ if err != nil {
+ t.Fatalf("EnsureSession: %v", err)
+ }
+ if sess == nil || sess.Token == "" || sess.UserPriv == "" {
+ t.Fatalf("self-provisioning did not yield a full session: %+v", sess)
+ }
+
+ // The invite is one-time: a second fresh boot against the same secret must
+ // fail loudly, not mint a second identity.
+ if _, err := bot.EnsureSession(ts.URL, filepath.Join(t.TempDir(), "again.json"), secret, "Eleven", nil); err == nil {
+ t.Fatal("re-redeeming a spent invite should fail")
+ }
+}
+
+// The admin API refuses agent invites when bots are off — the mint surfaces
+// that as an error instead of writing a half-staged env.
+func TestMintAgentInviteRespectsBotsGate(t *testing.T) {
+ ts, s := newTestServer(t)
+ if err := s.db.setBotsEnabled(false); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := mintAgentInvite(ts.URL, "admintok", "Eleven", "", ""); err == nil {
+ t.Fatal("expected the bots-off gate to reject the mint")
+ }
+}
diff --git a/docs/instance-router.md b/docs/instance-router.md
index 4e63033..641d921 100644
--- a/docs/instance-router.md
+++ b/docs/instance-router.md
@@ -296,7 +296,8 @@ no re-provisioning and no lost identity:
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.
+Go deployment. (`add-bot` itself no longer needs Node either: since #856 it
+mints agent invites in Go via `internal/e2ee`, not `agent/mint-invite.mjs`.)
### Upgrading an existing box to version-tracked bots