diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md index 845009ff42..faa1b18cd4 100644 --- a/docs/paseo-agent.md +++ b/docs/paseo-agent.md @@ -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` | diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index e09893d07e..f97eb59f32 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -43,6 +43,13 @@ const TEST_DAEMON_ENV_DEFAULTS: Record = { 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", diff --git a/packages/cli/tests/setup.ts b/packages/cli/tests/setup.ts index 94f872300f..7c12f8efb8 100644 --- a/packages/cli/tests/setup.ts +++ b/packages/cli/tests/setup.ts @@ -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 { 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 { $.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 { // 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 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 ff09d31ad3..794f93a528 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/catalog.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/catalog.ts @@ -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, }, { 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 11a482f204..43ec95f170 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 @@ -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[0]): string[] { return getModels(provider).map((model) => model.id); } +function deleteEnvVars(names: readonly string[]): Map { + const previousValues = new Map(); + for (const name of names) { + previousValues.set(name, process.env[name]); + delete process.env[name]; + } + + return previousValues; +} + +function restoreEnvVars(previousValues: ReadonlyMap): void { + for (const [name, value] of previousValues) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } +} + +function resolveCatalogAuthEntries(): Array< + [string, ReturnType] +> { + return PASEO_AGENT_PROVIDER_CATALOG.map((entry) => [ + entry.id, + resolvePaseoAgentCatalogAuth(entry), + ]); +} + function configWith(overrides?: Partial): 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()); 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 2254bacdfc..45b4dc7844 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -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.`); }