eleven / ocho Public

server+bot: bots can find, read, and fix existing member apps (#1401)

* server+bot: verify app deploys and report real versions

A bot could tell a member "✨ Notepad updated (app id: …)" without ever
calling create_app — the model parrots the deterministic confirmation
format (id included, copied from an earlier genuine build in the thread)
and nothing in the pipeline makes the fake distinguishable from the real
thing. And the app id it quotes is meaningless to members: it appears
nowhere in the UI, so it can't be checked against anything.

Make the confirmation a verifiable receipt:

- PUT /api/member-apps/{id} now returns the version the deploy produced
  (putMemberApp already computed it and threw it away).
- The sidecar reads the deployed list back after the PUT and confirms the
  id exists at (at least) that version before announcing anything; a
  mismatch fails the build rather than reporting success.
- The confirmation quotes the verified version — "✨ … updated — version
  3 is live" — and App Studio's list now shows each of your apps' current
  version and app id, so both halves of the receipt cross-check against
  server truth.
- The brain prompt + create_app tool description now say outright: never
  announce a build/update yourself; the system posts the confirmation
  only after the deploy verifies, and no create_app call means no build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+bot: give bots eyes on existing member apps — find, read, and fix them

"Fix the bug in my habit tracker" only worked if the app's opaque id
happened to appear in the last 30 messages of the current conversation —
otherwise the model had no way to know the app existed, couldn't see any
source, and silently built a blind duplicate instead of a fix.

Give the sidecar's brain real eyes, inside the ownership gate:

- Every model reply now carries a per-sender system note listing the apps
  THAT member owns (exact app_id, title, name, version, builder), decrypted
  from the deployed list under the instance key — briefly cached, and
  invalidated by the sidecar's own deploys.
- A new read_app_source tool returns one app's manifest + current source
  (owner-gated exactly like updating), run in a short bounded tool loop in
  modelReply — so the bot can diagnose a bug or explain behaviour before
  (or without) rebuilding. Never offered to the forced retry.
- create_app's update path is unchanged (it already regenerates from the
  current source) — the tool copy now points at the apps note instead of
  "an app you built in this conversation".
- Server: the overwrite gate widens by one deliberate notch — an AGENT that
  is neither author nor owner may overwrite when it asserts the app's
  recorded owner (the visible half of the manifest-owner check the sidecar
  already runs). That's what lets a bot fix an app the member wrote in App
  Studio, or one a different bot built. Ownership never transfers, and
  owner-less legacy rows stay closed.

E2E test drives the whole loop: a member-authored app, an ask that never
names it, discovery via the note, a read round-trip, and the fix landing as
version 2 of the same app with author/owner intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* bot: echo empty thinking blocks with the field present, not dropped

Adaptive thinking can emit a ZERO-LENGTH thinking block on a continuation
turn. The tool-loop echo (read_app_source, tasks, research) re-marshals the
assistant content, and omitempty dropped the empty thinking field entirely —
a hard 400 ("thinking.thinking: Field required") that silenced the whole
reply. Seen live on the bot-app-fix canary the first time a member asked for
an app change that made the model read source first.

Same trap, same fix as the argless tool_use input: marshal the field
explicitly when it's empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
James Adam pushed by codex agent, operated by paul@keymail.dev bfaa541129c10e0d167cd1c1037d2fdeaaa04d8a parent 58f00a6
12 files changed, +753 −52
  • bot_appfix_test.go +235 −0
  • changelog/2026-07-31-bots-fix-your-apps.md +6 −0
  • docs/member-apps.md +34 −2
  • internal/bot/anthropic.go +25 −13
  • internal/bot/anthropic_argless_test.go +34 −0
  • internal/bot/appbuild.go +209 −14
  • internal/bot/appbuild_test.go +50 −0
  • internal/bot/brain.go +61 −16
  • internal/bot/brain_test.go +20 −1
  • internal/bot/runtime.go +8 −0
  • memberapps.go +17 −6
  • memberapps_test.go +54 −0
