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.
This commit is contained in:
Mario Zechner 2026-02-04 23:01:55 +01:00
parent 7c39a12a28
commit 9cf5758b68
6 changed files with 408 additions and 58 deletions

View file

@ -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

View file

@ -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

View file

@ -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") {

View file

@ -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<string, string | undefined>();
/**
* 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<string, string> | undefined): Record<string, string> | undefined {
if (!headers) return undefined;
const resolved: Record<string, string> = {};
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.

View file

@ -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<string, string | undefined>();
/**
* 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<string, string> | undefined): Record<string, string> | undefined {
if (!headers) return undefined;
const resolved: Record<string, string> = {};
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();
}

View file

@ -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<string, unknown>) {
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");
});
});
});