Skip to content

Commit 044e9ec

Browse files
committed
fix(setup): authenticate local Intelligence through its dashboard
1 parent f1961c3 commit 044e9ec

3 files changed

Lines changed: 174 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),
9898
For fresh self-hosted setup, set `INTELLIGENCE_API_URL` to that deployment's
9999
HTTPS API origin (or `http://localhost` for a local one) and run the helper
100100
without the two `npx` commands. It opens an isolated Chrome or Edge window for
101-
your usual sign-in and project choice; install either browser first. It writes
101+
your usual sign-in and project choice; install either browser first. For CLI-managed
102+
local evaluation installs, it discovers the matching dashboard with `copilotkit local list`
103+
and uses that dashboard for login and session requests. Complete the local auth
104+
approval shown in the browser when prompted. The runtime API URL stays unchanged. It writes
102105
the API URL, the key and the container, not `INTELLIGENCE_GATEWAY_WS_URL`, so
103106
set that one to the same deployment yourself. Once `openbot` exists, the server assigns it
104107
as the default by itself unless [Admin → Automatic Learning](docs/automatic-learning.md)

‎scripts/self-hosted-learning.test.ts‎

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect, test } from "bun:test";
22
import {
33
intelligenceApiOrigin,
4+
learningSessionOrigin,
45
type SetupBrowser,
56
setupSelfHostedLearning,
67
} from "./self-hosted-learning";
@@ -14,6 +15,7 @@ type Response = { status: number; body: unknown };
1415

1516
function fixture(
1617
override?: (url: string, method: string) => Response | undefined,
18+
sessionOrigin = apiUrl,
1719
) {
1820
const calls: {
1921
url: string;
@@ -27,8 +29,8 @@ function fixture(
2729
navigations.push(url);
2830
},
2931
request: async (url, options) => {
30-
expect(new URL(url).origin).toBe(apiUrl);
31-
expect(options.headers.Origin).toBe(apiUrl);
32+
expect(new URL(url).origin).toBe(sessionOrigin);
33+
expect(options.headers.Origin).toBe(sessionOrigin);
3234
expect(options.headers).not.toHaveProperty("Authorization");
3335
expect(options.maxRedirects).toBe(0);
3436
calls.push({ url, method: options.method, data: options.data });
@@ -255,3 +257,106 @@ test("API origins reject credentials, paths, and insecure non-loopback servers",
255257
expect(() => intelligenceApiOrigin(value)).toThrow();
256258
}
257259
});
260+
261+
const localApi = "http://localhost:61860";
262+
const localDashboard = "http://cpk.localhost:61861";
263+
const localStatus = (api = localApi, dashboard = localDashboard) => ({
264+
status: "completed",
265+
stacks: [{ urls: { api, dashboard } }],
266+
});
267+
268+
test("local setup signs in and provisions through the dashboard while preserving the runtime API", async () => {
269+
const f = fixture(undefined, localDashboard);
270+
const connection = await setupSelfHostedLearning({
271+
...f.options,
272+
apiUrl: localApi,
273+
existingKey: "cpk-existing-runtime-key",
274+
runCli: async (args) => {
275+
expect(args).toEqual(["local", "list", "--json"]);
276+
return { exitCode: 0, stdout: JSON.stringify(localStatus()) };
277+
},
278+
});
279+
const login = new URL(f.navigations[0]);
280+
expect(login.origin).toBe(localDashboard);
281+
expect(login.pathname).toBe("/auth/signin");
282+
expect(login.searchParams.get("callbackUrl")).toBe(`${localDashboard}/`);
283+
expect(connection).toEqual({
284+
apiUrl: localApi,
285+
apiKey: "cpk-existing-runtime-key",
286+
learningContainerId: "openbot",
287+
});
288+
expect(
289+
f.calls.some(
290+
(call) =>
291+
call.url === `${localDashboard}${path}` && call.method === "POST",
292+
),
293+
).toBe(true);
294+
expect(f.closed()).toBe(1);
295+
});
296+
297+
test.each([
298+
localStatus(localApi, "https://unrelated.example"),
299+
localStatus(localApi, "http://cpk.localhost.evil.example:61861"),
300+
localStatus(localApi, "http://user:password@cpk.localhost:61861"),
301+
localStatus(localApi, `${localDashboard}/unexpected`),
302+
{ status: "error" },
303+
])(
304+
"invalid or mismatched local status fails before opening a browser: %j",
305+
async (status) => {
306+
const f = fixture();
307+
await expect(
308+
setupSelfHostedLearning({
309+
...f.options,
310+
apiUrl: localApi,
311+
runCli: async () => ({ exitCode: 0, stdout: JSON.stringify(status) }),
312+
}),
313+
).rejects.toThrow("Could not identify the local Intelligence dashboard");
314+
expect(f.navigations).toHaveLength(0);
315+
expect(f.calls).toHaveLength(0);
316+
},
317+
);
318+
319+
test("remote setup does not run the local CLI", async () => {
320+
expect(
321+
await learningSessionOrigin(apiUrl, async () => {
322+
throw new Error("must not run");
323+
}),
324+
).toBe(apiUrl);
325+
});
326+
327+
test.each([
328+
{ exitCode: 1, stdout: "private diagnostic" },
329+
{ exitCode: 0, stdout: "invalid JSON" },
330+
])(
331+
"local discovery errors are actionable and do not disclose CLI output",
332+
async (result) => {
333+
await expect(
334+
learningSessionOrigin(localApi, async () => result),
335+
).rejects.toThrow("Could not identify the local Intelligence dashboard");
336+
},
337+
);
338+
339+
test("an unrelated local stack does not change a manually hosted deployment", async () => {
340+
expect(
341+
await learningSessionOrigin(localApi, async () => ({
342+
exitCode: 0,
343+
stdout: JSON.stringify(localStatus("http://localhost:9999")),
344+
})),
345+
).toBe(localApi);
346+
});
347+
348+
test("multiple stacks are matched by API origin, not selection or list order", async () => {
349+
expect(
350+
await learningSessionOrigin(localApi, async () => ({
351+
exitCode: 0,
352+
stdout: JSON.stringify({
353+
status: "completed",
354+
stacks: [
355+
...localStatus("http://localhost:9999", "http://cpk.localhost:9998")
356+
.stacks,
357+
...localStatus().stacks,
358+
],
359+
}),
360+
})),
361+
).toBe(localDashboard);
362+
});

‎scripts/self-hosted-learning.ts‎

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createInterface } from "node:readline";
22
import { setTimeout as delay } from "node:timers/promises";
33
import { parseArgs } from "node:util";
44
import { chromium } from "playwright-core";
5+
import { runCli, type RunCli } from "../server/src/learning/ensure-container";
56