diff --git a/bot_appfix_test.go b/bot_appfix_test.go
new file mode 100644
index 0000000..91ebd81
--- /dev/null
+++ b/bot_appfix_test.go
@@ -0,0 +1,235 @@
+package main
+
+// End-to-end test of the bot FIXING an existing member app it didn't build
+// (docs/member-apps.md): the member deployed the app themselves (author =
+// owner = member, the App Studio shape), so nothing about it appears in the
+// conversation. The bot must discover it from the apps note in its context,
+// read its source via read_app_source, then update it in place via create_app
+// with the real app_id — landing as version 2 through the server's
+// agent-for-owner overwrite gate, with author/owner unchanged.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "lchat/internal/bot"
+ "lchat/internal/e2ee"
+)
+
+// recordingAnthropic is scriptedAnthropic plus request capture — each call's
+// raw body is appended to reqs, so the test can assert what the model was
+// actually shown (the apps note, the tool_result source, the build prompt).
+func recordingAnthropic(t *testing.T, mu *sync.Mutex, reqs *[]string, responses ...[]map[string]any) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw, _ := io.ReadAll(r.Body)
+ var body struct {
+ Stream bool `json:"stream"`
+ }
+ json.Unmarshal(raw, &body)
+ mu.Lock()
+ i := len(*reqs)
+ *reqs = append(*reqs, string(raw))
+ mu.Unlock()
+ if i >= len(responses) {
+ i = len(responses) - 1
+ }
+ if body.Stream {
+ writeAnthropicSSE(w, responses[i])
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"content": responses[i], "stop_reason": "end_turn"})
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+var buggyNotesSource = `import { defineApp, addItem, removeItem } from "../apps.js";
+
+export const app = defineApp({
+ name: "notes",
+ title: "Notes",
+ glyph: "📝",
+ initialState: () => ({ items: [] }),
+ events: {
+ "notes.add": (s, e) => ({ items: addItem(s.items, { id: e.id, text: "buggy-marker-7" }) }),
+ "notes.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
+ },
+ render(state, el, ctx) {
+ for (const it of ((state && state.items) || [])) {
+ const row = document.createElement("div");
+ row.textContent = it.text;
+ el.appendChild(row);
+ }
+ },
+});
+`
+
+var fixedNotesApp = "```js\n" +
+ strings.Replace(buggyNotesSource, `"buggy-marker-7"`, "e.text /* fixed-marker-9 */", 1) +
+ "\n```"
+
+func TestBotReadsAndFixesExistingApp(t *testing.T) {
+ var mu sync.Mutex
+ var reqs []string
+ fakeAI := recordingAnthropic(t, &mu, &reqs,
+ // Call 1 — the conversational turn: the model reads the app it found
+ // in its context's apps note (the id appears nowhere in the thread).
+ []map[string]any{
+ {"type": "tool_use", "id": "tu-read", "name": "read_app_source",
+ "input": map[string]any{"app_id": "app-fixme01"}},
+ },
+ // Call 2 — after the tool_result carrying the source: diagnose + fix.
+ []map[string]any{
+ {"type": "text", "text": "Found it — the add handler drops your text. Fixing now."},
+ {"type": "tool_use", "id": "tu-build", "name": "create_app", "input": map[string]any{
+ "app_id": "app-fixme01",
+ "name": "notes", "title": "Notes", "glyph": "📝",
+ "spec": "fix notes.add so the item keeps the text the member typed",
+ }},
+ },
+ // Call 3 — the dedicated build call (streamed): the corrected module.
+ []map[string]any{{"type": "text", "text": fixedNotesApp}},
+ )
+ t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
+
+ ts, s := newTestServer(t)
+ owner := provisionNamed(t, ts.URL, "Owner", false)
+ claude := provisionNamed(t, ts.URL, "Claude", true)
+ dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, claude)
+ ownerUID, _ := s.db.deviceTokenUser(owner.Token)
+ claudeUID, _ := s.db.deviceTokenUser(claude.Token)
+
+ instKey, err := e2ee.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), claude.UserPub)
+ if err != nil {
+ t.Fatal(err)
+ }
+ grant, _ := json.Marshal(map[string]any{
+ "token": owner.Token,
+ "grants": []map[string]string{{"user_id": claudeUID, "wrapped": wrapped}},
+ })
+ if resp, err := http.Post(ts.URL+"/api/instance-key", "application/json", bytes.NewReader(grant)); err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("grant instance key: %v %d", err, resp.StatusCode)
+ }
+
+ // The member deploys the buggy app THEMSELVES (App Studio shape: author =
+ // owner = member), before the bot ever connects.
+ seedBlob, err := e2ee.EncryptJSON(instKey, map[string]any{
+ "source": buggyNotesSource,
+ "manifest": map[string]any{
+ "v": 1, "name": "notes", "title": "Notes", "glyph": "📝",
+ "places": []string{"you"}, "types": []string{"notes.add", "notes.remove"},
+ "owner": ownerUID, "by": "Owner",
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ seed, _ := json.Marshal(map[string]string{"token": owner.Token, "blob": seedBlob})
+ seedReq, _ := http.NewRequest("PUT", ts.URL+"/api/member-apps/app-fixme01", bytes.NewReader(seed))
+ seedReq.Header.Set("Content-Type", "application/json")
+ if resp, err := http.DefaultClient.Do(seedReq); err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("seed deploy: %v %d", err, resp.StatusCode)
+ }
+
+ dir := t.TempDir()
+ sessionPath := dir + "/claude.session.json"
+ if err := claude.Save(sessionPath); err != nil {
+ t.Fatal(err)
+ }
+ ready := make(chan struct{})
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ eng := &bot.Engine{Base: ts.URL, Session: claude, SessionPath: sessionPath,
+ Recipe: bot.DefaultRecipe(), AIKey: "test-key",
+ Ready: func() { close(ready) }, Logf: t.Logf}
+ go eng.Run(ctx)
+ awaitReady(t, ready)
+
+ // The ask never mentions the app id — discovery is the point.
+ blob, err := e2ee.EncryptJSON(dmKey, map[string]any{
+ "name": owner.Name, "uid": ownerUID,
+ "text": "There's a bug in my notes app — items lose the text I type. Can you fix it?",
+ "ts": time.Now().UnixMilli(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := json.Marshal(map[string]string{"thread_id": dmID, "blob": blob})
+ if resp, err := http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(body)); err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("post ask: %v %d", err, resp.StatusCode)
+ }
+
+ // The fix lands as version 2 of the SAME app — no second app, and the
+ // server's agent-for-owner gate let the bot overwrite a member-authored row.
+ waitFor(t, "app updated to version 2", func() bool {
+ app, ok, _ := s.db.getMemberApp("app-fixme01")
+ return ok && app.Version == 2
+ })
+ apps, _ := s.db.listMemberApps()
+ if len(apps) != 1 {
+ t.Fatalf("the fix must update in place, not add an app: %d apps", len(apps))
+ }
+ app, _, _ := s.db.getMemberApp("app-fixme01")
+ if app.AuthorID != ownerUID || app.OwnerID != ownerUID {
+ t.Fatalf("author/owner must not transfer on a bot fix: %+v", app)
+ }
+ var doc struct {
+ Source string `json:"source"`
+ Manifest struct {
+ Name string `json:"name"`
+ Owner string `json:"owner"`
+ BuiltBy string `json:"builtBy"`
+ } `json:"manifest"`
+ }
+ if err := e2ee.DecryptJSON(instKey, app.Blob, &doc); err != nil {
+ t.Fatalf("decrypt updated blob: %v", err)
+ }
+ if !strings.Contains(doc.Source, "fixed-marker-9") || strings.Contains(doc.Source, "buggy-marker-7") {
+ t.Fatalf("deployed source should be the fixed module:\n%s", doc.Source)
+ }
+ if doc.Manifest.Name != "notes" || doc.Manifest.Owner != ownerUID || doc.Manifest.BuiltBy != "Claude" {
+ t.Fatalf("bad updated manifest: %+v", doc.Manifest)
+ }
+
+ // The confirmation quotes the verified new version.
+ waitFor(t, "confirmation posts version 2", func() bool {
+ for _, rp := range botReplies(t, s, dmID, dmKey, "Claude") {
+ if strings.Contains(rp.Text, "version 2") {
+ return true
+ }
+ }
+ return false
+ })
+
+ // What the model was shown, call by call: the first call's context carried
+ // the apps note (id + ownership framing); the second carried the app's
+ // real source as the read's tool_result; the build call regenerated FROM
+ // that source (prevSource in the prompt).
+ mu.Lock()
+ defer mu.Unlock()
+ if len(reqs) < 3 {
+ t.Fatalf("expected 3 model calls, got %d", len(reqs))
+ }
+ if !strings.Contains(reqs[0], "app-fixme01") || !strings.Contains(reqs[0], "already owns") {
+ t.Errorf("first call should carry the apps note with the exact id")
+ }
+ if !strings.Contains(reqs[1], "buggy-marker-7") {
+ t.Errorf("second call should carry the app's source as the read result")
+ }
+ if !strings.Contains(reqs[2], "buggy-marker-7") {
+ t.Errorf("build call should regenerate from the current source")
+ }
+}
diff --git a/changelog/2026-07-31-bots-fix-your-apps.md b/changelog/2026-07-31-bots-fix-your-apps.md
new file mode 100644
index 0000000..7909e21
--- /dev/null
+++ b/changelog/2026-07-31-bots-fix-your-apps.md
@@ -0,0 +1,6 @@
+🤖 Bots can now see and fix the apps you already have. Ask about a bug in any
+app you own — even one built in an older conversation, by a different bot, or
+written by hand in App Studio — and the bot finds it, reads its current code,
+and ships the fix as a new version of the same app instead of building a
+duplicate. Every fix is announced with its verified version number, and the
+previous version stays in App Studio's History if you ever want to step back.
diff --git a/docs/member-apps.md b/docs/member-apps.md
index e1d0b2e..057218c 100644
--- a/docs/member-apps.md
+++ b/docs/member-apps.md
@@ -48,6 +48,32 @@ coexist — so member apps get a cage instead of a vetting step.
events, and snapshots exactly like a built-in. Opening it mounts the
sandbox frame; the member sees their custom UI full-pane in the shell.
+## Fixing an existing app: the bot's eyes
+
+Building is only half the loop — members come back with "the done button does
+nothing, fix it". The server can't help (it holds ciphertext), so the bot's
+sight is client-side, built from the same instance key it deploys with
+(`internal/bot/appbuild.go`):
+
+- **Discovery.** Every model reply carries a per-sender system note listing
+ the apps *that member owns* — exact `app_id`, title, name, version, builder
+ — decrypted from the deployed list (briefly cached; the sidecar's own
+ deploys invalidate it). Without this, the model only ever knew an id that
+ happened to appear in the recent conversation, and "fix my habit tracker"
+ from a fresh chat silently became a blind second build.
+- **Reading.** A `read_app_source` tool returns one app's manifest + current
+ source (owner-gated, exactly like updating), run in a short bounded tool
+ loop inside the reply — so the bot can diagnose a bug or explain the app's
+ behaviour before (or without) rebuilding.
+- **Updating.** `create_app` with the `app_id` from the note; the build call
+ regenerates from the app's current source, so the fix is an edit, not a
+ from-scratch rewrite. The deploy verifies and announces the new version,
+ and the old one stays in App Studio's History to revert to.
+
+All three stay inside the ownership gate: the note lists only the sender's
+apps, reads and updates check the manifest's `owner` against the requester,
+and other members' apps are invisible and untouchable.
+
## The sandbox
The proxy's `render` mounts:
@@ -132,7 +158,7 @@ untouched.
| route | gate | notes |
|---|---|---|
| `GET /api/member-apps` | member token | blobs included (only clients can decrypt) |
-| `PUT /api/member-apps/{id}` | member token | new id: any member (cap 200); existing: author only |
+| `PUT /api/member-apps/{id}` | member token | new id: any member (cap 200); existing: author, owner, or an agent asserting that same recorded owner |
| `DELETE /api/member-apps/{id}` | author or admin | admin = moderation without readability |
| `GET /app-sandbox` | none (static runtime) | the caged frame document |
| `GET /api/member-app-guide` | none | the authoring guide the bot builds against |
@@ -151,7 +177,13 @@ moderation.
Ownership: the server's author is whoever deployed (the bot, for bot-built
apps). The *requester* is recorded inside the encrypted manifest (`owner`),
and the bot refuses to update an app for anyone but its requester — the gate
-lives where the plaintext lives. Residual: chat payloads aren't
+lives where the plaintext lives. The server's overwrite gate mirrors that
+split: author or owner always may, and an **agent** that is neither may
+overwrite *on the recorded owner's behalf* by asserting that same `owner_id`
+(the visible half of the manifest check it already ran) — which is what lets
+a bot fix an app the member wrote in App Studio, or one a different bot built
+for them. Ownership never transfers via an overwrite, and owner-less legacy
+rows stay closed to agents. Residual: chat payloads aren't
sender-authenticated (the thread key is symmetric — every member can encrypt
one), so a determined member could assert another's uid to redirect an
update, exactly as they could impersonate anyone in plain chat. That's a
diff --git a/internal/bot/anthropic.go b/internal/bot/anthropic.go
index 9945dfe..4e4eb42 100644
--- a/internal/bot/anthropic.go
+++ b/internal/bot/anthropic.go
@@ -65,22 +65,34 @@ type anthBlock struct {
Data string `json:"data,omitempty"` // redacted_thinking payload
}
-// MarshalJSON closes an omitempty trap on the tool-loop echo: the API
-// REQUIRES tool_use.input to be present, but Go's omitempty drops empty maps
-// along with nil ones — so echoing a tool the model called with NO arguments
-// (a bare issues.list, say) produced an assistant turn whose tool_use carried
-// no input at all, a hard 400 ("tool_use.input: Field required") on the
-// loop's next call. An argless tool_use marshals with an explicit empty
-// input; every other block shape is untouched.
+// MarshalJSON closes two omitempty traps on the tool-loop echo — both the
+// same shape: the API REQUIRES a field the model sometimes returns EMPTY,
+// and Go's omitempty drops empty values entirely, turning the echo into a
+// hard 400 on the loop's next call.
+//
+// - tool_use.input: a tool called with NO arguments (a bare issues.list)
+// echoed with no input at all → "tool_use.input: Field required".
+// - thinking.thinking: adaptive thinking can emit a ZERO-LENGTH thinking
+// block on a continuation turn (seen live from the read_app_source
+// loop) → "thinking.thinking: Field required".
+//
+// Each marshals with the field explicitly present; every other block shape
+// is untouched.
func (b anthBlock) MarshalJSON() ([]byte, error) {
type alias anthBlock // method-less: plain marshalling, no recursion
- if b.Type != "tool_use" || len(b.Input) > 0 {
- return json.Marshal(alias(b))
+ switch {
+ case b.Type == "tool_use" && len(b.Input) == 0:
+ return json.Marshal(struct {
+ alias
+ Input map[string]any `json:"input"` // shallower field wins: {} rides
+ }{alias(b), map[string]any{}})
+ case b.Type == "thinking" && b.Thinking == "":
+ return json.Marshal(struct {
+ alias
+ Thinking string `json:"thinking"` // shallower field wins: "" rides
+ }{alias(b), ""})
}
- return json.Marshal(struct {
- alias
- Input map[string]any `json:"input"` // shallower field wins: {} rides
- }{alias(b), map[string]any{}})
+ return json.Marshal(alias(b))
}
type anthCache struct {
diff --git a/internal/bot/anthropic_argless_test.go b/internal/bot/anthropic_argless_test.go
index ff871f7..203ed6d 100644
--- a/internal/bot/anthropic_argless_test.go
+++ b/internal/bot/anthropic_argless_test.go
@@ -36,3 +36,37 @@ func TestAnthBlockMarshalArglessToolUse(t *testing.T) {
}
}
}
+
+// TestAnthBlockMarshalEmptyThinking pins the other half of the echo trap: a
+// ZERO-LENGTH thinking block (adaptive thinking emits these on continuation
+// turns — seen live from the read_app_source loop) must round-trip with an
+// explicit empty thinking field, or the echo 400s ("thinking.thinking: Field
+// required") and the whole reply goes silent. Non-thinking blocks never grow
+// the field.
+func TestAnthBlockMarshalEmptyThinking(t *testing.T) {
+ empty, err := json.Marshal(anthBlock{Type: "thinking", Signature: "sig"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(empty), `"thinking":""`) {
+ t.Fatalf("empty thinking block must carry an explicit thinking field: %s", empty)
+ }
+ if !strings.Contains(string(empty), `"signature":"sig"`) {
+ t.Fatalf("signature lost from empty thinking block: %s", empty)
+ }
+
+ full, _ := json.Marshal(anthBlock{Type: "thinking", Thinking: "hmm", Signature: "sig"})
+ if !strings.Contains(string(full), `"thinking":"hmm"`) {
+ t.Fatalf("thinking text lost: %s", full)
+ }
+
+ for _, b := range []anthBlock{
+ {Type: "text", Text: "hi"},
+ {Type: "tool_result", ToolUseID: "tu_1", Content: "ok"},
+ } {
+ out, _ := json.Marshal(b)
+ if strings.Contains(string(out), `"thinking"`) {
+ t.Fatalf("%s block grew a thinking field: %s", b.Type, out)
+ }
+ }
+}
diff --git a/internal/bot/appbuild.go b/internal/bot/appbuild.go
index da5d98c..a223adb 100644
--- a/internal/bot/appbuild.go
+++ b/internal/bot/appbuild.go
@@ -21,9 +21,17 @@ package bot
//
// Updates reuse the same tool with app_id: the bot decrypts the existing app,
// checks the manifest's owner (the member who asked for it) against the
-// requester, and refuses otherwise — the server's author gate covers the
-// bot's own deploys; requester ownership is enforced here where the plaintext
+// requester, and refuses otherwise — the server's author-or-owner gate covers
+// the bot's own deploys (and its agent-for-owner extension covers apps the bot
+// didn't author); requester ownership is enforced here where the plaintext
// lives.
+//
+// The brain's EYES on existing apps live here too: memberAppsNote lists the
+// requester's deployed apps (exact ids included) into the model's context, and
+// the read_app_source tool returns one app's current code — together they're
+// what turns "fix the bug in my habit tracker" from a blind rebuild into a
+// targeted update (the model knows the id, can read the code, and the build
+// call regenerates FROM that code).
import (
"crypto/rand"
@@ -162,7 +170,8 @@ func createAppTool() anthTool {
return anthTool{
Name: "create_app",
Description: "Build a small personal mini-app for THIS member and deploy it to this chat's Apps " +
- "menu (or update one you built earlier). The app is theirs alone — a private tool like a " +
+ "menu (or update one they already own — fixing a bug counts, whoever built it: an update " +
+ "loads the app's current source into the build). The app is theirs alone — a private tool like a " +
"tracker, list, counter, or tiny game — and runs sandboxed. Before calling, make sure you " +
"understand what they want (at most one or two clarifying questions: the core thing being " +
"tracked and its lifecycle). Then call with a precise spec. The build takes about a minute " +
@@ -176,9 +185,9 @@ func createAppTool() anthTool {
"properties": map[string]any{
"app_id": map[string]any{
"type": "string",
- "description": "ONLY to UPDATE an app you already built in this conversation — pass the exact opaque id " +
- "from your earlier reply (looks like \"app-1a2b3c4d\"). NEVER invent one, and never use the app's name " +
- "here. Omit entirely to create a new app (the usual case).",
+ "description": "ONLY to UPDATE an app this member already owns — pass the exact opaque id " +
+ "(looks like \"app-1a2b3c4d\") from the list of their apps in your context, or from your earlier " +
+ "reply. NEVER invent one, and never use the app's name here. Omit entirely to create a new app.",
},
"name": map[string]any{
"type": "string",
@@ -691,6 +700,9 @@ func (r *Runtime) runAppBuild(tc *ThreadCtx, req appBuildReq, requester, request
return
}
r.logf("app build %q: deployed as %s version %d (%d types, %d bytes)", req.Name, id, listed, len(types), len(source))
+ // The next reply's apps note must already list this deploy at its new
+ // version — don't let a stale cache contradict the confirmation below.
+ r.invalidateAppsCache()
// New apps register live, so it's usable straight away; an update replaces
// code the app already loaded, so it needs a reload. Either way we point at
// the gentle Reload offer rather than implying a forced refresh.
@@ -832,23 +844,206 @@ func (r *Runtime) fetchMemberApp(id string, instKey []byte) (*memberAppDoc, erro
// a real load error.
var errMemberAppNotFound = errors.New("no app with that id")
-// deployedAppNames returns the set of names of every deployed member app, read
-// via the instance key — so the builder can guarantee a unique name without the
-// server ever seeing (or storing) a name. A blob we can't decrypt is skipped.
-func (r *Runtime) deployedAppNames(instKey []byte) (map[string]bool, error) {
+// deployedApp is one decrypted entry of the member-app list: the server-side
+// metadata (id, version) plus the document only instance-key holders can open.
+type deployedApp struct {
+ ID string
+ Version int
+ Doc memberAppDoc
+}
+
+// fetchDeployedApps loads and decrypts the full member-app list. A blob that
+// doesn't open under the instance key (foreign content, corruption) is skipped
+// rather than failing the list — same tolerance as deployedAppNames.
+func (r *Runtime) fetchDeployedApps(instKey []byte) ([]deployedApp, error) {
var list struct {
Apps []struct {
- Blob string `json:"blob"`
+ ID string `json:"id"`
+ Version int `json:"version"`
+ Blob string `json:"blob"`
} `json:"apps"`
}
if err := r.get("/api/member-apps", &list); err != nil {
return nil, err
}
- names := make(map[string]bool, len(list.Apps))
+ out := make([]deployedApp, 0, len(list.Apps))
for _, a := range list.Apps {
var doc memberAppDoc
- if e2ee.DecryptJSON(instKey, a.Blob, &doc) == nil && doc.Manifest.Name != "" {
- names[doc.Manifest.Name] = true
+ if e2ee.DecryptJSON(instKey, a.Blob, &doc) == nil {
+ out = append(out, deployedApp{ID: a.ID, Version: a.Version, Doc: doc})
+ }
+ }
+ return out, nil
+}
+
+// appsNoteTTL is how long one decrypted list serves memberAppsNote — the note
+// rides EVERY model reply, and refetching + decrypting every deployed blob per
+// message would be pure waste at chat cadence. Deploys through this sidecar
+// invalidate immediately (runAppBuild); a deploy from elsewhere (App Studio,
+// another bot) shows up within the TTL.
+const appsNoteTTL = 30 * time.Second
+
+// cachedDeployedApps returns the deployed list, refreshing after appsNoteTTL.
+func (r *Runtime) cachedDeployedApps() ([]deployedApp, error) {
+ r.appsMu.Lock()
+ if !r.appsAt.IsZero() && time.Since(r.appsAt) < appsNoteTTL {
+ apps := r.apps
+ r.appsMu.Unlock()
+ return apps, nil
+ }
+ r.appsMu.Unlock()
+ instKey, err := r.instanceKey()
+ if err != nil {
+ return nil, err
+ }
+ apps, err := r.fetchDeployedApps(instKey)
+ if err != nil {
+ return nil, err
+ }
+ r.appsMu.Lock()
+ r.apps, r.appsAt = apps, time.Now()
+ r.appsMu.Unlock()
+ return apps, nil
+}
+
+// invalidateAppsCache drops the cached list — called after this sidecar's own
+// deploys so the very next reply's note already shows the new version.
+func (r *Runtime) invalidateAppsCache() {
+ r.appsMu.Lock()
+ r.apps, r.appsAt = nil, time.Time{}
+ r.appsMu.Unlock()
+}
+
+// memberAppsNote renders the per-reply system addendum listing the apps the
+// current sender owns — the discovery half of fixing an existing app. Without
+// it the model only ever knows an app_id that happens to appear in the last 30
+// messages, so "fix my habit tracker" from a fresh conversation silently
+// became a blind NEW build. Empty (and silent) on any failure: the note is an
+// enrichment, never worth failing a reply over.
+func (r *Runtime) memberAppsNote(uid string) string {
+ if uid == "" || r.appGuide == "" {
+ return ""
+ }
+ apps, err := r.cachedDeployedApps()
+ if err != nil {
+ r.logf("memberAppsNote: %v", err)
+ return ""
+ }
+ return memberAppsNoteText(apps, uid)
+}
+
+// memberAppsNoteText is the pure render: only apps whose manifest owner is the
+// sender (an app with no recorded owner is nobody's to change — same rule as
+// the update gate), with the EXACT ids the tools need.
+func memberAppsNoteText(apps []deployedApp, uid string) string {
+ var own []deployedApp
+ for _, a := range apps {
+ if a.Doc.Manifest.Owner != "" && a.Doc.Manifest.Owner == uid {
+ own = append(own, a)
+ }
+ }
+ if len(own) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ 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")
+ for _, a := range own {
+ m := a.Doc.Manifest
+ fmt.Fprintf(&b, "- %s — %s %s (name: %s, version %d", a.ID, m.Title, m.Glyph, m.Name, a.Version)
+ if m.BuiltBy != "" {
+ fmt.Fprintf(&b, ", built by %s", m.BuiltBy)
+ }
+ b.WriteString(")\n")
+ }
+ b.WriteString("\nWhen they ask to fix, change, or extend one of these — whoever built it — call create_app " +
+ "with that exact app_id: the build loads the app's current source itself, so put the precise bug or " +
+ "change in the spec (never code). To diagnose a bug or answer how one works, call read_app_source " +
+ "first and read the code before replying. Apps owned by other members are not yours to touch — " +
+ "their owner must ask themselves.")
+ return b.String()
+}
+
+// readAppSourceTool lets the conversational brain LOOK at a deployed app
+// before acting — the diagnosis half of a bug fix ("why does the done button
+// do nothing?"). Reading is gated exactly like updating: the requester must
+// own the app. Offered alongside create_app (replyTools).
+func readAppSourceTool() anthTool {
+ return anthTool{
+ Name: "read_app_source",
+ Description: "Read the current source code of a mini-app THIS member owns — to diagnose a bug or " +
+ "answer a question about how the app works before you reply. Pass the exact app_id from the " +
+ "apps list in your context (or your earlier reply); you get the app's manifest and complete " +
+ "current source back, then you continue your reply. Call it on its own and wait for the " +
+ "result — other tools called in the same turn are not run. You never need this just to " +
+ "UPDATE an app: create_app loads the current source into the build automatically.",
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "app_id": map[string]any{
+ "type": "string",
+ "description": "the app's exact opaque id, e.g. \"app-1a2b3c4d\" — never its name",
+ },
+ },
+ "required": []any{"app_id"},
+ },
+ }
+}
+
+// maxReadSourceChars caps one read_app_source result's share of the model
+// context. Generous next to typical member apps (a few KB); a source at the
+// 256 KB deploy cap is truncated, not refused.
+const maxReadSourceChars = 48 * 1024
+
+// runReadAppSource executes one read_app_source call: fetch, decrypt, gate on
+// the requester's ownership, render manifest + source as the tool result.
+// Errors come back as result text — the model should know a read failed and
+// say so, not hallucinate code it never saw.
+func (r *Runtime) runReadAppSource(input map[string]any, requesterUID string) string {
+ id, _ := input["app_id"].(string)
+ id = strings.TrimSpace(id)
+ if !appIDRe.MatchString(id) {
+ return "bad app_id — pass the exact opaque id from the apps list (never the app's name)."
+ }
+ if requesterUID == "" {
+ return "the sender can't be verified, so app code stays closed."
+ }
+ instKey, err := r.instanceKey()
+ if err != nil {
+ return "this space's shared key isn't available right now — try again shortly."
+ }
+ doc, err := r.fetchMemberApp(id, instKey)
+ if errors.Is(err, errMemberAppNotFound) {
+ return "no deployed app has that id — check the apps list in your context."
+ }
+ if err != nil {
+ return "couldn't load that app: " + err.Error()
+ }
+ if doc.Manifest.Owner == "" || doc.Manifest.Owner != requesterUID {
+ return "that app belongs to another member — only its owner can ask about its code."
+ }
+ manifest, err := json.Marshal(doc.Manifest)
+ if err != nil {
+ return "couldn't render that app's manifest."
+ }
+ src, note := doc.Source, ""
+ if len(src) > maxReadSourceChars {
+ src, note = src[:maxReadSourceChars], "\n[source truncated]"
+ }
+ return "manifest: " + string(manifest) + "\n\nsource:\n```js\n" + src + "\n```" + note
+}
+
+// deployedAppNames returns the set of names of every deployed member app, read
+// via the instance key — so the builder can guarantee a unique name without the
+// server ever seeing (or storing) a name. A blob we can't decrypt is skipped.
+func (r *Runtime) deployedAppNames(instKey []byte) (map[string]bool, error) {
+ apps, err := r.fetchDeployedApps(instKey)
+ if err != nil {
+ return nil, err
+ }
+ names := make(map[string]bool, len(apps))
+ for _, a := range apps {
+ if a.Doc.Manifest.Name != "" {
+ names[a.Doc.Manifest.Name] = true
}
}
return names, nil
diff --git a/internal/bot/appbuild_test.go b/internal/bot/appbuild_test.go
index 86ee5c1..b76301d 100644
--- a/internal/bot/appbuild_test.go
+++ b/internal/bot/appbuild_test.go
@@ -325,6 +325,56 @@ func TestUniqueAppName(t *testing.T) {
}
}
+// The apps note lists exactly the SENDER's apps — never another member's,
+// never owner-less rows — with the exact ids the tools need; no owned apps
+// means no note at all.
+func TestMemberAppsNoteText(t *testing.T) {
+ apps := []deployedApp{
+ {ID: "app-aaaa11", Version: 3, Doc: memberAppDoc{Manifest: memberAppManifest{
+ Name: "habits", Title: "Habit Tracker", Glyph: "🌱", Owner: "u1", BuiltBy: "Claude"}}},
+ {ID: "app-bbbb22", Version: 1, Doc: memberAppDoc{Manifest: memberAppManifest{
+ Name: "notes", Title: "Notes", Glyph: "📝", Owner: "u2"}}},
+ {ID: "app-cccc33", Version: 2, Doc: memberAppDoc{Manifest: memberAppManifest{
+ Name: "legacy", Title: "Legacy", Glyph: "🗿"}}}, // owner-less: nobody's
+ }
+ note := memberAppsNoteText(apps, "u1")
+ for _, want := range []string{"app-aaaa11", "Habit Tracker", "version 3", "built by Claude", "create_app", "read_app_source"} {
+ if !strings.Contains(note, want) {
+ t.Errorf("note missing %q:\n%s", want, note)
+ }
+ }
+ for _, banned := range []string{"app-bbbb22", "app-cccc33", "Notes", "Legacy"} {
+ if strings.Contains(note, banned) {
+ t.Errorf("note leaked another member's app (%q):\n%s", banned, note)
+ }
+ }
+ if got := memberAppsNoteText(apps, "u3"); got != "" {
+ t.Errorf("member with no apps should get no note, got:\n%s", got)
+ }
+ if got := memberAppsNoteText(nil, "u1"); got != "" {
+ t.Errorf("empty list should give no note, got:\n%s", got)
+ }
+ // An empty uid must never match owner-less rows.
+ if got := memberAppsNoteText(apps, ""); got != "" {
+ t.Errorf("empty uid should give no note, got:\n%s", got)
+ }
+}
+
+// runReadAppSource's pre-network gates: a malformed id and an unverifiable
+// sender are refused before any fetch happens (no Runtime client needed).
+func TestRunReadAppSourceGates(t *testing.T) {
+ r := &Runtime{}
+ if got := r.runReadAppSource(map[string]any{"app_id": "Not-An-Id!"}, "u1"); !strings.Contains(got, "bad app_id") {
+ t.Errorf("malformed id: %q", got)
+ }
+ if got := r.runReadAppSource(map[string]any{}, "u1"); !strings.Contains(got, "bad app_id") {
+ t.Errorf("missing id: %q", got)
+ }
+ if got := r.runReadAppSource(map[string]any{"app_id": "app-abc123"}, ""); !strings.Contains(got, "can't be verified") {
+ t.Errorf("uid-less sender: %q", got)
+ }
+}
+
func TestStripJS(t *testing.T) {
src := `const a = "fetch("; // fetch( in comment
/* fetch( in block */ const b = ` + "`fetch(${x}`" + `; real(a);`
diff --git a/internal/bot/brain.go b/internal/bot/brain.go
index 42bdda1..af9ef27 100644
--- a/internal/bot/brain.go
+++ b/internal/bot/brain.go
@@ -25,6 +25,11 @@ const classifierModel = "claude-haiku-4-5"
const defaultPersona = "Keep replies short, warm, and easy to read."
+// maxSourceReadRounds bounds modelReply's read_app_source loop — a member owns
+// at most a handful of apps worth reading in one breath, and each round is a
+// full model call, so the ceiling is small and deliberate.
+const maxSourceReadRounds = 3
+
// adaptiveThinking returns the extended-thinking config for a model, or nil for
// models that reject it. Haiku 400s on adaptive thinking ("adaptive thinking is
// not supported on this model") where Sonnet/Opus accept it, so a Haiku bot must
@@ -169,8 +174,11 @@ func (r *Runtime) systemPrompt() string {
"alongside the call should only say you've started. Never claim an app was built or " +
"updated yourself — the build system posts the real confirmation (with the app's new " +
"version number) once the deploy is verified, and a build or update happens ONLY through " +
- "a create_app call, never through your text. To change an app you built earlier, " +
- "call create_app again with its app_id from your earlier reply. Apps are personal to the " +
+ "a create_app call, never through your text. You can also FIX and CHANGE apps a member " +
+ "already owns, whoever built them: any they own are listed in your context with their exact " +
+ "app_ids — call create_app with that app_id (the build loads the app's current source, so " +
+ "put the precise bug or change in the spec), and call read_app_source first when you need " +
+ "to see the code to diagnose a bug or answer how the app works. Apps are personal to the " +
"member who asked and appear in their Apps menu (+ New chat). An app can also offer slash " +
"commands for quick capture from any chat (e.g. /habit water) and a companion chat view where " +
"its activity reads as a conversation — include those in the spec when they fit."
@@ -206,7 +214,7 @@ func (r *Runtime) replyTools(tc *ThreadCtx) []anthTool {
// here rather than in the forced-retry chip palette.
tools := append(retryTools(), createViewTool())
if r.appGuide != "" {
- tools = append(tools, createAppTool())
+ tools = append(tools, createAppTool(), readAppSourceTool())
}
if r.Charters {
tools = append(tools, createBotTool(), removeBotTool())
@@ -511,19 +519,56 @@ func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) {
if tc.Kind == "dm" {
system = append(system, anthBlock{Type: "text", Text: r.memoryNote(tc)})
}
- resp, err := r.ai.create(anthRequest{
- Model: r.model,
- // Thinking, text and any tool call share this budget — a tight cap
- // truncates the tool_use off the end and the chip silently vanishes.
- MaxTokens: 4096,
- System: system,
- Thinking: adaptiveThinking(r.model),
- Tools: r.replyTools(tc),
- Messages: context,
- })
- if err != nil {
- r.logf("claude: %v", err)
- return Reply{}, false
+ // The sender's own deployed apps, exact ids included (appbuild.go) — what
+ // lets "fix my habit tracker" target the existing app instead of blindly
+ // building a second one.
+ if note := r.memberAppsNote(tc.SenderUID); note != "" {
+ system = append(system, anthBlock{Type: "text", Text: note})
+ }
+ // The reply is usually one call, but read_app_source turns it into a short
+ // tool loop: the model may read the code of apps the sender owns (bounded
+ // rounds), then everything below acts on the final, read-free response.
+ messages := context
+ var resp *anthResponse
+ for round := 0; ; round++ {
+ resp, err = r.ai.create(anthRequest{
+ Model: r.model,
+ // Thinking, text and any tool call share this budget — a tight cap
+ // truncates the tool_use off the end and the chip silently vanishes.
+ MaxTokens: 4096,
+ System: system,
+ Thinking: adaptiveThinking(r.model),
+ Tools: r.replyTools(tc),
+ Messages: messages,
+ })
+ if err != nil {
+ r.logf("claude: %v", err)
+ return Reply{}, false
+ }
+ reads := false
+ for _, u := range toolUses(resp.Content) {
+ if u.Name == "read_app_source" {
+ reads = true
+ break
+ }
+ }
+ if !reads || round >= maxSourceReadRounds {
+ break
+ }
+ // Continue the turn: the API requires a tool_result for EVERY tool_use
+ // in the assistant turn we echo back, so the reads get real results and
+ // anything else gets a "not now" — chips and builders are only ever
+ // acted on from the final, read-free response below.
+ messages = append(messages, anthMessage{Role: "assistant", Content: resp.Content})
+ var results []anthBlock
+ for _, u := range toolUses(resp.Content) {
+ out := "not run — only read_app_source runs mid-turn; call this tool again in your final reply, after the reads."
+ if u.Name == "read_app_source" {
+ out = r.runReadAppSource(u.Input, tc.SenderUID)
+ }
+ results = append(results, anthBlock{Type: "tool_result", ToolUseID: u.ID, Content: out})
+ }
+ messages = append(messages, anthMessage{Role: "user", Content: results})
}
text := scrubSelfNotes(resp.text())
// create_app / create_bot are EXECUTORS, not chips: the sidecar itself runs
diff --git a/internal/bot/brain_test.go b/internal/bot/brain_test.go
index b635ec1..b658c57 100644
--- a/internal/bot/brain_test.go
+++ b/internal/bot/brain_test.go
@@ -109,13 +109,32 @@ func TestRetryToolsIncludeServiceOp(t *testing.T) {
t.Fatalf("forced retry cannot propose %s — the promised action stays unfulfillable", want)
}
}
- for _, banned := range []string{"create_app", "create_bot", "remove_bot"} {
+ for _, banned := range []string{"create_app", "create_bot", "remove_bot", "read_app_source"} {
if names[banned] {
t.Fatalf("forced retry must never offer %s", banned)
}
}
}
+// The app tools (builder + source reader) are offered together, and only when
+// the instance served the authoring guide — no guide, no app surface at all.
+func TestReplyToolsAppTools(t *testing.T) {
+ withGuide := &Runtime{appGuide: "the guide"}
+ names := map[string]bool{}
+ for _, tool := range withGuide.replyTools(nil) {
+ names[tool.Name] = true
+ }
+ if !names["create_app"] || !names["read_app_source"] {
+ t.Fatalf("guide present: want create_app + read_app_source, got %v", names)
+ }
+ noGuide := &Runtime{}
+ for _, tool := range noGuide.replyTools(nil) {
+ if tool.Name == "create_app" || tool.Name == "read_app_source" {
+ t.Fatalf("no guide: %s must not be offered", tool.Name)
+ }
+ }
+}
+
// The app_command mapping: an exact slash command becomes a run_command chip;
// anything that isn't one clean app command never becomes a chip at all.
func TestActionsFromToolUseAppCommand(t *testing.T) {
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go
index dffb6cc..d1995f7 100644
--- a/internal/bot/runtime.go
+++ b/internal/bot/runtime.go
@@ -91,6 +91,14 @@ type Runtime struct {
buildMu sync.Mutex
building map[string]bool
+ // The decrypted member-app list behind memberAppsNote (appbuild.go),
+ // cached briefly so every model reply doesn't refetch + re-decrypt every
+ // deployed blob. Guarded by appsMu; invalidated after this sidecar's own
+ // deploys, refreshed after appsNoteTTL otherwise.
+ appsMu sync.Mutex
+ apps []deployedApp
+ appsAt time.Time
+
// The charter host's self-thread (id + key), snapshotted at connect so the
// builder goroutine and the reconcile ticker can read it without touching
// r.keys (which stays single-goroutine, owned by the frame loop).
diff --git a/memberapps.go b/memberapps.go
index d91a1ac..e135b4c 100644
--- a/memberapps.go
+++ b/memberapps.go
@@ -17,7 +17,8 @@ package main
// owner, size, times) — the same class of metadata a message row leaks; owner is
// the member id the app belongs to, so the app is manageable by the person it was
// built for. Deploying and listing are member-token-gated; overwriting is
-// author-or-owner; deleting is author-or-owner-or-admin, the moderation lever
+// author-or-owner (or an agent acting for that same recorded owner); deleting
+// is author-or-owner-or-admin, the moderation lever
// ("an admin can pull any member app without being able to read it").
import (
@@ -68,10 +69,10 @@ func (s *Server) listMemberAppsHandler(w http.ResponseWriter, r *http.Request) {
}
// putMemberAppHandler deploys (or updates) a member app. Any member may deploy a
-// new id; only the app's author OR its owner may overwrite an existing one — an
-// app someone relies on can't be swapped out from under them by a third party.
-// (Admins moderate by deletion, not by overwrite: they can't author content they
-// can't read.)
+// new id; only the app's author, its owner, or an agent asserting that same
+// owner may overwrite an existing one — an app someone relies on can't be
+// swapped out from under them by a third party. (Admins moderate by deletion,
+// not by overwrite: they can't author content they can't read.)
//
// owner_id lets a deploy record who the app belongs to. A regular member can only
// deploy apps owned by themselves (no spoofing); an AGENT may set it to the
@@ -130,7 +131,17 @@ func (s *Server) putMemberAppHandler(w http.ResponseWriter, r *http.Request) {
}
version := 1
if exists {
- if existing.AuthorID != uid && existing.OwnerID != uid {
+ // Author or owner may overwrite. An AGENT that is neither may also
+ // overwrite ON THE OWNER'S BEHALF when it asserts that same owner: the
+ // sidecar has already checked, against the decrypted manifest, that
+ // the member asking IS the recorded owner — the server can't read the
+ // blob, so the asserted owner_id is that check's visible half. This is
+ // what lets a bot fix an app the member wrote in App Studio (author =
+ // owner = member) or one a different bot built for them. Ownership
+ // still never transfers via an overwrite, and owner-less legacy rows
+ // stay closed to agents.
+ agentForOwner := existing.OwnerID != "" && owner == existing.OwnerID && s.db.isAgent(uid)
+ if existing.AuthorID != uid && existing.OwnerID != uid && !agentForOwner {
httpError(w, http.StatusForbidden, "only the app's author or owner may update it")
return
}
diff --git a/memberapps_test.go b/memberapps_test.go
index f9f6cfd..75776ed 100644
--- a/memberapps_test.go
+++ b/memberapps_test.go
@@ -132,6 +132,60 @@ func TestMemberAppsOwnerCanManage(t *testing.T) {
}
}
+// An agent that is neither author nor owner may overwrite an existing app when
+// it asserts the app's recorded owner — the server half of "ask a bot to fix an
+// app you wrote in App Studio" (the sidecar checks, against the decrypted
+// manifest, that the member asking IS that owner). Missing or wrong owner
+// assertions, and owner-less legacy rows, all stay 403.
+func TestMemberAppsAgentUpdatesForOwner(t *testing.T) {
+ ts, s := newTestServer(t)
+ ownerTok, ownerUID, _ := joinedMember(t, s, "Ana")
+ botTok, botUID, _ := joinedMember(t, s, "Claude")
+ if err := s.db.setUserAgent(botUID); err != nil {
+ t.Fatal(err)
+ }
+ otherBotTok, otherBotUID, _ := joinedMember(t, s, "Eleven")
+ if err := s.db.setUserAgent(otherBotUID); err != nil {
+ t.Fatal(err)
+ }
+ _, boUID, _ := joinedMember(t, s, "Bo")
+
+ // Ana deploys her own app (author = owner = Ana) — the App Studio shape.
+ if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-anasown", fmt.Sprintf(`{"token":"%s","blob":"v1"}`, ownerTok)); r.StatusCode != http.StatusOK {
+ t.Fatalf("deploy: want 200, got %d", r.StatusCode)
+ }
+
+ // An agent asserting the recorded owner may overwrite…
+ if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-anasown", fmt.Sprintf(`{"token":"%s","blob":"v2","owner_id":"%s"}`, botTok, ownerUID)); r.StatusCode != http.StatusOK {
+ t.Fatalf("agent-for-owner update: want 200, got %d", r.StatusCode)
+ }
+ app, _, _ := s.db.getMemberApp("app-anasown")
+ if app.Blob != "v2" || app.Version != 2 || app.AuthorID != ownerUID || app.OwnerID != ownerUID {
+ t.Fatalf("update must land without transferring author/owner: %+v", app)
+ }
+ // …and so may a different agent (a member may ask whichever bot they like).
+ if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-anasown", fmt.Sprintf(`{"token":"%s","blob":"v3","owner_id":"%s"}`, otherBotTok, ownerUID)); r.StatusCode != http.StatusOK {
+ t.Fatalf("second agent update: want 200, got %d", r.StatusCode)
+ }
+
+ // No owner assertion → the agent is just a stranger here.
+ if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-anasown", fmt.Sprintf(`{"token":"%s","blob":"evil"}`, botTok)); r.StatusCode != http.StatusForbidden {
+ t.Fatalf("agent without owner assertion: want 403, got %d", r.StatusCode)
+ }
+ // A wrong owner assertion is refused too.
+ if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-anasown", fmt.Sprintf(`{"token":"%s","blob":"evil","owner_id":"%s"}`, botTok, boUID)); r.StatusCode != http.StatusForbidden {
+ t.Fatalf("agent with wrong owner: want 403, got %d", r.StatusCode)
+ }
+ // An owner-less legacy row stays closed to agents — with nobody recorded,
+ // there is no owner an agent could be acting for.
+ if _, err := s.db.sql.Exec("INSERT INTO member_apps(id, blob, author_id, owner_id, version, created_at, updated_at) VALUES('app-legacy1','x','gone-uid','',1,0,0)"); err != nil {
+ t.Fatal(err)
+ }
+ if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-legacy1", fmt.Sprintf(`{"token":"%s","blob":"evil"}`, botTok)); r.StatusCode != http.StatusForbidden {
+ t.Fatalf("owner-less row: want 403, got %d", r.StatusCode)
+ }
+}
+
// A non-agent member can't deploy an app owned by someone else — owner_id is
// forced to the caller, so a regular member can't plant apps in others' lists.
func TestMemberAppsNonAgentCantSetOwner(t *testing.T) {