Skip to content
Open
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
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
45 changes: 41 additions & 4 deletions desktop/src-tauri/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions prompt.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
26 changes: 23 additions & 3 deletions scripts/setup-learning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down Expand Up @@ -601,8 +602,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({
Expand Down
15 changes: 7 additions & 8 deletions scripts/setup-learning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Comment thread
guidovizoso marked this conversation as resolved.
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
Expand Down Expand Up @@ -224,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,
Expand Down Expand Up @@ -282,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";
}
Expand Down
23 changes: 13 additions & 10 deletions scripts/start-restart-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
[
Expand Down
4 changes: 2 additions & 2 deletions scripts/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -972,14 +973,14 @@ 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),
Comment thread
guidovizoso marked this conversation as resolved.
licenseToken: optional(environment, "COPILOTKIT_LICENSE_TOKEN"),
};

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);
Expand Down
8 changes: 6 additions & 2 deletions server/src/learning/ensure-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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))
Expand Down
21 changes: 20 additions & 1 deletion server/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -113,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}`,
);
});

Expand Down
10 changes: 10 additions & 0 deletions shared/intelligence-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** The CLI key is authoritative after reconnecting; older deployments may use the legacy name. */
export function intelligenceApiKey(
environment: Record<string, string | undefined>,
): string | undefined {
return (
environment.CPK_INTELLIGENCE_API_KEY?.trim() ||
Comment thread
samjulien marked this conversation as resolved.
environment.INTELLIGENCE_API_KEY?.trim() ||
undefined
);
}
Loading