eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package 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
36import (
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
51const (
52 maxAppSourceBytes = 256 * 1024
53 maxAppBuildTries = 3
54 appBuildMaxTokens = 16384
55)
56
57var (
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).
79var 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).
87var 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.
98var 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.
124type 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.
133type 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
162type 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).
169func 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
232type 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.
243func 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
286var 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.
291func 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.
337func 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.
457func 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.
464func 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.
470var defaultCmdRe = regexp.MustCompile(`\bdefaultCommand\s*:\s*["'](/[a-z][a-z0-9-]{0,30})["']`)
471
472func 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).
481func 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.
494func (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.
510func (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.
520func (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.
720func (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.
801func (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.
819func (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.
845var 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.
849type 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.
858func (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.
884const appsNoteTTL = 30 * time.Second
885
886// cachedDeployedApps returns the deployed list, refreshing after appsNoteTTL.
887func (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.
911func (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.
923func (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.
938func 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).
970func 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.
995const 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.
1001func (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.
1038func (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.
1055func 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).
1076func (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.
1107func (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