| 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 |
| } |