feat: add the BitMind gateway — service auth, attestation, and a governed relay - #1
Conversation
…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
left a comment
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| const relayed = downstream.body.pipeThrough( | ||
| new TransformStream<Uint8Array, Uint8Array>({ |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
|
All six findings addressed in
Gate: One honest limit on finding 5: the abort-into- |
IshmaelRogers
left a comment
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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().
| // 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")) { |
There was a problem hiding this comment.
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); | ||
| }; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
All four follow-ups addressed in
Gate: |
IshmaelRogers
left a comment
There was a problem hiding this comment.
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.
| 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(); |
There was a problem hiding this comment.
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:
- Start a hanging run, cancel the response body, immediately POST the same idempotency key again.
- The retry returns
200, butactiveRuns()is0while the body is still streaming. - 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.
|
Round-3 finding addressed in Reproduced on The retry streamed with Cause. Fix. Ownership, as you suggested: 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. After the fix, same script: Regression test — Gate at 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. |
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,createBitmindGatewaymounts there and the entry retires.POST /bitmind/v1/run— Bearer service-token auth (timing-safe viashared/agent-authorisation), body validated against the pinnedRunAgentInputSchema(invalid bodies refused without being echoed), then relayed whole to the configured downstream AG-UI agent —agent-langgraphon loopback by default — authenticated with its managed-agent token. BitMind's identity statement (workspace_id,agent_id,run_id,message_id,fencing_token) rides inforwardedProps, 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.BITMIND_MAX_CONCURRENT_RUNS(default 2, matching the enclave note's staging start) with 429 +retry-after;idempotency-keyconflict detection (409 while the same claimed attempt is already streaming — BitMind sendsrun_id:fencing_token); a whole-relay ceiling so an abandoned stream cannot hold a slot;redirect: erroron the downstream call; loopback bind by default; refuses to start without both tokens.@ag-ui/coreadded to server deps at exactly0.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=ok— 1658 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,
/healthopen, 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:Service auth, the fencing idempotency key, SSE relay, and BitMind's stateful delta assembly all exercised across the wire.
Deliberately not in this slice
config.tsorindex.ts.agent-langgraphrefuses 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.mdrecords the surface, what it owns, and what it refuses to pretend to be.