diff --git a/README.md b/README.md index 7d5b936777..a1cff39412 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/scripts/self-hosted-learning.test.ts b/scripts/self-hosted-learning.test.ts index c0c182c6a7..597bfbd9e8 100644 --- a/scripts/self-hosted-learning.test.ts +++ b/scripts/self-hosted-learning.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { intelligenceApiOrigin, + learningSessionOrigin, type SetupBrowser, setupSelfHostedLearning, } from "./self-hosted-learning"; @@ -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; @@ -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 }); @@ -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); +}); diff --git a/scripts/self-hosted-learning.ts b/scripts/self-hosted-learning.ts index 4e76b2a3a5..f52e16bc88 100644 --- a/scripts/self-hosted-learning.ts +++ b/scripts/self-hosted-learning.ts @@ -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 = { @@ -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 { + 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 { signal.throwIfAborted(); const missingBrowser = (error: unknown) => @@ -139,6 +195,7 @@ export async function setupSelfHostedLearning(options: { ) => Promise; signal?: AbortSignal; openBrowser?: (signal: AbortSignal) => Promise; + runCli?: RunCli; pollIntervalMs?: number; timeoutMs?: number; }): Promise { @@ -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(); @@ -174,16 +233,16 @@ export async function setupSelfHostedLearning(options: { data?: Record, ) => { 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");