From 90459c4638346264e1426893f519b782375027d0 Mon Sep 17 00:00:00 2001 From: David McKay Date: Wed, 19 Aug 2026 10:46:10 -0700 Subject: [PATCH 1/3] Give each agent its own token, and stop trusting the body Calling a tool back used to need one deployment-wide secret, with the Bot and the person read out of the request body. Anything holding that secret could spend any Bot's grants and write any name into the audit trail. The trail is the product, and a forgeable trail is worse than none because it is believed. Two credentials now, because they answer different questions and one cannot answer both. The token says which agent is calling. Issued per agent, held by whoever runs it, and stored here only as a hash, so a database dump is not a set of working credentials. Issuing again rotates; revoking leaves the agent able to talk and unable to reach anything outside a conversation. Null is the default, which is what a URL somebody pasted should get. The run assertion says which Bot and which person. Signed by this deployment, ten minutes long, sent in forwardedProps and handed straight back. An agent is in no position to assert who it acts for, so it carries our note instead of making a claim. They are checked against each other: an agent may only act as the Bot its token was issued for, so an assertion seen once cannot be replayed by another credentialled agent. The deployment-wide token still authenticates the Bots that ship in the box, but it no longer asserts anything. Driven end to end, not just unit tested. A registered outside agent with its own token called a granted tool and the audit row named the visitor rather than "agent". The same assertion presented with a second agent's token was refused 403, no assertion at all 401, an unknown token 401. Also fixes a latent migration bug this work uncovered. drizzle.config.ts still named computer.ts, deleted when computer use went, and did not name the policy.ts that replaced it, so action_policy looked absent and the next generated migration opened with DROP TABLE "action_policy" CASCADE. It destroys the deployment's boundary. The config now names the right file, the new columns are folded into the single baseline this repo keeps rather than a second migration, and the baseline was verified by rebuilding the database from nothing. The demo seed also refuses with an explanation when the roster has not been written yet, instead of a foreign-key error naming a constraint. --- agent-langgraph/src/index.ts | 87 +- app/src/components/agents/agent-profile.tsx | 12 + .../agents/callback-token-panel.tsx | 103 + app/src/lib/agents/mutations.ts | 32 + app/src/lib/agents/queries.ts | 7 + server/drizzle/0001_swift_morph.sql | 2 + server/drizzle/meta/0001_snapshot.json | 2515 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/agents/callback-token.ts | 210 ++ server/src/agents/profile-store.ts | 113 + server/src/agents/profile-types.ts | 7 + server/src/agents/routes.ts | 35 + server/src/app.ts | 48 +- server/src/auth/signed-value.ts | 61 + server/src/copilot.ts | 50 +- server/src/db/schema/coworker.ts | 14 + server/src/index.ts | 11 + server/tests/agent-callback-token.test.ts | 188 ++ server/tests/schema.test.ts | 39 + 19 files changed, 3507 insertions(+), 34 deletions(-) create mode 100644 app/src/components/agents/callback-token-panel.tsx create mode 100644 server/drizzle/0001_swift_morph.sql create mode 100644 server/drizzle/meta/0001_snapshot.json create mode 100644 server/src/agents/callback-token.ts create mode 100644 server/src/auth/signed-value.ts create mode 100644 server/tests/agent-callback-token.test.ts diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index fd747f8..44204cb 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,7 +326,7 @@ 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; @@ -291,18 +338,20 @@ 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, - }); - }), + (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 }; }) diff --git a/app/src/components/agents/agent-profile.tsx b/app/src/components/agents/agent-profile.tsx index e6fcfe1..134d863 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 teammate 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..598c189 --- /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 teammate presents when it calls a tool back. + * + * Only for a teammate 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 teammate 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 teammate 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 teammate 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..08490be 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 teammate 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 teammate 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 teammate 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..34a914b 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 teammate 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..4a0a179 --- /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 = "okai_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..1c8b184 --- /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("okai_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), From 7318570fb8dccc5260626c2fd1c707d63d43bac3 Mon Sep 17 00:00:00 2001 From: David McKay Date: Wed, 19 Aug 2026 21:21:01 -0700 Subject: [PATCH 2/3] Keep the surface's own tools out of the agent's loop Two things the merge dropped, both found by driving it rather than by any gate. The set naming which tools this deployment runs was not built, so the filter that reads it threw `ours is not defined` and the run died before it said anything. Green typecheck, green suite, dead Bot: the variable is only reached once a model actually asks for a tool. With it back, the reason it exists matters more than the crash. A run is offered two kinds of tool that no naming rule separates: the Bot's grants, which execute through the policy and the audit trail, and the components the surface draws. A call the surface owns now ends the run, which is how a browser tool is supposed to work, the surface draws it or puts the question to a person and starts the next run with the answer. Running the loop through it instead invents a result: the Bot apologises for a chart the person is looking at, and an approval card answered on its behalf waits for a click that can never land. --- agent-langgraph/src/index.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index 44204cb..add070a 100644 --- a/agent-langgraph/src/index.ts +++ b/agent-langgraph/src/index.ts @@ -330,6 +330,7 @@ function buildGraph(input: RunAgentInput) { 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) => ({ @@ -338,6 +339,11 @@ function buildGraph(input: RunAgentInput) { .addNode("tools", async (state) => { const last = state.messages.at(-1) as AIMessage; const results = await Promise.all( + /* + * 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) => { @@ -358,7 +364,23 @@ function buildGraph(input: RunAgentInput) { .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(); From 13c1b9a438e51ccf6ff05500deb60b53f4c6e3c8 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 07:33:52 -0700 Subject: [PATCH 3/3] Stop the agent credential announcing the wrong product The token this deployment issues an agent was minted as `okai_agt_...`, which is the other product's prefix. The prefix exists so a leaked credential can be recognised on sight, in a log or by a secret scanner, and one naming a product this is not defeats the only job it has. It is `obot_agt_` now. The panel that issues it, and the code around it, still called a Bot a teammate. That rename happened everywhere else already. Found by opening the screen. The security work these sit on was proven through the API, which is the right place to prove a refusal, and it meant nobody had looked at the thing a person actually uses to mint one. --- app/src/components/agents/agent-profile.tsx | 2 +- app/src/components/agents/callback-token-panel.tsx | 10 +++++----- app/src/lib/agents/mutations.ts | 6 +++--- app/src/lib/agents/queries.ts | 2 +- server/src/agents/callback-token.ts | 2 +- server/tests/agent-callback-token.test.ts | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/src/components/agents/agent-profile.tsx b/app/src/components/agents/agent-profile.tsx index 134d863..3e3c0e9 100644 --- a/app/src/components/agents/agent-profile.tsx +++ b/app/src/components/agents/agent-profile.tsx @@ -144,7 +144,7 @@ export function AgentProfile({ agentId }: { agentId: string }) { )} {/* - * Only for a teammate that runs somewhere else, and only for somebody who may change it. + * 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 ? ( diff --git a/app/src/components/agents/callback-token-panel.tsx b/app/src/components/agents/callback-token-panel.tsx index 598c189..3aea8ea 100644 --- a/app/src/components/agents/callback-token-panel.tsx +++ b/app/src/components/agents/callback-token-panel.tsx @@ -7,16 +7,16 @@ import { } from "@/lib/agents/mutations"; /** - * The credential this teammate presents when it calls a tool back. + * The credential this coworker presents when it calls a tool back. * - * Only for a teammate that runs somewhere else. The Bot in the box has no endpoint and nothing to + * 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 teammate with no token can still hold a conversation. What it cannot do is reach anything outside + * 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({ @@ -42,8 +42,8 @@ export function CallbackTokenPanel({

{hasToken - ? "This teammate 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 teammate 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."} + ? "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 ? ( diff --git a/app/src/lib/agents/mutations.ts b/app/src/lib/agents/mutations.ts index 08490be..fc73e8a 100644 --- a/app/src/lib/agents/mutations.ts +++ b/app/src/lib/agents/mutations.ts @@ -99,10 +99,10 @@ export function deleteAgentMutationOptions(queryClient: QueryClient) { } /** - * Issue this teammate a credential for calling tools back, and hand it over once. + * 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 teammate that already has one rotates it, which is how a + * 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) { @@ -118,7 +118,7 @@ export function issueCallbackTokenMutationOptions(queryClient: QueryClient) { }); } -/** Take the credential away. The teammate may still talk; it may not reach anything outside a chat. */ +/** 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) => { diff --git a/app/src/lib/agents/queries.ts b/app/src/lib/agents/queries.ts index 34a914b..bf027b9 100644 --- a/app/src/lib/agents/queries.ts +++ b/app/src/lib/agents/queries.ts @@ -20,7 +20,7 @@ export type AgentProfile = { /** Whether a key is set for it. Never the key itself. */ hasAuth: boolean; /** - * Whether this teammate holds a credential for calling tools back. + * 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". diff --git a/server/src/agents/callback-token.ts b/server/src/agents/callback-token.ts index 4a0a179..7106a84 100644 --- a/server/src/agents/callback-token.ts +++ b/server/src/agents/callback-token.ts @@ -22,7 +22,7 @@ import { sign, verify } from "../auth/signed-value"; */ /** Recognisable on sight, so a leaked one can be found in a log or by a secret scanner. */ -const TOKEN_PREFIX = "okai_agt_"; +const TOKEN_PREFIX = "obot_agt_"; /** * Long enough that guessing is not a strategy. diff --git a/server/tests/agent-callback-token.test.ts b/server/tests/agent-callback-token.test.ts index 1c8b184..3bf6347 100644 --- a/server/tests/agent-callback-token.test.ts +++ b/server/tests/agent-callback-token.test.ts @@ -22,7 +22,7 @@ describe("an agent's callback token", () => { test("does not accept something that is not one of ours", () => { expect(looksLikeCallbackToken("Bearer hunter2")).toBe(false); - expect(looksLikeCallbackToken("okai_agt_")).toBe(false); + expect(looksLikeCallbackToken("obot_agt_")).toBe(false); }); test("matches only its own hash", () => {