From 700bc9f4afb1cd34ae735da73ca2899233630c01 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 12 Oct 2026 13:47:05 -0300 Subject: [PATCH 1/3] Pass a licence token from a store to the server The chart emitted COPILOTKIT_LICENSE_TOKEN only when secrets.licenseToken was a literal. A token synced through externalSecrets, which ci/eks-values itself does, reached the Secret and never the pod, so a self-hosted deployment ran unlicensed with nothing saying why. openbot.licenseTokenSource now answers where the token comes from: the chart's own Secret, a store whose data names license-token, or an existingSecret the chart cannot read, where the reference is optional so a Secret without the key still starts. No source, no variable, as before for managed Intelligence. check-rendered-chart compared what containers demand with what the chart's own Secrets write, and not with what an ExternalSecret syncs. It now fails a render in which a store syncs a key nothing reads; with the old condition restored it fails eks and eks-sandbox on license-token. --- charts/openbot/README.md | 8 ++++++- charts/openbot/templates/_helpers.tpl | 30 ++++++++++++++++++++++++++- charts/openbot/values.yaml | 4 +++- docs/deployment.md | 2 +- scripts/check-rendered-chart.ts | 24 +++++++++++++++++++++ 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/charts/openbot/README.md b/charts/openbot/README.md index 5c6075583..f043f1c5b 100644 --- a/charts/openbot/README.md +++ b/charts/openbot/README.md @@ -29,7 +29,13 @@ npx --yes copilotkit@latest project select # prints the cpk-... runtime key That key is the only Intelligence credential a managed install needs; the free plan is enough. `secrets.licenseToken` is optional and exists for a self-hosted Intelligence that issues its own licence. `npx --yes copilotkit@latest license --print` prints one without writing it to a local -`.env`, which is what you want for something you are about to paste into a Secret. +`.env`, which is what you want for something you are about to paste into a Secret. With +`externalSecrets`, name `license-token` in its `data` and the server receives it the same way. + +**A self-hosted Intelligence needs that licence too, on its own deployment.** Its chart takes it as +`appApi.licenseSecret` or `externalSecrets.secrets.license`. Without it Intelligence answers every +entitlement read with `RUNTIME_ENTITLEMENTS_SELF_HOSTED_MISCONFIGURED` and refuses memory, while +conversations carry on; OpenBot logs `intelligence-entitlement-not-ready` at boot when it does. **A default StorageClass**, or a named one. Both a Bot's computer and the bundled database ask for a volume, and a fresh cluster often has no class marked default. See diff --git a/charts/openbot/templates/_helpers.tpl b/charts/openbot/templates/_helpers.tpl index 21cce80c5..32b66a952 100644 --- a/charts/openbot/templates/_helpers.tpl +++ b/charts/openbot/templates/_helpers.tpl @@ -193,6 +193,30 @@ actually exists. A named `image.tag` is used verbatim. {{- printf "%s:%s" .Values.image.repository $tag -}} {{- end -}} +{{- /* + Whether the Secret carries a licence token, and how sure the chart can be. + + `chart` and `store` mean the key is certainly there: this chart wrote it from + `secrets.licenseToken`, or `externalSecrets.data` names it. `unknown` is a Secret somebody made + (`secrets.existingSecret`), which the chart cannot read at template time, so the reference is + optional and a Secret without the key still starts. Empty means no licence: managed Intelligence + issues none. + + It used to be emitted for `secrets.licenseToken` alone, so a token synced from a store reached the + Secret and never the pod, and a self-hosted Intelligence ran unlicensed with nothing saying why. +*/ -}} +{{- define "openbot.licenseTokenSource" -}} +{{- if .Values.secrets.licenseToken -}} +chart +{{- else if .Values.externalSecrets.enabled -}} +{{- $named := list -}} +{{- range .Values.externalSecrets.data }}{{- $named = append $named .secretKey }}{{- end -}} +{{- if has "license-token" $named }}store{{ end -}} +{{- else if .Values.secrets.existingSecret -}} +unknown +{{- end -}} +{{- end -}} + {{- define "openbot.secretName" -}} {{- default (printf "%s-secrets" (include "openbot.fullname" .)) .Values.secrets.existingSecret -}} {{- end -}} @@ -345,12 +369,16 @@ and in whatever holds the release, which is not where `KEY_ENCRYPTION_KEY` belon secretKeyRef: name: {{ include "openbot.secretName" . }} key: intelligence-api-key -{{- if .Values.secrets.licenseToken }} +{{- $license := include "openbot.licenseTokenSource" . }} +{{- if $license }} - name: COPILOTKIT_LICENSE_TOKEN valueFrom: secretKeyRef: name: {{ include "openbot.secretName" . }} key: license-token + {{- if eq $license "unknown" }} + optional: true + {{- end }} {{- end }} {{- with .Values.config.managedAgent.url }} - name: MANAGED_AGENT_AG_UI_URL diff --git a/charts/openbot/values.yaml b/charts/openbot/values.yaml index be9490730..0e4be5112 100644 --- a/charts/openbot/values.yaml +++ b/charts/openbot/values.yaml @@ -540,7 +540,9 @@ secrets: # Intelligence missing. intelligenceApiKey: "" # Optional. Managed Intelligence issues no licence token; set this only for a self-hosted - # Intelligence that has one, and it is passed to the server as COPILOTKIT_LICENSE_TOKEN. + # Intelligence that has one, and it is passed to the server as COPILOTKIT_LICENSE_TOKEN. From a + # store, name `license-token` in `externalSecrets.data` instead; it is passed the same way. That + # Intelligence needs the licence on its own deployment too, or it refuses memory. licenseToken: "" # Sent to `config.managedAgent.url` on every call. Required when that url is set. managedAgentToken: "" diff --git a/docs/deployment.md b/docs/deployment.md index ed3ef7648..a695fa884 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -120,7 +120,7 @@ and the fix would not be available. | `EMBEDDED_POSTGRES` | `on` to run the database inside the container. Off by default | | `KEY_ENCRYPTION_KEY` | base64 32 bytes. `openssl rand -base64 32`. The example key is refused in production | | `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY` | CopilotKit Intelligence. A free plan is available and it can be self-hosted | -| `COPILOTKIT_LICENSE_TOKEN` | optional. Managed Intelligence issues none; set it only for a self-hosted Intelligence that has one | +| `COPILOTKIT_LICENSE_TOKEN` | optional. Managed Intelligence issues none; set it for a self-hosted Intelligence, which needs the same licence on its own deployment too | | a model key | `OPENAI_API_KEY`, or the provider you configured | `COMPUTER_TOKEN` is generated at start if you do not set one. Both processes that need it are inside diff --git a/scripts/check-rendered-chart.ts b/scripts/check-rendered-chart.ts index 171f899f7..5e5090638 100644 --- a/scripts/check-rendered-chart.ts +++ b/scripts/check-rendered-chart.ts @@ -127,6 +127,30 @@ for (const [name, keys] of written) { } } +/** + * The same question of a key a store syncs in. + * + * An ExternalSecret's `data` is the list of keys that will exist in the Secret it creates, so it can + * be compared just as a rendered Secret is. Only the half above was asked, which is how a licence + * token synced from a store reached the Secret and never the pod: the server's environment named + * `license-token` only when `secrets.licenseToken` was a literal, and nothing compared the two. + */ +for (const document of documents) { + if (!/^kind:\s*ExternalSecret\s*$/m.test(document)) continue; + const name = + document.match(/^\s{2}name:\s*(\S+)/m)?.[1]?.replace(/^["']|["']$/g, "") ?? + "an ExternalSecret"; + for (const [, key] of document.matchAll( + /^\s*(?:-\s+)?secretKey:\s*([a-z0-9-]+)\s*$/gm, + )) { + if (key && !text.includes(`key: ${key}`)) { + problems.push( + `ExternalSecret "${name}" syncs "${key}", which nothing in this render reads.`, + ); + } + } +} + /** * A policy that fences the API off from the services it cannot work without. * From eb38bf01df309dfd7b333657f737d30fe1455e48 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 12 Oct 2026 13:47:05 -0300 Subject: [PATCH 2/3] Say at boot when Intelligence will refuse memory A self-hosted Intelligence without its own licence answers every runtime entitlement read with RUNTIME_ENTITLEMENTS_SELF_HOSTED_MISCONFIGURED and refuses memory, while conversations carry on and OpenBot's log is silent. The licence belonging on Intelligence's deployment as well as OpenBot's is the part that is easy to miss. The server now reads the runtime entitlement once at start-up, without waiting on it, and logs intelligence-entitlement-not-ready with the code and the fix for misconfigured, expired and unavailable answers, or intelligence-entitlement-unreadable when Intelligence cannot be asked. The self-host banner reads the same answer but only while it is enabled, so it could not be the thing that reports this. --- CHANGELOG.md | 18 +++ README.md | 4 +- docs/configuration.md | 5 +- server/src/index.ts | 7 ++ server/src/intelligence-entitlement-check.ts | 77 ++++++++++++ .../intelligence-entitlement-check.test.ts | 116 ++++++++++++++++++ 6 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 server/src/intelligence-entitlement-check.ts create mode 100644 server/tests/intelligence-entitlement-check.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 740c33d99..40c90bc8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A licence token from a store reaches the server + +The Helm chart passed `COPILOTKIT_LICENSE_TOKEN` to the server only when `secrets.licenseToken` was +written as a literal. A token synced through `externalSecrets`, which the chart's own EKS example +does, reached the Secret and never the pod, so a self-hosted deployment ran unlicensed with nothing +saying why. It is now passed whenever `externalSecrets.data` names `license-token`, and as an optional +reference with `secrets.existingSecret`, where the chart cannot see the Secret's keys. The chart's +CI check now also fails a render in which a store syncs a key that nothing reads. + +### OpenBot says at boot when Intelligence will refuse memory + +A self-hosted Intelligence needs its own licence. Without one it answers every runtime entitlement +read with `RUNTIME_ENTITLEMENTS_SELF_HOSTED_MISCONFIGURED` and refuses memory, while conversations +carry on and nothing in OpenBot's log says why. The server now asks once at start-up and logs +`intelligence-entitlement-not-ready` with the code and where the licence goes, and +`intelligence-entitlement-unreadable` when Intelligence cannot be asked. Start-up does not wait for +the answer. + ## 0.2.0 **Before upgrading.** Two things change for an existing deployment: diff --git a/README.md b/README.md index 440f9b4eb..691734b68 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,9 @@ See [docs/configuration.md](docs/configuration.md) and [docs/coworkers.md](docs/ `COPILOTKIT_LICENSE_TOKEN` is optional. A self-hosted Intelligence with its own licence can still set it and it is forwarded to the runtime; managed Intelligence -does not issue one and startup no longer asks for it. +does not issue one and startup no longer asks for it. A self-hosted Intelligence +also needs the licence on its own deployment, or it refuses memory; OpenBot logs +`intelligence-entitlement-not-ready` at boot when that is missing. Settings worth knowing: diff --git a/docs/configuration.md b/docs/configuration.md index 5c6ab519b..21b28e21d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,7 +29,10 @@ checked as a set, so a partial set is refused as a misconfiguration rather than unconfigured. `COPILOTKIT_LICENSE_TOKEN` is optional: managed Intelligence issues no licence token, and a -self-hosted Intelligence that has one sets this and has it forwarded to the runtime. +self-hosted Intelligence that has one sets this and has it forwarded to the runtime. That +Intelligence needs the same licence on its own deployment as well, or it refuses memory and the +other licensed features while conversations carry on. OpenBot asks Intelligence once at boot and +logs `intelligence-entitlement-not-ready`, naming the fix, when the answer is not ready. `MANAGED_AGENT_AG_UI_URL` names the Bot in the box: the default endpoint for coworkers created in the product. It needs `MANAGED_AGENT_TOKEN` beside it, or the server refuses to start. Unset, the diff --git a/server/src/index.ts b/server/src/index.ts index 81e6611b5..cfc56c002 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -253,6 +253,7 @@ import { createRoutineRunner } from "./routines/runner"; import { createRoutineStore } from "./routines/store"; import { createIntentRouter } from "./routing/classify"; import { createModelCompleter } from "./routing/model"; +import { reportIntelligenceEntitlement } from "./intelligence-entitlement-check"; import { createSelfHostBanner } from "./self-host-banner"; import { createTeamBots } from "./team-bots/team-bots"; import { @@ -2888,6 +2889,12 @@ const selfHostBannerIntelligence = createIntelligenceClient( config.runtime.intelligence, ); +// Said once, at boot, whether Intelligence will let this deployment use what it is licensed for. A +// self-hosted Intelligence without a licence refuses memory quietly; this is the line that says why. +void reportIntelligenceEntitlement({ + entitlements: () => selfHostBannerIntelligence.getRuntimeEntitlements(), +}); + const identityGithubCallback = config.githubApp && config.publicUrl ? githubCallbackRoutes({ diff --git a/server/src/intelligence-entitlement-check.ts b/server/src/intelligence-entitlement-check.ts new file mode 100644 index 000000000..265ed1a8c --- /dev/null +++ b/server/src/intelligence-entitlement-check.ts @@ -0,0 +1,77 @@ +import type { RuntimeEntitlementResponse } from "./self-host-banner"; + +/** + * Whether Intelligence will let this deployment use what it is licensed for, said once at boot. + * + * A self-hosted Intelligence without a licence answers every runtime entitlement read with + * `RUNTIME_ENTITLEMENTS_SELF_HOSTED_MISCONFIGURED`, and the features it gates, memory first, are + * then refused quietly: conversations still run, nothing fails loudly, and nothing in OpenBot's own + * log says why recall is empty. The licence belongs to Intelligence's own deployment rather than to + * OpenBot's, which is the part that is easy to miss, so the line says where it goes. + * + * Read once and never blocks start-up. The self-host banner reads the same answer on page loads, + * but only while the banner is enabled, so it cannot be the thing that reports this. + */ +export type EntitlementWarning = { + type: "intelligence-entitlement-not-ready"; + status: string; + code?: string; + message?: string; + fix: string; +}; + +const FIXES: Record = { + RUNTIME_ENTITLEMENTS_SELF_HOSTED_MISCONFIGURED: + "Intelligence has no valid licence. Give Intelligence's own deployment a COPILOTKIT_LICENSE_TOKEN " + + "(its Helm chart takes it as appApi.licenseSecret, or externalSecrets.secrets.license); setting it " + + "on OpenBot alone is not enough. Until then memory and other licensed features are refused.", + RUNTIME_ENTITLEMENTS_SELF_HOSTED_EXPIRED: + "Intelligence's licence has expired. Renew it and replace COPILOTKIT_LICENSE_TOKEN on Intelligence's " + + "deployment. Until then memory and other licensed features are refused.", + RUNTIME_ENTITLEMENTS_MANAGED_UNAVAILABLE: + "Managed Intelligence could not resolve this project's entitlement just now. This is usually " + + "temporary; licensed features may be refused until it recovers.", +}; + +/** The warning a response deserves, or null when Intelligence answers ready and active. */ +export function entitlementWarning( + response: RuntimeEntitlementResponse, +): EntitlementWarning | null { + if (response.status === "ready") { + if (response.entitlement.active) return null; + return { + type: "intelligence-entitlement-not-ready", + status: "inactive", + fix: "Intelligence answers that this deployment's entitlement is not active, so licensed features such as memory are refused.", + }; + } + const code = response.error?.code; + return { + type: "intelligence-entitlement-not-ready", + status: response.status, + ...(code ? { code } : {}), + ...(response.error?.message ? { message: response.error.message } : {}), + fix: + (code && FIXES[code]) || + "Intelligence did not confirm this deployment's entitlement, so licensed features such as memory may be refused.", + }; +} + +/** Ask once and log what was found. Resolves either way and never throws. */ +export async function reportIntelligenceEntitlement(options: { + entitlements: () => Promise; + log?: (message: string) => void; +}): Promise { + const log = options.log ?? ((message: string) => console.warn(message)); + try { + const warning = entitlementWarning(await options.entitlements()); + if (warning) log(JSON.stringify(warning)); + } catch (error) { + log( + JSON.stringify({ + type: "intelligence-entitlement-unreadable", + reason: error instanceof Error ? error.message : String(error), + }), + ); + } +} diff --git a/server/tests/intelligence-entitlement-check.test.ts b/server/tests/intelligence-entitlement-check.test.ts new file mode 100644 index 000000000..f053d409e --- /dev/null +++ b/server/tests/intelligence-entitlement-check.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import { + entitlementWarning, + reportIntelligenceEntitlement, +} from "../src/intelligence-entitlement-check"; +import type { RuntimeEntitlementResponse } from "../src/self-host-banner"; + +function ready(active = true): RuntimeEntitlementResponse { + return { + status: "ready", + entitlement: { + active, + source: "selfHostedDeploymentLicense", + features: {}, + limits: {}, + planCode: "enterprise", + }, + }; +} + +function notReady( + status: "degraded" | "misconfigured" | "unavailable", + code: string, + message = "not ready", +): RuntimeEntitlementResponse { + return { status, error: { code, message, retryable: false } }; +} + +describe("what Intelligence's entitlement answer means for this deployment", () => { + test("ready and active says nothing", () => { + expect(entitlementWarning(ready())).toBeNull(); + }); + + test("ready but inactive is reported", () => { + expect(entitlementWarning(ready(false))).toMatchObject({ + status: "inactive", + }); + }); + + test("an unlicensed self-hosted Intelligence names where the licence goes", () => { + // The state a self-hosted deployment reaches when OpenBot has the licence and Intelligence does + // not: memory is refused with nothing in OpenBot's log saying why. + const warning = entitlementWarning( + notReady( + "misconfigured", + "RUNTIME_ENTITLEMENTS_SELF_HOSTED_MISCONFIGURED", + "Self-hosted license configuration is missing or invalid.", + ), + ); + expect(warning).toMatchObject({ + type: "intelligence-entitlement-not-ready", + status: "misconfigured", + code: "RUNTIME_ENTITLEMENTS_SELF_HOSTED_MISCONFIGURED", + message: "Self-hosted license configuration is missing or invalid.", + }); + expect(warning?.fix).toContain("Intelligence's own deployment"); + expect(warning?.fix).toContain("appApi.licenseSecret"); + expect(warning?.fix).toContain("memory"); + }); + + test("an expired licence says to renew it", () => { + expect( + entitlementWarning( + notReady("misconfigured", "RUNTIME_ENTITLEMENTS_SELF_HOSTED_EXPIRED"), + )?.fix, + ).toContain("expired"); + }); + + test("an unknown code still says what it costs", () => { + expect( + entitlementWarning(notReady("unavailable", "SOMETHING_NEW"))?.fix, + ).toContain("memory"); + }); +}); + +describe("reporting it at boot", () => { + test("logs one line when the answer is not ready", async () => { + const lines: string[] = []; + await reportIntelligenceEntitlement({ + entitlements: async () => + notReady( + "misconfigured", + "RUNTIME_ENTITLEMENTS_SELF_HOSTED_MISCONFIGURED", + ), + log: (line) => lines.push(line), + }); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0] ?? "{}")).toMatchObject({ + type: "intelligence-entitlement-not-ready", + code: "RUNTIME_ENTITLEMENTS_SELF_HOSTED_MISCONFIGURED", + }); + }); + + test("logs nothing when ready", async () => { + const lines: string[] = []; + await reportIntelligenceEntitlement({ + entitlements: async () => ready(), + log: (line) => lines.push(line), + }); + expect(lines).toHaveLength(0); + }); + + test("an unreachable Intelligence is reported and does not throw", async () => { + const lines: string[] = []; + await reportIntelligenceEntitlement({ + entitlements: async () => { + throw new Error("connect ECONNREFUSED"); + }, + log: (line) => lines.push(line), + }); + expect(JSON.parse(lines[0] ?? "{}")).toMatchObject({ + type: "intelligence-entitlement-unreadable", + reason: "connect ECONNREFUSED", + }); + }); +}); From 096def7b0ed77f9769ae7fd9e692cd5bd43afa8b Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 13 Oct 2026 10:27:28 -0300 Subject: [PATCH 3/3] Use openbot.secretKeySource for the licence token #781 added openbot.secretKeySource for optional credentials, the same decision openbot.licenseTokenSource made for the licence alone: the chart's literal, a store that names the key, or an existingSecret referenced as optional. The licence now goes through it, and the licence-only helper is gone. Rendered output is unchanged in all five licence cases and on every ci target. --- charts/openbot/templates/_helpers.tpl | 28 +++------------------------ 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/charts/openbot/templates/_helpers.tpl b/charts/openbot/templates/_helpers.tpl index 67aacc6d3..11ba5371c 100644 --- a/charts/openbot/templates/_helpers.tpl +++ b/charts/openbot/templates/_helpers.tpl @@ -236,30 +236,6 @@ unknown {{- end }} {{- end -}} -{{- /* - Whether the Secret carries a licence token, and how sure the chart can be. - - `chart` and `store` mean the key is certainly there: this chart wrote it from - `secrets.licenseToken`, or `externalSecrets.data` names it. `unknown` is a Secret somebody made - (`secrets.existingSecret`), which the chart cannot read at template time, so the reference is - optional and a Secret without the key still starts. Empty means no licence: managed Intelligence - issues none. - - It used to be emitted for `secrets.licenseToken` alone, so a token synced from a store reached the - Secret and never the pod, and a self-hosted Intelligence ran unlicensed with nothing saying why. -*/ -}} -{{- define "openbot.licenseTokenSource" -}} -{{- if .Values.secrets.licenseToken -}} -chart -{{- else if .Values.externalSecrets.enabled -}} -{{- $named := list -}} -{{- range .Values.externalSecrets.data }}{{- $named = append $named .secretKey }}{{- end -}} -{{- if has "license-token" $named }}store{{ end -}} -{{- else if .Values.secrets.existingSecret -}} -unknown -{{- end -}} -{{- end -}} - {{- define "openbot.secretName" -}} {{- default (printf "%s-secrets" (include "openbot.fullname" .)) .Values.secrets.existingSecret -}} {{- end -}} @@ -442,7 +418,9 @@ and in whatever holds the release, which is not where `KEY_ENCRYPTION_KEY` belon secretKeyRef: name: {{ include "openbot.secretName" . }} key: intelligence-api-key -{{- $license := include "openbot.licenseTokenSource" . }} +{{- /* From a store or an existingSecret too: emitted for the literal alone, a synced token reached + the Secret and never the pod, and a self-hosted Intelligence ran unlicensed. */}} +{{- $license := include "openbot.secretKeySource" (dict "root" . "key" "license-token" "literal" .Values.secrets.licenseToken) }} {{- if $license }} - name: COPILOTKIT_LICENSE_TOKEN valueFrom: