From a5eb2b6a953f534407e8caaea0c50346bf07098d Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Thu, 22 Oct 2026 21:10:06 +0100 Subject: [PATCH] Let the coordinator set a workspace's picture update_workspace now takes an image media id (or null to remove the picture). updateWorkspace writes image_media_id through resolveWorkspacePicture, which only accepts the caller's own image and deletes the picture it replaced, so a bot can never show someone else's file or drop a picture it is still using. --- package.json | 2 +- .../profile-images/grab-own-picture-media.ts | 26 ++++++++ .../backend/db/workspaces/update-workspace.ts | 39 ++++++++++- .../tools/bot/update-workspace-tool.ts | 47 +++++++++++-- src/utils/resolve-workspace-picture.test.ts | 66 +++++++++++++++++++ src/utils/resolve-workspace-picture.ts | 51 ++++++++++++++ 6 files changed, 221 insertions(+), 10 deletions(-) create mode 100644 src/functions/backend/db/profile-images/grab-own-picture-media.ts create mode 100644 src/utils/resolve-workspace-picture.test.ts create mode 100644 src/utils/resolve-workspace-picture.ts diff --git a/package.json b/package.json index f9d3dcc..9cb17fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cobble", - "version": "0.1.2", + "version": "0.1.3", "module": "index.ts", "type": "module", "private": true, diff --git a/src/functions/backend/db/profile-images/grab-own-picture-media.ts b/src/functions/backend/db/profile-images/grab-own-picture-media.ts new file mode 100644 index 0000000..1e2c5a1 --- /dev/null +++ b/src/functions/backend/db/profile-images/grab-own-picture-media.ts @@ -0,0 +1,26 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db"; + +type Params = { + media_id: number | string; + /** + * Who it has to belong to: a bot may only show a picture the user the + * run is for uploaded, never someone else's. + */ + user_id: number | string; +}; + +/** + * One of a user's own media rows, for use as a bot's or workspace's + * picture, or null when it isn't theirs (or no longer exists). The file + * type is checked by the caller (`resolveWorkspacePicture`), so a + * document is never taken as a picture. + */ +export default async function grabOwnPictureMedia({ media_id, user_id }: Params) { + const res = await BunSQLite.sql({ + sql: `SELECT * FROM media WHERE id = ? AND user_id = ? LIMIT 1`, + values: [Number(media_id), Number(user_id)], + }); + + return res.payload?.[0] || null; +} diff --git a/src/functions/backend/db/workspaces/update-workspace.ts b/src/functions/backend/db/workspaces/update-workspace.ts index 63e54cc..2d6c280 100644 --- a/src/functions/backend/db/workspaces/update-workspace.ts +++ b/src/functions/backend/db/workspaces/update-workspace.ts @@ -8,6 +8,9 @@ import assertPromptLength from "@/src/utils/assert-prompt-length"; import grabUserWorkspace from "./grab-user-workspace"; import _ from "lodash"; import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager"; +import deleteMedia from "@/src/functions/backend/db/media/delete-media"; +import grabOwnPictureMedia from "@/src/functions/backend/db/profile-images/grab-own-picture-media"; +import resolveWorkspacePicture from "@/src/utils/resolve-workspace-picture"; type Params = { user_id: number | string; @@ -18,12 +21,19 @@ type Params = { data: Pick< BUN_SQLITE_COBBLE_WORKSPACES, "name" | "description" | "system_prompt" | "archived" - >; + > & { + /** + * A new picture (a `media` id of the user's own), null to remove + * it, or undefined to leave it alone. + */ + image_media_id?: number | null; + }; }; /** * Updates one of the user's workspaces. Its `system_prompt` is shared - * context for every bot in it. + * context for every bot in it, and `image_media_id` its picture: a media + * id replaces it (the old picture is deleted), null removes it. */ export default async function updateWorkspace({ user_id, @@ -40,6 +50,15 @@ export default async function updateWorkspace({ throw new AppError(`Workspace not found.`); } + const image_media_id = resolveWorkspacePicture({ + requested: data.image_media_id, + current: workspace.image_media_id, + media: + data.image_media_id == null + ? null + : await grabOwnPictureMedia({ media_id: data.image_media_id, user_id }), + }); + const res = await BunSQLite.update< BUN_SQLITE_COBBLE_WORKSPACES, (typeof BunSQLiteTables)[number] @@ -56,6 +75,7 @@ export default async function updateWorkspace({ description: data.description, system_prompt: data.system_prompt, archived: data.archived, + image_media_id, }, _.isUndefined, ), @@ -64,4 +84,19 @@ export default async function updateWorkspace({ if (!res.success) { throw new Error(`Couldn't update the workspace: ${res.msg}`); } + + /** + * The picture is written, so the one it replaced isn't shown anywhere + * any more. + */ + if ( + image_media_id !== undefined && + workspace.image_media_id && + Number(workspace.image_media_id) != Number(image_media_id) + ) { + await deleteMedia({ + id: workspace.image_media_id, + user_id: Number(user_id), + }).catch(() => {}); + } } diff --git a/src/harness/tools/bot/update-workspace-tool.ts b/src/harness/tools/bot/update-workspace-tool.ts index 0694c11..a888355 100644 --- a/src/harness/tools/bot/update-workspace-tool.ts +++ b/src/harness/tools/bot/update-workspace-tool.ts @@ -22,39 +22,72 @@ type Args = { name?: string; description?: string; context?: string; + /** + * A picture for the workspace: the media id the user uploaded, or null + * to remove it. + */ + picture_media_id?: number | null; }; /** * Lets the coordinator change a workspace's record: its name, its - * one-line description and the context every bot working there gets. + * one-line description and the context every bot working there gets, and + * its picture. */ -export default function updateWorkspaceTool({ user_id, workspaces, workspace_id }: Params): HarnessTool { +export default function updateWorkspaceTool({ + user_id, + workspaces, + workspace_id, +}: Params): HarnessTool { const list = workspaces.map((w) => `${w.id}: ${w.name}`).join("; "); return { name: "update_workspace", - description: `Change a workspace: its name, its one-line description, or its context (markdown every bot working there gets in its prompt). Only the fields you give change; \`context\` replaces the whole text, so keep what still applies. Use it when the user asks, or to record something every bot in that workspace must know.${workspace_id ? " Leave out workspace_id for this workspace." : ""} Workspaces: ${list}.`, + description: `Change a workspace: its name, its one-line description, its context (markdown every bot working there gets in its prompt), or its picture. Only the fields you give change; \`context\` replaces the whole text, so keep what still applies. Use it when the user asks, or to record something every bot in that workspace must know.${workspace_id ? " Leave out workspace_id for this workspace." : ""} Workspaces: ${list}.`, schema: z.object({ workspace_id: z.number().int().optional(), name: z.string().trim().min(1).max(60).optional(), description: z.string().trim().max(240).optional(), context: z.string().trim().max(AppData.PromptMaxLength).optional(), + picture_media_id: z + .number() + .int() + .positive() + .nullable() + .optional() + .describe( + `An image the user uploaded, by media id; null removes the picture. Leave it out to keep the one it has.`, + ), }), run: async ({ args }) => { const target = workspaces.find((w) => w.id == (args.workspace_id || workspace_id)); if (!target?.id) { - throw new Error(`No workspace has id ${args.workspace_id ?? "(none given)"}. Workspaces: ${list}.`); + throw new Error( + `No workspace has id ${args.workspace_id ?? "(none given)"}. Workspaces: ${list}.`, + ); } - if (args.name === undefined && args.description === undefined && args.context === undefined) { - throw new Error(`Give a name, a description or a context to change.`); + if ( + args.name === undefined && + args.description === undefined && + args.context === undefined && + args.picture_media_id === undefined + ) { + throw new Error( + `Give a name, a description, a context or a picture to change.`, + ); } await updateWorkspace({ user_id, workspace_id: target.id, - data: { name: args.name, description: args.description, system_prompt: args.context }, + data: { + name: args.name, + description: args.description, + system_prompt: args.context, + image_media_id: args.picture_media_id, + }, }); return `${args.name || target.name} is saved. Bots there get the change from their next run.`; diff --git a/src/utils/resolve-workspace-picture.test.ts b/src/utils/resolve-workspace-picture.test.ts new file mode 100644 index 0000000..9223a43 --- /dev/null +++ b/src/utils/resolve-workspace-picture.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import AppError from "./app-error"; +import resolveWorkspacePicture from "./resolve-workspace-picture"; + +const image = (id: number) => ({ id, media_type: "image" }); + +describe("resolveWorkspacePicture", () => { + test("leaves the picture alone when nothing is asked for", () => { + expect(resolveWorkspacePicture({ current: 4 })).toBeUndefined(); + }); + + test("sets a new image of the caller's", () => { + expect( + resolveWorkspacePicture({ requested: 9, current: 4, media: image(9) }), + ).toBe(9); + }); + + test("clears the picture with null", () => { + expect( + resolveWorkspacePicture({ requested: null, current: 4 }), + ).toBeNull(); + }); + + test("naming the picture it already has changes nothing, so it is never deleted", () => { + expect( + resolveWorkspacePicture({ requested: 4, current: 4 }), + ).toBeUndefined(); + expect( + resolveWorkspacePicture({ requested: "4", current: 4, media: image(4) }), + ).toBeUndefined(); + }); + + test("refuses media that isn't the caller's", () => { + expect(() => + resolveWorkspacePicture({ requested: 9, current: null, media: null }), + ).toThrow(AppError); + }); + + test("refuses anything that isn't an image", () => { + expect(() => + resolveWorkspacePicture({ + requested: 9, + current: null, + media: { id: 9, media_type: "document" }, + }), + ).toThrow(`A workspace picture has to be an image.`); + }); + + test("refuses an id that names a different row than the one looked up", () => { + expect(() => + resolveWorkspacePicture({ + requested: 9, + current: null, + media: image(10), + }), + ).toThrow(AppError); + }); + + test("refuses an id that isn't a positive integer", () => { + for (const requested of [0, -3, 1.5, "abc"]) { + expect(() => + resolveWorkspacePicture({ requested, current: null }), + ).toThrow(AppError); + } + }); +}); diff --git a/src/utils/resolve-workspace-picture.ts b/src/utils/resolve-workspace-picture.ts new file mode 100644 index 0000000..90e04a6 --- /dev/null +++ b/src/utils/resolve-workspace-picture.ts @@ -0,0 +1,51 @@ +import AppError from "@/src/utils/app-error"; + +type Params = { + /** + * The `workspace_media_id` the caller asked for: a new picture, or + * null to remove the one it has. Undefined leaves it alone. + */ + requested?: number | string | null; + /** + * The picture the workspace has now. + */ + current?: number | string | null; + /** + * The media row the requested id names, when it was found for the + * caller (`grabOwnPictureMedia`); required to set a new picture. + */ + media?: { id?: number | string | null; media_type?: string | null } | null; +}; + +/** + * What `updateWorkspace` should write into `workspaces.image_media_id`: + * undefined to leave it as it is, null to clear it, or the new media id. + * + * A new picture has to be the caller's own image: the resolver refuses an + * id that isn't an image, and the media lookup refuses one that isn't + * theirs. Handing back the picture it already has changes nothing, so + * saying "use this picture" twice never deletes it. + */ +export default function resolveWorkspacePicture({ requested, current, media }: Params) { + if (requested === undefined) return undefined; + + if (requested === null) return null; + + const id = Number(requested); + + if (!Number.isInteger(id) || id <= 0) { + throw new AppError(`Pick an image for the workspace.`); + } + + if (current != null && Number(current) == id) return undefined; + + if (!media || Number(media.id) != id) { + throw new AppError(`That picture isn't one of yours. Upload it again.`); + } + + if (media.media_type != "image") { + throw new AppError(`A workspace picture has to be an image.`); + } + + return id; +}