diff --git a/backend/src/bots/owner.ts b/backend/src/bots/owner.ts new file mode 100644 index 000000000..3935be219 --- /dev/null +++ b/backend/src/bots/owner.ts @@ -0,0 +1,21 @@ +import type { BotRow } from "../db/schema"; + +/** + * Who may change a bot's setup (its standing rules, preset and routines): its + * creator. Every turn the bot takes runs that setup, a routine's unattended ones + * as their creator, so nobody else writes it. Others may still message it. + */ +export function botEditableBy(bot: Pick, userId: string | null): boolean { + return userId !== null && bot.createdBy === userId; +} + +/** A routine someone other than its bot's creator added (before botEditableBy + * held): it would run as them with rules they do not set, so it does not fire. */ +export class RoutineOwnerError extends Error { + readonly code = "routine_not_bot_owner" as const; + + constructor(botName: string) { + super(`Only the creator of ${botName} can give it routines, so this one, added by someone else, no longer runs.`); + this.name = "RoutineOwnerError"; + } +} diff --git a/backend/src/bots/repo.ts b/backend/src/bots/repo.ts index aaa32db52..a3ed24306 100644 --- a/backend/src/bots/repo.ts +++ b/backend/src/bots/repo.ts @@ -12,6 +12,8 @@ import { botHandoffs, bots, commands, gatewayApprovalRequests, providerEvents, r type ScheduleRecord = typeof schedules.$inferSelect; import { isMemoryScope, type MemoryScope } from "../memory/scope"; +import { memberDisplayNames } from "../team/member-names"; +import { botEditableBy } from "./owner"; const NAME_MAX = 60; const TITLE_MAX = 80; @@ -50,6 +52,10 @@ export interface BotView { readonly handoffs: number; /** Root run ids of those delegated threads, newest first. */ readonly handoffThreadIds: readonly string[]; + /** Whether the viewer may change the bot's setup: only its creator (botEditableBy). */ + readonly editable: boolean; + /** The creator as the member directory names them; null when it has no name for them. */ + readonly ownerName: string | null; } export interface BotInput { @@ -248,6 +254,17 @@ export async function updateBotRow( return row ?? null; } +/** Archive or restore a bot and nothing else: who may only stop a bot (an org + * admin) never rewrites its setup, whatever was read before their turn. */ +export async function setBotArchived(orgId: string, id: string, archived: boolean): Promise { + const [row] = await db + .update(bots) + .set({ archived, updatedAt: new Date() }) + .where(and(eq(bots.orgId, orgId), eq(bots.id, id))) + .returning(); + return row ?? null; +} + /** The bot (archived or not) whose name matches ignoring case, other than `exceptId`. */ export async function findBotByName(orgId: string, name: string, exceptId?: string): Promise { const rows = await db @@ -455,6 +472,7 @@ function toView( pending: number, routines: number, handoffs: { threadIds: readonly string[]; live: number }, + owner: { readonly editable: boolean; readonly name: string | null }, ): BotView { return { id: row.id, @@ -480,20 +498,23 @@ function toView( routines, handoffs: handoffs.threadIds.length, handoffThreadIds: [...handoffs.threadIds], + editable: owner.editable, + ownerName: owner.name, }; } /** Attach the derived fields for many bots with batched queries only. */ -export async function describeBots(orgId: string, rows: readonly BotRow[]): Promise { +export async function describeBots(orgId: string, rows: readonly BotRow[], viewerId: string | null): Promise { const botIds = rows.map((row) => row.id); const handoffs = await handoffThreads(orgId, botIds); const homeThreadIds = rows.flatMap((row) => (row.homeThreadId ? [row.homeThreadId] : [])); const allThreadIds = [...homeThreadIds, ...[...handoffs.values()].flat()]; - const [heads, pending, pendingNative, routines] = await Promise.all([ + const [heads, pending, pendingNative, routines, ownerNames] = await Promise.all([ threadHeads(orgId, allThreadIds), pendingApprovalCounts(orgId, allThreadIds), pendingNativeHumanInputCounts(orgId, allThreadIds), routineCounts(orgId, botIds), + memberDisplayNames(orgId, rows.flatMap((row) => (row.createdBy ? [row.createdBy] : []))), ]); return rows.map((row) => { const delegated = handoffs.get(row.id) ?? []; @@ -518,12 +539,13 @@ export async function describeBots(orgId: string, rows: readonly BotRow[]): Prom pendingForBot, routines.get(row.id) ?? 0, { threadIds: delegated, live }, + { editable: botEditableBy(row, viewerId), name: row.createdBy ? (ownerNames.get(row.createdBy) ?? null) : null }, ); }); } -export async function describeBot(orgId: string, row: BotRow): Promise { - const [view] = await describeBots(orgId, [row]); +export async function describeBot(orgId: string, row: BotRow, viewerId: string | null): Promise { + const [view] = await describeBots(orgId, [row], viewerId); if (!view) throw new Error("describeBots returned no view"); return view; } diff --git a/backend/src/bots/routes.ts b/backend/src/bots/routes.ts index 1b5cdbd28..c987e9e13 100644 --- a/backend/src/bots/routes.ts +++ b/backend/src/bots/routes.ts @@ -23,14 +23,19 @@ import { parseBotInput, rowToInput, attachRoutine, + setBotArchived, updateBotRow, } from "./repo"; +import { botEditableBy } from "./owner"; +import type { BotRow } from "../db/schema"; +import { asOrgAdmin } from "../provider-connections/repo"; +import { memberDisplayNames } from "../team/member-names"; import { botsEnabled } from "./rollout"; import { listFirings, markFired } from "../schedules/repo"; import { createScheduleForOrg, deleteScheduleForOrg, - fireScheduleForOrg, + runScheduleNow, ScheduleServiceError, updateScheduleForOrg, } from "../schedules/service"; @@ -75,6 +80,17 @@ async function readBody(c: { req: { json: () => Promise } }): Promise }; +/** + * A bot's setup is its creator's (botEditableBy): only they change it or give + * it routines, and a routine, like every automation, answers to its creator + * (the schedules service). Who may message it is the command door's call. + */ +async function ownerOnlyRefusal(row: BotRow, userId: string | null): Promise { + if (botEditableBy(row, userId)) return null; + const name = (row.createdBy ? (await memberDisplayNames(row.orgId, [row.createdBy])).get(row.createdBy) : undefined) ?? "the bot's creator"; + return Response.json({ error: "bot_owner_only", reason: `Only ${name} can change this bot's setup.` }, { status: 403 }); +} + /** Names are unique per org ignoring case; the message names the bot that holds it. */ function nameTaken(name: string) { return { error: "invalid_bot", field: "name", reason: `A bot named ${name} already exists. Choose another name.` }; @@ -97,7 +113,7 @@ async function checkPreset(orgId: string, input: BotInput, userId: string | null botsRoutes.get("/", async (c) => { const rows = await listBotRows(c.get("orgId")); - return c.json({ bots: await describeBots(c.get("orgId"), rows) }); + return c.json({ bots: await describeBots(c.get("orgId"), rows, c.get("userId") ?? null) }); }); botsRoutes.post("/", async (c) => { @@ -117,7 +133,7 @@ botsRoutes.post("/", async (c) => { if (holder) return c.json(nameTaken(holder.name), 409); try { const row = await createBotRow(c.get("orgId"), parsed.input, c.get("userId")); - return c.json({ bot: await describeBot(c.get("orgId"), row) }, 201); + return c.json({ bot: await describeBot(c.get("orgId"), row, c.get("userId") ?? null) }, 201); } catch (error) { if (isUniqueViolation(error)) return c.json(nameTaken(parsed.input.name), 409); throw error; @@ -129,7 +145,7 @@ botsRoutes.get("/:id", async (c) => { if (!id) return c.json({ error: "not_found" }, 404); const row = await getBotRow(c.get("orgId"), id); if (!row) return c.json({ error: "not_found" }, 404); - return c.json({ bot: await describeBot(c.get("orgId"), row) }); + return c.json({ bot: await describeBot(c.get("orgId"), row, c.get("userId") ?? null) }); }); botsRoutes.patch("/:id", async (c) => { @@ -148,6 +164,14 @@ botsRoutes.patch("/:id", async (c) => { if (archived !== undefined && typeof archived !== "boolean") { return c.json({ error: "invalid_bot", field: "archived", reason: "archived must be true or false" }, 400); } + // An org owner or admin may archive or restore any bot, which runs nothing as anyone. + const userId = c.get("userId") ?? null; + if (archived !== undefined && Object.keys(body).every((key) => key === "archived") && userId && !botEditableBy(row, userId)) { + const done = await asOrgAdmin(c.get("orgId"), userId, () => setBotArchived(c.get("orgId"), row.id, archived)); + if (done) return done.value ? c.json({ bot: await describeBot(c.get("orgId"), done.value, userId) }) : c.json({ error: "not_found" }, 404); + } + const notOwner = await ownerOnlyRefusal(row, userId); + if (notOwner) return notOwner; // The home thread already runs on the stored preset; a changed engine would // 400 every follow-up and changed skills/repos/scope would silently not apply. const changed = changedPresetFields(base, parsed.input); @@ -170,7 +194,7 @@ botsRoutes.patch("/:id", async (c) => { try { const updated = await updateBotRow(c.get("orgId"), row.id, parsed.input, archived); if (!updated) return c.json({ error: "not_found" }, 404); - return c.json({ bot: await describeBot(c.get("orgId"), updated) }); + return c.json({ bot: await describeBot(c.get("orgId"), updated, userId) }); } catch (error) { if (isUniqueViolation(error)) return c.json(nameTaken(parsed.input.name), 409); throw error; @@ -278,6 +302,8 @@ botsRoutes.post("/:id/routines", async (c) => { if (!id) return c.json({ error: "not_found" }, 404); const row = await getBotRow(orgId, id); if (!row) return c.json({ error: "not_found" }, 404); + const notOwner = await ownerOnlyRefusal(row, c.get("userId") ?? null); + if (notOwner) return notOwner; const body = await readBody(c); if (!body) return c.json({ error: "invalid_body" }, 400); const skillId = row.skillIds[0]; @@ -297,7 +323,7 @@ botsRoutes.post("/:id/routines", async (c) => { const created = await createScheduleForOrg({ orgId, userId: c.get("userId") }, draft); await attachRoutine(orgId, created.id, row.id); // Automations are created paused; a routine is on unless asked otherwise. - if (body.enabled !== false) await updateScheduleForOrg(orgId, created.id, { enabled: true }); + if (body.enabled !== false) await updateScheduleForOrg(orgId, created.id, { enabled: true }, c.get("userId") ?? null); const routine = await getBotRoutine(orgId, row.id, created.id); if (!routine) return c.json({ error: "routine_not_attached" }, 500); return c.json({ routine: routineView(routine) }, 201); @@ -318,7 +344,7 @@ botsRoutes.patch("/:id/routines/:routineId", async (c) => { const patch: Record = {}; for (const field of ROUTINE_PATCH_FIELDS) if (body[field] !== undefined) patch[field] = body[field]; try { - await updateScheduleForOrg(orgId, routine.id, patch); + await updateScheduleForOrg(orgId, routine.id, patch, c.get("userId") ?? null); const updated = await getBotRoutine(orgId, id, routineId); return c.json({ routine: updated ? routineView(updated) : null }); } catch (error) { @@ -334,7 +360,7 @@ botsRoutes.delete("/:id/routines/:routineId", async (c) => { const routine = await getBotRoutine(orgId, id, routineId); if (!routine) return c.json({ error: "not_found" }, 404); try { - await deleteScheduleForOrg(orgId, routine.id); + await deleteScheduleForOrg(orgId, routine.id, c.get("userId") ?? null); return c.body(null, 204); } catch (error) { return scheduleError(error) ?? Promise.reject(error); @@ -351,7 +377,7 @@ botsRoutes.post("/:id/routines/:routineId/run-now", async (c) => { const routine = await getBotRoutine(orgId, id, routineId); if (!routine) return c.json({ error: "not_found" }, 404); try { - const runId = await fireScheduleForOrg(routine, "manual"); + const runId = await runScheduleNow(routine, c.get("userId") ?? null); // A test run is a firing too: the row's "last run" must show it, not wait for cron. await markFired(routine.id, new Date()); const fired = (await getBotRoutine(orgId, id, routineId)) ?? routine; diff --git a/backend/src/knowledge/gateway/automation-tools.ts b/backend/src/knowledge/gateway/automation-tools.ts index ddcadcd16..5507a7589 100644 --- a/backend/src/knowledge/gateway/automation-tools.ts +++ b/backend/src/knowledge/gateway/automation-tools.ts @@ -210,7 +210,7 @@ async function updateAutomation( delete patch.confirmEnable; delete patch.id; try { - const schedule = await updateScheduleForOrg(claims.orgId, id, patch); + const schedule = await updateScheduleForOrg(claims.orgId, id, patch, claims.userId || null); return textResult(`Updated automation ${schedule.name} (${schedule.id}).`, { automation: automationSummary(schedule), }); @@ -244,10 +244,10 @@ async function runAutomationNow( // Keep the standalone tool gateway importable without loading the backend's // command worker and authentication root. The execution graph is needed only // for this explicit mutating call. - const { fireScheduleForOrg } = await import("../../schedules/service"); + const { runScheduleNow } = await import("../../schedules/service"); let runId: string; try { - runId = await fireScheduleForOrg(schedule, "manual"); + runId = await runScheduleNow(schedule, claims.userId || null); } catch (error) { if (error instanceof ScheduleServiceError) return serviceError(error); throw error; @@ -280,7 +280,7 @@ async function deleteAutomation( const id = automationId(args); if (!id) return errorResult("automation_delete requires an automation id."); try { - await deleteScheduleForOrg(claims.orgId, id); + await deleteScheduleForOrg(claims.orgId, id, claims.userId || null); return textResult(`Deleted automation ${id}.`, { automation_id: id, deleted: true }); } catch (error) { if (error instanceof ScheduleServiceError) return serviceError(error); diff --git a/backend/src/middleware/org.ts b/backend/src/middleware/org.ts index 305cce7f4..bcf70c402 100644 --- a/backend/src/middleware/org.ts +++ b/backend/src/middleware/org.ts @@ -4,6 +4,7 @@ import { resolveSession, type IdentitySession } from "../auth/session"; import { db } from "../db/client"; import { member } from "../db/schema"; import { allowDevOrg } from "../env"; +import { isOrgAdmin } from "../provider-connections/repo"; import type { AppEnv } from "../http"; import { parsePreviewViewPath } from "../runs/preview-capability"; import { firstOrgForUser, getDevContext } from "../seed"; @@ -147,14 +148,6 @@ export const orgAdminScope = createMiddleware(async (c, next) => { const orgId = c.get("orgId"); const userId = c.get("userId"); if (!orgId || !userId) return c.json({ error: "forbidden" }, 403); - - const [membership] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.organizationId, orgId), eq(member.userId, userId))) - .limit(1); - if (membership?.role !== "owner" && membership?.role !== "admin") { - return c.json({ error: "organization_admin_required" }, 403); - } + if (!(await isOrgAdmin(orgId, userId))) return c.json({ error: "organization_admin_required" }, 403); return next(); }); diff --git a/backend/src/provider-connections/repo.ts b/backend/src/provider-connections/repo.ts index a8583d174..b0063005b 100644 --- a/backend/src/provider-connections/repo.ts +++ b/backend/src/provider-connections/repo.ts @@ -1,6 +1,8 @@ import { and, asc, eq, sql } from "drizzle-orm"; -import { db } from "../db/client"; +import { db, type Executor } from "../db/client"; +import { withOrgLock } from "../org-lock"; import { + member, providerConnections, providerConnectionThreads, type ProviderConnectionAuthMethod, @@ -11,6 +13,23 @@ import { export type ProviderConnectionRecord = typeof providerConnections.$inferSelect; +/** Whether the person is an owner or admin of the organization (exact roles). */ +export async function isOrgAdmin(orgId: string, userId: string, exec: Executor = db): Promise { + const [row] = await exec + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, orgId), eq(member.userId, userId))) + .limit(1); + return row?.role === "owner" || row?.role === "admin"; +} + +/** `act` for an org owner or admin, authorized in the organisation's turn + * (org-lock.ts) as role changes are, so one demoted meanwhile gets nothing + * done; null for anyone else. */ +export function asOrgAdmin(orgId: string, userId: string, act: () => Promise): Promise<{ readonly value: T } | null> { + return withOrgLock(orgId, async () => ((await isOrgAdmin(orgId, userId)) ? { value: await act() } : null)); +} + export interface ProviderConnectionScope { orgId: string; userId: string; diff --git a/backend/src/schedules/fire.ts b/backend/src/schedules/fire.ts index e395949e6..467fc2e33 100644 --- a/backend/src/schedules/fire.ts +++ b/backend/src/schedules/fire.ts @@ -23,7 +23,8 @@ import { resolveRunIntake, } from "../resources/run-intake"; import { resolveExecutableSkillPin } from "../skills/pins"; -import { botFiringTarget } from "../bots/repo"; +import { botFiringTarget, getBotRow } from "../bots/repo"; +import { botEditableBy, RoutineOwnerError } from "../bots/owner"; import { BotsDisabledError, botsEnabled } from "../bots/rollout"; import { acceptExistingThreadFollowup } from "../runs/thread-followups"; import type { MemoryScope } from "../memory/scope"; @@ -100,6 +101,10 @@ export async function fireScheduleWithOutcome( const target = schedule.botId ? await botFiringTarget(schedule.orgId, schedule.botId) : null; + // Judged on the bot itself, archived or not: archiving must not turn a routine + // someone else added into a plain automation that fires. + const bot = schedule.botId ? (target?.bot ?? (await getBotRow(schedule.orgId, schedule.botId))) : null; + if (bot && !botEditableBy(bot, schedule.userId)) throw new RoutineOwnerError(bot.name); const home = target?.head ?? null; const threadId = home ? home.threadId : runId; const memoryScope: MemoryScope = target ? target.bot.memoryScope : "org"; diff --git a/backend/src/schedules/routes.ts b/backend/src/schedules/routes.ts index 216dea613..c150b0326 100644 --- a/backend/src/schedules/routes.ts +++ b/backend/src/schedules/routes.ts @@ -9,7 +9,7 @@ import { import { createScheduleForOrg, deleteScheduleForOrg, - fireScheduleForOrg, + runScheduleNow, ScheduleServiceError, updateScheduleForOrg, } from "./service"; @@ -59,7 +59,7 @@ schedulesRoutes.patch("/:id", runCreateBodyLimit, async (c) => { } try { - return c.json(await updateScheduleForOrg(c.get("orgId"), id, body)); + return c.json(await updateScheduleForOrg(c.get("orgId"), id, body, c.get("userId") ?? null)); } catch (error) { if (error instanceof ScheduleServiceError) return c.json(error.body, error.status); throw error; @@ -71,7 +71,7 @@ schedulesRoutes.post("/:id/run-now", async (c) => { const schedule = await getScheduleForOrg(c.get("orgId"), c.req.param("id")); if (!schedule) return c.json({ error: "schedule not found" }, 404); try { - const runId = await fireScheduleForOrg(schedule, "manual"); + const runId = await runScheduleNow(schedule, c.get("userId") ?? null); return c.json({ run_id: runId }, 201); } catch (error) { if (error instanceof ScheduleServiceError) return c.json(error.body, error.status); @@ -90,7 +90,7 @@ schedulesRoutes.get("/:id/history", async (c) => { // remain in the durable run log. schedulesRoutes.delete("/:id", async (c) => { try { - await deleteScheduleForOrg(c.get("orgId"), c.req.param("id")); + await deleteScheduleForOrg(c.get("orgId"), c.req.param("id"), c.get("userId") ?? null); return c.body(null, 204); } catch (error) { if (error instanceof ScheduleServiceError) return c.json(error.body, error.status); diff --git a/backend/src/schedules/service.ts b/backend/src/schedules/service.ts index 689715eff..d55d2cc5b 100644 --- a/backend/src/schedules/service.ts +++ b/backend/src/schedules/service.ts @@ -30,6 +30,9 @@ import { RunAdmissionClosedError } from "../commands"; import { SpendAllowanceExceededError } from "../runs/spend"; import { SandboxMinutesExceededError } from "../runs/sandbox-minutes"; import { BotsDisabledError } from "../bots/rollout"; +import { RoutineOwnerError } from "../bots/owner"; +import { asOrgAdmin } from "../provider-connections/repo"; +import { memberDisplayNames } from "../team/member-names"; import { assertRunPromptLimit, RunPromptTooLargeError, @@ -46,6 +49,45 @@ import { type AutomationChange = Extract; +/** + * Who may act on an automation, a bot's routines included. Every firing runs as + * its creator: their keys, allowance, sandbox minutes and personal connections, + * where reads need no approval. So only the creator changes what it does, + * switches it on or runs it now. An org owner or admin may also pause or delete + * it, which runs nothing as anyone; that is authorized in the organisation's + * turn, as role changes are (org-lock.ts), so an admin demoted meanwhile gets + * nothing done. One with no creator runs as nobody, so any member may act on + * it. The service asks it around the change itself (update, delete, run now), + * so every door gets it: the automations API, the bot page and an agent's + * automation tools, where the agent acts as its run's owner. + */ +async function asAutomationActor( + orgId: string, + scheduleId: string, + actorId: string | null, + intent: "change" | "stop", + act: () => Promise, +): Promise { + const schedule = await getScheduleForOrg(orgId, scheduleId); + if (!schedule) throw new ScheduleServiceError(404, { error: "schedule not found" }); + const creator = schedule.userId; + if (creator === null || creator === actorId) return act(); + if (intent === "stop" && actorId !== null) { + const stopped = await asOrgAdmin(orgId, actorId, act); + if (stopped) return stopped.value; + } + const name = (await memberDisplayNames(orgId, [creator])).get(creator) ?? "the person who created it"; + throw new ScheduleServiceError(403, { + error: "automation_creator_only", + message: `This automation runs as ${name}, so only they can change it or run it now. An admin can pause or delete it.`, + }); +} + +/** A change that only pauses: the one an admin may make to someone else's automation. */ +function pausesOnly(body: Record): boolean { + return body.enabled === false && Object.keys(body).every((key) => key === "enabled"); +} + function publishAutomationChange(orgId: string, change: AutomationChange): void { publishOrgChange(orgId, change); } @@ -318,7 +360,16 @@ export async function createScheduleForOrg( return schedule; } -export async function updateScheduleForOrg( +export function updateScheduleForOrg( + orgId: string, + id: string, + body: Record, + actorId: string | null, +): Promise { + return asAutomationActor(orgId, id, actorId, pausesOnly(body) ? "stop" : "change", () => updateSchedulePatch(orgId, id, body)); +} + +async function updateSchedulePatch( orgId: string, id: string, body: Record, @@ -459,18 +510,34 @@ export async function updateScheduleForOrg( return updated; } -export async function deleteScheduleForOrg(orgId: string, id: string): Promise { - const deleted = await deleteSchedule(orgId, id); - if (!deleted) throw new ScheduleServiceError(404, { error: "schedule not found" }); - publishAutomationChange(orgId, { - type: "automation", - action: "deleted", - automationId: id, +export function deleteScheduleForOrg(orgId: string, id: string, actorId: string | null): Promise { + return asAutomationActor(orgId, id, actorId, "stop", async () => { + const deleted = await deleteSchedule(orgId, id); + if (!deleted) throw new ScheduleServiceError(404, { error: "schedule not found" }); + publishAutomationChange(orgId, { + type: "automation", + action: "deleted", + automationId: id, + }); + await removeFromContextIndex(orgId, `automation:${id}`); }); - await removeFromContextIndex(orgId, `automation:${id}`); } -export async function fireScheduleForOrg( +/** Run now: a manual firing, which runs as the creator like every firing. */ +export function runScheduleNow(schedule: ScheduleRecord, actorId: string | null): Promise { + return asAutomationActor(schedule.orgId, schedule.id, actorId, "change", () => fireSchedule(schedule, "manual")); +} + +/** The scheduler's firing of a due occurrence. A person's firing is runScheduleNow. */ +export function fireScheduleForOrg( + schedule: ScheduleRecord, + trigger: Extract, + occurrence: Date = new Date(), +): Promise { + return fireSchedule(schedule, trigger, occurrence); +} + +async function fireSchedule( schedule: ScheduleRecord, trigger: ScheduleTrigger, occurrence: Date = new Date(), @@ -485,6 +552,7 @@ export async function fireScheduleForOrg( if (error instanceof BotsDisabledError) { throw new ScheduleServiceError(409, { error: error.code, retryable: false }); } + if (error instanceof RoutineOwnerError) throw new ScheduleServiceError(403, { error: error.code, message: error.message }); if (error instanceof SkillPinIntegrityError) { throw new ScheduleServiceError(error.code === "invalid_skill_pin" ? 400 : 409, { error: error.code, diff --git a/backend/src/team/member-names.ts b/backend/src/team/member-names.ts new file mode 100644 index 000000000..ed033a74f --- /dev/null +++ b/backend/src/team/member-names.ts @@ -0,0 +1,28 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { member, user } from "../db/auth-schema"; +import { db } from "../db/client"; + +/** + * How the organization's member directory names these people (better-auth's + * member and user rows, the ones list-members serves the team card): their name, + * else their email, as the team card shows them. Someone who is not a member of + * the organization has no entry, so a caller shows no name rather than an id. + */ +export async function memberDisplayNames( + orgId: string, + userIds: readonly string[], +): Promise> { + const ids = [...new Set(userIds)]; + if (ids.length === 0) return new Map(); + const rows = await db + .select({ id: user.id, name: user.name, email: user.email }) + .from(member) + .innerJoin(user, eq(user.id, member.userId)) + .where(and(eq(member.organizationId, orgId), inArray(member.userId, ids))); + return new Map( + rows.flatMap((row): [string, string][] => { + const name = row.name?.trim() || row.email?.trim(); + return name ? [[row.id, name]] : []; + }), + ); +} diff --git a/backend/test/automation-creator.test.ts b/backend/test/automation-creator.test.ts new file mode 100644 index 000000000..7d12a2674 --- /dev/null +++ b/backend/test/automation-creator.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, test } from "bun:test"; +import { and, eq } from "drizzle-orm"; +import { member } from "../src/db/auth-schema"; +import { db } from "../src/db/client"; +import { runs, schedules } from "../src/db/schema"; +import { mintApprovalCapability } from "../src/knowledge/gateway/approval-capability"; +import { executeAutomationToolLocal } from "../src/knowledge/gateway/automation-tools"; +import { withOrgLock } from "../src/org-lock"; +import { asOrgAdmin } from "../src/provider-connections/repo"; +import { fireScheduleForOrg } from "../src/schedules/service"; +import { createOrgSession, fetchApi, joinOrg, json, userIdOf, type OrgSession } from "./helpers"; + +// An automation runs as its creator: their keys, allowance, sandbox minutes and +// personal connections. So only the creator changes it or runs it now, from +// every door (the automations API, a bot's routines, an agent's automation +// tools, where the agent acts as its run's owner); an org admin may pause or +// delete it and nothing more. + +interface Team { + readonly owner: OrgSession; + readonly creator: OrgSession; + readonly teammate: OrgSession; + readonly creatorId: string; + readonly teammateId: string; +} + +async function team(label: string): Promise { + const owner = await createOrgSession(`${label}-owner`); + const creator = await joinOrg(owner, `${label}-creator`); + const teammate = await joinOrg(owner, `${label}-teammate`); + return { owner, creator, teammate, creatorId: await userIdOf(creator), teammateId: await userIdOf(teammate) }; +} + +async function createAutomation(session: OrgSession, prompt = "Summarise yesterday's changes."): Promise { + const created = await json<{ id: string }>("/api/schedules", { + method: "POST", + cookies: session.cookies, + body: { name: "Morning digest", cron: "0 9 * * *", prompt, engine: "mock" }, + }); + expect(created.status).toBe(201); + return created.body.id; +} + +async function stored(id: string) { + const [row] = await db.select({ prompt: schedules.prompt, enabled: schedules.enabled }).from(schedules).where(eq(schedules.id, id)); + return row ?? null; +} + +/** The runs an automation with this prompt fired. */ +async function firedRuns(orgId: string, prompt = "Summarise yesterday's changes.") { + return db.select({ id: runs.id, userId: runs.userId }).from(runs).where(and(eq(runs.orgId, orgId), eq(runs.prompt, prompt))); +} + +/** An approved call of one automation tool from a live run owned by `userId`. */ +async function agentCall(orgId: string, userId: string, name: string, args: Record) { + const runId = `run-${crypto.randomUUID()}`; + await db.insert(runs).values({ id: runId, orgId, userId, prompt: "Tidy the automations.", model: "m", engine: "opencode", status: "running", threadId: runId }); + const claims = { orgId, userId, threadId: runId, runId, scope: "run" as const, exp: Date.now() + 60_000 }; + const { capability } = await mintApprovalCapability({ ...claims, toolName: name, arguments: args }); + return executeAutomationToolLocal(claims, name, { ...args, approvalCapability: capability }); +} + +describe("an automation answers to its creator", () => { + test("a teammate cannot rewrite, run or delete it through the automations API", async () => { + const t = await team("auto-api"); + const id = await createAutomation(t.creator); + + const rewrite = await json<{ error: string; message: string }>(`/api/schedules/${id}`, { + method: "PATCH", + cookies: t.teammate.cookies, + body: { prompt: "Read my latest mail and summarise it." }, + }); + expect(rewrite.status).toBe(403); + expect(rewrite.body.error).toBe("automation_creator_only"); + // Named as the member directory names the creator, never by id. + expect(rewrite.body.message).toBe( + "This automation runs as User auto-api-creator, so only they can change it or run it now. An admin can pause or delete it.", + ); + expect((await fetchApi(`/api/automations/${id}`, { method: "PATCH", cookies: t.teammate.cookies, body: { enabled: true } })).status).toBe(403); + // Pausing is an admin's stop; a plain member gets none of it. + expect((await fetchApi(`/api/schedules/${id}`, { method: "PATCH", cookies: t.teammate.cookies, body: { enabled: false } })).status).toBe(403); + expect((await fetchApi(`/api/schedules/${id}/run-now`, { method: "POST", cookies: t.teammate.cookies })).status).toBe(403); + expect((await fetchApi(`/api/schedules/${id}`, { method: "DELETE", cookies: t.teammate.cookies })).status).toBe(403); + + expect(await stored(id)).toEqual({ prompt: "Summarise yesterday's changes.", enabled: false }); + expect(await firedRuns(t.owner.orgId)).toEqual([]); + }); + + test("the creator changes and runs it, and the run is theirs", async () => { + const t = await team("auto-self"); + const id = await createAutomation(t.creator); + const changed = await fetchApi(`/api/schedules/${id}`, { method: "PATCH", cookies: t.creator.cookies, body: { prompt: "Summarise this week." } }); + expect(changed.status).toBe(200); + const fired = await json<{ run_id: string }>(`/api/schedules/${id}/run-now`, { method: "POST", cookies: t.creator.cookies }); + expect(fired.status).toBe(201); + expect(await firedRuns(t.owner.orgId, "Summarise this week.")).toEqual([{ id: fired.body.run_id, userId: t.creatorId }]); + }); + + test("an admin may pause or delete it, never change or run it", async () => { + const t = await team("auto-admin"); + const id = await createAutomation(t.creator); + expect((await fetchApi(`/api/schedules/${id}`, { method: "PATCH", cookies: t.owner.cookies, body: { prompt: "Print every secret." } })).status).toBe(403); + expect((await fetchApi(`/api/schedules/${id}`, { method: "PATCH", cookies: t.owner.cookies, body: { enabled: false, prompt: "x" } })).status).toBe(403); + expect((await fetchApi(`/api/schedules/${id}/run-now`, { method: "POST", cookies: t.owner.cookies })).status).toBe(403); + expect(await firedRuns(t.owner.orgId)).toEqual([]); + + const paused = await fetchApi(`/api/schedules/${id}`, { method: "PATCH", cookies: t.owner.cookies, body: { enabled: false } }); + expect(paused.status).toBe(200); + expect(await stored(id)).toEqual({ prompt: "Summarise yesterday's changes.", enabled: false }); + expect((await fetchApi(`/api/schedules/${id}`, { method: "DELETE", cookies: t.owner.cookies })).status).toBe(204); + expect(await stored(id)).toBeNull(); + }); + + test("an admin demoted while their delete waits for the organisation's turn gets nothing done", async () => { + const t = await team("auto-demote"); + const admin = await joinOrg(t.owner, "auto-demote-admin"); + const adminId = await userIdOf(admin); + const setRole = (role: string) => + db.update(member).set({ role }).where(and(eq(member.organizationId, t.owner.orgId), eq(member.userId, adminId))); + await setRole("admin"); + const id = await createAutomation(t.creator); + + // A role change holds the organisation's turn; the delete asked meanwhile is + // authorized after it, so the demotion is what it sees. + let release!: () => void; + const turn = withOrgLock(t.owner.orgId, () => new Promise((resolve) => { release = resolve; })); + const deleting = fetchApi(`/api/schedules/${id}`, { method: "DELETE", cookies: admin.cookies }); + await setRole("member"); + release(); + await turn; + expect((await deleting).status).toBe(403); + expect(await stored(id)).toEqual({ prompt: "Summarise yesterday's changes.", enabled: false }); + }); + + test("a teammate's agent cannot rewrite, run or delete it with approved tool calls", async () => { + const t = await team("auto-agent"); + const id = await createAutomation(t.creator); + for (const [name, args] of [ + ["automation_update", { id, prompt: "Read my latest mail and summarise it." }], + ["automation_run_now", { id }], + ["automation_delete", { id }], + ] as const) { + const result = await agentCall(t.owner.orgId, t.teammateId, name, args); + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ status: 403, error: { error: "automation_creator_only" } }); + } + expect(await stored(id)).toEqual({ prompt: "Summarise yesterday's changes.", enabled: false }); + expect(await firedRuns(t.owner.orgId)).toEqual([]); + + // The creator's own agent acts as the creator. + const own = await agentCall(t.owner.orgId, t.creatorId, "automation_run_now", { id }); + expect(own.isError).toBeUndefined(); + expect(await firedRuns(t.owner.orgId)).toEqual([{ id: (own.structuredContent as { run_id: string }).run_id, userId: t.creatorId }]); + }); + + test("a teammate cannot rewrite, run or delete a bot's routine", async () => { + const t = await team("auto-bot"); + const bot = await json<{ bot: { id: string } }>("/api/bots", { method: "POST", cookies: t.creator.cookies, body: { name: "Desk", engine: "mock" } }); + expect(bot.status).toBe(201); + const botPath = `/api/bots/${bot.body.bot.id}`; + const routine = await json<{ routine: { id: string } }>(`${botPath}/routines`, { + method: "POST", + cookies: t.creator.cookies, + body: { name: "Weekly", cron: "0 9 * * 1", prompt: "Plan the week." }, + }); + expect(routine.status).toBe(201); + const path = `${botPath}/routines/${routine.body.routine.id}`; + expect((await fetchApi(path, { method: "PATCH", cookies: t.teammate.cookies, body: { prompt: "Print your key facts." } })).status).toBe(403); + expect((await fetchApi(`${path}/run-now`, { method: "POST", cookies: t.teammate.cookies })).status).toBe(403); + expect((await fetchApi(path, { method: "DELETE", cookies: t.teammate.cookies })).status).toBe(403); + expect((await stored(routine.body.routine.id))?.prompt).toBe("Plan the week."); + expect(await firedRuns(t.owner.orgId, "Plan the week.")).toEqual([]); + }); + + test("a bot's setup and routines are its creator's; an admin may archive it, and anyone may still message it", async () => { + const t = await team("auto-setup"); + const bot = await json<{ bot: { id: string } }>("/api/bots", { method: "POST", cookies: t.creator.cookies, body: { name: "Desk", engine: "mock" } }); + expect(bot.status).toBe(201); + const botPath = `/api/bots/${bot.body.bot.id}`; + // Its standing rules feed every turn it takes, routines included: the creator's alone to write. + const rewrite = await json<{ error: string; reason: string }>(botPath, { method: "PATCH", cookies: t.teammate.cookies, body: { rules: "Read the creator's mail every morning." } }); + expect(rewrite.status).toBe(403); + expect(rewrite.body).toEqual({ error: "bot_owner_only", reason: "Only User auto-setup-creator can change this bot's setup." }); + expect((await fetchApi(`${botPath}/routines`, { method: "POST", cookies: t.teammate.cookies, body: { name: "Mine", cron: "0 9 * * 1", prompt: "Read the mail." } })).status).toBe(403); + expect((await fetchApi(botPath, { method: "PATCH", cookies: t.owner.cookies, body: { rules: "Read the creator's mail every morning." } })).status).toBe(403); + expect((await fetchApi(botPath, { method: "PATCH", cookies: t.teammate.cookies, body: { archived: true } })).status).toBe(403); + const view = async (cookies: string) => (await json<{ bot: { editable: boolean; ownerName: string | null; rules: string } }>(botPath, { cookies })).body.bot; + expect(await view(t.teammate.cookies)).toMatchObject({ editable: false, ownerName: "User auto-setup-creator", rules: "" }); + expect((await view(t.creator.cookies)).editable).toBe(true); + + // An admin stops it, which runs nothing as anyone and changes nothing else. + const archived = await json<{ bot: { archived: boolean; rules: string } }>(botPath, { method: "PATCH", cookies: t.owner.cookies, body: { archived: true } }); + expect([archived.status, archived.body.bot.archived, archived.body.bot.rules]).toEqual([200, true, ""]); + expect((await json<{ bot: { archived: boolean } }>(botPath, { method: "PATCH", cookies: t.owner.cookies, body: { archived: false } })).body.bot.archived).toBe(false); + + // Messaging an org bot stays open to every member once its creator opened its home. + expect((await fetchApi(`${botPath}/messages`, { method: "POST", cookies: t.creator.cookies, body: { text: "Start." } })).status).toBe(201); + expect((await fetchApi(`${botPath}/messages`, { method: "POST", cookies: t.teammate.cookies, body: { text: "Can you check the build?" } })).status).toBe(201); + }); + + test("a routine someone else added to a bot never fires", async () => { + const t = await team("auto-legacy"); + const bot = await json<{ bot: { id: string } }>("/api/bots", { method: "POST", cookies: t.creator.cookies, body: { name: "Desk", engine: "mock" } }); + const [legacy] = await db.insert(schedules).values({ + orgId: t.owner.orgId, userId: t.teammateId, name: "Mine", cron: "* * * * *", prompt: "Read the mail.", engine: "mock", model: "m", + botId: bot.body.bot.id, enabled: true, + }).returning(); + const refused = await json<{ error: string; message: string }>(`/api/schedules/${legacy!.id}/run-now`, { method: "POST", cookies: t.teammate.cookies }); + expect(refused.status).toBe(403); + expect(refused.body).toEqual({ error: "routine_not_bot_owner", message: "Only the creator of Desk can give it routines, so this one, added by someone else, no longer runs." }); + expect(await fireScheduleForOrg(legacy!, "cron").then(() => "fired", (error: unknown) => (error as { status?: number }).status)).toBe(403); + // Archiving the bot does not turn it into a plain automation that fires. + expect((await fetchApi(`/api/bots/${bot.body.bot.id}`, { method: "PATCH", cookies: t.creator.cookies, body: { archived: true } })).status).toBe(200); + expect((await fetchApi(`/api/schedules/${legacy!.id}/run-now`, { method: "POST", cookies: t.teammate.cookies })).status).toBe(403); + expect(await fireScheduleForOrg(legacy!, "cron").then(() => "fired", (error: unknown) => (error as { status?: number }).status)).toBe(403); + expect(await firedRuns(t.owner.orgId, "Read the mail.")).toEqual([]); + }); + + test("an admin is authorized in the organisation's turn, so a demotion that took the turn first is seen", async () => { + const t = await team("auto-turn"); + const admin = await joinOrg(t.owner, "auto-turn-admin"); + const adminId = await userIdOf(admin); + const setRole = (role: string) => + db.update(member).set({ role }).where(and(eq(member.organizationId, t.owner.orgId), eq(member.userId, adminId))); + await setRole("admin"); + let release!: () => void; + const turn = withOrgLock(t.owner.orgId, () => new Promise((resolve) => { release = resolve; })); + const attempt = asOrgAdmin(t.owner.orgId, adminId, async () => "stopped"); + await setRole("member"); + release(); + await turn; + expect(await attempt).toBeNull(); + }); +}); diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index 2adf3a16c..0d1f619d5 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -5,7 +5,10 @@ * the dev org/user/member are present by the time any test executes. No demo * skills or knowledge are seeded — those surfaces start empty. */ +import { eq } from "drizzle-orm"; import server from "../src/index"; +import { member, user } from "../src/db/auth-schema"; +import { db } from "../src/db/client"; // better-auth resolves same-origin against this base; a trusted Origin header is // required on its mutating routes (CSRF guard), so default it on every call. @@ -213,6 +216,23 @@ export async function createOrgSession(label = "org"): Promise { return { jar, cookies: jar.header(), orgId, email }; } +export async function userIdOf(session: OrgSession): Promise { + const [row] = await db.select({ id: user.id }).from(user).where(eq(user.email, session.email)); + if (!row) throw new Error(`no user for ${session.email}`); + return row.id; +} + +/** A second person inside `org`: their own account, switched into that organisation. */ +export async function joinOrg(org: OrgSession, label: string): Promise { + const other = await createOrgSession(label); + await db.insert(member).values({ id: uid("member"), organizationId: org.orgId, userId: await userIdOf(other), role: "member", createdAt: new Date() }); + const setActive = await fetchApi("/api/auth/organization/set-active", { method: "POST", cookies: other.cookies, body: { organizationId: org.orgId } }); + if (setActive.status !== 200) throw new Error(`set-active failed: ${setActive.status}`); + other.jar.absorb(setActive); + other.cookies = other.jar.header(); + return other; +} + /** Poll `fn` until it returns truthy or the timeout elapses. The default sits * under the suite's 30 s per-test budget: a loaded runner needs more than 8 s * for a spawn-and-settle wait, while a real hang still fails here first. */ diff --git a/docs-site/docs/api/automations.mdx b/docs-site/docs/api/automations.mdx index f902efc03..692d832a8 100644 --- a/docs-site/docs/api/automations.mdx +++ b/docs-site/docs/api/automations.mdx @@ -17,14 +17,22 @@ The router is mounted at both `/api/automations` (primary) and `/api/schedules` automation is always created disabled, so nothing auto-fires until it is explicitly enabled. +Every firing runs as the member who created the automation: their keys, spend +allowance, sandbox minutes and personal connections. So only the creator may +edit it, enable it or run it now; an org owner or admin may also pause it +(`PATCH {"enabled": false}`) or delete it. Anyone else gets `403 +automation_creator_only`, whose `message` names the creator. The same rule holds +for a bot's routines and for an agent's automation tools, where the agent acts +as its run's owner. + | Method | Path | Description | Notes | | --- | --- | --- | --- | | GET | `/api/automations` | List the org's automations, newest first. | Returns `{automations, schedules}` (the same array under both keys for resumed clients). | | POST | `/api/automations` | Create an automation (created disabled). | JSON body validated by the service. `201` created object; `400` on invalid JSON. | -| PATCH | `/api/automations/:id` | Update an automation (enable/disable or edit). | Partial JSON body. `200` updated object. | -| POST | `/api/automations/:id/run-now` | Fire immediately: create a real run and record a manual firing. | `201 {run_id}`. `404 schedule not found`. | +| PATCH | `/api/automations/:id` | Update an automation (enable/disable or edit). | Partial JSON body. `200` updated object. `403 automation_creator_only` for anyone but the creator (an admin may pause). | +| POST | `/api/automations/:id/run-now` | Fire immediately: create a real run and record a manual firing. | `201 {run_id}`. `403 automation_creator_only` for anyone but the creator. `404 schedule not found`. | | GET | `/api/automations/:id/history` | Firing history, newest first. | `{firings}`. `404 schedule not found`. | -| DELETE | `/api/automations/:id` | Delete an automation and its firing rows. | `204`. Runs from past firings remain in the durable run log. | +| DELETE | `/api/automations/:id` | Delete an automation and its firing rows. | `204`. Runs from past firings remain in the durable run log. `403 automation_creator_only` for anyone but the creator or an admin. | ## Secrets diff --git a/docs-site/docs/api/bots.mdx b/docs-site/docs/api/bots.mdx index 4627fe159..955308109 100644 --- a/docs-site/docs/api/bots.mdx +++ b/docs-site/docs/api/bots.mdx @@ -20,7 +20,7 @@ thread through the API-key lane, use the allowed `POST /api/runs` route with | GET | `/api/bots` | List active bots with home-thread and handoff status. | | POST | `/api/bots` | Create a preset: `name`, `title`, `rules`, `engine`, optional `model`, `skillIds`, `repos`, `memoryScope`, `avatarTone`, and `avatarIcon`. | | GET | `/api/bots/:id` | Read one preset and its current status. | -| PATCH | `/api/bots/:id` | Update allowed fields or archive/restore the bot. Execution-preset fields lock after the home thread exists. | +| PATCH | `/api/bots/:id` | Update allowed fields or archive/restore the bot. Execution-preset fields lock after the home thread exists. Only the bot's creator changes it (`403 bot_owner_only` names them); an org owner or admin may archive or restore it. Every view carries `editable` and `ownerName` for the viewer. | | POST | `/api/bots/:id/messages` | Send `{"text":"..."}`. The first message creates the home thread; later messages reply to its current head. | An archived bot stays readable but rejects new messages with `409 bot_archived`. @@ -31,7 +31,7 @@ Bot names are unique within the org, case-insensitively. | Method | Path | Description | | --- | --- | --- | | GET | `/api/bots/:id/routines` | List routines attached to the bot. | -| POST | `/api/bots/:id/routines` | Create a routine from `name`, `cron`, `timezone`, `prompt`, and optional `enabled`. Engine, model, repos, and skill come from the bot preset. | +| POST | `/api/bots/:id/routines` | Create a routine from `name`, `cron`, `timezone`, `prompt`, and optional `enabled`. Engine, model, repos, and skill come from the bot preset. The bot's creator only; a routine anyone else added earlier answers `403 routine_not_bot_owner` and is never run with the bot's setup (once the bot is archived, its routines run as plain automations of their creator). | | PATCH | `/api/bots/:id/routines/:routineId` | Update name, cron, timezone, prompt, or enabled state. | | DELETE | `/api/bots/:id/routines/:routineId` | Delete the routine. Returns `204`. | | POST | `/api/bots/:id/routines/:routineId/run-now` | Fire immediately. Returns `202` with `runId` and the updated routine. | diff --git a/docs-site/docs/product/bots.mdx b/docs-site/docs/product/bots.mdx index 42ae0f195..f4c7f5157 100644 --- a/docs-site/docs/product/bots.mdx +++ b/docs-site/docs/product/bots.mdx @@ -34,6 +34,12 @@ edit descriptive fields and standing rules. This prevents an existing thread from being rebound to a different execution identity. ::: +A bot's setup (its standing rules, preset and routines) is its creator's: every +turn it takes runs that setup, a routine's unattended ones as their creator, so +only the creator changes it. Everyone else sees the rules read-only with who can +change them, and can still message an org-memory bot. An org owner or admin can +archive it or pause its routines. + ## Use the home thread The bot page shows the roster and the selected bot's real session timeline. diff --git a/frontend/app/agent/schedules/automations-view.tsx b/frontend/app/agent/schedules/automations-view.tsx index 3001f8da0..e0f852cc0 100644 --- a/frontend/app/agent/schedules/automations-view.tsx +++ b/frontend/app/agent/schedules/automations-view.tsx @@ -26,6 +26,7 @@ import { AutomationHistoryDrawer } from "./automation-history-drawer"; import { AutomationOverview, EmptyAutomations } from "./automation-overview"; import { AutomationEditorModal } from "./new-schedule-modal"; import { + AutomationRefusal, createSchedule, deleteSchedule, fetchSchedules, @@ -112,11 +113,11 @@ export function AutomationsView() { previous?.map((item) => (item.id === id ? updated : item)) ?? null, ); setError(null); - } catch { + } catch (caught) { setAutomations((previous) => previous?.map((item) => (item.id === id ? { ...item, enabled: !enabled } : item)) ?? null, ); - setError("That status change didn’t save. The previous state was restored."); + setError(caught instanceof AutomationRefusal ? caught.message : "That status change didn’t save. The previous state was restored."); } finally { setBusy(setMutating, id, false); } @@ -130,8 +131,8 @@ export function AutomationsView() { if (automation) setHistoryFor(automation); setError(null); await load(); - } catch { - setError("The automation couldn’t start. No schedule settings were changed."); + } catch (caught) { + setError(caught instanceof AutomationRefusal ? caught.message : "The automation couldn’t start. No schedule settings were changed."); } finally { setBusy(setRunning, id, false); } @@ -157,8 +158,8 @@ export function AutomationsView() { setAutomations((previous) => previous?.filter((item) => item.id !== id) ?? []); if (historyFor?.id === id) setHistoryFor(null); setError(null); - } catch { - setError("The automation wasn’t deleted. Try again."); + } catch (caught) { + setError(caught instanceof AutomationRefusal ? caught.message : "The automation wasn’t deleted. Try again."); } finally { setBusy(setMutating, id, false); } diff --git a/frontend/app/agent/schedules/schedules-api.ts b/frontend/app/agent/schedules/schedules-api.ts index dbfb2e6a4..1a04a1454 100644 --- a/frontend/app/agent/schedules/schedules-api.ts +++ b/frontend/app/agent/schedules/schedules-api.ts @@ -11,6 +11,16 @@ import type { FiringRecord, ScheduleRecord } from "./schedules-data"; const jsonHeaders = { "content-type": "application/json" } as const; +/** A call the backend refused in words for people (its `message`), such as an + * automation only its creator may change. Other failures say only which call + * failed, and the page keeps its own words for them. */ +export class AutomationRefusal extends Error {} + +async function failure(res: Response, call: string): Promise { + const body = (await res.json().catch(() => null)) as { message?: unknown } | null; + return typeof body?.message === "string" ? new AutomationRefusal(body.message) : new Error(`${call} ${res.status}`); +} + export async function fetchSchedules(signal?: AbortSignal): Promise { const res = await backendFetch("/api/automations", { cache: "no-store", signal }); if (!res.ok) throw new Error(`automations ${res.status}`); @@ -52,7 +62,7 @@ export async function updateSchedule( headers: jsonHeaders, body: JSON.stringify(patch), }); - if (!res.ok) throw new Error(`update automation ${res.status}`); + if (!res.ok) throw await failure(res, "update automation"); return (await res.json()) as ScheduleRecord; } @@ -61,7 +71,7 @@ export async function runScheduleNow(id: string): Promise { const res = await backendFetch(`/api/automations/${id}/run-now`, { method: "POST", }); - if (!res.ok) throw new Error(`run-now ${res.status}`); + if (!res.ok) throw await failure(res, "run-now"); const data = (await res.json()) as { run_id: string }; return data.run_id; } @@ -70,7 +80,7 @@ export async function deleteSchedule(id: string): Promise { const res = await backendFetch(`/api/automations/${id}`, { method: "DELETE", }); - if (!res.ok) throw new Error(`delete automation ${res.status}`); + if (!res.ok) throw await failure(res, "delete automation"); } export async function fetchHistory(id: string, signal?: AbortSignal): Promise { diff --git a/frontend/components/automations/schedules-api.test.ts b/frontend/components/automations/schedules-api.test.ts index 1dd6356c6..7d34dbaba 100644 --- a/frontend/components/automations/schedules-api.test.ts +++ b/frontend/components/automations/schedules-api.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { + AutomationRefusal, createSchedule, deleteSchedule, fetchHistory, @@ -229,4 +230,19 @@ describe("Automations API client", () => { }, ]); }); + + test("a refusal the backend put in words reaches the page in those words", async () => { + const words = "This automation runs as Dana Reyes, so only they can change it or run it now. An admin can pause or delete it."; + for (const call of [() => updateSchedule(automation.id, { prompt: "x" }), () => runScheduleNow(automation.id), () => deleteSchedule(automation.id)]) { + responses.push(Response.json({ error: "automation_creator_only", message: words }, { status: 403 })); + const refused = await call().catch((error: unknown) => error); + expect(refused).toBeInstanceOf(AutomationRefusal); + expect((refused as Error).message).toBe(words); + } + // A failure without words stays a plain error: the page keeps its own. + responses.push(Response.json({ error: "model_not_allowed" }, { status: 400 })); + const plain = await updateSchedule(automation.id, { name: "x" }).catch((error: unknown) => error); + expect(plain).not.toBeInstanceOf(AutomationRefusal); + expect((plain as Error).message).toBe("update automation 400"); + }); }); diff --git a/frontend/components/bots/bot-details.tsx b/frontend/components/bots/bot-details.tsx index 7fde7d1b4..29f3d7d0c 100644 --- a/frontend/components/bots/bot-details.tsx +++ b/frontend/components/bots/bot-details.tsx @@ -40,6 +40,9 @@ export function BotThreadHeader({ bot, threadModel }: { bot: ApiBot; threadModel repos.length !== bot.repos.length || repos.some((repo, index) => repo !== bot.repos[index]); const model = threadModel ?? bot.model; + // Only the bot's creator changes its setup; everyone else reads it (an older + // backend sends no answer and let them). + const editable = bot.editable !== false; const save = async () => { if (busy || !dirty) return; @@ -104,31 +107,43 @@ export function BotThreadHeader({ bot, threadModel }: { bot: ApiBot; threadModel - { - setTitle(value); - setSaved(false); - }} - /> + {editable ? ( + <> + { + setTitle(value); + setSaved(false); + }} + /> - + + + ) : ( +
+

Standing rules

+

{bot.rules || "No standing rules yet."}

+ + Only {bot.ownerName?.trim() || "the bot's creator"} can change this bot's rules. + +
+ )} {(dirty || error || saved) && ( diff --git a/frontend/components/bots/routines-section.tsx b/frontend/components/bots/routines-section.tsx index 2fdb40300..4b45e67fc 100644 --- a/frontend/components/bots/routines-section.tsx +++ b/frontend/components/bots/routines-section.tsx @@ -213,7 +213,7 @@ export function RoutinesSection({ bot }: { bot: ApiBot }) {

Routines

- {!adding && ( + {!adding && bot.editable !== false && (
@@ -230,7 +230,11 @@ export function RoutinesSection({ bot }: { bot: ApiBot }) { {routines === null && !error ? (

Loading

) : routines && routines.length === 0 && !adding ? ( -

No routines yet. Add one and {bot.name} runs it on schedule into this thread.

+

+ {bot.editable === false + ? "No routines yet." + : `No routines yet. Add one and ${bot.name} runs it on schedule into this thread.`} +

) : ( routines?.map((routine) => ) )} diff --git a/frontend/components/bots/types.ts b/frontend/components/bots/types.ts index c8f9c91df..b0f6b2f57 100644 --- a/frontend/components/bots/types.ts +++ b/frontend/components/bots/types.ts @@ -36,6 +36,11 @@ export interface ApiBot { /** Delegated threads opened for this bot by @mentions, and their root run ids. */ handoffs: number; handoffThreadIds: string[]; + /** Whether this viewer may change the bot's setup (only its creator may); + * absent from an older backend, which let them. */ + editable?: boolean; + /** The creator as the member directory names them. */ + ownerName?: string | null; } /** Wire shape of GET /api/bots/:id/routines. */