Support shell command execution for API key resolution in models.json (#762)

* Support shell command execution for API key resolution in models.json

Add ! prefix support to apiKey field in models.json to execute shell commands
and use stdout as the API key. This allows users to store API keys in secure
credential managers like macOS Keychain, 1Password, Bitwarden, or HashiCorp Vault.

Example: "apiKey": "!security find-generic-password -ws 'anthropic'"

The apiKey field now supports three formats:
- !command - executes shell command, uses trimmed stdout
- ENV_VAR_NAME - uses environment variable value
- literal - uses value directly

fixes #697

* feat(coding-agent): cache API key command results for process lifetime

Shell commands (! prefix) are now executed once and cached. Environment
variables and literal values are not cached, so changes are picked up.

Addresses review feedback on #762.

---------

Co-authored-by: Mario Zechner <badlogicgames@gmail.com>
This commit is contained in:
Carlos Villela 2026-01-18 10:48:06 -08:00 committed by GitHub
parent a67f6f9916
commit def9e4e9a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 315 additions and 14 deletions

View file

@ -13,6 +13,7 @@ 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 type { AuthStorage } from "./auth-storage.js";
@ -99,14 +100,47 @@ 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 an API key config value to an actual key.
* Checks environment variable first, then treats as literal.
* - 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 resolveApiKeyConfig(keyConfig: string): string | undefined {
if (keyConfig.startsWith("!")) {
return executeApiKeyCommand(keyConfig);
}
const envValue = process.env[keyConfig];
if (envValue) return envValue;
return keyConfig;
return envValue || keyConfig;
}
function executeApiKeyCommand(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;
}
/** Clear the API key command cache. Exported for testing. */
export function clearApiKeyCache(): void {
commandResultCache.clear();
}
/**