Add global web auth flows

This commit is contained in:
Federico Jaramillo Martinez
2026-05-16 22:44:24 +02:00
parent 9b1b1bbdba
commit 6a8f8b6ccf
25 changed files with 1583 additions and 10 deletions
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { getLoginProviderOptions, getLogoutProviderOptions, isApiKeyLoginProvider, type AuthProviderModelRegistry } from "./authProviderOptions";
function registry(): AuthProviderModelRegistry {
const credentials = new Map<string, { type: "oauth" | "api_key" }>();
credentials.set("openai", { type: "api_key" });
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),
getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }),
};
}
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("includes Anthropic in both OAuth and API key login options", () => {
const options = getLoginProviderOptions(registry());
expect(options).toEqual(expect.arrayContaining([
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: "openai-codex", authType: "oauth" }),
]));
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })]));
});
it("returns only stored credentials for logout", () => {
expect(getLogoutProviderOptions(registry())).toEqual([
expect.objectContaining({ id: "openai", authType: "api_key" }),
]);
});
});
@@ -0,0 +1,68 @@
import { getProviders } from "@earendil-works/pi-ai";
import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js";
const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]);
const BUILT_IN_MODEL_PROVIDERS = new Set(getProviders());
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;
}
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),
}));
const modelProviders = new Set(modelRegistry.getAll().map((model) => model.provider));
for (const providerId of modelProviders) {
if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) continue;
options.push({
id: providerId,
name: modelRegistry.getProviderDisplayName(providerId),
authType: "api_key",
status: modelRegistry.getProviderAuthStatus(providerId),
});
}
return filterAndSort(options, authType);
}
export function getLogoutProviderOptions(modelRegistry: AuthProviderModelRegistry): AuthProviderOption[] {
const options: AuthProviderOption[] = [];
for (const providerId of modelRegistry.authStorage.list()) {
const credential = modelRegistry.authStorage.get(providerId);
if (credential === undefined) continue;
options.push({
id: providerId,
name: modelRegistry.getProviderDisplayName(providerId),
authType: credential.type,
status: modelRegistry.getProviderAuthStatus(providerId),
});
}
return filterAndSort(options);
}
export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet<string>, builtInProviderIds: ReadonlySet<string> = BUILT_IN_MODEL_PROVIDERS): boolean {
if (OAUTH_ONLY_PROVIDERS.has(providerId)) return false;
if (providerId === "anthropic") return true;
if (oauthProviderIds.has(providerId)) return false;
if (builtInProviderIds.has(providerId)) return true;
return true;
}
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));
}
+60
View File
@@ -0,0 +1,60 @@
import type { FastifyInstance } from "fastify";
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);
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Body: { providerId: string; key: string } }>(`${prefix}/auth/api-key`, async (request, reply) => {
try {
return auth.saveApiKey(request.body.providerId, request.body.key);
} 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 auth.logoutProvider(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/oauth`, async (request, reply) => {
try {
return auth.startOAuthLogin(request.body.providerId);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { flowId: string } }>(`${prefix}/auth/oauth/:flowId`, async (request, reply) => {
try {
return auth.oauthFlow(request.params.flowId);
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Params: { flowId: string }; Body: { requestId: string; value: string } }>(`${prefix}/auth/oauth/:flowId/respond`, async (request, reply) => {
try {
return auth.respondToOAuthFlow(request.params.flowId, request.body.requestId, request.body.value);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Params: { flowId: string } }>(`${prefix}/auth/oauth/:flowId/cancel`, async (request, reply) => {
try {
return auth.cancelOAuthFlow(request.params.flowId);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
}
+42
View File
@@ -0,0 +1,42 @@
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { AuthService, type AuthChange } from "./authService.js";
describe("AuthService", () => {
it("saves API keys and emits a global auth change", () => {
const { auth, authStorage, changes } = createAuthService();
expect(auth.saveApiKey("anthropic", "sk-test")).toEqual({ accepted: true });
expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-test" });
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" } });
expect(auth.logoutProvider("anthropic")).toEqual({ accepted: true });
expect(authStorage.get("anthropic")).toBeUndefined();
expect(changes).toEqual([{ removedProviderId: "anthropic" }]);
auth.dispose();
});
it("rejects blank API keys", () => {
const { auth, changes } = createAuthService();
expect(() => { auth.saveApiKey("anthropic", " "); }).toThrow("API key is required");
expect(changes).toEqual([]);
auth.dispose();
});
});
function createAuthService(data: Parameters<typeof AuthStorage.inMemory>[0] = {}) {
const authStorage = AuthStorage.inMemory(data);
const modelRegistry = ModelRegistry.create(authStorage);
const auth = new AuthService({ modelRegistry });
const changes: AuthChange[] = [];
auth.subscribe((change) => { changes.push(change); });
return { auth, authStorage, changes };
}
+99
View File
@@ -0,0 +1,99 @@
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js";
import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
export interface AuthChange {
removedProviderId?: string;
}
type AuthChangeListener = (change: AuthChange) => void;
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
export interface AuthServiceDependencies {
modelRegistry?: ModelRegistryInstance;
authFlows?: OAuthLoginFlowService;
}
export class AuthService {
readonly modelRegistry: ModelRegistryInstance;
private readonly authFlows: OAuthLoginFlowService;
private readonly listeners = new Set<AuthChangeListener>();
constructor(deps: AuthServiceDependencies = {}) {
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
this.authFlows = deps.authFlows ?? new OAuthLoginFlowService();
}
subscribe(listener: AuthChangeListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
dispose(): void {
this.authFlows.dispose();
this.listeners.clear();
}
authProviders(mode: "login" | "logout", authType?: AuthType): AuthProvidersResponse {
this.modelRegistry.refresh();
const providers = mode === "logout" ? getLogoutProviderOptions(this.modelRegistry) : getLoginProviderOptions(this.modelRegistry, authType);
return { providers };
}
saveApiKey(providerId: string, key: string): { accepted: true } {
if (key.trim() === "") throw new Error("API key is required");
this.modelRegistry.authStorage.set(providerId, { type: "api_key", key });
this.refreshAuthState();
return { accepted: true };
}
logoutProvider(providerId: string): { accepted: true } {
this.modelRegistry.authStorage.logout(providerId);
this.refreshAuthState({ removedProviderId: providerId });
return { accepted: true };
}
startOAuthLogin(providerId: string): OAuthFlowState {
const provider = this.requireOAuthLoginProvider(providerId);
return this.authFlows.start({
providerId,
providerName: provider.name,
authStorage: this.modelRegistry.authStorage,
onComplete: () => {
this.refreshAuthState();
},
});
}
oauthFlow(flowId: string): OAuthFlowState {
return this.authFlows.get(flowId);
}
respondToOAuthFlow(flowId: string, requestId: string, value: string): OAuthFlowState {
return this.authFlows.respond(flowId, requestId, value);
}
cancelOAuthFlow(flowId: string): OAuthFlowState {
return this.authFlows.cancel(flowId);
}
private refreshAuthState(change: AuthChange = {}): void {
this.modelRegistry.authStorage.reload();
this.modelRegistry.refresh();
this.emit(change);
}
private emit(change: AuthChange): void {
for (const listener of this.listeners) listener(change);
}
private requireOAuthLoginProvider(providerId: string) {
this.modelRegistry.refresh();
const provider = getLoginProviderOptions(this.modelRegistry, "oauth").find((option) => option.id === providerId);
if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`);
return provider;
}
}
@@ -0,0 +1,187 @@
import type { OAuthLoginCallbacks } from "@earendil-works/pi-ai";
import type { AuthStorage } 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<void>;
afterEach(() => {
vi.useRealTimers();
});
describe("OAuthLoginFlowService", () => {
it("round-trips prompt responses and completes the flow", async () => {
let promptValue: string | undefined;
const service = new 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}`);
}),
});
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" });
const afterRespond = service.respond(state.flowId, prompt.requestId, "abc123");
expect(afterRespond.prompt).toBeUndefined();
await flushAsyncLogin();
expect(promptValue).toBe("abc123");
expect(service.get(state.flowId)).toMatchObject({ status: "complete", progress: ["Waiting for code", "Got abc123", "Login complete"] });
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) => {
const select = callbacks.onSelect;
if (select === undefined) throw new Error("Expected select callback");
selectedValue = await select({
message: "Choose account",
options: [{ id: "work", label: "Work" }, { 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" }] });
service.respond(state.flowId, select.requestId, "personal");
await flushAsyncLogin();
expect(selectedValue).toBe("personal");
expect(service.get(state.flowId).status).toBe("complete");
service.dispose();
});
it("uses a manual-code prompt for callback-server flows", async () => {
let manualValue: string | undefined;
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();
}),
});
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" });
service.respond(state.flowId, prompt.requestId, "https://localhost/callback?code=abc");
await flushAsyncLogin();
expect(manualValue).toBe("https://localhost/callback?code=abc");
expect(service.get(state.flowId).status).toBe("complete");
service.dispose();
});
it("rejects pending prompts when cancelled", async () => {
const promptRejected = deferred<Error>();
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
try {
await callbacks.onPrompt({ message: "Paste code" });
} catch (error) {
promptRejected.resolve(toError(error));
throw error;
}
}),
});
expect(state.prompt).toBeDefined();
expect(service.cancel(state.flowId)).toMatchObject({ status: "cancelled", error: "Login cancelled" });
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" });
expect(service.get(state.flowId).status).toBe("cancelled");
service.dispose();
});
it("rejects stale or duplicate responses", () => {
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
await callbacks.onPrompt({ message: "Paste code" });
}),
});
const prompt = state.prompt;
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");
service.dispose();
});
it("expires abandoned running flows and evicts terminal flows", async () => {
vi.useFakeTimers();
const promptRejected = deferred<Error>();
const service = new OAuthLoginFlowService({ runningTtlMs: 1000, terminalTtlMs: 1000 });
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
try {
await callbacks.onPrompt({ message: "Paste code" });
} catch (error) {
promptRejected.resolve(toError(error));
throw error;
}
}),
});
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" });
await vi.advanceTimersByTimeAsync(1000);
expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found");
service.dispose();
});
});
function fakeAuthStorage(login: LoginHandler): Pick<AuthStorage, "login"> {
return { login };
}
async function flushAsyncLogin(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
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 toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
@@ -0,0 +1,262 @@
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 { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js";
type OAuthLoginStorage = Pick<AuthStorage, "login">;
type TimerHandle = ReturnType<typeof setTimeout>;
interface PendingOAuthRequest {
requestId: string;
allowEmpty: boolean;
resolve: (value: string | undefined) => void;
reject: (error: Error) => void;
}
interface OAuthFlowRecord {
flowId: string;
state: OAuthFlowState;
abort: AbortController;
pending: PendingOAuthRequest | undefined;
terminalAt?: number;
cleanupTimer?: TimerHandle;
}
export interface OAuthLoginFlowServiceOptions {
terminalTtlMs?: number;
runningTtlMs?: number;
now?: () => number;
}
const DEFAULT_TERMINAL_TTL_MS = 5 * 60 * 1000;
const DEFAULT_RUNNING_TTL_MS = 30 * 60 * 1000;
export class OAuthLoginFlowService {
private readonly flows = new Map<string, OAuthFlowRecord>();
private readonly terminalTtlMs: number;
private readonly runningTtlMs: number;
private readonly now: () => number;
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());
}
start(options: {
providerId: string;
providerName: string;
authStorage: OAuthLoginStorage;
onComplete?: () => void;
}): OAuthFlowState {
const flowId = crypto.randomUUID();
const abort = new AbortController();
const record: OAuthFlowRecord = {
flowId,
abort,
pending: undefined,
state: {
flowId,
providerId: options.providerId,
providerName: options.providerName,
status: "running",
progress: [],
},
};
this.flows.set(flowId, record);
this.scheduleRunningExpiry(record);
const callbacks: OAuthLoginCallbacks = {
signal: abort.signal,
onAuth: (info) => {
if (!this.isCurrentRunning(record)) return;
this.updateState(record, { ...record.state, auth: info });
},
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] });
},
};
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;
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");
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.state.status !== "running") return cloneState(record.state);
const pending = record.pending;
if (pending?.requestId !== requestId) throw new Error("OAuth login request expired");
if (!pending.allowEmpty && value.trim() === "") throw new Error("A value is required");
record.pending = undefined;
this.updateState(record, withoutInteraction(record.state));
pending.resolve(value);
return cloneState(record.state);
}
cancel(flowId: string): OAuthFlowState {
const record = this.flows.get(flowId);
if (record === undefined) throw new Error("OAuth login flow not found");
if (record.state.status === "running") {
record.abort.abort();
const pending = record.pending;
record.pending = undefined;
this.markTerminal(record, { ...withoutInteraction(record.state), status: "cancelled", error: "Login cancelled" });
pending?.reject(new Error("Login cancelled"));
}
return cloneState(record.state);
}
dispose(): void {
for (const record of this.flows.values()) {
this.clearTimer(record);
record.abort.abort();
const pending = record.pending;
record.pending = undefined;
pending?.reject(new Error("Login cancelled"));
}
this.flows.clear();
}
private waitForPrompt(record: OAuthFlowRecord, prompt: OAuthPrompt, kind: "prompt" | "manual"): Promise<string> {
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 base = withoutInteraction(record.state);
this.updateState(record, {
...base,
prompt: {
requestId,
message: prompt.message,
kind,
...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }),
...(prompt.allowEmpty === true ? { allowEmpty: true } : {}),
},
});
});
}
private waitForSelect(record: OAuthFlowRecord, prompt: OAuthSelectPrompt): Promise<string | undefined> {
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 base = withoutInteraction(record.state);
this.updateState(record, { ...base, select: { requestId, message: prompt.message, options } });
});
}
private isCurrentRunning(record: OAuthFlowRecord): boolean {
return this.flows.get(record.flowId) === record && record.state.status === "running";
}
private updateState(record: OAuthFlowRecord, state: OAuthFlowState): void {
record.state = state;
}
private markTerminal(record: OAuthFlowRecord, state: OAuthFlowState): void {
this.updateState(record, state);
record.terminalAt = this.now();
this.scheduleTerminalEviction(record);
}
private scheduleRunningExpiry(record: OAuthFlowRecord): void {
if (this.runningTtlMs <= 0) {
this.expireRunningFlow(record);
return;
}
this.setTimer(record, this.runningTtlMs, () => { this.expireRunningFlow(record); });
}
private scheduleTerminalEviction(record: OAuthFlowRecord): void {
if (this.terminalTtlMs <= 0) {
this.flows.delete(record.flowId);
this.clearTimer(record);
return;
}
this.setTimer(record, this.terminalTtlMs, () => {
if (this.flows.get(record.flowId) !== record) return;
if (record.terminalAt === undefined) return;
if (this.now() - record.terminalAt < this.terminalTtlMs) {
this.scheduleTerminalEviction(record);
return;
}
this.flows.delete(record.flowId);
this.clearTimer(record);
});
}
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"));
}
private setTimer(record: OAuthFlowRecord, delayMs: number, callback: () => void): void {
this.clearTimer(record);
record.cleanupTimer = setTimeout(callback, delayMs);
unrefTimer(record.cleanupTimer);
}
private clearTimer(record: OAuthFlowRecord): void {
if (record.cleanupTimer === undefined) return;
clearTimeout(record.cleanupTimer);
delete record.cleanupTimer;
}
}
function withoutInteraction(state: OAuthFlowState): OAuthFlowState {
const rest = { ...state };
delete rest.prompt;
delete rest.select;
return rest;
}
function cloneState(state: OAuthFlowState): OAuthFlowState {
return {
...state,
progress: [...state.progress],
...(state.auth === undefined ? {} : { auth: { ...state.auth } }),
...(state.prompt === undefined ? {} : { prompt: { ...state.prompt } }),
...(state.select === undefined ? {} : { select: { ...state.select, options: state.select.options.map((option) => ({ ...option })) } }),
};
}
function unrefTimer(timer: TimerHandle): void {
if (typeof timer !== "object" || !("unref" in timer) || typeof timer.unref !== "function") return;
timer.unref();
}
@@ -244,6 +244,69 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("auth-session");
(fake.runtime.session as unknown as { model: { provider: string; id: string } }).model = { provider: "anthropic", id: "claude-3-5-sonnet" };
const credentials = new Map<string, { type: "api_key" | "oauth"; key?: string }>([["anthropic", { type: "api_key", key: "sk-test" }]]);
const authStorage = {
get(provider: string) { return credentials.get(provider); },
list(): string[] { return Array.from(credentials.keys()); },
getOAuthProviders: () => [],
hasAuth(provider: string): boolean { return credentials.has(provider); },
getAuthStatus(provider: string) { return credentials.has(provider) ? { configured: true, source: "stored" as const } : { configured: false }; },
};
let refreshCalls = 0;
const knownModels = [{ provider: "anthropic", id: "claude-3-5-sonnet" }];
const modelRegistry = {
authStorage,
refresh(): void { refreshCalls += 1; },
getAll: () => knownModels,
getAvailable: () => credentials.has("anthropic") ? knownModels : [],
find: (provider: string, id: string) => knownModels.find((model) => model.provider === provider && model.id === id),
getProviderDisplayName: (provider: string) => provider,
getProviderAuthStatus: (provider: string) => authStorage.getAuthStatus(provider),
hasConfiguredAuth: (model: { provider: string }) => credentials.has(model.provider),
};
(fake.runtime.session as unknown as { modelRegistry: typeof modelRegistry }).modelRegistry = modelRegistry;
const service = new PiSessionService(hub, {
modelRegistry: modelRegistry as unknown as NonNullable<NonNullable<ConstructorParameters<typeof PiSessionService>[1]>["modelRegistry"]>,
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
createAgentRuntime: () => Promise.resolve(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => Promise.resolve([{ id: "auth-session", path: "/sessions/auth-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await service.status("auth-session");
hub.sessionEvents.length = 0;
hub.globalEvents.length = 0;
const refreshBefore = refreshCalls;
credentials.delete("anthropic");
service.applyAuthChange({ removedProviderId: "anthropic" });
service.applyAuthChange({ removedProviderId: "anthropic" });
const warningCount = () => hub.sessionEvents.filter(({ event }) => event.type === "command.output" && event.level === "error" && event.message.includes("anthropic/claude-3-5-sonnet")).length;
expect(refreshCalls).toBeGreaterThan(refreshBefore);
expect(warningCount()).toBe(1);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true);
credentials.set("anthropic", { type: "api_key", key: "sk-new" });
service.applyAuthChange();
credentials.delete("anthropic");
service.applyAuthChange({ removedProviderId: "anthropic" });
expect(warningCount()).toBe(2);
await service.dispose();
});
it("clears queued messages when stopping a session runtime", async () => {
const fake = fakeRuntime("stop-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
+48 -5
View File
@@ -17,19 +17,22 @@ import { BUILTIN_COMMANDS } from "./builtinCommands.js";
import { SessionCommandService } from "./sessionCommandService.js";
import { SessionArchiveStore } from "./sessionArchiveStore.js";
import type { ActiveSession } from "./sessionRuntimeStore.js";
import type { AuthChange } from "./authService.js";
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
function noop(): void {
// Intentionally empty default unsubscribe callback.
}
function authLossWarningKey(sessionId: string, provider: string, modelId: string): string {
return `${sessionId}:${provider}/${modelId}`;
}
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "archive" | "restore" | "isArchived">;
type SessionManagerGateway = Pick<typeof SessionManager, "list" | "create" | "listAll" | "open">;
type CreateAgentRuntime = typeof createAgentSessionRuntime;
function createDefaultRuntimeFactory(): CreateAgentSessionRuntimeFactory {
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ReturnType<typeof ModelRegistry.create>): CreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
const options = sessionStartEvent === undefined
@@ -55,6 +58,7 @@ export class PiSessionService {
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
private readonly heartbeat: NodeJS.Timeout;
private readonly commandService: SessionCommandService;
private readonly authLossWarnings = new Set<string>();
private readonly archiveStore: SessionArchiveRepository;
private readonly agentDir: string;
private readonly sessionManager: SessionManagerGateway;
@@ -66,9 +70,9 @@ export class PiSessionService {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
this.agentDir = deps.agentDir ?? getAgentDir();
this.sessionManager = deps.sessionManager ?? SessionManager;
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory();
this.createAgentRuntime = deps.createAgentRuntime ?? createAgentSessionRuntime;
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
this.createAgentRuntime = deps.createAgentRuntime ?? createAgentSessionRuntime;
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
this.commandService = new SessionCommandService(
(sessionId) => this.getActive(sessionId),
@@ -96,6 +100,7 @@ export class PiSessionService {
const activeSessions = Array.from(new Set(this.active.values()));
this.active.clear();
this.activities.clear();
this.authLossWarnings.clear();
await Promise.all(activeSessions.map(async (active) => {
active.unsubscribe();
await active.runtime.session.abort();
@@ -320,6 +325,7 @@ export class PiSessionService {
void active.runtime.session.abort().finally(() => active.runtime.dispose());
this.active.delete(sessionId);
this.activities.delete(sessionId);
this.clearAuthLossWarningsForSession(sessionId);
}
private async assertWritable(sessionId: string): Promise<void> {
@@ -384,6 +390,43 @@ export class PiSessionService {
this.publishSessionName(session);
}
applyAuthChange(change: AuthChange = {}): void {
this.modelRegistry.refresh();
for (const active of this.active.values()) {
const { session } = active.runtime;
session.modelRegistry.refresh();
this.syncCurrentModelAuthWarning(session, change.removedProviderId);
this.publishStatus(session);
}
}
private syncCurrentModelAuthWarning(session: AgentSession, removedProviderId: string | undefined): void {
const model = session.model;
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);
if (registered === undefined) return;
if (session.modelRegistry.hasConfiguredAuth(registered)) {
this.authLossWarnings.delete(warningKey);
return;
}
if (removedProviderId === undefined || model.provider !== removedProviderId || this.authLossWarnings.has(warningKey)) return;
this.authLossWarnings.add(warningKey);
this.events.publish(session.sessionId, {
type: "command.output",
level: "error",
message: `Authentication for ${model.provider}/${model.id} was removed. Use /model to select another model.`,
});
}
private clearAuthLossWarningsForSession(sessionId: string): void {
const prefix = `${sessionId}:`;
for (const key of this.authLossWarnings) {
if (key.startsWith(prefix)) this.authLossWarnings.delete(key);
}
}
private publishSessionName(session: AgentSession): void {
const event = session.sessionName === undefined
? { type: "session.name", sessionId: session.sessionId } as const