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
14 changes: 8 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -203,15 +203,17 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
# EGRESS_PROXY_SALES_BOT=http://sales.proxy.internal:8080


# The managed coworker AG-UI endpoint. Required: use an HTTP(S) URL.
# The managed coworker AG-UI endpoint. Optional: use an HTTP(S) URL, and set MANAGED_AGENT_TOKEN
# with it. A URL with no token refuses to start; a leftover token with no URL is ignored.
#
# Defaults to agent-langgraph on 4201, which runs a real framework and its own tool loop. The
# proof-of-concept on 4200 hand-writes the protocol and leaves the loop to whatever is watching, so
# it is a reference rather than something to build a deployment on.
MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui
# Unset in the one-container image, which does not carry a Bot. `scripts/start.sh` points this at
# agent-langgraph on 4201 for a laptop. The proof-of-concept on 4200 hand-writes the protocol and
# leaves the loop to whatever is watching, so 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.
# header. Required together with the URL above; the server starts without either.
#
# `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
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,8 @@ jobs:
-e EMBEDDED_POSTGRES=on \
-e KEY_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
-e TRUSTED_ORIGINS=http://localhost:3001 \
-e OPENBOT_SINGLE_USER=true \
-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 OPENBOT_SINGLE_USER=true \
-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 \
-e COPILOTKIT_LICENSE_TOKEN=ci-not-a-real-licence \
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ digit. The same rule container and volume names have always followed. A deployme
`AUDIT_RETENTION_DAYS` is new and unset, which keeps the audit trail forever, as before. Set it to a
whole number of days to have old rows removed.

`MANAGED_AGENT_AG_UI_URL` is no longer required to start. The one-container image does not carry a
Bot, so requiring it registered the shipped Risk Analyst against a host that was not there and every
conversation with it failed. Leave it unset for that image. A laptop `scripts/start.sh` still points
it at `agent-langgraph`. A URL with no `MANAGED_AGENT_TOKEN` still refuses to start; a leftover
token with no URL is ignored.

A `.env` copied from an older `.env.example` still has `MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui`.
Unset it before `docker run --env-file .env`, or the coworker comes back.
The built-in Bot refuses to start without `OPENAI_API_KEY`. It used to start, report healthy, and
then fail every conversation, so a missing key looked like a working deployment. The LangGraph Bot
already refused the same way.
Expand Down Expand Up @@ -114,6 +122,10 @@ Sessions survive and nobody signs in again.
is unavailable never blocks a sign-in.

### Fixed
- **The one-container image registered a coworker it could not run.** `MANAGED_AGENT_AG_UI_URL`
defaulted to `localhost:4201` and was required, so Risk Analyst appeared on the roster and every
conversation with it failed. The URL is optional; the package omits that coworker when it is
unset. `scripts/start.sh` still points it at `agent-langgraph` on a laptop.
- **A boundary rule applied on one server out of N.** The policy is read from memory on every action,
which is right, but memory was only ever filled at boot. An administrator's new deny rule was
enforced by whichever process served the request and roughly one action in N went through it, while
Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ From `/agents`, create a coworker with:
- optional AG-UI endpoint;
- optional write-only authorization header.

The server validates agent endpoints with the same target checks used for browser navigation. If no custom endpoint is set, product-created coworkers use `MANAGED_AGENT_AG_UI_URL`.
The server validates agent endpoints with the same target checks used for browser navigation. If no custom endpoint is set, product-created coworkers use `MANAGED_AGENT_AG_UI_URL` when it is configured, and are refused when it is not.

Tenant package agents are declared in `agents.yaml` as either:

Expand All @@ -183,8 +183,6 @@ 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
9 changes: 7 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,20 @@ 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. |
| `COPILOTKIT_LICENSE_TOKEN` | License token for the Intelligence project. |

All four Intelligence values are required together. Missing any of them stops server startup.

