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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,12 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),
For fresh self-hosted setup, set `INTELLIGENCE_API_URL` to that deployment's
HTTPS API origin (or `http://localhost` for a local one) and run the helper
without the two `npx` commands. It opens an isolated Chrome or Edge window for
your usual sign-in and project choice; install either browser first. It writes
your usual sign-in and project choice; install either browser first. For CLI-managed
local evaluation installs, it discovers the matching dashboard with the installed or
cached CopilotKit CLI and uses that dashboard for login and session requests. Discovery
runs offline with Bun, without downloading the CLI or requiring `npx` on your PATH.
If no CLI is available, manually hosted deployments use their API origin for sign-in. Complete the local auth
approval shown in the browser when prompted. The runtime API URL stays unchanged. It writes
the API URL, the key and the container, not `INTELLIGENCE_GATEWAY_WS_URL`, so
set that one to the same deployment yourself. Once `openbot` exists, the server assigns it
as the default by itself unless [Admin → Automatic Learning](docs/automatic-learning.md)
Expand Down
156 changes: 156 additions & 0 deletions scripts/local-intelligence-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { expect, test } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
localCliEntrypoint,
runLocalIntelligenceCli,
} from "./local-intelligence-cli";

async function fixture(
run: (
root: string,
install: (path: string, version?: string, code?: string) => Promise<string>,
) => Promise<void>,
) {
const root = await mkdtemp(join(tmpdir(), "openbot-cli-discovery-"));
try {
await run(
root,
async (
path,
version = "4.99.0",
code = 'console.log(JSON.stringify({status:"completed",stacks:[],args:process.argv.slice(2),secret:process.env.INTELLIGENCE_API_KEY ?? null}));',
) => {
await mkdir(path, { recursive: true });
await writeFile(
join(path, "package.json"),
JSON.stringify({
name: "copilotkit",
version,
bin: { copilotkit: "index.js" },
}),
);
const entry = join(path, "index.js");
await writeFile(entry, code);
return entry;
},
);
} finally {
await rm(root, { recursive: true, force: true });
}
}
const minimalPath = "/usr/bin:/bin:/usr/sbin:/sbin";

test("minimal-PATH child uses an existing npm cache offline without exposing credentials", async () => {
await fixture(async (root, install) => {
await install(join(root, ".npm/_npx/cache/node_modules/copilotkit"));
const environment = {
PATH: minimalPath,
HOME: root,
npm_config_registry: "http://127.0.0.1:1",
INTELLIGENCE_API_KEY: "private-value",
};
const result = await runLocalIntelligenceCli(
["local", "list", "--json"],
root,
{ home: root, globalRoots: [], environment },
);
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
status: "completed",
stacks: [],
args: ["local", "list", "--json"],
secret: null,
});
});
});

test("installed CLI wins over cache; otherwise newest cached version wins deterministically", async () => {
await fixture(async (root, install) => {
await install(join(root, ".npm/_npx/z/node_modules/copilotkit"), "4.9.0");
await install(
join(root, ".npm/_npx/pre/node_modules/copilotkit"),
"4.10.0-beta.1",
);
await install(
join(root, ".npm/_npx/broken/node_modules/copilotkit"),
"invalid-version",
);
const latest = await install(
join(root, ".npm/_npx/a/node_modules/copilotkit"),
"4.10.0",
);
expect(
await localCliEntrypoint(root, { PATH: minimalPath }, root, []),
).toBe(latest);
const installed = await install(
join(root, "node_modules/copilotkit"),
"4.8.0",
);
expect(
await localCliEntrypoint(root, { PATH: minimalPath }, root, []),
).toBe(installed);
});
});

test("missing and malformed cache entries are unavailable without a registry invocation", async () => {
await fixture(async (root) => {
const cache = join(root, ".npm/_npx/partial/node_modules/copilotkit");
await mkdir(cache, { recursive: true });
await writeFile(join(cache, "package.json"), "not json");
expect(
await runLocalIntelligenceCli([], root, {
home: root,
globalRoots: [],
environment: { PATH: minimalPath },
}),
).toEqual({ exitCode: 127, stdout: "" });
});
});

test("runtime failure is retained and stderr is not returned", async () => {
await fixture(async (root, install) => {
await install(
join(root, "node_modules/copilotkit"),
"4.0.0",
'console.error("private diagnostic"); process.exit(2);',
);
expect(
await runLocalIntelligenceCli([], root, {
home: root,
globalRoots: [],
environment: { PATH: minimalPath },
}),
).toEqual({ exitCode: 2, stdout: "" });
});
});

