| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "crypto/rand" |
| 6 | "encoding/json" |
| 7 | "flag" |
| 8 | "fmt" |
| 9 | "io" |
| 10 | "net/http" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | "time" |
| 15 | |
| 16 | "lchat/internal/e2ee" |
| 17 | ) |
| 18 | |
| 19 | // botEnvPath / botSessionDir: the per-instance sidecar convention — the |
| 20 | // EnvironmentFile add-bot writes (which lchat-bot@.service and the activator's |
| 21 | // co-spawn both exec against) and the session dir the sidecar persists its |
| 22 | // identities in. "Does this host have a sidecar?" is defined as botEnvPath |
| 23 | // existing — derived from the filesystem everywhere, the way the systemd units |
| 24 | // do it, never a registry column that could drift from what's staged. |
| 25 | func botEnvPath(host string) string { return filepath.Join(instanceDataDir, host+"-bot.env") } |
| 26 | func botSessionDir(host string) string { return filepath.Join(instanceDataDir, host+"-bot") } |
| 27 | |
| 28 | // botIdentitySpec is one identity a sidecar hosts: which env vars carry its |
| 29 | // invite + session path, and the catalog metadata its agent invite is minted |
| 30 | // with. Both stock identities are catalog-available so Eleven's roster |
| 31 | // baseline sees the joined members (docs/bots.md). (The Announcer is retired: |
| 32 | // release notes are announced by the server itself — announce.go.) |
| 33 | type botIdentitySpec struct { |
| 34 | inviteVar, sessionVar, sessionFile, label, desc string |
| 35 | } |
| 36 | |
| 37 | var claudeIdentity = botIdentitySpec{ |
| 38 | "LCHAT_INVITE", "LCHAT_SESSION", "session.json", |
| 39 | "Claude", "General assistant — DM me or @mention me in a group.", |
| 40 | } |
| 41 | var elevenIdentity = botIdentitySpec{ |
| 42 | "LCHAT_ELEVEN_INVITE", "LCHAT_ELEVEN_SESSION", "eleven-session.json", |
| 43 | "Eleven", "Your guide to the app — I welcome new members.", |
| 44 | } |
| 45 | |
| 46 | // botMintBase resolves the URL invites are minted against — a var so tests |
| 47 | // can point a fake host at an httptest instance. |
| 48 | var botMintBase = func(host string) string { return "https://" + host } |
| 49 | |
| 50 | // attachBot stands a sidecar up for an instance: mint one agent invite per |
| 51 | // identity via the instance's admin API (pure Go since #856), write the |
| 52 | // sidecar's EnvironmentFile, hand ownership to the service user, and start |
| 53 | // lchat-bot@<host> — unless the host hibernates, where the activator co-spawns |
| 54 | // the sidecar instead (starting the always-on unit would double-run it; on an |
| 55 | // already-awake child the activator's sweep adopts the staged env within |
| 56 | // seconds). Idempotent by env-file existence: a host with a staged bot env is |
| 57 | // left exactly as it is. Shared by `lchat add-bot` and controld's attach-bot |
| 58 | // endpoint (#858 — the AI opt-in's provisioner). |
| 59 | func attachBot(host string, hibernating bool, identities []botIdentitySpec, apiKey, model, unitPrefix string) (string, error) { |
| 60 | envPath := botEnvPath(host) |
| 61 | if _, err := os.Stat(envPath); err == nil { |
| 62 | return "already staged", nil |
| 63 | } |
| 64 | env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env")) |
| 65 | if err != nil { |
| 66 | return "", fmt.Errorf("read instance env: %w", err) |
| 67 | } |
| 68 | admin := env["LCHAT_ADMIN_TOKEN"] |
| 69 | if admin == "" { |
| 70 | return "", fmt.Errorf("no LCHAT_ADMIN_TOKEN for %s", host) |
| 71 | } |
| 72 | |
| 73 | base := botMintBase(host) |
| 74 | sessionDir := botSessionDir(host) |
| 75 | botEnv := map[string]string{"LCHAT_BASE": "https://" + host} |
| 76 | if apiKey != "" { |
| 77 | botEnv["ANTHROPIC_API_KEY"] = apiKey |
| 78 | } |
| 79 | if model != "" { |
| 80 | botEnv["LCHAT_MODEL"] = model |
| 81 | } |
| 82 | for _, id := range identities { |
| 83 | t, err := mintAgentInvite(base, admin, id.label, id.desc, model) |
| 84 | if err != nil { |
| 85 | return "", fmt.Errorf("mint %s invite: %w", id.label, err) |
| 86 | } |
| 87 | botEnv[id.inviteVar] = t |
| 88 | botEnv[id.sessionVar] = filepath.Join(sessionDir, id.sessionFile) |
| 89 | } |
| 90 | |
| 91 | // Persistent per-instance session dir + the sidecar's EnvironmentFile (0600 — |
| 92 | // it holds the invites and the API key). |
| 93 | if err := os.MkdirAll(sessionDir, 0o750); err != nil { |
| 94 | return "", fmt.Errorf("mkdir %s: %w", sessionDir, err) |
| 95 | } |
| 96 | if err := writeEnvFile(envPath, botEnv); err != nil { |
| 97 | return "", fmt.Errorf("write bot env: %w", err) |
| 98 | } |
| 99 | // Provisioning runs as root, so MkdirAll/writeEnvFile leave these root-owned. |
| 100 | // But the bot unit runs as serviceUser (hardened units) and must WRITE its |
| 101 | // session files into sessionDir — otherwise the sidecar can't persist, and |
| 102 | // the first boot crash-loops after burning its one-time invites. Hand both |
| 103 | // to the service user. |
| 104 | chownToService(sessionDir) |
| 105 | chownToService(envPath) |
| 106 | |
| 107 | unit := unitPrefix + host |
| 108 | if hibernating { |
| 109 | return "staged (hibernating host: the activator co-spawns the sidecar)", nil |
| 110 | } |
| 111 | if err := systemctl("enable", "--now", unit); err != nil { |
| 112 | return "", fmt.Errorf("start %s: %v (invites minted; env written to %s)", unit, err, envPath) |
| 113 | } |
| 114 | return "started " + unit, nil |
| 115 | } |
| 116 | |
| 117 | // runAddBot: `lchat add-bot <host>` — provision the sidecar (Claude + Eleven) |
| 118 | // for an instance, via attachBot above. The instance must be up first (Eleven |
| 119 | // baselines the roster on its first tick). See docs/instance-router.md. |
| 120 | func runAddBot(args []string) { |
| 121 | fs := flag.NewFlagSet("add-bot", flag.ExitOnError) |
| 122 | registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path") |
| 123 | model := fs.String("model", "", "LCHAT_MODEL for the bots (optional; sidecar default otherwise)") |
| 124 | botUnit := fs.String("unit", envOr("LCHAT_BOT_UNIT", "lchat-bot@"), "systemd bot unit template prefix") |
| 125 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 126 | // The API key is a secret, so read it from the environment (a flag would leak it |
| 127 | // into the process table + shell history). |
| 128 | apiKey := strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) |
| 129 | |
| 130 | host := strings.ToLower(strings.TrimSpace(fs.Arg(0))) |
| 131 | if !validHost(host) { |
| 132 | die("add-bot: invalid host %q", host) |
| 133 | } |
| 134 | |
| 135 | // The instance must exist (the bot connects to it), and we need its admin token |
| 136 | // (from the instance's EnvironmentFile) to mint agent invites. |
| 137 | reg, err := openRegistry(*registryPath) |
| 138 | if err != nil { |
| 139 | die("add-bot: open registry: %v", err) |
| 140 | } |
| 141 | rt, err := reg.get(host) |
| 142 | if err != nil { |
| 143 | die("add-bot: %v", err) |
| 144 | } |
| 145 | if rt == nil || rt.Kind != "instance" { |
| 146 | die("add-bot: %q is not a messenger instance (run add-instance first)", host) |
| 147 | } |
| 148 | |
| 149 | msg, err := attachBot(host, rt.Hibernate, []botIdentitySpec{claudeIdentity, elevenIdentity}, apiKey, *model, *botUnit) |
| 150 | if err != nil { |
| 151 | die("add-bot: %v", err) |
| 152 | } |
| 153 | unit := *botUnit + host |
| 154 | fmt.Printf("added bot sidecar for %s — %s\n", host, msg) |
| 155 | if apiKey == "" { |
| 156 | fmt.Printf(" note: no ANTHROPIC_API_KEY in env — bots are deterministic-only (Eleven welcomes +\n"+ |
| 157 | " slash commands); add ANTHROPIC_API_KEY to %s and restart %s for AI replies\n", botEnvPath(host), unit) |
| 158 | } |
| 159 | fmt.Printf(" logs: journalctl -u %s -f\n", unit) |
| 160 | } |
| 161 | |
| 162 | // mintAgentInvite mints one agent invite via the instance's admin API and |
| 163 | // returns the bare invite secret T (the sidecar's LCHAT_*_INVITE). The secret |
| 164 | // never reaches the server — only the id + claim hash derived from it do, |
| 165 | // via the same HKDF walk (e2ee.DeriveInvite) the claiming sidecar runs, which |
| 166 | // is what keeps the two sides' crypto from diverging now that no shared |
| 167 | // crypto.js sits between them. |
| 168 | func mintAgentInvite(base, admin, label, desc, model string) (string, error) { |
| 169 | raw := make([]byte, 16) // matches web/static/crypto.js randomSecret() |
| 170 | if _, err := rand.Read(raw); err != nil { |
| 171 | return "", err |
| 172 | } |
| 173 | t := e2ee.B64uEncode(raw) |
| 174 | inv, err := e2ee.DeriveInvite(t) |
| 175 | if err != nil { |
| 176 | return "", err |
| 177 | } |
| 178 | body, err := json.Marshal(map[string]any{ |
| 179 | "label": label, |
| 180 | "id": inv.ID, |
| 181 | "wrapped": "", // fresh-identity invite: the sidecar generates its own keypair |
| 182 | "claim_hash": inv.ClaimHash, |
| 183 | "agent": true, |
| 184 | // In the Helpers catalog, so Eleven's roster baseline sees members. |
| 185 | "available_to_members": true, |
| 186 | "description": desc, |
| 187 | "model": model, |
| 188 | "listen": "mention", |
| 189 | }) |
| 190 | if err != nil { |
| 191 | return "", err |
| 192 | } |
| 193 | req, err := http.NewRequest("POST", base+"/api/admin/key-invites", bytes.NewReader(body)) |
| 194 | if err != nil { |
| 195 | return "", err |
| 196 | } |
| 197 | req.Header.Set("Content-Type", "application/json") |
| 198 | req.Header.Set("Authorization", "Bearer "+admin) |
| 199 | client := &http.Client{Timeout: 30 * time.Second} |
| 200 | resp, err := client.Do(req) |
| 201 | if err != nil { |
| 202 | return "", err |
| 203 | } |
| 204 | defer resp.Body.Close() |
| 205 | if resp.StatusCode != http.StatusOK { |
| 206 | detail, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) |
| 207 | return "", fmt.Errorf("POST /api/admin/key-invites: HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(detail))) |
| 208 | } |
| 209 | return t, nil |
| 210 | } |
| 211 | |