Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions companion/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [
{ method: "POST", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ },
{ method: "PATCH", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ },
{ method: "DELETE", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ },
// Paired-safe profile subset. The harness route itself rejects fields
// outside identity, avatar, notifications, and voice preferences.
{ method: "PATCH", path: /^\/api\/bots\/[\w-]+\/profile$/ },
{ method: "POST", path: /^\/api\/bots\/[\w-]+\/avatar\/generate$/ },
// Full cloud desktop access. The route is narrow and the proxy applies a
// second, per-device capability check before it reaches the harness.
CLOUD_DESKTOP_JOIN_ROUTE,
Expand All @@ -87,6 +91,16 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [
{ method: "POST", path: /^\/api\/threads\/[\w-]+\/respond$/ },
{ method: "GET", path: /^\/api\/search$/ },

// App-owned profile images. Upload is image-only and capped at 10 MB by
// the harness; GET is a single bare generated filename, never a path.
{ method: "POST", path: /^\/api\/attachments$/ },
{ method: "GET", path: /^\/api\/attachments\/[\w-]+\.(?:png|jpe?g|gif|webp)$/i },

// Renderer-neutral voice operations. Neither route reads or writes the
// workspace ElevenLabs key; the phone receives labels or audio only.
{ method: "GET", path: /^\/api\/tts\/voices$/ },
{ method: "POST", path: /^\/api\/tts\/speak$/ },

// Multi-account Composio management exposes opaque ids and aliases only.
// Revocation stays on the Mac: the account DELETE route is deliberately
// absent — a paired phone can see and add accounts, never remove one.
Expand Down
9 changes: 9 additions & 0 deletions companion/test/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ describe("what the app may do", () => {
["POST", "/api/bots/bot_123/tasks/th_1"],
["PATCH", "/api/bots/bot_123/tasks/th_1"],
["DELETE", "/api/bots/bot_123/tasks/th_1"],
["PATCH", "/api/bots/bot_123/profile"],
["POST", "/api/bots/bot_123/avatar/generate"],
["POST", "/api/bots/bot_123/computer/join"],
["POST", "/api/groups/room-1/messages"],
["POST", "/api/groups/room-1/read"],
Expand All @@ -57,6 +59,10 @@ describe("what the app may do", () => {
["GET", "/api/threads/th_1/export"],
["POST", "/api/threads/th_1/respond"],
["GET", "/api/search"],
["POST", "/api/attachments"],
["GET", "/api/attachments/avatar-123.webp"],
["GET", "/api/tts/voices"],
["POST", "/api/tts/speak"],
["GET", "/api/connectors/catalog"],
["GET", "/api/connectors/connected"],
["GET", "/api/connectors"],
Expand Down Expand Up @@ -115,6 +121,9 @@ describe("what it may not", () => {
expect(allowed("POST", "/api/threads/th_1/messages")).toBe(false);
expect(allowed("GET", "/api/groups/room-1")).toBe(false);
expect(allowed("PATCH", "/api/bots/bot_123")).toBe(false);
expect(allowed("PATCH", "/api/bots/bot_123/profile/execution-policy")).toBe(false);
expect(allowed("PUT", "/api/config")).toBe(false);
expect(allowed("GET", "/api/attachments/../config.json")).toBe(false);
expect(allowed("DELETE", "/api/connectors/slack")).toBe(false);
expect(allowed("GET", "/api/connectors/connected/all")).toBe(false);
// revocation is a Mac-only affordance: the phone can list and add
Expand Down
26 changes: 26 additions & 0 deletions docs/avatar-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Avatar attachment lifecycle

Bot avatars intentionally reuse the image attachment store. Upload and GPT
Image output therefore get the same size checks, owner-only filesystem
permissions, immutable serving URL, and raster-only MIME allowlist as message
images.

## Deferred cleanup

Replacing or removing an avatar does **not** delete the prior file yet. The
current attachment record has no provenance: the same generated filename can
be referenced by a bot profile, by one or more persisted messages, or by both.
Deleting a file merely because no current bot uses it could break a historical
message, so broad "unreferenced file" cleanup is not reference-safe.

A future bounded cleanup may delete only files recorded as avatar-owned at
creation time. Before deleting one candidate it must still verify that:

1. no bot has that `avatarUrl`;
2. no active or archived task/room message references its stored path; and
3. the filename belongs to the avatar-owned registry, not the legacy shared
attachment pool.

Cleanup should process a small fixed number of candidates per run and retain a
grace period. Until that provenance registry exists, retaining an old avatar is
the safe non-destructive behavior.
Binary file added docs/screenshots/agent-profile-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/agent-profile-ios.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/agent-roster-avatar-only.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions electron/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,7 @@ const CREDENTIAL_PATCH = {
boxToken: (value) => ({ box: { token: value } }),
opencodeGoApiKey: (value) => ({ opencodeGo: { apiKey: value } }),
ttsKey: (value) => ({ tts: { key: value } }),
openaiImageApiKey: (value) => ({ imageGen: { key: value } }),
};

ipcMain.handle("credential:set", async (_event, name, value) => {
Expand Down
1 change: 1 addition & 0 deletions electron/workspace-credentials.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const WORKSPACE_CREDENTIALS = [
{ section: "xai", field: "key", name: "xaiApiKey", env: "XAI_API_KEY" },
{ section: "box", field: "token", name: "boxToken", env: "BOX_TOKEN" },
{ section: "tts", field: "key", name: "ttsKey", env: "OMB_TTS_KEY" },
{ section: "imageGen", field: "key", name: "openaiImageApiKey", env: "OMB_OPENAI_IMAGE_KEY" },
{ section: "opencodeGo", field: "apiKey", name: "opencodeGoApiKey", env: "OPENCODE_API_KEY" },
];

Expand Down
5 changes: 5 additions & 0 deletions electron/workspace-credentials.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe("workspace credential migration", () => {
xai: { key: "xai-secret", url: "https://api.example.test/v1" },
box: { token: "box-secret" },
tts: { key: "tts-secret", voice: "narrator" },
imageGen: { key: "image-secret" },
opencodeGo: { apiKey: "ocg-secret" },
profile: { name: "Ada" },
};
Expand All @@ -23,13 +24,15 @@ describe("workspace credential migration", () => {
boxToken: "box-secret",
ttsKey: "tts-secret",
opencodeGoApiKey: "ocg-secret",
openaiImageApiKey: "image-secret",
});
// secrets are DELETED (not blanked) so "" stays meaningful as "cleared";
// non-secret siblings (endpoint url, chosen voice) stay in the file
expect(result.config).toEqual({
xai: { url: "https://api.example.test/v1" },
box: {},
tts: { voice: "narrator" },
imageGen: {},
opencodeGo: {},
profile: { name: "Ada" },
});
Expand Down Expand Up @@ -95,13 +98,15 @@ describe("workspace credential env", () => {
boxToken: "box-secret",
ttsKey: "tts-secret",
opencodeGoApiKey: "ocg-secret",
openaiImageApiKey: "image-secret",
composioApiKey: "ak_handled-separately",
}),
).toEqual({
XAI_API_KEY: "xai-secret",
BOX_TOKEN: "box-secret",
OMB_TTS_KEY: "tts-secret",
OPENCODE_API_KEY: "ocg-secret",
OMB_OPENAI_IMAGE_KEY: "image-secret",
});
});

Expand Down
Loading
Loading