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
43 changes: 37 additions & 6 deletions docs/bitmind-gateway.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# BitMind gateway

The surface BitMind's run plane talks to, run as its own process
(`server/src/bitmind/main.ts`) inside the execution enclave. It exists so the two
sides can meet on the real protocol — AG-UI at the pinned `@ag-ui/core@0.0.57`
(bit-mind ADR-0002) — before the full server learns to boot without the Intelligence
contract.
The surface BitMind's run plane talks to, served by the OpenBot server itself inside
the execution enclave. It exists so the two sides can meet on the real protocol —
AG-UI at the pinned `@ag-ui/core@0.0.57` (bit-mind ADR-0002).

It began as a separate process, because the server refused to boot without the
Intelligence contract and the enclave has none. Standalone mode removed that reason,
so the gateway now boots with the server: one process for the enclave to supervise,
one shutdown, one place a deployment's configuration is read.

## Surface

Expand Down Expand Up @@ -51,10 +54,38 @@ statement rides in `forwardedProps` — `workspace_id`, `agent_id`, `run_id`,

## Running it

Set the variables and start the server as usual:

```
BITMIND_SERVICE_TOKEN=… BITMIND_AGENT_TOKEN=… bun run --filter server bitmind:start
OPENBOT_RUNTIME_MODE=standalone \
BITMIND_SERVICE_TOKEN=… BITMIND_AGENT_TOKEN=… \
bun run --filter server dev
```

| Variable | Default | Meaning |
| --- | --- | --- |
| `BITMIND_SERVICE_TOKEN` | — | Required. What BitMind authenticates with. |
| `BITMIND_AGENT_TOKEN` | — | Required. The downstream agent's managed-agent token. |
| `BITMIND_AGENT_URL` | `http://localhost:4201/ag-ui` | Where runs are relayed. |
| `BITMIND_GATEWAY_HOST` | `127.0.0.1` | Loopback unless deliberately changed. |
| `BITMIND_GATEWAY_PORT` | `4310` | The gateway's own port. |
| `BITMIND_MAX_CONCURRENT_RUNS` | `2` | Admission ceiling. |
| `BITMIND_RUN_TIMEOUT_MS` | `900000` | Whole-run relay ceiling. |

Setting none of them mounts no gateway; setting any of them requires all of the
required ones, so a half-configured enclave fails at boot rather than answering
BitMind with a server that cannot relay.

### Why a second port rather than a path on the server's

The gateway listens on its own host and port, in the server's process. The server's
port is what an operator publishes — an ingress, a compose port mapping — and putting a
service-token-authenticated relay on it would export the enclave's private surface
wherever that port goes, without anybody choosing it. Loopback stays the default here,
as bit-mind's `docs/operations/openbot-single-host-enclave.md` requires. A boot test
asserts both halves: the gateway answers on its own port, and the server's port answers
`404` to `/bitmind/v1/attestation` even with the service token in hand.

It refuses to start without both tokens, binds loopback by default, and never holds
BitMind database credentials, OIDC secrets, or the Docker socket — per the enclave
boundary.
4 changes: 1 addition & 3 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@
"db:generate": "bun --env-file=../.env drizzle-kit generate --config=drizzle.config.ts",
"db:migrate": "bun --env-file=../.env drizzle-kit migrate --config=drizzle.config.ts",
"dev": "bun --env-file=../.env --watch src/index.ts",
"typecheck": "tsc --noEmit",
"bitmind:dev": "bun --env-file=../.env --watch src/bitmind/main.ts",
"bitmind:start": "bun src/bitmind/main.ts"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ag-ui/client": "0.0.57",
Expand Down
31 changes: 0 additions & 31 deletions server/src/bitmind/main.ts

This file was deleted.

74 changes: 74 additions & 0 deletions server/src/bitmind/mount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { serve } from "bun";
import {
bitmindGatewayConfig,
bitmindGatewayListen,
type BitmindGatewayConfig,
type BitmindGatewayListen,
} from "./config";
import { createBitmindGateway } from "./gateway";

/**
* Every variable that configures the gateway.
*
* Presence of ANY of them is what says "this deployment means to serve BitMind".
* Configuration is then validated in full, so a half-set environment — a service
* token with no agent token, a port with no tokens at all — fails in front of whoever
* deployed it rather than booting a server that quietly serves nobody.
*/
const GATEWAY_VARIABLES = [
"BITMIND_SERVICE_TOKEN",
"BITMIND_AGENT_TOKEN",
"BITMIND_AGENT_URL",
"BITMIND_GATEWAY_HOST",
"BITMIND_GATEWAY_PORT",
"BITMIND_MAX_CONCURRENT_RUNS",
"BITMIND_RUN_TIMEOUT_MS",
] as const;

export interface BitmindGatewayMount {
config: BitmindGatewayConfig;
listen: BitmindGatewayListen;
gateway: ReturnType<typeof createBitmindGateway>;
}

