| 1 | package bot |
| 2 | |
| 3 | // The app-builder: the create_app tool that closes the loop "ask a bot for an |
| 4 | // app in chat → the app is deployed to this instance's Apps menu". The flow |
| 5 | // stays inside the trust model (docs/member-apps.md): |
| 6 | // |
| 7 | // 1. The conversational brain interviews the member and calls create_app |
| 8 | // with a spec. The tool call is an EXECUTOR (unlike the chip tools): the |
| 9 | // sidecar itself runs the build. |
| 10 | // 2. A dedicated generation call writes the single-file app module against |
| 11 | // the instance's own sandboxed authoring guide (fetched from |
| 12 | // /api/member-app-guide at connect, so bot and server can't disagree). |
| 13 | // 3. The source is statically validated here (imports, forbidden APIs, one |
| 14 | // defineApp, namespaced event types) and regenerated on failure. This is |
| 15 | // a QUALITY gate — the browser sandbox is the security boundary; a bad |
| 16 | // app that slipped through would still be caged. |
| 17 | // 4. { source, manifest } is encrypted under the INSTANCE KEY — the bot is |
| 18 | // an ordinary E2EE member and unwraps its own copy — and PUT to |
| 19 | // /api/member-apps with the bot's member token. The server stores an |
| 20 | // opaque blob under a random id; it never sees the app's name or code. |
| 21 | // |
| 22 | // Updates reuse the same tool with app_id: the bot decrypts the existing app, |
| 23 | // checks the manifest's owner (the member who asked for it) against the |
| 24 | // requester, and refuses otherwise — the server's author-or-owner gate covers |
| 25 | // the bot's own deploys (and its agent-for-owner extension covers apps the bot |
| 26 | // didn't author); requester ownership is enforced here where the plaintext |
| 27 | // lives. |
| 28 | // |
| 29 | // The brain's EYES on existing apps live here too: memberAppsNote lists the |
| 30 | // requester's deployed apps (exact ids included) into the model's context, and |
| 31 | // the read_app_source tool returns one app's current code — together they're |
| 32 | // what turns "fix the bug in my habit tracker" from a blind rebuild into a |
| 33 | // targeted update (the model knows the id, can read the code, and the build |
| 34 | // call regenerates FROM that code). |
| 35 | |
| 36 | import ( |
| 37 | "crypto/rand" |
| 38 | "encoding/hex" |
| 39 | "encoding/json" |
| 40 | "errors" |
| 41 | "fmt" |
| 42 | "net/http" |
| 43 | "regexp" |
| 44 | "sort" |
| 45 | "strings" |
| 46 | "time" |
| 47 | |
| 48 | "lchat/internal/e2ee" |
| 49 | ) |
| 50 | |
| 51 | const ( |
| 52 | maxAppSourceBytes = 256 * 1024 |
| 53 | maxAppBuildTries = 3 |
| 54 | appBuildMaxTokens = 16384 |
| 55 | ) |
| 56 | |
| 57 | var ( |
| 58 | appNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,23}$`) |
| 59 | appIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{5,47}$`) |
| 60 | importRe = regexp.MustCompile(`import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']`) |
| 61 | // nameFieldRe finds a `name: "..."` field value (lowercase id). Run over |
| 62 | // ALL matches on stripped source, so a `name:` in a comment/string can't |
| 63 | // throw the check off and an unrelated earlier `name:` can't shadow the |
| 64 | // real one — we assert the requested name is present, not that the first is. |
| 65 | nameFieldRe = regexp.MustCompile(`name\s*:\s*["']([a-z0-9-]+)["']`) |
| 66 | fenceRe = regexp.MustCompile("(?s)```(?:js|javascript)?\\s*\n(.*?)```") |
| 67 | // The sandbox's TYPE_TAIL rule (sandbox.js validateManifest): after the |
| 68 | // "<name>." prefix, only lowercase letters/digits/_/-, dot-separated. A |
| 69 | // generated type outside this (e.g. camelCase "notes.markDone") is REJECTED |
| 70 | // by the client, so the builder must catch it — not silently drop it. |
| 71 | typeTailRe = regexp.MustCompile(`^[a-z0-9_-]{1,40}(\.[a-z0-9_-]{1,40})*$`) |
| 72 | // exportRe finds any export keyword (stripped source) for the export-form |
| 73 | // check below. |
| 74 | exportRe = regexp.MustCompile(`(^|[^.\w$])export\b`) |
| 75 | ) |
| 76 | |
| 77 | // allowedAppImports mirrors ALLOWED_IMPORTS in web/static/sandbox-frame.js — |
| 78 | // the sandbox SDK surface (drift-pinned by TestAppImportsMatchSandbox). |
| 79 | var allowedAppImports = []string{ |
| 80 | "defineApp", "namespace", "addItem", "updateItem", "removeItem", |
| 81 | "partitionByGrace", "DONE_GRACE_MS", "keyedList", |
| 82 | } |
| 83 | |
| 84 | // allowedFmtImports mirrors ALLOWED_FMT in web/static/sandbox-frame.js — the |
| 85 | // pure text helpers a member app may import from "../format.js" |
| 86 | // (drift-pinned by TestAppImportsMatchSandbox). |
| 87 | var allowedFmtImports = []string{ |
| 88 | "tokenizeText", "richTokens", "blockTokens", "firstLink", "youtubeId", |
| 89 | "replySnippet", "emojiOnlyCount", "typingSummary", "autoGroupTitle", "formatBytes", |
| 90 | } |
| 91 | |
| 92 | // forbiddenApp pairs a human label with a boundary-aware regex, scanned on |
| 93 | // comment/string-stripped source. Advisory quality gate (the sandbox blocks |
| 94 | // all of this at runtime): an app reaching for any of these is wrong per the |
| 95 | // guide's hard constraints, so the build loop regenerates. Boundaries matter: |
| 96 | // a bare substring match flags legitimate identifiers (`refetch(` contains |
| 97 | // `fetch(`, `retrieval(` contains `eval(`), burning build tries on valid code. |
| 98 | var forbiddenApp = []struct { |
| 99 | label string |
| 100 | re *regexp.Regexp |
| 101 | hint string |
| 102 | }{ |
| 103 | {"fetch", regexp.MustCompile(`(^|[^.\w$])fetch\s*\(`), ""}, |
| 104 | {"XMLHttpRequest", regexp.MustCompile(`\bXMLHttpRequest\b`), ""}, |
| 105 | {"WebSocket", regexp.MustCompile(`\bWebSocket\b`), ""}, |
| 106 | {"EventSource", regexp.MustCompile(`\bEventSource\b`), ""}, |
| 107 | {"sendBeacon", regexp.MustCompile(`\bsendBeacon\b`), ""}, |
| 108 | {"localStorage", regexp.MustCompile(`\blocalStorage\b`), ""}, |
| 109 | {"sessionStorage", regexp.MustCompile(`\bsessionStorage\b`), ""}, |
| 110 | {"indexedDB", regexp.MustCompile(`\bindexedDB\b`), ""}, |
| 111 | {"document.cookie", regexp.MustCompile(`\bdocument\s*\.\s*cookie\b`), ""}, |
| 112 | {"window.parent", regexp.MustCompile(`\bwindow\s*\.\s*parent\b`), ""}, |
| 113 | {"window.top", regexp.MustCompile(`\bwindow\s*\.\s*top\b`), ""}, |
| 114 | {"postMessage", regexp.MustCompile(`\bpostMessage\s*\(`), ""}, |
| 115 | {"importScripts", regexp.MustCompile(`\bimportScripts\s*\(`), ""}, |
| 116 | {"eval", regexp.MustCompile(`(^|[^.\w$])eval\s*\(`), ""}, |
| 117 | {"new Function", regexp.MustCompile(`\bnew\s+Function\b`), ""}, |
| 118 | {"innerHTML", regexp.MustCompile(`\binnerHTML\b`), " (build DOM with createElement/textContent)"}, |
| 119 | {"setInterval", regexp.MustCompile(`\bsetInterval\s*\(`), " (use ctx.interval)"}, |
| 120 | } |
| 121 | |
| 122 | // manifestCommand is one slash command's METADATA (the handler lives in the |
| 123 | // source and runs in the frame) — feeds the "/" typeahead. |
| 124 | type manifestCommand struct { |
| 125 | Name string `json:"name"` |
| 126 | Desc string `json:"desc,omitempty"` |
| 127 | Args string `json:"args,omitempty"` |
| 128 | } |
| 129 | |
| 130 | // memberAppManifest is the plaintext half of a member-app blob — everything |
| 131 | // the client proxy needs to register the app without running its code. It |
| 132 | // rides INSIDE the encrypted blob, so none of it is server-visible. |
| 133 | type memberAppManifest struct { |
| 134 | V int `json:"v"` |
| 135 | Name string `json:"name"` |
| 136 | Title string `json:"title"` |
| 137 | Glyph string `json:"glyph"` |
| 138 | // Places is where copies of the app can live — the one placement |
| 139 | // declaration (docs/space-apps.md); bot-built apps are personal, ["you"]. |
| 140 | // Scope is its LEGACY spelling, kept so manifests written before the |
| 141 | // places model still unmarshal (the client accepts either forever) — |
| 142 | // never written by new builds. |
| 143 | Places []string `json:"places,omitempty"` |
| 144 | Scope string `json:"scope,omitempty"` |
| 145 | Types []string `json:"types"` |
| 146 | Commands []manifestCommand `json:"commands,omitempty"` |
| 147 | // Chat marks a companion app (its source implements chatMessage): the thread |
| 148 | // gets a Chat view alongside the Interface. Derived from the source. |
| 149 | Chat bool `json:"chat,omitempty"` |
| 150 | // DefaultCommand is the command plain (non-slash) text in the Chat view runs |
| 151 | // (like To-do's /add). Derived from the source; must name one of Commands. |
| 152 | DefaultCommand string `json:"defaultCommand,omitempty"` |
| 153 | // Owner is the requesting member's user id — the update gate this file |
| 154 | // enforces. By is their display name, for the client's "Built by …" byline. |
| 155 | Owner string `json:"owner,omitempty"` |
| 156 | By string `json:"by,omitempty"` |
| 157 | // BuiltBy is the bot's own name, so the client can show "you + <bot>" — you |
| 158 | // own it, this bot wrote it. Absent for hand-written (App Studio) apps. |
| 159 | BuiltBy string `json:"builtBy,omitempty"` |
| 160 | } |
| 161 | |
| 162 | type memberAppDoc struct { |
| 163 | Source string `json:"source"` |
| 164 | Manifest memberAppManifest `json:"manifest"` |
| 165 | } |
| 166 | |
| 167 | // createAppTool is the tool the conversational brain may call. Only offered |
| 168 | // when the instance served us the authoring guide (replyTools in brain.go). |
| 169 | func createAppTool() anthTool { |
| 170 | return anthTool{ |
| 171 | Name: "create_app", |
| 172 | Description: "Build a small personal mini-app for THIS member and deploy it to this chat's Apps " + |
| 173 | "menu (or update one they already own — fixing a bug counts, whoever built it: an update " + |
| 174 | "loads the app's current source into the build). The app is theirs alone — a private tool like a " + |
| 175 | "tracker, list, counter, or tiny game — and runs sandboxed. Before calling, make sure you " + |
| 176 | "understand what they want (at most one or two clarifying questions: the core thing being " + |
| 177 | "tracked and its lifecycle). Then call with a precise spec. The build takes about a minute " + |
| 178 | "and you will post the result to the thread when it finishes — your accompanying text should " + |
| 179 | "just say you've started. NEVER announce an app as built or updated yourself: the ✅/✨ " + |
| 180 | "confirmation (naming the app's new version) is posted by the build system only after the " + |
| 181 | "deploy is verified on the server. If you did not call this tool, nothing was built, and " + |
| 182 | "writing a confirmation-style message would be false.", |
| 183 | InputSchema: map[string]any{ |
| 184 | "type": "object", |
| 185 | "properties": map[string]any{ |
| 186 | "app_id": map[string]any{ |
| 187 | "type": "string", |
| 188 | "description": "ONLY to UPDATE an app this member already owns — pass the exact opaque id " + |
| 189 | "(looks like \"app-1a2b3c4d\") from the list of their apps in your context, or from your earlier " + |
| 190 | "reply. NEVER invent one, and never use the app's name here. Omit entirely to create a new app.", |
| 191 | }, |
| 192 | "name": map[string]any{ |
| 193 | "type": "string", |
| 194 | "description": "Short lowercase identifier: letters, digits, dashes; starts with a letter; max 24 chars. e.g. \"habits\"", |
| 195 | }, |
| 196 | "title": map[string]any{ |
| 197 | "type": "string", |
| 198 | "description": "Human name shown in the Apps list, max 40 chars. e.g. \"Habit Tracker\"", |
| 199 | }, |
| 200 | "glyph": map[string]any{ |
| 201 | "type": "string", |
| 202 | "description": "One emoji used as the app's avatar. e.g. \"🌱\"", |
| 203 | }, |
| 204 | "spec": map[string]any{ |
| 205 | "type": "string", |
| 206 | "description": "A precise build specification: the core entity and its lifecycle (one event " + |
| 207 | "type per transition), what the surface shows and in what order, the interactions " + |
| 208 | "(forms/buttons), the empty state, any time-based behaviour (schedules/notices), and — " + |
| 209 | "if useful — slash commands for quick capture from any chat and whether the thread " + |
| 210 | "should also read as a conversation (a companion Chat view via chatMessage).", |
| 211 | }, |
| 212 | "commands": map[string]any{ |
| 213 | "type": "array", |
| 214 | "description": "Optional slash commands the app offers (implement each in the source's `commands`). " + |
| 215 | "Quick capture from any chat, e.g. /habit water. Omit if the app is surface-only.", |
| 216 | "items": map[string]any{ |
| 217 | "type": "object", |
| 218 | "properties": map[string]any{ |
| 219 | "name": map[string]any{"type": "string", "description": "e.g. \"/habit\" — lowercase, leading slash"}, |
| 220 | "desc": map[string]any{"type": "string", "description": "one-line help for the / typeahead"}, |
| 221 | "args": map[string]any{"type": "string", "description": "argument hint, e.g. \"<name>\""}, |
| 222 | }, |
| 223 | "required": []any{"name"}, |
| 224 | }, |
| 225 | }, |
| 226 | }, |
| 227 | "required": []any{"name", "title", "glyph", "spec"}, |
| 228 | }, |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | type appBuildReq struct { |
| 233 | AppID string |
| 234 | Name string |
| 235 | Title string |
| 236 | Glyph string |
| 237 | Spec string |
| 238 | Commands []manifestCommand |
| 239 | } |
| 240 | |
| 241 | // parseAppBuildReq validates the model's tool input — same rules the client |
| 242 | // proxy applies to a manifest, enforced before we spend a build on it. |
| 243 | func parseAppBuildReq(input map[string]any) (appBuildReq, error) { |
| 244 | str := func(k string) string { v, _ := input[k].(string); return strings.TrimSpace(v) } |
| 245 | req := appBuildReq{ |
| 246 | AppID: str("app_id"), Name: str("name"), Title: str("title"), |
| 247 | Glyph: str("glyph"), Spec: str("spec"), |
| 248 | } |
| 249 | if !appNameRe.MatchString(req.Name) { |
| 250 | return req, fmt.Errorf("app name must be lowercase letters/digits/dashes, starting with a letter (got %q)", req.Name) |
| 251 | } |
| 252 | if req.Title == "" || len(req.Title) > 40 { |
| 253 | return req, fmt.Errorf("title must be 1-40 characters") |
| 254 | } |
| 255 | if req.Glyph == "" || len(req.Glyph) > 16 { |
| 256 | return req, fmt.Errorf("glyph must be one emoji") |
| 257 | } |
| 258 | if req.Spec == "" || len(req.Spec) > 4000 { |
| 259 | return req, fmt.Errorf("spec must be 1-4000 characters") |
| 260 | } |
| 261 | if req.AppID != "" && !appIDRe.MatchString(req.AppID) { |
| 262 | return req, fmt.Errorf("bad app_id") |
| 263 | } |
| 264 | if raw, ok := input["commands"].([]any); ok { |
| 265 | if len(raw) > 16 { |
| 266 | return req, fmt.Errorf("at most 16 commands") |
| 267 | } |
| 268 | for _, r := range raw { |
| 269 | c, _ := r.(map[string]any) |
| 270 | name, _ := c["name"].(string) |
| 271 | name = strings.TrimSpace(name) |
| 272 | if !cmdNameRe.MatchString(name) { |
| 273 | return req, fmt.Errorf("bad command name %q (want e.g. /habit)", name) |
| 274 | } |
| 275 | desc, _ := c["desc"].(string) |
| 276 | args, _ := c["args"].(string) |
| 277 | if len(desc) > 80 || len(args) > 40 { |
| 278 | return req, fmt.Errorf("command %q: desc/args too long", name) |
| 279 | } |
| 280 | req.Commands = append(req.Commands, manifestCommand{Name: name, Desc: strings.TrimSpace(desc), Args: strings.TrimSpace(args)}) |
| 281 | } |
| 282 | } |
| 283 | return req, nil |
| 284 | } |
| 285 | |
| 286 | var cmdNameRe = regexp.MustCompile(`^/[a-z][a-z0-9-]{0,30}$`) |
| 287 | |
| 288 | // stripJS drops comments and string/template contents so token scans don't |
| 289 | // trip on prose — the Go twin of the strip pass in tools/check-app.mjs and |
| 290 | // web/static/sandbox-frame.js. |
| 291 | func stripJS(code string) string { |
| 292 | var out strings.Builder |
| 293 | i, n := 0, len(code) |
| 294 | for i < n { |
| 295 | c := code[i] |
| 296 | var c2 byte |
| 297 | if i+1 < n { |
| 298 | c2 = code[i+1] |
| 299 | } |
| 300 | if c == '/' && c2 == '/' { |
| 301 | for i < n && code[i] != '\n' { |
| 302 | i++ |
| 303 | } |
| 304 | continue |
| 305 | } |
| 306 | if c == '/' && c2 == '*' { |
| 307 | i += 2 |
| 308 | for i+1 < n && !(code[i] == '*' && code[i+1] == '/') { |
| 309 | i++ |
| 310 | } |
| 311 | i += 2 |
| 312 | continue |
| 313 | } |
| 314 | if c == '"' || c == '\'' || c == '`' { |
| 315 | q := c |
| 316 | i++ |
| 317 | for i < n && code[i] != q { |
| 318 | if code[i] == '\\' { |
| 319 | i++ |
| 320 | } |
| 321 | i++ |
| 322 | } |
| 323 | i++ |
| 324 | out.WriteByte(q) |
| 325 | out.WriteByte(q) |
| 326 | continue |
| 327 | } |
| 328 | out.WriteByte(c) |
| 329 | i++ |
| 330 | } |
| 331 | return out.String() |
| 332 | } |
| 333 | |
| 334 | // validateMemberAppSource checks a generated module against the guide's |
| 335 | // mechanical rules and extracts its event types. An empty problems slice |
| 336 | // means deployable. |
| 337 | func validateMemberAppSource(name, src string) (types []string, problems []string) { |
| 338 | if len(src) > maxAppSourceBytes { |
| 339 | return nil, []string{fmt.Sprintf("source too large (%d bytes)", len(src))} |
| 340 | } |
| 341 | stripped := stripJS(src) |
| 342 | |
| 343 | if c := strings.Count(stripped, "defineApp("); c != 1 { |
| 344 | problems = append(problems, fmt.Sprintf("must call defineApp exactly once (found %d calls)", c)) |
| 345 | } |
| 346 | |
| 347 | // Imports: named bindings from "../apps.js" (SDK) or "../format.js" (pure |
| 348 | // text helpers) only, each name in its source's allow-list. |
| 349 | legal := 0 |
| 350 | for _, m := range importRe.FindAllStringSubmatch(src, -1) { |
| 351 | var allow []string |
| 352 | switch m[2] { |
| 353 | case "../apps.js": |
| 354 | allow = allowedAppImports |
| 355 | case "../format.js": |
| 356 | allow = allowedFmtImports |
| 357 | default: |
| 358 | problems = append(problems, fmt.Sprintf("import from %q — only \"../apps.js\" and \"../format.js\" are allowed", m[2])) |
| 359 | continue |
| 360 | } |
| 361 | legal++ |
| 362 | for _, part := range strings.Split(m[1], ",") { |
| 363 | bare := strings.TrimSpace(strings.Split(strings.TrimSpace(part), " as ")[0]) |
| 364 | if bare == "" { |
| 365 | continue |
| 366 | } |
| 367 | ok := false |
| 368 | for _, a := range allow { |
| 369 | if bare == a { |
| 370 | ok = true |
| 371 | break |
| 372 | } |
| 373 | } |
| 374 | if !ok { |
| 375 | problems = append(problems, fmt.Sprintf("%q is not importable from %q (allowed: %s)", bare, m[2], strings.Join(allow, ", "))) |
| 376 | } |
| 377 | } |
| 378 | } |
| 379 | // Any import the legal pattern didn't consume (side-effect imports, |
| 380 | // dynamic import(), namespace imports) is a violation. |
| 381 | if extra := len(regexp.MustCompile(`(^|[^.\w$])import\b`).FindAllString(stripped, -1)) - legal; extra > 0 { |
| 382 | problems = append(problems, `imports must be exactly: import { … } from "../apps.js" (or "../format.js")`) |
| 383 | } |
| 384 | |
| 385 | // Exports: mirror what the sandbox transform (sandbox-frame.js |
| 386 | // transformModuleSource) accepts — `export default`, `export <decl>`, and a |
| 387 | // bare `export { … }` list alone on its line. Anything else throws in the |
| 388 | // frame ("unsupported export form") and deploys a broken app, so reject it |
| 389 | // here where the build loop can fix it. |
| 390 | exportSurvivors := stripped |
| 391 | exportSurvivors = regexp.MustCompile(`\bexport\s+default\s+`).ReplaceAllString(exportSurvivors, " ") |
| 392 | exportSurvivors = regexp.MustCompile(`\bexport\s+(?:const|let|var|function|class|async)\b`).ReplaceAllString(exportSurvivors, " ") |
| 393 | exportSurvivors = regexp.MustCompile(`(?m)^\s*export\s*\{[^}]*\}\s*;?\s*$`).ReplaceAllString(exportSurvivors, " ") |
| 394 | if exportRe.MatchString(exportSurvivors) { |
| 395 | problems = append(problems, `unsupported export form — use "export const app = defineApp({…})" (or export default)`) |
| 396 | } |
| 397 | |
| 398 | for _, f := range forbiddenApp { |
| 399 | if f.re.MatchString(stripped) { |
| 400 | problems = append(problems, fmt.Sprintf("forbidden API %q%s", f.label, f.hint)) |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | // The app must register under the requested name. Assert the requested name |
| 405 | // is PRESENT among the name: fields rather than that the FIRST one matches — |
| 406 | // so an unrelated earlier `name:` (a config object, a UI label) can't |
| 407 | // false-reject. Run on raw source: the value is a string literal, which |
| 408 | // stripJS would blank. (A false accept — the name buried in a string — is |
| 409 | // caught authoritatively by the sandbox, which compares defineApp's real |
| 410 | // name to the manifest.) |
| 411 | nameOK := false |
| 412 | for _, m := range nameFieldRe.FindAllStringSubmatch(src, -1) { |
| 413 | if m[1] == name { |
| 414 | nameOK = true |
| 415 | break |
| 416 | } |
| 417 | } |
| 418 | if !nameOK { |
| 419 | problems = append(problems, fmt.Sprintf("defineApp's name field must be %q", name)) |
| 420 | } |
| 421 | |
| 422 | // Event types: every quoted "<name>.…" literal. Match a PERMISSIVE tail |
| 423 | // (including uppercase) so a camelCase type the sandbox would reject is |
| 424 | // caught and flagged here, not silently dropped from the manifest (which |
| 425 | // would leave that event dead end-to-end). A superset of valid types is |
| 426 | // harmless (extras route nowhere); a subset breaks folding. |
| 427 | typeRe := regexp.MustCompile(`["'](` + regexp.QuoteMeta(name) + `\.[A-Za-z0-9_.-]{1,48})["']`) |
| 428 | seen := map[string]bool{} |
| 429 | sawAnyType := false |
| 430 | for _, m := range typeRe.FindAllStringSubmatch(src, -1) { |
| 431 | t := m[1] |
| 432 | if seen[t] { |
| 433 | continue |
| 434 | } |
| 435 | seen[t] = true |
| 436 | sawAnyType = true |
| 437 | tail := t[len(name)+1:] |
| 438 | if !typeTailRe.MatchString(tail) { |
| 439 | problems = append(problems, fmt.Sprintf("event type %q must be lowercase letters/digits/_/- after the %q prefix", t, name+".")) |
| 440 | continue |
| 441 | } |
| 442 | types = append(types, t) |
| 443 | } |
| 444 | sort.Strings(types) |
| 445 | if !sawAnyType { |
| 446 | problems = append(problems, fmt.Sprintf("no event types found — the events map keys must be quoted strings prefixed %q", name+".")) |
| 447 | } |
| 448 | if len(types) > 64 { |
| 449 | problems = append(problems, "too many event types (max 64)") |
| 450 | } |
| 451 | return types, problems |
| 452 | } |
| 453 | |
| 454 | // sourceHasChatMessage reports whether the app source implements chatMessage |
| 455 | // (companion opt-in) — scanned on stripped source so a mention in a comment or |
| 456 | // string doesn't count. |
| 457 | func sourceHasChatMessage(src string) bool { |
| 458 | return regexp.MustCompile(`\bchatMessage\s*[:(]`).MatchString(stripJS(src)) |
| 459 | } |
| 460 | |
| 461 | // commandDefinedInSource reports whether the source registers a handler for the |
| 462 | // given "/name" command (a `"/name":` key in the commands object). Checked on |
| 463 | // raw source since the key is a string literal. |
| 464 | func commandDefinedInSource(src, name string) bool { |
| 465 | return regexp.MustCompile(`["']` + regexp.QuoteMeta(name) + `["']\s*:`).MatchString(src) |
| 466 | } |
| 467 | |
| 468 | // sourceDefaultCommand extracts the `defaultCommand: "/name"` value from the |
| 469 | // source ("" if none). Read on raw source since the value is a string literal. |
| 470 | var defaultCmdRe = regexp.MustCompile(`\bdefaultCommand\s*:\s*["'](/[a-z][a-z0-9-]{0,30})["']`) |
| 471 | |
| 472 | func sourceDefaultCommand(src string) string { |
| 473 | if m := defaultCmdRe.FindStringSubmatch(src); m != nil { |
| 474 | return m[1] |
| 475 | } |
| 476 | return "" |
| 477 | } |
| 478 | |
| 479 | // extractCodeBlock pulls the module out of the model's reply: the largest |
| 480 | // fenced code block (the reply should be nothing else, but be forgiving). |
| 481 | func extractCodeBlock(text string) string { |
| 482 | best := "" |
| 483 | for _, m := range fenceRe.FindAllStringSubmatch(text, -1) { |
| 484 | if len(m[1]) > len(best) { |
| 485 | best = m[1] |
| 486 | } |
| 487 | } |
| 488 | return strings.TrimSpace(best) |
| 489 | } |
| 490 | |
| 491 | // beginBuild atomically marks an app build in flight for (thread, requester). |
| 492 | // It returns false if one is already running, so the caller declines to start a |
| 493 | // duplicate — the fix for a member re-asking mid-build and getting two apps. |
| 494 | func (r *Runtime) beginBuild(threadID, uid string) bool { |
| 495 | key := threadID + "\x00" + uid |
| 496 | r.buildMu.Lock() |
| 497 | defer r.buildMu.Unlock() |
| 498 | if r.building == nil { |
| 499 | r.building = map[string]bool{} |
| 500 | } |
| 501 | if r.building[key] { |
| 502 | return false |
| 503 | } |
| 504 | r.building[key] = true |
| 505 | return true |
| 506 | } |
| 507 | |
| 508 | // endBuild clears the in-flight marker (deferred by runAppBuild), so the next |
| 509 | // request — including a retry after a failed build — can start. |
| 510 | func (r *Runtime) endBuild(threadID, uid string) { |
| 511 | r.buildMu.Lock() |
| 512 | delete(r.building, threadID+"\x00"+uid) |
| 513 | r.buildMu.Unlock() |
| 514 | } |
| 515 | |
| 516 | // runAppBuild is the whole pipeline, run on its own goroutine so a ~minute of |
| 517 | // generation never blocks the bot's other threads. It reports progress and its |
| 518 | // result by updating a single status line in place. The caller must have reserved |
| 519 | // the build slot via beginBuild; this releases it on return. |
| 520 | func (r *Runtime) runAppBuild(tc *ThreadCtx, req appBuildReq, requester, requesterUID string) { |
| 521 | defer r.endBuild(tc.ID, requesterUID) |
| 522 | |
| 523 | // One /me-style status line for the whole build: its start, progress, and |
| 524 | // (on success) result are all the SAME line, updated in place, so the wait |
| 525 | // never stacks bot bubbles. (actionLine is a general primitive — runtime.go.) |
| 526 | start := time.Now() |
| 527 | status := tc.actionLine(fmt.Sprintf("🔨 Building %s %s… (about a minute)", req.Title, req.Glyph)) |
| 528 | |
| 529 | // Refresh the line every 5s so it visibly moves — the bare typing dots read |
| 530 | // as "hung", and a slow beat leaves members wondering. Each tick advances the |
| 531 | // phase (holding the last) and shows elapsed seconds, so there's always fresh |
| 532 | // proof of life. The same tick re-beats "typing" (its 15s expiry outlives 5s). |
| 533 | // The heartbeat runs on its own goroutine; a done channel lets the terminal |
| 534 | // paths halt it first, so a late tick can't clobber the final state. |
| 535 | stop := make(chan struct{}) |
| 536 | done := make(chan struct{}) |
| 537 | go func() { |
| 538 | defer close(done) |
| 539 | tc.Typing(15) |
| 540 | tick := time.NewTicker(5 * time.Second) |
| 541 | defer tick.Stop() |
| 542 | phases := []string{ |
| 543 | "writing the code", |
| 544 | "wiring up the interface", |
| 545 | "checking and validating", |
| 546 | } |
| 547 | n := 0 |
| 548 | for { |
| 549 | select { |
| 550 | case <-tick.C: |
| 551 | tc.Typing(15) |
| 552 | phase := phases[min(n, len(phases)-1)] |
| 553 | n++ |
| 554 | status.set(fmt.Sprintf("🔨 Building %s %s — %s… (%ds)", |
| 555 | req.Title, req.Glyph, phase, int(time.Since(start).Seconds()))) |
| 556 | case <-stop: |
| 557 | tc.Typing(0) |
| 558 | return |
| 559 | } |
| 560 | } |
| 561 | }() |
| 562 | stopped := false |
| 563 | stopBeat := func() { |
| 564 | if stopped { |
| 565 | return |
| 566 | } |
| 567 | stopped = true |
| 568 | close(stop) |
| 569 | <-done |
| 570 | } |
| 571 | defer stopBeat() |
| 572 | |
| 573 | // settle ends the build. The status line only ever reflects work-in-progress; |
| 574 | // once we KNOW the outcome, it stops moving and becomes a short breadcrumb |
| 575 | // ("✅ …"/"❌ …") marking where the attempt was, and the real outcome — the |
| 576 | // result or the failure detail — is posted as a proper chat message (a real, |
| 577 | // notifying bubble one won't miss, not a muted /me line). |
| 578 | settle := func(breadcrumb, message string) { |
| 579 | stopBeat() |
| 580 | status.set(breadcrumb) |
| 581 | r.postReply(tc.ID, tc.key, Reply{Text: message}) |
| 582 | } |
| 583 | fail := func(format string, args ...any) { |
| 584 | msg := fmt.Sprintf(format, args...) |
| 585 | r.logf("app build %q: %s", req.Name, msg) |
| 586 | settle(fmt.Sprintf("❌ Couldn't build %s %s.", req.Title, req.Glyph), |
| 587 | "I couldn't finish building that app — "+msg+" Feel free to ask me to try again.") |
| 588 | } |
| 589 | |
| 590 | // The instance key first: without it there is nothing to deploy under, so |
| 591 | // fail before spending a generation call. |
| 592 | instKey, err := r.instanceKey() |
| 593 | if err != nil { |
| 594 | fail(fmt.Sprintf("I don't hold this space's shared key yet (%v). It's granted automatically when a member has the app open — try again in a minute.", err)) |
| 595 | return |
| 596 | } |
| 597 | |
| 598 | // Update path: fetch + decrypt the existing app, enforce requester |
| 599 | // ownership, pin the name (renaming would orphan its event types). |
| 600 | // |
| 601 | // Ownership is by the requester's uid recorded in the manifest. Note the |
| 602 | // residual: chat payloads aren't sender-authenticated (the thread key is |
| 603 | // symmetric — every member can encrypt one), so a determined member could |
| 604 | // assert another's uid, exactly as they could impersonate anyone in plain |
| 605 | // chat. That's a property of the messaging layer, not something this gate |
| 606 | // can close; the blast radius is capped by the sandbox (a hijacked update |
| 607 | // can only alter that app's own caged pane, never reach keys or data). What |
| 608 | // we DO enforce firmly: an unknown requester (no uid) can neither own nor |
| 609 | // update anything, and an app with no recorded owner can't be updated by |
| 610 | // anyone — it must be rebuilt — so the gate never silently opens. |
| 611 | if requesterUID == "" { |
| 612 | fail("I couldn't tell who's asking, so I can't build or change an app safely. Try again in a moment.") |
| 613 | return |
| 614 | } |
| 615 | prevSource := "" |
| 616 | if req.AppID != "" { |
| 617 | doc, err := r.fetchMemberApp(req.AppID, instKey) |
| 618 | switch { |
| 619 | case errors.Is(err, errMemberAppNotFound): |
| 620 | // The model supplied an app_id for an app that doesn't exist — a |
| 621 | // common slip where it invents an id (or reuses the app name) for a |
| 622 | // NEW app. Don't fail: treat it as a create and mint a fresh id. |
| 623 | r.logf("app build: app_id %q not found — creating a new app instead", req.AppID) |
| 624 | req.AppID = "" |
| 625 | case err != nil: |
| 626 | fail(fmt.Sprintf("I couldn't load the app to update (%v).", err)) |
| 627 | return |
| 628 | case doc.Manifest.Owner == "" || doc.Manifest.Owner != requesterUID: |
| 629 | fail("that app isn't one I can change for you — only the person it was built for can, by asking me themselves.") |
| 630 | return |
| 631 | default: |
| 632 | prevSource = doc.Source |
| 633 | req.Name = doc.Manifest.Name |
| 634 | } |
| 635 | } |
| 636 | |
| 637 | // A new app's name must not collide with an existing one — the client keys |
| 638 | // apps by name (and its event-type prefix). We hold the instance key, so read |
| 639 | // the deployed names and pick a free variant rather than colliding; the |
| 640 | // requester never chose the name, so "habits" → "habits2" is invisible (the |
| 641 | // title they see is unchanged). Done before generation so the event types are |
| 642 | // prefixed with the final name from the start. |
| 643 | if req.AppID == "" { |
| 644 | if taken, err := r.deployedAppNames(instKey); err != nil { |
| 645 | r.logf("app build: couldn't read names to de-dupe (%v) — using %q as-is", err, req.Name) |
| 646 | } else { |
| 647 | req.Name = uniqueAppName(req.Name, taken) |
| 648 | } |
| 649 | } |
| 650 | |
| 651 | source, types, err := r.generateApp(req, prevSource) |
| 652 | if err != nil { |
| 653 | fail(err.Error()) |
| 654 | return |
| 655 | } |
| 656 | |
| 657 | // Companion (Chat view) is derived from the source (chatMessage present), |
| 658 | // so a manifest can't claim a view the code doesn't project. Commands are |
| 659 | // the declared metadata; generateApp verified each has a handler. |
| 660 | manifest := memberAppManifest{ |
| 661 | V: 1, Name: req.Name, Title: req.Title, Glyph: req.Glyph, |
| 662 | Places: []string{"you"}, Types: types, Commands: req.Commands, Chat: sourceHasChatMessage(source), |
| 663 | DefaultCommand: sourceDefaultCommand(source), |
| 664 | Owner: requesterUID, By: requester, BuiltBy: r.Session.Name, |
| 665 | } |
| 666 | blob, err := e2ee.EncryptJSON(instKey, memberAppDoc{Source: source, Manifest: manifest}) |
| 667 | if err != nil { |
| 668 | fail(fmt.Sprintf("encryption failed (%v).", err)) |
| 669 | return |
| 670 | } |
| 671 | |
| 672 | id := req.AppID |
| 673 | verb := "updated" |
| 674 | if id == "" { |
| 675 | var buf [6]byte |
| 676 | if _, err := rand.Read(buf[:]); err != nil { |
| 677 | fail("couldn't mint an app id.") |
| 678 | return |
| 679 | } |
| 680 | id = "app-" + hex.EncodeToString(buf[:]) |
| 681 | verb = "built" |
| 682 | } |
| 683 | version, err := r.putMemberApp(id, blob, requesterUID) |
| 684 | if err != nil { |
| 685 | fail(fmt.Sprintf("the deploy was rejected (%v).", err)) |
| 686 | return |
| 687 | } |
| 688 | // Double-check before telling the member anything: read the deployed list |
| 689 | // back and confirm the app really is there at the version the PUT reported. |
| 690 | // The success message quotes that verified version (App Studio shows the |
| 691 | // same number), so "✨ … version N is live" is a checkable claim — never a |
| 692 | // report written on the strength of a 200 alone. |
| 693 | listed, err := r.deployedAppVersion(id) |
| 694 | if err != nil { |
| 695 | fail(fmt.Sprintf("the deploy didn't verify — I couldn't read the app back (%v).", err)) |
| 696 | return |
| 697 | } |
| 698 | if listed == 0 || (version > 0 && listed < version) { |
| 699 | fail(fmt.Sprintf("the deploy didn't verify — the server lists version %d, expected %d.", listed, version)) |
| 700 | return |
| 701 | } |
| 702 | r.logf("app build %q: deployed as %s version %d (%d types, %d bytes)", req.Name, id, listed, len(types), len(source)) |
| 703 | // The next reply's apps note must already list this deploy at its new |
| 704 | // version — don't let a stale cache contradict the confirmation below. |
| 705 | r.invalidateAppsCache() |
| 706 | // New apps register live, so it's usable straight away; an update replaces |
| 707 | // code the app already loaded, so it needs a reload. Either way we point at |
| 708 | // the gentle Reload offer rather than implying a forced refresh. |
| 709 | pickup := "Find it in the + New chat menu, under Apps." |
| 710 | if verb == "updated" { |
| 711 | pickup = "Tap Reload in the sidebar (or whenever you're ready) to pick up the changes — no rush." |
| 712 | } |
| 713 | settle(fmt.Sprintf("✅ %s %s %s (version %d).", req.Title, req.Glyph, verb, listed), |
| 714 | fmt.Sprintf("✨ %s %s %s — version %d is live. %s Want changes? Just describe them here. (app id: %s)", |
| 715 | req.Title, req.Glyph, verb, listed, pickup, id)) |
| 716 | } |
| 717 | |
| 718 | // generateApp is the dedicated build call (+ regenerate-on-validation-failure |
| 719 | // loop). Returns the validated source and its event types. |
| 720 | func (r *Runtime) generateApp(req appBuildReq, prevSource string) (string, []string, error) { |
| 721 | if r.ai == nil || r.appGuide == "" || r.budgetExhausted() { |
| 722 | return "", nil, fmt.Errorf("app building isn't available right now.") |
| 723 | } |
| 724 | system := []anthBlock{ |
| 725 | {Type: "text", Text: "You write single-file member apps for an end-to-end encrypted messenger, " + |
| 726 | "following this authoring guide exactly:\n\n" + r.appGuide, |
| 727 | CacheControl: &anthCache{Type: "ephemeral"}}, |
| 728 | } |
| 729 | ask := fmt.Sprintf("Build a member app.\n\nname: %s\ntitle: %s\nglyph: %s\n\nSpecification:\n%s\n\n"+ |
| 730 | "Reply with ONLY the complete JavaScript module in a single fenced code block. No prose outside the fence.", |
| 731 | req.Name, req.Title, req.Glyph, req.Spec) |
| 732 | if prevSource != "" { |
| 733 | ask = fmt.Sprintf("Update an existing member app (name: %s, title: %s, glyph: %s). Current source:\n\n```js\n%s\n```\n\n"+ |
| 734 | "Requested changes:\n%s\n\nKeep the same app name and keep existing event types working (old events will be re-folded "+ |
| 735 | "by the new code). Reply with ONLY the complete updated module in a single fenced code block.", |
| 736 | req.Name, req.Title, req.Glyph, prevSource, req.Spec) |
| 737 | } |
| 738 | msgs := []anthMessage{{Role: "user", Content: ask}} |
| 739 | |
| 740 | for try := 1; ; try++ { |
| 741 | // Stream this one: it's the long call (large max_tokens + thinking) that a |
| 742 | // non-streamed request lets sit idle until an intermediary cuts it |
| 743 | // ("unexpected EOF"). Streaming keeps bytes flowing so it completes. |
| 744 | resp, err := r.ai.createStream(anthRequest{ |
| 745 | Model: r.model, |
| 746 | MaxTokens: appBuildMaxTokens, |
| 747 | System: system, |
| 748 | Thinking: adaptiveThinking(r.model), |
| 749 | Messages: msgs, |
| 750 | }) |
| 751 | if err != nil { |
| 752 | return "", nil, fmt.Errorf("the build call failed (%v).", err) |
| 753 | } |
| 754 | source := extractCodeBlock(resp.text()) |
| 755 | var problems []string |
| 756 | var types []string |
| 757 | if source == "" { |
| 758 | problems = []string{"reply contained no fenced code block"} |
| 759 | } else { |
| 760 | types, problems = validateMemberAppSource(req.Name, source) |
| 761 | // Every declared command must have a handler in the source, or the |
| 762 | // manifest would advertise a "/" command that does nothing. |
| 763 | for _, c := range req.Commands { |
| 764 | if !commandDefinedInSource(source, c.Name) { |
| 765 | problems = append(problems, fmt.Sprintf("declared command %q has no handler — add %q to the app's `commands`", c.Name, c.Name)) |
| 766 | } |
| 767 | } |
| 768 | // A defaultCommand (plain text → this command) must be one the app |
| 769 | // declares, or the client rejects the manifest. |
| 770 | if dc := sourceDefaultCommand(source); dc != "" { |
| 771 | declared := false |
| 772 | for _, c := range req.Commands { |
| 773 | if c.Name == dc { |
| 774 | declared = true |
| 775 | break |
| 776 | } |
| 777 | } |
| 778 | if !declared { |
| 779 | problems = append(problems, fmt.Sprintf("defaultCommand %q must be one of the declared commands", dc)) |
| 780 | } |
| 781 | } |
| 782 | } |
| 783 | if len(problems) == 0 { |
| 784 | return source, types, nil |
| 785 | } |
| 786 | r.logf("app build %q try %d: %s", req.Name, try, strings.Join(problems, "; ")) |
| 787 | if try >= maxAppBuildTries { |
| 788 | return "", nil, fmt.Errorf("the generated code kept violating the app rules (%s).", strings.Join(problems, "; ")) |
| 789 | } |
| 790 | msgs = append(msgs, |
| 791 | anthMessage{Role: "assistant", Content: resp.text()}, |
| 792 | anthMessage{Role: "user", Content: "That module violates the authoring rules:\n- " + |
| 793 | strings.Join(problems, "\n- ") + |
| 794 | "\n\nFix every problem and reply again with ONLY the complete corrected module in a single fenced code block."}) |
| 795 | } |
| 796 | } |
| 797 | |
| 798 | // instanceKey fetches and unwraps this bot's copy of the instance key (the |
| 799 | // all-members content key member apps are sealed under). Same wrap format as |
| 800 | // a thread key. |
| 801 | func (r *Runtime) instanceKey() ([]byte, error) { |
| 802 | var ik struct { |
| 803 | Wrapped string `json:"wrapped"` |
| 804 | } |
| 805 | if err := r.get("/api/instance-key", &ik); err != nil { |
| 806 | return nil, err |
| 807 | } |
| 808 | if ik.Wrapped == "" { |
| 809 | return nil, fmt.Errorf("no wrapped copy for this bot") |
| 810 | } |
| 811 | keyB64, err := e2ee.UnwrapThreadKey(ik.Wrapped, r.Session.UserPriv) |
| 812 | if err != nil { |
| 813 | return nil, err |
| 814 | } |
| 815 | return e2ee.ImportKey(keyB64) |
| 816 | } |
| 817 | |
| 818 | // fetchMemberApp loads and decrypts one deployed member app by id. |
| 819 | func (r *Runtime) fetchMemberApp(id string, instKey []byte) (*memberAppDoc, error) { |
| 820 | var list struct { |
| 821 | Apps []struct { |
| 822 | ID string `json:"id"` |
| 823 | Blob string `json:"blob"` |
| 824 | } `json:"apps"` |
| 825 | } |
| 826 | if err := r.get("/api/member-apps", &list); err != nil { |
| 827 | return nil, err |
| 828 | } |
| 829 | for _, a := range list.Apps { |
| 830 | if a.ID != id { |
| 831 | continue |
| 832 | } |
| 833 | var doc memberAppDoc |
| 834 | if err := e2ee.DecryptJSON(instKey, a.Blob, &doc); err != nil { |
| 835 | return nil, fmt.Errorf("can't decrypt app %s", id) |
| 836 | } |
| 837 | return &doc, nil |
| 838 | } |
| 839 | return nil, fmt.Errorf("%w: %s", errMemberAppNotFound, id) |
| 840 | } |
| 841 | |
| 842 | // errMemberAppNotFound is returned by fetchMemberApp when no deployed app has |
| 843 | // the given id — so runAppBuild can distinguish "wrong id, make a new one" from |
| 844 | // a real load error. |
| 845 | var errMemberAppNotFound = errors.New("no app with that id") |
| 846 | |
| 847 | // deployedApp is one decrypted entry of the member-app list: the server-side |
| 848 | // metadata (id, version) plus the document only instance-key holders can open. |
| 849 | type deployedApp struct { |
| 850 | ID string |
| 851 | Version int |
| 852 | Doc memberAppDoc |
| 853 | } |
| 854 | |
| 855 | // fetchDeployedApps loads and decrypts the full member-app list. A blob that |
| 856 | // doesn't open under the instance key (foreign content, corruption) is skipped |
| 857 | // rather than failing the list — same tolerance as deployedAppNames. |
| 858 | func (r *Runtime) fetchDeployedApps(instKey []byte) ([]deployedApp, error) { |
| 859 | var list struct { |
| 860 | Apps []struct { |
| 861 | ID string `json:"id"` |
| 862 | Version int `json:"version"` |
| 863 | Blob string `json:"blob"` |
| 864 | } `json:"apps"` |
| 865 | } |
| 866 | if err := r.get("/api/member-apps", &list); err != nil { |
| 867 | return nil, err |
| 868 | } |
| 869 | out := make([]deployedApp, 0, len(list.Apps)) |
| 870 | for _, a := range list.Apps { |
| 871 | var doc memberAppDoc |
| 872 | if e2ee.DecryptJSON(instKey, a.Blob, &doc) == nil { |
| 873 | out = append(out, deployedApp{ID: a.ID, Version: a.Version, Doc: doc}) |
| 874 | } |
| 875 | } |
| 876 | return out, nil |
| 877 | } |
| 878 | |
| 879 | // appsNoteTTL is how long one decrypted list serves memberAppsNote — the note |
| 880 | // rides EVERY model reply, and refetching + decrypting every deployed blob per |
| 881 | // message would be pure waste at chat cadence. Deploys through this sidecar |
| 882 | // invalidate immediately (runAppBuild); a deploy from elsewhere (App Studio, |
| 883 | // another bot) shows up within the TTL. |
| 884 | const appsNoteTTL = 30 * time.Second |
| 885 | |
| 886 | // cachedDeployedApps returns the deployed list, refreshing after appsNoteTTL. |
| 887 | func (r *Runtime) cachedDeployedApps() ([]deployedApp, error) { |
| 888 | r.appsMu.Lock() |
| 889 | if !r.appsAt.IsZero() && time.Since(r.appsAt) < appsNoteTTL { |
| 890 | apps := r.apps |
| 891 | r.appsMu.Unlock() |
| 892 | return apps, nil |
| 893 | } |
| 894 | r.appsMu.Unlock() |
| 895 | instKey, err := r.instanceKey() |
| 896 | if err != nil { |
| 897 | return nil, err |
| 898 | } |
| 899 | apps, err := r.fetchDeployedApps(instKey) |
| 900 | if err != nil { |
| 901 | return nil, err |
| 902 | } |
| 903 | r.appsMu.Lock() |
| 904 | r.apps, r.appsAt = apps, time.Now() |
| 905 | r.appsMu.Unlock() |
| 906 | return apps, nil |
| 907 | } |
| 908 | |
| 909 | // invalidateAppsCache drops the cached list — called after this sidecar's own |
| 910 | // deploys so the very next reply's note already shows the new version. |
| 911 | func (r *Runtime) invalidateAppsCache() { |
| 912 | r.appsMu.Lock() |
| 913 | r.apps, r.appsAt = nil, time.Time{} |
| 914 | r.appsMu.Unlock() |
| 915 | } |
| 916 | |
| 917 | // memberAppsNote renders the per-reply system addendum listing the apps the |
| 918 | // current sender owns — the discovery half of fixing an existing app. Without |
| 919 | // it the model only ever knows an app_id that happens to appear in the last 30 |
| 920 | // messages, so "fix my habit tracker" from a fresh conversation silently |
| 921 | // became a blind NEW build. Empty (and silent) on any failure: the note is an |
| 922 | // enrichment, never worth failing a reply over. |
| 923 | func (r *Runtime) memberAppsNote(uid string) string { |
| 924 | if uid == "" || r.appGuide == "" { |
| 925 | return "" |
| 926 | } |
| 927 | apps, err := r.cachedDeployedApps() |
| 928 | if err != nil { |
| 929 | r.logf("memberAppsNote: %v", err) |
| 930 | return "" |
| 931 | } |
| 932 | return memberAppsNoteText(apps, uid) |
| 933 | } |
| 934 | |
| 935 | // memberAppsNoteText is the pure render: only apps whose manifest owner is the |
| 936 | // sender (an app with no recorded owner is nobody's to change — same rule as |
| 937 | // the update gate), with the EXACT ids the tools need. |
| 938 | func memberAppsNoteText(apps []deployedApp, uid string) string { |
| 939 | var own []deployedApp |
| 940 | for _, a := range apps { |
| 941 | if a.Doc.Manifest.Owner != "" && a.Doc.Manifest.Owner == uid { |
| 942 | own = append(own, a) |
| 943 | } |
| 944 | } |
| 945 | if len(own) == 0 { |
| 946 | return "" |
| 947 | } |
| 948 | var b strings.Builder |
| 949 | b.WriteString("Mini-apps the member you're replying to already owns — the complete list, with the EXACT app_id the app tools need:\n") |
| 950 | for _, a := range own { |
| 951 | m := a.Doc.Manifest |
| 952 | fmt.Fprintf(&b, "- %s — %s %s (name: %s, version %d", a.ID, m.Title, m.Glyph, m.Name, a.Version) |
| 953 | if m.BuiltBy != "" { |
| 954 | fmt.Fprintf(&b, ", built by %s", m.BuiltBy) |
| 955 | } |
| 956 | b.WriteString(")\n") |
| 957 | } |
| 958 | b.WriteString("\nWhen they ask to fix, change, or extend one of these — whoever built it — call create_app " + |
| 959 | "with that exact app_id: the build loads the app's current source itself, so put the precise bug or " + |
| 960 | "change in the spec (never code). To diagnose a bug or answer how one works, call read_app_source " + |
| 961 | "first and read the code before replying. Apps owned by other members are not yours to touch — " + |
| 962 | "their owner must ask themselves.") |
| 963 | return b.String() |
| 964 | } |
| 965 | |
| 966 | // readAppSourceTool lets the conversational brain LOOK at a deployed app |
| 967 | // before acting — the diagnosis half of a bug fix ("why does the done button |
| 968 | // do nothing?"). Reading is gated exactly like updating: the requester must |
| 969 | // own the app. Offered alongside create_app (replyTools). |
| 970 | func readAppSourceTool() anthTool { |
| 971 | return anthTool{ |
| 972 | Name: "read_app_source", |
| 973 | Description: "Read the current source code of a mini-app THIS member owns — to diagnose a bug or " + |
| 974 | "answer a question about how the app works before you reply. Pass the exact app_id from the " + |
| 975 | "apps list in your context (or your earlier reply); you get the app's manifest and complete " + |
| 976 | "current source back, then you continue your reply. Call it on its own and wait for the " + |
| 977 | "result — other tools called in the same turn are not run. You never need this just to " + |
| 978 | "UPDATE an app: create_app loads the current source into the build automatically.", |
| 979 | InputSchema: map[string]any{ |
| 980 | "type": "object", |
| 981 | "properties": map[string]any{ |
| 982 | "app_id": map[string]any{ |
| 983 | "type": "string", |
| 984 | "description": "the app's exact opaque id, e.g. \"app-1a2b3c4d\" — never its name", |
| 985 | }, |
| 986 | }, |
| 987 | "required": []any{"app_id"}, |
| 988 | }, |
| 989 | } |
| 990 | } |
| 991 | |
| 992 | // maxReadSourceChars caps one read_app_source result's share of the model |
| 993 | // context. Generous next to typical member apps (a few KB); a source at the |
| 994 | // 256 KB deploy cap is truncated, not refused. |
| 995 | const maxReadSourceChars = 48 * 1024 |
| 996 | |
| 997 | // runReadAppSource executes one read_app_source call: fetch, decrypt, gate on |
| 998 | // the requester's ownership, render manifest + source as the tool result. |
| 999 | // Errors come back as result text — the model should know a read failed and |
| 1000 | // say so, not hallucinate code it never saw. |
| 1001 | func (r *Runtime) runReadAppSource(input map[string]any, requesterUID string) string { |
| 1002 | id, _ := input["app_id"].(string) |
| 1003 | id = strings.TrimSpace(id) |
| 1004 | if !appIDRe.MatchString(id) { |
| 1005 | return "bad app_id — pass the exact opaque id from the apps list (never the app's name)." |
| 1006 | } |
| 1007 | if requesterUID == "" { |
| 1008 | return "the sender can't be verified, so app code stays closed." |
| 1009 | } |
| 1010 | instKey, err := r.instanceKey() |
| 1011 | if err != nil { |
| 1012 | return "this space's shared key isn't available right now — try again shortly." |
| 1013 | } |
| 1014 | doc, err := r.fetchMemberApp(id, instKey) |
| 1015 | if errors.Is(err, errMemberAppNotFound) { |
| 1016 | return "no deployed app has that id — check the apps list in your context." |
| 1017 | } |
| 1018 | if err != nil { |
| 1019 | return "couldn't load that app: " + err.Error() |
| 1020 | } |
| 1021 | if doc.Manifest.Owner == "" || doc.Manifest.Owner != requesterUID { |
| 1022 | return "that app belongs to another member — only its owner can ask about its code." |
| 1023 | } |
| 1024 | manifest, err := json.Marshal(doc.Manifest) |
| 1025 | if err != nil { |
| 1026 | return "couldn't render that app's manifest." |
| 1027 | } |
| 1028 | src, note := doc.Source, "" |
| 1029 | if len(src) > maxReadSourceChars { |
| 1030 | src, note = src[:maxReadSourceChars], "\n[source truncated]" |
| 1031 | } |
| 1032 | return "manifest: " + string(manifest) + "\n\nsource:\n```js\n" + src + "\n```" + note |
| 1033 | } |
| 1034 | |
| 1035 | // deployedAppNames returns the set of names of every deployed member app, read |
| 1036 | // via the instance key — so the builder can guarantee a unique name without the |
| 1037 | // server ever seeing (or storing) a name. A blob we can't decrypt is skipped. |
| 1038 | func (r *Runtime) deployedAppNames(instKey []byte) (map[string]bool, error) { |
| 1039 | apps, err := r.fetchDeployedApps(instKey) |
| 1040 | if err != nil { |
| 1041 | return nil, err |
| 1042 | } |
| 1043 | names := make(map[string]bool, len(apps)) |
| 1044 | for _, a := range apps { |
| 1045 | if a.Doc.Manifest.Name != "" { |
| 1046 | names[a.Doc.Manifest.Name] = true |
| 1047 | } |
| 1048 | } |
| 1049 | return names, nil |
| 1050 | } |
| 1051 | |
| 1052 | // uniqueAppName returns name if it's free, else the first "name2", "name3", … not |
| 1053 | // in taken, trimming the base so the numeric suffix keeps it within the 24-char |
| 1054 | // name limit. Falls back to the original after an absurd run of collisions. |
| 1055 | func uniqueAppName(name string, taken map[string]bool) string { |
| 1056 | if !taken[name] { |
| 1057 | return name |
| 1058 | } |
| 1059 | for n := 2; n < 1000; n++ { |
| 1060 | suffix := fmt.Sprintf("%d", n) |
| 1061 | base := name |
| 1062 | if len(base)+len(suffix) > 24 { |
| 1063 | base = base[:24-len(suffix)] |
| 1064 | } |
| 1065 | if cand := base + suffix; !taken[cand] { |
| 1066 | return cand |
| 1067 | } |
| 1068 | } |
| 1069 | return name |
| 1070 | } |
| 1071 | |
| 1072 | // putMemberApp deploys the encrypted blob with the bot's member token, recording |
| 1073 | // the requester as the app's owner so it's theirs to manage (App Studio |
| 1074 | // view/edit/remove) even though the bot deployed it. Returns the version the |
| 1075 | // server reports for this deploy (0 against an older server that doesn't say). |
| 1076 | func (r *Runtime) putMemberApp(id, blob, ownerID string) (int, error) { |
| 1077 | body, err := json.Marshal(map[string]string{"token": r.Session.Token, "blob": blob, "owner_id": ownerID}) |
| 1078 | if err != nil { |
| 1079 | return 0, err |
| 1080 | } |
| 1081 | req, err := http.NewRequest("PUT", r.Base+"/api/member-apps/"+id, strings.NewReader(string(body))) |
| 1082 | if err != nil { |
| 1083 | return 0, err |
| 1084 | } |
| 1085 | req.Header.Set("Content-Type", "application/json") |
| 1086 | resp, err := r.client.Do(req) |
| 1087 | if err != nil { |
| 1088 | return 0, err |
| 1089 | } |
| 1090 | defer resp.Body.Close() |
| 1091 | if resp.StatusCode != http.StatusOK { |
| 1092 | return 0, fmt.Errorf("server %d", resp.StatusCode) |
| 1093 | } |
| 1094 | var out struct { |
| 1095 | Version int `json:"version"` |
| 1096 | } |
| 1097 | // Tolerate a version-less response (older server): verification then only |
| 1098 | // requires the app to be listed at all. |
| 1099 | _ = json.NewDecoder(resp.Body).Decode(&out) |
| 1100 | return out.Version, nil |
| 1101 | } |
| 1102 | |
| 1103 | // deployedAppVersion returns the version the server currently lists for the |
| 1104 | // given app id — 0 when the id isn't deployed at all. This is the read-back |
| 1105 | // behind runAppBuild's verification: no metadata endpoint exists on purpose |
| 1106 | // (the list is the only surface), so it re-fetches the list like any client. |
| 1107 | func (r *Runtime) deployedAppVersion(id string) (int, error) { |
| 1108 | var list struct { |
| 1109 | Apps []struct { |
| 1110 | ID string `json:"id"` |
| 1111 | Version int `json:"version"` |
| 1112 | } `json:"apps"` |
| 1113 | } |
| 1114 | if err := r.get("/api/member-apps", &list); err != nil { |
| 1115 | return 0, err |
| 1116 | } |
| 1117 | for _, a := range list.Apps { |
| 1118 | if a.ID == id { |
| 1119 | return a.Version, nil |
| 1120 | } |
| 1121 | } |
| 1122 | return 0, nil |
| 1123 | } |
| 1124 | |