diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index f4005b7b6..6cfb9a959 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -609,6 +609,11 @@ ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts ade actions run pty.resumeSession --arg sessionId=session-id ade actions run external-sessions.list --input-json '{"scope":"project","limit":20}' --text # claude/codex/cursor/droid/opencode sessions on this machine; discovery that cannot run — `opencode` is not installed, say — fails the call when that provider is the only one asked for, rather than reporting an empty list; in a multi-provider scan it is skipped and logged ade actions run external-sessions.import --input-json '{"provider":"codex","sessionId":"thread-id","laneId":"lane-1","target":"cli","mode":"resume"}' --text +ade actions run ai.piLoginProviders --text # Pi providers that can be signed into, with the auth methods each accepts and whether it is already configured +ade --role cto actions run ai.piLoginStart --input-json '{"providerId":"anthropic"}' --json # blocks until the human finishes Pi's own OAuth/device-code flow +ade actions call stream_events --arg category=runtime --json # drain piAuthStatus prompts/notices raised by an in-flight sign-in +ade --role cto actions run ai.piLoginSubmit --input-json "$(jq -n --arg v "$PI_API_KEY" '{providerId:"anthropic",requestId:"req-1",value:$v}')" # answer a prompt; keep the value out of argv and shell history +ade --role cto actions run ai.piLoginCancel --input-json '{"providerId":"anthropic"}' ade cursor cloud agents list --text ade cursor cloud agents create --repo https://github.com/owner/repo --prompt "fix flaky test" --auto-pr ade --role cto github app-auth login # device-flow authorize the machine ADE GitHub App (headless/brain) @@ -635,6 +640,26 @@ stored PAT order. Writes skip the read-only GitHub App. `github.getStatus` reports the active read/write sources, per-credential failure/cooldown state, fallback details, and any background-refresh pause without exposing tokens. +Pi sign-in has no typed command, the same way OpenCode's `ai.opencodeOAuth*` +actions do not. `ai.piLoginStart` blocks until a human finishes Pi's own browser +or device-code flow, and any prompt it raises is answered by a *second* call +(`ai.piLoginSubmit`) carrying a `requestId` that only appears on the +`piAuthStatus` runtime event — so the workflow is two processes plus an event +drain, not one command. From a terminal the shorter path is `ade code`'s +`/login`, which drops into `pi` and uses Pi's native interactive sign-in. + +Three details when driving it through `ade actions run` anyway. The `piLogin` +start/submit/cancel actions are CTO-only, so they need `--role cto`; +`ai.piLoginProviders` is not. The two-process shape only works against a running +brain or desktop socket — a headless invocation builds its own runtime, so a +second `ade` process cannot see the first's in-flight sign-in. And +`ai.piLoginStart` carries its own transport floor (11 minutes, the same budget +the desktop client uses) so the CLI does not report a timeout while the daemon +is still waiting on the user; `--timeout-ms` still applies when it asks for +more. `ai.piLoginSubmit`'s `value` can be a raw API key: pass it through +`--input-json` built from an environment variable rather than typing it inline, +and never echo the result. + `ade tools` is deliberately not backed by a service action. The pinned-tool cache is a property of the machine's filesystem, not of a project runtime, so the command calls `src/services/tools/` in-process and works on a headless box with diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 756ae5e98..ec52e1ccf 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1100,6 +1100,45 @@ describe("adeRpcServer", () => { expect(runtime.ptyService.dispose).not.toHaveBeenCalled(); }); + it("exposes the Pi sign-in actions through ade actions, with the write half gated on cto", async () => { + // The CLI has no typed `pi login` command, so `ade actions run ai.piLogin*` + // is the only path to it. That makes these four names part of the CLI's + // contract: if the registry stops resolving them the escape hatch is gone. + const { runtime } = createRuntime(); + + const agentHandler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(agentHandler, { role: "agent" }); + const agentActions = await callTool(agentHandler, "list_ade_actions", { domain: "ai" }); + const agentNames = agentActions.structuredContent.actions.map( + (entry: { name: string }) => entry.name, + ); + expect(agentNames).toContain("ai.piLoginProviders"); + expect(agentNames).not.toContain("ai.piLoginStart"); + + // Rejected on the role gate, before the service is reached — an agent can + // never spawn a Pi worker waiting on a human that is not there. + const denied = await callTool(agentHandler, "run_ade_action", { + domain: "ai", + action: "piLoginStart", + args: { providerId: "anthropic" }, + }); + expect(denied.isError).toBe(true); + expect(JSON.stringify(denied.error)).toMatch(/elevated role/i); + + const ctoHandler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(ctoHandler, { callerId: "cto-1", role: "cto" }); + const ctoNames = (await callTool(ctoHandler, "list_ade_actions", { domain: "ai" })) + .structuredContent.actions.map((entry: { name: string }) => entry.name); + expect(ctoNames).toEqual( + expect.arrayContaining([ + "ai.piLoginProviders", + "ai.piLoginStart", + "ai.piLoginSubmit", + "ai.piLoginCancel", + ]), + ); + }); + it("routes app/navigate through the runtime navigation service", async () => { const { runtime } = createRuntime(); const navigate = vi.fn(async () => ({ ok: true, mode: "desktop", windowId: 7 })); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 99b25a34d..9a2c69602 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -46,6 +46,7 @@ import { DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, } from "../../desktop/src/shared/accountDirectory"; import { isAdeRuntimeNamedPipePath } from "../../desktop/src/shared/adeRuntimeIpc"; +import { PI_LOGIN_IPC_TIMEOUT_MS } from "../../desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy"; import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { generateRpcAuthToken } from "./rpcAuth"; import { JsonRpcClient } from "./tuiClient/jsonRpcClient"; @@ -2572,6 +2573,28 @@ describe("ADE CLI", () => { ).toThrow(/account actions accept object input/); }); + it("gives a Pi sign-in the daemon's own transport budget instead of the CLI default", () => { + // ai.piLoginStart blocks on a human finishing Pi's OAuth/device-code flow. + // The default 10-minute request budget expires inside that window, so the + // plan has to carry the shared long-running floor or the CLI reports a + // timeout for a sign-in the daemon is still legitimately running. + const login = expectExecutePlan( + buildCliPlan(["actions", "run", "ai.piLoginStart", "--arg", "providerId=anthropic"]), + ); + expect(login.minTimeoutMs).toBe(PI_LOGIN_IPC_TIMEOUT_MS); + expect(login.minTimeoutMs!).toBeGreaterThan(parseCliArgs([]).options.timeoutMs); + + // Everything else keeps the default budget; the floor is table-driven, not + // a blanket raise. + expect( + expectExecutePlan(buildCliPlan(["actions", "run", "ai.piLoginProviders"])).minTimeoutMs, + ).toBeUndefined(); + expect( + expectExecutePlan(buildCliPlan(["actions", "run", "git.push", "--arg", "laneId=lane-1"])) + .minTimeoutMs, + ).toBeUndefined(); + }); + it("builds chat create with both model and modelId plus explicit reasoning and fast-mode args", () => { // This strict-equality assertion must not absorb the ambient parent // default when the test itself runs inside an ADE-tracked agent shell. diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 1066dd817..d18e4027d 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -55,6 +55,7 @@ import { readAutomationsEnvOverride, } from "../../desktop/src/shared/automationAvailability"; import { parseLinearGraphQLInput } from "../../desktop/src/main/services/cto/linearGraphQLInput"; +import { longRunningLocalRuntimeActionTimeoutMs } from "../../desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy"; import { browseProjectDirectories } from "../../desktop/src/main/services/projects/projectBrowserService"; import { createProjectScaffoldService } from "../../desktop/src/main/services/projects/projectScaffoldService"; import { resolveRepoRoot } from "../../desktop/src/main/services/projects/projectService"; @@ -324,6 +325,18 @@ type CliPlan = * account action is CTO-only, so it must connect as the machine operator. */ connectRole?: GlobalOptions["role"]; + /** + * Floor for this plan's request timeout, in milliseconds. `--timeout-ms` + * (and its 10-minute default) still applies when it is the larger of the + * two, so a caller can always ask for more patience but never less than + * the daemon's own budget for the action being run. + * + * Sourced from the shared long-running action table the desktop client + * uses, so the CLI cannot report a transport timeout while the daemon is + * still legitimately working — `ai.piLoginStart` blocks on a human + * finishing Pi's sign-in and is budgeted well past the CLI default. + */ + minTimeoutMs?: number; historyOperationId?: string; historyStatusFilter?: string; historyListFilters?: { @@ -11076,12 +11089,21 @@ function buildActionsPlan(args: string[]): CliPlan { } if (sub === "run") { const target = parseActionRunTarget(args); + // Actions the daemon is allowed to spend longer on than the client's + // default budget carry their own transport floor, straight from the table + // the desktop client reads. Without it `ade actions run ai.piLoginStart` + // reports a timeout at 10 minutes while the sign-in it started is still + // waiting on the human. + const minTimeoutMs = longRunningLocalRuntimeActionTimeoutMs( + `${target.domain}.${target.action}`, + ); return { kind: "execute", label: "action run", ...(target.domain === "chat" && target.action === "createScheduledWork" ? { formatter: "scheduled-work-create" as const } : {}), + ...(minTimeoutMs != null ? { minTimeoutMs } : {}), steps: [buildActionRunStep(args, target)], }; } @@ -21232,10 +21254,16 @@ async function executePlan( // A plan may force a specific runtime role for its connection (e.g. `ade // logout`, whose signOut account action is CTO-only). Honor it so the caller // asserts the operator role and the machine account gate resolves to cto. - const connectionOptions = + const roledConnectionOptions = plan.connectRole ? { ...baseConnectionOptions, role: plan.connectRole } : baseConnectionOptions; + // An explicit --timeout-ms above the floor still wins; the floor only stops + // the client from giving up while the daemon is still inside its own budget. + const connectionOptions = + plan.minTimeoutMs != null && plan.minTimeoutMs > roledConnectionOptions.timeoutMs + ? { ...roledConnectionOptions, timeoutMs: plan.minTimeoutMs } + : roledConnectionOptions; try { connection = await createConnection(connectionOptions, { autoRegisterProject: shouldAutoRegisterProjectForPlan(plan), diff --git a/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts b/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts index 0787c3e3d..7cb6f9dc9 100644 --- a/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts +++ b/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { piModelDescriptorsFromInventory, probePiProfileInventory, resolvePiInstallation } from "../ai/piInstallation"; +import { PI_APPROVAL_ALLOW } from "../chat/piSdkEventMapper"; import { acquirePiSdkConnection, releasePiSdkConnection, @@ -57,6 +58,47 @@ function writeCompletion(response: http.ServerResponse, model: string, text = "P response.end("data: [DONE]\n\n"); } +/** Emit an OpenAI-style tool call so the installed Pi actually invokes a tool. */ +function writeToolCall( + response: http.ServerResponse, + model: string, + toolName: string, + args: Record, +): void { + if (response.writableEnded) return; + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const emit = (payload: Record) => { + response.write(`data: ${JSON.stringify({ + id: "ade-pi-test-tool-call", + object: "chat.completion.chunk", + created: 1, + model, + ...payload, + })}\n\n`); + }; + emit({ + choices: [{ + index: 0, + delta: { + role: "assistant", + tool_calls: [{ + index: 0, + id: "ade-pi-test-call-1", + type: "function", + function: { name: toolName, arguments: JSON.stringify(args) }, + }], + }, + finish_reason: null, + }], + }); + emit({ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }); + response.end("data: [DONE]\n\n"); +} + function queueRequest(request: CompletionRequest): void { const waiter = requestWaiters.shift(); if (waiter) waiter(request); @@ -112,6 +154,9 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: { modelId?: string; tools?: string[]; session?: { sessionFile?: string; sessionId?: string }; + askUserTool?: boolean; + approvalTools?: string[]; + extensions?: boolean; }): { poolKey: string; packageRoot: string; @@ -124,6 +169,9 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: { systemPrompt: string; tools?: string[]; session?: { sessionFile?: string; sessionId?: string }; + askUserTool?: boolean; + approvalTools?: string[]; + extensions?: boolean; baseEnv: NodeJS.ProcessEnv; } { const installation = resolvePiInstallation({ @@ -146,6 +194,9 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: { systemPrompt: "You are the isolated ADE Pi integration test model.", ...(options?.tools ? { tools: options.tools } : {}), ...(options?.session ? { session: options.session } : {}), + ...(options?.askUserTool ? { askUserTool: true } : {}), + ...(options?.approvalTools ? { approvalTools: options.approvalTools } : {}), + ...(options?.extensions ? { extensions: true } : {}), baseEnv: { PATH: process.env.PATH ?? "", HOME: fixture.root, @@ -194,7 +245,11 @@ describeInstalledPi("installed Pi SDK worker", () => { response.end(JSON.stringify({ error: { message: "invalid local test credential" } })); return; } - if (!textFromMessages(body).includes("abort-me")) { + // `manual-stream` hands the response to the test so it can emit a tool + // call instead of the canned reply. The marker lives in the user turn, + // so it still applies on the follow-up request after a tool result. + const latestUserText = textFromMessages(body); + if (!latestUserText.includes("abort-me") && !latestUserText.includes("manual-stream")) { writeCompletion(response, typeof body.model === "string" ? body.model : "test-model"); } }); @@ -367,4 +422,145 @@ describeInstalledPi("installed Pi SDK worker", () => { await connection.pooled.sendPrompt({ prompt: "after abort" }); await nextRequest(); }); + + it("exposes ask_user as a real tool and returns the user's answer to the model", async () => { + const fixture = createFixture(); + const connection = await acquireTracked(fixture, `ask-user:${Date.now()}`, { askUserTool: true }); + + const seen: Array<{ requestId: string; payload: { origin: string; kind: string; message: string; options?: Array<{ value: string; label: string }> } }> = []; + connection.pooled.bridge.onUiRequest = (requestId, payload) => { + seen.push({ requestId, payload }); + // Answer with the second option to prove the value round-trips. + connection.pooled.respondToUi(requestId, { ok: true, value: payload.options?.[1]?.value ?? "" }); + }; + + const prompt = connection.pooled.sendPrompt({ prompt: "ask me something (manual-stream)" }); + const first = await nextRequest(); + const offered = ((first.body.tools as Array<{ function?: { name?: string } }> | undefined) ?? []) + .map((tool) => tool.function?.name); + expect(offered).toContain("ask_user"); + + writeToolCall(first.response, "test-model", "ask_user", { + question: "Which database should I use?", + header: "Database", + options: [{ label: "Postgres" }, { label: "SQLite" }], + }); + + // Pi calls the tool, the bridge raises a card, and the answer comes back as + // a tool result on the follow-up request. + const second = await nextRequest(); + expect(seen).toHaveLength(1); + expect(seen[0]!.payload).toMatchObject({ origin: "tool", kind: "select", message: "Which database should I use?" }); + expect(JSON.stringify(second.body.messages)).toContain("SQLite"); + writeCompletion(second.response, "test-model", "Using SQLite then."); + await prompt; + }); + + it("gates an approved tool behind a card and fails the call when the user denies it", async () => { + const fixture = createFixture(); + const marker = path.join(fixture.cwd, "approval-marker.txt"); + const connection = await acquireTracked(fixture, `approval:${Date.now()}`, { + tools: ["read", "bash", "edit", "write"], + approvalTools: ["bash"], + }); + + const requests: Array<{ origin: string; message: string }> = []; + connection.pooled.bridge.onUiRequest = (requestId, payload) => { + requests.push({ origin: payload.origin, message: payload.message }); + connection.pooled.respondToUi(requestId, { ok: false }); + }; + + const prompt = connection.pooled.sendPrompt({ prompt: "run a command (manual-stream)" }); + const first = await nextRequest(); + writeToolCall(first.response, "test-model", "bash", { command: `touch ${marker}` }); + + const second = await nextRequest(); + // The gate ran before the command did. + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ origin: "approval" }); + expect(requests[0]!.message).toContain("touch"); + expect(fs.existsSync(marker)).toBe(false); + expect(JSON.stringify(second.body.messages)).toMatch(/denied this bash call/iu); + writeCompletion(second.response, "test-model", "Understood."); + await prompt; + }); + + it("runs an approved tool for real once the user allows it", async () => { + const fixture = createFixture(); + const marker = path.join(fixture.cwd, "allowed-marker.txt"); + const connection = await acquireTracked(fixture, `approval-allow:${Date.now()}`, { + tools: ["read", "bash", "edit", "write"], + approvalTools: ["bash"], + }); + const approvals: string[] = []; + connection.pooled.bridge.onUiRequest = (requestId, payload) => { + approvals.push(payload.origin); + connection.pooled.respondToUi(requestId, { ok: true, value: PI_APPROVAL_ALLOW }); + }; + + const prompt = connection.pooled.sendPrompt({ prompt: "run a command (manual-stream)" }); + const first = await nextRequest(); + writeToolCall(first.response, "test-model", "bash", { command: `touch ${marker}` }); + const second = await nextRequest(); + // The command ran because the gate was asked and answered, not bypassed. + expect(approvals).toEqual(["approval"]); + expect(fs.existsSync(marker)).toBe(true); + writeCompletion(second.response, "test-model", "Done."); + await prompt; + }); + + it("answers a blocked worker request when no ADE surface is listening", async () => { + const fixture = createFixture(); + const connection = await acquireTracked(fixture, `no-surface:${Date.now()}`, { askUserTool: true }); + // Deliberately leave bridge.onUiRequest unset. + const prompt = connection.pooled.sendPrompt({ prompt: "ask with nobody home (manual-stream)" }); + const first = await nextRequest(); + writeToolCall(first.response, "test-model", "ask_user", { question: "Anyone there?" }); + // Without the pool's fail-closed reply this would hang until the test timed out. + const second = await nextRequest(); + expect(JSON.stringify(second.body.messages)).toMatch(/did not answer/iu); + writeCompletion(second.response, "test-model", "Proceeding."); + await prompt; + }); + + it("loads extensions behind the UI bridge and reports them on the ready payload", async () => { + const fixture = createFixture(); + const extensionDir = path.join(fixture.agentDir, "extensions"); + fs.mkdirSync(extensionDir, { recursive: true }); + fs.writeFileSync( + path.join(extensionDir, "ade-test-extension.js"), + "export default function(pi) { pi.registerCommand?.({ name: 'ade-test', description: 'ADE test', handler: () => {} }); }\n", + ); + const connection = await acquireTracked(fixture, `extensions:${Date.now()}`, { extensions: true }); + // Naming the fixture proves the bridge bound real extensions rather than + // reporting an empty list that an opted-out session would also produce. + const loaded = connection.pooled.ready?.extensions ?? []; + expect(connection.pooled.ready?.extensionsError ?? null).toBeNull(); + expect(loaded.map((extension) => extension.name ?? extension.id).join(" ")) + .toContain("ade-test-extension"); + }); + + it("never loads extensions from the checkout, only from the user's own profile", async () => { + const fixture = createFixture(); + // Pi auto-loads project extensions when the project is trusted, which its + // CLI only does after prompting. ADE opens repositories the user has not + // vouched for, so this must stay out of the session. + const projectExtensions = path.join(fixture.cwd, ".pi", "extensions"); + fs.mkdirSync(projectExtensions, { recursive: true }); + fs.writeFileSync( + path.join(projectExtensions, "repo-supplied.js"), + "export default function(pi) { pi.registerCommand?.({ name: 'repo-supplied', description: 'repo', handler: () => {} }); }\n", + ); + const userExtensions = path.join(fixture.agentDir, "extensions"); + fs.mkdirSync(userExtensions, { recursive: true }); + fs.writeFileSync( + path.join(userExtensions, "user-owned.js"), + "export default function(pi) { pi.registerCommand?.({ name: 'user-owned', description: 'user', handler: () => {} }); }\n", + ); + + const connection = await acquireTracked(fixture, `trust:${Date.now()}`, { extensions: true }); + const names = (connection.pooled.ready?.extensions ?? []).map((extension) => extension.name ?? extension.id); + expect(names.join(" ")).toContain("user-owned"); + expect(names.join(" ")).not.toContain("repo-supplied"); + }); }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 6755a3148..72d087812 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -11,6 +11,13 @@ import { startOAuth as startOpenCodeOAuth, type OpenCodeAuthDeps, } from "../opencode/openCodeAuthService"; +import { + addPiAuthStatusListener, + cancelPiLogin, + listPiLoginProviders, + startPiLogin, + submitPiLoginPrompt, +} from "../ai/piAuthService"; import { getLastFetchedAt as getModelsDevLastFetchedAt, refreshNow as refreshModelsDevNow } from "../ai/modelsDevService"; import { BUILT_IN_BROWSER_DESKTOP_BRIDGE_METHODS } from "../../../../../ade-cli/src/services/builtInBrowser/desktopBridgeMethods"; import type { @@ -230,7 +237,7 @@ export const ADE_ACTION_CTO_ONLY: Partial(); -function ensureOpenCodeOAuthStatusRelayBridge(runtime: AdeRuntime): void { - if (!runtime.eventBuffer || oauthStatusBridgedRuntimes.has(runtime)) return; - oauthStatusBridgedRuntimes.add(runtime); - const unsubscribe = addOpenCodeOAuthStatusListener((event) => { +// runtime; the listeners are detached when the runtime is disposed. +const authStatusBridgedRuntimes = new WeakSet(); +function ensureAuthStatusRelayBridges(runtime: AdeRuntime): void { + if (!runtime.eventBuffer || authStatusBridgedRuntimes.has(runtime)) return; + authStatusBridgedRuntimes.add(runtime); + const push = (kind: string, event: unknown): void => { try { runtime.eventBuffer.push({ timestamp: new Date().toISOString(), category: "runtime", - payload: { kind: "opencodeOAuthStatus", event }, + payload: { kind, event }, }); } catch { - // A full/broken buffer must not break the OAuth flow. + // A full/broken buffer must not break the sign-in flow. } - }); + }; + const unsubscribeOpenCode = addOpenCodeOAuthStatusListener((event) => push("opencodeOAuthStatus", event)); + const unsubscribePi = addPiAuthStatusListener((event) => push("piAuthStatus", event)); const dispose = runtime.dispose; runtime.dispose = () => { - unsubscribe(); + unsubscribeOpenCode(); + unsubscribePi(); dispose(); }; } @@ -2750,7 +2764,7 @@ function ensureOpenCodeOAuthStatusRelayBridge(runtime: AdeRuntime): void { function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { const aiIntegrationService = runtime.aiIntegrationService; if (!aiIntegrationService) return null; - ensureOpenCodeOAuthStatusRelayBridge(runtime); + ensureAuthStatusRelayBridges(runtime); const buildOpenCodeAuthDeps = (): OpenCodeAuthDeps => ({ projectRoot: runtime.projectRoot, projectConfig: runtime.projectConfigService.getEffective(), @@ -2778,6 +2792,37 @@ function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { clearOpenCodeProviderKey(buildOpenCodeAuthDeps(), { providerId: requireNonEmptyString(args?.providerId, "providerId"), }), + piLoginProviders: () => listPiLoginProviders(), + piLoginStart: async (args?: { providerId?: string; method?: "oauth" | "api_key" }) => { + const providerId = requireNonEmptyString(args?.providerId, "providerId"); + const result = await startPiLogin({ + providerId, + ...(args?.method ? { method: args.method } : {}), + }); + // Signing in unlocks models, so the readiness cache is stale until it is + // dropped. The IPC handler does the same; this keeps the action path + // (remote runtime, `ade actions run`) consistent with it. + if (result.ok) { + try { + aiIntegrationService.invalidateProviderReadinessCaches(); + } catch (error) { + runtime.logger.warn("ai.pi_auth_cache_invalidation_failed", { + provider: providerId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return result; + }, + piLoginSubmit: (args?: { providerId?: string; requestId?: string; value?: string }) => + submitPiLoginPrompt({ + providerId: requireNonEmptyString(args?.providerId, "providerId"), + requestId: requireNonEmptyString(args?.requestId, "requestId"), + value: typeof args?.value === "string" ? args.value : "", + }), + piLoginCancel: (args?: { providerId?: string }) => { + cancelPiLogin({ providerId: requireNonEmptyString(args?.providerId, "providerId") }); + }, refreshModelsDev: async () => { try { await refreshModelsDevNow(); diff --git a/apps/desktop/src/main/services/ai/piAuthService.test.ts b/apps/desktop/src/main/services/ai/piAuthService.test.ts new file mode 100644 index 000000000..2a195dbde --- /dev/null +++ b/apps/desktop/src/main/services/ai/piAuthService.test.ts @@ -0,0 +1,314 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { JsonValue } from "../chat/piSdkProtocol"; +import type { PiSdkBridge, PiSdkPooled } from "../chat/piSdkPool"; +import type { PiAuthStatusEvent } from "../../../shared/types/config"; +import type { PiInstallation } from "./piInstallation"; +import { + __getActivePiLoginProviderIdsForTests, + __resetPiAuthServiceForTests, + __setPiAuthHooksForTests, + addPiAuthStatusListener, + cancelPiLogin, + listPiLoginProviders, + startPiLogin, + submitPiLoginPrompt, +} from "./piAuthService"; + +const SECRET = "sk-super-secret-value"; + +const installation: PiInstallation = { + cliPath: "/usr/local/bin/pi", + packageRoot: "/pi/package", + packageEntry: "/pi/package/dist/index.js", + version: "0.84.0", + nodeVersion: "22.19.0", + sdkAvailable: true, + cliAvailable: true, + agentDir: "/home/user/.pi/agent", + settingsPath: "/home/user/.pi/agent/settings.json", + authPath: "/home/user/.pi/agent/auth.json", + modelsPath: "/home/user/.pi/agent/models.json", + modelsStorePath: "/home/user/.pi/agent/models-store.json", + blocker: null, +}; + +type FakeWorker = { + pooled: PiSdkPooled; + bridge: PiSdkBridge; + release: ReturnType; + login: ReturnType; + cancelLogin: ReturnType; + respondToUi: ReturnType; + finishLogin: () => void; + failLogin: (error: Error) => void; +}; + +function createFakeWorker(authInventory: JsonValue = []): FakeWorker { + const bridge: PiSdkBridge = { + onEvent: null, + onLifecycle: null, + onError: null, + onReady: null, + onUiRequest: null, + onUiNotice: null, + onUiCancel: null, + }; + let finishLogin!: () => void; + let failLogin!: (error: Error) => void; + const loginPromise = new Promise((resolve, reject) => { + finishLogin = resolve; + failLogin = reject; + }); + // A rejection is only observed once the service attaches its handler. + loginPromise.catch(() => undefined); + const login = vi.fn(() => loginPromise); + const cancelLogin = vi.fn(); + const respondToUi = vi.fn(); + const pooled = { + bridge, + login, + cancelLogin, + respondToUi, + requestAuth: vi.fn(async () => authInventory), + } as unknown as PiSdkPooled; + return { pooled, bridge, release: vi.fn(), login, cancelLogin, respondToUi, finishLogin, failLogin }; +} + +function installWorker(worker: FakeWorker): void { + __setPiAuthHooksForTests({ + resolveInstallation: () => installation, + acquireWorker: async () => ({ pooled: worker.pooled, release: worker.release }), + }); +} + +function collectEvents(): PiAuthStatusEvent[] { + const events: PiAuthStatusEvent[] = []; + addPiAuthStatusListener((event) => events.push(event)); + return events; +} + +/** Let the service's awaited hooks and settled promises run. */ +async function flush(): Promise { + await vi.advanceTimersByTimeAsync(0); +} + +beforeEach(() => { + __resetPiAuthServiceForTests(); + vi.useFakeTimers(); +}); + +afterEach(() => { + __resetPiAuthServiceForTests(); + vi.useRealTimers(); +}); + +describe("piAuthService", () => { + it("lists only providers with an interactive login, sorted by name", async () => { + const worker = createFakeWorker([ + { id: "zed", name: "Zed", authTypes: ["api_key"] }, + { id: "ambient", name: "Ambient", configured: true }, + { id: "xai", name: "xAI", authTypes: ["oauth"], loginLabel: "Sign in with SuperGrok", isSubscription: true, configured: true }, + ]); + installWorker(worker); + + const providers = await listPiLoginProviders(); + + expect(providers).toEqual([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: true, loginLabel: "Sign in with SuperGrok", isSubscription: true }, + { id: "zed", name: "Zed", authTypes: ["api_key"], configured: false }, + ]); + expect(worker.release).toHaveBeenCalledTimes(1); + }); + + it("releases the worker when the auth inventory request fails", async () => { + const worker = createFakeWorker(); + (worker.pooled.requestAuth as unknown as ReturnType).mockRejectedValue(new Error("worker died")); + installWorker(worker); + + await expect(listPiLoginProviders()).rejects.toThrow("worker died"); + expect(worker.release).toHaveBeenCalledTimes(1); + }); + + it("refuses to start when Pi's SDK package is unavailable", async () => { + const events = collectEvents(); + __setPiAuthHooksForTests({ + resolveInstallation: () => ({ ...installation, sdkAvailable: false, blocker: "Pi is not installed." }), + acquireWorker: async () => { + throw new Error("should not acquire a worker"); + }, + }); + + await expect(startPiLogin({ providerId: "xai" })).resolves.toEqual({ + ok: false, + error: "Pi is not installed.", + }); + expect(events).toEqual([{ providerId: "xai", state: "error", error: "Pi is not installed." }]); + }); + + it("relays a prompt, sends the answer back to Pi, and never emits the value", async () => { + const events = collectEvents(); + const worker = createFakeWorker(); + installWorker(worker); + + const started = startPiLogin({ providerId: "anthropic", method: "api_key" }); + await flush(); + + expect(worker.login).toHaveBeenCalledWith({ providerId: "anthropic", method: "api_key" }); + expect(__getActivePiLoginProviderIdsForTests()).toEqual(["anthropic"]); + + worker.bridge.onUiNotice?.({ + origin: "auth", + level: "info", + message: "Enter code ABCD-1234 at https://x.ai/device", + detail: { kind: "device_code", userCode: "ABCD-1234", verificationUri: "https://x.ai/device" }, + }); + worker.bridge.onUiRequest?.("req-1", { + origin: "auth", + kind: "secret", + message: "Paste your Anthropic API key", + placeholder: "sk-ant-…", + }); + + expect(events).toEqual([ + { providerId: "anthropic", state: "pending" }, + { + providerId: "anthropic", + state: "pending", + notice: { + level: "info", + message: "Enter code ABCD-1234 at https://x.ai/device", + userCode: "ABCD-1234", + verificationUri: "https://x.ai/device", + }, + }, + { + providerId: "anthropic", + state: "prompt", + prompt: { + requestId: "req-1", + kind: "secret", + title: "Sign in to anthropic", + message: "Paste your Anthropic API key", + placeholder: "sk-ant-…", + }, + }, + ]); + + expect(submitPiLoginPrompt({ providerId: "anthropic", requestId: "req-1", value: SECRET })).toEqual({ ok: true }); + expect(worker.respondToUi).toHaveBeenCalledWith("req-1", { ok: true, value: SECRET }); + // A stale answer for the same prompt must not reach Pi twice. + expect(submitPiLoginPrompt({ providerId: "anthropic", requestId: "req-1", value: SECRET }).ok).toBe(false); + expect(worker.respondToUi).toHaveBeenCalledTimes(1); + + worker.finishLogin(); + await expect(started).resolves.toEqual({ ok: true }); + + expect(events.at(-1)).toEqual({ providerId: "anthropic", state: "success" }); + expect(worker.release).toHaveBeenCalledTimes(1); + expect(__getActivePiLoginProviderIdsForTests()).toEqual([]); + expect(JSON.stringify(events)).not.toContain(SECRET); + }); + + it("maps an auth URL notice onto the status event", async () => { + const events = collectEvents(); + const worker = createFakeWorker(); + installWorker(worker); + + const started = startPiLogin({ providerId: "xai" }); + await flush(); + worker.bridge.onUiNotice?.({ + origin: "auth", + level: "info", + message: "Open this URL to continue signing in.", + detail: { kind: "auth_url", url: "https://accounts.x.ai/authorize" }, + }); + + expect(events.at(-1)?.notice).toEqual({ + level: "info", + message: "Open this URL to continue signing in.", + url: "https://accounts.x.ai/authorize", + }); + + cancelPiLogin({ providerId: "xai" }); + await started; + }); + + it("cancel answers the pending prompt, stops Pi, and releases the worker", async () => { + const events = collectEvents(); + const worker = createFakeWorker(); + installWorker(worker); + + const started = startPiLogin({ providerId: "xai" }); + await flush(); + worker.bridge.onUiRequest?.("req-9", { origin: "auth", kind: "manual_code", message: "Paste the code" }); + + cancelPiLogin({ providerId: "xai" }); + + expect(worker.respondToUi).toHaveBeenCalledWith("req-9", { ok: false, error: "Sign-in cancelled." }); + expect(worker.cancelLogin).toHaveBeenCalledTimes(1); + await expect(started).resolves.toEqual({ ok: false, error: "Sign-in cancelled." }); + expect(events.at(-1)).toEqual({ providerId: "xai", state: "error", error: "Sign-in cancelled." }); + expect(worker.release).toHaveBeenCalledTimes(1); + expect(__getActivePiLoginProviderIdsForTests()).toEqual([]); + }); + + it("times out an abandoned flow and releases the worker", async () => { + const events = collectEvents(); + const worker = createFakeWorker(); + installWorker(worker); + + const started = startPiLogin({ providerId: "xai" }); + await flush(); + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + + await expect(started).resolves.toEqual({ + ok: false, + error: "Pi sign-in timed out. Start it again when you're ready.", + }); + expect(worker.cancelLogin).toHaveBeenCalledTimes(1); + expect(events.at(-1)?.state).toBe("error"); + expect(worker.release).toHaveBeenCalledTimes(1); + expect(__getActivePiLoginProviderIdsForTests()).toEqual([]); + }); + + it("releases the worker when Pi's login rejects", async () => { + const events = collectEvents(); + const worker = createFakeWorker(); + installWorker(worker); + + const started = startPiLogin({ providerId: "xai" }); + await flush(); + worker.failLogin(new Error("Pi SDK login failed: provider refused")); + + await expect(started).resolves.toEqual({ + ok: false, + error: "Pi SDK login failed: provider refused", + }); + expect(events.at(-1)).toEqual({ + providerId: "xai", + state: "error", + error: "Pi SDK login failed: provider refused", + }); + expect(worker.release).toHaveBeenCalledTimes(1); + }); + + it("starting a second flow for the same provider supersedes the first", async () => { + const first = createFakeWorker(); + installWorker(first); + const started = startPiLogin({ providerId: "xai" }); + await flush(); + + const second = createFakeWorker(); + installWorker(second); + const restarted = startPiLogin({ providerId: "xai" }); + await flush(); + + await expect(started).resolves.toEqual({ ok: false, error: "Sign-in cancelled." }); + expect(first.release).toHaveBeenCalledTimes(1); + expect(__getActivePiLoginProviderIdsForTests()).toEqual(["xai"]); + + second.finishLogin(); + await expect(restarted).resolves.toEqual({ ok: true }); + expect(second.release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/main/services/ai/piAuthService.ts b/apps/desktop/src/main/services/ai/piAuthService.ts new file mode 100644 index 000000000..36ca0d483 --- /dev/null +++ b/apps/desktop/src/main/services/ai/piAuthService.ts @@ -0,0 +1,340 @@ +// --------------------------------------------------------------------------- +// Pi in-app sign-in +// +// Drives Pi's own `ModelRuntime.login()` from inside ADE: enumerate the +// providers that can actually be signed into, run one login on a dedicated +// inventory-only worker, and relay Pi's prompts/notices to whatever surface is +// listening. +// +// Credentials stay Pi's. The worker hands ADE prompt text and the user's answer +// travels straight back through `respondToUi`; nothing that could be a token is +// stored, logged, or put on a status event. +// --------------------------------------------------------------------------- + +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { + acquirePiSdkConnection, + releasePiSdkConnection, + type PiSdkPooled, +} from "../chat/piSdkPool"; +import type { + JsonValue, + PiSdkUiNoticePayload, + PiSdkUiRequestPayload, +} from "../chat/piSdkProtocol"; +import { resolvePiInstallation, type PiInstallation } from "./piInstallation"; +import type { + PiAuthNotice, + PiAuthPrompt, + PiAuthStatusEvent, + PiLoginMethod, + PiLoginProvider, +} from "../../../shared/types/config"; + +export type { PiAuthStatusEvent, PiLoginProvider } from "../../../shared/types/config"; + +/** + * Give up on a login after this long. A device-code grant legitimately takes + * minutes, so this is generous — it exists to stop an abandoned flow from + * holding a worker forever, not to pace the user. + */ +const PI_LOGIN_TIMEOUT_MS = 10 * 60 * 1000; + +export type PiLoginResult = { ok: true } | { ok: false; error: string }; + +type AcquiredWorker = { pooled: PiSdkPooled; release: () => void }; + +type PiAuthHooks = { + resolveInstallation(env: NodeJS.ProcessEnv): PiInstallation; + acquireWorker(installation: PiInstallation, purpose: string): Promise; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +const defaultHooks: PiAuthHooks = { + resolveInstallation(env) { + return resolvePiInstallation(env); + }, + async acquireWorker(installation, purpose) { + // Unique per flow so a sign-in can never join — or be evicted by — the + // pooled worker backing a chat session. + const poolKey = `pi-login:${installation.agentDir}:${purpose}:${randomUUID()}`; + const acquired = await acquirePiSdkConnection({ + poolKey, + packageRoot: installation.packageRoot!, + packageEntry: installation.packageEntry!, + cwd: path.resolve(process.cwd()), + agentDir: installation.agentDir, + inventoryOnly: true, + baseEnv: process.env, + }); + return { + pooled: acquired.pooled, + release: () => releasePiSdkConnection(poolKey, acquired.generation), + }; + }, +}; + +let hooks: PiAuthHooks = { ...defaultHooks }; +type StatusListener = (event: PiAuthStatusEvent) => void; +const statusListeners = new Set(); + +type ActiveFlow = { + pooled: PiSdkPooled; + /** The prompt Pi is currently blocked on, if any. */ + pendingRequestId: string | null; + finish: (result: PiLoginResult) => void; +}; +const activeFlows = new Map(); +/** Monotonic per-provider claim, so a superseded start can detect it lost. */ +const startGenerations = new Map(); + +/** + * Subscribe a sink to Pi sign-in transitions. Multiple sinks may coexist so the + * same transition can fan out to renderer windows and the runtime event buffer + * remote/web clients drain. Returns an unsubscribe function. + */ +export function addPiAuthStatusListener(listener: StatusListener): () => void { + statusListeners.add(listener); + return () => { + statusListeners.delete(listener); + }; +} + +function emit(event: PiAuthStatusEvent): void { + for (const listener of statusListeners) { + try { + listener(event); + } catch { + // A broken sink must not break the flow or starve other listeners. + } + } +} + +function jsonRecord(value: JsonValue | undefined): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value : null; +} + +function jsonString(value: JsonValue | undefined): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function toPrompt(requestId: string, payload: PiSdkUiRequestPayload, providerId: string): PiAuthPrompt { + return { + requestId, + kind: payload.kind, + title: payload.title?.trim() || `Sign in to ${providerId}`, + message: payload.message, + ...(payload.placeholder?.trim() ? { placeholder: payload.placeholder.trim() } : {}), + ...(payload.options?.length + ? { + options: payload.options.map((option) => ({ + value: option.value, + label: option.label, + ...(option.description?.trim() ? { description: option.description.trim() } : {}), + })), + } + : {}), + }; +} + +function toNotice(payload: PiSdkUiNoticePayload): PiAuthNotice { + const detail = jsonRecord(payload.detail); + return { + level: payload.level, + message: payload.message, + ...(jsonString(detail?.url) ? { url: jsonString(detail?.url)! } : {}), + ...(jsonString(detail?.userCode) ? { userCode: jsonString(detail?.userCode)! } : {}), + ...(jsonString(detail?.verificationUri) + ? { verificationUri: jsonString(detail?.verificationUri)! } + : {}), + }; +} + +function requireSdk(installation: PiInstallation): void { + if (installation.sdkAvailable && installation.packageRoot && installation.packageEntry) return; + throw new Error(installation.blocker ?? "Pi's SDK package is unavailable, so ADE cannot sign in for you."); +} + +function toLoginProvider(entry: JsonValue): PiLoginProvider | null { + const record = jsonRecord(entry); + const id = jsonString(record?.id); + if (!id) return null; + const rawTypes = Array.isArray(record?.authTypes) ? record.authTypes : []; + const authTypes = rawTypes.filter( + (value): value is PiLoginMethod => value === "oauth" || value === "api_key", + ); + if (!authTypes.length) return null; + return { + id, + name: jsonString(record?.name) ?? id, + authTypes, + configured: record?.configured === true + || record?.authenticated === true + || record?.isAuthenticated === true, + ...(jsonString(record?.loginLabel) ? { loginLabel: jsonString(record?.loginLabel)! } : {}), + ...(record?.isSubscription === true ? { isSubscription: true } : {}), + }; +} + +/** Providers ADE can run an interactive sign-in for, in display order. */ +export async function listPiLoginProviders( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const installation = hooks.resolveInstallation(env); + requireSdk(installation); + const { pooled, release } = await hooks.acquireWorker(installation, "inventory"); + try { + const inventory = await pooled.requestAuth(); + const providers = (Array.isArray(inventory) ? inventory : []) + .map(toLoginProvider) + .filter((provider): provider is PiLoginProvider => provider !== null); + return providers.sort((a, b) => a.name.localeCompare(b.name)); + } finally { + release(); + } +} + +/** + * Run one provider sign-in. Only one flow per provider is active at a time — + * starting another cancels the first. Resolves once the flow settles; the + * prompts in between arrive through `addPiAuthStatusListener`. + */ +export async function startPiLogin(args: { + providerId: string; + method?: PiLoginMethod | null; +}): Promise { + const providerId = args.providerId.trim(); + if (!providerId) return { ok: false, error: "Provider ID is required." }; + cancelPiLogin({ providerId }); + // Acquiring a worker is async, so two starts for the same provider can both + // get past the cancel above. Claim the provider synchronously and re-check + // after the await, or the loser would orphan a worker nobody releases. + const generation = (startGenerations.get(providerId) ?? 0) + 1; + startGenerations.set(providerId, generation); + + let acquired: AcquiredWorker; + try { + const installation = hooks.resolveInstallation(process.env); + requireSdk(installation); + acquired = await hooks.acquireWorker(installation, providerId); + } catch (error) { + const message = errorMessage(error); + emit({ providerId, state: "error", error: message }); + return { ok: false, error: message }; + } + + if (startGenerations.get(providerId) !== generation) { + acquired.pooled.cancelLogin(); + acquired.release(); + return { ok: false, error: "Sign-in was superseded by a newer attempt." }; + } + + const { pooled, release } = acquired; + return await new Promise((resolve) => { + let settled = false; + let timer: ReturnType | null = null; + const flow: ActiveFlow = { + pooled, + pendingRequestId: null, + finish: (result) => { + if (settled) return; + settled = true; + if (activeFlows.get(providerId) === flow) activeFlows.delete(providerId); + if (timer) clearTimeout(timer); + pooled.bridge.onUiRequest = null; + pooled.bridge.onUiNotice = null; + pooled.bridge.onUiCancel = null; + release(); + emit(result.ok + ? { providerId, state: "success" } + : { providerId, state: "error", error: result.error }); + resolve(result); + }, + }; + activeFlows.set(providerId, flow); + + pooled.bridge.onUiRequest = (requestId, payload) => { + flow.pendingRequestId = requestId; + emit({ providerId, state: "prompt", prompt: toPrompt(requestId, payload, providerId) }); + }; + // The worker settles its own prompts on timeout or abort. Without this the + // card would stay on screen waiting for an answer nothing is listening for. + pooled.bridge.onUiCancel = (requestId) => { + if (flow.pendingRequestId !== requestId) return; + flow.pendingRequestId = null; + emit({ providerId, state: "pending" }); + }; + pooled.bridge.onUiNotice = (payload) => { + emit({ providerId, state: "pending", notice: toNotice(payload) }); + }; + + emit({ providerId, state: "pending" }); + timer = setTimeout(() => { + pooled.cancelLogin(); + flow.finish({ ok: false, error: "Pi sign-in timed out. Start it again when you're ready." }); + }, PI_LOGIN_TIMEOUT_MS); + timer.unref?.(); + + void pooled + .login({ providerId, ...(args.method ? { method: args.method } : {}) }) + .then(() => flow.finish({ ok: true })) + .catch((error: unknown) => flow.finish({ ok: false, error: errorMessage(error) })); + }); +} + +/** Answer the prompt Pi is blocked on. The value is relayed, never retained. */ +export function submitPiLoginPrompt(args: { + providerId: string; + requestId: string; + value: string; +}): { ok: boolean; error?: string } { + const flow = activeFlows.get(args.providerId.trim()); + if (!flow) return { ok: false, error: "That sign-in is no longer running." }; + if (flow.pendingRequestId !== args.requestId) { + return { ok: false, error: "That prompt has already been answered." }; + } + flow.pendingRequestId = null; + flow.pooled.respondToUi(args.requestId, { ok: true, value: args.value }); + return { ok: true }; +} + +/** Stop an in-flight sign-in (if any), release its worker, and settle it. */ +export function cancelPiLogin(args: { providerId: string }): void { + const providerId = args.providerId.trim(); + // Claiming a fresh generation cancels a start that is still acquiring its + // worker: it has no flow to find yet, so without this the cancel is a no-op + // and the sign-in the user stopped runs on holding a worker. + startGenerations.set(providerId, (startGenerations.get(providerId) ?? 0) + 1); + const flow = activeFlows.get(providerId); + if (!flow) return; + if (flow.pendingRequestId) { + flow.pooled.respondToUi(flow.pendingRequestId, { ok: false, error: "Sign-in cancelled." }); + flow.pendingRequestId = null; + } + flow.pooled.cancelLogin(); + flow.finish({ ok: false, error: "Sign-in cancelled." }); +} + +// --- Test hooks ------------------------------------------------------------ + +export function __setPiAuthHooksForTests(partial: Partial): void { + hooks = { ...hooks, ...partial }; +} + +export function __resetPiAuthServiceForTests(): void { + for (const providerId of [...activeFlows.keys()]) { + activeFlows.get(providerId)?.finish({ ok: false, error: "Pi auth service reset." }); + } + activeFlows.clear(); + startGenerations.clear(); + hooks = { ...defaultHooks }; + statusListeners.clear(); +} + +export function __getActivePiLoginProviderIdsForTests(): string[] { + return [...activeFlows.keys()]; +} diff --git a/apps/desktop/src/main/services/analytics/dailyUsageAnalytics.ts b/apps/desktop/src/main/services/analytics/dailyUsageAnalytics.ts index 4ae3c54eb..c9acd69f5 100644 --- a/apps/desktop/src/main/services/analytics/dailyUsageAnalytics.ts +++ b/apps/desktop/src/main/services/analytics/dailyUsageAnalytics.ts @@ -23,6 +23,9 @@ export function completedDailyUsageAnalyticsTarget( function coarseProvider(value: string): string { const normalized = value.trim().toLowerCase(); + // Pi is the harness, so a Pi-routed model reports as Pi rather than as the + // upstream provider its id happens to name. + if (normalized === "pi" || normalized.startsWith("pi/")) return "pi"; if (normalized.includes("codex")) return "codex"; if (normalized.includes("openai")) return "openai"; if (normalized.includes("claude") || normalized.includes("anthropic")) return "claude"; diff --git a/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts b/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts index b1dad387b..166f16401 100644 --- a/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts +++ b/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts @@ -170,7 +170,7 @@ const SAFE_STRING_VALUES: Partial>> = { // `ade_update_install_did_not_land`, so only the brain half is new signal. "service", "restart", "health", ]), - provider: new Set(["codex", "openai", "claude", "cursor", "droid", "opencode", "gemini", "local", "other"]), + provider: new Set(["codex", "openai", "claude", "cursor", "droid", "opencode", "pi", "gemini", "local", "other"]), model_family: new Set([ "gpt_5", "openai_reasoning", "claude_sonnet", "claude_opus", "claude_haiku", "cursor", "gemini", "grok", "local", "other", diff --git a/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts b/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts index 6e7d0ee5b..1f4d47d40 100644 --- a/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts +++ b/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts @@ -12,6 +12,7 @@ import { type ProductAnalyticsCapture, } from "../../../shared/types/productAnalytics"; import { captureAgentTurnSettledAnalytics } from "./agentTurnProductAnalytics"; +import { sanitizeProductAnalyticsProperties } from "./productAnalyticsPolicy"; import { captureDailyUsageAnalytics, completedDailyUsageAnalyticsTarget, @@ -1072,6 +1073,29 @@ function settledEvent(overrides: Partial = {}): Agent } describe("product analytics producers", () => { + it("keeps a Pi turn attributed to Pi instead of dropping it to the catch-all provider", () => { + const captures: ProductAnalyticsCapture[] = []; + const analytics = settledAnalytics(captures); + + captureAgentTurnSettledAnalytics({ + analytics, + projectId: "project-1", + event: settledEvent({ provider: "pi" }), + }); + + // Pi is a first-class harness; an unlisted value would be sanitized away + // and its sessions would be invisible in product analytics. + const settled = captures.find((capture) => capture.event === "ade_work_session_completed"); + expect(settled?.properties).toMatchObject({ feature: "chat", provider: "pi", outcome: "completed" }); + // Prove it survives the public sanitizer, not just the producer. + expect(sanitizeProductAnalyticsProperties("ade_work_session_completed", { + feature: "chat", + provider: "pi", + outcome: "completed", + source: "runtime", + })).toMatchObject({ provider: "pi" }); + }); + it("maps automation completion and failed chat turns into canonical bounded outcomes", () => { const captures: ProductAnalyticsCapture[] = []; const analytics = settledAnalytics(captures); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 7e3044e2f..e87386424 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -411,7 +411,7 @@ import { type ModelDescriptor, type ModelProviderGroup, } from "../../../shared/modelRegistry"; -import { piToolsForPermissionMode } from "../../../shared/cliLaunch"; +import { piSdkToolPolicyForPermissionMode } from "../../../shared/cliLaunch"; import { buildProviderGroupBlocks, createModelOrderMap, @@ -543,6 +543,7 @@ import { releasePiSdkConnection, type PiSdkPooled, } from "./piSdkPool"; +import type { PiSdkUiRequestPayload } from "./piSdkProtocol"; import { acquirePiSessionLease, piSessionCreationLeaseTarget, @@ -559,7 +560,13 @@ import { resolveCursorSdkModelSelectionParams, } from "./cursorModelsDiscovery"; import { discoverDroidSdkModelDescriptors } from "./droidModelsDiscovery"; -import { mapPiSdkEventToChatEvents } from "./piSdkEventMapper"; +import { + mapPiSdkEventToChatEvents, + piExtensionLoadNotice, + piUiNoticeToChatEvents, + piUiRequestToPendingInput, + piUiResponseFromAnswer, +} from "./piSdkEventMapper"; import { AUTO_LANE_IDENTITY_JSON_SCHEMA, AUTO_TITLE_SYSTEM_PROMPT, @@ -1787,6 +1794,8 @@ type PiRuntime = { modelId: string | null; activeCompactionId: string | null; lease: PiSessionLease | null; + /** Tool/extension policy this worker was built with; a change forces a restart. */ + toolPolicyKey: string; }; type ChatRuntime = CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime | PiRuntime; @@ -11106,6 +11115,38 @@ export function createAgentChatService(args: { throw new Error(`${descriptor.displayName} is reachable, but no models are currently loaded.`); }; + /** + * Render a blocking Pi request as an ADE question card and answer the worker + * with whatever the user chooses. + * + * The worker is holding a Pi callback open, so every path here must end in a + * `respondToUi` — including teardown, which `localPendingInputs` drains. + */ + const presentPiUiRequest = ( + managed: ManagedChatSession, + runtime: PiRuntime, + requestId: string, + payload: PiSdkUiRequestPayload, + ): void => { + const request = piUiRequestToPendingInput(requestId, payload, runtime.activeTurnId ?? null); + let answered = false; + managed.localPendingInputs.set(requestId, { + request, + resolve: (response) => { + // The worker holds a Pi callback open against this id, so it must be + // answered exactly once no matter which drain site got here first. + if (answered) return; + answered = true; + runtime.sdk.respondToUi(requestId, piUiResponseFromAnswer(payload, response)); + }, + }); + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: payload.message, + detail: { pi: true, origin: payload.origin, ...(payload.sourceId ? { sourceId: payload.sourceId } : {}) }, + }); + }; + const startPiRuntime = async (managed: ManagedChatSession): Promise => { if (piRuntimeSetupInterruptRequested.get(managed)) { piRuntimeSetupInterruptRequested.delete(managed); @@ -11123,8 +11164,22 @@ export function createAgentChatService(args: { if (piProviderId) managed.session.piProviderId = piProviderId; if (piModelId) managed.session.piModelId = piModelId; const poolKey = `pi:${projectRoot}:${path.resolve(managed.laneWorktreePath)}:${managed.session.id}:${piProfileId}`; + // Pi's tool allowlist and extension binding are fixed when the worker is + // created, so a session switched from default to plan would keep its write + // tools until something else restarted it. Restart on any policy change. + const piToolPolicy = piSdkToolPolicyForPermissionMode(managed.session.permissionMode); + const piExtensionsEnabled = piChatExtensionsEnabled(managed); + const toolPolicyKey = [ + piToolPolicy.tools.join(","), + piToolPolicy.approvalTools.join(","), + piExtensionsEnabled ? "ext" : "no-ext", + ].join("|"); if (managed.runtime?.kind === "pi") { - if (managed.runtime.poolKey === poolKey && isPiSdkPooledAlive(managed.runtime.sdk)) return managed.runtime; + if (managed.runtime.poolKey === poolKey + && managed.runtime.toolPolicyKey === toolPolicyKey + && isPiSdkPooledAlive(managed.runtime.sdk)) { + return managed.runtime; + } teardownRuntime(managed, "handle_close"); } else if (managed.runtime) { teardownRuntime(managed, "handle_close"); @@ -11190,10 +11245,13 @@ export function createAgentChatService(args: { orchestrationParentSessionId: managed.session.orchestrationParentSessionId, orchestrationStepId: managed.session.orchestrationStepId, }); - const piPermissionMode = toHarnessPermissionMode(managed.session.permissionMode); // Pi's built-in tool registry only contains read, bash, edit, and write. // Passing ADE's generic grep/find/ls names would make the SDK launch fail. - const piTools = piToolsForPermissionMode(piPermissionMode); + // + // The SDK policy is read from the session's own mode rather than the + // collapsed harness mode: in chat ADE can gate each call behind an approval + // card, so `default` keeps the write tools available but asks first. + const piTools = piToolPolicy.tools; let acquired: Awaited>; try { acquired = await acquirePiSdkConnection({ @@ -11204,6 +11262,11 @@ export function createAgentChatService(args: { agentDir: installation.agentDir, sessionDir, tools: piTools, + ...(piToolPolicy.approvalTools.length ? { approvalTools: piToolPolicy.approvalTools } : {}), + // Lets Pi ask the user a question mid-turn, which no tool allowlist can + // express. Personal chats get it too — it is how the model checks in. + askUserTool: true, + ...(piExtensionsEnabled ? { extensions: true } : {}), ...(piProviderId && piModelId ? { modelRef: { provider: piProviderId, id: piModelId } } : {}), thinkingLevel: managed.session.reasoningEffort ?? null, systemPrompt, @@ -11277,6 +11340,7 @@ export function createAgentChatService(args: { modelId: piModelId, activeCompactionId: null, lease: piLease, + toolPolicyKey, }; managed.runtime = runtime; managed.runtimeInvalidated = false; @@ -11298,6 +11362,32 @@ export function createAgentChatService(args: { persistChatState(managed); } }; + acquired.pooled.bridge.onUiRequest = (requestId, payload) => { + if (managed.runtime !== runtime) { + acquired.pooled.respondToUi(requestId, { ok: false, error: "This Pi session is no longer active." }); + return; + } + presentPiUiRequest(managed, runtime, requestId, payload); + }; + acquired.pooled.bridge.onUiCancel = (requestId) => { + if (managed.runtime !== runtime) return; + const pending = managed.localPendingInputs.get(requestId); + if (!pending) return; + managed.localPendingInputs.delete(requestId); + pending.resolve({ decision: "cancel" }); + emitPendingInputResolved(managed, { + itemId: requestId, + decision: "cancel", + turnId: pending.request.turnId ?? null, + questions: pending.request.questions, + }); + }; + acquired.pooled.bridge.onUiNotice = (payload) => { + if (managed.runtime !== runtime) return; + for (const event of piUiNoticeToChatEvents(payload, runtime.activeTurnId ?? undefined)) { + emitChatEvent(managed, event); + } + }; acquired.pooled.bridge.onError = (error, operation) => { if (managed.runtime !== runtime) return; logger.warn("agent_chat.pi_sdk_error", { sessionId: managed.session.id, operation: operation ?? null, error: error.message }); @@ -11310,6 +11400,16 @@ export function createAgentChatService(args: { persistChatState(managed); } }; + // Tell the user which of their extensions are live in this chat, and that + // the bridge is narrower than Pi's terminal UI. Only when they opted in — + // an unchanged default should not add a notice to every Pi chat. + for (const event of piExtensionLoadNotice( + piExtensionsEnabled ? acquired.pooled.ready?.extensions : undefined, + piExtensionsEnabled ? acquired.pooled.ready?.extensionsError : null, + acquired.pooled.ready?.ungateableTools ?? [], + )) { + emitChatEvent(managed, event); + } persistChatState(managed); sessionService.setResumeCommand(managed.session.id, `chat:pi:${managed.session.id}`); logger.info("agent_chat.pi_sdk_runtime_ready", { @@ -11461,6 +11561,27 @@ export function createAgentChatService(args: { return chat?.autoAllowAskUser !== false; }; + /** + * Pi extensions load in ADE chat by default, matching `pi` in a terminal. + * They are bound to ADE's limited UI bridge rather than Pi's TUI. + */ + const piChatExtensionsEnabled = (managed: ManagedChatSession): boolean => { + // A personal chat is not attached to a project worktree, so project-scoped + // extensions have no business loading into it. + if (isPersonalSession(managed.session)) return false; + // Enabling extensions means giving up Pi's `tools` allowlist, because an + // extension's tool names are not knowable until after the session exists — + // and an extension tool cannot be wrapped in an approval card the way a + // built-in can. So extensions load only in the modes that already grant + // their tools outright. A read-only mode promises no writes, and an + // ask-first mode promises a card before each one; neither promise survives + // an ungated tool, and quietly breaking it is worse than not loading the + // extension. + const policy = piSdkToolPolicyForPermissionMode(managed.session.permissionMode); + if (policy.readOnly || policy.approvalTools.length > 0) return false; + return projectConfigService.get().effective.ai?.chat?.piExtensionsEnabled !== false; + }; + const isAskUserToolName = (toolName: string | null | undefined): boolean => { if (!toolName) return false; const normalized = normalizeToolNameForApproval(toolName); @@ -16392,6 +16513,27 @@ export function createAgentChatService(args: { } }; + /** + * Cancel Pi cards that are still waiting on the user. + * + * The worker drains its own side when a turn aborts, but the desktop entry + * outlives it: without this the chat keeps rendering a card nobody can answer + * and `hasPendingInput` keeps reporting the session as blocked. + */ + const cancelPendingPiInputs = (managed: ManagedChatSession): void => { + for (const [itemId, pending] of [...managed.localPendingInputs]) { + if (pending.request.source !== "pi") continue; + managed.localPendingInputs.delete(itemId); + pending.resolve({ decision: "cancel" }); + emitPendingInputResolved(managed, { + itemId, + decision: "cancel", + turnId: pending.request.turnId ?? null, + questions: pending.request.questions, + }); + } + }; + /** Tear down the active runtime, releasing all resources and cancelling pending approvals. */ const teardownRuntime = ( managed: ManagedChatSession, @@ -16588,6 +16730,7 @@ export function createAgentChatService(args: { const rt = managed.runtime; rt.interrupted = true; cancelQueuedSteers(managed, rt, "interrupted"); + cancelPendingPiInputs(managed); if (preserveProviderResumeState) persistChatState(managed); if (isPiSdkPooledAlive(rt.sdk)) { void rt.sdk.abort().catch(() => {}); @@ -37518,6 +37661,7 @@ export function createAgentChatService(args: { // ignore } cancelQueuedSteers(managed, rt, "interrupted"); + cancelPendingPiInputs(managed); persistChatState(managed); return result; } diff --git a/apps/desktop/src/main/services/chat/piSdkEventMapper.test.ts b/apps/desktop/src/main/services/chat/piSdkEventMapper.test.ts new file mode 100644 index 000000000..1ed252a89 --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkEventMapper.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + PI_APPROVAL_ALLOW, + PI_APPROVAL_ALLOW_SESSION, + PI_UI_ANSWER_ID, + piExtensionLoadNotice, + piUiNoticeToChatEvents, + piUiRequestToPendingInput, + piUiResponseFromAnswer, +} from "./piSdkEventMapper"; +import type { PiSdkUiRequestPayload } from "./piSdkProtocol"; + +const question: PiSdkUiRequestPayload = { + origin: "tool", + kind: "select", + title: "Database", + message: "Which database?", + options: [{ value: "0", label: "Postgres", description: "Managed" }, { value: "1", label: "SQLite" }], +}; +const freeform: PiSdkUiRequestPayload = { origin: "tool", kind: "text", title: "Why", message: "Why that one?" }; +const approval: PiSdkUiRequestPayload = { origin: "approval", kind: "confirm", title: "Run bash?", message: "npm test" }; + +describe("piUiRequestToPendingInput", () => { + it("builds a pick-only card for a choice prompt", () => { + const request = piUiRequestToPendingInput("req-1", question, "turn-1"); + expect(request).toMatchObject({ requestId: "req-1", itemId: "req-1", source: "pi", kind: "structured_question", turnId: "turn-1" }); + // Free text cannot map back to an option id, which is what Pi expects. + expect(request.allowsFreeform).toBe(false); + expect(request.questions[0]).toMatchObject({ id: PI_UI_ANSWER_ID, header: "Database", allowsFreeform: false }); + expect(request.questions[0]!.options).toEqual([ + { label: "Postgres", value: "0", description: "Managed" }, + { label: "SQLite", value: "1" }, + ]); + }); + + it("builds a freeform card when the prompt has no options", () => { + const request = piUiRequestToPendingInput("req-2", freeform, null); + expect(request.kind).toBe("question"); + expect(request.allowsFreeform).toBe(true); + expect(request.questions[0]!.options).toBeUndefined(); + }); + + it("carries an editor's starting text onto the card", () => { + // A Pi extension's `editor` prefill is the document being edited, so the + // card has to surface it rather than dropping it. + const request = piUiRequestToPendingInput("req-5", { ...freeform, defaultValue: "existing draft" }, null); + expect(request.questions[0]!.defaultAssumption).toBe("existing draft"); + }); + + it("marks a secret prompt so the answer is not echoed", () => { + const request = piUiRequestToPendingInput("req-3", { ...freeform, kind: "secret" }, null); + expect(request.questions[0]!.isSecret).toBe(true); + }); + + it("renders an approval as an approval card", () => { + expect(piUiRequestToPendingInput("req-4", approval, null).kind).toBe("approval"); + }); +}); + +describe("piUiResponseFromAnswer", () => { + it("returns the chosen option value", () => { + expect(piUiResponseFromAnswer(question, { decision: "accept", answers: { [PI_UI_ANSWER_ID]: "1" } })) + .toEqual({ ok: true, value: "1" }); + }); + + it("unwraps a multi-select answer and falls back to free text", () => { + expect(piUiResponseFromAnswer(question, { decision: "accept", answers: { [PI_UI_ANSWER_ID]: ["0"] } })) + .toEqual({ ok: true, value: "0" }); + expect(piUiResponseFromAnswer(freeform, { decision: "accept", responseText: "because it is simple" })) + .toEqual({ ok: true, value: "because it is simple" }); + }); + + it("reports a dismissed or declined card as unanswered", () => { + expect(piUiResponseFromAnswer(question, { decision: "cancel" })).toEqual({ ok: false }); + expect(piUiResponseFromAnswer(question, { decision: "decline" })).toEqual({ ok: false }); + }); + + it("keeps an approval an allow even when the surface attaches a comment", () => { + expect(piUiResponseFromAnswer(approval, { decision: "accept", responseText: "looks fine" })) + .toEqual({ ok: true, value: PI_APPROVAL_ALLOW }); + }); + + it("maps an approval decision onto the value the gate reads", () => { + expect(piUiResponseFromAnswer(approval, { decision: "accept" })).toEqual({ ok: true, value: PI_APPROVAL_ALLOW }); + expect(piUiResponseFromAnswer(approval, { decision: "accept_for_session" })) + .toEqual({ ok: true, value: PI_APPROVAL_ALLOW_SESSION }); + // An approval with no decision at all is a denial, never a silent allow. + expect(piUiResponseFromAnswer(approval, {})).toEqual({ ok: false }); + }); +}); + +describe("piUiNoticeToChatEvents", () => { + it("renders progress as transient activity and anything else as a notice", () => { + expect(piUiNoticeToChatEvents({ origin: "extension", level: "progress", message: "indexing" }, "turn-1")) + .toEqual([{ type: "activity", activity: "working", detail: "indexing", turnId: "turn-1" }]); + expect(piUiNoticeToChatEvents({ origin: "extension", level: "warn", message: "skipped" })) + .toEqual([{ type: "system_notice", noticeKind: "warning", message: "skipped" }]); + }); + + it("drops an empty message rather than emitting a blank row", () => { + expect(piUiNoticeToChatEvents({ origin: "tool", level: "info", message: " " })).toEqual([]); + }); +}); + +describe("piExtensionLoadNotice", () => { + it("names the extensions that loaded", () => { + const [notice] = piExtensionLoadNotice([{ id: "/x/git-info/index.ts", name: "git-info" }], null); + expect(notice).toMatchObject({ type: "system_notice", noticeKind: "info" }); + expect((notice as { message: string }).message).toContain("git-info"); + }); + + it("says which tools this Pi build cannot gate", () => { + const [notice] = piExtensionLoadNotice(undefined, null, ["bash", "write"]); + expect(notice).toMatchObject({ noticeKind: "warning" }); + expect((notice as { message: string }).message).toContain("bash, write"); + }); + + it("reports a load failure separately from a clean empty list", () => { + expect(piExtensionLoadNotice([], null)).toEqual([]); + const events = piExtensionLoadNotice([], "bad.ts: boom"); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ noticeKind: "warning" }); + }); +}); diff --git a/apps/desktop/src/main/services/chat/piSdkEventMapper.ts b/apps/desktop/src/main/services/chat/piSdkEventMapper.ts index fc6b55072..9922d6c2d 100644 --- a/apps/desktop/src/main/services/chat/piSdkEventMapper.ts +++ b/apps/desktop/src/main/services/chat/piSdkEventMapper.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import type { AgentChatEvent } from "../../../shared/types/chat"; +import type { AgentChatApprovalDecision, AgentChatEvent, PendingInputRequest } from "../../../shared/types/chat"; +import type { PiSdkExtensionInfo, PiSdkUiNoticePayload, PiSdkUiRequestPayload, PiSdkUiResponsePayload } from "./piSdkProtocol"; function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -81,3 +82,143 @@ export function mapPiSdkEventToChatEvents( } return []; } + +/** + * Render a non-blocking message from a Pi tool or extension. + * + * Sign-in notices never arrive here: login runs on `piAuthService`'s own + * worker and is rendered by Settings, not by a chat. + */ +export function piUiNoticeToChatEvents( + payload: PiSdkUiNoticePayload, + turnId?: string, +): AgentChatEvent[] { + const message = payload.message.trim(); + if (!message) return []; + + if (payload.level === "progress") { + return [{ type: "activity", activity: "working", detail: message, ...(turnId ? { turnId } : {}) }]; + } + return [{ + type: "system_notice", + noticeKind: payload.level === "info" ? "info" : "warning", + message, + ...(turnId ? { turnId } : {}), + }]; +} + +/** + * One-time summary of which Pi extensions loaded into this chat and what the + * UI bridge cannot render, so a missing terminal widget reads as a known + * limitation rather than a broken extension. + */ +export function piExtensionLoadNotice( + extensions: PiSdkExtensionInfo[] | undefined, + extensionsError: string | null | undefined, + ungateableTools: string[] = [], +): AgentChatEvent[] { + const events: AgentChatEvent[] = []; + if (ungateableTools.length) { + const list = ungateableTools.join(", "); + events.push({ + type: "system_notice", + noticeKind: "warning", + message: `This Pi build cannot ask before running ${list}, so ${ungateableTools.length === 1 ? "it is" : "they are"} unavailable in this chat.`, + }); + } + if (extensions?.length) { + const names = extensions.map((extension) => extension.name?.trim() || extension.id); + events.push({ + type: "system_notice", + noticeKind: "info", + // Extensions only load in modes that grant their tools outright, but an + // extension's own tools are still outside this chat's allowlist, so say + // so rather than implying they are bounded by it. + message: `Pi extensions active: ${names.join(", ")}. Their own tools aren't limited to this chat's tool list.`, + }); + } + if (extensionsError?.trim()) { + events.push({ + type: "system_notice", + noticeKind: "warning", + message: `Some Pi extensions did not load: ${extensionsError.trim()}`, + }); + } + return events; +} + +/** The single question id every Pi card uses; answers come back keyed by it. */ +export const PI_UI_ANSWER_ID = "answer"; + +/** Values `createPiApprovalGate` recognizes; shared so the two cannot drift. */ +export const PI_APPROVAL_ALLOW = "allow"; +export const PI_APPROVAL_ALLOW_SESSION = "allow_session"; + +/** Turn a blocking worker request into the ADE card that answers it. */ +export function piUiRequestToPendingInput( + requestId: string, + payload: PiSdkUiRequestPayload, + turnId: string | null, +): PendingInputRequest { + const options = payload.options?.map((option) => ({ + label: option.label, + value: option.value, + ...(option.description ? { description: option.description } : {}), + })); + const hasOptions = Boolean(options?.length); + return { + requestId, + itemId: requestId, + source: "pi", + kind: payload.origin === "approval" + ? "approval" + : hasOptions ? "structured_question" : "question", + title: payload.title ?? null, + description: payload.message, + questions: [{ + id: PI_UI_ANSWER_ID, + ...(payload.title ? { header: payload.title } : {}), + question: payload.message, + ...(hasOptions ? { options } : {}), + // A choice must be answered by picking: free text would not match an + // option id, which is what Pi expects back. + allowsFreeform: !hasOptions, + ...(payload.kind === "secret" ? { isSecret: true } : {}), + ...(payload.defaultValue != null ? { defaultAssumption: payload.defaultValue } : {}), + }], + allowsFreeform: !hasOptions, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { + pi: true, + origin: payload.origin, + promptKind: payload.kind, + ...(payload.sourceId ? { sourceId: payload.sourceId } : {}), + }, + turnId, + }; +} + +/** Translate the user's card answer into the reply the worker is waiting on. */ +export function piUiResponseFromAnswer( + payload: PiSdkUiRequestPayload, + response: { + decision?: AgentChatApprovalDecision; + answers?: Record; + responseText?: string | null; + }, +): PiSdkUiResponsePayload { + if (response.decision === "cancel" || response.decision === "decline") return { ok: false }; + const raw = response.answers?.[PI_UI_ANSWER_ID]; + const picked = Array.isArray(raw) ? raw[0] : raw; + const value = typeof picked === "string" && picked.length + ? picked + : typeof response.responseText === "string" ? response.responseText : ""; + if (payload.origin !== "approval") return { ok: true, value }; + // An approval card carries its verdict in the decision, not in text — a + // surface that attaches a comment to an accept must still read as an allow + // rather than as an unrecognized value the gate would deny. + if (response.decision === "accept_for_session") return { ok: true, value: PI_APPROVAL_ALLOW_SESSION }; + if (response.decision === "accept") return { ok: true, value: PI_APPROVAL_ALLOW }; + return value ? { ok: true, value } : { ok: false }; +} diff --git a/apps/desktop/src/main/services/chat/piSdkPool.ts b/apps/desktop/src/main/services/chat/piSdkPool.ts index c005b5fb1..cc069e439 100644 --- a/apps/desktop/src/main/services/chat/piSdkPool.ts +++ b/apps/desktop/src/main/services/chat/piSdkPool.ts @@ -17,7 +17,11 @@ import { type PiSdkPackageLocation, type PiSdkPromptPayload, type PiSdkImage, + type PiSdkLoginPayload, type PiSdkReady, + type PiSdkUiNoticePayload, + type PiSdkUiRequestPayload, + type PiSdkUiResponsePayload, type PiSdkWorkerInit, type PiSdkWorkerRequest, } from "./piSdkProtocol"; @@ -28,6 +32,16 @@ export type PiSdkBridge = { onLifecycle: ((event: string, requestId?: string, detail?: JsonValue) => void) | null; onError: ((error: Error, operation?: string, requestId?: string) => void) | null; onReady: ((ready: PiSdkReady) => void) | null; + /** + * The worker is blocked on a human answer. Reply with `respondToUi`. + * + * When no handler is installed the pool answers `ok: false` immediately, so + * an unattended worker fails closed instead of hanging a turn. + */ + onUiRequest: ((requestId: string, payload: PiSdkUiRequestPayload) => void) | null; + onUiNotice: ((payload: PiSdkUiNoticePayload) => void) | null; + /** The worker settled a request itself; drop the card raised for it. */ + onUiCancel: ((requestId: string) => void) | null; }; type PiSdkRequestType = PiSdkWorkerRequest["type"]; @@ -40,6 +54,8 @@ type PiSdkRequestArgs = K extends | "follow_up" | "set_model" | "set_thinking" + | "login" + | "ui_response" ? [payload: PiSdkRequestPayload] : K extends "compact" ? [payload?: PiSdkRequestPayload] @@ -71,6 +87,14 @@ export type PiSdkPooled = { compact: (payload?: PiSdkCompactPayload) => Promise; requestModels: () => Promise; requestAuth: () => Promise; + /** + * Run Pi's native sign-in for one provider. Deliberately untimed: a device + * code flow legitimately takes minutes. Use `cancelLogin` to stop it. + */ + login: (payload: PiSdkLoginPayload) => Promise; + cancelLogin: () => void; + /** Answer a `onUiRequest`. Fire-and-forget; the worker owns the waiter. */ + respondToUi: (requestId: string, response: PiSdkUiResponsePayload) => void; dispose: () => void; /** Resolves only after the worker process has actually exited. */ waitForExit: () => Promise; @@ -90,6 +114,9 @@ export type AcquirePiSdkConnectionArgs = PiSdkPackageLocation & { session?: PiSdkWorkerInit["session"]; tools?: string[]; noTools?: PiSdkWorkerInit["noTools"]; + extensions?: boolean; + askUserTool?: boolean; + approvalTools?: string[]; /** Usually process.env; never put auth.json or API keys in this payload. */ baseEnv?: NodeJS.ProcessEnv; logger?: Logger; @@ -203,6 +230,9 @@ function createPiSdkConnection(args: AcquirePiSdkConnectionArgs): Promise((resolve) => { resolveExit = resolve; }); - const bridge: PiSdkBridge = { onEvent: null, onLifecycle: null, onError: null, onReady: null }; + const bridge: PiSdkBridge = { onEvent: null, onLifecycle: null, onError: null, onReady: null, onUiRequest: null, onUiNotice: null, onUiCancel: null }; let terminalFailure: ((error: Error) => void) | null = null; const ipcClosed = (): Error => new Error("Pi SDK worker IPC channel is closed."); @@ -340,6 +370,15 @@ function createPiSdkConnection(args: AcquirePiSdkConnectionArgs): Promise worker.request("auth"), + login: (payload) => worker.request("login", payload), + cancelLogin: () => { + send({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "login_cancel", requestId: randomUUID() }); + }, + respondToUi: (requestId, response) => { + // The worker keys its waiter by this id, so the reply reuses it rather + // than opening a request of its own. + send({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "ui_response", requestId, payload: response }); + }, waitForExit: () => exitPromise, dispose: () => { disposing = true; @@ -408,6 +447,24 @@ function createPiSdkConnection(args: AcquirePiSdkConnectionArgs): Promise { }); }); }); + +describe("protocol v2 message validation", () => { + const base = { protocolVersion: PI_SDK_PROTOCOL_VERSION, requestId: "r1" }; + + it("accepts the new worker requests and rejects malformed ones", () => { + expect(validatePiSdkWorkerRequest({ ...base, type: "login", payload: { providerId: "anthropic" } })).toBeNull(); + expect(validatePiSdkWorkerRequest({ ...base, type: "login_cancel" })).toBeNull(); + expect(validatePiSdkWorkerRequest({ ...base, type: "ui_response", payload: { ok: true, value: "x" } })).toBeNull(); + + expect(validatePiSdkWorkerRequest({ ...base, type: "login", payload: { providerId: " " } })).toMatch(/providerId/u); + expect(validatePiSdkWorkerRequest({ ...base, type: "login", payload: { providerId: "a", method: "" } })).toMatch(/method/u); + expect(validatePiSdkWorkerRequest({ ...base, type: "ui_response", payload: { value: "x" } })).toMatch(/ok/u); + expect(validatePiSdkWorkerRequest({ ...base, type: "ui_response", payload: { ok: true, value: 5 } })).toMatch(/value/u); + }); + + it("rejects init options that would widen the tool surface", () => { + const init = { + ...base, + type: "init", + payload: { protocolVersion: PI_SDK_PROTOCOL_VERSION, packageRoot: "/pkg", cwd: "/w", agentDir: "/a" }, + }; + expect(validatePiSdkWorkerRequest(init)).toBeNull(); + expect(validatePiSdkWorkerRequest({ ...init, payload: { ...init.payload, extensions: "yes" } })).toMatch(/extensions/u); + expect(validatePiSdkWorkerRequest({ ...init, payload: { ...init.payload, askUserTool: 1 } })).toMatch(/askUserTool/u); + expect(validatePiSdkWorkerRequest({ ...init, payload: { ...init.payload, approvalTools: ["bash", ""] } })).toMatch(/approvalTools/u); + }); + + it("validates ui_request and ui_notice coming back from the worker", () => { + const ok = { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + type: "ui_request", + requestId: "u1", + payload: { origin: "tool", kind: "select", message: "Which?", options: [{ value: "0", label: "A" }] }, + }; + expect(validatePiSdkWorkerResponse(ok)).toBeNull(); + expect(validatePiSdkWorkerResponse({ ...ok, payload: { ...ok.payload, origin: "elsewhere" } })).toMatch(/origin/u); + expect(validatePiSdkWorkerResponse({ ...ok, payload: { ...ok.payload, kind: "slider" } })).toMatch(/kind/u); + expect(validatePiSdkWorkerResponse({ ...ok, payload: { ...ok.payload, message: 7 } })).toMatch(/message/u); + expect(validatePiSdkWorkerResponse({ ...ok, payload: { ...ok.payload, options: [{ value: "0" }] } })).toMatch(/options/u); + expect(validatePiSdkWorkerResponse({ ...ok, requestId: "" })).toMatch(/requestId/u); + + const notice = { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + type: "ui_notice", + payload: { origin: "extension", level: "warn", message: "hi" }, + }; + expect(validatePiSdkWorkerResponse(notice)).toBeNull(); + expect(validatePiSdkWorkerResponse({ ...notice, payload: { ...notice.payload, level: "fatal" } })).toMatch(/level/u); + }); + + it("keeps extension metadata on ready payloads JSON-safe", () => { + const ready = { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + packageRoot: "/pkg", + packageEntry: "/pkg/dist/index.js", + version: "0.84.0", + sessionFile: null, + sessionId: null, + currentModel: null, + thinkingLevel: null, + availableModels: [], + }; + expect(validatePiSdkWorkerResult("init", { ...ready, extensions: [{ id: "/a/b.js", name: "b" }] })).toBeNull(); + expect(validatePiSdkWorkerResult("init", { ...ready, extensions: [{ name: "b" }] })).toMatch(/extensions/u); + expect(validatePiSdkWorkerResult("init", { ...ready, extensionsError: 3 })).toMatch(/extensionsError/u); + expect(validatePiSdkWorkerResult("login", { ok: true, providerId: "anthropic" })).toBeNull(); + expect(validatePiSdkWorkerResult("login", { providerId: "anthropic" })).toMatch(/ok/u); + }); +}); diff --git a/apps/desktop/src/main/services/chat/piSdkProtocol.ts b/apps/desktop/src/main/services/chat/piSdkProtocol.ts index aa1a86612..347e68800 100644 --- a/apps/desktop/src/main/services/chat/piSdkProtocol.ts +++ b/apps/desktop/src/main/services/chat/piSdkProtocol.ts @@ -5,7 +5,7 @@ * the bridge even when the user has not installed Pi. */ -export const PI_SDK_PROTOCOL_VERSION = 1 as const; +export const PI_SDK_PROTOCOL_VERSION = 2 as const; export const PI_SDK_MIN_NODE = "22.19.0" as const; export type JsonPrimitive = string | number | boolean | null; @@ -50,6 +50,20 @@ export type PiSdkWorkerInit = PiSdkPackageLocation & { /** Restrict built-in tools when an integration needs a read-only session. */ tools?: string[]; noTools?: "all" | "builtin"; + /** + * Load the user's Pi extensions and bind them to ADE's UI bridge. Off by + * default: extensions run arbitrary code, so ADE chat opts in explicitly and + * the Pi CLI stays the unrestricted escape hatch. + */ + extensions?: boolean; + /** Expose ADE's `ask_user` tool so the model can ask a blocking question. */ + askUserTool?: boolean; + /** + * Built-in tools that must clear an ADE approval card before each call. + * Empty means the `tools` allowlist is the only gate (the pre-approval + * behaviour). + */ + approvalTools?: string[]; }; export type PiSdkImage = { @@ -76,8 +90,80 @@ export type PiSdkCompactPayload = { customInstructions?: string | null; }; +/** + * Interactive surfaces the worker can drive from inside the Pi SDK. + * + * Pi hands ADE three different callback-shaped APIs — `AuthInteraction` during + * login, custom tool handlers, and an extension's UI context — that all need + * the same thing: ask the human something and block until they answer. One + * reverse-RPC channel serves all three so the desktop process has a single + * place to render cards and a single place to fail them closed. + */ +export type PiSdkUiOrigin = "auth" | "tool" | "extension" | "approval"; +/** + * `manual_code` is Pi's paste-the-code-from-your-browser step. It is a text + * field, but it is kept distinct so the card can say so — and because Pi races + * it against its OAuth callback server and aborts it when the callback wins. + */ +export type PiSdkUiPromptKind = "text" | "secret" | "select" | "confirm" | "manual_code"; + +export type PiSdkUiOption = { + value: string; + label: string; + description?: string | null; +}; + +export type PiSdkUiRequestPayload = { + origin: PiSdkUiOrigin; + kind: PiSdkUiPromptKind; + /** Card heading. Falls back to a per-origin default in the desktop process. */ + title?: string | null; + message: string; + placeholder?: string | null; + defaultValue?: string | null; + options?: PiSdkUiOption[]; + /** Provider id, extension id, or tool name — shown so the user knows who is asking. */ + sourceId?: string | null; + detail?: JsonValue; +}; + +export type PiSdkUiResponsePayload = { + /** False when the user dismissed, the turn was aborted, or the card timed out. */ + ok: boolean; + value?: string | null; + error?: string | null; +}; + +export type PiSdkUiNoticePayload = { + origin: PiSdkUiOrigin; + level: "info" | "warn" | "error" | "progress"; + message: string; + sourceId?: string | null; + /** + * Structured extras the desktop process renders specially — notably + * `{ url }` for an OAuth URL and `{ userCode, verificationUri }` for a + * device-code grant. + */ + detail?: JsonValue; +}; + +export type PiSdkLoginPayload = { + providerId: string; + /** Provider-specific login method id (`oauth`, `api`, …). Omit for Pi's default. */ + method?: string | null; +}; + +export type PiSdkExtensionInfo = { + id: string; + name?: string | null; + version?: string | null; +}; + export type PiSdkWorkerRequest = | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "init"; requestId: string; payload: PiSdkWorkerInit } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "login"; requestId: string; payload: PiSdkLoginPayload } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "login_cancel"; requestId: string } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "ui_response"; requestId: string; payload: PiSdkUiResponsePayload } | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "send"; requestId: string; payload: PiSdkPromptPayload } | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "steer"; requestId: string; payload: { prompt: string; images?: PiSdkImage[] } } | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "follow_up"; requestId: string; payload: { prompt: string; images?: PiSdkImage[] } } @@ -99,6 +185,12 @@ export type PiSdkReady = { currentModel: JsonValue | null; thinkingLevel: string | null; availableModels: JsonValue[]; + /** Extensions actually loaded into this session; empty when opted out. */ + extensions?: PiSdkExtensionInfo[]; + /** Why extensions could not be loaded, when the caller asked for them. */ + extensionsError?: string | null; + /** Tools withheld because this Pi build cannot supply a gateable definition. */ + ungateableTools?: string[]; }; export type PiSdkLifecycleName = @@ -124,6 +216,10 @@ export type PiSdkWorkerResponse = detail?: JsonValue; } | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "ready"; ready: PiSdkReady } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "ui_request"; requestId: string; payload: PiSdkUiRequestPayload } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "ui_notice"; payload: PiSdkUiNoticePayload } + /** The worker settled this request itself (extension timeout, abort, teardown). */ + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "ui_cancel"; requestId: string } | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "sdk_event"; event: JsonValue; requestId?: string } | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; @@ -165,6 +261,60 @@ function isJsonValue(value: unknown, depth = 0): value is JsonValue { return Object.values(value).every((item) => isJsonValue(item, depth + 1)); } +const UI_ORIGINS: PiSdkUiOrigin[] = ["auth", "tool", "extension", "approval"]; +const UI_PROMPT_KINDS: PiSdkUiPromptKind[] = ["text", "secret", "select", "confirm", "manual_code"]; + +function validateUiRequestPayload(payload: unknown): string | null { + if (!isRecord(payload)) return "Pi SDK ui_request payload must be an object."; + if (!UI_ORIGINS.includes(payload.origin as PiSdkUiOrigin)) return "Pi SDK ui_request origin is invalid."; + if (!UI_PROMPT_KINDS.includes(payload.kind as PiSdkUiPromptKind)) return "Pi SDK ui_request kind is invalid."; + if (typeof payload.message !== "string") return "Pi SDK ui_request message must be a string."; + for (const key of ["title", "placeholder", "defaultValue", "sourceId"] as const) { + if (payload[key] != null && typeof payload[key] !== "string") return `Pi SDK ui_request ${key} must be a string or null.`; + } + if (payload.options !== undefined) { + if (!Array.isArray(payload.options)) return "Pi SDK ui_request options must be an array."; + for (const option of payload.options) { + if (!isRecord(option) || typeof option.value !== "string" || typeof option.label !== "string") { + return "Pi SDK ui_request options must contain value and label strings."; + } + if (option.description != null && typeof option.description !== "string") { + return "Pi SDK ui_request option description must be a string or null."; + } + } + } + if (payload.detail !== undefined && !isJsonValue(payload.detail)) return "Pi SDK ui_request detail must be JSON-safe."; + return null; +} + +/** Shared by the `ready` event and by every result that returns ready data. */ +function validateReadyShape(ready: Record, label: string): string | null { + if (ready.protocolVersion !== PI_SDK_PROTOCOL_VERSION) return `Pi SDK ${label} has an invalid protocol version.`; + if (!nonEmptyString(ready.packageRoot) || !nonEmptyString(ready.packageEntry)) return `Pi SDK ${label} is missing package paths.`; + if (!("version" in ready) || (ready.version !== null && typeof ready.version !== "string")) return `Pi SDK ${label} version must be a string or null.`; + if (!("sessionFile" in ready) || (ready.sessionFile !== null && typeof ready.sessionFile !== "string")) return `Pi SDK ${label} sessionFile must be a string or null.`; + if (!("sessionId" in ready) || (ready.sessionId !== null && typeof ready.sessionId !== "string")) return `Pi SDK ${label} sessionId must be a string or null.`; + if (!("currentModel" in ready) || (ready.currentModel !== null && !isJsonValue(ready.currentModel))) return `Pi SDK ${label} currentModel must be JSON-safe.`; + if (!("thinkingLevel" in ready) || (ready.thinkingLevel !== null && typeof ready.thinkingLevel !== "string")) return `Pi SDK ${label} thinkingLevel must be a string or null.`; + if (!Array.isArray(ready.availableModels) || !ready.availableModels.every((model) => isJsonValue(model))) return `Pi SDK ${label} availableModels must be JSON-safe.`; + if (ready.extensions !== undefined && !isExtensionInfoList(ready.extensions)) return `Pi SDK ${label} extensions must be a list of {id}.`; + if (ready.extensionsError !== undefined && ready.extensionsError !== null && typeof ready.extensionsError !== "string") { + return `Pi SDK ${label} extensionsError must be a string or null.`; + } + if (ready.ungateableTools !== undefined + && (!Array.isArray(ready.ungateableTools) || !ready.ungateableTools.every((tool) => nonEmptyString(tool)))) { + return `Pi SDK ${label} ungateableTools must be an array of non-empty strings.`; + } + return null; +} + +function isExtensionInfoList(value: unknown): boolean { + return Array.isArray(value) && value.every((item) => isRecord(item) + && nonEmptyString(item.id) + && (item.name == null || typeof item.name === "string") + && (item.version == null || typeof item.version === "string")); +} + function isPackageLocation(value: Record): boolean { const paths = [value.packageDir, value.packageRoot, value.packageEntry].filter((item) => item !== undefined && item !== null); return paths.length > 0 && paths.every((item) => typeof item === "string" && item.trim().length > 0); @@ -210,6 +360,16 @@ export function validatePiSdkWorkerRequest(raw: unknown): string | null { if (payload.inventoryOnly != null && typeof payload.inventoryOnly !== "boolean") { return "Pi SDK inventoryOnly must be a boolean."; } + if (payload.extensions != null && typeof payload.extensions !== "boolean") { + return "Pi SDK extensions must be a boolean."; + } + if (payload.askUserTool != null && typeof payload.askUserTool !== "boolean") { + return "Pi SDK askUserTool must be a boolean."; + } + if (payload.approvalTools != null && (!Array.isArray(payload.approvalTools) + || payload.approvalTools.some((tool) => !nonEmptyString(tool)))) { + return "Pi SDK approvalTools must be an array of non-empty strings."; + } if (payload.session != null && !isSessionTarget(payload.session)) return "Pi SDK session target is invalid."; return null; } @@ -227,10 +387,17 @@ export function validatePiSdkWorkerRequest(raw: unknown): string | null { if (!isRecord(payload) || !isModelRef(payload.modelRef)) return "Pi SDK set_model requires a valid modelRef."; } else if (type === "set_thinking") { if (!isRecord(payload) || !nonEmptyString(payload.thinkingLevel)) return "Pi SDK set_thinking requires a thinkingLevel."; + } else if (type === "login") { + if (!isRecord(payload) || !nonEmptyString(payload.providerId)) return "Pi SDK login requires a providerId."; + if (payload.method != null && !nonEmptyString(payload.method)) return "Pi SDK login method cannot be empty."; + } else if (type === "ui_response") { + if (!isRecord(payload) || typeof payload.ok !== "boolean") return "Pi SDK ui_response requires ok."; + if (payload.value != null && typeof payload.value !== "string") return "Pi SDK ui_response value must be a string or null."; + if (payload.error != null && typeof payload.error !== "string") return "Pi SDK ui_response error must be a string or null."; } else if (type === "compact" && payload != null && (!isRecord(payload) || (payload.customInstructions != null && typeof payload.customInstructions !== "string"))) { return "Pi SDK compact payload is invalid."; - } else if (!["init", "abort", "dispose", "models", "auth", "compact"].includes(type)) { + } else if (!["init", "abort", "dispose", "models", "auth", "compact", "login", "login_cancel", "ui_response"].includes(type)) { return `Unsupported Pi SDK worker request: ${String(type)}.`; } return null; @@ -262,15 +429,26 @@ export function validatePiSdkWorkerResponse(raw: unknown): string | null { if (raw.type === "ready") { if (!isRecord(raw.ready)) return "Pi SDK ready response is missing ready data."; - const ready = raw.ready; - if (ready.protocolVersion !== PI_SDK_PROTOCOL_VERSION) return "Pi SDK ready response has an invalid protocol version."; - if (!nonEmptyString(ready.packageRoot) || !nonEmptyString(ready.packageEntry)) return "Pi SDK ready response is missing package paths."; - if (!("version" in ready) || (ready.version !== null && typeof ready.version !== "string")) return "Pi SDK ready version must be a string or null."; - if (!("sessionFile" in ready) || (ready.sessionFile !== null && typeof ready.sessionFile !== "string")) return "Pi SDK ready sessionFile must be a string or null."; - if (!("sessionId" in ready) || (ready.sessionId !== null && typeof ready.sessionId !== "string")) return "Pi SDK ready sessionId must be a string or null."; - if (!("currentModel" in ready) || (ready.currentModel !== null && !isJsonValue(ready.currentModel))) return "Pi SDK ready currentModel must be JSON-safe."; - if (!("thinkingLevel" in ready) || (ready.thinkingLevel !== null && typeof ready.thinkingLevel !== "string")) return "Pi SDK ready thinkingLevel must be a string or null."; - if (!Array.isArray(ready.availableModels) || !ready.availableModels.every((model) => isJsonValue(model))) return "Pi SDK ready availableModels must be JSON-safe."; + return validateReadyShape(raw.ready, "ready"); + } + + if (raw.type === "ui_request") { + if (!nonEmptyString(raw.requestId)) return "Pi SDK ui_request is missing requestId."; + return validateUiRequestPayload(raw.payload); + } + + if (raw.type === "ui_cancel") { + return nonEmptyString(raw.requestId) ? null : "Pi SDK ui_cancel is missing requestId."; + } + + if (raw.type === "ui_notice") { + const payload = raw.payload; + if (!isRecord(payload)) return "Pi SDK ui_notice payload must be an object."; + if (!UI_ORIGINS.includes(payload.origin as PiSdkUiOrigin)) return "Pi SDK ui_notice origin is invalid."; + if (!["info", "warn", "error", "progress"].includes(payload.level as string)) return "Pi SDK ui_notice level is invalid."; + if (typeof payload.message !== "string") return "Pi SDK ui_notice message must be a string."; + if (payload.sourceId != null && typeof payload.sourceId !== "string") return "Pi SDK ui_notice sourceId must be a string or null."; + if (payload.detail !== undefined && !isJsonValue(payload.detail)) return "Pi SDK ui_notice detail must be JSON-safe."; return null; } @@ -320,14 +498,12 @@ export function validatePiSdkWorkerResult( ): string | null { if (type === "init" || type === "set_model" || type === "set_thinking") { if (!isRecord(result)) return `Pi SDK ${type} result must contain ready session data.`; - if (result.protocolVersion !== PI_SDK_PROTOCOL_VERSION) return `Pi SDK ${type} result has an invalid protocol version.`; - if (!nonEmptyString(result.packageRoot) || !nonEmptyString(result.packageEntry)) return `Pi SDK ${type} result is missing package paths.`; - if (!("version" in result) || (result.version !== null && typeof result.version !== "string")) return `Pi SDK ${type} version must be a string or null.`; - if (!("sessionFile" in result) || (result.sessionFile !== null && typeof result.sessionFile !== "string")) return `Pi SDK ${type} sessionFile must be a string or null.`; - if (!("sessionId" in result) || (result.sessionId !== null && typeof result.sessionId !== "string")) return `Pi SDK ${type} sessionId must be a string or null.`; - if (!("currentModel" in result) || (result.currentModel !== null && !isJsonValue(result.currentModel))) return `Pi SDK ${type} currentModel must be JSON-safe.`; - if (!("thinkingLevel" in result) || (result.thinkingLevel !== null && typeof result.thinkingLevel !== "string")) return `Pi SDK ${type} thinkingLevel must be a string or null.`; - if (!Array.isArray(result.availableModels) || !result.availableModels.every((model) => isJsonValue(model))) return `Pi SDK ${type} availableModels must be JSON-safe.`; + return validateReadyShape(result, `${type} result`); + } + if (type === "login") { + if (!isRecord(result)) return "Pi SDK login result must be an object."; + if (typeof result.ok !== "boolean") return "Pi SDK login result must report ok."; + if (result.providerId !== undefined && !nonEmptyString(result.providerId)) return "Pi SDK login result providerId must be a non-empty string."; return null; } if (type === "models") { diff --git a/apps/desktop/src/main/services/chat/piSdkUiBridge.test.ts b/apps/desktop/src/main/services/chat/piSdkUiBridge.test.ts new file mode 100644 index 000000000..8fff0993d --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkUiBridge.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PI_LOGIN_CANCELLED_MESSAGE, + createPiApprovalGate, + createPiAuthInteraction, + createPiExtensionUiContext, + createPiUiBridge, + piAskUserRequestFromArgs, + piAskUserResultText, + piApprovalSummary, + piUiOptionsFromLabels, + withPiApproval, + type PiToolDefinitionLike, +} from "./piSdkUiBridge"; +import { PI_APPROVAL_ALLOW, PI_APPROVAL_ALLOW_SESSION } from "./piSdkEventMapper"; +import type { PiSdkWorkerResponse } from "./piSdkProtocol"; + +function harness() { + const sent: PiSdkWorkerResponse[] = []; + let counter = 0; + const bridge = createPiUiBridge((message) => sent.push(message), () => `req-${++counter}`); + const requests = () => sent.filter((message) => message.type === "ui_request") as Array< + Extract + >; + const notices = () => sent.filter((message) => message.type === "ui_notice") as Array< + Extract + >; + return { bridge, sent, requests, notices }; +} + +describe("createPiUiBridge", () => { + it("round-trips an answer and stops tracking the request", async () => { + const { bridge, requests } = harness(); + const pending = bridge.request({ origin: "tool", kind: "text", message: "Which one?" }); + expect(bridge.pendingCount()).toBe(1); + + expect(bridge.resolve(requests()[0]!.requestId, { ok: true, value: "left" })).toBe(true); + await expect(pending).resolves.toBe("left"); + expect(bridge.pendingCount()).toBe(0); + }); + + it("resolves to null instead of rejecting when the user dismisses the card", async () => { + const { bridge, requests } = harness(); + const pending = bridge.request({ origin: "tool", kind: "text", message: "Which one?" }); + bridge.resolve(requests()[0]!.requestId, { ok: false }); + await expect(pending).resolves.toBeNull(); + }); + + it("ignores a second answer for the same request", async () => { + const { bridge, requests } = harness(); + const pending = bridge.request({ origin: "tool", kind: "text", message: "Which one?" }); + const requestId = requests()[0]!.requestId; + bridge.resolve(requestId, { ok: true, value: "first" }); + expect(bridge.resolve(requestId, { ok: true, value: "second" })).toBe(false); + await expect(pending).resolves.toBe("first"); + }); + + it("drains only the requested origin so a cancelled sign-in leaves a tool card alone", async () => { + const { bridge } = harness(); + const auth = bridge.request({ origin: "auth", kind: "text", message: "code?" }); + const tool = bridge.request({ origin: "tool", kind: "text", message: "which?" }); + + bridge.drain("auth"); + await expect(auth).resolves.toBeNull(); + expect(bridge.pendingCount()).toBe(1); + + bridge.drain(); + await expect(tool).resolves.toBeNull(); + }); + + it("refuses new requests once closed so teardown cannot hang a Pi callback", async () => { + const { bridge } = harness(); + const pending = bridge.request({ origin: "tool", kind: "text", message: "which?" }); + bridge.close(); + await expect(pending).resolves.toBeNull(); + await expect(bridge.request({ origin: "tool", kind: "text", message: "again?" })).resolves.toBeNull(); + }); + + it("tells the desktop when it settles a request on its own, so the card can close", async () => { + const { bridge, sent, requests } = harness(); + const controller = new AbortController(); + const pending = bridge.request({ origin: "extension", kind: "text", message: "which?" }, { signal: controller.signal }); + const requestId = requests()[0]!.requestId; + controller.abort(); + await pending; + expect(sent.some((message) => message.type === "ui_cancel" && message.requestId === requestId)).toBe(true); + }); + + it("does not echo a cancel for an answer the desktop supplied", async () => { + const { bridge, sent, requests } = harness(); + const pending = bridge.request({ origin: "tool", kind: "text", message: "which?" }); + bridge.resolve(requests()[0]!.requestId, { ok: true, value: "x" }); + await pending; + expect(sent.some((message) => message.type === "ui_cancel")).toBe(false); + }); + + it("survives a signal whose listener registration throws", async () => { + const { bridge } = harness(); + const hostile = { + aborted: false, + addEventListener: () => { throw new Error("hostile extension"); }, + removeEventListener: () => undefined, + }; + await expect(bridge.request({ origin: "extension", kind: "text", message: "x" }, { signal: hostile })) + .resolves.toBeNull(); + expect(bridge.pendingCount()).toBe(0); + }); + + it("honours an abort signal supplied by the caller", async () => { + const { bridge } = harness(); + const controller = new AbortController(); + const pending = bridge.request({ origin: "extension", kind: "text", message: "which?" }, { signal: controller.signal }); + controller.abort(); + await expect(pending).resolves.toBeNull(); + }); +}); + +describe("piUiOptionsFromLabels", () => { + it("disambiguates duplicate labels while preserving the original value", () => { + const mapped = piUiOptionsFromLabels(["Yes", "Yes", " padded "]); + expect(mapped.map((option) => option.label)).toEqual(["Yes", "Yes (2)", "padded"]); + // Pi expects the exact string it handed in, whitespace included. + expect(mapped[2]!.original).toBe(" padded "); + }); +}); + +describe("extension UI context", () => { + it("returns the extension's own option string for a selection", async () => { + const { bridge, requests } = harness(); + const ui = createPiExtensionUiContext({ bridge }); + const select = (ui.select as (t: string, o: string[]) => Promise)("Pick", ["alpha", "beta"]); + bridge.resolve(requests()[0]!.requestId, { ok: true, value: "1" }); + await expect(select).resolves.toBe("beta"); + }); + + it("denies a confirm the user never answered", async () => { + const { bridge, requests } = harness(); + const ui = createPiExtensionUiContext({ bridge }); + const confirm = (ui.confirm as (t: string, m: string) => Promise)("Delete?", "This cannot be undone."); + bridge.resolve(requests()[0]!.requestId, { ok: false }); + await expect(confirm).resolves.toBe(false); + }); + + it("keeps an editor's document when the user dismisses the card", async () => { + const { bridge, requests } = harness(); + const ui = createPiExtensionUiContext({ bridge }); + const editing = (ui.editor as (t: string, p?: string) => Promise)("Edit", "original text"); + expect(requests()[0]!.payload.defaultValue).toBe("original text"); + bridge.resolve(requests()[0]!.requestId, { ok: false }); + // Cancelling an editor means "leave it as it was", not "discard it". + await expect(editing).resolves.toBe("original text"); + }); + + it("warns once per unsupported terminal-only API", () => { + const { bridge, notices } = harness(); + const onUnsupported = vi.fn(); + const ui = createPiExtensionUiContext({ bridge, onUnsupported }); + (ui.setWidget as () => void)(); + (ui.setWidget as () => void)(); + expect(onUnsupported).toHaveBeenCalledTimes(1); + expect(notices()).toHaveLength(1); + }); + + it("suppresses a repeated status value but reports a changed one", () => { + const { bridge, notices } = harness(); + const ui = createPiExtensionUiContext({ bridge }); + const setStatus = ui.setStatus as (key: string, text?: string) => void; + setStatus("build", "compiling"); + setStatus("build", "compiling"); + setStatus("build", "linking"); + expect(notices().map((notice) => notice.payload.message)).toEqual(["build: compiling", "build: linking"]); + }); + + it("exposes a theme whose helpers return plain text", () => { + const { bridge } = harness(); + const ui = createPiExtensionUiContext({ bridge }); + const theme = ui.theme as { bold: (text: string) => string }; + expect(theme.bold("hello")).toBe("hello"); + }); +}); + +describe("auth interaction", () => { + it("answers a select prompt with the option id Pi expects", async () => { + const { bridge, requests } = harness(); + const interaction = createPiAuthInteraction(bridge, "anthropic"); + const pending = interaction.prompt({ + type: "select", + message: "How do you want to sign in?", + options: [{ id: "max", label: "Claude Max" }, { id: "key", label: "API key" }], + }); + const request = requests()[0]!; + expect(request.payload.options?.map((option) => option.value)).toEqual(["max", "key"]); + bridge.resolve(request.requestId, { ok: true, value: "max" }); + await expect(pending).resolves.toBe("max"); + }); + + it("rejects when the user cancels, which is how Pi ends a login", async () => { + const { bridge, requests } = harness(); + const interaction = createPiAuthInteraction(bridge, "anthropic"); + const pending = interaction.prompt({ type: "secret", message: "API key" }); + bridge.resolve(requests()[0]!.requestId, { ok: false }); + await expect(pending).rejects.toThrow(PI_LOGIN_CANCELLED_MESSAGE); + }); + + it("surfaces a device code with the value the user has to type", () => { + const { bridge, notices } = harness(); + createPiAuthInteraction(bridge, "github-copilot").notify({ + type: "device_code", + userCode: "ABCD-1234", + verificationUri: "https://github.com/login/device", + }); + const notice = notices()[0]!.payload; + expect(notice.message).toContain("ABCD-1234"); + expect(notice.detail).toMatchObject({ kind: "device_code", userCode: "ABCD-1234" }); + }); +}); + +describe("approval gating", () => { + const definition = (execute = vi.fn().mockResolvedValue({ content: [], details: null })): PiToolDefinitionLike => ({ + name: "bash", + label: "Bash", + description: "Run a command", + parameters: {}, + execute, + }); + + it("runs the tool once allowed", async () => { + const { bridge, requests } = harness(); + const execute = vi.fn().mockResolvedValue({ content: [], details: null }); + const wrapped = withPiApproval(definition(execute), createPiApprovalGate(bridge)); + const call = wrapped.execute("call-1", { command: "ls" }, undefined, undefined, {}); + bridge.resolve(requests()[0]!.requestId, { ok: true, value: PI_APPROVAL_ALLOW }); + await call; + expect(execute).toHaveBeenCalledTimes(1); + }); + + it("throws without running the tool when denied, so Pi marks the call failed", async () => { + const { bridge, requests } = harness(); + const execute = vi.fn(); + const wrapped = withPiApproval(definition(execute), createPiApprovalGate(bridge)); + const call = wrapped.execute("call-1", { command: "rm -rf /" }, undefined, undefined, {}); + bridge.resolve(requests()[0]!.requestId, { ok: false }); + await expect(call).rejects.toThrow(/denied this bash call/iu); + expect(execute).not.toHaveBeenCalled(); + }); + + it("stops asking after the user allows the tool for the session", async () => { + const { bridge, requests } = harness(); + const execute = vi.fn().mockResolvedValue({ content: [], details: null }); + const wrapped = withPiApproval(definition(execute), createPiApprovalGate(bridge)); + + const first = wrapped.execute("call-1", { command: "ls" }, undefined, undefined, {}); + bridge.resolve(requests()[0]!.requestId, { ok: true, value: PI_APPROVAL_ALLOW_SESSION }); + await first; + + await wrapped.execute("call-2", { command: "pwd" }, undefined, undefined, {}); + expect(requests()).toHaveLength(1); + expect(execute).toHaveBeenCalledTimes(2); + }); + + it("summarizes a call by its command or path rather than raw JSON", () => { + expect(piApprovalSummary("bash", { command: "npm test" })).toBe("npm test"); + expect(piApprovalSummary("write", { filePath: "/tmp/a.txt" })).toBe("/tmp/a.txt"); + expect(piApprovalSummary("edit", { unexpected: 1 })).toBe('{"unexpected":1}'); + }); +}); + +describe("ask_user arguments", () => { + it("builds a select card from options and a text card without them", () => { + const withOptions = piAskUserRequestFromArgs({ + question: "Which database?", + header: "Database", + options: [{ label: "Postgres", description: "Managed" }, { label: "" }], + }); + expect(withOptions.kind).toBe("select"); + // The blank label is dropped rather than rendering an unpickable choice. + expect(withOptions.options).toEqual([{ value: "0", label: "Postgres", description: "Managed" }]); + expect(piAskUserRequestFromArgs({ question: "Why?" }).kind).toBe("text"); + }); + + it("reports the chosen label back to the model, and says so when unanswered", () => { + const request = piAskUserRequestFromArgs({ question: "Which?", options: [{ label: "Postgres" }] }); + expect(piAskUserResultText(request, "0")).toBe("The user answered: Postgres"); + expect(piAskUserResultText(request, null)).toMatch(/did not answer/iu); + }); +}); diff --git a/apps/desktop/src/main/services/chat/piSdkUiBridge.ts b/apps/desktop/src/main/services/chat/piSdkUiBridge.ts new file mode 100644 index 000000000..1aabe84f5 --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkUiBridge.ts @@ -0,0 +1,652 @@ +/** + * Worker-side bridge between Pi's callback-shaped UI APIs and ADE's chat cards. + * + * Pi asks the human questions from three unrelated places — `AuthInteraction` + * during login, a custom tool's `execute`, and an extension's UI context — and + * all three are plain callbacks that block until they get a value back. This + * module funnels them into one reverse-RPC channel so the desktop process has a + * single place to render a card and a single place to fail one closed. + * + * Deliberately free of Pi imports: the desktop process bundles this file into + * the worker, which loads the user's Pi installation only after init validation. + */ +import { PI_APPROVAL_ALLOW, PI_APPROVAL_ALLOW_SESSION } from "./piSdkEventMapper"; +import { + PI_SDK_PROTOCOL_VERSION, + type PiSdkUiNoticePayload, + type PiSdkUiOption, + type PiSdkUiOrigin, + type PiSdkUiPromptKind, + type PiSdkUiRequestPayload, + type PiSdkUiResponsePayload, + type PiSdkWorkerResponse, +} from "./piSdkProtocol"; + +export type PiUiBridge = { + /** + * Ask the desktop process a question. Never rejects — a dismissed card, an + * aborted turn, or a disposed worker all resolve to `null`, so a Pi callback + * awaiting an answer degrades to "no answer" instead of throwing through the + * SDK's own error channel. + */ + request: (payload: PiSdkUiRequestPayload, options?: PiUiRequestOptions) => Promise; + notify: (payload: PiSdkUiNoticePayload) => void; + /** Settle a pending request with the desktop process's answer. */ + resolve: (requestId: string, response: PiSdkUiResponsePayload) => boolean; + /** + * Resolve outstanding requests to `null` without closing the bridge — used + * when a turn is aborted or a login is cancelled. Pass an origin to drain + * only that surface, so cancelling a sign-in leaves a tool's card alone. + */ + drain: (origin?: PiSdkUiOrigin) => void; + /** Drain everything and refuse further requests. Terminal. */ + close: () => void; + pendingCount: () => number; +}; + +export type PiUiRequestOptions = { + /** Honours the caller's own deadline; Pi extensions supply this themselves. */ + timeoutMs?: number; + signal?: { aborted: boolean; addEventListener: (type: "abort", listener: () => void, options?: { once?: boolean }) => void; removeEventListener: (type: "abort", listener: () => void) => void } | null; +}; + +type PendingUiRequest = { + origin: PiSdkUiOrigin; + /** `fromDesktop` suppresses the `ui_cancel` echo for an answer we were given. */ + settle: (value: string | null, fromDesktop?: boolean) => void; +}; + +export function createPiUiBridge( + post: (message: PiSdkWorkerResponse) => void, + makeId: () => string, +): PiUiBridge { + const pending = new Map(); + let closed = false; + + const request = (payload: PiSdkUiRequestPayload, options?: PiUiRequestOptions): Promise => { + if (closed || options?.signal?.aborted) return Promise.resolve(null); + const requestId = makeId(); + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + const onAbort = (): void => settle(null); + const settle = (value: string | null, fromDesktop = false): void => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + options?.signal?.removeEventListener("abort", onAbort); + pending.delete(requestId); + // A settle the desktop did not ask for — an extension's own timeout or + // abort signal — would otherwise leave its card on screen forever. + if (!fromDesktop && !closed) { + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "ui_cancel", requestId }); + } + resolve(value); + }; + + // Register before emitting so a synchronous answer cannot race the map. + pending.set(requestId, { origin: payload.origin, settle }); + try { + if (options?.timeoutMs !== undefined && options.timeoutMs > 0) { + timer = setTimeout(onAbort, options.timeoutMs); + timer.unref?.(); + } + options?.signal?.addEventListener("abort", onAbort, { once: true }); + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "ui_request", requestId, payload }); + } catch { + // An extension can hand over a signal-shaped object whose methods + // throw. Settling keeps the never-rejects contract and stops the entry + // leaking into `pending`. + settle(null); + } + }); + }; + + return { + request, + notify: (payload) => { + if (closed) return; + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "ui_notice", payload }); + }, + resolve: (requestId, response) => { + const waiter = pending.get(requestId); + if (!waiter) return false; + waiter.settle(response.ok ? response.value ?? "" : null, true); + return true; + }, + drain: (origin) => { + for (const waiter of [...pending.values()]) { + if (origin === undefined || waiter.origin === origin) waiter.settle(null); + } + }, + close: () => { + closed = true; + for (const waiter of [...pending.values()]) waiter.settle(null); + pending.clear(); + }, + pendingCount: () => pending.size, + }; +} + +function trimmed(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +/** + * Pi hands extensions a bare `string[]` of choices and expects the chosen + * string back verbatim. ADE's cards need a stable value and a unique label, so + * duplicates are disambiguated for display while `value` keeps the original — + * including any whitespace the extension deliberately included. + */ +export function piUiOptionsFromLabels(labels: readonly string[]): Array { + const seen = new Map(); + return labels.map((label, index) => { + const base = trimmed(label) ?? `Option ${index + 1}`; + const count = (seen.get(base) ?? 0) + 1; + seen.set(base, count); + return { + value: String(index), + label: count === 1 ? base : `${base} (${count})`, + description: base, + original: label, + }; + }); +} + +/** + * A no-op ANSI theme. + * + * Extensions call `ui.theme.bold(...)` unconditionally while rendering, so the + * property has to exist and return plain text rather than be absent. + */ +const PLAIN_THEME = { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, + underline: (text: string) => text, + inverse: (text: string) => text, + strikethrough: (text: string) => text, + dim: (text: string) => text, + getFgAnsi: () => "", + getBgAnsi: () => "", + getColorMode: () => "truecolor", + getThinkingBorderColor: () => (text: string) => text, + getBashModeBorderColor: () => (text: string) => text, +}; + +export type PiExtensionUiContextOptions = { + bridge: PiUiBridge; + /** Emitted once per unsupported API so a chatty extension cannot spam the thread. */ + onUnsupported?: (method: string) => void; +}; + +/** + * Implement Pi's `ExtensionUIContext` over ADE's card bridge. + * + * Three tiers: `select`/`confirm`/`input`/`editor` become real ADE cards, + * `notify`/`setStatus`/`setWorkingMessage`/`setTitle` become thread notices, + * and the TUI-only surface (widgets, footers, editor hooks, themes) no-ops — + * warning once for anything whose absence the user could actually notice. + */ +export function createPiExtensionUiContext(options: PiExtensionUiContextOptions): Record { + const { bridge } = options; + const warned = new Set(); + const statuses = new Map(); + let workingMessage: string | null = null; + + const warnUnsupported = (method: string): void => { + if (warned.has(method)) return; + warned.add(method); + options.onUnsupported?.(method); + bridge.notify({ + origin: "extension", + level: "warn", + message: `This Pi extension used "${method}", which only works in Pi's terminal UI. ADE ignored it.`, + detail: { method }, + }); + }; + const progress = (message: string): void => { + const normalized = trimmed(message); + if (normalized) bridge.notify({ origin: "extension", level: "progress", message: normalized }); + }; + const promptOptions = (opts: unknown): PiUiRequestOptions => { + const record = opts && typeof opts === "object" ? opts as Record : {}; + // An extension supplies these values, so a malformed `signal` must not + // reach `bridge.request` — calling addEventListener on it would throw and + // break the promise's never-rejecting contract. + const candidate = record.signal as { addEventListener?: unknown; removeEventListener?: unknown } | null | undefined; + const usableSignal = candidate + && typeof candidate.addEventListener === "function" + && typeof candidate.removeEventListener === "function" + ? candidate as PiUiRequestOptions["signal"] + : null; + return { + ...(typeof record.timeout === "number" && record.timeout > 0 ? { timeoutMs: record.timeout } : {}), + ...(usableSignal ? { signal: usableSignal } : {}), + }; + }; + + const context: Record = { + async select(title: string, choices: string[], opts?: unknown): Promise { + const mapped = piUiOptionsFromLabels(Array.isArray(choices) ? choices : []); + const answer = await bridge.request({ + origin: "extension", + kind: "select", + title: trimmed(title) ?? "Pi extension", + message: trimmed(title) ?? "Choose an option.", + options: mapped.map(({ value, label, description }) => ({ value, label, description })), + }, promptOptions(opts)); + if (answer === null) return undefined; + // Match on value first (what ADE's card sends back), then on the visible + // label so a surface that echoes the label still resolves. + const chosen = mapped.find((option) => option.value === answer) + ?? mapped.find((option) => option.label === answer); + return chosen?.original; + }, + async confirm(title: string, message?: string, opts?: unknown): Promise { + const answer = await bridge.request({ + origin: "extension", + kind: "confirm", + title: trimmed(title) ?? "Pi extension", + message: trimmed(message) ?? trimmed(title) ?? "Confirm this action?", + options: [ + { value: "yes", label: "Yes" }, + { value: "no", label: "No" }, + ], + }, promptOptions(opts)); + // Dismissal, timeout, and abort all land here as `null` — deny. + return answer === "yes"; + }, + async input(title: string, placeholder?: string, opts?: unknown): Promise { + const answer = await bridge.request({ + origin: "extension", + kind: "text", + title: trimmed(title) ?? "Pi extension", + message: trimmed(title) ?? trimmed(placeholder) ?? "Type a response.", + ...(trimmed(placeholder) ? { placeholder: trimmed(placeholder) } : {}), + }, promptOptions(opts)); + return answer === null ? undefined : answer; + }, + async editor(title: string, prefill?: string, opts?: unknown): Promise { + // An editor hands the user a document to change, so the prefill is its + // starting value — not a placeholder hint. Routing it through `input` + // dropped it, and a dismissed card then returned nothing, losing the very + // text the extension asked to have edited. Leaving it unchanged is what + // "cancel" means for an editor. + const answer = await bridge.request({ + origin: "extension", + kind: "text", + title: trimmed(title) ?? "Pi extension", + message: trimmed(title) ?? "Edit this text.", + ...(prefill != null ? { defaultValue: prefill } : {}), + }, promptOptions(opts)); + return answer ?? prefill; + }, + notify(message: string, type?: string): void { + const normalized = trimmed(message); + if (!normalized) return; + if (type === "warning" || type === "error") { + bridge.notify({ origin: "extension", level: type === "error" ? "error" : "warn", message: normalized }); + return; + } + progress(normalized); + }, + setStatus(key: string, text?: string): void { + const normalizedKey = trimmed(key) ?? "status"; + const normalizedText = trimmed(text); + if (!normalizedText) { + statuses.delete(normalizedKey); + return; + } + // Extensions re-set the same status on every render tick. + if (statuses.get(normalizedKey) === normalizedText) return; + statuses.set(normalizedKey, normalizedText); + progress(`${normalizedKey}: ${normalizedText}`); + }, + setWorkingMessage(message: string): void { + const normalized = trimmed(message); + if (!normalized || normalized === workingMessage) return; + workingMessage = normalized; + progress(normalized); + }, + setTitle(title: string): void { + if (trimmed(title)) progress(trimmed(title)!); + }, + + // Visual-only in Pi's TUI; silently ignored because nothing is lost. + setWorkingVisible(): void {}, + setWorkingIndicator(): void {}, + setHiddenThinkingLabel(): void {}, + setToolsExpanded(): void {}, + getToolsExpanded(): boolean { return false; }, + + // Surfaces whose absence the user could notice — warn once each. + setWidget(): void { warnUnsupported("setWidget"); }, + setFooter(): void { warnUnsupported("setFooter"); }, + setHeader(): void { warnUnsupported("setHeader"); }, + async custom(): Promise { warnUnsupported("custom"); return undefined; }, + onTerminalInput(): () => void { warnUnsupported("onTerminalInput"); return () => undefined; }, + pasteToEditor(): void { warnUnsupported("pasteToEditor"); }, + setEditorText(): void { warnUnsupported("setEditorText"); }, + getEditorText(): string { return ""; }, + addAutocompleteProvider(): void { warnUnsupported("addAutocompleteProvider"); }, + setEditorComponent(): void { warnUnsupported("setEditorComponent"); }, + getEditorComponent(): undefined { return undefined; }, + + theme: PLAIN_THEME, + getAllThemes(): unknown[] { return []; }, + getTheme(): undefined { return undefined; }, + setTheme(): { success: false; error: string } { + return { success: false, error: "ADE renders Pi extensions with its own chat styling." }; + }, + }; + return context; +} + +/** Pi's `AuthPrompt`, narrowed to the fields ADE reads. */ +type PiAuthPrompt = { + type: "text" | "secret" | "select" | "manual_code"; + message: string; + placeholder?: string; + options?: ReadonlyArray<{ id: string; label: string; description?: string }>; + signal?: PiUiRequestOptions["signal"]; +}; + +/** Pi's `AuthEvent`, narrowed to the fields ADE reads. */ +type PiAuthEvent = + | { type: "info"; message: string; links?: ReadonlyArray<{ url: string; label?: string }> } + | { type: "auth_url"; url: string; instructions?: string } + | { type: "device_code"; userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number } + | { type: "progress"; message: string }; + +/** Message Pi's own TUI uses for a user-cancelled login; Pi suppresses its error toast on this text. */ +export const PI_LOGIN_CANCELLED_MESSAGE = "Login cancelled"; + +/** + * Build the `AuthInteraction` ADE hands to `ModelRuntime.login`. + * + * ADE never sees a token: Pi's own `AuthStorage` writes `auth.json` under its + * cross-process lock. This only renders Pi's prompts and returns what the user + * typed or picked. + */ +export function createPiAuthInteraction( + bridge: PiUiBridge, + providerId: string, + signal?: AbortSignal, +): { signal?: AbortSignal; prompt: (prompt: PiAuthPrompt) => Promise; notify: (event: PiAuthEvent) => void } { + return { + ...(signal ? { signal } : {}), + prompt: async (prompt: PiAuthPrompt): Promise => { + const kind: PiSdkUiPromptKind = prompt.type === "select" + ? "select" + : prompt.type === "secret" + ? "secret" + : prompt.type === "manual_code" + ? "manual_code" + : "text"; + const answer = await bridge.request({ + origin: "auth", + kind, + title: `Sign in to ${providerId}`, + message: trimmed(prompt.message) ?? "Pi needs a value to continue signing in.", + sourceId: providerId, + ...(trimmed(prompt.placeholder) ? { placeholder: trimmed(prompt.placeholder) } : {}), + // Pi resolves a `select` prompt by option id, so the card's value must + // be the id rather than the visible label. + ...(prompt.type === "select" && Array.isArray(prompt.options) + ? { + options: prompt.options.map((option) => ({ + value: option.id, + label: trimmed(option.label) ?? option.id, + ...(trimmed(option.description) ? { description: trimmed(option.description) } : {}), + })), + } + : {}), + }, { + // Pi aborts an individual prompt when an out-of-band step wins the race + // (a manual-code entry superseded by the OAuth callback server). + ...(prompt.signal ? { signal: prompt.signal } : {}), + }); + // Pi's contract is reject-on-cancel, not resolve-with-undefined. + if (answer === null) throw new Error(PI_LOGIN_CANCELLED_MESSAGE); + return answer; + }, + notify: (event: PiAuthEvent): void => { + if (event.type === "auth_url") { + bridge.notify({ + origin: "auth", + level: "info", + message: trimmed(event.instructions) ?? "Open this URL to continue signing in.", + sourceId: providerId, + detail: { kind: "auth_url", url: event.url }, + }); + return; + } + if (event.type === "device_code") { + bridge.notify({ + origin: "auth", + level: "info", + message: `Enter code ${event.userCode} at ${event.verificationUri}`, + sourceId: providerId, + detail: { + kind: "device_code", + userCode: event.userCode, + verificationUri: event.verificationUri, + ...(event.expiresInSeconds != null ? { expiresInSeconds: event.expiresInSeconds } : {}), + }, + }); + return; + } + if (event.type === "info") { + bridge.notify({ + origin: "auth", + level: "info", + message: trimmed(event.message) ?? "", + sourceId: providerId, + ...(event.links?.length ? { detail: { kind: "info", links: event.links.map((link) => ({ url: link.url, ...(link.label ? { label: link.label } : {}) })) } } : {}), + }); + return; + } + bridge.notify({ + origin: "auth", + level: "progress", + message: trimmed(event.message) ?? "Working…", + sourceId: providerId, + }); + }, + }; +} + +/** JSON Schema for the `ask_user` tool ADE injects into Pi sessions. */ +export const PI_ASK_USER_TOOL_NAME = "ask_user"; + +export const PI_ASK_USER_PARAMETERS = { + type: "object", + properties: { + question: { + type: "string", + description: "The question to ask. Be specific and self-contained.", + }, + header: { + type: "string", + description: "Short label for the question card, 12 characters or fewer.", + }, + options: { + type: "array", + description: "Optional choices. Omit for a free-text answer.", + items: { + type: "object", + properties: { + label: { type: "string", description: "Short choice text shown to the user." }, + description: { type: "string", description: "What picking this choice means." }, + }, + required: ["label"], + }, + }, + }, + required: ["question"], +} as const; + +export const PI_ASK_USER_DESCRIPTION = [ + "Ask the user a question and wait for their answer.", + "Use this only when you are blocked on a decision that is genuinely theirs —", + "one you cannot resolve from the request, the code, or a sensible default.", + "Provide `options` when the answer is a choice between known alternatives;", + "omit them for free-text. The tool returns the user's answer, or a note that", + "they declined, in which case proceed with your best judgement.", +].join(" "); + +/** Normalize the model's `ask_user` arguments into an ADE card request. */ +export function piAskUserRequestFromArgs(args: unknown): PiSdkUiRequestPayload { + const record = args && typeof args === "object" ? args as Record : {}; + const question = trimmed(record.question) ?? "The agent needs your input before it can continue."; + const rawOptions = Array.isArray(record.options) ? record.options : []; + const options = rawOptions.flatMap((option, index): PiSdkUiOption[] => { + const entry = option && typeof option === "object" ? option as Record : {}; + const label = trimmed(entry.label); + if (!label) return []; + return [{ + value: String(index), + label, + ...(trimmed(entry.description) ? { description: trimmed(entry.description) } : {}), + }]; + }); + return { + origin: "tool", + kind: options.length ? "select" : "text", + title: trimmed(record.header) ?? "Question", + message: question, + sourceId: PI_ASK_USER_TOOL_NAME, + ...(options.length ? { options } : {}), + }; +} + +/** Map an ADE card answer back to the text the model receives. */ +export function piAskUserResultText(request: PiSdkUiRequestPayload, answer: string | null): string { + if (answer === null) { + return "The user did not answer. Continue with your best judgement and state the assumption you made."; + } + const chosen = request.options?.find((option) => option.value === answer); + return `The user answered: ${chosen ? chosen.label : answer}`; +} + +/** + * Human-readable one-liner describing a tool call awaiting approval. + * + * Pi's built-in tools use stable argument names, so the common cases read as a + * command or a path rather than as a JSON blob. + */ +export function piApprovalSummary(toolName: string, args: unknown): string { + const record = args && typeof args === "object" ? args as Record : {}; + const command = trimmed(record.command); + if (command) return command; + const filePath = trimmed(record.filePath) ?? trimmed(record.path) ?? trimmed(record.file); + if (filePath) return filePath; + try { + const encoded = JSON.stringify(record); + return encoded.length > 300 ? `${encoded.slice(0, 300)}…` : encoded; + } catch { + return toolName; + } +} + +/** + * Minimal shape of a Pi `ToolDefinition`. + * + * The worker loads Pi dynamically and cannot import its types, so tool + * definitions are described structurally. `parameters` is a TypeBox schema in + * Pi's typings, which at runtime is a plain JSON Schema object. + */ +export type PiToolDefinitionLike = { + name: string; + label: string; + description: string; + parameters: unknown; + promptSnippet?: string; + execute: ( + toolCallId: string, + params: unknown, + signal: AbortSignal | undefined, + onUpdate: unknown, + ctx: unknown, + ) => Promise<{ content: Array<{ type: "text"; text: string }>; details: unknown }>; +}; + +/** ADE's `ask_user` tool, resolved through the chat's question card. */ +export function createPiAskUserTool(bridge: PiUiBridge): PiToolDefinitionLike { + return { + name: PI_ASK_USER_TOOL_NAME, + label: "Ask user", + description: PI_ASK_USER_DESCRIPTION, + promptSnippet: "ask_user: ask the user a question and wait for their answer", + parameters: PI_ASK_USER_PARAMETERS, + execute: async (_toolCallId, params, signal) => { + const request = piAskUserRequestFromArgs(params); + const answer = await bridge.request(request, { ...(signal ? { signal } : {}) }); + const text = piAskUserResultText(request, answer); + return { content: [{ type: "text", text }], details: { answered: answer !== null, answer } }; + }, + }; +} + +export type PiApprovalGate = { + /** Resolves true when the call may proceed. */ + check: (toolName: string, args: unknown, signal: AbortSignal | undefined) => Promise; +}; + +/** + * Ask before each call to a gated tool, remembering an "allow for this chat" + * answer so the user is not asked about the same tool repeatedly. + */ +export function createPiApprovalGate(bridge: PiUiBridge): PiApprovalGate { + const alwaysAllow = new Set(); + return { + check: async (toolName, args, signal) => { + if (alwaysAllow.has(toolName)) return true; + const answer = await bridge.request(piApprovalRequest(toolName, args), { ...(signal ? { signal } : {}) }); + if (answer === PI_APPROVAL_ALLOW_SESSION) { + alwaysAllow.add(toolName); + return true; + } + // A dismissed card or an aborted turn denies the call. + return answer === PI_APPROVAL_ALLOW; + }, + }; +} + +/** + * Wrap a Pi tool definition so the call clears an ADE approval card first. + * + * The wrapper keeps the original name, so registering it through `customTools` + * replaces Pi's built-in of the same name while preserving its schema, prompt + * text, and rendering. + */ +export function withPiApproval( + definition: PiToolDefinitionLike, + gate: PiApprovalGate, +): PiToolDefinitionLike { + return { + ...definition, + execute: async (toolCallId, params, signal, onUpdate, ctx) => { + if (!await gate.check(definition.name, params, signal)) { + // Pi marks a tool call failed only when execute throws. + throw new Error(`The user denied this ${definition.name} call. Ask before trying it again, or take a different approach.`); + } + return definition.execute(toolCallId, params, signal, onUpdate, ctx); + }, + }; +} + +export function piApprovalRequest(toolName: string, args: unknown): PiSdkUiRequestPayload { + // No options: ADE renders an approval card with its own accept/decline + // controls and never reads `questions[].options`, so supplying them would + // ship labels nothing displays. + return { + origin: "approval", + kind: "confirm", + title: `Run ${toolName}?`, + message: piApprovalSummary(toolName, args), + sourceId: toolName, + }; +} diff --git a/apps/desktop/src/main/services/chat/piSdkWorker.ts b/apps/desktop/src/main/services/chat/piSdkWorker.ts index f216c9dc4..834dd11be 100644 --- a/apps/desktop/src/main/services/chat/piSdkWorker.ts +++ b/apps/desktop/src/main/services/chat/piSdkWorker.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -19,6 +20,17 @@ import { piSessionHeaderMatchesCwd, readPiSessionHeader, } from "./piSessionLease"; +import { + PI_ASK_USER_TOOL_NAME, + createPiApprovalGate, + createPiAskUserTool, + createPiAuthInteraction, + createPiExtensionUiContext, + createPiUiBridge, + withPiApproval, + type PiToolDefinitionLike, + type PiUiBridge, +} from "./piSdkUiBridge"; // Deliberately no static import (or type import) from Pi. This process is // started by ADE and loads the user's installation only after init validation. @@ -39,9 +51,22 @@ let session: PiSession | null = null; let modelInventory: JsonValue[] = []; let lastAssistantError: string | null = null; const VALID_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); -const PI_BUILTIN_TOOLS = new Set(["read", "bash", "edit", "write"]); +/** Root-exported factory per built-in tool, used to rebuild it behind an approval gate. */ +const PI_TOOL_DEFINITION_FACTORIES: Record = { + read: "createReadToolDefinition", + bash: "createBashToolDefinition", + edit: "createEditToolDefinition", + write: "createWriteToolDefinition", +}; +/** Derived so the gateable set and the built-in set cannot drift apart. */ +const PI_BUILTIN_TOOLS = new Set(Object.keys(PI_TOOL_DEFINITION_FACTORIES)); let unsubscribe: (() => void) | null = null; let disposed = false; +let loadedExtensions: Array<{ id: string; name?: string | null }> = []; +let ungateableTools: string[] = []; +let extensionsError: string | null = null; +let activeLogin: { providerId: string; controller: AbortController } | null = null; +const uiBridge: PiUiBridge = createPiUiBridge(post, () => randomUUID()); function post(message: PiSdkWorkerResponse): void { if (!process.send) return; @@ -210,9 +235,21 @@ async function authInventory(): Promise { const provider = record(providerValue); const id = nonEmpty(provider?.id); if (!id) continue; + const auth = record(provider?.auth); + const oauth = record(auth?.oauth); + const apiKey = record(auth?.apiKey); + // An api-key provider with no interactive `login` resolves ambiently (env + // var, cloud profile), so ADE must not offer to sign into it. + const authTypes = [ + ...(oauth ? ["oauth"] : []), + ...(apiKey && typeof apiKey.login === "function" ? ["api_key"] : []), + ]; const item: Record = { id, ...(nonEmpty(provider?.name) ? { name: nonEmpty(provider?.name)! } : {}), + ...(authTypes.length ? { authTypes } : {}), + ...(nonEmpty(oauth?.loginLabel) ? { loginLabel: nonEmpty(oauth?.loginLabel)! } : {}), + ...(oauth?.isSubscription === true ? { isSubscription: true } : {}), }; try { const status = typeof runtime.getProviderAuthStatus === "function" @@ -348,7 +385,7 @@ async function openSessionManager(init: PiSdkWorkerInit, sdk: PiModule): Promise } } -function makeResourceLoader(init: PiSdkWorkerInit, sdk: PiModule): unknown { +function makeResourceLoader(init: PiSdkWorkerInit, sdk: PiModule, settingsManager: unknown): unknown { const Loader = sdk.DefaultResourceLoader; if (typeof Loader !== "function") throw new Error("Pi SDK export DefaultResourceLoader is unavailable."); const rawSkillRoots = init.skillsEnv?.ADE_AGENT_SKILLS_DIRS ?? ""; @@ -359,10 +396,14 @@ function makeResourceLoader(init: PiSdkWorkerInit, sdk: PiModule): unknown { return new (Loader as new (options: Record) => unknown)({ cwd: init.cwd, agentDir: init.agentDir, - // Pi extensions can execute arbitrary code and can install custom UI - // handlers. Native ADE chat deliberately keeps them out of the worker; - // the Pi CLI remains the escape hatch for extension-owned experiences. - noExtensions: true, + // Without this the loader builds its own settings manager, which trusts + // the project and would auto-load extensions from the checkout. + ...(settingsManager ? { settingsManager } : {}), + // Pi extensions execute arbitrary code and install their own UI handlers, + // so ADE chat keeps them out unless the caller opts in. When enabled they + // are bound to ADE's limited UI bridge; the Pi CLI remains the escape hatch + // for extension experiences the bridge cannot render. + noExtensions: init.extensions !== true, // Do not inherit project/user Pi skill settings. ADE passes only its // explicitly approved skill roots below; the CLI remains the escape hatch // for Pi-native skill discovery. @@ -372,6 +413,63 @@ function makeResourceLoader(init: PiSdkWorkerInit, sdk: PiModule): unknown { }); } +/** + * Assemble ADE's custom tools: `ask_user`, plus approval-gated rebuilds of the + * built-ins the caller wants gated. + * + * A custom tool whose name matches a built-in replaces it in Pi's registry, so + * rebuilding `bash` from Pi's own factory keeps its schema, prompt text, and + * rendering while adding the gate. Pi's shell settings are passed through so a + * gated bash still honours the user's configured shell. + */ +function buildCustomTools( + init: PiSdkWorkerInit, + sdk: PiModule, + settingsManager: unknown, +): { tools: PiToolDefinitionLike[]; ungateable: string[] } { + const tools: PiToolDefinitionLike[] = []; + const ungateable: string[] = []; + const define = typeof sdk.defineTool === "function" ? sdk.defineTool as Callable : null; + const finish = (definition: PiToolDefinitionLike): PiToolDefinitionLike => + (define ? define.call(null, definition) as PiToolDefinitionLike : definition); + + if (init.askUserTool) tools.push(finish(createPiAskUserTool(uiBridge))); + + // Only gate a tool this session actually grants. Relying on Pi's own + // allowlist to drop an unrequested wrapper would make the gate depend on + // resolution order rather than on ADE's permission mode. + const requested = new Set(init.tools ?? ["read"]); + const gated = (init.approvalTools ?? []).filter((tool) => PI_BUILTIN_TOOLS.has(tool) && requested.has(tool)); + if (!gated.length) return { tools, ungateable }; + const gate = createPiApprovalGate(uiBridge); + const settings = record(settingsManager); + const shellPath = typeof settings?.getShellPath === "function" + ? nonEmpty((settings.getShellPath as Callable).call(settings)) + : null; + const commandPrefix = typeof settings?.getShellCommandPrefix === "function" + ? nonEmpty((settings.getShellCommandPrefix as Callable).call(settings)) + : null; + for (const toolName of gated) { + const factoryName = PI_TOOL_DEFINITION_FACTORIES[toolName]; + const factory = factoryName ? sdk[factoryName] : undefined; + if (typeof factory !== "function") { + // Nothing to wrap, so the caller withholds the tool. Granting it ungated + // would silently downgrade the permission mode. + ungateable.push(toolName); + continue; + } + const options = toolName === "bash" + ? { + ...(shellPath ? { shellPath } : {}), + ...(commandPrefix ? { commandPrefix } : {}), + } + : undefined; + const definition = (factory as Callable).call(null, init.cwd, options) as PiToolDefinitionLike; + tools.push(finish(withPiApproval(definition, gate))); + } + return { tools, ungateable }; +} + function ready(): PiSdkReady { if (!initState || !piRoot || !piEntry || (!session && !initState.inventoryOnly)) { throw new Error("Pi SDK worker is not initialized."); @@ -391,9 +489,81 @@ function ready(): PiSdkReady { currentModel: currentModelDescriptor(), thinkingLevel: typeof session?.thinkingLevel === "string" ? session.thinkingLevel : null, availableModels: modelInventory, + extensions: loadedExtensions, + extensionsError, + ungateableTools, }; } +/** + * Bind the user's extensions to ADE's UI bridge and report what loaded. + * + * `bindExtensions` is also what fires Pi's `session_start` event, and it is + * where extension-registered providers are flushed into the model runtime — so + * it must run before the model inventory is read. + */ +async function bindExtensions(active: PiSession, loadResult: unknown): Promise { + loadedExtensions = []; + extensionsError = null; + const bind = (active as Record).bindExtensions; + if (typeof bind !== "function") { + extensionsError = "This Pi build does not support binding extensions to a host UI."; + return; + } + try { + await (bind as Callable).call(active, { + uiContext: createPiExtensionUiContext({ bridge: uiBridge }), + // Pi's own non-terminal host mode: dialogs and notifications are + // supported, terminal-only widgets are not. + mode: "rpc", + onError: (error: unknown) => { + const detail = record(error); + uiBridge.notify({ + origin: "extension", + level: "error", + message: `A Pi extension failed: ${nonEmpty(detail?.error) ?? errorMessage(error)}`, + ...(nonEmpty(detail?.extensionPath) ? { sourceId: nonEmpty(detail?.extensionPath)! } : {}), + }); + }, + }); + } catch (error) { + extensionsError = errorMessage(error); + return; + } + + // `createAgentSession` hands back the load result directly; prefer it. The + // resource-loader accessor stays only as a fallback for Pi builds that do + // not return one — reading it first reported "no extensions" whenever that + // accessor was shaped differently. + const loader = record((active as Record).resourceLoader); + const getExtensions = loader?.getExtensions; + const result = record(loadResult) + ?? (typeof getExtensions === "function" ? record((getExtensions as Callable).call(loader)) : null); + if (!result) return; + const entries = Array.isArray(result?.extensions) ? result.extensions : []; + loadedExtensions = entries.flatMap((entry) => { + const item = record(entry); + // Pi identifies an extension by path. `sourceInfo.source` names the origin + // ("auto", "local", a package name), so it only doubles as a label when it + // is a package; otherwise the path is what the user recognizes, and an + // `index.*` entry is named by its folder rather than "index". + const id = nonEmpty(item?.resolvedPath) ?? nonEmpty(item?.path); + if (!id || item?.hidden === true) return []; + const base = path.basename(id).replace(/\.(?:ts|js|mjs|cjs)$/, ""); + const name = base === "index" ? path.basename(path.dirname(id)) : base; + return [{ id, name }]; + }); + const failures = Array.isArray(result?.errors) ? result.errors : []; + if (failures.length) { + extensionsError = failures + .map((failure) => { + const item = record(failure); + return `${nonEmpty(item?.path) ?? "extension"}: ${nonEmpty(item?.error) ?? "failed to load"}`; + }) + .join("; "); + } +} + async function initWorker(init: PiSdkWorkerInit): Promise { if (!isNodeSupported()) { throw new Error(`Pi SDK requires Node >= ${PI_SDK_MIN_NODE}; ADE worker is running Node ${process.versions.node}. Start ADE with a newer Node runtime.`); @@ -448,25 +618,69 @@ async function initWorker(init: PiSdkWorkerInit): Promise { sessionManager = await openSessionManager(init, pi); const selectedModel = init.modelRef == null ? undefined : await resolveModel(init.modelRef); - const resourceLoader = makeResourceLoader(init, pi); + // One settings manager governs both the resource loader and the session. + // + // `projectTrusted` is deliberately false. Pi treats a trusted project as + // permission to auto-load and execute extensions from the checkout's own + // `.pi/` directory, which its CLI only does after prompting. ADE opens + // repositories the user has not vouched for, so only the user's own profile + // extensions may load here; without this the loader would build its own + // settings manager that defaults to trusted. + const SettingsManagerCtor = typeof pi.SettingsManager === "function" + ? pi.SettingsManager as unknown as Record + : null; + const settingsManager = SettingsManagerCtor && typeof SettingsManagerCtor.create === "function" + ? (SettingsManagerCtor.create as Callable).call(SettingsManagerCtor, init.cwd, init.agentDir, { projectTrusted: false }) + : null; + // Without a settings manager ADE cannot pin `projectTrusted`, and Pi's own + // default would trust the checkout — so extensions stay off rather than + // loading repository code on a Pi build ADE cannot constrain. + const extensionsAllowed = init.extensions === true && settingsManager !== null; + if (init.extensions && !extensionsAllowed) { + extensionsError = "This Pi build does not let ADE mark the project untrusted, so extensions stayed off."; + } + const resourceLoader = makeResourceLoader({ ...init, extensions: extensionsAllowed }, pi, settingsManager); if (resourceLoader && typeof (resourceLoader as Record).reload === "function") { await method(resourceLoader, "reload").call(resourceLoader); } + const { tools: customTools, ungateable } = buildCustomTools(init, pi, settingsManager); + // A Pi build that cannot supply a tool's definition factory cannot have that + // tool gated, so it is withheld rather than granted ungated — and the chat + // still starts. + const grantedTools = requestedTools.filter((tool) => !ungateable.includes(tool)); + // Reported on the ready payload rather than as a notice: nothing is listening + // for notices until the desktop process finishes acquiring this worker. + ungateableTools = ungateable; const createSession = callable(pi.createAgentSession, "createAgentSession"); const options: Record = { cwd: init.cwd, agentDir: init.agentDir, modelRuntime, sessionManager, + ...(settingsManager ? { settingsManager } : {}), ...(selectedModel ? { model: selectedModel } : {}), ...(init.thinkingLevel ? { thinkingLevel: init.thinkingLevel } : {}), ...(resourceLoader ? { resourceLoader } : {}), - tools: init.tools ?? ["read"], + // Pi's `tools` option is one flat allowlist covering built-ins, extension + // tools, and custom tools, and anything unlisted is dropped. With + // extensions enabled ADE cannot name their tools in advance, so it denies + // the unwanted built-ins instead and leaves the rest of the namespace + // alone. Without extensions the tighter allowlist still applies. + ...(extensionsAllowed + ? { excludeTools: [...PI_BUILTIN_TOOLS].filter((tool) => !grantedTools.includes(tool)) } + : { + tools: [ + ...grantedTools, + ...(init.askUserTool ? [PI_ASK_USER_TOOL_NAME] : []), + ], + }), + ...(customTools.length ? { customTools } : {}), ...(init.noTools ? { noTools: init.noTools } : {}), }; const created = record(await createSession(options)); session = record(created?.session); if (!session) throw new Error("Pi SDK createAgentSession returned no session."); + if (extensionsAllowed) await bindExtensions(session, created?.extensionsResult); const subscribe = method(session, "subscribe"); const listener = (event: unknown): void => { const eventRecord = record(event); @@ -546,9 +760,100 @@ async function compact(customInstructions?: string | null): Promise { return toPiSdkJson(await (compactMethod as Callable).call(active, customInstructions ?? undefined)); } +/** + * Run Pi's native login for one provider. + * + * Credentials stay Pi's: `ModelRuntime.login` persists them through Pi's own + * `AuthStorage`, under its cross-process lock. ADE only renders the prompts and + * relays what the user typed, and never receives or stores a token. + */ +async function loginProvider(providerId: string, method?: string | null): Promise { + if (!modelRuntime) throw new Error("Pi SDK model runtime is not initialized."); + const runtime = modelRuntime as Record; + const login = runtime.login; + if (typeof login !== "function") { + throw new Error("This Pi build does not expose ModelRuntime.login(). Update Pi, or sign in with the Pi CLI."); + } + + const provider = typeof runtime.getProvider === "function" + ? record((runtime.getProvider as Callable).call(runtime, providerId)) + : null; + const auth = record(provider?.auth); + // Pi only has two login types. An api-key provider without an interactive + // `login` is ambient-only (env var, cloud profile) and has nothing to run. + const requested = nonEmpty(method); + const authType = requested === "api_key" || requested === "oauth" + ? requested + : auth?.oauth + ? "oauth" + : "api_key"; + if (authType === "oauth" && auth && !auth.oauth) { + throw new Error(`Pi provider "${providerId}" does not support OAuth sign-in.`); + } + if (authType === "api_key" && auth) { + const apiKeyAuth = record(auth.apiKey); + if (!apiKeyAuth) throw new Error(`Pi provider "${providerId}" does not support API key sign-in.`); + if (typeof apiKeyAuth.login !== "function") { + throw new Error(`Pi provider "${providerId}" reads its API key from the environment, so there is nothing to sign in to.`); + } + } + + if (activeLogin) { + // Aborting the old flow rejects its in-flight prompt, but a card already + // on screen belongs to the bridge — settle it so the superseded sign-in + // cannot leave an unanswerable prompt next to the new one. + activeLogin.controller.abort(); + uiBridge.drain("auth"); + } + const controller = new AbortController(); + activeLogin = { providerId, controller }; + try { + await (login as Callable).call( + modelRuntime, + providerId, + authType, + createPiAuthInteraction(uiBridge, providerId, controller.signal), + ); + } catch (error) { + // Credentials were written but Pi could not resync its local snapshot. + // That is a successful sign-in with a stale catalog, not a failed login. + if (error instanceof Error && error.name === "CredentialSynchronizationError") { + uiBridge.notify({ + origin: "auth", + level: "warn", + message: "Signed in, but Pi could not refresh its model list. Reopen settings to retry.", + sourceId: providerId, + }); + } else { + throw error; + } + } finally { + if (activeLogin?.controller === controller) activeLogin = null; + } + + // login() settles once local credential state is consistent, but not once + // remote catalogs are fresh. Refresh so newly unlocked models appear. + if (typeof runtime.refresh === "function") { + try { + await (runtime.refresh as Callable).call(modelRuntime, { allowNetwork: true, providers: [providerId] }); + } catch { + // Refresh failures are reported through the model list, not the login. + } + } + // Deliberately does not return the model inventory: the credential is + // already written, so letting an inventory read throw here would report a + // completed sign-in as a failure. `refresh` above owns catalog freshness. + return toPiSdkJson({ ok: true, providerId, authType }); +} + async function disposeWorker(): Promise { if (disposed) return; disposed = true; + activeLogin?.controller.abort(); + activeLogin = null; + // Every awaiting Pi callback resolves to "no answer" rather than hanging a + // teardown that is already underway. + uiBridge.close(); unsubscribe?.(); unsubscribe = null; try { @@ -583,7 +888,27 @@ async function dispatch(request: PiSdkWorkerRequest): Promise { autoTitleRefreshOnComplete: false, autoAllowAskUser: false, scheduledWorkPaused: true, + piExtensionsEnabled: false, codexSandbox: "workspace-write", }, }, @@ -535,6 +536,9 @@ describe("projectConfigService - AI mode migration", () => { expect(snapshot.effective.ai?.sessionIntelligence?.titles?.refreshOnComplete).toBe(false); expect(snapshot.effective.ai?.chat?.autoAllowAskUser).toBe(false); expect(snapshot.effective.ai?.chat?.scheduledWorkPaused).toBe(true); + // The opt-out only works if the coercer copies it; a dropped field would + // silently read back as undefined and leave extensions enabled. + expect(snapshot.effective.ai?.chat?.piExtensionsEnabled).toBe(false); expect(snapshot.effective.ai?.chat?.codexSandbox).toBe("workspace-write"); service.save({ diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index c9e34d41d..9fb8a78b7 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -375,6 +375,8 @@ function coerceAiChatConfig(value: unknown): AiConfig["chat"] { if (autoAllowAskUser != null) chat.autoAllowAskUser = autoAllowAskUser; const scheduledWorkPaused = asBool(value.scheduledWorkPaused); if (scheduledWorkPaused != null) chat.scheduledWorkPaused = scheduledWorkPaused; + const piExtensionsEnabled = asBool(value.piExtensionsEnabled); + if (piExtensionsEnabled != null) chat.piExtensionsEnabled = piExtensionsEnabled; const codexSandbox = asString(value.codexSandbox)?.trim(); if (codexSandbox === "read-only" || codexSandbox === "workspace-write" || codexSandbox === "danger-full-access") { chat.codexSandbox = codexSandbox; diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts index 582bdba9c..fb1e8988a 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts @@ -16,6 +16,20 @@ import { } from "../localRuntime/localRuntimeTimeoutPolicy"; describe("ipcInvokeTimeoutMs", () => { + it("gives a Pi sign-in longer than the flow it waits on, on every transport", () => { + // The flow blocks on a human finishing OAuth in a browser. At the 30s + // default the renderer reported failure while the daemon was still signing + // in, so in-app sign-in could never complete. + const flowBudgetMs = 10 * 60 * 1000; + + expect(ipcInvokeTimeoutMs(IPC.aiPiLoginStart)).toBeGreaterThan(flowBudgetMs); + expect(longRunningLocalRuntimeActionTimeoutMs("ai.piLoginStart")).toBeGreaterThan(flowBudgetMs); + // The runtime-action route the preload actually prefers, local and remote. + const routed = [IPC.localRuntimeCallAction, IPC.remoteRuntimeCallAction].map((channel) => + ipcInvokeTimeoutMs(channel, [{ request: { domain: "ai", action: "piLoginStart", args: { providerId: "anthropic" } } }])); + for (const timeoutMs of routed) expect(timeoutMs).toBeGreaterThan(flowBudgetMs); + }); + it("keeps local lane delete IPC alive through cold setup and the daemon action", () => { const innerTimeoutMs = longRunningLocalRuntimeActionTimeoutMs("lane.delete")!; const outerTimeoutMs = ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts index 695b36441..d2cabcb0d 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts @@ -5,6 +5,7 @@ import { LOCAL_RUNTIME_IPC_ACTION_REGISTRY_TIMEOUT_MS, LOCAL_RUNTIME_IPC_EVENT_POLL_TIMEOUT_MS, LOCAL_RUNTIME_IPC_PROJECT_COMPLETION_TIMEOUT_MS, + PI_LOGIN_IPC_TIMEOUT_MS, LOCAL_RUNTIME_IPC_SYNC_TIMEOUT_MS, } from "../localRuntime/localRuntimeTimeoutPolicy"; @@ -13,6 +14,9 @@ function isRecord(value: unknown): value is Record { } const RUNTIME_ACTION_CHANNEL: Record> = { + ai: { + piLoginStart: IPC.aiPiLoginStart, + }, lane: { create: IPC.lanesCreate, createChild: IPC.lanesCreateChild, @@ -83,6 +87,9 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [ // renderer's outcome known through setup and result delivery. case IPC.projectSwitchToPath: return LOCAL_RUNTIME_IPC_PROJECT_COMPLETION_TIMEOUT_MS; + // Awaits the user finishing Pi's own sign-in in a browser. + case IPC.aiPiLoginStart: + return PI_LOGIN_IPC_TIMEOUT_MS; case IPC.remoteRuntimeConnect: case IPC.remoteRuntimeListProjects: case IPC.remoteRuntimeAddProject: diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 442a84a70..35505ccab 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -531,6 +531,9 @@ import type { OpenCodeOAuthStatusEvent, OpenCodeProviderAuthMethods, OpenCodeRuntimeSnapshot, + PiAuthStatusEvent, + PiLoginMethod, + PiLoginProvider, SyncDesktopConnectionDraft, SyncCloudRelayStatus, SyncDeviceRecord, @@ -657,6 +660,14 @@ import { startOAuth as startOpenCodeOAuth, type OpenCodeAuthDeps, } from "../opencode/openCodeAuthService"; +import { + addPiAuthStatusListener, + cancelPiLogin, + listPiLoginProviders, + startPiLogin, + submitPiLoginPrompt, + type PiLoginResult, +} from "../ai/piAuthService"; import { getLastFetchedAt as getModelsDevLastFetchedAt, refreshNow as refreshModelsDevNow } from "../ai/modelsDevService"; import type { createTestService } from "../tests/testService"; import type { createGitOperationsService } from "../git/gitOperationsService"; @@ -1940,6 +1951,9 @@ export function registerIpc({ [IPC.accountRemoveMachine]: new Set(["machineKey"]), [IPC.attentionNotchPublishSnapshot]: new Set(["items"]), [IPC.attentionNotchPublishToast]: new Set(["title", "subtitle"]), + // A Pi sign-in prompt answer is the credential itself when Pi asks for an + // API key, so it must never reach a verbose IPC trace. + [IPC.aiPiLoginSubmit]: new Set(["value"]), }; const redactIpcArgsForChannel = (channel: string, args: unknown[]): unknown[] => { @@ -4848,6 +4862,55 @@ export function registerIpc({ }, ); + // Broadcast Pi sign-in transitions to all renderer windows. The relay/web + // fan-out is registered separately in the adeActions AI domain. + addPiAuthStatusListener((event: PiAuthStatusEvent) => { + for (const win of BrowserWindow.getAllWindows()) { + try { + win.webContents.send(IPC.aiPiAuthStatus, event); + } catch { + // ignore broadcast failures + } + } + }); + + ipcMain.handle(IPC.aiPiLoginProviders, async (): Promise => { + return await listPiLoginProviders(); + }); + + ipcMain.handle( + IPC.aiPiLoginStart, + async (_event, arg: { providerId: string; method?: PiLoginMethod }): Promise => { + const ctx = getCtx(); + const result = await startPiLogin(arg); + if (result.ok) { + try { + ctx.aiIntegrationService?.invalidateProviderReadinessCaches(); + } catch (error) { + // Matches the action-path twin so both halves of one incident are + // searchable under a single event name. + ctx.logger.warn("ai.pi_auth_cache_invalidation_failed", { + provider: arg.providerId, + error: getErrorMessage(error), + }); + } + } + return result; + }, + ); + + ipcMain.handle( + IPC.aiPiLoginSubmit, + async ( + _event, + arg: { providerId: string; requestId: string; value: string }, + ): Promise<{ ok: boolean; error?: string }> => submitPiLoginPrompt(arg), + ); + + ipcMain.handle(IPC.aiPiLoginCancel, async (_event, arg: { providerId: string }): Promise => { + cancelPiLogin(arg); + }); + ipcMain.handle(IPC.aiRefreshModelsDev, async (): Promise<{ lastFetchedAt: number | null }> => { const ctx = getCtx(); try { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts index ff434c260..2a0f8e806 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts @@ -31,7 +31,15 @@ export const LOCAL_RUNTIME_IPC_ACTION_REGISTRY_TIMEOUT_MS = export const LOCAL_RUNTIME_IPC_EVENT_POLL_TIMEOUT_MS = localRuntimeCallIpcTimeoutMs(LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS); +/** + * A Pi sign-in blocks on a human completing an OAuth or device-code flow, which + * `piAuthService` bounds at 10 minutes. The transport budget has to outlive + * that, or the renderer reports failure while the daemon is still signing in. + */ +export const PI_LOGIN_IPC_TIMEOUT_MS = 11 * 60_000; + const LONG_RUNNING_LOCAL_RUNTIME_ACTION_TIMEOUTS: ReadonlyMap = new Map([ + ["ai.piLoginStart", PI_LOGIN_IPC_TIMEOUT_MS], // Lane deletion can legitimately include a 60s worktree removal followed by // a 45s remote-branch deletion. The old 30s client budget reported failure // while the daemon kept mutating state to a successful completion. diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index a14e2b5d3..5c27403d3 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -239,6 +239,9 @@ import type { OpenCodeOAuthStartResult, OpenCodeOAuthStatusEvent, OpenCodeProviderAuthMethods, + PiAuthStatusEvent, + PiLoginMethod, + PiLoginProvider, CursorCloudAgentSummary, CursorCloudArtifactDownload, CursorCloudArtifactSummary, @@ -1025,6 +1028,18 @@ declare global { }) => Promise<{ ok: boolean; error?: string }>; refreshModelsDev: () => Promise<{ lastFetchedAt: number | null }>; onOpencodeOAuthStatus: (cb: (event: OpenCodeOAuthStatusEvent) => void) => () => void; + piLoginProviders: () => Promise; + piLoginStart: (args: { + providerId: string; + method?: PiLoginMethod; + }) => Promise<{ ok: boolean; error?: string }>; + piLoginSubmit: (args: { + providerId: string; + requestId: string; + value: string; + }) => Promise<{ ok: boolean; error?: string }>; + piLoginCancel: (args: { providerId: string }) => Promise; + onPiAuthStatus: (cb: (event: PiAuthStatusEvent) => void) => () => void; cursorCloudListRepositories: () => Promise; cursorCloudListAgents: (args?: { includeArchived?: boolean; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index af637c5bd..5c44e2051 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -118,6 +118,9 @@ import type { OpenCodeOAuthStartResult, OpenCodeOAuthStatusEvent, OpenCodeProviderAuthMethods, + PiAuthStatusEvent, + PiLoginMethod, + PiLoginProvider, CursorCloudAgentSummary, CursorCloudArtifactDownload, CursorCloudArtifactSummary, @@ -1751,6 +1754,7 @@ const remoteLaneOAuthEventCallbacks = new Set< const remoteOpenCodeOAuthStatusCallbacks = new Set< (payload: OpenCodeOAuthStatusEvent) => void >(); +const remotePiAuthStatusCallbacks = new Set<(payload: PiAuthStatusEvent) => void>(); const remoteLaneDiagnosticsEventCallbacks = new Set< (payload: RuntimeDiagnosticsEvent) => void >(); @@ -1871,6 +1875,11 @@ const subscribeLocalOpenCodeOAuthStatusEvents = IPC.aiOpencodeOAuthStatus, "OpenCode OAuth status", ); +const subscribeLocalPiAuthStatusEvents = + createLocalIpcEventSubscription( + IPC.aiPiAuthStatus, + "Pi sign-in status", + ); let remoteRuntimeEventTimer: ReturnType | null = null; let remoteRuntimeEventInFlight = false; @@ -1945,6 +1954,7 @@ function hasRemoteRuntimeEventSubscribers(): boolean { remoteLaneProxyEventCallbacks.size > 0 || remoteLaneOAuthEventCallbacks.size > 0 || remoteOpenCodeOAuthStatusCallbacks.size > 0 || + remotePiAuthStatusCallbacks.size > 0 || remoteLaneDiagnosticsEventCallbacks.size > 0 || remotePtyDataEventCallbacks.size > 0 || remotePtyExitEventCallbacks.size > 0 || @@ -2310,6 +2320,19 @@ function dispatchRemoteRuntimeEventPayload( } } + if (payload.kind === "piAuthStatus" && isRecord(payload.event)) { + const event = payload.event; + if (typeof event.providerId === "string" && typeof event.state === "string") { + for (const cb of [...remotePiAuthStatusCallbacks]) { + try { + cb(event as unknown as PiAuthStatusEvent); + } catch (error) { + console.error("preload remote Pi sign-in status listener failed", error); + } + } + } + } + if (payload.type === "sync-status" && isRecord(payload.snapshot)) { for (const cb of [...remoteSyncStatusEventCallbacks]) { try { @@ -2857,6 +2880,16 @@ function subscribeRemoteOpenCodeOAuthStatusEvents( }; } +function subscribeRemotePiAuthStatusEvents( + cb: (payload: PiAuthStatusEvent) => void, +): () => void { + remotePiAuthStatusCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remotePiAuthStatusCallbacks.delete(cb); + }; +} + function subscribeRemoteLaneDiagnosticsEvents( cb: (payload: RuntimeDiagnosticsEvent) => void, ): () => void { @@ -4253,6 +4286,41 @@ contextBridge.exposeInMainWorld("ade", { removeLocal(); }; }, + piLoginProviders: async (): Promise => + callProjectRuntimeActionOr("ai", "piLoginProviders", {}, () => + ipcRenderer.invoke(IPC.aiPiLoginProviders), + ), + piLoginStart: async (args: { + providerId: string; + method?: PiLoginMethod; + }): Promise<{ ok: boolean; error?: string }> => + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr("ai", "piLoginStart", { args }, () => + ipcRenderer.invoke(IPC.aiPiLoginStart, args), + ), + ), + piLoginSubmit: async (args: { + providerId: string; + requestId: string; + value: string; + }): Promise<{ ok: boolean; error?: string }> => + callProjectRuntimeActionOr("ai", "piLoginSubmit", { args }, () => + ipcRenderer.invoke(IPC.aiPiLoginSubmit, args), + ), + piLoginCancel: async (args: { providerId: string }): Promise => + callProjectRuntimeActionOr("ai", "piLoginCancel", { args }, () => + ipcRenderer.invoke(IPC.aiPiLoginCancel, args), + ), + onPiAuthStatus: (cb: (event: PiAuthStatusEvent) => void) => { + const removeLocal = subscribeLocalPiAuthStatusEvents(cb); + const removeRemote = subscribeRemotePiAuthStatusEvents(cb); + return () => { + removeRemote(); + removeLocal(); + }; + }, cursorCloudListRepositories: async (): Promise => callProjectRuntimeActionOr("ai", "listCursorCloudRepositories", {}, () => ipcRenderer.invoke(IPC.aiCursorCloudListRepositories), diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 3c636ea90..465437c62 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3651,6 +3651,11 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { clearOpencodeProviderKey: resolvedArg({ ok: false, error: "browser" } as any), refreshModelsDev: resolved({ lastFetchedAt: null }), onOpencodeOAuthStatus: () => () => {}, + piLoginProviders: resolved([]), + piLoginStart: resolvedArg({ ok: false, error: "browser" } as any), + piLoginSubmit: resolvedArg({ ok: false, error: "browser" } as any), + piLoginCancel: resolvedArg(undefined), + onPiAuthStatus: () => () => {}, }, agentTools: { detect: resolved([]), diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx index bc1bdbd83..59ed1b40a 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx @@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testi import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ProvidersSection } from "./ProvidersSection"; -import type { AgentChatEventEnvelope, AiSettingsStatus } from "../../../shared/types"; +import type { AgentChatEventEnvelope, AiSettingsStatus, PiAuthStatusEvent } from "../../../shared/types"; vi.mock("@lobehub/icons", () => { const brand = () => { @@ -205,6 +205,42 @@ function buildStatus( } as AiSettingsStatus; } +function buildPiInstallation(): NonNullable { + return { + installed: true, + sdkAvailable: true, + cliAvailable: true, + cliPath: "/Users/example/.local/bin/pi", + packageRoot: "/Users/example/.pi/agent/node_modules/@earendil-works/pi-coding-agent", + version: "0.84.0", + agentDir: "/Users/example/.pi/agent", + settingsPath: "/Users/example/.pi/agent/settings.json", + authPath: "/Users/example/.pi/agent/auth.json", + modelsPath: "/Users/example/.pi/agent/models.json", + modelsStorePath: "/Users/example/.pi/agent/models-store.json", + blocker: null, + providers: [ + { + id: "openai-codex", + name: "OpenAI Codex", + modelCount: 7, + availableModelCount: 7, + configured: true, + authType: "oauth", + authMethods: ["oauth"], + authSource: "stored", + authLabel: "OAuth", + subscription: true, + }, + ], + availableModelIds: ["pi/openai-codex/gpt-5.4"], + authFileDetected: true, + modelsFileDetected: false, + settingsFileDetected: true, + stale: false, + }; +} + function renderProvidersSection() { return render( @@ -217,10 +253,12 @@ describe("ProvidersSection", () => { const originalAde = globalThis.window.ade; let emitChatEvent: ((envelope: AgentChatEventEnvelope) => void) | null = null; let emitOAuthStatus: ((event: { providerId: string; state: string; error?: string }) => void) | null = null; + let emitPiAuthStatus: ((event: PiAuthStatusEvent) => void) | null = null; beforeEach(() => { emitChatEvent = null; emitOAuthStatus = null; + emitPiAuthStatus = null; globalThis.window.ade = { ai: { @@ -257,6 +295,16 @@ describe("ProvidersSection", () => { if (emitOAuthStatus === cb) emitOAuthStatus = null; }; }), + piLoginProviders: vi.fn().mockResolvedValue([]), + piLoginStart: vi.fn().mockResolvedValue({ ok: true }), + piLoginSubmit: vi.fn().mockResolvedValue({ ok: true }), + piLoginCancel: vi.fn().mockResolvedValue(undefined), + onPiAuthStatus: vi.fn((cb: (event: PiAuthStatusEvent) => void) => { + emitPiAuthStatus = cb; + return () => { + if (emitPiAuthStatus === cb) emitPiAuthStatus = null; + }; + }), }, projectConfig: { get: vi.fn().mockResolvedValue({ @@ -530,41 +578,7 @@ describe("ProvidersSection", () => { it("renders the Pi card with connected providers and opens Pi settings files", async () => { const getStatusMock = window.ade.ai.getStatus as ReturnType; getStatusMock.mockReset(); - getStatusMock.mockResolvedValue(buildStatus(true, [], { - piInstallation: { - installed: true, - sdkAvailable: true, - cliAvailable: true, - cliPath: "/Users/example/.local/bin/pi", - packageRoot: "/Users/example/.pi/agent/node_modules/@earendil-works/pi-coding-agent", - version: "0.84.0", - agentDir: "/Users/example/.pi/agent", - settingsPath: "/Users/example/.pi/agent/settings.json", - authPath: "/Users/example/.pi/agent/auth.json", - modelsPath: "/Users/example/.pi/agent/models.json", - modelsStorePath: "/Users/example/.pi/agent/models-store.json", - blocker: null, - providers: [ - { - id: "openai-codex", - name: "OpenAI Codex", - modelCount: 7, - availableModelCount: 7, - configured: true, - authType: "oauth", - authMethods: ["oauth"], - authSource: "stored", - authLabel: "OAuth", - subscription: true, - }, - ], - availableModelIds: ["pi/openai-codex/gpt-5.4"], - authFileDetected: true, - modelsFileDetected: false, - settingsFileDetected: true, - stale: false, - }, - })); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); const listApiKeysMock = window.ade.ai.listApiKeys as ReturnType; listApiKeysMock.mockReset(); listApiKeysMock.mockResolvedValue([]); @@ -574,8 +588,8 @@ describe("ProvidersSection", () => { expect(await screen.findByText("Pi")).toBeTruthy(); expect(screen.getByText(/Uses Pi’s installed SDK package/)).toBeTruthy(); expect(screen.getByText(/Version 0.84.0/)).toBeTruthy(); - expect(screen.getByText("Configured providers")).toBeTruthy(); expect(screen.getByText("OpenAI Codex")).toBeTruthy(); + expect(screen.getByText(/7 models/)).toBeTruthy(); expect(screen.getByRole("button", { name: "Open settings.json" })).toBeTruthy(); await act(async () => { @@ -584,6 +598,213 @@ describe("ProvidersSection", () => { expect(window.ade.app.openPath).toHaveBeenCalledWith("/Users/example/.pi/agent/settings.json"); }); + it("signs into a Pi provider in-app: device code, prompt, then success", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + const listApiKeysMock = window.ade.ai.listApiKeys as ReturnType; + listApiKeysMock.mockReset(); + listApiKeysMock.mockResolvedValue([]); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth", "api_key"], configured: false, loginLabel: "Sign in with SuperGrok" }, + ]); + let resolveLogin: ((result: { ok: boolean; error?: string }) => void) | null = null; + (window.ade.ai.piLoginStart as ReturnType).mockImplementation( + () => new Promise<{ ok: boolean; error?: string }>((resolve) => { + resolveLogin = resolve; + }), + ); + + renderProvidersSection(); + + const signIn = await screen.findByRole("button", { name: "Sign in with SuperGrok — xAI" }); + expect(screen.getByRole("button", { name: "Use an API key — xAI" })).toBeTruthy(); + + await act(async () => { + signIn.click(); + }); + expect(window.ade.ai.piLoginStart).toHaveBeenCalledWith({ providerId: "xai", method: "oauth" }); + + await act(async () => { + emitPiAuthStatus?.({ + providerId: "xai", + state: "pending", + notice: { level: "info", message: "Enter the code", userCode: "ABCD-1234", verificationUri: "https://x.ai/device" }, + }); + }); + expect(screen.getByText("ABCD-1234")).toBeTruthy(); + + await act(async () => { + emitPiAuthStatus?.({ + providerId: "xai", + state: "prompt", + prompt: { requestId: "req-1", kind: "manual_code", title: "Sign in to xAI", message: "Paste the code from your browser" }, + }); + }); + const input = screen.getByLabelText("Paste the code from your browser") as HTMLInputElement; + fireEvent.change(input, { target: { value: "code-42" } }); + await act(async () => { + screen.getByRole("button", { name: "Continue" }).click(); + }); + expect(window.ade.ai.piLoginSubmit).toHaveBeenCalledWith({ + providerId: "xai", + requestId: "req-1", + value: "code-42", + }); + + await act(async () => { + resolveLogin?.({ ok: true }); + }); + expect(screen.getByText(/Signed in to xAI\./)).toBeTruthy(); + }); + + it("surfaces a rejected Pi prompt answer instead of waiting out the login timeout", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false }, + ]); + // The login promise never settles here: only the submit result can tell the + // user their answer was rejected. + (window.ade.ai.piLoginStart as ReturnType).mockImplementation( + () => new Promise<{ ok: boolean; error?: string }>(() => undefined), + ); + (window.ade.ai.piLoginSubmit as ReturnType).mockResolvedValue({ + ok: false, + error: "That prompt has already been answered.", + }); + + renderProvidersSection(); + + const signIn = await screen.findByRole("button", { name: "Sign in — xAI" }); + await act(async () => { + signIn.click(); + }); + await act(async () => { + emitPiAuthStatus?.({ + providerId: "xai", + state: "prompt", + prompt: { requestId: "req-1", kind: "manual_code", title: "Sign in to xAI", message: "Paste the code from your browser" }, + }); + }); + + const input = screen.getByLabelText("Paste the code from your browser") as HTMLInputElement; + fireEvent.change(input, { target: { value: "code-42" } }); + await act(async () => { + screen.getByRole("button", { name: "Continue" }).click(); + }); + + const failure = await screen.findByText(/xAI: That prompt has already been answered\./); + expect(failure.closest('[role="alert"]')).toBeTruthy(); + expect(screen.getByRole("button", { name: "Try again" })).toBeTruthy(); + }); + + it("offers a retry when a Pi sign-in fails", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false, loginLabel: "Sign in with SuperGrok" }, + ]); + const startMock = window.ade.ai.piLoginStart as ReturnType; + startMock.mockResolvedValue({ ok: false, error: "Device code expired." }); + + renderProvidersSection(); + + const signIn = await screen.findByRole("button", { name: "Sign in with SuperGrok — xAI" }); + await act(async () => { + signIn.click(); + }); + expect(screen.getByText(/xAI: Device code expired\./)).toBeTruthy(); + + await act(async () => { + screen.getByRole("button", { name: "Try again" }).click(); + }); + expect(startMock).toHaveBeenCalledTimes(2); + expect(startMock).toHaveBeenLastCalledWith({ providerId: "xai", method: "oauth" }); + }); + + it("treats a cancelled Pi sign-in as a choice, not a failure", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false }, + ]); + let resolveLogin: ((result: { ok: boolean; error?: string }) => void) | null = null; + (window.ade.ai.piLoginStart as ReturnType).mockImplementation( + () => new Promise<{ ok: boolean; error?: string }>((resolve) => { + resolveLogin = resolve; + }), + ); + + renderProvidersSection(); + + const signIn = await screen.findByRole("button", { name: "Sign in — xAI" }); + await act(async () => { + signIn.click(); + }); + await act(async () => { + screen.getByRole("button", { name: "Cancel" }).click(); + }); + await act(async () => { + resolveLogin?.({ ok: false, error: "Sign-in cancelled." }); + }); + + const cancelled = screen.getByText("Sign-in cancelled."); + expect(cancelled.closest('[role="status"]')).toBeTruthy(); + expect(cancelled.closest('[role="alert"]')).toBeNull(); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); + }); + + it("merges a configured provider and its sign-in options into one tile", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "openai-codex", name: "OpenAI Codex", authTypes: ["oauth"], configured: true }, + ]); + + renderProvidersSection(); + + expect((await screen.findAllByText("OpenAI Codex")).length).toBe(1); + expect(screen.getByRole("button", { name: "Sign in — OpenAI Codex" })).toBeTruthy(); + expect(screen.getByText(/7 models/)).toBeTruthy(); + }); + + it("keeps Pi's terminal login reachable in one click", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([]); + + renderProvidersSection(); + + expect(await screen.findByRole("button", { name: /Open Pi \/login/ })).toBeTruthy(); + }); + + it("explains the Pi card instead of stranding it when Pi's SDK is missing", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { + piInstallation: { + ...buildPiInstallation(), + sdkAvailable: false, + providers: [], + availableModelIds: [], + blocker: "Pi is installed, but ADE cannot load its package here.", + }, + })); + + renderProvidersSection(); + + expect(await screen.findAllByText("Pi is installed, but ADE cannot load its package here.")).toHaveLength(1); + expect(screen.getByRole("button", { name: /Open Pi \/login/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Refresh providers/ })).toBeNull(); + expect(window.ade.ai.piLoginProviders).not.toHaveBeenCalled(); + }); + it("collapses the OpenCode group to an install card when the binary is missing", async () => { const getStatusMock = window.ade.ai.getStatus as ReturnType; getStatusMock.mockReset(); diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 9df29b3d3..9d73ea406 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -13,6 +13,10 @@ import type { import type { AiCustomProviderConfig, OpenCodeProviderAuthMethods, + PiAuthNotice, + PiAuthPrompt, + PiLoginMethod, + PiLoginProvider, } from "../../../shared/types/config"; import { getLocalModelIdTail, @@ -535,6 +539,502 @@ function buildPiMessage( return "Pi is installed, but no configured providers or available models were detected yet."; } +type PiSignInFlow = { + /** Identifies this attempt, so a superseded start cannot tear down its replacement. */ + attemptId: number; + providerId: string; + /** Kept so a failed flow can be retried with the button the user actually pressed. */ + method: PiLoginMethod | null; + prompt: PiAuthPrompt | null; + /** Sticky auth URL / device code the user still has to act on. */ + link: PiAuthNotice | null; + progress: string | null; +}; + +/** Cancelling is a choice, not a failure, so it gets its own state instead of an error. */ +type PiSignInOutcome = { + providerId: string; + method: PiLoginMethod | null; + state: "ok" | "cancelled" | "error"; + error?: string; +}; + +/** One provider row: what Pi already has configured, what can be signed into, or both. */ +type PiProviderRow = { + id: string; + name: string; + status: AiPiProviderStatus | null; + login: PiLoginProvider | null; +}; + +function piLoginMethodLabel(provider: PiLoginProvider, method: PiLoginMethod): string { + if (method === "api_key") return "Use an API key"; + return provider.loginLabel ?? (provider.isSubscription ? "Sign in with your subscription" : "Sign in"); +} + +/** Pi only sends options for select prompts; every other kind takes free text. */ +function piChoiceOptions(prompt: PiAuthPrompt | null | undefined): NonNullable { + return prompt?.options ?? []; +} + +/** Merges both provider sources by id so a signable, configured provider is one row, not two. */ +function buildPiProviderRows(configured: AiPiProviderStatus[], signable: PiLoginProvider[]): PiProviderRow[] { + const rows = new Map(); + for (const status of configured) { + rows.set(status.id, { id: status.id, name: status.name, status, login: null }); + } + for (const login of signable) { + const existing = rows.get(login.id); + if (existing) existing.login = login; + else rows.set(login.id, { id: login.id, name: login.name, status: null, login }); + } + return [...rows.values()]; +} + +/** Shared shell so a provider looks the same whether it is connected, signable, or both. */ +function PiProviderTile({ provider, children }: { provider: PiProviderRow; children: React.ReactNode }) { + const connected = provider.status?.configured ?? provider.login?.configured ?? false; + const noModels = connected && provider.status != null && provider.status.availableModelCount === 0; + return ( +
+
+
+ + + {provider.name} + +
+ {noModels ? ( + + No models + + ) : connected ? : null} +
+ {children} +
+ ); +} + +/** Pi's own terminal flow, offered next to the in-app one rather than behind a reveal. */ +function PiTerminalFallback({ + installation, + onRevealTerminal, +}: { + installation: AiPiInstallationStatus; + onRevealTerminal: (terminal: { terminalId: string; laneId: string }) => void; +}) { + return ( +
+ {installation.cliAvailable ? ( + + ) : ( + + Install Pi to sign in from its terminal. + + )} +
+ ); +} + +/** + * Runs Pi's own sign-in inside ADE: pick a provider, then answer whatever Pi + * asks. Pi writes the credential itself — nothing typed here is kept by ADE. + */ +function PiSignIn({ + installation, + onSignedIn, + onRevealTerminal, +}: { + installation: AiPiInstallationStatus; + onSignedIn: () => void; + onRevealTerminal: (terminal: { terminalId: string; laneId: string }) => void; +}) { + const [providers, setProviders] = useState(null); + const [providersError, setProvidersError] = useState(null); + const [loadingProviders, setLoadingProviders] = useState(false); + const [flow, setFlow] = useState(null); + const [promptValue, setPromptValue] = useState(""); + const [outcome, setOutcome] = useState(null); + const promptInputRef = useRef(null); + const firstChoiceRef = useRef(null); + const retryButtonRef = useRef(null); + /** Pi reports a cancel as a plain failure, so remember that the user asked for it. */ + const cancelledProviderRef = useRef(null); + const lastPromptRequestIdRef = useRef(null); + const piSignInAttemptCounter = useRef(0); + const promptFieldId = React.useId(); + const promptLabelId = `${promptFieldId}-label`; + const { copy, copied } = useCopyToClipboard(); + + const loadProviders = useCallback(async () => { + setLoadingProviders(true); + try { + const listed = await window.ade.ai.piLoginProviders(); + setProviders(listed); + setProvidersError(null); + } catch (err) { + setProvidersError(err instanceof Error ? err.message : String(err)); + } finally { + setLoadingProviders(false); + } + }, []); + + useEffect(() => { + if (!installation.sdkAvailable) return; + void loadProviders(); + }, [installation.sdkAvailable, loadProviders]); + + useEffect(() => { + const unsubscribe = window.ade.ai.onPiAuthStatus((event) => { + setFlow((current) => { + if (!current || current.providerId !== event.providerId) return current; + if (event.state === "prompt" && event.prompt) return { ...current, prompt: event.prompt }; + if (event.state !== "pending" || !event.notice) return current; + // A URL or device code is the step the user has to act on, so it stays + // on screen; plain progress lines replace each other. + return event.notice.url || event.notice.userCode + ? { ...current, link: event.notice, progress: null } + : { ...current, progress: event.notice.message }; + }); + // A local runtime delivers each status twice (direct IPC broadcast plus + // the buffered relay), and the second copy can land after the user has + // started typing. Only a genuinely new prompt clears the field, or the + // duplicate would erase a half-entered API key. + if (event.state === "prompt" && event.prompt) { + setPromptValue((current) => (lastPromptRequestIdRef.current === event.prompt!.requestId ? current : "")); + lastPromptRequestIdRef.current = event.prompt.requestId; + } + }); + return unsubscribe; + }, []); + + // Leaving Settings mid-sign-in would otherwise strand the flow: the worker + // keeps waiting for an answer for its full budget, and reopening Settings + // cannot adopt it because status events are ignored without a current flow. + const activeFlowProviderRef = useRef(null); + useEffect(() => { + activeFlowProviderRef.current = flow?.providerId ?? null; + }, [flow?.providerId]); + useEffect(() => () => { + const providerId = activeFlowProviderRef.current; + if (providerId) void window.ade.ai.piLoginCancel({ providerId }).catch(() => undefined); + }, []); + + useEffect(() => { + if (!flow?.prompt) return; + // A choice prompt unmounts whatever held focus, so hand focus to the first + // option rather than letting it fall back to the document. + if (piChoiceOptions(flow.prompt).length) firstChoiceRef.current?.focus(); + else promptInputRef.current?.focus(); + }, [flow?.prompt]); + + useEffect(() => { + // Settling unmounts the flow card, which is where focus was; without this a + // keyboard user lands back on the document body instead of the one control + // that can recover the failed attempt. + if (outcome?.state === "error") retryButtonRef.current?.focus(); + }, [outcome]); + + const start = async (providerId: string, method?: PiLoginMethod) => { + setOutcome(null); + setPromptValue(""); + cancelledProviderRef.current = null; + const attemptId = ++piSignInAttemptCounter.current; + setFlow({ attemptId, providerId, method: method ?? null, prompt: null, link: null, progress: null }); + // Every update below runs after an await, by which time "Try again" may have + // started a replacement. A superseded attempt must not report its own + // outcome or refresh providers on the newer one's behalf. + const isCurrentAttempt = () => piSignInAttemptCounter.current === attemptId; + try { + const result = await window.ade.ai.piLoginStart({ providerId, ...(method ? { method } : {}) }); + if (!isCurrentAttempt()) return; + const cancelled = !result.ok && cancelledProviderRef.current === providerId; + setOutcome({ + providerId, + method: method ?? null, + state: result.ok ? "ok" : cancelled ? "cancelled" : "error", + ...(result.ok || cancelled || !result.error ? {} : { error: result.error }), + }); + if (result.ok) { + onSignedIn(); + void loadProviders(); + } + } catch (err) { + if (!isCurrentAttempt()) return; + setOutcome({ + providerId, + method: method ?? null, + state: "error", + error: err instanceof Error ? err.message : String(err), + }); + } finally { + // A second sign-in may already own these, so only this attempt's own + // state is torn down here. + if (cancelledProviderRef.current === providerId) cancelledProviderRef.current = null; + setFlow((current) => (current?.attemptId === attemptId ? null : current)); + } + }; + + const answer = async (value: string) => { + const prompt = flow?.prompt; + if (!flow || !prompt) return; + setFlow((current) => (current ? { ...current, prompt: null } : current)); + setPromptValue(""); + const fail = (error: string) => + setOutcome({ providerId: flow.providerId, method: flow.method, state: "error", error }); + try { + // A rejected answer comes back as ok:false rather than a throw, and the + // prompt is already gone — without this the user waits out Pi's login + // timeout with nothing on screen. + const result = await window.ade.ai.piLoginSubmit({ + providerId: flow.providerId, + requestId: prompt.requestId, + value, + }); + if (!result.ok) fail(result.error ?? "Pi did not accept that answer."); + } catch (err) { + fail(err instanceof Error ? err.message : String(err)); + } + }; + + const cancel = () => { + if (!flow) return; + cancelledProviderRef.current = flow.providerId; + void window.ade.ai.piLoginCancel({ providerId: flow.providerId }).catch(() => undefined); + }; + + const signableProviders = providers ?? []; + const providerName = (providerId: string) => + signableProviders.find((provider) => provider.id === providerId)?.name + ?? installation.providers.find((provider) => provider.id === providerId)?.name + ?? providerId; + const providerRows = buildPiProviderRows( + installation.providers.filter((provider) => provider.configured), + signableProviders, + ); + const prompt = flow?.prompt ?? null; + const choiceOptions = piChoiceOptions(prompt); + const link = flow?.link ?? null; + const userCode = link?.userCode ?? null; + const verifyUrl = link?.url ?? link?.verificationUri ?? null; + + if (!installation.sdkAvailable) { + return ( + // The card's own message already states the blocker, so this branch only + // has to offer the way out. +
+
Sign in
+ +
+ ); + } + + return ( +
+
+
Providers
+ +
+ + {providersError ? ( +
+ Could not list Pi providers: {providersError} +
+ ) : null} + + {flow ? ( +
+
+ Signing in to {providerName(flow.providerId)} +
+
+ {flow.progress ?? "Waiting for Pi…"} +
+ + {userCode ? ( +
+ Code + + {userCode} + + +
+ ) : null} + + {verifyUrl ? ( +
+ + {verifyUrl} + + +
+ ) : null} + + {prompt ? ( +
+ {/* A label may only point at a form control, so the choice + branch names its button group instead of borrowing htmlFor. */} + + {choiceOptions.length ? ( +
+ {choiceOptions.map((option, index) => ( + + ))} +
+ ) : ( +
{ + event.preventDefault(); + void answer(promptValue); + }} + > + setPromptValue(event.target.value)} + style={{ flex: "1 1 220px", minWidth: 0, background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, padding: "6px 8px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} + /> + +
+ )} +
+ ) : null} + +
+ +
+
+ ) : null} + + {/* The live region stays mounted across settles: a status/alert node + inserted at the same instant its text appears is announced + unreliably. Empty, it leaves the flow so it adds no column gap. The + role alone carries urgency — an explicit aria-live would demote an + alert back to polite. */} +
+ {outcome ? ( + + {outcome.state === "ok" + ? `Signed in to ${providerName(outcome.providerId)}.` + : outcome.state === "cancelled" + ? "Sign-in cancelled." + : `${providerName(outcome.providerId)}: ${outcome.error ?? "Sign-in did not finish."}`} + + ) : null} + {outcome?.state === "error" ? ( + + ) : null} +
+ + {/* Starting a second sign-in cancels the first, so the list steps aside + while one is running. */} + {!flow && providerRows.length ? ( +
+ {providerRows.map((row) => { + const { status, login } = row; + const modelCount = status ? status.availableModelCount || status.modelCount : 0; + return ( + + {status ? ( + <> +
+ {modelCount} model{modelCount === 1 ? "" : "s"} + {status.availableModelCount > 0 && status.modelCount > status.availableModelCount ? ` · ${status.modelCount} known` : ""} +
+
+ {piProviderAuthSummary(status)}{status.authLabel ? ` · ${status.authLabel}` : ""} +
+ + ) : null} + {login ? ( +
+ {login.authTypes.map((method) => ( + + ))} +
+ ) : null} +
+ ); + })} +
+ ) : null} + + {!flow && providers !== null && !signableProviders.length && !providersError ? ( +
+ {providerRows.length + ? "These read their keys from the environment — there is nothing to sign in to here." + : "No providers are set up in Pi yet."} +
+ ) : null} + + +
+ ); +} + function piProviderAuthSummary(provider: AiPiProviderStatus): string { if (provider.authType === "oauth") return provider.subscription ? "OAuth subscription" : "OAuth"; if (provider.authType === "api-key") return "API key"; @@ -1361,7 +1861,6 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh : isInitialCheckInFlight ? "Checking Pi installation and provider inventory." : buildPiMessage(piConnection, piInstallation); - const configuredProviders = piInstallation?.providers.filter((provider) => provider.configured) ?? []; return (
@@ -1397,55 +1896,18 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh ) : null} {piConnection?.path && !isInitialCheckInFlight ? {piConnection.path} : null} - {configuredProviders.length > 0 ? ( -
-
- Configured providers -
-
- {configuredProviders.map((provider) => ( -
-
-
- - - {provider.name} - -
- {provider.availableModelCount > 0 ? ( - - ) : ( - - No models - - )} -
-
- {provider.availableModelCount || provider.modelCount} model{(provider.availableModelCount || provider.modelCount) === 1 ? "" : "s"} - {provider.availableModelCount > 0 && provider.modelCount > provider.availableModelCount ? ` · ${provider.modelCount} known` : ""} -
-
- {piProviderAuthSummary(provider)}{provider.authLabel ? ` · ${provider.authLabel}` : ""} -
-
- ))} -
-
+ {piInstallation ? ( + void refreshStatus({ force: true })} + onRevealTerminal={(terminal) => revealTerminalSessionInWork(navigate, terminal)} + /> ) : null} {piInstallation ? (
- revealTerminalSessionInWork(navigate, terminal)} - /> - {!piInstallation.cliAvailable ? ( - <> - - Install the Pi CLI to use Pi’s native /login flow. - - - + {!piInstallation.sdkAvailable ? ( + ) : null} {piInstallation.settingsFileDetected ? (