This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
pi-web/src/server/sessions/authService.test.ts
T
Federico Jaramillo Martinez 20b65cf5b9 chore(deps): require pi 0.82.1 and port the removed reloadConfig()
Bump @earendil-works/pi-coding-agent, pi-ai, and pi-agent-core to 0.82.1
together and raise the peer range to >=0.82.1 <0.83. All three must move in
lockstep: bumping only two leaves a duplicate pi-ai copy in the tree, which
surfaces as misleading type-identity errors rather than real API breaks.

Pi 0.82 removed ModelRuntime.reloadConfig() and merged it into refresh(),
which now does ModelConfig.load, configureRadiusProviders, and rebuildProviders
before refreshing. Port the five production call sites literally, passing no
options so refresh() keeps defaulting allowNetwork to modelNetworkEnabled --
which the shared runtime pins to false by constructing under PI_OFFLINE. No
call site passes allowNetwork: true.

The auth tests lose reloadConfig() as an observation seam, so the offline
regression cases now drive removeRuntimeApiKey(), the surviving public mutation
that still forwards the construction-time network flag to refresh().
2026-07-25 19:09:18 +02:00

622 lines
26 KiB
TypeScript

import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { InMemoryCredentialStore, type AuthPrompt, type Credential } from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OAuthFlowState } from "../../shared/apiTypes.js";
import { AuthService, createModelRuntimeForAgentDir, type AuthChange, type AuthServiceLogger } from "./authService.js";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
const tempDirs: string[] = [];
beforeEach(() => {
// Pi 0.82 uses PI_OFFLINE for refreshes after runtime creation. Auth tests
// exercise local credential behavior and must never fetch provider catalogs.
vi.stubEnv("PI_OFFLINE", "1");
});
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 after the runtime refreshes", async () => {
const { auth, runtime, credentials, changes } = await createAuthService();
// Pi 0.82 merged reloadConfig() into refresh(), so the provider lookup and
// the post-login refresh both land on refresh(): one call each.
const refresh = vi.spyOn(runtime, "refresh");
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(refresh).toHaveBeenCalledTimes(2);
expect(changes).toEqual([{}]);
auth.dispose();
});
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");
await expect(auth.logoutProvider("anthropic")).resolves.toEqual({ accepted: true });
await expect(credentials.read("anthropic")).resolves.toBeUndefined();
expect(refresh).toHaveBeenCalledOnce();
expect(changes).toEqual([{ removedProviderId: "anthropic" }]);
auth.dispose();
});
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");
});
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 runtime = await createModelRuntimeForAgentDir(agentDir);
const auth = await AuthService.create({ runtime });
await auth.saveApiKey("anthropic", "sk-test");
await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-test");
auth.dispose();
});
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);
const refreshStarted = deferred<undefined>();
const finishRefresh = deferred<undefined>();
// Pi 0.82 merged reloadConfig() into refresh(), so the provider lookup now
// shares this seam with the post-login refresh. Only the post-login call
// (the second) is held open; deferring the lookup would stall the flow
// before it starts.
let refreshCalls = 0;
const refresh = vi.spyOn(runtime, "refresh").mockImplementation(async () => {
refreshCalls += 1;
if (refreshCalls < 2) return { aborted: false, errors: new Map() };
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).toHaveBeenCalledTimes(2);
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 = await AuthService.create({ runtime, authFlows });
const changes: AuthChange[] = [];
auth.subscribe((change) => { changes.push(change); });
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");
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.runtime).toBe(runtime);
expect(changes).toEqual([]);
refresh.mockClear();
if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback");
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();
});
});
describe("createModelRuntimeForAgentDir", () => {
it("keeps runtime-owned refreshes local so request paths cannot stall", async () => {
// Runtime-owned mutations refresh with allowNetwork = the construction-time
// network flag and no abort signal; that is what regressed. Pi 0.82 removed
// reloadConfig(), so removeRuntimeApiKey() is the surviving public seam that
// still forwards that flag explicitly and can be observed on refresh().
// The ambient PI_OFFLINE=1 stub has to go, or the runtime would be offline
// whether or not the helper forces it and this would assert nothing.
vi.stubEnv("PI_OFFLINE", undefined);
const agentDir = await tempAgentDir();
const runtime = await createModelRuntimeForAgentDir(agentDir);
const refresh = vi.spyOn(runtime, "refresh");
await runtime.removeRuntimeApiKey("anthropic");
expect(refresh).toHaveBeenCalledWith({ allowNetwork: false });
});
it("restores a previously set PI_OFFLINE after runtime creation", async () => {
// The file-level beforeEach stubs PI_OFFLINE=1.
const agentDir = await tempAgentDir();
await createModelRuntimeForAgentDir(agentDir);
expect(process.env["PI_OFFLINE"]).toBe("1");
});
it("restores a previously unset PI_OFFLINE after runtime creation", async () => {
vi.unstubAllEnvs();
const previous = process.env["PI_OFFLINE"];
delete process.env["PI_OFFLINE"];
try {
const agentDir = await tempAgentDir();
const runtime = await createModelRuntimeForAgentDir(agentDir);
const refresh = vi.spyOn(runtime, "refresh");
await runtime.removeRuntimeApiKey("anthropic");
expect(refresh).toHaveBeenCalledWith({ allowNetwork: false });
expect(process.env["PI_OFFLINE"]).toBeUndefined();
} finally {
if (previous !== undefined) process.env["PI_OFFLINE"] = previous;
}
});
it("restores PI_OFFLINE when creations overlap, because the env windows are serialized", async () => {
vi.unstubAllEnvs();
const previous = process.env["PI_OFFLINE"];
delete process.env["PI_OFFLINE"];
try {
const dirs = await Promise.all([tempAgentDir(), tempAgentDir(), tempAgentDir()]);
const runtimes = await Promise.all(dirs.map((dir) => createModelRuntimeForAgentDir(dir)));
// Interleaved save/restore pairs would leave PI_OFFLINE set process-wide.
expect(process.env["PI_OFFLINE"]).toBeUndefined();
for (const runtime of runtimes) {
const refresh = vi.spyOn(runtime, "refresh");
await runtime.removeRuntimeApiKey("anthropic");
expect(refresh).toHaveBeenCalledWith({ allowNetwork: false });
}
} finally {
if (previous !== undefined) process.env["PI_OFFLINE"] = previous;
}
});
});
async function createAuthService(seed: Record<string, Credential> = {}, 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, runtime, credentials, changes };
}
async function createFileBackedAuthService(seed: Record<string, Credential>) {
const agentDir = await tempAgentDir();
const authPath = join(agentDir, "auth.json");
await writeFile(authPath, JSON.stringify(seed, null, 2));
const runtime = await createModelRuntimeForAgentDir(agentDir);
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<string> {
const dir = await mkdtemp(join(tmpdir(), "pi-web-auth-agent-"));
tempDirs.push(dir);
return dir;
}
function deferred<T>() {
let resolveValue: (value: T) => void = () => undefined;
let rejectValue: (reason?: unknown) => void = () => undefined;
const promise = new Promise<T>((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<OAuthLoginFlowService["start"]>[0][] = [];
disposed = false;
override start(options: Parameters<OAuthLoginFlowService["start"]>[0]): OAuthFlowState {
this.startCalls.push(options);
return { flowId: "flow-1", providerId: options.providerId, providerName: options.providerName, status: "running", progress: [] };
}
override dispose(): void {
this.disposed = true;
}
}