eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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.
25func botEnvPath(host string) string { return filepath.Join(instanceDataDir, host+"-bot.env") }
26func 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.)
33type botIdentitySpec struct {
34 inviteVar, sessionVar, sessionFile, label, desc string
35}
36
37var claudeIdentity = botIdentitySpec{
38 "LCHAT_INVITE", "LCHAT_SESSION", "session.json",
39 "Claude", "General assistant — DM me or @mention me in a group.",
40}
41var 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.
48var 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).
59func 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.
120func 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.
168func 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