/**
* The gateway this environment asks for, or nothing.
*
* Returns undefined only when the environment says nothing about BitMind at all: an
* ordinary OpenBot deployment mounts no gateway and needs no opinion about one.
*/
export function bitmindGatewayFrom(
environment: NodeJS.ProcessEnv,
): BitmindGatewayMount | undefined {
const mentioned = GATEWAY_VARIABLES.some((name) =>
Boolean(environment[name]?.trim()),
);
if (!mentioned) return undefined;
const config = bitmindGatewayConfig(environment);
return {
config,
listen: bitmindGatewayListen(environment),
gateway: createBitmindGateway(config),
};
}

/**
* Serves the gateway on a listener of its own, in this process.
*
* A second listener rather than a path on the server's own port, deliberately. The
* enclave boundary is stated in terms of exposure: the server port is what an operator
* publishes — an ingress, a compose port mapping — and adding `/bitmind/v1` to it would
* put a service-token-authenticated relay wherever that port goes, by accident and
* without anybody choosing it. Its own host and port keep the loopback default that
* bit-mind's `openbot-single-host-enclave.md` requires, while the process, its
* supervision, and its shutdown are the server's.
*/
export function serveBitmindGateway(mount: BitmindGatewayMount) {
return serve({
hostname: mount.listen.host,
port: mount.listen.port,
// Idle SSE relays are kept open well past Bun's default while a model thinks.
idleTimeout: 120,
fetch: (request) => mount.gateway.fetch(request),
});
}
23 changes: 23 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { handoffTool } from "./agents/handoff-tool";
import { createAgentProfileStore } from "./agents/profile-store";
import type { AgentActor } from "./agents/profile-types";
import { createApp } from "./app";
import { bitmindGatewayFrom, serveBitmindGateway } from "./bitmind/mount";
import { createAuditReader, createAuditStore, recordAuditEvent } from "./audit";
import { startRetentionSweeps } from "./audit-retention";
import { createAuth } from "./auth";
Expand Down Expand Up @@ -1276,6 +1277,25 @@ serve<SocketData>({
},
});

/*
* BitMind's doorway, in this process.
*
* Configured or not: an ordinary deployment sets none of the BITMIND_* variables and
* gets no gateway. Where they are set they are validated in full, so a half-configured
* enclave fails here rather than answering BitMind with a server that cannot relay.
*
* On a listener of its own — see serveBitmindGateway for why the server's own port is
* the wrong place for a service-token surface. Same process, so the enclave supervises
* one thing and a signal stops both.
*/
const bitmind = bitmindGatewayFrom(process.env);
const bitmindServer = bitmind ? serveBitmindGateway(bitmind) : undefined;
if (bitmind) {
console.info(
`bitmind-gateway listening on http://${bitmind.listen.host}:${String(bitmind.listen.port)}/bitmind/v1 (agent: ${bitmind.config.agentUrl})`,
);
}

if (config.singleUser) {
// Loud, every boot. A server that is not checking who is asking should never be a quiet default.
console.warn(
Expand All @@ -1295,6 +1315,9 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) {
// Started only where handing work between Bots is switched on, so it is often not there.
workOfferedListener?.stop() ?? Promise.resolve(),
Promise.resolve(retentionSweeps.stop()),
// Stops accepting BitMind runs on the way out rather than dying mid-relay with
// the port still bound.
Promise.resolve(bitmindServer?.stop()),
]).finally(() => process.exit(0));
});
}
Expand Down
76 changes: 76 additions & 0 deletions server/tests/bitmind-mount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test";
import { bitmindGatewayFrom } from "../src/bitmind/mount";

const COMPLETE = {
BITMIND_SERVICE_TOKEN: "service-token-for-tests-0000000000000000",
BITMIND_AGENT_TOKEN: "managed-agent-token-for-tests-00000000",
};

