From 9cf5758b687fb8cade428157bd0a6b653418bdf9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 4 Feb 2026 23:01:55 +0100 Subject: [PATCH] feat(coding-agent): support shell commands and env vars in auth.json API keys API keys in auth.json now support the same resolution as models.json: - Shell command: "\!command" executes and uses stdout (cached) - Environment variable: uses the value of the named variable - Literal value: used directly Extracted shared resolveConfigValue() to new resolve-config-value.ts module. --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/providers.md | 18 + .../coding-agent/src/core/auth-storage.ts | 3 +- .../coding-agent/src/core/model-registry.ts | 59 +--- .../src/core/resolve-config-value.ts | 64 ++++ .../coding-agent/test/auth-storage.test.ts | 318 ++++++++++++++++++ 6 files changed, 408 insertions(+), 58 deletions(-) create mode 100644 packages/coding-agent/src/core/resolve-config-value.ts create mode 100644 packages/coding-agent/test/auth-storage.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fd208f33..09b87ccb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- API keys in `auth.json` now support shell command resolution (`!command`) and environment variable lookup, matching the behavior in `models.json` + ## [0.51.6] - 2026-02-04 ### New Features diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index e387b323..a84cd1cf 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -82,6 +82,24 @@ Store credentials in `~/.pi/agent/auth.json`: The file is created with `0600` permissions (user read/write only). Auth file credentials take priority over environment variables. +### Key Resolution + +The `key` field supports three formats: + +- **Shell command:** `"!command"` executes and uses stdout (cached for process lifetime) + ```json + { "type": "api_key", "key": "!security find-generic-password -ws 'anthropic'" } + { "type": "api_key", "key": "!op read 'op://vault/item/credential'" } + ``` +- **Environment variable:** Uses the value of the named variable + ```json + { "type": "api_key", "key": "MY_ANTHROPIC_KEY" } + ``` +- **Literal value:** Used directly + ```json + { "type": "api_key", "key": "sk-ant-..." } + ``` + OAuth credentials are also stored here after `/login` and managed automatically. ## Cloud Providers diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 47b798ae..21b62153 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -19,6 +19,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "f import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { getAgentDir } from "../config.js"; +import { resolveConfigValue } from "./resolve-config-value.js"; export type ApiKeyCredential = { type: "api_key"; @@ -273,7 +274,7 @@ export class AuthStorage { const cred = this.data[providerId]; if (cred?.type === "api_key") { - return cred.key; + return resolveConfigValue(cred.key); } if (cred?.type === "oauth") { diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index f5d9abdd..f2f31859 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -17,11 +17,11 @@ import { } from "@mariozechner/pi-ai"; import { type Static, Type } from "@sinclair/typebox"; import AjvModule from "ajv"; -import { execSync } from "child_process"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { getAgentDir } from "../config.js"; import type { AuthStorage } from "./auth-storage.js"; +import { clearConfigValueCache, resolveConfigValue, resolveHeaders } from "./resolve-config-value.js"; const Ajv = (AjvModule as any).default || AjvModule; @@ -117,63 +117,8 @@ function emptyCustomModelsResult(error?: string): CustomModelsResult { return { models: [], replacedProviders: new Set(), overrides: new Map(), error }; } -// Cache for shell command results (persists for process lifetime) -const commandResultCache = new Map(); - -/** - * Resolve a config value (API key, header value, etc.) to an actual value. - * - If starts with "!", executes the rest as a shell command and uses stdout (cached) - * - Otherwise checks environment variable first, then treats as literal (not cached) - */ -function resolveConfigValue(config: string): string | undefined { - if (config.startsWith("!")) { - return executeCommand(config); - } - const envValue = process.env[config]; - return envValue || config; -} - -function executeCommand(commandConfig: string): string | undefined { - if (commandResultCache.has(commandConfig)) { - return commandResultCache.get(commandConfig); - } - - const command = commandConfig.slice(1); - let result: string | undefined; - try { - const output = execSync(command, { - encoding: "utf-8", - timeout: 10000, - stdio: ["ignore", "pipe", "ignore"], - }); - result = output.trim() || undefined; - } catch { - result = undefined; - } - - commandResultCache.set(commandConfig, result); - return result; -} - -/** - * Resolve all header values using the same resolution logic as API keys. - */ -function resolveHeaders(headers: Record | undefined): Record | undefined { - if (!headers) return undefined; - const resolved: Record = {}; - for (const [key, value] of Object.entries(headers)) { - const resolvedValue = resolveConfigValue(value); - if (resolvedValue) { - resolved[key] = resolvedValue; - } - } - return Object.keys(resolved).length > 0 ? resolved : undefined; -} - /** Clear the config value command cache. Exported for testing. */ -export function clearApiKeyCache(): void { - commandResultCache.clear(); -} +export const clearApiKeyCache = clearConfigValueCache; /** * Model registry - loads and manages models, resolves API keys via AuthStorage. diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts new file mode 100644 index 00000000..da127869 --- /dev/null +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -0,0 +1,64 @@ +/** + * Resolve configuration values that may be shell commands, environment variables, or literals. + * Used by auth-storage.ts and model-registry.ts. + */ + +import { execSync } from "child_process"; + +// Cache for shell command results (persists for process lifetime) +const commandResultCache = new Map(); + +/** + * Resolve a config value (API key, header value, etc.) to an actual value. + * - If starts with "!", executes the rest as a shell command and uses stdout (cached) + * - Otherwise checks environment variable first, then treats as literal (not cached) + */ +export function resolveConfigValue(config: string): string | undefined { + if (config.startsWith("!")) { + return executeCommand(config); + } + const envValue = process.env[config]; + return envValue || config; +} + +function executeCommand(commandConfig: string): string | undefined { + if (commandResultCache.has(commandConfig)) { + return commandResultCache.get(commandConfig); + } + + const command = commandConfig.slice(1); + let result: string | undefined; + try { + const output = execSync(command, { + encoding: "utf-8", + timeout: 10000, + stdio: ["ignore", "pipe", "ignore"], + }); + result = output.trim() || undefined; + } catch { + result = undefined; + } + + commandResultCache.set(commandConfig, result); + return result; +} + +/** + * Resolve all header values using the same resolution logic as API keys. + */ +export function resolveHeaders(headers: Record | undefined): Record | undefined { + if (!headers) return undefined; + const resolved: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const resolvedValue = resolveConfigValue(value); + if (resolvedValue) { + resolved[key] = resolvedValue; + } + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +/** Clear the config value command cache. Exported for testing. */ +export function clearConfigValueCache(): void { + commandResultCache.clear(); +} diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts new file mode 100644 index 00000000..0fdab3c8 --- /dev/null +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -0,0 +1,318 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.js"; +import { clearConfigValueCache } from "../src/core/resolve-config-value.js"; + +describe("AuthStorage", () => { + let tempDir: string; + let authJsonPath: string; + let authStorage: AuthStorage; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-test-auth-storage-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + authJsonPath = join(tempDir, "auth.json"); + }); + + afterEach(() => { + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true }); + } + clearConfigValueCache(); + }); + + function writeAuthJson(data: Record) { + writeFileSync(authJsonPath, JSON.stringify(data)); + } + + describe("API key resolution", () => { + test("literal API key is returned directly", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "sk-ant-literal-key" }, + }); + + authStorage = new AuthStorage(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("sk-ant-literal-key"); + }); + + test("apiKey with ! prefix executes command and uses stdout", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "!echo test-api-key-from-command" }, + }); + + authStorage = new AuthStorage(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("test-api-key-from-command"); + }); + + test("apiKey with ! prefix trims whitespace from command output", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "!echo ' spaced-key '" }, + }); + + authStorage = new AuthStorage(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("spaced-key"); + }); + + test("apiKey with ! prefix handles multiline output (uses trimmed result)", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "!printf 'line1\\nline2'" }, + }); + + authStorage = new AuthStorage(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("line1\nline2"); + }); + + test("apiKey with ! prefix returns undefined on command failure", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "!exit 1" }, + }); + + authStorage = new AuthStorage(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBeUndefined(); + }); + + test("apiKey with ! prefix returns undefined on nonexistent command", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "!nonexistent-command-12345" }, + }); + + authStorage = new AuthStorage(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBeUndefined(); + }); + + test("apiKey with ! prefix returns undefined on empty output", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "!printf ''" }, + }); + + authStorage = new AuthStorage(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBeUndefined(); + }); + + test("apiKey as environment variable name resolves to env value", async () => { + const originalEnv = process.env.TEST_AUTH_API_KEY_12345; + process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value"; + + try { + writeAuthJson({ + anthropic: { type: "api_key", key: "TEST_AUTH_API_KEY_12345" }, + }); + + authStorage = new AuthStorage(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("env-api-key-value"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_AUTH_API_KEY_12345; + } else { + process.env.TEST_AUTH_API_KEY_12345 = originalEnv; + } + } + }); + + test("apiKey as literal value is used directly when not an env var", async () => { + // Make sure this isn't an env var + delete process.env.literal_api_key_value; + + writeAuthJson({ + anthropic: { type: "api_key", key: "literal_api_key_value" }, + }); + + authStorage = new AuthStorage(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("literal_api_key_value"); + }); + + test("apiKey command can use shell features like pipes", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "!echo 'hello world' | tr ' ' '-'" }, + }); + + authStorage = new AuthStorage(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("hello-world"); + }); + + describe("caching", () => { + test("command is only executed once per process", async () => { + // Use a command that writes to a file to count invocations + const counterFile = join(tempDir, "counter"); + writeFileSync(counterFile, "0"); + + const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`; + writeAuthJson({ + anthropic: { type: "api_key", key: command }, + }); + + authStorage = new AuthStorage(authJsonPath); + + // Call multiple times + await authStorage.getApiKey("anthropic"); + await authStorage.getApiKey("anthropic"); + await authStorage.getApiKey("anthropic"); + + // Command should have only run once + const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10); + expect(count).toBe(1); + }); + + test("cache persists across AuthStorage instances", async () => { + const counterFile = join(tempDir, "counter"); + writeFileSync(counterFile, "0"); + + const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`; + writeAuthJson({ + anthropic: { type: "api_key", key: command }, + }); + + // Create multiple AuthStorage instances + const storage1 = new AuthStorage(authJsonPath); + await storage1.getApiKey("anthropic"); + + const storage2 = new AuthStorage(authJsonPath); + await storage2.getApiKey("anthropic"); + + // Command should still have only run once + const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10); + expect(count).toBe(1); + }); + + test("clearConfigValueCache allows command to run again", async () => { + const counterFile = join(tempDir, "counter"); + writeFileSync(counterFile, "0"); + + const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`; + writeAuthJson({ + anthropic: { type: "api_key", key: command }, + }); + + authStorage = new AuthStorage(authJsonPath); + await authStorage.getApiKey("anthropic"); + + // Clear cache and call again + clearConfigValueCache(); + await authStorage.getApiKey("anthropic"); + + // Command should have run twice + const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10); + expect(count).toBe(2); + }); + + test("different commands are cached separately", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "!echo key-anthropic" }, + openai: { type: "api_key", key: "!echo key-openai" }, + }); + + authStorage = new AuthStorage(authJsonPath); + + const keyA = await authStorage.getApiKey("anthropic"); + const keyB = await authStorage.getApiKey("openai"); + + expect(keyA).toBe("key-anthropic"); + expect(keyB).toBe("key-openai"); + }); + + test("failed commands are cached (not retried)", async () => { + const counterFile = join(tempDir, "counter"); + writeFileSync(counterFile, "0"); + + const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; exit 1'`; + writeAuthJson({ + anthropic: { type: "api_key", key: command }, + }); + + authStorage = new AuthStorage(authJsonPath); + + // Call multiple times - all should return undefined + const key1 = await authStorage.getApiKey("anthropic"); + const key2 = await authStorage.getApiKey("anthropic"); + + expect(key1).toBeUndefined(); + expect(key2).toBeUndefined(); + + // Command should have only run once despite failures + const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10); + expect(count).toBe(1); + }); + + test("environment variables are not cached (changes are picked up)", async () => { + const envVarName = "TEST_AUTH_KEY_CACHE_TEST_98765"; + const originalEnv = process.env[envVarName]; + + try { + process.env[envVarName] = "first-value"; + + writeAuthJson({ + anthropic: { type: "api_key", key: envVarName }, + }); + + authStorage = new AuthStorage(authJsonPath); + + const key1 = await authStorage.getApiKey("anthropic"); + expect(key1).toBe("first-value"); + + // Change env var + process.env[envVarName] = "second-value"; + + const key2 = await authStorage.getApiKey("anthropic"); + expect(key2).toBe("second-value"); + } finally { + if (originalEnv === undefined) { + delete process.env[envVarName]; + } else { + process.env[envVarName] = originalEnv; + } + } + }); + }); + }); + + describe("runtime overrides", () => { + test("runtime override takes priority over auth.json", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "!echo stored-key" }, + }); + + authStorage = new AuthStorage(authJsonPath); + authStorage.setRuntimeApiKey("anthropic", "runtime-key"); + + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("runtime-key"); + }); + + test("removing runtime override falls back to auth.json", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "!echo stored-key" }, + }); + + authStorage = new AuthStorage(authJsonPath); + authStorage.setRuntimeApiKey("anthropic", "runtime-key"); + authStorage.removeRuntimeApiKey("anthropic"); + + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("stored-key"); + }); + }); +});