67
export type SetupProject = { id: string; name: string };
78
export type LearningConnection = {
@@ -59,6 +60,61 @@ export function intelligenceApiOrigin(value: string): string {
5960
return url.origin;
6061
}
6162

63+
/** Local evaluation authenticates and proxies session requests on its dashboard origin. */
64+
export async function learningSessionOrigin(
65+
apiUrl: string,
66+
run: RunCli = runCli,
67+
): Promise<string> {
68+
if (!["localhost", "127.0.0.1", "[::1]"].includes(new URL(apiUrl).hostname))
69+
return apiUrl;
70+
try {
71+
const result = await run(["local", "list", "--json"], process.cwd());
72+
const status: unknown = JSON.parse(result.stdout);
73+
if (
74+
result.exitCode !== 0 ||
75+
!object(status) ||
76+
status.status !== "completed" ||
77+
!Array.isArray(status.stacks)
78+
)
79+
throw new Error();
80+
const matches = status.stacks.filter(
81+
(stack: unknown) =>
82+
object(stack) &&
83+
object(stack.urls) &&
84+
typeof stack.urls.api === "string" &&
85+
new URL(stack.urls.api).origin === apiUrl,
86+
);
87+
// A manually hosted local deployment has no CLI record; retain its existing API login flow.
88+
if (matches.length === 0) return apiUrl;
89+
if (matches.length !== 1) throw new Error();
90+
const stack: unknown = matches[0];
91+
if (
92+
!object(stack) ||
93+
!object(stack.urls) ||
94+
typeof stack.urls.dashboard !== "string"
95+
)
96+
throw new Error();
97+
const dashboard = new URL(stack.urls.dashboard);
98+
if (
99+
dashboard.protocol !== "http:" ||
100+
!["localhost", "cpk.localhost", "127.0.0.1", "[::1]"].includes(
101+
dashboard.hostname,
102+
) ||
103+
dashboard.username ||
104+
dashboard.password ||
105+
dashboard.pathname !== "/" ||
106+
dashboard.search ||
107+
dashboard.hash
108+
)
109+
throw new Error();
110+
return dashboard.origin;
111+
} catch {
112+
throw new SetupError(
113+
"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.",
114+
);
115+
}
116+
}
117+
62118
async function openBrowser(signal: AbortSignal): Promise<SetupBrowser> {
63119
signal.throwIfAborted();
64120
const missingBrowser = (error: unknown) =>
@@ -139,6 +195,7 @@ export async function setupSelfHostedLearning(options: {
139195
) => Promise<string>;
140196
signal?: AbortSignal;
141197
openBrowser?: (signal: AbortSignal) => Promise<SetupBrowser>;
198+
runCli?: RunCli;
142199
pollIntervalMs?: number;
143200
timeoutMs?: number;
144201
}): Promise<LearningConnection> {
@@ -164,6 +221,8 @@ export async function setupSelfHostedLearning(options: {
164221
};
165222
signal.addEventListener("abort", cancel, { once: true });
166223
try {
224+
signal.throwIfAborted();
225+
const sessionOrigin = await learningSessionOrigin(apiUrl, options.runCli);
167226
signal.throwIfAborted();
168227
session = await (options.openBrowser ?? openBrowser)(signal);
169228
signal.throwIfAborted();
@@ -174,16 +233,16 @@ export async function setupSelfHostedLearning(options: {
174233
data?: Record<string, string>,
175234
) => {
176235
signal.throwIfAborted();
177-
return browser.request(`${apiUrl}${path}`, {
236+
return browser.request(`${sessionOrigin}${path}`, {
178237
method,
179-
headers: { Origin: apiUrl, Accept: "application/json" },
238+
headers: { Origin: sessionOrigin, Accept: "application/json" },
180239
...(data ? { data } : {}),
181240
maxRedirects: 0,
182241
signal,
183242
});
184243
};
185-
const signIn = new URL("/auth/signin", apiUrl);
186-
signIn.searchParams.set("callbackUrl", `${apiUrl}/`);
244+
const signIn = new URL("/auth/signin", sessionOrigin);
245+
signIn.searchParams.set("callbackUrl", `${sessionOrigin}/`);
187246
await session.navigate(signIn.toString());
188247
while (true) {
189248
const me = await request("/api/me");

0 commit comments

Comments
 (0)