diff --git a/server/src/bitmind/gateway.ts b/server/src/bitmind/gateway.ts index 5a6bf6d81..5b3ddde5c 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. @@ -77,14 +80,33 @@ export function createBitmindGateway( */ const active = new Map(); - function attestation(): BitmindAttestation { + /** + * 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. `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 computersReachable(): Promise { + if (!computerGateway) return false; + try { + await computerGateway.provider.list(); + return true; + } 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 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, @@ -94,6 +116,128 @@ 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 }, + ); + } + + /** + * The actor BitMind's request names, for the audit trail a control handover writes. + * + * 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 (!computerGateway) return computerUnavailable(); + try { + await computerGateway.locate(agentId); + return Response.json(await computerGateway.status(agentId)); + } catch { + return computerErrorResponse(); + } + } + + /** `GET /bitmind/v1/computer/{agent_id}/screenshot` — a snapshot, not a video pipe. */ + async function computerScreenshot(agentId: string): Promise { + if (!computerGateway) return computerUnavailable(); + try { + 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( + { + 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: "Body must be JSON." }, { status: 400 }); + } + const input = ControlActionSchema.safeParse(body); + if (!input.success) { + return Response.json( + { error: 'action must be "take" or "release".' }, + { status: 400 }, + ); + } + 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 { let body: unknown; try { @@ -325,11 +469,38 @@ 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 computerMatch = + /^\/bitmind\/v1\/computer\/([^/]+)(\/[a-z]+)?$/.exec(url.pathname); + if (computerMatch) { + // 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); + } + 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 }); }, /** 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..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, @@ -36,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()), @@ -48,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 554d324dc..2728f995d 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, @@ -9,6 +10,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"; +/** + * 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]> = {}, ) { @@ -108,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", @@ -128,6 +157,263 @@ describe("attestation", () => { }; expect(manifest.version).toBe(AG_UI_PROTOCOL_VERSION); }); + + 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 gateway.fetch( + new Request("http://gateway/bitmind/v1/attestation", { + headers: { authorization: `Bearer ${SERVICE_TOKEN}` }, + }), + ); + expect((await up.json()).isolated_computers).toBe(true); + }); + + 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, + }), + ); + const response = await gateway.fetch( + new Request("http://gateway/bitmind/v1/attestation", { + headers: { authorization: `Bearer ${SERVICE_TOKEN}` }, + }), + ); + expect((await response.json()).isolated_computers).toBe( + false, + ); + }); +}); + +describe("computer", () => { + 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("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 [ + 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(), + undefined, + fakeComputerGateway(), + ); + const bare = await gateway.fetch( + new Request("http://gateway/bitmind/v1/computer/agent-1", { + method: "GET", + }), + ); + 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({ + botId: "agent-1", + state: "absent", + }); + expect(seen).toEqual(["agent-1"]); + }); + + 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" }), + }), + ); + 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({ + base64: "aGVsbG8=", + width: 1280, + height: 800, + capturedAt: "2026-09-03T00:00:00.000Z", + url: "https://example.com", + }); + }); + + 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 }; + }, + }), + ); + 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("control without an actor is refused before it reaches the gateway", async () => { + const gateway = createBitmindGateway( + config(), + undefined, + fakeComputerGateway({ + takeControl: () => { + throw new Error("must not be called without an actor"); + }, + }), + ); + const response = await gateway.fetch( + request("agent-1", "/control", { + method: "POST", + body: JSON.stringify({ action: "take" }), + }), + ); + 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.", + }); + }); }); describe("run relay", () => {