Skip to content

feat: add the BitMind gateway — service auth, attestation, and a governed relay - #1

Merged
IshmaelRogers merged 4 commits into
mainfrom
feat/bitmind-gateway
Sep 2, 2026
Merged

feat: add the BitMind gateway — service auth, attestation, and a governed relay#1
IshmaelRogers merged 4 commits into
mainfrom
feat/bitmind-gateway

Conversation

@IshmaelRogers

Copy link
Copy Markdown

First slice of the bit-mind CopilotKit#20 enclave work: the surface BitMind's run plane talks to, speaking the real AG-UI protocol at the pinned @ag-ui/core@0.0.57 (bit-mind ADR-0002). This is a public repository — everything here is placeholder-credentialed and enclave-shaped by design.

What this adds

server/src/bitmind/ — a handler factory plus its own process entry (main.ts), because the server itself still requires the Intelligence contract to boot and the enclave has none. When the server grows a standalone mode, createBitmindGateway mounts there and the entry retires.

  • POST /bitmind/v1/run — Bearer service-token auth (timing-safe via shared/agent-authorisation), body validated against the pinned RunAgentInputSchema (invalid bodies refused without being echoed), then relayed whole to the configured downstream AG-UI agent — agent-langgraph on loopback by default — authenticated with its managed-agent token. BitMind's identity statement (workspace_id, agent_id, run_id, message_id, fencing_token) rides in forwardedProps, untouched.
  • GET /bitmind/v1/attestation — the fork-defined surface bit-mind's enclave activation gate reads. Every field is a statement BitMind may act on, so it is honest: isolated_computers: false (no enclave computers exist yet, so BitMind's worker correctly stays off), execution: { backend: relay, tools: false, interrupts: false }, plus the concurrency and timeout ceilings.
  • Boundary behaviours: admission control at BITMIND_MAX_CONCURRENT_RUNS (default 2, matching the enclave note's staging start) with 429 + retry-after; idempotency-key conflict detection (409 while the same claimed attempt is already streaming — BitMind sends run_id:fencing_token); a whole-relay ceiling so an abandoned stream cannot hold a slot; redirect: error on the downstream call; loopback bind by default; refuses to start without both tokens.
  • Pin discipline: @ag-ui/core added to server deps at exactly 0.0.57; a test asserts the attested protocol version equals the installed package, so a lockfile accident cannot move the protocol under either side. Mirrors the drift fixture on the BitMind side.

Verified how

Full workspace gate on the branch: format=ok lint=ok typecheck=ok test=ok1658 pass / 0 fail (baseline before this change: 1648, all green given a pgvector Postgres and a 30s timeout for the retention integration tests on a loaded host; the 10 new tests cover auth, attestation honesty, the version drift fence, relay forwarding + token separation, non-echoing validation, idempotency conflict, ceiling refusal, 502-not-hung-slot on backend failure, and startup refusal without tokens).

Live process smoke: boots, binds loopback, /health open, attestation 401 without / correct JSON with the token, invalid run body → 400.

Cross-repo end-to-end — the first time the two repositories have spoken the real protocol to each other: bit-mind's actual OpenBotRunEngine (dev @ 943d49d) pointed at this gateway running live, relaying to a stand-in downstream agent:

NORMALIZED {"type":"run_started","external_event_id":"agui:run-e2e-1:started"}
NORMALIZED {"type":"step_updated","external_event_id":"agui:message:m1","ordinal":30000,"kind":"message","state":"succeeded","summary":"Hello from the other side of the gateway."}
NORMALIZED {"type":"run_completed","external_event_id":"agui:run-e2e-1:finished"}
E2E COMPLETE

Service auth, the fencing idempotency key, SSE relay, and BitMind's stateful delta assembly all exercised across the wire.

Deliberately not in this slice

  • No computers, tools, or interrupts — the relayed agent is prose-only, so nothing consequential can pass this door, which is exactly why the attestation says so and the activation gate stays closed.
  • No policy/audit surface — those live in the server's governed gateway and join this path when tool execution does.
  • The server still cannot boot without Intelligence — the standalone mode is the next, much larger slice; this gateway deliberately does not touch config.ts or index.ts.
  • A real model run needs a provider key on the enclave host (agent-langgraph refuses to start without one) — host provisioning is the other half of bit-mind Tell somebody when a Bot is blocked on them CopilotKit/OpenBot#20 and is owner work.

Docs: docs/bitmind-gateway.md records the surface, what it owns, and what it refuses to pretend to be.

…rned relay

The doorway BitMind's run plane talks through, as its own process
(server/src/bitmind/main.ts) because the server itself cannot yet boot
without the Intelligence contract and the enclave it runs in has none.

- POST /bitmind/v1/run: Bearer service-token auth (timing-safe), body
  validated against the pinned @ag-ui/core@0.0.57 RunAgentInputSchema,
  then relayed whole to the configured downstream AG-UI agent
  (agent-langgraph by default) with its managed-agent token. BitMind's
  identity rides in forwardedProps untouched.
- GET /bitmind/v1/attestation: what stands behind the door, honestly -
  isolated_computers: false until enclave computers exist, which keeps
  BitMind's activation gate closed and its worker off.
- Admission control (429 past the concurrency ceiling, staging default 2
  per the enclave note), idempotency-key conflict detection (409 while
  the same claimed attempt is already streaming), a whole-run relay
  ceiling, redirect refusal, and loopback bind by default.
- @ag-ui/core pinned exactly at 0.0.57 in server deps; a test asserts
  the attested protocol version equals the installed package so the pin
  cannot drift silently.

@IshmaelRogers IshmaelRogers left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the gateway against its advertised AG-UI/BitMind boundary and the shipped LangGraph downstream. The focused gateway tests and server typecheck pass, but the inline findings cover stream lifecycle/admission correctness, protocol validation, identity/capability enforcement, downstream cancellation, and strict configuration parsing. I could not inspect the private bitcloud-labs/bit-mind implementation with this credential, so the comments are limited to behavior reproducible from this repository and the available BitBot contracts.

Comment thread server/src/bitmind/gateway.ts Outdated
}

