package main // Member bots: autonomous, scheduled agents members create by asking a bot — // the other axis from member apps (docs/member-bots.md; issue #432): // // - member APPS are interactive surfaces you open — sandboxed, no network, // foreground-only (memberapps.go). // - member BOTS are agents that message you — a standing charter (persona + // schedule + task) run unattended by the bot sidecar, which fetches the // web and posts the result into a dedicated thread. // // The server's role here is identical to member apps: store an OPAQUE blob. // A charter is encrypted under the instance key before it reaches us; the // server learns (random id, author, owner, size, times) and nothing else — // not the bot's name, schedule, sources, or prompt. Everything that RUNS // lives in the sidecar (internal/bot/charter.go), which is an ordinary E2EE // member; the server process never holds a usable key (CLAUDE.md, the one // rule). // // Ownership mirrors putMemberAppHandler: any member may create; an AGENT may // set owner_id to the member it built for; overwrite is author-or-owner; // delete is author-or-owner-or-admin (moderation without readability). import ( "encoding/json" "net/http" "strings" ) const ( // maxMemberBotBytes caps one encrypted charter blob — charters are a few // KB of JSON; 64K leaves lavish headroom. maxMemberBotBytes = 64 * 1024 // maxMemberBots / maxMemberBotsPerOwner bound the store. Tighter than // apps: every bot is a standing scheduled model run, so the cap is also a // cost/abuse dial. maxMemberBots = 100 maxMemberBotsPerOwner = 10 ) // listMemberBotsHandler returns every stored charter, blobs included — // member-token-gated exactly like messages (the sidecar and clients decrypt // with the instance key). func (s *Server) listMemberBotsHandler(w http.ResponseWriter, r *http.Request) { if !s.tokenOK(r.URL.Query().Get("token")) { httpError(w, http.StatusForbidden, "invalid token") return } bots, err := s.db.listMemberBots() if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"bots": bots}) } // putMemberBotHandler stores (or updates) a charter. Same id rules and // ownership gates as member apps; see the header comment. func (s *Server) putMemberBotHandler(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !memberAppID.MatchString(id) { // same slug shape as app ids ("bot-…") httpError(w, http.StatusBadRequest, "bot id must be 6-48 lowercase letters, digits, and dashes") return } var body struct { Token string `json:"token"` Blob string `json:"blob"` OwnerID string `json:"owner_id"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, maxMemberBotBytes+4096)).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } uid, ok := s.db.deviceTokenUser(body.Token) if !ok { httpError(w, http.StatusForbidden, "invalid token") return } // The bots gate stops new charters (and edits); already-stored charters // keep running in the sidecar — the admin retires those by deletion. if !s.db.botsEnabled() { httpError(w, http.StatusForbidden, "bots are turned off for this space") return } if strings.TrimSpace(body.Blob) == "" || len(body.Blob) > maxMemberBotBytes { httpError(w, http.StatusBadRequest, "blob missing or too large") return } // Resolve the owner: a member owns what they store; only an agent may // create on someone else's behalf (and only for a real user). owner := uid if body.OwnerID != "" && body.OwnerID != uid { if s.db.isAgent(uid) && s.db.userExists(body.OwnerID) { owner = body.OwnerID } } existing, exists, err := s.db.getMemberBot(id) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } if exists { if existing.AuthorID != uid && existing.OwnerID != uid { httpError(w, http.StatusForbidden, "only the bot's author or owner may update it") return } if err := s.db.putMemberBot(id, body.Blob); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } } else { switch err := s.db.createMemberBot(id, body.Blob, uid, owner, maxMemberBots, maxMemberBotsPerOwner); { case err == ErrMemberBotLimit: httpError(w, http.StatusForbidden, "member bot limit reached") return case err == ErrMemberBotOwnerLimit: httpError(w, http.StatusForbidden, "you've reached your bot limit — retire one first") return case err != nil: httpError(w, http.StatusInternalServerError, "db error") return } } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id}) } // postCharterNextDue receives the charter host's wake-time report: the // EARLIEST next run across every member-bot charter the sidecar hosts for this // instance ({token, due} — unix seconds; 0 = no live charters). The activator's // due-time index (activator.go) reads it back through GET /api/next-due, so a // hibernating instance is woken in time for schedules only the sidecar can see. // // Privacy: this makes one charter wake-time server-visible metadata. That is // the same class of leak as scheduled messages, which already carry a plaintext // deliver_at (miniapps.go postSchedule) — indeed the charters' armed alarms ARE // scheduled messages (internal/bot/chartersched.go). The charter's CONTENT // (name, sources, prompt, even which charter is next) stays encrypted; the // server learns only "wake me at T", and only the single earliest T at that. // // Auth: the sidecar is an ordinary E2EE member — it holds device tokens, never // the admin token — so this is member-token-gated and then narrowed to // bot-kind users, the declareBotKind pattern (a sidecar self-reporting a fact // about what it hosts). The value is deliberately low-stakes: any member can // already cause wakes (a request wakes; POST /api/schedule feeds the same // index), so a forged report buys an attacker nothing new — the agent gate is // narrowness, not a security boundary. func (s *Server) postCharterNextDue(w http.ResponseWriter, r *http.Request) { var body struct { Token string `json:"token"` Due int64 `json:"due"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } uid, ok := s.db.deviceTokenUser(body.Token) if !ok || !s.db.isAgent(uid) { httpError(w, http.StatusForbidden, "bot token required") return } if body.Due < 0 { httpError(w, http.StatusBadRequest, "bad due time") return } if err := s.db.setCharterNextDue(body.Due); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // deleteMemberBotHandler removes a charter: its author or owner retiring their // own, or an admin moderating one they can't read. func (s *Server) deleteMemberBotHandler(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") bot, exists, err := s.db.getMemberBot(id) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } uid, member := s.db.deviceTokenUser(r.URL.Query().Get("token")) authorised := member && exists && (bot.AuthorID == uid || bot.OwnerID == uid) if !authorised && !s.adminBearer(r) { httpError(w, http.StatusForbidden, "author or admin required") return } if !exists { httpError(w, http.StatusNotFound, "no such bot") return } if _, err := s.db.deleteMemberBot(id); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }