Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
11 changes: 11 additions & 0 deletions agent-bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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.
*
Expand Down Expand Up @@ -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);
}
Expand Down
11 changes: 11 additions & 0 deletions agent-langgraph/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down
12 changes: 10 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand All @@ -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:-}
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
25 changes: 25 additions & 0 deletions scripts/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"; }
Expand Down
12 changes: 12 additions & 0 deletions server/src/agents/runtime-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RegisteredAgent[]> => {
const [active, tombstones] = await Promise.all([
Expand 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) {
Expand Down
3 changes: 3 additions & 0 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions server/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
});
Expand All @@ -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<string, string | undefined> = {
...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({
Expand Down
7 changes: 6 additions & 1 deletion server/tests/runtime-agents.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions server/tests/support/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
33 changes: 33 additions & 0 deletions shared/agent-authorisation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 20 additions & 0 deletions shared/agent-authorisation.ts
Original file line number Diff line number Diff line change
@@ -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() ?? "",
);
}