mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
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:
+1
-1
@@ -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` |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user