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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions charts/openbot/ci/standalone-values.yaml
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Standalone chart coverage looks good locally — validation refusals, extraEnv guards, and the subprocess boot test all check out on head 4bf956b. The one remaining gap called out here is still accurate: CI's chart matrix does not include standalone yet, so merges rely on this test (which skips when Helm is absent, as in the plain CI test job) rather than the workflow render/refusal job. Non-blocking once this lands, but worth tracking for when workflow permissions allow the matrix change.

# .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
5 changes: 5 additions & 0 deletions charts/openbot/templates/_helpers.tpl

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

config.extraEnv is appended after the generated runtime variables (around line 319), and this chart intentionally lets the last duplicate name win. In standalone, an extra OPENBOT_RUNTIME_MODE, INTELLIGENCE_*, or COPILOTKIT_LICENSE_TOKEN entry bypasses template validation and can override/contradict the selected mode; the pod then crash-loops or boots in a different mode than the chart validated. Please reject these reserved names in standalone validation (and test the refusal), or render the mode-defining variables after the operator escape hatch.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand Down
6 changes: 4 additions & 2 deletions charts/openbot/templates/secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
36 changes: 31 additions & 5 deletions charts/openbot/templates/validation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

{{- /*
Expand Down
12 changes: 9 additions & 3 deletions charts/openbot/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down
33 changes: 17 additions & 16 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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 }>();

Expand Down Expand Up @@ -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),
);
}

Expand Down
80 changes: 68 additions & 12 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"),
Expand All @@ -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 };

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The server can choose this mode, but the shipped Helm chart still cannot render it: charts/openbot/templates/validation.yaml fails when the Intelligence URLs are absent, templates/secret.yaml requires both Intelligence secrets, and the values file documents them as mandatory. Please add a chart runtime mode and condition those validation/secret/env paths, with a Helm render test for standalone; otherwise this new deployment shape is available only through ad-hoc/manual startup.

}
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.`,
);
}

Expand Down Expand Up @@ -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"),
Expand All @@ -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 },
Expand All @@ -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 }
: {}),
Expand Down
7 changes: 7 additions & 0 deletions server/src/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
Loading