const relayed = downstream.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slot is released only from flush(), which runs for a normal close but not when the source stream errors or the returned body is cancelled. I reproduced both paths: after response.text() rejected on a source error, and after response.body.cancel(), activeRuns() remained 1 until the timeout. With the default limit of two, two broken streams can make the gateway reject healthy work for 15 minutes. Please relay through a reader-backed stream with cleanup in finally/cancel() (and abort the downstream), and cover source-error and consumer-cancellation cases.

{ status: 502, headers: JSON_HEADERS },
);
}
if (!downstream.ok || !downstream.body) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A body plus a 2xx status is not sufficient to establish the AG-UI stream contract. I verified that a downstream Response.json(...) is currently returned from /bitmind/v1/run as 200 application/json, so a proxy login page or JSON error is reported to BitMind as a successful run transport. Please require the supported AG-UI/SSE content type before reserving this as a successful relay (return 502 otherwise), with a regression test for 200 JSON/HTML responses.

} catch {
return Response.json({ error: "Body must be JSON." }, { status: 400 });
}
const input = RunAgentInputSchema.safeParse(body);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RunAgentInputSchema validates only the generic AG-UI envelope, not the BitMind identity extension this gateway relies on. The new happy-path fixture is accepted without the documented message_id, and nothing checks that forwardedProps.run_id === input.runId or that idempotency-key === run_id:fencing_token. That allows malformed or contradictory attempt identities through the boundary and makes the fencing key caller-selectable independently of the run. Please add a strict BitMind forwardedProps schema plus cross-field refinements, and negative tests for missing/mismatched identity fields.

},
// The validated input, forwarded whole. `forwardedProps` carries BitMind's
// identity statement (workspace, agent, run, fencing token) untouched.
body: JSON.stringify(input.data),

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The attestation says tools: false and interrupts: false, but forwarding the validated generic input whole does not enforce either statement: non-empty tools, tool-classification forwarded props, and resume input all pass through. The shipped LangGraph downstream binds every supplied tool. Its signed-run check limits actual OpenBot tool execution today, but that is downstream behavior rather than this gateway holding its advertised capability boundary. While these flags are false, please reject unsupported tool/resume inputs (and test that they never reach fetchImplementation), or project an explicitly allowed payload instead of forwarding the generic object whole.

