Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 183 additions & 12 deletions server/src/bitmind/gateway.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -77,14 +80,33 @@ export function createBitmindGateway(
*/
const active = new Map<string, AbortController>();

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<boolean> {
if (!computerGateway) return false;
try {
await computerGateway.provider.list();
return true;
} catch {
return false;
}
}

async function attestation(): Promise<BitmindAttestation> {
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,
Expand All @@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
let body: unknown;
try {
Expand Down Expand Up @@ -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. */
Expand Down
11 changes: 10 additions & 1 deletion server/src/bitmind/mount.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { serve } from "bun";
import type { ComputerGateway } from "../computer/gateway";
import {
bitmindGatewayConfig,
bitmindGatewayListen,
Expand Down Expand Up @@ -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()),
Expand All @@ -48,7 +57,7 @@ export function bitmindGatewayFrom(
return {
config,
listen: bitmindGatewayListen(environment),
gateway: createBitmindGateway(config),
gateway: createBitmindGateway(config, undefined, computerGateway),
};
}

Expand Down
2 changes: 1 addition & 1 deletion server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1288,7 +1288,7 @@ serve<SocketData>({
* 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(
Expand Down
Loading