diff --git a/.env.example b/.env.example index e7a6b7b..a56977d 100644 --- a/.env.example +++ b/.env.example @@ -185,6 +185,13 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true # it is a reference rather than something to build a deployment on. MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui +# Shared secret sent by the server on every call to a managed Bot, as the `x-openbot-agent-token` +# header. Both Bots refuse to start without it, and so does the server. +# +# `scripts/start.sh` generates one and writes it back here on first run, so leaving this empty is +# fine locally. Set it yourself for a deployment: openssl rand -base64 32 +MANAGED_AGENT_TOKEN= + # The second Bot in the box runs on http://localhost:4201/ag-ui, on a framework rather than # proof of concept, and is reached the same way: point MANAGED_AGENT_AG_UI_URL at it, or add it as a # Bot of its own in the tenant package or at /agents. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8578d8e..0da5ede 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,6 +157,7 @@ jobs: -e TRUSTED_ORIGINS=http://localhost:3001 \ -e OPENBOT_DEV_NO_AUTH=1 \ -e MANAGED_AGENT_AG_UI_URL=http://127.0.0.1:4201/ag-ui \ + -e MANAGED_AGENT_TOKEN=ci-not-a-real-token \ -e INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai \ -e INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai \ -e INTELLIGENCE_API_KEY=ci-not-a-real-key \ diff --git a/README.md b/README.md index 14ee517..4ad4e5b 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ See [docs/configuration.md](docs/configuration.md) and [docs/coworkers.md](docs/ - `DATABASE_URL` - `KEY_ENCRYPTION_KEY` - `MANAGED_AGENT_AG_UI_URL` +- `MANAGED_AGENT_TOKEN` - `INTELLIGENCE_API_URL` - `INTELLIGENCE_GATEWAY_WS_URL` - `INTELLIGENCE_API_KEY` diff --git a/agent-bot/src/index.ts b/agent-bot/src/index.ts index 56cf0df..3fecd86 100644 --- a/agent-bot/src/index.ts +++ b/agent-bot/src/index.ts @@ -2,6 +2,7 @@ import type { BaseEvent, RunAgentInput } from "@ag-ui/core"; import { EventEncoder } from "@ag-ui/encoder"; import { serve } from "bun"; import OpenAI from "openai"; +import { hasManagedAgentToken } from "../../shared/agent-authorisation"; import { SYSTEM_PROMPT } from "../../shared/bot-prompt"; /** @@ -16,6 +17,13 @@ import { SYSTEM_PROMPT } from "../../shared/bot-prompt"; */ const PORT = Number.parseInt(process.env.PORT ?? "4200", 10); +const MANAGED_AGENT_TOKEN = process.env.MANAGED_AGENT_TOKEN?.trim(); +if (!MANAGED_AGENT_TOKEN) { + console.error( + "MANAGED_AGENT_TOKEN is not set. This process holds a model credential and will not start without a token for OpenBot's server.", + ); + process.exit(1); +} /** * Which model drives the Bot. * @@ -224,6 +232,9 @@ serve({ } if (url.pathname === "/ag-ui" && request.method === "POST") { + if (!hasManagedAgentToken(request, MANAGED_AGENT_TOKEN)) { + return Response.json({ error: "Unauthorized." }, { status: 401 }); + } const input = (await request.json()) as RunAgentInput; return runAgent(input); } diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index add070a..c524fb0 100644 --- a/agent-langgraph/src/index.ts +++ b/agent-langgraph/src/index.ts @@ -17,6 +17,7 @@ import { } from "@langchain/langgraph"; import { ChatOpenAI } from "@langchain/openai"; import { serve } from "bun"; +import { hasManagedAgentToken } from "../../shared/agent-authorisation"; import { SYSTEM_PROMPT } from "../../shared/bot-prompt"; /** @@ -39,6 +40,13 @@ import { SYSTEM_PROMPT } from "../../shared/bot-prompt"; */ const PORT = Number.parseInt(process.env.PORT ?? "4201", 10); +const MANAGED_AGENT_TOKEN = process.env.MANAGED_AGENT_TOKEN?.trim(); +if (!MANAGED_AGENT_TOKEN) { + console.error( + "MANAGED_AGENT_TOKEN is not set. This process holds a model credential and will not start without a token for OpenBot's server.", + ); + process.exit(1); +} /** * Which model drives this Bot, and from whom. @@ -562,6 +570,9 @@ serve({ } if (url.pathname === "/ag-ui" && request.method === "POST") { + if (!hasManagedAgentToken(request, MANAGED_AGENT_TOKEN)) { + return Response.json({ error: "Unauthorized." }, { status: 401 }); + } const input = (await request.json()) as RunAgentInput; return runAgent(input); } diff --git a/docker-compose.yml b/docker-compose.yml index 9f38be9..a7ae5c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -160,9 +160,14 @@ services: context: . dockerfile: agent-bot/Dockerfile ports: - - "${BOT_PORT:-4200}:4200" + # Loopback, not every interface. The token is the boundary; this means an attacker needs to be + # on the machine before they can even try it. Nothing legitimate reaches a Bot from another + # host: the server calls it over localhost, and other containers use the compose network. + - "127.0.0.1:${BOT_PORT:-4200}:4200" environment: OPENAI_API_KEY: ${OPENAI_API_KEY} + # Server sends this on every call to the managed Bot. It refuses to start without it. + MANAGED_AGENT_TOKEN: ${MANAGED_AGENT_TOKEN:-} # Unset means OpenAI. Set, it is any endpoint speaking the same API, and BOT_MODEL is sent # to it verbatim. OPENAI_BASE_URL: ${OPENAI_BASE_URL:-} @@ -179,11 +184,14 @@ services: context: . dockerfile: agent-langgraph/Dockerfile ports: - - "${LANGGRAPH_PORT:-4201}:4201" + # Loopback, for the same reason as agent-bot above. + - "127.0.0.1:${LANGGRAPH_PORT:-4201}:4201" environment: # The selected provider reads its own key. Models requiring the Responses API use # BOT_RESPONSES_API instead of changing the streaming loop here. BOT_PROVIDER: ${BOT_PROVIDER:-openai} + # Same server-to-Bot request boundary as agent-bot above. + MANAGED_AGENT_TOKEN: ${MANAGED_AGENT_TOKEN:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} diff --git a/docs/configuration.md b/docs/configuration.md index e4dd30e..d5bca7a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,6 +21,7 @@ bash scripts/start.sh | `DATABASE_URL` | PostgreSQL connection string. | | `KEY_ENCRYPTION_KEY` | Base64-encoded 32-byte key for encrypted stored credentials. Generate with `openssl rand -base64 32`. | | `MANAGED_AGENT_AG_UI_URL` | Default AG-UI endpoint for coworkers created in the product. Must be HTTP(S). | +| `MANAGED_AGENT_TOKEN` | Secret sent only to the managed AG-UI endpoint. Generate with `openssl rand -base64 32`. | | `INTELLIGENCE_API_URL` | CopilotKit Intelligence API URL. | | `INTELLIGENCE_GATEWAY_WS_URL` | CopilotKit Intelligence realtime gateway URL. | | `INTELLIGENCE_API_KEY` | Runtime key for the Intelligence project. | diff --git a/scripts/start.sh b/scripts/start.sh index 5f8ad63..6cf05bb 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -36,6 +36,31 @@ export APP_PORT SERVER_PORT SUPERVISOR_TOKEN="$(setting SUPERVISOR_TOKEN openbot-dev-supervisor-token)" COMPUTER_TOKEN="$(setting COMPUTER_TOKEN openbot-dev-computer-token)" +# The secret the server sends to a managed Bot, generated and written back on first run. +# +# Not a fixed default like the two above. Those reach services bound to loopback; a Bot publishes a +# port, so a well-known token from a public repository would be no boundary at all. Generated once +# per machine and persisted, because the server and the Bot are separate processes that have to +# agree on it across restarts. +# +# Written into .env rather than exported for this run alone, so `docker compose up` by hand later +# sees the same value the script used. +MANAGED_AGENT_TOKEN="$(setting MANAGED_AGENT_TOKEN "")" +if [ -z "$MANAGED_AGENT_TOKEN" ]; then + MANAGED_AGENT_TOKEN="$(openssl rand -base64 32)" + if grep -qE '^MANAGED_AGENT_TOKEN=' "$ROOT/.env"; then + # A present but empty line, which is what .env.example ships. + tmp="$(mktemp)" + grep -vE '^MANAGED_AGENT_TOKEN=' "$ROOT/.env" > "$tmp" + printf 'MANAGED_AGENT_TOKEN=%s\n' "$MANAGED_AGENT_TOKEN" >> "$tmp" + mv "$tmp" "$ROOT/.env" + else + printf '\nMANAGED_AGENT_TOKEN=%s\n' "$MANAGED_AGENT_TOKEN" >> "$ROOT/.env" + fi + printf '\033[2m%s\033[0m\n' "Generated MANAGED_AGENT_TOKEN and wrote it to .env." +fi +export MANAGED_AGENT_TOKEN + green() { printf '\033[32m%s\033[0m\n' "$1"; } red() { printf '\033[31m%s\033[0m\n' "$1"; } info() { printf '\033[2m%s\033[0m\n' "$1"; } diff --git a/server/src/agents/runtime-agents.ts b/server/src/agents/runtime-agents.ts index 257900c..2e152ed 100644 --- a/server/src/agents/runtime-agents.ts +++ b/server/src/agents/runtime-agents.ts @@ -22,6 +22,8 @@ export function createRuntimeAgentLoader( database: Database, /** Resolves a customer agent's key at load time. Absent means no agent can carry one. */ vault?: { reader: CredentialSecretReader; encryptionKey: string }, + /** Secret for the deployment-managed Bot. Never sent to customer-owned endpoints. */ + managedAgent?: { endpoint: URL; token: string }, ) { return async (actor: AgentActor): Promise => { const [active, tombstones] = await Promise.all([ @@ -45,6 +47,16 @@ export function createRuntimeAgentLoader( }); if (headers) agent.headers = headers; } + if ( + agent.type === "remote_ag_ui" && + managedAgent && + agent.endpoint === managedAgent.endpoint.toString() + ) { + agent.headers = { + ...agent.headers, + "x-openbot-agent-token": managedAgent.token, + }; + } registered.set(agent.id, agent); } for (const row of tombstones) { diff --git a/server/src/config.ts b/server/src/config.ts index 96f45ce..6d67d2e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -44,6 +44,8 @@ export type DeploymentConfig = { databaseUrl: string; keyEncryptionKey: string; managedAgentAgUiUrl: URL; + /** Secret sent only to the managed Bot endpoint. Never stored in an agent row. */ + managedAgentToken: string; /** * What this deployment calls itself, when more than one shares an Intelligence project. * @@ -397,6 +399,7 @@ export function loadConfig( environment, "MANAGED_AGENT_AG_UI_URL", ), + managedAgentToken: required(environment, "MANAGED_AGENT_TOKEN"), deploymentId: optional(environment, "DEPLOYMENT_ID"), tenantPackageDirectory: optional(environment, "TENANT_PACKAGE_DIR") ?? "../examples/fintech", diff --git a/server/src/index.ts b/server/src/index.ts index 2934ca2..54aaf3c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -151,7 +151,10 @@ const channelActivityListener = await startChannelActivityListener( channelEvents, ); const roleRepository = createRoleRepository(database); -const loadAgentsForActor = createRuntimeAgentLoader(database, agentVault); +const loadAgentsForActor = createRuntimeAgentLoader(database, agentVault, { + endpoint: config.managedAgentAgUiUrl, + token: config.managedAgentToken, +}); await synchronizeTenantPackage(database, tenantPackage); const auth = config.auth ? createAuth(config, database) : undefined; const computerProvider = config.computer diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 5ce2c1e..eb0de13 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -16,6 +16,7 @@ const baseEnvironment = { INTELLIGENCE_API_KEY: "tenant-api-key", COPILOTKIT_LICENSE_TOKEN: "license-token", MANAGED_AGENT_AG_UI_URL: " http://localhost:4200/ag-ui ", + MANAGED_AGENT_TOKEN: "managed-agent-token", }; describe("deployment configuration", () => { @@ -47,6 +48,7 @@ describe("deployment configuration", () => { INTELLIGENCE_API_KEY: baseEnvironment.INTELLIGENCE_API_KEY, COPILOTKIT_LICENSE_TOKEN: baseEnvironment.COPILOTKIT_LICENSE_TOKEN, MANAGED_AGENT_AG_UI_URL: baseEnvironment.MANAGED_AGENT_AG_UI_URL, + MANAGED_AGENT_TOKEN: baseEnvironment.MANAGED_AGENT_TOKEN, }); expect(config.auth).toBeUndefined(); @@ -77,6 +79,7 @@ describe("deployment configuration", () => { DATABASE_URL: baseEnvironment.DATABASE_URL, KEY_ENCRYPTION_KEY: baseEnvironment.KEY_ENCRYPTION_KEY, MANAGED_AGENT_AG_UI_URL: baseEnvironment.MANAGED_AGENT_AG_UI_URL, + MANAGED_AGENT_TOKEN: baseEnvironment.MANAGED_AGENT_TOKEN, }), ).toThrow("CopilotKit Intelligence is required and is not configured"); }); @@ -102,6 +105,15 @@ describe("deployment configuration", () => { expect(() => loadConfig(environment)).toThrow("MANAGED_AGENT_AG_UI_URL"); }); + test("refuses to start when MANAGED_AGENT_TOKEN is missing", () => { + const environment: Record = { + ...baseEnvironment, + }; + delete environment.MANAGED_AGENT_TOKEN; + + expect(() => loadConfig(environment)).toThrow("MANAGED_AGENT_TOKEN"); + }); + test("refuses a non-HTTP MANAGED_AGENT_AG_UI_URL", () => { expect(() => loadConfig({ diff --git a/server/tests/runtime-agents.integration.test.ts b/server/tests/runtime-agents.integration.test.ts index 0756a33..82113b8 100644 --- a/server/tests/runtime-agents.integration.test.ts +++ b/server/tests/runtime-agents.integration.test.ts @@ -28,7 +28,11 @@ const channelStore = createChannelStore( profileStore, createThreadIdentity("test-deployment"), ); -const loadAgents = createRuntimeAgentLoader(database); +const managedAgentToken = "managed-agent-token"; +const loadAgents = createRuntimeAgentLoader(database, undefined, { + endpoint: managedEndpoint, + token: managedAgentToken, +}); const testPrefix = `runtime-agents-${randomUUID()}`; const createdUserIds: string[] = []; @@ -104,6 +108,7 @@ describe("runtime agent loading", () => { name: "Expense Manager", type: "remote_ag_ui", endpoint: managedEndpoint.toString(), + headers: { "x-openbot-agent-token": managedAgentToken }, standingMessage: standingRoleMessage({ id: profile.id, name: "Expense Manager", diff --git a/server/tests/support/environment.ts b/server/tests/support/environment.ts index 2422ef7..6912067 100644 --- a/server/tests/support/environment.ts +++ b/server/tests/support/environment.ts @@ -23,6 +23,7 @@ export function testEnvironment( INTELLIGENCE_API_KEY: "tenant-api-key", COPILOTKIT_LICENSE_TOKEN: "license-token", MANAGED_AGENT_AG_UI_URL: "http://localhost:4200/ag-ui", + MANAGED_AGENT_TOKEN: "managed-agent-token", ...overrides, }; } diff --git a/shared/agent-authorisation.test.ts b/shared/agent-authorisation.test.ts new file mode 100644 index 0000000..e7133fd --- /dev/null +++ b/shared/agent-authorisation.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { hasManagedAgentToken, matchesToken } from "./agent-authorisation"; + +describe("managed agent authorization", () => { + test("accepts only the configured token", () => { + const expected = "agent-bot-secret"; + + expect( + hasManagedAgentToken( + new Request("http://bot.local/ag-ui", { + headers: { "x-openbot-agent-token": expected }, + }), + expected, + ), + ).toBe(true); + expect( + hasManagedAgentToken(new Request("http://bot.local/ag-ui"), expected), + ).toBe(false); + expect( + hasManagedAgentToken( + new Request("http://bot.local/ag-ui", { + headers: { "x-openbot-agent-token": "wrong" }, + }), + expected, + ), + ).toBe(false); + }); + + test("rejects empty and differently sized tokens", () => { + expect(matchesToken("", "")).toBe(false); + expect(matchesToken("expected", "short")).toBe(false); + }); +}); diff --git a/shared/agent-authorisation.ts b/shared/agent-authorisation.ts new file mode 100644 index 0000000..beeab88 --- /dev/null +++ b/shared/agent-authorisation.ts @@ -0,0 +1,20 @@ +/** Compare the caller's Bot token without leaking its contents through timing. */ +export function matchesToken(expected: string, offered: string): boolean { + if (expected.length === 0 || offered.length !== expected.length) return false; + let difference = 0; + for (let index = 0; index < offered.length; index += 1) { + difference |= offered.charCodeAt(index) ^ expected.charCodeAt(index); + } + return difference === 0; +} + +/** The one header accepted from OpenBot's server when it calls a managed Bot. */ +export function hasManagedAgentToken( + request: Request, + expected: string, +): boolean { + return matchesToken( + expected, + request.headers.get("x-openbot-agent-token")?.trim() ?? "", + ); +}