diff --git a/.changeset/web-login-commands.md b/.changeset/web-login-commands.md new file mode 100644 index 0000000..10f56a2 --- /dev/null +++ b/.changeset/web-login-commands.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": minor +--- + +Add global web UI `/login` and `/logout` flows for configuring API key and subscription provider authentication. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index a95134b..47b0324 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; -export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes"; +export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 085f8a5..556588f 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -5,6 +5,7 @@ import { parseAborted, parseAccepted, parseArchived, + parseAuthProvidersResponse, parseClosed, parseCommandResult, parseDetached, @@ -15,6 +16,7 @@ import { parseGitStatusResponse, parseMessagePage, parseModelSelectionResponse, + parseOAuthFlowState, parseProject, parseRestored, parseSessionInfo, @@ -61,6 +63,19 @@ export const sessionsApi = { archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }), restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }), detachParent: (sessionId: string) => request(`/api/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }), + authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" }) => { + const params = new URLSearchParams(); + if (options?.mode !== undefined) params.set("mode", options.mode); + if (options?.authType !== undefined) params.set("authType", options.authType); + const query = params.toString(); + return request(`/api/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse); + }, + saveApiKey: (providerId: string, key: string) => request("/api/auth/api-key", parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }), + logoutProvider: (providerId: string) => request("/api/auth/logout", parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }), + startOAuthLogin: (providerId: string) => request("/api/auth/oauth", parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }), + oauthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState), + respondOAuthFlow: (flowId: string, requestId: string, value: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/respond`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ requestId, value }) }), + cancelOAuthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/cancel`, parseOAuthFlowState, { method: "POST" }), }; export const terminalsApi = { diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index b0819e8..2edf19b 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes"; +import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -152,6 +152,76 @@ export function parseThinkingLevelsResponse(value: unknown): ThinkingLevelsRespo return { levels: arrayOf(parseThinkingLevel)(record["levels"]) }; } +function parseAuthType(value: unknown): AuthType { + if (value !== "oauth" && value !== "api_key") throw new Error("Invalid auth type"); + return value; +} + +function parseAuthStatusSource(value: unknown): AuthStatusSource { + if (value !== "stored" && value !== "runtime" && value !== "environment" && value !== "fallback" && value !== "models_json_key" && value !== "models_json_command") throw new Error("Invalid auth status source"); + return value; +} + +function parseAuthProviderStatus(value: unknown): AuthProviderStatus { + const record = requireRecord(value); + const source = record["source"] === undefined ? undefined : parseAuthStatusSource(record["source"]); + return { configured: requireBoolean(record, "configured"), ...optionalField("source", source), ...optionalField("label", optionalString(record, "label")) }; +} + +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"]) }; +} + +export function parseAuthProvidersResponse(value: unknown): AuthProvidersResponse { + const record = requireRecord(value); + return { providers: arrayOf(parseAuthProviderOption)(record["providers"]) }; +} + +export function parseOAuthFlowState(value: unknown): OAuthFlowState { + const record = requireRecord(value); + const flow = { + flowId: requireString(record, "flowId"), + providerId: requireString(record, "providerId"), + providerName: requireString(record, "providerName"), + status: parseOAuthFlowStatus(record["status"]), + progress: arrayOf((item) => { + if (typeof item !== "string") throw new Error("Expected progress item string"); + return item; + })(record["progress"]), + ...optionalField("error", optionalString(record, "error")), + ...optionalField("auth", optionalOAuthAuth(record["auth"])), + ...optionalField("prompt", optionalOAuthPrompt(record["prompt"])), + ...optionalField("select", optionalOAuthSelect(record["select"])), + }; + return flow; +} + +function parseOAuthFlowStatus(value: unknown): OAuthFlowState["status"] { + if (value !== "running" && value !== "complete" && value !== "error" && value !== "cancelled") throw new Error("Invalid OAuth flow status"); + return value; +} + +function optionalOAuthAuth(value: unknown): OAuthFlowState["auth"] | undefined { + if (value === undefined) return undefined; + const record = requireRecord(value); + return { url: requireString(record, "url"), ...optionalField("instructions", optionalString(record, "instructions")) }; +} + +function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefined { + if (value === undefined) return undefined; + const record = requireRecord(value); + const kind = requireString(record, "kind"); + if (kind !== "prompt" && kind !== "manual") throw new Error("Invalid OAuth prompt kind"); + return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), kind, ...optionalField("placeholder", optionalString(record, "placeholder")), ...(record["allowEmpty"] === true ? { allowEmpty: true } : {}) }; +} + +function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefined { + if (value === undefined) return undefined; + const record = requireRecord(value); + return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), options: arrayOf(parseCommandOption)(record["options"]) }; +} + function optionalContextUsage(value: unknown): Pick | object { if (value === undefined) return {}; const record = requireRecord(value); diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index cbe5ac0..5ef6f89 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -1,4 +1,4 @@ -import type { CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api"; +import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api"; import type { ChatLine } from "./components/shared"; import type { QualifiedContributionId } from "./plugins/types"; @@ -21,6 +21,7 @@ export interface AppState { commandDialog: Extract | undefined; modelDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined; thinkingDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined; + authDialog: AuthDialogState | undefined; actionPaletteOpen: boolean; projectDialogOpen: boolean; workspaceTool: QualifiedContributionId; @@ -39,6 +40,13 @@ export interface AppState { error: string; } +export type AuthDialogState = + | { step: "method" } + | { step: "providers"; mode: "login"; authType?: "oauth" | "api_key"; providers: AuthProviderOption[] } + | { step: "apiKey"; provider: AuthProviderOption; value: string; saving?: boolean; error?: string } + | { step: "oauth"; flow: OAuthFlowState; responding?: boolean; inputValue?: string; error?: string } + | { step: "logout"; providers: AuthProviderOption[] }; + export function initialAppState(): AppState { return { projects: [], @@ -59,6 +67,7 @@ export function initialAppState(): AppState { commandDialog: undefined, modelDialog: undefined, thinkingDialog: undefined, + authDialog: undefined, actionPaletteOpen: false, projectDialogOpen: false, workspaceTool: "core:workspace.files", diff --git a/src/client/src/components/AuthDialog.ts b/src/client/src/components/AuthDialog.ts new file mode 100644 index 0000000..5c5b5c1 --- /dev/null +++ b/src/client/src/components/AuthDialog.ts @@ -0,0 +1,187 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; +import type { AuthDialogState } from "../appState"; +import type { AuthProviderOption } from "../api"; +import { commandPickerStyles } from "./shared"; + +@customElement("auth-dialog") +export class AuthDialog extends LitElement { + @property({ attribute: false }) state?: AuthDialogState; + @property({ attribute: false }) onChooseMethod?: (authType: "oauth" | "api_key") => void; + @property({ attribute: false }) onSelectProvider?: (providerId: string, authType: "oauth" | "api_key") => void; + @property({ attribute: false }) onApiKeyInput?: (value: string) => void; + @property({ attribute: false }) onSaveApiKey?: () => void; + @property({ attribute: false }) onLogoutProvider?: (providerId: string) => void; + @property({ attribute: false }) onOAuthInput?: (value: string) => void; + @property({ attribute: false }) onOAuthRespond?: (value?: string) => void; + @property({ attribute: false }) onOAuthCancel?: () => void; + @property({ attribute: false }) onCancel?: () => void; + @query("input") private input?: HTMLInputElement; + private lastFocusedInputKey: string | undefined; + + override render() { + const state = this.state; + if (state === undefined) return null; + return html` +
{ this.cancel(); }}> +
{ event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}> +
+ ${this.dialogTitle(state)} + +
+ ${this.renderBody(state)} +
+
+ `; + } + + protected override updated(): void { + this.focusInputIfNeeded(); + } + + 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 "apiKey": return `API key for ${state.provider.name}`; + case "oauth": return `Login to ${state.flow.providerName}`; + case "logout": return "Remove stored provider authentication"; + } + } + + private renderBody(state: AuthDialogState) { + switch (state.step) { + case "method": return html` +
+ + +
+ `; + case "providers": return html`
${state.providers.length === 0 ? html`
No providers available.
` : state.providers.map((provider) => this.renderProviderButton(provider))}
`; + case "apiKey": return html` +
+

Enter the API key for ${state.provider.name}. It will be stored by pi in auth.json.

+ { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}> + ${state.error !== undefined && state.error !== "" ? html`
${state.error}
` : null} +
+
+ `; + case "oauth": return this.renderOAuth(state); + case "logout": return html`
${state.providers.length === 0 ? html`
No stored credentials. Environment variables and models.json settings are unchanged.
` : state.providers.map((provider) => html` + + `)}
`; + } + } + + private renderProviderButton(provider: AuthProviderOption) { + return html` + + `; + } + + private renderOAuth(state: Extract) { + const flow = state.flow; + const prompt = flow.prompt; + const select = flow.select; + return html` +
+ ${flow.auth !== undefined ? html` +

Open this authorization link:

+

${flow.auth.url}

+ ${flow.auth.instructions !== undefined ? html`

${flow.auth.instructions}

` : null} + ` : html`

Starting login flow…

`} + ${flow.progress.length > 0 ? html`
    ${flow.progress.map((line) => html`
  • ${line}
  • `)}
` : null} + ${prompt !== undefined ? html` + + { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}> +
+ ` : null} + ${select !== undefined ? html` +

${select.message}

+
${select.options.map((option) => html``)}
+ ` : null} + ${state.error !== undefined && state.error !== "" ? html`
${state.error}
` : null} + ${flow.status === "error" || flow.status === "cancelled" ? html`
${flow.error ?? flow.status}
` : null} + ${prompt === undefined && select === undefined && flow.status === "running" ? html`
` : null} +
+ `; + } + + private focusInputIfNeeded(): void { + const key = focusKey(this.state); + if (key === undefined) { + this.lastFocusedInputKey = undefined; + return; + } + if (key === this.lastFocusedInputKey) return; + this.lastFocusedInputKey = key; + this.input?.focus(); + } + + private handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Escape") { + event.preventDefault(); + this.cancel(); + return; + } + if (event.key !== "Enter") return; + const state = this.state; + if (state?.step === "apiKey") { + event.preventDefault(); + this.onSaveApiKey?.(); + } else if (state?.step === "oauth" && state.flow.prompt !== undefined) { + event.preventDefault(); + this.onOAuthRespond?.(); + } + } + + private cancel(): void { + const state = this.state; + if (state?.step === "oauth") this.onOAuthCancel?.(); + else this.onCancel?.(); + } + + static override styles = [commandPickerStyles, css` + .form { display: grid; gap: 12px; padding: 14px; overflow: auto; } + .form p { margin: 0; color: #c9d1d9; overflow-wrap: anywhere; } + .form a { color: #58a6ff; overflow-wrap: anywhere; } + .form code { border: 1px solid #30363d; border-radius: 4px; background: #161b22; padding: 1px 4px; } + label { color: #8b949e; } + .actions { display: flex; justify-content: flex-end; gap: 8px; } + .actions button, .inline-options button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; } + .actions button.primary { border-color: #238636; background: #0f2a16; color: #3fb950; } + .actions button:disabled { opacity: .6; cursor: wait; } + .warning { color: #d29922; } + .error-text { color: #ff7b72; } + .progress { margin: 0; padding-left: 18px; color: #8b949e; } + .inline-options { display: grid; gap: 8px; } + em { color: #3fb950; font-style: normal; font-size: 12px; } + `]; +} + +function authTypeLabel(authType: "oauth" | "api_key"): string { + return authType === "oauth" ? "subscription" : "API key"; +} + +function focusKey(state: AuthDialogState | undefined): string | undefined { + if (state?.step === "apiKey") return `api-key:${state.provider.authType}:${state.provider.id}`; + if (state?.step === "oauth" && state.flow.prompt !== undefined) return `oauth:${state.flow.flowId}:${state.flow.prompt.requestId}`; + return undefined; +} + +function statusLabel(provider: AuthProviderOption): string { + if (provider.status.source === undefined) return ""; + switch (provider.status.source) { + case "stored": return "✓ configured"; + case "environment": return `✓ env${provider.status.label === undefined ? "" : `: ${provider.status.label}`}`; + case "runtime": return "✓ runtime"; + case "fallback": return "✓ custom key"; + case "models_json_key": return "✓ models.json key"; + case "models_json_command": return "✓ models.json command"; + default: return ""; + } +} + diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 73858dc..6a5b8ab 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -3,6 +3,7 @@ import { customElement, query, state } from "lit/decorators.js"; import { terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api"; import type { AppAction } from "../actions"; import { initialAppState, type AppState } from "../appState"; +import { AuthController } from "../controllers/authController"; import { FileExplorerController } from "../controllers/fileExplorerController"; import { GitController } from "../controllers/gitController"; import { ProjectController } from "../controllers/projectController"; @@ -26,6 +27,7 @@ import type { PromptEditor } from "./PromptEditor"; import "./StatusBar"; import "./CommandPicker"; import "./ActionPalette"; +import "./AuthDialog"; import "./ProjectDialog"; import "./WorkspacePanel"; import { appStyles } from "./shared"; @@ -41,6 +43,11 @@ export class PiWebApp extends LitElement { (patch) => { this.setState(patch); }, () => { this.updateUrl(); }, ); + private readonly auth = new AuthController( + () => this.state, + (patch) => { this.setState(patch); }, + (status) => { this.sessions.applySessionStatus(status); }, + ); private readonly workspaces = new WorkspaceController( () => this.state, (patch) => { this.setState(patch); }, @@ -103,6 +110,7 @@ export class PiWebApp extends LitElement { window.removeEventListener("keydown", this.onKeyDown); this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange); this.keyboard.reset(); + this.auth.dispose(); this.sessions.dispose(); this.realtime.close(); this.git.dispose(); @@ -327,6 +335,8 @@ export class PiWebApp extends LitElement { openActionPalette: () => { this.setState({ actionPaletteOpen: true }); }, focusPrompt: () => { this.promptEditor?.focusInput(); }, addProject: () => { this.setState({ projectDialogOpen: true }); }, + configureAuth: () => this.auth.openLogin(), + logoutAuth: () => this.auth.openLogout(), selectMainView: (view) => { this.selectMainView(view); }, selectWorkspaceTool: (tool) => { this.openWorkspaceTool(tool); }, refreshFiles: () => this.files.refreshFiles(), @@ -384,6 +394,11 @@ export class PiWebApp extends LitElement { if (isThinkingLevel(value)) await this.sessions.setThinkingLevel(value); } + private sendPrompt(text: string, streamingBehavior?: "steer" | "followUp"): void { + if (streamingBehavior === undefined && this.auth.handleSlashCommand(text)) return; + void this.sessions.send(text, streamingBehavior); + } + override render() { const state = this.state; return html` @@ -401,11 +416,12 @@ export class PiWebApp extends LitElement {
${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}
${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> - 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> + 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} ${state.thinkingDialog !== undefined ? html` { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}>` : null} + ${state.authDialog !== undefined ? html` { void this.auth.chooseLoginMethod(authType); }} .onSelectProvider=${(providerId: string, authType: "oauth" | "api_key") => { void this.auth.selectLoginProvider(providerId, authType); }} .onApiKeyInput=${(value: string) => { this.auth.updateApiKey(value); }} .onSaveApiKey=${() => { void this.auth.saveApiKey(); }} .onLogoutProvider=${(providerId: string) => { void this.auth.logoutProvider(providerId); }} .onOAuthInput=${(value: string) => { this.auth.updateOAuthInput(value); }} .onOAuthRespond=${(value?: string) => { void this.auth.respondOAuth(value); }} .onOAuthCancel=${() => { void this.auth.cancelOAuth(); }} .onCancel=${() => { this.auth.closeDialog(); }}>` : null} ` : html`
Select or start a session.
`} ${this.renderWorkspacePanel()} diff --git a/src/client/src/controllers/authController.test.ts b/src/client/src/controllers/authController.test.ts new file mode 100644 index 0000000..5fa8c82 --- /dev/null +++ b/src/client/src/controllers/authController.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { api as defaultApi, type AuthProviderOption, type OAuthFlowState } from "../api"; +import { initialAppState, type AppState } from "../appState"; +import { AuthController, parseAuthSlashCommand } from "./authController"; + +describe("parseAuthSlashCommand", () => { + it("parses login and logout commands", () => { + expect(parseAuthSlashCommand("/login")).toEqual({ command: "login" }); + expect(parseAuthSlashCommand("/logout")).toEqual({ command: "logout" }); + }); + + it("parses provider arguments", () => { + expect(parseAuthSlashCommand("/login openai")).toEqual({ command: "login", providerId: "openai" }); + expect(parseAuthSlashCommand("/logout openai-codex ")).toEqual({ command: "logout", providerId: "openai-codex" }); + }); + + it("ignores non-auth commands and extra arguments", () => { + expect(parseAuthSlashCommand("/model")).toBeUndefined(); + expect(parseAuthSlashCommand("hello /login")).toBeUndefined(); + expect(parseAuthSlashCommand("/login openai extra")).toBeUndefined(); + }); +}); + +describe("AuthController", () => { + it("uses auth type to disambiguate provider options with the same id", async () => { + const providers = [authProvider("anthropic", "oauth"), authProvider("anthropic", "api_key")]; + const { controller, getState } = createController({ authDialog: { step: "providers", mode: "login", providers } }); + + await controller.selectLoginProvider("anthropic", "api_key"); + + expect(getState().authDialog).toMatchObject({ step: "apiKey", provider: { id: "anthropic", authType: "api_key" } }); + }); + + 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( + { authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } }, + { respondOAuthFlow: () => Promise.resolve(oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" }, progress: ["Still waiting"] })) }, + ); + + await controller.respondOAuth(); + + expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true }); + }); +}); + +function createController(statePatch: Partial, apiPatch: Partial = {}) { + let state: AppState = { ...initialAppState(), ...statePatch }; + const api = { ...defaultApi, ...apiPatch }; + const controller = new AuthController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + { api }, + ); + return { controller, getState: () => state }; +} + +function authProvider(id: string, authType: "oauth" | "api_key"): AuthProviderOption { + return { id, authType, name: `${id} ${authType}`, status: { configured: false } }; +} + +function oauthFlow(patch: Partial = {}): OAuthFlowState { + return { + flowId: "flow-1", + providerId: "anthropic", + providerName: "Anthropic", + status: "running", + progress: [], + ...patch, + }; +} diff --git a/src/client/src/controllers/authController.ts b/src/client/src/controllers/authController.ts new file mode 100644 index 0000000..20add64 --- /dev/null +++ b/src/client/src/controllers/authController.ts @@ -0,0 +1,261 @@ +import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api"; +import type { GetState, SetState } from "./types"; + +export interface AuthControllerDependencies { + api?: typeof defaultApi; + pollIntervalMs?: number; +} + +export class AuthController { + private readonly api: typeof defaultApi; + private readonly pollIntervalMs: number; + private pollTimer: number | undefined; + + constructor( + private readonly getState: GetState, + private readonly setState: SetState, + private readonly applyStatus: (status: SessionStatus) => void, + deps: AuthControllerDependencies = {}, + ) { + this.api = deps.api ?? defaultApi; + this.pollIntervalMs = deps.pollIntervalMs ?? 1000; + } + + dispose(): void { + this.stopPolling(); + } + + handleSlashCommand(text: string): boolean { + const parsed = parseAuthSlashCommand(text); + if (parsed === undefined) return false; + if (parsed.command === "login") void this.openLogin(parsed.providerId); + else void this.openLogout(parsed.providerId); + return true; + } + + async openLogin(providerId?: string): Promise { + if (providerId !== undefined && providerId !== "") { + await this.openLoginProvider(providerId); + return; + } + this.setState({ authDialog: { step: "method" } }); + } + + async chooseLoginMethod(authType: AuthType): Promise { + try { + const { providers } = await this.api.authProviders({ mode: "login", authType }); + this.setState({ authDialog: { step: "providers", mode: "login", authType, providers } }); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + async selectLoginProvider(providerId: string, authType?: AuthType): Promise { + const dialog = this.getState().authDialog; + 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); + else this.setState({ authDialog: { step: "apiKey", provider, value: "" } }); + } + + updateApiKey(value: string): void { + const dialog = this.getState().authDialog; + if (dialog?.step !== "apiKey") return; + const clean = { ...dialog }; + delete clean.error; + this.setState({ authDialog: { ...clean, value } }); + } + + async saveApiKey(): Promise { + const dialog = this.getState().authDialog; + if (dialog?.step !== "apiKey") return; + const key = dialog.value.trim(); + if (key === "") { + this.setState({ authDialog: { ...dialog, error: "API key is required" } }); + return; + } + const clean = { ...dialog }; + delete clean.error; + this.setState({ authDialog: { ...clean, saving: true } }); + try { + await this.api.saveApiKey(dialog.provider.id, key); + this.closeDialog(); + void this.refreshStatus(); + } catch (error) { + this.setState({ authDialog: { ...dialog, saving: false, error: String(error) } }); + } + } + + async openLogout(providerId?: string): Promise { + try { + const { providers } = await this.api.authProviders({ mode: "logout" }); + if (providerId !== undefined && providerId !== "") { + const provider = providers.find((candidate) => candidate.id === providerId); + if (provider !== undefined) await this.logoutProvider(provider.id); + else this.setState({ error: `No stored credentials for ${providerId}` }); + return; + } + this.setState({ authDialog: { step: "logout", providers } }); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + async logoutProvider(providerId: string): Promise { + try { + await this.api.logoutProvider(providerId); + this.closeDialog(); + void this.refreshStatus(); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + updateOAuthInput(value: string): void { + const dialog = this.getState().authDialog; + if (dialog?.step !== "oauth") return; + const clean = { ...dialog }; + delete clean.error; + this.setState({ authDialog: { ...clean, inputValue: value } }); + } + + async respondOAuth(value?: string): Promise { + const dialog = this.getState().authDialog; + if (dialog?.step !== "oauth") return; + const request = dialog.flow.prompt ?? dialog.flow.select; + if (request === undefined) return; + const responseValue = value ?? dialog.inputValue ?? ""; + const clean = { ...dialog }; + delete clean.error; + this.setState({ authDialog: { ...clean, responding: true } }); + try { + const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue); + this.updateOAuthFlow(flow); + } catch (error) { + this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } }); + } + } + + async cancelOAuth(): Promise { + const dialog = this.getState().authDialog; + if (dialog?.step !== "oauth") { + this.closeDialog(); + return; + } + this.stopPolling(); + try { + await this.api.cancelOAuthFlow(dialog.flow.flowId); + } catch { + // Best-effort cancel. The dialog closes either way. + } + this.closeDialog(); + } + + closeDialog(): void { + this.stopPolling(); + this.setState({ authDialog: undefined }); + } + + private async openLoginProvider(providerId: string): Promise { + try { + const { providers } = await this.api.authProviders({ mode: "login" }); + const exact = providers.filter((provider) => provider.id === providerId); + if (exact.length === 0) { + this.setState({ error: `Auth provider not found: ${providerId}` }); + return; + } + if (exact.length > 1) { + this.setState({ authDialog: { step: "providers", mode: "login", providers: exact } }); + return; + } + const provider = exact[0]; + if (provider === undefined) return; + if (provider.authType === "oauth") await this.startOAuth(provider); + else this.setState({ authDialog: { step: "apiKey", provider, value: "" } }); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + private async startOAuth(provider: AuthProviderOption): Promise { + try { + const flow = await this.api.startOAuthLogin(provider.id); + this.updateOAuthFlow(flow); + this.startPolling(flow.flowId); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + private updateOAuthFlow(flow: OAuthFlowState): void { + if (flow.status === "complete") { + this.stopPolling(); + this.closeDialog(); + void this.refreshStatus(); + return; + } + if (flow.status === "error" || flow.status === "cancelled") this.stopPolling(); + const existing = this.getState().authDialog; + const previousInput = existing?.step === "oauth" && existing.flow.flowId === flow.flowId ? existing.inputValue ?? "" : ""; + const previousRequestId = existing?.step === "oauth" ? existing.flow.prompt?.requestId ?? existing.flow.select?.requestId : undefined; + const newRequestId = flow.prompt?.requestId ?? flow.select?.requestId; + const sameRequest = previousRequestId !== undefined && previousRequestId === newRequestId; + const inputValue = sameRequest ? previousInput : ""; + const responding = sameRequest && existing?.step === "oauth" ? existing.responding === true : false; + this.setState({ authDialog: { step: "oauth", flow, inputValue, responding } }); + } + + private startPolling(flowId: string): void { + this.stopPolling(); + this.pollTimer = window.setInterval(() => { void this.poll(flowId); }, this.pollIntervalMs); + } + + private stopPolling(): void { + if (this.pollTimer === undefined) return; + window.clearInterval(this.pollTimer); + this.pollTimer = undefined; + } + + private async poll(flowId: string): Promise { + const dialog = this.getState().authDialog; + if (dialog?.step !== "oauth" || dialog.flow.flowId !== flowId) { + this.stopPolling(); + return; + } + try { + this.updateOAuthFlow(await this.api.oauthFlow(flowId)); + } catch (error) { + this.stopPolling(); + this.setState({ authDialog: { ...dialog, error: String(error) } }); + } + } + + private async refreshStatus(): Promise { + const sessionId = this.sessionId(); + if (sessionId === undefined) return; + try { + this.applyStatus(await this.api.status(sessionId)); + } catch { + // Status refresh is opportunistic after login completes. + } + } + + private sessionId(): string | undefined { + const session = this.getState().selectedSession; + if (session === undefined || session.archived === true) return undefined; + return session.id; + } +} + +export function parseAuthSlashCommand(text: string): { command: "login" | "logout"; providerId?: string } | undefined { + const trimmed = text.trim(); + const match = /^\/(login|logout)(?:\s+(\S+))?\s*$/u.exec(trimmed); + if (match === null) return undefined; + const command = match[1]; + if (command !== "login" && command !== "logout") return undefined; + const providerId = match[2]; + return providerId === undefined || providerId === "" ? { command } : { command, providerId }; +} + +export type { AuthDialogState } from "../appState"; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index c166a0d..57aeff4 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -199,6 +199,10 @@ export class SessionController { this.setState({ commandDialog: undefined }); } + applySessionStatus(status: SessionStatus): void { + this.applyStatus(status); + } + async archiveSession(session = this.getState().selectedSession) { if (!session) return; if (isCachedNewSessionInfo(session)) { diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index e124056..9af93ca 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -25,6 +25,20 @@ export function createCoreActions(): PluginAction[] { group: "Project", run: (context) => context.addProject(), }, + { + id: "auth.login", + title: "Configure Provider Authentication", + description: "Run /login without tying authentication to a session", + group: "General", + run: (context) => context.configureAuth(), + }, + { + id: "auth.logout", + title: "Remove Provider Authentication", + description: "Run /logout for stored pi credentials", + group: "General", + run: (context) => context.logoutAuth(), + }, { id: "view.chat", title: "Go to Chat", diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index e2e009f..c8a459c 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -12,6 +12,8 @@ function createContext(statePatch: Partial = {}) { openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }), focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }), addProject: vi.fn(() => { calls.push("addProject"); }), + configureAuth: vi.fn(() => { calls.push("configureAuth"); }), + logoutAuth: vi.fn(() => { calls.push("logoutAuth"); }), selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }), selectWorkspaceTool: vi.fn((tool: AppState["workspaceTool"]) => { calls.push(`selectWorkspaceTool:${tool}`); }), refreshFiles: vi.fn(() => { calls.push("refreshFiles"); }), diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index a557b12..8c7cad2 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -28,6 +28,8 @@ export interface PluginRuntimeContext { openActionPalette: () => void; focusPrompt: () => void; addProject: () => void | Promise; + configureAuth: () => void | Promise; + logoutAuth: () => void | Promise; selectMainView: (view: AppState["mainView"]) => void; selectWorkspaceTool: (tool: QualifiedContributionId) => void; refreshFiles: () => void | Promise; diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 26b1a8d..55a657d 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -4,6 +4,8 @@ import { dirname } from "node:path"; import Fastify from "fastify"; import fastifyWebsocket from "@fastify/websocket"; import { SessionEventHub } from "./realtime/sessionEventHub.js"; +import { AuthService } from "./sessions/authService.js"; +import { registerAuthRoutes } from "./sessions/authRoutes.js"; import { PiSessionService } from "./sessions/piSessionService.js"; import { registerSessionRoutes } from "./sessions/sessionRoutes.js"; import { sessiondSocketPath } from "./sessiond/config.js"; @@ -14,8 +16,11 @@ const app = Fastify({ logger: true }); await app.register(fastifyWebsocket); const eventHub = new SessionEventHub(); -const sessions = new PiSessionService(eventHub); +const auth = new AuthService(); +const sessions = new PiSessionService(eventHub, { modelRegistry: auth.modelRegistry }); +auth.subscribe((change) => { sessions.applyAuthChange(change); }); const terminals = new TerminalService(eventHub); +registerAuthRoutes(app, auth); registerSessionRoutes(app, sessions, eventHub); registerTerminalRoutes(app, terminals); @@ -27,6 +32,7 @@ async function shutdown(signal: NodeJS.Signals): Promise { shuttingDown = true; app.log.info({ signal }, "shutting down session daemon"); terminals.dispose(); + auth.dispose(); await sessions.dispose(); await app.close(); } diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts index 5c53d9b..b6f0e99 100644 --- a/src/server/sessiond/sessionProxyRoutes.ts +++ b/src/server/sessiond/sessionProxyRoutes.ts @@ -30,6 +30,8 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se bridgeSockets(socket, daemon.connectWebSocket("/events")); }); + app.all("/api/auth", (request, reply) => proxy(request, reply)); + app.all("/api/auth/*", (request, reply) => proxy(request, reply)); app.all("/api/sessions", (request, reply) => proxy(request, reply)); app.all("/api/sessions/*", (request, reply) => proxy(request, reply)); } diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts new file mode 100644 index 0000000..854bfab --- /dev/null +++ b/src/server/sessions/authProviderOptions.test.ts @@ -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(); + 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" }), + ]); + }); +}); diff --git a/src/server/sessions/authProviderOptions.ts b/src/server/sessions/authProviderOptions.ts new file mode 100644 index 0000000..61940db --- /dev/null +++ b/src/server/sessions/authProviderOptions.ts @@ -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, builtInProviderIds: ReadonlySet = 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)); +} diff --git a/src/server/sessions/authRoutes.ts b/src/server/sessions/authRoutes.ts new file mode 100644 index 0000000..1f000f3 --- /dev/null +++ b/src/server/sessions/authRoutes.ts @@ -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) }); + } + }); +} diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts new file mode 100644 index 0000000..3efe1b3 --- /dev/null +++ b/src/server/sessions/authService.test.ts @@ -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[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 }; +} diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts new file mode 100644 index 0000000..c884af9 --- /dev/null +++ b/src/server/sessions/authService.ts @@ -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; + +export interface AuthServiceDependencies { + modelRegistry?: ModelRegistryInstance; + authFlows?: OAuthLoginFlowService; +} + +export class AuthService { + readonly modelRegistry: ModelRegistryInstance; + private readonly authFlows: OAuthLoginFlowService; + private readonly listeners = new Set(); + + 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; + } +} diff --git a/src/server/sessions/oauthLoginFlowService.test.ts b/src/server/sessions/oauthLoginFlowService.test.ts new file mode 100644 index 0000000..e785e31 --- /dev/null +++ b/src/server/sessions/oauthLoginFlowService.test.ts @@ -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; + +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(); + 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(); + 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 { + return { login }; +} + +async function flushAsyncLogin(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +function deferred() { + let resolveValue: (value: T) => void = () => undefined; + let rejectValue: (reason?: unknown) => void = () => undefined; + const promise = new Promise((resolve, reject) => { + resolveValue = resolve; + rejectValue = reject; + }); + return { promise, resolve: resolveValue, reject: rejectValue }; +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/src/server/sessions/oauthLoginFlowService.ts b/src/server/sessions/oauthLoginFlowService.ts new file mode 100644 index 0000000..0678254 --- /dev/null +++ b/src/server/sessions/oauthLoginFlowService.ts @@ -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; +type TimerHandle = ReturnType; + +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(); + 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 { + 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 { + 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(); +} diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index 8f7168f..adc2b52 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -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([["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[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(), { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 17d798c..282da67 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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; type SessionManagerGateway = Pick; type CreateAgentRuntime = typeof createAgentSessionRuntime; -function createDefaultRuntimeFactory(): CreateAgentSessionRuntimeFactory { - const authStorage = AuthStorage.create(); - const modelRegistry = ModelRegistry.create(authStorage); +function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ReturnType): 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(); private readonly heartbeat: NodeJS.Timeout; private readonly commandService: SessionCommandService; + private readonly authLossWarnings = new Set(); 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 { @@ -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 diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index c1ad77d..2d9ac70 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -53,6 +53,38 @@ export interface SessionModel { export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; +export type AuthType = "oauth" | "api_key"; +export type AuthStatusSource = "stored" | "runtime" | "environment" | "fallback" | "models_json_key" | "models_json_command"; + +export interface AuthProviderStatus { + configured: boolean; + source?: AuthStatusSource; + label?: string; +} + +export interface AuthProviderOption { + id: string; + name: string; + authType: AuthType; + status: AuthProviderStatus; +} + +export interface AuthProvidersResponse { + providers: AuthProviderOption[]; +} + +export interface OAuthFlowState { + flowId: string; + providerId: string; + providerName: string; + status: "running" | "complete" | "error" | "cancelled"; + auth?: { url: string; instructions?: string }; + prompt?: { requestId: string; message: string; placeholder?: string; allowEmpty?: boolean; kind: "prompt" | "manual" }; + select?: { requestId: string; message: string; options: CommandOption[] }; + progress: string[]; + error?: string; +} + export interface ModelSelectionResponse { models: SessionModel[]; }