describe("mounting the gateway", () => {
test("an ordinary deployment mounts nothing", () => {
expect(bitmindGatewayFrom({})).toBeUndefined();
// Unrelated variables do not summon a gateway either.
expect(
bitmindGatewayFrom({ PORT: "3000", MANAGED_AGENT_TOKEN: "token" }),
).toBeUndefined();
});

test.each([
["BITMIND_SERVICE_TOKEN", { BITMIND_SERVICE_TOKEN: "token" }],
["BITMIND_AGENT_TOKEN", { BITMIND_AGENT_TOKEN: "token" }],
["BITMIND_GATEWAY_PORT", { BITMIND_GATEWAY_PORT: "4310" }],
["BITMIND_AGENT_URL", { BITMIND_AGENT_URL: "http://localhost:4201/ag-ui" }],
["BITMIND_MAX_CONCURRENT_RUNS", { BITMIND_MAX_CONCURRENT_RUNS: "2" }],
] as const)(
"%s alone is a boot failure, not a quiet half-gateway",
(_name, environment) => {
// Whichever half is present, the missing token is what the operator is told
// about — a server that boots and then refuses every BitMind call would look
// like BitMind's fault.
expect(() => bitmindGatewayFrom(environment)).toThrow(
/BITMIND_\w+_TOKEN/,
);
},
);

test("a configured environment yields a loopback gateway", () => {
const mount = bitmindGatewayFrom(COMPLETE);
expect(mount).toBeDefined();
expect(mount?.listen).toEqual({ host: "127.0.0.1", port: 4310 });
expect(mount?.config.agentUrl).toBe("http://localhost:4201/ag-ui");
expect(mount?.gateway.activeRuns()).toBe(0);
});

test("the listener stays where it is put", () => {
const mount = bitmindGatewayFrom({
...COMPLETE,
BITMIND_GATEWAY_HOST: "10.1.2.3",
BITMIND_GATEWAY_PORT: "4999",
});
expect(mount?.listen).toEqual({ host: "10.1.2.3", port: 4999 });
});

test("a malformed limit fails the boot rather than being coerced", () => {
expect(() =>
bitmindGatewayFrom({ ...COMPLETE, BITMIND_GATEWAY_PORT: "4310ish" }),
).toThrow(/whole number/);
});

test("the mounted gateway is the real one, and still refuses without the token", async () => {
const mount = bitmindGatewayFrom(COMPLETE);
expect(mount).toBeDefined();
const anonymous = await mount?.gateway.fetch(
new Request("http://gateway/bitmind/v1/attestation"),
);
expect(anonymous?.status).toBe(401);
const authenticated = await mount?.gateway.fetch(
new Request("http://gateway/bitmind/v1/attestation", {
headers: { authorization: `Bearer ${COMPLETE.BITMIND_SERVICE_TOKEN}` },
}),
);
expect(authenticated?.status).toBe(200);
expect(await authenticated?.json()).toMatchObject({
isolated_computers: false,
});
});
});
95 changes: 95 additions & 0 deletions server/tests/standalone-boot.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,98 @@ test.skipIf(!databaseUrl)(
}
},
);

/**
* The gateway, mounted in the real server process.
*
* Two claims only this boot can settle. That `src/index.ts` actually serves BitMind
* when the environment configures it — the focused tests build the gateway by hand and
* would pass with the wiring removed. And that mounting it did NOT put a
* service-token surface on the server's own port, which is the mistake this design
* exists to avoid: the server port is what an operator publishes, the gateway port is
* loopback by choice.
*/
test.skipIf(!databaseUrl)(
"the BitMind gateway is served on its own port, and only there",
{ timeout: 60_000 },
async () => {
const port = 40_000 + Math.floor(Math.random() * 10_000);
const gatewayPort = port + 1;
const serviceToken = "bitmind-service-token-for-the-boot-test";
const child = Bun.spawn(["bun", "src/index.ts"], {
cwd: new URL("..", import.meta.url).pathname,
env: {
PATH: process.env.PATH ?? "",
HOME: process.env.HOME ?? "",
DATABASE_URL: databaseUrl ?? "",
KEY_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
OPENBOT_SINGLE_USER: "true",
OPENBOT_RUNTIME_MODE: "standalone",
MANAGED_AGENT_AG_UI_URL: "http://localhost:4201/ag-ui",
MANAGED_AGENT_TOKEN: "standalone-boot-test-token",
PORT: String(port),
NODE_ENV: "development",
BITMIND_SERVICE_TOKEN: serviceToken,
BITMIND_AGENT_TOKEN: "bitmind-agent-token-for-the-boot-test",
BITMIND_GATEWAY_PORT: String(gatewayPort),
},
stdout: "pipe",
stderr: "pipe",
});

try {
const deadline = Date.now() + 45_000;
let up = false;
while (Date.now() < deadline) {
if (child.killed) break;
try {
const health = await fetch(
`http://127.0.0.1:${String(gatewayPort)}/health`,
);
if (health.ok) {
up = true;
break;
}
} catch {
// Not listening yet.
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
if (!up) {
const stderr = await new Response(child.stderr).text();
throw new Error(`the gateway never came up:\n${stderr.slice(-2_000)}`);
}

const anonymous = await fetch(
`http://127.0.0.1:${String(gatewayPort)}/bitmind/v1/attestation`,
);
expect(anonymous.status).toBe(401);

const attested = await fetch(
`http://127.0.0.1:${String(gatewayPort)}/bitmind/v1/attestation`,
{ headers: { authorization: `Bearer ${serviceToken}` } },
);
expect(attested.status).toBe(200);
expect(await attested.json()).toMatchObject({
service: "openbot-bitmind-gateway",
isolated_computers: false,
});

// The server's own port serves the app and nothing of BitMind's — with the
// right service token in hand, which is the only way this assertion means
// anything.
const onServerPort = await fetch(
`http://127.0.0.1:${String(port)}/bitmind/v1/attestation`,
{ headers: { authorization: `Bearer ${serviceToken}` } },
);
expect(onServerPort.status).toBe(404);
const stillTheApp = await fetch(
`http://127.0.0.1:${String(port)}/api/capabilities`,
);
expect(stillTheApp.status).toBe(200);
} finally {
child.kill();
await child.exited;
}
},
);