Merge pull request #64 from jmfederico/fix/issue-62-authstorage

fix: migrate auth/model plumbing to ModelRuntime (fixes #62)
This commit is contained in:
Federico Jaramillo Martinez
2026-07-18 18:20:03 +02:00
committed by GitHub
43 changed files with 2183 additions and 608 deletions
+1
View File
@@ -241,6 +241,7 @@ export const sessionsApi = {
return request(`${machinePrefix(options?.machineId)}/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
},
saveApiKey: (providerId: string, key: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }),
startInteractiveApiKeyLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key/interactive`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
logoutProvider: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/logout`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }),
startOAuthLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
oauthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState),
@@ -88,6 +88,7 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
ignoreParseFailure(sessionsApi.startInteractiveApiKeyLogin("amazon-bedrock", machineId)),
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)),
ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)),
+52 -1
View File
@@ -1,8 +1,59 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
const base = { id: "openai", name: "OpenAI", authType: "api_key", status: { configured: false } };
expect(parseAuthProvidersResponse({ providers: [{ ...base, loginFlow: "interactive" }, base] }).providers).toEqual([
{ ...base, loginFlow: "interactive" },
base,
]);
});
it("preserves additive OAuth interaction semantics", () => {
expect(parseOAuthFlowState({
flowId: "flow-1",
providerId: "provider",
providerName: "Provider",
status: "running",
auth: {
url: "https://example.test/device",
instructions: "Enter code",
deviceCode: { userCode: "ABCD", intervalSeconds: 5, expiresInSeconds: 900 },
},
prompt: { requestId: "prompt-1", message: "Secret", kind: "prompt", promptType: "secret", allowEmpty: false, placeholder: "token" },
select: { requestId: "select-1", message: "Choose", options: [{ value: "work", label: "Work", description: "Company account" }] },
progress: ["Read the guide"],
info: [{ message: "Read the guide", links: [{ url: "https://example.test/docs", label: "Guide" }] }],
})).toMatchObject({
auth: { deviceCode: { userCode: "ABCD", intervalSeconds: 5, expiresInSeconds: 900 } },
prompt: { kind: "prompt", promptType: "secret", allowEmpty: false },
select: { options: [{ value: "work", description: "Company account" }] },
info: [{ links: [{ url: "https://example.test/docs", label: "Guide" }] }],
});
});
it("defaults semantic prompt types from legacy OAuth wire kinds", () => {
const flow = {
flowId: "flow-1",
providerId: "provider",
providerName: "Provider",
status: "running",
progress: [],
};
expect(parseOAuthFlowState({ ...flow, prompt: { requestId: "text", message: "Value", kind: "prompt" } }).prompt).toMatchObject({
kind: "prompt",
promptType: "text",
});
expect(parseOAuthFlowState({ ...flow, prompt: { requestId: "manual", message: "Code", kind: "manual" } }).prompt).toMatchObject({
kind: "manual",
promptType: "manual_code",
});
});
it("parses PI WEB config responses", () => {
expect(parsePiWebConfigResponse({
path: "/tmp/config.json",
+55 -3
View File
@@ -369,7 +369,15 @@ function parseAuthProviderStatus(value: unknown): AuthProviderStatus {
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"]) };
const loginFlow = record["loginFlow"];
if (loginFlow !== undefined && loginFlow !== "interactive") throw new Error("Invalid auth provider login flow");
return {
id: requireString(record, "id"),
name: requireString(record, "name"),
authType: parseAuthType(record["authType"]),
status: parseAuthProviderStatus(record["status"]),
...(loginFlow === undefined ? {} : { loginFlow }),
};
}
export function parseAuthProvidersResponse(value: unknown): AuthProvidersResponse {
@@ -392,6 +400,7 @@ export function parseOAuthFlowState(value: unknown): OAuthFlowState {
...optionalField("auth", optionalOAuthAuth(record["auth"])),
...optionalField("prompt", optionalOAuthPrompt(record["prompt"])),
...optionalField("select", optionalOAuthSelect(record["select"])),
...optionalField("info", optionalOAuthInfo(record["info"])),
};
return flow;
}
@@ -404,7 +413,21 @@ function parseOAuthFlowStatus(value: unknown): OAuthFlowState["status"] {
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")) };
return {
url: requireString(record, "url"),
...optionalField("instructions", optionalString(record, "instructions")),
...optionalField("deviceCode", optionalOAuthDeviceCode(record["deviceCode"])),
};
}
function optionalOAuthDeviceCode(value: unknown): NonNullable<OAuthFlowState["auth"]>["deviceCode"] | undefined {
if (value === undefined) return undefined;
const record = requireRecord(value);
return {
userCode: requireString(record, "userCode"),
...optionalField("intervalSeconds", optionalNumber(record, "intervalSeconds")),
...optionalField("expiresInSeconds", optionalNumber(record, "expiresInSeconds")),
};
}
function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefined {
@@ -412,7 +435,20 @@ function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefin
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 } : {}) };
const promptType = record["promptType"] === undefined ? (kind === "manual" ? "manual_code" : "text") : parseOAuthPromptType(record["promptType"]);
return {
requestId: requireString(record, "requestId"),
message: requireString(record, "message"),
kind,
promptType,
...optionalField("placeholder", optionalString(record, "placeholder")),
...optionalField("allowEmpty", optionalBoolean(record, "allowEmpty")),
};
}
function parseOAuthPromptType(value: unknown): "text" | "secret" | "manual_code" {
if (value !== "text" && value !== "secret" && value !== "manual_code") throw new Error("Invalid OAuth prompt type");
return value;
}
function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefined {
@@ -421,6 +457,22 @@ function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefin
return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), options: arrayOf(parseCommandOption)(record["options"]) };
}
function optionalOAuthInfo(value: unknown): OAuthFlowState["info"] | undefined {
if (value === undefined) return undefined;
return arrayOf((item) => {
const record = requireRecord(item);
return {
message: requireString(record, "message"),
...optionalField("links", record["links"] === undefined ? undefined : arrayOf(parseOAuthInfoLink)(record["links"])),
};
})(value);
}
function parseOAuthInfoLink(value: unknown): NonNullable<NonNullable<OAuthFlowState["info"]>[number]["links"]>[number] {
const record = requireRecord(value);
return { url: requireString(record, "url"), ...optionalField("label", optionalString(record, "label")) };
}
function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object {
if (value === undefined) return {};
const record = requireRecord(value);
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { oauthPromptInputType } from "./AuthDialog";
describe("oauthPromptInputType", () => {
it("renders additive secret prompts as password inputs and defaults legacy prompts to text", () => {
expect(oauthPromptInputType("secret")).toBe("password");
expect(oauthPromptInputType("text")).toBe("text");
expect(oauthPromptInputType("manual_code")).toBe("text");
expect(oauthPromptInputType(undefined)).toBe("text");
});
});
+27 -7
View File
@@ -1,7 +1,7 @@
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 type { AuthProviderOption, OAuthFlowState } from "../api";
import { commandPickerStyles } from "./shared";
@customElement("auth-dialog")
@@ -42,7 +42,7 @@ export class AuthDialog extends LitElement {
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 "providers": return state.authType === undefined ? "Select provider authentication" : state.authType === "oauth" ? "Select subscription provider" : "Select credential 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";
@@ -54,7 +54,7 @@ export class AuthDialog extends LitElement {
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 the active Pi-compatible profile's auth.json</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use provider credentials</span><small>Configure an API key or provider-specific credentials in the active Pi-compatible profile's 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>`;
@@ -86,22 +86,35 @@ export class AuthDialog extends LitElement {
const flow = state.flow;
const prompt = flow.prompt;
const select = flow.select;
const promptInputType = oauthPromptInputType(prompt?.promptType);
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}
${flow.auth.deviceCode !== undefined ? html`
<p class="warning">Enter code: <code>${flow.auth.deviceCode.userCode}</code></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}
${flow.info?.map((item) => item.links === undefined || item.links.length === 0 ? null : html`
<div class="info-links" aria-label="Related information">
${item.links.map((link) => html`<a href=${link.url} target="_blank" rel="noreferrer" title=${item.message}>${link.label ?? link.url}</a>`)}
</div>
`) ?? 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); }}>
<input type=${promptInputType} autocomplete=${promptInputType === "password" ? "off" : "on"} .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>
<div class="inline-options">${select.options.map((option) => html`
<button @click=${() => { this.onOAuthRespond?.(option.value); }}>
<span>${option.label}</span>
${option.description === undefined ? null : html`<small>${option.description}</small>`}
</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}
@@ -157,13 +170,20 @@ export class AuthDialog extends LitElement {
.warning { color: var(--pi-warning); }
.error-text { color: var(--pi-danger); }
.progress { margin: 0; padding-left: 18px; color: var(--pi-muted); }
.info-links { display: flex; flex-wrap: wrap; gap: 8px 12px; }
.inline-options { display: grid; gap: 8px; }
.inline-options button { display: grid; gap: 2px; text-align: left; }
.inline-options small { color: var(--pi-muted); }
em { color: var(--pi-success); font-style: normal; font-size: 12px; }
`];
}
export function oauthPromptInputType(promptType: NonNullable<OAuthFlowState["prompt"]>["promptType"]): "text" | "password" {
return promptType === "secret" ? "password" : "text";
}
function authTypeLabel(authType: "oauth" | "api_key"): string {
return authType === "oauth" ? "subscription" : "API key";
return authType === "oauth" ? "subscription" : "credentials";
}
function focusKey(state: AuthDialogState | undefined): string | undefined {
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { api as defaultApi, type AuthProviderOption, type OAuthFlowState, type SessionInfo, type SessionStatus } from "../api";
import { initialAppState, type AppState } from "../appState";
import { AuthController, parseAuthSlashCommand } from "./authController";
@@ -31,6 +31,34 @@ describe("AuthController", () => {
expect(getState().authDialog).toMatchObject({ step: "apiKey", provider: { id: "anthropic", authType: "api_key" } });
});
it("starts provider-driven API-key interactions instead of opening the legacy one-secret form", async () => {
vi.stubGlobal("window", { setInterval: () => 1, clearInterval: () => undefined });
const provider: AuthProviderOption = { ...authProvider("amazon-bedrock", "api_key"), loginFlow: "interactive" };
const calls: { providerId: string; machineId: string | undefined }[] = [];
const { controller, getState } = createController(
{ authDialog: { step: "providers", mode: "login", authType: "api_key", providers: [provider] } },
{
startInteractiveApiKeyLogin: (providerId, machineId) => {
calls.push({ providerId, machineId });
return Promise.resolve(oauthFlow({ providerId, providerName: "Amazon Bedrock", select: { requestId: "request-1", message: "Choose method", options: [] } }));
},
},
);
try {
await controller.selectLoginProvider(provider.id, "api_key");
expect(calls).toEqual([{ providerId: "amazon-bedrock", machineId: "local" }]);
expect(getState().authDialog).toMatchObject({
step: "oauth",
flow: { providerId: "amazon-bedrock", select: { requestId: "request-1" } },
});
} finally {
controller.dispose();
vi.unstubAllGlobals();
}
});
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(
@@ -43,6 +71,26 @@ describe("AuthController", () => {
expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true });
});
it("submits an allowed blank OAuth text response without client-side rejection", async () => {
const flow = oauthFlow({
prompt: { requestId: "request-1", message: "GitHub Enterprise URL/domain (blank for github.com)", kind: "prompt", promptType: "text", allowEmpty: true },
});
const respondCalls: string[] = [];
const { controller } = createController(
{ authDialog: { step: "oauth", flow, inputValue: "" } },
{
respondOAuthFlow: (_flowId, _requestId, value) => {
respondCalls.push(value);
return Promise.resolve(oauthFlow({ status: "complete" }));
},
},
);
await controller.respondOAuth();
expect(respondCalls).toEqual([""]);
});
it("resets OAuth prompt input and submit state when the request id changes", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const { controller, getState } = createController(
@@ -114,6 +162,108 @@ describe("AuthController", () => {
});
});
it("does not recreate an OAuth dialog when a pending response settles during cancellation", async () => {
const prompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const;
const flow = oauthFlow({ prompt });
const response = deferred<OAuthFlowState>();
const cancellation = deferred<OAuthFlowState>();
const { controller, getState } = createController(
{ authDialog: { step: "oauth", flow, inputValue: "https://callback" } },
{
respondOAuthFlow: () => response.promise,
cancelOAuthFlow: () => cancellation.promise,
},
);
const responsePending = controller.respondOAuth();
const cancellationPending = controller.cancelOAuth();
const dialogAfterCancel = getState().authDialog;
response.resolve(oauthFlow({ prompt, progress: ["Stale response"] }));
await responsePending;
const dialogAfterResponse = getState().authDialog;
cancellation.resolve(oauthFlow({ status: "cancelled" }));
await cancellationPending;
expect(dialogAfterCancel).toBeUndefined();
expect(dialogAfterResponse).toBeUndefined();
expect(getState().authDialog).toBeUndefined();
});
it("does not let a stale OAuth response overwrite a newer flow", async () => {
vi.stubGlobal("window", { setInterval: () => 1, clearInterval: () => undefined });
const oldPrompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const;
const oldFlow = oauthFlow({ prompt: oldPrompt });
const newFlow = oauthFlow({ flowId: "flow-2", prompt: { requestId: "request-2", message: "Paste callback", kind: "manual" } });
const response = deferred<OAuthFlowState>();
const providers = [authProvider("anthropic", "oauth")];
const { controller, getState } = createController(
{ authDialog: { step: "oauth", flow: oldFlow, inputValue: "https://old-callback" } },
{
respondOAuthFlow: () => response.promise,
authProviders: () => Promise.resolve({ providers }),
startOAuthLogin: () => Promise.resolve(newFlow),
},
);
try {
const responsePending = controller.respondOAuth();
await controller.openLogin("anthropic");
const dialogAfterNewFlow = getState().authDialog;
response.resolve(oauthFlow({ prompt: oldPrompt, progress: ["Stale response"] }));
await responsePending;
expect(dialogAfterNewFlow).toMatchObject({ step: "oauth", flow: { flowId: "flow-2" } });
expect(getState().authDialog).toMatchObject({ step: "oauth", flow: { flowId: "flow-2" } });
} finally {
response.resolve(oldFlow);
controller.dispose();
vi.unstubAllGlobals();
}
});
it("does not let an older poll restore a running flow after a newer poll stops polling", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", { setInterval: globalThis.setInterval, clearInterval: globalThis.clearInterval });
const prompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const;
const runningFlow = oauthFlow({ prompt });
const stalePoll = deferred<OAuthFlowState>();
const providers = [authProvider("anthropic", "oauth")];
let pollCalls = 0;
const { controller, getState } = createController(
{},
{
authProviders: () => Promise.resolve({ providers }),
startOAuthLogin: () => Promise.resolve(runningFlow),
oauthFlow: () => {
pollCalls += 1;
return pollCalls === 1 ? stalePoll.promise : Promise.resolve(oauthFlow({ status: "cancelled", prompt }));
},
},
);
try {
await controller.openLogin("anthropic");
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(1000);
const dialogAfterPollingStopped = getState().authDialog;
stalePoll.resolve(oauthFlow({ prompt, progress: ["Stale running poll"] }));
await flushMicrotasks();
expect(pollCalls).toBe(2);
expect(dialogAfterPollingStopped).toMatchObject({ step: "oauth", flow: { status: "cancelled" } });
expect(getState().authDialog).toMatchObject({ step: "oauth", flow: { status: "cancelled" } });
} finally {
stalePoll.resolve(runningFlow);
controller.dispose();
vi.unstubAllGlobals();
vi.useRealTimers();
}
});
it("cancels the active OAuth flow and closes the dialog even when cancellation fails", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const cancelCalls: { flowId: string; machineId: string | undefined }[] = [];
@@ -226,6 +376,13 @@ async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolveDeferred: ((value: T) => void) | undefined;
const promise = new Promise<T>((resolve) => { resolveDeferred = resolve; });
if (resolveDeferred === undefined) throw new Error("Deferred promise was not initialized");
return { promise, resolve: resolveDeferred };
}
function remoteMachine(id: string): NonNullable<AppState["selectedMachine"]> {
return {
id,
+67 -23
View File
@@ -1,6 +1,9 @@
import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api";
import type { AuthDialogState } from "../appState";
import { selectedMachineId, type GetState, type SetState } from "./types";
type OAuthDialogState = Extract<AuthDialogState, { step: "oauth" }>;
export interface AuthControllerDependencies {
api?: typeof defaultApi;
pollIntervalMs?: number;
@@ -9,6 +12,8 @@ export interface AuthControllerDependencies {
export class AuthController {
private readonly api: typeof defaultApi;
private readonly pollIntervalMs: number;
private oauthOperationGeneration = 0;
private pollGeneration = 0;
private pollTimer: number | undefined;
constructor(
@@ -22,6 +27,7 @@ export class AuthController {
}
dispose(): void {
this.oauthOperationGeneration += 1;
this.stopPolling();
}
@@ -55,7 +61,7 @@ export class AuthController {
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);
if (provider.authType === "oauth" || provider.loginFlow === "interactive") await this.startLoginFlow(provider);
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
}
@@ -128,15 +134,22 @@ export class AuthController {
if (dialog?.step !== "oauth") return;
const request = dialog.flow.prompt ?? dialog.flow.select;
if (request === undefined) return;
const operationGeneration = this.oauthOperationGeneration;
const flowId = dialog.flow.flowId;
const requestId = request.requestId;
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, selectedMachineId(this.getState()));
const flow = await this.api.respondOAuthFlow(flowId, requestId, responseValue, selectedMachineId(this.getState()));
const current = this.currentOAuthDialog(operationGeneration, flowId);
if (flow.flowId !== flowId || current === undefined || oauthRequestId(current.flow) !== requestId) return;
this.updateOAuthFlow(flow);
} catch (error) {
this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } });
const current = this.currentOAuthDialog(operationGeneration, flowId);
if (current === undefined || oauthRequestId(current.flow) !== requestId) return;
this.setState({ authDialog: { ...current, responding: false, error: String(error) } });
}
}
@@ -146,16 +159,18 @@ export class AuthController {
this.closeDialog();
return;
}
this.stopPolling();
try {
await this.api.cancelOAuthFlow(dialog.flow.flowId, selectedMachineId(this.getState()));
} catch {
// Best-effort cancel. The dialog closes either way.
}
const flowId = dialog.flow.flowId;
const machineId = selectedMachineId(this.getState());
this.closeDialog();
try {
await this.api.cancelOAuthFlow(flowId, machineId);
} catch {
// Best-effort cancel. The dialog is already closed either way.
}
}
closeDialog(): void {
this.oauthOperationGeneration += 1;
this.stopPolling();
this.setState({ authDialog: undefined });
}
@@ -174,21 +189,27 @@ export class AuthController {
}
const provider = exact[0];
if (provider === undefined) return;
if (provider.authType === "oauth") await this.startOAuth(provider);
if (provider.authType === "oauth" || provider.loginFlow === "interactive") await this.startLoginFlow(provider);
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
} catch (error) {
this.setState({ error: String(error) });
}
}
private async startOAuth(provider: AuthProviderOption): Promise<void> {
private async startLoginFlow(provider: AuthProviderOption): Promise<void> {
if (this.rejectRemoteOAuth("login", provider)) return;
const operationGeneration = ++this.oauthOperationGeneration;
this.stopPolling();
try {
const flow = await this.api.startOAuthLogin(provider.id, selectedMachineId(this.getState()));
const machineId = selectedMachineId(this.getState());
const flow = provider.authType === "oauth"
? await this.api.startOAuthLogin(provider.id, machineId)
: await this.api.startInteractiveApiKeyLogin(provider.id, machineId);
if (operationGeneration !== this.oauthOperationGeneration) return;
this.updateOAuthFlow(flow);
this.startPolling(flow.flowId);
if (flow.status === "running") this.startPolling(flow.flowId);
} catch (error) {
this.setState({ error: String(error) });
if (operationGeneration === this.oauthOperationGeneration) this.setState({ error: String(error) });
}
}
@@ -207,11 +228,14 @@ export class AuthController {
void this.refreshStatus();
return;
}
if (flow.status === "error" || flow.status === "cancelled") this.stopPolling();
if (flow.status === "error" || flow.status === "cancelled") {
this.oauthOperationGeneration += 1;
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 previousRequestId = existing?.step === "oauth" ? oauthRequestId(existing.flow) : undefined;
const newRequestId = oauthRequestId(flow);
const sameRequest = previousRequestId !== undefined && previousRequestId === newRequestId;
const inputValue = sameRequest ? previousInput : "";
const responding = sameRequest && existing?.step === "oauth" ? existing.responding === true : false;
@@ -220,29 +244,45 @@ export class AuthController {
private startPolling(flowId: string): void {
this.stopPolling();
this.pollTimer = window.setInterval(() => { void this.poll(flowId); }, this.pollIntervalMs);
const operationGeneration = this.oauthOperationGeneration;
const pollGeneration = this.pollGeneration;
this.pollTimer = window.setInterval(() => { void this.poll(flowId, operationGeneration, pollGeneration); }, this.pollIntervalMs);
}
private stopPolling(): void {
this.pollGeneration += 1;
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) {
private async poll(flowId: string, operationGeneration: number, pollGeneration: number): Promise<void> {
if (pollGeneration !== this.pollGeneration) return;
const dialog = this.currentOAuthDialog(operationGeneration, flowId);
if (dialog === undefined) {
this.stopPolling();
return;
}
const requestId = oauthRequestId(dialog.flow);
try {
this.updateOAuthFlow(await this.api.oauthFlow(flowId, selectedMachineId(this.getState())));
const flow = await this.api.oauthFlow(flowId, selectedMachineId(this.getState()));
const current = this.currentOAuthDialog(operationGeneration, flowId);
if (flow.flowId !== flowId || pollGeneration !== this.pollGeneration || current === undefined || oauthRequestId(current.flow) !== requestId) return;
this.updateOAuthFlow(flow);
} catch (error) {
const current = this.currentOAuthDialog(operationGeneration, flowId);
if (pollGeneration !== this.pollGeneration || current === undefined || oauthRequestId(current.flow) !== requestId) return;
this.stopPolling();
this.setState({ authDialog: { ...dialog, error: String(error) } });
this.setState({ authDialog: { ...current, error: String(error) } });
}
}
private currentOAuthDialog(operationGeneration: number, flowId: string): OAuthDialogState | undefined {
if (operationGeneration !== this.oauthOperationGeneration) return undefined;
const dialog = this.getState().authDialog;
return dialog?.step === "oauth" && dialog.flow.flowId === flowId ? dialog : undefined;
}
private async refreshStatus(): Promise<void> {
const session = this.session();
if (session === undefined) return;
@@ -260,6 +300,10 @@ export class AuthController {
}
}
function oauthRequestId(flow: OAuthFlowState): string | undefined {
return flow.prompt?.requestId ?? flow.select?.requestId;
}
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);