From 9eee2488771d12995809d82b4123c673446bfbf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:24:56 +0000 Subject: [PATCH] feat(capabilities): least-privilege builtin tools per bot and schedules on behalf Every bot saw every builtin tool, and nothing outside the runtime's own declaration kept a run to the tools it was given: the executor ran any tool a runtime asked for, and the Pi runtime exposed all builtins whenever a caller passed an empty tool list, which the safety checker, the history summarizer and the team chat judge do on purpose. A specialist could not be kept to the tools of its lane, bots had no capability tags, and the main bot could only schedule routines for itself. - A bot can carry a builtin tool allowlist. Its runs see only the allowlisted builtins, and an empty allowlist means none. MAIN_ONLY_BUILTIN_TOOLS (empty until the user profile and delegation tools land) never reach other bots. applyTool refuses any tool the run did not expose, and an empty tool list now means no tools for the Pi runtime and its subagents. - A bot spawned by a restricted bot inherits the restriction and a duplicate keeps it, so neither path widens a bot's tools. - bots.create and bots.update accept tags, restrictBuiltinTools and allowedBuiltinTools; the router rejects names that are not builtin tools. bots.builtinTools lists the names a bot's allowlist can use. - Bot settings get a Tags field and a switch with a tool checklist under Advanced. - The main bot's schedule_create takes an optional bot: one of the user's bots in any space the user still belongs to. The routine belongs to that bot in its space and wakes in the main conversation when the bot is a member there, otherwise in its 1:1 thread. Routines record the bot that scheduled them (additive migration), so schedule_list and schedule_cancel also cover exactly the routines a bot scheduled for others. Cancelling by a name that matches several routines is refused. Tested with unit tests for the per-bot filter, the applyTool guard, the empty tool list, and on-behalf resolution, creation, listing and cancelling; with Postgres journeys for on-behalf routines waking across spaces and in the main conversation and for allowlist enforcement through the scripted runtime; and with a web E2E for the settings section. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/router.ts | 25 ++ apps/web/e2e/bot-capabilities.spec.ts | 63 +++++ apps/web/src/pages/shell/bot-capabilities.tsx | 81 ++++++ apps/web/src/pages/shell/bot-panel.tsx | 31 ++ .../src/executor-capabilities.test.ts | 267 ++++++++++++++++++ packages/adapters/src/executor-tools.test.ts | 87 ++++++ packages/adapters/src/executor-tools.ts | 64 ++++- packages/adapters/src/executor.test.ts | 5 + packages/adapters/src/executor.ts | 57 ++-- .../src/pi-runtime-cancellation.test.ts | 3 +- .../src/pi-runtime-thinking-level.test.ts | 3 +- .../src/pi-runtime-tool-scope.test.ts | 86 ++++++ packages/adapters/src/pi-runtime.ts | 10 +- packages/adapters/src/schedule-tools.test.ts | 262 ++++++++++++++++- packages/adapters/src/schedule-tools.ts | 164 +++++++++-- packages/contracts/src/domain.ts | 28 ++ packages/contracts/src/index.test.ts | 45 +++ packages/contracts/src/rpc.ts | 2 + .../migration.sql | 7 + packages/db/prisma/schema.prisma | 3 + packages/db/src/repos.ts | 15 + packages/testkit/src/authorization.test.ts | 2 + packages/testkit/src/journeys.test.ts | 218 ++++++++++++++ 23 files changed, 1462 insertions(+), 66 deletions(-) create mode 100644 apps/web/e2e/bot-capabilities.spec.ts create mode 100644 apps/web/src/pages/shell/bot-capabilities.tsx create mode 100644 packages/adapters/src/executor-capabilities.test.ts create mode 100644 packages/adapters/src/executor-tools.test.ts create mode 100644 packages/adapters/src/pi-runtime-tool-scope.test.ts create mode 100644 packages/db/prisma/migrations/20261002210600_ws6_routine_created_by_bot/migration.sql diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 7067820..f94b1d7 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -35,6 +35,7 @@ import { assertTeamWorkspacePathAllowed, buildMcpCredentialBlob, buildModelConnectPlaintext, + builtinToolNamesForBot, ComputerBusyError, cancelComputerRunWork, checkpointAndRecordComputerWorkspace, @@ -83,6 +84,7 @@ import { teamBotWorkspaceDirectory, toComputerRef, touchRunningComputer, + unknownBuiltinToolNames, verifyMcpInstall, } from "@rakazo/adapters"; import type { Auth } from "@rakazo/auth"; @@ -981,6 +983,7 @@ export function createRouter(deps: RouterDeps) { return found; }), create: authed.bots.create.handler(async ({ context, input }) => { + assertBuiltinToolNames(input.allowedBuiltinTools); try { return await repos.createBot(context.actor, input); } catch (error) { @@ -1001,6 +1004,9 @@ export function createRouter(deps: RouterDeps) { modelProvider: source.modelProvider, modelId: source.modelId, thinkingLevel: source.thinkingLevel, + tags: source.tags, + restrictBuiltinTools: source.restrictBuiltinTools, + allowedBuiltinTools: source.allowedBuiltinTools, }) .catch((error: unknown) => { throw mapSpaceLifecycleError(error); @@ -1032,6 +1038,7 @@ export function createRouter(deps: RouterDeps) { }), update: authed.bots.update.handler(async ({ context, input }) => { const existing = await repos.getBot(context.actor, input.botId); + assertBuiltinToolNames(input.allowedBuiltinTools); if (input.sectionId) { const section = await deps.prisma.botSection.findFirst({ where: { @@ -1133,6 +1140,9 @@ export function createRouter(deps: RouterDeps) { ? { teamChatAmbientEnabled: input.teamChatAmbientEnabled } : {}), ...(input.teamChatRules !== undefined ? { teamChatRules: input.teamChatRules } : {}), + tags: input.tags, + restrictBuiltinTools: input.restrictBuiltinTools, + allowedBuiltinTools: input.allowedBuiltinTools, }, }); const bots = await repos.listBots(context.actor); @@ -1140,6 +1150,14 @@ export function createRouter(deps: RouterDeps) { if (!bot) throw new IsolationError(); return bot; }), + builtinTools: authed.bots.builtinTools.handler(async ({ context, input }) => { + const bot = await repos.getBot(context.actor, input.botId); + const identity = await deps.prisma.messagingIdentity.findUnique({ + where: { botId: bot.id }, + select: { id: true }, + }); + return builtinToolNamesForBot({ kind: bot.kind, chatLinked: Boolean(identity) }); + }), setComputer: authed.bots.setComputer.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId); if (!bot.computer) throw new IsolationError(); @@ -5333,6 +5351,13 @@ function withViewOnly(url: string, viewOnly: boolean) { } } +function assertBuiltinToolNames(names: readonly string[] | undefined) { + const unknown = unknownBuiltinToolNames(names ?? []); + if (unknown.length > 0) { + throw new ORPCError("BAD_REQUEST", { message: `Unknown tools: ${unknown.join(", ")}` }); + } +} + function duplicateBotName(name: string) { return `${name.slice(0, 75)} copy`; } diff --git a/apps/web/e2e/bot-capabilities.spec.ts b/apps/web/e2e/bot-capabilities.spec.ts new file mode 100644 index 0000000..0a5135e --- /dev/null +++ b/apps/web/e2e/bot-capabilities.spec.ts @@ -0,0 +1,63 @@ +import { expect, type Page, test } from "@playwright/test"; +import type { Bot } from "@rakazo/contracts"; +import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; + +async function openCapabilities(page: Page) { + await page.getByTestId("bot-settings-trigger").click(); + const settings = page.getByTestId("bot-settings"); + await expect(settings).toBeVisible(); + await settings.getByText("Advanced", { exact: true }).click(); + const capabilities = settings.getByTestId("bot-capabilities"); + await capabilities.scrollIntoViewIfNeeded(); + return { settings, capabilities }; +} + +test("bot settings edit capability tags and the built-in tool allowlist", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `capabilities-${stamp}@rakazo.test`, "password12", "Capabilities"); + await completeOnboarding(page); + const botId = activeBotId(page); + + const { settings, capabilities } = await openCapabilities(page); + await capabilities.getByLabel("Tags").fill("research, writing, research"); + await expect(capabilities.getByRole("checkbox")).toHaveCount(0); + await capabilities.getByRole("switch", { name: "Limit built-in tools" }).click(); + const tools = capabilities.getByRole("group", { name: "Limit built-in tools" }); + await tools.getByRole("checkbox", { name: "web_fetch", exact: true }).click(); + await tools.getByRole("checkbox", { name: "web_search", exact: true }).click(); + await tools.getByRole("checkbox", { name: "remember", exact: true }).click(); + await tools.getByRole("checkbox", { name: "remember", exact: true }).click(); + await expect(tools.getByRole("checkbox", { name: "web_search", exact: true })).toBeChecked(); + await expect(tools.getByRole("checkbox", { name: "shell", exact: true })).not.toBeChecked(); + await captureScreenshot(page, testInfo, "bot-capabilities-settings"); + + const saved = page.waitForResponse( + (response) => response.url().includes("/rpc/bots/update") && response.ok(), + ); + await settings.getByRole("button", { name: "Save", exact: true }).click(); + await saved; + expect(await rpc(page, "bots/get", { botId })).toMatchObject({ + tags: ["research", "writing"], + restrictBuiltinTools: true, + allowedBuiltinTools: ["web_search", "web_fetch"], + }); + + await page.reload(); + const reopened = await openCapabilities(page); + await expect(reopened.capabilities.getByLabel("Tags")).toHaveValue("research, writing"); + await expect( + reopened.capabilities.getByRole("switch", { name: "Limit built-in tools" }), + ).toBeChecked(); + const reopenedTools = reopened.capabilities.getByRole("group", { + name: "Limit built-in tools", + }); + await expect( + reopenedTools.getByRole("checkbox", { name: "web_fetch", exact: true }), + ).toBeChecked(); + await expect( + reopenedTools.getByRole("checkbox", { name: "remember", exact: true }), + ).not.toBeChecked(); + await captureScreenshot(page, testInfo, "bot-capabilities-persisted"); +}); diff --git a/apps/web/src/pages/shell/bot-capabilities.tsx b/apps/web/src/pages/shell/bot-capabilities.tsx new file mode 100644 index 0000000..937d9c6 --- /dev/null +++ b/apps/web/src/pages/shell/bot-capabilities.tsx @@ -0,0 +1,81 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { Checkbox, Input, Switch } from "@rakazo/ui-web"; +import { useId } from "react"; + +/** Capability tags and the builtin tool allowlist of one bot, edited inside its settings. */ +export function BotCapabilitiesFields({ + tags, + onTagsChange, + restrictBuiltinTools, + onRestrictBuiltinToolsChange, + allowedBuiltinTools, + onAllowedBuiltinToolsChange, + toolNames, +}: { + tags: string; + onTagsChange: (tags: string) => void; + restrictBuiltinTools: boolean; + onRestrictBuiltinToolsChange: (restrict: boolean) => void; + allowedBuiltinTools: string[]; + onAllowedBuiltinToolsChange: (names: string[]) => void; + toolNames: string[]; +}) { + const { t } = useLingui(); + const ids = useId(); + return ( +
+ + + {restrictBuiltinTools ? ( +
+ {toolNames.map((name) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/pages/shell/bot-panel.tsx b/apps/web/src/pages/shell/bot-panel.tsx index 74c336c..8f0837a 100644 --- a/apps/web/src/pages/shell/bot-panel.tsx +++ b/apps/web/src/pages/shell/bot-panel.tsx @@ -14,6 +14,7 @@ import { BOT_DESCRIPTION_MAX_LENGTH, BOT_NAME_MAX_LENGTH, BOT_TITLE_MAX_LENGTH, + normalizeBotTags, } from "@rakazo/contracts"; import { Button, @@ -29,6 +30,7 @@ import { Suspense, useEffect, useId, useRef, useState } from "react"; import { lazyWithRecovery } from "../../lib/lazy-recovery"; import { rpc } from "../../lib/rpc"; import { AvatarStudioPopover } from "./avatar-studio-popover"; +import { BotCapabilitiesFields } from "./bot-capabilities"; const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () => import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })), @@ -212,6 +214,9 @@ export function BotSettings({ modelProvider?: string | null; modelId?: string | null; thinkingLevel?: ThinkingLevel | null; + tags?: string[]; + restrictBuiltinTools?: boolean; + allowedBuiltinTools?: string[]; }) => Promise; onExport: () => Promise; onClear: () => void; @@ -233,6 +238,10 @@ export function BotSettings({ bot.modelProvider && bot.modelId ? modelOptionKey(bot.modelProvider, bot.modelId) : "", ); const [thinkingLevel, setThinkingLevel] = useState(bot.thinkingLevel ?? ""); + const [tags, setTags] = useState(bot.tags.join(", ")); + const [restrictBuiltinTools, setRestrictBuiltinTools] = useState(bot.restrictBuiltinTools); + const [allowedBuiltinTools, setAllowedBuiltinTools] = useState(bot.allowedBuiltinTools); + const [toolNames, setToolNames] = useState(null); const [credentials, setCredentials] = useState([]); const [catalog, setCatalog] = useState([]); const [me, setMe] = useState(null); @@ -254,6 +263,10 @@ export function BotSettings({ .voices({}) .then(setVoices) .catch(() => setVoices([])); + void rpc.bots + .builtinTools({ botId: bot.id }) + .then(setToolNames) + .catch(() => undefined); void Promise.all([rpc.models.credentials(), rpc.models.list(), rpc.me()]) .then(([nextCredentials, nextCatalog, nextMe]) => { setCredentials(nextCredentials); @@ -344,9 +357,12 @@ export function BotSettings({ const nextNotify = patchOverrides?.notifyOnFinish !== undefined ? patchOverrides.notifyOnFinish : notifyOnFinish; + const nextTags = normalizeBotTags(tags.split(",")); + if (nextName) setName(nextName); setTitle(nextTitle); setDescription(nextDescription); + setTags(nextTags.join(", ")); try { setSaving(true); @@ -364,6 +380,12 @@ export function BotSettings({ voiceId: voiceId || null, modelProvider: selected?.provider ?? null, modelId: selected?.modelId ?? null, + tags: nextTags, + restrictBuiltinTools, + // Names a newer release no longer ships would make the whole save fail validation. + allowedBuiltinTools: toolNames + ? allowedBuiltinTools.filter((tool) => toolNames.includes(tool)) + : allowedBuiltinTools, ...(modelMetaReady ? { thinkingLevel: thinkingOptions.length @@ -598,6 +620,15 @@ export function BotSettings({ ) : null} + {error ?

{error}

: null}
diff --git a/packages/adapters/src/executor-capabilities.test.ts b/packages/adapters/src/executor-capabilities.test.ts new file mode 100644 index 0000000..1d3e480 --- /dev/null +++ b/packages/adapters/src/executor-capabilities.test.ts @@ -0,0 +1,267 @@ +import type { AgentRunRequest } from "@rakazo/adapter-kit"; +import { describe, expect, it, vi } from "vitest"; +import type * as ComputerLifecycleModule from "./computer-lifecycle.js"; +import { createRunExecutor, MAIN_ONLY_BUILTIN_TOOLS } from "./executor.js"; + +vi.mock("./computer-lifecycle.js", async (importOriginal) => ({ + ...(await importOriginal()), + acquireComputerExecutionLease: async () => null, + provisionComputer: async () => ({ id: "computer-1", kind: "desktop" }), +})); + +type BotPolicy = { kind?: string; restrictBuiltinTools?: boolean; allowedBuiltinTools?: string[] }; +type Call = { name: string; args?: Record }; +type Effect = { id: string; status: string; idempotencyKey: string } & Record; + +/** In-memory external effects, enough for the idempotency record of a mutating builtin tool. */ +function effectStore() { + const effects: Effect[] = []; + const find = (where: { id?: string; idempotencyKey?: string }) => + effects.find((effect) => + where.id ? effect.id === where.id : effect.idempotencyKey === where.idempotencyKey, + ); + return { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async ({ where }: { where: { id?: string; idempotencyKey?: string } }) => { + const effect = find(where); + return effect ? { ...effect } : null; + }), + create: vi.fn(async ({ data }: { data: Omit }) => { + const effect = { ...data, id: `effect-${effects.length + 1}` } as Effect; + effects.push(effect); + return { ...effect }; + }), + update: vi.fn(async ({ where, data }: { where: { id: string }; data: Partial }) => { + Object.assign(find(where) ?? {}, data); + }), + updateMany: vi.fn( + async ({ where, data }: { where: { id: string; status: string }; data: Partial }) => { + const effect = find(where); + if (!effect || effect.status !== where.status) return { count: 0 }; + Object.assign(effect, data); + return { count: 1 }; + }, + ), + }; +} + +async function runBot(policy: BotPolicy, calls: Call[] = [], models: Record = {}) { + const run = { + id: "run-1", + botId: "bot-1", + threadId: "thread-1", + taskId: "task-1", + spaceId: "space-1", + userId: "user-1", + status: "queued", + trigger: "user", + leaseFence: 0, + }; + const routineFindMany = vi.fn(async () => []); + const prisma = { + run: { + findUnique: vi.fn(async () => run), + findUniqueOrThrow: vi.fn(async () => run), + updateMany: vi.fn(async ({ data }: { data: Record }) => { + Object.assign(run, data); + return { count: 1 }; + }), + }, + bot: { + findUniqueOrThrow: vi.fn(async () => ({ + id: run.botId, + name: "Assistant", + title: "Assistant", + description: "Test assistant", + computerId: "computer-1", + computer: { id: "computer-1", scope: "dedicated" }, + kind: "specialist", + restrictBuiltinTools: false, + allowedBuiltinTools: [], + ...policy, + })), + findMany: vi.fn(async () => []), + }, + attempt: { + create: vi.fn(async () => ({ id: "attempt-1" })), + update: vi.fn(), + updateMany: vi.fn(async () => ({ count: 1 })), + }, + thread: { findUniqueOrThrow: vi.fn(async () => ({ id: run.threadId, groupId: null })) }, + message: { findMany: vi.fn(async () => []) }, + task: { findUniqueOrThrow: vi.fn(async () => ({ id: run.taskId, prompt: "Work" })) }, + connection: { findMany: vi.fn(async () => []) }, + spaceModelPreference: { findFirst: vi.fn(async () => null) }, + userModelCredential: { findFirst: vi.fn(async () => null) }, + deploymentSettings: { + findUnique: vi.fn(async () => ({ + defaultModelProvider: "scripted", + defaultModelId: "scripted", + })), + }, + taughtSkill: { findMany: vi.fn(async () => []) }, + agentSecret: { findMany: vi.fn(async () => []) }, + agentSkill: { findMany: vi.fn(async () => []) }, + scratchpadItem: { findMany: vi.fn(async () => []) }, + actionApprovalRule: { findMany: vi.fn(async () => []) }, + actionAutoReviewPreference: { findUnique: vi.fn(async () => ({ enabled: false })) }, + externalEffect: effectStore(), + routine: { findMany: routineFindMany }, + ...models, + }; + let request: AgentRunRequest | undefined; + const results: unknown[] = []; + const jobs = { enqueue: vi.fn(async () => undefined) }; + const executor = createRunExecutor({ + prisma, + runtime: { + describe: () => ({ capabilities: { scripted: false } }), + run: async function* (next: AgentRunRequest) { + request = next; + for (const [index, call] of calls.entries()) { + results.push(await next.executeTool!(call.name, call.args ?? {}, `call-${index}`)); + } + yield { type: "done" as const, text: "Done" }; + }, + }, + sandbox: { describe: () => ({ capabilities: { graphical: false } }) }, + memory: { read: async () => ({ documents: [] }) }, + memoryProviders: { resolve: async () => null }, + events: { + append: vi.fn(async () => undefined), + pauseRunForInput: vi.fn(async () => true), + finalizeRun: vi.fn(async () => ({ continuationRunId: null })), + }, + jobs, + secrets: [], + } as unknown as Parameters[0]); + await executor.continueRun(run.id, "worker-1"); + if (!request) throw new Error("the runtime was never started"); + const tools = request.tools; + return { names: tools.map((tool) => tool.name), tools, results, routineFindMany }; +} + +describe("bot capabilities in runs", () => { + it("exposes every builtin to an unrestricted bot", async () => { + const { names } = await runBot({}); + expect(names).toEqual(expect.arrayContaining(["write_file", "shell", "schedule_list"])); + }); + + it("exposes only the allowlisted builtins and refuses calls to any other", async () => { + const { names, results, routineFindMany } = await runBot( + { restrictBuiltinTools: true, allowedBuiltinTools: ["schedule_list", "web_search"] }, + [ + { name: "write_file", args: { path: "notes/x.txt", content: "nope" } }, + { name: "schedule_list" }, + ], + ); + expect(names).toEqual(["web_search", "schedule_list"]); + expect(results[0]).toEqual({ error: "Tool write_file is not available in this run." }); + expect(results[1]).toEqual({ routines: [] }); + expect(routineFindMany).toHaveBeenCalledOnce(); + }); + + it("exposes no builtins when the allowlist is empty", async () => { + const { names, results } = await runBot({ restrictBuiltinTools: true }, [ + { name: "schedule_list" }, + ]); + expect(names).toEqual([]); + expect(results[0]).toEqual({ error: "Tool schedule_list is not available in this run." }); + }); + + it("refuses a tool no run ever exposes", async () => { + const { results } = await runBot({}, [{ name: "made_up_tool" }]); + expect(results[0]).toEqual({ error: "Tool made_up_tool is not available in this run." }); + }); + + it("offers schedule_create's bot option to the main bot only", async () => { + const properties = async (policy: BotPolicy) => + Object.keys( + ((await runBot(policy)).tools.find((tool) => tool.name === "schedule_create")?.inputSchema + .properties ?? {}) as object, + ); + expect(await properties({ kind: "main" })).toContain("bot"); + expect(await properties({})).not.toContain("bot"); + }); + + it("withholds main-only tools from every other bot", async () => { + const mainOnly = MAIN_ONLY_BUILTIN_TOOLS as Set; + // No main-only tool ships yet in this package; schedule_list stands in for one. + mainOnly.add("schedule_list"); + try { + const specialist = await runBot({}, [{ name: "schedule_list" }]); + expect(specialist.names).not.toContain("schedule_list"); + expect(specialist.results[0]).toEqual({ + error: "Tool schedule_list is not available in this run.", + }); + const main = await runBot({ kind: "main" }, [{ name: "schedule_list" }]); + expect(main.names).toContain("schedule_list"); + expect(main.results[0]).toEqual({ routines: [] }); + } finally { + mainOnly.delete("schedule_list"); + } + }); + + it("schedules for a named specialist when the main bot asks, in that bot's space and thread", async () => { + const routineCreate = vi.fn(async ({ data }: { data: Record }) => ({ + id: "routine-1", + ...data, + })); + const { results } = await runBot( + { kind: "main" }, + [ + { + name: "schedule_create", + args: { name: "Digest", prompt: "Summarize", delayMinutes: 5, bot: "Atlas" }, + }, + ], + { + bot: { + findUniqueOrThrow: vi.fn(async () => ({ + id: "bot-1", + name: "Chief", + title: "", + description: "", + computerId: "computer-1", + computer: { id: "computer-1", scope: "dedicated" }, + kind: "main", + restrictBuiltinTools: false, + allowedBuiltinTools: [], + })), + findMany: vi.fn(async () => [ + { id: "atlas-1", name: "Atlas", spaceId: "space-2", thread: { id: "atlas-dm" } }, + ]), + }, + chatGroup: { findFirst: vi.fn(async () => null) }, + routine: { findMany: vi.fn(async () => []), create: routineCreate }, + }, + ); + expect(results[0]).toMatchObject({ ok: true, routineId: "routine-1", bot: "Atlas" }); + expect(routineCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + spaceId: "space-2", + botId: "atlas-1", + threadId: "atlas-dm", + createdByBotId: "bot-1", + }), + }), + ); + }); + + it("refuses the bot option from any other bot", async () => { + const routineCreate = vi.fn(); + const { results } = await runBot( + {}, + [ + { + name: "schedule_create", + args: { name: "x", prompt: "y", delayMinutes: 5, bot: "Atlas" }, + }, + ], + { routine: { findMany: vi.fn(async () => []), create: routineCreate } }, + ); + expect(results[0]).toEqual({ error: "Only the main agent can schedule for another bot." }); + expect(routineCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapters/src/executor-tools.test.ts b/packages/adapters/src/executor-tools.test.ts new file mode 100644 index 0000000..ee03485 --- /dev/null +++ b/packages/adapters/src/executor-tools.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js"; +import { + builtinToolNamesForBot, + filterBuiltinToolsForBot, + MAIN_ONLY_BUILTIN_TOOLS, + selectBuiltinToolsForRun, + unknownBuiltinToolNames, +} from "./executor-tools.js"; + +const unrestricted = { kind: "specialist", restrictBuiltinTools: false, allowedBuiltinTools: [] }; + +function select( + bot: { kind: string; restrictBuiltinTools: boolean; allowedBuiltinTools: string[] }, + options: { graphicalToolsAllowed?: boolean; agentConnectionsEnabled?: boolean } = {}, +) { + return selectBuiltinToolsForRun({ + graphicalToolsAllowed: options.graphicalToolsAllowed ?? true, + groupId: null, + trigger: "user", + semanticMemoryEnabled: false, + messagingChannelRun: false, + agentConnectionsEnabled: options.agentConnectionsEnabled, + bot, + }).map((tool) => tool.name); +} + +describe("builtin tools per bot", () => { + it("adds agent connection tools only for chat-linked bots", () => { + expect(select(unrestricted)).not.toContain("message_agent"); + expect(select(unrestricted, { agentConnectionsEnabled: true })).toEqual( + expect.arrayContaining(agentConnectionTools.map((tool) => tool.name)), + ); + }); + + it("applies the allowlist to agent connection tools too", () => { + const bot = { + kind: "specialist", + restrictBuiltinTools: true, + allowedBuiltinTools: ["message_agent", "read_file"], + }; + expect(select(bot, { agentConnectionsEnabled: true })).toEqual(["read_file", "message_agent"]); + }); + + it("never brings back a tool the run itself withholds", () => { + const bot = { + kind: "specialist", + restrictBuiltinTools: true, + allowedBuiltinTools: ["computer_observe", "read_file"], + }; + expect(select(bot, { graphicalToolsAllowed: false })).toEqual(["read_file"]); + }); + + it("ignores the allowlist while the restriction is off", () => { + const bot = { ...unrestricted, allowedBuiltinTools: ["read_file"] }; + expect(filterBuiltinToolsForBot(builtinAgentTools, bot)).toHaveLength(builtinAgentTools.length); + }); + + it("lists main-only tools for the main bot alone", () => { + const mainOnly = MAIN_ONLY_BUILTIN_TOOLS as Set; + // No main-only tool ships yet in this package; remember stands in for one. + mainOnly.add("remember"); + try { + expect(builtinToolNamesForBot({ kind: "specialist", chatLinked: false })).not.toContain( + "remember", + ); + expect(builtinToolNamesForBot({ kind: "main", chatLinked: false })).toContain("remember"); + } finally { + mainOnly.delete("remember"); + } + }); + + it("lists agent connection tools only for chat-linked bots", () => { + expect(builtinToolNamesForBot({ kind: "specialist", chatLinked: false })).toEqual( + builtinAgentTools.map((tool) => tool.name), + ); + expect(builtinToolNamesForBot({ kind: "specialist", chatLinked: true })).toEqual( + [...builtinAgentTools, ...agentConnectionTools].map((tool) => tool.name), + ); + }); + + it("flags names that are not builtin tools", () => { + expect( + unknownBuiltinToolNames(["web_search", "message_agent", "web_serach", "mcp__notion__search"]), + ).toEqual(["web_serach", "mcp__notion__search"]); + }); +}); diff --git a/packages/adapters/src/executor-tools.ts b/packages/adapters/src/executor-tools.ts index 88fad49..26733d8 100644 --- a/packages/adapters/src/executor-tools.ts +++ b/packages/adapters/src/executor-tools.ts @@ -1,8 +1,13 @@ -import { builtinAgentTools } from "./builtin-tools.js"; +import type { ConnectorTool } from "@rakazo/adapter-kit"; +import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js"; import { selectCloudAgentTools } from "./cloud-agent-tools-select.js"; import { selectMemoryTools } from "./memory-tools.js"; import { filterImageReturningComputerTools } from "./model-vision.js"; -import { filterBuiltinToolsForRun, filterBuiltinToolsForThread } from "./schedule-tools.js"; +import { + filterBuiltinToolsForRun, + filterBuiltinToolsForThread, + withScheduleTargetBot, +} from "./schedule-tools.js"; /** Browser tools that require a graphical computer (Chrome). */ export const PAGE_BROWSER_TOOL_NAMES = new Set([ @@ -11,6 +16,52 @@ export const PAGE_BROWSER_TOOL_NAMES = new Set([ "browser_act", ]); +/** Builtin tools only the user's main bot may use; every other bot's runs never see them. */ +export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet = new Set(); + +const BUILTIN_TOOL_NAMES = [...builtinAgentTools, ...agentConnectionTools].map((tool) => tool.name); + +/** The bot fields that decide which builtin tools its runs may use. */ +export interface BuiltinToolPolicy { + kind: string; + restrictBuiltinTools: boolean; + allowedBuiltinTools: readonly string[]; +} + +/** + * Builtin tool names a bot's allowlist can choose from, in catalog order. Agent connection tools + * only ever reach chat-linked bots, so only those list them. + */ +export function builtinToolNamesForBot(bot: { kind: string; chatLinked: boolean }): string[] { + return [...builtinAgentTools, ...(bot.chatLinked ? agentConnectionTools : [])] + .map((tool) => tool.name) + .filter((name) => bot.kind === "main" || !MAIN_ONLY_BUILTIN_TOOLS.has(name)); +} + +/** Names that are not builtin tools; an allowlist must not carry typos or connector tools. */ +export function unknownBuiltinToolNames(names: readonly string[]): string[] { + return names.filter((name) => !BUILTIN_TOOL_NAMES.includes(name)); +} + +/** + * Least privilege per bot: main-only tools stay with the main bot, a restricted bot keeps only + * its allowlist (empty means none), and the main bot may schedule for another bot. + */ +export function filterBuiltinToolsForBot( + tools: ConnectorTool[], + bot: BuiltinToolPolicy, +): ConnectorTool[] { + const main = bot.kind === "main"; + const allowed = new Set(bot.allowedBuiltinTools); + return tools + .filter( + (tool) => + (main || !MAIN_ONLY_BUILTIN_TOOLS.has(tool.name)) && + (!bot.restrictBuiltinTools || allowed.has(tool.name)), + ) + .map((tool) => (main && tool.name === "schedule_create" ? withScheduleTargetBot(tool) : tool)); +} + export function filterPageBrowserTools( tools: T[], pageBrowserAllowed: boolean, @@ -28,8 +79,11 @@ export function selectBuiltinToolsForRun(options: { semanticMemoryEnabled: boolean; cloudAgentEnabled?: boolean; messagingChannelRun: boolean; + /** Cross-owner agent connections only exist for chat-linked bots. */ + agentConnectionsEnabled?: boolean; + bot: BuiltinToolPolicy; }) { - return selectCloudAgentTools( + const selected = selectCloudAgentTools( selectMemoryTools( filterBuiltinToolsForRun( filterBuiltinToolsForThread( @@ -50,4 +104,8 @@ export function selectBuiltinToolsForRun(options: { (!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) && !tool.name.startsWith("scratchpad_")), ); + return filterBuiltinToolsForBot( + options.agentConnectionsEnabled ? [...selected, ...agentConnectionTools] : selected, + options.bot, + ); } diff --git a/packages/adapters/src/executor.test.ts b/packages/adapters/src/executor.test.ts index 5fd1d90..5a41e07 100644 --- a/packages/adapters/src/executor.test.ts +++ b/packages/adapters/src/executor.test.ts @@ -141,6 +141,8 @@ describe("run workspace checkpoint", () => { }); }); +const specialist = { kind: "specialist", restrictBuiltinTools: false, allowedBuiltinTools: [] }; + describe("run tool selection", () => { it.each([ [false, false], @@ -156,6 +158,7 @@ describe("run tool selection", () => { trigger: "message", semanticMemoryEnabled: false, messagingChannelRun: false, + bot: specialist, }).map((tool) => tool.name); expect(names.includes("browser_snapshot")).toBe(page); expect(names.includes("cloud_agent_status")).toBe(cloud); @@ -174,6 +177,7 @@ describe("run tool selection", () => { trigger, semanticMemoryEnabled: false, messagingChannelRun: false, + bot: specialist, }).map((tool) => tool.name); it("keeps page browser tools without vision, and hides them without a graphical computer", () => { @@ -491,6 +495,7 @@ describe("createRunExecutor", () => { trigger: "messaging", semanticMemoryEnabled: true, messagingChannelRun: true, + bot: specialist, }).map((tool) => tool.name); expect(tools).not.toContain("recall_memory"); expect(tools).not.toContain("remember"); diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 399906e..bf2b80e 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -166,7 +166,7 @@ import { browserNavigateFromTool, browserSnapshotFromTool, } from "./browser-tools.js"; -import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js"; +import { builtinAgentTools } from "./builtin-tools.js"; import { archiveSpawnedBot, spawnBot } from "./child-bots.js"; import { type CloudAgentConnection, cloudAgentsEnabled } from "./cloud-agent-factory.js"; import { executeCloudAgentTool } from "./cloud-agent-service.js"; @@ -324,6 +324,7 @@ import { filterBuiltinToolsForRun, filterBuiltinToolsForThread, listSchedulesFromTool, + resolveScheduleOwner, } from "./schedule-tools.js"; import { loadAgentScratchpadContext } from "./scratchpad-context.js"; import { @@ -1400,19 +1401,17 @@ export function createRunExecutor(deps: ExecutorDeps) { const graphicalToolsAllowed = graphical && acceptsImages && !heldForTakeover; const pageBrowserAllowed = graphical && browser.describe().capabilities.page && !heldForTakeover; - const builtins = [ - ...selectBuiltinToolsForRun({ - graphicalToolsAllowed, - pageBrowserAllowed, - groupId: thread.groupId, - trigger: run.trigger, - semanticMemoryEnabled, - cloudAgentEnabled: cloudAgentsEnabled(cloudAgent, run.spaceId), - messagingChannelRun, - }), - // Cross-owner agent connections only exist for chat-linked bots. - ...(hasMessagingIdentity ? agentConnectionTools : []), - ]; + const builtins = selectBuiltinToolsForRun({ + graphicalToolsAllowed, + pageBrowserAllowed, + groupId: thread.groupId, + trigger: run.trigger, + semanticMemoryEnabled, + cloudAgentEnabled: cloudAgentsEnabled(cloudAgent, run.spaceId), + messagingChannelRun, + agentConnectionsEnabled: hasMessagingIdentity, + bot, + }); const exposedConnectorTools = discovered.filter( (tool) => !builtinAgentTools.some((builtin) => builtin.name === tool.name), ); @@ -1450,6 +1449,7 @@ export function createRunExecutor(deps: ExecutorDeps) { return autoReviewPreferencePromise; }; const tools = [...builtins, ...exposedConnectorTools]; + const exposedToolNames = new Set(tools.map((tool) => tool.name)); const approvedEffects = await deps.prisma.externalEffect.findMany({ where: { runId, status: "approved" }, orderBy: APPROVED_EFFECT_REPLAY_ORDER, @@ -1609,6 +1609,11 @@ export function createRunExecutor(deps: ExecutorDeps) { executionId: string, ) => { context.signal.throwIfAborted(); + // The runtime declaration is not the boundary: a runtime may still emit a call for a + // tool this run never exposed (an allowlisted-out or main-only tool, a scripted call). + if (!exposedToolNames.has(name)) { + return { error: `Tool ${name} is not available in this run.` }; + } if (handedOff) { return { error: "This stage was handed off. End the turn without more tool calls." }; } @@ -2568,11 +2573,20 @@ export function createRunExecutor(deps: ExecutorDeps) { return finish(removed); } if (name === "schedule_create") { + const owner = await resolveScheduleOwner( + deps.prisma, + { + botId: bot.id, + kind: bot.kind, + spaceId: run.spaceId, + userId: run.userId, + threadId: thread.id, + }, + args.bot ? String(args.bot) : undefined, + ); + if ("error" in owner) return finish(owner); const created = await createScheduleFromTool(deps, { - spaceId: run.spaceId, - botId: bot.id, - userId: run.userId, - threadId: thread.id, + ...owner, name: String(args.name ?? ""), prompt: String(args.prompt ?? ""), timezone: args.timezone ? String(args.timezone) : undefined, @@ -3482,7 +3496,7 @@ export function createRunExecutor(deps: ExecutorDeps) { const approvalContinuation = buildApprovalContinuation( approvedEffects, (request) => redactSecrets(JSON.stringify(request), runSecrets), - { exposedToolNames: new Set(tools.map((tool) => tool.name)) }, + { exposedToolNames }, ); const replyContext = await loadReplyContext(deps.prisma, thread.id, run.sourceMessageId); const prompt = [replyContext, basePrompt, takeoverResume?.promptNote, approvalContinuation] @@ -4347,10 +4361,15 @@ export { } from "./executor-computer.js"; export { completeEffect, recordEffect, uncertainEffectError } from "./executor-effects.js"; export { isProtectedComputerLifecycleCommand } from "./executor-shell-guard.js"; +export type { BuiltinToolPolicy } from "./executor-tools.js"; export { + builtinToolNamesForBot, + filterBuiltinToolsForBot, filterPageBrowserTools, + MAIN_ONLY_BUILTIN_TOOLS, PAGE_BROWSER_TOOL_NAMES, selectBuiltinToolsForRun, + unknownBuiltinToolNames, } from "./executor-tools.js"; export type { ExecutorDeps } from "./executor-types.js"; export { loadCurrentTurnImages, selectRunConnections } from "./executor-utils.js"; diff --git a/packages/adapters/src/pi-runtime-cancellation.test.ts b/packages/adapters/src/pi-runtime-cancellation.test.ts index 164a4c5..0c8706f 100644 --- a/packages/adapters/src/pi-runtime-cancellation.test.ts +++ b/packages/adapters/src/pi-runtime-cancellation.test.ts @@ -75,6 +75,7 @@ vi.mock("./pi-openai-compatible-provider.js", () => ({ registerOpenAiCompatibleRuntime: (models: unknown) => models, })); +import { builtinAgentTools } from "./builtin-tools.js"; import { PiAgentRuntime } from "./pi-runtime.js"; const request: AgentRunRequest = { @@ -84,7 +85,7 @@ const request: AgentRunRequest = { prompt: "fake task", instructions: "test", history: [], - tools: [], + tools: builtinAgentTools.filter((tool) => tool.name === "run_subagent" || tool.name === "shell"), model: { provider: "test", id: "fake-model" }, }; diff --git a/packages/adapters/src/pi-runtime-thinking-level.test.ts b/packages/adapters/src/pi-runtime-thinking-level.test.ts index 74839a5..7177d47 100644 --- a/packages/adapters/src/pi-runtime-thinking-level.test.ts +++ b/packages/adapters/src/pi-runtime-thinking-level.test.ts @@ -99,6 +99,7 @@ vi.mock("./pi-openai-compatible-provider.js", () => ({ registerOpenAiCompatibleRuntime: (models: unknown) => models, })); +import { builtinAgentTools } from "./builtin-tools.js"; import { PiAgentRuntime } from "./pi-runtime.js"; async function runWithModel( @@ -127,7 +128,7 @@ async function runWithModel( prompt: "hello", instructions: "", history: [], - tools: [], + tools: builtinAgentTools.filter((tool) => tool.name === "run_subagent"), model: { provider, id: modelId, thinkingLevel, maxImagesPerPrompt }, executeTool: vi.fn(async () => ({ ok: true })), resolveModel, diff --git a/packages/adapters/src/pi-runtime-tool-scope.test.ts b/packages/adapters/src/pi-runtime-tool-scope.test.ts new file mode 100644 index 0000000..50b7422 --- /dev/null +++ b/packages/adapters/src/pi-runtime-tool-scope.test.ts @@ -0,0 +1,86 @@ +import type { AgentRunRequest } from "@rakazo/adapter-kit"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ toolNames: [] as string[][] })); + +type FakeTool = { + name: string; + execute: (toolCallId: string, params: Record) => Promise; +}; + +vi.mock("@earendil-works/pi-agent-core", () => ({ + Agent: class { + state = { errorMessage: undefined, messages: [] }; + private readonly tools: FakeTool[]; + + constructor(options: { initialState: { tools: FakeTool[] } }) { + this.tools = options.initialState.tools; + state.toolNames.push(this.tools.map((tool) => tool.name)); + } + + subscribe() {} + async prompt() { + await this.tools + .find((tool) => tool.name === "run_subagent") + ?.execute("subagent-call", { name: "helper", task: "help" }); + } + async waitForIdle() {} + abort() {} + }, +})); +vi.mock("@earendil-works/pi-ai/providers/all", () => ({ + builtinModels: () => ({ + getModel: () => ({ provider: "test", id: "fake-model" }), + streamSimple: () => { + throw new Error("No model requests are allowed"); + }, + }), +})); +vi.mock("./pi-local-provider.js", () => ({ + registerLocalProvider: (models: unknown) => models, +})); +vi.mock("./pi-openai-compatible-provider.js", () => ({ + OPENAI_COMPATIBLE_PROVIDER_ID: "openai-compatible", + registerOpenAiCompatibleCatalog: (models: unknown) => models, + registerOpenAiCompatibleRuntime: (models: unknown) => models, +})); + +import { builtinAgentTools } from "./builtin-tools.js"; +import { PiAgentRuntime } from "./pi-runtime.js"; + +async function run(tools: AgentRunRequest["tools"]) { + for await (const _event of new PiAgentRuntime().run( + { + botId: "bot", + threadId: "thread", + runId: "run", + prompt: "work", + instructions: "test", + history: [], + tools, + model: { provider: "test", id: "fake-model" }, + executeTool: vi.fn(async () => ({ ok: true })), + }, + { signal: new AbortController().signal }, + )) { + // Exhaust the runtime event stream. + } +} + +describe("Pi runtime tool scope", () => { + beforeEach(() => { + state.toolNames = []; + }); + + it("exposes no tools when the request lists none", async () => { + await run([]); + expect(state.toolNames).toEqual([[]]); + }); + + it("gives a subagent only the parent's tools, minus delegation", async () => { + await run( + builtinAgentTools.filter((tool) => ["run_subagent", "read_file"].includes(tool.name)), + ); + expect(state.toolNames).toEqual([["read_file", "run_subagent"], ["read_file"]]); + }); +}); diff --git a/packages/adapters/src/pi-runtime.ts b/packages/adapters/src/pi-runtime.ts index 13f8f97..5d0ef59 100644 --- a/packages/adapters/src/pi-runtime.ts +++ b/packages/adapters/src/pi-runtime.ts @@ -16,7 +16,7 @@ import type { } from "@rakazo/adapter-kit"; import { getLogger } from "@rakazo/logging"; import { isToolPauseResult } from "./approval-effect.js"; -import { builtinAgentTools, DELEGATION_TOOL_NAMES } from "./builtin-tools.js"; +import { DELEGATION_TOOL_NAMES } from "./builtin-tools.js"; import { normalizeOpenAiToolParameters, openAiToolParametersNeedNormalization, @@ -156,7 +156,9 @@ export class PiAgentRuntime implements AgentRuntime { return; } const { models, model, apiKey } = selectedModel; - const toolDefs = request.tools.length ? request.tools : builtinAgentTools; + // The request's list is the whole tool set. An empty list means no tools: checkers and + // summarizers pass [] on purpose, and so does a bot whose allowlist is empty. + const toolDefs = request.tools; const nestedAgents = new Set(); const completionModel = modelForCompletion(model, request.model.maxTokens); trackedBudget = toolCallBudgetFor(request.runId); @@ -851,9 +853,7 @@ async function executeSubagent(host: ToolHost, executionId: string, args: Record } const subagentModel = modelForCompletion(selectedModel.model, requestModel.maxTokens); - const childDefs = (host.request.tools.length ? host.request.tools : builtinAgentTools).filter( - (tool) => !DELEGATION_TOOL_NAMES.has(tool.name), - ); + const childDefs = host.request.tools.filter((tool) => !DELEGATION_TOOL_NAMES.has(tool.name)); const nestedHost: ToolHost = { ...host, models: selectedModel.models, diff --git a/packages/adapters/src/schedule-tools.test.ts b/packages/adapters/src/schedule-tools.test.ts index 84e487a..09122f8 100644 --- a/packages/adapters/src/schedule-tools.test.ts +++ b/packages/adapters/src/schedule-tools.test.ts @@ -7,8 +7,10 @@ import { filterBuiltinToolsForThread, isOneShotRoutineCron, listSchedulesFromTool, + resolveScheduleOwner, resolveScheduleTiming, SCHEDULE_TOOL_NAMES, + withScheduleTargetBot, } from "./schedule-tools.js"; describe("resolveScheduleTiming", () => { @@ -360,14 +362,14 @@ describe("schedule tool persistence", () => { it("scopes group list and cancel to the current thread without narrowing DMs", async () => { const findMany = vi.fn(async () => []); - const findFirst = vi.fn(async () => null); const deps = { prisma: { - routine: { findMany, findFirst, delete: vi.fn(async () => undefined) }, + routine: { findMany, delete: vi.fn(async () => undefined) }, }, events: { append: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, } as unknown as Parameters[0]; + const scheduledForOthers = { userId: "user-1", createdByBotId: "bot-1" }; await listSchedulesFromTool(deps, { spaceId: "ws-1", @@ -378,10 +380,10 @@ describe("schedule tool persistence", () => { expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { - spaceId: "ws-1", - botId: "bot-1", - userId: "user-1", - threadId: "group-thread-1", + OR: [ + { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "group-thread-1" }, + scheduledForOthers, + ], }, }), ); @@ -394,13 +396,13 @@ describe("schedule tool persistence", () => { routineId: "routine-1", }); expect(cancelled).toEqual({ error: "Schedule not found." }); - expect(findFirst).toHaveBeenCalledWith( + expect(findMany).toHaveBeenLastCalledWith( expect.objectContaining({ where: { - spaceId: "ws-1", - botId: "bot-1", - userId: "user-1", - threadId: "group-thread-1", + OR: [ + { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "group-thread-1" }, + scheduledForOthers, + ], id: "routine-1", }, }), @@ -410,21 +412,251 @@ describe("schedule tool persistence", () => { await listSchedulesFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1" }); expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { spaceId: "ws-1", botId: "bot-1", userId: "user-1" }, + where: { + OR: [{ spaceId: "ws-1", botId: "bot-1", userId: "user-1" }, scheduledForOthers], + }, }), ); - findFirst.mockClear(); + findMany.mockClear(); await cancelScheduleFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1", routineId: "routine-1", }); - expect(findFirst).toHaveBeenCalledWith( + expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { spaceId: "ws-1", botId: "bot-1", userId: "user-1", id: "routine-1" }, + where: { + OR: [{ spaceId: "ws-1", botId: "bot-1", userId: "user-1" }, scheduledForOthers], + id: "routine-1", + }, }), ); }); }); + +describe("scheduling on behalf of another bot", () => { + const main = { + botId: "main-1", + kind: "main", + spaceId: "home", + userId: "user-1", + threadId: "main-thread", + }; + const bots = [ + { id: "main-1", name: "Chief", spaceId: "home", thread: { id: "chief-dm" } }, + { id: "writer-1", name: "Writer", spaceId: "home", thread: { id: "writer-dm" } }, + { id: "scout-1", name: "Scout", spaceId: "work", thread: { id: "scout-dm" } }, + { id: "scout-2", name: "Scout", spaceId: "lab", thread: { id: "scout-lab-dm" } }, + { id: "atlas-1", name: "Atlas", spaceId: "work", thread: { id: "atlas-dm" } }, + { id: "atlas-2", name: "atlas", spaceId: "home", thread: { id: "atlas-home-dm" } }, + ]; + + function prismaWith(mainConversationThreadId: string | null) { + return { + bot: { findMany: vi.fn(async () => bots) }, + chatGroup: { + findFirst: vi.fn(async () => + mainConversationThreadId ? { thread: { id: mainConversationThreadId } } : null, + ), + }, + } as unknown as Parameters[0]; + } + + it("keeps a schedule with its creator when no bot is named", async () => { + const prisma = prismaWith(null); + await expect(resolveScheduleOwner(prisma, main, undefined)).resolves.toEqual({ + spaceId: "home", + botId: "main-1", + userId: "user-1", + threadId: "main-thread", + }); + await expect(resolveScheduleOwner(prisma, main, "Chief")).resolves.toMatchObject({ + botId: "main-1", + threadId: "main-thread", + }); + expect(prisma.chatGroup.findFirst).not.toHaveBeenCalled(); + }); + + it("lets only the main bot schedule for another bot", async () => { + const prisma = prismaWith(null); + await expect( + resolveScheduleOwner(prisma, { ...main, kind: "specialist" }, "Writer"), + ).resolves.toEqual({ error: "Only the main agent can schedule for another bot." }); + expect(prisma.bot.findMany).not.toHaveBeenCalled(); + }); + + it("wakes in the target's 1:1 thread in its own space unless it is in the main conversation", async () => { + const prisma = prismaWith(null); + await expect(resolveScheduleOwner(prisma, main, "atlas-1")).resolves.toEqual({ + spaceId: "work", + botId: "atlas-1", + userId: "user-1", + threadId: "atlas-dm", + createdByBotId: "main-1", + botName: "Atlas", + }); + expect(prisma.bot.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId: "user-1", + archivedAt: null, + space: { deletingAt: null, memberships: { some: { userId: "user-1" } } }, + }, + }), + ); + expect(prisma.chatGroup.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId: "user-1", + kind: "main", + archivedAt: null, + spaceId: "work", + members: { some: { botId: "atlas-1" } }, + }, + }), + ); + }); + + it("wakes in the main conversation when the target is a member", async () => { + await expect(resolveScheduleOwner(prismaWith("main-thread"), main, "Writer")).resolves.toEqual({ + spaceId: "home", + botId: "writer-1", + userId: "user-1", + threadId: "main-thread", + createdByBotId: "main-1", + botName: "Writer", + }); + }); + + it("resolves names exactly first, then case-insensitively, preferring the creator's space", async () => { + const prisma = prismaWith(null); + await expect(resolveScheduleOwner(prisma, main, "Atlas")).resolves.toMatchObject({ + botId: "atlas-1", + }); + await expect(resolveScheduleOwner(prisma, main, "ATLAS")).resolves.toMatchObject({ + botId: "atlas-2", + }); + await expect(resolveScheduleOwner(prisma, main, "Scout")).resolves.toEqual({ + error: "Several bots are named Scout. Use the bot id.", + }); + await expect(resolveScheduleOwner(prisma, main, "scout-2")).resolves.toMatchObject({ + botId: "scout-2", + spaceId: "lab", + }); + await expect(resolveScheduleOwner(prisma, main, "Nobody")).resolves.toEqual({ + error: "No bot named Nobody.", + }); + }); + + it("records the creating bot and names the target in the result", async () => { + const create = vi.fn(async (args: { data: Record }) => ({ + id: "routine-9", + ...args.data, + })); + const append = vi.fn(async () => undefined); + const deps = { + prisma: { routine: { create } }, + events: { append }, + jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, + } as unknown as Parameters[0]; + + const result = await createScheduleFromTool(deps, { + spaceId: "work", + botId: "atlas-1", + userId: "user-1", + threadId: "atlas-dm", + createdByBotId: "main-1", + botName: "Atlas", + name: "Weekly digest", + prompt: "Summarize the week", + schedule: { cron: "0 9 * * 1" }, + }); + + expect(result).toMatchObject({ ok: true, routineId: "routine-9", bot: "Atlas" }); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + spaceId: "work", + botId: "atlas-1", + threadId: "atlas-dm", + createdByBotId: "main-1", + }), + }), + ); + expect(append).toHaveBeenCalledWith( + expect.objectContaining({ spaceId: "work", threadId: "atlas-dm", botId: "atlas-1" }), + ); + }); + + it("lists routines scheduled for other bots with their bot's name", async () => { + const row = (id: string, botId: string, botName: string) => ({ + id, + botId, + name: `${id} name`, + prompt: "p", + crons: ["0 9 * * *"], + active: true, + nextRunAt: null, + bot: { name: botName }, + }); + const deps = { + prisma: { + routine: { + findMany: vi.fn(async () => [ + row("own", "main-1", "Chief"), + row("for-atlas", "atlas-1", "Atlas"), + ]), + }, + }, + } as unknown as Parameters[0]; + + const listed = await listSchedulesFromTool(deps, { + spaceId: "home", + botId: "main-1", + userId: "user-1", + }); + expect(listed.routines[0]).not.toHaveProperty("bot"); + expect(listed.routines[1]).toMatchObject({ routineId: "for-atlas", bot: "Atlas" }); + }); + + it("refuses to cancel by a name that matches several routines", async () => { + const remove = vi.fn(async () => undefined); + const deps = { + prisma: { + routine: { + findMany: vi.fn(async () => [ + { id: "a", name: "Digest" }, + { id: "b", name: "Digest" }, + ]), + delete: remove, + }, + }, + events: { append: vi.fn(async () => undefined) }, + jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, + } as unknown as Parameters[0]; + + await expect( + cancelScheduleFromTool(deps, { + spaceId: "home", + botId: "main-1", + userId: "user-1", + name: "Digest", + }), + ).resolves.toEqual({ error: "Several schedules have that name. Cancel by routineId." }); + expect(remove).not.toHaveBeenCalled(); + }); + + it("adds the bot option to schedule_create without touching the original definition", () => { + const tool = { + name: "schedule_create", + description: "Create a schedule", + inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + }; + const extended = withScheduleTargetBot(tool); + expect(Object.keys(extended.inputSchema.properties as object)).toEqual(["name", "bot"]); + expect(extended.inputSchema.required).toEqual(["name"]); + expect(Object.keys(tool.inputSchema.properties)).toEqual(["name"]); + }); +}); diff --git a/packages/adapters/src/schedule-tools.ts b/packages/adapters/src/schedule-tools.ts index f3e1b3f..37ce593 100644 --- a/packages/adapters/src/schedule-tools.ts +++ b/packages/adapters/src/schedule-tools.ts @@ -1,4 +1,4 @@ -import type { JobPublisher } from "@rakazo/adapter-kit"; +import type { ConnectorTool, JobPublisher } from "@rakazo/adapter-kit"; import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; import { cronFromPreset, @@ -13,6 +13,25 @@ export { isOneShotRoutineCron, ONCE_ROUTINE_CRON }; export const SCHEDULE_TOOL_NAMES = new Set(["schedule_create", "schedule_list", "schedule_cancel"]); +/** schedule_create as the main bot sees it: it may also schedule for one of the user's bots. */ +export function withScheduleTargetBot(tool: ConnectorTool): ConnectorTool { + const properties = (tool.inputSchema.properties ?? {}) as Record; + return { + ...tool, + inputSchema: { + ...tool.inputSchema, + properties: { + ...properties, + bot: { + type: "string", + description: + "Optional name or id of one of the user's specialists who should run this schedule. Omit to schedule for yourself.", + }, + }, + }, + }; +} + export function filterBuiltinToolsForRun( tools: T[], runTrigger: string, @@ -171,14 +190,95 @@ export interface ScheduleToolDeps { jobs: JobPublisher; } +/** The bot a schedule belongs to and the thread it wakes in. */ +export interface ScheduleOwner { + spaceId: string; + botId: string; + userId: string; + threadId: string; + /** Set when the main bot schedules for another bot. */ + createdByBotId?: string; + botName?: string; +} + +/** + * Without `botRef` a schedule belongs to the creating bot and wakes in its current thread. The + * main bot may name one of the user's other bots in any space the user still belongs to: the + * schedule then belongs to that bot in its space and wakes in the main conversation when the bot + * is a member there, otherwise in the bot's 1:1 thread (`wakeRoutine` accepts only those). + */ +export async function resolveScheduleOwner( + prisma: PrismaClient, + creator: { botId: string; kind: string; spaceId: string; userId: string; threadId: string }, + botRef: string | undefined, +): Promise { + const own = { + spaceId: creator.spaceId, + botId: creator.botId, + userId: creator.userId, + threadId: creator.threadId, + }; + const ref = botRef?.trim(); + if (!ref) return own; + if (creator.kind !== "main") + return { error: "Only the main agent can schedule for another bot." }; + const candidates = await prisma.bot.findMany({ + where: { + userId: creator.userId, + archivedAt: null, + space: { deletingAt: null, memberships: { some: { userId: creator.userId } } }, + }, + select: { id: true, name: true, spaceId: true, thread: { select: { id: true } } }, + }); + const target = pickScheduleTarget(candidates, ref, creator.spaceId); + if ("error" in target) return target; + if (target.id === creator.botId) return own; + if (!target.thread) return { error: `${target.name} has no chat to run in.` }; + const mainConversation = await prisma.chatGroup.findFirst({ + where: { + userId: creator.userId, + kind: "main", + archivedAt: null, + spaceId: target.spaceId, + members: { some: { botId: target.id } }, + }, + select: { thread: { select: { id: true } } }, + }); + return { + spaceId: target.spaceId, + botId: target.id, + userId: creator.userId, + threadId: mainConversation?.thread?.id ?? target.thread.id, + createdByBotId: creator.botId, + botName: target.name, + }; +} + +/** Id first, then exact name, then a case-insensitive name; ties prefer the creator's space. */ +function pickScheduleTarget( + candidates: T[], + ref: string, + preferredSpaceId: string, +): T | { error: string } { + const byId = candidates.find((candidate) => candidate.id === ref); + if (byId) return byId; + for (const matches of [ + candidates.filter((candidate) => candidate.name === ref), + candidates.filter((candidate) => candidate.name.toLowerCase() === ref.toLowerCase()), + ]) { + if (matches.length === 1) return matches[0]!; + if (matches.length === 0) continue; + const local = matches.filter((candidate) => candidate.spaceId === preferredSpaceId); + if (local.length === 1) return local[0]!; + return { error: `Several bots are named ${ref}. Use the bot id.` }; + } + return { error: `No bot named ${ref}.` }; +} + /** Persist and enqueue a bot routine that wakes in the originating thread. */ export async function createScheduleFromTool( deps: ScheduleToolDeps, - input: { - spaceId: string; - botId: string; - userId: string; - threadId: string; + input: ScheduleOwner & { name: string; prompt: string; timezone?: string; @@ -200,6 +300,7 @@ export async function createScheduleFromTool( botId: input.botId, userId: input.userId, threadId: input.threadId, + createdByBotId: input.createdByBotId ?? null, name, prompt, crons: [resolved.cron], @@ -244,22 +345,42 @@ export async function createScheduleFromTool( cron: row.crons[0], nextRunAt: row.nextRunAt?.toISOString() ?? null, oneShot: resolved.oneShot, + ...(input.createdByBotId ? { bot: input.botName } : {}), }; } -/** List bot routines, optionally restricted to the current group thread. */ +/** + * Routines a bot manages: its own (in a group only the ones of that thread) and the ones it + * scheduled for other bots, wherever they wake. + */ +function managedRoutines(input: { + spaceId: string; + botId: string; + userId: string; + threadId?: string; +}) { + return { + OR: [ + { + spaceId: input.spaceId, + botId: input.botId, + userId: input.userId, + ...(input.threadId ? { threadId: input.threadId } : {}), + }, + { userId: input.userId, createdByBotId: input.botId }, + ], + }; +} + +/** List the routines a bot manages; ones it scheduled for another bot name that bot. */ export async function listSchedulesFromTool( deps: Pick, input: { spaceId: string; botId: string; userId: string; threadId?: string }, ) { const rows = await deps.prisma.routine.findMany({ - where: { - spaceId: input.spaceId, - botId: input.botId, - userId: input.userId, - ...(input.threadId ? { threadId: input.threadId } : {}), - }, + where: managedRoutines(input), orderBy: { createdAt: "desc" }, + include: { bot: { select: { name: true } } }, }); return { routines: rows.map((row) => ({ @@ -270,11 +391,12 @@ export async function listSchedulesFromTool( active: row.active, nextRunAt: row.nextRunAt?.toISOString() ?? null, oneShot: isOneShotRoutineCrons(row.crons), + ...(row.botId === input.botId ? {} : { bot: row.bot.name }), })), }; } -/** Cancel a bot routine, optionally restricted to the current group thread. */ +/** Cancel a routine the bot manages by id or by a name that matches exactly one of them. */ export async function cancelScheduleFromTool( deps: ScheduleToolDeps, input: { @@ -292,15 +414,13 @@ export async function cancelScheduleFromTool( return { error: "Provide routineId or the exact schedule name to cancel." }; } - const existing = await deps.prisma.routine.findFirst({ - where: { - spaceId: input.spaceId, - botId: input.botId, - userId: input.userId, - ...(input.threadId ? { threadId: input.threadId } : {}), - ...(routineId ? { id: routineId } : { name: name! }), - }, + const matches = await deps.prisma.routine.findMany({ + where: { ...managedRoutines(input), ...(routineId ? { id: routineId } : { name: name! }) }, + take: 2, }); + if (matches.length > 1) + return { error: "Several schedules have that name. Cancel by routineId." }; + const existing = matches[0]; if (!existing) return { error: "Schedule not found." }; await deps.prisma.routine.delete({ where: { id: existing.id } }); diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 18fe1c7..fb4ff0f 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -320,6 +320,28 @@ export const BOT_NAME_MAX_LENGTH = 80; export const BOT_TITLE_MAX_LENGTH = 500; export const BOT_DESCRIPTION_MAX_LENGTH = 4000; export const BOT_INSTRUCTIONS_MAX_LENGTH = 20000; +export const BOT_TAG_MAX_LENGTH = 40; +export const BOT_TAGS_MAX = 20; +/** Upper bound for an allowlist; the router checks each name against the builtin tools. */ +export const BOT_ALLOWED_TOOLS_MAX = 100; + +const distinct = (values: string[]) => new Set(values).size === values.length; + +const BotTags = z + .array(z.string().trim().min(1).max(BOT_TAG_MAX_LENGTH)) + .max(BOT_TAGS_MAX) + .refine(distinct, { error: "tags must be distinct" }); + +/** Trims tags and drops empty or repeated ones, within the limits the bot inputs enforce. */ +export function normalizeBotTags(values: readonly string[]): string[] { + const tags = values.map((value) => value.trim().slice(0, BOT_TAG_MAX_LENGTH).trim()); + return [...new Set(tags.filter(Boolean))].slice(0, BOT_TAGS_MAX); +} + +const BotAllowedBuiltinTools = z + .array(z.string().min(1).max(64)) + .max(BOT_ALLOWED_TOOLS_MAX) + .refine(distinct, { error: "allowedBuiltinTools must be distinct" }); export const CreateBotInput = z.object({ name: z.string().trim().min(1).max(BOT_NAME_MAX_LENGTH), @@ -331,6 +353,9 @@ export const CreateBotInput = z.object({ computerMode: ComputerModeSchema.default("team"), /** Idempotency key within a space (unique with spaceId). */ spawnKey: z.string().trim().min(1).max(120).optional(), + tags: BotTags.optional(), + restrictBuiltinTools: z.boolean().optional(), + allowedBuiltinTools: BotAllowedBuiltinTools.optional(), }); export type CreateBotInput = z.infer; @@ -365,6 +390,9 @@ export const UpdateBotInput = z thinkingLevel: ThinkingLevelSchema.nullable().optional(), teamChatAmbientEnabled: z.boolean().optional(), teamChatRules: z.string().max(TEAM_CHAT_RULES_MAX_LENGTH).optional(), + tags: BotTags.optional(), + restrictBuiltinTools: z.boolean().optional(), + allowedBuiltinTools: BotAllowedBuiltinTools.optional(), }) .superRefine((value, ctx) => { const providerProvided = value.modelProvider !== undefined; diff --git a/packages/contracts/src/index.test.ts b/packages/contracts/src/index.test.ts index 6f15467..1041bf4 100644 --- a/packages/contracts/src/index.test.ts +++ b/packages/contracts/src/index.test.ts @@ -12,6 +12,7 @@ import { MessageBlock, ModelConnectInputSchema, ModelOAuthBeginSchema, + normalizeBotTags, normalizeCreateBotProfile, ProductEventType, parseModelContextWindow, @@ -171,6 +172,50 @@ describe("contracts", () => { expect(UpdateBotInput.safeParse({ botId: "bot-1", name: " " }).success).toBe(false); }); + it("accepts capability tags and a builtin tool allowlist on bot create and update", () => { + expect( + CreateBotInput.parse({ + name: "Scout", + tags: [" research ", "web"], + restrictBuiltinTools: true, + allowedBuiltinTools: ["web_search"], + }), + ).toMatchObject({ + tags: ["research", "web"], + restrictBuiltinTools: true, + allowedBuiltinTools: ["web_search"], + }); + expect(UpdateBotInput.parse({ botId: "bot-1", allowedBuiltinTools: [] })).toEqual({ + botId: "bot-1", + allowedBuiltinTools: [], + }); + expect(UpdateBotInput.safeParse({ botId: "bot-1", tags: ["a", "a"] }).success).toBe(false); + expect(UpdateBotInput.safeParse({ botId: "bot-1", tags: [" "] }).success).toBe(false); + expect( + UpdateBotInput.safeParse({ + botId: "bot-1", + tags: Array.from({ length: 21 }, (_, index) => `tag-${index}`), + }).success, + ).toBe(false); + expect( + UpdateBotInput.safeParse({ botId: "bot-1", allowedBuiltinTools: ["shell", "shell"] }).success, + ).toBe(false); + }); + + it("normalizes typed tags into what the bot inputs accept", () => { + expect(normalizeBotTags([" research", "", "web ", "research", " "])).toEqual([ + "research", + "web", + ]); + const tags = normalizeBotTags([ + "t".repeat(60), + ...Array.from({ length: 30 }, (_, index) => `tag-${index}`), + ]); + expect(tags).toHaveLength(20); + expect(tags[0]).toHaveLength(40); + expect(UpdateBotInput.safeParse({ botId: "bot-1", tags }).success).toBe(true); + }); + it("rejects partial model override clears on bot update", () => { expect(UpdateBotInput.safeParse({ botId: "bot-1", modelId: null }).success).toBe(false); expect(UpdateBotInput.safeParse({ botId: "bot-1", modelProvider: null }).success).toBe(false); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b3a7091..0442839 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -254,6 +254,8 @@ export const appContract = { duplicate: oc.input(botId).output(BotSchema), reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })), update: oc.input(UpdateBotInput).output(BotSchema), + /** Builtin tool names this bot's allowlist can choose from. */ + builtinTools: oc.input(botId).output(z.array(z.string())), setComputer: oc.input(z.object({ botId: Id, mode: ComputerModeSchema })).output(BotSchema), archive: oc.input(botId).output(z.object({ ok: z.literal(true) })), restore: oc.input(botId).output(z.object({ ok: z.literal(true) })), diff --git a/packages/db/prisma/migrations/20261002210600_ws6_routine_created_by_bot/migration.sql b/packages/db/prisma/migrations/20261002210600_ws6_routine_created_by_bot/migration.sql new file mode 100644 index 0000000..4ef7b17 --- /dev/null +++ b/packages/db/prisma/migrations/20261002210600_ws6_routine_created_by_bot/migration.sql @@ -0,0 +1,7 @@ +-- The main bot can schedule routines for the user's other bots. Remember which bot created a +-- routine so the main bot can list and cancel exactly the ones it scheduled. Like the other +-- provenance ids (user_facts.sourceBotId, delegated_tasks.requesterBotId) it has no foreign key. +ALTER TABLE "routines" ADD COLUMN "createdByBotId" TEXT; + +-- CreateIndex +CREATE INDEX "routines_createdByBotId_idx" ON "routines"("createdByBotId"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 93f9a61..a679e5e 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -753,6 +753,8 @@ model Routine { userId String threadId String? thread Thread? @relation(fields: [threadId], references: [id], onDelete: SetNull) + /// The main bot when it scheduled this routine for another bot; null when the bot or the user created it. + createdByBotId String? name String prompt String crons String[] @@ -770,6 +772,7 @@ model Routine { @@index([active, nextRunAt]) @@index([spaceId, botId]) + @@index([createdByBotId]) @@map("routines") } diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index c8d1c0c..086a950 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -401,6 +401,9 @@ export function createRepos(prisma: PrismaClient) { spawnKey?: string; /** Only the main-agent helpers pass "main"; every other caller creates specialists. */ kind?: BotKind; + tags?: string[]; + restrictBuiltinTools?: boolean; + allowedBuiltinTools?: string[]; modelProvider?: string | null; modelId?: string | null; thinkingLevel?: string | null; @@ -421,6 +424,8 @@ export function createRepos(prisma: PrismaClient) { let modelProvider = input.modelProvider ?? null; let modelId = input.modelId ?? null; let thinkingLevel = input.thinkingLevel ?? null; + let restrictBuiltinTools = input.restrictBuiltinTools ?? false; + let allowedBuiltinTools = input.allowedBuiltinTools ?? []; if (input.parentBotId) { const parent = await prisma.bot.findFirst({ where: { @@ -435,6 +440,13 @@ export function createRepos(prisma: PrismaClient) { modelId = parent.modelId ?? null; } if (thinkingLevel == null) thinkingLevel = parent.thinkingLevel ?? null; + // A bot it creates never gets builtin tools the restricted parent lacks. + if (parent.restrictBuiltinTools) { + allowedBuiltinTools = restrictBuiltinTools + ? allowedBuiltinTools.filter((name) => parent.allowedBuiltinTools.includes(name)) + : parent.allowedBuiltinTools; + restrictBuiltinTools = true; + } } const settings = await prisma.deploymentSettings.findUnique({ where: { id: "default" } }); const envKind = process.env.SANDBOX_PROVIDER ?? "docker"; @@ -471,6 +483,9 @@ export function createRepos(prisma: PrismaClient) { computerId: teamComputer.id, spawnKey: input.spawnKey, kind: input.kind ?? "specialist", + tags: input.tags ?? [], + restrictBuiltinTools, + allowedBuiltinTools, modelProvider, modelId, thinkingLevel, diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 6fe0dd9..f28d592 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -83,6 +83,7 @@ describeWithDatabase("API authorization and resource isolation", () => { ["bots/create", botInput("Unauthenticated")], ["bots/duplicate", { botId: "missing-bot" }], ["bots/update", { botId: "missing-bot", name: "Nope" }], + ["bots/builtinTools", { botId: "missing-bot" }], ["bots/archive", { botId: "missing-bot" }], ["bots/restore", { botId: "missing-bot" }], ["bots/remove", { botId: "missing-bot" }], @@ -310,6 +311,7 @@ describeWithDatabase("API authorization and resource isolation", () => { ["bots/get", { botId: ownerBot.id }], ["bots/duplicate", { botId: ownerBot.id }], ["bots/update", { botId: ownerBot.id, name: "Stolen Bot" }], + ["bots/builtinTools", { botId: ownerBot.id }], ["bots/archive", { botId: ownerBot.id }], ["bots/restore", { botId: ownerBot.id }], ["threads/get", { botId: ownerBot.id }], diff --git a/packages/testkit/src/journeys.test.ts b/packages/testkit/src/journeys.test.ts index 217ec22..91b48e0 100644 --- a/packages/testkit/src/journeys.test.ts +++ b/packages/testkit/src/journeys.test.ts @@ -3,18 +3,24 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { ComposioEmulator, + cancelScheduleFromTool, createScheduleFromTool, DesktopSandboxProvider, FakeSandboxProvider, handoffToGroupBot, + listSchedulesFromTool, ManagedSandboxEmulator, + resolveScheduleOwner, toComputerRef, } from "@rakazo/adapters"; import { ACTIVE_RUN_STATUSES, ONCE_ROUTINE_CRON } from "@rakazo/core"; import { appendEvent, + createRepos, createThreadEvents, createThreadMessage, + ensureMainBot, + joinMainConversation, RunHistoryWriteError, } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; @@ -1233,6 +1239,218 @@ describeJourneys("required product journeys", () => { ).toBe(1); }); + it("5c: the main bot schedules for a specialist in its own space or in the main conversation", async () => { + const cookie = await signup(app, `schedule-behalf-j-${stamp}@rakazo.test`, "Schedule Behalf"); + const me = await rpc(app, cookie, "me"); + const main = await ensureMainBot(prisma, me.userId); + const writer = await rpc(app, cookie, "bots/create", { + name: "Writer", + title: "", + description: "", + instructions: "", + notifyOnFinish: false, + }); + const lab = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Lab" }); + const scout = await createRepos(prisma).createBot( + { userId: me.userId, spaceId: lab.id, email: "", isDeploymentOwner: false }, + { name: "Scout", title: "", description: "", instructions: "", notifyOnFinish: false }, + ); + const scheduleDeps = { prisma, events: createThreadEvents(prisma), jobs }; + const creator = { + botId: main.id, + kind: "main", + spaceId: main.spaceId, + userId: me.userId, + threadId: main.threadId, + }; + + async function scheduleAndWake(botName: string, routineName: string) { + const owner = await resolveScheduleOwner(prisma, creator, botName); + if ("error" in owner) throw new Error(owner.error); + const created = await createScheduleFromTool(scheduleDeps, { + ...owner, + name: routineName, + prompt: `say ${routineName}-ok`, + schedule: { delaySeconds: 30 }, + }); + if (!("ok" in created)) throw new Error(created.error); + expect(created.bot).toBe(botName); + const routine = await prisma.routine.findUniqueOrThrow({ where: { id: created.routineId } }); + const dueAt = new Date(Date.now() - 1_000); + await prisma.routine.update({ where: { id: routine.id }, data: { nextRunAt: dueAt } }); + await executor.wakeRoutine(routine.id, dueAt.toISOString()); + await waitForDatabase(async () => + Boolean(await prisma.run.findFirst({ where: { routineId: routine.id } })), + ); + const run = await prisma.run.findFirstOrThrow({ where: { routineId: routine.id } }); + return { routine, run }; + } + + const inLab = await scheduleAndWake("Scout", "lab-digest"); + expect(inLab.routine).toMatchObject({ + botId: scout.id, + spaceId: lab.id, + threadId: scout.threadId, + createdByBotId: main.id, + }); + expect(inLab.run).toMatchObject({ + botId: scout.id, + spaceId: lab.id, + threadId: scout.threadId, + trigger: "routine", + }); + + await joinMainConversation(prisma, { userId: me.userId, botIds: [writer.id] }); + const conversation = await prisma.chatGroup.findFirstOrThrow({ + where: { userId: me.userId, kind: "main" }, + include: { thread: true }, + }); + const inMain = await scheduleAndWake("Writer", "main-digest"); + expect(inMain.routine).toMatchObject({ + botId: writer.id, + threadId: conversation.thread?.id, + createdByBotId: main.id, + }); + expect(inMain.run).toMatchObject({ botId: writer.id, threadId: conversation.thread?.id }); + + const listed = await listSchedulesFromTool(scheduleDeps, { + spaceId: main.spaceId, + botId: main.id, + userId: me.userId, + threadId: conversation.thread?.id, + }); + expect(listed.routines.map((routine) => [routine.name, routine.bot])).toEqual( + expect.arrayContaining([ + ["lab-digest", "Scout"], + ["main-digest", "Writer"], + ]), + ); + expect( + await listSchedulesFromTool(scheduleDeps, { + spaceId: lab.id, + botId: scout.id, + userId: me.userId, + }), + ).toMatchObject({ routines: [{ name: "lab-digest" }] }); + + expect( + await cancelScheduleFromTool(scheduleDeps, { + spaceId: main.spaceId, + botId: main.id, + userId: me.userId, + name: "lab-digest", + }), + ).toMatchObject({ ok: true, routineId: inLab.routine.id }); + expect(await prisma.routine.findUnique({ where: { id: inLab.routine.id } })).toBeNull(); + // Another specialist neither owns nor scheduled the Writer's routine. + expect( + await cancelScheduleFromTool(scheduleDeps, { + spaceId: lab.id, + botId: scout.id, + userId: me.userId, + name: "main-digest", + }), + ).toEqual({ error: "Schedule not found." }); + expect(await prisma.routine.findUnique({ where: { id: inMain.routine.id } })).not.toBeNull(); + + const retired = await rpc(app, cookie, "bots/create", { + name: "Retired", + title: "", + description: "", + instructions: "", + notifyOnFinish: false, + }); + await rpc(app, cookie, "bots/archive", { botId: retired.id }); + expect(await resolveScheduleOwner(prisma, creator, "Retired")).toEqual({ + error: "No bot named Retired.", + }); + expect( + await resolveScheduleOwner( + prisma, + { ...creator, botId: scout.id, kind: "specialist" }, + "Writer", + ), + ).toEqual({ error: "Only the main agent can schedule for another bot." }); + }); + + it("5d: a bot's builtin tool allowlist holds in its runs and in bots it creates", async () => { + const cookie = await signup(app, `allowlist-j-${stamp}@rakazo.test`, "Allowlist"); + const bot = await rpc( + app, + cookie, + "bots/create", + { + name: "Reader", + title: "", + description: "", + instructions: "", + notifyOnFinish: false, + tags: ["research"], + restrictBuiltinTools: true, + allowedBuiltinTools: ["read_file"], + }, + ); + expect(bot).toMatchObject({ + tags: ["research"], + restrictBuiltinTools: true, + allowedBuiltinTools: ["read_file"], + }); + expect(await rpc(app, cookie, "bots/builtinTools", { botId: bot.id })).toEqual( + expect.arrayContaining(["read_file", "write_file", "schedule_create"]), + ); + + await sendAndWait( + app, + cookie, + bot.id, + "write a file in your home called notes/blocked.txt that says blocked", + ); + await expect( + rpc(app, cookie, "computer/readFile", { botId: bot.id, path: "notes/blocked.txt" }), + ).rejects.toThrow(); + + await expect( + rpc(app, cookie, "bots/update", { botId: bot.id, allowedBuiltinTools: ["write_fiel"] }), + ).rejects.toThrow(/Unknown tools: write_fiel/); + await rpc(app, cookie, "bots/update", { + botId: bot.id, + allowedBuiltinTools: ["write_file", "spawn_bot"], + }); + await sendAndWait( + app, + cookie, + bot.id, + "write a file in your home called notes/allowed.txt that says allowed-ok", + ); + expect( + ( + await rpc<{ content: string }>(app, cookie, "computer/readFile", { + botId: bot.id, + path: "notes/allowed.txt", + }) + ).content, + ).toContain("allowed-ok"); + + await sendAndWait(app, cookie, bot.id, "create a bot named Junior"); + const child = await prisma.bot.findFirstOrThrow({ where: { parentBotId: bot.id } }); + expect(child).toMatchObject({ + name: "Junior", + restrictBuiltinTools: true, + allowedBuiltinTools: ["write_file", "spawn_bot"], + }); + + const copy = await rpc<{ restrictBuiltinTools: boolean; allowedBuiltinTools: string[] }>( + app, + cookie, + "bots/duplicate", + { botId: bot.id }, + ); + expect(copy).toMatchObject({ + restrictBuiltinTools: true, + allowedBuiltinTools: ["write_file", "spawn_bot"], + }); + }); + it("allocates event and message cursors atomically under concurrent writes", async () => { const cookie = await signup(app, `sequence-j-${stamp}@rakazo.test`, "Sequence"); const actor = await rpc(app, cookie, "me");