try {
downstream = await fetchImplementation(config.agentUrl, {
method: "POST",
signal: controller.signal,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aborting this fetch does not currently stop the shipped downstream's model work. agent-langgraph creates a ReadableStream with no cancel() hook and does not pass request.signal into streamEvents/the model invocation. On disconnect or timeout this gateway releases admission, while the abandoned model can continue consuming resources, so replacement requests can exceed the attested execution ceiling. Please propagate cancellation through the downstream handler/model call and release the slot only once termination is confirmed; an integration test should cover timeout/disconnect against the shipped downstream contract.

if (!raw) return fallback;
const value = Number.parseInt(raw, 10);
if (!Number.isInteger(value) || value < minimum || value > maximum) {
throw new Error(

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Number.parseInt accepts a numeric prefix, so malformed values such as 2workers, 2.5, and 1000ms silently become valid limits. I reproduced BITMIND_MAX_CONCURRENT_RUNS=2workers and BITMIND_RUN_TIMEOUT_MS=1000ms loading as 2 and 1000. Please parse the complete string (for example, strict decimal validation plus Number(...)/Number.isSafeInteger) and add malformed-value cases.

- The relay is pumped by hand instead of piped through a transform: the
  admission slot comes back on every ending - clean close, source error,
  consumer cancellation, timeout - where flush() only ran for the first.
  Consumer cancellation also aborts the downstream fetch, so the far side
  is told to stop rather than abandoned.
- A 2xx with a body is not a run: only text/event-stream may be reserved
  and relayed; a JSON login page or error is a 502 with the slot released.
- BitMind's identity statement is now required in full (strict
  forwardedProps schema incl. message_id), must agree with the envelope
  (forwardedProps.run_id === runId), and the idempotency-key header must
  equal run_id:fencing_token - the key is bound to the attempt, never
  caller-selectable.
- The gateway holds its own attestation: while tools: false and
  interrupts: false, non-empty tools and resume inputs are refused before
  they can reach the downstream, rather than trusting it to.
- agent-langgraph propagates cancellation: the response stream's cancel()
  and the request's own signal both abort the model invocation
  (streamEvents receives the signal), so a disconnected consumer stops
  the work instead of leaving it running beyond the attested ceiling.
  Response lifecycle factored into respond.ts so tests reach it without
  binding a port.
- Gateway limits parse the whole string: 2workers, 1000ms and 2.5 are
  refused at startup instead of silently becoming ceilings nobody set.
@IshmaelRogers

Copy link
Copy Markdown
Author

All six findings addressed in 77095ce. Every one reproduced before fixing, then covered by a regression test.

  1. Slot leak on error/cancel — the relay is now a hand-pumped ReadableStream: the reader loop releases the slot on clean close and on source error, and cancel() (plus the timeout/abort path) releases it and aborts the downstream fetch, so the far side is told to stop rather than abandoned. Tests: source error mid-stream (response.text() rejects, activeRuns() back to 0) and consumer cancellation (slot released, downstream signal observed aborted).
  2. Content-type — only text/event-stream may be reserved and relayed; a 200 JSON (your proxy-login-page case, reproduced) is a 502 with the slot released, tested.
  3. Identity — a strict forwardedProps schema now requires the full BitMind statement including message_id; forwardedProps.run_id must equal runId; and the idempotency-key header, when present, must equal run_id:fencing_token — the key derives from the attempt and is never caller-selectable (the gateway computes it either way). Negative tests for each. The happy-path fixture gained the message_id it was missing — your catch.
  4. Attested capabilities enforced here — non-empty tools and any resume entries are refused with 400 before the downstream is reached (tested that fetchImplementation is never called), instead of trusting the LangGraph agent's own checks to hold this gateway's advertised boundary.
  5. Downstream cancellationagent-langgraph now propagates it: the response lifecycle moved into respond.ts (testable without binding a port, per the house pattern), where both the request's own signal and the stream's cancel() abort a controller whose signal is passed into streamEvents — the model invocation itself stops, not just the reading of it. streamRun also breaks on an aborted signal as the belt for an iterable that ignores it. Tests: consumer cancel stops the yield loop and fires the signal; request-signal disconnect does the same; a pre-aborted signal never yields.
  6. Strict limits — the whole string must be a decimal integer (/^\d+$/ + Number.isSafeInteger); 2workers, 2.5, 1000ms, -1 and 1e3 are all refused at startup, tested.

Gate: format=ok lint=ok typecheck=ok, server suite 1671/0 (pgvector Postgres), agent-langgraph suite 28/0. The cross-repo end-to-end re-ran against the hardened gateway — bit-mind's OpenBotRunEngine at dev still completes a run through it (its headers and identity already satisfied the new checks), and a live probe with an empty forwardedProps gets the new 400.

One honest limit on finding 5: the abort-into-streamEvents line is covered by type-checked wiring and the respond.ts contract tests with a signal-honouring fake; driving a real LangGraph graph mid-token requires a model key this environment doesn't hold.

@IshmaelRogers IshmaelRogers left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed commit 77095ce. The six original findings are substantially addressed, and the updated gateway/LangGraph focused tests pass after installing each package's lockfile. The remaining inline findings cover cancellation gaps on rejected responses, already-aborted requests and in-flight tool calls, plus exact SSE media-type validation.

{ status: 502, headers: JSON_HEADERS },
);
}
if (!downstream.ok || !downstream.body) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This failure path releases admission without aborting the fetch or cancelling downstream.body. A backend can send non-2xx headers and continue streaming a body; the gateway immediately admits replacement work while that request remains live. Please abort the controller/cancel the body before releasing here, as the non-SSE path below already does via controller.abort().

Comment thread server/src/bitmind/gateway.ts Outdated
// answers exactly that way. Only the AG-UI stream content type may be reserved
// and reported to BitMind as a run in progress.
const contentType = downstream.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().startsWith("text/event-stream")) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startsWith still accepts invalid media types such as text/event-streaming and text/event-stream+json. Please compare the media type before the first ; exactly to text/event-stream (trimmed/case-insensitive), then preserve parameters when forwarding the original header.

const release = () => {
clearTimeout(timeout);
active.delete(key);
};

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Abort events are not replayed. If the caller disconnects while request.json() or validation is running, this listener is added after the signal has already fired and the gateway still starts downstream work. Please check request.signal.aborted before reserving the slot/starting the fetch (and abort immediately when true), in addition to registering the listener.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new abort signal reaches the model stream, but not an in-flight tool call. If cancellation arrives while callTool's fetch is executing, the graph can stop waiting while the governed action continues and may still complete after the run was cancelled. Please thread the run signal through buildGraph/the tool node into callTool and pass it to fetch, with a cancellation test that blocks inside the tool request rather than only inside model event generation.

- A refused (non-2xx) response can arrive over a body that keeps streaming:
  the fetch is now aborted and the body cancelled before admission is
  released, matching what the non-SSE path already did - and that path now
  cancels the body too.
- The media type is compared exactly (segment before the first ';',
  trimmed, case-insensitive): text/event-streaming and
  text/event-stream+json are refused, while charset parameters survive
  both the check and the forwarded header.
- Abort events are not replayed: a caller that hung up while the body was
  being read or validated is now caught by an explicit aborted check after
  the listener registers, before a slot is reserved or the agent reached.
- A cancelled run now stops an in-flight governed tool call, not only the
  model stream: the call moved to tools.ts with the signal passed into its
  fetch, the graph's nodes take the node config (streamEvents propagates
  the run signal through it) and hand it to both the model invocation and
  the tool call. Tested with a fetch that blocks until aborted.