`MANAGED_AGENT_AG_UI_URL` names the Bot in the box: the default endpoint for coworkers created in
the product. It needs `MANAGED_AGENT_TOKEN` beside it, or the server refuses to start. Unset, the
server starts without a managed Bot, the shipped Risk Analyst coworker is omitted, and creating a
coworker without its own endpoint is refused. A leftover token with no URL is ignored. The
one-container image has no Bot process, so leave the URL unset there. `scripts/start.sh` points it
at `agent-langgraph` on a laptop.

## General variables

| Variable | Default | Meaning |
Expand Down
5 changes: 4 additions & 1 deletion docs/coworkers.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ That is `agent-langgraph`, which runs a real framework and its own tool loop. Th
`4200` hand-writes the protocol and leaves the loop to whatever is watching, so it is a reference
rather than something to build a deployment on.

The server requires this setting at startup. Package-provided agents use their own `agents.yaml` configuration.
The URL is optional. Set it with `MANAGED_AGENT_TOKEN`, or leave it unset: product-created coworkers
then need their own endpoint, and a package agent whose endpoint expands to nothing is omitted
rather than registered against a missing host. A leftover token with no URL is ignored.
Package-provided agents otherwise use their own `agents.yaml` configuration.

## Register an external AG-UI agent

Expand Down
16 changes: 10 additions & 6 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,17 @@ and the fix would not be available.
| `KEY_ENCRYPTION_KEY` | base64 32 bytes. `openssl rand -base64 32`. The example key is refused in production |
| `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY` | CopilotKit Intelligence. A free plan is available and it can be self-hosted |
| `COPILOTKIT_LICENSE_TOKEN` | from `npx copilotkit@latest license --write` |
| `MANAGED_AGENT_AG_UI_URL` | the AG-UI endpoint for the example remote Bot |
| a model key | `OPENAI_API_KEY`, or the provider you configured |

`COMPUTER_TOKEN` is generated at start if you do not set one. Both processes that need it are inside
the container, so there is nothing to share it with.

`MANAGED_AGENT_AG_UI_URL` is not required here. The image does not carry `agent-langgraph` or
`agent-bot`. Leave it unset and the shipped Risk Analyst coworker is omitted rather than registered
against a host that is not there. Set it, with `MANAGED_AGENT_TOKEN`, only when a Bot is actually
reachable from this container. Unset it if your `.env` still has the laptop default
`http://localhost:4201/ag-ui`.

