From 23756b920a0251d978b85a856e5bc54c3d3139ad Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Mon, 19 Oct 2026 16:06:43 -0700 Subject: [PATCH 1/3] fix(config): accept the Intelligence runtime key written by the CLI --- .env.example | 7 ++++--- README.md | 11 ++++------- docs/configuration.md | 3 ++- prompt.txt | 6 +++--- scripts/setup-learning.test.ts | 23 +++++++++++++++++++++-- scripts/setup-learning.ts | 6 +++++- scripts/start-restart-guard.test.ts | 23 +++++++++++++---------- scripts/start.sh | 4 ++-- server/src/config.ts | 3 ++- server/src/learning/ensure-container.ts | 8 ++++++-- server/tests/config.test.ts | 19 +++++++++++++++++++ shared/intelligence-key.ts | 10 ++++++++++ 12 files changed, 91 insertions(+), 32 deletions(-) create mode 100644 shared/intelligence-key.ts diff --git a/.env.example b/.env.example index acc971401c..afd5b6a8f2 100644 --- a/.env.example +++ b/.env.example @@ -129,12 +129,13 @@ INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai # # npx --yes copilotkit@latest login # browser sign-in # npx --yes copilotkit@latest project select # writes the cpk-... key to .env as CPK_INTELLIGENCE_API_KEY -# bun scripts/setup-learning.ts # copies it here, as INTELLIGENCE_API_KEY +# bun scripts/setup-learning.ts # ensures the Learning container exists # -# The server reads only INTELLIGENCE_API_KEY, so a key left as CPK_INTELLIGENCE_API_KEY is not seen. +# OpenBot reads CPK_INTELLIGENCE_API_KEY directly. INTELLIGENCE_API_KEY is a legacy fallback. +# When both are set, the non-empty CPK_INTELLIGENCE_API_KEY takes precedence. # # The runtime key is also under "API Keys" in your project at https://intelligence.copilotkit.ai -INTELLIGENCE_API_KEY= +CPK_INTELLIGENCE_API_KEY= # Optional, and blank on purpose. Managed Intelligence derives entitlement from the project key # above and issues no licence token. A self-hosted Intelligence with its own licence sets this and diff --git a/README.md b/README.md index 7d5b936777..07f0138ee0 100644 --- a/README.md +++ b/README.md @@ -138,13 +138,10 @@ not a production installation. Run its `npx copilotkit@latest local connect` from the OpenBot root, where `.env` is. That command writes `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL` and -`CPK_INTELLIGENCE_API_KEY` to `.env`. OpenBot reads the first two as they are, but takes its project -key only from `INTELLIGENCE_API_KEY` and refuses to start without it, so copy the `cpk-...` value -across before `bash scripts/start.sh`: - -```sh -INTELLIGENCE_API_KEY=cpk-... # the value of CPK_INTELLIGENCE_API_KEY -``` +`CPK_INTELLIGENCE_API_KEY` to `.env`. OpenBot reads those settings directly; no key +copying or renaming is needed. `INTELLIGENCE_API_KEY` remains supported for older +configurations. When both are set, the non-empty CLI key takes precedence so +reconnecting uses the new project's key. To give the Bots a Learning container on that stack, see [Automatic Learning](docs/automatic-learning.md). diff --git a/docs/configuration.md b/docs/configuration.md index 79ff05ee35..ef66bccb74 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,7 +22,8 @@ bash scripts/start.sh | `KEY_ENCRYPTION_KEY` | Base64-encoded 32-byte key for encrypted stored credentials. Generate with `openssl rand -base64 32`. | | `INTELLIGENCE_API_URL` | CopilotKit Intelligence API URL. | | `INTELLIGENCE_GATEWAY_WS_URL` | CopilotKit Intelligence realtime gateway URL. | -| `INTELLIGENCE_API_KEY` | Runtime key for the Intelligence project. | +| `CPK_INTELLIGENCE_API_KEY` | Runtime key written by the CopilotKit CLI. Preferred when non-empty. | +| `INTELLIGENCE_API_KEY` | Legacy runtime key; used when `CPK_INTELLIGENCE_API_KEY` is empty or absent. | All five above stop server startup if missing. The three `INTELLIGENCE_` values are additionally checked as a set, so a partial set is refused as a misconfiguration rather than treated as diff --git a/prompt.txt b/prompt.txt index aedf0bfa69..fc506f7169 100644 --- a/prompt.txt +++ b/prompt.txt @@ -103,9 +103,9 @@ OpenBot root where `.env` is: npx copilotkit@latest local connect --approve-connection `local connect` writes INTELLIGENCE_API_URL, INTELLIGENCE_GATEWAY_WS_URL and -CPK_INTELLIGENCE_API_KEY to `.env`. OpenBot reads the first two as they are but takes its project -key only from INTELLIGENCE_API_KEY, so copy the `cpk-...` value of CPK_INTELLIGENCE_API_KEY into -INTELLIGENCE_API_KEY before `bash scripts/start.sh`. The local licence lasts 30 days; +CPK_INTELLIGENCE_API_KEY to `.env`. OpenBot reads those values directly; do not copy or rename +the key. INTELLIGENCE_API_KEY remains a legacy fallback when the CLI key is empty or absent. +When both are set, the non-empty CLI key takes precedence. The local licence lasts 30 days; `npx copilotkit@latest local renew` renews it as often as needed while the account is in good standing. Skip the managed `project select` and setup-learning commands on this path. diff --git a/scripts/setup-learning.test.ts b/scripts/setup-learning.test.ts index ad73a2582e..9f87821dda 100644 --- a/scripts/setup-learning.test.ts +++ b/scripts/setup-learning.test.ts @@ -601,8 +601,27 @@ test("self-hosted setup failure or concurrent env edits do not save credentials" ); }); -test("a partial key provision cannot reuse a stale CLI key or create a container", async () => { - const f = await fixture("CPK_INTELLIGENCE_API_KEY=cpk-old-project-key\n"); +test.each(["", "INTELLIGENCE_API_KEY=cpk-old-project-key\n"])( + "a CLI key is used without reprovisioning or rewriting configuration: %s", + async (legacy) => { + const f = await fixture(`${legacy}CPK_INTELLIGENCE_API_KEY=cpk-cli-key\n`); + const before = await readFile(f.envPath, "utf8"); + const view = keyView(["present"]); + expect( + await setupLearning({ + ...f.options, + runCli: f.runCli, + keyIntelligence: view.keyIntelligence, + }), + ).toBe("present"); + expect(view.keys).toEqual(["cpk-cli-key"]); + expect(f.calls).toHaveLength(0); + expect(await readFile(f.envPath, "utf8")).toBe(before); + }, +); + +test("a partial key provision does not create a container", async () => { + const f = await fixture(); const before = await readFile(f.envPath, "utf8"); await expect( setupLearning({ diff --git a/scripts/setup-learning.ts b/scripts/setup-learning.ts index be3bbd7f0c..bbb40d3be5 100644 --- a/scripts/setup-learning.ts +++ b/scripts/setup-learning.ts @@ -2,6 +2,7 @@ import { readFile, rename, rm, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { createInterface } from "node:readline/promises"; import { parseEnv } from "node:util"; +import { intelligenceApiKey } from "../shared/intelligence-key"; import { cliLoginPresent, containerMissing, @@ -136,7 +137,10 @@ export async function setupLearning( const effective = (name: string) => (environment[name] || values[name] || "").trim(); if (effective(CONTAINER)) return "preserved"; - const existingKey = effective("INTELLIGENCE_API_KEY"); + const existingKey = intelligenceApiKey({ + CPK_INTELLIGENCE_API_KEY: effective("CPK_INTELLIGENCE_API_KEY"), + INTELLIGENCE_API_KEY: effective("INTELLIGENCE_API_KEY"), + }); if (existingKey) { /* * The key cannot create containers, and it is never replaced here. Creation goes through a diff --git a/scripts/start-restart-guard.test.ts b/scripts/start-restart-guard.test.ts index e0ad2e8337..6c804418d8 100644 --- a/scripts/start-restart-guard.test.ts +++ b/scripts/start-restart-guard.test.ts @@ -351,17 +351,20 @@ describe("start.sh Learning container check", () => { * The server assigns `openbot` once it exists, but its runtime key cannot create it. start.sh asks * the setup helper, without a browser or a prompt, to make sure it exists, and relays its last line. */ - test("runs the non-interactive check when a key is set and nothing is assigned", async () => { - const result = await runStartWithStaleServerProbe(401, { - settings: { INTELLIGENCE_API_KEY: "cpk-synthetic" }, - }); + test.each(["INTELLIGENCE_API_KEY", "CPK_INTELLIGENCE_API_KEY"])( + "runs the non-interactive check when %s is set", + async (keyName) => { + const result = await runStartWithStaleServerProbe(401, { + settings: { [keyName]: "cpk-synthetic" }, + }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain( - "LEARNING-CHECK scripts/setup-learning.ts --non-interactive", - ); - expect(result.stdout).not.toContain("noise"); - }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "LEARNING-CHECK scripts/setup-learning.ts --non-interactive", + ); + expect(result.stdout).not.toContain("noise"); + }, + ); test.each([ [ diff --git a/scripts/start.sh b/scripts/start.sh index 36c45ff468..4b1cdeaeac 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -300,7 +300,7 @@ green " managed coworker endpoint: $MANAGED_AGENT_AG_UI_URL" LEARNING_CONTAINER="$(setting CPK_INTELLIGENCE_LEARNING_CONTAINER_ID "")" LEARNING_AUTO_ASSIGN="$(setting OPENBOT_LEARNING_AUTO_ASSIGN true)" if [ -z "$LEARNING_CONTAINER" ] && [ "$LEARNING_AUTO_ASSIGN" != "false" ] \ - && [ "$LEARNING_AUTO_ASSIGN" != "0" ] && [ -n "$(setting INTELLIGENCE_API_KEY "")" ]; then + && [ "$LEARNING_AUTO_ASSIGN" != "0" ] && { [ -n "$(setting CPK_INTELLIGENCE_API_KEY "")" ] || [ -n "$(setting INTELLIGENCE_API_KEY "")" ]; }; then LEARNING_LINE="$(bun scripts/setup-learning.ts --non-interactive 2>>"$LOGS/learning-setup.log" | tail -1 || true)" info " ${LEARNING_LINE:-Automatic Learning: the container check did not run. Log: $LOGS/learning-setup.log}" fi @@ -413,7 +413,7 @@ const status = info.licenseStatus; const agents = Object.keys(info.agents ?? {}); if (status !== "valid") { console.log(`\x1b[31m licence is '${status}', not 'valid'.\x1b[0m`); - console.log("\x1b[31m Check INTELLIGENCE_API_KEY: npx copilotkit@latest login && npx copilotkit@latest project select\x1b[0m"); + console.log("\x1b[31m Check CPK_INTELLIGENCE_API_KEY (or INTELLIGENCE_API_KEY): npx copilotkit@latest login && npx copilotkit@latest project select\x1b[0m"); console.log("\x1b[31m See README.md for Intelligence setup.\x1b[0m"); process.exit(1); } diff --git a/server/src/config.ts b/server/src/config.ts index 6188b79c1e..1d5d251490 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -3,6 +3,7 @@ * for durable threads and memory. Configuration the product cannot function without belongs at the * boot boundary. */ +import { intelligenceApiKey } from "@openbot/shared/intelligence-key"; import { isLearningContainerId, type LearningTarget, @@ -972,7 +973,7 @@ function runtimeCapabilities(environment: Environment): RuntimeCapabilities { const settings = { apiUrl: url(environment, "INTELLIGENCE_API_URL"), gatewayWsUrl: url(environment, "INTELLIGENCE_GATEWAY_WS_URL"), - apiKey: optional(environment, "INTELLIGENCE_API_KEY"), + apiKey: intelligenceApiKey(environment), licenseToken: optional(environment, "COPILOTKIT_LICENSE_TOKEN"), }; diff --git a/server/src/learning/ensure-container.ts b/server/src/learning/ensure-container.ts index 916389e576..e1a31e0ee7 100644 --- a/server/src/learning/ensure-container.ts +++ b/server/src/learning/ensure-container.ts @@ -21,6 +21,7 @@ * Shared by `scripts/setup-learning.ts` (run by `scripts/start.sh`), the server (at boot and from * Admin → Automatic Learning), and the Helm chart's setup Job, which runs this file directly. */ +import { intelligenceApiKey } from "@openbot/shared/intelligence-key"; import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -536,8 +537,11 @@ export async function ensureLearningContainer(input: { async function jobMain() { const environment = process.env; const apiUrl = intelligenceApiOrigin(environment.INTELLIGENCE_API_URL ?? ""); - const apiKey = environment.INTELLIGENCE_API_KEY?.trim(); - if (!apiKey) throw new Error("INTELLIGENCE_API_KEY is required."); + const apiKey = intelligenceApiKey(environment); + if (!apiKey) + throw new Error( + "CPK_INTELLIGENCE_API_KEY (or INTELLIGENCE_API_KEY) is required.", + ); const token = environment.INTELLIGENCE_SERVICE_TOKEN?.trim(); const projectId = environment.INTELLIGENCE_PROJECT_ID?.trim(); if (Boolean(token) !== Boolean(projectId)) diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 9b271fb1e9..a996401ac0 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -51,6 +51,25 @@ const { } = baseEnvironment; describe("deployment configuration", () => { + test.each([undefined, "", "stale-legacy-key"])( + "uses the CLI runtime key with legacy value %s", + (legacy) => { + const config = loadConfig({ + ...baseEnvironment, + INTELLIGENCE_API_KEY: legacy, + CPK_INTELLIGENCE_API_KEY: " cpk-current-project ", + }); + expect(config.runtime.intelligence.apiKey).toBe("cpk-current-project"); + }, + ); + + test("an empty CLI key preserves legacy-only deployments", () => { + expect( + loadConfig({ ...baseEnvironment, CPK_INTELLIGENCE_API_KEY: " " }).runtime + .intelligence.apiKey, + ).toBe("tenant-api-key"); + }); + test("organization authority disables the standalone actor without requiring local provider secrets", () => { const config = loadConfig({ ...withoutSignIn, diff --git a/shared/intelligence-key.ts b/shared/intelligence-key.ts new file mode 100644 index 0000000000..9d7301415c --- /dev/null +++ b/shared/intelligence-key.ts @@ -0,0 +1,10 @@ +/** The CLI key is authoritative after reconnecting; older deployments may use the legacy name. */ +export function intelligenceApiKey( + environment: Record, +): string | undefined { + return ( + environment.CPK_INTELLIGENCE_API_KEY?.trim() || + environment.INTELLIGENCE_API_KEY?.trim() || + undefined + ); +} From 02938bfaf58d0c2ef6633e6dc84b1348ffd5b6db Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Mon, 19 Oct 2026 16:41:14 -0700 Subject: [PATCH 2/3] fix(desktop): keep selected Intelligence project authoritative --- desktop/src-tauri/src/env.rs | 45 +++++++++++++++++++++++++++++++--- desktop/src-tauri/src/vault.rs | 2 ++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index 2ee9a33410..3433f294ec 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -538,10 +538,11 @@ pub fn compose( "INTELLIGENCE_GATEWAY_WS_URL".into(), intelligence.gateway_ws_url.trim().to_string(), ); - env.insert( - "INTELLIGENCE_API_KEY".into(), - intelligence.api_key.trim().to_string(), - ); + // Explicit desktop selection must override a CLI key left in the checkout's .env. + // Supply both names for consumers that still read the legacy variable directly. + for key in ["CPK_INTELLIGENCE_API_KEY", "INTELLIGENCE_API_KEY"] { + env.insert(key.into(), intelligence.api_key.trim().to_string()); + } /* * MINTED ONCE PER DEPLOYMENT, NOT ONCE PER START. @@ -1344,6 +1345,42 @@ HTTPS_PROXY=http://proxy:8080 assert_eq!(env["OPENAI_API_KEY"], "sk-model"); } + #[test] + fn desktop_project_selection_replaces_and_purges_a_previous_cli_key() { + let dir = temp_root("env-cli-project-selection"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(".env"); + std::fs::write( + &path, + "CPK_INTELLIGENCE_API_KEY=cpk-old-cli-project\nINTELLIGENCE_API_KEY=cpk-old-legacy\nCUSTOM=keep-me\n", + ) + .unwrap(); + let selected = Intelligence { + api_url: "https://selected.example".into(), + gateway_ws_url: "wss://selected.example".into(), + api_key: " cpk-desktop-project ".into(), + }; + let (settings, secrets) = crate::vault::split(compose( + &selected, + &Model::default(), + &engine_status(None), + &Ports::default(), + &pinned(), + None, + &BTreeMap::new(), + )); + for key in ["CPK_INTELLIGENCE_API_KEY", "INTELLIGENCE_API_KEY"] { + assert_eq!(secrets[key], "cpk-desktop-project"); + assert!(!settings.contains_key(key)); + } + write(&path, &settings, &secrets).unwrap(); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(!saved.contains("INTELLIGENCE_API_KEY=")); + assert!(saved.contains("CUSTOM=keep-me")); + assert!(saved.contains("INTELLIGENCE_API_URL=https://selected.example")); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn a_value_with_nothing_around_it_is_untouched() { let env = compose( diff --git a/desktop/src-tauri/src/vault.rs b/desktop/src-tauri/src/vault.rs index a2f48ad838..4fdb77fed1 100644 --- a/desktop/src-tauri/src/vault.rs +++ b/desktop/src-tauri/src/vault.rs @@ -31,6 +31,7 @@ pub fn is_secret(key: &str) -> bool { key, // Somebody's own credentials, pasted or signed in for. "INTELLIGENCE_API_KEY" + | "CPK_INTELLIGENCE_API_KEY" | "OPENAI_API_KEY" | "ANTHROPIC_API_KEY" | "CLAUDE_CODE_OAUTH_TOKEN" @@ -1451,6 +1452,7 @@ mod tests { fn every_credential_is_named_and_nothing_else_is() { for key in [ "INTELLIGENCE_API_KEY", + "CPK_INTELLIGENCE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", From cc900241997663cf2f1c10ccc032fe8584a18e5a Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Tue, 20 Oct 2026 11:15:02 -0700 Subject: [PATCH 3/3] fix(setup): remove legacy key copy and clarify configuration errors --- scripts/setup-learning.test.ts | 3 ++- scripts/setup-learning.ts | 9 ++------- server/src/config.ts | 2 +- server/tests/config.test.ts | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/scripts/setup-learning.test.ts b/scripts/setup-learning.test.ts index 9f87821dda..3b676369a9 100644 --- a/scripts/setup-learning.test.ts +++ b/scripts/setup-learning.test.ts @@ -132,7 +132,8 @@ test("fresh managed setup creates and verifies the selected project's container const content = await readFile(f.envPath, "utf8"); expect(content).toContain("# Keep my other configuration"); expect(parseEnv(content)).toMatchObject({ - INTELLIGENCE_API_KEY: key, + CPK_INTELLIGENCE_API_KEY: key, + INTELLIGENCE_API_KEY: "", CPK_INTELLIGENCE_LEARNING_CONTAINER_ID: "openbot", OPENAI_API_KEY: "synthetic-model", }); diff --git a/scripts/setup-learning.ts b/scripts/setup-learning.ts index bbb40d3be5..9fbbc6b5ea 100644 --- a/scripts/setup-learning.ts +++ b/scripts/setup-learning.ts @@ -228,8 +228,7 @@ export async function setupLearning( "The selected project is invalid. Run npx --yes copilotkit@latest project select again.", ); } - // A saved record alone does not prove a stale CPK key belongs to it. Re-provision here. - // CLI project select removes INTELLIGENCE_API_KEY, hence the precondition above. + // No runtime key is available. Provision one for the saved project before assigning Learning. const provision = await run( ["project", "select", "--project", projectId, "--json"], directory, @@ -286,11 +285,7 @@ export async function setupLearning( "The CLI did not write a runtime key. Run project select and this helper again.", ); } - const content = upsert( - upsert(current, "INTELLIGENCE_API_KEY", key), - CONTAINER, - "openbot", - ); + const content = upsert(current, CONTAINER, "openbot"); await saveEnvironment(envPath, content); return "configured"; } diff --git a/server/src/config.ts b/server/src/config.ts index 1d5d251490..15e4b38e6b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -980,7 +980,7 @@ function runtimeCapabilities(environment: Environment): RuntimeCapabilities { const missing = Object.entries({ INTELLIGENCE_API_URL: settings.apiUrl, INTELLIGENCE_GATEWAY_WS_URL: settings.gatewayWsUrl, - INTELLIGENCE_API_KEY: settings.apiKey, + "CPK_INTELLIGENCE_API_KEY (or INTELLIGENCE_API_KEY)": settings.apiKey, }) .filter(([, value]) => !value) .map(([name]) => name); diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index a996401ac0..9b60dc0c90 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -132,7 +132,7 @@ describe("deployment configuration", () => { delete environment[name]; expect(() => loadConfig(environment)).toThrow( - `CopilotKit Intelligence is required and is not configured. Missing: ${name}`, + `CopilotKit Intelligence is required and is not configured. Missing: ${name === "INTELLIGENCE_API_KEY" ? "CPK_INTELLIGENCE_API_KEY (or INTELLIGENCE_API_KEY)" : name}`, ); });