package main import ( "bytes" "crypto/rand" "encoding/json" "flag" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "lchat/internal/e2ee" ) // botEnvPath / botSessionDir: the per-instance sidecar convention — the // EnvironmentFile add-bot writes (which lchat-bot@.service and the activator's // co-spawn both exec against) and the session dir the sidecar persists its // identities in. "Does this host have a sidecar?" is defined as botEnvPath // existing — derived from the filesystem everywhere, the way the systemd units // do it, never a registry column that could drift from what's staged. func botEnvPath(host string) string { return filepath.Join(instanceDataDir, host+"-bot.env") } func botSessionDir(host string) string { return filepath.Join(instanceDataDir, host+"-bot") } // botIdentitySpec is one identity a sidecar hosts: which env vars carry its // invite + session path, and the catalog metadata its agent invite is minted // with. Both stock identities are catalog-available so Eleven's roster // baseline sees the joined members (docs/bots.md). (The Announcer is retired: // release notes are announced by the server itself — announce.go.) type botIdentitySpec struct { inviteVar, sessionVar, sessionFile, label, desc string } var claudeIdentity = botIdentitySpec{ "LCHAT_INVITE", "LCHAT_SESSION", "session.json", "Claude", "General assistant — DM me or @mention me in a group.", } var elevenIdentity = botIdentitySpec{ "LCHAT_ELEVEN_INVITE", "LCHAT_ELEVEN_SESSION", "eleven-session.json", "Eleven", "Your guide to the app — I welcome new members.", } // botMintBase resolves the URL invites are minted against — a var so tests // can point a fake host at an httptest instance. var botMintBase = func(host string) string { return "https://" + host } // attachBot stands a sidecar up for an instance: mint one agent invite per // identity via the instance's admin API (pure Go since #856), write the // sidecar's EnvironmentFile, hand ownership to the service user, and start // lchat-bot@ — unless the host hibernates, where the activator co-spawns // the sidecar instead (starting the always-on unit would double-run it; on an // already-awake child the activator's sweep adopts the staged env within // seconds). Idempotent by env-file existence: a host with a staged bot env is // left exactly as it is. Shared by `lchat add-bot` and controld's attach-bot // endpoint (#858 — the AI opt-in's provisioner). func attachBot(host string, hibernating bool, identities []botIdentitySpec, apiKey, model, unitPrefix string) (string, error) { envPath := botEnvPath(host) if _, err := os.Stat(envPath); err == nil { return "already staged", nil } env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env")) if err != nil { return "", fmt.Errorf("read instance env: %w", err) } admin := env["LCHAT_ADMIN_TOKEN"] if admin == "" { return "", fmt.Errorf("no LCHAT_ADMIN_TOKEN for %s", host) } base := botMintBase(host) sessionDir := botSessionDir(host) botEnv := map[string]string{"LCHAT_BASE": "https://" + host} if apiKey != "" { botEnv["ANTHROPIC_API_KEY"] = apiKey } if model != "" { botEnv["LCHAT_MODEL"] = model } for _, id := range identities { t, err := mintAgentInvite(base, admin, id.label, id.desc, model) if err != nil { return "", fmt.Errorf("mint %s invite: %w", id.label, err) } botEnv[id.inviteVar] = t botEnv[id.sessionVar] = filepath.Join(sessionDir, id.sessionFile) } // Persistent per-instance session dir + the sidecar's EnvironmentFile (0600 — // it holds the invites and the API key). if err := os.MkdirAll(sessionDir, 0o750); err != nil { return "", fmt.Errorf("mkdir %s: %w", sessionDir, err) } if err := writeEnvFile(envPath, botEnv); err != nil { return "", fmt.Errorf("write bot env: %w", err) } // Provisioning runs as root, so MkdirAll/writeEnvFile leave these root-owned. // But the bot unit runs as serviceUser (hardened units) and must WRITE its // session files into sessionDir — otherwise the sidecar can't persist, and // the first boot crash-loops after burning its one-time invites. Hand both // to the service user. chownToService(sessionDir) chownToService(envPath) unit := unitPrefix + host if hibernating { return "staged (hibernating host: the activator co-spawns the sidecar)", nil } if err := systemctl("enable", "--now", unit); err != nil { return "", fmt.Errorf("start %s: %v (invites minted; env written to %s)", unit, err, envPath) } return "started " + unit, nil } // runAddBot: `lchat add-bot ` — provision the sidecar (Claude + Eleven) // for an instance, via attachBot above. 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)") 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 // into the process table + shell history). apiKey := strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) host := strings.ToLower(strings.TrimSpace(fs.Arg(0))) if !validHost(host) { die("add-bot: invalid host %q", host) } // The instance must exist (the bot connects to it), and we need its admin token // (from the instance's EnvironmentFile) to mint agent invites. reg, err := openRegistry(*registryPath) if err != nil { die("add-bot: open registry: %v", err) } rt, err := reg.get(host) if err != nil { die("add-bot: %v", err) } if rt == nil || rt.Kind != "instance" { die("add-bot: %q is not a messenger instance (run add-instance first)", host) } msg, err := attachBot(host, rt.Hibernate, []botIdentitySpec{claudeIdentity, elevenIdentity}, apiKey, *model, *botUnit) if err != nil { die("add-bot: %v", err) } unit := *botUnit + host fmt.Printf("added bot sidecar for %s — %s\n", host, msg) if apiKey == "" { fmt.Printf(" note: no ANTHROPIC_API_KEY in env — bots are deterministic-only (Eleven welcomes +\n"+ " slash commands); add ANTHROPIC_API_KEY to %s and restart %s for AI replies\n", botEnvPath(host), unit) } fmt.Printf(" logs: journalctl -u %s -f\n", unit) } // 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 }