| 1 | package main |
| 2 | |
| 3 | // Member bots: autonomous, scheduled agents members create by asking a bot — |
| 4 | // the other axis from member apps (docs/member-bots.md; issue #432): |
| 5 | // |
| 6 | // - member APPS are interactive surfaces you open — sandboxed, no network, |
| 7 | // foreground-only (memberapps.go). |
| 8 | // - member BOTS are agents that message you — a standing charter (persona + |
| 9 | // schedule + task) run unattended by the bot sidecar, which fetches the |
| 10 | // web and posts the result into a dedicated thread. |
| 11 | // |
| 12 | // The server's role here is identical to member apps: store an OPAQUE blob. |
| 13 | // A charter is encrypted under the instance key before it reaches us; the |
| 14 | // server learns (random id, author, owner, size, times) and nothing else — |
| 15 | // not the bot's name, schedule, sources, or prompt. Everything that RUNS |
| 16 | // lives in the sidecar (internal/bot/charter.go), which is an ordinary E2EE |
| 17 | // member; the server process never holds a usable key (CLAUDE.md, the one |
| 18 | // rule). |
| 19 | // |
| 20 | // Ownership mirrors putMemberAppHandler: any member may create; an AGENT may |
| 21 | // set owner_id to the member it built for; overwrite is author-or-owner; |
| 22 | // delete is author-or-owner-or-admin (moderation without readability). |
| 23 | |
| 24 | import ( |
| 25 | "encoding/json" |
| 26 | "net/http" |
| 27 | "strings" |
| 28 | ) |
| 29 | |
| 30 | const ( |
| 31 | // maxMemberBotBytes caps one encrypted charter blob — charters are a few |
| 32 | // KB of JSON; 64K leaves lavish headroom. |
| 33 | maxMemberBotBytes = 64 * 1024 |
| 34 | // maxMemberBots / maxMemberBotsPerOwner bound the store. Tighter than |
| 35 | // apps: every bot is a standing scheduled model run, so the cap is also a |
| 36 | // cost/abuse dial. |
| 37 | maxMemberBots = 100 |
| 38 | maxMemberBotsPerOwner = 10 |
| 39 | ) |
| 40 | |
| 41 | // listMemberBotsHandler returns every stored charter, blobs included — |
| 42 | // member-token-gated exactly like messages (the sidecar and clients decrypt |
| 43 | // with the instance key). |
| 44 | func (s *Server) listMemberBotsHandler(w http.ResponseWriter, r *http.Request) { |
| 45 | if !s.tokenOK(r.URL.Query().Get("token")) { |
| 46 | httpError(w, http.StatusForbidden, "invalid token") |
| 47 | return |
| 48 | } |
| 49 | bots, err := s.db.listMemberBots() |
| 50 | if err != nil { |
| 51 | httpError(w, http.StatusInternalServerError, "db error") |
| 52 | return |
| 53 | } |
| 54 | writeJSON(w, http.StatusOK, map[string]any{"bots": bots}) |
| 55 | } |
| 56 | |
| 57 | // putMemberBotHandler stores (or updates) a charter. Same id rules and |
| 58 | // ownership gates as member apps; see the header comment. |
| 59 | func (s *Server) putMemberBotHandler(w http.ResponseWriter, r *http.Request) { |
| 60 | id := r.PathValue("id") |
| 61 | if !memberAppID.MatchString(id) { // same slug shape as app ids ("bot-…") |
| 62 | httpError(w, http.StatusBadRequest, "bot id must be 6-48 lowercase letters, digits, and dashes") |
| 63 | return |
| 64 | } |
| 65 | var body struct { |
| 66 | Token string `json:"token"` |
| 67 | Blob string `json:"blob"` |
| 68 | OwnerID string `json:"owner_id"` |
| 69 | } |
| 70 | if json.NewDecoder(http.MaxBytesReader(w, r.Body, maxMemberBotBytes+4096)).Decode(&body) != nil { |
| 71 | httpError(w, http.StatusBadRequest, "bad request") |
| 72 | return |
| 73 | } |
| 74 | uid, ok := s.db.deviceTokenUser(body.Token) |
| 75 | if !ok { |
| 76 | httpError(w, http.StatusForbidden, "invalid token") |
| 77 | return |
| 78 | } |
| 79 | // The bots gate stops new charters (and edits); already-stored charters |
| 80 | // keep running in the sidecar — the admin retires those by deletion. |
| 81 | if !s.db.botsEnabled() { |
| 82 | httpError(w, http.StatusForbidden, "bots are turned off for this space") |
| 83 | return |
| 84 | } |
| 85 | if strings.TrimSpace(body.Blob) == "" || len(body.Blob) > maxMemberBotBytes { |
| 86 | httpError(w, http.StatusBadRequest, "blob missing or too large") |
| 87 | return |
| 88 | } |
| 89 | // Resolve the owner: a member owns what they store; only an agent may |
| 90 | // create on someone else's behalf (and only for a real user). |
| 91 | owner := uid |
| 92 | if body.OwnerID != "" && body.OwnerID != uid { |
| 93 | if s.db.isAgent(uid) && s.db.userExists(body.OwnerID) { |
| 94 | owner = body.OwnerID |
| 95 | } |
| 96 | } |
| 97 | existing, exists, err := s.db.getMemberBot(id) |
| 98 | if err != nil { |
| 99 | httpError(w, http.StatusInternalServerError, "db error") |
| 100 | return |
| 101 | } |
| 102 | if exists { |
| 103 | if existing.AuthorID != uid && existing.OwnerID != uid { |
| 104 | httpError(w, http.StatusForbidden, "only the bot's author or owner may update it") |
| 105 | return |
| 106 | } |
| 107 | if err := s.db.putMemberBot(id, body.Blob); err != nil { |
| 108 | httpError(w, http.StatusInternalServerError, "db error") |
| 109 | return |
| 110 | } |
| 111 | } else { |
| 112 | switch err := s.db.createMemberBot(id, body.Blob, uid, owner, maxMemberBots, maxMemberBotsPerOwner); { |
| 113 | case err == ErrMemberBotLimit: |
| 114 | httpError(w, http.StatusForbidden, "member bot limit reached") |
| 115 | return |
| 116 | case err == ErrMemberBotOwnerLimit: |
| 117 | httpError(w, http.StatusForbidden, "you've reached your bot limit — retire one first") |
| 118 | return |
| 119 | case err != nil: |
| 120 | httpError(w, http.StatusInternalServerError, "db error") |
| 121 | return |
| 122 | } |
| 123 | } |
| 124 | writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id}) |
| 125 | } |
| 126 | |
| 127 | // postCharterNextDue receives the charter host's wake-time report: the |
| 128 | // EARLIEST next run across every member-bot charter the sidecar hosts for this |
| 129 | // instance ({token, due} — unix seconds; 0 = no live charters). The activator's |
| 130 | // due-time index (activator.go) reads it back through GET /api/next-due, so a |
| 131 | // hibernating instance is woken in time for schedules only the sidecar can see. |
| 132 | // |
| 133 | // Privacy: this makes one charter wake-time server-visible metadata. That is |
| 134 | // the same class of leak as scheduled messages, which already carry a plaintext |
| 135 | // deliver_at (miniapps.go postSchedule) — indeed the charters' armed alarms ARE |
| 136 | // scheduled messages (internal/bot/chartersched.go). The charter's CONTENT |
| 137 | // (name, sources, prompt, even which charter is next) stays encrypted; the |
| 138 | // server learns only "wake me at T", and only the single earliest T at that. |
| 139 | // |
| 140 | // Auth: the sidecar is an ordinary E2EE member — it holds device tokens, never |
| 141 | // the admin token — so this is member-token-gated and then narrowed to |
| 142 | // bot-kind users, the declareBotKind pattern (a sidecar self-reporting a fact |
| 143 | // about what it hosts). The value is deliberately low-stakes: any member can |
| 144 | // already cause wakes (a request wakes; POST /api/schedule feeds the same |
| 145 | // index), so a forged report buys an attacker nothing new — the agent gate is |
| 146 | // narrowness, not a security boundary. |
| 147 | func (s *Server) postCharterNextDue(w http.ResponseWriter, r *http.Request) { |
| 148 | var body struct { |
| 149 | Token string `json:"token"` |
| 150 | Due int64 `json:"due"` |
| 151 | } |
| 152 | if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body) != nil { |
| 153 | httpError(w, http.StatusBadRequest, "bad request") |
| 154 | return |
| 155 | } |
| 156 | uid, ok := s.db.deviceTokenUser(body.Token) |
| 157 | if !ok || !s.db.isAgent(uid) { |
| 158 | httpError(w, http.StatusForbidden, "bot token required") |
| 159 | return |
| 160 | } |
| 161 | if body.Due < 0 { |
| 162 | httpError(w, http.StatusBadRequest, "bad due time") |
| 163 | return |
| 164 | } |
| 165 | if err := s.db.setCharterNextDue(body.Due); err != nil { |
| 166 | httpError(w, http.StatusInternalServerError, "db error") |
| 167 | return |
| 168 | } |
| 169 | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| 170 | } |
| 171 | |
| 172 | // deleteMemberBotHandler removes a charter: its author or owner retiring their |
| 173 | // own, or an admin moderating one they can't read. |
| 174 | func (s *Server) deleteMemberBotHandler(w http.ResponseWriter, r *http.Request) { |
| 175 | id := r.PathValue("id") |
| 176 | bot, exists, err := s.db.getMemberBot(id) |
| 177 | if err != nil { |
| 178 | httpError(w, http.StatusInternalServerError, "db error") |
| 179 | return |
| 180 | } |
| 181 | uid, member := s.db.deviceTokenUser(r.URL.Query().Get("token")) |
| 182 | authorised := member && exists && (bot.AuthorID == uid || bot.OwnerID == uid) |
| 183 | if !authorised && !s.adminBearer(r) { |
| 184 | httpError(w, http.StatusForbidden, "author or admin required") |
| 185 | return |
| 186 | } |
| 187 | if !exists { |
| 188 | httpError(w, http.StatusNotFound, "no such bot") |
| 189 | return |
| 190 | } |
| 191 | if _, err := s.db.deleteMemberBot(id); err != nil { |
| 192 | httpError(w, http.StatusInternalServerError, "db error") |
| 193 | return |
| 194 | } |
| 195 | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| 196 | } |
| 197 | |