Skip to content

Commit d90ff63

Browse files
Require a token on every call from the server to a Bot (#52)
* Require a token on every call from the server to a Bot * 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. * 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. --------- Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
1 parent 5eb35ae commit d90ff63

16 files changed

Lines changed: 158 additions & 4 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,13 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
185185
# it is a reference rather than something to build a deployment on.
186186
MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui
187187

188+
# Shared secret sent by the server on every call to a managed Bot, as the `x-openbot-agent-token`
189+
# header. Both Bots refuse to start without it, and so does the server.
190+
#
191+
# `scripts/start.sh` generates one and writes it back here on first run, so leaving this empty is
192+
# fine locally. Set it yourself for a deployment: openssl rand -base64 32
193+
MANAGED_AGENT_TOKEN=
194+
188195
# The second Bot in the box runs on http://localhost:4201/ag-ui, on a framework rather than
189196
# proof of concept, and is reached the same way: point MANAGED_AGENT_AG_UI_URL at it, or add it as a
190197
# Bot of its own in the tenant package or at /agents.

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ jobs:
157157
-e TRUSTED_ORIGINS=http://localhost:3001 \
158158
-e OPENBOT_DEV_NO_AUTH=1 \
159159
-e MANAGED_AGENT_AG_UI_URL=http://127.0.0.1:4201/ag-ui \
160+
-e MANAGED_AGENT_TOKEN=ci-not-a-real-token \
160161
-e INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai \
161162
-e INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai \
162163
-e INTELLIGENCE_API_KEY=ci-not-a-real-key \

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ See [docs/configuration.md](docs/configuration.md) and [docs/coworkers.md](docs/
181181
- `DATABASE_URL`
182182
- `KEY_ENCRYPTION_KEY`
183183
- `MANAGED_AGENT_AG_UI_URL`
184+
- `MANAGED_AGENT_TOKEN`
184185
- `INTELLIGENCE_API_URL`
185186
- `INTELLIGENCE_GATEWAY_WS_URL`
186187
- `INTELLIGENCE_API_KEY`

agent-bot/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { BaseEvent, RunAgentInput } from "@ag-ui/core";
22
import { EventEncoder } from "@ag-ui/encoder";
33
import { serve } from "bun";
44
import OpenAI from "openai";
5+
import { hasManagedAgentToken } from "../../shared/agent-authorisation";
56
import { SYSTEM_PROMPT } from "../../shared/bot-prompt";
67

78
/**
@@ -16,6 +17,13 @@ import { SYSTEM_PROMPT } from "../../shared/bot-prompt";
1617
*/
1718

1819
const PORT = Number.parseInt(process.env.PORT ?? "4200", 10);
20+
const MANAGED_AGENT_TOKEN = process.env.MANAGED_AGENT_TOKEN?.trim();
21+
if (!MANAGED_AGENT_TOKEN) {
22+
console.error(
23+
"MANAGED_AGENT_TOKEN is not set. This process holds a model credential and will not start without a token for OpenBot's server.",
24+
);
25+
process.exit(1);
26+
}
1927
/**
2028
* Which model drives the Bot.
2129
*
@@ -224,6 +232,9 @@ serve({
224232
}
225233

226234
if (url.pathname === "/ag-ui" && request.method === "POST") {
235+
if (!hasManagedAgentToken(request, MANAGED_AGENT_TOKEN)) {
236+
return Response.json({ error: "Unauthorized." }, { status: 401 });
237+
}
227238
const input = (await request.json()) as RunAgentInput;
228239
return runAgent(input);
229240
}

agent-langgraph/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "@langchain/langgraph";
1818
import { ChatOpenAI } from "@langchain/openai";
1919
import { serve } from "bun";
20+
import { hasManagedAgentToken } from "../../shared/agent-authorisation";
2021
import { SYSTEM_PROMPT } from "../../shared/bot-prompt";
2122

2223
/**
@@ -39,6 +40,13 @@ import { SYSTEM_PROMPT } from "../../shared/bot-prompt";
3940
*/
4041

4142
const PORT = Number.parseInt(process.env.PORT ?? "4201", 10);
43+
const MANAGED_AGENT_TOKEN = process.env.MANAGED_AGENT_TOKEN?.trim();
44+
if (!MANAGED_AGENT_TOKEN) {
45+
console.error(
46+
"MANAGED_AGENT_TOKEN is not set. This process holds a model credential and will not start without a token for OpenBot's server.",
47+
);
48+
process.exit(1);
49+
}
4250

4351
/**
4452
* Which model drives this Bot, and from whom.
@@ -562,6 +570,9 @@ serve({
562570
}
563571

564572
if (url.pathname === "/ag-ui" && request.method === "POST") {
573+
if (!hasManagedAgentToken(request, MANAGED_AGENT_TOKEN)) {
574+
return Response.json({ error: "Unauthorized." }, { status: 401 });
575+
}
565576
const input = (await request.json()) as RunAgentInput;
566577
return runAgent(input);
567578
}

docker-compose.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,14 @@ services:
160160
context: .
161161
dockerfile: agent-bot/Dockerfile
162162
ports:
163-
- "${BOT_PORT:-4200}:4200"
163+
# Loopback, not every interface. The token is the boundary; this means an attacker needs to be
164+
# on the machine before they can even try it. Nothing legitimate reaches a Bot from another
165+
# host: the server calls it over localhost, and other containers use the compose network.
166+
- "127.0.0.1:${BOT_PORT:-4200}:4200"
164167
environment:
165168
OPENAI_API_KEY: ${OPENAI_API_KEY}
169+
# Server sends this on every call to the managed Bot. It refuses to start without it.
170+
MANAGED_AGENT_TOKEN: ${MANAGED_AGENT_TOKEN:-}
166171
# Unset means OpenAI. Set, it is any endpoint speaking the same API, and BOT_MODEL is sent
167172
# to it verbatim.
168173
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-}
@@ -179,11 +184,14 @@ services:
179184
context: .
180185
dockerfile: agent-langgraph/Dockerfile
181186
ports:
182-
- "${LANGGRAPH_PORT:-4201}:4201"
187+
# Loopback, for the same reason as agent-bot above.
188+
- "127.0.0.1:${LANGGRAPH_PORT:-4201}:4201"
183189
environment:
184190
# The selected provider reads its own key. Models requiring the Responses API use
185191
# BOT_RESPONSES_API instead of changing the streaming loop here.
186192
BOT_PROVIDER: ${BOT_PROVIDER:-openai}
193+
# Same server-to-Bot request boundary as agent-bot above.
194+
MANAGED_AGENT_TOKEN: ${MANAGED_AGENT_TOKEN:-}
187195
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
188196
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-}
189197
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}

docs/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ bash scripts/start.sh
2121
| `DATABASE_URL` | PostgreSQL connection string. |
2222
| `KEY_ENCRYPTION_KEY` | Base64-encoded 32-byte key for encrypted stored credentials. Generate with `openssl rand -base64 32`. |
2323
| `MANAGED_AGENT_AG_UI_URL` | Default AG-UI endpoint for coworkers created in the product. Must be HTTP(S). |
24+
| `MANAGED_AGENT_TOKEN` | Secret sent only to the managed AG-UI endpoint. Generate with `openssl rand -base64 32`. |
2425
| `INTELLIGENCE_API_URL` | CopilotKit Intelligence API URL. |
2526
| `INTELLIGENCE_GATEWAY_WS_URL` | CopilotKit Intelligence realtime gateway URL. |
2627
| `INTELLIGENCE_API_KEY` | Runtime key for the Intelligence project. |

scripts/start.sh

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,31 @@ export APP_PORT SERVER_PORT
3636
SUPERVISOR_TOKEN="$(setting SUPERVISOR_TOKEN openbot-dev-supervisor-token)"
3737
COMPUTER_TOKEN="$(setting COMPUTER_TOKEN openbot-dev-computer-token)"
3838

39+
# The secret the server sends to a managed Bot, generated and written back on first run.
40+
#
41+
# Not a fixed default like the two above. Those reach services bound to loopback; a Bot publishes a
42+
# port, so a well-known token from a public repository would be no boundary at all. Generated once
43+
# per machine and persisted, because the server and the Bot are separate processes that have to
44+
# agree on it across restarts.
45+
#
46+
# Written into .env rather than exported for this run alone, so `docker compose up` by hand later
47+
# sees the same value the script used.
48+
MANAGED_AGENT_TOKEN="$(setting MANAGED_AGENT_TOKEN "")"
49+
if [ -z "$MANAGED_AGENT_TOKEN" ]; then
50+
MANAGED_AGENT_TOKEN="$(openssl rand -base64 32)"
51+
if grep -qE '^MANAGED_AGENT_TOKEN=' "$ROOT/.env"; then
52+
# A present but empty line, which is what .env.example ships.
53+
tmp="$(mktemp)"
54+
grep -vE '^MANAGED_AGENT_TOKEN=' "$ROOT/.env" > "$tmp"
55+
printf 'MANAGED_AGENT_TOKEN=%s\n' "$MANAGED_AGENT_TOKEN" >> "$tmp"
56+
mv "$tmp" "$ROOT/.env"
57+
else
58+
printf '\nMANAGED_AGENT_TOKEN=%s\n' "$MANAGED_AGENT_TOKEN" >> "$ROOT/.env"
59+
fi
60+
printf '\033[2m%s\033[0m\n' "Generated MANAGED_AGENT_TOKEN and wrote it to .env."
61+
fi
62+
export MANAGED_AGENT_TOKEN
63+
3964
green() { printf '\033[32m%s\033[0m\n' "$1"; }
4065
red() { printf '\033[31m%s\033[0m\n' "$1"; }
4166
info() { printf '\033[2m%s\033[0m\n' "$1"; }

server/src/agents/runtime-agents.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export function createRuntimeAgentLoader(
2222
database: Database,
2323
/** Resolves a customer agent's key at load time. Absent means no agent can carry one. */
2424
vault?: { reader: CredentialSecretReader; encryptionKey: string },
25+
/** Secret for the deployment-managed Bot. Never sent to customer-owned endpoints. */
26+
managedAgent?: { endpoint: URL; token: string },
2527
) {
2628
return async (actor: AgentActor): Promise<RegisteredAgent[]> => {
2729
const [active, tombstones] = await Promise.all([
@@ -45,6 +47,16 @@ export function createRuntimeAgentLoader(
4547
});
4648
if (headers) agent.headers = headers;
4749
}
50+
if (
51+
agent.type === "remote_ag_ui" &&
52+
managedAgent &&
53+
agent.endpoint === managedAgent.endpoint.toString()
54+
) {
55+
agent.headers = {
56+
...agent.headers,
57+
"x-openbot-agent-token": managedAgent.token,
58+
};
59+
}
4860
registered.set(agent.id, agent);
4961
}
5062
for (const row of tombstones) {

server/src/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export type DeploymentConfig = {
4444
databaseUrl: string;
4545
keyEncryptionKey: string;
4646
managedAgentAgUiUrl: URL;
47+
/** Secret sent only to the managed Bot endpoint. Never stored in an agent row. */
48+
managedAgentToken: string;
4749
/**
4850
* What this deployment calls itself, when more than one shares an Intelligence project.
4951
*
@@ -397,6 +399,7 @@ export function loadConfig(
397399
environment,
398400
"MANAGED_AGENT_AG_UI_URL",
399401
),
402+
managedAgentToken: required(environment, "MANAGED_AGENT_TOKEN"),
400403
deploymentId: optional(environment, "DEPLOYMENT_ID"),
401404
tenantPackageDirectory:
402405
optional(environment, "TENANT_PACKAGE_DIR") ?? "../examples/fintech",

0 commit comments

Comments
 (0)