fix(paseo-agent): make catalog auth deterministic

Catalog auth derivation used an environment probe, so missing local keys could make provider listing throw. Catalog hints are now static and CLI tests scrub provider-key env vars.
This commit is contained in:
Mohamed Boudra
2026-07-02 13:57:12 +02:00
parent 02ec891fe4
commit 03b45f8e11
6 changed files with 125 additions and 15 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ The current catalog contains four entries:
| id | Pi provider | default models | auth source |
| ------------- | -------------- | -------------- | --------------------------------------------- |
| `openrouter` | `openrouter` | none | Pi env key `OPENROUTER_API_KEY` |
| `openrouter` | `openrouter` | none | Paseo hint `OPENROUTER_API_KEY` |
| `chatgpt` | `openai-codex` | Pi full list | Pi OAuth registry (`openai-codex`) |
| `kimi` | `kimi-coding` | Pi full list | Paseo hint `KIMI_API_KEY` (Pi has no env key) |
| `opencode-go` | `opencode-go` | Pi full list | Paseo hint `OPENCODE_API_KEY` |
+19 -4
View File
@@ -43,6 +43,13 @@ const TEST_DAEMON_ENV_DEFAULTS: Record<string, string> = {
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
const TEST_DAEMON_HOST = "127.0.0.1";
// Keep in sync with catalog.ts api_key auth envVar hints. These are scrubbed so
// local developer credentials cannot hide CI-missing-auth failures.
const PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS = [
"OPENROUTER_API_KEY",
"KIMI_API_KEY",
"OPENCODE_API_KEY",
] as const;
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024;
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
@@ -59,6 +66,14 @@ function createOutputCapture(): OutputCapture {
return { value: "", truncated: false };
}
function scrubPaseoAgentProviderAuthEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const scrubbed = { ...env };
for (const key of PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS) {
delete scrubbed[key];
}
return scrubbed;
}
function appendOutputCapture(target: OutputCapture, chunk: Buffer): void {
const next = target.value + chunk.toString();
if (next.length <= TEST_OUTPUT_CAPTURE_LIMIT) {
@@ -234,7 +249,7 @@ export async function startTestDaemon(options?: {
// Start daemon process using tsx to run TypeScript directly
const daemonProcess = spawn("npx", ["tsx", cliSrcPath, "daemon", "start", "--foreground"], {
env: {
env: scrubPaseoAgentProviderAuthEnv({
...process.env,
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOME: paseoHome,
@@ -242,7 +257,7 @@ export async function startTestDaemon(options?: {
// Force no TTY to prevent QR code output
CI: "true",
...options?.env,
},
}),
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
});
@@ -347,13 +362,13 @@ export async function runPaseoCli(
return new Promise((resolve, reject) => {
const proc = spawn("npx", ["tsx", cliSrcPath, ...args], {
env: {
env: scrubPaseoAgentProviderAuthEnv({
...process.env,
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
PASEO_HOME: ctx.paseoHome,
...options?.env,
},
}),
cwd,
stdio: [options?.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
detached: process.platform !== "win32",
+43 -4
View File
@@ -19,6 +19,44 @@ const TEST_ENV_DEFAULTS = {
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
// Keep in sync with catalog.ts api_key auth envVar hints. These are scrubbed so
// local developer credentials cannot hide CI-missing-auth failures.
const PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS = [
"OPENROUTER_API_KEY",
"KIMI_API_KEY",
"OPENCODE_API_KEY",
] as const;
function scrubPaseoAgentProviderAuthEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const scrubbed = { ...env };
for (const key of PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS) {
delete scrubbed[key];
}
return scrubbed;
}
function testCliEnv(port: number): NodeJS.ProcessEnv {
return scrubPaseoAgentProviderAuthEnv({
...process.env,
PASEO_HOST: `localhost:${port}`,
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
PASEO_DICTATION_ENABLED: TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED,
PASEO_VOICE_MODE_ENABLED: TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED,
});
}
function testDaemonEnv(port: number, paseoHome: string): NodeJS.ProcessEnv {
return scrubPaseoAgentProviderAuthEnv({
...process.env,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: "false",
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
PASEO_DICTATION_ENABLED: TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED,
PASEO_VOICE_MODE_ENABLED: TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED,
CI: "true",
});
}
function killPidTree(pid: number, signal: NodeJS.Signals): void {
if (!Number.isInteger(pid) || pid <= 0) {
@@ -85,7 +123,7 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st
*/
async function probeDaemon(port: number): Promise<boolean> {
try {
const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow();
const result = await $({ env: testCliEnv(port) })`paseo agent ls`.nothrow();
return result.exitCode === 0;
} catch {
return false;
@@ -110,8 +148,9 @@ export async function waitForDaemon(port: number, timeout = 30000): Promise<void
*/
export async function startDaemon(port: number, paseoHome: string): Promise<ProcessPromise> {
$.verbose = false;
const daemon =
$`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow();
const daemon = $({
env: testDaemonEnv(port, paseoHome),
})`paseo daemon start --foreground`.nothrow();
return daemon;
}
@@ -125,7 +164,7 @@ export async function createTestContext(): Promise<TestContext> {
// Helper to run CLI commands against test daemon
const paseo = (args: string[]): ProcessPromise => {
$.verbose = false;
return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow();
return $({ env: testCliEnv(port) })`paseo ${args}`.nothrow();
};
// Cleanup function
@@ -2,7 +2,7 @@ import type { KnownProvider } from "@earendil-works/pi-ai";
export interface PaseoAgentKeyAuthHint {
kind: "api_key";
envVar?: string;
envVar: string;
keyUrl?: string;
placeholder?: string;
hint?: string;
@@ -30,6 +30,7 @@ export const PASEO_AGENT_PROVIDER_CATALOG = [
id: "openrouter",
piProvider: "openrouter",
label: "OpenRouter",
auth: { kind: "api_key", envVar: "OPENROUTER_API_KEY" },
defaultModels: false,
},
{
@@ -8,14 +8,56 @@ import {
paseoAgentHasUsableModel,
paseoAgentModelProviders,
parsePaseoAgentModelId,
resolvePaseoAgentCatalogAuth,
resolvePaseoAgentModel,
type PaseoAgentConfig,
} from "./config.js";
import { PASEO_AGENT_PROVIDER_CATALOG } from "./catalog.js";
const CATALOG_AUTH_ENV_VARS = [
...new Set([
"OPENROUTER_API_KEY",
"KIMI_API_KEY",
"OPENCODE_API_KEY",
...PASEO_AGENT_PROVIDER_CATALOG.flatMap((entry) =>
entry.auth?.kind === "api_key" ? [entry.auth.envVar] : [],
),
]),
];
function piModelIds(provider: Parameters<typeof getModels>[0]): string[] {
return getModels(provider).map((model) => model.id);
}
function deleteEnvVars(names: readonly string[]): Map<string, string | undefined> {
const previousValues = new Map<string, string | undefined>();
for (const name of names) {
previousValues.set(name, process.env[name]);
delete process.env[name];
}
return previousValues;
}
function restoreEnvVars(previousValues: ReadonlyMap<string, string | undefined>): void {
for (const [name, value] of previousValues) {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
}
function resolveCatalogAuthEntries(): Array<
[string, ReturnType<typeof resolvePaseoAgentCatalogAuth>]
> {
return PASEO_AGENT_PROVIDER_CATALOG.map((entry) => [
entry.id,
resolvePaseoAgentCatalogAuth(entry),
]);
}
function configWith(overrides?: Partial<PaseoAgentConfig>): PaseoAgentConfig {
return PaseoAgentConfigSchema.parse({
providers: {
@@ -98,6 +140,22 @@ describe("model id encoding", () => {
});
});
describe("resolvePaseoAgentCatalogAuth", () => {
it("resolves every catalog entry without provider key env vars", () => {
const previousValues = deleteEnvVars(CATALOG_AUTH_ENV_VARS);
try {
expect(resolveCatalogAuthEntries()).toEqual([
["openrouter", { kind: "api_key", envVar: "OPENROUTER_API_KEY" }],
["chatgpt", { kind: "oauth", flow: "openai-codex" }],
["kimi", { kind: "api_key", envVar: "KIMI_API_KEY" }],
["opencode-go", { kind: "api_key", envVar: "OPENCODE_API_KEY" }],
]);
} finally {
restoreEnvVars(previousValues);
}
});
});
describe("listPaseoAgentModels", () => {
it("exposes every configured model with provider-prefixed ids", () => {
const models = listPaseoAgentModels(configWith());
@@ -1,4 +1,4 @@
import { findEnvKeys, getModels, type Api, type Model } from "@earendil-works/pi-ai";
import { getModels, type Api, type Model } from "@earendil-works/pi-ai";
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
import type { PaseoAgentCatalogEntry as PaseoAgentCatalogManifestEntry } from "@getpaseo/protocol/messages";
import { z } from "zod";
@@ -123,10 +123,7 @@ export function resolvePaseoAgentCatalogAuth(
return { kind: "oauth", flow: catalogEntry.piProvider };
}
const envVar =
catalogEntry.auth?.kind === "api_key"
? catalogEntry.auth.envVar
: findEnvKeys(catalogEntry.piProvider)?.[0];
const envVar = catalogEntry.auth?.kind === "api_key" ? catalogEntry.auth.envVar : undefined;
if (!envVar) {
throw new Error(`Paseo Agent provider "${catalogEntry.id}" has no auth source.`);
}