From b1abec4512bfa03ccf9226ead8982e7f5ee90e30 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 19 Jun 2026 11:27:45 +0700 Subject: [PATCH] Resolve Paseo Agent credentials asynchronously --- .../agent/providers/paseo-agent/agent.ts | 2 +- .../providers/paseo-agent/config.test.ts | 26 +++++---- .../agent/providers/paseo-agent/config.ts | 58 +++++++++++-------- .../paseo-agent/oauth-credentials.test.ts | 22 ++++--- .../paseo-agent/oauth-credentials.ts | 43 ++++++++++---- 5 files changed, 93 insertions(+), 58 deletions(-) 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 abb0d150a2..03f5a7244f 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.ts @@ -535,7 +535,7 @@ export class PaseoAgentClient implements AgentClient { config: AgentSessionConfig, _launchContext?: AgentLaunchContext, ): Promise { - const inferenceProviders = paseoAgentInferenceProviders(this.config); + const inferenceProviders = await paseoAgentInferenceProviders(this.config); if (inferenceProviders.length === 0) { throw new Error( "Paseo Agent has no configured inference providers. Add agents.paseo.providers to your Paseo config.", diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts index deee44070c..92e279f53f 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts @@ -115,8 +115,8 @@ describe("listPaseoAgentModels", () => { }); describe("paseoAgentInferenceProviders (per-type defaults)", () => { - it("applies openrouter defaults (base url, api, model fields)", () => { - const [provider] = paseoAgentInferenceProviders(configWith()); + it("applies openrouter defaults (base url, api, model fields)", async () => { + const [provider] = await paseoAgentInferenceProviders(configWith()); expect(provider.name).toBe("openrouter-main"); expect(provider.config.baseUrl).toBe("https://openrouter.ai/api/v1"); expect(provider.config.apiKey).toBe("sk-test"); @@ -130,19 +130,19 @@ describe("paseoAgentInferenceProviders (per-type defaults)", () => { }); }); - it("falls back to the type's env var when no apiKey is given", () => { + it("falls back to the type's env var when no apiKey is given", async () => { const config = PaseoAgentConfigSchema.parse({ providers: { anthropic: { type: "anthropic", options: { models: [{ id: "claude-x" }] } }, }, }); - const [provider] = paseoAgentInferenceProviders(config); + const [provider] = await paseoAgentInferenceProviders(config); expect(provider.config.baseUrl).toBe("https://api.anthropic.com"); expect(provider.config.apiKey).toBe("$ANTHROPIC_API_KEY"); expect(provider.config.models?.[0]?.api).toBe("anthropic-messages"); }); - it("supports an OpenCode Zen / openai-compatible endpoint with per-model api override", () => { + it("supports an OpenCode Zen / openai-compatible endpoint with per-model api override", async () => { const config = PaseoAgentConfigSchema.parse({ providers: { zen: { @@ -155,13 +155,13 @@ describe("paseoAgentInferenceProviders (per-type defaults)", () => { }, }, }); - const [provider] = paseoAgentInferenceProviders(config); + const [provider] = await paseoAgentInferenceProviders(config); expect(provider.config.baseUrl).toBe("https://opencode.ai/zen/v1"); expect(provider.config.models?.[0]?.api).toBe("openai-completions"); expect(provider.config.models?.[1]?.api).toBe("anthropic-messages"); }); - it("passes through the custom escape hatch (explicit api + authHeader)", () => { + it("passes through the custom escape hatch (explicit api + authHeader)", async () => { const config = PaseoAgentConfigSchema.parse({ providers: { vertex: { @@ -177,7 +177,7 @@ describe("paseoAgentInferenceProviders (per-type defaults)", () => { }, }, }); - const [provider] = paseoAgentInferenceProviders(config); + const [provider] = await paseoAgentInferenceProviders(config); expect(provider.config.api).toBe("google-generative-ai"); expect(provider.config.authHeader).toBe(true); expect(provider.config.headers).toEqual({ "x-extra": "1" }); @@ -288,8 +288,8 @@ describe("openai-codex (ChatGPT subscription) provider", () => { ).toThrow(); }); - it("maps to a codex inference provider with an oauth marker and no api key", () => { - const [provider] = paseoAgentInferenceProviders(codexConfig({}), {}); + it("maps to a codex inference provider with an oauth marker and no api key", async () => { + const [provider] = await paseoAgentInferenceProviders(codexConfig({}), {}); expect(provider.name).toBe("chatgpt"); expect(provider.oauth).toEqual({ kind: "openai-codex" }); expect(provider.config.apiKey).toBeUndefined(); @@ -298,9 +298,11 @@ describe("openai-codex (ChatGPT subscription) provider", () => { expect(provider.config.models?.[0]?.api).toBe("openai-codex-responses"); }); - it("carries an advanced self-supplied refresh token resolved from an env var", () => { + it("carries an advanced self-supplied refresh token resolved from an env var", async () => { const config = codexConfig({ refreshToken: "$CODEX_REFRESH_TOKEN" }); - const [provider] = paseoAgentInferenceProviders(config, { CODEX_REFRESH_TOKEN: "rt-env" }); + const [provider] = await paseoAgentInferenceProviders(config, { + CODEX_REFRESH_TOKEN: "rt-env", + }); expect(provider.oauth).toEqual({ kind: "openai-codex", refreshToken: "rt-env" }); }); 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 e8952e41f2..a1d2f55bec 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -268,45 +268,46 @@ function toPiModels(entry: PaseoAgentInferenceProviderEntry, settings: ResolvedP * normally lives in the Paseo-owned store (populated by `paseo login chatgpt`); an * advanced `options.refreshToken` may supply the user's own token instead. */ -export function paseoAgentInferenceProviders( +export async function paseoAgentInferenceProviders( config: PaseoAgentConfig, env: NodeJS.ProcessEnv = process.env, -): PaseoAgentInferenceProvider[] { - return entries(config).flatMap(([name, entry]): PaseoAgentInferenceProvider[] => { +): Promise { + const providers: PaseoAgentInferenceProvider[] = []; + + for (const [name, entry] of entries(config)) { const settings = resolveProviderSettings(entry); const models = toPiModels(entry, settings); if (entry.type === "openai-codex") { const refreshToken = entry.options.refreshToken - ? resolveRefreshTokenExpression(entry.options.refreshToken, env) + ? await resolveRefreshTokenExpression(entry.options.refreshToken, env) : undefined; - return [ - { - name, - config: { - ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), - ...(settings.api ? { api: settings.api } : {}), - models, - }, - oauth: { kind: "openai-codex" as const, ...(refreshToken ? { refreshToken } : {}) }, - }, - ]; - } - - return [ - { + providers.push({ name, config: { ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), - ...(settings.apiKey ? { apiKey: settings.apiKey } : {}), ...(settings.api ? { api: settings.api } : {}), - ...(settings.headers ? { headers: settings.headers } : {}), - ...(settings.authHeader ? { authHeader: settings.authHeader } : {}), models, }, + oauth: { kind: "openai-codex" as const, ...(refreshToken ? { refreshToken } : {}) }, + }); + continue; + } + + providers.push({ + name, + config: { + ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), + ...(settings.apiKey ? { apiKey: settings.apiKey } : {}), + ...(settings.api ? { api: settings.api } : {}), + ...(settings.headers ? { headers: settings.headers } : {}), + ...(settings.authHeader ? { authHeader: settings.authHeader } : {}), + models, }, - ]; - }); + }); + } + + return providers; } /** Enumerate configured models as Paseo model definitions (no Pi disk/auth reads). */ @@ -363,7 +364,7 @@ export function paseoAgentHasUsableModel( export function resolvePaseoAgentModel( config: PaseoAgentConfig, requestedModelId: string | null | undefined, - registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentInferenceProviders(config), + registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentModelInventory(config), agentDefaultModelId?: string | null, ): PaseoAgentModelReference | undefined { if (requestedModelId) { @@ -383,6 +384,13 @@ export function resolvePaseoAgentModel( return firstRegisteredModel(registeredProviders); } +function paseoAgentModelInventory(config: PaseoAgentConfig): PaseoAgentInferenceProvider[] { + return entries(config).map(([name, entry]) => { + const settings = resolveProviderSettings(entry); + return { name, config: { models: toPiModels(entry, settings) } }; + }); +} + function firstModelId(config: PaseoAgentConfig): string | undefined { for (const [name, entry] of entries(config)) { const first = entry.options.models[0]; diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts index d8e65b5e6b..a59b6f6301 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts @@ -6,19 +6,23 @@ import { } from "./oauth-credentials.js"; describe("resolveRefreshTokenExpression", () => { - it("returns a literal value", () => { - expect(resolveRefreshTokenExpression("rt-literal", {})).toBe("rt-literal"); + it("returns a literal value", async () => { + await expect(resolveRefreshTokenExpression("rt-literal", {})).resolves.toBe("rt-literal"); }); - it("resolves an env reference and returns undefined when unset", () => { - expect(resolveRefreshTokenExpression("$CODEX_RT", { CODEX_RT: "rt-env" })).toBe("rt-env"); - expect(resolveRefreshTokenExpression("${CODEX_RT}", { CODEX_RT: "rt-env" })).toBe("rt-env"); - expect(resolveRefreshTokenExpression("$CODEX_RT", {})).toBeUndefined(); + it("resolves an env reference and returns undefined when unset", async () => { + await expect(resolveRefreshTokenExpression("$CODEX_RT", { CODEX_RT: "rt-env" })).resolves.toBe( + "rt-env", + ); + await expect( + resolveRefreshTokenExpression("${CODEX_RT}", { CODEX_RT: "rt-env" }), + ).resolves.toBe("rt-env"); + await expect(resolveRefreshTokenExpression("$CODEX_RT", {})).resolves.toBeUndefined(); }); - it("runs a !command and returns its trimmed output", () => { - expect(resolveRefreshTokenExpression("!printf rt-cmd", {})).toBe("rt-cmd"); - expect(resolveRefreshTokenExpression("!exit 1", {})).toBeUndefined(); + it("runs a !command asynchronously and returns its trimmed output", async () => { + await expect(resolveRefreshTokenExpression("!printf rt-cmd", {})).resolves.toBe("rt-cmd"); + await expect(resolveRefreshTokenExpression("!exit 1", {})).resolves.toBeUndefined(); }); }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts index 5613efbb36..ed7f17f02a 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts @@ -1,6 +1,10 @@ -import { execSync } from "node:child_process"; +import { exec } from "node:child_process"; +import { promisify } from "node:util"; import { hasEnvReference, substituteEnvReferences } from "./env-references.js"; +const execAsync = promisify(exec); +const CREDENTIAL_COMMAND_TIMEOUT_MS = 30_000; + // Resolution of a *self-supplied* OAuth refresh token expression — a literal, an env // reference (`$VAR` / `${VAR}`), or a `!command` that prints the token. This is an // advanced/manual escape hatch for users who already hold their own ChatGPT/Codex @@ -17,23 +21,40 @@ import { hasEnvReference, substituteEnvReferences } from "./env-references.js"; export function resolveRefreshTokenExpression( value: string, env: NodeJS.ProcessEnv = process.env, -): string | undefined { +): Promise { if (value.startsWith("!")) { const command = value.slice(1).trim(); if (!command) { - return undefined; - } - try { - const output = execSync(command, { encoding: "utf8", env }).trim(); - return output.length > 0 ? output : undefined; - } catch { - return undefined; + return Promise.resolve(undefined); } + + return execAsync(command, { + encoding: "utf8", + env, + timeout: CREDENTIAL_COMMAND_TIMEOUT_MS, + }) + .then(({ stdout }) => { + const output = stdout.trim(); + return output.length > 0 ? output : undefined; + }) + .catch(() => undefined); } + + return Promise.resolve(resolveStaticRefreshTokenExpression(value, env)); +} + +function resolveStaticRefreshTokenExpression( + value: string, + env: NodeJS.ProcessEnv, +): string | undefined { if (hasEnvReference(value)) { - const substituted = substituteEnvReferences(value, env); - return substituted && substituted.length > 0 ? substituted : undefined; + const output = substituteEnvReferences(value, env); + if (output) { + return output.length > 0 ? output : undefined; + } + return undefined; } + return value.length > 0 ? value : undefined; }