AI opt-in: onboarding asks honestly, and saying yes provisions an Eleven sidecar (#877)
* server+cloud+web: the AI opt-in provisions an Eleven sidecar (#858) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * server: the opt-in signal keys on EXPLICIT consent, not the implicit default Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
19 files changed,
+673
−184
activator.go+12 −3attach_bot_test.go+227 −0bootstrap.go+1 −1bot.go+96 −71changelog/2026-07-14-ai-opt-in.md+1 −0cloud.go+9 −0cloud_usage.go+50 −0controld.go+43 −0db.go+11 −0docs/ai-provisioning.md+4 −2docs/cloud-provisioner.md+10 −2ios/LChatCore/Sources/LChatCore/Flows/setup.json+36 −35main.go+15 −0test/flow.test.mjs+28 −18test/quickform.test.mjs+8 −4usage.go+46 −0web/static/apps/quickform.js+15 −5web/static/apps/setup.flow.js+49 −39web/static/shell.js+12 −4
diff --git a/activator.go b/activator.go| index ced1a4b..ad5ba1e 100644 |
| --- a/activator.go |
| +++ b/activator.go |
| @@ -824,9 +824,18 @@ func (a *activatorSvc) sweepOnce() { |
| // deliberately: an exec is quick, and the lock is what makes a restart |
| // impossible to race with a stop (sidecar fields only change in the |
| // awake state, and every stop flips the state under this same lock |
| - // first). Only what the wake meant to spawn is managed — a bot env |
| - // file appearing mid-flight (add-bot on an awake host) stays systemd's |
| - // until the next wake, so the two never double-run one sidecar. |
| + // first). |
| + // |
| + // A bot env staged MID-FLIGHT (attach-bot on an awake child — the AI |
| + // opt-in, #858) is ADOPTED here rather than waiting for the next |
| + // wake, so opting in takes effect in seconds. Safe against |
| + // double-running: attach-bot never enables the systemd unit for a |
| + // hibernating host, and only hibernating hosts are activator |
| + // children — the staged sidecar has exactly one possible runner. |
| + if !e.sidecarWanted && a.backend.hasSidecar(e.host) { |
| + e.sidecarWanted = true |
| + log.Printf("activator: sidecar staged for awake %s — adopting (AI opt-in)", e.host) |
| + } |
| if e.sidecarWanted && (e.sidecar == nil || e.sidecar.exited()) && !now.Before(e.sidecarRetryAt) { |
| if e.sidecar != nil && now.Sub(e.sidecarStarted) >= sidecarHealthyRun { |
| e.sidecarBackoff = 0 |
diff --git a/attach_bot_test.go b/attach_bot_test.go| new file mode 100644 |
| index 0000000..3a508c1 |
| --- /dev/null |
| +++ b/attach_bot_test.go |
| @@ -0,0 +1,227 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// swapForBotTests points attachBot's seams at test doubles: invites mint |
| +// against a real httptest instance, chown is a no-op (no service user on a |
| +// dev machine), and systemctl records instead of running. |
| +func swapForBotTests(t *testing.T, instanceURL string) *[]string { |
| + t.Helper() |
| + oldData, oldMint, oldChown, oldSysctl := instanceDataDir, botMintBase, chownToService, systemctl |
| + t.Cleanup(func() { |
| + instanceDataDir, botMintBase, chownToService, systemctl = oldData, oldMint, oldChown, oldSysctl |
| + }) |
| + instanceDataDir = t.TempDir() |
| + botMintBase = func(string) string { return instanceURL } |
| + chownToService = func(string) {} |
| + var calls []string |
| + systemctl = func(args ...string) error { |
| + calls = append(calls, strings.Join(args, " ")) |
| + return nil |
| + } |
| + return &calls |
| +} |
| + |
| +// attachBot end to end against a real instance: invites minted (Eleven-only |
| +// for the provisioner path), env staged with the right session vars, the unit |
| +// started — and a second attach is a pure no-op (idempotence is what lets the |
| +// opt-in signal fire on every flip). |
| +func TestAttachBotStagesElevenOnly(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + calls := swapForBotTests(t, ts.URL) |
| + host := "space.11ch.at" |
| + if err := writeEnvFile(filepath.Join(instanceDataDir, host+".env"), map[string]string{"LCHAT_ADMIN_TOKEN": "admintok"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + msg, err := attachBot(host, false, []botIdentitySpec{elevenIdentity}, "sk-test", "", "lchat-bot@") |
| + if err != nil { |
| + t.Fatalf("attachBot: %v", err) |
| + } |
| + if !strings.Contains(msg, "started lchat-bot@"+host) { |
| + t.Fatalf("msg = %q", msg) |
| + } |
| + env, err := readEnvFile(botEnvPath(host)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if env["LCHAT_ELEVEN_INVITE"] == "" || env["LCHAT_ELEVEN_SESSION"] == "" { |
| + t.Fatalf("Eleven identity missing from env: %v", env) |
| + } |
| + if env["LCHAT_INVITE"] != "" || env["LCHAT_SESSION"] != "" { |
| + t.Fatalf("provisioner path must stage Eleven only, got %v", env) |
| + } |
| + if env["ANTHROPIC_API_KEY"] != "sk-test" || env["LCHAT_BASE"] != "https://"+host { |
| + t.Fatalf("env: %v", env) |
| + } |
| + if len(*calls) != 1 || (*calls)[0] != "enable --now lchat-bot@"+host { |
| + t.Fatalf("systemctl calls: %v", *calls) |
| + } |
| + // The minted invite is real: it resolves on the instance under its |
| + // derived id (the sidecar redeems it at first boot — #806). |
| + if n := countAgentInvites(t, s); n != 1 { |
| + t.Fatalf("agent invites on the instance = %d, want 1", n) |
| + } |
| + |
| + // Idempotent: attach again → no new invite, no unit action, "already". |
| + msg, err = attachBot(host, false, []botIdentitySpec{elevenIdentity}, "sk-test", "", "lchat-bot@") |
| + if err != nil || msg != "already staged" { |
| + t.Fatalf("re-attach: %q %v", msg, err) |
| + } |
| + if len(*calls) != 1 { |
| + t.Fatalf("re-attach touched systemd: %v", *calls) |
| + } |
| + if n := countAgentInvites(t, s); n != 1 { |
| + t.Fatalf("re-attach minted again: %d invites", n) |
| + } |
| +} |
| + |
| +// A hibernating host stages the env and leaves systemd alone — the activator |
| +// co-spawns (at wake, or the sweep adopts an awake child within seconds). |
| +func TestAttachBotHibernatingSkipsUnit(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + calls := swapForBotTests(t, ts.URL) |
| + host := "sleepy.11ch.at" |
| + if err := writeEnvFile(filepath.Join(instanceDataDir, host+".env"), map[string]string{"LCHAT_ADMIN_TOKEN": "admintok"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + msg, err := attachBot(host, true, []botIdentitySpec{elevenIdentity}, "", "", "lchat-bot@") |
| + if err != nil { |
| + t.Fatalf("attachBot: %v", err) |
| + } |
| + if !strings.Contains(msg, "activator co-spawns") { |
| + t.Fatalf("msg = %q", msg) |
| + } |
| + if len(*calls) != 0 { |
| + t.Fatalf("hibernating attach must not touch systemd: %v", *calls) |
| + } |
| + if _, err := os.Stat(botEnvPath(host)); err != nil { |
| + t.Fatalf("bot env not staged: %v", err) |
| + } |
| +} |
| + |
| +// countAgentInvites counts the bot identities a mint created — the agent flag |
| +// lives on the pre-seeded user row, not the invite row. |
| +func countAgentInvites(t *testing.T, s *Server) int { |
| + t.Helper() |
| + var n int |
| + if err := s.db.sql.QueryRow(`SELECT COUNT(*) FROM users WHERE COALESCE(is_agent, 0) = 1`).Scan(&n); err != nil { |
| + t.Fatal(err) |
| + } |
| + return n |
| +} |
| + |
| +// The cloud's opt-in endpoint: meter-token auth resolves the space, controld |
| +// gets the attach call, a stranger is refused. |
| +func TestAIOptInHandler(t *testing.T) { |
| + db := openTestCloudDB(t) |
| + id := insertSpaceRow(t, db, "space.11ch.at", 1000) |
| + if err := db.setMeterTokenHash(id, hashToken("meter-tok")); err != nil { |
| + t.Fatal(err) |
| + } |
| + var attached []string |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("POST /instances/{host}/bot", func(w http.ResponseWriter, r *http.Request) { |
| + attached = append(attached, r.PathValue("host")) |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "detail": "started"}) |
| + }) |
| + sock := fakeControl(t, mux.ServeHTTP) |
| + s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t"} |
| + |
| + call := func(token string) int { |
| + req := httptest.NewRequest("POST", "/api/ai-optin", nil) |
| + if token != "" { |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + } |
| + rec := httptest.NewRecorder() |
| + s.aiOptInHandler(rec, req) |
| + return rec.Code |
| + } |
| + if code := call("meter-tok"); code != http.StatusOK { |
| + t.Fatalf("opt-in: %d", code) |
| + } |
| + if len(attached) != 1 || attached[0] != "space.11ch.at" { |
| + t.Fatalf("controld attach calls: %v", attached) |
| + } |
| + if code := call("wrong"); code != http.StatusForbidden { |
| + t.Fatal("stranger must be refused") |
| + } |
| + if len(attached) != 1 { |
| + t.Fatalf("refused call still attached: %v", attached) |
| + } |
| +} |
| + |
| +func TestMeterSibling(t *testing.T) { |
| + if got := meterSibling("https://cloud.example/api/usage", "ai-optin"); got != "https://cloud.example/api/ai-optin" { |
| + t.Fatalf("meterSibling = %q", got) |
| + } |
| +} |
| + |
| +// Flipping AI on in the admin settings asks the provisioner for the sidecar; |
| +// re-sending true (no transition) and flipping off stay silent. |
| +func TestSetSettingsAIFlipSignals(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + signals := make(chan struct{}, 8) |
| + cloud := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path == "/api/ai-optin" { |
| + signals <- struct{}{} |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| + })) |
| + defer cloud.Close() |
| + s.meterURL, s.meterToken = cloud.URL+"/api/usage", "meter-tok" |
| + |
| + set := func(on bool) { |
| + t.Helper() |
| + raw, _ := json.Marshal(map[string]any{"ai_enabled": on}) |
| + req, _ := http.NewRequest("POST", ts.URL+"/api/admin/settings", strings.NewReader(string(raw))) |
| + req.Header.Set("Authorization", "Bearer admintok") |
| + req.Header.Set("Content-Type", "application/json") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("settings: %d", resp.StatusCode) |
| + } |
| + } |
| + waitSignal := func(want bool) { |
| + t.Helper() |
| + select { |
| + case <-signals: |
| + if !want { |
| + t.Fatal("unexpected opt-in signal") |
| + } |
| + case <-time.After(2 * time.Second): |
| + if want { |
| + t.Fatal("no opt-in signal arrived") |
| + } |
| + } |
| + } |
| + // THE core case: a fresh space's FIRST explicit "yes" signals, even |
| + // though the implicit default already read as on (the reviewer-caught |
| + // bug — a was/now comparison against the default swallowed it). |
| + set(true) |
| + waitSignal(true) |
| + // Re-sending true is not new consent; turning off never signals. |
| + set(true) |
| + set(false) |
| + select { |
| + case <-signals: |
| + t.Fatal("non-consent writes must not signal") |
| + case <-time.After(300 * time.Millisecond): |
| + } |
| + // Off → on is consent again (the re-enable path re-asks the provisioner). |
| + set(true) |
| + waitSignal(true) |
| +} |
diff --git a/bootstrap.go b/bootstrap.go| index 9540dbb..a14e6e6 100644 |
| --- a/bootstrap.go |
| +++ b/bootstrap.go |
| @@ -87,7 +87,7 @@ func ensureServiceUser() { |
| // chownToService recursively gives a path to the service user (best-effort exec of |
| // chown -R, so it also fixes ownership of any data already present). |
| -func chownToService(path string) { |
| +var chownToService = func(path string) { |
| out, err := exec.Command("chown", "-R", serviceUser+":"+serviceUser, path).CombinedOutput() |
| if err != nil { |
| die("bootstrap: chown %s: %v: %s", path, err, strings.TrimSpace(string(out))) |
diff --git a/bot.go b/bot.go| index f0eac24..03e3b0d 100644 |
| --- a/bot.go |
| +++ b/bot.go |
| @@ -25,13 +25,98 @@ import ( |
| 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@<host> — 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 <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. |
| +// 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") |
| @@ -60,76 +145,16 @@ func runAddBot(args []string) { |
| if rt == nil || rt.Kind != "instance" { |
| die("add-bot: %q is not a messenger instance (run add-instance first)", host) |
| } |
| - env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env")) |
| - if err != nil { |
| - die("add-bot: read instance env: %v", err) |
| - } |
| - admin := env["LCHAT_ADMIN_TOKEN"] |
| - if admin == "" { |
| - die("add-bot: no LCHAT_ADMIN_TOKEN for %s", host) |
| - } |
| - |
| - base := "https://" + host |
| - sessionDir := botSessionDir(host) |
| - |
| - // One agent invite per identity. Both 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 — |
| - // and read in the Admin app's update stream.) |
| - identities := []struct{ envVar, label, desc string }{ |
| - {"LCHAT_INVITE", "Claude", "General assistant — DM me or @mention me in a group."}, |
| - {"LCHAT_ELEVEN_INVITE", "Eleven", "Your guide to the app — I welcome new members."}, |
| - } |
| - botEnv := map[string]string{ |
| - "LCHAT_BASE": base, |
| - "LCHAT_SESSION": filepath.Join(sessionDir, "session.json"), |
| - "LCHAT_ELEVEN_SESSION": filepath.Join(sessionDir, "eleven-session.json"), |
| - } |
| - 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 { |
| - die("add-bot: mint %s invite: %v", id.label, err) |
| - } |
| - botEnv[id.envVar] = t |
| - } |
| - // 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 { |
| - die("add-bot: mkdir %s: %v", sessionDir, err) |
| - } |
| - envPath := botEnvPath(host) |
| - if err := writeEnvFile(envPath, botEnv); err != nil { |
| - die("add-bot: write bot env: %v", err) |
| + msg, err := attachBot(host, rt.Hibernate, []botIdentitySpec{claudeIdentity, elevenIdentity}, apiKey, *model, *botUnit) |
| + if err != nil { |
| + die("add-bot: %v", err) |
| } |
| - // add-bot 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 := *botUnit + host |
| - if rt.Hibernate { |
| - // An activator-managed host co-spawns its sidecar with each wake |
| - // (activator.go) — starting the always-on unit here would double-run |
| - // it. Staging the env file is enough: the sidecar joins on the host's |
| - // next wake and provisions itself from the invites then. |
| - fmt.Printf("added bot sidecar for %s (hibernating host: the activator co-spawns it on the next wake — no %s unit)\n", host, unit) |
| - } else if err := systemctl("enable", "--now", unit); err != nil { |
| - die("add-bot: start %s: %v (invites minted; env written to %s)", unit, err, envPath) |
| - } else { |
| - fmt.Printf("added bot sidecar for %s (%s)\n", host, unit) |
| - } |
| + 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", envPath, unit) |
| + " 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) |
| } |
diff --git a/changelog/2026-07-14-ai-opt-in.md b/changelog/2026-07-14-ai-opt-in.md| new file mode 100644 |
| index 0000000..41a0672 |
| --- /dev/null |
| +++ b/changelog/2026-07-14-ai-opt-in.md |
| @@ -0,0 +1 @@ |
| +🤖 New spaces now choose AI for themselves. During setup, Eleven asks plainly whether to bring AI in — with an honest note first: Eleven's AI runs on Eleven Messenger's machines and can read the chats it's invited to, which is exactly what lets it help. Say yes and Eleven joins your space moments later; say no and nothing AI ever runs there. You can change your mind anytime in Admin — switching AI on later brings Eleven in the same way. |
diff --git a/cloud.go b/cloud.go| index 5726718..9e75bb8 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -110,6 +110,9 @@ func (s *cloudServer) handler() http.Handler { |
| // Usage ingest (#857): meter-token-gated, not caller-gated — an instance |
| // can only ever report as itself. |
| mux.HandleFunc("POST /api/usage", s.usageHandler) |
| + // AI opt-in (#858): same meter-token gate — an instance whose admin |
| + // switched AI on asks for its sidecar; idempotent all the way down. |
| + mux.HandleFunc("POST /api/ai-optin", s.aiOptInHandler) |
| // Admin surface: mint / list / revoke caller tokens at runtime (mirrors the |
| // push relay's admin API). |
| mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintTokenHandler)) |
| @@ -244,6 +247,12 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| } |
| log.Printf("cloud: created %s (id %d, caller %.8s…)", host, id, callerID) |
| s.pushQuota(id, host, entFree, "") // insurance cap for a fresh free space (no-op unless caps configured) |
| + if s.usageURL != "" { |
| + // Meter token from first boot (#858): the usage pipe AND the AI |
| + // opt-in channel — a fresh space's onboarding opt-in must not |
| + // wait for the sweep to mint its credential. |
| + s.pushMeterToken(id, host) |
| + } |
| writeJSON(w, http.StatusCreated, map[string]any{ |
| "host": host, |
| "url": body["url"], |
diff --git a/cloud_usage.go b/cloud_usage.go| index 07d1d3d..351ee0e 100644 |
| --- a/cloud_usage.go |
| +++ b/cloud_usage.go |
| @@ -53,6 +53,56 @@ func (s *cloudServer) usageHandler(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": map[string]any{"state": "ok"}}) |
| } |
| +// aiOptInHandler provisions a space's bot sidecar when its admin switches AI |
| +// on (#858, docs/ai-provisioning.md phase 2). Auth is the space's meter token |
| +// — the instance's one cloud-facing credential — so a space can only ever ask |
| +// for itself. Idempotent end to end (controld answers ok for an |
| +// already-staged sidecar), so the instance signals on every flip-to-on. |
| +func (s *cloudServer) aiOptInHandler(w http.ResponseWriter, r *http.Request) { |
| + spaceID, ok := s.db.spaceByMeterToken(bearerTokenHeader(r)) |
| + if !ok { |
| + writeJSON(w, http.StatusForbidden, map[string]any{"error": "bad meter token"}) |
| + return |
| + } |
| + m, err := s.db.spaceBillingByID(spaceID) |
| + if err != nil || m.Host == "" { |
| + writeJSON(w, http.StatusNotFound, map[string]any{"error": "unknown space"}) |
| + return |
| + } |
| + detail, err := s.controlAttachBot(m.Host) |
| + if err != nil { |
| + log.Printf("cloud: ai-optin %s: %v", m.Host, err) |
| + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "provisioning failed"}) |
| + return |
| + } |
| + log.Printf("cloud: ai-optin %s — %s", m.Host, detail) |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "detail": detail}) |
| +} |
| + |
| +// controlAttachBot asks controld to stage one instance's sidecar. |
| +func (s *cloudServer) controlAttachBot(host string) (string, error) { |
| + req, err := http.NewRequest("POST", "http://control/instances/"+host+"/bot", nil) |
| + if err != nil { |
| + return "", err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+s.controlToken) |
| + resp, err := s.control.Do(req) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return "", fmt.Errorf("control attach-bot %s: HTTP %d", host, resp.StatusCode) |
| + } |
| + var out struct { |
| + Detail string `json:"detail"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { |
| + return "", err |
| + } |
| + return out.Detail, nil |
| +} |
| + |
| // ---- meter-token mint + push (sweep-driven) -------------------------------------- |
| // pushMeterToken mints a fresh meter token for a space and hands it to the |
diff --git a/controld.go b/controld.go| index 99630e9..9dc71df 100644 |
| --- a/controld.go |
| +++ b/controld.go |
| @@ -107,6 +107,7 @@ func (c *controld) handler() http.Handler { |
| mux.HandleFunc("GET /instances/{host}/stats", c.auth(c.statsHandler)) |
| mux.HandleFunc("POST /instances/{host}/quota", c.auth(c.quotaHandler)) |
| mux.HandleFunc("POST /instances/{host}/meter", c.auth(c.meterHandler)) |
| + mux.HandleFunc("POST /instances/{host}/bot", c.auth(c.botHandler)) |
| mux.HandleFunc("POST /instances/{host}/replication", c.auth(c.replicationHandler)) |
| mux.HandleFunc("POST /instances/{host}/materialize", c.auth(c.materializeHandler)) |
| return mux |
| @@ -324,6 +325,48 @@ func (c *controld) meterHandler(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "restarted": restarted}) |
| } |
| +// botHandler attaches a bot sidecar to one instance — the provisioner's half |
| +// of the AI opt-in (#858, docs/ai-provisioning.md phase 2). Cloud spaces get |
| +// **Eleven only** (the product voice; admins add further agents through the |
| +// admin console, which the managed sidecar picks up). Idempotent: a host |
| +// whose bot env is already staged answers ok without touching anything, so |
| +// re-opt-ins and admin toggle flips are safe to signal every time. |
| +// |
| +// The model key comes from controld's own environment (ANTHROPIC_API_KEY) — |
| +// the sidecar holds it, never the instance (CLAUDE.md's bot invariant). With |
| +// no key configured the sidecar still attaches deterministic-only and the |
| +// response says so, so the operator gap is visible rather than silent. |
| +func (c *controld) botHandler(w http.ResponseWriter, r *http.Request) { |
| + host := strings.ToLower(strings.TrimSpace(r.PathValue("host"))) |
| + if !validHost(host) { |
| + writeControlErr(w, http.StatusBadRequest, "invalid_host", "not a valid hostname") |
| + return |
| + } |
| + hibernating := false |
| + if c.reg != nil { |
| + rt, err := c.reg.get(host) |
| + if err != nil || rt == nil || rt.Kind != "instance" { |
| + writeControlErr(w, http.StatusNotFound, "unknown_host", "no such instance") |
| + return |
| + } |
| + hibernating = rt.Hibernate |
| + } |
| + apiKey := strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) |
| + msg, err := attachBot(host, hibernating, []botIdentitySpec{elevenIdentity}, apiKey, "", envOr("LCHAT_BOT_UNIT", "lchat-bot@")) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + // No instance env file: a registered-but-never-materialized host. |
| + writeControlErr(w, http.StatusNotFound, "unknown_host", "no such instance") |
| + return |
| + } |
| + log.Printf("controld: attach-bot %s: %v", host, err) |
| + writeControlErr(w, http.StatusInternalServerError, "attach_failed", err.Error()) |
| + return |
| + } |
| + log.Printf("controld: attach-bot %s — %s (ai=%v)", host, msg, apiKey != "") |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "detail": msg, "ai": apiKey != ""}) |
| +} |
| + |
| // statsHandler reports the reaper's guards for a space — setup_complete, member |
| // count, and has_content (any message or thread). Read-only against the live |
| // instance DB. validHost guards the DB path against traversal before it's joined |
diff --git a/db.go b/db.go| index 95cf953..78a5257 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -135,6 +135,17 @@ func (db *DB) aiEnabled() bool { |
| v, ok, _ := db.getConfig("ai_enabled") |
| return !ok || v == "1" |
| } |
| + |
| +// aiEnabledExplicitly distinguishes "on because nothing was ever written" |
| +// (the compat default above) from "an admin explicitly said yes". The AI |
| +// opt-in signal (#858) keys on the latter: a fresh space's first "Bring |
| +// Eleven in" must count as consent even though the implicit default already |
| +// read true — and the default-on reading itself is harmless there, because |
| +// no sidecar exists until that explicit consent asks for one. |
| +func (db *DB) aiEnabledExplicitly() bool { |
| + v, ok, _ := db.getConfig("ai_enabled") |
| + return ok && v == "1" |
| +} |
| func (db *DB) setAIEnabled(on bool) error { return db.setConfig("ai_enabled", boolStr(on)) } |
| func (db *DB) botsEnabled() bool { |
diff --git a/docs/ai-provisioning.md b/docs/ai-provisioning.md| index 287a23e..e0300dc 100644 |
| --- a/docs/ai-provisioning.md |
| +++ b/docs/ai-provisioning.md |
| @@ -1,7 +1,9 @@ |
| # 🤖 Managed AI on provisioned spaces — design |
| -Status: **design agreed 2026-07-14** (decisions below are Paul's); nothing in |
| -this document is built unless a phase says so. This is the plan for making the |
| +Status: **design agreed 2026-07-14** (decisions below are Paul's). Built so |
| +far: phase 0 (#806 fix + Go invite mint, PRs #864/#865), phase 1 (the usage |
| +pipe, PR #867), phase 2 (attach-bot + the onboarding opt-in, #858). Phases |
| +3–5 are unbuilt. This is the plan for making the |
| onboarding "enable AI" answer *do* something on self-serve spaces: provision a |
| sidecar, meter what it spends, bill it honestly, and eventually move the whole |
| bot trust plane off the serving box. |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index 35c6e1a..cf8150b 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -557,6 +557,14 @@ curl -sX POST https://cloud.<domain>/api/admin/tokens \ |
| - **App UI** — "+ → Sign in (home.elevenmessenger.com) → new space", and the |
| server list syncing/sorting through the existing encrypted blob. |
| -- **Bot sidecars on provisioned spaces** — once the Go bot runtime |
| - (`lchat bot`) lands; controld grows an attach-bot step. |
| +- ~~**Bot sidecars on provisioned spaces**~~ — SHIPPED (#858): controld's |
| + `POST /instances/{host}/bot` stages an **Eleven-only** sidecar (idempotent; |
| + the model key comes from controld's own env, never the instance's). The |
| + trigger is the admin's AI opt-in: setup's dedicated `ai-optin` step (or any |
| + later flip of the AI toggle) makes the instance signal |
| + `POST /api/ai-optin` on the cloud over its meter token, and the cloud calls |
| + controld. Hibernating hosts stage the env only — the activator co-spawns at |
| + wake, and its sweep adopts an already-awake child within seconds. Meter |
| + tokens are now also minted eagerly at create, so a fresh space can opt in |
| + during onboarding without waiting for the sweep. |
| - **Multi-box placement** — a cloud front choosing among several router boxes. |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json| index 69a5247..be120b9 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| @@ -322,7 +322,7 @@ |
| "skipIf": [ |
| "done" |
| ], |
| - "skipTo": "keys", |
| + "skipTo": "ai-optin", |
| "entries": [ |
| { |
| "if": "done.extras-apps", |
| @@ -330,156 +330,157 @@ |
| "label": "chose this space's extras", |
| "value": "chosen" |
| }, |
| - "goto": "keys" |
| + "goto": "ai-optin" |
| } |
| ], |
| "bubbles": [ |
| { |
| - "text": "Nearly there. Your space comes with a few extras:\n\n• AI — ask Eleven things in plain language and it helps out.\n• Bots — little helpers members can create for reminders and jobs.\n• Apps — to-dos, polls, whiteboards, and a studio for building your own.\n\nEverything essential works with any of these off, and you can change your mind anytime in Admin." |
| + "text": "Nearly there. Your space comes with a couple of extras:\n\n• Bots — little helpers members can create for reminders and jobs.\n• Apps — to-dos, polls, whiteboards, and a studio for building your own.\n\nEverything essential works with these off, and you can change your mind anytime in Admin." |
| } |
| ], |
| "chips": [ |
| { |
| - "label": "Keep everything on", |
| + "label": "Keep both on", |
| "spent": "Kept", |
| "record": { |
| - "label": "kept all the extras on", |
| + "label": "kept bots and apps on", |
| "value": "all-on" |
| }, |
| - "goto": "keys" |
| + "goto": "ai-optin" |
| }, |
| { |
| "label": "Let me choose", |
| - "goto": "extras-ai" |
| + "goto": "extras-bots" |
| } |
| ] |
| }, |
| { |
| - "id": "extras-ai", |
| + "id": "extras-bots", |
| "skipIf": [ |
| "done" |
| ], |
| "bubbles": [ |
| { |
| - "text": "AI first. With it on, members can ask Eleven things in plain language and it answers, builds, and helps out. Everything works without it — you just won't have the natural-language help." |
| + "text": "Bots — helpers that live in chats and do jobs: reminders, summaries, little tools. Members can create their own." |
| } |
| ], |
| "chips": [ |
| { |
| - "label": "Enable AI", |
| + "label": "Enable bots", |
| "effect": { |
| "verb": "setFeature", |
| "args": [ |
| - "ai", |
| + "bots", |
| true |
| ] |
| }, |
| "record": { |
| - "label": "enabled AI", |
| + "label": "enabled bots", |
| "value": "on" |
| }, |
| - "goto": "extras-bots" |
| + "goto": "extras-apps" |
| }, |
| { |
| - "label": "No AI here", |
| + "label": "No bots here", |
| "spent": "Off", |
| "effect": { |
| "verb": "setFeature", |
| "args": [ |
| - "ai", |
| + "bots", |
| false |
| ] |
| }, |
| "record": { |
| - "label": "switched AI off", |
| + "label": "switched bots off", |
| "value": "off" |
| }, |
| - "goto": "extras-bots" |
| + "goto": "extras-apps" |
| } |
| ] |
| }, |
| { |
| - "id": "extras-bots", |
| + "id": "extras-apps", |
| "skipIf": [ |
| "done" |
| ], |
| "bubbles": [ |
| { |
| - "text": "Bots next — helpers that live in chats and do jobs: reminders, summaries, little tools. Members can create their own." |
| + "text": "And apps — the built-in extras like to-dos, polls, and whiteboards, plus the App Studio where members build their own." |
| } |
| ], |
| "chips": [ |
| { |
| - "label": "Enable bots", |
| + "label": "Enable apps", |
| "effect": { |
| "verb": "setFeature", |
| "args": [ |
| - "bots", |
| + "apps", |
| true |
| ] |
| }, |
| "record": { |
| - "label": "enabled bots", |
| + "label": "enabled apps", |
| "value": "on" |
| }, |
| - "goto": "extras-apps" |
| + "goto": "ai-optin" |
| }, |
| { |
| - "label": "No bots here", |
| + "label": "No apps here", |
| "spent": "Off", |
| "effect": { |
| "verb": "setFeature", |
| "args": [ |
| - "bots", |
| + "apps", |
| false |
| ] |
| }, |
| "record": { |
| - "label": "switched bots off", |
| + "label": "switched apps off", |
| "value": "off" |
| }, |
| - "goto": "extras-apps" |
| + "goto": "ai-optin" |
| } |
| ] |
| }, |
| { |
| - "id": "extras-apps", |
| + "id": "ai-optin", |
| "skipIf": [ |
| "done" |
| ], |
| "bubbles": [ |
| { |
| - "text": "And apps — the built-in extras like to-dos, polls, and whiteboards, plus the App Studio where members build their own." |
| + "text": "Last choice — AI. Say yes and Eleven, the built-in helper, joins this space: members can ask it things in plain language and it answers, builds, and helps out.\n\nAn honest note before you decide: Eleven's AI runs on Eleven Messenger's machines, and it can read the chats it's invited to — that's what lets it help. It never sees chats it isn't in, and you can switch it off anytime in Admin." |
| } |
| ], |
| "chips": [ |
| { |
| - "label": "Enable apps", |
| + "label": "Bring Eleven in", |
| + "spent": "On", |
| "effect": { |
| "verb": "setFeature", |
| "args": [ |
| - "apps", |
| + "ai", |
| true |
| ] |
| }, |
| "record": { |
| - "label": "enabled apps", |
| + "label": "invited Eleven's AI in", |
| "value": "on" |
| }, |
| "goto": "keys" |
| }, |
| { |
| - "label": "No apps here", |
| + "label": "Not now", |
| "spent": "Off", |
| "effect": { |
| "verb": "setFeature", |
| "args": [ |
| - "apps", |
| + "ai", |
| false |
| ] |
| }, |
| "record": { |
| - "label": "switched apps off", |
| + "label": "kept AI off", |
| "value": "off" |
| }, |
| "goto": "keys" |
diff --git a/main.go b/main.go| index c20c7b4..b27cb72 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -4365,10 +4365,25 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| } |
| } |
| if body.AIEnabled != nil { |
| + // EXPLICIT consent is what signals, not the implicit default: |
| + // aiEnabled() reads true on a fresh space (absent key, the compat |
| + // default), so a plain was/now comparison would swallow the very |
| + // first "Bring Eleven in" — the feature's core case. Explicitly-on |
| + // before the write is the one state that stays silent. |
| + wasExplicitlyOn := s.db.aiEnabledExplicitly() |
| if err := s.db.setAIEnabled(*body.AIEnabled); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + // An explicit enable asks the provisioner to make sure this space's |
| + // sidecar exists (#858) — from ANY admin surface (setup flow, admin |
| + // console, Admin app), so the toggle and the provisioning can't |
| + // drift. Async and best-effort: idempotent end to end, no-op on |
| + // self-hosted instances (no meter env), and a lost signal is |
| + // re-sent by the next flip. |
| + if *body.AIEnabled && !wasExplicitlyOn { |
| + go s.signalAIOptIn() |
| + } |
| } |
| if body.BotsEnabled != nil { |
| if err := s.db.setBotsEnabled(*body.BotsEnabled); err != nil { |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index ba88d85..326b6d9 100644 |
| --- a/test/flow.test.mjs |
| +++ b/test/flow.test.mjs |
| @@ -189,13 +189,18 @@ await test("setup: the full first-run transcript, happy path with detours", asyn |
| await h.tap("Keep this photo"); |
| assert.deepEqual(h.records[3], { step: "photo", label: "set the space photo", value: "Our logo" }); |
| - // Invites → extras → keys: the one-tap common path records and moves on. |
| + // Invites → extras → the AI opt-in → keys: the one-tap common path records |
| + // and moves on, and AI is always its own explicit question. |
| await h.tap("Only me"); |
| assert.ok(h.verbLog.find((v) => v[0] === "setUserInvites" && v[1] === false)); |
| - assert.ok(h.transcript.find((t) => /comes with a few extras/.test(t.text))); |
| - await h.tap("Keep everything on"); |
| + assert.ok(h.transcript.find((t) => /comes with a couple of extras/.test(t.text))); |
| + await h.tap("Keep both on"); |
| assert.ok(h.records.find((r) => r.step === "extras" && r.value === "all-on")); |
| assert.equal(h.verbLog.some((v) => v[0] === "setFeature"), false, "keeping defaults writes nothing"); |
| + assert.ok(h.transcript.find((t) => /read the chats it's invited to/.test(t.text)), "the AI question carries the honest note"); |
| + await h.tap("Bring Eleven in"); |
| + assert.deepEqual(h.verbLog.at(-1), ["setFeature", "ai", true], "opting in writes the gate explicitly"); |
| + assert.ok(h.records.find((r) => r.step === "ai-optin" && r.value === "on")); |
| assert.ok(h.transcript.find((t) => /Now the most important thing of all/.test(t.text))); |
| await h.tap("Sign in"); |
| assert.ok(h.verbLog.find((v) => v[0] === "savePasskey")); |
| @@ -246,7 +251,9 @@ await test("setup: resumed run — skips, Keep chip, skip-everything path", asyn |
| await h.tap("Skip for now"); // photo — must disarm the waiter |
| assert.equal(h.cancelled, 1, "photo skip cancels the pending answer"); |
| await h.tap("Members can invite people"); |
| - await h.tap("Keep everything on"); |
| + await h.tap("Keep both on"); |
| + await h.tap("Not now"); // the AI opt-in — declining writes an explicit off |
| + assert.deepEqual(h.verbLog.at(-1), ["setFeature", "ai", false]); |
| // Keys: decline twice through the plain-words confirm. |
| await h.tap("Not now"); |
| @@ -266,7 +273,7 @@ await test("setup: already enrolled — keys short-circuits with the once-only a |
| const h = harness({ |
| myName: "Paul", |
| enrolled: true, |
| - done: { name: true, group: true, email: true, photo: true, invites: true, extras: true }, |
| + done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true }, |
| }); |
| runFlow(SETUP_FLOW, h.env, h.io); |
| await tick(); |
| @@ -276,7 +283,7 @@ await test("setup: already enrolled — keys short-circuits with the once-only a |
| assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete")); |
| // Second entry with keys done: no duplicate record, straight to finish. |
| - const h2 = harness({ myName: "Paul", enrolled: true, done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, keys: true } }); |
| + const h2 = harness({ myName: "Paul", enrolled: true, done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true, keys: true } }); |
| runFlow(SETUP_FLOW, h2.env, h2.io); |
| await tick(); |
| // No duplicate keys record — only finish stamping its completion marker. |
| @@ -284,30 +291,32 @@ await test("setup: already enrolled — keys short-circuits with the once-only a |
| assert.equal(h2.transcript.some((t) => /key is safe. ✅/.test(t.text)), false); |
| }); |
| -await test("setup: extras — 'Let me choose' walks the trio and writes each gate", async () => { |
| +await test("setup: extras — 'Let me choose' walks the pair, then the AI opt-in", async () => { |
| const h = harness({ |
| myName: "Paul", |
| done: { name: true, group: true, email: true, photo: true, invites: true }, |
| }); |
| runFlow(SETUP_FLOW, h.env, h.io); |
| await tick(); |
| - assert.ok(h.transcript.find((t) => /comes with a few extras/.test(t.text))); |
| + assert.ok(h.transcript.find((t) => /comes with a couple of extras/.test(t.text))); |
| await h.tap("Let me choose"); |
| - assert.ok(h.transcript.find((t) => /AI first/.test(t.text))); |
| - await h.tap("No AI here"); |
| - assert.deepEqual(h.verbLog.at(-1), ["setFeature", "ai", false]); |
| + assert.ok(h.transcript.find((t) => /Bots — helpers/.test(t.text))); |
| await h.tap("Enable bots"); |
| assert.deepEqual(h.verbLog.at(-1), ["setFeature", "bots", true]); |
| await h.tap("No apps here"); |
| assert.deepEqual(h.verbLog.at(-1), ["setFeature", "apps", false]); |
| - assert.ok(h.records.find((r) => r.step === "extras-ai" && r.value === "off")); |
| + // The AI opt-in follows the granular pair too — never skippable by path. |
| + assert.ok(h.transcript.find((t) => /read the chats it's invited to/.test(t.text))); |
| + await h.tap("Not now"); |
| + assert.deepEqual(h.verbLog.at(-1), ["setFeature", "ai", false]); |
| assert.ok(h.records.find((r) => r.step === "extras-apps" && r.value === "off")); |
| - // The trio lands on the keys pitch like the one-tap path. |
| + assert.ok(h.records.find((r) => r.step === "ai-optin" && r.value === "off")); |
| + // The path lands on the keys pitch like the one-tap path. |
| assert.ok(h.transcript.find((t) => /Now the most important thing of all/.test(t.text))); |
| // Resume after the granular path: the extras entry closes the step out and |
| - // the trio never replays. |
| + // neither the pair nor the AI question replays. |
| const h2 = harness({ |
| myName: "Paul", |
| enrolled: true, |
| @@ -315,7 +324,7 @@ await test("setup: extras — 'Let me choose' walks the trio and writes each gat |
| }); |
| runFlow(SETUP_FLOW, h2.env, h2.io); |
| await tick(); |
| - assert.equal(h2.transcript.some((t) => /AI first/.test(t.text)), false, "granular steps don't replay"); |
| + assert.equal(h2.transcript.some((t) => /read the chats it's invited to/.test(t.text)), false, "answered steps don't replay"); |
| assert.ok(h2.records.find((r) => r.step === "extras" && r.value === "chosen"), "the entry closes extras"); |
| assert.ok(h2.records.find((r) => r.step === "keys"), "resume reaches keys"); |
| }); |
| @@ -324,7 +333,7 @@ await test("setup: from-home key step + settle auto-detects the enrolment return |
| const h = harness({ |
| myName: "Paul", |
| fromHome: true, // arrived from the Eleven sign-in page → the keys-home variant |
| - done: { name: true, group: true, email: true, photo: true, invites: true, extras: true }, |
| + done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true }, |
| }); |
| runFlow(SETUP_FLOW, h.env, h.io); |
| await tick(); |
| @@ -365,7 +374,8 @@ await test("setup: native creator — greeting shows, name pre-set, passkey neve |
| await h.tap("Skip for now"); // email; photo auto-skips under native |
| assert.equal(h.transcript.some((t) => t.extra.key === "q-photo"), false); |
| await h.tap("Members can invite people"); |
| - await h.tap("Keep everything on"); |
| + await h.tap("Keep both on"); |
| + await h.tap("Not now"); // the AI opt-in runs under native too |
| // Keys: never pitched — recorded as covered by the Eleven account. |
| assert.equal(h.transcript.some((t) => /Now the most important thing of all/.test(t.text)), false); |
| @@ -625,7 +635,7 @@ await test("welcome: enrolment confirmed while on access-waiting settles through |
| await test("setup: enrolment confirmed while on keys-waiting settles through", async () => { |
| const h = harness({ |
| myName: "Paul", |
| - done: { name: true, group: true, email: true, photo: true, invites: true, extras: true }, |
| + done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true }, |
| }); |
| runFlow(SETUP_FLOW, h.env, h.io); |
| await tick(); |
diff --git a/test/quickform.test.mjs b/test/quickform.test.mjs| index e32a53b..a0fdd49 100644 |
| --- a/test/quickform.test.mjs |
| +++ b/test/quickform.test.mjs |
| @@ -39,6 +39,9 @@ await test("setup plan, everything on: no setFeature writes, the one-tap extras |
| { verb: "setGroupName", args: ["Team Awesome"] }, |
| { verb: "setContactEmail", args: ["paul@example.com"] }, |
| { verb: "setUserInvites", args: [true] }, |
| + // AI is its own explicit opt-in: ALWAYS written, even in the |
| + // everything-on plan (bots/apps stay write-nothing defaults). |
| + { verb: "setFeature", args: ["ai", true] }, |
| ]); |
| assert.deepEqual(p.records, [ |
| { step: "name", label: "noted: you're Paul", value: "Paul" }, |
| @@ -46,7 +49,8 @@ await test("setup plan, everything on: no setFeature writes, the one-tap extras |
| { step: "email", label: "noted a contact email", value: "paul@example.com" }, |
| { step: "photo", label: "skipped the space photo", value: "skipped" }, |
| { step: "invites", label: "decided members can invite people", value: "members" }, |
| - { step: "extras", label: "kept all the extras on", value: "all-on" }, |
| + { step: "extras", label: "kept bots and apps on", value: "all-on" }, |
| + { step: "ai-optin", label: "invited Eleven's AI in", value: "on" }, |
| ]); |
| }); |
| @@ -62,18 +66,18 @@ await test("setup plan, granular extras + skipped email + admin-only invites", ( |
| }); |
| assert.equal(p.verbs.some((v) => v.verb === "setContactEmail"), false, "an empty email writes nothing"); |
| assert.deepEqual(p.verbs.filter((v) => v.verb === "setFeature"), [ |
| - { verb: "setFeature", args: ["ai", false] }, |
| { verb: "setFeature", args: ["bots", true] }, |
| { verb: "setFeature", args: ["apps", false] }, |
| + { verb: "setFeature", args: ["ai", false] }, |
| ]); |
| assert.deepEqual(p.verbs.find((v) => v.verb === "setUserInvites"), { verb: "setUserInvites", args: [false] }); |
| const by = Object.fromEntries(p.records.map((r) => [r.step, r])); |
| assert.deepEqual(by.email, { step: "email", label: "skipped the contact email", value: "skipped" }); |
| assert.deepEqual(by.invites, { step: "invites", label: "decided only the admin invites people", value: "admin only" }); |
| - assert.deepEqual(by["extras-ai"], { step: "extras-ai", label: "switched AI off", value: "off" }); |
| assert.deepEqual(by["extras-bots"], { step: "extras-bots", label: "enabled bots", value: "on" }); |
| assert.deepEqual(by["extras-apps"], { step: "extras-apps", label: "switched apps off", value: "off" }); |
| - assert.equal(by.extras, undefined, "the one-tap record only when everything stays on"); |
| + assert.deepEqual(by["ai-optin"], { step: "ai-optin", label: "kept AI off", value: "off" }); |
| + assert.equal(by.extras, undefined, "the one-tap record only when the pair stays on"); |
| }); |
| // The crucial invariant behind the fast-fill: every planned record names a |
diff --git a/usage.go b/usage.go| index 6436919..49bddd6 100644 |
| --- a/usage.go |
| +++ b/usage.go |
| @@ -18,7 +18,10 @@ import ( |
| "bytes" |
| "encoding/json" |
| "fmt" |
| + "log" |
| "net/http" |
| + "net/url" |
| + "path" |
| "regexp" |
| "time" |
| ) |
| @@ -115,6 +118,49 @@ func (s *Server) postBotUsage(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": budget}) |
| } |
| +// meterSibling swaps the last path segment of the meter URL for a sibling |
| +// endpoint — the meter env carries one URL (…/api/usage) and the opt-in |
| +// signal (…/api/ai-optin) rides the same credential and base. |
| +func meterSibling(meterURL, name string) string { |
| + u, err := url.Parse(meterURL) |
| + if err != nil { |
| + return "" |
| + } |
| + u.Path = path.Join(path.Dir(u.Path), name) |
| + return u.String() |
| +} |
| + |
| +// signalAIOptIn tells the provisioner this space wants its bot sidecar |
| +// (#858). Fire-and-forget from setSettings: the attach is idempotent, a |
| +// failure only means the admin flips the toggle again (or asks), and a |
| +// self-hosted instance (no meter env) has no provisioner to ask. |
| +func (s *Server) signalAIOptIn() { |
| + if s.meterURL == "" || s.meterToken == "" { |
| + return |
| + } |
| + target := meterSibling(s.meterURL, "ai-optin") |
| + if target == "" { |
| + return |
| + } |
| + req, err := http.NewRequest("POST", target, nil) |
| + if err != nil { |
| + return |
| + } |
| + req.Header.Set("Authorization", "Bearer "+s.meterToken) |
| + client := &http.Client{Timeout: 30 * time.Second} |
| + resp, err := client.Do(req) |
| + if err != nil { |
| + log.Printf("ai-optin: provisioner unreachable: %v", err) |
| + return |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + log.Printf("ai-optin: provisioner answered HTTP %d", resp.StatusCode) |
| + return |
| + } |
| + log.Printf("ai-optin: sidecar requested from the provisioner") |
| +} |
| + |
| // relayUsage forwards a validated report to the cloud ledger and returns the |
| // budget object from its response. |
| func relayUsage(meterURL, meterToken, reportID string, rows []botUsageRow) (map[string]any, error) { |
diff --git a/web/static/apps/quickform.js b/web/static/apps/quickform.js| index e208122..baf70ad 100644 |
| --- a/web/static/apps/quickform.js |
| +++ b/web/static/apps/quickform.js |
| @@ -52,16 +52,15 @@ export function planQuickForm(kind, values) { |
| : { step: "invites", label: "decided only the admin invites people", value: "admin only" }, |
| ); |
| - // The extras trio mirrors the conversation's two paths: everything on is the |
| - // one-tap record (defaults are already on server-side — writing nothing is |
| - // the point), anything off walks the granular records, writing each gate. |
| + // Bots + apps mirror the conversation's two paths: both on is the one-tap |
| + // record (defaults are already on server-side — writing nothing is the |
| + // point), anything off walks the granular records, writing each gate. |
| const extras = [ |
| - ["ai", v.ai !== false, "AI"], |
| ["bots", v.bots !== false, "bots"], |
| ["apps", v.apps !== false, "apps"], |
| ]; |
| if (extras.every(([, on]) => on)) { |
| - records.push({ step: "extras", label: "kept all the extras on", value: "all-on" }); |
| + records.push({ step: "extras", label: "kept bots and apps on", value: "all-on" }); |
| } else { |
| for (const [key, on, noun] of extras) { |
| verbs.push({ verb: "setFeature", args: [key, on] }); |
| @@ -72,5 +71,16 @@ export function planQuickForm(kind, values) { |
| }); |
| } |
| } |
| + // AI is its own explicit opt-IN (docs/ai-provisioning.md): ALWAYS written — |
| + // unticked means off, matching the conversation's dedicated step — and |
| + // never bundled into the everything-on shortcut, because saying yes |
| + // provisions a real helper on a cloud space. |
| + const ai = v.ai === true; |
| + verbs.push({ verb: "setFeature", args: ["ai", ai] }); |
| + records.push({ |
| + step: "ai-optin", |
| + label: ai ? "invited Eleven's AI in" : "kept AI off", |
| + value: ai ? "on" : "off", |
| + }); |
| return { name, verbs, records }; |
| } |
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js| index 8469811..3804b67 100644 |
| --- a/web/static/apps/setup.flow.js |
| +++ b/web/static/apps/setup.flow.js |
| @@ -205,70 +205,48 @@ export default { |
| ], |
| }, |
| { |
| - // The extras trio (AI / bots / apps — the #712 gates) as ONE question |
| - // with a one-tap common path; "Let me choose" opens the granular three. |
| - // Defaults are already on server-side, so keeping everything on writes |
| - // nothing — the granular chips write the setting either way. |
| + // Bots + apps (two of the #712 gates) as ONE question with a one-tap |
| + // common path; "Let me choose" opens the granular pair. Defaults are |
| + // already on server-side, so keeping both on writes nothing — the |
| + // granular chips write the setting either way. AI is deliberately NOT |
| + // in this bundle: it gets its own explicit opt-in step below |
| + // (docs/ai-provisioning.md — saying yes provisions a real helper, so |
| + // it's never a side effect of "keep everything on"). |
| id: "extras", |
| skipIf: ["done"], |
| - skipTo: "keys", // past the granular trio, which only "Let me choose" enters |
| + skipTo: "ai-optin", // past the granular pair, which only "Let me choose" enters |
| entries: [ |
| // A resume that already finished the granular path: close this out. |
| { |
| if: "done.extras-apps", |
| recordIfNew: { label: "chose this space's extras", value: "chosen" }, |
| - goto: "keys", |
| + goto: "ai-optin", |
| }, |
| ], |
| bubbles: [ |
| { |
| text: |
| - "Nearly there. Your space comes with a few extras:\n\n" + |
| - "• AI — ask Eleven things in plain language and it helps out.\n" + |
| + "Nearly there. Your space comes with a couple of extras:\n\n" + |
| "• Bots — little helpers members can create for reminders and jobs.\n" + |
| "• Apps — to-dos, polls, whiteboards, and a studio for building your own.\n\n" + |
| - "Everything essential works with any of these off, and you can change your mind anytime in Admin.", |
| + "Everything essential works with these off, and you can change your mind anytime in Admin.", |
| }, |
| ], |
| chips: [ |
| { |
| - label: "Keep everything on", |
| + label: "Keep both on", |
| spent: "Kept", |
| - record: { label: "kept all the extras on", value: "all-on" }, |
| - goto: "keys", |
| - }, |
| - { label: "Let me choose", goto: "extras-ai" }, |
| - ], |
| - }, |
| - { |
| - id: "extras-ai", |
| - skipIf: ["done"], |
| - bubbles: [ |
| - { |
| - text: "AI first. With it on, members can ask Eleven things in plain language and it answers, builds, and helps out. Everything works without it — you just won't have the natural-language help.", |
| - }, |
| - ], |
| - chips: [ |
| - { |
| - label: "Enable AI", |
| - effect: { verb: "setFeature", args: ["ai", true] }, |
| - record: { label: "enabled AI", value: "on" }, |
| - goto: "extras-bots", |
| - }, |
| - { |
| - label: "No AI here", |
| - spent: "Off", |
| - effect: { verb: "setFeature", args: ["ai", false] }, |
| - record: { label: "switched AI off", value: "off" }, |
| - goto: "extras-bots", |
| + record: { label: "kept bots and apps on", value: "all-on" }, |
| + goto: "ai-optin", |
| }, |
| + { label: "Let me choose", goto: "extras-bots" }, |
| ], |
| }, |
| { |
| id: "extras-bots", |
| skipIf: ["done"], |
| bubbles: [ |
| - { text: "Bots next — helpers that live in chats and do jobs: reminders, summaries, little tools. Members can create their own." }, |
| + { text: "Bots — helpers that live in chats and do jobs: reminders, summaries, little tools. Members can create their own." }, |
| ], |
| chips: [ |
| { |
| @@ -297,13 +275,45 @@ export default { |
| label: "Enable apps", |
| effect: { verb: "setFeature", args: ["apps", true] }, |
| record: { label: "enabled apps", value: "on" }, |
| - goto: "keys", |
| + goto: "ai-optin", |
| }, |
| { |
| label: "No apps here", |
| spent: "Off", |
| effect: { verb: "setFeature", args: ["apps", false] }, |
| record: { label: "switched apps off", value: "off" }, |
| + goto: "ai-optin", |
| + }, |
| + ], |
| + }, |
| + { |
| + // AI is opt-in with the honest note (docs/ai-provisioning.md): saying |
| + // yes flips ai_enabled on, and on a cloud space the server asks the |
| + // provisioner for the sidecar — Eleven joins moments later. Declining |
| + // writes an explicit off. Either way the choice is the admin's, never |
| + // a default's. |
| + id: "ai-optin", |
| + skipIf: ["done"], |
| + bubbles: [ |
| + { |
| + text: |
| + "Last choice — AI. Say yes and Eleven, the built-in helper, joins this space: members can ask it things in plain language and it answers, builds, and helps out.\n\n" + |
| + "An honest note before you decide: Eleven's AI runs on Eleven Messenger's machines, and it can read the chats it's invited to — that's what lets it help. It never sees chats it isn't in, and you can switch it off anytime in Admin.", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Bring Eleven in", |
| + spent: "On", |
| + effect: { verb: "setFeature", args: ["ai", true] }, |
| + record: { label: "invited Eleven's AI in", value: "on" }, |
| + goto: "keys", |
| + }, |
| + { |
| + label: "Not now", |
| + spent: "Off", |
| + effect: { verb: "setFeature", args: ["ai", false] }, |
| + record: { label: "kept AI off", value: "off" }, |
| goto: "keys", |
| }, |
| ], |
diff --git a/web/static/shell.js b/web/static/shell.js| index 14f881c..510f2ae 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -6720,13 +6720,21 @@ async function openSetupQuickForm(threadId) { |
| gh.className = "qf-label"; |
| gh.textContent = "Extras"; |
| extras.appendChild(gh); |
| + const bots = quickFormToggle(extras, { |
| + id: "qf-bots", |
| + label: "Bots", |
| + desc: "Everything essential works with these off — change your mind anytime in Admin.", |
| + }); |
| + const apps = quickFormToggle(extras, { id: "qf-apps", label: "Apps" }); |
| + // AI is opt-IN — unticked by default, with the honest note (mirrors the |
| + // conversation's dedicated ai-optin step; docs/ai-provisioning.md). |
| const ai = quickFormToggle(extras, { |
| id: "qf-ai", |
| - label: "AI", |
| - desc: "Everything essential works with any of these off — change your mind anytime in Admin.", |
| + label: "AI — bring Eleven in", |
| + desc: |
| + "Eleven's AI runs on Eleven Messenger's machines and can read the chats it's invited to — that's what lets it help. It never sees chats it isn't in.", |
| + checked: false, |
| }); |
| - const bots = quickFormToggle(extras, { id: "qf-bots", label: "Bots" }); |
| - const apps = quickFormToggle(extras, { id: "qf-apps", label: "Apps" }); |
| sheet.card.appendChild(extras); |
| sheet.finish(); |
| sheet.submit.onclick = async () => { |