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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ 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 `copilotkit local list`
and uses that dashboard for login and session requests. 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
109 changes: 107 additions & 2 deletions scripts/self-hosted-learning.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import {
intelligenceApiOrigin,
learningSessionOrigin,
type SetupBrowser,
setupSelfHostedLearning,
} from "./self-hosted-learning";
Expand All @@ -14,6 +15,7 @@ type Response = { status: number; body: unknown };

function fixture(
override?: (url: string, method: string) => Response | undefined,
sessionOrigin = apiUrl,
) {
const calls: {
url: string;
Expand All @@ -27,8 +29,8 @@ function fixture(
navigations.push(url);
},
request: async (url, options) => {
expect(new URL(url).origin).toBe(apiUrl);
expect(options.headers.Origin).toBe(apiUrl);
expect(new URL(url).origin).toBe(sessionOrigin);
expect(options.headers.Origin).toBe(sessionOrigin);
expect(options.headers).not.toHaveProperty("Authorization");
expect(options.maxRedirects).toBe(0);
calls.push({ url, method: options.method, data: options.data });
Expand Down Expand Up @@ -255,3 +257,106 @@ test("API origins reject credentials, paths, and insecure non-loopback servers",
expect(() => intelligenceApiOrigin(value)).toThrow();
}
});

const localApi = "http://localhost:61860";
const localDashboard = "http://cpk.localhost:61861";
const localStatus = (api = localApi, dashboard = localDashboard) => ({
status: "completed",
stacks: [{ urls: { api, dashboard } }],
});

test("local setup signs in and provisions through the dashboard while preserving the runtime API", async () => {
const f = fixture(undefined, localDashboard);
const connection = await setupSelfHostedLearning({
...f.options,
apiUrl: localApi,
existingKey: "cpk-existing-runtime-key",
runCli: async (args) => {
expect(args).toEqual(["local", "list", "--json"]);
return { exitCode: 0, stdout: JSON.stringify(localStatus()) };
},
});
const login = new URL(f.navigations[0]);
expect(login.origin).toBe(localDashboard);
expect(login.pathname).toBe("/auth/signin");
expect(login.searchParams.get("callbackUrl")).toBe(`${localDashboard}/`);
expect(connection).toEqual({
apiUrl: localApi,
apiKey: "cpk-existing-runtime-key",
learningContainerId: "openbot",
});
expect(
f.calls.some(
(call) =>
call.url === `${localDashboard}${path}` && call.method === "POST",
),
).toBe(true);
expect(f.closed()).toBe(1);
});

test.each([
localStatus(localApi, "https://unrelated.example"),
localStatus(localApi, "http://cpk.localhost.evil.example:61861"),
localStatus(localApi, "http://user:password@cpk.localhost:61861"),
localStatus(localApi, `${localDashboard}/unexpected`),
{ status: "error" },
])(
"invalid or mismatched local status fails before opening a browser: %j",
async (status) => {
const f = fixture();
await expect(
setupSelfHostedLearning({
...f.options,
apiUrl: localApi,
runCli: async () => ({ exitCode: 0, stdout: JSON.stringify(status) }),
}),
).rejects.toThrow("Could not identify the local Intelligence dashboard");
expect(f.navigations).toHaveLength(0);
expect(f.calls).toHaveLength(0);
},
);

test("remote setup does not run the local CLI", async () => {
expect(
await learningSessionOrigin(apiUrl, async () => {
throw new Error("must not run");
}),
).toBe(apiUrl);
});

test.each([
{ exitCode: 1, stdout: "private diagnostic" },
{ exitCode: 0, stdout: "invalid JSON" },
])(
"local discovery errors are actionable and do not disclose CLI output",
async (result) => {
await expect(
learningSessionOrigin(localApi, async () => result),
).rejects.toThrow("Could not identify the local Intelligence dashboard");
},
);

test("an unrelated local stack does not change a manually hosted deployment", async () => {
expect(
await learningSessionOrigin(localApi, async () => ({
exitCode: 0,
stdout: JSON.stringify(localStatus("http://localhost:9999")),
})),
).toBe(localApi);
});

test("multiple stacks are matched by API origin, not selection or list order", async () => {
expect(
await learningSessionOrigin(localApi, async () => ({
exitCode: 0,
stdout: JSON.stringify({
status: "completed",
stacks: [
...localStatus("http://localhost:9999", "http://cpk.localhost:9998")
.stacks,
...localStatus().stacks,
],
}),
})),
).toBe(localDashboard);
});
67 changes: 63 additions & 4 deletions scripts/self-hosted-learning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createInterface } from "node:readline";
import { setTimeout as delay } from "node:timers/promises";
import { parseArgs } from "node:util";
import { chromium } from "playwright-core";
import { runCli, type RunCli } from "../server/src/learning/ensure-container";