**Authentication is required.** With no identity provider configured the deployment refuses to start,
because a public URL where every visitor is an administrator fails silently: it looks like it works.
Configure Google, Microsoft or Okta, or set `OPENBOT_SINGLE_USER=true` to say you meant an open
Expand All @@ -98,12 +103,11 @@ docker run --rm --env-file .env openbot \
sh -c "cd /app/server && bun x drizzle-kit migrate --config=drizzle.config.ts"
```

## One replica, for now
## Replicas

Run one. The gateway still caches the page snapshot a Bot resolves element references against in
process memory, so a second replica answers a click with a snapshot it never took. The symptom is an
element that cannot be found, intermittently, which reads as a flaky Bot rather than as a
configuration problem. Pin the platform's maximum instance count until that moves to the database.
The page snapshot a Bot resolves element references against lives in Postgres, so a second replica
can answer a click the first one snapshotted. Run more than one if the platform wants it. The
supervisor is still not in this image, so every replica shares the one browser inside it.

## Platform notes

Expand Down
2 changes: 1 addition & 1 deletion examples/fintech/agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ agents:
role_description: Investigate policies, transaction monitoring, and control evidence.
avatar_seed: risk-analyst
type: remote-ag-ui
endpoint: ${MANAGED_AGENT_AG_UI_URL:-http://localhost:4201/ag-ui}
endpoint: ${MANAGED_AGENT_AG_UI_URL:-}
6 changes: 6 additions & 0 deletions scripts/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ COMPUTER_TOKEN="$(setting COMPUTER_TOKEN openbot-dev-computer-token)"
#
# Written into .env rather than exported for this run alone, so `docker compose up` by hand later
# sees the same value the script used.
# The laptop stack runs agent-langgraph on LANGGRAPH_PORT. The one-container image does not, so
# this default stays in the script rather than in .env: a `docker run --env-file .env` must not
# inherit a URL that points at a process the image does not contain.
MANAGED_AGENT_AG_UI_URL="$(setting MANAGED_AGENT_AG_UI_URL "http://localhost:${LANGGRAPH_PORT}/ag-ui")"
export MANAGED_AGENT_AG_UI_URL

MANAGED_AGENT_TOKEN="$(setting MANAGED_AGENT_TOKEN "")"
if [ -z "$MANAGED_AGENT_TOKEN" ]; then
MANAGED_AGENT_TOKEN="$(openssl rand -base64 32)"
Expand Down
30 changes: 23 additions & 7 deletions server/src/agents/profile-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ export class ProtectedAgentError extends Error {
}
}

export class ManagedAgentUnavailableError extends Error {
constructor() {
super(
"This deployment has no managed Bot. Give the coworker its own AG-UI endpoint.",
);
this.name = "ManagedAgentUnavailableError";
}
}

const joinedProjection = {
id: agents.id,
name: agents.name,
Expand Down Expand Up @@ -258,16 +267,16 @@ async function findByTokenHash(

export function createAgentProfileStore(
database: Database,
managedAgentAgUiUrl: URL,
managedAgentAgUiUrl: URL | undefined,
/**
* Where a customer agent's key is kept. Optional so a deployment without a vault still runs; an
* agent with a key then simply cannot be created, which is better than storing it in the clear.
*/
vault?: { store: CredentialStore; encryptionKey: string },
): AgentProfileStore {
const managedConfiguration = {
endpoint: managedAgentAgUiUrl.toString(),
};
const managedConfiguration = managedAgentAgUiUrl
? { endpoint: managedAgentAgUiUrl.toString() }
: undefined;

return {
async list(actor, hidden = false) {
Expand Down Expand Up @@ -295,6 +304,12 @@ export function createAgentProfileStore(
create(actor, input) {
return database.transaction(async (transaction) => {
const id = newAgentId();
const endpoint = input.endpoint
? { endpoint: input.endpoint }
: managedConfiguration;
if (!endpoint) {
throw new ManagedAgentUnavailableError();
}
await transaction.insert(agents).values({
id,
name: input.name,
Expand All @@ -305,9 +320,7 @@ export function createAgentProfileStore(
// The key, if there is one, goes to the vault and only its reference is stored here. See
// auth-header.ts for why a bearer token must not sit next to the endpoint.
configuration: {
...(input.endpoint
? { endpoint: input.endpoint }
: managedConfiguration),
...endpoint,
...(input.auth && vault
? {
auth: await storeAgentAuth({
Expand Down Expand Up @@ -420,6 +433,9 @@ export function createAgentProfileStore(
const source = await findAccessibleProfile(transaction, actor, id);
if (!source) throw new AgentNotFoundError(id);

if (!managedConfiguration) {
throw new ManagedAgentUnavailableError();
}
const duplicateId = newAgentId();
await transaction.insert(agents).values({
id: duplicateId,
Expand Down
4 changes: 4 additions & 0 deletions server/src/agents/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AgentNotFoundError,
AgentNotManageableError,
type AgentProfileStore,
ManagedAgentUnavailableError,
ProtectedAgentError,
} from "./profile-store";
import type {
Expand Down Expand Up @@ -476,5 +477,8 @@ function mapStoreError(context: Context, error: unknown): Response {
if (error instanceof ProtectedAgentError) {
return context.json({ error: "System-owned agents are protected." }, 403);
}
if (error instanceof ManagedAgentUnavailableError) {
return context.json({ error: error.message }, 400);
}
throw error;
}
57 changes: 47 additions & 10 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,23 @@ export function configuredAuthProviders(
return providers;
}

export type ManagedAgentConfig = {
endpoint: URL;
/** Secret sent only to the managed Bot endpoint. Never stored in an agent row. */
token: string;
};

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;
/**
* The Bot in the box, when this deployment has one.
*
* Absent is the one-container image: it carries no AG-UI process, and a required URL would
* register a coworker against a host that is not there. Set both the URL and the token together
* when a remote Bot is actually running.
*/
managedAgent?: ManagedAgentConfig;
/**
* What this deployment calls itself, when more than one shares an Intelligence project.
*
Expand Down Expand Up @@ -220,8 +231,14 @@ function url(environment: Environment, name: string): string | undefined {
return value;
}

function requiredHttpUrl(environment: Environment, name: string): URL {
const value = required(environment, name);
function optionalHttpUrl(
environment: Environment,
name: string,
): URL | undefined {
const value = optional(environment, name);
if (!value) {
return undefined;
}

let parsed: URL;
try {
Expand All @@ -237,6 +254,29 @@ function requiredHttpUrl(environment: Environment, name: string): URL {
return parsed;
}

/**
* The Bot in the box, if this deployment has one.
*
* A URL with no token would send unauthenticated calls to a Bot that refuses them, so that half
* alone refuses to start. A token with no URL is the leftover `scripts/start.sh` writes into
* `.env`; it names nothing and is ignored, so a one-container image can boot from that file.
*/
function managedAgentConfig(
environment: Environment,
): ManagedAgentConfig | undefined {
const endpoint = optionalHttpUrl(environment, "MANAGED_AGENT_AG_UI_URL");
const token = optional(environment, "MANAGED_AGENT_TOKEN");
if (endpoint && !token) {
throw new Error(
"MANAGED_AGENT_TOKEN must be set when MANAGED_AGENT_AG_UI_URL is set",
);
}
if (!endpoint || !token) {
return undefined;
}
return { endpoint, token };
}

function oauthClient(
environment: Environment,
provider: "GOOGLE" | "MICROSOFT" | "OKTA",
Expand Down Expand Up @@ -542,15 +582,12 @@ export function loadConfig(
): DeploymentConfig {
const google = oauthClient(environment, "GOOGLE");
const auth = authConfig(environment, google);
const managedAgent = managedAgentConfig(environment);

return {
databaseUrl: required(environment, "DATABASE_URL"),
keyEncryptionKey: keyEncryptionKey(environment),
managedAgentAgUiUrl: requiredHttpUrl(
environment,
"MANAGED_AGENT_AG_UI_URL",
),
managedAgentToken: required(environment, "MANAGED_AGENT_TOKEN"),
...(managedAgent ? { managedAgent } : {}),
deploymentId: optional(environment, "DEPLOYMENT_ID"),
tenantPackageDirectory:
optional(environment, "TENANT_PACKAGE_DIR") ?? "../examples/fintech",
Expand Down
11 changes: 6 additions & 5 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ const agentVault = {
};
const agentProfileStore = createAgentProfileStore(
database,
config.managedAgentAgUiUrl,
config.managedAgent?.endpoint,
agentVault,
);
// Read here rather than beside the synchronise below, because the package names the deployment and
Expand Down Expand Up @@ -158,10 +158,11 @@ const channelActivityListener = await startChannelActivityListener(
channelEvents,
);
const roleRepository = createRoleRepository(database);
const loadAgentsForActor = createRuntimeAgentLoader(database, agentVault, {
endpoint: config.managedAgentAgUiUrl,
token: config.managedAgentToken,
});
const loadAgentsForActor = createRuntimeAgentLoader(
database,
agentVault,
config.managedAgent,
);
await synchronizeTenantPackage(database, tenantPackage);
/*
* Built before `auth`, because the deny list is consulted during sign-in and the store is what
Expand Down
Loading
Loading