From f920e108975cc641b0a731d611d19c1842088887 Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Thu, 20 Aug 2026 23:13:59 +0530 Subject: [PATCH 1/3] Require a token on every call from the server to a Bot --- .env.example | 5 +++ README.md | 1 + agent-bot/src/index.ts | 11 +++++++ agent-langgraph/src/index.ts | 11 +++++++ docker-compose.yml | 4 +++ docs/configuration.md | 1 + server/src/agents/runtime-agents.ts | 12 +++++++ server/src/config.ts | 3 ++ server/src/index.ts | 5 ++- server/tests/config.test.ts | 12 +++++++ .../tests/runtime-agents.integration.test.ts | 7 +++- server/tests/support/environment.ts | 1 + shared/agent-authorisation.test.ts | 33 +++++++++++++++++++ shared/agent-authorisation.ts | 20 +++++++++++ 14 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 shared/agent-authorisation.test.ts create mode 100644 shared/agent-authorisation.ts diff --git a/.env.example b/.env.example index e7a6b7b..29fd3e0 100644 --- a/.env.example +++ b/.env.example @@ -185,6 +185,11 @@ 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. Generate one: +# 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/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..1426faf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -163,6 +163,8 @@ services: - "${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:-} @@ -184,6 +186,8 @@ services: # 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/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() ?? "", + ); +} From b49183e5ca8b56481ba336d2df45e433d13577d1 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 20:33:35 -0700 Subject: [PATCH 2/3] Generate the token this needs, and keep the Bot ports on loopback Two things the token boundary needs to be usable and to be worth having. `MANAGED_AGENT_TOKEN` is required and ships empty in `.env.example`, so a fresh clone doing `cp .env.example .env && bash scripts/start.sh` refused to start with "MANAGED_AGENT_TOKEN must be configured". That is step four of the quick start, and the first thing a stranger does. `start.sh` already supplies the two neighbouring secrets; this one is generated and written back to `.env` rather than defaulted to a fixed string, because a well-known token from a public repository is no boundary at all, and because the server and the Bot are separate processes that have to agree on it across restarts. The Bot ports were published on every interface, which this PR's own description names as the reason the hole mattered. `agent-computer` was already bound to loopback, so the two Bots were the exception rather than the rule. Binding them means somebody has to be on the machine before the token is even the thing standing in their way. Driven rather than reasoned about: from an empty token, the script generates 44 characters and writes one line back; the Bots then publish on 127.0.0.1 only, answer 401 with no token and with a wrong one, and 200 with the right one. --- .env.example | 6 ++++-- docker-compose.yml | 8 ++++++-- scripts/start.sh | 25 +++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 29fd3e0..a56977d 100644 --- a/.env.example +++ b/.env.example @@ -186,8 +186,10 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true 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. Generate one: -# openssl rand -base64 32 +# 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 diff --git a/docker-compose.yml b/docker-compose.yml index 1426faf..a7ae5c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -160,7 +160,10 @@ 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. @@ -181,7 +184,8 @@ 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. 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"; } From e6e3ccbe2c8c12ccb40250747c9c45c300ffcd2b Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 20:48:51 -0700 Subject: [PATCH 3/3] Give the image check the token the server now requires `MANAGED_AGENT_TOKEN` is required, so the container refuses to start without it and the check waited 150 seconds for an answer that was never coming. The job could not have been updated in the original commit: this branch predates the check, and the workflow that ran came from the merge commit rather than from here. Rebasing onto main brings it into view. Reproduced with the job's own command: answers on /api/capabilities in four seconds, nothing respawning after fifteen. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) 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 \