Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-runtimes-align.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---

Migrate session daemon authentication and model discovery to Pi's public ModelRuntime API. Pi 0.80.10 is the minimum supported version and the exact development/CI baseline; newer Pi releases are permitted by the peer dependency range but may not yet be verified.
410 changes: 214 additions & 196 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
"@earendil-works/pi-agent-core": "^0.80.6",
"@earendil-works/pi-ai": "^0.80.6",
"@earendil-works/pi-coding-agent": "^0.80.6",
"@earendil-works/pi-agent-core": "0.80.10",
"@earendil-works/pi-ai": "0.80.10",
"@earendil-works/pi-coding-agent": "0.80.10",
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.3",
"@types/ws": "^8.18.1",
Expand All @@ -101,7 +101,7 @@
"access": "public"
},
"engines": {
"node": ">=22"
"node": ">=22.19.0"
},
"repository": {
"type": "git",
Expand All @@ -113,9 +113,9 @@
"homepage": "https://pi-web.dev/",
"packageManager": "npm@11.11.0",
"peerDependencies": {
"@earendil-works/pi-agent-core": ">=0.80.0 <1",
"@earendil-works/pi-ai": ">=0.80.0 <1",
"@earendil-works/pi-coding-agent": ">=0.80.0 <1"
"@earendil-works/pi-agent-core": ">=0.80.10",
"@earendil-works/pi-ai": ">=0.80.10",
"@earendil-works/pi-coding-agent": ">=0.80.10"
},
"keywords": [
"pi-package",
Expand Down
23 changes: 22 additions & 1 deletion src/client/src/api/parsers.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
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, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";

describe("API parsers", () => {
it("preserves OAuth prompt, selection, and device-code metadata", () => {
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: "secret", placeholder: "token" },
select: { requestId: "select-1", message: "Choose", options: [{ value: "work", label: "Work", description: "Company account" }] },
progress: [],
})).toMatchObject({
auth: { deviceCode: { userCode: "ABCD", intervalSeconds: 5, expiresInSeconds: 900 } },
prompt: { kind: "secret" },
select: { options: [{ value: "work", description: "Company account" }] },
});
});

