From 5ead5cf406a4e43155c11c97bcc615d144894d97 Mon Sep 17 00:00:00 2001 From: Bit Cloud Date: Thu, 3 Sep 2026 21:15:37 +0000 Subject: [PATCH 1/3] feat: wire the BitMind gateway to the container supervisor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap between #20's two proven pieces: the gateway relays runs but always attested isolated_computers: false, and supervisor can create a real isolated computer but the gateway had no idea it existed. Neither was wrong on its own, but together they meant BitMind's activation gate could never legitimately turn on — there was no path from "a supervisor is running" to "the gateway says so." Adds: - BITMIND_SUPERVISOR_URL / BITMIND_SUPERVISOR_TOKEN, set together or not at all (same half-configured-fails-at-boot rule as every other pair in this config). Absent, behavior is unchanged from before a supervisor existed. - attestation() is now async and checks supervisor /health on every call (unauthenticated, matching the supervisor's own design) rather than caching a value that could go stale exactly when it matters most. isolated_computers is true only while a configured supervisor answers. - POST /bitmind/v1/computer/{agent_id}/ensure, proxying to the supervisor's own ensure verb. The response is a small named subset (status, started_at) — never the container name, the internal container-DNS url, or supervisor's raw identity/error text, none of which BitMind can use and none of which belongs on the wire to a different trust domain. The supervisor's own status code passes through on refusal (400/409/503 carry real meaning); its body does not. Deliberately not done here: control transfer and live frames. `agent-computer` already has both as real features (`/control/take`+`/control/release`, `/stream`'s CDP screencast) — relaying a live frame stream or a control-transfer session through this gateway is its own design question (does it proxy a websocket? poll? does the gateway hold state across a transfer?), not a small extension of the ensure route. Tracked as follow-up, not silently dropped. Tests: 3 new attestation cases (reachable, unreachable, unwell), 5 new computer-route cases (unconfigured, successful proxy + response trimming, status passthrough on refusal, unreachable supervisor, auth requirement), 2 new config cases (paired validation, credential/scheme rejection). Gate: biome format/lint clean, server typecheck clean, full `bun test server/tests --timeout 30000` → 1708 pass / 0 fail. Co-Authored-By: Claude Sonnet 5 --- server/src/bitmind/config.ts | 39 +++++ server/src/bitmind/gateway.ts | 98 +++++++++++- server/src/bitmind/mount.ts | 2 + server/tests/bitmind-gateway.test.ts | 216 +++++++++++++++++++++++++++ 4 files changed, 349 insertions(+), 6 deletions(-) diff --git a/server/src/bitmind/config.ts b/server/src/bitmind/config.ts index f5040558a..c11c311d5 100644 --- a/server/src/bitmind/config.ts +++ b/server/src/bitmind/config.ts @@ -24,6 +24,16 @@ export interface BitmindGatewayConfig { maxConcurrentRuns: number; /** Whole-run ceiling on the relay, so an abandoned stream cannot hold a slot. */ runTimeoutMs: number; + /** + * The container supervisor, when this enclave has one. Both this and `supervisorToken` + * are set together or not at all — a half-configured pair is a deployment mistake, not a + * degraded mode, so it fails at boot rather than reporting an honest-looking `false` + * for a reason nobody chose. Absent, the gateway attests `isolated_computers: false` + * unconditionally, the same as before a supervisor existed at all. + */ + supervisorUrl?: string; + /** Bearer token the supervisor requires. Never logged, never echoed. */ + supervisorToken?: string; } /** Where the gateway listens. Loopback by default: the enclave boundary requires it. */ @@ -85,10 +95,39 @@ export function bitmindGatewayConfig( `BITMIND_AGENT_URL must be http or https, not ${parsed.protocol}`, ); } + const supervisorUrlRaw = environment.BITMIND_SUPERVISOR_URL?.trim(); + const supervisorToken = environment.BITMIND_SUPERVISOR_TOKEN?.trim(); + if (supervisorUrlRaw && !supervisorToken) { + throw new Error( + "BITMIND_SUPERVISOR_URL is set without BITMIND_SUPERVISOR_TOKEN. Set both, or neither to run without computer isolation.", + ); + } + if (supervisorToken && !supervisorUrlRaw) { + throw new Error( + "BITMIND_SUPERVISOR_TOKEN is set without BITMIND_SUPERVISOR_URL. Set both, or neither to run without computer isolation.", + ); + } + if (supervisorUrlRaw) { + const parsedSupervisor = new URL(supervisorUrlRaw); + if (parsedSupervisor.username || parsedSupervisor.password) { + throw new Error("BITMIND_SUPERVISOR_URL must not carry credentials."); + } + if ( + parsedSupervisor.protocol !== "http:" && + parsedSupervisor.protocol !== "https:" + ) { + throw new Error( + `BITMIND_SUPERVISOR_URL must be http or https, not ${parsedSupervisor.protocol}`, + ); + } + } return { serviceToken, agentToken, agentUrl, + ...(supervisorUrlRaw && supervisorToken + ? { supervisorUrl: supervisorUrlRaw, supervisorToken } + : {}), // The enclave note starts staging at two concurrent agent computers; the same // ceiling applies to runs until computers exist at all. maxConcurrentRuns: integer( diff --git a/server/src/bitmind/gateway.ts b/server/src/bitmind/gateway.ts index 5a6bf6d81..d4be35f26 100644 --- a/server/src/bitmind/gateway.ts +++ b/server/src/bitmind/gateway.ts @@ -77,14 +77,36 @@ export function createBitmindGateway( */ const active = new Map(); - function attestation(): BitmindAttestation { + /** + * Whether the configured supervisor is actually there, right now. + * + * Checked on every attestation rather than cached: a stale "true" is the one this + * field must never say, because it is what turns BitMind's worker on. A supervisor + * that was reachable a minute ago and has since died must attest `false` on the + * very next check, not on the next restart. Health is unauthenticated on the + * supervisor's own side, so this asks nothing it would need a token for. + */ + async function supervisorReachable(): Promise { + if (!config.supervisorUrl) return false; + try { + const response = await fetchImplementation( + new URL("/health", config.supervisorUrl), + { signal: AbortSignal.timeout(3_000) }, + ); + return response.ok; + } catch { + return false; + } + } + + async function attestation(): Promise { return { service: "openbot-bitmind-gateway", protocol: { ag_ui: AG_UI_PROTOCOL_VERSION }, - // No agent computers exist behind this gateway yet, so no isolation exists to - // attest. BitMind's activation gate requires true; false keeps its worker off, - // which is the correct state until the enclave provides real computers. - isolated_computers: false, + // True only when a supervisor is configured AND answering right now. Anything + // else is the pre-enclave state: no isolation exists to attest, so BitMind's + // worker must stay off. + isolated_computers: await supervisorReachable(), execution: { backend: "relay", tools: false, interrupts: false }, limits: { max_concurrent_runs: config.maxConcurrentRuns, @@ -94,6 +116,64 @@ export function createBitmindGateway( }; } + /** + * What BitMind is told when it asks for an agent's computer. + * + * Deliberately a small, named subset of what the supervisor returns — never the + * container name, the internal container-DNS url, or the raw identity/error text, + * none of which BitMind can use and none of which belongs on the wire to a + * different trust domain. `computer/control` and `computer/frames` (control + * transfer, live frames) are not implemented yet: `agent-computer` already has + * both as real features of its own (`/control/take`, `/stream`), but relaying a + * live CDP frame stream or a control-transfer session through this gateway is its + * own design question, not an extension of this one route. + */ + async function ensureComputer(agentId: string): Promise { + if (!config.supervisorUrl || !config.supervisorToken) { + return Response.json( + { error: "This gateway has no computer supervisor configured." }, + { status: 503 }, + ); + } + let downstream: Response; + try { + downstream = await fetchImplementation( + new URL( + `/computers/${encodeURIComponent(agentId)}/ensure`, + config.supervisorUrl, + ), + { + method: "POST", + signal: AbortSignal.timeout(config.runTimeoutMs), + headers: { authorization: `Bearer ${config.supervisorToken}` }, + }, + ); + } catch { + return Response.json( + { error: "The computer supervisor is unreachable." }, + { status: 502 }, + ); + } + if (!downstream.ok) { + // The supervisor's own status carries real meaning here (400 a bad agent id, + // 409 a name held elsewhere, 503 Docker itself unavailable) — passed through + // rather than flattened to one generic code, but never its response body, + // which is written for an operator, not for BitMind. + return Response.json( + { error: "The computer supervisor refused the request." }, + { status: downstream.status }, + ); + } + const state = (await downstream.json()) as { + status?: string; + startedAt?: string; + }; + return Response.json({ + status: state.status ?? "unknown", + ...(state.startedAt ? { started_at: state.startedAt } : {}), + }); + } + async function relayRun(request: Request): Promise { let body: unknown; try { @@ -325,11 +405,17 @@ export function createBitmindGateway( url.pathname === "/bitmind/v1/attestation" && request.method === "GET" ) { - return Response.json(attestation()); + return Response.json(await attestation()); } if (url.pathname === "/bitmind/v1/run" && request.method === "POST") { return relayRun(request); } + const computerEnsure = /^\/bitmind\/v1\/computer\/([^/]+)\/ensure$/.exec( + url.pathname, + ); + if (computerEnsure && request.method === "POST") { + return ensureComputer(decodeURIComponent(computerEnsure[1] ?? "")); + } return Response.json({ error: "Not found." }, { status: 404 }); }, /** Exposed for tests: how many relays are live right now. */ diff --git a/server/src/bitmind/mount.ts b/server/src/bitmind/mount.ts index b94448fe0..fc4bb9c01 100644 --- a/server/src/bitmind/mount.ts +++ b/server/src/bitmind/mount.ts @@ -23,6 +23,8 @@ const GATEWAY_VARIABLES = [ "BITMIND_GATEWAY_PORT", "BITMIND_MAX_CONCURRENT_RUNS", "BITMIND_RUN_TIMEOUT_MS", + "BITMIND_SUPERVISOR_URL", + "BITMIND_SUPERVISOR_TOKEN", ] as const; export interface BitmindGatewayMount { diff --git a/server/tests/bitmind-gateway.test.ts b/server/tests/bitmind-gateway.test.ts index 554d324dc..09ad4b88e 100644 --- a/server/tests/bitmind-gateway.test.ts +++ b/server/tests/bitmind-gateway.test.ts @@ -8,6 +8,8 @@ import type { BitmindAttestation } from "../src/bitmind/gateway"; const SERVICE_TOKEN = "service-token-for-tests-0000000000000000"; const AGENT_TOKEN = "managed-agent-token-for-tests-00000000"; +const SUPERVISOR_TOKEN = "supervisor-token-for-tests-00000000000"; +const SUPERVISOR_URL = "http://localhost:4300"; function config( overrides: Partial[0]> = {}, @@ -72,6 +74,22 @@ function fakeAgent(events: object[]) { return { seen, agentFetch }; } +/** + * A fake supervisor, dispatched by URL the same way the gateway itself is only ever + * given one injected `fetch` — a test that needs both an agent and a supervisor + * combines two of these into one function rather than the gateway growing a second + * fetch parameter. + */ +function fakeSupervisor(respond: (url: URL, init?: RequestInit) => Response) { + const seen: { url?: string; init?: RequestInit } = {}; + const supervisorFetch: typeof fetch = (url, init) => { + seen.url = String(url); + seen.init = init; + return Promise.resolve(respond(new URL(String(url)), init)); + }; + return { seen, supervisorFetch }; +} + describe("authentication", () => { test("everything but /health requires the service token", async () => { const gateway = createBitmindGateway(config()); @@ -128,6 +146,159 @@ describe("attestation", () => { }; expect(manifest.version).toBe(AG_UI_PROTOCOL_VERSION); }); + + test("isolated_computers is true only while a configured supervisor answers", async () => { + const { supervisorFetch, seen } = fakeSupervisor( + () => new Response(null, { status: 200 }), + ); + const withSupervisor = createBitmindGateway( + config({ + supervisorUrl: SUPERVISOR_URL, + supervisorToken: SUPERVISOR_TOKEN, + }), + supervisorFetch, + ); + const up = await withSupervisor.fetch( + new Request("http://gateway/bitmind/v1/attestation", { + headers: { authorization: `Bearer ${SERVICE_TOKEN}` }, + }), + ); + expect((await up.json()).isolated_computers).toBe(true); + // The supervisor's own health is unauthenticated, per its own design — this + // check must not need a token for it, and must not send one either. + expect(seen.url).toBe(`${SUPERVISOR_URL}/health`); + expect(seen.init?.headers).toBeUndefined(); + }); + + test("isolated_computers is false when the supervisor is unreachable", async () => { + const down = createBitmindGateway( + config({ + supervisorUrl: SUPERVISOR_URL, + supervisorToken: SUPERVISOR_TOKEN, + }), + () => Promise.reject(new Error("connect ECONNREFUSED")), + ); + const response = await down.fetch( + new Request("http://gateway/bitmind/v1/attestation", { + headers: { authorization: `Bearer ${SERVICE_TOKEN}` }, + }), + ); + expect((await response.json()).isolated_computers).toBe( + false, + ); + }); + + test("isolated_computers is false when the supervisor answers but is unwell", async () => { + const unwell = createBitmindGateway( + config({ + supervisorUrl: SUPERVISOR_URL, + supervisorToken: SUPERVISOR_TOKEN, + }), + () => Promise.resolve(new Response(null, { status: 503 })), + ); + const response = await unwell.fetch( + new Request("http://gateway/bitmind/v1/attestation", { + headers: { authorization: `Bearer ${SERVICE_TOKEN}` }, + }), + ); + expect((await response.json()).isolated_computers).toBe( + false, + ); + }); +}); + +describe("computer", () => { + function ensureRequest(agentId: string) { + return new Request(`http://gateway/bitmind/v1/computer/${agentId}/ensure`, { + method: "POST", + headers: { authorization: `Bearer ${SERVICE_TOKEN}` }, + }); + } + + test("is unavailable when no supervisor is configured", async () => { + const gateway = createBitmindGateway(config()); + const response = await gateway.fetch(ensureRequest("agent-1")); + expect(response.status).toBe(503); + }); + + test("proxies to the supervisor and trims the response to what BitMind needs", async () => { + const { supervisorFetch, seen } = fakeSupervisor((url) => { + expect(url.pathname).toBe("/computers/agent-1/ensure"); + return Response.json({ + botId: "agent-1", + // Internal details a response to BitMind must never carry. + container: "openbot-computer-agent-1", + url: "http://openbot-computer-agent-1:4100", + identity: "spiffe://openbot.local/agent-1", + status: "running", + startedAt: "2026-09-03T00:00:00.000Z", + }); + }); + const gateway = createBitmindGateway( + config({ + supervisorUrl: SUPERVISOR_URL, + supervisorToken: SUPERVISOR_TOKEN, + }), + supervisorFetch, + ); + const response = await gateway.fetch(ensureRequest("agent-1")); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + status: "running", + started_at: "2026-09-03T00:00:00.000Z", + }); + expect(seen.init?.headers).toEqual({ + authorization: `Bearer ${SUPERVISOR_TOKEN}`, + }); + }); + + test("passes the supervisor's own status through on refusal, never its body", async () => { + const conflict = createBitmindGateway( + config({ + supervisorUrl: SUPERVISOR_URL, + supervisorToken: SUPERVISOR_TOKEN, + }), + () => + Promise.resolve( + Response.json( + { error: "A container named ... already exists ..." }, + { status: 409 }, + ), + ), + ); + const response = await conflict.fetch(ensureRequest("agent-1")); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: "The computer supervisor refused the request.", + }); + }); + + test("an unreachable supervisor is a 502", async () => { + const unreachable = createBitmindGateway( + config({ + supervisorUrl: SUPERVISOR_URL, + supervisorToken: SUPERVISOR_TOKEN, + }), + () => Promise.reject(new Error("connect ECONNREFUSED")), + ); + const response = await unreachable.fetch(ensureRequest("agent-1")); + expect(response.status).toBe(502); + }); + + test("requires the service token, like every other BitMind route", async () => { + const gateway = createBitmindGateway( + config({ + supervisorUrl: SUPERVISOR_URL, + supervisorToken: SUPERVISOR_TOKEN, + }), + ); + const bare = await gateway.fetch( + new Request("http://gateway/bitmind/v1/computer/agent-1/ensure", { + method: "POST", + }), + ); + expect(bare.status).toBe(401); + }); }); describe("run relay", () => { @@ -292,6 +463,51 @@ describe("configuration", () => { }), ).toThrow(/http or https/); }); + + test("the supervisor's url and token are set together or not at all", () => { + const base = { + BITMIND_SERVICE_TOKEN: "token", + BITMIND_AGENT_TOKEN: "token", + }; + // Neither set: a deployment with no supervisor, valid. + expect(bitmindGatewayConfig(base).supervisorUrl).toBeUndefined(); + expect(() => + bitmindGatewayConfig({ + ...base, + BITMIND_SUPERVISOR_URL: "http://localhost:4300", + }), + ).toThrow(/BITMIND_SUPERVISOR_TOKEN/); + expect(() => + bitmindGatewayConfig({ ...base, BITMIND_SUPERVISOR_TOKEN: "token" }), + ).toThrow(/BITMIND_SUPERVISOR_URL/); + const both = bitmindGatewayConfig({ + ...base, + BITMIND_SUPERVISOR_URL: "http://localhost:4300", + BITMIND_SUPERVISOR_TOKEN: "token", + }); + expect(both.supervisorUrl).toBe("http://localhost:4300"); + expect(both.supervisorToken).toBe("token"); + }); + + test("refuses a supervisor url that carries credentials or an odd scheme", () => { + const base = { + BITMIND_SERVICE_TOKEN: "token", + BITMIND_AGENT_TOKEN: "token", + BITMIND_SUPERVISOR_TOKEN: "token", + }; + expect(() => + bitmindGatewayConfig({ + ...base, + BITMIND_SUPERVISOR_URL: "http://user:pw@host:4300", + }), + ).toThrow(/credentials/); + expect(() => + bitmindGatewayConfig({ + ...base, + BITMIND_SUPERVISOR_URL: "file:///etc/passwd", + }), + ).toThrow(/http or https/); + }); }); describe("identity enforcement", () => { From b480a9d75f2da24c8a27ad15730010f1343a5033 Mon Sep 17 00:00:00 2001 From: Bit Cloud Date: Thu, 3 Sep 2026 23:27:19 +0000 Subject: [PATCH 2/3] fix: reuse the deployment's own computer gateway, not a parallel one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first commit on this branch built BITMIND_SUPERVISOR_URL/TOKEN and a bespoke fetch-to-supervisor client from scratch. Partway through adding control-transfer and screenshot routes, found that server/src/computer/ already has exactly this: ComputerProvider (createDockerSupervisorProvider talks to the identical supervisor, over the identical /computers/{id}/ensure verb this branch had reimplemented), ComputerGateway (locate/status/ screenshot/takeControl/releaseControl, with address validation, audit trail, and error mapping none of which the first commit had), and COMPUTER_SUPERVISOR_URL/SUPERVISOR_TOKEN/COMPUTER_TOKEN — config the main server (index.ts) already parses and already builds a ComputerGateway from today, for the product's own UI. Reworked to depend on that instance instead of building a second one: - BITMIND_SUPERVISOR_URL/TOKEN removed. BitMind's gateway now takes an optional ComputerGateway, threaded through from index.ts's own already-constructed one (undefined when no computer provider is configured — unchanged deployment story for everyone who isn't running BitMind). - attestation()'s isolated_computers checks computerGateway.provider.list() reachability, same as before, just against the real seam instead of a bespoke unauthenticated /health probe. - POST .../computer/{agent_id}/ensure now calls locate() (which validates the resolved address, not just fetches it) then status(). - New: GET .../computer/{agent_id} (observe without starting), GET .../computer/{agent_id}/screenshot (a snapshot, matching bit-bot#58's own stated baseline of "a snapshot URL, not a video pipe" — streaming is named there as a later upgrade, not required for this), POST .../computer/{agent_id}/control {action:"take"|"release"} against agent-computer's existing control-transfer state machine, with the actor for its audit trail carried in x-bitmind-actor-id (BitMind's callers have no row in this deployment's own `users` table). Still deliberately not done: relaying agent-computer's live CDP frame stream (`/stream`) through this gateway. That is a genuinely separate design question (websocket relay vs. polling, whether state needs to survive a control transfer) from reusing an existing HTTP seam, and bit-bot#58's own scope treats it as a follow-up to the snapshot baseline, not a blocker. Tests rewritten around a fakeComputerGateway (every method throws unless overridden, so a test exercising two methods cannot silently pass by reaching a third) — same partial-fake-cast pattern computer-routes.test.ts already uses for this exact interface. Net: 3 attestation cases, 9 computer -route cases (unconfigured, auth, status, ensure, screenshot, take+release with actor propagation asserted, missing actor, invalid action, generic failure mapping), config tests for the removed supervisor pair deleted. Gate: biome format/lint clean, server typecheck clean, full `bun test server/tests --timeout 30000` → 1709 pass / 0 fail. Co-Authored-By: Claude Sonnet 5 --- server/src/bitmind/config.ts | 39 --- server/src/bitmind/gateway.ts | 214 ++++++++++----- server/src/bitmind/mount.ts | 13 +- server/src/index.ts | 2 +- server/tests/bitmind-gateway.test.ts | 390 +++++++++++++++------------ 5 files changed, 379 insertions(+), 279 deletions(-) diff --git a/server/src/bitmind/config.ts b/server/src/bitmind/config.ts index c11c311d5..f5040558a 100644 --- a/server/src/bitmind/config.ts +++ b/server/src/bitmind/config.ts @@ -24,16 +24,6 @@ export interface BitmindGatewayConfig { maxConcurrentRuns: number; /** Whole-run ceiling on the relay, so an abandoned stream cannot hold a slot. */ runTimeoutMs: number; - /** - * The container supervisor, when this enclave has one. Both this and `supervisorToken` - * are set together or not at all — a half-configured pair is a deployment mistake, not a - * degraded mode, so it fails at boot rather than reporting an honest-looking `false` - * for a reason nobody chose. Absent, the gateway attests `isolated_computers: false` - * unconditionally, the same as before a supervisor existed at all. - */ - supervisorUrl?: string; - /** Bearer token the supervisor requires. Never logged, never echoed. */ - supervisorToken?: string; } /** Where the gateway listens. Loopback by default: the enclave boundary requires it. */ @@ -95,39 +85,10 @@ export function bitmindGatewayConfig( `BITMIND_AGENT_URL must be http or https, not ${parsed.protocol}`, ); } - const supervisorUrlRaw = environment.BITMIND_SUPERVISOR_URL?.trim(); - const supervisorToken = environment.BITMIND_SUPERVISOR_TOKEN?.trim(); - if (supervisorUrlRaw && !supervisorToken) { - throw new Error( - "BITMIND_SUPERVISOR_URL is set without BITMIND_SUPERVISOR_TOKEN. Set both, or neither to run without computer isolation.", - ); - } - if (supervisorToken && !supervisorUrlRaw) { - throw new Error( - "BITMIND_SUPERVISOR_TOKEN is set without BITMIND_SUPERVISOR_URL. Set both, or neither to run without computer isolation.", - ); - } - if (supervisorUrlRaw) { - const parsedSupervisor = new URL(supervisorUrlRaw); - if (parsedSupervisor.username || parsedSupervisor.password) { - throw new Error("BITMIND_SUPERVISOR_URL must not carry credentials."); - } - if ( - parsedSupervisor.protocol !== "http:" && - parsedSupervisor.protocol !== "https:" - ) { - throw new Error( - `BITMIND_SUPERVISOR_URL must be http or https, not ${parsedSupervisor.protocol}`, - ); - } - } return { serviceToken, agentToken, agentUrl, - ...(supervisorUrlRaw && supervisorToken - ? { supervisorUrl: supervisorUrlRaw, supervisorToken } - : {}), // The enclave note starts staging at two concurrent agent computers; the same // ceiling applies to runs until computers exist at all. maxConcurrentRuns: integer( diff --git a/server/src/bitmind/gateway.ts b/server/src/bitmind/gateway.ts index d4be35f26..ce4d698eb 100644 --- a/server/src/bitmind/gateway.ts +++ b/server/src/bitmind/gateway.ts @@ -1,6 +1,7 @@ import { RunAgentInputSchema } from "@ag-ui/core"; import { z } from "zod"; import { matchesToken } from "../../../shared/agent-authorisation"; +import type { ActionActor, ComputerGateway } from "../computer/gateway"; import { AG_UI_PROTOCOL_VERSION, type BitmindGatewayConfig } from "./config"; /** @@ -23,16 +24,17 @@ const BitmindForwardedPropsSchema = z.object({ /** * The doorway BitMind talks through: one POST per run, answered with the AG-UI event - * stream, plus the attestation its activation gate reads before it will enable a - * worker at all. + * stream, an observe/control surface for a Bot's computer, plus the attestation its + * activation gate reads before it will enable a worker at all. * * This is deliberately a relay and not a runtime. The downstream agent owns the model - * conversation; this process owns what an enclave boundary needs owned on its edge — + * conversation, and the deployment's own `computer/` module (the same seam the + * product's own UI uses — one supervisor, one audit trail, no parallel path) owns a + * Bot's computer; this process owns what an enclave boundary needs owned on its edge — * service authentication, input validation against the pinned protocol schemas, * admission control, and an honest statement of what is and is not behind the door. - * Nothing consequential can happen through it yet: the relayed agent is prose-only - * (BitMind sends no tools and no computer exists here), which is exactly why - * `isolated_computers` attests false and BitMind's worker stays disabled. + * `isolated_computers` attests true only while a configured computer provider is + * actually reachable — see `computersReachable`. * * Kept as a handler factory rather than a bound server, the way `agent-langgraph` * splits its logic from `serve()`, so tests drive it with plain Requests. @@ -65,6 +67,7 @@ function bearerToken(request: Request): string { export function createBitmindGateway( config: BitmindGatewayConfig, fetchImplementation: typeof fetch = fetch, + computerGateway?: ComputerGateway, ) { /** * Runs currently relayed, by idempotency key. @@ -78,22 +81,19 @@ export function createBitmindGateway( const active = new Map(); /** - * Whether the configured supervisor is actually there, right now. + * Whether the deployment's own computer feature is actually there, right now. * * Checked on every attestation rather than cached: a stale "true" is the one this - * field must never say, because it is what turns BitMind's worker on. A supervisor - * that was reachable a minute ago and has since died must attest `false` on the - * very next check, not on the next restart. Health is unauthenticated on the - * supervisor's own side, so this asks nothing it would need a token for. + * field must never say, because it is what turns BitMind's worker on. `list()` is a + * read against the supervisor (or whichever provider is configured) that needs no + * particular Bot to exist, so it is the cheapest real proof of reachability this + * seam offers. */ - async function supervisorReachable(): Promise { - if (!config.supervisorUrl) return false; + async function computersReachable(): Promise { + if (!computerGateway) return false; try { - const response = await fetchImplementation( - new URL("/health", config.supervisorUrl), - { signal: AbortSignal.timeout(3_000) }, - ); - return response.ok; + await computerGateway.provider.list(); + return true; } catch { return false; } @@ -103,10 +103,10 @@ export function createBitmindGateway( return { service: "openbot-bitmind-gateway", protocol: { ag_ui: AG_UI_PROTOCOL_VERSION }, - // True only when a supervisor is configured AND answering right now. Anything - // else is the pre-enclave state: no isolation exists to attest, so BitMind's - // worker must stay off. - isolated_computers: await supervisorReachable(), + // True only when this deployment's own computer feature is configured AND + // answering right now. Anything else is the pre-enclave state: no isolation + // exists to attest, so BitMind's worker must stay off. + isolated_computers: await computersReachable(), execution: { backend: "relay", tools: false, interrupts: false }, limits: { max_concurrent_runs: config.maxConcurrentRuns, @@ -116,62 +116,126 @@ export function createBitmindGateway( }; } + function computerUnavailable(): Response { + return Response.json( + { error: "This deployment has no computer feature configured." }, + { status: 503 }, + ); + } + + /** + * Maps every failure from the computer seam to one status, never the message. + * + * `ComputerUnavailableError`, `SupervisorError`, `ProviderError` and friends are all + * reachability/refusal failures from the same seam the product's own UI hits — none + * of their messages are written for a caller in a different trust domain, the same + * reasoning `relayRun` above already applies to the agent backend. + */ + function computerErrorResponse(): Response { + return Response.json( + { error: "The computer backend refused the request." }, + { status: 502 }, + ); + } + /** - * What BitMind is told when it asks for an agent's computer. + * The actor BitMind's request names, for the audit trail a control handover writes. * - * Deliberately a small, named subset of what the supervisor returns — never the - * container name, the internal container-DNS url, or the raw identity/error text, - * none of which BitMind can use and none of which belongs on the wire to a - * different trust domain. `computer/control` and `computer/frames` (control - * transfer, live frames) are not implemented yet: `agent-computer` already has - * both as real features of its own (`/control/take`, `/stream`), but relaying a - * live CDP frame stream or a control-transfer session through this gateway is its - * own design question, not an extension of this one route. + * There is no row for this identity in `users` — BitMind's callers are not + * accounts this deployment has ever signed in — so only `id` is ever set, per + * `ActionActor`'s own contract ("Null unless this is a real row in users"). + */ + function actorFrom(request: Request): ActionActor | null { + const id = request.headers.get("x-bitmind-actor-id")?.trim(); + return id ? { id: `bitmind:${id}` } : null; + } + + /** `GET /bitmind/v1/computer/{agent_id}` — observes without starting anything. */ + async function computerStatus(agentId: string): Promise { + if (!computerGateway) return computerUnavailable(); + try { + return Response.json(await computerGateway.status(agentId)); + } catch { + return computerErrorResponse(); + } + } + + /** + * `POST /bitmind/v1/computer/{agent_id}/ensure` — starts the computer if it is not + * already running, then reports where it actually is. `ComputerGateway.locate` + * validates the address itself (private-host checks, the same ones the product's + * own agent path gets), which a bespoke fetch straight to the supervisor would not. */ async function ensureComputer(agentId: string): Promise { - if (!config.supervisorUrl || !config.supervisorToken) { - return Response.json( - { error: "This gateway has no computer supervisor configured." }, - { status: 503 }, - ); + if (!computerGateway) return computerUnavailable(); + try { + await computerGateway.locate(agentId); + return Response.json(await computerGateway.status(agentId)); + } catch { + return computerErrorResponse(); } - let downstream: Response; + } + + /** `GET /bitmind/v1/computer/{agent_id}/screenshot` — a snapshot, not a video pipe. */ + async function computerScreenshot(agentId: string): Promise { + if (!computerGateway) return computerUnavailable(); try { - downstream = await fetchImplementation( - new URL( - `/computers/${encodeURIComponent(agentId)}/ensure`, - config.supervisorUrl, - ), + return Response.json(await computerGateway.screenshot(agentId)); + } catch { + return computerErrorResponse(); + } + } + + /** + * `POST /bitmind/v1/computer/{agent_id}/control` — the handover verb. `agent- + * computer` already has a full control-transfer state machine + * (`/control/take`+`/control/release`); this is the two calls that reach it, not a + * new one. Live frames (a websocket relay of `agent-computer`'s CDP screencast) are + * the one piece of the original "observe/control/frame" scope still not done — + * `screenshot` above covers the polling case bit-bot#58 itself describes as the + * baseline ("a snapshot URL, not a video pipe"; streaming is named there as a later + * upgrade), so it is not blocking, but it is not this route either. + */ + const ControlActionSchema = z.object({ + action: z.enum(["take", "release"]), + }); + async function computerControl( + agentId: string, + request: Request, + ): Promise { + if (!computerGateway) return computerUnavailable(); + const actor = actorFrom(request); + if (!actor) { + return Response.json( { - method: "POST", - signal: AbortSignal.timeout(config.runTimeoutMs), - headers: { authorization: `Bearer ${config.supervisorToken}` }, + error: + "x-bitmind-actor-id is required to change who controls a computer.", }, + { status: 400 }, ); + } + let body: unknown; + try { + body = await request.json(); } catch { - return Response.json( - { error: "The computer supervisor is unreachable." }, - { status: 502 }, - ); + return Response.json({ error: "Body must be JSON." }, { status: 400 }); } - if (!downstream.ok) { - // The supervisor's own status carries real meaning here (400 a bad agent id, - // 409 a name held elsewhere, 503 Docker itself unavailable) — passed through - // rather than flattened to one generic code, but never its response body, - // which is written for an operator, not for BitMind. + const input = ControlActionSchema.safeParse(body); + if (!input.success) { return Response.json( - { error: "The computer supervisor refused the request." }, - { status: downstream.status }, + { error: 'action must be "take" or "release".' }, + { status: 400 }, ); } - const state = (await downstream.json()) as { - status?: string; - startedAt?: string; - }; - return Response.json({ - status: state.status ?? "unknown", - ...(state.startedAt ? { started_at: state.startedAt } : {}), - }); + try { + const state = + input.data.action === "take" + ? await computerGateway.takeControl(agentId, actor) + : await computerGateway.releaseControl(agentId, actor); + return Response.json(state); + } catch { + return computerErrorResponse(); + } } async function relayRun(request: Request): Promise { @@ -410,11 +474,23 @@ export function createBitmindGateway( if (url.pathname === "/bitmind/v1/run" && request.method === "POST") { return relayRun(request); } - const computerEnsure = /^\/bitmind\/v1\/computer\/([^/]+)\/ensure$/.exec( - url.pathname, - ); - if (computerEnsure && request.method === "POST") { - return ensureComputer(decodeURIComponent(computerEnsure[1] ?? "")); + const computerMatch = + /^\/bitmind\/v1\/computer\/([^/]+)(\/[a-z]+)?$/.exec(url.pathname); + if (computerMatch) { + const agentId = decodeURIComponent(computerMatch[1] ?? ""); + const sub = computerMatch[2]; + if (!sub && request.method === "GET") { + return computerStatus(agentId); + } + if (sub === "/ensure" && request.method === "POST") { + return ensureComputer(agentId); + } + if (sub === "/screenshot" && request.method === "GET") { + return computerScreenshot(agentId); + } + if (sub === "/control" && request.method === "POST") { + return computerControl(agentId, request); + } } return Response.json({ error: "Not found." }, { status: 404 }); }, diff --git a/server/src/bitmind/mount.ts b/server/src/bitmind/mount.ts index fc4bb9c01..2deb3eb32 100644 --- a/server/src/bitmind/mount.ts +++ b/server/src/bitmind/mount.ts @@ -1,4 +1,5 @@ import { serve } from "bun"; +import type { ComputerGateway } from "../computer/gateway"; import { bitmindGatewayConfig, bitmindGatewayListen, @@ -23,8 +24,6 @@ const GATEWAY_VARIABLES = [ "BITMIND_GATEWAY_PORT", "BITMIND_MAX_CONCURRENT_RUNS", "BITMIND_RUN_TIMEOUT_MS", - "BITMIND_SUPERVISOR_URL", - "BITMIND_SUPERVISOR_TOKEN", ] as const; export interface BitmindGatewayMount { @@ -38,9 +37,17 @@ export interface BitmindGatewayMount { * * 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. + * + * `computerGateway` is the SAME instance `index.ts` already built for the deployment's + * own computer feature (`undefined` when none is configured) — passed through rather + * than this module building a second one. There is one computer supervisor per + * deployment, one `COMPUTER_SUPERVISOR_URL`, one audit trail; BitMind reaches Bots' + * computers through the identical seam the product's own UI does, not a parallel path + * with its own idea of what a computer's address or state looks like. */ export function bitmindGatewayFrom( environment: NodeJS.ProcessEnv, + computerGateway?: ComputerGateway, ): BitmindGatewayMount | undefined { const mentioned = GATEWAY_VARIABLES.some((name) => Boolean(environment[name]?.trim()), @@ -50,7 +57,7 @@ export function bitmindGatewayFrom( return { config, listen: bitmindGatewayListen(environment), - gateway: createBitmindGateway(config), + gateway: createBitmindGateway(config, undefined, computerGateway), }; } diff --git a/server/src/index.ts b/server/src/index.ts index 39dad6a44..67c3a251a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1288,7 +1288,7 @@ serve({ * 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 bitmind = bitmindGatewayFrom(process.env, computerGateway); const bitmindServer = bitmind ? serveBitmindGateway(bitmind) : undefined; if (bitmind) { console.info( diff --git a/server/tests/bitmind-gateway.test.ts b/server/tests/bitmind-gateway.test.ts index 09ad4b88e..546b331fc 100644 --- a/server/tests/bitmind-gateway.test.ts +++ b/server/tests/bitmind-gateway.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import type { ComputerGateway } from "../src/computer/gateway"; import { AG_UI_PROTOCOL_VERSION, bitmindGatewayConfig, @@ -8,8 +9,34 @@ import type { BitmindAttestation } from "../src/bitmind/gateway"; const SERVICE_TOKEN = "service-token-for-tests-0000000000000000"; const AGENT_TOKEN = "managed-agent-token-for-tests-00000000"; -const SUPERVISOR_TOKEN = "supervisor-token-for-tests-00000000000"; -const SUPERVISOR_URL = "http://localhost:4300"; + +/** + * A computer gateway with every method stubbed to fail loudly, so a test that + * overrides only the two or three methods it exercises cannot silently pass by + * calling into a method it never meant to reach — the same + * `as unknown as ComputerGateway` partial-fake pattern `computer-routes.test.ts` + * already establishes for this same interface. + */ +function fakeComputerGateway( + overrides: Partial = {}, +): ComputerGateway { + const unexpected = (name: string) => () => { + throw new Error( + `fakeComputerGateway.${name} was not expected to be called`, + ); + }; + return { + provider: { + list: async () => [], + } as unknown as ComputerGateway["provider"], + locate: unexpected("locate"), + status: unexpected("status"), + screenshot: unexpected("screenshot"), + takeControl: unexpected("takeControl"), + releaseControl: unexpected("releaseControl"), + ...overrides, + } as unknown as ComputerGateway; +} function config( overrides: Partial[0]> = {}, @@ -74,22 +101,6 @@ function fakeAgent(events: object[]) { return { seen, agentFetch }; } -/** - * A fake supervisor, dispatched by URL the same way the gateway itself is only ever - * given one injected `fetch` — a test that needs both an agent and a supervisor - * combines two of these into one function rather than the gateway growing a second - * fetch parameter. - */ -function fakeSupervisor(respond: (url: URL, init?: RequestInit) => Response) { - const seen: { url?: string; init?: RequestInit } = {}; - const supervisorFetch: typeof fetch = (url, init) => { - seen.url = String(url); - seen.init = init; - return Promise.resolve(respond(new URL(String(url)), init)); - }; - return { seen, supervisorFetch }; -} - describe("authentication", () => { test("everything but /health requires the service token", async () => { const gateway = createBitmindGateway(config()); @@ -126,8 +137,8 @@ describe("attestation", () => { expect(response.status).toBe(200); const body = (await response.json()) as BitmindAttestation; expect(body.protocol.ag_ui).toBe(AG_UI_PROTOCOL_VERSION); - // No enclave computers exist behind this gateway yet: attesting true here would - // switch BitMind's worker on against isolation that does not exist. + // No computer gateway configured: attesting true here would switch BitMind's + // worker on against isolation that does not exist. expect(body.isolated_computers).toBe(false); expect(body.execution).toEqual({ backend: "relay", @@ -147,56 +158,31 @@ describe("attestation", () => { expect(manifest.version).toBe(AG_UI_PROTOCOL_VERSION); }); - test("isolated_computers is true only while a configured supervisor answers", async () => { - const { supervisorFetch, seen } = fakeSupervisor( - () => new Response(null, { status: 200 }), - ); - const withSupervisor = createBitmindGateway( - config({ - supervisorUrl: SUPERVISOR_URL, - supervisorToken: SUPERVISOR_TOKEN, - }), - supervisorFetch, + test("isolated_computers is true only while the computer gateway answers", async () => { + const gateway = createBitmindGateway( + config(), + undefined, + fakeComputerGateway({ provider: { list: async () => [] } as never }), ); - const up = await withSupervisor.fetch( + const up = await gateway.fetch( new Request("http://gateway/bitmind/v1/attestation", { headers: { authorization: `Bearer ${SERVICE_TOKEN}` }, }), ); expect((await up.json()).isolated_computers).toBe(true); - // The supervisor's own health is unauthenticated, per its own design — this - // check must not need a token for it, and must not send one either. - expect(seen.url).toBe(`${SUPERVISOR_URL}/health`); - expect(seen.init?.headers).toBeUndefined(); - }); - - test("isolated_computers is false when the supervisor is unreachable", async () => { - const down = createBitmindGateway( - config({ - supervisorUrl: SUPERVISOR_URL, - supervisorToken: SUPERVISOR_TOKEN, - }), - () => Promise.reject(new Error("connect ECONNREFUSED")), - ); - const response = await down.fetch( - new Request("http://gateway/bitmind/v1/attestation", { - headers: { authorization: `Bearer ${SERVICE_TOKEN}` }, - }), - ); - expect((await response.json()).isolated_computers).toBe( - false, - ); }); - test("isolated_computers is false when the supervisor answers but is unwell", async () => { - const unwell = createBitmindGateway( - config({ - supervisorUrl: SUPERVISOR_URL, - supervisorToken: SUPERVISOR_TOKEN, + test("isolated_computers is false when the computer gateway's own provider fails", async () => { + const gateway = createBitmindGateway( + config(), + undefined, + fakeComputerGateway({ + provider: { + list: () => Promise.reject(new Error("supervisor unreachable")), + } as never, }), - () => Promise.resolve(new Response(null, { status: 503 })), ); - const response = await unwell.fetch( + const response = await gateway.fetch( new Request("http://gateway/bitmind/v1/attestation", { headers: { authorization: `Bearer ${SERVICE_TOKEN}` }, }), @@ -208,96 +194,211 @@ describe("attestation", () => { }); describe("computer", () => { - function ensureRequest(agentId: string) { - return new Request(`http://gateway/bitmind/v1/computer/${agentId}/ensure`, { - method: "POST", - headers: { authorization: `Bearer ${SERVICE_TOKEN}` }, + function request( + agentId: string, + sub: string, + init: RequestInit = {}, + ): Request { + return new Request(`http://gateway/bitmind/v1/computer/${agentId}${sub}`, { + ...init, + headers: { authorization: `Bearer ${SERVICE_TOKEN}`, ...init.headers }, }); } - test("is unavailable when no supervisor is configured", async () => { + test("every route is unavailable when no computer gateway is configured", async () => { const gateway = createBitmindGateway(config()); - const response = await gateway.fetch(ensureRequest("agent-1")); - expect(response.status).toBe(503); - }); - - test("proxies to the supervisor and trims the response to what BitMind needs", async () => { - const { supervisorFetch, seen } = fakeSupervisor((url) => { - expect(url.pathname).toBe("/computers/agent-1/ensure"); - return Response.json({ - botId: "agent-1", - // Internal details a response to BitMind must never carry. - container: "openbot-computer-agent-1", - url: "http://openbot-computer-agent-1:4100", - identity: "spiffe://openbot.local/agent-1", - status: "running", - startedAt: "2026-09-03T00:00:00.000Z", - }); - }); + for (const req of [ + request("agent-1", ""), + request("agent-1", "/ensure", { method: "POST" }), + request("agent-1", "/screenshot"), + request("agent-1", "/control", { + method: "POST", + headers: { "x-bitmind-actor-id": "user-1" }, + body: JSON.stringify({ action: "take" }), + }), + ]) { + const response = await gateway.fetch(req); + expect(response.status).toBe(503); + } + }); + + test("requires the service token, like every other BitMind route", async () => { const gateway = createBitmindGateway( - config({ - supervisorUrl: SUPERVISOR_URL, - supervisorToken: SUPERVISOR_TOKEN, + config(), + undefined, + fakeComputerGateway(), + ); + const bare = await gateway.fetch( + new Request("http://gateway/bitmind/v1/computer/agent-1", { + method: "GET", }), - supervisorFetch, ); - const response = await gateway.fetch(ensureRequest("agent-1")); + expect(bare.status).toBe(401); + }); + + test("GET observes status without starting anything", async () => { + const seen: string[] = []; + const gateway = createBitmindGateway( + config(), + undefined, + fakeComputerGateway({ + status: async (botId) => { + seen.push(botId); + return { botId, state: "absent" }; + }, + }), + ); + const response = await gateway.fetch(request("agent-1", "")); expect(response.status).toBe(200); expect(await response.json()).toEqual({ - status: "running", - started_at: "2026-09-03T00:00:00.000Z", - }); - expect(seen.init?.headers).toEqual({ - authorization: `Bearer ${SUPERVISOR_TOKEN}`, + botId: "agent-1", + state: "absent", }); + expect(seen).toEqual(["agent-1"]); }); - test("passes the supervisor's own status through on refusal, never its body", async () => { - const conflict = createBitmindGateway( - config({ - supervisorUrl: SUPERVISOR_URL, - supervisorToken: SUPERVISOR_TOKEN, + test("ensure locates (starting if needed) and reports the resulting status", async () => { + const located: string[] = []; + const gateway = createBitmindGateway( + config(), + undefined, + fakeComputerGateway({ + locate: async (botId) => { + located.push(botId); + return "http://openbot-computer-agent-1:4100"; + }, + status: async (botId) => ({ botId, state: "ready" }), }), - () => - Promise.resolve( - Response.json( - { error: "A container named ... already exists ..." }, - { status: 409 }, - ), - ), ); - const response = await conflict.fetch(ensureRequest("agent-1")); - expect(response.status).toBe(409); + const response = await gateway.fetch( + request("agent-1", "/ensure", { method: "POST" }), + ); + expect(response.status).toBe(200); + // Never the address `locate` resolved — that is an internal container-DNS url, + // not something BitMind can reach or should see. + expect(await response.json()).toEqual({ botId: "agent-1", state: "ready" }); + expect(located).toEqual(["agent-1"]); + }); + + test("screenshot passes the gateway's own result through", async () => { + const gateway = createBitmindGateway( + config(), + undefined, + fakeComputerGateway({ + screenshot: async () => ({ + base64: "aGVsbG8=", + width: 1280, + height: 800, + capturedAt: "2026-09-03T00:00:00.000Z", + url: "https://example.com", + }), + }), + ); + const response = await gateway.fetch(request("agent-1", "/screenshot")); + expect(response.status).toBe(200); expect(await response.json()).toEqual({ - error: "The computer supervisor refused the request.", + base64: "aGVsbG8=", + width: 1280, + height: 800, + capturedAt: "2026-09-03T00:00:00.000Z", + url: "https://example.com", }); }); - test("an unreachable supervisor is a 502", async () => { - const unreachable = createBitmindGateway( - config({ - supervisorUrl: SUPERVISOR_URL, - supervisorToken: SUPERVISOR_TOKEN, + test("control take and release call the matching verb with an actor from the request", async () => { + const calls: { verb: string; botId: string; actor: unknown }[] = []; + const gateway = createBitmindGateway( + config(), + undefined, + fakeComputerGateway({ + takeControl: async (botId, actor) => { + calls.push({ verb: "take", botId, actor }); + return { holder: "human", since: "now", requested: false }; + }, + releaseControl: async (botId, actor) => { + calls.push({ verb: "release", botId, actor }); + return { holder: "bot", since: "now", requested: false }; + }, }), - () => Promise.reject(new Error("connect ECONNREFUSED")), ); - const response = await unreachable.fetch(ensureRequest("agent-1")); - expect(response.status).toBe(502); + const take = await gateway.fetch( + request("agent-1", "/control", { + method: "POST", + headers: { "x-bitmind-actor-id": "person-7" }, + body: JSON.stringify({ action: "take" }), + }), + ); + expect(take.status).toBe(200); + expect(await take.json()).toEqual({ + holder: "human", + since: "now", + requested: false, + }); + + const release = await gateway.fetch( + request("agent-1", "/control", { + method: "POST", + headers: { "x-bitmind-actor-id": "person-7" }, + body: JSON.stringify({ action: "release" }), + }), + ); + expect(release.status).toBe(200); + + expect(calls).toEqual([ + { verb: "take", botId: "agent-1", actor: { id: "bitmind:person-7" } }, + { verb: "release", botId: "agent-1", actor: { id: "bitmind:person-7" } }, + ]); }); - test("requires the service token, like every other BitMind route", async () => { + test("control without an actor is refused before it reaches the gateway", async () => { const gateway = createBitmindGateway( - config({ - supervisorUrl: SUPERVISOR_URL, - supervisorToken: SUPERVISOR_TOKEN, + config(), + undefined, + fakeComputerGateway({ + takeControl: () => { + throw new Error("must not be called without an actor"); + }, }), ); - const bare = await gateway.fetch( - new Request("http://gateway/bitmind/v1/computer/agent-1/ensure", { + const response = await gateway.fetch( + request("agent-1", "/control", { method: "POST", + body: JSON.stringify({ action: "take" }), }), ); - expect(bare.status).toBe(401); + expect(response.status).toBe(400); + }); + + test("control with an invalid action is refused", async () => { + const gateway = createBitmindGateway( + config(), + undefined, + fakeComputerGateway(), + ); + const response = await gateway.fetch( + request("agent-1", "/control", { + method: "POST", + headers: { "x-bitmind-actor-id": "person-7" }, + body: JSON.stringify({ action: "reboot" }), + }), + ); + expect(response.status).toBe(400); + }); + + test("a failure anywhere in the computer seam is a 502, never the underlying message", async () => { + const gateway = createBitmindGateway( + config(), + undefined, + fakeComputerGateway({ + status: () => + Promise.reject(new Error("supervisor said something private")), + }), + ); + const response = await gateway.fetch(request("agent-1", "")); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + error: "The computer backend refused the request.", + }); }); }); @@ -463,51 +564,6 @@ describe("configuration", () => { }), ).toThrow(/http or https/); }); - - test("the supervisor's url and token are set together or not at all", () => { - const base = { - BITMIND_SERVICE_TOKEN: "token", - BITMIND_AGENT_TOKEN: "token", - }; - // Neither set: a deployment with no supervisor, valid. - expect(bitmindGatewayConfig(base).supervisorUrl).toBeUndefined(); - expect(() => - bitmindGatewayConfig({ - ...base, - BITMIND_SUPERVISOR_URL: "http://localhost:4300", - }), - ).toThrow(/BITMIND_SUPERVISOR_TOKEN/); - expect(() => - bitmindGatewayConfig({ ...base, BITMIND_SUPERVISOR_TOKEN: "token" }), - ).toThrow(/BITMIND_SUPERVISOR_URL/); - const both = bitmindGatewayConfig({ - ...base, - BITMIND_SUPERVISOR_URL: "http://localhost:4300", - BITMIND_SUPERVISOR_TOKEN: "token", - }); - expect(both.supervisorUrl).toBe("http://localhost:4300"); - expect(both.supervisorToken).toBe("token"); - }); - - test("refuses a supervisor url that carries credentials or an odd scheme", () => { - const base = { - BITMIND_SERVICE_TOKEN: "token", - BITMIND_AGENT_TOKEN: "token", - BITMIND_SUPERVISOR_TOKEN: "token", - }; - expect(() => - bitmindGatewayConfig({ - ...base, - BITMIND_SUPERVISOR_URL: "http://user:pw@host:4300", - }), - ).toThrow(/credentials/); - expect(() => - bitmindGatewayConfig({ - ...base, - BITMIND_SUPERVISOR_URL: "file:///etc/passwd", - }), - ).toThrow(/http or https/); - }); }); describe("identity enforcement", () => { From 1f2b986b4881f739bd8f54c241eaf81077ae73fc Mon Sep 17 00:00:00 2001 From: Bit Cloud Date: Fri, 4 Sep 2026 04:15:31 +0000 Subject: [PATCH 3/3] fix: a malformed percent-escape in the agent id is a 404, not a URIError `decodeURIComponent` throws on a path like `/bitmind/v1/computer/%`, and this fetch handler is the outermost frame, so an unguarded decode answered a bad path with an unhandled error instead of a status. It is not a route this gateway serves either way, so it now gets the same 404 as any other miss. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/bitmind/gateway.ts | 11 ++++++++++- server/tests/bitmind-gateway.test.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/server/src/bitmind/gateway.ts b/server/src/bitmind/gateway.ts index ce4d698eb..5b3ddde5c 100644 --- a/server/src/bitmind/gateway.ts +++ b/server/src/bitmind/gateway.ts @@ -477,7 +477,16 @@ export function createBitmindGateway( const computerMatch = /^\/bitmind\/v1\/computer\/([^/]+)(\/[a-z]+)?$/.exec(url.pathname); if (computerMatch) { - const agentId = decodeURIComponent(computerMatch[1] ?? ""); + // A malformed percent-escape (`/computer/%`) makes `decodeURIComponent` throw, + // and this handler is the outermost frame — an unguarded decode turns a bad + // path into an unhandled error rather than an answer. It is not a route this + // gateway serves either way, so it gets the same 404 as any other miss. + let agentId: string; + try { + agentId = decodeURIComponent(computerMatch[1] ?? ""); + } catch { + return Response.json({ error: "Not found." }, { status: 404 }); + } const sub = computerMatch[2]; if (!sub && request.method === "GET") { return computerStatus(agentId); diff --git a/server/tests/bitmind-gateway.test.ts b/server/tests/bitmind-gateway.test.ts index 546b331fc..2728f995d 100644 --- a/server/tests/bitmind-gateway.test.ts +++ b/server/tests/bitmind-gateway.test.ts @@ -205,6 +205,20 @@ describe("computer", () => { }); } + test("a malformed percent-escape in the agent id is a 404, not a thrown URIError", async () => { + // `decodeURIComponent("%")` throws, and this handler is the outermost frame, so an + // unguarded decode answered a bad path with an unhandled error instead of a status. + const gateway = createBitmindGateway( + config(), + undefined, + fakeComputerGateway(), + ); + for (const path of ["%", "%zz", "a%2"]) { + const response = await gateway.fetch(request(path, "")); + expect(response.status).toBe(404); + } + }); + test("every route is unavailable when no computer gateway is configured", async () => { const gateway = createBitmindGateway(config()); for (const req of [