Archived
Add global web auth flows
This commit is contained in:
@@ -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,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";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"); }),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user