it("parses PI WEB config responses", () => {
expect(parsePiWebConfigResponse({
path: "/tmp/config.json",
Expand Down
21 changes: 18 additions & 3 deletions src/client/src/api/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,15 +367,30 @@ 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")) };
const deviceCode = optionalOAuthDeviceCode(record["deviceCode"]);
return {
url: requireString(record, "url"),
...optionalField("instructions", optionalString(record, "instructions")),
...optionalField("deviceCode", 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 {
if (value === undefined) return undefined;
const record = requireRecord(value);
const kind = requireString(record, "kind");
if (kind !== "prompt" && kind !== "manual") throw new Error("Invalid OAuth prompt kind");
return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), kind, ...optionalField("placeholder", optionalString(record, "placeholder")), ...(record["allowEmpty"] === true ? { allowEmpty: true } : {}) };
if (kind !== "text" && kind !== "secret" && kind !== "manual-code") throw new Error("Invalid OAuth prompt kind");
return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), kind, ...optionalField("placeholder", optionalString(record, "placeholder")) };
}

function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefined {
Expand Down
10 changes: 10 additions & 0 deletions src/client/src/components/AuthDialog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { oauthPromptInputType } from "./AuthDialog";

describe("oauthPromptInputType", () => {
it("renders secret prompts as password inputs", () => {
expect(oauthPromptInputType("secret")).toBe("password");
expect(oauthPromptInputType("text")).toBe("text");
expect(oauthPromptInputType("manual-code")).toBe("text");
});
});
15 changes: 13 additions & 2 deletions src/client/src/components/AuthDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,17 @@ export class AuthDialog extends LitElement {
${flow.progress.length > 0 ? html`<ul class="progress">${flow.progress.map((line) => html`<li>${line}</li>`)}</ul>` : null}
${prompt !== undefined ? html`
<label>${prompt.message}</label>
<input .value=${state.inputValue ?? ""} placeholder=${prompt.placeholder ?? ""} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}>
<input type=${oauthPromptInputType(prompt.kind)} autocomplete=${prompt.kind === "secret" ? "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}
Expand Down Expand Up @@ -158,6 +163,8 @@ export class AuthDialog extends LitElement {
.error-text { color: var(--pi-danger); }
.progress { margin: 0; padding-left: 18px; color: var(--pi-muted); }
.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; }
`];
}
Expand Down Expand Up @@ -185,3 +192,7 @@ function statusLabel(provider: AuthProviderOption): string {
}
}


export function oauthPromptInputType(kind: "text" | "secret" | "manual-code"): "text" | "password" {
return kind === "secret" ? "password" : "text";
}
12 changes: 6 additions & 6 deletions src/client/src/controllers/authController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ describe("AuthController", () => {
});

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 flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual-code" } });
const { controller, getState } = createController(
{ authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } },
{ respondOAuthFlow: () => Promise.resolve(oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" }, progress: ["Still waiting"] })) },
{ respondOAuthFlow: () => Promise.resolve(oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual-code" }, progress: ["Still waiting"] })) },
);

await controller.respondOAuth();
Expand All @@ -44,7 +44,7 @@ describe("AuthController", () => {
});

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 flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual-code" } });
const { controller, getState } = createController(
{ authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } },
{
Expand All @@ -66,7 +66,7 @@ describe("AuthController", () => {
});

it("closes the OAuth dialog and refreshes selected session status when the flow completes", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual-code" } });
const session = sessionInfo("session-1");
const refreshedStatus = sessionStatus(session.id);
const respondCalls: { flowId: string; requestId: string; value: string; machineId: string | undefined }[] = [];
Expand Down Expand Up @@ -97,7 +97,7 @@ describe("AuthController", () => {
});

it("leaves the OAuth dialog ready to retry if responding fails", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual-code" } });
const { controller, getState } = createController(
{ authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } },
{ respondOAuthFlow: () => Promise.reject(new Error("Invalid callback")) },
Expand All @@ -115,7 +115,7 @@ describe("AuthController", () => {
});

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 flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual-code" } });
const cancelCalls: { flowId: string; machineId: string | undefined }[] = [];
const { controller, getState } = createController(
{ authDialog: { step: "oauth", flow } },
Expand Down
9 changes: 5 additions & 4 deletions src/server/sessiond.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import fastifyWebsocket from "@fastify/websocket";
import { WorkspaceActivityService } from "./activity/workspaceActivityService.js";
import { registerWorkspaceActivityRoutes } from "./activity/workspaceActivityRoutes.js";
import { SessionEventHub } from "./realtime/sessionEventHub.js";
import { AuthService } from "./sessions/authService.js";
import { AuthService, createModelRuntimeForAgentDir } from "./sessions/authService.js";
import { registerAuthRoutes } from "./sessions/authRoutes.js";
import { PiSessionService } from "./sessions/piSessionService.js";
import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js";
Expand Down Expand Up @@ -36,15 +36,16 @@ 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 modelRuntime = await createModelRuntimeForAgentDir(activeAgentProfile.dir);
const auth = new AuthService({ modelRuntime });
const spawnTargets = config.spawnSessions
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined;
const sessions = new PiSessionService(eventHub, {
modelRegistry: auth.modelRegistry,
modelRuntime,
agentDir: activeAgentProfile.dir,
workspaceActivity,
logger: app.log,
Expand Down
4 changes: 2 additions & 2 deletions src/server/sessiond/sessionDaemonStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand All @@ -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;
Expand Down
61 changes: 27 additions & 34 deletions src/server/sessions/authProviderOptions.test.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,45 @@
import { describe, expect, it } from "vitest";
import { getLoginProviderOptions, getLogoutProviderOptions, isApiKeyLoginProvider, type AuthProviderModelRegistry } from "./authProviderOptions";
import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderModelRuntime } from "./authProviderOptions.js";

function registry(): AuthProviderModelRegistry {
const credentials = new Map<string, { type: "oauth" | "api_key" }>();
credentials.set("openai", { type: "api_key" });
function runtime(): AuthProviderModelRuntime {
const providers = [
{ id: "anthropic", name: "Anthropic", auth: { oauth: {}, apiKey: { login: () => undefined } } },
{ id: "openai", name: "OpenAI", auth: { apiKey: { login: () => undefined } } },
{ id: "openai-codex", name: "ChatGPT Plus/Pro", auth: { oauth: {} } },
{ id: "ambient", name: "Ambient", 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),
getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }),
getProviders: () => providers,
getProvider: (providerId) => providers.find((provider) => provider.id === providerId),
listCredentials: () => Promise.resolve([{ providerId: "openai", type: "api_key" }]),
getProviderAuthStatus: (providerId) => providerId === "openai"
? { configured: true, source: "stored" }
: { configured: false },
};
}

describe("auth provider options", () => {
it("keeps OAuth-only providers out of API key login options", () => {
expect(isApiKeyLoginProvider("openai-codex", new Set(["openai-codex"]))).toBe(false);
expect(isApiKeyLoginProvider("github-copilot", new Set(["github-copilot"]))).toBe(false);
expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true);
});

it("builds login options for OAuth-only, dual-auth, and API-key providers", () => {
const options = getLoginProviderOptions(registry());
it("builds login options from provider-owned auth capabilities", () => {
const options = getLoginProviderOptions(runtime());
expect(options).toEqual(expect.arrayContaining([
expect.objectContaining({ id: "anthropic", authType: "oauth" }),
expect.objectContaining({ id: "anthropic", authType: "api_key" }),
expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }),
expect.objectContaining({ id: "openai-codex", authType: "oauth" }),
]));
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })]));
expect(options).not.toEqual(expect.arrayContaining([
expect.objectContaining({ id: "openai-codex", authType: "api_key" }),
expect.objectContaining({ id: "ambient", authType: "api_key" }),
]));
});

it("filters login options by auth type", () => {
expect(getLoginProviderOptions(runtime(), "oauth").every((option) => option.authType === "oauth")).toBe(true);
});

it("returns only currently stored credentials for logout", () => {
expect(getLogoutProviderOptions(registry())).toEqual([
expect.objectContaining({ id: "openai", authType: "api_key" }),
it("returns only currently stored credentials for logout", async () => {
await expect(getLogoutProviderOptions(runtime())).resolves.toEqual([
expect.objectContaining({ id: "openai", name: "OpenAI", authType: "api_key" }),
]);
});
});
Loading