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..7600f4791 100644 --- a/charts/openbot/templates/validation.yaml +++ b/charts/openbot/templates/validation.yaml @@ -68,14 +68,40 @@ 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 }} +{{- /* + 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 "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 ff81b1783..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,21 +1058,10 @@ export function createApp( ); } - if (threadIdentity) { + if (threadIdentity && threadReader) { 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. - createThreadReader( - createIntelligenceClient(config.runtime.intelligence), - ), - ), + createThreadRoutes(threadIdentity, requireUser, threadReader), ); } diff --git a/server/src/config.ts b/server/src/config.ts index 1c235f9c3..bd9d5dc91 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,13 +569,27 @@ function oktaAuth( } /** - * Resolve the Intelligence contract, 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 failing on the partial - * set alone (as this did) let a completely unconfigured deployment through as if that were a choice. + * 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"), @@ -579,9 +606,17 @@ function runtimeCapabilities(environment: Environment): RuntimeCapabilities { .filter(([, value]) => !value) .map(([name]) => name); + 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 required and is not configured. Missing: ${missing.join(", ")}`, + `CopilotKit Intelligence is not fully configured. Missing: ${missing.join(", ")}. Set all four, or set OPENBOT_RUNTIME_MODE=standalone to run without it.`, ); } @@ -861,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"), @@ -879,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 }, @@ -894,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/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..ab4ac6041 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 @@ -336,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. @@ -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,229 @@ 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. * - * `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. + * This is the guard the old single-mode comment promised: standalone leaves the + * routine runner off `createApp` entirely and mounts no chat runtime. + * + * 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 }, + { 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 passing = couldHandOn - ? handoffTool({ - desk: handoffDesk, + const loadAgentsForActor = createRuntimeAgentLoader( + database, + agentVault, + config.managedAgent, + ); + + 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, + // 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 + // /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 +894,12 @@ 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 && + copilotRuntime +) { const runner = createHandoffRunner({ queue: createWorkQueue(database), owner: `handoff/${process.env.HOSTNAME ?? randomUUID().slice(0, 8)}`, @@ -928,7 +973,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 +1088,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, @@ -1074,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 5b1d9ce2a..9f0c248c4 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,67 @@ 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("OPENBOT_RUNTIME_MODE=standalone"); }); - test("refuses to start when Intelligence is absent entirely, rather than degrading", () => { + 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 required and is not configured"); + ).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({ + mode: "standalone", + durableHistory: false, + }); + }); + + 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", () => { diff --git a/server/tests/standalone-boot.integration.test.ts b/server/tests/standalone-boot.integration.test.ts new file mode 100644 index 000000000..f7e49d53b --- /dev/null +++ b/server/tests/standalone-boot.integration.test.ts @@ -0,0 +1,87 @@ +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; + +// 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; + } + } 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); + + // 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/server/tests/standalone-runtime.test.ts b/server/tests/standalone-runtime.test.ts new file mode 100644 index 000000000..c19200074 --- /dev/null +++ b/server/tests/standalone-runtime.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { createApp } from "../src/app"; +import { loadConfig } from "../src/config"; +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", + OPENBOT_RUNTIME_MODE: "standalone", + }); + 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("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, + { provider: "openai", model: "gpt-5.5" }, + () => Promise.resolve([]), + () => undefined, + () => Promise.resolve(null), + () => Promise.resolve({ id: "", role: "user" }), + { stallTimeoutMs: 0 }, + ), + ).toThrow("standalone"); + }); +}); diff --git a/tests/helm-standalone.test.ts b/tests/helm-standalone.test.ts new file mode 100644 index 000000000..697800ba7 --- /dev/null +++ b/tests/helm-standalone.test.ts @@ -0,0 +1,115 @@ +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, + ]); +} + +// 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"); + + // 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); + + // 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); + }, +);