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
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": minor
---
Add global web UI `/login` and `/logout` flows for configuring API key and subscription provider authentication.
+1 -1
View File
@@ -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";
+15
View File
@@ -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 = {
+71 -1
View File
@@ -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<string, unknown> {
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<SessionStatus, "contextUsage"> | object {
if (value === undefined) return {};
const record = requireRecord(value);
+10 -1
View File
@@ -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<CommandResult, { type: "select" }> | 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",
+187
View File
@@ -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`
<div class="backdrop" @mousedown=${() => { this.cancel(); }}>
<section @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
<header>
<strong>${this.dialogTitle(state)}</strong>
<button title="Close" @click=${() => { this.cancel(); }}>×</button>
</header>
${this.renderBody(state)}
</section>
</div>
`;
}
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`
<div class="options">
<button @click=${() => { this.onChooseMethod?.("oauth"); }}><span>Use a subscription</span><small>ChatGPT Plus/Pro, Claude Pro/Max, or GitHub Copilot</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in pi auth.json</small></button>
</div>
`;
case "providers": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No providers available.</div>` : state.providers.map((provider) => this.renderProviderButton(provider))}</div>`;
case "apiKey": return html`
<div class="form">
<p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored by pi in <code>auth.json</code>.</p>
<input type="password" autocomplete="off" placeholder="API key" .value=${state.value} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}>
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
<div class="actions"><button @click=${() => { this.cancel(); }}>Cancel</button><button class="primary" ?disabled=${state.saving === true} @click=${() => { this.onSaveApiKey?.(); }}>${state.saving === true ? "Saving…" : "Save API key"}</button></div>
</div>
`;
case "oauth": return this.renderOAuth(state);
case "logout": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No stored credentials. Environment variables and models.json settings are unchanged.</div>` : state.providers.map((provider) => html`
<button @click=${() => { this.onLogoutProvider?.(provider.id); }}><span>${provider.name}</span><small>${provider.id} · ${authTypeLabel(provider.authType)}</small></button>
`)}</div>`;
}
}
private renderProviderButton(provider: AuthProviderOption) {
return html`
<button @click=${() => { this.onSelectProvider?.(provider.id, provider.authType); }}>
<span>${provider.name}${provider.status.source !== undefined ? html` <em>${statusLabel(provider)}</em>` : null}</span>
<small>${provider.id} · ${authTypeLabel(provider.authType)}</small>
</button>
`;
}
private renderOAuth(state: Extract<AuthDialogState, { step: "oauth" }>) {
const flow = state.flow;
const prompt = flow.prompt;
const select = flow.select;
return html`
<div class="form">
${flow.auth !== undefined ? html`
<p>Open this authorization link:</p>
<p><a href=${flow.auth.url} target="_blank" rel="noreferrer">${flow.auth.url}</a></p>
${flow.auth.instructions !== undefined ? html`<p class="warning">${flow.auth.instructions}</p>` : null}
` : html`<p>Starting login flow…</p>`}
${flow.progress.length > 0 ? html`<ul class="progress">${flow.progress.map((line) => html`<li>${line}</li>`)}</ul>` : null}
${prompt !== undefined ? html`
<label>${prompt.message}</label>
<input .value=${state.inputValue ?? ""} placeholder=${prompt.placeholder ?? ""} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}>
<div class="actions"><button @click=${() => { this.onOAuthCancel?.(); }}>Cancel</button><button class="primary" ?disabled=${state.responding === true} @click=${() => { this.onOAuthRespond?.(); }}>Submit</button></div>
` : null}
${select !== undefined ? html`
<p>${select.message}</p>
<div class="inline-options">${select.options.map((option) => html`<button @click=${() => { this.onOAuthRespond?.(option.value); }}>${option.label}</button>`)}</div>
` : null}
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
${flow.status === "error" || flow.status === "cancelled" ? html`<div class="error-text">${flow.error ?? flow.status}</div><div class="actions"><button @click=${() => { this.cancel(); }}>Close</button></div>` : null}
${prompt === undefined && select === undefined && flow.status === "running" ? html`<div class="actions"><button @click=${() => { this.onOAuthCancel?.(); }}>Cancel</button></div>` : null}
</div>
`;
}
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 "";
}
}
+17 -1
View File
@@ -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 {
<div class="mobile-navigation-panel">${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 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())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 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(); }}></prompt-editor>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 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(); }}></prompt-editor>
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
${state.thinkingDialog !== undefined ? html`<command-picker title=${state.thinkingDialog.title} .options=${state.thinkingDialog.options} .selectedValue=${state.thinkingDialog.selectedValue} .onPick=${(value: string) => { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}></command-picker>` : null}
${state.authDialog !== undefined ? html`<auth-dialog .state=${state.authDialog} .onChooseMethod=${(authType: "oauth" | "api_key") => { 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(); }}></auth-dialog>` : null}
` : html`<div class="empty">Select or start a session.</div>`}
</main>
${this.renderWorkspacePanel()}
@@ -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<AppState>, apiPatch: Partial<typeof defaultApi> = {}) {
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> = {}): OAuthFlowState {
return {
flowId: "flow-1",
providerId: "anthropic",
providerName: "Anthropic",
status: "running",
progress: [],
...patch,
};
}
@@ -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<void> {
if (providerId !== undefined && providerId !== "") {
await this.openLoginProvider(providerId);
return;
}
this.setState({ authDialog: { step: "method" } });
}
async chooseLoginMethod(authType: AuthType): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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";
@@ -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)) {
+14
View File
@@ -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",
+2
View File
@@ -12,6 +12,8 @@ function createContext(statePatch: Partial<AppState> = {}) {
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"); }),
+2
View File
@@ -28,6 +28,8 @@ export interface PluginRuntimeContext {
openActionPalette: () => void;
focusPrompt: () => void;
addProject: () => void | Promise<void>;
configureAuth: () => void | Promise<void>;
logoutAuth: () => void | Promise<void>;
selectMainView: (view: AppState["mainView"]) => void;
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
refreshFiles: () => void | Promise<void>;
+7 -1
View File
@@ -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<void> {
shuttingDown = true;
app.log.info({ signal }, "shutting down session daemon");
terminals.dispose();
auth.dispose();
await sessions.dispose();
await app.close();
}
@@ -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));
}
@@ -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
+32
View File
@@ -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[];
}