Archived
Merge pull request #64 from jmfederico/fix/issue-62-authstorage
fix: migrate auth/model plumbing to ModelRuntime (fixes #62)
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
doctorExitCode,
|
||||
isCliEntrypoint,
|
||||
launchdRuntimeDetails,
|
||||
nodeVersionCheck,
|
||||
regularFileExists,
|
||||
serviceBackendForPlatform,
|
||||
} from "./cli.js";
|
||||
@@ -58,6 +59,18 @@ describe("commandWithVersionCheck", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodeVersionCheck", () => {
|
||||
it("checks the complete supported Node version with the resolved executable", () => {
|
||||
process.env["SHELL"] = "/bin/bash";
|
||||
|
||||
const command = nodeVersionCheck();
|
||||
|
||||
expect(command).toContain("22.19.0");
|
||||
expect(command).toContain("process.versions.node");
|
||||
expect(command).toContain("\"$pi_web_probe_executable\"");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentCommandForChecks", () => {
|
||||
it("reads the configured agent command for doctor checks", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-"));
|
||||
|
||||
+10
-6
@@ -15,6 +15,7 @@ import {
|
||||
type NativeServiceInstallFailure,
|
||||
} from "./nativeServices/serviceInstall.js";
|
||||
import {
|
||||
minimumSupportedNodeVersion,
|
||||
nativeServiceManagerRefs,
|
||||
productionNativeServiceIds,
|
||||
type NativeServiceBackend,
|
||||
@@ -772,11 +773,14 @@ export function commandWithVersionCheck(command: string): string {
|
||||
return `${found} && (${commandWord} --version 2>&1 || true)`;
|
||||
}
|
||||
|
||||
function nodeVersionCheck(): string {
|
||||
return [
|
||||
commandCheck("node"),
|
||||
"node -e \"const major = Number(process.versions.node.split('.')[0]); console.log(process.version); process.exit(major >= 22 ? 0 : 1);\"",
|
||||
].join(" && ");
|
||||
export function nodeVersionCheck(): string {
|
||||
return nativeServicePrerequisiteShellCheck(detectServiceShell().name, {
|
||||
id: "caller.node",
|
||||
kind: "node-version",
|
||||
command: "node",
|
||||
minimumVersion: minimumSupportedNodeVersion,
|
||||
description: `node >= ${minimumSupportedNodeVersion}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string {
|
||||
@@ -787,7 +791,7 @@ function generalDoctorChecks(): Check[] {
|
||||
const shell = serviceShellLabel();
|
||||
const agentCommand = agentCommandForChecks();
|
||||
return [
|
||||
[`Caller login ${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
|
||||
[`Caller login ${shell} can find node >= ${minimumSupportedNodeVersion}`, serviceShellCommand(nodeVersionCheck())],
|
||||
[`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
|
||||
[`Caller login ${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
|
||||
];
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("production native service planning", () => {
|
||||
wants: [],
|
||||
prerequisites: [
|
||||
{ id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" },
|
||||
{ id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 },
|
||||
{ id: "sessiond.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -111,7 +111,7 @@ describe("production native service planning", () => {
|
||||
wants: ["sessiond"],
|
||||
prerequisites: [
|
||||
{ id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" },
|
||||
{ id: "web.node", kind: "node-version", command: "node", minimumMajor: 22 },
|
||||
{ id: "web.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -196,7 +196,7 @@ describe("production native service planning", () => {
|
||||
namedCommandFailure: "command not found",
|
||||
},
|
||||
prerequisites: [
|
||||
{ id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 },
|
||||
{ id: "sessiond.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" },
|
||||
{ id: "sessiond.entrypoint", kind: "readable-file", path: "/package with space/sessiond's entry.js" },
|
||||
],
|
||||
});
|
||||
@@ -324,7 +324,7 @@ describe("development native service planning", () => {
|
||||
environment: { PI_WEB_CONFIG: "/tmp/config.json" },
|
||||
workingDirectory: "/checkout with space",
|
||||
prerequisites: [
|
||||
{ id: "sessiond.node", kind: "node-version", minimumMajor: 22 },
|
||||
{ id: "sessiond.node", kind: "node-version", minimumVersion: "22.19.0" },
|
||||
{ id: "sessiond.command.npm", kind: "command-available", command: "npm" },
|
||||
{ id: "sessiond.package-scripts", kind: "package-scripts", scripts: ["start:sessiond"] },
|
||||
],
|
||||
@@ -338,7 +338,7 @@ describe("development native service planning", () => {
|
||||
after: ["sessiond"],
|
||||
wants: ["sessiond"],
|
||||
prerequisites: [
|
||||
{ id: "uiDev.node", kind: "node-version", minimumMajor: 22 },
|
||||
{ id: "uiDev.node", kind: "node-version", minimumVersion: "22.19.0" },
|
||||
{ id: "uiDev.command.npm", kind: "command-available", command: "npm" },
|
||||
{ id: "uiDev.command.bash", kind: "command-available", command: "bash" },
|
||||
{ id: "uiDev.package-scripts", kind: "package-scripts", scripts: ["dev:web", "dev:client"] },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const minimumSupportedNodeVersion = "22.19.0";
|
||||
|
||||
export type NativeServiceBackendKind = "systemd" | "launchd";
|
||||
export type NativeServiceMode = "production" | "development";
|
||||
export type NativeServiceId = "sessiond" | "web" | "uiDev";
|
||||
@@ -64,7 +66,7 @@ export type NativeServicePrerequisite =
|
||||
id: string;
|
||||
kind: "node-version";
|
||||
command: "node";
|
||||
minimumMajor: number;
|
||||
minimumVersion: string;
|
||||
description: string;
|
||||
}
|
||||
| {
|
||||
@@ -571,8 +573,8 @@ function nodeRequirement(serviceId: NativeServiceId): NativeServicePrerequisite
|
||||
id: `${serviceId}.node`,
|
||||
kind: "node-version",
|
||||
command: "node",
|
||||
minimumMajor: 22,
|
||||
description: "node >= 22 is available to the service shell",
|
||||
minimumVersion: minimumSupportedNodeVersion,
|
||||
description: `node >= ${minimumSupportedNodeVersion} is available to the service shell`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
LaunchdNativeServiceProbe,
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
SystemdNativeServiceProbe,
|
||||
launchdProbePlist,
|
||||
nativeServicePrerequisiteShellCheck,
|
||||
nodeVersionCheckScript,
|
||||
systemdRunArguments,
|
||||
type LaunchdProbeFileSystem,
|
||||
type ProbeCommandResult,
|
||||
@@ -433,13 +435,26 @@ describe("probe service definitions", () => {
|
||||
id: "sessiond.node",
|
||||
kind: "node-version",
|
||||
command: "node",
|
||||
minimumMajor: 22,
|
||||
description: "node >= 22",
|
||||
minimumVersion: "22.19.0",
|
||||
description: "node >= 22.19.0",
|
||||
});
|
||||
expect(check).toContain("\"$pi_web_probe_executable\" '-e'");
|
||||
expect(check).toContain("22.19.0");
|
||||
expect(check).not.toContain("&& node -e");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ version: "21.99.99", accepted: false },
|
||||
{ version: "22.18.99", accepted: false },
|
||||
{ version: "22.19.0", accepted: true },
|
||||
{ version: "22.19.1", accepted: true },
|
||||
{ version: "23.0.0", accepted: true },
|
||||
])("checks the complete Node version for $version", ({ version, accepted }) => {
|
||||
const result = spawnSync(process.execPath, ["-e", nodeVersionCheckScript("22.19.0"), version]);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(accepted ? 0 : 1);
|
||||
});
|
||||
|
||||
it("requires bundled entrypoints to be readable regular files", () => {
|
||||
const check = nativeServicePrerequisiteShellCheck("bash", {
|
||||
id: "sessiond.entrypoint",
|
||||
|
||||
@@ -434,10 +434,8 @@ export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellNam
|
||||
switch (prerequisite.kind) {
|
||||
case "command-available":
|
||||
return externalExecutableShellCheck(shell, prerequisite.command);
|
||||
case "node-version": {
|
||||
const script = `const major=Number(process.versions.node.split('.')[0]);process.exit(major>=${String(prerequisite.minimumMajor)}?0:1)`;
|
||||
return externalExecutableShellCheck(shell, "node", ["-e", script]);
|
||||
}
|
||||
case "node-version":
|
||||
return externalExecutableShellCheck(shell, "node", ["-e", nodeVersionCheckScript(prerequisite.minimumVersion)]);
|
||||
case "readable-file": {
|
||||
const path = shellQuote(shell, prerequisite.path);
|
||||
return `test -f ${path} && test -r ${path}`;
|
||||
@@ -449,6 +447,11 @@ export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellNam
|
||||
}
|
||||
}
|
||||
|
||||
export function nodeVersionCheckScript(minimumVersion: string): string {
|
||||
const encodedMinimum = JSON.stringify(minimumVersion);
|
||||
return `const version=process.argv[1]??process.versions.node;console.log(process.version);const current=version.split('.').map(Number);const minimum=${encodedMinimum}.split('.').map(Number);const length=Math.max(current.length,minimum.length);let comparison=0;for(let index=0;index<length;index+=1){const left=current[index]??0;const right=minimum[index]??0;if(left!==right){comparison=left>right?1:-1;break}}process.exit(comparison>=0?0:1)`;
|
||||
}
|
||||
|
||||
function externalExecutableShellCheck(
|
||||
shell: NativeServiceShellName,
|
||||
command: string,
|
||||
@@ -518,7 +521,7 @@ function unsatisfiedDetail(prerequisite: NativeServicePrerequisite): string {
|
||||
case "command-available":
|
||||
return `${prerequisite.command} did not resolve to an external executable in the native service environment.`;
|
||||
case "node-version":
|
||||
return `node >= ${String(prerequisite.minimumMajor)} was not available in the native service environment.`;
|
||||
return `node >= ${prerequisite.minimumVersion} was not available in the native service environment.`;
|
||||
case "readable-file":
|
||||
return `${prerequisite.path} was not a readable regular file in the native service environment.`;
|
||||
case "package-scripts":
|
||||
|
||||
@@ -6,6 +6,7 @@ class FakeSocket extends EventEmitter implements RealtimeSocket {
|
||||
readonly OPEN = 1;
|
||||
readyState = this.OPEN;
|
||||
send = vi.fn();
|
||||
terminate = vi.fn();
|
||||
}
|
||||
|
||||
describe("SessionEventHub", () => {
|
||||
@@ -54,6 +55,30 @@ describe("SessionEventHub", () => {
|
||||
expect(removed.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("terminates a failed session socket without disrupting healthy delivery or sequence watermarks", () => {
|
||||
const hub = new SessionEventHub();
|
||||
const failed = new FakeSocket();
|
||||
const healthy = new FakeSocket();
|
||||
failed.send.mockImplementation(() => { throw new Error("socket closed"); });
|
||||
hub.add("s1", failed);
|
||||
hub.add("s1", healthy);
|
||||
|
||||
hub.publish("s1", { type: "assistant.delta", text: "hello" });
|
||||
|
||||
expect(failed.send).toHaveBeenCalledOnce();
|
||||
expect(failed.terminate).toHaveBeenCalledOnce();
|
||||
expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "assistant.delta", text: "hello", seq: 1 }));
|
||||
expect(hub.currentSeq("s1")).toBe(1);
|
||||
|
||||
failed.send.mockClear();
|
||||
hub.publish("s1", { type: "assistant.delta", text: "again" });
|
||||
|
||||
expect(failed.send).not.toHaveBeenCalled();
|
||||
expect(failed.terminate).toHaveBeenCalledOnce();
|
||||
expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "assistant.delta", text: "again", seq: 2 }));
|
||||
expect(hub.currentSeq("s1")).toBe(2);
|
||||
});
|
||||
|
||||
it("publishes global events only to global sockets", () => {
|
||||
const hub = new SessionEventHub();
|
||||
const globalSocket = new FakeSocket();
|
||||
@@ -78,6 +103,29 @@ describe("SessionEventHub", () => {
|
||||
expect(sessionSocket.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("contains termination failures while publishing unstamped global events", () => {
|
||||
const hub = new SessionEventHub();
|
||||
const failed = new FakeSocket();
|
||||
const healthy = new FakeSocket();
|
||||
failed.send.mockImplementation(() => { throw new Error("socket closed"); });
|
||||
failed.terminate.mockImplementation(() => { throw new Error("termination failed"); });
|
||||
hub.addGlobal(failed);
|
||||
hub.addGlobal(healthy);
|
||||
|
||||
hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed" });
|
||||
|
||||
expect(failed.send).toHaveBeenCalledOnce();
|
||||
expect(failed.terminate).toHaveBeenCalledOnce();
|
||||
expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed" }));
|
||||
|
||||
failed.send.mockClear();
|
||||
hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed again" });
|
||||
|
||||
expect(failed.send).not.toHaveBeenCalled();
|
||||
expect(failed.terminate).toHaveBeenCalledOnce();
|
||||
expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed again" }));
|
||||
});
|
||||
|
||||
it("stamps a monotonically increasing per-session seq on published events", () => {
|
||||
const hub = new SessionEventHub();
|
||||
const socket = new FakeSocket();
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface RealtimeSocket {
|
||||
readonly OPEN: number;
|
||||
readyState: number;
|
||||
send(payload: string): void;
|
||||
terminate(): void;
|
||||
on(event: "close", listener: () => void): unknown;
|
||||
}
|
||||
|
||||
@@ -34,9 +35,7 @@ export class SessionEventHub {
|
||||
const seq = (this.seqBySession.get(sessionId) ?? 0) + 1;
|
||||
this.seqBySession.set(sessionId, seq);
|
||||
const payload = JSON.stringify({ ...projectBrowserSessionEvent(event), seq });
|
||||
for (const socket of this.socketsBySession.get(sessionId) ?? []) {
|
||||
if (socket.readyState === socket.OPEN) socket.send(payload);
|
||||
}
|
||||
this.sendToSockets(this.socketsBySession.get(sessionId), payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,8 +54,23 @@ export class SessionEventHub {
|
||||
|
||||
publishRealtime(event: RealtimeEvent): void {
|
||||
const payload = JSON.stringify(event);
|
||||
for (const socket of this.globalSockets) {
|
||||
if (socket.readyState === socket.OPEN) socket.send(payload);
|
||||
this.sendToSockets(this.globalSockets, payload);
|
||||
}
|
||||
|
||||
private sendToSockets(sockets: Set<RealtimeSocket> | undefined, payload: string): void {
|
||||
if (sockets === undefined) return;
|
||||
for (const socket of sockets) {
|
||||
if (socket.readyState !== socket.OPEN) continue;
|
||||
try {
|
||||
socket.send(payload);
|
||||
} catch {
|
||||
sockets.delete(socket);
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
// Removal is authoritative; cleanup failure must not block healthy sockets.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +36,15 @@ await app.register(fastifyWebsocket);
|
||||
|
||||
await runSessionDaemonStartup({
|
||||
logger: app.log,
|
||||
createRuntime() {
|
||||
async createRuntime() {
|
||||
const eventHub = new SessionEventHub();
|
||||
const workspaceActivity = new WorkspaceActivityService(eventHub);
|
||||
const auth = new AuthService({ agentDir: activeAgentProfile.dir });
|
||||
const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log });
|
||||
const spawnTargets = config.spawnSessions
|
||||
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
||||
: undefined;
|
||||
const sessions = new PiSessionService(eventHub, {
|
||||
modelRegistry: auth.modelRegistry,
|
||||
modelRuntime: auth.runtime,
|
||||
agentDir: activeAgentProfile.dir,
|
||||
workspaceActivity,
|
||||
logger: app.log,
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface SessionDaemonStartupLogger {
|
||||
|
||||
export interface SessionDaemonStartupSteps<Runtime> {
|
||||
logger: SessionDaemonStartupLogger;
|
||||
createRuntime(): Runtime;
|
||||
createRuntime(): Runtime | Promise<Runtime>;
|
||||
registerRoutes(runtime: Runtime): void;
|
||||
listen(runtime: Runtime): Promise<void>;
|
||||
migrateArchive?: () => Promise<LegacySessionArchiveMigrationResult>;
|
||||
@@ -36,7 +36,7 @@ export async function runSessionDaemonStartup<Runtime>(
|
||||
);
|
||||
}
|
||||
|
||||
const runtime = steps.createRuntime();
|
||||
const runtime = await steps.createRuntime();
|
||||
steps.registerRoutes(runtime);
|
||||
await steps.listen(runtime);
|
||||
return runtime;
|
||||
|
||||
@@ -1,52 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getLoginProviderOptions, getLogoutProviderOptions, isApiKeyLoginProvider, type AuthProviderModelRegistry } from "./authProviderOptions";
|
||||
import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderRuntime } from "./authProviderOptions";
|
||||
|
||||
function registry(): AuthProviderModelRegistry {
|
||||
const credentials = new Map<string, { type: "oauth" | "api_key" }>();
|
||||
credentials.set("openai", { type: "api_key" });
|
||||
function runtime(configuredProviders: ReadonlySet<string> = new Set(["openai"])): AuthProviderRuntime {
|
||||
const credentials = [{ providerId: "openai", type: "api_key" as const }];
|
||||
// Auth shapes mirror what the Pi SDK actually reports for these providers:
|
||||
// github-copilot supports both methods, openai-codex is OAuth-only, and
|
||||
// ambient providers resolve credentials without offering interactive login.
|
||||
const providers = [
|
||||
{ id: "anthropic", name: "Anthropic", auth: { oauth: {}, apiKey: { login: () => undefined } } },
|
||||
{ id: "github-copilot", name: "GitHub Copilot", auth: { oauth: {}, apiKey: { login: () => undefined } } },
|
||||
{ id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)", auth: { oauth: {} } },
|
||||
{ id: "openai", name: "OpenAI", auth: { apiKey: { login: () => undefined } } },
|
||||
{ id: "custom", name: "Custom", auth: { apiKey: { login: () => undefined } } },
|
||||
{ id: "cloudflare-ai-gateway", name: "Cloudflare AI Gateway", auth: { apiKey: { login: () => undefined } } },
|
||||
{ id: "cloudflare-workers-ai", name: "Cloudflare Workers AI", auth: { apiKey: { login: () => undefined } } },
|
||||
{ id: "amazon-bedrock", name: "Amazon Bedrock", auth: { apiKey: { login: () => undefined } } },
|
||||
{ id: "google-vertex", name: "Google Vertex AI", auth: { apiKey: { login: () => undefined } } },
|
||||
{ id: "ambient", name: "Ambient credentials", auth: { apiKey: {} } },
|
||||
];
|
||||
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),
|
||||
getProviders: () => providers,
|
||||
listCredentials: () => Promise.resolve(credentials),
|
||||
getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }),
|
||||
hasConfiguredAuth: (provider: string) => configuredProviders.has(provider),
|
||||
};
|
||||
}
|
||||
|
||||
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("builds login options for OAuth-only, dual-auth, and API-key providers", () => {
|
||||
const options = getLoginProviderOptions(registry());
|
||||
it("offers each interactive login method reported by the backend", () => {
|
||||
const options = getLoginProviderOptions(runtime());
|
||||
expect(options).toEqual(expect.arrayContaining([
|
||||
// Dual-capable providers surface both login methods, driven purely by SDK data.
|
||||
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: "github-copilot", authType: "oauth" }),
|
||||
expect.objectContaining({ id: "github-copilot", authType: "api_key" }),
|
||||
// OAuth-only provider surfaces only oauth.
|
||||
expect.objectContaining({ id: "openai-codex", authType: "oauth" }),
|
||||
// API-key options use the generic AuthInteraction flow, including
|
||||
// multi-field and select-first providers the legacy form cannot execute.
|
||||
expect.objectContaining({ id: "openai", authType: "api_key", loginFlow: "interactive", status: { configured: true, source: "stored" } }),
|
||||
expect.objectContaining({ id: "custom", authType: "api_key", loginFlow: "interactive" }),
|
||||
expect.objectContaining({ id: "cloudflare-ai-gateway", authType: "api_key", loginFlow: "interactive" }),
|
||||
expect.objectContaining({ id: "cloudflare-workers-ai", authType: "api_key", loginFlow: "interactive" }),
|
||||
expect.objectContaining({ id: "amazon-bedrock", authType: "api_key", loginFlow: "interactive" }),
|
||||
expect.objectContaining({ id: "google-vertex", authType: "api_key", loginFlow: "interactive" }),
|
||||
]));
|
||||
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })]));
|
||||
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai", authType: "oauth" })]));
|
||||
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "ambient", authType: "api_key" })]));
|
||||
});
|
||||
|
||||
it("returns only currently stored credentials for logout", () => {
|
||||
expect(getLogoutProviderOptions(registry())).toEqual([
|
||||
expect.objectContaining({ id: "openai", authType: "api_key" }),
|
||||
it("does not report a stored credential as configured when provider resolution is incomplete", async () => {
|
||||
const unresolvedRuntime = runtime(new Set());
|
||||
|
||||
expect(getLoginProviderOptions(unresolvedRuntime, "api_key")).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "openai", status: { configured: false } }),
|
||||
]));
|
||||
expect(await getLogoutProviderOptions(unresolvedRuntime)).toEqual([
|
||||
expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: false } }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns only currently stored credentials for logout", async () => {
|
||||
expect(await getLogoutProviderOptions(runtime())).toEqual([
|
||||
expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,62 +1,78 @@
|
||||
import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js";
|
||||
|
||||
const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]);
|
||||
|
||||
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;
|
||||
/** Minimal provider shape needed to enumerate login/logout options. */
|
||||
interface AuthProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
auth: { apiKey?: { login?: unknown }; oauth?: unknown };
|
||||
}
|
||||
|
||||
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),
|
||||
}));
|
||||
/** Non-secret stored-credential metadata, keyed by provider id. */
|
||||
interface AuthProviderCredentialInfo {
|
||||
providerId: string;
|
||||
type: AuthType;
|
||||
}
|
||||
|
||||
const modelProviders = new Set(modelRegistry.getAll().map((model) => model.provider));
|
||||
for (const providerId of modelProviders) {
|
||||
if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) continue;
|
||||
/**
|
||||
* Structural slice of the SDK `ModelRuntime` used to derive auth provider
|
||||
* options. Kept structural (rather than `Pick<ModelRuntime, ...>`) so tests can
|
||||
* supply a lightweight double without constructing a full runtime; a real
|
||||
* `ModelRuntime` satisfies it.
|
||||
*/
|
||||
export interface AuthProviderRuntime {
|
||||
getProviders(): readonly AuthProviderInfo[];
|
||||
listCredentials(): Promise<readonly AuthProviderCredentialInfo[]>;
|
||||
getProviderAuthStatus(providerId: string): AuthProviderStatus;
|
||||
hasConfiguredAuth(providerId: string): boolean;
|
||||
}
|
||||
|
||||
export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): AuthProviderOption[] {
|
||||
const providers = runtime.getProviders();
|
||||
|
||||
const options: AuthProviderOption[] = [];
|
||||
for (const provider of providers) {
|
||||
if (provider.auth.oauth === undefined) continue;
|
||||
options.push({
|
||||
id: providerId,
|
||||
name: modelRegistry.getProviderDisplayName(providerId),
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
authType: "oauth",
|
||||
status: truthfulProviderStatus(runtime, provider.id),
|
||||
});
|
||||
}
|
||||
|
||||
for (const provider of providers) {
|
||||
if (provider.auth.apiKey?.login === undefined) continue;
|
||||
options.push({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
authType: "api_key",
|
||||
status: modelRegistry.getProviderAuthStatus(providerId),
|
||||
status: truthfulProviderStatus(runtime, provider.id),
|
||||
loginFlow: "interactive",
|
||||
});
|
||||
}
|
||||
|
||||
return filterAndSort(options, authType);
|
||||
}
|
||||
|
||||
export function getLogoutProviderOptions(modelRegistry: AuthProviderModelRegistry): AuthProviderOption[] {
|
||||
export async function getLogoutProviderOptions(runtime: AuthProviderRuntime): Promise<AuthProviderOption[]> {
|
||||
const providerNames = new Map(runtime.getProviders().map((provider) => [provider.id, provider.name]));
|
||||
const options: AuthProviderOption[] = [];
|
||||
for (const providerId of modelRegistry.authStorage.list()) {
|
||||
const credential = modelRegistry.authStorage.get(providerId);
|
||||
if (credential === undefined) continue;
|
||||
for (const credential of await runtime.listCredentials()) {
|
||||
options.push({
|
||||
id: providerId,
|
||||
name: modelRegistry.getProviderDisplayName(providerId),
|
||||
id: credential.providerId,
|
||||
name: providerNames.get(credential.providerId) ?? credential.providerId,
|
||||
authType: credential.type,
|
||||
status: modelRegistry.getProviderAuthStatus(providerId),
|
||||
status: truthfulProviderStatus(runtime, credential.providerId),
|
||||
});
|
||||
}
|
||||
return filterAndSort(options);
|
||||
}
|
||||
|
||||
export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet<string>): boolean {
|
||||
if (OAUTH_ONLY_PROVIDERS.has(providerId)) return false;
|
||||
if (providerId === "anthropic") return true;
|
||||
if (oauthProviderIds.has(providerId)) return false;
|
||||
return true;
|
||||
function truthfulProviderStatus(runtime: AuthProviderRuntime, providerId: string): AuthProviderStatus {
|
||||
const reported = runtime.getProviderAuthStatus(providerId);
|
||||
// ModelRuntime reports any stored entry as configured before checking whether
|
||||
// the provider can resolve all required credential and ambient fields.
|
||||
return reported.configured && !runtime.hasConfiguredAuth(providerId) ? { configured: false } : reported;
|
||||
}
|
||||
|
||||
function filterAndSort(options: AuthProviderOption[], authType?: AuthType): AuthProviderOption[] {
|
||||
|
||||
@@ -4,7 +4,7 @@ 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);
|
||||
return await auth.authProviders(request.query.mode ?? "login", request.query.authType);
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -12,7 +12,17 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref
|
||||
|
||||
app.post<{ Body: { providerId: string; key: string } }>(`${prefix}/auth/api-key`, async (request, reply) => {
|
||||
try {
|
||||
return auth.saveApiKey(request.body.providerId, request.body.key);
|
||||
return await auth.saveApiKey(request.body.providerId, request.body.key);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Additive endpoint for newer browsers; the one-secret route remains for
|
||||
// rolling compatibility with older browser bundles.
|
||||
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/api-key/interactive`, async (request, reply) => {
|
||||
try {
|
||||
return await auth.startApiKeyLogin(request.body.providerId);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -20,7 +30,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref
|
||||
|
||||
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/logout`, async (request, reply) => {
|
||||
try {
|
||||
return auth.logoutProvider(request.body.providerId);
|
||||
return await auth.logoutProvider(request.body.providerId);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -28,7 +38,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref
|
||||
|
||||
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/oauth`, async (request, reply) => {
|
||||
try {
|
||||
return auth.startOAuthLogin(request.body.providerId);
|
||||
return await auth.startOAuthLogin(request.body.providerId);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
@@ -1,98 +1,505 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { InMemoryCredentialStore, type AuthPrompt, type Credential } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OAuthFlowState } from "../../shared/apiTypes.js";
|
||||
import { AuthService, type AuthChange } from "./authService.js";
|
||||
import { AuthService, createModelRuntimeForAgentDir, type AuthChange, type AuthServiceLogger } from "./authService.js";
|
||||
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("AuthService", () => {
|
||||
it("saves API keys and emits a global auth change", () => {
|
||||
const { auth, authStorage, changes } = createAuthService();
|
||||
it("saves API keys and emits a global auth change after the runtime refreshes", async () => {
|
||||
const { auth, runtime, credentials, changes } = await createAuthService();
|
||||
const reloadConfig = vi.spyOn(runtime, "reloadConfig").mockResolvedValue(undefined);
|
||||
const refresh = vi.spyOn(runtime, "refresh");
|
||||
|
||||
expect(auth.saveApiKey("anthropic", "sk-test")).toEqual({ accepted: true });
|
||||
await expect(auth.saveApiKey("anthropic", "sk-test")).resolves.toEqual({ accepted: true });
|
||||
|
||||
expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-test" });
|
||||
await expect(credentials.read("anthropic")).resolves.toEqual({ type: "api_key", key: "sk-test" });
|
||||
expect(reloadConfig).toHaveBeenCalledOnce();
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
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" } });
|
||||
it("logs out providers and emits the removed provider id after the runtime refreshes", async () => {
|
||||
const { auth, runtime, credentials, changes } = await createAuthService({ anthropic: { type: "api_key", key: "sk-test" } });
|
||||
const refresh = vi.spyOn(runtime, "refresh");
|
||||
|
||||
expect(auth.logoutProvider("anthropic")).toEqual({ accepted: true });
|
||||
await expect(auth.logoutProvider("anthropic")).resolves.toEqual({ accepted: true });
|
||||
|
||||
expect(authStorage.get("anthropic")).toBeUndefined();
|
||||
await expect(credentials.read("anthropic")).resolves.toBeUndefined();
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(changes).toEqual([{ removedProviderId: "anthropic" }]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("rejects blank API keys", () => {
|
||||
const { auth, changes } = createAuthService();
|
||||
it("persists an API key and attempts every listener when failure logging throws", async () => {
|
||||
const loggingFailure = new Error("auth logger failed");
|
||||
const error = vi.fn(() => { throw loggingFailure; });
|
||||
const logger: AuthServiceLogger = { error };
|
||||
const { auth, credentials, changes } = await createAuthService({}, logger);
|
||||
const failure = new Error("session auth refresh failed");
|
||||
const attempts: string[] = [];
|
||||
auth.subscribe(() => {
|
||||
attempts.push("throwing");
|
||||
throw failure;
|
||||
});
|
||||
auth.subscribe(async () => {
|
||||
await Promise.resolve();
|
||||
attempts.push("healthy");
|
||||
});
|
||||
|
||||
expect(() => { auth.saveApiKey("anthropic", " "); }).toThrow("API key is required");
|
||||
await expect(auth.saveApiKey("anthropic", "sk-test")).resolves.toEqual({ accepted: true });
|
||||
|
||||
await expect(credentials.read("anthropic")).resolves.toEqual({ type: "api_key", key: "sk-test" });
|
||||
expect(changes).toEqual([{}]);
|
||||
expect(attempts).toEqual(["throwing", "healthy"]);
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
{ err: failure, operation: "login", providerId: "anthropic", authType: "api_key" },
|
||||
"auth-change listener failed",
|
||||
);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("removes a credential when auth-change propagation rejects", async () => {
|
||||
const error = vi.fn();
|
||||
const logger: AuthServiceLogger = { error };
|
||||
const { auth, credentials, changes } = await createAuthService(
|
||||
{ anthropic: { type: "api_key", key: "sk-test" } },
|
||||
logger,
|
||||
);
|
||||
const failure = new Error("session logout refresh failed");
|
||||
auth.subscribe(() => Promise.reject(failure));
|
||||
|
||||
await expect(auth.logoutProvider("anthropic")).resolves.toEqual({ accepted: true });
|
||||
|
||||
await expect(credentials.read("anthropic")).resolves.toBeUndefined();
|
||||
expect(changes).toEqual([{ removedProviderId: "anthropic" }]);
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
{ err: failure, operation: "logout", providerId: "anthropic" },
|
||||
"auth-change listener failed",
|
||||
);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("rejects blank API keys", async () => {
|
||||
const { auth, changes } = await createAuthService();
|
||||
|
||||
await expect(auth.saveApiKey("anthropic", " ")).rejects.toThrow("API key is required");
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("keeps existing file-backed credentials unchanged when legacy Cloudflare setup cannot finish", async () => {
|
||||
const seed = {
|
||||
"cloudflare-ai-gateway": {
|
||||
type: "api_key" as const,
|
||||
key: "existing-secret",
|
||||
env: { CLOUDFLARE_ACCOUNT_ID: "existing-account", CLOUDFLARE_GATEWAY_ID: "existing-gateway" },
|
||||
},
|
||||
};
|
||||
const { auth, authPath, changes } = await createFileBackedAuthService(seed);
|
||||
const before = await readFile(authPath, "utf8");
|
||||
|
||||
await expect(auth.saveApiKey("cloudflare-ai-gateway", "new-secret")).rejects.toThrow(
|
||||
"Cloudflare AI Gateway requires interactive setup; use Pi's generic /login flow",
|
||||
);
|
||||
|
||||
await expect(readFile(authPath, "utf8")).resolves.toBe(before);
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ providerId: "amazon-bedrock", providerName: "Amazon Bedrock" },
|
||||
{ providerId: "google-vertex", providerName: "Google Vertex AI" },
|
||||
])("keeps an empty file-backed store unchanged when legacy $providerName setup starts with a selection", async ({ providerId, providerName }) => {
|
||||
const { auth, authPath, changes } = await createFileBackedAuthService({});
|
||||
const before = await readFile(authPath, "utf8");
|
||||
|
||||
await expect(auth.saveApiKey(providerId, "submitted-secret")).rejects.toThrow(
|
||||
`${providerName} requires interactive setup; use Pi's generic /login flow`,
|
||||
);
|
||||
|
||||
await expect(readFile(authPath, "utf8")).resolves.toBe(before);
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("executes Cloudflare multi-field API-key setup through the interactive flow", async () => {
|
||||
const { auth, credentials, changes } = await createAuthService();
|
||||
|
||||
const state = await auth.startApiKeyLogin("cloudflare-ai-gateway");
|
||||
expect(state.prompt).toMatchObject({ message: "Enter Cloudflare API key", promptType: "secret" });
|
||||
if (state.prompt === undefined) throw new Error("Expected Cloudflare key prompt");
|
||||
auth.respondToOAuthFlow(state.flowId, state.prompt.requestId, "cf-secret");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: "Enter Cloudflare account ID", promptType: "text" });
|
||||
});
|
||||
const accountPrompt = auth.oauthFlow(state.flowId).prompt;
|
||||
if (accountPrompt === undefined) throw new Error("Expected Cloudflare account prompt");
|
||||
auth.respondToOAuthFlow(state.flowId, accountPrompt.requestId, "account-1");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: "Enter Cloudflare AI Gateway ID", promptType: "text" });
|
||||
});
|
||||
const gatewayPrompt = auth.oauthFlow(state.flowId).prompt;
|
||||
if (gatewayPrompt === undefined) throw new Error("Expected Cloudflare gateway prompt");
|
||||
auth.respondToOAuthFlow(state.flowId, gatewayPrompt.requestId, "gateway-1");
|
||||
|
||||
await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
|
||||
await expect(credentials.read("cloudflare-ai-gateway")).resolves.toEqual({
|
||||
type: "api_key",
|
||||
key: "cf-secret",
|
||||
env: { CLOUDFLARE_ACCOUNT_ID: "account-1", CLOUDFLARE_GATEWAY_ID: "gateway-1" },
|
||||
});
|
||||
expect(changes).toEqual([{}]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ providerId: "amazon-bedrock", selection: "bearer-token", secretPrompt: "Enter Amazon Bedrock bearer token" },
|
||||
{ providerId: "google-vertex", selection: "api-key", secretPrompt: "Enter Google Cloud API key" },
|
||||
])("executes $providerId select-first API-key setup through the interactive flow", async ({ providerId, selection, secretPrompt }) => {
|
||||
const { auth, credentials, changes } = await createAuthService();
|
||||
|
||||
const state = await auth.startApiKeyLogin(providerId);
|
||||
expect(state.select).toBeDefined();
|
||||
if (state.select === undefined) throw new Error("Expected auth method selection");
|
||||
auth.respondToOAuthFlow(state.flowId, state.select.requestId, selection);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: secretPrompt, promptType: "secret" });
|
||||
});
|
||||
const prompt = auth.oauthFlow(state.flowId).prompt;
|
||||
if (prompt === undefined) throw new Error("Expected provider secret prompt");
|
||||
auth.respondToOAuthFlow(state.flowId, prompt.requestId, "provider-secret");
|
||||
|
||||
await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
|
||||
await expect(credentials.read(providerId)).resolves.toEqual({ type: "api_key", key: "provider-secret" });
|
||||
expect(changes).toEqual([{}]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("reports a key-only legacy Cloudflare credential as unconfigured", async () => {
|
||||
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "");
|
||||
vi.stubEnv("CLOUDFLARE_GATEWAY_ID", "");
|
||||
const { auth } = await createFileBackedAuthService({
|
||||
"cloudflare-ai-gateway": { type: "api_key", key: "legacy-secret" },
|
||||
});
|
||||
|
||||
const response = await auth.authProviders("login", "api_key");
|
||||
|
||||
expect(response.providers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "cloudflare-ai-gateway",
|
||||
loginFlow: "interactive",
|
||||
status: { configured: false },
|
||||
}),
|
||||
]));
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("reports a stored Cloudflare key as configured when ambient fields complete it", async () => {
|
||||
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "ambient-account");
|
||||
vi.stubEnv("CLOUDFLARE_GATEWAY_ID", "ambient-gateway");
|
||||
const { auth } = await createFileBackedAuthService({
|
||||
"cloudflare-ai-gateway": { type: "api_key", key: "legacy-secret" },
|
||||
});
|
||||
|
||||
const response = await auth.authProviders("login", "api_key");
|
||||
|
||||
expect(response.providers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "cloudflare-ai-gateway",
|
||||
loginFlow: "interactive",
|
||||
status: { configured: true, source: "stored" },
|
||||
}),
|
||||
]));
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "text", prompt: { type: "text", message: "Account" } satisfies AuthPrompt },
|
||||
{
|
||||
label: "select",
|
||||
prompt: { type: "select", message: "Region", options: [{ id: "us", label: "US" }] } satisfies AuthPrompt,
|
||||
},
|
||||
{ label: "manual-code", prompt: { type: "manual_code", message: "Code" } satisfies AuthPrompt },
|
||||
])("rejects a first $label prompt before credential persistence", async ({ prompt }) => {
|
||||
const { auth, runtime, credentials, changes } = await createAuthService();
|
||||
const login = mockLoginPromptsBeforePersistence(runtime, credentials, [prompt]);
|
||||
|
||||
await expect(auth.saveApiKey("anthropic", "sk-test")).rejects.toThrow(
|
||||
"Anthropic requires interactive setup; use Pi's generic /login flow",
|
||||
);
|
||||
|
||||
expect(login).toHaveBeenCalledOnce();
|
||||
await expect(credentials.read("anthropic")).resolves.toBeUndefined();
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("rejects a repeated secret prompt before credential persistence", async () => {
|
||||
const { auth, runtime, credentials, changes } = await createAuthService();
|
||||
const login = mockLoginPromptsBeforePersistence(runtime, credentials, [
|
||||
{ type: "secret", message: "API key" },
|
||||
{ type: "secret", message: "API key again" },
|
||||
]);
|
||||
|
||||
await expect(auth.saveApiKey("anthropic", "sk-test")).rejects.toThrow(
|
||||
"Anthropic requires interactive setup; use Pi's generic /login flow",
|
||||
);
|
||||
|
||||
expect(login).toHaveBeenCalledOnce();
|
||||
await expect(credentials.read("anthropic")).resolves.toBeUndefined();
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("rejects an aborted secret prompt before credential persistence", async () => {
|
||||
const { auth, runtime, credentials, changes } = await createAuthService();
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
const login = mockLoginPromptsBeforePersistence(runtime, credentials, [
|
||||
{ type: "secret", message: "API key", signal: abort.signal },
|
||||
]);
|
||||
|
||||
await expect(auth.saveApiKey("anthropic", "sk-test")).rejects.toThrow("Login cancelled");
|
||||
|
||||
expect(login).toHaveBeenCalledOnce();
|
||||
await expect(credentials.read("anthropic")).resolves.toBeUndefined();
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("rejects unknown providers before starting API-key login", async () => {
|
||||
const { auth, runtime, credentials, changes } = await createAuthService();
|
||||
const login = vi.spyOn(runtime, "login");
|
||||
|
||||
await expect(auth.saveApiKey("unknown-provider", "sk-test")).rejects.toThrow(
|
||||
"API key provider not found: unknown-provider",
|
||||
);
|
||||
|
||||
expect(login).not.toHaveBeenCalled();
|
||||
await expect(credentials.read("unknown-provider")).resolves.toBeUndefined();
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("rejects ambient-only providers before starting API-key login", async () => {
|
||||
const { auth, runtime, credentials, changes } = await createAuthService();
|
||||
const providers = [...runtime.getProviders()];
|
||||
const interactiveProvider = providers.find((provider) => provider.auth.apiKey?.login !== undefined);
|
||||
if (interactiveProvider?.auth.apiKey === undefined) throw new Error("Expected an interactive API-key provider");
|
||||
const ambientApiKey = { ...interactiveProvider.auth.apiKey };
|
||||
delete ambientApiKey.login;
|
||||
const ambientProvider = {
|
||||
...interactiveProvider,
|
||||
id: "ambient-only",
|
||||
name: "Ambient Only",
|
||||
auth: { apiKey: ambientApiKey },
|
||||
};
|
||||
vi.spyOn(runtime, "getProviders").mockReturnValue([...providers, ambientProvider]);
|
||||
const login = vi.spyOn(runtime, "login");
|
||||
|
||||
await expect(auth.saveApiKey("ambient-only", "sk-test")).rejects.toThrow(
|
||||
"Ambient Only does not support interactive API-key setup",
|
||||
);
|
||||
|
||||
expect(login).not.toHaveBeenCalled();
|
||||
await expect(credentials.read("ambient-only")).resolves.toBeUndefined();
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("reloads models.json before enumerating and validating OAuth providers", async () => {
|
||||
const agentDir = await tempAgentDir();
|
||||
const modelsPath = join(agentDir, "models.json");
|
||||
const runtime = await ModelRuntime.create({
|
||||
credentials: new InMemoryCredentialStore(),
|
||||
modelsPath,
|
||||
allowModelNetwork: false,
|
||||
});
|
||||
const authFlows = new CapturingOAuthLoginFlowService();
|
||||
const auth = await AuthService.create({ runtime, authFlows });
|
||||
|
||||
await writeFile(modelsPath, radiusModelsConfig("First Radius"));
|
||||
const response = await auth.authProviders("login", "oauth");
|
||||
expect(response.providers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "test-radius", name: "First Radius", authType: "oauth" }),
|
||||
]));
|
||||
|
||||
await writeFile(modelsPath, radiusModelsConfig("Updated Radius"));
|
||||
await expect(auth.startOAuthLogin("test-radius")).resolves.toMatchObject({
|
||||
providerId: "test-radius",
|
||||
providerName: "Updated Radius",
|
||||
status: "running",
|
||||
});
|
||||
expect(authFlows.startCalls.at(0)).toMatchObject({
|
||||
providerId: "test-radius",
|
||||
providerName: "Updated Radius",
|
||||
runtime,
|
||||
});
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("stores credentials in the configured agent directory", async () => {
|
||||
const agentDir = await tempAgentDir();
|
||||
const auth = new AuthService({ agentDir });
|
||||
const runtime = await createModelRuntimeForAgentDir(agentDir, false);
|
||||
const auth = await AuthService.create({ runtime });
|
||||
|
||||
auth.saveApiKey("anthropic", "sk-test");
|
||||
await auth.saveApiKey("anthropic", "sk-test");
|
||||
|
||||
await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-test");
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("refreshes auth state after OAuth login completes", () => {
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
const modelRegistry = ModelRegistry.create(authStorage);
|
||||
it("reconciles cancellation after ModelRuntime persists OAuth but before its refresh completes", async () => {
|
||||
const { auth, runtime, credentials, changes } = await createAuthService();
|
||||
const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined);
|
||||
if (provider?.auth.oauth === undefined) throw new Error("Expected built-in OAuth provider");
|
||||
const credential: Credential = {
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
access: "access-token",
|
||||
expires: Date.now() + 60_000,
|
||||
};
|
||||
vi.spyOn(provider.auth.oauth, "login").mockResolvedValue(credential);
|
||||
vi.spyOn(runtime, "reloadConfig").mockResolvedValue(undefined);
|
||||
const refreshStarted = deferred<undefined>();
|
||||
const finishRefresh = deferred<undefined>();
|
||||
const refresh = vi.spyOn(runtime, "refresh").mockImplementation(async () => {
|
||||
refreshStarted.resolve(undefined);
|
||||
await finishRefresh.promise;
|
||||
return { aborted: false, errors: new Map() };
|
||||
});
|
||||
|
||||
const state = await auth.startOAuthLogin(provider.id);
|
||||
await refreshStarted.promise;
|
||||
|
||||
await expect(credentials.read(provider.id)).resolves.toEqual(credential);
|
||||
expect(auth.cancelOAuthFlow(state.flowId)).toMatchObject({ status: "cancelled", error: "Login cancelled" });
|
||||
expect(changes).toEqual([]);
|
||||
|
||||
finishRefresh.resolve(undefined);
|
||||
await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
|
||||
|
||||
expect(auth.oauthFlow(state.flowId)).toMatchObject({ status: "complete", progress: ["Login complete"] });
|
||||
expect(auth.oauthFlow(state.flowId)).not.toHaveProperty("error");
|
||||
await expect(credentials.read(provider.id)).resolves.toEqual(credential);
|
||||
expect(changes).toEqual([{}]);
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("emits an auth change after OAuth login completes without refreshing twice", async () => {
|
||||
const runtime = await ModelRuntime.create({
|
||||
credentials: new InMemoryCredentialStore(),
|
||||
modelsPath: null,
|
||||
allowModelNetwork: false,
|
||||
});
|
||||
const authFlows = new CapturingOAuthLoginFlowService();
|
||||
const auth = new AuthService({ modelRegistry, authFlows });
|
||||
const auth = await AuthService.create({ runtime, authFlows });
|
||||
const changes: AuthChange[] = [];
|
||||
auth.subscribe((change) => { changes.push(change); });
|
||||
const reload = vi.spyOn(authStorage, "reload");
|
||||
const refresh = vi.spyOn(modelRegistry, "refresh");
|
||||
const provider = authStorage.getOAuthProviders().find((option) => option.id === "anthropic");
|
||||
const refresh = vi.spyOn(runtime, "refresh");
|
||||
const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined);
|
||||
if (provider === undefined) throw new Error("Expected built-in OAuth provider");
|
||||
|
||||
expect(auth.startOAuthLogin(provider.id)).toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" });
|
||||
await expect(auth.startOAuthLogin(provider.id)).resolves.toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" });
|
||||
|
||||
const startOptions = authFlows.startCalls.at(0);
|
||||
if (startOptions === undefined) throw new Error("Expected OAuth flow to start");
|
||||
expect(startOptions.providerId).toBe(provider.id);
|
||||
expect(startOptions.providerName).toBe(provider.name);
|
||||
expect(startOptions.authStorage).toBe(authStorage);
|
||||
expect(startOptions.runtime).toBe(runtime);
|
||||
expect(changes).toEqual([]);
|
||||
|
||||
reload.mockClear();
|
||||
refresh.mockClear();
|
||||
if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback");
|
||||
startOptions.onComplete();
|
||||
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
await startOptions.onComplete();
|
||||
expect(changes).toEqual([{}]);
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
auth.dispose();
|
||||
expect(authFlows.disposed).toBe(true);
|
||||
});
|
||||
|
||||
it("completes OAuth when an auth-change listener and failure logging throw", async () => {
|
||||
const loggingFailure = new Error("auth logger failed");
|
||||
const error = vi.fn(() => { throw loggingFailure; });
|
||||
const logger: AuthServiceLogger = { error };
|
||||
const { auth, runtime, changes } = await createAuthService({}, logger);
|
||||
const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined);
|
||||
if (provider === undefined) throw new Error("Expected built-in OAuth provider");
|
||||
vi.spyOn(runtime, "login").mockResolvedValue({
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
access: "access-token",
|
||||
expires: Date.now() + 60_000,
|
||||
});
|
||||
const failure = new Error("session OAuth refresh failed");
|
||||
auth.subscribe(() => Promise.reject(failure));
|
||||
|
||||
const state = await auth.startOAuthLogin(provider.id);
|
||||
await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
|
||||
|
||||
expect(changes).toEqual([{}]);
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
{ err: failure, operation: "login", providerId: provider.id, authType: "oauth" },
|
||||
"auth-change listener failed",
|
||||
);
|
||||
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 });
|
||||
async function createAuthService(seed: Record<string, Credential> = {}, logger?: AuthServiceLogger) {
|
||||
const credentials = new InMemoryCredentialStore();
|
||||
for (const [providerId, credential] of Object.entries(seed)) {
|
||||
await credentials.modify(providerId, () => Promise.resolve(credential));
|
||||
}
|
||||
const runtime = await ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false });
|
||||
const auth = await AuthService.create({ runtime, ...(logger === undefined ? {} : { logger }) });
|
||||
const changes: AuthChange[] = [];
|
||||
auth.subscribe((change) => { changes.push(change); });
|
||||
return { auth, authStorage, changes };
|
||||
return { auth, runtime, credentials, changes };
|
||||
}
|
||||
|
||||
async function createFileBackedAuthService(seed: Record<string, Credential>) {
|
||||
const agentDir = await tempAgentDir();
|
||||
const authPath = join(agentDir, "auth.json");
|
||||
await writeFile(authPath, JSON.stringify(seed, null, 2));
|
||||
const runtime = await createModelRuntimeForAgentDir(agentDir, false);
|
||||
const auth = await AuthService.create({ runtime });
|
||||
const changes: AuthChange[] = [];
|
||||
auth.subscribe((change) => { changes.push(change); });
|
||||
return { auth, runtime, authPath, changes };
|
||||
}
|
||||
|
||||
function mockLoginPromptsBeforePersistence(
|
||||
runtime: ModelRuntime,
|
||||
credentials: InMemoryCredentialStore,
|
||||
prompts: readonly AuthPrompt[],
|
||||
) {
|
||||
return vi.spyOn(runtime, "login").mockImplementation(async (providerId, _authType, interaction) => {
|
||||
let key: string | undefined;
|
||||
for (const prompt of prompts) key = await interaction.prompt(prompt);
|
||||
if (key === undefined) throw new Error("Expected at least one login prompt");
|
||||
const credential: Credential = { type: "api_key", key };
|
||||
await credentials.modify(providerId, () => Promise.resolve(credential));
|
||||
return credential;
|
||||
});
|
||||
}
|
||||
|
||||
async function tempAgentDir(): Promise<string> {
|
||||
@@ -101,6 +508,28 @@ async function tempAgentDir(): Promise<string> {
|
||||
return dir;
|
||||
}
|
||||
|
||||
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 radiusModelsConfig(name: string): string {
|
||||
return JSON.stringify({
|
||||
providers: {
|
||||
"test-radius": {
|
||||
name,
|
||||
baseUrl: "https://radius.example.test/v1",
|
||||
oauth: "radius",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
class CapturingOAuthLoginFlowService extends OAuthLoginFlowService {
|
||||
readonly startCalls: Parameters<OAuthLoginFlowService["start"]>[0][] = [];
|
||||
disposed = false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { join } from "node:path";
|
||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import type { AuthInteraction } from "@earendil-works/pi-ai";
|
||||
import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js";
|
||||
import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js";
|
||||
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
||||
@@ -8,28 +9,53 @@ export interface AuthChange {
|
||||
removedProviderId?: string;
|
||||
}
|
||||
|
||||
type AuthChangeListener = (change: AuthChange) => void;
|
||||
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
||||
type AuthChangeListener = (change: AuthChange) => void | Promise<void>;
|
||||
|
||||
export interface AuthServiceDependencies {
|
||||
agentDir?: string;
|
||||
modelRegistry?: ModelRegistryInstance;
|
||||
runtime?: ModelRuntime;
|
||||
authFlows?: OAuthLoginFlowService;
|
||||
logger?: AuthServiceLogger;
|
||||
}
|
||||
|
||||
export function createModelRegistryForAgentDir(agentDir: string): ModelRegistryInstance {
|
||||
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
|
||||
return ModelRegistry.create(authStorage, join(agentDir, "models.json"));
|
||||
/** Minimal structured-logging seam for non-fatal auth propagation failures. */
|
||||
export interface AuthServiceLogger {
|
||||
error(details: Record<string, unknown>, message: string): void;
|
||||
}
|
||||
|
||||
interface AuthChangeContext {
|
||||
operation: "login" | "logout";
|
||||
providerId: string;
|
||||
authType?: AuthType;
|
||||
}
|
||||
|
||||
const noopLogger: AuthServiceLogger = { error() { /* no-op */ } };
|
||||
|
||||
export function createModelRuntimeForAgentDir(agentDir: string, allowModelNetwork?: boolean): Promise<ModelRuntime> {
|
||||
return ModelRuntime.create({
|
||||
authPath: join(agentDir, "auth.json"),
|
||||
modelsPath: join(agentDir, "models.json"),
|
||||
...(allowModelNetwork === undefined ? {} : { allowModelNetwork }),
|
||||
});
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
readonly modelRegistry: ModelRegistryInstance;
|
||||
readonly runtime: ModelRuntime;
|
||||
private readonly authFlows: OAuthLoginFlowService;
|
||||
private readonly logger: AuthServiceLogger;
|
||||
private readonly listeners = new Set<AuthChangeListener>();
|
||||
|
||||
constructor(deps: AuthServiceDependencies = {}) {
|
||||
this.modelRegistry = deps.modelRegistry ?? (deps.agentDir === undefined ? ModelRegistry.create(AuthStorage.create()) : createModelRegistryForAgentDir(deps.agentDir));
|
||||
this.authFlows = deps.authFlows ?? new OAuthLoginFlowService();
|
||||
private constructor(runtime: ModelRuntime, authFlows: OAuthLoginFlowService, logger: AuthServiceLogger) {
|
||||
this.runtime = runtime;
|
||||
this.authFlows = authFlows;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
static async create(deps: AuthServiceDependencies = {}): Promise<AuthService> {
|
||||
const runtime = deps.runtime ?? (deps.agentDir === undefined ? await ModelRuntime.create({}) : await createModelRuntimeForAgentDir(deps.agentDir));
|
||||
const logger = deps.logger ?? noopLogger;
|
||||
const authFlows = deps.authFlows ?? new OAuthLoginFlowService({ logger });
|
||||
return new AuthService(runtime, authFlows, logger);
|
||||
}
|
||||
|
||||
subscribe(listener: AuthChangeListener): () => void {
|
||||
@@ -44,34 +70,60 @@ export class AuthService {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
authProviders(mode: "login" | "logout", authType?: AuthType): AuthProvidersResponse {
|
||||
this.modelRegistry.refresh();
|
||||
const providers = mode === "logout" ? getLogoutProviderOptions(this.modelRegistry) : getLoginProviderOptions(this.modelRegistry, authType);
|
||||
async authProviders(mode: "login" | "logout", authType?: AuthType): Promise<AuthProvidersResponse> {
|
||||
await this.runtime.reloadConfig();
|
||||
const providers = mode === "logout" ? await getLogoutProviderOptions(this.runtime) : getLoginProviderOptions(this.runtime, authType);
|
||||
return { providers };
|
||||
}
|
||||
|
||||
saveApiKey(providerId: string, key: string): { accepted: true } {
|
||||
async saveApiKey(providerId: string, key: string): Promise<{ accepted: true }> {
|
||||
if (key.trim() === "") throw new Error("API key is required");
|
||||
this.modelRegistry.authStorage.set(providerId, { type: "api_key", key });
|
||||
this.refreshAuthState();
|
||||
const provider = await this.requireApiKeyLoginProvider(providerId);
|
||||
let promptAttempted = false;
|
||||
const interaction: AuthInteraction = {
|
||||
prompt: (prompt) => {
|
||||
if (promptAttempted) {
|
||||
throw new Error(`${provider.name} requires interactive setup; use Pi's generic /login flow`);
|
||||
}
|
||||
promptAttempted = true;
|
||||
if (prompt.signal?.aborted === true) throw new Error("Login cancelled");
|
||||
if (prompt.type !== "secret") {
|
||||
throw new Error(`${provider.name} requires interactive setup; use Pi's generic /login flow`);
|
||||
}
|
||||
return Promise.resolve(key);
|
||||
},
|
||||
notify: () => undefined,
|
||||
};
|
||||
await this.runtime.login(providerId, "api_key", interaction);
|
||||
await this.emit({}, { operation: "login", providerId, authType: "api_key" });
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
logoutProvider(providerId: string): { accepted: true } {
|
||||
this.modelRegistry.authStorage.logout(providerId);
|
||||
this.refreshAuthState({ removedProviderId: providerId });
|
||||
async logoutProvider(providerId: string): Promise<{ accepted: true }> {
|
||||
await this.runtime.logout(providerId);
|
||||
await this.emit({ removedProviderId: providerId }, { operation: "logout", providerId });
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
startOAuthLogin(providerId: string): OAuthFlowState {
|
||||
const provider = this.requireOAuthLoginProvider(providerId);
|
||||
async startApiKeyLogin(providerId: string): Promise<OAuthFlowState> {
|
||||
const provider = await this.requireApiKeyLoginProvider(providerId);
|
||||
return this.authFlows.start({
|
||||
providerId,
|
||||
providerName: provider.name,
|
||||
authStorage: this.modelRegistry.authStorage,
|
||||
onComplete: () => {
|
||||
this.refreshAuthState();
|
||||
},
|
||||
runtime: this.runtime,
|
||||
authType: "api_key",
|
||||
onComplete: () => this.emit({}, { operation: "login", providerId, authType: "api_key" }),
|
||||
});
|
||||
}
|
||||
|
||||
async startOAuthLogin(providerId: string): Promise<OAuthFlowState> {
|
||||
const provider = await this.requireOAuthLoginProvider(providerId);
|
||||
return this.authFlows.start({
|
||||
providerId,
|
||||
providerName: provider.name,
|
||||
runtime: this.runtime,
|
||||
authType: "oauth",
|
||||
onComplete: () => this.emit({}, { operation: "login", providerId, authType: "oauth" }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,19 +139,38 @@ export class AuthService {
|
||||
return this.authFlows.cancel(flowId);
|
||||
}
|
||||
|
||||
private refreshAuthState(change: AuthChange = {}): void {
|
||||
this.modelRegistry.authStorage.reload();
|
||||
this.modelRegistry.refresh();
|
||||
this.emit(change);
|
||||
private async emit(change: AuthChange, context: AuthChangeContext): Promise<void> {
|
||||
const results = await Promise.allSettled([...this.listeners].map(async (listener) => listener(change)));
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
this.logErrorNoThrow({ err: result.reason, ...context }, "auth-change listener failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emit(change: AuthChange): void {
|
||||
for (const listener of this.listeners) listener(change);
|
||||
private logErrorNoThrow(details: Record<string, unknown>, message: string): void {
|
||||
try {
|
||||
this.logger.error(details, message);
|
||||
} catch {
|
||||
// A diagnostic failure cannot turn an already-committed auth mutation into an API failure.
|
||||
}
|
||||
}
|
||||
|
||||
private requireOAuthLoginProvider(providerId: string) {
|
||||
this.modelRegistry.refresh();
|
||||
const provider = getLoginProviderOptions(this.modelRegistry, "oauth").find((option) => option.id === providerId);
|
||||
private async requireApiKeyLoginProvider(providerId: string) {
|
||||
await this.runtime.reloadConfig();
|
||||
const provider = getLoginProviderOptions(this.runtime, "api_key").find((option) => option.id === providerId);
|
||||
if (provider !== undefined) return provider;
|
||||
|
||||
const knownProvider = this.runtime.getProviders().find((option) => option.id === providerId);
|
||||
if (knownProvider !== undefined) {
|
||||
throw new Error(`${knownProvider.name} does not support interactive API-key setup`);
|
||||
}
|
||||
throw new Error(`API key provider not found: ${providerId}`);
|
||||
}
|
||||
|
||||
private async requireOAuthLoginProvider(providerId: string) {
|
||||
await this.runtime.reloadConfig();
|
||||
const provider = getLoginProviderOptions(this.runtime, "oauth").find((option) => option.id === providerId);
|
||||
if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`);
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { OAuthLoginCallbacks } from "@earendil-works/pi-ai";
|
||||
import type { AuthStorage } from "@earendil-works/pi-coding-agent";
|
||||
import type { AuthInteraction, AuthType } from "@earendil-works/pi-ai";
|
||||
import type { ModelRuntime } 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>;
|
||||
type LoginHandler = (providerId: string, interaction: AuthInteraction) => Promise<void>;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -17,19 +17,19 @@ describe("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}`);
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
interaction.notify({ type: "auth_url", url: "https://example.test/auth", instructions: "Open it" });
|
||||
interaction.notify({ type: "progress", message: "Waiting for code" });
|
||||
promptValue = await interaction.prompt({ type: "text", message: "Paste code", placeholder: "code" });
|
||||
interaction.notify({ type: "progress", message: `Got ${promptValue}` });
|
||||
}),
|
||||
onComplete,
|
||||
});
|
||||
|
||||
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" });
|
||||
expect(state).toMatchObject({ auth: { url: "https://example.test/auth", instructions: "Open it" }, progress: ["Waiting for code"] });
|
||||
expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt", promptType: "text", allowEmpty: true });
|
||||
|
||||
const afterRespond = service.respond(state.flowId, prompt.requestId, "abc123");
|
||||
expect(afterRespond.prompt).toBeUndefined();
|
||||
@@ -41,23 +41,191 @@ describe("OAuthLoginFlowService", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("runs API-key login through the same AuthInteraction transport", async () => {
|
||||
const authTypes: AuthType[] = [];
|
||||
let key: string | undefined;
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
key = await interaction.prompt({ type: "secret", message: "Enter API key" });
|
||||
}, authTypes),
|
||||
authType: "api_key",
|
||||
});
|
||||
|
||||
const prompt = state.prompt;
|
||||
if (prompt === undefined) throw new Error("Expected API-key prompt");
|
||||
service.respond(state.flowId, prompt.requestId, "sk-test");
|
||||
await flushAsyncLogin();
|
||||
|
||||
expect(authTypes).toEqual(["api_key"]);
|
||||
expect(key).toBe("sk-test");
|
||||
expect(service.get(state.flowId).status).toBe("complete");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("awaits async completion propagation before marking the flow complete", async () => {
|
||||
const completion = deferred<undefined>();
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
runtime: fakeRuntime(() => Promise.resolve()),
|
||||
onComplete: () => completion.promise,
|
||||
});
|
||||
|
||||
await flushAsyncLogin();
|
||||
expect(service.get(state.flowId).status).toBe("running");
|
||||
|
||||
completion.resolve(undefined);
|
||||
await flushAsyncLogin();
|
||||
expect(service.get(state.flowId).status).toBe("complete");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("keeps a committed login complete when its completion callback and logger throw", async () => {
|
||||
const completionFailure = new Error("completion propagation failed");
|
||||
const loggingFailure = new Error("OAuth logger failed");
|
||||
const error = vi.fn(() => { throw loggingFailure; });
|
||||
const onComplete = vi.fn(() => { throw completionFailure; });
|
||||
const service = new OAuthLoginFlowService({ logger: { error } });
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
runtime: fakeRuntime(() => Promise.resolve()),
|
||||
onComplete,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => { expect(service.get(state.flowId).status).toBe("complete"); });
|
||||
|
||||
expect(service.get(state.flowId)).toMatchObject({ status: "complete", progress: ["Login complete"] });
|
||||
expect(onComplete).toHaveBeenCalledOnce();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
{ err: completionFailure, flowId: state.flowId, providerId: "test-provider" },
|
||||
"login completion callback failed",
|
||||
);
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("allows blank text responses for providers that use blank as a default", async () => {
|
||||
let domain: string | undefined;
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "github-copilot",
|
||||
providerName: "GitHub Copilot",
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
domain = await interaction.prompt({
|
||||
type: "text",
|
||||
message: "GitHub Enterprise URL/domain (blank for github.com)",
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
const prompt = state.prompt;
|
||||
if (prompt === undefined) throw new Error("Expected text prompt");
|
||||
expect(prompt).toMatchObject({ kind: "prompt", promptType: "text", allowEmpty: true });
|
||||
|
||||
service.respond(state.flowId, prompt.requestId, "");
|
||||
await flushAsyncLogin();
|
||||
|
||||
expect(domain).toBe("");
|
||||
expect(service.get(state.flowId).status).toBe("complete");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("preserves secret prompt semantics behind the legacy prompt kind", () => {
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
await interaction.prompt({ type: "secret", message: "Enter secret", placeholder: "token" });
|
||||
}),
|
||||
});
|
||||
|
||||
const prompt = state.prompt;
|
||||
if (prompt === undefined) throw new Error("Expected secret prompt");
|
||||
expect(prompt).toMatchObject({
|
||||
kind: "prompt",
|
||||
promptType: "secret",
|
||||
message: "Enter secret",
|
||||
placeholder: "token",
|
||||
});
|
||||
expect(prompt).not.toHaveProperty("allowEmpty");
|
||||
expect(() => { service.respond(state.flowId, prompt.requestId, ""); }).toThrow("A value is required");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("preserves info-event links without replacing the authorization URL", () => {
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
interaction.notify({ type: "auth_url", url: "https://example.test/login" });
|
||||
interaction.notify({
|
||||
type: "info",
|
||||
message: "Review the provider setup guide",
|
||||
links: [{ url: "https://example.test/docs", label: "Setup guide" }],
|
||||
});
|
||||
await interaction.prompt({ type: "text", message: "Continue" });
|
||||
}),
|
||||
});
|
||||
|
||||
expect(state).toMatchObject({
|
||||
auth: { url: "https://example.test/login" },
|
||||
progress: ["Review the provider setup guide"],
|
||||
info: [{ message: "Review the provider setup guide", links: [{ url: "https://example.test/docs", label: "Setup guide" }] }],
|
||||
});
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("surfaces device-code events through the auth field", () => {
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
interaction.notify({
|
||||
type: "device_code",
|
||||
userCode: "WXYZ-1234",
|
||||
verificationUri: "https://example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900,
|
||||
});
|
||||
await interaction.prompt({ type: "text", message: "Waiting" });
|
||||
}),
|
||||
});
|
||||
|
||||
expect(service.get(state.flowId)).toMatchObject({
|
||||
auth: {
|
||||
url: "https://example.test/device",
|
||||
instructions: "Enter code: WXYZ-1234",
|
||||
deviceCode: { userCode: "WXYZ-1234", intervalSeconds: 5, expiresInSeconds: 900 },
|
||||
},
|
||||
});
|
||||
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) => {
|
||||
selectedValue = await callbacks.onSelect({
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
selectedValue = await interaction.prompt({
|
||||
type: "select",
|
||||
message: "Choose account",
|
||||
options: [{ id: "work", label: "Work" }, { id: "personal", label: "Personal" }],
|
||||
options: [{ id: "work", label: "Work", description: "Company account" }, { 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" }] });
|
||||
expect(select).toMatchObject({ message: "Choose account", options: [{ value: "work", label: "Work", description: "Company account" }, { value: "personal", label: "Personal" }] });
|
||||
|
||||
service.respond(state.flowId, select.requestId, "personal");
|
||||
await flushAsyncLogin();
|
||||
@@ -67,40 +235,97 @@ describe("OAuthLoginFlowService", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("uses a manual-code prompt for callback-server flows", async () => {
|
||||
let manualValue: string | undefined;
|
||||
it("rejects responses outside the pending select options", () => {
|
||||
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();
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
await interaction.prompt({
|
||||
type: "select",
|
||||
message: "Choose account",
|
||||
options: [{ id: "work", label: "Work" }],
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
const select = state.select;
|
||||
if (select === undefined) throw new Error("Expected select prompt");
|
||||
expect(() => { service.respond(state.flowId, select.requestId, "personal"); }).toThrow("Invalid login selection");
|
||||
expect(service.get(state.flowId).select).toEqual(select);
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("uses a manual-code prompt for callback-server flows and cleans up its abort listener", async () => {
|
||||
let manualValue: string | undefined;
|
||||
const service = new OAuthLoginFlowService();
|
||||
const controller = new AbortController();
|
||||
const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener");
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
manualValue = await interaction.prompt({
|
||||
type: "manual_code",
|
||||
message: "Paste the callback URL or authorization code",
|
||||
signal: controller.signal,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
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" });
|
||||
expect(prompt).toMatchObject({ kind: "manual", promptType: "manual_code", 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(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function));
|
||||
expect(service.get(state.flowId).status).toBe("complete");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("rejects a pending prompt when its own signal aborts without ending the flow", async () => {
|
||||
const promptRejected = deferred<Error>();
|
||||
const service = new OAuthLoginFlowService();
|
||||
const controller = new AbortController();
|
||||
const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener");
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
try {
|
||||
await interaction.prompt({ type: "manual_code", message: "Paste code", signal: controller.signal });
|
||||
} catch (error) {
|
||||
promptRejected.resolve(toError(error));
|
||||
}
|
||||
// The flow keeps running (e.g. the callback server resolves it) until we
|
||||
// resolve the follow-up prompt below.
|
||||
await interaction.prompt({ type: "text", message: "Waiting for callback" });
|
||||
}),
|
||||
});
|
||||
|
||||
expect(state.prompt).toMatchObject({ kind: "manual" });
|
||||
controller.abort();
|
||||
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Prompt cancelled" });
|
||||
expect(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function));
|
||||
|
||||
const afterAbort = service.get(state.flowId);
|
||||
expect(afterAbort.status).toBe("running");
|
||||
expect(afterAbort.prompt).toMatchObject({ kind: "prompt", message: "Waiting for callback" });
|
||||
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) => {
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
try {
|
||||
await callbacks.onPrompt({ message: "Paste code" });
|
||||
await interaction.prompt({ type: "text", message: "Paste code" });
|
||||
} catch (error) {
|
||||
promptRejected.resolve(toError(error));
|
||||
throw error;
|
||||
@@ -122,9 +347,9 @@ describe("OAuthLoginFlowService", () => {
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
try {
|
||||
await callbacks.onPrompt({ message: "Paste code" });
|
||||
await interaction.prompt({ type: "text", message: "Paste code" });
|
||||
} catch (error) {
|
||||
promptRejected.resolve(toError(error));
|
||||
throw error;
|
||||
@@ -137,7 +362,7 @@ describe("OAuthLoginFlowService", () => {
|
||||
service.dispose();
|
||||
|
||||
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" });
|
||||
expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found");
|
||||
expect(() => { service.get(state.flowId); }).toThrow("Login flow not found");
|
||||
});
|
||||
|
||||
it("rejects stale or duplicate responses", () => {
|
||||
@@ -145,8 +370,8 @@ describe("OAuthLoginFlowService", () => {
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
||||
await callbacks.onPrompt({ message: "Paste code" });
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
await interaction.prompt({ type: "text", message: "Paste code" });
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -154,7 +379,7 @@ describe("OAuthLoginFlowService", () => {
|
||||
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");
|
||||
expect(() => { service.respond(state.flowId, prompt.requestId, "abc123"); }).toThrow("Login request expired");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
@@ -165,9 +390,9 @@ describe("OAuthLoginFlowService", () => {
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
try {
|
||||
await callbacks.onPrompt({ message: "Paste code" });
|
||||
await interaction.prompt({ type: "text", message: "Paste code" });
|
||||
} catch (error) {
|
||||
promptRejected.resolve(toError(error));
|
||||
throw error;
|
||||
@@ -177,18 +402,25 @@ describe("OAuthLoginFlowService", () => {
|
||||
|
||||
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" });
|
||||
expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "Login flow expired" });
|
||||
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login flow expired" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found");
|
||||
expect(() => { service.get(state.flowId); }).toThrow("Login flow not found");
|
||||
service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
function fakeAuthStorage(login: LoginHandler): Pick<AuthStorage, "login"> {
|
||||
return { login };
|
||||
function fakeRuntime(login: LoginHandler, authTypes?: AuthType[]): Pick<ModelRuntime, "login"> {
|
||||
return {
|
||||
login: (providerId, type, interaction) => {
|
||||
authTypes?.push(type);
|
||||
return login(providerId, interaction).then(() => type === "api_key"
|
||||
? { type: "api_key", key: "test" }
|
||||
: { type: "oauth", refresh: "r", access: "a", expires: 0 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushAsyncLogin(): Promise<void> {
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
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 { AuthEvent, AuthInteraction, AuthPrompt, AuthType } from "@earendil-works/pi-ai";
|
||||
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import type { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js";
|
||||
|
||||
type OAuthLoginStorage = Pick<AuthStorage, "login">;
|
||||
/** The single runtime capability this service drives — narrowed for testable DI. */
|
||||
type OAuthLoginRuntime = Pick<ModelRuntime, "login">;
|
||||
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||
type SelectPrompt = Extract<AuthPrompt, { type: "select" }>;
|
||||
type ValuePrompt = Exclude<AuthPrompt, { type: "select" }>;
|
||||
|
||||
interface PendingOAuthRequest {
|
||||
requestId: string;
|
||||
allowEmpty: boolean;
|
||||
resolve: (value: string | undefined) => void;
|
||||
resolve: (value: string) => void;
|
||||
reject: (error: Error) => void;
|
||||
allowedValues?: ReadonlySet<string>;
|
||||
cleanup?: () => void;
|
||||
}
|
||||
|
||||
interface OAuthFlowRecord {
|
||||
@@ -22,32 +27,46 @@ interface OAuthFlowRecord {
|
||||
cleanupTimer?: TimerHandle;
|
||||
}
|
||||
|
||||
export interface OAuthLoginFlowLogger {
|
||||
error(details: Record<string, unknown>, message: string): void;
|
||||
}
|
||||
|
||||
export interface OAuthLoginFlowServiceOptions {
|
||||
terminalTtlMs?: number;
|
||||
runningTtlMs?: number;
|
||||
now?: () => number;
|
||||
logger?: OAuthLoginFlowLogger;
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINAL_TTL_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_RUNNING_TTL_MS = 30 * 60 * 1000;
|
||||
const noopLogger: OAuthLoginFlowLogger = { error() { /* no-op */ } };
|
||||
|
||||
/**
|
||||
* AuthInteraction transport shared by OAuth and provider-driven API-key login.
|
||||
* The historical class and wire names remain for rolling browser/sessiond compatibility.
|
||||
*/
|
||||
export class OAuthLoginFlowService {
|
||||
private readonly flows = new Map<string, OAuthFlowRecord>();
|
||||
private readonly terminalTtlMs: number;
|
||||
private readonly runningTtlMs: number;
|
||||
private readonly now: () => number;
|
||||
private readonly logger: OAuthLoginFlowLogger;
|
||||
|
||||
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());
|
||||
this.logger = options.logger ?? noopLogger;
|
||||
}
|
||||
|
||||
start(options: {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
authStorage: OAuthLoginStorage;
|
||||
onComplete?: () => void;
|
||||
runtime: OAuthLoginRuntime;
|
||||
/** Defaults to OAuth so established callers retain their existing behavior. */
|
||||
authType?: AuthType;
|
||||
onComplete?: () => void | Promise<void>;
|
||||
}): OAuthFlowState {
|
||||
const flowId = crypto.randomUUID();
|
||||
const abort = new AbortController();
|
||||
@@ -66,58 +85,43 @@ export class OAuthLoginFlowService {
|
||||
this.flows.set(flowId, record);
|
||||
this.scheduleRunningExpiry(record);
|
||||
|
||||
const callbacks: OAuthLoginCallbacks = {
|
||||
// Adapt the pi-ai AuthInteraction contract onto the web-UI flow state:
|
||||
// `prompt()` returns the entered/selected string; `notify()` surfaces
|
||||
// out-of-band login events (auth URL, device code, progress).
|
||||
const interaction: AuthInteraction = {
|
||||
signal: abort.signal,
|
||||
onAuth: (info) => {
|
||||
if (!this.isCurrentRunning(record)) return;
|
||||
this.updateState(record, { ...record.state, auth: info });
|
||||
},
|
||||
// Device-code flows have no redirect URL; reuse the auth field so the web UI
|
||||
// shows the verification link and user code without a dedicated API shape.
|
||||
onDeviceCode: (info) => {
|
||||
if (!this.isCurrentRunning(record)) return;
|
||||
this.updateState(record, { ...record.state, auth: { url: info.verificationUri, instructions: `Enter code: ${info.userCode}` } });
|
||||
},
|
||||
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] });
|
||||
},
|
||||
prompt: (prompt) => this.handlePrompt(record, prompt),
|
||||
notify: (event) => { this.handleEvent(record, event); },
|
||||
};
|
||||
|
||||
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;
|
||||
void options.runtime.login(options.providerId, options.authType ?? "oauth", interaction).then(
|
||||
() => this.reconcileCommittedLogin(record, options.onComplete),
|
||||
(error: unknown) => {
|
||||
if (!this.isCurrent(record)) return;
|
||||
this.clearPending(record);
|
||||
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");
|
||||
if (record === undefined) throw new Error("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 === undefined) throw new Error("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?.requestId !== requestId) throw new Error("Login request expired");
|
||||
if (!pending.allowEmpty && value.trim() === "") throw new Error("A value is required");
|
||||
record.pending = undefined;
|
||||
if (pending.allowedValues !== undefined && !pending.allowedValues.has(value)) throw new Error("Invalid login selection");
|
||||
this.clearPending(record);
|
||||
this.updateState(record, withoutInteraction(record.state));
|
||||
pending.resolve(value);
|
||||
return cloneState(record.state);
|
||||
@@ -125,11 +129,10 @@ export class OAuthLoginFlowService {
|
||||
|
||||
cancel(flowId: string): OAuthFlowState {
|
||||
const record = this.flows.get(flowId);
|
||||
if (record === undefined) throw new Error("OAuth login flow not found");
|
||||
if (record === undefined) throw new Error("Login flow not found");
|
||||
if (record.state.status === "running") {
|
||||
record.abort.abort();
|
||||
const pending = record.pending;
|
||||
record.pending = undefined;
|
||||
const pending = this.clearPending(record);
|
||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "cancelled", error: "Login cancelled" });
|
||||
pending?.reject(new Error("Login cancelled"));
|
||||
}
|
||||
@@ -140,51 +143,179 @@ export class OAuthLoginFlowService {
|
||||
for (const record of this.flows.values()) {
|
||||
this.clearTimer(record);
|
||||
record.abort.abort();
|
||||
const pending = record.pending;
|
||||
record.pending = undefined;
|
||||
const pending = this.clearPending(record);
|
||||
pending?.reject(new Error("Login cancelled"));
|
||||
}
|
||||
this.flows.clear();
|
||||
}
|
||||
|
||||
private waitForPrompt(record: OAuthFlowRecord, prompt: OAuthPrompt, kind: "prompt" | "manual"): Promise<string> {
|
||||
private handlePrompt(record: OAuthFlowRecord, prompt: AuthPrompt): Promise<string> {
|
||||
if (prompt.type === "select") return this.waitForSelect(record, prompt);
|
||||
return this.waitForPrompt(record, prompt);
|
||||
}
|
||||
|
||||
private handleEvent(record: OAuthFlowRecord, event: AuthEvent): void {
|
||||
if (!this.isCurrentRunning(record)) return;
|
||||
switch (event.type) {
|
||||
case "auth_url":
|
||||
this.updateState(record, { ...record.state, auth: { url: event.url, ...(event.instructions === undefined ? {} : { instructions: event.instructions }) } });
|
||||
return;
|
||||
// Keep the legacy auth URL/instructions while adding structured metadata
|
||||
// that newer browsers can use during rolling sessiond upgrades.
|
||||
case "device_code":
|
||||
this.updateState(record, {
|
||||
...record.state,
|
||||
auth: {
|
||||
url: event.verificationUri,
|
||||
instructions: `Enter code: ${event.userCode}`,
|
||||
deviceCode: {
|
||||
userCode: event.userCode,
|
||||
...(event.intervalSeconds === undefined ? {} : { intervalSeconds: event.intervalSeconds }),
|
||||
...(event.expiresInSeconds === undefined ? {} : { expiresInSeconds: event.expiresInSeconds }),
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
case "info":
|
||||
this.updateState(record, {
|
||||
...record.state,
|
||||
progress: [...record.state.progress, event.message],
|
||||
info: [
|
||||
...(record.state.info ?? []),
|
||||
{
|
||||
message: event.message,
|
||||
...(event.links === undefined ? {} : {
|
||||
links: event.links.map((link) => ({
|
||||
url: link.url,
|
||||
...(link.label === undefined ? {} : { label: link.label }),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
case "progress":
|
||||
this.updateState(record, { ...record.state, progress: [...record.state.progress, event.message] });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private waitForPrompt(record: OAuthFlowRecord, prompt: ValuePrompt): 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 pending: PendingOAuthRequest = {
|
||||
requestId,
|
||||
allowEmpty: prompt.type === "text",
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
record.pending = pending;
|
||||
if (!this.bindPromptSignal(record, pending, prompt.signal)) return;
|
||||
const base = withoutInteraction(record.state);
|
||||
this.updateState(record, {
|
||||
...base,
|
||||
prompt: {
|
||||
requestId,
|
||||
message: prompt.message,
|
||||
kind,
|
||||
kind: prompt.type === "manual_code" ? "manual" : "prompt",
|
||||
promptType: prompt.type,
|
||||
...(prompt.type === "text" ? { allowEmpty: true } : {}),
|
||||
...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }),
|
||||
...(prompt.allowEmpty === true ? { allowEmpty: true } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private waitForSelect(record: OAuthFlowRecord, prompt: OAuthSelectPrompt): Promise<string | undefined> {
|
||||
private waitForSelect(record: OAuthFlowRecord, prompt: SelectPrompt): Promise<string> {
|
||||
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 options: CommandOption[] = prompt.options.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
...(option.description === undefined ? {} : { description: option.description }),
|
||||
}));
|
||||
const pending: PendingOAuthRequest = {
|
||||
requestId,
|
||||
allowEmpty: false,
|
||||
resolve,
|
||||
reject,
|
||||
allowedValues: new Set(options.map((option) => option.value)),
|
||||
};
|
||||
record.pending = pending;
|
||||
if (!this.bindPromptSignal(record, pending, prompt.signal)) return;
|
||||
const base = withoutInteraction(record.state);
|
||||
this.updateState(record, { ...base, select: { requestId, message: prompt.message, options } });
|
||||
});
|
||||
}
|
||||
|
||||
// A prompt may carry its own AbortSignal (e.g. a manual_code prompt raced
|
||||
// against a callback server). When it fires, drop just that pending request
|
||||
// and clear the interaction from state — the overall login keeps running.
|
||||
private bindPromptSignal(record: OAuthFlowRecord, pending: PendingOAuthRequest, signal?: AbortSignal): boolean {
|
||||
if (signal === undefined) return true;
|
||||
const onAbort = () => {
|
||||
if (record.pending !== pending) return;
|
||||
this.clearPending(record);
|
||||
if (this.isCurrentRunning(record)) this.updateState(record, withoutInteraction(record.state));
|
||||
pending.reject(new Error("Prompt cancelled"));
|
||||
};
|
||||
pending.cleanup = () => { signal.removeEventListener("abort", onAbort); };
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private clearPending(record: OAuthFlowRecord): PendingOAuthRequest | undefined {
|
||||
const pending = record.pending;
|
||||
record.pending = undefined;
|
||||
pending?.cleanup?.();
|
||||
return pending;
|
||||
}
|
||||
|
||||
// ModelRuntime persists the credential before its post-login refresh. If a
|
||||
// cancellation lands during that refresh, the resolved login is committed
|
||||
// truth and must supersede the transient cancelled state.
|
||||
private async reconcileCommittedLogin(record: OAuthFlowRecord, onComplete?: () => void | Promise<void>): Promise<void> {
|
||||
if (this.isCurrent(record)) this.clearPending(record);
|
||||
try {
|
||||
await onComplete?.();
|
||||
} catch (error) {
|
||||
this.logErrorNoThrow(
|
||||
{ err: error, flowId: record.flowId, providerId: record.state.providerId },
|
||||
"login completion callback failed",
|
||||
);
|
||||
}
|
||||
if (!this.isCurrent(record)) return;
|
||||
const completed = withoutInteraction(record.state);
|
||||
delete completed.error;
|
||||
this.markTerminal(record, { ...completed, status: "complete", progress: [...record.state.progress, "Login complete"] });
|
||||
}
|
||||
|
||||
private isCurrent(record: OAuthFlowRecord): boolean {
|
||||
return this.flows.get(record.flowId) === record;
|
||||
}
|
||||
|
||||
private isCurrentRunning(record: OAuthFlowRecord): boolean {
|
||||
return this.flows.get(record.flowId) === record && record.state.status === "running";
|
||||
return this.isCurrent(record) && record.state.status === "running";
|
||||
}
|
||||
|
||||
private logErrorNoThrow(details: Record<string, unknown>, message: string): void {
|
||||
try {
|
||||
this.logger.error(details, message);
|
||||
} catch {
|
||||
// Logging is post-commit diagnostics and must never change auth truth.
|
||||
}
|
||||
}
|
||||
|
||||
private updateState(record: OAuthFlowRecord, state: OAuthFlowState): void {
|
||||
@@ -226,10 +357,9 @@ export class OAuthLoginFlowService {
|
||||
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"));
|
||||
const pending = this.clearPending(record);
|
||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "Login flow expired" });
|
||||
pending?.reject(new Error("Login flow expired"));
|
||||
}
|
||||
|
||||
private setTimer(record: OAuthFlowRecord, delayMs: number, callback: () => void): void {
|
||||
@@ -256,9 +386,20 @@ function cloneState(state: OAuthFlowState): OAuthFlowState {
|
||||
return {
|
||||
...state,
|
||||
progress: [...state.progress],
|
||||
...(state.auth === undefined ? {} : { auth: { ...state.auth } }),
|
||||
...(state.auth === undefined ? {} : {
|
||||
auth: {
|
||||
...state.auth,
|
||||
...(state.auth.deviceCode === undefined ? {} : { deviceCode: { ...state.auth.deviceCode } }),
|
||||
},
|
||||
}),
|
||||
...(state.prompt === undefined ? {} : { prompt: { ...state.prompt } }),
|
||||
...(state.select === undefined ? {} : { select: { ...state.select, options: state.select.options.map((option) => ({ ...option })) } }),
|
||||
...(state.info === undefined ? {} : {
|
||||
info: state.info.map((item) => ({
|
||||
...item,
|
||||
...(item.links === undefined ? {} : { links: item.links.map((link) => ({ ...link })) }),
|
||||
})),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PiSessionService } from "./piSessionService.js";
|
||||
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js";
|
||||
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
|
||||
@@ -15,6 +15,7 @@ describe("PiSessionService archive and cleanup", () => {
|
||||
const fake = fakeRuntime("root", { sessionFile: root.path });
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]),
|
||||
@@ -49,6 +50,7 @@ describe("PiSessionService archive and cleanup", () => {
|
||||
const deletedSessionIds: string[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([]),
|
||||
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
|
||||
@@ -83,6 +85,7 @@ describe("PiSessionService archive and cleanup", () => {
|
||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
@@ -118,6 +121,7 @@ describe("PiSessionService archive and cleanup", () => {
|
||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: () => {
|
||||
createCalls += 1;
|
||||
return Promise.resolve(busy.runtime);
|
||||
@@ -159,6 +163,7 @@ describe("PiSessionService archive and cleanup", () => {
|
||||
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(busy.runtime),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([busyRecord, idleRecord]),
|
||||
@@ -196,6 +201,7 @@ describe("PiSessionService archive and cleanup", () => {
|
||||
const listCalls: string[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([
|
||||
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
|
||||
@@ -239,6 +245,7 @@ describe("PiSessionService archive and cleanup", () => {
|
||||
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([archived, otherArchived]),
|
||||
@@ -292,6 +299,7 @@ describe("PiSessionService archive and cleanup", () => {
|
||||
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([
|
||||
@@ -334,6 +342,7 @@ describe("PiSessionService archive and cleanup", () => {
|
||||
const archivedInputs: string[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
archiveStore: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js";
|
||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
};
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -60,6 +61,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
try {
|
||||
service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -87,6 +89,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
const open = vi.fn(() => fakeSessionManager());
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
@@ -136,6 +139,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
const open = vi.spyOn(gateway, "open");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: emptyArchiveStore(),
|
||||
createAgentRuntime,
|
||||
sessionManager: gateway,
|
||||
@@ -190,6 +194,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: emptyArchiveStore(),
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([sessionRecord(sessionId)]),
|
||||
@@ -230,6 +235,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
const fake = fakeRuntime(sessionId);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: emptyArchiveStore(),
|
||||
createAgentRuntime: () => {
|
||||
createStarted.resolve();
|
||||
@@ -264,6 +270,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -291,6 +298,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
});
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -326,6 +334,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
});
|
||||
service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("idle-session")]),
|
||||
heartbeatIntervalMs: 1_000,
|
||||
@@ -362,6 +371,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
});
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("completion-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -381,6 +391,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
it("uses injected archive and session-manager gateways for listing", async () => {
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
@@ -411,6 +422,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
it("lists archived records that have been moved out of the active session directory", async () => {
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
@@ -442,6 +454,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
const fake = fakeRuntime("runtime-reload-session");
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -475,6 +488,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([sessionRecord("reload-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -499,6 +513,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
const fake = fakeRuntime("busy-session", { isStreaming: true });
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("busy-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -514,6 +529,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
it("refuses to reload an archived session", async () => {
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([]),
|
||||
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
|
||||
@@ -536,6 +552,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
@@ -573,6 +590,7 @@ describe("PiSessionService.streamSnapshot", () => {
|
||||
const fake = fakeRuntime("snap-idle");
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -601,6 +619,7 @@ describe("PiSessionService.streamSnapshot", () => {
|
||||
const fake = fakeRuntime("snap-live", { state: { streamingMessage } });
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createAssistantMessageEventStream, InMemoryCredentialStore, type AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PiSessionService } from "./piSessionService.js";
|
||||
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||
import { CapturingSessionEventHub, createTestModelRuntime, fakeRuntime, runtimeCreator, seedCredential, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
|
||||
@@ -12,6 +15,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
const fake = fakeRuntime("prompt-session");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -30,6 +34,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("echo-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -60,6 +65,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -96,6 +102,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("name-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -118,6 +125,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("status-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -139,6 +147,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -155,6 +164,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
const fake = fakeRuntime("queued-session", { isStreaming: true });
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("queued-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -181,6 +191,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
};
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("compacting-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -250,6 +261,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
fake.session.clearQueue = clearRuntimeQueue;
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("clear-queue-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -290,6 +302,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
const fake = fakeRuntime("clear-empty-queue-session");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -309,6 +322,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
const fake = fakeRuntime("abort-session");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("abort-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -326,6 +340,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -341,17 +356,66 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("reloads models.json before listing and selecting models", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-web-model-runtime-"));
|
||||
try {
|
||||
const modelsPath = join(agentDir, "models.json");
|
||||
await writeLocalModelsConfig(modelsPath, "initial-model");
|
||||
const modelRuntime = await ModelRuntime.create({
|
||||
credentials: new InMemoryCredentialStore(),
|
||||
modelsPath,
|
||||
allowModelNetwork: false,
|
||||
});
|
||||
const setSessionModel = vi.fn(() => Promise.resolve());
|
||||
const fake = fakeRuntime("models-session", { modelRuntime, setModel: setSessionModel });
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir,
|
||||
modelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("models-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
try {
|
||||
await writeLocalModelsConfig(modelsPath, "listed-model");
|
||||
const listed = await service.availableModels(sessionRef("models-session"));
|
||||
expect(listed).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ provider: "test-local", id: "listed-model" }),
|
||||
]));
|
||||
expect(listed).not.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ provider: "test-local", id: "initial-model" }),
|
||||
]));
|
||||
|
||||
await writeLocalModelsConfig(modelsPath, "selected-model");
|
||||
await expect(service.setModel(sessionRef("models-session"), "test-local", "selected-model")).resolves.toBeDefined();
|
||||
expect(setSessionModel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
provider: "test-local",
|
||||
id: "selected-model",
|
||||
}));
|
||||
} finally {
|
||||
await service.dispose();
|
||||
}
|
||||
} finally {
|
||||
await rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } });
|
||||
const modelRegistry = ModelRegistry.inMemory(authStorage);
|
||||
const model = modelRegistry.find(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
|
||||
// The shared model runtime reads a live credential store. Mutating the store
|
||||
// and refreshing here simulates the committed snapshot that
|
||||
// ModelRuntime.login()/logout() establishes before AuthService emits.
|
||||
// applyAuthChange then only needs to notify active sessions.
|
||||
const credentials = new InMemoryCredentialStore();
|
||||
await seedCredential(credentials, "anthropic", { type: "api_key", key: "sk-test" });
|
||||
const modelRuntime = await createTestModelRuntime(credentials);
|
||||
const model = modelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
|
||||
if (model === undefined) throw new Error("Expected Anthropic model fixture");
|
||||
const fake = fakeRuntime("auth-session", { model, modelRegistry });
|
||||
const fake = fakeRuntime("auth-session", { model, modelRuntime });
|
||||
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRegistry,
|
||||
modelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("auth-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -361,7 +425,8 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
hub.sessionEvents.length = 0;
|
||||
hub.globalEvents.length = 0;
|
||||
|
||||
authStorage.logout("anthropic");
|
||||
await credentials.delete("anthropic");
|
||||
await modelRuntime.refresh();
|
||||
service.applyAuthChange({ removedProviderId: "anthropic" });
|
||||
service.applyAuthChange({ removedProviderId: "anthropic" });
|
||||
|
||||
@@ -369,9 +434,11 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
expect(warningCount()).toBe(1);
|
||||
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true);
|
||||
|
||||
authStorage.set("anthropic", { type: "api_key", key: "sk-new" });
|
||||
await seedCredential(credentials, "anthropic", { type: "api_key", key: "sk-new" });
|
||||
await modelRuntime.refresh();
|
||||
service.applyAuthChange();
|
||||
authStorage.logout("anthropic");
|
||||
await credentials.delete("anthropic");
|
||||
await modelRuntime.refresh();
|
||||
service.applyAuthChange({ removedProviderId: "anthropic" });
|
||||
expect(warningCount()).toBe(2);
|
||||
|
||||
@@ -382,6 +449,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
const fake = fakeRuntime("stop-session");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("stop-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
@@ -394,3 +462,25 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
async function writeLocalModelsConfig(path: string, modelId: string): Promise<void> {
|
||||
await writeFile(path, JSON.stringify({
|
||||
providers: {
|
||||
"test-local": {
|
||||
name: "Test Local",
|
||||
baseUrl: "http://127.0.0.1:1234/v1",
|
||||
apiKey: "offline-test-key",
|
||||
api: "openai-completions",
|
||||
models: [{
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000,
|
||||
maxTokens: 100,
|
||||
}],
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
|
||||
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
|
||||
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
|
||||
@@ -12,6 +12,7 @@ describe("PiSessionService", () => {
|
||||
const log: { details: Record<string, unknown>; message: string }[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
|
||||
@@ -45,6 +46,7 @@ describe("PiSessionService", () => {
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||
@@ -80,6 +82,7 @@ describe("PiSessionService", () => {
|
||||
const fake = fakeRuntime("spawned-x");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
|
||||
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
|
||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("PiSessionService", () => {
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
archiveStore,
|
||||
@@ -81,6 +82,7 @@ describe("PiSessionService", () => {
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
archiveStore: emptyArchiveStore(),
|
||||
@@ -121,6 +123,7 @@ describe("PiSessionService", () => {
|
||||
let index = 0;
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? child.runtime;
|
||||
index += 1;
|
||||
@@ -173,6 +176,7 @@ describe("PiSessionService", () => {
|
||||
const open = vi.fn(() => childManager);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? child.runtime;
|
||||
index += 1;
|
||||
@@ -215,6 +219,7 @@ describe("PiSessionService", () => {
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||
archiveStore: emptyArchiveStore(),
|
||||
@@ -240,6 +245,7 @@ describe("PiSessionService", () => {
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||
archiveStore: emptyArchiveStore(),
|
||||
@@ -262,6 +268,7 @@ describe("PiSessionService", () => {
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||
archiveStore: emptyArchiveStore(),
|
||||
@@ -283,6 +290,7 @@ describe("PiSessionService", () => {
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
|
||||
archiveStore: emptyArchiveStore(),
|
||||
@@ -304,6 +312,7 @@ describe("PiSessionService", () => {
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(forkedParent.runtime),
|
||||
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||
archiveStore: emptyArchiveStore(),
|
||||
@@ -343,6 +352,7 @@ describe("PiSessionService", () => {
|
||||
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: (_createRuntime, options) => {
|
||||
delegationCapabilities.push(options.delegationToolsEnabled);
|
||||
const runtime = runtimes[index] ?? parent.runtime;
|
||||
@@ -409,6 +419,7 @@ describe("PiSessionService", () => {
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? parent.runtime;
|
||||
index += 1;
|
||||
@@ -469,6 +480,7 @@ describe("PiSessionService", () => {
|
||||
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? parent.runtime;
|
||||
index += 1;
|
||||
@@ -536,6 +548,7 @@ describe("PiSessionService", () => {
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime,
|
||||
sessionManager: {
|
||||
create: () => parentManager,
|
||||
@@ -614,6 +627,7 @@ describe("PiSessionService", () => {
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime,
|
||||
sessionManager: {
|
||||
create: () => copiedParentManager,
|
||||
@@ -674,6 +688,7 @@ describe("PiSessionService", () => {
|
||||
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? parent.runtime;
|
||||
index += 1;
|
||||
@@ -728,6 +743,7 @@ describe("PiSessionService", () => {
|
||||
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? parent.runtime;
|
||||
index += 1;
|
||||
@@ -770,6 +786,7 @@ describe("PiSessionService", () => {
|
||||
const open = vi.fn(() => childManager);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(child.runtime),
|
||||
sessionManager: {
|
||||
create: () => childManager,
|
||||
@@ -959,6 +976,7 @@ describe("PiSessionService", () => {
|
||||
const fake = fakeRuntime("nope");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { InMemoryCredentialStore, type Credential, type CredentialStore } from "@earendil-works/pi-ai";
|
||||
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.js";
|
||||
@@ -62,8 +63,34 @@ export function sessionRef(id: string, cwd = "/workspace") {
|
||||
export const TEST_MODEL_PROVIDER = "anthropic";
|
||||
export const TEST_MODEL_ID = "claude-sonnet-4-5-20250929";
|
||||
|
||||
/**
|
||||
* Seed a credential into an {@link InMemoryCredentialStore}. `modify` is the
|
||||
* only write path on the pi-ai `CredentialStore` contract, so tests that need a
|
||||
* pre-populated store go through it rather than mutating internals.
|
||||
*/
|
||||
export async function seedCredential(store: InMemoryCredentialStore, providerId: string, credential: Credential): Promise<void> {
|
||||
await store.modify(providerId, () => Promise.resolve(credential));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a real {@link ModelRuntime} over an in-memory credential store — the
|
||||
* async test seam that replaces the removed `ModelRegistry.create(AuthStorage
|
||||
* .inMemory())`. Pass a pre-seeded store to exercise credential-dependent
|
||||
* behavior (e.g. auth-loss warnings).
|
||||
*/
|
||||
export function createTestModelRuntime(credentials: CredentialStore = new InMemoryCredentialStore()): Promise<ModelRuntime> {
|
||||
return ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared runtime for the common case where a test only needs model catalog
|
||||
* reads and no configured auth. Built once so the many `fakeRuntime` sessions
|
||||
* and `PiSessionService` constructions can inject it synchronously.
|
||||
*/
|
||||
export const testModelRuntime = await createTestModelRuntime();
|
||||
|
||||
export function testModel(): NonNullable<PiAgentSession["model"]> {
|
||||
const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
|
||||
const model = testModelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
|
||||
if (model === undefined) throw new Error("test model not found");
|
||||
return model;
|
||||
}
|
||||
@@ -88,7 +115,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession>
|
||||
pendingMessageCount: 0,
|
||||
sessionManager: fakeSessionManager(),
|
||||
settingsManager: { getWarnings: () => ({}), setWarnings: () => undefined },
|
||||
modelRegistry: ModelRegistry.create(AuthStorage.inMemory()),
|
||||
modelRuntime: testModelRuntime,
|
||||
scopedModels: [],
|
||||
extensionRunner: { getRegisteredCommands: () => [] },
|
||||
promptTemplates: [],
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { open, readFile, writeFile } from "node:fs/promises";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
AuthStorage,
|
||||
createAgentSessionFromServices,
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
createEditToolDefinition,
|
||||
defineTool,
|
||||
ModelRegistry,
|
||||
readStoredCredential,
|
||||
SessionManager,
|
||||
type AgentSessionRuntimeDiagnostic,
|
||||
type AgentSessionServices,
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
type EditToolDetails,
|
||||
type ModelRuntime,
|
||||
type ResourceDiagnostic,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionStreamSnapshot, SessionUiEvent } from "../types.js";
|
||||
@@ -26,7 +27,6 @@ import { SessionCommandService } from "./sessionCommandService.js";
|
||||
import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js";
|
||||
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js";
|
||||
import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||
import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js";
|
||||
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
@@ -34,6 +34,7 @@ import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
||||
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionWarning } from "../../shared/apiTypes.js";
|
||||
import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js";
|
||||
|
||||
import { type AuthChange } from "./authService.js";
|
||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
||||
@@ -171,7 +172,6 @@ interface BulkDeletePlanItem {
|
||||
}
|
||||
|
||||
type AgentModel = NonNullable<SpawnSessionInvocation["model"]>;
|
||||
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
||||
|
||||
export interface PiSessionManager {
|
||||
getCwd(): string;
|
||||
@@ -208,7 +208,7 @@ interface PiExtensionBindings {
|
||||
}
|
||||
|
||||
export interface PiAgentSession {
|
||||
modelRegistry: ModelRegistryInstance;
|
||||
modelRuntime: ModelRuntime;
|
||||
/**
|
||||
* Narrow read/write of the SDK `SettingsManager`, exposing only the warning
|
||||
* suppression flags consumed here (e.g. `anthropicExtraUsage`). Used to gate
|
||||
@@ -383,11 +383,12 @@ const ANTHROPIC_EXTRA_USAGE_DISMISS_ID = "anthropicExtraUsage";
|
||||
* synchronous live status computation.
|
||||
*/
|
||||
export function anthropicSubscriptionWarning(
|
||||
session: Pick<PiAgentSession, "model" | "modelRegistry" | "settingsManager">,
|
||||
session: Pick<PiAgentSession, "model" | "settingsManager">,
|
||||
authPath?: string,
|
||||
): SessionWarning | undefined {
|
||||
if (session.settingsManager.getWarnings().anthropicExtraUsage === false) return undefined;
|
||||
if (session.model?.provider !== "anthropic") return undefined;
|
||||
const credential = session.modelRegistry.authStorage.get("anthropic");
|
||||
const credential = readStoredCredential("anthropic", authPath);
|
||||
if (credential === undefined) return undefined;
|
||||
const isSubscriptionAuth = credential.type === "oauth"
|
||||
? true
|
||||
@@ -487,14 +488,13 @@ export function createPiWebCustomToolDefinitions(
|
||||
}
|
||||
|
||||
function createDefaultRuntimeFactory(
|
||||
authStorage: AuthStorage,
|
||||
modelRegistry: ModelRegistryInstance,
|
||||
modelRuntime: ModelRuntime,
|
||||
sessionManagers: Pick<PiSessionManagerGateway, "open">,
|
||||
spawn?: SpawnSessionFn,
|
||||
subsessions?: SubsessionToolDeps,
|
||||
): PiWebCreateAgentSessionRuntimeFactory {
|
||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
|
||||
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
|
||||
const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
|
||||
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
||||
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
||||
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
|
||||
@@ -539,7 +539,7 @@ export interface PiSessionServiceDependencies {
|
||||
archiveStore?: SessionArchiveRepository;
|
||||
createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
|
||||
createAgentRuntime?: CreateAgentRuntime;
|
||||
modelRegistry?: ModelRegistryInstance;
|
||||
modelRuntime: ModelRuntime;
|
||||
heartbeatIntervalMs?: number;
|
||||
workspaceActivity?: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity">;
|
||||
/**
|
||||
@@ -589,7 +589,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
private readonly sessionManager: PiSessionManagerGateway;
|
||||
private readonly createRuntime: PiWebCreateAgentSessionRuntimeFactory;
|
||||
private readonly createAgentRuntime: CreateAgentRuntime;
|
||||
private readonly modelRegistry: ModelRegistryInstance;
|
||||
private readonly modelRuntime: ModelRuntime;
|
||||
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
|
||||
private readonly spawnTargets: SpawnTargetResolver | undefined;
|
||||
private readonly logger: PiSessionLogger;
|
||||
@@ -599,7 +599,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
||||
this.agentDir = deps.agentDir;
|
||||
this.sessionManager = deps.sessionManager;
|
||||
this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir);
|
||||
this.modelRuntime = deps.modelRuntime;
|
||||
this.spawnTargets = deps.spawnTargets;
|
||||
this.logger = deps.logger ?? noopLogger;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
@@ -607,8 +607,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
// also require the spawn capability (they share its project-scope resolver).
|
||||
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
|
||||
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(
|
||||
this.modelRegistry.authStorage,
|
||||
this.modelRegistry,
|
||||
this.modelRuntime,
|
||||
this.sessionManager,
|
||||
this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input),
|
||||
!subsessionsActive ? undefined : {
|
||||
@@ -1159,22 +1158,22 @@ export class PiSessionService implements SessionRouteService {
|
||||
|
||||
async availableModels(ref: PiSessionLookup): Promise<ClientSessionModel[]> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
session.modelRegistry.refresh();
|
||||
await session.modelRuntime.reloadConfig();
|
||||
const models = session.scopedModels.length > 0
|
||||
? session.scopedModels.map((scoped) => scoped.model)
|
||||
: session.modelRegistry.getAvailable();
|
||||
: session.modelRuntime.getAvailableSnapshot();
|
||||
return models.map(modelToClientModel);
|
||||
}
|
||||
|
||||
async setModel(ref: PiSessionLookup, provider: string, modelId: string): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
session.modelRegistry.refresh();
|
||||
await session.modelRuntime.reloadConfig();
|
||||
const candidates = session.scopedModels.length > 0
|
||||
? session.scopedModels.map((scoped) => scoped.model)
|
||||
: session.modelRegistry.getAvailable();
|
||||
: session.modelRuntime.getAvailableSnapshot();
|
||||
const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
|
||||
?? session.modelRegistry.find(provider, modelId);
|
||||
?? session.modelRuntime.getModel(provider, modelId);
|
||||
if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`);
|
||||
await session.setModel(model);
|
||||
this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
|
||||
@@ -2020,10 +2019,11 @@ export class PiSessionService implements SessionRouteService {
|
||||
}
|
||||
|
||||
applyAuthChange(change: AuthChange = {}): void {
|
||||
this.modelRegistry.refresh();
|
||||
// ModelRuntime.login()/logout() refresh the shared runtime before AuthService
|
||||
// emits the change, so no refresh is needed here. Keeping this synchronous
|
||||
// also lets every active session observe the same committed auth snapshot.
|
||||
for (const active of this.active.values()) {
|
||||
const { session } = active.runtime;
|
||||
session.modelRegistry.refresh();
|
||||
this.syncCurrentModelAuthWarning(session, change.removedProviderId);
|
||||
this.publishStatus(session);
|
||||
}
|
||||
@@ -2034,9 +2034,9 @@ export class PiSessionService implements SessionRouteService {
|
||||
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);
|
||||
const registered = session.modelRuntime.getModel(model.provider, model.id);
|
||||
if (registered === undefined) return;
|
||||
if (session.modelRegistry.hasConfiguredAuth(registered)) {
|
||||
if (session.modelRuntime.hasConfiguredAuth(model.provider)) {
|
||||
this.authLossWarnings.delete(warningKey);
|
||||
return;
|
||||
}
|
||||
@@ -2182,7 +2182,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
private warningsForSession(session: PiAgentSession): SessionWarning[] {
|
||||
const runtime = this.active.get(session.sessionId)?.runtime;
|
||||
const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
|
||||
const anthropic = anthropicSubscriptionWarning(session);
|
||||
const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
|
||||
if (anthropic !== undefined) warnings.push(anthropic);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AuthStorage, ModelRegistry, type AgentSessionRuntimeDiagnostic, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type AgentSessionRuntimeDiagnostic, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
|
||||
import { anthropicSubscriptionWarning, collectRuntimeWarnings, dismissSessionWarning, type RuntimeWarningSources } from "./piSessionService.js";
|
||||
import { testModel } from "./piSessionService.testSupport.js";
|
||||
import type { PiAgentSession } from "./piSessionService.js";
|
||||
import type { SessionWarning } from "../../shared/apiTypes.js";
|
||||
|
||||
@@ -83,47 +87,55 @@ describe("collectRuntimeWarnings", () => {
|
||||
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
|
||||
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage.";
|
||||
|
||||
type SubscriptionSession = Pick<PiAgentSession, "model" | "modelRegistry" | "settingsManager">;
|
||||
type SubscriptionSession = Pick<PiAgentSession, "model" | "settingsManager">;
|
||||
|
||||
function anthropicModel(provider: string): PiAgentSession["model"] {
|
||||
const registry = ModelRegistry.inMemory(AuthStorage.inMemory());
|
||||
const model = registry.getAll().find((candidate) => candidate.provider === provider) ?? registry.getAll()[0];
|
||||
if (model === undefined) throw new Error("expected at least one built-in model");
|
||||
return { ...model, provider };
|
||||
// anthropicSubscriptionWarning only reads `model.provider`, so any built-in
|
||||
// model re-tagged with the desired provider is a sufficient fixture.
|
||||
return { ...testModel(), provider };
|
||||
}
|
||||
|
||||
function subscriptionSession(options: {
|
||||
provider?: string;
|
||||
anthropicExtraUsage?: boolean;
|
||||
credential?: AuthStorage;
|
||||
}): SubscriptionSession {
|
||||
const authStorage = options.credential ?? AuthStorage.inMemory();
|
||||
return {
|
||||
model: options.provider === undefined ? undefined : anthropicModel(options.provider),
|
||||
settingsManager: {
|
||||
getWarnings: () => (options.anthropicExtraUsage === undefined ? {} : { anthropicExtraUsage: options.anthropicExtraUsage }),
|
||||
setWarnings: () => undefined,
|
||||
},
|
||||
modelRegistry: ModelRegistry.create(authStorage),
|
||||
};
|
||||
}
|
||||
|
||||
function anthropicAuth(credential: { type: "oauth" } | { type: "api_key"; key: string }): AuthStorage {
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
if (credential.type === "oauth") {
|
||||
authStorage.set("anthropic", { type: "oauth", access: "a", refresh: "r", expires: Date.now() + 3_600_000 });
|
||||
} else {
|
||||
authStorage.set("anthropic", { type: "api_key", key: credential.key });
|
||||
}
|
||||
return authStorage;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
/**
|
||||
* Write an `auth.json` holding a single anthropic credential and return its
|
||||
* path. `anthropicSubscriptionWarning` reads it via `readStoredCredential`, so
|
||||
* the credential seam is the on-disk auth file rather than an in-memory store.
|
||||
*/
|
||||
async function anthropicAuthPath(credential: { type: "oauth" } | { type: "api_key"; key: string }): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-web-warnings-"));
|
||||
tempDirs.push(dir);
|
||||
const authPath = join(dir, "auth.json");
|
||||
const stored = credential.type === "oauth"
|
||||
? { type: "oauth", access: "a", refresh: "r", expires: Date.now() + 3_600_000 }
|
||||
: { type: "api_key", key: credential.key };
|
||||
await writeFile(authPath, JSON.stringify({ anthropic: stored }));
|
||||
return authPath;
|
||||
}
|
||||
|
||||
describe("anthropicSubscriptionWarning", () => {
|
||||
it("warns with the verbatim SDK wording for a stored oauth credential", () => {
|
||||
expect(anthropicSubscriptionWarning(subscriptionSession({
|
||||
provider: "anthropic",
|
||||
credential: anthropicAuth({ type: "oauth" }),
|
||||
}))).toEqual({
|
||||
it("warns with the verbatim SDK wording for a stored oauth credential", async () => {
|
||||
expect(anthropicSubscriptionWarning(
|
||||
subscriptionSession({ provider: "anthropic" }),
|
||||
await anthropicAuthPath({ type: "oauth" }),
|
||||
)).toEqual({
|
||||
severity: "warning",
|
||||
message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING,
|
||||
source: "anthropic",
|
||||
@@ -131,37 +143,41 @@ describe("anthropicSubscriptionWarning", () => {
|
||||
} satisfies SessionWarning);
|
||||
});
|
||||
|
||||
it("warns for an sk-ant-oat subscription API key", () => {
|
||||
expect(anthropicSubscriptionWarning(subscriptionSession({
|
||||
provider: "anthropic",
|
||||
credential: anthropicAuth({ type: "api_key", key: "sk-ant-oat-abc123" }),
|
||||
}))?.message).toBe(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
|
||||
it("warns for an sk-ant-oat subscription API key", async () => {
|
||||
expect(anthropicSubscriptionWarning(
|
||||
subscriptionSession({ provider: "anthropic" }),
|
||||
await anthropicAuthPath({ type: "api_key", key: "sk-ant-oat-abc123" }),
|
||||
)?.message).toBe(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
|
||||
});
|
||||
|
||||
it("does not warn for a standard anthropic API key", () => {
|
||||
expect(anthropicSubscriptionWarning(subscriptionSession({
|
||||
provider: "anthropic",
|
||||
credential: anthropicAuth({ type: "api_key", key: "sk-ant-api-abc123" }),
|
||||
}))).toBeUndefined();
|
||||
it("does not warn for a standard anthropic API key", async () => {
|
||||
expect(anthropicSubscriptionWarning(
|
||||
subscriptionSession({ provider: "anthropic" }),
|
||||
await anthropicAuthPath({ type: "api_key", key: "sk-ant-api-abc123" }),
|
||||
)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects the anthropicExtraUsage suppression gate", () => {
|
||||
expect(anthropicSubscriptionWarning(subscriptionSession({
|
||||
provider: "anthropic",
|
||||
anthropicExtraUsage: false,
|
||||
credential: anthropicAuth({ type: "oauth" }),
|
||||
}))).toBeUndefined();
|
||||
it("respects the anthropicExtraUsage suppression gate", async () => {
|
||||
expect(anthropicSubscriptionWarning(
|
||||
subscriptionSession({ provider: "anthropic", anthropicExtraUsage: false }),
|
||||
await anthropicAuthPath({ type: "oauth" }),
|
||||
)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn when the active provider is not anthropic", () => {
|
||||
expect(anthropicSubscriptionWarning(subscriptionSession({
|
||||
provider: "openai",
|
||||
credential: anthropicAuth({ type: "oauth" }),
|
||||
}))).toBeUndefined();
|
||||
it("does not warn when the active provider is not anthropic", async () => {
|
||||
expect(anthropicSubscriptionWarning(
|
||||
subscriptionSession({ provider: "openai" }),
|
||||
await anthropicAuthPath({ type: "oauth" }),
|
||||
)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn when no anthropic credential is stored", () => {
|
||||
expect(anthropicSubscriptionWarning(subscriptionSession({ provider: "anthropic" }))).toBeUndefined();
|
||||
it("does not warn when no anthropic credential is stored", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-web-warnings-"));
|
||||
tempDirs.push(dir);
|
||||
expect(anthropicSubscriptionWarning(
|
||||
subscriptionSession({ provider: "anthropic" }),
|
||||
join(dir, "auth.json"),
|
||||
)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus, SessionStreamSnapshot } from "../../shared/apiTypes.js";
|
||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
|
||||
import { testModelRuntime } from "./piSessionService.testSupport.js";
|
||||
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
|
||||
import { registerSessionRoutes } from "./sessionRoutes.js";
|
||||
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
|
||||
@@ -20,7 +21,7 @@ beforeEach(async () => {
|
||||
await app.register(fastifyWebsocket);
|
||||
sessionManager = new RejectingSessionManager();
|
||||
const eventHub = new SessionEventHub();
|
||||
service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, sessionManager, heartbeatIntervalMs: 60_000 });
|
||||
service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, modelRuntime: testModelRuntime, sessionManager, heartbeatIntervalMs: 60_000 });
|
||||
registerSessionRoutes(app, service, eventHub);
|
||||
});
|
||||
|
||||
|
||||
+17
-2
@@ -373,6 +373,8 @@ export interface AuthProviderOption {
|
||||
name: string;
|
||||
authType: AuthType;
|
||||
status: AuthProviderStatus;
|
||||
/** Additive hint: use the generic AuthInteraction transport instead of the legacy one-secret form. */
|
||||
loginFlow?: "interactive";
|
||||
}
|
||||
|
||||
export interface AuthProvidersResponse {
|
||||
@@ -384,10 +386,23 @@ export interface OAuthFlowState {
|
||||
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" };
|
||||
auth?: {
|
||||
url: string;
|
||||
instructions?: string;
|
||||
deviceCode?: { userCode: string; intervalSeconds?: number; expiresInSeconds?: number };
|
||||
};
|
||||
prompt?: {
|
||||
requestId: string;
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
allowEmpty?: boolean;
|
||||
/** Additive semantic detail; legacy peers continue to use `kind`. */
|
||||
promptType?: "text" | "secret" | "manual_code";
|
||||
kind: "prompt" | "manual";
|
||||
};
|
||||
select?: { requestId: string; message: string; options: CommandOption[] };
|
||||
progress: string[];
|
||||
info?: { message: string; links?: { url: string; label?: string }[] }[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
|
||||
{ method: "GET", path: "/auth/providers" },
|
||||
{ method: "POST", path: "/auth/api-key" },
|
||||
{ method: "POST", path: "/auth/api-key/interactive" },
|
||||
{ method: "POST", path: "/auth/logout" },
|
||||
{ method: "POST", path: "/auth/oauth" },
|
||||
{ method: "GET", path: "/auth/oauth/:flowId" },
|
||||
|
||||
Reference in New Issue
Block a user