test("cancellation terminates discovery and timeout is a runtime failure", async () => {
await fixture(async (root, install) => {
await install(
join(root, "node_modules/copilotkit"),
"4.0.0",
"setInterval(() => {}, 1000);",
);
const controller = new AbortController();
const pending = runLocalIntelligenceCli([], root, {
home: root,
globalRoots: [],
environment: { PATH: minimalPath },
signal: controller.signal,
});
setTimeout(() => controller.abort(new Error("cancelled")), 50);
await expect(pending).rejects.toThrow("cancelled");
expect(
(
await runLocalIntelligenceCli([], root, {
home: root,
globalRoots: [],
environment: { PATH: minimalPath },
timeoutMs: 50,
})
).exitCode,
).toBe(1);
});
});
151 changes: 151 additions & 0 deletions scripts/local-intelligence-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { spawn } from "node:child_process";
import { readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import {
delimiter,
dirname,
isAbsolute,
join,
relative,
resolve,
} from "node:path";
import {
cliEnvironment,
type CliResult,
} from "../server/src/learning/ensure-container";

/** Resolve only installed packages. Discovery must never contact a package registry. */
export async function localCliEntrypoint(
directory: string,
environment: NodeJS.ProcessEnv = process.env,
home = homedir(),
globalRoots = [
"/opt/homebrew/lib/node_modules/copilotkit",
"/usr/local/lib/node_modules/copilotkit",
],
): Promise<string | undefined> {
const entry = async (root: string) => {
try {
const pkg = JSON.parse(
await readFile(join(root, "package.json"), "utf8"),
);
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.copilotkit;
if (
pkg.name !== "copilotkit" ||
typeof bin !== "string" ||
typeof pkg.version !== "string"
)
return;
// Validate before sorting; stale metadata must not break other cached installs.
Bun.semver.order(pkg.version, pkg.version);
const path = resolve(root, bin);
if (
isAbsolute(relative(root, path)) ||
relative(root, path).startsWith("..") ||
!(await stat(path)).isFile()
)
return;
return {
path,
version: pkg.version,
};
} catch {
// Partial/stale package-cache entries are not installed CLIs.
return;
}
};
const npmPrefix =
environment.npm_config_prefix ?? environment.NPM_CONFIG_PREFIX;
const installed = [
join(directory, "node_modules/copilotkit"),
join(
environment.BUN_INSTALL ?? join(home, ".bun"),
"install/global/node_modules/copilotkit",
),
...(environment.APPDATA
? [join(environment.APPDATA, "npm/node_modules/copilotkit")]
: []),
...(npmPrefix ? [join(npmPrefix, "lib/node_modules/copilotkit")] : []),
...globalRoots,
...(environment.PATH ?? "")
.split(delimiter)
.filter(Boolean)
.map((bin) => join(dirname(bin), "lib/node_modules/copilotkit")),
];
for (const root of installed) {
const found = await entry(root);
if (found) return found.path;
}
// npm's npx cache is a complete installation with its dependency graph. Bun's raw
// package cache is not, so only Bun's installed global package is considered above.
const cacheRoot = join(
environment.npm_config_cache ??
environment.NPM_CONFIG_CACHE ??
join(home, ".npm"),
"_npx",
);
const cached: { path: string; version: string }[] = [];
const names = await readdir(cacheRoot).catch(() => [] as string[]);
for (const name of names.sort()) {
const found = await entry(join(cacheRoot, name, "node_modules/copilotkit"));
if (found) cached.push(found);
}
cached.sort(
(a, b) =>
Bun.semver.order(b.version, a.version) || a.path.localeCompare(b.path),
);
return cached[0]?.path;
}

/** Execute the installed CLI with Desktop's already-resolved Bun, even with Finder's PATH. */
export async function runLocalIntelligenceCli(
args: string[],
directory: string,
options: {
signal?: AbortSignal;
environment?: NodeJS.ProcessEnv;
home?: string;
timeoutMs?: number;
globalRoots?: string[];
} = {},
): Promise<CliResult> {
options.signal?.throwIfAborted();
const environment = options.environment ?? process.env;
const entry = await localCliEntrypoint(
directory,
environment,
options.home,
options.globalRoots,
);
options.signal?.throwIfAborted();
if (!entry) return { exitCode: 127, stdout: "" };
return new Promise((resolveResult, reject) => {
const child = spawn(process.execPath, [entry, ...args], {
cwd: directory,
env: cliEnvironment(environment),
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
const abort = () => child.kill("SIGKILL");
options.signal?.addEventListener("abort", abort, { once: true });
if (options.signal?.aborted) child.kill("SIGKILL");
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, options.timeoutMs ?? 10_000);
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout = (stdout + chunk).slice(0, 1_048_576);
});
child.stderr.resume();
const finish = (exitCode: number) => {
clearTimeout(timer);
options.signal?.removeEventListener("abort", abort);
if (options.signal?.aborted) reject(options.signal.reason);
else resolveResult({ exitCode: timedOut ? 1 : exitCode, stdout });
};
child.on("error", () => finish(127));
child.on("close", (code) => finish(code ?? 1));
});
}
Loading
Loading