@IshmaelRogers

Copy link
Copy Markdown
Author

All four follow-ups addressed in ed08c53.

  1. Refusal over a streaming body — the non-2xx path now aborts the controller and cancels downstream.body before releasing admission, and the non-SSE path cancels the body too (it only aborted before). Tested with a 503 whose body never closes: slot back to 0, downstream signal aborted, body cancel() observed.
  2. Exact media type — the segment before the first ; is compared, trimmed and case-insensitive, to exactly text/event-stream; text/event-streaming and text/event-stream+json are 502s (both tested), while text/event-stream; charset=utf-8 passes and the original header — parameters included — is what gets forwarded (tested).
  3. Already-fired abort — after the listener registers, request.signal.aborted is checked explicitly before any slot is reserved or the agent reached; whichever side of the race the abort lands on, one of the two catches it. Tested with a Request whose signal aborted before the handler ran: 400, zero active runs, fetchImplementation never called.
  4. In-flight tool calls — the governed call moved to tools.ts with the signal passed into its fetch, and the graph's nodes now take the node config (which streamEvents propagates the run signal through) and hand it to both the model invocation and the tool call — so cancellation stops the action, not just the reading of the stream. The new test blocks inside the tool request (a fetch that only ends on abort) and asserts the call returns promptly with the cancellation named in the transcript result. One caveat stated plainly: agent-langgraph has no tsconfig upstream, so the node-config threading is exercised by the runtime tests rather than a compiler.

Gate: format=ok lint=ok typecheck=ok, gateway suite 28/0, agent suite 31/0.

@IshmaelRogers IshmaelRogers left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 3 re-review (ed08c53)

The prior round's findings are addressed well — identity/capability enforcement, exact SSE media-type check, pre-aborted caller handling, downstream abort on refusal/non-SSE, strict integer parsing, and agent-langgraph cancellation (model stream + in-flight tool fetch) all have focused tests and pass locally (bitmind-gateway.test.ts: 28/28; respond.test.ts + tools.test.ts: 7/7).

One new issue: consumer cancellation still has a double-release() race on the same idempotency key (inline comment on gateway.ts). After cancel, a same-key retry can stream with activeRuns() === 0, and a third same-key POST is admitted instead of 409 — bypassing both admission accounting and idempotency. Recommend fixing before merge.

