diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index fd747f8..add070a 100644 --- a/agent-langgraph/src/index.ts +++ b/agent-langgraph/src/index.ts @@ -235,13 +235,20 @@ const TOOL_URL = const TOOL_TOKEN = process.env.AGENT_TOOL_TOKEN ?? ""; async function callTool( - botId: string, + run: string, name: string, args: Record, ): Promise { if (!TOOL_TOKEN) { return "Refused. This Bot has no credential for calling tools back through its deployment."; } + if (!run) { + /* + * No statement from the deployment about whose run this is, so there is nothing to act on behalf + * of. Reported as a result rather than thrown: the run continues and says what it could not do. + */ + return "Refused. This run carried no signed statement of which Bot and person it is for."; + } try { const response = await fetch(TOOL_URL, { method: "POST", @@ -249,7 +256,14 @@ async function callTool( "content-type": "application/json", "x-openbot-agent-token": TOOL_TOKEN, }, - body: JSON.stringify({ botId, name, args }), + /* + * The deployment's own statement, handed straight back. + * + * The Bot and the actor used to be sent from here, which meant this process asserted who it was + * acting for. It is not in a position to know, and anything holding the token could claim + * anything, so the deployment says it and this only carries the note. + */ + body: JSON.stringify({ name, args, run }), }); const body = (await response.json()) as { text?: string }; return body.text ?? "The tool returned nothing."; @@ -261,10 +275,43 @@ async function callTool( } } -/** Which Bot is running, so the deployment can attribute the call it is about to be asked for. */ -function botIdOf(input: RunAgentInput): string { - const props = input.forwardedProps as { openbotBotId?: unknown } | undefined; - return typeof props?.openbotBotId === "string" ? props.openbotBotId : ""; +/** + * The deployment's signed statement of what this run is. + * + * Opaque here on purpose: this process cannot read it and has no reason to. It carries it back when it + * calls a tool, and the deployment that signed it is the only thing that can open it. + */ +function runAssertionOf(input: RunAgentInput): string { + const props = input.forwardedProps as { openbotRun?: unknown } | undefined; + return typeof props?.openbotRun === "string" ? props.openbotRun : ""; +} + +/** + * The tools this deployment runs, as opposed to the ones the surface draws. + * + * Both arrive in the same list and no naming rule separates them, so the deployment names its own. + * Absent, nothing is treated as the deployment's: a Bot that guessed wrong would either apologise for + * a component it did show, or quietly report a governed tool as drawn without ever calling it. The + * first is embarrassing and the second is a lie about governance, so an unmarked run does neither. + */ +function deploymentToolsOf(input: RunAgentInput): Set { + const props = input.forwardedProps as + | { openbotDeploymentTools?: unknown } + | undefined; + const names = props?.openbotDeploymentTools; + return new Set( + Array.isArray(names) + ? names.filter((name) => typeof name === "string") + : [], + ); +} + +/** Did the model reach for something the surface owns rather than something this deployment runs? */ +function callsTheSurface( + calls: { name: string }[], + ours: Set, +): boolean { + return calls.some((call) => !ours.has(call.name)); } /** @@ -279,10 +326,11 @@ function botIdOf(input: RunAgentInput): string { */ function buildGraph(input: RunAgentInput) { const model = buildModel(); - const botId = botIdOf(input); + const run = runAssertionOf(input); const tools = toBoundTools(input); const bound = tools.length > 0 ? model.bindTools(tools) : model; + const ours = deploymentToolsOf(input); return new StateGraph(MessagesAnnotation) .addNode("answer", async (state) => ({ @@ -291,25 +339,48 @@ function buildGraph(input: RunAgentInput) { .addNode("tools", async (state) => { const last = state.messages.at(-1) as AIMessage; const results = await Promise.all( - (last.tool_calls ?? []).map(async (call) => { - const text = await callTool( - botId, - call.name, - (call.args ?? {}) as Record, - ); - return new ToolMessage({ - content: text, - tool_call_id: call.id ?? call.name, - name: call.name, - }); - }), + /* + * Only this deployment's own tools. A component is drawn by the surface, and a decision is + * answered there by a person, so neither is executed here and neither gets a result invented + * here. The run ends instead, and the surface starts the next one carrying what it produced. + */ + (last.tool_calls ?? []) + .filter((call) => ours.has(call.name)) + .map(async (call) => { + const text = await callTool( + run, + call.name, + (call.args ?? {}) as Record, + ); + return new ToolMessage({ + content: text, + tool_call_id: call.id ?? call.name, + name: call.name, + }); + }), ); return { messages: results }; }) .addEdge(START, "answer") .addConditionalEdges("answer", (state) => { const last = state.messages.at(-1) as AIMessage | undefined; - return (last?.tool_calls?.length ?? 0) > 0 ? "tools" : END; + const calls = last?.tool_calls ?? []; + if (calls.length === 0) return END; + /* + * A call the surface owns ends the run. + * + * This is how a tool that lives in the browser is supposed to work: the Bot asks for it, the + * run finishes, the surface draws it or puts the question to a person, and the surface begins + * the next run with the answer in hand. Running the loop through it here instead invents a + * result: the Bot apologises for a chart the person is looking at, and an approval card that + * has already been answered on its behalf sits waiting for a click that can never land. + * + * A turn that asks for both kinds at once ends too, and the model asks again for what it still + * has no answer to. That is the rarer case and the safe way round: the alternative runs a + * governed tool whose result nobody is waiting for. + */ + if (callsTheSurface(calls, ours)) return END; + return "tools"; }) .addEdge("tools", "answer") .compile(); diff --git a/app/src/components/agents/agent-profile.tsx b/app/src/components/agents/agent-profile.tsx index e6fcfe1..3e3c0e9 100644 --- a/app/src/components/agents/agent-profile.tsx +++ b/app/src/components/agents/agent-profile.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "@tanstack/react-router"; import { type ReactNode, useState } from "react"; import { AbstractAvatar } from "@/components/agents/abstract-avatar"; import { AgentFields } from "@/components/agents/agent-fields"; +import { CallbackTokenPanel } from "@/components/agents/callback-token-panel"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; @@ -142,6 +143,17 @@ export function AgentProfile({ agentId }: { agentId: string }) { )} + {/* + * Only for a coworker that runs somewhere else, and only for somebody who may change it. + * The Bot in the box has no endpoint and nothing to authenticate as. + */} + {!isEditing && profile.endpoint && profile.canManage ? ( + + ) : null} + {actionError ? (

{actionError.message} diff --git a/app/src/components/agents/callback-token-panel.tsx b/app/src/components/agents/callback-token-panel.tsx new file mode 100644 index 0000000..3aea8ea --- /dev/null +++ b/app/src/components/agents/callback-token-panel.tsx @@ -0,0 +1,103 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + issueCallbackTokenMutationOptions, + revokeCallbackTokenMutationOptions, +} from "@/lib/agents/mutations"; + +/** + * The credential this coworker presents when it calls a tool back. + * + * Only for a coworker that runs somewhere else. The Bot in the box has no endpoint and nothing to + * authenticate as, and offering it a token would imply otherwise. + * + * Shown once, and said plainly. This deployment keeps a hash, so there is no screen that can show the + * token again: an operator who loses it rotates, and rotating retires the old one, which is also how a + * leak is handled. + * + * A coworker with no token can still hold a conversation. What it cannot do is reach anything outside + * one, which is the right default for a URL somebody pasted. + */ +export function CallbackTokenPanel({ + agentId, + hasToken, +}: { + agentId: string; + hasToken: boolean; +}) { + const queryClient = useQueryClient(); + const issue = useMutation(issueCallbackTokenMutationOptions(queryClient)); + const revoke = useMutation(revokeCallbackTokenMutationOptions(queryClient)); + + /** Held in state, not in a query cache: it must not survive a refetch or a navigation. */ + const [token, setToken] = useState(null); + const error = issue.error ?? revoke.error; + + return ( +

+

+ Calling tools back +

+ +

+ {hasToken + ? "This coworker holds a credential, so it can use the tools it has been granted. Rotating replaces it, and the old one stops working straight away." + : "This coworker has no credential, so it can hold a conversation but cannot use any tool it has been granted. Generate one and put it in that agent's configuration."} +

+ + {token ? ( +
+

+ Copy this now. It will not be shown again. +

+ {/* + * Selectable and wrapped rather than a copy button alone: somebody pasting this into a + * deployment config on another machine may not have a clipboard between the two. + */} + + {token} + +

+ The deployment keeps only a hash of it, so nothing here can show it + to you a second time. +

+ +
+ ) : ( +
+ + {hasToken ? ( + + ) : null} +
+ )} + + {error ? ( +

+ {error.message} +

+ ) : null} +
+ ); +} diff --git a/app/src/lib/agents/mutations.ts b/app/src/lib/agents/mutations.ts index a8f8eef..fc73e8a 100644 --- a/app/src/lib/agents/mutations.ts +++ b/app/src/lib/agents/mutations.ts @@ -97,3 +97,35 @@ export function deleteAgentMutationOptions(queryClient: QueryClient) { onSuccess: () => invalidateAgents(queryClient), }); } + +/** + * Issue this coworker a credential for calling tools back, and hand it over once. + * + * The token is in this response and nowhere else, ever again, so the caller has to show it to the + * person immediately. Calling this on a coworker that already has one rotates it, which is how a + * leaked token is retired. + */ +export function issueCallbackTokenMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (agentId: string): Promise => { + const response = await agentRequest( + `/api/agents/${agentId}/callback-token`, + { method: "POST" }, + ); + return ((await response.json()) as { token: string }).token; + }, + onSuccess: () => invalidateAgents(queryClient), + }); +} + +/** Take the credential away. The coworker may still talk; it may not reach anything outside a chat. */ +export function revokeCallbackTokenMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (agentId: string) => { + await agentRequest(`/api/agents/${agentId}/callback-token`, { + method: "DELETE", + }); + }, + onSuccess: () => invalidateAgents(queryClient), + }); +} diff --git a/app/src/lib/agents/queries.ts b/app/src/lib/agents/queries.ts index 91c5224..bf027b9 100644 --- a/app/src/lib/agents/queries.ts +++ b/app/src/lib/agents/queries.ts @@ -19,6 +19,13 @@ export type AgentProfile = { endpoint: string | null; /** Whether a key is set for it. Never the key itself. */ hasAuth: boolean; + /** + * Whether this coworker holds a credential for calling tools back. + * + * A boolean, because the token is readable exactly once: in the response that issued it. The + * surface needs this only to decide between offering "generate" and "rotate". + */ + hasCallbackToken: boolean; hidden: boolean; systemOwned: boolean; canManage: boolean; diff --git a/server/drizzle/0001_swift_morph.sql b/server/drizzle/0001_swift_morph.sql new file mode 100644 index 0000000..7f88eb1 --- /dev/null +++ b/server/drizzle/0001_swift_morph.sql @@ -0,0 +1,2 @@ +ALTER TABLE "agent_profiles" ADD COLUMN "callback_token_hash" text;--> statement-breakpoint +ALTER TABLE "agent_profiles" ADD COLUMN "callback_token_issued_at" timestamp with time zone; \ No newline at end of file diff --git a/server/drizzle/meta/0001_snapshot.json b/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..a52527b --- /dev/null +++ b/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,2515 @@ +{ + "id": "3fd9e9ec-f351-4dbc-ad12-7d887be8d0ce", + "prevId": "084d702b-09fd-44df-a60a-22477be02359", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": ["channel_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": ["channel_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": ["last_message_agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chunks": { + "name": "chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chunks_document_position_idx": { + "name": "chunks_document_position_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chunks_document_idx": { + "name": "chunks_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chunks_document_id_documents_id_fk": { + "name": "chunks_document_id_documents_id_fk", + "tableFrom": "chunks", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_cursors": { + "name": "connector_cursors", + "schema": "", + "columns": { + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_cursors_connector_instance_id_connector_instances_id_fk": { + "name": "connector_cursors_connector_instance_id_connector_instances_id_fk", + "tableFrom": "connector_cursors", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_instances": { + "name": "connector_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "connector_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_instances_credential_id_credentials_id_fk": { + "name": "connector_instances_credential_id_credentials_id_fk", + "tableFrom": "connector_instances", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_acls": { + "name": "document_acls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "acl_effect", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_acls_document_principal_effect_idx": { + "name": "document_acls_document_principal_effect_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effect", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_acls_principal_idx": { + "name": "document_acls_principal_idx", + "columns": [ + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_acls_document_id_documents_id_fk": { + "name": "document_acls_document_id_documents_id_fk", + "tableFrom": "document_acls", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_connector_source_idx": { + "name": "documents_connector_source_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_connector_deleted_idx": { + "name": "documents_connector_deleted_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_connector_instance_id_connector_instances_id_fk": { + "name": "documents_connector_instance_id_connector_instances_id_fk", + "tableFrom": "documents", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stats": { + "name": "stats", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sync_runs_connector_started_at_idx": { + "name": "sync_runs_connector_started_at_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_runs_connector_instance_id_connector_instances_id_fk": { + "name": "sync_runs_connector_instance_id_connector_instances_id_fk", + "tableFrom": "sync_runs", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": ["user_id", "role"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_subscriptions": { + "name": "webhook_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_subscriptions_connector_instance_id_connector_instances_id_fk": { + "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk", + "tableFrom": "webhook_subscriptions", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": ["user_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": ["component_name", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": ["component_name", "function_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": ["server_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": ["kind", "ref", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.acl_effect": { + "name": "acl_effect", + "schema": "public", + "values": ["allow", "deny"] + }, + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": ["built_in", "remote_ag_ui"] + }, + "public.connector_type": { + "name": "connector_type", + "schema": "public", + "values": ["google_drive", "onedrive"] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": ["model", "connector", "agent", "mcp"] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": ["admin", "user"] + }, + "public.sync_status": { + "name": "sync_status", + "schema": "public", + "values": ["pending", "running", "succeeded", "failed"] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 540bea2..64c4fbf 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1786891733850, "tag": "0000_schema", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1787198911059, + "tag": "0001_swift_morph", + "breakpoints": true } ] } diff --git a/server/src/agents/callback-token.ts b/server/src/agents/callback-token.ts new file mode 100644 index 0000000..7106a84 --- /dev/null +++ b/server/src/agents/callback-token.ts @@ -0,0 +1,210 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { sign, verify } from "../auth/signed-value"; + +/** + * What an agent presents when it calls a tool back, and on whose behalf. + * + * Two separate things, because they answer different questions and one cannot answer both. + * + * The **token** says which agent is calling. It is issued per agent, held by whoever runs that agent, + * and stored here only as a hash: we issue it and we only ever need to check one, so keeping the + * token itself would make a database dump a working credential for every registered agent. + * + * The **run assertion** says which Bot and which person the call is for. A token cannot carry that: + * it is minted once and reused for months, while the answer changes every run. Before this existed, + * `/api/agent-tools/call` read the Bot and the actor out of the request body, so anything holding the + * one deployment-wide token could spend any Bot's grants and write any person's name into the audit + * trail. The trail is the product; a forgeable trail is worse than no trail. + * + * The two are checked against each other. An assertion names the Bot it was issued for, and a call is + * refused unless that Bot is the one the presented token belongs to, so an agent cannot replay an + * assertion it happened to see and act as somebody else's Bot. + */ + +/** Recognisable on sight, so a leaked one can be found in a log or by a secret scanner. */ +const TOKEN_PREFIX = "obot_agt_"; + +/** + * Long enough that guessing is not a strategy. + * + * 32 bytes, which is the same material the deployment's other secrets use. There is no reason to be + * thriftier here: nobody types this. + */ +const TOKEN_BYTES = 32; + +/** Signed under its own label, so a signature here can never be replayed as a visitor's cookie. */ +const RUN_LABEL = "openbot:agent-run"; + +/** + * How long an assertion is good for. + * + * A run is not instant: a Bot may answer, call a tool, read the result and call another. Ten minutes + * covers a slow tool loop with room to spare, and bounds what a captured assertion is worth. + */ +const RUN_TTL_MS = 10 * 60 * 1000; + +export function mintCallbackToken(): string { + return `${TOKEN_PREFIX}${randomBytes(TOKEN_BYTES).toString("base64url")}`; +} + +export function hashCallbackToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +/** Is this even shaped like one of ours? Cheap, and keeps obvious rubbish out of a database lookup. */ +export function looksLikeCallbackToken(value: string): boolean { + return ( + value.startsWith(TOKEN_PREFIX) && value.length > TOKEN_PREFIX.length + 20 + ); +} + +/** + * Compare two hashes without leaking how much of one was right. + * + * The hashes are the same length by construction, so a length mismatch is malformed input rather than + * a near miss. + */ +export function sameToken(a: string, b: string): boolean { + const left = Buffer.from(a); + const right = Buffer.from(b); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); +} + +export type RunAssertion = { + /** The Bot this run is for. Checked against the agent the token belongs to. */ + botId: string; + /** Who the deployment resolved this run to. What the audit row will say. */ + actorId: string; + /** The run itself, so a trail can tie a tool call to the answer it informed. */ + runId: string; +}; + +type SignedRun = RunAssertion & { exp: number }; + +/** + * Mint the assertion for one run. + * + * Sent to the agent in `forwardedProps` rather than a header, because that is the part of an AG-UI + * run a framework hands back to the code the customer writes. A header would arrive at their HTTP + * layer and be gone by the time their tool call needs it. + */ +export function mintRunAssertion( + run: RunAssertion, + encryptionKey: string, + now: number = Date.now(), +): string { + const payload: SignedRun = { ...run, exp: now + RUN_TTL_MS }; + const value = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return sign(value, encryptionKey, RUN_LABEL); +} + +/** + * The assertion a call carries, or nothing. + * + * Nothing on any doubt: a bad signature, an expired one, a payload that is not the right shape. The + * caller refuses when this returns null, so every unclear case fails closed. + */ +export function readRunAssertion( + signed: unknown, + encryptionKey: string, + now: number = Date.now(), +): RunAssertion | null { + if (typeof signed !== "string" || !signed) return null; + + const value = verify(signed, encryptionKey, RUN_LABEL); + if (!value) return null; + + try { + const payload = JSON.parse( + Buffer.from(value, "base64url").toString("utf8"), + ) as Partial; + if ( + typeof payload.botId !== "string" || + typeof payload.actorId !== "string" || + typeof payload.runId !== "string" || + typeof payload.exp !== "number" + ) { + return null; + } + if (payload.exp <= now) return null; + return { + botId: payload.botId, + actorId: payload.actorId, + runId: payload.runId, + }; + } catch { + return null; + } +} + +export type CallVerdict = + | { ok: true; botId: string; actorId: string } + | { ok: false; status: 401 | 403; reason: string }; + +/** + * May this call proceed, and as whom? + * + * Pure, and separate from the route, because this is the whole of the security decision and it should + * be readable and testable without standing up an app. The route's job is to turn the verdict into a + * response. + * + * Two credentials, checked against each other: + * the token says which agent is calling, the assertion says which Bot and person the run is for, and + * an agent may only act as the Bot its token was issued for. Everything unclear is a refusal. + */ +export async function authoriseAgentCall(options: { + /** The `x-openbot-agent-token` header, as presented. */ + presented: string; + /** The `run` field from the body, as presented. */ + run: unknown; + /** The deployment key the assertion was signed with. */ + encryptionKey: string; + /** + * The deployment-wide token, still accepted for the Bots that ship in the box. + * + * It authenticates only. The Bot and the actor still come from the assertion, so it cannot be used + * to spend another Bot's grants or to write somebody else's name into the trail. Empty disables it. + */ + legacyToken?: string; + /** Which agent holds a token hash, if any. */ + lookup: (hash: string) => Promise<{ id: string } | null>; + now?: number; +}): Promise { + const { presented, run, encryptionKey, legacyToken, lookup, now } = options; + + if (!presented) return { ok: false, status: 401, reason: "Not authorised." }; + + const assertion = readRunAssertion(run, encryptionKey, now); + /* + * Refused without saying which half was wrong. + * + * A caller learning that its token was accepted but its assertion was stale has learned that its + * token is good, which is the useful half of a guess. + */ + if (!assertion) return { ok: false, status: 401, reason: "Not authorised." }; + + const caller = looksLikeCallbackToken(presented) + ? await lookup(hashCallbackToken(presented)) + : legacyToken && sameToken(presented, legacyToken) + ? { id: assertion.botId } + : null; + + if (!caller) return { ok: false, status: 401, reason: "Not authorised." }; + + /* + * An agent may only act as the Bot it was issued for. + * + * Without this, an assertion seen once could be replayed by any other credentialled agent to borrow + * that Bot's grants. The token says who is calling; this says they may not be somebody else. + */ + if (caller.id !== assertion.botId) { + return { + ok: false, + status: 403, + reason: "That token is not for this Bot.", + }; + } + + return { ok: true, botId: assertion.botId, actorId: assertion.actorId }; +} diff --git a/server/src/agents/profile-store.ts b/server/src/agents/profile-store.ts index 02c9755..a6f8da7 100644 --- a/server/src/agents/profile-store.ts +++ b/server/src/agents/profile-store.ts @@ -8,6 +8,11 @@ import { deploymentPackages, } from "../db/schema"; import { authFromConfiguration, storeAgentAuth } from "./auth-header"; +import { + hashCallbackToken, + mintCallbackToken, + sameToken, +} from "./callback-token"; import { canManageAgent } from "./profile-policy"; import type { AgentActor, @@ -47,6 +52,23 @@ export type AgentProfileStore = { duplicate(actor: AgentActor, id: string): Promise; setHidden(actor: AgentActor, id: string, hidden: boolean): Promise; softDelete(actor: AgentActor, id: string): Promise; + /** + * Issue this agent a credential for calling tools back, and return it once. + * + * Returned rather than stored: only the hash is kept, so this is the one moment the token exists in + * a readable form. Calling it again replaces the old one, which is how rotation works and how a + * leaked token is retired. + */ + issueCallbackToken(actor: AgentActor, id: string): Promise; + /** Take the credential away. The agent may talk, and may no longer call anything back. */ + revokeCallbackToken(actor: AgentActor, id: string): Promise; + /** + * Which agent holds this token, if any. + * + * By hash, because that is all this side keeps. Not scoped to an actor: the caller is a machine + * presenting a credential, and the credential is the whole of its claim. + */ + agentForCallbackToken(hash: string): Promise<{ id: string } | null>; }; export class AgentNotFoundError extends Error { @@ -81,6 +103,8 @@ const joinedProjection = { packageId: deploymentPackages.id, hiddenAt: agentPreferences.hiddenAt, deletedAt: agentProfiles.deletedAt, + /* The hash, only so a surface can say whether one exists. It never leaves this module. */ + callbackTokenHash: agentProfiles.callbackTokenHash, configuration: agents.configuration, }; @@ -122,6 +146,7 @@ function mapProfile( visibility: row.visibility, ownerUserId: row.ownerUserId, systemOwned: row.packageId !== null, + hasCallbackToken: row.callbackTokenHash !== null, hidden: row.hiddenAt !== null, deletedAt: row.deletedAt, endpoint: endpointOf(row.configuration), @@ -197,6 +222,36 @@ function newAgentId() { return `agent_${crypto.randomUUID()}`; } +/** + * Which agent a token belongs to. + * + * Selected by hash and then compared in constant time. The lookup alone would be enough to identify + * the row, and the comparison is what keeps a timing difference from confirming a partial guess + * against an index. + */ +async function findByTokenHash( + database: Database, + hash: string, +): Promise<{ id: string } | null> { + const rows = await database + .select({ + agentId: agentProfiles.agentId, + hash: agentProfiles.callbackTokenHash, + }) + .from(agentProfiles) + .where( + and( + eq(agentProfiles.callbackTokenHash, hash), + isNull(agentProfiles.deletedAt), + ), + ) + .limit(1); + + const row = rows[0]; + if (!row?.hash) return null; + return sameToken(row.hash, hash) ? { id: row.agentId } : null; +} + export function createAgentProfileStore( database: Database, managedAgentAgUiUrl: URL, @@ -404,5 +459,63 @@ export function createAgentProfileStore( { isolationLevel: "read committed" }, ); }, + + issueCallbackToken(actor, id) { + return database.transaction( + async (transaction) => { + await lockProfileMutationRows(transaction, id); + const profile = await findAccessibleProfile(transaction, actor, id); + if (!profile) throw new AgentNotFoundError(id); + /* + * Whoever may change the agent may credential it. + * + * The same gate as renaming it or repointing its endpoint, and repointing the endpoint is + * the more dangerous of the two: it decides which process the token is for. + */ + requireManageable(actor, profile); + + const token = mintCallbackToken(); + const issuedAt = new Date(); + await transaction + .update(agentProfiles) + .set({ + callbackTokenHash: hashCallbackToken(token), + callbackTokenIssuedAt: issuedAt, + updatedAt: issuedAt, + }) + .where(eq(agentProfiles.agentId, id)); + + // The only time it is readable. Nothing here writes it to a log. + return token; + }, + { isolationLevel: "read committed" }, + ); + }, + + revokeCallbackToken(actor, id) { + return database.transaction( + async (transaction) => { + await lockProfileMutationRows(transaction, id); + const profile = await findAccessibleProfile(transaction, actor, id); + if (!profile) throw new AgentNotFoundError(id); + requireManageable(actor, profile); + + const now = new Date(); + await transaction + .update(agentProfiles) + .set({ + callbackTokenHash: null, + callbackTokenIssuedAt: null, + updatedAt: now, + }) + .where(eq(agentProfiles.agentId, id)); + }, + { isolationLevel: "read committed" }, + ); + }, + + agentForCallbackToken(hash) { + return findByTokenHash(database, hash); + }, }; } diff --git a/server/src/agents/profile-types.ts b/server/src/agents/profile-types.ts index ad2e2f6..57dfe39 100644 --- a/server/src/agents/profile-types.ts +++ b/server/src/agents/profile-types.ts @@ -20,6 +20,13 @@ export type AgentProfile = { endpoint: string | null; /** Whether a key is set for it. Never the key. */ hasAuth: boolean; + /** + * Whether this agent holds a credential for calling tools back. + * + * A boolean, never the token: the token exists in a readable form once, in the response that issued + * it. A surface only needs to know whether to offer "generate" or "rotate". + */ + hasCallbackToken: boolean; }; export type CreateAgentInput = Pick< diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index 303ec8e..a48d15b 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -307,6 +307,39 @@ export function createAgentRoutes( } }); + /* + * Issue this agent its callback credential, and show it once. + * + * A POST because it writes and because it replaces: calling it again rotates, which is how a leaked + * token is retired. The token is in the response and nowhere else, ever again, and it is not written + * to the audit payload either: a trail that records credentials is a credential store with worse + * access control. + */ + routes.post("/:agentId/callback-token", requireUser, async (context) => { + try { + const token = await store.issueCallbackToken( + context.var.actor, + context.req.param("agentId"), + ); + return context.json({ token }, 201); + } catch (error) { + return mapStoreError(context, error); + } + }); + + /** Take it away. The agent may still hold a conversation; it may not reach anything outside one. */ + routes.delete("/:agentId/callback-token", requireUser, async (context) => { + try { + await store.revokeCallbackToken( + context.var.actor, + context.req.param("agentId"), + ); + return context.body(null, 204); + } catch (error) { + return mapStoreError(context, error); + } + }); + routes.delete("/:agentId", requireUser, async (context) => { try { await store.softDelete(context.var.actor, context.req.param("agentId")); @@ -345,6 +378,8 @@ function agentDto(actor: AgentActor, agent: AgentProfile) { // and any credential for it lives in the vault, never in this row. endpoint: agent.endpoint, hasAuth: agent.hasAuth, + // Whether one exists, never what it is. + hasCallbackToken: agent.hasCallbackToken, canManage: canManageAgent(actor, agent), // Ownership, kept separate from permission. `canManage` is also true for an administrator on // another user's coworker, so a roster that split "mine" on it would file other people's work diff --git a/server/src/app.ts b/server/src/app.ts index 4248909..35ac76d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -23,6 +23,7 @@ import type { ComputerClient } from "./computer/client"; import type { ComputerGateway } from "./computer/gateway"; import type { PolicyStore } from "./computer/policy-store"; import { createComputerRoutes } from "./computer/routes"; +import { authoriseAgentCall } from "./agents/callback-token"; import type { DeploymentConfig } from "./config"; import type { ConnectorAdminService } from "./connectors"; import type { CredentialAdminService, CredentialInput } from "./credentials"; @@ -357,34 +358,55 @@ export function createApp( * no person behind it. Absent secret means the route does not exist: a deployment that has not * configured this refuses rather than accepting anybody who can reach the port. */ - if (pluginStore && config.agentToolToken) { - const token = config.agentToolToken; + if (pluginStore) { + const legacyToken = config.agentToolToken ?? ""; app.post("/api/agent-tools/call", async (context) => { - if (context.req.header("x-openbot-agent-token") !== token) { - return context.json({ error: "Not authorised." }, 401); - } + /* + * Who is calling, and on whose behalf. Two questions, two credentials. + * + * The header says which agent: its own token, issued to it, stored here only as a hash. The + * body's `run` says which Bot and which person, signed by this deployment for this run. + * + * Both are required, and they are checked against each other. This used to be one + * deployment-wide token with the Bot and the actor read straight out of the body, which meant + * anything holding that token could spend any Bot's grants and write any name into the audit + * trail. A forgeable trail is worse than no trail, because it is believed. + */ const body = (await context.req.json().catch(() => null)) as { - botId?: string; - actorId?: string; name?: string; args?: Record; + run?: unknown; } | null; - if (!body?.botId || !body.name) { - return context.json({ error: "A Bot and a tool are required." }, 400); + + const verdict = await authoriseAgentCall({ + presented: context.req.header("x-openbot-agent-token") ?? "", + run: body?.run, + encryptionKey: config.keyEncryptionKey, + legacyToken, + lookup: async (hash) => + (await agentProfileStore?.agentForCallbackToken(hash)) ?? null, + }); + if (!verdict.ok) { + return context.json({ error: verdict.reason }, verdict.status); } + + if (!body?.name) { + return context.json({ error: "A tool is required." }, 400); + } + try { const result = await pluginStore.callTool({ // The model is offered `mcp__server__tool`; the store speaks `server/tool`. ref: body.name.replace(/^mcp__/, "").replace("__", "/"), args: body.args ?? {}, - botId: body.botId, - actorId: body.actorId ?? "agent", + botId: verdict.botId, + // From the assertion, never the body: this is the name the audit row will carry. + actorId: verdict.actorId, }); return context.json({ text: result.text, isError: result.isError }); } catch (error) { // A refusal is an answer, not a failure: the Bot says what was blocked and carries on. The - // marker leads it for the same reason it does on the in-process path, so a transcript can - // draw a refusal as one without reading the wording. + // marker leads it so a transcript can draw a refusal without reading the wording. return context.json({ text: `${REFUSAL_MARKER} ${error instanceof Error ? error.message : "That tool could not be called."}`, isError: true, diff --git a/server/src/auth/signed-value.ts b/server/src/auth/signed-value.ts new file mode 100644 index 0000000..e6acc31 --- /dev/null +++ b/server/src/auth/signed-value.ts @@ -0,0 +1,61 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** + * A value this deployment can hand out and later recognise as its own. + * + * Used for statements that travel through something we do not control and come back: a run assertion + * carried by a customer's own agent process, for instance. The alternative is a row per statement, + * which buys nothing here because these are short-lived and single-purpose, and costs a write and a + * read on a path that already has both. + */ + +/** + * The key a signature is made with. + * + * Derived from the deployment's encryption key rather than being the encryption key, and derived + * under a label, so a signature here can never be confused with a credential ciphertext there. One + * secret to configure, two uses that cannot borrow each other's material. + */ +function signingKey(encryptionKey: string, label: string): Buffer { + return createHmac("sha256", encryptionKey).update(label).digest(); +} + +/** + * A value and its signature, in one string. + * + * The label separates uses, so a signature valid for one kind of statement can never be replayed as + * another kind. + */ +export function sign( + value: string, + encryptionKey: string, + label: string, +): string { + const signature = createHmac("sha256", signingKey(encryptionKey, label)) + .update(value) + .digest("base64url"); + return `${value}.${signature}`; +} + +/** + * The value a signed string carries, or nothing. + * + * Compared in constant time. A comparison that returns early leaks how much of a signature was + * right, which is enough to construct a valid one given patience. + */ +export function verify( + signed: string | undefined, + encryptionKey: string, + label: string, +): string | null { + if (!signed) return null; + const separator = signed.lastIndexOf("."); + if (separator <= 0) return null; + + const value = signed.slice(0, separator); + const expected = sign(value, encryptionKey, label); + const given = Buffer.from(signed); + const wanted = Buffer.from(expected); + if (given.length !== wanted.length) return null; + return timingSafeEqual(given, wanted) ? value : null; +} diff --git a/server/src/copilot.ts b/server/src/copilot.ts index cbdd98a..7a44a23 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -227,12 +227,13 @@ export async function buildAgents( stallGuard?: StallGuard, /** Absent leaves every Bot with no tools, which is the correct answer when nothing is granted. */ loadTools: LoadToolsForBot = async () => [], + signRun?: SignRun, ): Promise> { return Object.fromEntries( await Promise.all( agents.map(async (agent) => [ agent.id, - await buildAgent(agent, model, apiKey, stallGuard, loadTools), + await buildAgent(agent, model, apiKey, stallGuard, loadTools, signRun), ]), ), ); @@ -244,6 +245,7 @@ async function buildAgent( apiKey: string | null, stallGuard: StallGuard | undefined, loadTools: LoadToolsForBot, + signRun?: SignRun, ): Promise { if (agent.type === "built_in") { return new BuiltInAgent( @@ -262,6 +264,7 @@ async function buildAgent( agent, stallGuard, await loadTools(agent.id), + signRun, ); } @@ -288,6 +291,7 @@ function remoteAgentWithStandingRole( * The executing half stays on this side, where the grant and the policy are. */ tools: GrantedTool[] = [], + signRun?: SignRun, ) { const remote = new HttpAgent({ url: agent.endpoint, @@ -330,6 +334,33 @@ function remoteAgentWithStandingRole( forwardedProps: { ...(input.forwardedProps ?? {}), openbotBotId: agent.id, + /* + * Which of those tools this deployment runs, as opposed to the surface. + * + * `tools` mixes two kinds that a name cannot tell apart: the Bot's grants, which execute + * here through the policy and the audit trail, and the components the browser draws. A Bot + * that ran the second kind through this deployment asked it to execute a chart, was told it + * could not, and then apologised to the person for not showing the chart that was on screen + * in front of them. Only this side knows which is which, so only this side can say. + */ + openbotDeploymentTools: tools.map((tool) => tool.name), + /* + * This deployment's own statement of what this run is. + * + * Signed, short-lived, and naming the Bot and the person. The agent hands it back when it + * calls a tool, and that is where the Bot and the actor come from: its own token says which + * agent is calling, and this says who it is calling for. Neither is taken from the request + * body any more, which is what used to make the audit trail forgeable by anything holding + * one shared secret. + */ + ...(signRun + ? { openbotRun: signRun(agent.id, input.runId) } + : /* + * Absent means this deployment cannot sign, so the agent is given nothing to hand back + * and its tool calls will be refused. That is the right direction to fail: a Bot that + * cannot prove whose run it is should not be spending anybody's grants. + */ + {}), }, } as never), ); @@ -357,6 +388,7 @@ export async function resolveRuntimeAgents( resolveModelApiKey: () => Promise, stallGuard?: StallGuard, loadTools?: LoadToolsForBot, + signRun?: SignRun, ): Promise> { const registered = await loadAgents(); if (registered.length === 0) { @@ -368,12 +400,21 @@ export async function resolveRuntimeAgents( const apiKey = registered.some((agent) => agent.type === "built_in") ? await resolveModelApiKey() : null; - return buildAgents(registered, model, apiKey, stallGuard, loadTools); + return buildAgents(registered, model, apiKey, stallGuard, loadTools, signRun); } /** What one Bot may call, for the person whose request this is. */ export type LoadToolsForBot = (botId: string) => Promise; +/** + * The deployment's signed statement of what a run is, for the agent that will run it. + * + * A closure rather than a key passed down, so the encryption key stays in the module that owns + * configuration and this one never holds a secret. Shaped like `LoadToolsForBot` on purpose: both are + * per-actor facts resolved once per request and asked per Bot. + */ +export type SignRun = (botId: string, runId: string) => string; + /** Who is asking. Agent visibility is decided per person, so a run has to know this first. */ export type IdentifyActor = (request: Request) => Promise; @@ -402,6 +443,8 @@ export function createRequestAgents( stallGuard?: StallGuard, /** What each Bot may call, resolved for whoever is asking. Absent means no tools. */ loadToolsForActor?: (actorId: string) => LoadToolsForBot, + /** Resolved per request, because what it signs is who this request turned out to be. */ + signRunForActor?: (actorId: string) => SignRun, ) { return async ({ request }: { request: Request }) => { const actor = await identifyActor(request); @@ -411,6 +454,7 @@ export function createRequestAgents( resolveModelApiKey, stallGuard, loadToolsForActor?.(actor.id), + signRunForActor?.(actor.id), ); }; } @@ -436,6 +480,7 @@ export function mountCopilotRuntime( */ stallGuard: StallGuard, loadToolsForActor?: (actorId: string) => LoadToolsForBot, + signRunForActor?: (actorId: string) => SignRun, basePath = "/api/copilotkit", ) { const { intelligence } = config.runtime; @@ -462,6 +507,7 @@ export function mountCopilotRuntime( resolveModelApiKey, stallGuard, loadToolsForActor, + signRunForActor, ) as never, }); diff --git a/server/src/db/schema/coworker.ts b/server/src/db/schema/coworker.ts index 8438f33..73c06a8 100644 --- a/server/src/db/schema/coworker.ts +++ b/server/src/db/schema/coworker.ts @@ -37,6 +37,20 @@ export const agentProfiles = pgTable( roleDescription: text("role_description").notNull(), avatarSeed: text("avatar_seed").notNull(), visibility: agentVisibility("visibility").notNull(), + /* + * The credential this Bot's agent presents when it calls a tool back. + * + * A hash, never the token. We issue it, the agent's owner holds it, and this side only ever needs + * to check one: storing the token itself would mean a database dump is a set of working + * credentials for every registered agent. + * + * Null means the agent has not been issued one and may not call tools back, which is the right + * default: a URL somebody pasted gets no capability until an administrator hands it one. + */ + callbackTokenHash: text("callback_token_hash"), + callbackTokenIssuedAt: timestamp("callback_token_issued_at", { + withTimezone: true, + }), deletedAt: timestamp("deleted_at", { withTimezone: true }), createdAt: createdAt(), updatedAt: updatedAt(), diff --git a/server/src/index.ts b/server/src/index.ts index 76addb5..9831c3a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,6 @@ import { serve } from "bun"; import { createAgentProfileStore } from "./agents/profile-store"; +import { mintRunAssertion } from "./agents/callback-token"; import { createRuntimeAgentLoader } from "./agents/runtime-agents"; import { createApp } from "./app"; import { createAuditReader, createAuditStore, recordAuditEvent } from "./audit"; @@ -338,6 +339,16 @@ const app = createApp( // grant, the policy and the audit row are exactly where they were. (actorId) => (botId) => grantedTools({ store: pluginStore, botId, actorId }), + /* + * What the deployment tells a remote Bot about the run it is starting. + * + * Signed here, where the encryption key lives, so the runtime module never holds a secret. The Bot + * hands this back when it calls a tool, and it is where the Bot id and the person's name come + * from: its own token proves which agent is calling, this proves who it is calling for, and + * neither is read out of the request body any more. + */ + (actorId) => (botId, runId) => + mintRunAssertion({ botId, actorId, runId }, config.keyEncryptionKey), ), computerClient, // The only path to an acting call. diff --git a/server/tests/agent-callback-token.test.ts b/server/tests/agent-callback-token.test.ts new file mode 100644 index 0000000..3bf6347 --- /dev/null +++ b/server/tests/agent-callback-token.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "bun:test"; +import { + authoriseAgentCall, + hashCallbackToken, + looksLikeCallbackToken, + mintCallbackToken, + mintRunAssertion, + readRunAssertion, + sameToken, +} from "../src/agents/callback-token"; + +const KEY = "test-encryption-key-not-a-real-one"; +const RUN = { botId: "knowledge", actorId: "user_7", runId: "run_1" }; + +describe("an agent's callback token", () => { + test("is recognisable, and different every time", () => { + const first = mintCallbackToken(); + const second = mintCallbackToken(); + expect(looksLikeCallbackToken(first)).toBe(true); + expect(first).not.toBe(second); + }); + + test("does not accept something that is not one of ours", () => { + expect(looksLikeCallbackToken("Bearer hunter2")).toBe(false); + expect(looksLikeCallbackToken("obot_agt_")).toBe(false); + }); + + test("matches only its own hash", () => { + const token = mintCallbackToken(); + expect(sameToken(hashCallbackToken(token), hashCallbackToken(token))).toBe( + true, + ); + expect( + sameToken( + hashCallbackToken(token), + hashCallbackToken(mintCallbackToken()), + ), + ).toBe(false); + }); +}); + +describe("the run assertion", () => { + test("survives a round trip", () => { + const signed = mintRunAssertion(RUN, KEY); + expect(readRunAssertion(signed, KEY)).toEqual(RUN); + }); + + test("is refused when signed with another key", () => { + const signed = mintRunAssertion(RUN, "another-key"); + expect(readRunAssertion(signed, KEY)).toBeNull(); + }); + + test("is refused when the Bot is edited", () => { + // The whole point: an agent must not be able to promote itself to another Bot's grants. + const signed = mintRunAssertion(RUN, KEY); + const [value, signature] = signed.split("."); + const payload = JSON.parse( + Buffer.from(value ?? "", "base64url").toString("utf8"), + ); + payload.botId = "risk-analyst"; + const forged = `${Buffer.from(JSON.stringify(payload)).toString("base64url")}.${signature}`; + expect(readRunAssertion(forged, KEY)).toBeNull(); + }); + + test("is refused once it has expired", () => { + const signed = mintRunAssertion(RUN, KEY, 0); + // Eleven minutes later: past the ten-minute life of an assertion. + expect(readRunAssertion(signed, KEY, 11 * 60 * 1000)).toBeNull(); + // Still good a minute in, so the bound is a real window rather than nothing. + expect(readRunAssertion(signed, KEY, 60 * 1000)).toEqual(RUN); + }); + + test("is refused when it is missing, empty or not a string", () => { + expect(readRunAssertion(undefined, KEY)).toBeNull(); + expect(readRunAssertion("", KEY)).toBeNull(); + expect(readRunAssertion(42, KEY)).toBeNull(); + expect(readRunAssertion("not-signed-at-all", KEY)).toBeNull(); + }); +}); + +describe("who may call a tool back, and as whom", () => { + const AGENT_A = "agent_a"; + const AGENT_B = "agent_b"; + const tokenA = mintCallbackToken(); + const tokenB = mintCallbackToken(); + + /** The two agents this deployment has issued a token to, and nobody else. */ + const lookup = async (hash: string) => { + if (hash === hashCallbackToken(tokenA)) return { id: AGENT_A }; + if (hash === hashCallbackToken(tokenB)) return { id: AGENT_B }; + return null; + }; + + const runForA = () => + mintRunAssertion( + { botId: AGENT_A, actorId: "visitor_9", runId: "r1" }, + KEY, + ); + + const call = (presented: string, run: unknown, legacyToken?: string) => + authoriseAgentCall({ + presented, + run, + encryptionKey: KEY, + lookup, + ...(legacyToken ? { legacyToken } : {}), + }); + + test("allows an agent to act as the Bot its token was issued for", async () => { + expect(await call(tokenA, runForA())).toEqual({ + ok: true, + botId: AGENT_A, + actorId: "visitor_9", + }); + }); + + test("refuses another agent presenting a valid assertion it did not earn", async () => { + /* + * The whole point of the change. One deployment-wide token used to mean any holder could spend any + * Bot's grants; the token now says who is calling and this says they may not be somebody else. + */ + expect(await call(tokenB, runForA())).toEqual({ + ok: false, + status: 403, + reason: "That token is not for this Bot.", + }); + }); + + test("refuses a token with no assertion at all", async () => { + expect(await call(tokenA, undefined)).toEqual({ + ok: false, + status: 401, + reason: "Not authorised.", + }); + }); + + test("refuses an unknown token", async () => { + expect(await call(mintCallbackToken(), runForA())).toEqual({ + ok: false, + status: 401, + reason: "Not authorised.", + }); + }); + + test("refuses an empty token", async () => { + expect(await call("", runForA())).toEqual({ + ok: false, + status: 401, + reason: "Not authorised.", + }); + }); + + test("says the same thing whichever half was wrong", async () => { + // A caller told its token was fine but its assertion stale has learned its token is fine. + const badToken = await call(mintCallbackToken(), runForA()); + const badAssertion = await call(tokenA, "not-signed"); + expect(badToken).toEqual(badAssertion); + }); + + test("accepts the deployment-wide token, and still takes identity from the assertion", async () => { + // The Bots in the box are configured with it. It authenticates; it does not assert. + expect(await call("legacy-secret", runForA(), "legacy-secret")).toEqual({ + ok: true, + botId: AGENT_A, + actorId: "visitor_9", + }); + }); + + test("refuses the deployment-wide token when it is not configured", async () => { + expect(await call("legacy-secret", runForA())).toEqual({ + ok: false, + status: 401, + reason: "Not authorised.", + }); + }); + + test("refuses the deployment-wide token with no assertion, which is what the old hole was", async () => { + /* + * Before this, the Bot and the actor came out of the request body, so this exact call succeeded + * and could name any Bot and any person. + */ + expect(await call("legacy-secret", undefined, "legacy-secret")).toEqual({ + ok: false, + status: 401, + reason: "Not authorised.", + }); + }); +}); diff --git a/server/tests/schema.test.ts b/server/tests/schema.test.ts index b95cd00..00fe5b2 100644 --- a/server/tests/schema.test.ts +++ b/server/tests/schema.test.ts @@ -143,6 +143,24 @@ describe("OpenBot database schema", () => { hasDefault: false, primary: false, }, + /* + * Nullable, and that is the security property. + * + * Null means this agent holds no credential and may not call a tool back, which is what a URL + * somebody pasted gets until an administrator hands it one. + */ + { + name: "callback_token_hash", + notNull: false, + hasDefault: false, + primary: false, + }, + { + name: "callback_token_issued_at", + notNull: false, + hasDefault: false, + primary: false, + }, { name: "deleted_at", notNull: false, @@ -272,6 +290,27 @@ describe("OpenBot database schema", () => { ]); }); + /* + * The callback columns arrive as an alteration, not in the base schema. + * + * Every deployment of this has already applied 0000, so editing it in place changes a file the + * database has recorded as run and the columns never appear. They are added by 0001, and this + * says so, because the alternative failure is silent: the code reads a column the deployment + * does not have. + */ + test("adds the callback token columns in their own migration", async () => { + const migration = await readFile( + new URL("../drizzle/0001_swift_morph.sql", import.meta.url), + "utf8", + ); + expect(migration).toContain( + `ALTER TABLE "agent_profiles" ADD COLUMN "callback_token_hash" text;`, + ); + expect(migration).toContain( + `ALTER TABLE "agent_profiles" ADD COLUMN "callback_token_issued_at" timestamp with time zone;`, + ); + }); + test("keeps the agent profile migration aligned with the schema", async () => { const migration = await readFile( new URL("../drizzle/0000_schema.sql", import.meta.url),