diff --git a/.changeset/fix-pi-0-80-8-modelruntime-auth.md b/.changeset/fix-pi-0-80-8-modelruntime-auth.md
index 822b379..24951dd 100644
--- a/.changeset/fix-pi-0-80-8-modelruntime-auth.md
+++ b/.changeset/fix-pi-0-80-8-modelruntime-auth.md
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch
---
-Restore session-daemon startup and authentication on supported Pi `>=0.80.8 <0.81` releases by migrating model and credential handling to `ModelRuntime`. Login options now follow each provider's interactive API-key and OAuth capabilities, OAuth prompts retain their input, selection, and device-code semantics, committed OAuth login remains truthful when cancellation races the final refresh, and unsupported multi-step API-key setup fails safely instead of storing malformed credentials. PI WEB now requires Node.js `>=22.19.0`.
+Restore session-daemon startup and authentication on supported Pi `>=0.80.8 <0.81` releases by migrating model and credential handling to `ModelRuntime`. Login options now follow each provider's interactive API-key and OAuth capabilities, provider-driven API-key setup supports multi-step prompts while legacy one-secret clients still fail safely before storing malformed credentials, OAuth prompts retain their input, selection, and device-code semantics, and committed login remains truthful when cancellation races the final refresh. PI WEB now requires Node.js `>=22.19.0`.
diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts
index 7bf96e6..9bdc898 100644
--- a/src/client/src/api/clients.ts
+++ b/src/client/src/api/clients.ts
@@ -241,6 +241,7 @@ export const sessionsApi = {
return request(`${machinePrefix(options?.machineId)}/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
},
saveApiKey: (providerId: string, key: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }),
+ startInteractiveApiKeyLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key/interactive`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
logoutProvider: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/logout`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }),
startOAuthLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
oauthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState),
diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts
index d8fa3c7..748bcf0 100644
--- a/src/client/src/api/federatedRouteContract.test.ts
+++ b/src/client/src/api/federatedRouteContract.test.ts
@@ -88,6 +88,7 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
+ ignoreParseFailure(sessionsApi.startInteractiveApiKeyLogin("amazon-bedrock", machineId)),
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)),
ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)),
diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts
index 6020eb5..cf60131 100644
--- a/src/client/src/api/parsers.test.ts
+++ b/src/client/src/api/parsers.test.ts
@@ -1,8 +1,17 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
-import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
+import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
+ it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
+ const base = { id: "openai", name: "OpenAI", authType: "api_key", status: { configured: false } };
+
+ expect(parseAuthProvidersResponse({ providers: [{ ...base, loginFlow: "interactive" }, base] }).providers).toEqual([
+ { ...base, loginFlow: "interactive" },
+ base,
+ ]);
+ });
+
it("preserves additive OAuth interaction semantics", () => {
expect(parseOAuthFlowState({
flowId: "flow-1",
diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts
index d97aa17..1ed5ba8 100644
--- a/src/client/src/api/parsers.ts
+++ b/src/client/src/api/parsers.ts
@@ -369,7 +369,15 @@ function parseAuthProviderStatus(value: unknown): AuthProviderStatus {
function parseAuthProviderOption(value: unknown): AuthProviderOption {
const record = requireRecord(value);
- return { id: requireString(record, "id"), name: requireString(record, "name"), authType: parseAuthType(record["authType"]), status: parseAuthProviderStatus(record["status"]) };
+ const loginFlow = record["loginFlow"];
+ if (loginFlow !== undefined && loginFlow !== "interactive") throw new Error("Invalid auth provider login flow");
+ return {
+ id: requireString(record, "id"),
+ name: requireString(record, "name"),
+ authType: parseAuthType(record["authType"]),
+ status: parseAuthProviderStatus(record["status"]),
+ ...(loginFlow === undefined ? {} : { loginFlow }),
+ };
}
export function parseAuthProvidersResponse(value: unknown): AuthProvidersResponse {
diff --git a/src/client/src/components/AuthDialog.ts b/src/client/src/components/AuthDialog.ts
index 7663a55..900f90f 100644
--- a/src/client/src/components/AuthDialog.ts
+++ b/src/client/src/components/AuthDialog.ts
@@ -42,7 +42,7 @@ export class AuthDialog extends LitElement {
private dialogTitle(state: AuthDialogState): string {
switch (state.step) {
case "method": return "Configure provider authentication";
- case "providers": return state.authType === undefined ? "Select provider authentication" : state.authType === "oauth" ? "Select subscription provider" : "Select API key provider";
+ case "providers": return state.authType === undefined ? "Select provider authentication" : state.authType === "oauth" ? "Select subscription provider" : "Select credential provider";
case "apiKey": return `API key for ${state.provider.name}`;
case "oauth": return `Login to ${state.flow.providerName}`;
case "logout": return "Remove stored provider authentication";
@@ -54,7 +54,7 @@ export class AuthDialog extends LitElement {
case "method": return html`
-
+
`;
case "providers": return html`${state.providers.length === 0 ? html`
No providers available.
` : state.providers.map((provider) => this.renderProviderButton(provider))}
`;
@@ -183,7 +183,7 @@ export function oauthPromptInputType(promptType: NonNullable {
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(
diff --git a/src/client/src/controllers/authController.ts b/src/client/src/controllers/authController.ts
index b9df1c9..d9e2ba6 100644
--- a/src/client/src/controllers/authController.ts
+++ b/src/client/src/controllers/authController.ts
@@ -61,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: "" } });
}
@@ -189,19 +189,22 @@ 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);
if (flow.status === "running") this.startPolling(flow.flowId);
diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts
index b7d5112..778403d 100644
--- a/src/server/sessions/authProviderOptions.test.ts
+++ b/src/server/sessions/authProviderOptions.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderRuntime } from "./authProviderOptions";
-function runtime(): AuthProviderRuntime {
+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
@@ -12,12 +12,17 @@ function runtime(): AuthProviderRuntime {
{ 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 {
getProviders: () => providers,
listCredentials: () => Promise.resolve(credentials),
getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }),
+ hasConfiguredAuth: (provider: string) => configuredProviders.has(provider),
};
}
@@ -32,18 +37,34 @@ describe("auth provider options", () => {
expect.objectContaining({ id: "github-copilot", authType: "api_key" }),
// OAuth-only provider surfaces only oauth.
expect.objectContaining({ id: "openai-codex", authType: "oauth" }),
- // API-key-only providers surface only api_key.
- expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }),
- expect.objectContaining({ id: "custom", authType: "api_key" }),
+ // 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("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" }),
+ 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 529ae90..f188413 100644
--- a/src/server/sessions/authProviderOptions.ts
+++ b/src/server/sessions/authProviderOptions.ts
@@ -23,6 +23,7 @@ export interface AuthProviderRuntime {
getProviders(): readonly AuthProviderInfo[];
listCredentials(): Promise;
getProviderAuthStatus(providerId: string): AuthProviderStatus;
+ hasConfiguredAuth(providerId: string): boolean;
}
export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): AuthProviderOption[] {
@@ -35,7 +36,7 @@ export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?:
id: provider.id,
name: provider.name,
authType: "oauth",
- status: runtime.getProviderAuthStatus(provider.id),
+ status: truthfulProviderStatus(runtime, provider.id),
});
}
@@ -45,7 +46,8 @@ export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?:
id: provider.id,
name: provider.name,
authType: "api_key",
- status: runtime.getProviderAuthStatus(provider.id),
+ status: truthfulProviderStatus(runtime, provider.id),
+ loginFlow: "interactive",
});
}
@@ -60,12 +62,19 @@ export async function getLogoutProviderOptions(runtime: AuthProviderRuntime): Pr
id: credential.providerId,
name: providerNames.get(credential.providerId) ?? credential.providerId,
authType: credential.type,
- status: runtime.getProviderAuthStatus(credential.providerId),
+ status: truthfulProviderStatus(runtime, credential.providerId),
});
}
return filterAndSort(options);
}
+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[] {
const filtered = authType === undefined ? options : options.filter((option) => option.authType === authType);
return filtered.sort((a, b) => a.name.localeCompare(b.name) || a.authType.localeCompare(b.authType) || a.id.localeCompare(b.id));
diff --git a/src/server/sessions/authRoutes.ts b/src/server/sessions/authRoutes.ts
index a8f516c..70089af 100644
--- a/src/server/sessions/authRoutes.ts
+++ b/src/server/sessions/authRoutes.ts
@@ -18,6 +18,16 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref
}
});
+ // 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) });
+ }
+ });
+
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/logout`, async (request, reply) => {
try {
return await auth.logoutProvider(request.body.providerId);
diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts
index d3fb5ae..e427e6c 100644
--- a/src/server/sessions/authService.test.ts
+++ b/src/server/sessions/authService.test.ts
@@ -11,6 +11,7 @@ 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 })));
});
@@ -98,14 +99,22 @@ describe("AuthService", () => {
auth.dispose();
});
- it("rejects Cloudflare multi-field setup without storing the secret as provider metadata", async () => {
- const { auth, credentials, changes } = await createAuthService();
+ 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", "cf-secret")).rejects.toThrow(
+ 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(credentials.read("cloudflare-ai-gateway")).resolves.toBeUndefined();
+ await expect(readFile(authPath, "utf8")).resolves.toBe(before);
expect(changes).toEqual([]);
auth.dispose();
});
@@ -113,18 +122,113 @@ describe("AuthService", () => {
it.each([
{ providerId: "amazon-bedrock", providerName: "Amazon Bedrock" },
{ providerId: "google-vertex", providerName: "Google Vertex AI" },
- ])("rejects $providerName select-first setup without storing the secret", async ({ providerId, providerName }) => {
- const { auth, credentials, changes } = await createAuthService();
+ ])("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(credentials.read(providerId)).resolves.toBeUndefined();
+ 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 },
{
@@ -372,6 +476,17 @@ async function createAuthService(seed: Record = {}, logger?:
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,
diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts
index 4e26c04..5a237ff 100644
--- a/src/server/sessions/authService.ts
+++ b/src/server/sessions/authService.ts
@@ -105,12 +105,24 @@ export class AuthService {
return { accepted: true };
}
+ async startApiKeyLogin(providerId: string): Promise {
+ const provider = await this.requireApiKeyLoginProvider(providerId);
+ return this.authFlows.start({
+ providerId,
+ providerName: provider.name,
+ 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" }),
});
}
diff --git a/src/server/sessions/oauthLoginFlowService.test.ts b/src/server/sessions/oauthLoginFlowService.test.ts
index 90c0500..713d743 100644
--- a/src/server/sessions/oauthLoginFlowService.test.ts
+++ b/src/server/sessions/oauthLoginFlowService.test.ts
@@ -1,4 +1,4 @@
-import type { AuthInteraction } from "@earendil-works/pi-ai";
+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";
@@ -41,6 +41,30 @@ 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();
@@ -79,7 +103,7 @@ describe("OAuthLoginFlowService", () => {
expect(onComplete).toHaveBeenCalledOnce();
expect(error).toHaveBeenCalledWith(
{ err: completionFailure, flowId: state.flowId, providerId: "test-provider" },
- "OAuth login completion callback failed",
+ "login completion callback failed",
);
service.dispose();
});
@@ -227,7 +251,7 @@ describe("OAuthLoginFlowService", () => {
const select = state.select;
if (select === undefined) throw new Error("Expected select prompt");
- expect(() => { service.respond(state.flowId, select.requestId, "personal"); }).toThrow("Invalid OAuth selection");
+ expect(() => { service.respond(state.flowId, select.requestId, "personal"); }).toThrow("Invalid login selection");
expect(service.get(state.flowId).select).toEqual(select);
service.dispose();
});
@@ -338,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", () => {
@@ -355,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();
});
@@ -378,19 +402,24 @@ 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 fakeRuntime(login: LoginHandler): Pick {
+function fakeRuntime(login: LoginHandler, authTypes?: AuthType[]): Pick {
return {
- login: (providerId, _type, interaction) => login(providerId, interaction).then(() => ({ type: "oauth", refresh: "r", access: "a", expires: 0 })),
+ 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 });
+ },
};
}
diff --git a/src/server/sessions/oauthLoginFlowService.ts b/src/server/sessions/oauthLoginFlowService.ts
index d38cf18..2e15130 100644
--- a/src/server/sessions/oauthLoginFlowService.ts
+++ b/src/server/sessions/oauthLoginFlowService.ts
@@ -1,5 +1,5 @@
import crypto from "node:crypto";
-import type { AuthEvent, AuthInteraction, AuthPrompt } from "@earendil-works/pi-ai";
+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";
@@ -42,6 +42,10 @@ 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;
@@ -60,6 +64,8 @@ export class OAuthLoginFlowService {
providerId: string;
providerName: string;
runtime: OAuthLoginRuntime;
+ /** Defaults to OAuth so established callers retain their existing behavior. */
+ authType?: AuthType;
onComplete?: () => void | Promise;
}): OAuthFlowState {
const flowId = crypto.randomUUID();
@@ -88,7 +94,7 @@ export class OAuthLoginFlowService {
notify: (event) => { this.handleEvent(record, event); },
};
- void options.runtime.login(options.providerId, "oauth", interaction).then(
+ void options.runtime.login(options.providerId, options.authType ?? "oauth", interaction).then(
() => this.reconcileCommittedLogin(record, options.onComplete),
(error: unknown) => {
if (!this.isCurrent(record)) return;
@@ -103,18 +109,18 @@ export class OAuthLoginFlowService {
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");
- if (pending.allowedValues !== undefined && !pending.allowedValues.has(value)) throw new Error("Invalid OAuth selection");
+ 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);
@@ -123,7 +129,7 @@ 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 = this.clearPending(record);
@@ -287,7 +293,7 @@ export class OAuthLoginFlowService {
} catch (error) {
this.logErrorNoThrow(
{ err: error, flowId: record.flowId, providerId: record.state.providerId },
- "OAuth login completion callback failed",
+ "login completion callback failed",
);
}
if (!this.isCurrent(record)) return;
@@ -352,8 +358,8 @@ export class OAuthLoginFlowService {
if (!this.isCurrentRunning(record)) return;
record.abort.abort();
const pending = this.clearPending(record);
- this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "OAuth login flow expired" });
- pending?.reject(new Error("OAuth login flow expired"));
+ 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 {
diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts
index 78666d4..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 {
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" },