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.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cobble",
|
"name": "cobble",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -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<BUN_SQLITE_COBBLE_MEDIA>({
|
||||||
|
sql: `SELECT * FROM media WHERE id = ? AND user_id = ? LIMIT 1`,
|
||||||
|
values: [Number(media_id), Number(user_id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload?.[0] || null;
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ import assertPromptLength from "@/src/utils/assert-prompt-length";
|
|||||||
import grabUserWorkspace from "./grab-user-workspace";
|
import grabUserWorkspace from "./grab-user-workspace";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
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 = {
|
type Params = {
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
@@ -18,12 +21,19 @@ type Params = {
|
|||||||
data: Pick<
|
data: Pick<
|
||||||
BUN_SQLITE_COBBLE_WORKSPACES,
|
BUN_SQLITE_COBBLE_WORKSPACES,
|
||||||
"name" | "description" | "system_prompt" | "archived"
|
"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
|
* 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({
|
export default async function updateWorkspace({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -40,6 +50,15 @@ export default async function updateWorkspace({
|
|||||||
throw new AppError(`Workspace not found.`);
|
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<
|
const res = await BunSQLite.update<
|
||||||
BUN_SQLITE_COBBLE_WORKSPACES,
|
BUN_SQLITE_COBBLE_WORKSPACES,
|
||||||
(typeof BunSQLiteTables)[number]
|
(typeof BunSQLiteTables)[number]
|
||||||
@@ -56,6 +75,7 @@ export default async function updateWorkspace({
|
|||||||
description: data.description,
|
description: data.description,
|
||||||
system_prompt: data.system_prompt,
|
system_prompt: data.system_prompt,
|
||||||
archived: data.archived,
|
archived: data.archived,
|
||||||
|
image_media_id,
|
||||||
},
|
},
|
||||||
_.isUndefined,
|
_.isUndefined,
|
||||||
),
|
),
|
||||||
@@ -64,4 +84,19 @@ export default async function updateWorkspace({
|
|||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
throw new Error(`Couldn't update the workspace: ${res.msg}`);
|
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(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,39 +22,72 @@ type Args = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
context?: 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
|
* 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<Args> {
|
export default function updateWorkspaceTool({
|
||||||
|
user_id,
|
||||||
|
workspaces,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): HarnessTool<Args> {
|
||||||
const list = workspaces.map((w) => `${w.id}: ${w.name}`).join("; ");
|
const list = workspaces.map((w) => `${w.id}: ${w.name}`).join("; ");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "update_workspace",
|
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({
|
schema: z.object({
|
||||||
workspace_id: z.number().int().optional(),
|
workspace_id: z.number().int().optional(),
|
||||||
name: z.string().trim().min(1).max(60).optional(),
|
name: z.string().trim().min(1).max(60).optional(),
|
||||||
description: z.string().trim().max(240).optional(),
|
description: z.string().trim().max(240).optional(),
|
||||||
context: z.string().trim().max(AppData.PromptMaxLength).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 }) => {
|
run: async ({ args }) => {
|
||||||
const target = workspaces.find((w) => w.id == (args.workspace_id || workspace_id));
|
const target = workspaces.find((w) => w.id == (args.workspace_id || workspace_id));
|
||||||
|
|
||||||
if (!target?.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) {
|
if (
|
||||||
throw new Error(`Give a name, a description or a context to change.`);
|
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({
|
await updateWorkspace({
|
||||||
user_id,
|
user_id,
|
||||||
workspace_id: target.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.`;
|
return `${args.name || target.name} is saved. Bots there get the change from their next run.`;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user