From 519a875ef9f346bd88ff9348761415268d90e842 Mon Sep 17 00:00:00 2001 From: Bit Cloud Date: Wed, 2 Sep 2026 21:37:47 +0000 Subject: [PATCH] feat: serve the BitMind gateway from the server process Standalone mode removed the reason the gateway ran on its own: the server refused to boot without the Intelligence contract, and the enclave has none. It now boots with the server, so the enclave supervises one process and one signal stops both, and src/bitmind/main.ts retires. On a listener of its own, not a path on the server's port. The server port is what an operator publishes; 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. Setting none of the BITMIND_* variables mounts no gateway; setting any of them requires the rest, so a half-configured enclave fails at boot rather than answering BitMind with a server that cannot relay. --- docs/bitmind-gateway.md | 43 +++++++-- server/package.json | 4 +- server/src/bitmind/main.ts | 31 ------ server/src/bitmind/mount.ts | 74 +++++++++++++++ server/src/index.ts | 23 +++++ server/tests/bitmind-mount.test.ts | 76 +++++++++++++++ .../tests/standalone-boot.integration.test.ts | 95 +++++++++++++++++++ 7 files changed, 306 insertions(+), 40 deletions(-) delete mode 100644 server/src/bitmind/main.ts create mode 100644 server/src/bitmind/mount.ts create mode 100644 server/tests/bitmind-mount.test.ts 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; + } + }, +);