Resolve Paseo Agent credentials asynchronously

This commit is contained in:
Mohamed Boudra
2026-07-02 08:38:58 +02:00
parent 1a084eb8b0
commit b1abec4512
5 changed files with 93 additions and 58 deletions
@@ -535,7 +535,7 @@ export class PaseoAgentClient implements AgentClient {
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
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.",
@@ -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" });
});
@@ -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<PaseoAgentInferenceProvider[]> {
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];
@@ -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();
});
});
@@ -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<string | undefined> {
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;
}