diff --git a/docs/bitmind-gateway.md b/docs/bitmind-gateway.md index 8e09c13d8..3de8a8176 100644 --- a/docs/bitmind-gateway.md +++ b/docs/bitmind-gateway.md @@ -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 @@ -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. diff --git a/server/package.json b/server/package.json index 970307c8d..86fb16154 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/bitmind/main.ts b/server/src/bitmind/main.ts deleted file mode 100644 index 8e00c7496..000000000 --- a/server/src/bitmind/main.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { serve } from "bun"; -import { bitmindGatewayConfig, bitmindGatewayListen } from "./config"; -import { createBitmindGateway } from "./gateway"; - -/** - * The BitMind gateway as its own process. - * - * A separate entry rather than a mount inside `src/index.ts`, because the server - * refuses to start without the Intelligence contract and the enclave this runs in has - * none. When the server grows a standalone mode, `createBitmindGateway` mounts there - * and this file retires; until then the enclave supervises this process directly. - * - * Configuration is validated before the port binds: a gateway that cannot - * authenticate its caller or reach its agent must fail in front of whoever deployed - * it, not in front of the first run. - */ -const config = bitmindGatewayConfig(process.env); -const listen = bitmindGatewayListen(process.env); -const gateway = createBitmindGateway(config); - -serve({ - hostname: listen.host, - port: listen.port, - // Idle SSE relays are kept open well past Bun's default while a model thinks. - idleTimeout: 120, - fetch: (request) => gateway.fetch(request), -}); - -console.info( - `bitmind-gateway listening on http://${listen.host}:${String(listen.port)}/bitmind/v1 (agent: ${config.agentUrl})`, -); diff --git a/server/src/bitmind/mount.ts b/server/src/bitmind/mount.ts new file mode 100644 index 000000000..b94448fe0 --- /dev/null +++ b/server/src/bitmind/mount.ts @@ -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; +} + +/** + * 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), + }); +} diff --git a/server/src/index.ts b/server/src/index.ts index ab4ac6041..39dad6a44 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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"; @@ -1276,6 +1277,25 @@ serve({ }, }); +/* + * 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( @@ -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)); }); } diff --git a/server/tests/bitmind-mount.test.ts b/server/tests/bitmind-mount.test.ts new file mode 100644 index 000000000..0604836cb --- /dev/null +++ b/server/tests/bitmind-mount.test.ts @@ -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, + }); + }); +}); diff --git a/server/tests/standalone-boot.integration.test.ts b/server/tests/standalone-boot.integration.test.ts index f7e49d53b..b465fa1ad 100644 --- a/server/tests/standalone-boot.integration.test.ts +++ b/server/tests/standalone-boot.integration.test.ts @@ -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; + } + }, +);