From 5d32ee0605a5503338db51312d3b403f24f4c681 Mon Sep 17 00:00:00 2001 From: Bit Cloud Date: Wed, 2 Sep 2026 16:53:52 +0000 Subject: [PATCH 1/3] feat: boot standalone when the Intelligence contract is absent RuntimeCapabilities grows the second mode its own comment predicted. All four INTELLIGENCE_*/COPILOTKIT_* values absent now means standalone - a working deployment with the admin, people, computer and plugin surfaces, and no chat runtime - rather than a refusal to boot. A partial set is still refused, naming what is missing and both valid shapes. - The chat runtime, threads and routines are unmounted in standalone, so those paths 404 by design instead of mounting doors that refuse; the handoff delivery loop says out loud that it stays off when configured on. createApp already treated all three as optional, so this is wiring, not route surgery. - mountCopilotRuntime guards and narrows: mounting it without the contract fails in front of the deployer, per the old single-mode comment's promise. - The Intelligence-bound wiring in index.ts (agent loader, buildAgentFor, the headless-turn pair, the runtime mount) moved behind dynamic imports inside the intelligence branch. Load-bearing, not tidy: that import graph reaches @modelcontextprotocol/sdk's CommonJS SSE client, whose require() of the ESM-only eventsource package crashed Bun at import time - in BOTH modes, on 1.3.14 and 1.4.0, before this change. Standalone now never evaluates the graph; intelligence mode loads it dynamically, which Bun handles, so both modes boot where neither did. --- server/src/app.ts | 10 +- server/src/config.ts | 36 ++- server/src/copilot.ts | 7 + server/src/index.ts | 413 +++++++++++++----------- server/tests/config.test.ts | 40 ++- server/tests/standalone-runtime.test.ts | 94 ++++++ 6 files changed, 388 insertions(+), 212 deletions(-) create mode 100644 server/tests/standalone-runtime.test.ts diff --git a/server/src/app.ts b/server/src/app.ts index ff81b1783..e1f7b5dba 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1046,17 +1046,15 @@ export function createApp( ); } - if (threadIdentity) { + if (threadIdentity && config.runtime.mode === "intelligence") { app.route( "/api/threads", createThreadRoutes( threadIdentity, requireUser, - // config.ts refuses to boot without the full Intelligence contract (see copilot.ts's - // header comment), so `config.runtime.intelligence` is never missing here. Built from it - // rather than assumed, though: this is the one place besides the runtime mount itself that - // needs to reach Intelligence, and it should keep working unmodified if that guarantee ever - // loosens and a deployment can legitimately have no reader to build. + // The guarantee this comment once leaned on has loosened: a standalone deployment + // has no Intelligence to ask about a thread, so it has no reader to build and the + // routes stay unmounted — threads 404 by design there, like the chat runtime. createThreadReader( createIntelligenceClient(config.runtime.intelligence), ), diff --git a/server/src/config.ts b/server/src/config.ts index 1c235f9c3..180ed4592 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -7,11 +7,24 @@ import { singleUserEnabled } from "./auth/dev-actor"; import type { ActionPolicy } from "./computer/policy"; import { parseActionPolicy } from "./computer/policy-store"; -export type RuntimeCapabilities = { - mode: "intelligence"; - durableHistory: true; - intelligence: IntelligenceSettings; -}; +export type RuntimeCapabilities = + | { + mode: "intelligence"; + durableHistory: true; + intelligence: IntelligenceSettings; + } + /** + * A deployment with no Intelligence contract at all. + * + * The admin, people, computer and plugin surfaces all work; the chat runtime, threads + * and routines do not mount, so those paths 404 by design rather than refusing. This + * is the shape the BitMind execution enclave runs in: runs arrive through the BitMind + * gateway and an AG-UI agent, never through the chat surface. + */ + | { + mode: "standalone"; + durableHistory: false; + }; /** The Intelligence contract. Every field is required; see runtimeCapabilities. */ export type IntelligenceSettings = { @@ -556,11 +569,13 @@ function oktaAuth( } /** - * Resolve the Intelligence contract, or refuse to start. + * Resolve the Intelligence contract, choose standalone, or refuse to start. * * All four values are required together. A partial set is the more dangerous shape than none at all: - * it means somebody intended to configure Intelligence and got it wrong, so failing on the partial - * set alone (as this did) let a completely unconfigured deployment through as if that were a choice. + * it means somebody intended to configure Intelligence and got it wrong, so it is still a refusal to + * boot that names what is missing. All four absent is different: it is a deployment that never meant + * to configure Intelligence, and it boots standalone — admin working, chat and threads unmounted — + * which is the shape the BitMind execution enclave runs. */ function runtimeCapabilities(environment: Environment): RuntimeCapabilities { const settings = { @@ -579,9 +594,12 @@ function runtimeCapabilities(environment: Environment): RuntimeCapabilities { .filter(([, value]) => !value) .map(([name]) => name); + if (missing.length === 4) { + return { mode: "standalone", durableHistory: false }; + } if (missing.length > 0) { throw new Error( - `CopilotKit Intelligence is required and is not configured. Missing: ${missing.join(", ")}`, + `CopilotKit Intelligence is partially configured, which reads as a mistake rather than a choice. Missing: ${missing.join(", ")}. Set all four, or unset all four to run standalone.`, ); } diff --git a/server/src/copilot.ts b/server/src/copilot.ts index 547e329a7..50de9e78f 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -1024,6 +1024,13 @@ export function mountCopilotRuntime( */ onRunBusy?: (input: { threadId: string; busy: boolean }) => void, ) { + if (config.runtime.mode !== "intelligence") { + // The one line config.ts's old single-mode comment promised would grow a guard. + // A standalone deployment never calls this: the runtime is not mounted at all. + throw new Error( + "mountCopilotRuntime requires the Intelligence runtime; a standalone deployment must not mount it.", + ); + } const { intelligence } = config.runtime; /** diff --git a/server/src/index.ts b/server/src/index.ts index 6828cb9a9..ac8ebb40b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,8 +1,4 @@ import { randomUUID } from "node:crypto"; -import { - CopilotKitIntelligence, - IntelligenceAgentRunner, -} from "@copilotkit/runtime/v2"; import { serve } from "bun"; import { eq } from "drizzle-orm"; import { COMPUTER_GUIDANCE } from "../../shared/bot-prompt"; @@ -15,7 +11,6 @@ import { createHandoffRunner } from "./agents/handoff-runner"; import { handoffTool } from "./agents/handoff-tool"; import { createAgentProfileStore } from "./agents/profile-store"; import type { AgentActor } from "./agents/profile-types"; -import { createRuntimeAgentLoader } from "./agents/runtime-agents"; import { createApp } from "./app"; import { createAuditReader, createAuditStore, recordAuditEvent } from "./audit"; import { startRetentionSweeps } from "./audit-retention"; @@ -47,13 +42,7 @@ import { } from "./computer/provider"; import { createSnapshotStore } from "./computer/snapshot-store"; import { loadConfig } from "./config"; -import { - type IdentifyActor, - type IdentifyUser, - mountCopilotRuntime, - resolveRuntimeAgents, - type ToolSelection, -} from "./copilot"; +import type { IdentifyActor, IdentifyUser, ToolSelection } from "./copilot"; import { createCredentialAdminService, createCredentialStore, @@ -67,7 +56,6 @@ import { useRoutineTools } from "./plugins/builtin-routines"; import { redirectUriFor } from "./plugins/oauth"; import { createPluginStore } from "./plugins/store"; import { grantedSkills, grantedTools } from "./plugins/tools"; -import { createTurnRunner } from "./routines/run-turn"; import { createRoutineRunner } from "./routines/runner"; import { createRoutineStore } from "./routines/store"; import { createIntentRouter } from "./routing/classify"; @@ -197,11 +185,6 @@ const channelActivityListener = await startChannelActivityListener( channelEvents, ); const roleRepository = createRoleRepository(database); -const loadAgentsForActor = createRuntimeAgentLoader( - database, - agentVault, - config.managedAgent, -); await synchronizeTenantPackage(database, tenantPackage); /* * Built before `auth`, because the deny list is consulted during sign-in and the store is what @@ -645,48 +628,6 @@ const actorFor = async (ownerUserId: string): Promise => { * routine row — which is the whole point of doing it here rather than adding an impersonation path to * a public route. */ -const buildAgentFor = async ({ - ownerUserId, - agentId, -}: { - ownerUserId: string; - agentId: string; -}) => { - const actor = await actorFor(ownerUserId); - const agents = await resolveRuntimeAgents( - () => loadAgentsForActor(actor), - tenantPackage.model, - resolveRuntimeModelApiKey, - stallGuard, - loadToolsForActor(actor.id), - signRunForActor(actor.id), - config.computer ? COMPUTER_GUIDANCE : undefined, - loadVendors, - selectionForActor(actor.id), - agentFetch, - undefined, - // Only the Bot this routine names. Same reason as the hop delivery: the roster is still read in - // full so a Bot this owner cannot see is still absent, but the other Bots are neither built nor - // asked what they hold. - agentId, - ); - const agent = agents[agentId]; - if (!agent) { - /* - * Named, and raised rather than swallowed. The routine's Bot was deleted, or made private by - * somebody else, or the owner lost the role that could see it. The runner turns this into a - * failed run row with this sentence on it, the first failure is said once in the channel, and - * the fatigue rule switches the routine off after ten — which is exactly the right handling for - * a routine pointed at something that is not coming back. - */ - const error = new Error( - `That Bot is no longer registered, so this routine has nothing to run: ${agentId}.`, - ); - error.name = "RoutineBotNotRegistered"; - throw error; - } - return agent; -}; /* * The pair a headless turn is driven through, built ONCE. @@ -703,130 +644,219 @@ const buildAgentFor = async ({ * connection, but its `threads` map is per instance, and a runner per turn would fragment the * already-running check that keeps two turns off one thread. See `routines/run-turn.ts`. */ -const routineIntelligence = new CopilotKitIntelligence({ - apiUrl: config.runtime.intelligence.apiUrl, - wsUrl: config.runtime.intelligence.gatewayWsUrl, - apiKey: config.runtime.intelligence.apiKey, -}); -const routineAgentRunner = new IntelligenceAgentRunner({ - url: routineIntelligence.ɵgetRunnerWsUrl(), - authToken: routineIntelligence.ɵgetRunnerAuthToken(), -}); - -const routineRunner = createRoutineRunner({ - routineStore, - channelStore, - runTurn: createTurnRunner({ - intelligence: routineIntelligence, - runner: routineAgentRunner, - buildAgentFor, - }), -}); - -/** - * The runtime, and the two things beside it a hop needs. +/* + * The Intelligence-bound wiring, built ONCE — and only in intelligence mode. + * + * This is the guard the old single-mode comment promised: standalone leaves the + * routine runner off `createApp` entirely and mounts no chat runtime. * - * `agentFor` builds the addressed Bot exactly the way a person's run builds it, and `history` reads - * the conversation through the same client. Taken from here rather than assembled again, because a - * Bot built by parallel wiring drifts the first time one of these arguments changes, and the drift is - * invisible: it runs, and quietly holds different tools or a different role from the one the person - * is talking to. + * WHY THE IMPORTS ARE DYNAMIC. Standalone must not evaluate the runtime's import + * graph at all, and that is load-bearing rather than tidy: the graph reaches + * `@modelcontextprotocol/sdk`'s CommonJS SSE client, whose `require()` of the + * ESM-only `eventsource` package can crash Bun at import time depending on graph + * shape. A standalone boot has no reason to gamble on that; an intelligence boot + * loads exactly what it always loaded, just at this line instead of the top. + * + * One headless pair for the process, reused across firings: the runner opens a + * socket per run and holds no idle connection, but its `threads` map is per + * instance, and a runner per turn would fragment the already-running check that + * keeps two turns off one thread. See `routines/run-turn.ts`. */ -const copilotRuntime = mountCopilotRuntime( - config, - tenantPackage.model, - loadAgentsForActor, - resolveRuntimeModelApiKey, - identifyUser, - identifyActor, - stallGuard, - loadToolsForActor, - signRunForActor, - undefined, - loadVendors, - selectionForActor, - agentFetch, - /* - * What a Bot may reach past itself for: another Bot, and a person. Made per run and per person. - * - * Per person because which Bots may be reached is decided against the roster that person can - * see: a Bot must never be able to address one they cannot, or this becomes a way around agent - * visibility. Per run because the caps need to know how deep the chain already is and where an - * answer belongs, and both of those are the deployment's own statement about the run rather than - * anything the model can edit. - */ - (actorId) => async (botId, input) => { - const from = readRunAssertion( - (input.forwardedProps as { openbotRun?: unknown } | undefined) - ?.openbotRun, - config.keyEncryptionKey, - ); - const run = { - botId, - actorId, - runId: input.runId, - threadId: input.threadId, - depth: from?.depth ?? 0, - }; - /* - * The caps are checked BEFORE the grants query, not inside the tool that would discard it. - * - * `handoffTool` short-circuits on all three of these, but only after being handed a - * `hasSomebodyToAsk` that costs a query. So a deployment which switched the capability off - * still paid one grants read per run of every Bot, for a tool it was never going to be offered, - * and a run already at the cap paid it again. - */ - const couldHandOn = - config.handoff.maxDepth > 0 && - config.handoff.maxPerRun > 0 && - run.depth < config.handoff.maxDepth; +const intelligence = + config.runtime.mode === "intelligence" + ? await (async (runtime) => { + const [ + { CopilotKitIntelligence, IntelligenceAgentRunner }, + { mountCopilotRuntime, resolveRuntimeAgents }, + { createRuntimeAgentLoader }, + { createTurnRunner }, + ] = await Promise.all([ + import("@copilotkit/runtime/v2"), + import("./copilot"), + import("./agents/runtime-agents"), + import("./routines/run-turn"), + ]); + + const loadAgentsForActor = createRuntimeAgentLoader( + database, + agentVault, + config.managedAgent, + ); - const passing = couldHandOn - ? handoffTool({ - desk: handoffDesk, + const buildAgentFor = async ({ + ownerUserId, + agentId, + }: { + ownerUserId: string; + agentId: string; + }) => { + const actor = await actorFor(ownerUserId); + const agents = await resolveRuntimeAgents( + () => loadAgentsForActor(actor), + tenantPackage.model, + resolveRuntimeModelApiKey, + stallGuard, + loadToolsForActor(actor.id), + signRunForActor(actor.id), + config.computer ? COMPUTER_GUIDANCE : undefined, + loadVendors, + selectionForActor(actor.id), + agentFetch, + undefined, + // Only the Bot this routine names. Same reason as the hop delivery: the roster is still read in + // full so a Bot this owner cannot see is still absent, but the other Bots are neither built nor + // asked what they hold. + agentId, + ); + const agent = agents[agentId]; + if (!agent) { + /* + * Named, and raised rather than swallowed. The routine's Bot was deleted, or made private by + * somebody else, or the owner lost the role that could see it. The runner turns this into a + * failed run row with this sentence on it, the first failure is said once in the channel, and + * the fatigue rule switches the routine off after ten — which is exactly the right handling for + * a routine pointed at something that is not coming back. + */ + const error = new Error( + `That Bot is no longer registered, so this routine has nothing to run: ${agentId}.`, + ); + error.name = "RoutineBotNotRegistered"; + throw error; + } + return agent; + }; + + const routineIntelligence = new CopilotKitIntelligence({ + apiUrl: runtime.intelligence.apiUrl, + wsUrl: runtime.intelligence.gatewayWsUrl, + apiKey: runtime.intelligence.apiKey, + }); + const routineAgentRunner = new IntelligenceAgentRunner({ + url: routineIntelligence.ɵgetRunnerWsUrl(), + authToken: routineIntelligence.ɵgetRunnerAuthToken(), + }); + const routineRunner = createRoutineRunner({ + routineStore, + channelStore, + runTurn: createTurnRunner({ + intelligence: routineIntelligence, + runner: routineAgentRunner, + buildAgentFor, + }), + }); + + const copilotRuntime = mountCopilotRuntime( + config, + tenantPackage.model, + loadAgentsForActor, + resolveRuntimeModelApiKey, + identifyUser, + identifyActor, + stallGuard, + loadToolsForActor, + signRunForActor, + undefined, + loadVendors, + selectionForActor, + agentFetch, /* - * How deep this run already is comes from the assertion the deployment signed when it handed - * this work on. A run a person started carries none, and none means zero. + * What a Bot may reach past itself for: another Bot, and a person. Made per run and per person. * - * NOT `from.botId`. The assertion proves what this run is, and the Bot is whichever one the - * runtime is building right now: on a hop those agree, and taking the id from the signed - * value rather than from the build would let a stale assertion aim the next hop at the - * wrong Bot's grants. + * Per person because which Bots may be reached is decided against the roster that person can + * see: a Bot must never be able to address one they cannot, or this becomes a way around agent + * visibility. Per run because the caps need to know how deep the chain already is and where an + * answer belongs, and both of those are the deployment's own statement about the run rather than + * anything the model can edit. */ - from: run, - // Read now rather than at boot, so a grant made a minute ago counts and one revoked a - // minute ago stops counting. - hasSomebodyToAsk: - ( - await pluginStore - .botsReachableFrom(botId) - .catch(() => [] as string[]) - ).length > 0, - maxDepth: config.handoff.maxDepth, - maxPerRun: config.handoff.maxPerRun, - }) - : null; - /* - * The way to stop and ask is offered whether or not there is a Bot to hand to. - * - * It is the cheaper of the two and the one a Bot should reach for first: asking the person who - * is already in the conversation spends nothing and cannot be aimed anywhere they cannot see. - * A deployment that offered only the expensive exit would push every unanswerable question - * sideways into another run. - */ - const asking = escalationTool({ - from: run, - route: askTheirOwnPerson, - auditStore: bootAuditStore, - }); - return passing ? [passing, asking] : [asking]; - }, - // A run started or ended on a thread; light the channel it belongs to. Fire-and-forget, keyed by - // thread, and a scratch thread maps to no channel and signals nowhere. - (input) => { - void channelStore.signalBusy(input.threadId, input.busy).catch(() => {}); - }, -); + (actorId) => async (botId, input) => { + const from = readRunAssertion( + (input.forwardedProps as { openbotRun?: unknown } | undefined) + ?.openbotRun, + config.keyEncryptionKey, + ); + const run = { + botId, + actorId, + runId: input.runId, + threadId: input.threadId, + depth: from?.depth ?? 0, + }; + /* + * The caps are checked BEFORE the grants query, not inside the tool that would discard it. + * + * `handoffTool` short-circuits on all three of these, but only after being handed a + * `hasSomebodyToAsk` that costs a query. So a deployment which switched the capability off + * still paid one grants read per run of every Bot, for a tool it was never going to be offered, + * and a run already at the cap paid it again. + */ + const couldHandOn = + config.handoff.maxDepth > 0 && + config.handoff.maxPerRun > 0 && + run.depth < config.handoff.maxDepth; + + const passing = couldHandOn + ? handoffTool({ + desk: handoffDesk, + /* + * How deep this run already is comes from the assertion the deployment signed when it handed + * this work on. A run a person started carries none, and none means zero. + * + * NOT `from.botId`. The assertion proves what this run is, and the Bot is whichever one the + * runtime is building right now: on a hop those agree, and taking the id from the signed + * value rather than from the build would let a stale assertion aim the next hop at the + * wrong Bot's grants. + */ + from: run, + // Read now rather than at boot, so a grant made a minute ago counts and one revoked a + // minute ago stops counting. + hasSomebodyToAsk: + ( + await pluginStore + .botsReachableFrom(botId) + .catch(() => [] as string[]) + ).length > 0, + maxDepth: config.handoff.maxDepth, + maxPerRun: config.handoff.maxPerRun, + }) + : null; + /* + * The way to stop and ask is offered whether or not there is a Bot to hand to. + * + * It is the cheaper of the two and the one a Bot should reach for first: asking the person who + * is already in the conversation spends nothing and cannot be aimed anywhere they cannot see. + * A deployment that offered only the expensive exit would push every unanswerable question + * sideways into another run. + */ + const asking = escalationTool({ + from: run, + route: askTheirOwnPerson, + auditStore: bootAuditStore, + }); + return passing ? [passing, asking] : [asking]; + }, + // A run started or ended on a thread; light the channel it belongs to. Fire-and-forget, keyed by + // thread, and a scratch thread maps to no channel and signals nowhere. + (input) => { + void channelStore + .signalBusy(input.threadId, input.busy) + .catch(() => {}); + }, + ); + + return { + copilotRuntime, + routineRunner, + // The class itself, for the handoff delivery below: it must construct its + // runner from the runtime's own connection, and only this branch loaded it. + AgentRunner: IntelligenceAgentRunner, + }; + })(config.runtime) + : // Standalone: no chat runtime to mount. createApp leaves its routes off, so + // /api/copilotkit 404s by design rather than mounting a door that refuses. + undefined; + +const copilotRuntime = intelligence?.copilotRuntime; +const routineRunner = intelligence?.routineRunner; /** * Delivering hops, on every replica. @@ -854,7 +884,25 @@ const copilotRuntime = mountCopilotRuntime( */ let workOfferedListener: WorkOfferedListener | undefined; -if (config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0) { +if ( + config.handoff.maxDepth > 0 && + config.handoff.maxPerRun > 0 && + !intelligence +) { + // Configured on, but there is no runtime to deliver a hop through. Said out loud + // rather than silently ignored: the deployment asked for a capability standalone + // cannot provide. + console.warn( + "[handoff] handing work between Bots is configured on, but a standalone deployment has no runtime to deliver through; hops stay off.", + ); +} + +if ( + config.handoff.maxDepth > 0 && + config.handoff.maxPerRun > 0 && + intelligence && + copilotRuntime +) { const runner = createHandoffRunner({ queue: createWorkQueue(database), owner: `handoff/${process.env.HOSTNAME ?? randomUUID().slice(0, 8)}`, @@ -928,7 +976,7 @@ if (config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0) { // The same address and the same token the runtime uses. Assembling either from configuration // produced a runner every join was refused for, because the thread's active run is a lock the // platform issues rather than something an API key can claim. - runner: new IntelligenceAgentRunner( + runner: new intelligence.AgentRunner( copilotRuntime.runnerConnection(), ) as never, }), @@ -1043,8 +1091,9 @@ const app = createApp( ), createPackageStatusReader(database), // The runtime call: the model, per-actor agent loading, and the two identity - // functions are how a run is attributed to a person. - copilotRuntime.handler, + // functions are how a run is attributed to a person. Absent on a standalone + // deployment, which leaves the chat surface unmounted. + copilotRuntime?.handler, // The only path to an acting call. computerGateway, policyStore, diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 5b1d9ce2a..f5058afc0 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -89,9 +89,10 @@ describe("deployment configuration", () => { expect(config.auth).toBeUndefined(); }); - // The product does not have a mode without Intelligence, so each of these is a refusal to boot - // rather than a degraded capability. Named individually because a deployment that sets three of - // four is the likeliest real mistake, and the message has to say which one is missing. + // A partial set is still a refusal to boot rather than a degraded capability: three of four + // reads as somebody configuring Intelligence and getting it wrong, not as a choice. Named + // individually because that is the likeliest real mistake, and the message has to say which + // one is missing — and now also has to say that unsetting all four is the other valid shape. test.each([ "INTELLIGENCE_API_URL", "INTELLIGENCE_GATEWAY_WS_URL", @@ -103,20 +104,29 @@ describe("deployment configuration", () => { }; delete environment[name]; - expect(() => loadConfig(environment)).toThrow( - `CopilotKit Intelligence is required and is not configured. Missing: ${name}`, - ); + const attempt = () => loadConfig(environment); + expect(attempt).toThrow(`Missing: ${name}`); + expect(attempt).toThrow("unset all four to run standalone"); }); - test("refuses to start when Intelligence is absent entirely, rather than degrading", () => { - expect(() => - loadConfig({ - DATABASE_URL: baseEnvironment.DATABASE_URL, - KEY_ENCRYPTION_KEY: baseEnvironment.KEY_ENCRYPTION_KEY, - MANAGED_AGENT_AG_UI_URL: baseEnvironment.MANAGED_AGENT_AG_UI_URL, - MANAGED_AGENT_TOKEN: baseEnvironment.MANAGED_AGENT_TOKEN, - }), - ).toThrow("CopilotKit Intelligence is required and is not configured"); + test("boots standalone when Intelligence is absent entirely", () => { + // The reversal of the old "refuses rather than degrading" decision, made deliberately for + // the BitMind execution enclave (bit-mind #20 / ADR-0002): a deployment that never meant to + // configure Intelligence gets a working admin surface and no chat runtime, not a refusal. + // Nothing is degraded silently — the mode is stated, and the chat/thread/routine surfaces + // are unmounted rather than mounted-and-refusing. + const config = loadConfig({ + DATABASE_URL: baseEnvironment.DATABASE_URL, + KEY_ENCRYPTION_KEY: baseEnvironment.KEY_ENCRYPTION_KEY, + MANAGED_AGENT_AG_UI_URL: baseEnvironment.MANAGED_AGENT_AG_UI_URL, + MANAGED_AGENT_TOKEN: baseEnvironment.MANAGED_AGENT_TOKEN, + OPENBOT_SINGLE_USER: "true", + }); + + expect(config.runtime).toEqual({ + mode: "standalone", + durableHistory: false, + }); }); test("rejects incomplete OAuth client configuration", () => { diff --git a/server/tests/standalone-runtime.test.ts b/server/tests/standalone-runtime.test.ts new file mode 100644 index 000000000..1341345ad --- /dev/null +++ b/server/tests/standalone-runtime.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { createApp } from "../src/app"; +import { loadConfig } from "../src/config"; +import { mountCopilotRuntime } from "../src/copilot"; +import type { ThreadIdentity } from "../src/routing/thread-identity"; +import { testEnvironment } from "./support/environment"; + +/** + * The standalone runtime: what a deployment without the Intelligence contract serves, + * and — just as deliberately — what it does not. + * + * The rule under test comes from the enclave work (bit-mind #20): admin surfaces work, + * the chat runtime, threads and routines are unmounted so those paths 404 by design, + * and nothing is mounted that would refuse every call. A door that does not exist is + * the honest shape for a capability the deployment cannot have. + */ + +function standaloneEnvironment() { + const environment = testEnvironment({ OPENBOT_SINGLE_USER: "true" }); + delete environment.INTELLIGENCE_API_URL; + delete environment.INTELLIGENCE_GATEWAY_WS_URL; + delete environment.INTELLIGENCE_API_KEY; + delete environment.COPILOTKIT_LICENSE_TOKEN; + // Standalone needs no sign-in provider either: the enclave has no browser users. + // The auth secrets go with the provider — set without one, they are refused as a + // half-configuration, the same rule the Intelligence contract follows. + delete environment.GOOGLE_OAUTH_CLIENT_ID; + delete environment.GOOGLE_OAUTH_CLIENT_SECRET; + delete environment.INITIAL_ADMIN_EMAILS; + delete environment.BETTER_AUTH_SECRET; + delete environment.BETTER_AUTH_URL; + return environment; +} + +/** A thread namespace, present on purpose: the test proves the routes still stay off. */ +const threadIdentity: ThreadIdentity = { + mint: () => "thread-1", + owns: () => true, +}; + +describe("a standalone deployment", () => { + const config = loadConfig(standaloneEnvironment()); + + test("states its mode instead of pretending to a platform it has not got", async () => { + const app = createApp(config); + const response = await app.request("/api/capabilities"); + expect(response.status).toBe(200); + const body = (await response.json()) as { + mode: string; + durableHistory: boolean; + }; + expect(body.mode).toBe("standalone"); + expect(body.durableHistory).toBe(false); + }); + + test("health answers, because the process is genuinely up", async () => { + const app = createApp(config); + const response = await app.request("/health"); + expect(response.status).toBe(200); + }); + + test("the chat runtime is 404 by design, not mounted-and-refusing", async () => { + const app = createApp(config); + for (const path of ["/api/copilotkit/info", "/api/copilotkit"]) { + const response = await app.request(path); + expect(response.status).toBe(404); + } + }); + + test("threads stay unmounted even when a thread namespace exists", async () => { + const args: Parameters = [config]; + args[16] = threadIdentity; + const app = createApp(...args); + const response = await app.request("/api/threads/thread-1/status"); + // There is no Intelligence to ask about a thread, so there is no door to ask at. + expect(response.status).toBe(404); + }); + + test("mounting the chat runtime anyway is refused loudly", () => { + // The guard config.ts's old single-mode comment promised: if wiring ever tries to + // mount the runtime without the contract, it must fail in front of the deployer. + expect(() => + mountCopilotRuntime( + config, + { provider: "openai", model: "gpt-5.5" }, + () => Promise.resolve([]), + () => undefined, + () => Promise.resolve(null), + () => Promise.resolve({ id: "", role: "user" }), + { stallTimeoutMs: 0 }, + ), + ).toThrow("standalone"); + }); +}); From 884779ea621dc902dcb66b6bf3256f260191f399 Mon Sep 17 00:00:00 2001 From: Bit Cloud Date: Wed, 2 Sep 2026 17:39:39 +0000 Subject: [PATCH 2/3] fix: address review on standalone boot - Standalone is now an explicit choice: OPENBOT_RUNTIME_MODE=standalone, the same shape as OPENBOT_SINGLE_USER. Total absence of the Intelligence values without it stays a refusal to boot - a Kubernetes Secret that fails to mount must crash in front of somebody, not come up 'healthy' with the chat runtime quietly missing. Chosen standalone with Intelligence values set is refused as the contradiction it is. - The routine surface is withheld in standalone: createApp gets no routineStore, so /api/routines is unmounted and no schedule can be enabled for a worker to dispatch into a 404. Routine tools follow. - Handoff caps are zeroed at the source in standalone (warned when env explicitly set them), so the capability endpoint, the grant surface and the delivery loop all give the same answer instead of a warning only one of them reflected. - app.ts no longer imports the intelligence client: the thread reader is built inside index.ts's intelligence branch (dynamic imports) and injected as createApp's trailing optional parameter; the standalone suite's runtime-mount test now imports copilot.ts dynamically too, and a subprocess boot test drives the real src/index.ts module graph. - The Helm chart can render the mode: config.runtimeMode conditions the validation, the Secret's intelligence keys, and the env injection (OPENBOT_RUNTIME_MODE replaces the four INTELLIGENCE_* vars); a standalone ci values target plus a render test cover it, including the contradiction and routines refusals. Adding the target to the CI matrix needs the workflow permission and is left to a maintainer. --- charts/openbot/ci/standalone-values.yaml | 36 +++++++ charts/openbot/templates/_helpers.tpl | 5 + charts/openbot/templates/secret.yaml | 6 +- charts/openbot/templates/validation.yaml | 25 ++++- charts/openbot/values.yaml | 12 ++- server/src/app.ts | 31 ++++--- server/src/config.ts | 58 ++++++++++-- server/src/index.ts | 33 +++---- server/tests/config.test.ts | 52 +++++++++-- .../tests/standalone-boot.integration.test.ts | 71 ++++++++++++++ server/tests/standalone-runtime.test.ts | 20 +++- tests/helm-standalone.test.ts | 93 +++++++++++++++++++ 12 files changed, 382 insertions(+), 60 deletions(-) create mode 100644 charts/openbot/ci/standalone-values.yaml create mode 100644 server/tests/standalone-boot.integration.test.ts create mode 100644 tests/helm-standalone.test.ts diff --git a/charts/openbot/ci/standalone-values.yaml b/charts/openbot/ci/standalone-values.yaml new file mode 100644 index 000000000..c3f46daba --- /dev/null +++ b/charts/openbot/ci/standalone-values.yaml @@ -0,0 +1,36 @@ +# The BitMind execution enclave's shape: no Intelligence contract at all. +# +# The server boots with OPENBOT_RUNTIME_MODE=standalone — admin surfaces working, chat, threads +# and routines unmounted — so nothing here carries an Intelligence URL, key or licence, and the +# render must produce a Secret without those keys. Routines stay off: the standalone server has +# no runtime to hand a firing to. +# +# NOTE FOR CI: this target must be added to the chart job's matrix in +# .github/workflows/ci.yml (a change that needs the workflow permission). +config: + runtimeMode: standalone + initialAdminEmails: admin@example.com + auth: + google: + clientId: example.apps.googleusercontent.com + publicUrl: https://openbot.internal +postgresql: + enabled: true + auth: + # Yours to choose, and the same value on every upgrade. Rendering example only. + password: "example-for-rendering-only" +secrets: + # Sessions are signed with this. Rendering example only; generate one with: openssl rand -base64 32 + betterAuthSecret: "example-for-rendering-only-at-least-32-chars" + # keyEncryptionKey is supplied on the command line, like every target: + # --set-string secrets.keyEncryptionKey="$(openssl rand -base64 32)" + googleClientSecret: "example-for-rendering-only" + computerToken: "example-for-rendering-only" +ingress: + enabled: true + className: nginx + hosts: + - host: openbot.internal + paths: + - path: / + pathType: Prefix diff --git a/charts/openbot/templates/_helpers.tpl b/charts/openbot/templates/_helpers.tpl index 059653a62..faca8e9bb 100644 --- a/charts/openbot/templates/_helpers.tpl +++ b/charts/openbot/templates/_helpers.tpl @@ -202,6 +202,10 @@ and in whatever holds the release, which is not where `KEY_ENCRYPTION_KEY` belon value: {{ $maxDepth | quote }} - name: BOT_HANDOFF_MAX_PER_RUN value: {{ $maxPerRun | quote }} +{{- if eq (.Values.config.runtimeMode | default "intelligence") "standalone" }} +- name: OPENBOT_RUNTIME_MODE + value: "standalone" +{{- else }} - name: INTELLIGENCE_API_URL value: {{ .Values.config.intelligence.apiUrl | quote }} - name: INTELLIGENCE_GATEWAY_WS_URL @@ -216,6 +220,7 @@ and in whatever holds the release, which is not where `KEY_ENCRYPTION_KEY` belon secretKeyRef: name: {{ include "openbot.secretName" . }} key: license-token +{{- end }} {{- with .Values.config.managedAgent.url }} - name: MANAGED_AGENT_AG_UI_URL value: {{ . | quote }} diff --git a/charts/openbot/templates/secret.yaml b/charts/openbot/templates/secret.yaml index 4e9e60f2d..314c6d19f 100644 --- a/charts/openbot/templates/secret.yaml +++ b/charts/openbot/templates/secret.yaml @@ -21,8 +21,10 @@ metadata: type: Opaque stringData: key-encryption-key: {{ required "secrets.keyEncryptionKey is required unless secrets.existingSecret or externalSecrets is used. Generate one with: openssl rand -base64 32" .Values.secrets.keyEncryptionKey | quote }} - intelligence-api-key: {{ required "secrets.intelligenceApiKey is required. OpenBot needs CopilotKit Intelligence and refuses to start without it." .Values.secrets.intelligenceApiKey | quote }} - license-token: {{ required "secrets.licenseToken is required. OpenBot needs CopilotKit Intelligence and refuses to start without it." .Values.secrets.licenseToken | quote }} + {{- if ne (.Values.config.runtimeMode | default "intelligence") "standalone" }} + intelligence-api-key: {{ required "secrets.intelligenceApiKey is required. The intelligence runtime refuses to start without it; set config.runtimeMode=standalone to run without Intelligence." .Values.secrets.intelligenceApiKey | quote }} + license-token: {{ required "secrets.licenseToken is required. The intelligence runtime refuses to start without it; set config.runtimeMode=standalone to run without Intelligence." .Values.secrets.licenseToken | quote }} + {{- end }} {{- with .Values.secrets.betterAuthSecret }} better-auth-secret: {{ . | quote }} {{- end }} diff --git a/charts/openbot/templates/validation.yaml b/charts/openbot/templates/validation.yaml index 08f2e811c..ce8ddd0c6 100644 --- a/charts/openbot/templates/validation.yaml +++ b/charts/openbot/templates/validation.yaml @@ -68,14 +68,29 @@ This template renders nothing. {{- end }} {{- /* - Intelligence, which is not optional. + The runtime, chosen explicitly. - All four values are required together and the server refuses to start on a partial set, so the - same rule is applied here: caught at install with the values named, rather than in a crash loop - whose message is in a log nobody has opened. + "intelligence" requires the full contract — all four values together, the server refuses a + partial set, so the same rule is applied here: caught at install with the values named, rather + than in a crash loop whose message is in a log nobody has opened. "standalone" requires the + opposite: Intelligence values present alongside it are the contradiction the server also + refuses, caught here first. */}} +{{- $mode := .Values.config.runtimeMode | default "intelligence" }} +{{- if and (ne $mode "intelligence") (ne $mode "standalone") }} +{{- fail (printf "config.runtimeMode=%s is not a mode. Use intelligence or standalone." $mode) }} +{{- end }} +{{- if eq $mode "standalone" }} +{{- if or .Values.config.intelligence.apiUrl .Values.config.intelligence.gatewayWsUrl .Values.secrets.intelligenceApiKey .Values.secrets.licenseToken }} +{{- fail "config.runtimeMode=standalone contradicts the Intelligence values that are set. Unset config.intelligence.* and secrets.intelligenceApiKey/licenseToken, or drop the mode." }} +{{- end }} +{{- if (.Values.routines).enabled }} +{{- fail "routines.enabled needs the intelligence runtime: a standalone server unmounts the routine surface, and a CronJob whose every dispatch is a 404 is worse than none. Disable routines, or drop config.runtimeMode=standalone." }} +{{- end }} +{{- else }} {{- if or (not .Values.config.intelligence.apiUrl) (not .Values.config.intelligence.gatewayWsUrl) }} -{{- fail "OpenBot requires CopilotKit Intelligence. Set config.intelligence.apiUrl and config.intelligence.gatewayWsUrl, and the matching secrets.intelligenceApiKey and secrets.licenseToken." }} +{{- fail "The intelligence runtime requires CopilotKit Intelligence. Set config.intelligence.apiUrl and config.intelligence.gatewayWsUrl, and the matching secrets.intelligenceApiKey and secrets.licenseToken — or set config.runtimeMode=standalone." }} +{{- end }} {{- end }} {{- /* diff --git a/charts/openbot/values.yaml b/charts/openbot/values.yaml index 7bd6f2cc3..f6cf58233 100644 --- a/charts/openbot/values.yaml +++ b/charts/openbot/values.yaml @@ -141,9 +141,15 @@ config: issuer: "" tenantPackageDir: /app/examples/fintech - # CopilotKit Intelligence, which OpenBot requires. All four values are needed together: the server - # refuses to start on a partial set, deliberately, because a half-configured Intelligence is a - # mistake somebody made rather than a deployment that meant to run without one. + # Which runtime this deployment is. "intelligence" (the default) requires the full CopilotKit + # Intelligence contract below. "standalone" runs without it — admin surfaces working, chat, + # threads and routines unmounted — the shape the BitMind execution enclave uses. Explicit, + # never inferred: a Secret that failed to mount must crash the server, not look like a choice. + runtimeMode: "intelligence" + + # CopilotKit Intelligence, which the intelligence runtime requires. All four values are needed + # together: the server refuses to start on a partial set, deliberately, because a half-configured + # Intelligence is a mistake somebody made rather than a deployment that meant to run without one. intelligence: apiUrl: "" gatewayWsUrl: "" diff --git a/server/src/app.ts b/server/src/app.ts index e1f7b5dba..0c066132a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -23,8 +23,10 @@ import type { IdentityProviderStore } from "./auth/identity-provider-store"; import type { ChannelEventHub } from "./channels/events"; import { type ChannelStore, createChannelRoutes } from "./channels/routes"; import type { ThreadIdentity } from "./channels/thread-identity"; -import { createThreadRoutes } from "./channels/thread-routes"; -import { createThreadReader } from "./channels/thread-status"; +import { + createThreadRoutes, + type ThreadReader, +} from "./channels/thread-routes"; import { createComponentRoutes } from "./components/routes"; import type { SandboxedStore } from "./components/sandboxed"; import { createSandboxedRoutes } from "./components/sandboxed-routes"; @@ -35,7 +37,6 @@ import type { PolicyStore } from "./computer/policy-store"; import { createComputerRoutes } from "./computer/routes"; import { configuredAuthProviders, type DeploymentConfig } from "./config"; import type { CredentialAdminService, CredentialInput } from "./credentials"; -import { createIntelligenceClient } from "./intelligence-client"; import type { OnboardingStore } from "./people/onboarding"; import type { PeopleStore } from "./people/store"; import { createPluginRoutes } from "./plugins/routes"; @@ -202,6 +203,17 @@ export function createApp( * nothing can finish. */ onboardingStore?: OnboardingStore, + /** + * How a thread's continued existence is checked, built by whoever holds the + * Intelligence client. Appended last, like everything optional here: these are + * positional, and inserting one anywhere else silently shifts every call site. + * + * Absent leaves the thread routes unmounted rather than mounted and refusing — a + * standalone deployment has no Intelligence to ask about a thread, so it has no + * door for the question at all. It also keeps this module free of the runtime's + * import graph, which standalone must never evaluate. + */ + threadReader?: ThreadReader, ) { const app = new Hono<{ Variables: AppVariables }>(); @@ -1046,19 +1058,10 @@ export function createApp( ); } - if (threadIdentity && config.runtime.mode === "intelligence") { + if (threadIdentity && threadReader) { app.route( "/api/threads", - createThreadRoutes( - threadIdentity, - requireUser, - // The guarantee this comment once leaned on has loosened: a standalone deployment - // has no Intelligence to ask about a thread, so it has no reader to build and the - // routes stay unmounted — threads 404 by design there, like the chat runtime. - createThreadReader( - createIntelligenceClient(config.runtime.intelligence), - ), - ), + createThreadRoutes(threadIdentity, requireUser, threadReader), ); } diff --git a/server/src/config.ts b/server/src/config.ts index 180ed4592..bd9d5dc91 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -569,15 +569,27 @@ function oktaAuth( } /** - * Resolve the Intelligence contract, choose standalone, or refuse to start. + * Resolve the Intelligence contract, or the explicitly chosen standalone mode, or refuse. * - * All four values are required together. A partial set is the more dangerous shape than none at all: - * it means somebody intended to configure Intelligence and got it wrong, so it is still a refusal to - * boot that names what is missing. All four absent is different: it is a deployment that never meant - * to configure Intelligence, and it boots standalone — admin working, chat and threads unmounted — - * which is the shape the BitMind execution enclave runs. + * All four Intelligence values are required together; any missing value is a refusal to boot that + * names what is absent. That includes ALL of them being absent: a Kubernetes Secret that failed to + * mount makes all four disappear at once, and a deployment that silently came up "healthy" with the + * chat runtime missing would turn a secret outage into a mystery. Standalone is therefore an + * explicit choice — `OPENBOT_RUNTIME_MODE=standalone` — the same shape as `OPENBOT_SINGLE_USER`: + * the deployment says it meant it. Chosen standalone with Intelligence values also set is refused + * as the contradiction it is. */ function runtimeCapabilities(environment: Environment): RuntimeCapabilities { + const chosen = optional(environment, "OPENBOT_RUNTIME_MODE"); + if ( + chosen !== undefined && + chosen !== "standalone" && + chosen !== "intelligence" + ) { + throw new Error( + `OPENBOT_RUNTIME_MODE=${chosen} is not a mode. Use standalone or intelligence, or unset it.`, + ); + } const settings = { apiUrl: url(environment, "INTELLIGENCE_API_URL"), gatewayWsUrl: url(environment, "INTELLIGENCE_GATEWAY_WS_URL"), @@ -594,12 +606,17 @@ function runtimeCapabilities(environment: Environment): RuntimeCapabilities { .filter(([, value]) => !value) .map(([name]) => name); - if (missing.length === 4) { + if (chosen === "standalone") { + if (missing.length < 4) { + throw new Error( + "OPENBOT_RUNTIME_MODE=standalone contradicts the Intelligence values that are set. Unset the INTELLIGENCE_* / COPILOTKIT_LICENSE_TOKEN values, or drop the mode.", + ); + } return { mode: "standalone", durableHistory: false }; } if (missing.length > 0) { throw new Error( - `CopilotKit Intelligence is partially configured, which reads as a mistake rather than a choice. Missing: ${missing.join(", ")}. Set all four, or unset all four to run standalone.`, + `CopilotKit Intelligence is not fully configured. Missing: ${missing.join(", ")}. Set all four, or set OPENBOT_RUNTIME_MODE=standalone to run without it.`, ); } @@ -879,6 +896,27 @@ export function loadConfig( const auth = authConfig(environment, google); const managedAgent = managedAgentConfig(environment); const workerSharedSecret = optional(environment, "WORKER_SHARED_SECRET"); + const runtime = runtimeCapabilities(environment); + // Zeroed HERE, where every consumer reads it, rather than warned about where only + // one does: the capability endpoint, the grant surface and the delivery loop all + // derive "may Bots hand work off" from these caps, and a standalone deployment has + // no runtime to deliver a hop through. Zeroing at the source keeps every one of + // those answers the same. Said out loud when somebody explicitly asked for it. + let handoff = handoffCaps(environment); + if ( + runtime.mode === "standalone" && + (handoff.maxDepth > 0 || handoff.maxPerRun > 0) + ) { + if ( + optional(environment, "BOT_HANDOFF_MAX_DEPTH") !== undefined || + optional(environment, "BOT_HANDOFF_MAX_PER_RUN") !== undefined + ) { + console.warn( + "BOT_HANDOFF_* is set, but a standalone deployment has no runtime to deliver a hop through; handing work between Bots is off.", + ); + } + handoff = { maxDepth: 0, maxPerRun: 0 }; + } return { databaseUrl: required(environment, "DATABASE_URL"), @@ -897,7 +935,7 @@ export function loadConfig( )?.replace(/\/+$/, ""), tenantPackageDirectory: optional(environment, "TENANT_PACKAGE_DIR") ?? "../examples/fintech", - runtime: runtimeCapabilities(environment), + runtime, agentStallTimeoutMs: agentStallTimeoutMs(environment), auditRetentionDays: auditRetentionDays(environment), oauth: { google }, @@ -912,7 +950,7 @@ export function loadConfig( ? { appDistDir: optional(environment, "APP_DIST_DIR") as string } : {}), computer: computerConfig(environment), - handoff: handoffCaps(environment), + handoff, ...(optional(environment, "AGENT_TOOL_TOKEN") ? { agentToolToken: optional(environment, "AGENT_TOOL_TOKEN") as string } : {}), diff --git a/server/src/index.ts b/server/src/index.ts index ac8ebb40b..ab4ac6041 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -319,7 +319,7 @@ const pluginStore = createPluginStore({ * silent outage for this one. */ const routineStore = createRoutineStore(database); -useRoutineTools(routineStore); +if (config.runtime.mode === "intelligence") useRoutineTools(routineStore); /** * Where a Bot handing work to another gets decided. @@ -670,11 +670,15 @@ const intelligence = { mountCopilotRuntime, resolveRuntimeAgents }, { createRuntimeAgentLoader }, { createTurnRunner }, + { createIntelligenceClient }, + { createThreadReader }, ] = await Promise.all([ import("@copilotkit/runtime/v2"), import("./copilot"), import("./agents/runtime-agents"), import("./routines/run-turn"), + import("./intelligence-client"), + import("./channels/thread-status"), ]); const loadAgentsForActor = createRuntimeAgentLoader( @@ -849,6 +853,12 @@ const intelligence = // The class itself, for the handoff delivery below: it must construct its // runner from the runtime's own connection, and only this branch loaded it. AgentRunner: IntelligenceAgentRunner, + // Thread status is a question only Intelligence can answer, so the reader + // is built here and handed to createApp — which mounts no thread routes + // without one, keeping app.ts free of the runtime's import graph. + threadReader: createThreadReader( + createIntelligenceClient(runtime.intelligence), + ), }; })(config.runtime) : // Standalone: no chat runtime to mount. createApp leaves its routes off, so @@ -884,19 +894,6 @@ const routineRunner = intelligence?.routineRunner; */ let workOfferedListener: WorkOfferedListener | undefined; -if ( - config.handoff.maxDepth > 0 && - config.handoff.maxPerRun > 0 && - !intelligence -) { - // Configured on, but there is no runtime to deliver a hop through. Said out loud - // rather than silently ignored: the deployment asked for a capability standalone - // cannot provide. - console.warn( - "[handoff] handing work between Bots is configured on, but a standalone deployment has no runtime to deliver through; hops stay off.", - ); -} - if ( config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0 && @@ -1123,10 +1120,14 @@ const app = createApp( pageFrameStore, // What a due routine actually does: a turn, run as its owner, into the thread they will open. routineRunner, - // A person's own standing instructions: the list, and a switch to stop one. - routineStore, + // A person's own standing instructions: the list, and a switch to stop one. Withheld + // in standalone: a schedule that can be enabled but never runs is a lie with a UI, + // and the worker would dispatch every due run into a 404. + intelligence ? routineStore : undefined, // Where each person is in first-run onboarding, read by /api/me and written by the wizard. createOnboardingStore(database), + // Present only when Intelligence is: threads are its conversations. + intelligence?.threadReader, ); /** diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index f5058afc0..9f0c248c4 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -106,21 +106,35 @@ describe("deployment configuration", () => { const attempt = () => loadConfig(environment); expect(attempt).toThrow(`Missing: ${name}`); - expect(attempt).toThrow("unset all four to run standalone"); + expect(attempt).toThrow("OPENBOT_RUNTIME_MODE=standalone"); }); - test("boots standalone when Intelligence is absent entirely", () => { - // The reversal of the old "refuses rather than degrading" decision, made deliberately for - // the BitMind execution enclave (bit-mind #20 / ADR-0002): a deployment that never meant to - // configure Intelligence gets a working admin surface and no chat runtime, not a refusal. - // Nothing is degraded silently — the mode is stated, and the chat/thread/routine surfaces - // are unmounted rather than mounted-and-refusing. + test("refuses to start when Intelligence is absent and standalone was not chosen", () => { + // A Kubernetes Secret that fails to mount makes all four values disappear at once. + // That must stay a crash in front of somebody, not a "healthy" deployment quietly + // missing its chat runtime — so total absence without the explicit mode refuses too. + expect(() => + loadConfig({ + DATABASE_URL: baseEnvironment.DATABASE_URL, + KEY_ENCRYPTION_KEY: baseEnvironment.KEY_ENCRYPTION_KEY, + MANAGED_AGENT_AG_UI_URL: baseEnvironment.MANAGED_AGENT_AG_UI_URL, + MANAGED_AGENT_TOKEN: baseEnvironment.MANAGED_AGENT_TOKEN, + OPENBOT_SINGLE_USER: "true", + }), + ).toThrow("CopilotKit Intelligence is not fully configured"); + }); + + test("boots standalone when the deployment says it means it", () => { + // The mode the BitMind execution enclave runs (bit-mind #20 / ADR-0002): admin + // working, chat and threads unmounted. Explicit — the same shape as + // OPENBOT_SINGLE_USER — so a secret outage can never look like a choice. const config = loadConfig({ DATABASE_URL: baseEnvironment.DATABASE_URL, KEY_ENCRYPTION_KEY: baseEnvironment.KEY_ENCRYPTION_KEY, MANAGED_AGENT_AG_UI_URL: baseEnvironment.MANAGED_AGENT_AG_UI_URL, MANAGED_AGENT_TOKEN: baseEnvironment.MANAGED_AGENT_TOKEN, OPENBOT_SINGLE_USER: "true", + OPENBOT_RUNTIME_MODE: "standalone", }); expect(config.runtime).toEqual({ @@ -129,6 +143,30 @@ describe("deployment configuration", () => { }); }); + test("standalone chosen with Intelligence values set is the contradiction it looks like", () => { + expect(() => + loadConfig({ ...baseEnvironment, OPENBOT_RUNTIME_MODE: "standalone" }), + ).toThrow("contradicts"); + }); + + test("standalone zeroes the handoff caps every consumer reads", () => { + // The capability endpoint, the grant surface and the delivery loop all derive + // "may Bots hand work off" from these caps; zeroing at the source keeps every + // answer the same, instead of a warning only one of them would reflect. + const config = loadConfig({ + DATABASE_URL: baseEnvironment.DATABASE_URL, + KEY_ENCRYPTION_KEY: baseEnvironment.KEY_ENCRYPTION_KEY, + MANAGED_AGENT_AG_UI_URL: baseEnvironment.MANAGED_AGENT_AG_UI_URL, + MANAGED_AGENT_TOKEN: baseEnvironment.MANAGED_AGENT_TOKEN, + OPENBOT_SINGLE_USER: "true", + OPENBOT_RUNTIME_MODE: "standalone", + BOT_HANDOFF_MAX_DEPTH: "2", + BOT_HANDOFF_MAX_PER_RUN: "5", + }); + + expect(config.handoff).toEqual({ maxDepth: 0, maxPerRun: 0 }); + }); + test("rejects incomplete OAuth client configuration", () => { expect(() => loadConfig({ diff --git a/server/tests/standalone-boot.integration.test.ts b/server/tests/standalone-boot.integration.test.ts new file mode 100644 index 000000000..7815f6df5 --- /dev/null +++ b/server/tests/standalone-boot.integration.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test"; + +/** + * The invariant the focused suite cannot prove: `src/index.ts` itself — the real + * module graph, not a route table — boots in standalone mode. The runtime's import + * chain has crashed Bun at import time before (see the dynamic-import note in + * index.ts), and only an actual subprocess boot exercises the graph a deployment + * loads. Spawned with `cwd: server/` so the repository's own `.env` cannot leak + * Intelligence values into what is meant to be a bare environment. + */ +const databaseUrl = process.env.DATABASE_URL; + +test("src/index.ts boots standalone as a real process", { + skip: databaseUrl ? false : "DATABASE_URL is not set", + timeout: 60_000, +}, async () => { + const port = 40_000 + Math.floor(Math.random() * 20_000); + const child = Bun.spawn(["bun", "src/index.ts"], { + cwd: new URL("..", import.meta.url).pathname, + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "", + DATABASE_URL: databaseUrl ?? "", + KEY_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + OPENBOT_SINGLE_USER: "true", + OPENBOT_RUNTIME_MODE: "standalone", + MANAGED_AGENT_AG_UI_URL: "http://localhost:4201/ag-ui", + MANAGED_AGENT_TOKEN: "standalone-boot-test-token", + PORT: String(port), + NODE_ENV: "development", + }, + stdout: "pipe", + stderr: "pipe", + }); + + try { + const deadline = Date.now() + 45_000; + let up = false; + while (Date.now() < deadline) { + if (child.killed) break; + try { + const health = await fetch(`http://127.0.0.1:${String(port)}/health`); + if (health.ok) { + up = true; + break; + } + } catch { + // Not listening yet. + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (!up) { + const stderr = await new Response(child.stderr).text(); + throw new Error(`the server never came up:\n${stderr.slice(-2_000)}`); + } + + const capabilities = (await ( + await fetch(`http://127.0.0.1:${String(port)}/api/capabilities`) + ).json()) as { mode: string; durableHistory: boolean }; + expect(capabilities.mode).toBe("standalone"); + expect(capabilities.durableHistory).toBe(false); + + const chat = await fetch( + `http://127.0.0.1:${String(port)}/api/copilotkit/info`, + ); + expect(chat.status).toBe(404); + } finally { + child.kill(); + await child.exited; + } +}); diff --git a/server/tests/standalone-runtime.test.ts b/server/tests/standalone-runtime.test.ts index 1341345ad..c19200074 100644 --- a/server/tests/standalone-runtime.test.ts +++ b/server/tests/standalone-runtime.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createApp } from "../src/app"; import { loadConfig } from "../src/config"; -import { mountCopilotRuntime } from "../src/copilot"; import type { ThreadIdentity } from "../src/routing/thread-identity"; import { testEnvironment } from "./support/environment"; @@ -16,7 +15,10 @@ import { testEnvironment } from "./support/environment"; */ function standaloneEnvironment() { - const environment = testEnvironment({ OPENBOT_SINGLE_USER: "true" }); + const environment = testEnvironment({ + OPENBOT_SINGLE_USER: "true", + OPENBOT_RUNTIME_MODE: "standalone", + }); delete environment.INTELLIGENCE_API_URL; delete environment.INTELLIGENCE_GATEWAY_WS_URL; delete environment.INTELLIGENCE_API_KEY; @@ -76,9 +78,21 @@ describe("a standalone deployment", () => { expect(response.status).toBe(404); }); - test("mounting the chat runtime anyway is refused loudly", () => { + test("routines stay unmounted: a schedule that can never run must not be enableable", async () => { + // index.ts withholds the routine store in standalone; this proves the shape that + // wiring produces. A mounted management surface would let somebody enable an + // existing schedule the worker then dispatches into a 404, forever. + const app = createApp(config); + const response = await app.request("/api/routines"); + expect(response.status).toBe(404); + }); + + test("mounting the chat runtime anyway is refused loudly", async () => { // The guard config.ts's old single-mode comment promised: if wiring ever tries to // mount the runtime without the contract, it must fail in front of the deployer. + // Imported dynamically, the way index.ts loads it: a static import here would + // couple this very suite to the runtime graph standalone exists to avoid. + const { mountCopilotRuntime } = await import("../src/copilot"); expect(() => mountCopilotRuntime( config, diff --git a/tests/helm-standalone.test.ts b/tests/helm-standalone.test.ts new file mode 100644 index 000000000..d264da299 --- /dev/null +++ b/tests/helm-standalone.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; + +/** + * The standalone chart target, rendered and held to its refusals. + * + * CI's chart job renders each `charts/openbot/ci/*-values.yaml` target through its + * workflow matrix; until `standalone` is added there (a change that needs the + * workflow permission), this test is the render coverage for the new mode — and it + * remains the local answer to "does the chart still refuse what it should" either way. + * + * Skips when helm is not installed, and when the chart's dependencies cannot be + * fetched (an offline machine), rather than failing on tooling the change did not touch. + */ + +const helm = Bun.which("helm"); + +async function run( + command: string[], +): Promise<{ ok: boolean; output: string }> { + const child = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + const status = await child.exited; + return { ok: status === 0, output: stdout + stderr }; +} + +const KEY = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE="; + +function template( + ...extra: string[] +): Promise<{ ok: boolean; output: string }> { + return run([ + helm ?? "helm", + "template", + "ci", + "charts/openbot", + "--values", + "charts/openbot/ci/standalone-values.yaml", + "--set-string", + `secrets.keyEncryptionKey=${KEY}`, + "--api-versions", + "agents.x-k8s.io/v1beta1/Sandbox", + "--api-versions", + "extensions.agents.x-k8s.io/v1beta1/SandboxTemplate", + ...extra, + ]); +} + +test("the standalone chart target renders without Intelligence and refuses contradictions", { + skip: helm ? false : "helm is not installed", + timeout: 120_000, +}, async () => { + const dependencies = await run([ + helm ?? "helm", + "dependency", + "build", + "charts/openbot", + ]); + if (!dependencies.ok) { + // Offline: fetching the postgresql subchart is the part that failed, and it is + // not what this test is about. + console.warn("helm dependency build failed; skipping chart render test"); + return; + } + + const rendered = await template(); + expect(rendered.ok).toBe(true); + // The server is told its mode, and nothing Intelligence-shaped survives into the + // manifests: no env pointing at the platform, no secret keys for it. + expect(rendered.output).toContain("OPENBOT_RUNTIME_MODE"); + expect(rendered.output).not.toContain("INTELLIGENCE_API_URL"); + expect(rendered.output).not.toContain("intelligence-api-key"); + expect(rendered.output).not.toContain("license-token"); + + // Standalone alongside Intelligence values is the contradiction the server also + // refuses; the chart catches it at install instead of in a crash loop. + const contradiction = await template( + "--set", + "config.intelligence.apiUrl=https://api.example", + ); + expect(contradiction.ok).toBe(false); + + // A routines CronJob whose every dispatch is a 404 must not render. + const routines = await template( + "--set", + "routines.enabled=true", + "--set", + "secrets.workerSharedSecret=example", + ); + expect(routines.ok).toBe(false); +}); From 27f1edc0476384ac2a64fe86cada480e327db4e8 Mon Sep 17 00:00:00 2001 From: Bit Cloud Date: Wed, 2 Sep 2026 18:12:23 +0000 Subject: [PATCH 3/3] fix: make the skips real, guard extraEnv, and probe the wiring itself - Both prerequisite skips used Bun's skip option with a reason string, which is not treated as true - the tests ran and failed on ENOENT / an empty DATABASE_URL exactly as the review reproduced. Now test.skipIf, and both verified to genuinely skip without their prerequisite. - config.extraEnv renders after the generated variables and the last name wins - deliberately, except for the variables that define which runtime the pod IS. Standalone validation now refuses extraEnv entries naming OPENBOT_RUNTIME_MODE or the four Intelligence variables, so the escape hatch cannot boot a pod in a mode the chart never validated. Harmless extraEnv entries still render; both directions tested. - The subprocess boot test now probes /api/threads and /api/routines on the spawned server: the focused route tests build createApp without those collaborators themselves, so only this proves index.ts's real wiring withholds them. --- charts/openbot/templates/validation.yaml | 11 ++ .../tests/standalone-boot.integration.test.ts | 124 ++++++++++-------- tests/helm-standalone.test.ts | 102 ++++++++------ 3 files changed, 143 insertions(+), 94 deletions(-) diff --git a/charts/openbot/templates/validation.yaml b/charts/openbot/templates/validation.yaml index ce8ddd0c6..7600f4791 100644 --- a/charts/openbot/templates/validation.yaml +++ b/charts/openbot/templates/validation.yaml @@ -87,6 +87,17 @@ This template renders nothing. {{- if (.Values.routines).enabled }} {{- fail "routines.enabled needs the intelligence runtime: a standalone server unmounts the routine surface, and a CronJob whose every dispatch is a 404 is worse than none. Disable routines, or drop config.runtimeMode=standalone." }} {{- end }} +{{- /* + The escape hatch must not smuggle the mode back in. `config.extraEnv` renders after the + generated variables and the last duplicate name wins — deliberately, for every variable + except the ones that define which runtime this pod IS. An extraEnv entry naming one of + those would boot a pod in a mode this validation never saw. +*/}} +{{- range .Values.config.extraEnv }} +{{- if has .name (list "OPENBOT_RUNTIME_MODE" "INTELLIGENCE_API_URL" "INTELLIGENCE_GATEWAY_WS_URL" "INTELLIGENCE_API_KEY" "COPILOTKIT_LICENSE_TOKEN") }} +{{- fail (printf "config.extraEnv must not set %s in standalone: it would override the mode this chart validated. Configure the runtime through config.runtimeMode and config.intelligence.* instead." .name) }} +{{- end }} +{{- end }} {{- else }} {{- if or (not .Values.config.intelligence.apiUrl) (not .Values.config.intelligence.gatewayWsUrl) }} {{- fail "The intelligence runtime requires CopilotKit Intelligence. Set config.intelligence.apiUrl and config.intelligence.gatewayWsUrl, and the matching secrets.intelligenceApiKey and secrets.licenseToken — or set config.runtimeMode=standalone." }} diff --git a/server/tests/standalone-boot.integration.test.ts b/server/tests/standalone-boot.integration.test.ts index 7815f6df5..f7e49d53b 100644 --- a/server/tests/standalone-boot.integration.test.ts +++ b/server/tests/standalone-boot.integration.test.ts @@ -10,62 +10,78 @@ import { expect, test } from "bun:test"; */ const databaseUrl = process.env.DATABASE_URL; -test("src/index.ts boots standalone as a real process", { - skip: databaseUrl ? false : "DATABASE_URL is not set", - timeout: 60_000, -}, async () => { - const port = 40_000 + Math.floor(Math.random() * 20_000); - const child = Bun.spawn(["bun", "src/index.ts"], { - cwd: new URL("..", import.meta.url).pathname, - env: { - PATH: process.env.PATH ?? "", - HOME: process.env.HOME ?? "", - DATABASE_URL: databaseUrl ?? "", - KEY_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - OPENBOT_SINGLE_USER: "true", - OPENBOT_RUNTIME_MODE: "standalone", - MANAGED_AGENT_AG_UI_URL: "http://localhost:4201/ag-ui", - MANAGED_AGENT_TOKEN: "standalone-boot-test-token", - PORT: String(port), - NODE_ENV: "development", - }, - stdout: "pipe", - stderr: "pipe", - }); +// skipIf, not a `skip` string: Bun treats a reason string as falsy configuration and +// runs the test anyway — reproduced as a child process crashing on an empty +// DATABASE_URL where a skip was intended. +test.skipIf(!databaseUrl)( + "src/index.ts boots standalone as a real process", + { timeout: 60_000 }, + async () => { + const port = 40_000 + Math.floor(Math.random() * 20_000); + const child = Bun.spawn(["bun", "src/index.ts"], { + cwd: new URL("..", import.meta.url).pathname, + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "", + DATABASE_URL: databaseUrl ?? "", + KEY_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + OPENBOT_SINGLE_USER: "true", + OPENBOT_RUNTIME_MODE: "standalone", + MANAGED_AGENT_AG_UI_URL: "http://localhost:4201/ag-ui", + MANAGED_AGENT_TOKEN: "standalone-boot-test-token", + PORT: String(port), + NODE_ENV: "development", + }, + stdout: "pipe", + stderr: "pipe", + }); - try { - const deadline = Date.now() + 45_000; - let up = false; - while (Date.now() < deadline) { - if (child.killed) break; - try { - const health = await fetch(`http://127.0.0.1:${String(port)}/health`); - if (health.ok) { - up = true; - break; + try { + const deadline = Date.now() + 45_000; + let up = false; + while (Date.now() < deadline) { + if (child.killed) break; + try { + const health = await fetch(`http://127.0.0.1:${String(port)}/health`); + if (health.ok) { + up = true; + break; + } + } catch { + // Not listening yet. } - } catch { - // Not listening yet. + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (!up) { + const stderr = await new Response(child.stderr).text(); + throw new Error(`the server never came up:\n${stderr.slice(-2_000)}`); } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - if (!up) { - const stderr = await new Response(child.stderr).text(); - throw new Error(`the server never came up:\n${stderr.slice(-2_000)}`); - } - const capabilities = (await ( - await fetch(`http://127.0.0.1:${String(port)}/api/capabilities`) - ).json()) as { mode: string; durableHistory: boolean }; - expect(capabilities.mode).toBe("standalone"); - expect(capabilities.durableHistory).toBe(false); + const capabilities = (await ( + await fetch(`http://127.0.0.1:${String(port)}/api/capabilities`) + ).json()) as { mode: string; durableHistory: boolean }; + expect(capabilities.mode).toBe("standalone"); + expect(capabilities.durableHistory).toBe(false); - const chat = await fetch( - `http://127.0.0.1:${String(port)}/api/copilotkit/info`, - ); - expect(chat.status).toBe(404); - } finally { - child.kill(); - await child.exited; - } -}); + const chat = await fetch( + `http://127.0.0.1:${String(port)}/api/copilotkit/info`, + ); + expect(chat.status).toBe(404); + + // The two wiring fixes, proven against the REAL index.ts collaborators: the + // focused route tests build createApp without a thread reader or routine store + // themselves, so they would still pass if index.ts accidentally supplied either. + const threads = await fetch( + `http://127.0.0.1:${String(port)}/api/threads/thread-1/status`, + ); + expect(threads.status).toBe(404); + const routines = await fetch( + `http://127.0.0.1:${String(port)}/api/routines`, + ); + expect(routines.status).toBe(404); + } finally { + child.kill(); + await child.exited; + } + }, +); diff --git a/tests/helm-standalone.test.ts b/tests/helm-standalone.test.ts index d264da299..697800ba7 100644 --- a/tests/helm-standalone.test.ts +++ b/tests/helm-standalone.test.ts @@ -48,46 +48,68 @@ function template( ]); } -test("the standalone chart target renders without Intelligence and refuses contradictions", { - skip: helm ? false : "helm is not installed", - timeout: 120_000, -}, async () => { - const dependencies = await run([ - helm ?? "helm", - "dependency", - "build", - "charts/openbot", - ]); - if (!dependencies.ok) { - // Offline: fetching the postgresql subchart is the part that failed, and it is - // not what this test is about. - console.warn("helm dependency build failed; skipping chart render test"); - return; - } +// Bun's `skip` option is a boolean — a reason string is not treated as true, which +// let this run (and fail on ENOENT) on machines without helm. skipIf is the honest +// form: skipped where helm is absent, which today includes the plain CI test job. +test.skipIf(!helm)( + "the standalone chart target renders without Intelligence and refuses contradictions", + { timeout: 120_000 }, + async () => { + const dependencies = await run([ + helm ?? "helm", + "dependency", + "build", + "charts/openbot", + ]); + if (!dependencies.ok) { + // Offline: fetching the postgresql subchart is the part that failed, and it is + // not what this test is about. + console.warn("helm dependency build failed; skipping chart render test"); + return; + } + + const rendered = await template(); + expect(rendered.ok).toBe(true); + // The server is told its mode, and nothing Intelligence-shaped survives into the + // manifests: no env pointing at the platform, no secret keys for it. + expect(rendered.output).toContain("OPENBOT_RUNTIME_MODE"); + expect(rendered.output).not.toContain("INTELLIGENCE_API_URL"); + expect(rendered.output).not.toContain("intelligence-api-key"); + expect(rendered.output).not.toContain("license-token"); - const rendered = await template(); - expect(rendered.ok).toBe(true); - // The server is told its mode, and nothing Intelligence-shaped survives into the - // manifests: no env pointing at the platform, no secret keys for it. - expect(rendered.output).toContain("OPENBOT_RUNTIME_MODE"); - expect(rendered.output).not.toContain("INTELLIGENCE_API_URL"); - expect(rendered.output).not.toContain("intelligence-api-key"); - expect(rendered.output).not.toContain("license-token"); + // Standalone alongside Intelligence values is the contradiction the server also + // refuses; the chart catches it at install instead of in a crash loop. + const contradiction = await template( + "--set", + "config.intelligence.apiUrl=https://api.example", + ); + expect(contradiction.ok).toBe(false); - // Standalone alongside Intelligence values is the contradiction the server also - // refuses; the chart catches it at install instead of in a crash loop. - const contradiction = await template( - "--set", - "config.intelligence.apiUrl=https://api.example", - ); - expect(contradiction.ok).toBe(false); + // A routines CronJob whose every dispatch is a 404 must not render. + const routines = await template( + "--set", + "routines.enabled=true", + "--set", + "secrets.workerSharedSecret=example", + ); + expect(routines.ok).toBe(false); - // A routines CronJob whose every dispatch is a 404 must not render. - const routines = await template( - "--set", - "routines.enabled=true", - "--set", - "secrets.workerSharedSecret=example", - ); - expect(routines.ok).toBe(false); -}); + // The operator escape hatch must not smuggle the mode back in: extraEnv entries + // naming the runtime variables are refused in standalone rather than rendered + // into a pod that validation never saw. + const smuggled = await template( + "--set", + "config.extraEnv[0].name=INTELLIGENCE_API_URL", + "--set", + "config.extraEnv[0].value=https://api.example", + ); + expect(smuggled.ok).toBe(false); + const modeOverride = await template( + "--set", + "config.extraEnv[0].name=OPENBOT_RUNTIME_MODE", + "--set", + "config.extraEnv[0].value=intelligence", + ); + expect(modeOverride.ok).toBe(false); + }, +);