Comment on lines +270 to +277
async cancel(reason) {
// BitMind hung up. The far side is told, not just abandoned: the model must
// stop working, and the slot comes back only alongside that abort.
controller.abort(
reason instanceof Error ? reason : new Error("relay cancelled"),
);
await reader.cancel(reason).catch(() => undefined);
release();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new cancellation path still double-releases the same idempotency key, which drops a replacement run from active while its stream is live. On consumer cancel, controller.abort() runs the { once: true } listener (which calls release()), then cancel() awaits reader.cancel() and calls release() again. If BitMind retries with the same run_id:fencing_token during that await, the second release() deletes the new controller's map entry even though the relay is still open.

I reproduced on head ed08c53:

  1. Start a hanging run, cancel the response body, immediately POST the same idempotency key again.
  2. The retry returns 200, but activeRuns() is 0 while the body is still streaming.
  3. A third POST with the same key also returns 200 (409/idempotency no longer holds), and the backend is invoked three times.

Please make release() controller-aware (active.get(key) === controller before delete), and ensure only one cleanup path owns release for a given relay (either drop the duplicate call in cancel() or guard the abort listener). A regression test for cancel-then-same-key-retry would lock this in.

A cancelled relay ends through several paths at once — the abort listener,
the stream's cancel(), and the pending read resolving — and each called
release(), which deleted the idempotency key unconditionally. A same-key
retry admitted between two of those endings had its own entry deleted while
its stream was still live, so the concurrency accounting lost the run and a
third POST with the same key was admitted instead of refused with 409.

release() now gives the slot back only when this relay still holds it, which
also makes every ending safe to run more than once.
@IshmaelRogers

Copy link
Copy Markdown
Author

Round-3 finding addressed in 0a179b3. Reproduced first, then fixed, then covered.

Reproduced on ed08c53, exactly your sequence (hanging SSE backend, cancel the body, retry the same idempotency-key, then a third POST):

retry status 200 active 0
after cancel settled, active 0
third status 200 active 1 backend calls 3

The retry streamed with activeRuns() === 0, the third same-key POST was admitted instead of refused, and the backend was invoked three times — all three of your observations.

Cause. release() did active.delete(key) unconditionally, and a cancelled relay ends through three paths, not two: the { once: true } abort listener, the stream's cancel() after await reader.cancel(), and — the one that made this reproduce so readily — the pull() whose pending reader.read() resolves done once the reader is cancelled. Any of those firing after a same-key retry has been admitted evicts the new controller's entry while its stream is live.

Fix. Ownership, as you suggested: release() gives the slot back only when this relay still holds it —

const release = () => {
  clearTimeout(timeout);
  if (active.get(key) === controller) {
    active.delete(key);
  }
};

I kept all three call sites rather than nominating one owner. Ownership makes every ending idempotent, so no ending can leak a slot either — which a single-owner arrangement has to argue for path by path. clearTimeout stays unconditional; that timer belongs to this relay whatever the map says.

After the fix, same script:

retry status 200 active 1
after cancel settled, active 1
third status 409 active 1 backend calls 2

Regression testcancellation and the slot's owner > a cancelled relay does not release the retry that replaced it in server/tests/bitmind-gateway.test.ts: cancel, same-key retry admitted (200, activeRuns() === 1), the cancel settles without disturbing it, the third same-key POST is 409, and the backend is asked exactly twice. Verified red before the fix and green after:

# with the ownership check reverted
error: expect(received).toBe(expected)   # activeRuns() 0, expected 1
(fail) cancellation and the slot's owner > a cancelled relay does not release the retry that replaced it
 28 pass, 1 fail
# with the fix
 29 pass, 0 fail   (bitmind-gateway.test.ts)

Gate at 0a179b3 (local; Actions is still blocked account-wide by org billing, so local runs are the only evidence I can offer):

FORMAT=ok LINT=ok TYPECHECK=ok TESTS=ok
bun test server/tests --timeout 60000 → 1677 pass, 0 fail (111 files, 220s)
cd agent-langgraph && bun test tests/ → 31 pass, 0 fail

Not done, and why: no live LangGraph model run — there is no provider key on this host, so cancellation remains covered by contract tests against signal-honouring fakes rather than a real provider stream. Unchanged from previous rounds; still worth an eye when someone runs it against a real model.

@IshmaelRogers
IshmaelRogers merged commit c035d1d into main Sep 2, 2026
@IshmaelRogers
IshmaelRogers deleted the feat/bitmind-gateway branch September 2, 2026 20:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants