From 4e5737169f7b80d8d6bfb2a01d38e99bbfd99a68 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Jul 2026 11:07:49 +0200 Subject: [PATCH] feat: wire paseo agent provider catalog --- packages/app/e2e/helpers/paseo-agent.ts | 6 +- .../paseo-agent-settings-sheet-model.ts | 3 + packages/cli/src/commands/login/index.test.ts | 21 +- packages/cli/src/commands/login/index.ts | 8 +- .../cli/src/commands/provider/openrouter.ts | 2 +- packages/client/src/daemon-client.ts | 53 ++- .../src/messages.paseo-agent-config.test.ts | 132 +++++++- packages/protocol/src/messages.ts | 145 +++++++-- .../agent/providers/paseo-agent/agent.test.ts | 22 ++ .../agent/providers/paseo-agent/agent.ts | 30 +- .../agent/providers/paseo-agent/catalog.ts | 4 + .../paseo-agent/config-service.test.ts | 89 ++++- .../providers/paseo-agent/config-service.ts | 82 ++++- .../agent/providers/paseo-agent/config.ts | 9 +- .../paseo-agent/oauth-store.posix.test.ts | 2 + .../providers/paseo-agent/oauth-store.test.ts | 47 +++ .../providers/paseo-agent/oauth-store.ts | 187 ++++++++++- packages/server/src/server/session.test.ts | 156 ++++++++- packages/server/src/server/session.ts | 307 +++++++++++++++++- .../server/src/server/websocket-server.ts | 4 +- 20 files changed, 1226 insertions(+), 83 deletions(-) diff --git a/packages/app/e2e/helpers/paseo-agent.ts b/packages/app/e2e/helpers/paseo-agent.ts index e08597814..23e93a04c 100644 --- a/packages/app/e2e/helpers/paseo-agent.ts +++ b/packages/app/e2e/helpers/paseo-agent.ts @@ -12,7 +12,7 @@ type PaseoAgentDaemonClient = Pick< | "connect" | "removePaseoAgentProvider" | "setPaseoAgentProvider" - | "storePaseoAgentChatGptCredential" + | "storePaseoAgentOAuthCredential" >; interface OpenRouterProviderInput { @@ -82,8 +82,8 @@ export async function seedChatGptProvider(providerName: string): Promise { models: [{ id: "gpt-5.3-codex", reasoning: true }], }, }); - await client.storePaseoAgentChatGptCredential({ - providerName, + await client.storePaseoAgentOAuthCredential({ + name: providerName, credential: { type: "oauth", access: "fake-access-token", diff --git a/packages/app/src/components/paseo-agent-settings-sheet-model.ts b/packages/app/src/components/paseo-agent-settings-sheet-model.ts index cb73d5056..d361f93ab 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet-model.ts +++ b/packages/app/src/components/paseo-agent-settings-sheet-model.ts @@ -2,6 +2,9 @@ import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messag import type { PaseoAgentSetProviderInput } from "@/hooks/use-paseo-agent-providers"; export function paseoAgentAuthLabel(auth: RedactedPaseoAgentProviderConfig["auth"]): string { + if (!auth) { + return "Auth state unavailable"; + } if (auth.kind === "oauth") { return auth.configured ? "ChatGPT login stored" : "Login required"; } diff --git a/packages/cli/src/commands/login/index.test.ts b/packages/cli/src/commands/login/index.test.ts index 7da40fd8a..cdc844462 100644 --- a/packages/cli/src/commands/login/index.test.ts +++ b/packages/cli/src/commands/login/index.test.ts @@ -6,6 +6,7 @@ import { createLoginCommand } from "./index.js"; interface RecordedLogin { mode: "browser" | "device"; providerInstance?: string; + baseUrl?: string; envHome?: string | undefined; } @@ -60,12 +61,12 @@ describe("paseo login command", () => { serverId: "test-daemon", features: { paseoAgentConfig: true }, }), - storePaseoAgentChatGptCredential: async (input) => { + storePaseoAgentOAuthCredential: async (input) => { stored.push(input); return { requestId: "request-1", success: true, - providerName: input.providerName, + name: input.name, auth: { kind: "oauth", configured: true, source: "stored" }, error: null, }; @@ -83,7 +84,7 @@ describe("paseo login command", () => { expect(recorded).toEqual([{ mode: "browser" }]); expect(stored).toEqual([ { - providerName: "chatgpt", + name: "chatgpt", credential: { type: "oauth", access: "access-token", @@ -121,6 +122,7 @@ describe("paseo login command", () => { loginDeviceCode: async (options) => { recorded.push({ providerInstance: options.providerInstance, + baseUrl: options.baseUrl, envHome: options.env?.PASEO_HOME, mode: "device", }); @@ -144,7 +146,12 @@ describe("paseo login command", () => { ]); expect(recorded).toEqual([ - { providerInstance: "chatgpt", envHome: "/tmp/paseo-home", mode: "device" }, + { + providerInstance: "chatgpt", + baseUrl: "https://chatgpt.com/backend-api", + envHome: "/tmp/paseo-home", + mode: "device", + }, ]); expect(output.join("\n")).toContain("headless device-code flow"); expect(output.join("\n")).toContain("ABCD-EFGH"); @@ -211,7 +218,7 @@ describe("paseo login command", () => { serverId: "test-daemon", features: {}, }), - storePaseoAgentChatGptCredential: async (input) => { + storePaseoAgentOAuthCredential: async (input) => { stored.push(input); throw new Error("store RPC should not be called without the capability flag"); }, @@ -252,10 +259,10 @@ describe("paseo login command", () => { serverId: "test-daemon", features: { paseoAgentConfig: true }, }), - storePaseoAgentChatGptCredential: async (input) => ({ + storePaseoAgentOAuthCredential: async (input) => ({ requestId: "request-1", success: true, - providerName: input.providerName, + name: input.name, auth: { kind: "oauth", configured: true, source: "stored" }, error: null, }), diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts index 58fbba5cb..5606a6d29 100644 --- a/packages/cli/src/commands/login/index.ts +++ b/packages/cli/src/commands/login/index.ts @@ -22,6 +22,7 @@ import { const PROVIDER_INSTANCE = "chatgpt"; const OAUTH_FLOW = "openai-codex"; +const OAUTH_BASE_URL = "https://chatgpt.com/backend-api"; interface LoginChatgptOptions extends CommandOptions { deviceCode?: boolean; @@ -41,7 +42,7 @@ interface LoginCommandDependencies { connectDaemon: (options: { host?: string; }) => Promise< - Pick + Pick >; openBrowser: (url: string) => boolean; promptForCode: (message: string) => Promise; @@ -139,6 +140,7 @@ async function runChatgptLogin( write("Paseo login — ChatGPT/Codex subscription (headless device-code flow)\n"); const { path } = await dependencies.loginDeviceCode({ flow: OAUTH_FLOW, + baseUrl: OAUTH_BASE_URL, providerInstance: PROVIDER_INSTANCE, env, onDeviceCode: (info) => printDeviceCode(write, info), @@ -176,8 +178,8 @@ async function runChatgptLogin( onProgress: (message) => write(message), promptForCode: dependencies.promptForCode, }); - const result = await client.storePaseoAgentChatGptCredential({ - providerName: PROVIDER_INSTANCE, + const result = await client.storePaseoAgentOAuthCredential({ + name: PROVIDER_INSTANCE, credential, }); if (!result.success || result.error) { diff --git a/packages/cli/src/commands/provider/openrouter.ts b/packages/cli/src/commands/provider/openrouter.ts index dca369de6..08ccd6bcf 100644 --- a/packages/cli/src/commands/provider/openrouter.ts +++ b/packages/cli/src/commands/provider/openrouter.ts @@ -101,7 +101,7 @@ function toConfiguredItem(provider: RedactedPaseoAgentProviderConfig): OpenRoute return { name: provider.name, providerType: provider.providerType, - auth: provider.auth.configured ? (provider.auth.source ?? "configured") : "not configured", + auth: provider.auth?.configured ? (provider.auth.source ?? "configured") : "not configured", available: provider.available ? "yes" : "no", models: provider.models.map((model) => model.id).join(", "), }; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 8c6d31286..23d7af07e 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -97,12 +97,15 @@ import type { import type { MutableDaemonConfig, MutableDaemonConfigPatch, + PaseoAgentGetCatalogResponse, PaseoAgentGetProvidersResponse, + PaseoAgentOAuthCompleteResponse, PaseoAgentOAuthCredential, + PaseoAgentOAuthStartResponse, + PaseoAgentOAuthStoreCredentialResponse, PaseoAgentRemoveProviderResponse, PaseoAgentSetProviderRequest, PaseoAgentSetProviderResponse, - PaseoAgentStoreChatGptCredentialResponse, } from "@getpaseo/protocol/messages"; import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; @@ -3882,6 +3885,16 @@ export class DaemonClient { }); } + async getPaseoAgentCatalog(requestId?: string): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId, + message: { + type: "config.paseo_agent.get_catalog.request", + }, + timeout: 10000, + }); + } + async setPaseoAgentProvider( input: Omit & { requestId?: string }, ): Promise { @@ -3911,16 +3924,44 @@ export class DaemonClient { }); } - async storePaseoAgentChatGptCredential(input: { - providerName: string; + async startPaseoAgentOAuth( + name: string, + requestId?: string, + ): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId, + message: { + type: "config.paseo_agent.oauth.start.request", + name, + }, + timeout: 30000, + }); + } + + async completePaseoAgentOAuth( + name: string, + requestId?: string, + ): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId, + message: { + type: "config.paseo_agent.oauth.complete.request", + name, + }, + timeout: 30000, + }); + } + + async storePaseoAgentOAuthCredential(input: { + name: string; credential: PaseoAgentOAuthCredential; requestId?: string; - }): Promise { + }): Promise { return this.sendNamespacedCorrelatedSessionRequest({ requestId: input.requestId, message: { - type: "config.paseo_agent.store_chatgpt_credential.request", - providerName: input.providerName, + type: "config.paseo_agent.oauth.store_credential.request", + name: input.name, credential: input.credential, }, timeout: 30000, diff --git a/packages/protocol/src/messages.paseo-agent-config.test.ts b/packages/protocol/src/messages.paseo-agent-config.test.ts index 0392bbedf..2890110e3 100644 --- a/packages/protocol/src/messages.paseo-agent-config.test.ts +++ b/packages/protocol/src/messages.paseo-agent-config.test.ts @@ -30,7 +30,7 @@ describe("Paseo Agent config RPC schemas", () => { name: "kimi-main", providerType: "kimi-coding", models: [{ id: "kimi-k3" }], - auth: { kind: "api_key", configured: true, source: "env" }, + auth: { kind: "future_auth_kind", configured: true, source: "future_source" }, available: true, error: null, }, @@ -68,11 +68,120 @@ describe("Paseo Agent config RPC schemas", () => { expect(JSON.stringify(parsed)).not.toContain("apiKey"); }); + test("parses get_catalog request and response with forward-tolerant entries", () => { + const request = SessionInboundMessageSchema.parse({ + type: "config.paseo_agent.get_catalog.request", + requestId: "req-catalog", + }); + const response = SessionOutboundMessageSchema.parse({ + type: "config.paseo_agent.get_catalog.response", + payload: { + requestId: "req-catalog", + catalog: [ + { + id: "future-provider", + label: "Future Provider", + iconName: "sparkles", + docsUrl: "https://docs.example.test/provider", + api: "future-api", + baseUrl: "https://api.example.test", + headers: { "User-Agent": "PaseoTest/1" }, + compat: { minHost: "0.1.104" }, + auth: { kind: "future_oauth", flow: "future-flow", extraAuthField: true }, + models: [ + { + id: "future-model", + label: "Future Model", + futureModelField: "kept", + }, + ], + futureEntryField: { keep: true }, + }, + ], + error: null, + }, + }); + + expect(request.type).toBe("config.paseo_agent.get_catalog.request"); + expect(response.payload.catalog[0]?.auth.kind).toBe("future_oauth"); + expect(response.payload.catalog[0]?.futureEntryField).toEqual({ keep: true }); + expect(response.payload.catalog[0]?.models[0]?.futureModelField).toBe("kept"); + }); + + test("parses oauth.start request and device-code response", () => { + const request = SessionInboundMessageSchema.parse({ + type: "config.paseo_agent.oauth.start.request", + requestId: "req-oauth-start", + name: "subscription", + }); + const response = SessionOutboundMessageSchema.parse({ + type: "config.paseo_agent.oauth.start.response", + payload: { + requestId: "req-oauth-start", + success: true, + name: "subscription", + authorization: { + kind: "device_code", + userCode: "ABCD-EFGH", + verificationUri: "https://auth.example.test/device", + intervalSeconds: 5, + expiresInSeconds: 900, + futureField: "kept", + }, + error: null, + }, + }); + + expect(request.name).toBe("subscription"); + expect(response.payload.authorization?.kind).toBe("device_code"); + expect(response.payload.authorization?.futureField).toBe("kept"); + }); + + test("parses oauth.start auth-url response", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "config.paseo_agent.oauth.start.response", + payload: { + requestId: "req-oauth-start-url", + success: true, + name: "subscription", + authorization: { + kind: "auth_url", + url: "https://auth.example.test/oauth", + instructions: "Open this URL to continue.", + }, + error: null, + }, + }); + + expect(parsed.payload.authorization?.url).toBe("https://auth.example.test/oauth"); + }); + + test("parses oauth.complete request and response", () => { + const request = SessionInboundMessageSchema.parse({ + type: "config.paseo_agent.oauth.complete.request", + requestId: "req-oauth-complete", + name: "subscription", + }); + const response = SessionOutboundMessageSchema.parse({ + type: "config.paseo_agent.oauth.complete.response", + payload: { + requestId: "req-oauth-complete", + success: true, + name: "subscription", + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }, + }); + + expect(request.name).toBe("subscription"); + expect(response.payload.auth?.configured).toBe(true); + }); + test("preserves future OAuth credential fields on inbound schema parse", () => { const parsed = SessionInboundMessageSchema.parse({ - type: "config.paseo_agent.store_chatgpt_credential.request", + type: "config.paseo_agent.oauth.store_credential.request", requestId: "req-oauth", - providerName: "chatgpt", + name: "subscription", credential: { type: "oauth", access: "access-token", @@ -86,6 +195,23 @@ describe("Paseo Agent config RPC schemas", () => { expect(parsed.credential.futureField).toEqual({ keep: true }); }); + test("parses oauth.store_credential response without credential material", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "config.paseo_agent.oauth.store_credential.response", + payload: { + requestId: "req-oauth", + success: true, + name: "subscription", + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }, + }); + + expect(parsed.payload.name).toBe("subscription"); + expect(JSON.stringify(parsed)).not.toContain("access-token"); + expect(JSON.stringify(parsed)).not.toContain("refresh-token"); + }); + test("parses ChatGPT provider config separately from credential storage", () => { const parsed = SessionInboundMessageSchema.parse({ type: "config.paseo_agent.set_provider.request", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 0c75b74c9..9b75937ea 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1960,16 +1960,46 @@ const PaseoAgentOAuthCredentialSchema = z }) .passthrough(); +const PaseoAgentCatalogModelSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1).optional(), + api: z.string().min(1).optional(), + reasoning: z.boolean().optional(), + contextWindow: z.number().int().positive().optional(), + maxTokens: z.number().int().positive().optional(), + }) + .passthrough(); + +const PaseoAgentCatalogAuthSchema = z + .object({ + kind: z.string().min(1), + }) + .passthrough(); + +export const PaseoAgentCatalogEntrySchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + iconName: z.string().min(1).optional(), + docsUrl: z.string().optional(), + api: z.string().min(1), + baseUrl: z.string().min(1), + headers: z.record(z.string(), z.string()).optional(), + compat: z.record(z.string(), z.unknown()).optional(), + auth: PaseoAgentCatalogAuthSchema, + models: z.array(PaseoAgentCatalogModelSchema), + }) + .passthrough(); + export const PaseoAgentProviderAuthStateSchema = z .object({ - kind: z.enum(["api_key", "oauth", "none"]), + kind: z.string().min(1), configured: z.boolean(), - source: z - .enum(["literal", "env", "default_env", "command", "stored", "refresh_token"]) - .optional(), + source: z.string().min(1).optional(), hint: z.string().optional(), }) - .strict(); + .passthrough(); export const RedactedPaseoAgentProviderConfigSchema = z .object({ @@ -1978,17 +2008,22 @@ export const RedactedPaseoAgentProviderConfigSchema = z baseUrl: z.string().optional(), api: z.string().optional(), models: z.array(PaseoAgentProviderModelConfigSchema), - auth: PaseoAgentProviderAuthStateSchema, + auth: PaseoAgentProviderAuthStateSchema.optional(), available: z.boolean(), error: z.string().nullable().optional(), }) - .strict(); + .passthrough(); export const PaseoAgentGetProvidersRequestSchema = z.object({ type: z.literal("config.paseo_agent.get_providers.request"), requestId: z.string(), }); +export const PaseoAgentGetCatalogRequestSchema = z.object({ + type: z.literal("config.paseo_agent.get_catalog.request"), + requestId: z.string(), +}); + export const PaseoAgentSetProviderRequestSchema = z.object({ type: z.literal("config.paseo_agent.set_provider.request"), requestId: z.string(), @@ -2003,10 +2038,22 @@ export const PaseoAgentRemoveProviderRequestSchema = z.object({ name: z.string().trim().min(1), }); -export const PaseoAgentStoreChatGptCredentialRequestSchema = z.object({ - type: z.literal("config.paseo_agent.store_chatgpt_credential.request"), +export const PaseoAgentOAuthStartRequestSchema = z.object({ + type: z.literal("config.paseo_agent.oauth.start.request"), requestId: z.string(), - providerName: z.string().trim().min(1), + name: z.string().trim().min(1), +}); + +export const PaseoAgentOAuthCompleteRequestSchema = z.object({ + type: z.literal("config.paseo_agent.oauth.complete.request"), + requestId: z.string(), + name: z.string().trim().min(1), +}); + +export const PaseoAgentOAuthStoreCredentialRequestSchema = z.object({ + type: z.literal("config.paseo_agent.oauth.store_credential.request"), + requestId: z.string(), + name: z.string().trim().min(1), credential: PaseoAgentOAuthCredentialSchema, }); @@ -2162,9 +2209,12 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ListProviderModesRequestMessageSchema, ListProviderFeaturesRequestMessageSchema, PaseoAgentGetProvidersRequestSchema, + PaseoAgentGetCatalogRequestSchema, PaseoAgentSetProviderRequestSchema, PaseoAgentRemoveProviderRequestSchema, - PaseoAgentStoreChatGptCredentialRequestSchema, + PaseoAgentOAuthStartRequestSchema, + PaseoAgentOAuthCompleteRequestSchema, + PaseoAgentOAuthStoreCredentialRequestSchema, ListAvailableProvidersRequestMessageSchema, GetProvidersSnapshotRequestMessageSchema, RefreshProvidersSnapshotRequestMessageSchema, @@ -2458,6 +2508,8 @@ export const ServerInfoStatusPayloadSchema = z agentForkContext: z.boolean().optional(), // COMPAT(paseoAgentConfig): added in v0.1.103, remove gate after 2027-01-02. paseoAgentConfig: z.boolean().optional(), + // COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104 + paseoAgentCatalog: z.boolean().optional(), }) .optional(), }) @@ -3974,6 +4026,15 @@ export const PaseoAgentGetProvidersResponseSchema = z.object({ }), }); +export const PaseoAgentGetCatalogResponseSchema = z.object({ + type: z.literal("config.paseo_agent.get_catalog.response"), + payload: z.object({ + requestId: z.string(), + catalog: z.array(PaseoAgentCatalogEntrySchema), + error: z.string().nullable(), + }), +}); + export const PaseoAgentSetProviderResponseSchema = z.object({ type: z.literal("config.paseo_agent.set_provider.response"), payload: z.object({ @@ -3994,13 +4055,47 @@ export const PaseoAgentRemoveProviderResponseSchema = z.object({ }), }); -export const PaseoAgentStoreChatGptCredentialResponseSchema = z.object({ - type: z.literal("config.paseo_agent.store_chatgpt_credential.response"), +const PaseoAgentOAuthStartAuthorizationSchema = z + .object({ + kind: z.string().min(1), + url: z.string().optional(), + instructions: z.string().optional(), + userCode: z.string().optional(), + verificationUri: z.string().optional(), + intervalSeconds: z.number().optional(), + expiresInSeconds: z.number().optional(), + }) + .passthrough(); + +export const PaseoAgentOAuthStartResponseSchema = z.object({ + type: z.literal("config.paseo_agent.oauth.start.response"), payload: z.object({ requestId: z.string(), success: z.boolean(), - providerName: z.string(), - auth: PaseoAgentProviderAuthStateSchema, + name: z.string(), + authorization: PaseoAgentOAuthStartAuthorizationSchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const PaseoAgentOAuthCompleteResponseSchema = z.object({ + type: z.literal("config.paseo_agent.oauth.complete.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + name: z.string(), + auth: PaseoAgentProviderAuthStateSchema.optional(), + error: z.string().nullable(), + }), +}); + +export const PaseoAgentOAuthStoreCredentialResponseSchema = z.object({ + type: z.literal("config.paseo_agent.oauth.store_credential.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + name: z.string(), + auth: PaseoAgentProviderAuthStateSchema.optional(), error: z.string().nullable(), }), }); @@ -4401,9 +4496,12 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListProviderModesResponseMessageSchema, ListProviderFeaturesResponseMessageSchema, PaseoAgentGetProvidersResponseSchema, + PaseoAgentGetCatalogResponseSchema, PaseoAgentSetProviderResponseSchema, PaseoAgentRemoveProviderResponseSchema, - PaseoAgentStoreChatGptCredentialResponseSchema, + PaseoAgentOAuthStartResponseSchema, + PaseoAgentOAuthCompleteResponseSchema, + PaseoAgentOAuthStoreCredentialResponseSchema, ListAvailableProvidersResponseSchema, GetProvidersSnapshotResponseMessageSchema, ProvidersSnapshotUpdateMessageSchema, @@ -4541,13 +4639,17 @@ export type RedactedPaseoAgentProviderConfig = z.infer< >; export type PaseoAgentProviderAuthState = z.infer; export type PaseoAgentOAuthCredential = z.infer; +export type PaseoAgentCatalogEntry = z.infer; export type PaseoAgentGetProvidersResponse = z.infer; +export type PaseoAgentGetCatalogResponse = z.infer; export type PaseoAgentSetProviderResponse = z.infer; export type PaseoAgentRemoveProviderResponse = z.infer< typeof PaseoAgentRemoveProviderResponseSchema >; -export type PaseoAgentStoreChatGptCredentialResponse = z.infer< - typeof PaseoAgentStoreChatGptCredentialResponseSchema +export type PaseoAgentOAuthStartResponse = z.infer; +export type PaseoAgentOAuthCompleteResponse = z.infer; +export type PaseoAgentOAuthStoreCredentialResponse = z.infer< + typeof PaseoAgentOAuthStoreCredentialResponseSchema >; export type ListAvailableProvidersResponse = z.infer; export type DaemonGetStatusResponse = z.infer; @@ -4626,10 +4728,13 @@ export type ListProviderFeaturesRequestMessage = z.infer< typeof ListProviderFeaturesRequestMessageSchema >; export type PaseoAgentGetProvidersRequest = z.infer; +export type PaseoAgentGetCatalogRequest = z.infer; export type PaseoAgentSetProviderRequest = z.infer; export type PaseoAgentRemoveProviderRequest = z.infer; -export type PaseoAgentStoreChatGptCredentialRequest = z.infer< - typeof PaseoAgentStoreChatGptCredentialRequestSchema +export type PaseoAgentOAuthStartRequest = z.infer; +export type PaseoAgentOAuthCompleteRequest = z.infer; +export type PaseoAgentOAuthStoreCredentialRequest = z.infer< + typeof PaseoAgentOAuthStoreCredentialRequestSchema >; export type ListAvailableProvidersRequestMessage = z.infer< typeof ListAvailableProvidersRequestMessageSchema diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts index 4e1a2c06d..c24552855 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts @@ -184,6 +184,7 @@ describe("PaseoAgentClient", () => { storeOAuthCredential({ providerInstance: "chatgpt", credential: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 0 }, + binding: { flow: "openai-codex", baseUrl: "https://chatgpt.com/backend-api" }, env: { PASEO_HOME: paseoHome }, }); const config = PaseoAgentConfigSchema.parse({ @@ -206,6 +207,27 @@ describe("PaseoAgentClient", () => { } }); + it("does not treat an endpoint-mismatched OAuth credential as available", async () => { + const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); + tempDirs.push(paseoHome); + storeOAuthCredential({ + providerInstance: "chatgpt", + credential: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 0 }, + binding: { flow: "openai-codex", baseUrl: "https://chatgpt.example.test/changed" }, + env: { PASEO_HOME: paseoHome }, + }); + const config = PaseoAgentConfigSchema.parse({ + providers: { + chatgpt: { + type: "chatgpt", + }, + }, + }); + + const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome }); + expect(await client.isAvailable()).toBe(false); + }); + it("lists only configured models, never Pi disk/default models", async () => { const client = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() }); const { models } = await client.fetchCatalog({ diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.ts index 5b0299f9d..d42a3110f 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.ts @@ -44,7 +44,11 @@ import { type PiTrackedToolCall, } from "./event-mapping.js"; import { createMcpToolBridge, type McpToolBridge } from "./mcp-bridge.js"; -import { createPaseoAgentAuthStorage, hasStoredOAuthCredential } from "./oauth-store.js"; +import { + createBoundPaseoAgentAuthStorage, + hasStoredOAuthCredential, + type OAuthCredentialBinding, +} from "./oauth-store.js"; import { createPaseoAgentSession, type PaseoAgentSessionHandle } from "./pi-services.js"; import { createToolPermissionPolicy } from "./agent-permissions.js"; import { @@ -102,6 +106,21 @@ function isAbortError(error: unknown): boolean { return /\brequest was aborted\b|\babort(ed)?\b/i.test(errorToMessage(error)); } +function oauthCredentialBindings( + providers: Awaited>, +): Record { + const bindings: Record = {}; + for (const provider of providers) { + if (provider.oauth) { + bindings[provider.name] = { + flow: provider.oauth.flow, + baseUrl: provider.config.baseUrl ?? "", + }; + } + } + return bindings; +} + interface PaseoAgentClientOptions { logger: Logger; config: PaseoAgentConfig; @@ -564,7 +583,10 @@ export class PaseoAgentClient implements AgentClient { // the stored credential and persists refreshed tokens (rotation) back to it. const usesOAuth = modelProviders.some((provider) => provider.oauth); const authStorage = usesOAuth - ? createPaseoAgentAuthStorage(envForPaseoHome(this.paseoHome)) + ? createBoundPaseoAgentAuthStorage( + oauthCredentialBindings(modelProviders), + envForPaseoHome(this.paseoHome), + ) : undefined; // Bridge Paseo-injected MCP servers (e.g. the `paseo` HTTP server) into Pi tools. @@ -607,8 +629,8 @@ export class PaseoAgentClient implements AgentClient { async isAvailable(): Promise { const env = envForPaseoHome(this.paseoHome); - return paseoAgentHasUsableModel(this.config, env, (providerInstance) => - hasStoredOAuthCredential(providerInstance, env), + return paseoAgentHasUsableModel(this.config, env, (providerInstance, binding) => + hasStoredOAuthCredential(providerInstance, env, binding), ); } diff --git a/packages/server/src/server/agent/providers/paseo-agent/catalog.ts b/packages/server/src/server/agent/providers/paseo-agent/catalog.ts index 8b2cdc893..f57222983 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/catalog.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/catalog.ts @@ -1,4 +1,5 @@ export interface PaseoAgentCatalogModel { + [key: string]: unknown; id: string; label?: string; api?: string; @@ -8,6 +9,7 @@ export interface PaseoAgentCatalogModel { } export interface PaseoAgentApiKeyAuth { + [key: string]: unknown; kind: "api_key"; envVar: string; keyUrl?: string; @@ -16,6 +18,7 @@ export interface PaseoAgentApiKeyAuth { } export interface PaseoAgentOAuthAuth { + [key: string]: unknown; kind: "oauth"; flow: string; } @@ -23,6 +26,7 @@ export interface PaseoAgentOAuthAuth { export type PaseoAgentCatalogAuth = PaseoAgentApiKeyAuth | PaseoAgentOAuthAuth; export interface PaseoAgentCatalogEntry { + [key: string]: unknown; id: string; label: string; iconName?: string; diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts index 418f14836..8e6ab0dab 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts @@ -7,7 +7,7 @@ import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { loadPersistedConfig, savePersistedConfig } from "../../../persisted-config.js"; import { PaseoAgentConfigService } from "./config-service.js"; import { PaseoAgentConfigSchema } from "./config.js"; -import { paseoAgentAuthStoragePath } from "./oauth-store.js"; +import { paseoAgentAuthStoragePath, storeOAuthCredential } from "./oauth-store.js"; describe("PaseoAgentConfigService", () => { let home: string; @@ -56,6 +56,48 @@ describe("PaseoAgentConfigService", () => { ); }); + test("reports API-key default env auth state without resolving the key value", () => { + const missingService = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + env: { PASEO_HOME: home }, + }); + missingService.setProvider({ + name: "openrouter-main", + providerType: "openrouter", + options: { + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }); + + expect(missingService.getProviders().providers[0]).toMatchObject({ + auth: { + kind: "api_key", + configured: false, + source: "default_env", + hint: "OPENROUTER_API_KEY", + }, + available: false, + }); + + const presentService = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + env: { PASEO_HOME: home, OPENROUTER_API_KEY: "sk-env-secret" }, + }); + + expect(presentService.getProviders().providers[0]).toMatchObject({ + auth: { + kind: "api_key", + configured: true, + source: "default_env", + hint: "OPENROUTER_API_KEY", + }, + available: true, + }); + expect(JSON.stringify(presentService.getProviders())).not.toContain("sk-env-secret"); + }); + test("rejects an unknown provider type with a clear error and persists nothing", () => { const service = new PaseoAgentConfigService({ paseoHome: home, @@ -185,6 +227,10 @@ describe("PaseoAgentConfigService", () => { type: "oauth", access: "access-token", refresh: "refresh-token", + binding: { + flow: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + }, futureField: { keep: true }, }); expect(authPath).toBe(join(home, "paseo-agent", "auth.json")); @@ -221,6 +267,47 @@ describe("PaseoAgentConfigService", () => { expect(JSON.stringify(providers)).not.toContain("refresh-token"); }); + test("reports OAuth as missing or needing attention for absent and mismatched credentials", () => { + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + }); + service.setProvider({ + name: "chatgpt", + providerType: "chatgpt", + options: {}, + }); + + expect(service.getProviders().providers[0]).toMatchObject({ + auth: { kind: "oauth", configured: false }, + available: false, + }); + + storeOAuthCredential({ + providerInstance: "chatgpt", + env: { PASEO_HOME: home }, + binding: { flow: "openai-codex", baseUrl: "https://chatgpt.example.test/changed" }, + credential: { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }, + }); + + expect(service.getProviders().providers[0]).toMatchObject({ + auth: { + kind: "oauth", + configured: false, + source: "stored", + hint: "binding_mismatch", + }, + available: false, + }); + expect(JSON.stringify(service.getProviders())).not.toContain("access-token"); + expect(JSON.stringify(service.getProviders())).not.toContain("refresh-token"); + }); + test("removes providers and clears a default model owned by that provider", () => { const service = new PaseoAgentConfigService({ paseoHome: home, diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts index faa50c551..a899ead96 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts @@ -16,8 +16,16 @@ import { resolvePaseoAgentProviderModels, resolvePaseoAgentProviderSettings, } from "./config.js"; -import { requirePaseoAgentCatalogEntry } from "./catalog.js"; -import { hasStoredOAuthCredential, storeOAuthCredential } from "./oauth-store.js"; +import { + PASEO_AGENT_PROVIDER_CATALOG, + type PaseoAgentCatalogEntry, + requirePaseoAgentCatalogEntry, +} from "./catalog.js"; +import { + getStoredOAuthCredentialState, + storeOAuthCredential, + type OAuthCredentialBinding, +} from "./oauth-store.js"; import { isRefreshTokenExpressionConfigured } from "./oauth-credentials.js"; import { findEnvReferences } from "./env-references.js"; @@ -83,6 +91,23 @@ function authStateForApiKey( return { kind: "api_key", configured: true, source: "literal" }; } +function copyCatalogEntry(entry: PaseoAgentCatalogEntry): PaseoAgentCatalogEntry { + return { + ...entry, + ...(entry.headers ? { headers: { ...entry.headers } } : {}), + ...(entry.compat ? { compat: { ...entry.compat } } : {}), + auth: { ...entry.auth }, + models: entry.models.map((model) => ({ ...model })), + }; +} + +function oauthBindingForSettings( + flow: string, + settings: ReturnType, +): OAuthCredentialBinding { + return { flow, baseUrl: settings.baseUrl }; +} + function readPaseoAgentConfig(persisted: PersistedConfig): PaseoAgentConfig { return validatePaseoAgentConfig(PaseoAgentConfigSchema.parse(persisted.agents?.paseo ?? {})); } @@ -110,10 +135,23 @@ function redactedProviders( if (hasRefreshToken) { auth = { kind: "oauth", configured: true, source: "refresh_token" }; } else { - const stored = hasStoredOAuthCredential(name, env); - auth = stored - ? { kind: "oauth", configured: true, source: "stored" } - : { kind: "oauth", configured: false }; + const stored = getStoredOAuthCredentialState( + name, + env, + oauthBindingForSettings(catalogEntry.auth.flow, settings), + ); + if (stored.present && stored.bindingMatches) { + auth = { kind: "oauth", configured: true, source: "stored" }; + } else if (stored.present) { + auth = { + kind: "oauth", + configured: false, + source: "stored", + hint: "binding_mismatch", + }; + } else { + auth = { kind: "oauth", configured: false }; + } } } else { auth = authStateForApiKey(entry.options.apiKey, catalogEntry.auth.envVar, env); @@ -158,6 +196,10 @@ export class PaseoAgentConfigService { this.onConfigChanged = options.onConfigChanged; } + getCatalog(): PaseoAgentCatalogEntry[] { + return PASEO_AGENT_PROVIDER_CATALOG.map(copyCatalogEntry); + } + getProviders(): { defaultModel: string | null; providers: RedactedPaseoAgentProviderConfig[] } { const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger)); return { @@ -198,7 +240,7 @@ export class PaseoAgentConfigService { return removed; } - storeOAuthCredential(providerName: string, credential: PaseoAgentOAuthCredential): void { + getOAuthCredentialBinding(providerName: string): OAuthCredentialBinding { const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger)); const entry = config.providers?.[providerName]; if (!entry) { @@ -208,12 +250,38 @@ export class PaseoAgentConfigService { if (catalogEntry.auth.kind !== "oauth") { throw new Error(`Paseo Agent provider '${providerName}' does not use OAuth.`); } + const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry); + return oauthBindingForSettings(catalogEntry.auth.flow, settings); + } + + storeOAuthCredential( + providerName: string, + credential: PaseoAgentOAuthCredential, + binding: OAuthCredentialBinding | undefined = undefined, + ): PaseoAgentProviderAuthState { + const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger)); + const entry = config.providers?.[providerName]; + if (!entry) { + throw new Error(`Paseo Agent provider '${providerName}' is not configured.`); + } + const catalogEntry = requirePaseoAgentCatalogEntry(entry.type); + if (catalogEntry.auth.kind !== "oauth") { + throw new Error(`Paseo Agent provider '${providerName}' does not use OAuth.`); + } + const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry); storeOAuthCredential({ providerInstance: providerName, credential, + binding: binding ?? oauthBindingForSettings(catalogEntry.auth.flow, settings), env: this.env, }); this.onConfigChanged?.(config); + return ( + this.requireRedactedProvider(config, providerName).auth ?? { + kind: "oauth", + configured: false, + } + ); } private requireRedactedProvider( diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts index fcb418550..9be61a27a 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -5,6 +5,7 @@ import { isRefreshTokenExpressionConfigured, resolveRefreshTokenExpression, } from "./oauth-credentials.js"; +import type { OAuthCredentialBinding } from "./oauth-store.js"; import type { PaseoAgentModelProvider, PaseoAgentModelReference } from "./pi-services.js"; import { requirePaseoAgentCatalogEntry, @@ -225,7 +226,8 @@ export function listPaseoAgentModels(config: PaseoAgentConfig): AgentModelDefini export function paseoAgentHasUsableModel( config: PaseoAgentConfig, env: NodeJS.ProcessEnv = process.env, - isOAuthAuthed: (providerInstance: string) => boolean = () => false, + isOAuthAuthed: (providerInstance: string, binding: OAuthCredentialBinding) => boolean = () => + false, ): boolean { return entries(config).some(([name, entry]) => { const catalogEntry = requirePaseoAgentCatalogEntry(entry.type); @@ -239,7 +241,10 @@ export function paseoAgentHasUsableModel( ) { return true; } - return isOAuthAuthed(name); + return isOAuthAuthed(name, { + flow: catalogEntry.auth.flow, + baseUrl: resolvePaseoAgentProviderSettings(entry, catalogEntry).baseUrl, + }); } return isAuthConfigured(resolvePaseoAgentProviderSettings(entry, catalogEntry).apiKey, env); }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.posix.test.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.posix.test.ts index e04ec9767..1515e7836 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.posix.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.posix.test.ts @@ -25,6 +25,7 @@ describe.skipIf(isPlatform("win32"))("oauth-store POSIX-only", () => { const { path } = await loginAndStoreOAuth({ flow: "paseo-test-oauth", + baseUrl: "https://api.example.test/oauth", providerInstance: "chatgpt", env, onDeviceCode: () => {}, @@ -38,6 +39,7 @@ describe.skipIf(isPlatform("win32"))("oauth-store POSIX-only", () => { const { path } = storeOAuthCredential({ providerInstance: "chatgpt", env, + binding: { flow: "paseo-test-oauth", baseUrl: "https://api.example.test/oauth" }, credential: { type: "oauth", access: "ac", refresh: "rt", expires: 123 }, }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts index 0429843de..ec86adf28 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts @@ -6,6 +6,8 @@ import { registerOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-a import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + createBoundPaseoAgentAuthStorage, + getStoredOAuthCredentialState, hasStoredOAuthCredential, loginAndStoreOAuth, loginOAuthBrowser, @@ -14,6 +16,7 @@ import { } from "./oauth-store.js"; const TEST_FLOW = "paseo-test-oauth-store"; +const TEST_BASE_URL = "https://api.example.test/oauth"; function registerTestOAuthProvider(): void { const provider: OAuthProviderInterface = { @@ -63,6 +66,7 @@ describe("oauth-store", () => { const { path } = storeOAuthCredential({ providerInstance: "chatgpt", env, + binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL }, credential: { type: "oauth", access: "access-token", @@ -73,20 +77,62 @@ describe("oauth-store", () => { }); expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); + expect( + hasStoredOAuthCredential("chatgpt", env, { flow: TEST_FLOW, baseUrl: TEST_BASE_URL }), + ).toBe(true); + expect( + getStoredOAuthCredentialState("chatgpt", env, { + flow: TEST_FLOW, + baseUrl: "https://api.example.test/changed", + }), + ).toEqual({ present: true, bindingMatches: false }); const stored = JSON.parse(readFileSync(path, "utf8")); expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "refresh-token", + binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL }, futureField: { keep: true }, }); }); + it("hides mismatched credentials from runtime auth storage without deleting them", async () => { + const { path } = storeOAuthCredential({ + providerInstance: "chatgpt", + env, + binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL }, + credential: { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }, + }); + + const authStorage = createBoundPaseoAgentAuthStorage( + { + chatgpt: { flow: TEST_FLOW, baseUrl: "https://api.example.test/changed" }, + }, + env, + ); + + expect(authStorage.has("chatgpt")).toBe(false); + await expect(authStorage.getApiKey("chatgpt", { includeFallback: false })).resolves.toBe( + undefined, + ); + expect(JSON.parse(readFileSync(path, "utf8")).chatgpt).toMatchObject({ + type: "oauth", + refresh: "refresh-token", + binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL }, + }); + }); + it("runs the Pi registry login flow and persists a Paseo-owned credential", async () => { registerTestOAuthProvider(); const deviceCodes: unknown[] = []; const { path } = await loginAndStoreOAuth({ flow: TEST_FLOW, + baseUrl: TEST_BASE_URL, providerInstance: "chatgpt", env, onDeviceCode: (info) => deviceCodes.push(info), @@ -103,6 +149,7 @@ describe("oauth-store", () => { const login = async () => ({ refresh: "rt", access: "", expires: 0 }); await loginAndStoreOAuth({ flow: TEST_FLOW, + baseUrl: TEST_BASE_URL, providerInstance: "work-chatgpt", env, onDeviceCode: () => {}, diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts index 69814c0f6..a85dabfee 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts @@ -1,7 +1,12 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { AuthStorage } from "@earendil-works/pi-coding-agent"; +import { + AuthStorage, + FileAuthStorageBackend, + type AuthCredential, + type AuthStorageBackend, +} from "@earendil-works/pi-coding-agent"; import type { OAuthCredentials, OAuthDeviceCodeInfo as PiOAuthDeviceCodeInfo, @@ -20,6 +25,25 @@ export type OAuthDeviceCodeInfo = PiOAuthDeviceCodeInfo; type OAuthLogin = (callbacks: OAuthLoginCallbacks) => Promise; type OAuthLoginPreference = "browser" | "device"; +export interface OAuthCredentialBinding { + flow: string; + baseUrl: string; +} + +export interface StoredOAuthCredentialState { + present: boolean; + bindingMatches: boolean; +} + +interface BoundOAuthCredential extends PaseoAgentOAuthCredential { + binding?: OAuthCredentialBinding; +} + +interface StorageLockResult { + result: T; + next?: string; +} + /** Path to the Paseo-owned auth store. Uses PASEO_HOME; falls back to ~/.paseo. */ export function paseoAgentAuthStoragePath(env: NodeJS.ProcessEnv = process.env): string { const base = env.PASEO_HOME ?? join(homedir(), ".paseo"); @@ -34,6 +58,15 @@ export function createPaseoAgentAuthStorage(env: NodeJS.ProcessEnv = process.env return AuthStorage.create(paseoAgentAuthStoragePath(env)); } +export function createBoundPaseoAgentAuthStorage( + bindings: Record, + env: NodeJS.ProcessEnv = process.env, +): AuthStorage { + return AuthStorage.fromStorage( + new BindingAwareAuthStorageBackend(paseoAgentAuthStoragePath(env), bindings), + ); +} + /** * Read-only check (no file creation) for whether a Paseo-owned OAuth credential exists * for a provider instance. Used for availability without constructing AuthStorage. @@ -41,22 +74,33 @@ export function createPaseoAgentAuthStorage(env: NodeJS.ProcessEnv = process.env export function hasStoredOAuthCredential( providerInstance: string, env: NodeJS.ProcessEnv = process.env, + binding?: OAuthCredentialBinding, ): boolean { + const state = getStoredOAuthCredentialState(providerInstance, env, binding); + return state.present && state.bindingMatches; +} + +export function getStoredOAuthCredentialState( + providerInstance: string, + env: NodeJS.ProcessEnv = process.env, + binding?: OAuthCredentialBinding, +): StoredOAuthCredentialState { const path = paseoAgentAuthStoragePath(env); if (!existsSync(path)) { - return false; + return { present: false, bindingMatches: false }; } try { const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if (typeof parsed !== "object" || parsed === null) { - return false; + return { present: false, bindingMatches: false }; } const entry = (parsed as Record)[providerInstance]; - return ( - typeof entry === "object" && entry !== null && (entry as { type?: unknown }).type === "oauth" - ); + if (!isOAuthCredentialRecord(entry)) { + return { present: false, bindingMatches: false }; + } + return { present: true, bindingMatches: !binding || bindingsEqual(entry.binding, binding) }; } catch { - return false; + return { present: false, bindingMatches: false }; } } @@ -68,16 +112,21 @@ export function hasStoredOAuthCredential( export function storeOAuthCredential(options: { providerInstance: string; credential: PaseoAgentOAuthCredential; + binding: OAuthCredentialBinding; env?: NodeJS.ProcessEnv; }): { path: string } { const path = paseoAgentAuthStoragePath(options.env); const authStorage = AuthStorage.create(path); - authStorage.set(options.providerInstance, options.credential); + authStorage.set(options.providerInstance, { + ...options.credential, + binding: { ...options.binding }, + }); return { path }; } export async function loginAndStoreOAuth(options: { flow: string; + baseUrl: string; providerInstance: string; onDeviceCode: (info: OAuthDeviceCodeInfo) => void; env?: NodeJS.ProcessEnv; @@ -97,6 +146,7 @@ export async function loginAndStoreOAuth(options: { return storeOAuthCredential({ providerInstance: options.providerInstance, credential: { type: "oauth", ...credentials }, + binding: { flow: options.flow, baseUrl: options.baseUrl }, env: options.env, }); } @@ -124,6 +174,127 @@ export async function loginOAuthBrowser(options: { return { type: "oauth", ...credentials }; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isOAuthCredentialRecord(value: unknown): value is BoundOAuthCredential { + return isRecord(value) && value.type === "oauth"; +} + +function bindingsEqual( + actual: OAuthCredentialBinding | undefined, + expected: OAuthCredentialBinding, +): boolean { + return actual?.flow === expected.flow && actual.baseUrl === expected.baseUrl; +} + +function credentialBinding( + credential: AuthCredential | undefined, +): OAuthCredentialBinding | undefined { + if (!credential || credential.type !== "oauth") { + return undefined; + } + const binding = credential.binding; + if (!isRecord(binding)) { + return undefined; + } + return typeof binding.flow === "string" && typeof binding.baseUrl === "string" + ? { flow: binding.flow, baseUrl: binding.baseUrl } + : undefined; +} + +function parseAuthStorageData(current: string | undefined): Record { + if (!current) { + return {}; + } + const parsed: unknown = JSON.parse(current); + return isRecord(parsed) ? (parsed as Record) : {}; +} + +function serializeAuthStorageData(data: Record): string { + return JSON.stringify(data, null, 2); +} + +function filterBoundCredentials( + data: Record, + bindings: Record, +): Record { + const filtered: Record = {}; + for (const [provider, credential] of Object.entries(data)) { + const binding = bindings[provider]; + if ( + !binding || + credential.type !== "oauth" || + bindingsEqual(credentialBinding(credential), binding) + ) { + filtered[provider] = credential; + } + } + return filtered; +} + +function mergeBindingAwareWrite( + original: Record, + visible: Record, + next: string, +): string { + const nextData = parseAuthStorageData(next); + const merged: Record = { ...original }; + for (const provider of Object.keys(visible)) { + if (!Object.prototype.hasOwnProperty.call(nextData, provider)) { + delete merged[provider]; + } + } + for (const [provider, credential] of Object.entries(nextData)) { + const originalBinding = credentialBinding(original[provider]); + if (credential.type === "oauth" && !credential.binding && originalBinding) { + merged[provider] = { ...credential, binding: originalBinding }; + } else { + merged[provider] = credential; + } + } + return serializeAuthStorageData(merged); +} + +class BindingAwareAuthStorageBackend implements AuthStorageBackend { + private readonly delegate: FileAuthStorageBackend; + private readonly bindings: Record; + + constructor(path: string, bindings: Record) { + this.delegate = new FileAuthStorageBackend(path); + this.bindings = bindings; + } + + withLock(fn: (current: string | undefined) => StorageLockResult): T { + return this.delegate.withLock((current) => { + const original = parseAuthStorageData(current); + const visible = filterBoundCredentials(original, this.bindings); + const result = fn(serializeAuthStorageData(visible)); + return { + result: result.result, + ...(result.next !== undefined + ? { next: mergeBindingAwareWrite(original, visible, result.next) } + : {}), + }; + }); + } + + withLockAsync(fn: (current: string | undefined) => Promise>): Promise { + return this.delegate.withLockAsync(async (current) => { + const original = parseAuthStorageData(current); + const visible = filterBoundCredentials(original, this.bindings); + const result = await fn(serializeAuthStorageData(visible)); + return { + result: result.result, + ...(result.next !== undefined + ? { next: mergeBindingAwareWrite(original, visible, result.next) } + : {}), + }; + }); + } +} + function resolveOAuthLogin(flow: string, login: OAuthLogin | undefined): OAuthLogin { if (login) { return login; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index d85ce7f2b..51448df67 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,5 +1,5 @@ import { execSync } from "child_process"; -import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; +import { mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join, resolve as resolvePath } from "path"; import pino from "pino"; @@ -1154,6 +1154,160 @@ afterEach(() => { vi.clearAllMocks(); }); +describe("Paseo Agent config catalog RPCs", () => { + test("get_catalog returns static manifests without credential material", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages }); + + await session.handleMessage({ + type: "config.paseo_agent.get_catalog.request", + requestId: "catalog-1", + }); + + const response = messages.find( + ( + message, + ): message is Extract< + SessionOutboundMessage, + { type: "config.paseo_agent.get_catalog.response" } + > => message.type === "config.paseo_agent.get_catalog.response", + ); + expect(response?.payload.error).toBeNull(); + expect(response?.payload.catalog.map((entry) => entry.id)).toEqual([ + "openrouter", + "chatgpt", + "kimi", + "opencode-go", + ]); + expect(JSON.stringify(response?.payload.catalog)).not.toContain("apiKey"); + expect(JSON.stringify(response?.payload.catalog)).not.toContain("access-token"); + expect(JSON.stringify(response?.payload.catalog)).not.toContain("refresh-token"); + }); + + test("oauth.start fails clearly for an API-key provider instance", async () => { + const home = mkdtempSync(join(tmpdir(), "paseo-agent-session-")); + try { + writeFileSync( + join(home, "config.json"), + JSON.stringify({ + agents: { + paseo: { + providers: { + "openrouter-main": { + type: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }, + }, + }, + }, + }), + ); + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages, paseoHome: home }); + + await session.handleMessage({ + type: "config.paseo_agent.oauth.start.request", + requestId: "oauth-start-api-key", + name: "openrouter-main", + }); + + const response = messages.find( + ( + message, + ): message is Extract< + SessionOutboundMessage, + { type: "config.paseo_agent.oauth.start.response" } + > => message.type === "config.paseo_agent.oauth.start.response", + ); + expect(response?.payload).toMatchObject({ + requestId: "oauth-start-api-key", + success: false, + name: "openrouter-main", + authorization: null, + }); + expect(response?.payload.error).toContain("does not use OAuth"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("oauth.store_credential persists a bound credential without echoing tokens", async () => { + const home = mkdtempSync(join(tmpdir(), "paseo-agent-session-")); + try { + writeFileSync( + join(home, "config.json"), + JSON.stringify({ + agents: { + paseo: { + providers: { + subscription: { + type: "chatgpt", + }, + }, + }, + }, + }), + ); + const { manager: providerSnapshotManager, refreshSettingsSnapshot } = + createProviderSnapshotManagerStub(); + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + paseoHome: home, + providerSnapshotManager, + agentManager: { updateProviderRegistry: vi.fn() }, + }); + + await session.handleMessage({ + type: "config.paseo_agent.oauth.store_credential.request", + requestId: "oauth-store-1", + name: "subscription", + credential: { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }, + }); + + const response = messages.find( + ( + message, + ): message is Extract< + SessionOutboundMessage, + { type: "config.paseo_agent.oauth.store_credential.response" } + > => message.type === "config.paseo_agent.oauth.store_credential.response", + ); + expect(response?.payload).toEqual({ + requestId: "oauth-store-1", + success: true, + name: "subscription", + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }); + expect(JSON.stringify(response)).not.toContain("access-token"); + expect(JSON.stringify(response)).not.toContain("refresh-token"); + expect(refreshSettingsSnapshot).toHaveBeenCalledWith({ providers: ["paseo"] }); + + const stored = JSON.parse(readFileSync(join(home, "paseo-agent", "auth.json"), "utf8")); + expect(stored.subscription).toMatchObject({ + type: "oauth", + access: "access-token", + refresh: "refresh-token", + binding: { + flow: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + }, + }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); + describe("session provider refresh cwd routing", () => { test("routes no-cwd provider snapshot refreshes through settings refresh", async () => { const { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c7c7074ac..675ee0cde 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4,6 +4,14 @@ import { stat } from "node:fs/promises"; import { basename, normalize, resolve, sep } from "path"; import { homedir } from "node:os"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; +import type { + OAuthAuthInfo, + OAuthCredentials, + OAuthDeviceCodeInfo, + OAuthProviderInterface, + OAuthSelectPrompt, +} from "@earendil-works/pi-ai"; +import { getOAuthProvider } from "@earendil-works/pi-ai/oauth"; import { serializeAgentStreamEvent, type AgentSnapshotPayload, @@ -18,6 +26,7 @@ import { type ProjectPlacementPayload, type WorkspaceSetupSnapshot, type WorkspaceDescriptorPayload, + type PaseoAgentOAuthCredential, } from "./messages.js"; import type { TerminalManager, @@ -69,6 +78,7 @@ import { import { AgentManager } from "./agent/agent-manager.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { PaseoAgentConfigService } from "./agent/providers/paseo-agent/config-service.js"; +import type { OAuthCredentialBinding } from "./agent/providers/paseo-agent/oauth-store.js"; import type { AgentManagerEvent, AgentTimelineCursor, @@ -377,6 +387,126 @@ interface WorkspaceUpdatesSubscriptionState { lastEmittedByWorkspaceId: Map; } +const PASEO_AGENT_OAUTH_PENDING_TTL_MS = 15 * 60 * 1000; + +interface PaseoAgentOAuthStartAuthorization { + [key: string]: unknown; + kind: string; + url?: string; + instructions?: string; + userCode?: string; + verificationUri?: string; + intervalSeconds?: number; + expiresInSeconds?: number; +} + +interface DeferredValue { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (error: unknown) => void; +} + +interface PendingPaseoAgentOAuthFlow { + name: string; + binding: OAuthCredentialBinding; + abortController: AbortController; + expiresAt: number; + authorizationPromise: Promise; + credentialPromise: Promise; +} + +function deferredValue(): DeferredValue { + let resolveValue!: (value: T | PromiseLike) => void; + let rejectValue!: (error: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolveValue = innerResolve; + rejectValue = innerReject; + }); + return { promise, resolve: resolveValue, reject: rejectValue }; +} + +function requireOAuthProvider(flow: string): OAuthProviderInterface { + const provider = getOAuthProvider(flow); + if (!provider) { + throw new Error(`Paseo Agent: OAuth flow "${flow}" is not registered by Pi.`); + } + return provider; +} + +function deviceCodeAuthorization(info: OAuthDeviceCodeInfo): PaseoAgentOAuthStartAuthorization { + return { + kind: "device_code", + userCode: info.userCode, + verificationUri: info.verificationUri, + ...(info.intervalSeconds !== undefined ? { intervalSeconds: info.intervalSeconds } : {}), + ...(info.expiresInSeconds !== undefined ? { expiresInSeconds: info.expiresInSeconds } : {}), + }; +} + +function authUrlAuthorization(info: OAuthAuthInfo): PaseoAgentOAuthStartAuthorization { + return { + kind: "auth_url", + url: info.url, + ...(info.instructions ? { instructions: info.instructions } : {}), + }; +} + +function selectOAuthOption(prompt: OAuthSelectPrompt): Promise { + const preferred = prompt.options.find((option) => option.label.toLowerCase().includes("device")); + return Promise.resolve((preferred ?? prompt.options[0])?.id); +} + +function createPendingPaseoAgentOAuthFlow( + name: string, + binding: OAuthCredentialBinding, +): PendingPaseoAgentOAuthFlow { + const provider = requireOAuthProvider(binding.flow); + const abortController = new AbortController(); + const authorization = deferredValue(); + let authorizationSettled = false; + + function settleAuthorization(value: PaseoAgentOAuthStartAuthorization | null): void { + if (authorizationSettled) { + return; + } + authorizationSettled = true; + authorization.resolve(value); + } + + function rejectAuthorization(error: unknown): void { + if (authorizationSettled) { + return; + } + authorizationSettled = true; + authorization.reject(error); + } + + const credentialPromise = provider + .login({ + onAuth: (info) => settleAuthorization(authUrlAuthorization(info)), + onDeviceCode: (info) => settleAuthorization(deviceCodeAuthorization(info)), + onPrompt: async () => { + throw new Error("OAuth login requested manual input, but no prompt handler is available."); + }, + onSelect: selectOAuthOption, + signal: abortController.signal, + }) + .then((credentials: OAuthCredentials): PaseoAgentOAuthCredential => { + settleAuthorization(null); + return { type: "oauth", ...credentials }; + }); + void credentialPromise.catch((error) => rejectAuthorization(error)); + + return { + name, + binding, + abortController, + expiresAt: Date.now() + PASEO_AGENT_OAUTH_PENDING_TTL_MS, + authorizationPromise: authorization.promise, + credentialPromise, + }; +} + class SessionRequestError extends Error { constructor( readonly code: string, @@ -571,6 +701,7 @@ export class Session { private readonly terminalManager: TerminalManager | null; private readonly providerSnapshotManager: ProviderSnapshotManager; private paseoAgentConfigService: PaseoAgentConfigService | null = null; + private readonly pendingPaseoAgentOAuthFlows = new Map(); private readonly serviceProxy: ServiceProxySubsystem | null; private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; private readonly getDaemonTcpPort: (() => number | null) | null; @@ -1656,12 +1787,18 @@ export class Session { return this.providerCatalogSession.handleListProviderFeaturesRequest(msg); case "config.paseo_agent.get_providers.request": return this.handlePaseoAgentGetProvidersRequest(msg); + case "config.paseo_agent.get_catalog.request": + return this.handlePaseoAgentGetCatalogRequest(msg); case "config.paseo_agent.set_provider.request": return this.handlePaseoAgentSetProviderRequest(msg); case "config.paseo_agent.remove_provider.request": return this.handlePaseoAgentRemoveProviderRequest(msg); - case "config.paseo_agent.store_chatgpt_credential.request": - return this.handlePaseoAgentStoreChatGptCredentialRequest(msg); + case "config.paseo_agent.oauth.start.request": + return this.handlePaseoAgentOAuthStartRequest(msg); + case "config.paseo_agent.oauth.complete.request": + return this.handlePaseoAgentOAuthCompleteRequest(msg); + case "config.paseo_agent.oauth.store_credential.request": + return this.handlePaseoAgentOAuthStoreCredentialRequest(msg); case "list_available_providers_request": return this.providerCatalogSession.handleListAvailableProvidersRequest(msg); case "get_providers_snapshot_request": @@ -1693,6 +1830,58 @@ export class Session { await this.providerSnapshotManager.refreshSettingsSnapshot({ providers: ["paseo"] }); } + private deleteExpiredPaseoAgentOAuthFlows(now = Date.now()): void { + for (const [name, pending] of this.pendingPaseoAgentOAuthFlows) { + if (pending.expiresAt > now) { + continue; + } + pending.abortController.abort(); + this.pendingPaseoAgentOAuthFlows.delete(name); + } + } + + private replacePendingPaseoAgentOAuthFlow(pending: PendingPaseoAgentOAuthFlow): void { + const previous = this.pendingPaseoAgentOAuthFlows.get(pending.name); + previous?.abortController.abort(); + this.pendingPaseoAgentOAuthFlows.set(pending.name, pending); + } + + private takePendingPaseoAgentOAuthFlow(name: string): PendingPaseoAgentOAuthFlow { + this.deleteExpiredPaseoAgentOAuthFlows(); + const pending = this.pendingPaseoAgentOAuthFlows.get(name); + if (!pending) { + throw new Error(`No pending OAuth flow for Paseo Agent provider '${name}'.`); + } + this.pendingPaseoAgentOAuthFlows.delete(name); + return pending; + } + + private async handlePaseoAgentGetCatalogRequest( + msg: Extract, + ): Promise { + try { + const catalog = this.createPaseoAgentConfigService().getCatalog(); + this.emit({ + type: "config.paseo_agent.get_catalog.response", + payload: { + requestId: msg.requestId, + catalog, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error({ err: error }, "Failed to read Paseo Agent catalog"); + this.emit({ + type: "config.paseo_agent.get_catalog.response", + payload: { + requestId: msg.requestId, + catalog: [], + error: getErrorMessage(error), + }, + }); + } + } + private async handlePaseoAgentGetProvidersRequest( msg: Extract, ): Promise { @@ -1789,36 +1978,117 @@ export class Session { } } - private async handlePaseoAgentStoreChatGptCredentialRequest( - msg: Extract< - SessionInboundMessage, - { type: "config.paseo_agent.store_chatgpt_credential.request" } - >, + private async handlePaseoAgentOAuthStartRequest( + msg: Extract, ): Promise { try { - this.createPaseoAgentConfigService().storeOAuthCredential(msg.providerName, msg.credential); - await this.refreshPaseoAgentRuntimeSnapshot(); + this.deleteExpiredPaseoAgentOAuthFlows(); + const binding = this.createPaseoAgentConfigService().getOAuthCredentialBinding(msg.name); + const pending = createPendingPaseoAgentOAuthFlow(msg.name, binding); + this.replacePendingPaseoAgentOAuthFlow(pending); + const authorization = await pending.authorizationPromise; this.emit({ - type: "config.paseo_agent.store_chatgpt_credential.response", + type: "config.paseo_agent.oauth.start.response", payload: { requestId: msg.requestId, success: true, - providerName: msg.providerName, - auth: { kind: "oauth", configured: true, source: "stored" }, + name: msg.name, + authorization, + error: null, + }, + }); + } catch (error) { + this.pendingPaseoAgentOAuthFlows.delete(msg.name); + this.sessionLogger.error( + { err: error, providerName: msg.name }, + "Failed to start Paseo Agent OAuth flow", + ); + this.emit({ + type: "config.paseo_agent.oauth.start.response", + payload: { + requestId: msg.requestId, + success: false, + name: msg.name, + authorization: null, + error: getErrorMessage(error), + }, + }); + } + } + + private async handlePaseoAgentOAuthCompleteRequest( + msg: Extract, + ): Promise { + try { + const pending = this.takePendingPaseoAgentOAuthFlow(msg.name); + const credential = await pending.credentialPromise; + const auth = this.createPaseoAgentConfigService().storeOAuthCredential( + msg.name, + credential, + pending.binding, + ); + await this.refreshPaseoAgentRuntimeSnapshot(); + this.emit({ + type: "config.paseo_agent.oauth.complete.response", + payload: { + requestId: msg.requestId, + success: true, + name: msg.name, + auth, error: null, }, }); } catch (error) { this.sessionLogger.error( - { err: error, providerName: msg.providerName }, - "Failed to store Paseo Agent OAuth credential", + { err: error, providerName: msg.name }, + "Failed to complete Paseo Agent OAuth flow", ); this.emit({ - type: "config.paseo_agent.store_chatgpt_credential.response", + type: "config.paseo_agent.oauth.complete.response", payload: { requestId: msg.requestId, success: false, - providerName: msg.providerName, + name: msg.name, + auth: { kind: "oauth", configured: false }, + error: getErrorMessage(error), + }, + }); + } + } + + private async handlePaseoAgentOAuthStoreCredentialRequest( + msg: Extract< + SessionInboundMessage, + { type: "config.paseo_agent.oauth.store_credential.request" } + >, + ): Promise { + try { + const auth = this.createPaseoAgentConfigService().storeOAuthCredential( + msg.name, + msg.credential, + ); + await this.refreshPaseoAgentRuntimeSnapshot(); + this.emit({ + type: "config.paseo_agent.oauth.store_credential.response", + payload: { + requestId: msg.requestId, + success: true, + name: msg.name, + auth, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, providerName: msg.name }, + "Failed to store Paseo Agent OAuth credential", + ); + this.emit({ + type: "config.paseo_agent.oauth.store_credential.response", + payload: { + requestId: msg.requestId, + success: false, + name: msg.name, auth: { kind: "oauth", configured: false }, error: getErrorMessage(error), }, @@ -5827,6 +6097,11 @@ export class Session { public async cleanup(): Promise { this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup"); + for (const pending of this.pendingPaseoAgentOAuthFlows.values()) { + pending.abortController.abort(); + } + this.pendingPaseoAgentOAuthFlows.clear(); + if (this.unsubscribeAgentEvents) { this.unsubscribeAgentEvents(); this.unsubscribeAgentEvents = null; diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ea8322583..37000edd4 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1227,6 +1227,8 @@ export class VoiceAssistantWebSocketServer { agentForkContext: true, // COMPAT(paseoAgentConfig): added in v0.1.103, remove gate after 2027-01-02. paseoAgentConfig: true, + // COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104 + paseoAgentCatalog: true, }, }; } @@ -2335,7 +2337,7 @@ function redactPaseoAgentConfigSecrets(payload: unknown): unknown { }; } - if (message.type === "config.paseo_agent.store_chatgpt_credential.request") { + if (message.type === "config.paseo_agent.oauth.store_credential.request") { return { ...record, message: {