export type SetupProject = { id: string; name: string };
export type LearningConnection = {
Expand Down Expand Up @@ -59,6 +60,61 @@ export function intelligenceApiOrigin(value: string): string {
return url.origin;
}

/** Local evaluation authenticates and proxies session requests on its dashboard origin. */
export async function learningSessionOrigin(
apiUrl: string,
run: RunCli = runCli,
): Promise<string> {
if (!["localhost", "127.0.0.1", "[::1]"].includes(new URL(apiUrl).hostname))
return apiUrl;
try {
const result = await run(["local", "list", "--json"], process.cwd());
const status: unknown = JSON.parse(result.stdout);
if (
result.exitCode !== 0 ||
!object(status) ||
status.status !== "completed" ||
!Array.isArray(status.stacks)
)
throw new Error();
const matches = status.stacks.filter(
(stack: unknown) =>
object(stack) &&
object(stack.urls) &&
typeof stack.urls.api === "string" &&
new URL(stack.urls.api).origin === apiUrl,
);
// A manually hosted local deployment has no CLI record; retain its existing API login flow.
if (matches.length === 0) return apiUrl;
if (matches.length !== 1) throw new Error();
const stack: unknown = matches[0];
if (
!object(stack) ||
!object(stack.urls) ||
typeof stack.urls.dashboard !== "string"
)
throw new Error();
const dashboard = new URL(stack.urls.dashboard);
if (
dashboard.protocol !== "http:" ||
!["localhost", "cpk.localhost", "127.0.0.1", "[::1]"].includes(
dashboard.hostname,
) ||
dashboard.username ||
dashboard.password ||
dashboard.pathname !== "/" ||
dashboard.search ||
dashboard.hash
)
throw new Error();
return dashboard.origin;
} catch {
throw new SetupError(
"Could not identify the local Intelligence dashboard for this API. Run copilotkit local list --json and check the saved dashboard URL for INTELLIGENCE_API_URL.",
);
}
}

async function openBrowser(signal: AbortSignal): Promise<SetupBrowser> {
signal.throwIfAborted();
const missingBrowser = (error: unknown) =>
Expand Down Expand Up @@ -139,6 +195,7 @@ export async function setupSelfHostedLearning(options: {
) => Promise<string>;
signal?: AbortSignal;
openBrowser?: (signal: AbortSignal) => Promise<SetupBrowser>;
runCli?: RunCli;
pollIntervalMs?: number;
timeoutMs?: number;
}): Promise<LearningConnection> {
Expand All @@ -164,6 +221,8 @@ export async function setupSelfHostedLearning(options: {
};
signal.addEventListener("abort", cancel, { once: true });
try {
signal.throwIfAborted();
const sessionOrigin = await learningSessionOrigin(apiUrl, options.runCli);
signal.throwIfAborted();
session = await (options.openBrowser ?? openBrowser)(signal);
signal.throwIfAborted();
Expand All @@ -174,16 +233,16 @@ export async function setupSelfHostedLearning(options: {
data?: Record<string, string>,
) => {
signal.throwIfAborted();
return browser.request(`${apiUrl}${path}`, {
return browser.request(`${sessionOrigin}${path}`, {
method,
headers: { Origin: apiUrl, Accept: "application/json" },
headers: { Origin: sessionOrigin, Accept: "application/json" },
...(data ? { data } : {}),
maxRedirects: 0,
signal,
});
};
const signIn = new URL("/auth/signin", apiUrl);
signIn.searchParams.set("callbackUrl", `${apiUrl}/`);
const signIn = new URL("/auth/signin", sessionOrigin);
signIn.searchParams.set("callbackUrl", `${sessionOrigin}/`);
await session.navigate(signIn.toString());
while (true) {
const me = await request("/api/me");
Expand Down
Loading