${state.providers.length === 0 ? html`
No providers available.
` : state.providers.map((provider) => this.renderProviderButton(provider))}
`;
@@ -86,22 +86,35 @@ export class AuthDialog extends LitElement {
const flow = state.flow;
const prompt = flow.prompt;
const select = flow.select;
+ const promptInputType = oauthPromptInputType(prompt?.promptType);
return html`
${flow.auth !== undefined ? html`
Open this authorization link:
${flow.auth.url}
- ${flow.auth.instructions !== undefined ? html`
${flow.auth.instructions}
` : null}
+ ${flow.auth.deviceCode !== undefined ? html`
+
Enter code: ${flow.auth.deviceCode.userCode}
+ ` : flow.auth.instructions !== undefined ? html`
${flow.auth.instructions}
` : null}
` : html`
Starting login flow…
`}
${flow.progress.length > 0 ? html`
${flow.progress.map((line) => html`${line} `)} ` : null}
+ ${flow.info?.map((item) => item.links === undefined || item.links.length === 0 ? null : html`
+
+ `) ?? null}
${prompt !== undefined ? html`
${prompt.message}
-
{ if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}>
+
{ if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}>
{ this.onOAuthCancel?.(); }}>Cancel { this.onOAuthRespond?.(); }}>Submit
` : null}
${select !== undefined ? html`
${select.message}
-
${select.options.map((option) => html` { this.onOAuthRespond?.(option.value); }}>${option.label} `)}
+
${select.options.map((option) => html`
+ { this.onOAuthRespond?.(option.value); }}>
+ ${option.label}
+ ${option.description === undefined ? null : html`${option.description} `}
+
+ `)}
` : null}
${state.error !== undefined && state.error !== "" ? html`
${state.error}
` : null}
${flow.status === "error" || flow.status === "cancelled" ? html`
${flow.error ?? flow.status}
{ this.cancel(); }}>Close
` : null}
@@ -157,13 +170,20 @@ export class AuthDialog extends LitElement {
.warning { color: var(--pi-warning); }
.error-text { color: var(--pi-danger); }
.progress { margin: 0; padding-left: 18px; color: var(--pi-muted); }
+ .info-links { display: flex; flex-wrap: wrap; gap: 8px 12px; }
.inline-options { display: grid; gap: 8px; }
+ .inline-options button { display: grid; gap: 2px; text-align: left; }
+ .inline-options small { color: var(--pi-muted); }
em { color: var(--pi-success); font-style: normal; font-size: 12px; }
`];
}
+export function oauthPromptInputType(promptType: NonNullable
["promptType"]): "text" | "password" {
+ return promptType === "secret" ? "password" : "text";
+}
+
function authTypeLabel(authType: "oauth" | "api_key"): string {
- return authType === "oauth" ? "subscription" : "API key";
+ return authType === "oauth" ? "subscription" : "credentials";
}
function focusKey(state: AuthDialogState | undefined): string | undefined {
diff --git a/src/client/src/controllers/authController.test.ts b/src/client/src/controllers/authController.test.ts
index e344174..05999d5 100644
--- a/src/client/src/controllers/authController.test.ts
+++ b/src/client/src/controllers/authController.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import { api as defaultApi, type AuthProviderOption, type OAuthFlowState, type SessionInfo, type SessionStatus } from "../api";
import { initialAppState, type AppState } from "../appState";
import { AuthController, parseAuthSlashCommand } from "./authController";
@@ -31,6 +31,34 @@ describe("AuthController", () => {
expect(getState().authDialog).toMatchObject({ step: "apiKey", provider: { id: "anthropic", authType: "api_key" } });
});
+ it("starts provider-driven API-key interactions instead of opening the legacy one-secret form", async () => {
+ vi.stubGlobal("window", { setInterval: () => 1, clearInterval: () => undefined });
+ const provider: AuthProviderOption = { ...authProvider("amazon-bedrock", "api_key"), loginFlow: "interactive" };
+ const calls: { providerId: string; machineId: string | undefined }[] = [];
+ const { controller, getState } = createController(
+ { authDialog: { step: "providers", mode: "login", authType: "api_key", providers: [provider] } },
+ {
+ startInteractiveApiKeyLogin: (providerId, machineId) => {
+ calls.push({ providerId, machineId });
+ return Promise.resolve(oauthFlow({ providerId, providerName: "Amazon Bedrock", select: { requestId: "request-1", message: "Choose method", options: [] } }));
+ },
+ },
+ );
+
+ try {
+ await controller.selectLoginProvider(provider.id, "api_key");
+
+ expect(calls).toEqual([{ providerId: "amazon-bedrock", machineId: "local" }]);
+ expect(getState().authDialog).toMatchObject({
+ step: "oauth",
+ flow: { providerId: "amazon-bedrock", select: { requestId: "request-1" } },
+ });
+ } finally {
+ controller.dispose();
+ vi.unstubAllGlobals();
+ }
+ });
+
it("keeps OAuth prompt input and submit state across poll refreshes for the same request", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const { controller, getState } = createController(
@@ -43,6 +71,26 @@ describe("AuthController", () => {
expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true });
});
+ it("submits an allowed blank OAuth text response without client-side rejection", async () => {
+ const flow = oauthFlow({
+ prompt: { requestId: "request-1", message: "GitHub Enterprise URL/domain (blank for github.com)", kind: "prompt", promptType: "text", allowEmpty: true },
+ });
+ const respondCalls: string[] = [];
+ const { controller } = createController(
+ { authDialog: { step: "oauth", flow, inputValue: "" } },
+ {
+ respondOAuthFlow: (_flowId, _requestId, value) => {
+ respondCalls.push(value);
+ return Promise.resolve(oauthFlow({ status: "complete" }));
+ },
+ },
+ );
+
+ await controller.respondOAuth();
+
+ expect(respondCalls).toEqual([""]);
+ });
+
it("resets OAuth prompt input and submit state when the request id changes", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const { controller, getState } = createController(
@@ -114,6 +162,108 @@ describe("AuthController", () => {
});
});
+ it("does not recreate an OAuth dialog when a pending response settles during cancellation", async () => {
+ const prompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const;
+ const flow = oauthFlow({ prompt });
+ const response = deferred();
+ const cancellation = deferred();
+ const { controller, getState } = createController(
+ { authDialog: { step: "oauth", flow, inputValue: "https://callback" } },
+ {
+ respondOAuthFlow: () => response.promise,
+ cancelOAuthFlow: () => cancellation.promise,
+ },
+ );
+
+ const responsePending = controller.respondOAuth();
+ const cancellationPending = controller.cancelOAuth();
+ const dialogAfterCancel = getState().authDialog;
+
+ response.resolve(oauthFlow({ prompt, progress: ["Stale response"] }));
+ await responsePending;
+ const dialogAfterResponse = getState().authDialog;
+
+ cancellation.resolve(oauthFlow({ status: "cancelled" }));
+ await cancellationPending;
+
+ expect(dialogAfterCancel).toBeUndefined();
+ expect(dialogAfterResponse).toBeUndefined();
+ expect(getState().authDialog).toBeUndefined();
+ });
+
+ it("does not let a stale OAuth response overwrite a newer flow", async () => {
+ vi.stubGlobal("window", { setInterval: () => 1, clearInterval: () => undefined });
+ const oldPrompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const;
+ const oldFlow = oauthFlow({ prompt: oldPrompt });
+ const newFlow = oauthFlow({ flowId: "flow-2", prompt: { requestId: "request-2", message: "Paste callback", kind: "manual" } });
+ const response = deferred();
+ const providers = [authProvider("anthropic", "oauth")];
+ const { controller, getState } = createController(
+ { authDialog: { step: "oauth", flow: oldFlow, inputValue: "https://old-callback" } },
+ {
+ respondOAuthFlow: () => response.promise,
+ authProviders: () => Promise.resolve({ providers }),
+ startOAuthLogin: () => Promise.resolve(newFlow),
+ },
+ );
+
+ try {
+ const responsePending = controller.respondOAuth();
+ await controller.openLogin("anthropic");
+ const dialogAfterNewFlow = getState().authDialog;
+
+ response.resolve(oauthFlow({ prompt: oldPrompt, progress: ["Stale response"] }));
+ await responsePending;
+
+ expect(dialogAfterNewFlow).toMatchObject({ step: "oauth", flow: { flowId: "flow-2" } });
+ expect(getState().authDialog).toMatchObject({ step: "oauth", flow: { flowId: "flow-2" } });
+ } finally {
+ response.resolve(oldFlow);
+ controller.dispose();
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("does not let an older poll restore a running flow after a newer poll stops polling", async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal("window", { setInterval: globalThis.setInterval, clearInterval: globalThis.clearInterval });
+ const prompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const;
+ const runningFlow = oauthFlow({ prompt });
+ const stalePoll = deferred();
+ const providers = [authProvider("anthropic", "oauth")];
+ let pollCalls = 0;
+ const { controller, getState } = createController(
+ {},
+ {
+ authProviders: () => Promise.resolve({ providers }),
+ startOAuthLogin: () => Promise.resolve(runningFlow),
+ oauthFlow: () => {
+ pollCalls += 1;
+ return pollCalls === 1 ? stalePoll.promise : Promise.resolve(oauthFlow({ status: "cancelled", prompt }));
+ },
+ },
+ );
+
+ try {
+ await controller.openLogin("anthropic");
+ await vi.advanceTimersByTimeAsync(1000);
+ await vi.advanceTimersByTimeAsync(1000);
+ const dialogAfterPollingStopped = getState().authDialog;
+
+ stalePoll.resolve(oauthFlow({ prompt, progress: ["Stale running poll"] }));
+ await flushMicrotasks();
+
+ expect(pollCalls).toBe(2);
+ expect(dialogAfterPollingStopped).toMatchObject({ step: "oauth", flow: { status: "cancelled" } });
+ expect(getState().authDialog).toMatchObject({ step: "oauth", flow: { status: "cancelled" } });
+ } finally {
+ stalePoll.resolve(runningFlow);
+ controller.dispose();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ }
+ });
+
it("cancels the active OAuth flow and closes the dialog even when cancellation fails", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const cancelCalls: { flowId: string; machineId: string | undefined }[] = [];
@@ -226,6 +376,13 @@ async function flushMicrotasks(): Promise {
await Promise.resolve();
}
+function deferred(): { promise: Promise; resolve: (value: T) => void } {
+ let resolveDeferred: ((value: T) => void) | undefined;
+ const promise = new Promise((resolve) => { resolveDeferred = resolve; });
+ if (resolveDeferred === undefined) throw new Error("Deferred promise was not initialized");
+ return { promise, resolve: resolveDeferred };
+}
+
function remoteMachine(id: string): NonNullable {
return {
id,
diff --git a/src/client/src/controllers/authController.ts b/src/client/src/controllers/authController.ts
index fe1b209..d9e2ba6 100644
--- a/src/client/src/controllers/authController.ts
+++ b/src/client/src/controllers/authController.ts
@@ -1,6 +1,9 @@
import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api";
+import type { AuthDialogState } from "../appState";
import { selectedMachineId, type GetState, type SetState } from "./types";
+type OAuthDialogState = Extract;
+
export interface AuthControllerDependencies {
api?: typeof defaultApi;
pollIntervalMs?: number;
@@ -9,6 +12,8 @@ export interface AuthControllerDependencies {
export class AuthController {
private readonly api: typeof defaultApi;
private readonly pollIntervalMs: number;
+ private oauthOperationGeneration = 0;
+ private pollGeneration = 0;
private pollTimer: number | undefined;
constructor(
@@ -22,6 +27,7 @@ export class AuthController {
}
dispose(): void {
+ this.oauthOperationGeneration += 1;
this.stopPolling();
}
@@ -55,7 +61,7 @@ export class AuthController {
if (dialog?.step !== "providers") return;
const provider = dialog.providers.find((candidate) => candidate.id === providerId && (authType === undefined || candidate.authType === authType));
if (provider === undefined) return;
- if (provider.authType === "oauth") await this.startOAuth(provider);
+ if (provider.authType === "oauth" || provider.loginFlow === "interactive") await this.startLoginFlow(provider);
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
}
@@ -128,15 +134,22 @@ export class AuthController {
if (dialog?.step !== "oauth") return;
const request = dialog.flow.prompt ?? dialog.flow.select;
if (request === undefined) return;
+ const operationGeneration = this.oauthOperationGeneration;
+ const flowId = dialog.flow.flowId;
+ const requestId = request.requestId;
const responseValue = value ?? dialog.inputValue ?? "";
const clean = { ...dialog };
delete clean.error;
this.setState({ authDialog: { ...clean, responding: true } });
try {
- const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue, selectedMachineId(this.getState()));
+ const flow = await this.api.respondOAuthFlow(flowId, requestId, responseValue, selectedMachineId(this.getState()));
+ const current = this.currentOAuthDialog(operationGeneration, flowId);
+ if (flow.flowId !== flowId || current === undefined || oauthRequestId(current.flow) !== requestId) return;
this.updateOAuthFlow(flow);
} catch (error) {
- this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } });
+ const current = this.currentOAuthDialog(operationGeneration, flowId);
+ if (current === undefined || oauthRequestId(current.flow) !== requestId) return;
+ this.setState({ authDialog: { ...current, responding: false, error: String(error) } });
}
}
@@ -146,16 +159,18 @@ export class AuthController {
this.closeDialog();
return;
}
- this.stopPolling();
- try {
- await this.api.cancelOAuthFlow(dialog.flow.flowId, selectedMachineId(this.getState()));
- } catch {
- // Best-effort cancel. The dialog closes either way.
- }
+ const flowId = dialog.flow.flowId;
+ const machineId = selectedMachineId(this.getState());
this.closeDialog();
+ try {
+ await this.api.cancelOAuthFlow(flowId, machineId);
+ } catch {
+ // Best-effort cancel. The dialog is already closed either way.
+ }
}
closeDialog(): void {
+ this.oauthOperationGeneration += 1;
this.stopPolling();
this.setState({ authDialog: undefined });
}
@@ -174,21 +189,27 @@ export class AuthController {
}
const provider = exact[0];
if (provider === undefined) return;
- if (provider.authType === "oauth") await this.startOAuth(provider);
+ if (provider.authType === "oauth" || provider.loginFlow === "interactive") await this.startLoginFlow(provider);
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
} catch (error) {
this.setState({ error: String(error) });
}
}
- private async startOAuth(provider: AuthProviderOption): Promise {
+ private async startLoginFlow(provider: AuthProviderOption): Promise {
if (this.rejectRemoteOAuth("login", provider)) return;
+ const operationGeneration = ++this.oauthOperationGeneration;
+ this.stopPolling();
try {
- const flow = await this.api.startOAuthLogin(provider.id, selectedMachineId(this.getState()));
+ const machineId = selectedMachineId(this.getState());
+ const flow = provider.authType === "oauth"
+ ? await this.api.startOAuthLogin(provider.id, machineId)
+ : await this.api.startInteractiveApiKeyLogin(provider.id, machineId);
+ if (operationGeneration !== this.oauthOperationGeneration) return;
this.updateOAuthFlow(flow);
- this.startPolling(flow.flowId);
+ if (flow.status === "running") this.startPolling(flow.flowId);
} catch (error) {
- this.setState({ error: String(error) });
+ if (operationGeneration === this.oauthOperationGeneration) this.setState({ error: String(error) });
}
}
@@ -207,11 +228,14 @@ export class AuthController {
void this.refreshStatus();
return;
}
- if (flow.status === "error" || flow.status === "cancelled") this.stopPolling();
+ if (flow.status === "error" || flow.status === "cancelled") {
+ this.oauthOperationGeneration += 1;
+ this.stopPolling();
+ }
const existing = this.getState().authDialog;
const previousInput = existing?.step === "oauth" && existing.flow.flowId === flow.flowId ? existing.inputValue ?? "" : "";
- const previousRequestId = existing?.step === "oauth" ? existing.flow.prompt?.requestId ?? existing.flow.select?.requestId : undefined;
- const newRequestId = flow.prompt?.requestId ?? flow.select?.requestId;
+ const previousRequestId = existing?.step === "oauth" ? oauthRequestId(existing.flow) : undefined;
+ const newRequestId = oauthRequestId(flow);
const sameRequest = previousRequestId !== undefined && previousRequestId === newRequestId;
const inputValue = sameRequest ? previousInput : "";
const responding = sameRequest && existing?.step === "oauth" ? existing.responding === true : false;
@@ -220,29 +244,45 @@ export class AuthController {
private startPolling(flowId: string): void {
this.stopPolling();
- this.pollTimer = window.setInterval(() => { void this.poll(flowId); }, this.pollIntervalMs);
+ const operationGeneration = this.oauthOperationGeneration;
+ const pollGeneration = this.pollGeneration;
+ this.pollTimer = window.setInterval(() => { void this.poll(flowId, operationGeneration, pollGeneration); }, this.pollIntervalMs);
}
private stopPolling(): void {
+ this.pollGeneration += 1;
if (this.pollTimer === undefined) return;
window.clearInterval(this.pollTimer);
this.pollTimer = undefined;
}
- private async poll(flowId: string): Promise {
- const dialog = this.getState().authDialog;
- if (dialog?.step !== "oauth" || dialog.flow.flowId !== flowId) {
+ private async poll(flowId: string, operationGeneration: number, pollGeneration: number): Promise {
+ if (pollGeneration !== this.pollGeneration) return;
+ const dialog = this.currentOAuthDialog(operationGeneration, flowId);
+ if (dialog === undefined) {
this.stopPolling();
return;
}
+ const requestId = oauthRequestId(dialog.flow);
try {
- this.updateOAuthFlow(await this.api.oauthFlow(flowId, selectedMachineId(this.getState())));
+ const flow = await this.api.oauthFlow(flowId, selectedMachineId(this.getState()));
+ const current = this.currentOAuthDialog(operationGeneration, flowId);
+ if (flow.flowId !== flowId || pollGeneration !== this.pollGeneration || current === undefined || oauthRequestId(current.flow) !== requestId) return;
+ this.updateOAuthFlow(flow);
} catch (error) {
+ const current = this.currentOAuthDialog(operationGeneration, flowId);
+ if (pollGeneration !== this.pollGeneration || current === undefined || oauthRequestId(current.flow) !== requestId) return;
this.stopPolling();
- this.setState({ authDialog: { ...dialog, error: String(error) } });
+ this.setState({ authDialog: { ...current, error: String(error) } });
}
}
+ private currentOAuthDialog(operationGeneration: number, flowId: string): OAuthDialogState | undefined {
+ if (operationGeneration !== this.oauthOperationGeneration) return undefined;
+ const dialog = this.getState().authDialog;
+ return dialog?.step === "oauth" && dialog.flow.flowId === flowId ? dialog : undefined;
+ }
+
private async refreshStatus(): Promise {
const session = this.session();
if (session === undefined) return;
@@ -260,6 +300,10 @@ export class AuthController {
}
}
+function oauthRequestId(flow: OAuthFlowState): string | undefined {
+ return flow.prompt?.requestId ?? flow.select?.requestId;
+}
+
export function parseAuthSlashCommand(text: string): { command: "login" | "logout"; providerId?: string } | undefined {
const trimmed = text.trim();
const match = /^\/(login|logout)(?:\s+(\S+))?\s*$/u.exec(trimmed);
diff --git a/src/nativeServices/servicePlan.test.ts b/src/nativeServices/servicePlan.test.ts
index c8b44eb..31dee77 100644
--- a/src/nativeServices/servicePlan.test.ts
+++ b/src/nativeServices/servicePlan.test.ts
@@ -100,7 +100,7 @@ describe("production native service planning", () => {
wants: [],
prerequisites: [
{ id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" },
- { id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 },
+ { id: "sessiond.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" },
],
},
{
@@ -111,7 +111,7 @@ describe("production native service planning", () => {
wants: ["sessiond"],
prerequisites: [
{ id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" },
- { id: "web.node", kind: "node-version", command: "node", minimumMajor: 22 },
+ { id: "web.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" },
],
},
],
@@ -196,7 +196,7 @@ describe("production native service planning", () => {
namedCommandFailure: "command not found",
},
prerequisites: [
- { id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 },
+ { id: "sessiond.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" },
{ id: "sessiond.entrypoint", kind: "readable-file", path: "/package with space/sessiond's entry.js" },
],
});
@@ -324,7 +324,7 @@ describe("development native service planning", () => {
environment: { PI_WEB_CONFIG: "/tmp/config.json" },
workingDirectory: "/checkout with space",
prerequisites: [
- { id: "sessiond.node", kind: "node-version", minimumMajor: 22 },
+ { id: "sessiond.node", kind: "node-version", minimumVersion: "22.19.0" },
{ id: "sessiond.command.npm", kind: "command-available", command: "npm" },
{ id: "sessiond.package-scripts", kind: "package-scripts", scripts: ["start:sessiond"] },
],
@@ -338,7 +338,7 @@ describe("development native service planning", () => {
after: ["sessiond"],
wants: ["sessiond"],
prerequisites: [
- { id: "uiDev.node", kind: "node-version", minimumMajor: 22 },
+ { id: "uiDev.node", kind: "node-version", minimumVersion: "22.19.0" },
{ id: "uiDev.command.npm", kind: "command-available", command: "npm" },
{ id: "uiDev.command.bash", kind: "command-available", command: "bash" },
{ id: "uiDev.package-scripts", kind: "package-scripts", scripts: ["dev:web", "dev:client"] },
diff --git a/src/nativeServices/servicePlan.ts b/src/nativeServices/servicePlan.ts
index 077fc5a..af3507b 100644
--- a/src/nativeServices/servicePlan.ts
+++ b/src/nativeServices/servicePlan.ts
@@ -1,3 +1,5 @@
+export const minimumSupportedNodeVersion = "22.19.0";
+
export type NativeServiceBackendKind = "systemd" | "launchd";
export type NativeServiceMode = "production" | "development";
export type NativeServiceId = "sessiond" | "web" | "uiDev";
@@ -64,7 +66,7 @@ export type NativeServicePrerequisite =
id: string;
kind: "node-version";
command: "node";
- minimumMajor: number;
+ minimumVersion: string;
description: string;
}
| {
@@ -571,8 +573,8 @@ function nodeRequirement(serviceId: NativeServiceId): NativeServicePrerequisite
id: `${serviceId}.node`,
kind: "node-version",
command: "node",
- minimumMajor: 22,
- description: "node >= 22 is available to the service shell",
+ minimumVersion: minimumSupportedNodeVersion,
+ description: `node >= ${minimumSupportedNodeVersion} is available to the service shell`,
};
}
diff --git a/src/nativeServices/serviceProbe.test.ts b/src/nativeServices/serviceProbe.test.ts
index bd1b491..6a3a8f8 100644
--- a/src/nativeServices/serviceProbe.test.ts
+++ b/src/nativeServices/serviceProbe.test.ts
@@ -1,3 +1,4 @@
+import { spawnSync } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import {
LaunchdNativeServiceProbe,
@@ -5,6 +6,7 @@ import {
SystemdNativeServiceProbe,
launchdProbePlist,
nativeServicePrerequisiteShellCheck,
+ nodeVersionCheckScript,
systemdRunArguments,
type LaunchdProbeFileSystem,
type ProbeCommandResult,
@@ -433,13 +435,26 @@ describe("probe service definitions", () => {
id: "sessiond.node",
kind: "node-version",
command: "node",
- minimumMajor: 22,
- description: "node >= 22",
+ minimumVersion: "22.19.0",
+ description: "node >= 22.19.0",
});
expect(check).toContain("\"$pi_web_probe_executable\" '-e'");
+ expect(check).toContain("22.19.0");
expect(check).not.toContain("&& node -e");
});
+ it.each([
+ { version: "21.99.99", accepted: false },
+ { version: "22.18.99", accepted: false },
+ { version: "22.19.0", accepted: true },
+ { version: "22.19.1", accepted: true },
+ { version: "23.0.0", accepted: true },
+ ])("checks the complete Node version for $version", ({ version, accepted }) => {
+ const result = spawnSync(process.execPath, ["-e", nodeVersionCheckScript("22.19.0"), version]);
+ expect(result.error).toBeUndefined();
+ expect(result.status).toBe(accepted ? 0 : 1);
+ });
+
it("requires bundled entrypoints to be readable regular files", () => {
const check = nativeServicePrerequisiteShellCheck("bash", {
id: "sessiond.entrypoint",
diff --git a/src/nativeServices/serviceProbe.ts b/src/nativeServices/serviceProbe.ts
index 8036f8b..12db593 100644
--- a/src/nativeServices/serviceProbe.ts
+++ b/src/nativeServices/serviceProbe.ts
@@ -434,10 +434,8 @@ export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellNam
switch (prerequisite.kind) {
case "command-available":
return externalExecutableShellCheck(shell, prerequisite.command);
- case "node-version": {
- const script = `const major=Number(process.versions.node.split('.')[0]);process.exit(major>=${String(prerequisite.minimumMajor)}?0:1)`;
- return externalExecutableShellCheck(shell, "node", ["-e", script]);
- }
+ case "node-version":
+ return externalExecutableShellCheck(shell, "node", ["-e", nodeVersionCheckScript(prerequisite.minimumVersion)]);
case "readable-file": {
const path = shellQuote(shell, prerequisite.path);
return `test -f ${path} && test -r ${path}`;
@@ -449,6 +447,11 @@ export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellNam
}
}
+export function nodeVersionCheckScript(minimumVersion: string): string {
+ const encodedMinimum = JSON.stringify(minimumVersion);
+ return `const version=process.argv[1]??process.versions.node;console.log(process.version);const current=version.split('.').map(Number);const minimum=${encodedMinimum}.split('.').map(Number);const length=Math.max(current.length,minimum.length);let comparison=0;for(let index=0;indexright?1:-1;break}}process.exit(comparison>=0?0:1)`;
+}
+
function externalExecutableShellCheck(
shell: NativeServiceShellName,
command: string,
@@ -518,7 +521,7 @@ function unsatisfiedDetail(prerequisite: NativeServicePrerequisite): string {
case "command-available":
return `${prerequisite.command} did not resolve to an external executable in the native service environment.`;
case "node-version":
- return `node >= ${String(prerequisite.minimumMajor)} was not available in the native service environment.`;
+ return `node >= ${prerequisite.minimumVersion} was not available in the native service environment.`;
case "readable-file":
return `${prerequisite.path} was not a readable regular file in the native service environment.`;
case "package-scripts":
diff --git a/src/server/realtime/sessionEventHub.test.ts b/src/server/realtime/sessionEventHub.test.ts
index bf190e5..c397dec 100644
--- a/src/server/realtime/sessionEventHub.test.ts
+++ b/src/server/realtime/sessionEventHub.test.ts
@@ -6,6 +6,7 @@ class FakeSocket extends EventEmitter implements RealtimeSocket {
readonly OPEN = 1;
readyState = this.OPEN;
send = vi.fn();
+ terminate = vi.fn();
}
describe("SessionEventHub", () => {
@@ -54,6 +55,30 @@ describe("SessionEventHub", () => {
expect(removed.send).not.toHaveBeenCalled();
});
+ it("terminates a failed session socket without disrupting healthy delivery or sequence watermarks", () => {
+ const hub = new SessionEventHub();
+ const failed = new FakeSocket();
+ const healthy = new FakeSocket();
+ failed.send.mockImplementation(() => { throw new Error("socket closed"); });
+ hub.add("s1", failed);
+ hub.add("s1", healthy);
+
+ hub.publish("s1", { type: "assistant.delta", text: "hello" });
+
+ expect(failed.send).toHaveBeenCalledOnce();
+ expect(failed.terminate).toHaveBeenCalledOnce();
+ expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "assistant.delta", text: "hello", seq: 1 }));
+ expect(hub.currentSeq("s1")).toBe(1);
+
+ failed.send.mockClear();
+ hub.publish("s1", { type: "assistant.delta", text: "again" });
+
+ expect(failed.send).not.toHaveBeenCalled();
+ expect(failed.terminate).toHaveBeenCalledOnce();
+ expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "assistant.delta", text: "again", seq: 2 }));
+ expect(hub.currentSeq("s1")).toBe(2);
+ });
+
it("publishes global events only to global sockets", () => {
const hub = new SessionEventHub();
const globalSocket = new FakeSocket();
@@ -78,6 +103,29 @@ describe("SessionEventHub", () => {
expect(sessionSocket.send).not.toHaveBeenCalled();
});
+ it("contains termination failures while publishing unstamped global events", () => {
+ const hub = new SessionEventHub();
+ const failed = new FakeSocket();
+ const healthy = new FakeSocket();
+ failed.send.mockImplementation(() => { throw new Error("socket closed"); });
+ failed.terminate.mockImplementation(() => { throw new Error("termination failed"); });
+ hub.addGlobal(failed);
+ hub.addGlobal(healthy);
+
+ hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed" });
+
+ expect(failed.send).toHaveBeenCalledOnce();
+ expect(failed.terminate).toHaveBeenCalledOnce();
+ expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed" }));
+
+ failed.send.mockClear();
+ hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed again" });
+
+ expect(failed.send).not.toHaveBeenCalled();
+ expect(failed.terminate).toHaveBeenCalledOnce();
+ expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed again" }));
+ });
+
it("stamps a monotonically increasing per-session seq on published events", () => {
const hub = new SessionEventHub();
const socket = new FakeSocket();
diff --git a/src/server/realtime/sessionEventHub.ts b/src/server/realtime/sessionEventHub.ts
index b57e6d1..2cbb39f 100644
--- a/src/server/realtime/sessionEventHub.ts
+++ b/src/server/realtime/sessionEventHub.ts
@@ -5,6 +5,7 @@ export interface RealtimeSocket {
readonly OPEN: number;
readyState: number;
send(payload: string): void;
+ terminate(): void;
on(event: "close", listener: () => void): unknown;
}
@@ -34,9 +35,7 @@ export class SessionEventHub {
const seq = (this.seqBySession.get(sessionId) ?? 0) + 1;
this.seqBySession.set(sessionId, seq);
const payload = JSON.stringify({ ...projectBrowserSessionEvent(event), seq });
- for (const socket of this.socketsBySession.get(sessionId) ?? []) {
- if (socket.readyState === socket.OPEN) socket.send(payload);
- }
+ this.sendToSockets(this.socketsBySession.get(sessionId), payload);
}
/**
@@ -55,8 +54,23 @@ export class SessionEventHub {
publishRealtime(event: RealtimeEvent): void {
const payload = JSON.stringify(event);
- for (const socket of this.globalSockets) {
- if (socket.readyState === socket.OPEN) socket.send(payload);
+ this.sendToSockets(this.globalSockets, payload);
+ }
+
+ private sendToSockets(sockets: Set | undefined, payload: string): void {
+ if (sockets === undefined) return;
+ for (const socket of sockets) {
+ if (socket.readyState !== socket.OPEN) continue;
+ try {
+ socket.send(payload);
+ } catch {
+ sockets.delete(socket);
+ try {
+ socket.terminate();
+ } catch {
+ // Removal is authoritative; cleanup failure must not block healthy sockets.
+ }
+ }
}
}
}
diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts
index de7e0f6..e35fde1 100644
--- a/src/server/sessiond.ts
+++ b/src/server/sessiond.ts
@@ -36,15 +36,15 @@ await app.register(fastifyWebsocket);
await runSessionDaemonStartup({
logger: app.log,
- createRuntime() {
+ async createRuntime() {
const eventHub = new SessionEventHub();
const workspaceActivity = new WorkspaceActivityService(eventHub);
- const auth = new AuthService({ agentDir: activeAgentProfile.dir });
+ const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log });
const spawnTargets = config.spawnSessions
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined;
const sessions = new PiSessionService(eventHub, {
- modelRegistry: auth.modelRegistry,
+ modelRuntime: auth.runtime,
agentDir: activeAgentProfile.dir,
workspaceActivity,
logger: app.log,
diff --git a/src/server/sessiond/sessionDaemonStartup.ts b/src/server/sessiond/sessionDaemonStartup.ts
index b0c645d..ef3f197 100644
--- a/src/server/sessiond/sessionDaemonStartup.ts
+++ b/src/server/sessiond/sessionDaemonStartup.ts
@@ -12,7 +12,7 @@ export interface SessionDaemonStartupLogger {
export interface SessionDaemonStartupSteps {
logger: SessionDaemonStartupLogger;
- createRuntime(): Runtime;
+ createRuntime(): Runtime | Promise;
registerRoutes(runtime: Runtime): void;
listen(runtime: Runtime): Promise;
migrateArchive?: () => Promise;
@@ -36,7 +36,7 @@ export async function runSessionDaemonStartup(
);
}
- const runtime = steps.createRuntime();
+ const runtime = await steps.createRuntime();
steps.registerRoutes(runtime);
await steps.listen(runtime);
return runtime;
diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts
index e6fa31e..778403d 100644
--- a/src/server/sessions/authProviderOptions.test.ts
+++ b/src/server/sessions/authProviderOptions.test.ts
@@ -1,52 +1,70 @@
import { describe, expect, it } from "vitest";
-import { getLoginProviderOptions, getLogoutProviderOptions, isApiKeyLoginProvider, type AuthProviderModelRegistry } from "./authProviderOptions";
+import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderRuntime } from "./authProviderOptions";
-function registry(): AuthProviderModelRegistry {
- const credentials = new Map();
- credentials.set("openai", { type: "api_key" });
+function runtime(configuredProviders: ReadonlySet = new Set(["openai"])): AuthProviderRuntime {
+ const credentials = [{ providerId: "openai", type: "api_key" as const }];
+ // Auth shapes mirror what the Pi SDK actually reports for these providers:
+ // github-copilot supports both methods, openai-codex is OAuth-only, and
+ // ambient providers resolve credentials without offering interactive login.
+ const providers = [
+ { id: "anthropic", name: "Anthropic", auth: { oauth: {}, apiKey: { login: () => undefined } } },
+ { id: "github-copilot", name: "GitHub Copilot", auth: { oauth: {}, apiKey: { login: () => undefined } } },
+ { id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)", auth: { oauth: {} } },
+ { id: "openai", name: "OpenAI", auth: { apiKey: { login: () => undefined } } },
+ { id: "custom", name: "Custom", auth: { apiKey: { login: () => undefined } } },
+ { id: "cloudflare-ai-gateway", name: "Cloudflare AI Gateway", auth: { apiKey: { login: () => undefined } } },
+ { id: "cloudflare-workers-ai", name: "Cloudflare Workers AI", auth: { apiKey: { login: () => undefined } } },
+ { id: "amazon-bedrock", name: "Amazon Bedrock", auth: { apiKey: { login: () => undefined } } },
+ { id: "google-vertex", name: "Google Vertex AI", auth: { apiKey: { login: () => undefined } } },
+ { id: "ambient", name: "Ambient credentials", auth: { apiKey: {} } },
+ ];
return {
- authStorage: {
- getOAuthProviders: () => [
- { id: "anthropic", name: "Anthropic (Claude Pro/Max)" },
- { id: "github-copilot", name: "GitHub Copilot" },
- { id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)" },
- ],
- list: () => Array.from(credentials.keys()),
- get: (provider: string) => credentials.get(provider),
- },
- getAll: () => [
- { provider: "anthropic" },
- { provider: "openai" },
- { provider: "openai-codex" },
- { provider: "github-copilot" },
- { provider: "custom" },
- ],
- getProviderDisplayName: (provider: string) => ({ anthropic: "Anthropic", openai: "OpenAI", custom: "Custom" }[provider] ?? provider),
+ getProviders: () => providers,
+ listCredentials: () => Promise.resolve(credentials),
getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }),
+ hasConfiguredAuth: (provider: string) => configuredProviders.has(provider),
};
}
describe("auth provider options", () => {
- it("keeps OAuth-only providers out of API key login options", () => {
- expect(isApiKeyLoginProvider("openai-codex", new Set(["openai-codex"]))).toBe(false);
- expect(isApiKeyLoginProvider("github-copilot", new Set(["github-copilot"]))).toBe(false);
- expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true);
- });
-
- it("builds login options for OAuth-only, dual-auth, and API-key providers", () => {
- const options = getLoginProviderOptions(registry());
+ it("offers each interactive login method reported by the backend", () => {
+ const options = getLoginProviderOptions(runtime());
expect(options).toEqual(expect.arrayContaining([
+ // Dual-capable providers surface both login methods, driven purely by SDK data.
expect.objectContaining({ id: "anthropic", authType: "oauth" }),
expect.objectContaining({ id: "anthropic", authType: "api_key" }),
- expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }),
+ expect.objectContaining({ id: "github-copilot", authType: "oauth" }),
+ expect.objectContaining({ id: "github-copilot", authType: "api_key" }),
+ // OAuth-only provider surfaces only oauth.
expect.objectContaining({ id: "openai-codex", authType: "oauth" }),
+ // API-key options use the generic AuthInteraction flow, including
+ // multi-field and select-first providers the legacy form cannot execute.
+ expect.objectContaining({ id: "openai", authType: "api_key", loginFlow: "interactive", status: { configured: true, source: "stored" } }),
+ expect.objectContaining({ id: "custom", authType: "api_key", loginFlow: "interactive" }),
+ expect.objectContaining({ id: "cloudflare-ai-gateway", authType: "api_key", loginFlow: "interactive" }),
+ expect.objectContaining({ id: "cloudflare-workers-ai", authType: "api_key", loginFlow: "interactive" }),
+ expect.objectContaining({ id: "amazon-bedrock", authType: "api_key", loginFlow: "interactive" }),
+ expect.objectContaining({ id: "google-vertex", authType: "api_key", loginFlow: "interactive" }),
]));
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })]));
+ expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai", authType: "oauth" })]));
+ expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "ambient", authType: "api_key" })]));
});
- it("returns only currently stored credentials for logout", () => {
- expect(getLogoutProviderOptions(registry())).toEqual([
- expect.objectContaining({ id: "openai", authType: "api_key" }),
+ it("does not report a stored credential as configured when provider resolution is incomplete", async () => {
+ const unresolvedRuntime = runtime(new Set());
+
+ expect(getLoginProviderOptions(unresolvedRuntime, "api_key")).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: "openai", status: { configured: false } }),
+ ]));
+ expect(await getLogoutProviderOptions(unresolvedRuntime)).toEqual([
+ expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: false } }),
+ ]);
+ });
+
+ it("returns only currently stored credentials for logout", async () => {
+ expect(await getLogoutProviderOptions(runtime())).toEqual([
+ expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }),
]);
});
});
diff --git a/src/server/sessions/authProviderOptions.ts b/src/server/sessions/authProviderOptions.ts
index 58211d8..f188413 100644
--- a/src/server/sessions/authProviderOptions.ts
+++ b/src/server/sessions/authProviderOptions.ts
@@ -1,62 +1,78 @@
import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js";
-const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]);
-
-export interface AuthProviderModelRegistry {
- authStorage: {
- getOAuthProviders(): { id: string; name: string }[];
- list(): string[];
- get(provider: string): { type: AuthType } | undefined;
- };
- getAll(): { provider: string }[];
- getProviderDisplayName(provider: string): string;
- getProviderAuthStatus(provider: string): AuthProviderStatus;
+/** Minimal provider shape needed to enumerate login/logout options. */
+interface AuthProviderInfo {
+ id: string;
+ name: string;
+ auth: { apiKey?: { login?: unknown }; oauth?: unknown };
}
-export function getLoginProviderOptions(modelRegistry: AuthProviderModelRegistry, authType?: AuthType): AuthProviderOption[] {
- const oauthProviders = modelRegistry.authStorage.getOAuthProviders();
- const oauthProviderIds = new Set(oauthProviders.map((provider) => provider.id));
- const options: AuthProviderOption[] = oauthProviders.map((provider) => ({
- id: provider.id,
- name: provider.name,
- authType: "oauth",
- status: modelRegistry.getProviderAuthStatus(provider.id),
- }));
+/** Non-secret stored-credential metadata, keyed by provider id. */
+interface AuthProviderCredentialInfo {
+ providerId: string;
+ type: AuthType;
+}
- const modelProviders = new Set(modelRegistry.getAll().map((model) => model.provider));
- for (const providerId of modelProviders) {
- if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) continue;
+/**
+ * Structural slice of the SDK `ModelRuntime` used to derive auth provider
+ * options. Kept structural (rather than `Pick`) so tests can
+ * supply a lightweight double without constructing a full runtime; a real
+ * `ModelRuntime` satisfies it.
+ */
+export interface AuthProviderRuntime {
+ getProviders(): readonly AuthProviderInfo[];
+ listCredentials(): Promise;
+ getProviderAuthStatus(providerId: string): AuthProviderStatus;
+ hasConfiguredAuth(providerId: string): boolean;
+}
+
+export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): AuthProviderOption[] {
+ const providers = runtime.getProviders();
+
+ const options: AuthProviderOption[] = [];
+ for (const provider of providers) {
+ if (provider.auth.oauth === undefined) continue;
options.push({
- id: providerId,
- name: modelRegistry.getProviderDisplayName(providerId),
+ id: provider.id,
+ name: provider.name,
+ authType: "oauth",
+ status: truthfulProviderStatus(runtime, provider.id),
+ });
+ }
+
+ for (const provider of providers) {
+ if (provider.auth.apiKey?.login === undefined) continue;
+ options.push({
+ id: provider.id,
+ name: provider.name,
authType: "api_key",
- status: modelRegistry.getProviderAuthStatus(providerId),
+ status: truthfulProviderStatus(runtime, provider.id),
+ loginFlow: "interactive",
});
}
return filterAndSort(options, authType);
}
-export function getLogoutProviderOptions(modelRegistry: AuthProviderModelRegistry): AuthProviderOption[] {
+export async function getLogoutProviderOptions(runtime: AuthProviderRuntime): Promise {
+ const providerNames = new Map(runtime.getProviders().map((provider) => [provider.id, provider.name]));
const options: AuthProviderOption[] = [];
- for (const providerId of modelRegistry.authStorage.list()) {
- const credential = modelRegistry.authStorage.get(providerId);
- if (credential === undefined) continue;
+ for (const credential of await runtime.listCredentials()) {
options.push({
- id: providerId,
- name: modelRegistry.getProviderDisplayName(providerId),
+ id: credential.providerId,
+ name: providerNames.get(credential.providerId) ?? credential.providerId,
authType: credential.type,
- status: modelRegistry.getProviderAuthStatus(providerId),
+ status: truthfulProviderStatus(runtime, credential.providerId),
});
}
return filterAndSort(options);
}
-export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet): boolean {
- if (OAUTH_ONLY_PROVIDERS.has(providerId)) return false;
- if (providerId === "anthropic") return true;
- if (oauthProviderIds.has(providerId)) return false;
- return true;
+function truthfulProviderStatus(runtime: AuthProviderRuntime, providerId: string): AuthProviderStatus {
+ const reported = runtime.getProviderAuthStatus(providerId);
+ // ModelRuntime reports any stored entry as configured before checking whether
+ // the provider can resolve all required credential and ambient fields.
+ return reported.configured && !runtime.hasConfiguredAuth(providerId) ? { configured: false } : reported;
}
function filterAndSort(options: AuthProviderOption[], authType?: AuthType): AuthProviderOption[] {
diff --git a/src/server/sessions/authRoutes.ts b/src/server/sessions/authRoutes.ts
index 1f000f3..70089af 100644
--- a/src/server/sessions/authRoutes.ts
+++ b/src/server/sessions/authRoutes.ts
@@ -4,7 +4,7 @@ import type { AuthService } from "./authService.js";
export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, prefix = ""): void {
app.get<{ Querystring: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" } }>(`${prefix}/auth/providers`, async (request, reply) => {
try {
- return auth.authProviders(request.query.mode ?? "login", request.query.authType);
+ return await auth.authProviders(request.query.mode ?? "login", request.query.authType);
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
@@ -12,7 +12,17 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref
app.post<{ Body: { providerId: string; key: string } }>(`${prefix}/auth/api-key`, async (request, reply) => {
try {
- return auth.saveApiKey(request.body.providerId, request.body.key);
+ return await auth.saveApiKey(request.body.providerId, request.body.key);
+ } catch (error) {
+ return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
+ }
+ });
+
+ // Additive endpoint for newer browsers; the one-secret route remains for
+ // rolling compatibility with older browser bundles.
+ app.post<{ Body: { providerId: string } }>(`${prefix}/auth/api-key/interactive`, async (request, reply) => {
+ try {
+ return await auth.startApiKeyLogin(request.body.providerId);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
@@ -20,7 +30,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/logout`, async (request, reply) => {
try {
- return auth.logoutProvider(request.body.providerId);
+ return await auth.logoutProvider(request.body.providerId);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
@@ -28,7 +38,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/oauth`, async (request, reply) => {
try {
- return auth.startOAuthLogin(request.body.providerId);
+ return await auth.startOAuthLogin(request.body.providerId);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts
index 685295f..e427e6c 100644
--- a/src/server/sessions/authService.test.ts
+++ b/src/server/sessions/authService.test.ts
@@ -1,98 +1,505 @@
-import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
+import { ModelRuntime } from "@earendil-works/pi-coding-agent";
+import { InMemoryCredentialStore, type AuthPrompt, type Credential } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OAuthFlowState } from "../../shared/apiTypes.js";
-import { AuthService, type AuthChange } from "./authService.js";
+import { AuthService, createModelRuntimeForAgentDir, type AuthChange, type AuthServiceLogger } from "./authService.js";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
const tempDirs: string[] = [];
afterEach(async () => {
+ vi.unstubAllEnvs();
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("AuthService", () => {
- it("saves API keys and emits a global auth change", () => {
- const { auth, authStorage, changes } = createAuthService();
+ it("saves API keys and emits a global auth change after the runtime refreshes", async () => {
+ const { auth, runtime, credentials, changes } = await createAuthService();
+ const reloadConfig = vi.spyOn(runtime, "reloadConfig").mockResolvedValue(undefined);
+ const refresh = vi.spyOn(runtime, "refresh");
- expect(auth.saveApiKey("anthropic", "sk-test")).toEqual({ accepted: true });
+ await expect(auth.saveApiKey("anthropic", "sk-test")).resolves.toEqual({ accepted: true });
- expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-test" });
+ await expect(credentials.read("anthropic")).resolves.toEqual({ type: "api_key", key: "sk-test" });
+ expect(reloadConfig).toHaveBeenCalledOnce();
+ expect(refresh).toHaveBeenCalledOnce();
expect(changes).toEqual([{}]);
auth.dispose();
});
- it("logs out providers and emits the removed provider id", () => {
- const { auth, authStorage, changes } = createAuthService({ anthropic: { type: "api_key", key: "sk-test" } });
+ it("logs out providers and emits the removed provider id after the runtime refreshes", async () => {
+ const { auth, runtime, credentials, changes } = await createAuthService({ anthropic: { type: "api_key", key: "sk-test" } });
+ const refresh = vi.spyOn(runtime, "refresh");
- expect(auth.logoutProvider("anthropic")).toEqual({ accepted: true });
+ await expect(auth.logoutProvider("anthropic")).resolves.toEqual({ accepted: true });
- expect(authStorage.get("anthropic")).toBeUndefined();
+ await expect(credentials.read("anthropic")).resolves.toBeUndefined();
+ expect(refresh).toHaveBeenCalledOnce();
expect(changes).toEqual([{ removedProviderId: "anthropic" }]);
auth.dispose();
});
- it("rejects blank API keys", () => {
- const { auth, changes } = createAuthService();
+ it("persists an API key and attempts every listener when failure logging throws", async () => {
+ const loggingFailure = new Error("auth logger failed");
+ const error = vi.fn(() => { throw loggingFailure; });
+ const logger: AuthServiceLogger = { error };
+ const { auth, credentials, changes } = await createAuthService({}, logger);
+ const failure = new Error("session auth refresh failed");
+ const attempts: string[] = [];
+ auth.subscribe(() => {
+ attempts.push("throwing");
+ throw failure;
+ });
+ auth.subscribe(async () => {
+ await Promise.resolve();
+ attempts.push("healthy");
+ });
- expect(() => { auth.saveApiKey("anthropic", " "); }).toThrow("API key is required");
+ await expect(auth.saveApiKey("anthropic", "sk-test")).resolves.toEqual({ accepted: true });
+
+ await expect(credentials.read("anthropic")).resolves.toEqual({ type: "api_key", key: "sk-test" });
+ expect(changes).toEqual([{}]);
+ expect(attempts).toEqual(["throwing", "healthy"]);
+ expect(error).toHaveBeenCalledWith(
+ { err: failure, operation: "login", providerId: "anthropic", authType: "api_key" },
+ "auth-change listener failed",
+ );
+ auth.dispose();
+ });
+
+ it("removes a credential when auth-change propagation rejects", async () => {
+ const error = vi.fn();
+ const logger: AuthServiceLogger = { error };
+ const { auth, credentials, changes } = await createAuthService(
+ { anthropic: { type: "api_key", key: "sk-test" } },
+ logger,
+ );
+ const failure = new Error("session logout refresh failed");
+ auth.subscribe(() => Promise.reject(failure));
+
+ await expect(auth.logoutProvider("anthropic")).resolves.toEqual({ accepted: true });
+
+ await expect(credentials.read("anthropic")).resolves.toBeUndefined();
+ expect(changes).toEqual([{ removedProviderId: "anthropic" }]);
+ expect(error).toHaveBeenCalledWith(
+ { err: failure, operation: "logout", providerId: "anthropic" },
+ "auth-change listener failed",
+ );
+ auth.dispose();
+ });
+
+ it("rejects blank API keys", async () => {
+ const { auth, changes } = await createAuthService();
+
+ await expect(auth.saveApiKey("anthropic", " ")).rejects.toThrow("API key is required");
expect(changes).toEqual([]);
auth.dispose();
});
+ it("keeps existing file-backed credentials unchanged when legacy Cloudflare setup cannot finish", async () => {
+ const seed = {
+ "cloudflare-ai-gateway": {
+ type: "api_key" as const,
+ key: "existing-secret",
+ env: { CLOUDFLARE_ACCOUNT_ID: "existing-account", CLOUDFLARE_GATEWAY_ID: "existing-gateway" },
+ },
+ };
+ const { auth, authPath, changes } = await createFileBackedAuthService(seed);
+ const before = await readFile(authPath, "utf8");
+
+ await expect(auth.saveApiKey("cloudflare-ai-gateway", "new-secret")).rejects.toThrow(
+ "Cloudflare AI Gateway requires interactive setup; use Pi's generic /login flow",
+ );
+
+ await expect(readFile(authPath, "utf8")).resolves.toBe(before);
+ expect(changes).toEqual([]);
+ auth.dispose();
+ });
+
+ it.each([
+ { providerId: "amazon-bedrock", providerName: "Amazon Bedrock" },
+ { providerId: "google-vertex", providerName: "Google Vertex AI" },
+ ])("keeps an empty file-backed store unchanged when legacy $providerName setup starts with a selection", async ({ providerId, providerName }) => {
+ const { auth, authPath, changes } = await createFileBackedAuthService({});
+ const before = await readFile(authPath, "utf8");
+
+ await expect(auth.saveApiKey(providerId, "submitted-secret")).rejects.toThrow(
+ `${providerName} requires interactive setup; use Pi's generic /login flow`,
+ );
+
+ await expect(readFile(authPath, "utf8")).resolves.toBe(before);
+ expect(changes).toEqual([]);
+ auth.dispose();
+ });
+
+ it("executes Cloudflare multi-field API-key setup through the interactive flow", async () => {
+ const { auth, credentials, changes } = await createAuthService();
+
+ const state = await auth.startApiKeyLogin("cloudflare-ai-gateway");
+ expect(state.prompt).toMatchObject({ message: "Enter Cloudflare API key", promptType: "secret" });
+ if (state.prompt === undefined) throw new Error("Expected Cloudflare key prompt");
+ auth.respondToOAuthFlow(state.flowId, state.prompt.requestId, "cf-secret");
+
+ await vi.waitFor(() => {
+ expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: "Enter Cloudflare account ID", promptType: "text" });
+ });
+ const accountPrompt = auth.oauthFlow(state.flowId).prompt;
+ if (accountPrompt === undefined) throw new Error("Expected Cloudflare account prompt");
+ auth.respondToOAuthFlow(state.flowId, accountPrompt.requestId, "account-1");
+
+ await vi.waitFor(() => {
+ expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: "Enter Cloudflare AI Gateway ID", promptType: "text" });
+ });
+ const gatewayPrompt = auth.oauthFlow(state.flowId).prompt;
+ if (gatewayPrompt === undefined) throw new Error("Expected Cloudflare gateway prompt");
+ auth.respondToOAuthFlow(state.flowId, gatewayPrompt.requestId, "gateway-1");
+
+ await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
+ await expect(credentials.read("cloudflare-ai-gateway")).resolves.toEqual({
+ type: "api_key",
+ key: "cf-secret",
+ env: { CLOUDFLARE_ACCOUNT_ID: "account-1", CLOUDFLARE_GATEWAY_ID: "gateway-1" },
+ });
+ expect(changes).toEqual([{}]);
+ auth.dispose();
+ });
+
+ it.each([
+ { providerId: "amazon-bedrock", selection: "bearer-token", secretPrompt: "Enter Amazon Bedrock bearer token" },
+ { providerId: "google-vertex", selection: "api-key", secretPrompt: "Enter Google Cloud API key" },
+ ])("executes $providerId select-first API-key setup through the interactive flow", async ({ providerId, selection, secretPrompt }) => {
+ const { auth, credentials, changes } = await createAuthService();
+
+ const state = await auth.startApiKeyLogin(providerId);
+ expect(state.select).toBeDefined();
+ if (state.select === undefined) throw new Error("Expected auth method selection");
+ auth.respondToOAuthFlow(state.flowId, state.select.requestId, selection);
+
+ await vi.waitFor(() => {
+ expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: secretPrompt, promptType: "secret" });
+ });
+ const prompt = auth.oauthFlow(state.flowId).prompt;
+ if (prompt === undefined) throw new Error("Expected provider secret prompt");
+ auth.respondToOAuthFlow(state.flowId, prompt.requestId, "provider-secret");
+
+ await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
+ await expect(credentials.read(providerId)).resolves.toEqual({ type: "api_key", key: "provider-secret" });
+ expect(changes).toEqual([{}]);
+ auth.dispose();
+ });
+
+ it("reports a key-only legacy Cloudflare credential as unconfigured", async () => {
+ vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "");
+ vi.stubEnv("CLOUDFLARE_GATEWAY_ID", "");
+ const { auth } = await createFileBackedAuthService({
+ "cloudflare-ai-gateway": { type: "api_key", key: "legacy-secret" },
+ });
+
+ const response = await auth.authProviders("login", "api_key");
+
+ expect(response.providers).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ id: "cloudflare-ai-gateway",
+ loginFlow: "interactive",
+ status: { configured: false },
+ }),
+ ]));
+ auth.dispose();
+ });
+
+ it("reports a stored Cloudflare key as configured when ambient fields complete it", async () => {
+ vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "ambient-account");
+ vi.stubEnv("CLOUDFLARE_GATEWAY_ID", "ambient-gateway");
+ const { auth } = await createFileBackedAuthService({
+ "cloudflare-ai-gateway": { type: "api_key", key: "legacy-secret" },
+ });
+
+ const response = await auth.authProviders("login", "api_key");
+
+ expect(response.providers).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ id: "cloudflare-ai-gateway",
+ loginFlow: "interactive",
+ status: { configured: true, source: "stored" },
+ }),
+ ]));
+ auth.dispose();
+ });
+
+ it.each([
+ { label: "text", prompt: { type: "text", message: "Account" } satisfies AuthPrompt },
+ {
+ label: "select",
+ prompt: { type: "select", message: "Region", options: [{ id: "us", label: "US" }] } satisfies AuthPrompt,
+ },
+ { label: "manual-code", prompt: { type: "manual_code", message: "Code" } satisfies AuthPrompt },
+ ])("rejects a first $label prompt before credential persistence", async ({ prompt }) => {
+ const { auth, runtime, credentials, changes } = await createAuthService();
+ const login = mockLoginPromptsBeforePersistence(runtime, credentials, [prompt]);
+
+ await expect(auth.saveApiKey("anthropic", "sk-test")).rejects.toThrow(
+ "Anthropic requires interactive setup; use Pi's generic /login flow",
+ );
+
+ expect(login).toHaveBeenCalledOnce();
+ await expect(credentials.read("anthropic")).resolves.toBeUndefined();
+ expect(changes).toEqual([]);
+ auth.dispose();
+ });
+
+ it("rejects a repeated secret prompt before credential persistence", async () => {
+ const { auth, runtime, credentials, changes } = await createAuthService();
+ const login = mockLoginPromptsBeforePersistence(runtime, credentials, [
+ { type: "secret", message: "API key" },
+ { type: "secret", message: "API key again" },
+ ]);
+
+ await expect(auth.saveApiKey("anthropic", "sk-test")).rejects.toThrow(
+ "Anthropic requires interactive setup; use Pi's generic /login flow",
+ );
+
+ expect(login).toHaveBeenCalledOnce();
+ await expect(credentials.read("anthropic")).resolves.toBeUndefined();
+ expect(changes).toEqual([]);
+ auth.dispose();
+ });
+
+ it("rejects an aborted secret prompt before credential persistence", async () => {
+ const { auth, runtime, credentials, changes } = await createAuthService();
+ const abort = new AbortController();
+ abort.abort();
+ const login = mockLoginPromptsBeforePersistence(runtime, credentials, [
+ { type: "secret", message: "API key", signal: abort.signal },
+ ]);
+
+ await expect(auth.saveApiKey("anthropic", "sk-test")).rejects.toThrow("Login cancelled");
+
+ expect(login).toHaveBeenCalledOnce();
+ await expect(credentials.read("anthropic")).resolves.toBeUndefined();
+ expect(changes).toEqual([]);
+ auth.dispose();
+ });
+
+ it("rejects unknown providers before starting API-key login", async () => {
+ const { auth, runtime, credentials, changes } = await createAuthService();
+ const login = vi.spyOn(runtime, "login");
+
+ await expect(auth.saveApiKey("unknown-provider", "sk-test")).rejects.toThrow(
+ "API key provider not found: unknown-provider",
+ );
+
+ expect(login).not.toHaveBeenCalled();
+ await expect(credentials.read("unknown-provider")).resolves.toBeUndefined();
+ expect(changes).toEqual([]);
+ auth.dispose();
+ });
+
+ it("rejects ambient-only providers before starting API-key login", async () => {
+ const { auth, runtime, credentials, changes } = await createAuthService();
+ const providers = [...runtime.getProviders()];
+ const interactiveProvider = providers.find((provider) => provider.auth.apiKey?.login !== undefined);
+ if (interactiveProvider?.auth.apiKey === undefined) throw new Error("Expected an interactive API-key provider");
+ const ambientApiKey = { ...interactiveProvider.auth.apiKey };
+ delete ambientApiKey.login;
+ const ambientProvider = {
+ ...interactiveProvider,
+ id: "ambient-only",
+ name: "Ambient Only",
+ auth: { apiKey: ambientApiKey },
+ };
+ vi.spyOn(runtime, "getProviders").mockReturnValue([...providers, ambientProvider]);
+ const login = vi.spyOn(runtime, "login");
+
+ await expect(auth.saveApiKey("ambient-only", "sk-test")).rejects.toThrow(
+ "Ambient Only does not support interactive API-key setup",
+ );
+
+ expect(login).not.toHaveBeenCalled();
+ await expect(credentials.read("ambient-only")).resolves.toBeUndefined();
+ expect(changes).toEqual([]);
+ auth.dispose();
+ });
+
+ it("reloads models.json before enumerating and validating OAuth providers", async () => {
+ const agentDir = await tempAgentDir();
+ const modelsPath = join(agentDir, "models.json");
+ const runtime = await ModelRuntime.create({
+ credentials: new InMemoryCredentialStore(),
+ modelsPath,
+ allowModelNetwork: false,
+ });
+ const authFlows = new CapturingOAuthLoginFlowService();
+ const auth = await AuthService.create({ runtime, authFlows });
+
+ await writeFile(modelsPath, radiusModelsConfig("First Radius"));
+ const response = await auth.authProviders("login", "oauth");
+ expect(response.providers).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: "test-radius", name: "First Radius", authType: "oauth" }),
+ ]));
+
+ await writeFile(modelsPath, radiusModelsConfig("Updated Radius"));
+ await expect(auth.startOAuthLogin("test-radius")).resolves.toMatchObject({
+ providerId: "test-radius",
+ providerName: "Updated Radius",
+ status: "running",
+ });
+ expect(authFlows.startCalls.at(0)).toMatchObject({
+ providerId: "test-radius",
+ providerName: "Updated Radius",
+ runtime,
+ });
+ auth.dispose();
+ });
+
it("stores credentials in the configured agent directory", async () => {
const agentDir = await tempAgentDir();
- const auth = new AuthService({ agentDir });
+ const runtime = await createModelRuntimeForAgentDir(agentDir, false);
+ const auth = await AuthService.create({ runtime });
- auth.saveApiKey("anthropic", "sk-test");
+ await auth.saveApiKey("anthropic", "sk-test");
await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-test");
auth.dispose();
});
- it("refreshes auth state after OAuth login completes", () => {
- const authStorage = AuthStorage.inMemory();
- const modelRegistry = ModelRegistry.create(authStorage);
+ it("reconciles cancellation after ModelRuntime persists OAuth but before its refresh completes", async () => {
+ const { auth, runtime, credentials, changes } = await createAuthService();
+ const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined);
+ if (provider?.auth.oauth === undefined) throw new Error("Expected built-in OAuth provider");
+ const credential: Credential = {
+ type: "oauth",
+ refresh: "refresh-token",
+ access: "access-token",
+ expires: Date.now() + 60_000,
+ };
+ vi.spyOn(provider.auth.oauth, "login").mockResolvedValue(credential);
+ vi.spyOn(runtime, "reloadConfig").mockResolvedValue(undefined);
+ const refreshStarted = deferred();
+ const finishRefresh = deferred();
+ const refresh = vi.spyOn(runtime, "refresh").mockImplementation(async () => {
+ refreshStarted.resolve(undefined);
+ await finishRefresh.promise;
+ return { aborted: false, errors: new Map() };
+ });
+
+ const state = await auth.startOAuthLogin(provider.id);
+ await refreshStarted.promise;
+
+ await expect(credentials.read(provider.id)).resolves.toEqual(credential);
+ expect(auth.cancelOAuthFlow(state.flowId)).toMatchObject({ status: "cancelled", error: "Login cancelled" });
+ expect(changes).toEqual([]);
+
+ finishRefresh.resolve(undefined);
+ await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
+
+ expect(auth.oauthFlow(state.flowId)).toMatchObject({ status: "complete", progress: ["Login complete"] });
+ expect(auth.oauthFlow(state.flowId)).not.toHaveProperty("error");
+ await expect(credentials.read(provider.id)).resolves.toEqual(credential);
+ expect(changes).toEqual([{}]);
+ expect(refresh).toHaveBeenCalledOnce();
+ auth.dispose();
+ });
+
+ it("emits an auth change after OAuth login completes without refreshing twice", async () => {
+ const runtime = await ModelRuntime.create({
+ credentials: new InMemoryCredentialStore(),
+ modelsPath: null,
+ allowModelNetwork: false,
+ });
const authFlows = new CapturingOAuthLoginFlowService();
- const auth = new AuthService({ modelRegistry, authFlows });
+ const auth = await AuthService.create({ runtime, authFlows });
const changes: AuthChange[] = [];
auth.subscribe((change) => { changes.push(change); });
- const reload = vi.spyOn(authStorage, "reload");
- const refresh = vi.spyOn(modelRegistry, "refresh");
- const provider = authStorage.getOAuthProviders().find((option) => option.id === "anthropic");
+ const refresh = vi.spyOn(runtime, "refresh");
+ const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined);
if (provider === undefined) throw new Error("Expected built-in OAuth provider");
- expect(auth.startOAuthLogin(provider.id)).toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" });
+ await expect(auth.startOAuthLogin(provider.id)).resolves.toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" });
const startOptions = authFlows.startCalls.at(0);
if (startOptions === undefined) throw new Error("Expected OAuth flow to start");
expect(startOptions.providerId).toBe(provider.id);
expect(startOptions.providerName).toBe(provider.name);
- expect(startOptions.authStorage).toBe(authStorage);
+ expect(startOptions.runtime).toBe(runtime);
expect(changes).toEqual([]);
- reload.mockClear();
refresh.mockClear();
if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback");
- startOptions.onComplete();
-
- expect(reload).toHaveBeenCalledOnce();
- expect(refresh).toHaveBeenCalledOnce();
+ await startOptions.onComplete();
expect(changes).toEqual([{}]);
+
+ expect(refresh).not.toHaveBeenCalled();
auth.dispose();
expect(authFlows.disposed).toBe(true);
});
+
+ it("completes OAuth when an auth-change listener and failure logging throw", async () => {
+ const loggingFailure = new Error("auth logger failed");
+ const error = vi.fn(() => { throw loggingFailure; });
+ const logger: AuthServiceLogger = { error };
+ const { auth, runtime, changes } = await createAuthService({}, logger);
+ const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined);
+ if (provider === undefined) throw new Error("Expected built-in OAuth provider");
+ vi.spyOn(runtime, "login").mockResolvedValue({
+ type: "oauth",
+ refresh: "refresh-token",
+ access: "access-token",
+ expires: Date.now() + 60_000,
+ });
+ const failure = new Error("session OAuth refresh failed");
+ auth.subscribe(() => Promise.reject(failure));
+
+ const state = await auth.startOAuthLogin(provider.id);
+ await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
+
+ expect(changes).toEqual([{}]);
+ expect(error).toHaveBeenCalledWith(
+ { err: failure, operation: "login", providerId: provider.id, authType: "oauth" },
+ "auth-change listener failed",
+ );
+ auth.dispose();
+ });
});
-function createAuthService(data: Parameters[0] = {}) {
- const authStorage = AuthStorage.inMemory(data);
- const modelRegistry = ModelRegistry.create(authStorage);
- const auth = new AuthService({ modelRegistry });
+async function createAuthService(seed: Record = {}, logger?: AuthServiceLogger) {
+ const credentials = new InMemoryCredentialStore();
+ for (const [providerId, credential] of Object.entries(seed)) {
+ await credentials.modify(providerId, () => Promise.resolve(credential));
+ }
+ const runtime = await ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false });
+ const auth = await AuthService.create({ runtime, ...(logger === undefined ? {} : { logger }) });
const changes: AuthChange[] = [];
auth.subscribe((change) => { changes.push(change); });
- return { auth, authStorage, changes };
+ return { auth, runtime, credentials, changes };
+}
+
+async function createFileBackedAuthService(seed: Record) {
+ const agentDir = await tempAgentDir();
+ const authPath = join(agentDir, "auth.json");
+ await writeFile(authPath, JSON.stringify(seed, null, 2));
+ const runtime = await createModelRuntimeForAgentDir(agentDir, false);
+ const auth = await AuthService.create({ runtime });
+ const changes: AuthChange[] = [];
+ auth.subscribe((change) => { changes.push(change); });
+ return { auth, runtime, authPath, changes };
+}
+
+function mockLoginPromptsBeforePersistence(
+ runtime: ModelRuntime,
+ credentials: InMemoryCredentialStore,
+ prompts: readonly AuthPrompt[],
+) {
+ return vi.spyOn(runtime, "login").mockImplementation(async (providerId, _authType, interaction) => {
+ let key: string | undefined;
+ for (const prompt of prompts) key = await interaction.prompt(prompt);
+ if (key === undefined) throw new Error("Expected at least one login prompt");
+ const credential: Credential = { type: "api_key", key };
+ await credentials.modify(providerId, () => Promise.resolve(credential));
+ return credential;
+ });
}
async function tempAgentDir(): Promise {
@@ -101,6 +508,28 @@ async function tempAgentDir(): Promise {
return dir;
}
+function deferred() {
+ let resolveValue: (value: T) => void = () => undefined;
+ let rejectValue: (reason?: unknown) => void = () => undefined;
+ const promise = new Promise((resolve, reject) => {
+ resolveValue = resolve;
+ rejectValue = reject;
+ });
+ return { promise, resolve: resolveValue, reject: rejectValue };
+}
+
+function radiusModelsConfig(name: string): string {
+ return JSON.stringify({
+ providers: {
+ "test-radius": {
+ name,
+ baseUrl: "https://radius.example.test/v1",
+ oauth: "radius",
+ },
+ },
+ });
+}
+
class CapturingOAuthLoginFlowService extends OAuthLoginFlowService {
readonly startCalls: Parameters[0][] = [];
disposed = false;
diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts
index 3cb1305..5a237ff 100644
--- a/src/server/sessions/authService.ts
+++ b/src/server/sessions/authService.ts
@@ -1,5 +1,6 @@
import { join } from "node:path";
-import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
+import { ModelRuntime } from "@earendil-works/pi-coding-agent";
+import type { AuthInteraction } from "@earendil-works/pi-ai";
import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js";
import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
@@ -8,28 +9,53 @@ export interface AuthChange {
removedProviderId?: string;
}
-type AuthChangeListener = (change: AuthChange) => void;
-type ModelRegistryInstance = ReturnType;
+type AuthChangeListener = (change: AuthChange) => void | Promise;
export interface AuthServiceDependencies {
agentDir?: string;
- modelRegistry?: ModelRegistryInstance;
+ runtime?: ModelRuntime;
authFlows?: OAuthLoginFlowService;
+ logger?: AuthServiceLogger;
}
-export function createModelRegistryForAgentDir(agentDir: string): ModelRegistryInstance {
- const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
- return ModelRegistry.create(authStorage, join(agentDir, "models.json"));
+/** Minimal structured-logging seam for non-fatal auth propagation failures. */
+export interface AuthServiceLogger {
+ error(details: Record, message: string): void;
+}
+
+interface AuthChangeContext {
+ operation: "login" | "logout";
+ providerId: string;
+ authType?: AuthType;
+}
+
+const noopLogger: AuthServiceLogger = { error() { /* no-op */ } };
+
+export function createModelRuntimeForAgentDir(agentDir: string, allowModelNetwork?: boolean): Promise {
+ return ModelRuntime.create({
+ authPath: join(agentDir, "auth.json"),
+ modelsPath: join(agentDir, "models.json"),
+ ...(allowModelNetwork === undefined ? {} : { allowModelNetwork }),
+ });
}
export class AuthService {
- readonly modelRegistry: ModelRegistryInstance;
+ readonly runtime: ModelRuntime;
private readonly authFlows: OAuthLoginFlowService;
+ private readonly logger: AuthServiceLogger;
private readonly listeners = new Set();
- constructor(deps: AuthServiceDependencies = {}) {
- this.modelRegistry = deps.modelRegistry ?? (deps.agentDir === undefined ? ModelRegistry.create(AuthStorage.create()) : createModelRegistryForAgentDir(deps.agentDir));
- this.authFlows = deps.authFlows ?? new OAuthLoginFlowService();
+ private constructor(runtime: ModelRuntime, authFlows: OAuthLoginFlowService, logger: AuthServiceLogger) {
+ this.runtime = runtime;
+ this.authFlows = authFlows;
+ this.logger = logger;
+ }
+
+ static async create(deps: AuthServiceDependencies = {}): Promise {
+ const runtime = deps.runtime ?? (deps.agentDir === undefined ? await ModelRuntime.create({}) : await createModelRuntimeForAgentDir(deps.agentDir));
+ const logger = deps.logger ?? noopLogger;
+ const authFlows = deps.authFlows ?? new OAuthLoginFlowService({ logger });
+ return new AuthService(runtime, authFlows, logger);
}
subscribe(listener: AuthChangeListener): () => void {
@@ -44,34 +70,60 @@ export class AuthService {
this.listeners.clear();
}
- authProviders(mode: "login" | "logout", authType?: AuthType): AuthProvidersResponse {
- this.modelRegistry.refresh();
- const providers = mode === "logout" ? getLogoutProviderOptions(this.modelRegistry) : getLoginProviderOptions(this.modelRegistry, authType);
+ async authProviders(mode: "login" | "logout", authType?: AuthType): Promise {
+ await this.runtime.reloadConfig();
+ const providers = mode === "logout" ? await getLogoutProviderOptions(this.runtime) : getLoginProviderOptions(this.runtime, authType);
return { providers };
}
- saveApiKey(providerId: string, key: string): { accepted: true } {
+ async saveApiKey(providerId: string, key: string): Promise<{ accepted: true }> {
if (key.trim() === "") throw new Error("API key is required");
- this.modelRegistry.authStorage.set(providerId, { type: "api_key", key });
- this.refreshAuthState();
+ const provider = await this.requireApiKeyLoginProvider(providerId);
+ let promptAttempted = false;
+ const interaction: AuthInteraction = {
+ prompt: (prompt) => {
+ if (promptAttempted) {
+ throw new Error(`${provider.name} requires interactive setup; use Pi's generic /login flow`);
+ }
+ promptAttempted = true;
+ if (prompt.signal?.aborted === true) throw new Error("Login cancelled");
+ if (prompt.type !== "secret") {
+ throw new Error(`${provider.name} requires interactive setup; use Pi's generic /login flow`);
+ }
+ return Promise.resolve(key);
+ },
+ notify: () => undefined,
+ };
+ await this.runtime.login(providerId, "api_key", interaction);
+ await this.emit({}, { operation: "login", providerId, authType: "api_key" });
return { accepted: true };
}
- logoutProvider(providerId: string): { accepted: true } {
- this.modelRegistry.authStorage.logout(providerId);
- this.refreshAuthState({ removedProviderId: providerId });
+ async logoutProvider(providerId: string): Promise<{ accepted: true }> {
+ await this.runtime.logout(providerId);
+ await this.emit({ removedProviderId: providerId }, { operation: "logout", providerId });
return { accepted: true };
}
- startOAuthLogin(providerId: string): OAuthFlowState {
- const provider = this.requireOAuthLoginProvider(providerId);
+ async startApiKeyLogin(providerId: string): Promise {
+ const provider = await this.requireApiKeyLoginProvider(providerId);
return this.authFlows.start({
providerId,
providerName: provider.name,
- authStorage: this.modelRegistry.authStorage,
- onComplete: () => {
- this.refreshAuthState();
- },
+ runtime: this.runtime,
+ authType: "api_key",
+ onComplete: () => this.emit({}, { operation: "login", providerId, authType: "api_key" }),
+ });
+ }
+
+ async startOAuthLogin(providerId: string): Promise {
+ const provider = await this.requireOAuthLoginProvider(providerId);
+ return this.authFlows.start({
+ providerId,
+ providerName: provider.name,
+ runtime: this.runtime,
+ authType: "oauth",
+ onComplete: () => this.emit({}, { operation: "login", providerId, authType: "oauth" }),
});
}
@@ -87,19 +139,38 @@ export class AuthService {
return this.authFlows.cancel(flowId);
}
- private refreshAuthState(change: AuthChange = {}): void {
- this.modelRegistry.authStorage.reload();
- this.modelRegistry.refresh();
- this.emit(change);
+ private async emit(change: AuthChange, context: AuthChangeContext): Promise {
+ const results = await Promise.allSettled([...this.listeners].map(async (listener) => listener(change)));
+ for (const result of results) {
+ if (result.status === "rejected") {
+ this.logErrorNoThrow({ err: result.reason, ...context }, "auth-change listener failed");
+ }
+ }
}
- private emit(change: AuthChange): void {
- for (const listener of this.listeners) listener(change);
+ private logErrorNoThrow(details: Record, message: string): void {
+ try {
+ this.logger.error(details, message);
+ } catch {
+ // A diagnostic failure cannot turn an already-committed auth mutation into an API failure.
+ }
}
- private requireOAuthLoginProvider(providerId: string) {
- this.modelRegistry.refresh();
- const provider = getLoginProviderOptions(this.modelRegistry, "oauth").find((option) => option.id === providerId);
+ private async requireApiKeyLoginProvider(providerId: string) {
+ await this.runtime.reloadConfig();
+ const provider = getLoginProviderOptions(this.runtime, "api_key").find((option) => option.id === providerId);
+ if (provider !== undefined) return provider;
+
+ const knownProvider = this.runtime.getProviders().find((option) => option.id === providerId);
+ if (knownProvider !== undefined) {
+ throw new Error(`${knownProvider.name} does not support interactive API-key setup`);
+ }
+ throw new Error(`API key provider not found: ${providerId}`);
+ }
+
+ private async requireOAuthLoginProvider(providerId: string) {
+ await this.runtime.reloadConfig();
+ const provider = getLoginProviderOptions(this.runtime, "oauth").find((option) => option.id === providerId);
if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`);
return provider;
}
diff --git a/src/server/sessions/oauthLoginFlowService.test.ts b/src/server/sessions/oauthLoginFlowService.test.ts
index 24e3ab8..713d743 100644
--- a/src/server/sessions/oauthLoginFlowService.test.ts
+++ b/src/server/sessions/oauthLoginFlowService.test.ts
@@ -1,9 +1,9 @@
-import type { OAuthLoginCallbacks } from "@earendil-works/pi-ai";
-import type { AuthStorage } from "@earendil-works/pi-coding-agent";
+import type { AuthInteraction, AuthType } from "@earendil-works/pi-ai";
+import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
-type LoginHandler = (providerId: string, callbacks: OAuthLoginCallbacks) => Promise;
+type LoginHandler = (providerId: string, interaction: AuthInteraction) => Promise;
afterEach(() => {
vi.useRealTimers();
@@ -17,19 +17,19 @@ describe("OAuthLoginFlowService", () => {
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
- authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
- callbacks.onAuth({ url: "https://example.test/auth", instructions: "Open it" });
- callbacks.onProgress?.("Waiting for code");
- promptValue = await callbacks.onPrompt({ message: "Paste code", placeholder: "code" });
- callbacks.onProgress?.(`Got ${promptValue}`);
+ runtime: fakeRuntime(async (_providerId, interaction) => {
+ interaction.notify({ type: "auth_url", url: "https://example.test/auth", instructions: "Open it" });
+ interaction.notify({ type: "progress", message: "Waiting for code" });
+ promptValue = await interaction.prompt({ type: "text", message: "Paste code", placeholder: "code" });
+ interaction.notify({ type: "progress", message: `Got ${promptValue}` });
}),
onComplete,
});
const prompt = state.prompt;
if (prompt === undefined) throw new Error("Expected prompt");
- expect(state).toMatchObject({ auth: { url: "https://example.test/auth" }, progress: ["Waiting for code"] });
- expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt" });
+ expect(state).toMatchObject({ auth: { url: "https://example.test/auth", instructions: "Open it" }, progress: ["Waiting for code"] });
+ expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt", promptType: "text", allowEmpty: true });
const afterRespond = service.respond(state.flowId, prompt.requestId, "abc123");
expect(afterRespond.prompt).toBeUndefined();
@@ -41,23 +41,191 @@ describe("OAuthLoginFlowService", () => {
service.dispose();
});
+ it("runs API-key login through the same AuthInteraction transport", async () => {
+ const authTypes: AuthType[] = [];
+ let key: string | undefined;
+ const service = new OAuthLoginFlowService();
+ const state = service.start({
+ providerId: "test-provider",
+ providerName: "Test Provider",
+ runtime: fakeRuntime(async (_providerId, interaction) => {
+ key = await interaction.prompt({ type: "secret", message: "Enter API key" });
+ }, authTypes),
+ authType: "api_key",
+ });
+
+ const prompt = state.prompt;
+ if (prompt === undefined) throw new Error("Expected API-key prompt");
+ service.respond(state.flowId, prompt.requestId, "sk-test");
+ await flushAsyncLogin();
+
+ expect(authTypes).toEqual(["api_key"]);
+ expect(key).toBe("sk-test");
+ expect(service.get(state.flowId).status).toBe("complete");
+ service.dispose();
+ });
+
+ it("awaits async completion propagation before marking the flow complete", async () => {
+ const completion = deferred();
+ const service = new OAuthLoginFlowService();
+ const state = service.start({
+ providerId: "test-provider",
+ providerName: "Test Provider",
+ runtime: fakeRuntime(() => Promise.resolve()),
+ onComplete: () => completion.promise,
+ });
+
+ await flushAsyncLogin();
+ expect(service.get(state.flowId).status).toBe("running");
+
+ completion.resolve(undefined);
+ await flushAsyncLogin();
+ expect(service.get(state.flowId).status).toBe("complete");
+ service.dispose();
+ });
+
+ it("keeps a committed login complete when its completion callback and logger throw", async () => {
+ const completionFailure = new Error("completion propagation failed");
+ const loggingFailure = new Error("OAuth logger failed");
+ const error = vi.fn(() => { throw loggingFailure; });
+ const onComplete = vi.fn(() => { throw completionFailure; });
+ const service = new OAuthLoginFlowService({ logger: { error } });
+ const state = service.start({
+ providerId: "test-provider",
+ providerName: "Test Provider",
+ runtime: fakeRuntime(() => Promise.resolve()),
+ onComplete,
+ });
+
+ await vi.waitFor(() => { expect(service.get(state.flowId).status).toBe("complete"); });
+
+ expect(service.get(state.flowId)).toMatchObject({ status: "complete", progress: ["Login complete"] });
+ expect(onComplete).toHaveBeenCalledOnce();
+ expect(error).toHaveBeenCalledWith(
+ { err: completionFailure, flowId: state.flowId, providerId: "test-provider" },
+ "login completion callback failed",
+ );
+ service.dispose();
+ });
+
+ it("allows blank text responses for providers that use blank as a default", async () => {
+ let domain: string | undefined;
+ const service = new OAuthLoginFlowService();
+ const state = service.start({
+ providerId: "github-copilot",
+ providerName: "GitHub Copilot",
+ runtime: fakeRuntime(async (_providerId, interaction) => {
+ domain = await interaction.prompt({
+ type: "text",
+ message: "GitHub Enterprise URL/domain (blank for github.com)",
+ });
+ }),
+ });
+
+ const prompt = state.prompt;
+ if (prompt === undefined) throw new Error("Expected text prompt");
+ expect(prompt).toMatchObject({ kind: "prompt", promptType: "text", allowEmpty: true });
+
+ service.respond(state.flowId, prompt.requestId, "");
+ await flushAsyncLogin();
+
+ expect(domain).toBe("");
+ expect(service.get(state.flowId).status).toBe("complete");
+ service.dispose();
+ });
+
+ it("preserves secret prompt semantics behind the legacy prompt kind", () => {
+ const service = new OAuthLoginFlowService();
+ const state = service.start({
+ providerId: "test-provider",
+ providerName: "Test Provider",
+ runtime: fakeRuntime(async (_providerId, interaction) => {
+ await interaction.prompt({ type: "secret", message: "Enter secret", placeholder: "token" });
+ }),
+ });
+
+ const prompt = state.prompt;
+ if (prompt === undefined) throw new Error("Expected secret prompt");
+ expect(prompt).toMatchObject({
+ kind: "prompt",
+ promptType: "secret",
+ message: "Enter secret",
+ placeholder: "token",
+ });
+ expect(prompt).not.toHaveProperty("allowEmpty");
+ expect(() => { service.respond(state.flowId, prompt.requestId, ""); }).toThrow("A value is required");
+ service.dispose();
+ });
+
+ it("preserves info-event links without replacing the authorization URL", () => {
+ const service = new OAuthLoginFlowService();
+ const state = service.start({
+ providerId: "test-provider",
+ providerName: "Test Provider",
+ runtime: fakeRuntime(async (_providerId, interaction) => {
+ interaction.notify({ type: "auth_url", url: "https://example.test/login" });
+ interaction.notify({
+ type: "info",
+ message: "Review the provider setup guide",
+ links: [{ url: "https://example.test/docs", label: "Setup guide" }],
+ });
+ await interaction.prompt({ type: "text", message: "Continue" });
+ }),
+ });
+
+ expect(state).toMatchObject({
+ auth: { url: "https://example.test/login" },
+ progress: ["Review the provider setup guide"],
+ info: [{ message: "Review the provider setup guide", links: [{ url: "https://example.test/docs", label: "Setup guide" }] }],
+ });
+ service.dispose();
+ });
+
+ it("surfaces device-code events through the auth field", () => {
+ const service = new OAuthLoginFlowService();
+ const state = service.start({
+ providerId: "test-provider",
+ providerName: "Test Provider",
+ runtime: fakeRuntime(async (_providerId, interaction) => {
+ interaction.notify({
+ type: "device_code",
+ userCode: "WXYZ-1234",
+ verificationUri: "https://example.test/device",
+ intervalSeconds: 5,
+ expiresInSeconds: 900,
+ });
+ await interaction.prompt({ type: "text", message: "Waiting" });
+ }),
+ });
+
+ expect(service.get(state.flowId)).toMatchObject({
+ auth: {
+ url: "https://example.test/device",
+ instructions: "Enter code: WXYZ-1234",
+ deviceCode: { userCode: "WXYZ-1234", intervalSeconds: 5, expiresInSeconds: 900 },
+ },
+ });
+ service.dispose();
+ });
+
it("round-trips select responses", async () => {
let selectedValue: string | undefined;
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
- authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
- selectedValue = await callbacks.onSelect({
+ runtime: fakeRuntime(async (_providerId, interaction) => {
+ selectedValue = await interaction.prompt({
+ type: "select",
message: "Choose account",
- options: [{ id: "work", label: "Work" }, { id: "personal", label: "Personal" }],
+ options: [{ id: "work", label: "Work", description: "Company account" }, { id: "personal", label: "Personal" }],
});
}),
});
const select = state.select;
if (select === undefined) throw new Error("Expected select prompt");
- expect(select).toMatchObject({ message: "Choose account", options: [{ value: "work", label: "Work" }, { value: "personal", label: "Personal" }] });
+ expect(select).toMatchObject({ message: "Choose account", options: [{ value: "work", label: "Work", description: "Company account" }, { value: "personal", label: "Personal" }] });
service.respond(state.flowId, select.requestId, "personal");
await flushAsyncLogin();
@@ -67,40 +235,97 @@ describe("OAuthLoginFlowService", () => {
service.dispose();
});
- it("uses a manual-code prompt for callback-server flows", async () => {
- let manualValue: string | undefined;
+ it("rejects responses outside the pending select options", () => {
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
- authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
- const manualCodeInput = callbacks.onManualCodeInput;
- if (manualCodeInput === undefined) throw new Error("Expected manual-code callback");
- manualValue = await manualCodeInput();
+ runtime: fakeRuntime(async (_providerId, interaction) => {
+ await interaction.prompt({
+ type: "select",
+ message: "Choose account",
+ options: [{ id: "work", label: "Work" }],
+ });
+ }),
+ });
+
+ const select = state.select;
+ if (select === undefined) throw new Error("Expected select prompt");
+ expect(() => { service.respond(state.flowId, select.requestId, "personal"); }).toThrow("Invalid login selection");
+ expect(service.get(state.flowId).select).toEqual(select);
+ service.dispose();
+ });
+
+ it("uses a manual-code prompt for callback-server flows and cleans up its abort listener", async () => {
+ let manualValue: string | undefined;
+ const service = new OAuthLoginFlowService();
+ const controller = new AbortController();
+ const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener");
+ const state = service.start({
+ providerId: "test-provider",
+ providerName: "Test Provider",
+ runtime: fakeRuntime(async (_providerId, interaction) => {
+ manualValue = await interaction.prompt({
+ type: "manual_code",
+ message: "Paste the callback URL or authorization code",
+ signal: controller.signal,
+ });
}),
});
const prompt = state.prompt;
if (prompt === undefined) throw new Error("Expected manual prompt");
- expect(prompt).toMatchObject({ kind: "manual", message: "Paste the callback URL or authorization code" });
+ expect(prompt).toMatchObject({ kind: "manual", promptType: "manual_code", message: "Paste the callback URL or authorization code" });
service.respond(state.flowId, prompt.requestId, "https://localhost/callback?code=abc");
await flushAsyncLogin();
expect(manualValue).toBe("https://localhost/callback?code=abc");
+ expect(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function));
expect(service.get(state.flowId).status).toBe("complete");
service.dispose();
});
+ it("rejects a pending prompt when its own signal aborts without ending the flow", async () => {
+ const promptRejected = deferred();
+ const service = new OAuthLoginFlowService();
+ const controller = new AbortController();
+ const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener");
+ const state = service.start({
+ providerId: "test-provider",
+ providerName: "Test Provider",
+ runtime: fakeRuntime(async (_providerId, interaction) => {
+ try {
+ await interaction.prompt({ type: "manual_code", message: "Paste code", signal: controller.signal });
+ } catch (error) {
+ promptRejected.resolve(toError(error));
+ }
+ // The flow keeps running (e.g. the callback server resolves it) until we
+ // resolve the follow-up prompt below.
+ await interaction.prompt({ type: "text", message: "Waiting for callback" });
+ }),
+ });
+
+ expect(state.prompt).toMatchObject({ kind: "manual" });
+ controller.abort();
+ await expect(promptRejected.promise).resolves.toMatchObject({ message: "Prompt cancelled" });
+ expect(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function));
+
+ const afterAbort = service.get(state.flowId);
+ expect(afterAbort.status).toBe("running");
+ expect(afterAbort.prompt).toMatchObject({ kind: "prompt", message: "Waiting for callback" });
+ service.dispose();
+ });
+
it("rejects pending prompts when cancelled", async () => {
const promptRejected = deferred();
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
- authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
+ runtime: fakeRuntime(async (_providerId, interaction) => {
try {
- await callbacks.onPrompt({ message: "Paste code" });
+ await interaction.prompt({ type: "text", message: "Paste code" });
} catch (error) {
promptRejected.resolve(toError(error));
throw error;
@@ -122,9 +347,9 @@ describe("OAuthLoginFlowService", () => {
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
- authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
+ runtime: fakeRuntime(async (_providerId, interaction) => {
try {
- await callbacks.onPrompt({ message: "Paste code" });
+ await interaction.prompt({ type: "text", message: "Paste code" });
} catch (error) {
promptRejected.resolve(toError(error));
throw error;
@@ -137,7 +362,7 @@ describe("OAuthLoginFlowService", () => {
service.dispose();
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" });
- expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found");
+ expect(() => { service.get(state.flowId); }).toThrow("Login flow not found");
});
it("rejects stale or duplicate responses", () => {
@@ -145,8 +370,8 @@ describe("OAuthLoginFlowService", () => {
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
- authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
- await callbacks.onPrompt({ message: "Paste code" });
+ runtime: fakeRuntime(async (_providerId, interaction) => {
+ await interaction.prompt({ type: "text", message: "Paste code" });
}),
});
@@ -154,7 +379,7 @@ describe("OAuthLoginFlowService", () => {
if (prompt === undefined) throw new Error("Expected prompt");
service.respond(state.flowId, prompt.requestId, "abc123");
- expect(() => { service.respond(state.flowId, prompt.requestId, "abc123"); }).toThrow("OAuth login request expired");
+ expect(() => { service.respond(state.flowId, prompt.requestId, "abc123"); }).toThrow("Login request expired");
service.dispose();
});
@@ -165,9 +390,9 @@ describe("OAuthLoginFlowService", () => {
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
- authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
+ runtime: fakeRuntime(async (_providerId, interaction) => {
try {
- await callbacks.onPrompt({ message: "Paste code" });
+ await interaction.prompt({ type: "text", message: "Paste code" });
} catch (error) {
promptRejected.resolve(toError(error));
throw error;
@@ -177,18 +402,25 @@ describe("OAuthLoginFlowService", () => {
await vi.advanceTimersByTimeAsync(1000);
- expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "OAuth login flow expired" });
- await expect(promptRejected.promise).resolves.toMatchObject({ message: "OAuth login flow expired" });
+ expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "Login flow expired" });
+ await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login flow expired" });
await vi.advanceTimersByTimeAsync(1000);
- expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found");
+ expect(() => { service.get(state.flowId); }).toThrow("Login flow not found");
service.dispose();
});
});
-function fakeAuthStorage(login: LoginHandler): Pick {
- return { login };
+function fakeRuntime(login: LoginHandler, authTypes?: AuthType[]): Pick {
+ return {
+ login: (providerId, type, interaction) => {
+ authTypes?.push(type);
+ return login(providerId, interaction).then(() => type === "api_key"
+ ? { type: "api_key", key: "test" }
+ : { type: "oauth", refresh: "r", access: "a", expires: 0 });
+ },
+ };
}
async function flushAsyncLogin(): Promise {
diff --git a/src/server/sessions/oauthLoginFlowService.ts b/src/server/sessions/oauthLoginFlowService.ts
index 02035b5..2e15130 100644
--- a/src/server/sessions/oauthLoginFlowService.ts
+++ b/src/server/sessions/oauthLoginFlowService.ts
@@ -1,16 +1,21 @@
import crypto from "node:crypto";
-import type { OAuthLoginCallbacks, OAuthSelectPrompt, OAuthPrompt } from "@earendil-works/pi-ai";
-import type { AuthStorage } from "@earendil-works/pi-coding-agent";
+import type { AuthEvent, AuthInteraction, AuthPrompt, AuthType } from "@earendil-works/pi-ai";
+import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
import type { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js";
-type OAuthLoginStorage = Pick;
+/** The single runtime capability this service drives — narrowed for testable DI. */
+type OAuthLoginRuntime = Pick;
type TimerHandle = ReturnType;
+type SelectPrompt = Extract;
+type ValuePrompt = Exclude;
interface PendingOAuthRequest {
requestId: string;
allowEmpty: boolean;
- resolve: (value: string | undefined) => void;
+ resolve: (value: string) => void;
reject: (error: Error) => void;
+ allowedValues?: ReadonlySet;
+ cleanup?: () => void;
}
interface OAuthFlowRecord {
@@ -22,32 +27,46 @@ interface OAuthFlowRecord {
cleanupTimer?: TimerHandle;
}
+export interface OAuthLoginFlowLogger {
+ error(details: Record, message: string): void;
+}
+
export interface OAuthLoginFlowServiceOptions {
terminalTtlMs?: number;
runningTtlMs?: number;
now?: () => number;
+ logger?: OAuthLoginFlowLogger;
}
const DEFAULT_TERMINAL_TTL_MS = 5 * 60 * 1000;
const DEFAULT_RUNNING_TTL_MS = 30 * 60 * 1000;
+const noopLogger: OAuthLoginFlowLogger = { error() { /* no-op */ } };
+/**
+ * AuthInteraction transport shared by OAuth and provider-driven API-key login.
+ * The historical class and wire names remain for rolling browser/sessiond compatibility.
+ */
export class OAuthLoginFlowService {
private readonly flows = new Map();
private readonly terminalTtlMs: number;
private readonly runningTtlMs: number;
private readonly now: () => number;
+ private readonly logger: OAuthLoginFlowLogger;
constructor(options: OAuthLoginFlowServiceOptions = {}) {
this.terminalTtlMs = options.terminalTtlMs ?? DEFAULT_TERMINAL_TTL_MS;
this.runningTtlMs = options.runningTtlMs ?? DEFAULT_RUNNING_TTL_MS;
this.now = options.now ?? (() => Date.now());
+ this.logger = options.logger ?? noopLogger;
}
start(options: {
providerId: string;
providerName: string;
- authStorage: OAuthLoginStorage;
- onComplete?: () => void;
+ runtime: OAuthLoginRuntime;
+ /** Defaults to OAuth so established callers retain their existing behavior. */
+ authType?: AuthType;
+ onComplete?: () => void | Promise;
}): OAuthFlowState {
const flowId = crypto.randomUUID();
const abort = new AbortController();
@@ -66,58 +85,43 @@ export class OAuthLoginFlowService {
this.flows.set(flowId, record);
this.scheduleRunningExpiry(record);
- const callbacks: OAuthLoginCallbacks = {
+ // Adapt the pi-ai AuthInteraction contract onto the web-UI flow state:
+ // `prompt()` returns the entered/selected string; `notify()` surfaces
+ // out-of-band login events (auth URL, device code, progress).
+ const interaction: AuthInteraction = {
signal: abort.signal,
- onAuth: (info) => {
- if (!this.isCurrentRunning(record)) return;
- this.updateState(record, { ...record.state, auth: info });
- },
- // Device-code flows have no redirect URL; reuse the auth field so the web UI
- // shows the verification link and user code without a dedicated API shape.
- onDeviceCode: (info) => {
- if (!this.isCurrentRunning(record)) return;
- this.updateState(record, { ...record.state, auth: { url: info.verificationUri, instructions: `Enter code: ${info.userCode}` } });
- },
- onPrompt: (prompt) => this.waitForPrompt(record, prompt, "prompt"),
- onManualCodeInput: () => this.waitForPrompt(record, { message: "Paste the callback URL or authorization code", allowEmpty: false }, "manual"),
- onSelect: (prompt) => this.waitForSelect(record, prompt),
- onProgress: (message) => {
- if (!this.isCurrentRunning(record)) return;
- this.updateState(record, { ...record.state, progress: [...record.state.progress, message] });
- },
+ prompt: (prompt) => this.handlePrompt(record, prompt),
+ notify: (event) => { this.handleEvent(record, event); },
};
- void options.authStorage.login(options.providerId, callbacks)
- .then(() => {
- if (!this.isCurrentRunning(record)) return;
- record.pending = undefined;
- this.markTerminal(record, { ...withoutInteraction(record.state), status: "complete", progress: [...record.state.progress, "Login complete"] });
- options.onComplete?.();
- })
- .catch((error: unknown) => {
- if (this.flows.get(record.flowId) !== record) return;
- record.pending = undefined;
+ void options.runtime.login(options.providerId, options.authType ?? "oauth", interaction).then(
+ () => this.reconcileCommittedLogin(record, options.onComplete),
+ (error: unknown) => {
+ if (!this.isCurrent(record)) return;
+ this.clearPending(record);
if (record.state.status !== "running") return;
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: error instanceof Error ? error.message : String(error) });
- });
+ },
+ );
return this.get(flowId);
}
get(flowId: string): OAuthFlowState {
const record = this.flows.get(flowId);
- if (record === undefined) throw new Error("OAuth login flow not found");
+ if (record === undefined) throw new Error("Login flow not found");
return cloneState(record.state);
}
respond(flowId: string, requestId: string, value: string): OAuthFlowState {
const record = this.flows.get(flowId);
- if (record === undefined) throw new Error("OAuth login flow not found");
+ if (record === undefined) throw new Error("Login flow not found");
if (record.state.status !== "running") return cloneState(record.state);
const pending = record.pending;
- if (pending?.requestId !== requestId) throw new Error("OAuth login request expired");
+ if (pending?.requestId !== requestId) throw new Error("Login request expired");
if (!pending.allowEmpty && value.trim() === "") throw new Error("A value is required");
- record.pending = undefined;
+ if (pending.allowedValues !== undefined && !pending.allowedValues.has(value)) throw new Error("Invalid login selection");
+ this.clearPending(record);
this.updateState(record, withoutInteraction(record.state));
pending.resolve(value);
return cloneState(record.state);
@@ -125,11 +129,10 @@ export class OAuthLoginFlowService {
cancel(flowId: string): OAuthFlowState {
const record = this.flows.get(flowId);
- if (record === undefined) throw new Error("OAuth login flow not found");
+ if (record === undefined) throw new Error("Login flow not found");
if (record.state.status === "running") {
record.abort.abort();
- const pending = record.pending;
- record.pending = undefined;
+ const pending = this.clearPending(record);
this.markTerminal(record, { ...withoutInteraction(record.state), status: "cancelled", error: "Login cancelled" });
pending?.reject(new Error("Login cancelled"));
}
@@ -140,51 +143,179 @@ export class OAuthLoginFlowService {
for (const record of this.flows.values()) {
this.clearTimer(record);
record.abort.abort();
- const pending = record.pending;
- record.pending = undefined;
+ const pending = this.clearPending(record);
pending?.reject(new Error("Login cancelled"));
}
this.flows.clear();
}
- private waitForPrompt(record: OAuthFlowRecord, prompt: OAuthPrompt, kind: "prompt" | "manual"): Promise {
+ private handlePrompt(record: OAuthFlowRecord, prompt: AuthPrompt): Promise {
+ if (prompt.type === "select") return this.waitForSelect(record, prompt);
+ return this.waitForPrompt(record, prompt);
+ }
+
+ private handleEvent(record: OAuthFlowRecord, event: AuthEvent): void {
+ if (!this.isCurrentRunning(record)) return;
+ switch (event.type) {
+ case "auth_url":
+ this.updateState(record, { ...record.state, auth: { url: event.url, ...(event.instructions === undefined ? {} : { instructions: event.instructions }) } });
+ return;
+ // Keep the legacy auth URL/instructions while adding structured metadata
+ // that newer browsers can use during rolling sessiond upgrades.
+ case "device_code":
+ this.updateState(record, {
+ ...record.state,
+ auth: {
+ url: event.verificationUri,
+ instructions: `Enter code: ${event.userCode}`,
+ deviceCode: {
+ userCode: event.userCode,
+ ...(event.intervalSeconds === undefined ? {} : { intervalSeconds: event.intervalSeconds }),
+ ...(event.expiresInSeconds === undefined ? {} : { expiresInSeconds: event.expiresInSeconds }),
+ },
+ },
+ });
+ return;
+ case "info":
+ this.updateState(record, {
+ ...record.state,
+ progress: [...record.state.progress, event.message],
+ info: [
+ ...(record.state.info ?? []),
+ {
+ message: event.message,
+ ...(event.links === undefined ? {} : {
+ links: event.links.map((link) => ({
+ url: link.url,
+ ...(link.label === undefined ? {} : { label: link.label }),
+ })),
+ }),
+ },
+ ],
+ });
+ return;
+ case "progress":
+ this.updateState(record, { ...record.state, progress: [...record.state.progress, event.message] });
+ return;
+ }
+ }
+
+ private waitForPrompt(record: OAuthFlowRecord, prompt: ValuePrompt): Promise {
return new Promise((resolve, reject) => {
if (!this.isCurrentRunning(record)) {
reject(new Error("Login cancelled"));
return;
}
const requestId = crypto.randomUUID();
- record.pending = { requestId, allowEmpty: prompt.allowEmpty === true, resolve: (value) => { resolve(value ?? ""); }, reject };
+ const pending: PendingOAuthRequest = {
+ requestId,
+ allowEmpty: prompt.type === "text",
+ resolve,
+ reject,
+ };
+ record.pending = pending;
+ if (!this.bindPromptSignal(record, pending, prompt.signal)) return;
const base = withoutInteraction(record.state);
this.updateState(record, {
...base,
prompt: {
requestId,
message: prompt.message,
- kind,
+ kind: prompt.type === "manual_code" ? "manual" : "prompt",
+ promptType: prompt.type,
+ ...(prompt.type === "text" ? { allowEmpty: true } : {}),
...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }),
- ...(prompt.allowEmpty === true ? { allowEmpty: true } : {}),
},
});
});
}
- private waitForSelect(record: OAuthFlowRecord, prompt: OAuthSelectPrompt): Promise {
+ private waitForSelect(record: OAuthFlowRecord, prompt: SelectPrompt): Promise {
return new Promise((resolve, reject) => {
if (!this.isCurrentRunning(record)) {
reject(new Error("Login cancelled"));
return;
}
const requestId = crypto.randomUUID();
- const options: CommandOption[] = prompt.options.map((option) => ({ value: option.id, label: option.label }));
- record.pending = { requestId, allowEmpty: true, resolve, reject };
+ const options: CommandOption[] = prompt.options.map((option) => ({
+ value: option.id,
+ label: option.label,
+ ...(option.description === undefined ? {} : { description: option.description }),
+ }));
+ const pending: PendingOAuthRequest = {
+ requestId,
+ allowEmpty: false,
+ resolve,
+ reject,
+ allowedValues: new Set(options.map((option) => option.value)),
+ };
+ record.pending = pending;
+ if (!this.bindPromptSignal(record, pending, prompt.signal)) return;
const base = withoutInteraction(record.state);
this.updateState(record, { ...base, select: { requestId, message: prompt.message, options } });
});
}
+ // A prompt may carry its own AbortSignal (e.g. a manual_code prompt raced
+ // against a callback server). When it fires, drop just that pending request
+ // and clear the interaction from state — the overall login keeps running.
+ private bindPromptSignal(record: OAuthFlowRecord, pending: PendingOAuthRequest, signal?: AbortSignal): boolean {
+ if (signal === undefined) return true;
+ const onAbort = () => {
+ if (record.pending !== pending) return;
+ this.clearPending(record);
+ if (this.isCurrentRunning(record)) this.updateState(record, withoutInteraction(record.state));
+ pending.reject(new Error("Prompt cancelled"));
+ };
+ pending.cleanup = () => { signal.removeEventListener("abort", onAbort); };
+ signal.addEventListener("abort", onAbort, { once: true });
+ if (signal.aborted) {
+ onAbort();
+ return false;
+ }
+ return true;
+ }
+
+ private clearPending(record: OAuthFlowRecord): PendingOAuthRequest | undefined {
+ const pending = record.pending;
+ record.pending = undefined;
+ pending?.cleanup?.();
+ return pending;
+ }
+
+ // ModelRuntime persists the credential before its post-login refresh. If a
+ // cancellation lands during that refresh, the resolved login is committed
+ // truth and must supersede the transient cancelled state.
+ private async reconcileCommittedLogin(record: OAuthFlowRecord, onComplete?: () => void | Promise): Promise {
+ if (this.isCurrent(record)) this.clearPending(record);
+ try {
+ await onComplete?.();
+ } catch (error) {
+ this.logErrorNoThrow(
+ { err: error, flowId: record.flowId, providerId: record.state.providerId },
+ "login completion callback failed",
+ );
+ }
+ if (!this.isCurrent(record)) return;
+ const completed = withoutInteraction(record.state);
+ delete completed.error;
+ this.markTerminal(record, { ...completed, status: "complete", progress: [...record.state.progress, "Login complete"] });
+ }
+
+ private isCurrent(record: OAuthFlowRecord): boolean {
+ return this.flows.get(record.flowId) === record;
+ }
+
private isCurrentRunning(record: OAuthFlowRecord): boolean {
- return this.flows.get(record.flowId) === record && record.state.status === "running";
+ return this.isCurrent(record) && record.state.status === "running";
+ }
+
+ private logErrorNoThrow(details: Record, message: string): void {
+ try {
+ this.logger.error(details, message);
+ } catch {
+ // Logging is post-commit diagnostics and must never change auth truth.
+ }
}
private updateState(record: OAuthFlowRecord, state: OAuthFlowState): void {
@@ -226,10 +357,9 @@ export class OAuthLoginFlowService {
private expireRunningFlow(record: OAuthFlowRecord): void {
if (!this.isCurrentRunning(record)) return;
record.abort.abort();
- const pending = record.pending;
- record.pending = undefined;
- this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "OAuth login flow expired" });
- pending?.reject(new Error("OAuth login flow expired"));
+ const pending = this.clearPending(record);
+ this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "Login flow expired" });
+ pending?.reject(new Error("Login flow expired"));
}
private setTimer(record: OAuthFlowRecord, delayMs: number, callback: () => void): void {
@@ -256,9 +386,20 @@ function cloneState(state: OAuthFlowState): OAuthFlowState {
return {
...state,
progress: [...state.progress],
- ...(state.auth === undefined ? {} : { auth: { ...state.auth } }),
+ ...(state.auth === undefined ? {} : {
+ auth: {
+ ...state.auth,
+ ...(state.auth.deviceCode === undefined ? {} : { deviceCode: { ...state.auth.deviceCode } }),
+ },
+ }),
...(state.prompt === undefined ? {} : { prompt: { ...state.prompt } }),
...(state.select === undefined ? {} : { select: { ...state.select, options: state.select.options.map((option) => ({ ...option })) } }),
+ ...(state.info === undefined ? {} : {
+ info: state.info.map((item) => ({
+ ...item,
+ ...(item.links === undefined ? {} : { links: item.links.map((link) => ({ ...link })) }),
+ })),
+ }),
};
}
diff --git a/src/server/sessions/piSessionService.archiveCleanup.test.ts b/src/server/sessions/piSessionService.archiveCleanup.test.ts
index 969ff15..22433e8 100644
--- a/src/server/sessions/piSessionService.archiveCleanup.test.ts
+++ b/src/server/sessions/piSessionService.archiveCleanup.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
-import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js";
+import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
@@ -15,6 +15,7 @@ describe("PiSessionService archive and cleanup", () => {
const fake = fakeRuntime("root", { sessionFile: root.path });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]),
@@ -49,6 +50,7 @@ describe("PiSessionService archive and cleanup", () => {
const deletedSessionIds: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
@@ -83,6 +85,7 @@ describe("PiSessionService archive and cleanup", () => {
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
@@ -118,6 +121,7 @@ describe("PiSessionService archive and cleanup", () => {
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: () => {
createCalls += 1;
return Promise.resolve(busy.runtime);
@@ -159,6 +163,7 @@ describe("PiSessionService archive and cleanup", () => {
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(busy.runtime),
archiveStore: {
list: () => Promise.resolve([busyRecord, idleRecord]),
@@ -196,6 +201,7 @@ describe("PiSessionService archive and cleanup", () => {
const listCalls: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
@@ -239,6 +245,7 @@ describe("PiSessionService archive and cleanup", () => {
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([archived, otherArchived]),
@@ -292,6 +299,7 @@ describe("PiSessionService archive and cleanup", () => {
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([
@@ -334,6 +342,7 @@ describe("PiSessionService archive and cleanup", () => {
const archivedInputs: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
now: () => new Date("2026-06-25T00:00:00.000Z"),
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
diff --git a/src/server/sessions/piSessionService.lifecycle.test.ts b/src/server/sessions/piSessionService.lifecycle.test.ts
index 51d5b08..bf33407 100644
--- a/src/server/sessions/piSessionService.lifecycle.test.ts
+++ b/src/server/sessions/piSessionService.lifecycle.test.ts
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js";
-import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js";
+import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
@@ -31,6 +31,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
};
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -60,6 +61,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
try {
service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -87,6 +89,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const open = vi.fn(() => fakeSessionManager());
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
@@ -136,6 +139,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const open = vi.spyOn(gateway, "open");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
archiveStore: emptyArchiveStore(),
createAgentRuntime,
sessionManager: gateway,
@@ -190,6 +194,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
archiveStore: emptyArchiveStore(),
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord(sessionId)]),
@@ -230,6 +235,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const fake = fakeRuntime(sessionId);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
archiveStore: emptyArchiveStore(),
createAgentRuntime: () => {
createStarted.resolve();
@@ -264,6 +270,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -291,6 +298,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
});
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -326,6 +334,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
});
service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("idle-session")]),
heartbeatIntervalMs: 1_000,
@@ -362,6 +371,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
});
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("completion-session")]),
heartbeatIntervalMs: 60_000,
@@ -381,6 +391,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("uses injected archive and session-manager gateways for listing", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]),
get: () => Promise.resolve(undefined),
@@ -411,6 +422,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("lists archived records that have been moved out of the active session directory", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined),
@@ -442,6 +454,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const fake = fakeRuntime("runtime-reload-session");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
heartbeatIntervalMs: 60_000,
@@ -475,6 +488,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("reload-session")]),
heartbeatIntervalMs: 60_000,
@@ -499,6 +513,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const fake = fakeRuntime("busy-session", { isStreaming: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("busy-session")]),
heartbeatIntervalMs: 60_000,
@@ -514,6 +529,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("refuses to reload an archived session", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
@@ -536,6 +552,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined),
@@ -573,6 +590,7 @@ describe("PiSessionService.streamSnapshot", () => {
const fake = fakeRuntime("snap-idle");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -601,6 +619,7 @@ describe("PiSessionService.streamSnapshot", () => {
const fake = fakeRuntime("snap-live", { state: { streamingMessage } });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts
index 92cab69..20d8314 100644
--- a/src/server/sessions/piSessionService.promptQueue.test.ts
+++ b/src/server/sessions/piSessionService.promptQueue.test.ts
@@ -1,9 +1,12 @@
-import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createAssistantMessageEventStream, InMemoryCredentialStore, type AssistantMessage } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
-import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
+import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
-import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
+import { CapturingSessionEventHub, createTestModelRuntime, fakeRuntime, runtimeCreator, seedCredential, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
@@ -12,6 +15,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("prompt-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
@@ -30,6 +34,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const hub = new CapturingSessionEventHub();
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("echo-session")]),
heartbeatIntervalMs: 60_000,
@@ -60,6 +65,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
@@ -96,6 +102,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("name-session")]),
heartbeatIntervalMs: 60_000,
@@ -118,6 +125,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("status-session")]),
heartbeatIntervalMs: 60_000,
@@ -139,6 +147,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
heartbeatIntervalMs: 60_000,
@@ -155,6 +164,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("queued-session", { isStreaming: true });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("queued-session")]),
heartbeatIntervalMs: 60_000,
@@ -181,6 +191,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
};
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("compacting-session")]),
heartbeatIntervalMs: 60_000,
@@ -250,6 +261,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
fake.session.clearQueue = clearRuntimeQueue;
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("clear-queue-session")]),
heartbeatIntervalMs: 60_000,
@@ -290,6 +302,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("clear-empty-queue-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]),
heartbeatIntervalMs: 60_000,
@@ -309,6 +322,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("abort-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-session")]),
heartbeatIntervalMs: 60_000,
@@ -326,6 +340,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
heartbeatIntervalMs: 60_000,
@@ -341,17 +356,66 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
await service.dispose();
});
+ it("reloads models.json before listing and selecting models", async () => {
+ const agentDir = await mkdtemp(join(tmpdir(), "pi-web-model-runtime-"));
+ try {
+ const modelsPath = join(agentDir, "models.json");
+ await writeLocalModelsConfig(modelsPath, "initial-model");
+ const modelRuntime = await ModelRuntime.create({
+ credentials: new InMemoryCredentialStore(),
+ modelsPath,
+ allowModelNetwork: false,
+ });
+ const setSessionModel = vi.fn(() => Promise.resolve());
+ const fake = fakeRuntime("models-session", { modelRuntime, setModel: setSessionModel });
+ const service = new PiSessionService(new CapturingSessionEventHub(), {
+ agentDir,
+ modelRuntime,
+ createAgentRuntime: runtimeCreator(fake.runtime),
+ sessionManager: sessionGateway([sessionRecord("models-session")]),
+ heartbeatIntervalMs: 60_000,
+ });
+
+ try {
+ await writeLocalModelsConfig(modelsPath, "listed-model");
+ const listed = await service.availableModels(sessionRef("models-session"));
+ expect(listed).toEqual(expect.arrayContaining([
+ expect.objectContaining({ provider: "test-local", id: "listed-model" }),
+ ]));
+ expect(listed).not.toEqual(expect.arrayContaining([
+ expect.objectContaining({ provider: "test-local", id: "initial-model" }),
+ ]));
+
+ await writeLocalModelsConfig(modelsPath, "selected-model");
+ await expect(service.setModel(sessionRef("models-session"), "test-local", "selected-model")).resolves.toBeDefined();
+ expect(setSessionModel).toHaveBeenCalledWith(expect.objectContaining({
+ provider: "test-local",
+ id: "selected-model",
+ }));
+ } finally {
+ await service.dispose();
+ }
+ } finally {
+ await rm(agentDir, { recursive: true, force: true });
+ }
+ });
+
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
const hub = new CapturingSessionEventHub();
- const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } });
- const modelRegistry = ModelRegistry.inMemory(authStorage);
- const model = modelRegistry.find(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
+ // The shared model runtime reads a live credential store. Mutating the store
+ // and refreshing here simulates the committed snapshot that
+ // ModelRuntime.login()/logout() establishes before AuthService emits.
+ // applyAuthChange then only needs to notify active sessions.
+ const credentials = new InMemoryCredentialStore();
+ await seedCredential(credentials, "anthropic", { type: "api_key", key: "sk-test" });
+ const modelRuntime = await createTestModelRuntime(credentials);
+ const model = modelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
if (model === undefined) throw new Error("Expected Anthropic model fixture");
- const fake = fakeRuntime("auth-session", { model, modelRegistry });
+ const fake = fakeRuntime("auth-session", { model, modelRuntime });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
- modelRegistry,
+ modelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("auth-session")]),
heartbeatIntervalMs: 60_000,
@@ -361,7 +425,8 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
hub.sessionEvents.length = 0;
hub.globalEvents.length = 0;
- authStorage.logout("anthropic");
+ await credentials.delete("anthropic");
+ await modelRuntime.refresh();
service.applyAuthChange({ removedProviderId: "anthropic" });
service.applyAuthChange({ removedProviderId: "anthropic" });
@@ -369,9 +434,11 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
expect(warningCount()).toBe(1);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true);
- authStorage.set("anthropic", { type: "api_key", key: "sk-new" });
+ await seedCredential(credentials, "anthropic", { type: "api_key", key: "sk-new" });
+ await modelRuntime.refresh();
service.applyAuthChange();
- authStorage.logout("anthropic");
+ await credentials.delete("anthropic");
+ await modelRuntime.refresh();
service.applyAuthChange({ removedProviderId: "anthropic" });
expect(warningCount()).toBe(2);
@@ -382,6 +449,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("stop-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("stop-session")]),
heartbeatIntervalMs: 60_000,
@@ -394,3 +462,25 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
await service.dispose();
});
});
+
+async function writeLocalModelsConfig(path: string, modelId: string): Promise {
+ await writeFile(path, JSON.stringify({
+ providers: {
+ "test-local": {
+ name: "Test Local",
+ baseUrl: "http://127.0.0.1:1234/v1",
+ apiKey: "offline-test-key",
+ api: "openai-completions",
+ models: [{
+ id: modelId,
+ name: modelId,
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 1_000,
+ maxTokens: 100,
+ }],
+ },
+ },
+ }));
+}
diff --git a/src/server/sessions/piSessionService.spawnSession.test.ts b/src/server/sessions/piSessionService.spawnSession.test.ts
index 29346ee..0d10106 100644
--- a/src/server/sessions/piSessionService.spawnSession.test.ts
+++ b/src/server/sessions/piSessionService.spawnSession.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
-import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
+import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
@@ -12,6 +12,7 @@ describe("PiSessionService", () => {
const log: { details: Record; message: string }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
@@ -45,6 +46,7 @@ describe("PiSessionService", () => {
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
@@ -80,6 +82,7 @@ describe("PiSessionService", () => {
const fake = fakeRuntime("spawned-x");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts
index 376b08d..13177eb 100644
--- a/src/server/sessions/piSessionService.spawnSubsession.test.ts
+++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
-import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
+import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
@@ -40,6 +40,7 @@ describe("PiSessionService", () => {
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore,
@@ -81,6 +82,7 @@ describe("PiSessionService", () => {
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore: emptyArchiveStore(),
@@ -121,6 +123,7 @@ describe("PiSessionService", () => {
let index = 0;
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime;
index += 1;
@@ -173,6 +176,7 @@ describe("PiSessionService", () => {
const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime;
index += 1;
@@ -215,6 +219,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -240,6 +245,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -262,6 +268,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -283,6 +290,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -304,6 +312,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(forkedParent.runtime),
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -343,6 +352,7 @@ describe("PiSessionService", () => {
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: (_createRuntime, options) => {
delegationCapabilities.push(options.delegationToolsEnabled);
const runtime = runtimes[index] ?? parent.runtime;
@@ -409,6 +419,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -469,6 +480,7 @@ describe("PiSessionService", () => {
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -536,6 +548,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: {
create: () => parentManager,
@@ -614,6 +627,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: {
create: () => copiedParentManager,
@@ -674,6 +688,7 @@ describe("PiSessionService", () => {
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -728,6 +743,7 @@ describe("PiSessionService", () => {
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -770,6 +786,7 @@ describe("PiSessionService", () => {
const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(child.runtime),
sessionManager: {
create: () => childManager,
@@ -959,6 +976,7 @@ describe("PiSessionService", () => {
const fake = fakeRuntime("nope");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts
index 84a7247..5ddefa3 100644
--- a/src/server/sessions/piSessionService.testSupport.ts
+++ b/src/server/sessions/piSessionService.testSupport.ts
@@ -1,4 +1,5 @@
-import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
+import { ModelRuntime } from "@earendil-works/pi-coding-agent";
+import { InMemoryCredentialStore, type Credential, type CredentialStore } from "@earendil-works/pi-ai";
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.js";
@@ -62,8 +63,34 @@ export function sessionRef(id: string, cwd = "/workspace") {
export const TEST_MODEL_PROVIDER = "anthropic";
export const TEST_MODEL_ID = "claude-sonnet-4-5-20250929";
+/**
+ * Seed a credential into an {@link InMemoryCredentialStore}. `modify` is the
+ * only write path on the pi-ai `CredentialStore` contract, so tests that need a
+ * pre-populated store go through it rather than mutating internals.
+ */
+export async function seedCredential(store: InMemoryCredentialStore, providerId: string, credential: Credential): Promise {
+ await store.modify(providerId, () => Promise.resolve(credential));
+}
+
+/**
+ * Build a real {@link ModelRuntime} over an in-memory credential store — the
+ * async test seam that replaces the removed `ModelRegistry.create(AuthStorage
+ * .inMemory())`. Pass a pre-seeded store to exercise credential-dependent
+ * behavior (e.g. auth-loss warnings).
+ */
+export function createTestModelRuntime(credentials: CredentialStore = new InMemoryCredentialStore()): Promise {
+ return ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false });
+}
+
+/**
+ * Shared runtime for the common case where a test only needs model catalog
+ * reads and no configured auth. Built once so the many `fakeRuntime` sessions
+ * and `PiSessionService` constructions can inject it synchronously.
+ */
+export const testModelRuntime = await createTestModelRuntime();
+
export function testModel(): NonNullable {
- const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
+ const model = testModelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
if (model === undefined) throw new Error("test model not found");
return model;
}
@@ -88,7 +115,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial
pendingMessageCount: 0,
sessionManager: fakeSessionManager(),
settingsManager: { getWarnings: () => ({}), setWarnings: () => undefined },
- modelRegistry: ModelRegistry.create(AuthStorage.inMemory()),
+ modelRuntime: testModelRuntime,
scopedModels: [],
extensionRunner: { getRegisteredCommands: () => [] },
promptTemplates: [],
diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts
index 6b26a43..1e8d9cd 100644
--- a/src/server/sessions/piSessionService.ts
+++ b/src/server/sessions/piSessionService.ts
@@ -1,20 +1,21 @@
import { statSync } from "node:fs";
+import { join } from "node:path";
import { open, readFile, writeFile } from "node:fs/promises";
import type { ImageContent } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import {
- AuthStorage,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
createEditToolDefinition,
defineTool,
- ModelRegistry,
+ readStoredCredential,
SessionManager,
type AgentSessionRuntimeDiagnostic,
type AgentSessionServices,
type CreateAgentSessionRuntimeFactory,
type EditToolDetails,
+ type ModelRuntime,
type ResourceDiagnostic,
} from "@earendil-works/pi-coding-agent";
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionStreamSnapshot, SessionUiEvent } from "../types.js";
@@ -26,7 +27,6 @@ import { SessionCommandService } from "./sessionCommandService.js";
import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js";
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js";
import type { ActiveSession } from "./sessionRuntimeStore.js";
-import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js";
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
@@ -34,6 +34,7 @@ import { parsePromptAttachments } from "../../shared/promptAttachments.js";
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionWarning } from "../../shared/apiTypes.js";
import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js";
+import { type AuthChange } from "./authService.js";
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
@@ -171,7 +172,6 @@ interface BulkDeletePlanItem {
}
type AgentModel = NonNullable;
-type ModelRegistryInstance = ReturnType;
export interface PiSessionManager {
getCwd(): string;
@@ -208,7 +208,7 @@ interface PiExtensionBindings {
}
export interface PiAgentSession {
- modelRegistry: ModelRegistryInstance;
+ modelRuntime: ModelRuntime;
/**
* Narrow read/write of the SDK `SettingsManager`, exposing only the warning
* suppression flags consumed here (e.g. `anthropicExtraUsage`). Used to gate
@@ -383,11 +383,12 @@ const ANTHROPIC_EXTRA_USAGE_DISMISS_ID = "anthropicExtraUsage";
* synchronous live status computation.
*/
export function anthropicSubscriptionWarning(
- session: Pick,
+ session: Pick,
+ authPath?: string,
): SessionWarning | undefined {
if (session.settingsManager.getWarnings().anthropicExtraUsage === false) return undefined;
if (session.model?.provider !== "anthropic") return undefined;
- const credential = session.modelRegistry.authStorage.get("anthropic");
+ const credential = readStoredCredential("anthropic", authPath);
if (credential === undefined) return undefined;
const isSubscriptionAuth = credential.type === "oauth"
? true
@@ -487,14 +488,13 @@ export function createPiWebCustomToolDefinitions(
}
function createDefaultRuntimeFactory(
- authStorage: AuthStorage,
- modelRegistry: ModelRegistryInstance,
+ modelRuntime: ModelRuntime,
sessionManagers: Pick,
spawn?: SpawnSessionFn,
subsessions?: SubsessionToolDeps,
): PiWebCreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
- const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
+ const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
const resolvedDelegationToolsEnabled = delegationToolsEnabled
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
@@ -539,7 +539,7 @@ export interface PiSessionServiceDependencies {
archiveStore?: SessionArchiveRepository;
createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
createAgentRuntime?: CreateAgentRuntime;
- modelRegistry?: ModelRegistryInstance;
+ modelRuntime: ModelRuntime;
heartbeatIntervalMs?: number;
workspaceActivity?: Pick;
/**
@@ -589,7 +589,7 @@ export class PiSessionService implements SessionRouteService {
private readonly sessionManager: PiSessionManagerGateway;
private readonly createRuntime: PiWebCreateAgentSessionRuntimeFactory;
private readonly createAgentRuntime: CreateAgentRuntime;
- private readonly modelRegistry: ModelRegistryInstance;
+ private readonly modelRuntime: ModelRuntime;
private readonly workspaceActivity: Pick | undefined;
private readonly spawnTargets: SpawnTargetResolver | undefined;
private readonly logger: PiSessionLogger;
@@ -599,7 +599,7 @@ export class PiSessionService implements SessionRouteService {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
this.agentDir = deps.agentDir;
this.sessionManager = deps.sessionManager;
- this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir);
+ this.modelRuntime = deps.modelRuntime;
this.spawnTargets = deps.spawnTargets;
this.logger = deps.logger ?? noopLogger;
this.now = deps.now ?? (() => new Date());
@@ -607,8 +607,7 @@ export class PiSessionService implements SessionRouteService {
// also require the spawn capability (they share its project-scope resolver).
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(
- this.modelRegistry.authStorage,
- this.modelRegistry,
+ this.modelRuntime,
this.sessionManager,
this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input),
!subsessionsActive ? undefined : {
@@ -1159,22 +1158,22 @@ export class PiSessionService implements SessionRouteService {
async availableModels(ref: PiSessionLookup): Promise {
const session = await this.getOrOpen(ref);
- session.modelRegistry.refresh();
+ await session.modelRuntime.reloadConfig();
const models = session.scopedModels.length > 0
? session.scopedModels.map((scoped) => scoped.model)
- : session.modelRegistry.getAvailable();
+ : session.modelRuntime.getAvailableSnapshot();
return models.map(modelToClientModel);
}
async setModel(ref: PiSessionLookup, provider: string, modelId: string): Promise {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
- session.modelRegistry.refresh();
+ await session.modelRuntime.reloadConfig();
const candidates = session.scopedModels.length > 0
? session.scopedModels.map((scoped) => scoped.model)
- : session.modelRegistry.getAvailable();
+ : session.modelRuntime.getAvailableSnapshot();
const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
- ?? session.modelRegistry.find(provider, modelId);
+ ?? session.modelRuntime.getModel(provider, modelId);
if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`);
await session.setModel(model);
this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
@@ -2020,10 +2019,11 @@ export class PiSessionService implements SessionRouteService {
}
applyAuthChange(change: AuthChange = {}): void {
- this.modelRegistry.refresh();
+ // ModelRuntime.login()/logout() refresh the shared runtime before AuthService
+ // emits the change, so no refresh is needed here. Keeping this synchronous
+ // also lets every active session observe the same committed auth snapshot.
for (const active of this.active.values()) {
const { session } = active.runtime;
- session.modelRegistry.refresh();
this.syncCurrentModelAuthWarning(session, change.removedProviderId);
this.publishStatus(session);
}
@@ -2034,9 +2034,9 @@ export class PiSessionService implements SessionRouteService {
if (model === undefined) return;
if (model.provider === "unknown" && model.id === "unknown") return;
const warningKey = authLossWarningKey(session.sessionId, model.provider, model.id);
- const registered = session.modelRegistry.find(model.provider, model.id);
+ const registered = session.modelRuntime.getModel(model.provider, model.id);
if (registered === undefined) return;
- if (session.modelRegistry.hasConfiguredAuth(registered)) {
+ if (session.modelRuntime.hasConfiguredAuth(model.provider)) {
this.authLossWarnings.delete(warningKey);
return;
}
@@ -2182,7 +2182,7 @@ export class PiSessionService implements SessionRouteService {
private warningsForSession(session: PiAgentSession): SessionWarning[] {
const runtime = this.active.get(session.sessionId)?.runtime;
const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
- const anthropic = anthropicSubscriptionWarning(session);
+ const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
if (anthropic !== undefined) warnings.push(anthropic);
return warnings;
}
diff --git a/src/server/sessions/piSessionService.warnings.test.ts b/src/server/sessions/piSessionService.warnings.test.ts
index c4ec0a7..502f810 100644
--- a/src/server/sessions/piSessionService.warnings.test.ts
+++ b/src/server/sessions/piSessionService.warnings.test.ts
@@ -1,6 +1,10 @@
-import { describe, expect, it } from "vitest";
-import { AuthStorage, ModelRegistry, type AgentSessionRuntimeDiagnostic, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { type AgentSessionRuntimeDiagnostic, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
import { anthropicSubscriptionWarning, collectRuntimeWarnings, dismissSessionWarning, type RuntimeWarningSources } from "./piSessionService.js";
+import { testModel } from "./piSessionService.testSupport.js";
import type { PiAgentSession } from "./piSessionService.js";
import type { SessionWarning } from "../../shared/apiTypes.js";
@@ -83,47 +87,55 @@ describe("collectRuntimeWarnings", () => {
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage.";
-type SubscriptionSession = Pick;
+type SubscriptionSession = Pick;
function anthropicModel(provider: string): PiAgentSession["model"] {
- const registry = ModelRegistry.inMemory(AuthStorage.inMemory());
- const model = registry.getAll().find((candidate) => candidate.provider === provider) ?? registry.getAll()[0];
- if (model === undefined) throw new Error("expected at least one built-in model");
- return { ...model, provider };
+ // anthropicSubscriptionWarning only reads `model.provider`, so any built-in
+ // model re-tagged with the desired provider is a sufficient fixture.
+ return { ...testModel(), provider };
}
function subscriptionSession(options: {
provider?: string;
anthropicExtraUsage?: boolean;
- credential?: AuthStorage;
}): SubscriptionSession {
- const authStorage = options.credential ?? AuthStorage.inMemory();
return {
model: options.provider === undefined ? undefined : anthropicModel(options.provider),
settingsManager: {
getWarnings: () => (options.anthropicExtraUsage === undefined ? {} : { anthropicExtraUsage: options.anthropicExtraUsage }),
setWarnings: () => undefined,
},
- modelRegistry: ModelRegistry.create(authStorage),
};
}
-function anthropicAuth(credential: { type: "oauth" } | { type: "api_key"; key: string }): AuthStorage {
- const authStorage = AuthStorage.inMemory();
- if (credential.type === "oauth") {
- authStorage.set("anthropic", { type: "oauth", access: "a", refresh: "r", expires: Date.now() + 3_600_000 });
- } else {
- authStorage.set("anthropic", { type: "api_key", key: credential.key });
- }
- return authStorage;
+const tempDirs: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
+});
+
+/**
+ * Write an `auth.json` holding a single anthropic credential and return its
+ * path. `anthropicSubscriptionWarning` reads it via `readStoredCredential`, so
+ * the credential seam is the on-disk auth file rather than an in-memory store.
+ */
+async function anthropicAuthPath(credential: { type: "oauth" } | { type: "api_key"; key: string }): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "pi-web-warnings-"));
+ tempDirs.push(dir);
+ const authPath = join(dir, "auth.json");
+ const stored = credential.type === "oauth"
+ ? { type: "oauth", access: "a", refresh: "r", expires: Date.now() + 3_600_000 }
+ : { type: "api_key", key: credential.key };
+ await writeFile(authPath, JSON.stringify({ anthropic: stored }));
+ return authPath;
}
describe("anthropicSubscriptionWarning", () => {
- it("warns with the verbatim SDK wording for a stored oauth credential", () => {
- expect(anthropicSubscriptionWarning(subscriptionSession({
- provider: "anthropic",
- credential: anthropicAuth({ type: "oauth" }),
- }))).toEqual({
+ it("warns with the verbatim SDK wording for a stored oauth credential", async () => {
+ expect(anthropicSubscriptionWarning(
+ subscriptionSession({ provider: "anthropic" }),
+ await anthropicAuthPath({ type: "oauth" }),
+ )).toEqual({
severity: "warning",
message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING,
source: "anthropic",
@@ -131,37 +143,41 @@ describe("anthropicSubscriptionWarning", () => {
} satisfies SessionWarning);
});
- it("warns for an sk-ant-oat subscription API key", () => {
- expect(anthropicSubscriptionWarning(subscriptionSession({
- provider: "anthropic",
- credential: anthropicAuth({ type: "api_key", key: "sk-ant-oat-abc123" }),
- }))?.message).toBe(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
+ it("warns for an sk-ant-oat subscription API key", async () => {
+ expect(anthropicSubscriptionWarning(
+ subscriptionSession({ provider: "anthropic" }),
+ await anthropicAuthPath({ type: "api_key", key: "sk-ant-oat-abc123" }),
+ )?.message).toBe(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
});
- it("does not warn for a standard anthropic API key", () => {
- expect(anthropicSubscriptionWarning(subscriptionSession({
- provider: "anthropic",
- credential: anthropicAuth({ type: "api_key", key: "sk-ant-api-abc123" }),
- }))).toBeUndefined();
+ it("does not warn for a standard anthropic API key", async () => {
+ expect(anthropicSubscriptionWarning(
+ subscriptionSession({ provider: "anthropic" }),
+ await anthropicAuthPath({ type: "api_key", key: "sk-ant-api-abc123" }),
+ )).toBeUndefined();
});
- it("respects the anthropicExtraUsage suppression gate", () => {
- expect(anthropicSubscriptionWarning(subscriptionSession({
- provider: "anthropic",
- anthropicExtraUsage: false,
- credential: anthropicAuth({ type: "oauth" }),
- }))).toBeUndefined();
+ it("respects the anthropicExtraUsage suppression gate", async () => {
+ expect(anthropicSubscriptionWarning(
+ subscriptionSession({ provider: "anthropic", anthropicExtraUsage: false }),
+ await anthropicAuthPath({ type: "oauth" }),
+ )).toBeUndefined();
});
- it("does not warn when the active provider is not anthropic", () => {
- expect(anthropicSubscriptionWarning(subscriptionSession({
- provider: "openai",
- credential: anthropicAuth({ type: "oauth" }),
- }))).toBeUndefined();
+ it("does not warn when the active provider is not anthropic", async () => {
+ expect(anthropicSubscriptionWarning(
+ subscriptionSession({ provider: "openai" }),
+ await anthropicAuthPath({ type: "oauth" }),
+ )).toBeUndefined();
});
- it("does not warn when no anthropic credential is stored", () => {
- expect(anthropicSubscriptionWarning(subscriptionSession({ provider: "anthropic" }))).toBeUndefined();
+ it("does not warn when no anthropic credential is stored", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "pi-web-warnings-"));
+ tempDirs.push(dir);
+ expect(anthropicSubscriptionWarning(
+ subscriptionSession({ provider: "anthropic" }),
+ join(dir, "auth.json"),
+ )).toBeUndefined();
});
});
diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts
index 54dd4b3..2507177 100644
--- a/src/server/sessions/sessionRoutes.test.ts
+++ b/src/server/sessions/sessionRoutes.test.ts
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus, SessionStreamSnapshot } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
+import { testModelRuntime } from "./piSessionService.testSupport.js";
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
import { registerSessionRoutes } from "./sessionRoutes.js";
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
@@ -20,7 +21,7 @@ beforeEach(async () => {
await app.register(fastifyWebsocket);
sessionManager = new RejectingSessionManager();
const eventHub = new SessionEventHub();
- service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, sessionManager, heartbeatIntervalMs: 60_000 });
+ service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, modelRuntime: testModelRuntime, sessionManager, heartbeatIntervalMs: 60_000 });
registerSessionRoutes(app, service, eventHub);
});
diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts
index 24ffc97..d687d43 100644
--- a/src/shared/apiTypes.ts
+++ b/src/shared/apiTypes.ts
@@ -373,6 +373,8 @@ export interface AuthProviderOption {
name: string;
authType: AuthType;
status: AuthProviderStatus;
+ /** Additive hint: use the generic AuthInteraction transport instead of the legacy one-secret form. */
+ loginFlow?: "interactive";
}
export interface AuthProvidersResponse {
@@ -384,10 +386,23 @@ export interface OAuthFlowState {
providerId: string;
providerName: string;
status: "running" | "complete" | "error" | "cancelled";
- auth?: { url: string; instructions?: string };
- prompt?: { requestId: string; message: string; placeholder?: string; allowEmpty?: boolean; kind: "prompt" | "manual" };
+ auth?: {
+ url: string;
+ instructions?: string;
+ deviceCode?: { userCode: string; intervalSeconds?: number; expiresInSeconds?: number };
+ };
+ prompt?: {
+ requestId: string;
+ message: string;
+ placeholder?: string;
+ allowEmpty?: boolean;
+ /** Additive semantic detail; legacy peers continue to use `kind`. */
+ promptType?: "text" | "secret" | "manual_code";
+ kind: "prompt" | "manual";
+ };
select?: { requestId: string; message: string; options: CommandOption[] };
progress: string[];
+ info?: { message: string; links?: { url: string; label?: string }[] }[];
error?: string;
}
diff --git a/src/shared/federatedRoutes.ts b/src/shared/federatedRoutes.ts
index 8a4558f..6614c79 100644
--- a/src/shared/federatedRoutes.ts
+++ b/src/shared/federatedRoutes.ts
@@ -76,6 +76,7 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
{ method: "GET", path: "/auth/providers" },
{ method: "POST", path: "/auth/api-key" },
+ { method: "POST", path: "/auth/api-key/interactive" },
{ method: "POST", path: "/auth/logout" },
{ method: "POST", path: "/auth/oauth" },
{ method: "GET", path: "/auth/oauth/:flowId" },