Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9a79fd8
Borrow upstream security and correctness fixes
Clondin Aug 20, 2026
cf59362
Keep the playground's delete off a component the build ships (#109)
Hotragn Aug 21, 2026
132acb5
Refuse a ref this server cannot resolve, instead of acting blind (#183)
beardthelion Aug 22, 2026
c231146
Keep a Bot from being named after a deployment route (#152)
beardthelion Aug 22, 2026
b50c817
fix: refuse the private-hosts switch in production (#185)
NathanTarbert Aug 23, 2026
cb3c0d3
Keep a rule about one action surface from refusing another
kevin9327 Aug 21, 2026
108f3b0
Answer the Docker socket on loopback, not on every address the host has
beardthelion Aug 23, 2026
7686cde
Say where the supervisor answers, everywhere that already says it for…
beardthelion Aug 23, 2026
45fea84
Read the computer's port and timeouts through the fallback the browse…
kevin9327 Aug 21, 2026
a45196d
Read a thread nobody has run yet as empty, not as broken
Hotragn Aug 22, 2026
013285f
Say which of a connector's granted tools it no longer offers
Hotragn Aug 22, 2026
059cc11
Merge latest Kayco main into upstream hardening
Clondin Aug 23, 2026
43230c7
Close the statement that emptied the audit trail in silence (#148)
davidmckayv Aug 22, 2026
9dc07a3
Keep the shipped Bot ports on loopback too
Clondin Aug 23, 2026
64f0752
Merge latest Kayco main into upstream hardening
Clondin Aug 23, 2026
ea64788
Format the upstream hardening additions
Clondin Aug 23, 2026
675a0cc
Keep managed secrets and neutral policy fields in their lanes
Clondin Aug 23, 2026
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
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ AGENT_COMPUTER_URL=http://localhost:4100
# without this value and refuses every request that does not present it. Use a long random value;
# `scripts/start.sh` sets a development one for you.
COMPUTER_TOKEN=
# Local only. Lets a Bot browse this machine's own services; never set this in a deployment.
# Local only. Kayco's laptop stack uses loopback for its managed Bot and computer, so quick-start
# enables this. The server refuses to start if it is copied into a NODE_ENV=production deployment.
AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
#
# What a Bot may do on its computer, as one JSON object. Absent uses the built-in default, which
Expand Down Expand Up @@ -204,6 +205,11 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
# The managed coworker AG-UI endpoint. Required: use an HTTP(S) URL.
MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui

# Shared secret sent by the server on every call to a deployment-managed Bot, in the
# x-openbot-agent-token header. Both shipped Bots refuse to start without it. Generate one with:
# 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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),
3. Fill the remaining required values:

- `OPENAI_API_KEY`
- `MANAGED_AGENT_TOKEN` (generate with `openssl rand -base64 32`)

Keep the managed Intelligence URLs from `.env.example` unless you run Intelligence yourself. The example `KEY_ENCRYPTION_KEY` is public and fine locally; generate your own with:

Expand Down Expand Up @@ -156,7 +157,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),
- **An audit trail you can export**: `/admin/audit` lists what was permitted, refused and failed and downloads a redacted SHA-256-chained evidence bundle.
- **Operational readiness**: liveness and readiness endpoints surface database, model, task-lease and connector health without leaking details publicly.
- **Credentials encrypted at rest**: stored through `/admin/credentials`, never returned by an API, and redacted from audit events.
- **Loopback by default**: computers bind to `127.0.0.1` and require a per-container token, so nothing reaches a logged-in browser by knowing its port.
- **Loopback by default**: computers bind to `127.0.0.1` and require a per-container token, so nothing reaches a logged-in browser by knowing its port. The supervisor binds there too, because it holds the Docker socket and its token is a shared secret rather than a network boundary.
- **Durable threads and governed context**: conversations survive restarts through CopilotKit Intelligence, while the inspectable memory and work records owned by this deployment stay in PostgreSQL.

## Bring your own agent
Expand Down Expand Up @@ -186,6 +187,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 All @@ -203,7 +205,7 @@ Settings worth knowing:
| `COMPUTER_SUPERVISOR_URL` | Gives each Bot a computer of its own instead of one shared computer. |
| `COMPUTER_RUNTIME` | Set to `runsc` to run computers under gVisor, where the host has it. |
| `AGENT_COMPUTER_POLICY` | JSON action policy. Malformed JSON stops server startup. |
| `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Lets a Bot reach this machine's own services. |
| `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Local-only access to this machine's services; refused in production. |
| `TENANT_PACKAGE_DIR` | Directory containing tenant YAML. Defaults to `../examples/fintech`. |
| `DEPLOYMENT_ID` | Names this deployment when two share one Intelligence project. |

Expand Down Expand Up @@ -254,7 +256,7 @@ A partial set is refused rather than ignored: the server will not start with `BE

- `agent-computer` drives a browser holding real logins. `docker-compose.yml` binds it to loopback; leave it there.
- Store credentials through `/admin/credentials`, which encrypts them. Do not put credential values in tenant YAML or in committed files.
- `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` lets a Bot reach services on this machine. Unset it if you would rather it could not.
- `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` lets a Bot reach services on this machine during local development. The server refuses to start with it enabled in production.

## Development

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 Bot 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
12 changes: 12 additions & 0 deletions agent-computer/src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Read a positive number from the environment, or use the fallback.
*
* Compose can pass an unset value as an empty string, so nullish coalescing alone produces `NaN`
* instead of the documented default. Empty, malformed, zero, and negative values all fall back.
*/
export function numberFromEnv(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
const value = Number(raw);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
13 changes: 4 additions & 9 deletions agent-computer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
NO_SECRET_PENDING,
TAKE_CONTROL_FIRST,
} from "./control";
import { numberFromEnv } from "./env";
import { identity } from "./identity";
import { createProfiles, VIEWPORT } from "./profiles";
import {
Expand Down Expand Up @@ -69,11 +70,8 @@ if (!COMPUTER_TOKEN) {
process.exit(1);
}

const PORT = Number.parseInt(process.env.PORT ?? "4100", 10);
const NAVIGATION_TIMEOUT_MS = Number.parseInt(
process.env.NAVIGATION_TIMEOUT_MS ?? "30000",
10,
);
const PORT = numberFromEnv("PORT", 4100);
const NAVIGATION_TIMEOUT_MS = numberFromEnv("NAVIGATION_TIMEOUT_MS", 30000);

/**
* How long one action waits for its element.
Expand All @@ -82,10 +80,7 @@ const NAVIGATION_TIMEOUT_MS = Number.parseInt(
* behaviour we want, but a ref that no longer resolves would otherwise hang for the full navigation
* timeout before saying so, and the person is sitting watching a screen that is not changing.
*/
const ACTION_TIMEOUT_MS = Number.parseInt(
process.env.ACTION_TIMEOUT_MS ?? "4000",
10,
);
const ACTION_TIMEOUT_MS = numberFromEnv("ACTION_TIMEOUT_MS", 4000);

/**
* How much page text a navigation hands back.
Expand Down
28 changes: 28 additions & 0 deletions agent-computer/tests/number-from-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, test } from "bun:test";
import { numberFromEnv } from "../src/env";

const NAME = "OPENBOT_TEST_NUMBER_FROM_ENV";

afterEach(() => {
delete process.env[NAME];
});

describe("numberFromEnv", () => {
test("takes a positive number and trims whitespace", () => {
process.env[NAME] = " 5000 ";
expect(numberFromEnv(NAME, 10000)).toBe(5000);
});

test("falls back when unset or empty", () => {
expect(numberFromEnv(NAME, 10000)).toBe(10000);
process.env[NAME] = "";
expect(numberFromEnv(NAME, 10000)).toBe(10000);
});

test("falls back for malformed and non-positive values", () => {
for (const value of ["soon", "0", "-5"]) {
process.env[NAME] = value;
expect(numberFromEnv(NAME, 10000)).toBe(10000);
}
});
});
11 changes: 11 additions & 0 deletions agent-langgraph/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { ChatOpenAI } from "@langchain/openai";
import { ChatXAI } from "@langchain/xai";
import { serve } from "bun";
import { hasManagedAgentToken } from "../../shared/agent-authorisation";
import { SYSTEM_PROMPT } from "../../shared/bot-prompt";

/**
Expand All @@ -40,6 +41,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 Bot 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 @@ -570,6 +578,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
7 changes: 7 additions & 0 deletions app/src/lib/plugins/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export type PluginTool = {
grantedTo: string[];
};

export type WithdrawnGrant = {
ref: string;
name: string;
grantedTo: string[];
};

export type PluginServer = {
id: string;
title: string;
Expand All @@ -30,6 +36,7 @@ export type PluginServer = {
lastError: string | null;
addedBy: string | null;
tools: PluginTool[];
withdrawn: WithdrawnGrant[];
};

export type PluginSkill = {
Expand Down
36 changes: 36 additions & 0 deletions app/src/routes/_authed/admin/plugins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,42 @@ function Yours({
))}
</div>
)}

{server.withdrawn.length > 0 ? (
<div className="border-border border-t bg-amber-500/5 px-4 py-3">
<div className="font-medium text-amber-800 text-sm dark:text-amber-400">
Held but not offered
</div>
<p className="mt-0.5 text-muted-foreground text-xs">
This server no longer lists these tools. No model can call them,
but their grants remain recorded and will become active again if
the server advertises the same names.
</p>
<div className="mt-3 space-y-3">
{server.withdrawn.map((held) => (
<div key={held.ref}>
<code className="font-mono text-xs">{held.name}</code>
<div className="mt-1.5 flex flex-wrap gap-2">
{held.grantedTo.map((botId) => {
const bot = bots.find((item) => item.id === botId);
return (
<Button
key={botId}
onClick={() => onGrant(held.ref, botId, true)}
size="sm"
type="button"
variant="default"
>
Revoke from {bot?.name ?? botId}
</Button>
);
})}
</div>
</div>
))}
</div>
</div>
) : null}
</div>
))}
</div>
Expand Down
1 change: 1 addition & 0 deletions deploy/hetzner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ openssl rand -base64 48 # BETTER_AUTH_SECRET
openssl rand -hex 32 # POSTGRES_PASSWORD
openssl rand -hex 32 # COMPUTER_TOKEN
openssl rand -hex 32 # SUPERVISOR_TOKEN
openssl rand -base64 32 # MANAGED_AGENT_TOKEN
```

Fill the remaining settings in `env.production`. In Google Cloud, register this redirect URI:
Expand Down
2 changes: 2 additions & 0 deletions deploy/hetzner/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ services:
CODEX_PROCESS_IDLE_MS: ${CODEX_PROCESS_IDLE_MS:-300000}
CODEX_DEFAULT_MODEL: ${CODEX_DEFAULT_MODEL:-}
MANAGED_AGENT_AG_UI_URL: http://agent-langgraph:4201/ag-ui
MANAGED_AGENT_TOKEN: ${MANAGED_AGENT_TOKEN:?Set MANAGED_AGENT_TOKEN in env.production}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
GOOGLE_API_KEY: ${GOOGLE_API_KEY:-}
Expand Down Expand Up @@ -143,6 +144,7 @@ services:
restart: unless-stopped
environment:
PORT: "4201"
MANAGED_AGENT_TOKEN: ${MANAGED_AGENT_TOKEN:?Set MANAGED_AGENT_TOKEN in env.production}
BOT_PROVIDER: ${BOT_PROVIDER:-openai}
BOT_MODEL: ${BOT_MODEL:-}
BOT_RESPONSES_API: ${BOT_RESPONSES_API:-false}
Expand Down
1 change: 1 addition & 0 deletions deploy/hetzner/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ BETTER_AUTH_SECRET=replace-me
BETTER_AUTH_CROSS_SITE_COOKIES=false
COMPUTER_TOKEN=replace-me
SUPERVISOR_TOKEN=replace-me
MANAGED_AGENT_TOKEN=replace-me

GOOGLE_OAUTH_CLIENT_ID=replace-me
GOOGLE_OAUTH_CLIENT_SECRET=replace-me
Expand Down
17 changes: 14 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,16 @@ services:
SPIRE_AGENT_SOCKET_VOLUME: ${COMPOSE_PROJECT_NAME:-openbot}_spire-agent-socket
ports:
# For the server on the host to ask for a Bot's computer.
- "${SUPERVISOR_PORT:-4500}:4300"
#
# Loopback only, like the computer's own port and for a stronger version of the same reason.
# This process holds the Docker socket, so reaching it is root on the host through four verbs,
# and SUPERVISOR_TOKEN is a shared secret rather than a network boundary. Published without an
# interface in front of it this answered every address the host has.
#
# A deployment running the server inside this network does not use this mapping at all: it sets
# COMPUTER_NETWORK and reaches the supervisor as `supervisor:4300`, which is unaffected because
# the process still listens on every interface inside its own container.
- "127.0.0.1:${SUPERVISOR_PORT:-4500}:4300"
volumes:
# Read-only because this service only ever needs to ask; it is still root-equivalent, which is
# the whole reason nothing else here gets it.
Expand All @@ -182,9 +191,10 @@ services:
context: .
dockerfile: agent-bot/Dockerfile
ports:
- "${BOT_PORT:-4200}:4200"
- "127.0.0.1:${BOT_PORT:-4200}:4200"
environment:
OPENAI_API_KEY: ${OPENAI_API_KEY}
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 @@ -201,11 +211,12 @@ services:
context: .
dockerfile: agent-langgraph/Dockerfile
ports:
- "${LANGGRAPH_PORT:-4201}:4201"
- "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}
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
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ administrator can see which Bot computers hold the slots, stop one deliberately,
Bot, and retry the same turn. Regular users receive the limit and recovery instruction without the
identities of other users' Bots.

The supervisor exposes only ensure, stop, reset, and list operations. It holds the Docker socket, so do not expose it outside the deployment network. Set `COMPUTER_RUNTIME=runsc` to run computers under gVisor on hosts that support it.
The supervisor exposes only ensure, stop, reset, and list operations. It holds the Docker socket, so do not expose it outside the deployment network: Docker Compose binds it to `127.0.0.1:4500`, and a deployment running the server inside the compose network reaches it as `supervisor:4300` and needs no published port at all. Set `COMPUTER_RUNTIME=runsc` to run computers under gVisor on hosts that support it.

The server leases a supervisor-reported computer address for one minute and deduplicates concurrent
cold starts. Stop and reset invalidate the lease immediately. Opening the screen also warms the
Expand Down
5 changes: 3 additions & 2 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 Expand Up @@ -163,7 +164,7 @@ Google OAuth client id and secret must be configured together. If Google OAuth i
| `COMPUTER_TOKEN` | Secret every computer request must present. The computer refuses to start without it. |
| `COMPUTER_SUPERVISOR_URL` | Supervisor URL for per-Bot computers. If absent, Bots share `AGENT_COMPUTER_URL`. |
| `SUPERVISOR_TOKEN` | Bearer token required by the supervisor. |
| `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Local-only private-host browsing when `true`. Cloud metadata addresses are still refused. |
| `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Local-only private-host browsing when `true`; refused in production. Cloud metadata addresses are always refused. |
| `AGENT_COMPUTER_POLICY` | JSON action policy: `{"mode":"enforce","deny":[...],"approve":[...],"allow":[...]}`. |
| `COMPUTER_RUNTIME` | Set to `runsc` to run supervised computers under gVisor. |

Expand Down Expand Up @@ -276,7 +277,7 @@ agents:
role_description: Answer company knowledge questions and cite sources.
avatar_seed: knowledge
type: built-in
system_prompt: Answer from authorized company knowledge and cite every source.
system_prompt: Answer only from authorized company knowledge and cite every source you use. If no authorized source is connected or the answer is not present, say so plainly instead of guessing.

- id: risk-analyst
name: Risk Analyst
Expand Down
5 changes: 4 additions & 1 deletion docs/coworkers.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,12 @@ Product-created coworkers use:

```dotenv
MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui
MANAGED_AGENT_TOKEN=<openssl rand -base64 32>
```

The server requires this setting at startup. Package-provided agents use their own `agents.yaml` configuration.
The server and both shipped Bots require the token at startup. The server sends it only to the
managed endpoint; customer-owned endpoints never receive it. Package-provided agents use their own
`agents.yaml` configuration.

## Register an external AG-UI agent

Expand Down
Loading
Loading