diff --git a/AGENTS.md b/AGENTS.md index 17c851e..e3e31ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker ### Bot types -`bot_types` is to bots what `user_types` is to users (`src/dict/bot-types-dict.ts`): `coordinator` (install-wide, written only by `ensureCoordinatorBot`), `admin` (scoped to one workspace), `worker` (no row). `BotTypePermissionMatrix`, `BotTypeTargetMatrix`, `BotTypeAssignmentMatrix`/`DeletionMatrix` are read by the generic interpreter `src/utils/rbac/type-assignment.ts`. Every run reads `grabBotRunPowers` once and gates tools on its `is_coordinator`, `admin_workspace_ids` and `can(permission)`, **never on `bots.kind`** (`kind` is only the legacy marker of the coordinator for visibility SQL). A run's powers are its type's AND its user's. A workspace admin gets `grabWorkspaceAdminTools` (delegate to members, set their workspace instructions, edit that workspace). Set via `api/admin/bots/set-bot-type`. Admins are marked with `AdminShield` (green, beside the name, never on the stone). +`bot_types` extends `bots.kind` (worker | coordinator) the way `user_types` extends users (`src/dict/bot-types-dict.ts`): today only `workspace_admin`, each row naming its workspace (no row = just the kind; the coordinator is its kind, written only by `ensureCoordinatorBot`). A bot's role where it works is its kind plus the types it holds there (`botTypesIn`). `BotTypePermissionMatrix`, `BotTypeTargetMatrix` (keyed by role), `BotTypeAssignmentMatrix`/`DeletionMatrix` (types only) are read by the generic interpreter `src/utils/rbac/type-assignment.ts`. Every run reads `grabBotRunPowers` once and gates tools on its `is_coordinator`, `admin_workspace_ids` and `can(permission)`, never on its own check of `kind`. A run's powers are its role's AND its user's. Rows from v0.2.8 and earlier (`admin`, `coordinator`) are fixed at boot by `migrateBotTypes`. A workspace admin gets `grabWorkspaceAdminTools` (delegate to members, set their workspace instructions, edit that workspace). Set via `api/admin/bots/set-bot-type`. Admins are marked with `AdminShield` (green, beside the name, never on the stone). ### Bots, sessions, chat diff --git a/db/schema.ts b/db/schema.ts index 83cce61..1befe03 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -238,7 +238,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { }, { fieldName: "kind", - fieldDescription: `Legacy: marks the one coordinator, which only the super admin sees and reaches. What a bot can do comes from \`bot_types\``, + fieldDescription: `Worker or the one coordinator (which only the super admin sees and reaches). \`bot_types\` add to it`, dataType: "TEXT", options: BotKinds.map((bk) => bk.value), defaultValue: "worker", @@ -1621,7 +1621,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { }, { tableName: "bot_types", - tableDescription: `A bot's types, like \`user_types\` for users: what it can do (\`BotTypePermissionMatrix\`). A row with no workspace holds everywhere (the coordinator); one with a workspace only there (a workspace admin). A bot with no row is a worker. One per bot, type and workspace, enforced in \`setBotType\` since SQLite treats NULL ids as distinct`, + tableDescription: `Types a bot holds on top of its \`kind\`, like \`user_types\` for users (\`BotTypePermissionMatrix\`): each row holds in one workspace (a workspace admin). A bot with no row is just its kind. One per bot, type and workspace, enforced in \`setBotType\``, fields: [ { fieldName: "bot_id", @@ -1641,7 +1641,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { }, { fieldName: "workspace_id", - fieldDescription: `The workspace the type holds in; null for install-wide`, + fieldDescription: `The workspace the type holds in`, dataType: "INTEGER", foreignKey: { cascadeDelete: true, diff --git a/db/types/db.ts b/db/types/db.ts index d3100da..214b4cf 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -198,7 +198,7 @@ export type BUN_SQLITE_COBBLE_BOTS = { user_id?: number | ""; name?: string; /** - * Legacy: marks the one coordinator, which only the super admin sees and reaches. What a bot can do comes from `bot_types` + * Worker or the one coordinator (which only the super admin sees and reaches). `bot_types` add to it */ kind?: "worker" | "coordinator" | ""; /** @@ -1036,9 +1036,9 @@ export type BUN_SQLITE_COBBLE_BOT_TYPES = { */ updated_at?: number | ""; bot_id?: number | ""; - bot_type?: "coordinator" | "admin" | "worker" | ""; + bot_type?: "workspace_admin" | ""; /** - * The workspace the type holds in; null for install-wide + * The workspace the type holds in */ workspace_id?: number | ""; /** diff --git a/src/bots-queue/push-bot-updated.ts b/src/bots-queue/push-bot-updated.ts index 42ab69f..e362c9a 100644 --- a/src/bots-queue/push-bot-updated.ts +++ b/src/bots-queue/push-bot-updated.ts @@ -80,7 +80,7 @@ async function push({ const admin_workspace_ids = ( ( await BunSQLite.sql<{ workspace_id: number }>({ - sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'admin' AND workspace_id IS NOT NULL`, + sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'workspace_admin' AND workspace_id IS NOT NULL`, values: [bot_id], }) ).payload || [] diff --git a/src/dict/bot-types-dict.ts b/src/dict/bot-types-dict.ts index e10cca4..094ef65 100644 --- a/src/dict/bot-types-dict.ts +++ b/src/dict/bot-types-dict.ts @@ -1,30 +1,15 @@ -import type { BotPermission, BotType, TypeAssignmentMatrixType } from "../types"; +import type { BotPermission, BotRole, BotType, TypeAssignmentMatrixType } from "../types"; /** - * Every bot type. A bot with no `bot_types` row is a worker. A type holds - * install-wide (`scope: "install"`, its row has no workspace) or in one - * workspace (`scope: "workspace"`, its row names it). `bots.kind` stays the - * legacy marker of the one coordinator for who sees it; what a bot can do - * comes from these. + * Types a bot holds on top of its kind (`bots.kind`: coordinator or + * worker), like `user_types` for users. A bot with none is just its kind. + * Each `bot_types` row names the workspace the type holds in. */ export const BotTypes = [ { - value: "coordinator", - title: "Coordinator", - description: "Coordinates the whole crew: hands work to every bot, grows the crew, runs workspaces and extensions. One per install, the super admin's alone.", - scope: "install", - }, - { - value: "admin", + value: "workspace_admin", title: "Workspace admin", description: "Leads one workspace: hands tasks to the other bots there, sets their instructions there, runs the workspace's task list and keeps its context.", - scope: "workspace", - }, - { - value: "worker", - title: "Worker", - description: "Does its own job. Every bot without another type.", - scope: "install", }, ] as const; @@ -75,12 +60,11 @@ export const BotPermissions = [ ] as const; /** - * What each bot type may do, where it holds (everywhere for an - * install-wide type, in its workspace for a workspace one). + * What each role (a kind, or a type held where the bot works) may do. */ -export const BotTypePermissionMatrix: Record = { +export const BotTypePermissionMatrix: Record = { coordinator: BotPermissions.map((bp) => bp.value), - admin: [ + workspace_admin: [ "delegate_task", "set_member_instructions", "manage_workspace_tasks", @@ -91,37 +75,32 @@ export const BotTypePermissionMatrix: Record = { /** * Which bots a bot may act on (hand a task to, set instructions for), keyed - * by the target's type. Same shape as `UserTypeAssignmentMatrix`: the + * by the target's role. Same shape as `UserTypeAssignmentMatrix`: the * coordinator passes through. A workspace admin directs workers only, so * two admins never hand work back and forth. */ -export const BotTypeTargetMatrix: TypeAssignmentMatrixType = { +export const BotTypeTargetMatrix: TypeAssignmentMatrixType = { coordinator: {}, - admin: { + workspace_admin: { allowed_actors: [], }, worker: { - allowed_actors: ["admin"], + allowed_actors: ["workspace_admin"], }, }; /** * Who may grant a bot type: user types (`admin`; the super admin passes - * through) and bot types (`coordinator`). The coordinator type is never - * granted: `ensureCoordinatorBot` writes it. + * through) and bot kinds (`coordinator`). */ export const BotTypeAssignmentMatrix: TypeAssignmentMatrixType = { - coordinator: {}, - admin: { + workspace_admin: { allowed_actors: ["admin", "coordinator"], }, - worker: {}, }; export const BotTypeDeletionMatrix: TypeAssignmentMatrixType = { - coordinator: {}, - admin: { + workspace_admin: { allowed_actors: ["admin", "coordinator"], }, - worker: {}, }; diff --git a/src/functions/backend/db/bot-types/assert-bot-type-change.ts b/src/functions/backend/db/bot-types/assert-bot-type-change.ts index 4b17aac..9612c94 100644 --- a/src/functions/backend/db/bot-types/assert-bot-type-change.ts +++ b/src/functions/backend/db/bot-types/assert-bot-type-change.ts @@ -6,7 +6,7 @@ import botTypeAssignment from "@/src/utils/rbac/bot-type-assignment"; import grabUserBot from "../bots/grab-user-bot"; import grabUserTypes from "../users/grab-user-types"; import grabUserWorkspace from "../workspaces/grab-user-workspace"; -import grabBotTypes from "./grab-bot-types"; +import grabBotRunPowers from "./grab-bot-run-powers"; export type BotTypeChangeParams = { /** @@ -25,9 +25,8 @@ export type BotTypeChangeParams = { /** * Checks a grant or removal of a bot type: the user (and the bot, when one - * acts) may do it (`botTypeAssignment`), only workspace types are ever - * changed (the coordinator's is written by `ensureCoordinatorBot`), and the - * bot works in the workspace. Returns the bot and the workspace id. + * acts) may do it (`botTypeAssignment`), the type exists, and the bot + * works in the workspace. Returns the bot and the workspace id. */ export default async function assertBotTypeChange({ user_id, @@ -39,8 +38,8 @@ export default async function assertBotTypeChange({ }: BotTypeChangeParams & { action: "assign" | "delete" }) { const type = BotTypes.find((bt) => bt.value == bot_type); - if (!type || type.scope != "workspace") { - throw new AppError(`Only a workspace type can be given or taken away.`); + if (!type) { + throw new AppError(`There's no such bot type.`); } if (!workspace_id) { @@ -54,9 +53,10 @@ export default async function assertBotTypeChange({ } if (actor_bot_id) { - const actor_bot_types = await grabBotTypes({ bot_id: actor_bot_id }); + const actor = await grabBotRunPowers({ bot_id: actor_bot_id }); + const actor_bot = { kind: actor.is_coordinator ? ("coordinator" as const) : ("worker" as const), bot_types: actor.bot_types }; - if (!botTypeAssignment({ actor_bot_types, target_bot_type: bot_type, action })) { + if (!botTypeAssignment({ actor_bot, target_bot_type: bot_type, action })) { throw new AppError(`Only the coordinator can do that.`); } } diff --git a/src/functions/backend/db/bot-types/ensure-coordinator-type.ts b/src/functions/backend/db/bot-types/ensure-coordinator-type.ts deleted file mode 100644 index 79c27d1..0000000 --- a/src/functions/backend/db/bot-types/ensure-coordinator-type.ts +++ /dev/null @@ -1,19 +0,0 @@ -import BunSQLite from "@moduletrace/bun-sqlite"; - -type Params = { - bot_id: number | string; -}; - -/** - * Gives the coordinator its install-wide `coordinator` bot type, once. - * The only place that type is ever written. - */ -export default async function ensureCoordinatorType({ bot_id }: Params) { - await BunSQLite.sql({ - sql: `INSERT INTO bot_types (bot_id, bot_type, workspace_id, created_at, updated_at) - SELECT ?, 'coordinator', NULL, ?, ? WHERE NOT EXISTS ( - SELECT 1 FROM bot_types WHERE bot_id = ? AND bot_type = 'coordinator' AND workspace_id IS NULL - )`, - values: [Number(bot_id), Date.now(), Date.now(), Number(bot_id)], - }); -} diff --git a/src/functions/backend/db/bot-types/grab-bot-run-powers.ts b/src/functions/backend/db/bot-types/grab-bot-run-powers.ts index d300961..f6b643f 100644 --- a/src/functions/backend/db/bot-types/grab-bot-run-powers.ts +++ b/src/functions/backend/db/bot-types/grab-bot-run-powers.ts @@ -1,3 +1,5 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BotKind } from "@/src/dict/bot-dict"; import type { BotPermission } from "@/src/types"; import botCan from "@/src/utils/rbac/bot-can"; import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in"; @@ -14,7 +16,7 @@ type Params = { export type BotRunPowers = { bot_types: BotTypeRow[]; /** - * Holds the install-wide coordinator type. + * Its kind is `coordinator`. */ is_coordinator: boolean; /** @@ -26,27 +28,35 @@ export type BotRunPowers = { */ is_workspace_admin: boolean; /** - * Whether its types allow `permission` where the run works. The bot's + * Whether its kind and types allow `permission` where the run works. The bot's * side only: the run's user must still be allowed. */ can: (permission: BotPermission) => boolean; }; /** - * What a bot's types let it do on one run, read once (`grabBotTypes`). + * What a bot's kind and types let it do on one run, read once + * (`grabBotTypes`). */ export default async function grabBotRunPowers({ bot_id, workspace_id }: Params): Promise { const bot_types = await grabBotTypes({ bot_id }); + const bot = await BunSQLite.sql<{ kind: BotKind | null }>({ + sql: `SELECT kind FROM bots WHERE id = ? LIMIT 1`, + values: [Number(bot_id)], + }); + + const kind = bot.payload?.[0]?.kind || "worker"; + const admin_workspace_ids = bot_types - .filter((bt) => bt.bot_type == "admin" && bt.workspace_id) + .filter((bt) => bt.bot_type == "workspace_admin" && bt.workspace_id) .map((bt) => Number(bt.workspace_id)); return { bot_types, - is_coordinator: bot_types.some((bt) => bt.bot_type == "coordinator" && !bt.workspace_id), + is_coordinator: kind == "coordinator", admin_workspace_ids, is_workspace_admin: Boolean(workspace_id && admin_workspace_ids.includes(Number(workspace_id))), - can: (permission) => botCan({ bot_types, permission, workspace_id }), + can: (permission) => botCan({ kind, bot_types, permission, workspace_id }), }; } diff --git a/src/functions/backend/db/bot-types/grab-bot-types.ts b/src/functions/backend/db/bot-types/grab-bot-types.ts index fa3152c..b6ca553 100644 --- a/src/functions/backend/db/bot-types/grab-bot-types.ts +++ b/src/functions/backend/db/bot-types/grab-bot-types.ts @@ -6,15 +6,13 @@ type Params = { }; /** - * A bot's types (`bot_types`), plus the install-wide coordinator type for - * the legacy `kind = 'coordinator'` bot, so the coordinator keeps its - * powers before `ensureCoordinatorBot` has written its row. + * A bot's types (`bot_types`), each held in one workspace. Its kind + * (`bots.kind`) is read on its own. */ export default async function grabBotTypes({ bot_id }: Params): Promise { const res = await BunSQLite.sql({ - sql: `SELECT bot_type, workspace_id FROM bot_types WHERE bot_id = ? - UNION SELECT 'coordinator' AS bot_type, NULL AS workspace_id FROM bots WHERE id = ? AND kind = 'coordinator'`, - values: [Number(bot_id), Number(bot_id)], + sql: `SELECT bot_type, workspace_id FROM bot_types WHERE bot_id = ? AND workspace_id IS NOT NULL`, + values: [Number(bot_id)], }); return res.payload || []; diff --git a/src/functions/backend/db/bot-types/grab-bots-types.ts b/src/functions/backend/db/bot-types/grab-bots-types.ts index c5ab49e..360340c 100644 --- a/src/functions/backend/db/bot-types/grab-bots-types.ts +++ b/src/functions/backend/db/bot-types/grab-bots-types.ts @@ -9,7 +9,7 @@ type Row = BotTypeRow & { bot_id: number }; /** * `grabBotTypes` for a list of bots at once, keyed by bot id. A bot with no - * row is missing from the map (a worker). + * row is missing from the map (just its kind). */ export default async function grabBotsTypes({ bot_ids }: Params): Promise> { const ids = [...new Set(bot_ids.map(Number).filter(Boolean))]; @@ -19,9 +19,8 @@ export default async function grabBotsTypes({ bot_ids }: Params): Promise "?").join(", "); const res = await BunSQLite.sql({ - sql: `SELECT bot_id, bot_type, workspace_id FROM bot_types WHERE bot_id IN (${marks}) - UNION SELECT id AS bot_id, 'coordinator' AS bot_type, NULL AS workspace_id FROM bots WHERE id IN (${marks}) AND kind = 'coordinator'`, - values: [...ids, ...ids], + sql: `SELECT bot_id, bot_type, workspace_id FROM bot_types WHERE bot_id IN (${marks}) AND workspace_id IS NOT NULL`, + values: ids, }); for (const row of res.payload || []) { diff --git a/src/functions/backend/db/bot-types/grab-directable-bots.ts b/src/functions/backend/db/bot-types/grab-directable-bots.ts index 3121abf..09afb50 100644 --- a/src/functions/backend/db/bot-types/grab-directable-bots.ts +++ b/src/functions/backend/db/bot-types/grab-directable-bots.ts @@ -1,7 +1,7 @@ import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; import botCanTarget from "@/src/utils/rbac/bot-can-target"; import grabCrewWorkers from "../bots/grab-crew-workers"; -import grabBotTypes from "./grab-bot-types"; +import grabBotRunPowers from "./grab-bot-run-powers"; import grabBotsTypes from "./grab-bots-types"; type Params = { @@ -22,12 +22,14 @@ export default async function grabDirectableBots({ bot_id, workspace_id }: Param if (!members.length) return []; - const actor_bot_types = await grabBotTypes({ bot_id }); + const actor = await grabBotRunPowers({ bot_id, workspace_id }); const types = await grabBotsTypes({ bot_ids: members.map((bot) => bot.id) }); return members.filter((bot) => botCanTarget({ - actor_bot_types, + actor_kind: actor.is_coordinator ? "coordinator" : "worker", + actor_bot_types: actor.bot_types, + target_kind: bot.kind, target_bot_types: types.get(Number(bot.id)) || [], workspace_id, }), diff --git a/src/functions/backend/db/bot-types/grab-workspace-admins.ts b/src/functions/backend/db/bot-types/grab-workspace-admins.ts index 8035015..d2d94d3 100644 --- a/src/functions/backend/db/bot-types/grab-workspace-admins.ts +++ b/src/functions/backend/db/bot-types/grab-workspace-admins.ts @@ -11,7 +11,7 @@ type Params = { export default async function grabWorkspaceAdmins({ workspace_id }: Params): Promise { const res = await BunSQLite.sql({ sql: `SELECT b.* FROM bots b - JOIN bot_types bt ON bt.bot_id = b.id AND bt.bot_type = 'admin' AND bt.workspace_id = ? + JOIN bot_types bt ON bt.bot_id = b.id AND bt.bot_type = 'workspace_admin' AND bt.workspace_id = ? JOIN workspace_bots wb ON wb.bot_id = b.id AND wb.workspace_id = bt.workspace_id GROUP BY b.id ORDER BY b.id ASC`, values: [Number(workspace_id)], diff --git a/src/functions/backend/db/bot-types/migrate-bot-types.ts b/src/functions/backend/db/bot-types/migrate-bot-types.ts new file mode 100644 index 0000000..027bd91 --- /dev/null +++ b/src/functions/backend/db/bot-types/migrate-bot-types.ts @@ -0,0 +1,18 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; + +/** + * Brings `bot_types` rows from v0.2.8 and earlier up to date, safe to run + * on every start: the coordinator is its `bots.kind` now, so its old + * `coordinator` row (and any row without a workspace) goes, and `admin` + * became `workspace_admin`. + */ +export default async function migrateBotTypes() { + await BunSQLite.sql({ + sql: `DELETE FROM bot_types WHERE bot_type = 'coordinator' OR bot_type = 'worker' OR workspace_id IS NULL`, + }); + + await BunSQLite.sql({ + sql: `UPDATE bot_types SET bot_type = 'workspace_admin', updated_at = ? WHERE bot_type = 'admin'`, + values: [Date.now()], + }); +} diff --git a/src/functions/backend/db/bots/ensure-coordinator-bot.ts b/src/functions/backend/db/bots/ensure-coordinator-bot.ts index 67aeb90..54253b0 100644 --- a/src/functions/backend/db/bots/ensure-coordinator-bot.ts +++ b/src/functions/backend/db/bots/ensure-coordinator-bot.ts @@ -3,7 +3,6 @@ import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db"; import { CoordinatorBotName } from "@/src/dict/bot-dict"; import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt"; import attachDefaultExtensions from "../extensions/attach-default-extensions"; -import ensureCoordinatorType from "../bot-types/ensure-coordinator-type"; type Params = { /** @@ -17,8 +16,7 @@ type Params = { * alone), creating it first (with the default `CoordinatorSystemPrompt`) * if there is none. A * coordinator without a prompt gets the default; an edited prompt is never - * overwritten. Its install-wide `coordinator` bot type is written too - * (`ensureCoordinatorType`), which backfills older installs. Safe to call + * overwritten. Its `kind` is what makes it the coordinator. Safe to call * on every visit. */ export default async function ensureCoordinatorBot({ user_id }: Params) { @@ -46,8 +44,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) { }); } - await ensureCoordinatorType({ bot_id: existing.id }); - return existing.id; } @@ -73,7 +69,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) { } await attachDefaultExtensions({ bot_id }); - await ensureCoordinatorType({ bot_id }); return bot_id; } diff --git a/src/functions/backend/db/bots/grab-bots.ts b/src/functions/backend/db/bots/grab-bots.ts index d3e8a46..ce0b8c0 100644 --- a/src/functions/backend/db/bots/grab-bots.ts +++ b/src/functions/backend/db/bots/grab-bots.ts @@ -42,7 +42,7 @@ export default async function grabBots({ sql += ` t.last_activity_at AS last_activity_at,\n`; sql += ` lm.text AS last_message_text,\n`; sql += ` lm.created_at AS last_message_at,\n`; - sql += ` EXISTS (SELECT 1 FROM bot_types bt WHERE bt.bot_id = b.id AND bt.bot_type = 'admin' AND bt.workspace_id = ?) AS is_admin\n`; + sql += ` EXISTS (SELECT 1 FROM bot_types bt WHERE bt.bot_id = b.id AND bt.bot_type = 'workspace_admin' AND bt.workspace_id = ?) AS is_admin\n`; sql += `FROM bots b\n`; sql += `LEFT JOIN (\n`; sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\n`; diff --git a/src/functions/backend/db/workspaces/grab-bot-workspaces.ts b/src/functions/backend/db/workspaces/grab-bot-workspaces.ts index 923015e..f32f9d4 100644 --- a/src/functions/backend/db/workspaces/grab-bot-workspaces.ts +++ b/src/functions/backend/db/workspaces/grab-bot-workspaces.ts @@ -41,7 +41,7 @@ export default async function grabBotWorkspaces({ ( ( await BunSQLite.sql<{ workspace_id: number }>({ - sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'admin'`, + sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'workspace_admin'`, values: [Number(bot.id)], }) ).payload || [] diff --git a/src/harness/tools/bot/find-bot-tool.ts b/src/harness/tools/bot/find-bot-tool.ts index 2dd54f6..bc5eca1 100644 --- a/src/harness/tools/bot/find-bot-tool.ts +++ b/src/harness/tools/bot/find-bot-tool.ts @@ -60,7 +60,7 @@ export default function findBotTool(): HarnessTool { return matches .map((bot) => { const led = (types.get(Number(bot.id)) || []) - .filter((bt) => bt.bot_type == "admin" && bt.workspace_id) + .filter((bt) => bt.bot_type == "workspace_admin" && bt.workspace_id) .map((bt) => workspaceRef({ id: Number(bt.workspace_id) })); return `${botRef({ id: bot.id || 0 })} ${bot.name} (${bot.kind}${bot.role ? `: ${bot.role}` : ""})${led.length ? `, admin of ${led.join(", ")}` : ""}\n## Instructions:\n${bot.system_prompt}`; diff --git a/src/harness/tools/bot/set-bot-type-tool.ts b/src/harness/tools/bot/set-bot-type-tool.ts index d1d59e3..99d3495 100644 --- a/src/harness/tools/bot/set-bot-type-tool.ts +++ b/src/harness/tools/bot/set-bot-type-tool.ts @@ -68,14 +68,14 @@ export default function setBotTypeTool({ user_id, bot_id, workers, workspaces, w user_id, actor_bot_id: bot_id, bot_id: args.bot_id, - bot_type: "admin" as const, + bot_type: "workspace_admin" as const, workspace_id: target_workspace_id, }; const { bot, workspace } = args.admin ? await setBotType(change) : await removeBotType(change); const saved = (await grabBotTypes({ bot_id: args.bot_id })).some( - (bt) => bt.bot_type == "admin" && bt.workspace_id == target_workspace_id, + (bt) => bt.bot_type == "workspace_admin" && bt.workspace_id == target_workspace_id, ); if (saved != args.admin) { diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-admin-menu-item.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-admin-menu-item.tsx index b803661..e33c842 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-admin-menu-item.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-admin-menu-item.tsx @@ -54,7 +54,7 @@ export default function BotAdminMenuItem({ const res = await postAdminApi({ path: "bots/set-bot-type", - body: { bot_id, workspace_id, bot_type: "admin", given }, + body: { bot_id, workspace_id, bot_type: "workspace_admin", given }, }).catch(() => null); setLoading(false); diff --git a/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bots-group.tsx b/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bots-group.tsx index 535cac3..79b6211 100644 --- a/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bots-group.tsx +++ b/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bots-group.tsx @@ -81,7 +81,7 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots: const res = await postAdminApi({ path: "bots/set-bot-type", - body: { workspace_id, bot_id: bot.id, bot_type: "admin", given }, + body: { workspace_id, bot_id: bot.id, bot_type: "workspace_admin", given }, }); setAdminBusyId(undefined); diff --git a/src/schemas/bot-type-schema.ts b/src/schemas/bot-type-schema.ts index a482462..a8d2c56 100644 --- a/src/schemas/bot-type-schema.ts +++ b/src/schemas/bot-type-schema.ts @@ -7,7 +7,7 @@ import { z } from "zod"; export const SetBotTypeRequestSchema = z.object({ bot_id: z.coerce.number().int().positive("Pick a bot"), workspace_id: z.coerce.number().int().positive("Pick a workspace"), - bot_type: z.enum(["admin"]).default("admin"), + bot_type: z.enum(["workspace_admin"]).default("workspace_admin"), given: z.boolean(), }); diff --git a/src/scripts/backfill-bot-types.ts b/src/scripts/backfill-bot-types.ts deleted file mode 100644 index 1d4a455..0000000 --- a/src/scripts/backfill-bot-types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import BunSQLite from "@moduletrace/bun-sqlite"; -import ensureCoordinatorType from "@/src/functions/backend/db/bot-types/ensure-coordinator-type"; - -/** - * Safe to run again: gives the coordinator (`kind = 'coordinator'`) its - * install-wide `coordinator` bot type. The super admin's `/admin/bots` does - * the same through `ensureCoordinatorBot`; this is for a headless install. - * - * `bun src/scripts/backfill-bot-types.ts` - */ -async function backfillBotTypes() { - const res = await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM bots WHERE kind = 'coordinator'` }); - - for (const row of res.payload || []) await ensureCoordinatorType({ bot_id: row.id }); - - console.log(`Checked ${res.payload?.length || 0} coordinator bot types.`); -} - -await backfillBotTypes(); -process.exit(0); diff --git a/src/server.ts b/src/server.ts index 80b2314..82d2862 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,7 @@ import botsQueue from "./bots-queue"; import fileIngest from "./file-ingest"; import liveRelay from "./live-relay"; import enableForeignKeys from "./functions/backend/db/enable-foreign-keys"; +import migrateBotTypes from "./functions/backend/db/bot-types/migrate-bot-types"; import handleComputerProxy from "./functions/server/computers/handle-computer-proxy"; import handlePwaFile from "./functions/server/pwa/handle-pwa-file"; import handleInstallIconFile from "./functions/server/pwa/handle-install-icon-file"; @@ -30,6 +31,9 @@ const development = process.env.NODE_ENV == "development"; const port = process.env.PORT || 7227; await enableForeignKeys(); +await migrateBotTypes().catch((error) => + logError({ title: "Could not migrate bot types", error }).catch(() => {}), +); await bunext.bunextInit(); /** diff --git a/src/types/index.ts b/src/types/index.ts index a1794fe..febf2b8 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -34,7 +34,7 @@ import type { HarnessKind } from "../dict/harness-dict"; import type { HarnessProviderKind } from "../dict/harness-provider-dict"; import type { EnvParadigm, KnownEnv, SecretRequestStatus } from "../dict/env-dict"; import type { ContainerStatus } from "../dict/computer-dict"; -import type { BotFace, BotMessageReactionEmoji } from "../dict/bot-dict"; +import type { BotFace, BotKind, BotMessageReactionEmoji } from "../dict/bot-dict"; import type { HarnessCallSettings, TokenUsageSummary } from "../harness/usage/types"; import type { McpServerStatus, McpTransport } from "../dict/mcp-dict"; import type { ExtensionProposalKind, ExtensionProposalStatus } from "../dict/extension-proposal-dict"; @@ -2051,6 +2051,11 @@ export type TypeAssignmentMatrixType< export type BotType = (typeof BotTypes)[number]["value"]; +/** + * What a bot is where it works: its kind, or a type it holds there. + */ +export type BotRole = BotKind | BotType; + export type BotPermission = (typeof BotPermissions)[number]["value"]; export type LoginFormObject = { diff --git a/src/utils/rbac/bot-can-target.ts b/src/utils/rbac/bot-can-target.ts index 519fff1..cd5df9f 100644 --- a/src/utils/rbac/bot-can-target.ts +++ b/src/utils/rbac/bot-can-target.ts @@ -1,9 +1,12 @@ +import type { BotKind } from "@/src/dict/bot-dict"; import { BotTypeTargetMatrix } from "@/src/dict/bot-types-dict"; import botTypesIn, { type BotTypeRow } from "./bot-types-in"; import typeAssignment from "./type-assignment"; type Params = { + actor_kind?: BotKind | "" | null; actor_bot_types?: BotTypeRow[] | null; + target_kind?: BotKind | "" | null; target_bot_types?: BotTypeRow[] | null; /** * Where they're working: types count there. @@ -16,9 +19,15 @@ type Params = { * where they're working, through `BotTypeTargetMatrix`: the coordinator * acts on anyone, a workspace admin on the workers there. */ -export default function botCanTarget({ actor_bot_types, target_bot_types, workspace_id }: Params): boolean { - const actor_types = botTypesIn({ bot_types: actor_bot_types, workspace_id }); - const target_types = botTypesIn({ bot_types: target_bot_types, workspace_id }); +export default function botCanTarget({ + actor_kind, + actor_bot_types, + target_kind, + target_bot_types, + workspace_id, +}: Params): boolean { + const actor_types = botTypesIn({ kind: actor_kind, bot_types: actor_bot_types, workspace_id }); + const target_types = botTypesIn({ kind: target_kind, bot_types: target_bot_types, workspace_id }); return typeAssignment({ actor_types, diff --git a/src/utils/rbac/bot-can.ts b/src/utils/rbac/bot-can.ts index 3a164d6..39fb6ef 100644 --- a/src/utils/rbac/bot-can.ts +++ b/src/utils/rbac/bot-can.ts @@ -1,8 +1,10 @@ import { BotTypePermissionMatrix } from "@/src/dict/bot-types-dict"; +import type { BotKind } from "@/src/dict/bot-dict"; import type { BotPermission } from "@/src/types"; import botTypesIn, { type BotTypeRow } from "./bot-types-in"; type Params = { + kind?: BotKind | "" | null; bot_types?: BotTypeRow[] | null; permission: BotPermission; /** @@ -13,12 +15,12 @@ type Params = { }; /** - * Whether a bot's types let it do `permission` where it's working + * Whether a bot's kind and types let it do `permission` where it's working * (`BotTypePermissionMatrix`). This is the bot's side only: the user the * run works for must still be allowed to do it. */ -export default function botCan({ bot_types, permission, workspace_id }: Params): boolean { - return botTypesIn({ bot_types, workspace_id }).some((bt) => +export default function botCan({ kind, bot_types, permission, workspace_id }: Params): boolean { + return botTypesIn({ kind, bot_types, workspace_id }).some((bt) => BotTypePermissionMatrix[bt].includes(permission), ); } diff --git a/src/utils/rbac/bot-type-assignment.ts b/src/utils/rbac/bot-type-assignment.ts index d5d9c52..ede9073 100644 --- a/src/utils/rbac/bot-type-assignment.ts +++ b/src/utils/rbac/bot-type-assignment.ts @@ -1,4 +1,5 @@ import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db"; +import type { BotKind } from "@/src/dict/bot-dict"; import { BotTypeAssignmentMatrix, BotTypeDeletionMatrix } from "@/src/dict/bot-types-dict"; import type { BotType } from "@/src/types"; import botTypesIn, { type BotTypeRow } from "./bot-types-in"; @@ -12,10 +13,13 @@ type Params = { */ actor_user_types?: Pick[] | null; /** - * A bot granting it from chat: its types. Only install-wide ones count, - * so a workspace admin never grants types. + * A bot granting it from chat: its kind and types. Only its kind counts + * (no workspace), so a workspace admin never grants types. */ - actor_bot_types?: BotTypeRow[] | null; + actor_bot?: { + kind?: BotKind | "" | null; + bot_types?: BotTypeRow[] | null; + } | null; target_bot_type: BotType; action: "assign" | "delete"; }; @@ -27,17 +31,17 @@ type Params = { */ export default function botTypeAssignment({ actor_user_types, - actor_bot_types, + actor_bot, target_bot_type, action, }: Params): boolean { - const actor_types = actor_bot_types - ? botTypesIn({ bot_types: actor_bot_types }) + const actor_types = actor_bot + ? botTypesIn({ kind: actor_bot.kind, bot_types: actor_bot.bot_types }) : (actor_user_types || []).map((ut) => ut.user_type).filter((ut): ut is string => Boolean(ut)); return typeAssignment({ actor_types, - pass_through: actor_bot_types ? false : isPassThroughUser({ user_types: actor_user_types as never }), + pass_through: actor_bot ? false : isPassThroughUser({ user_types: actor_user_types as never }), target_type: target_bot_type, action, assignment_matrix: BotTypeAssignmentMatrix, diff --git a/src/utils/rbac/bot-types-in.ts b/src/utils/rbac/bot-types-in.ts index ca57827..993db8b 100644 --- a/src/utils/rbac/bot-types-in.ts +++ b/src/utils/rbac/bot-types-in.ts @@ -1,14 +1,19 @@ import type { BUN_SQLITE_COBBLE_BOT_TYPES } from "@/db/types/db"; -import type { BotType } from "@/src/types"; +import type { BotKind } from "@/src/dict/bot-dict"; +import type { BotRole } from "@/src/types"; export type BotTypeRow = Pick & { /** - * Null (or empty) for an install-wide type. + * The workspace the type holds in. */ workspace_id?: number | "" | null; }; type Params = { + /** + * The bot's kind (`bots.kind`): a worker when missing. + */ + kind?: BotKind | "" | null; bot_types?: BotTypeRow[] | null; /** * Where the bot is working: its own chat (null) or a workspace. @@ -20,25 +25,25 @@ type Params = { * Strongest first, so a bot holding several is judged by the one that * reaches furthest. */ -const BotTypeRank: BotType[] = ["coordinator", "admin", "worker"]; +const BotRoleRank: BotRole[] = ["coordinator", "workspace_admin", "worker"]; /** - * The bot types that hold where the bot is working: install-wide rows - * everywhere, workspace rows only in their workspace. Strongest first, - * `worker` when nothing else holds. + * The roles a bot holds where it's working: the coordinator kind + * everywhere, a type only in its workspace. Strongest first, `worker` when + * nothing else holds. */ -export default function botTypesIn({ bot_types, workspace_id }: Params): BotType[] { - const held = new Set(); +export default function botTypesIn({ kind, bot_types, workspace_id }: Params): BotRole[] { + const held = new Set(); + + if (kind == "coordinator") held.add("coordinator"); for (const row of bot_types || []) { - if (!row.bot_type) continue; + if (!row.bot_type || !row.workspace_id || !workspace_id) continue; - const applies = !row.workspace_id || (workspace_id && row.workspace_id == Number(workspace_id)); - - if (applies) held.add(row.bot_type); + if (row.workspace_id == Number(workspace_id)) held.add(row.bot_type); } if (!held.size) held.add("worker"); - return BotTypeRank.filter((bt) => held.has(bt)); + return BotRoleRank.filter((role) => held.has(role)); } diff --git a/src/utils/rbac/bot-types.test.ts b/src/utils/rbac/bot-types.test.ts index 6e7fa74..3cabd99 100644 --- a/src/utils/rbac/bot-types.test.ts +++ b/src/utils/rbac/bot-types.test.ts @@ -4,22 +4,29 @@ import botCanTarget from "./bot-can-target"; import botTypeAssignment from "./bot-type-assignment"; import botTypesIn from "./bot-types-in"; -const coordinator = [{ bot_type: "coordinator" as const, workspace_id: null }]; -const admin_of_3 = [{ bot_type: "admin" as const, workspace_id: 3 }]; +const admin_of_3 = [{ bot_type: "workspace_admin" as const, workspace_id: 3 }]; describe("botTypesIn", () => { test("a bot with no rows is a worker", () => { expect(botTypesIn({ bot_types: [] })).toEqual(["worker"]); + expect(botTypesIn({ kind: "worker", bot_types: [] })).toEqual(["worker"]); }); test("a workspace type only holds in its workspace", () => { - expect(botTypesIn({ bot_types: admin_of_3, workspace_id: 3 })).toEqual(["admin"]); + expect(botTypesIn({ bot_types: admin_of_3, workspace_id: 3 })).toEqual(["workspace_admin"]); expect(botTypesIn({ bot_types: admin_of_3, workspace_id: 4 })).toEqual(["worker"]); expect(botTypesIn({ bot_types: admin_of_3 })).toEqual(["worker"]); }); - test("an install-wide type holds everywhere", () => { - expect(botTypesIn({ bot_types: coordinator, workspace_id: 9 })).toEqual(["coordinator"]); + test("a type row without a workspace holds nowhere", () => { + expect(botTypesIn({ bot_types: [{ bot_type: "workspace_admin", workspace_id: null }], workspace_id: 3 })).toEqual([ + "worker", + ]); + }); + + test("the coordinator kind holds everywhere", () => { + expect(botTypesIn({ kind: "coordinator", workspace_id: 9 })).toEqual(["coordinator"]); + expect(botTypesIn({ kind: "coordinator" })).toEqual(["coordinator"]); }); }); @@ -35,12 +42,12 @@ describe("botCan", () => { }); test("the coordinator can do everything", () => { - expect(botCan({ bot_types: coordinator, permission: "manage_extensions" })).toBe(true); - expect(botCan({ bot_types: coordinator, permission: "delegate_task", workspace_id: 3 })).toBe(true); + expect(botCan({ kind: "coordinator", permission: "manage_extensions" })).toBe(true); + expect(botCan({ kind: "coordinator", permission: "delegate_task", workspace_id: 3 })).toBe(true); }); test("a worker can't delegate", () => { - expect(botCan({ bot_types: [], permission: "delegate_task", workspace_id: 3 })).toBe(false); + expect(botCan({ kind: "worker", bot_types: [], permission: "delegate_task", workspace_id: 3 })).toBe(false); }); }); @@ -51,22 +58,22 @@ describe("botCanTarget", () => { test("a workspace admin doesn't direct another admin or the coordinator", () => { expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: admin_of_3, workspace_id: 3 })).toBe(false); - expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: coordinator, workspace_id: 3 })).toBe(false); + expect(botCanTarget({ actor_bot_types: admin_of_3, target_kind: "coordinator", workspace_id: 3 })).toBe(false); }); test("an admin elsewhere is a worker here", () => { expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: [], workspace_id: 4 })).toBe(false); expect(botCanTarget({ actor_bot_types: admin_of_3, - target_bot_types: [{ bot_type: "admin", workspace_id: 8 }], + target_bot_types: [{ bot_type: "workspace_admin", workspace_id: 8 }], workspace_id: 3, })).toBe(true); }); test("the coordinator directs workers and admins, never itself", () => { - expect(botCanTarget({ actor_bot_types: coordinator, target_bot_types: [], workspace_id: 3 })).toBe(true); - expect(botCanTarget({ actor_bot_types: coordinator, target_bot_types: admin_of_3, workspace_id: 3 })).toBe(true); - expect(botCanTarget({ actor_bot_types: coordinator, target_bot_types: coordinator })).toBe(false); + expect(botCanTarget({ actor_kind: "coordinator", target_bot_types: [], workspace_id: 3 })).toBe(true); + expect(botCanTarget({ actor_kind: "coordinator", target_bot_types: admin_of_3, workspace_id: 3 })).toBe(true); + expect(botCanTarget({ actor_kind: "coordinator", target_kind: "coordinator" })).toBe(false); }); test("a worker directs no one", () => { @@ -79,19 +86,14 @@ describe("botTypeAssignment", () => { test("super admins and admins grant and remove workspace admin", () => { for (const action of ["assign", "delete"] as const) { - expect(botTypeAssignment({ actor_user_types: user("super_admin"), target_bot_type: "admin", action })).toBe(true); - expect(botTypeAssignment({ actor_user_types: user("admin"), target_bot_type: "admin", action })).toBe(true); - expect(botTypeAssignment({ actor_user_types: user("member"), target_bot_type: "admin", action })).toBe(false); + expect(botTypeAssignment({ actor_user_types: user("super_admin"), target_bot_type: "workspace_admin", action })).toBe(true); + expect(botTypeAssignment({ actor_user_types: user("admin"), target_bot_type: "workspace_admin", action })).toBe(true); + expect(botTypeAssignment({ actor_user_types: user("member"), target_bot_type: "workspace_admin", action })).toBe(false); } }); - test("only the super admin passes for the coordinator type", () => { - expect(botTypeAssignment({ actor_user_types: user("admin"), target_bot_type: "coordinator", action: "assign" })).toBe(false); - }); - test("the coordinator bot grants workspace admin, a workspace admin doesn't", () => { - expect(botTypeAssignment({ actor_bot_types: coordinator, target_bot_type: "admin", action: "assign" })).toBe(true); - expect(botTypeAssignment({ actor_bot_types: admin_of_3, target_bot_type: "admin", action: "assign" })).toBe(false); - expect(botTypeAssignment({ actor_bot_types: coordinator, target_bot_type: "coordinator", action: "assign" })).toBe(false); + expect(botTypeAssignment({ actor_bot: { kind: "coordinator" }, target_bot_type: "workspace_admin", action: "assign" })).toBe(true); + expect(botTypeAssignment({ actor_bot: { kind: "worker", bot_types: admin_of_3 }, target_bot_type: "workspace_admin", action: "assign" })).toBe(false); }); });