fix(coding-agent): make config env references explicit

closes #5095
This commit is contained in:
Armin Ronacher
2026-05-28 11:57:10 +02:00
parent 5b31ffd744
commit 3e9f717445
17 changed files with 930 additions and 71 deletions

View File

@@ -112,13 +112,13 @@ describe("AuthStorage", () => {
expect(apiKey).toBeUndefined();
});
test("apiKey as environment variable name resolves to env value", async () => {
test("apiKey with $ prefix 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" },
anthropic: { type: "api_key", key: "$TEST_AUTH_API_KEY_12345" },
});
authStorage = AuthStorage.create(authJsonPath);
@@ -134,6 +134,140 @@ describe("AuthStorage", () => {
}
});
test("apiKey with braced env syntax resolves to env value", async () => {
const originalEnv = process.env.TEST_AUTH_BRACED_API_KEY_12345;
process.env.TEST_AUTH_BRACED_API_KEY_12345 = "braced-env-api-key-value";
const bracedKey = "$" + "{TEST_AUTH_BRACED_API_KEY_12345}";
try {
writeAuthJson({
anthropic: { type: "api_key", key: bracedKey },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("braced-env-api-key-value");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_AUTH_BRACED_API_KEY_12345;
} else {
process.env.TEST_AUTH_BRACED_API_KEY_12345 = originalEnv;
}
}
});
test("apiKey interpolates braced env references inside literals", async () => {
const originalPartA = process.env.TEST_AUTH_INTERPOLATED_PART_A_12345;
const originalPartB = process.env.TEST_AUTH_INTERPOLATED_PART_B_12345;
process.env.TEST_AUTH_INTERPOLATED_PART_A_12345 = "left";
process.env.TEST_AUTH_INTERPOLATED_PART_B_12345 = "right";
const interpolatedKey = [
"$",
"{TEST_AUTH_INTERPOLATED_PART_A_12345}_$",
"{TEST_AUTH_INTERPOLATED_PART_B_12345}",
].join("");
try {
writeAuthJson({
anthropic: { type: "api_key", key: interpolatedKey },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("left_right");
} finally {
if (originalPartA === undefined) {
delete process.env.TEST_AUTH_INTERPOLATED_PART_A_12345;
} else {
process.env.TEST_AUTH_INTERPOLATED_PART_A_12345 = originalPartA;
}
if (originalPartB === undefined) {
delete process.env.TEST_AUTH_INTERPOLATED_PART_B_12345;
} else {
process.env.TEST_AUTH_INTERPOLATED_PART_B_12345 = originalPartB;
}
}
});
test("apiKey with $$ prefix escapes a leading dollar", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "$$TEST_AUTH_API_KEY_12345" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("$TEST_AUTH_API_KEY_12345");
});
test("apiKey with $! escapes a literal bang and still interpolates later env refs", 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: "$!literal-$TEST_AUTH_API_KEY_12345" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("!literal-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("plain API key is used directly even when it matches an env var", 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 = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("TEST_AUTH_API_KEY_12345");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_AUTH_API_KEY_12345;
} else {
process.env.TEST_AUTH_API_KEY_12345 = originalEnv;
}
}
});
test("literal public API key is not corrupted by the Windows PUBLIC env var", async () => {
const originalPublic = process.env.PUBLIC;
process.env.PUBLIC = "C:\\Users\\Public";
try {
writeAuthJson({
opencode: { type: "api_key", key: "public" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("opencode");
expect(apiKey).toBe("public");
} finally {
if (originalPublic === undefined) {
delete process.env.PUBLIC;
} else {
process.env.PUBLIC = originalPublic;
}
}
});
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;
@@ -274,7 +408,7 @@ describe("AuthStorage", () => {
process.env[envVarName] = "first-value";
writeAuthJson({
anthropic: { type: "api_key", key: envVarName },
anthropic: { type: "api_key", key: `$${envVarName}` },
});
authStorage = AuthStorage.create(authJsonPath);

View File

@@ -0,0 +1,138 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR } from "../src/config.ts";
import { runMigrations } from "../src/migrations.ts";
describe("config value env var syntax migration", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
vi.restoreAllMocks();
});
function createAgentDir(): string {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-config-value-migration-test-"));
tempDirs.push(agentDir);
return agentDir;
}
function withAgentDir(agentDir: string, fn: () => void): void {
const previousAgentDir = process.env[ENV_AGENT_DIR];
process.env[ENV_AGENT_DIR] = agentDir;
try {
fn();
} finally {
if (previousAgentDir === undefined) {
delete process.env[ENV_AGENT_DIR];
} else {
process.env[ENV_AGENT_DIR] = previousAgentDir;
}
}
}
it("rewrites legacy uppercase auth.json API key values to explicit env references", () => {
const agentDir = createAgentDir();
fs.writeFileSync(
path.join(agentDir, "auth.json"),
`${JSON.stringify(
{
anthropic: { type: "api_key", key: "ANTHROPIC_API_KEY" },
openai: { type: "api_key", key: "$OPENAI_API_KEY" },
opencode: { type: "api_key", key: "public" },
github: { type: "oauth", access: "ACCESS_TOKEN", refresh: "REFRESH_TOKEN", expires: 1 },
},
null,
2,
)}\n`,
"utf-8",
);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
withAgentDir(agentDir, () => runMigrations(agentDir));
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "auth.json"), "utf-8")) as Record<
string,
Record<string, unknown>
>;
expect(migrated.anthropic.key).toBe("$ANTHROPIC_API_KEY");
expect(migrated.openai.key).toBe("$OPENAI_API_KEY");
expect(migrated.opencode.key).toBe("public");
expect(migrated.github.access).toBe("ACCESS_TOKEN");
const logMessage = String(logSpy.mock.calls[0]?.[0] ?? "");
expect(logMessage).toContain("explicit $ENV_VAR syntax");
expect(logMessage).toContain('auth.json["anthropic"].key: ANTHROPIC_API_KEY -> $ANTHROPIC_API_KEY');
});
it("rewrites legacy uppercase models.json API key and header values", () => {
const agentDir = createAgentDir();
fs.writeFileSync(
path.join(agentDir, "models.json"),
`${JSON.stringify(
{
providers: {
"custom-provider": {
baseUrl: "https://example.com/v1",
apiKey: "CUSTOM_API_KEY",
api: "openai-completions",
headers: {
"x-api-key": "HEADER_API_KEY",
"x-literal": "literal",
},
models: [
{
id: "model-a",
headers: { "x-model-key": "MODEL_API_KEY" },
},
],
modelOverrides: {
"model-b": { headers: { "x-override-key": "OVERRIDE_API_KEY" } },
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
withAgentDir(agentDir, () => runMigrations(agentDir));
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "models.json"), "utf-8")) as {
providers: Record<
string,
{
apiKey?: string;
headers?: Record<string, string>;
models?: Array<{ headers?: Record<string, string> }>;
modelOverrides?: Record<string, { headers?: Record<string, string> }>;
}
>;
};
const provider = migrated.providers["custom-provider"]!;
expect(provider.apiKey).toBe("$CUSTOM_API_KEY");
expect(provider.headers?.["x-api-key"]).toBe("$HEADER_API_KEY");
expect(provider.headers?.["x-literal"]).toBe("literal");
expect(provider.models?.[0]?.headers?.["x-model-key"]).toBe("$MODEL_API_KEY");
expect(provider.modelOverrides?.["model-b"]?.headers?.["x-override-key"]).toBe("$OVERRIDE_API_KEY");
const logMessage = String(logSpy.mock.calls[0]?.[0] ?? "");
expect(logMessage).toContain(
'models.json.providers["custom-provider"].apiKey: CUSTOM_API_KEY -> $CUSTOM_API_KEY',
);
expect(logMessage).toContain(
'models.json.providers["custom-provider"].headers["x-api-key"]: HEADER_API_KEY -> $HEADER_API_KEY',
);
expect(logMessage).toContain(
'models.json.providers["custom-provider"].models["model-a"].headers["x-model-key"]: MODEL_API_KEY -> $MODEL_API_KEY',
);
expect(logMessage).toContain(
'models.json.providers["custom-provider"].modelOverrides["model-b"].headers["x-override-key"]: OVERRIDE_API_KEY -> $OVERRIDE_API_KEY',
);
});
});

View File

@@ -36,7 +36,7 @@ describe("ExtensionRunner", () => {
const providerModelConfig: ProviderConfig = {
baseUrl: "https://provider.test/v1",
apiKey: "PROVIDER_TEST_KEY",
apiKey: "provider-test-key",
api: "openai-completions",
models: [
{

View File

@@ -4,9 +4,10 @@ import { join } from "node:path";
import type { AnthropicMessagesCompat, Api, Context, Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
import { getApiProvider } from "@earendil-works/pi-ai";
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.ts";
import { clearDeprecationWarningsForTests } from "../src/utils/deprecation.ts";
describe("ModelRegistry", () => {
let tempDir: string;
@@ -18,6 +19,7 @@ describe("ModelRegistry", () => {
mkdirSync(tempDir, { recursive: true });
modelsJsonPath = join(tempDir, "models.json");
authStorage = AuthStorage.create(join(tempDir, "auth.json"));
clearDeprecationWarningsForTests();
});
afterEach(() => {
@@ -25,6 +27,8 @@ describe("ModelRegistry", () => {
rmSync(tempDir, { recursive: true });
}
clearApiKeyCache();
clearDeprecationWarningsForTests();
vi.restoreAllMocks();
});
/** Create minimal provider config */
@@ -35,7 +39,7 @@ describe("ModelRegistry", () => {
): ProviderConfigInput {
return {
baseUrl,
apiKey: "TEST_KEY",
apiKey: "test-key",
api: api as Api,
models: models.map((m) => ({
id: m.id,
@@ -852,7 +856,7 @@ describe("ModelRegistry", () => {
registry.registerProvider("named-provider", {
name: "Named Provider",
baseUrl: "https://provider.test/v1",
apiKey: "TEST_KEY",
apiKey: "test-key",
api: "openai-completions",
models: [
{
@@ -892,6 +896,30 @@ describe("ModelRegistry", () => {
expect(registry.getProviderDisplayName("oauth-provider")).toBe("OAuth Provider");
});
test("registerProvider warns and temporarily treats uppercase apiKey as an env reference", async () => {
const originalEnv = process.env.CUSTOM_NAME;
process.env.CUSTOM_NAME = "legacy-env-key";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
registry.registerProvider("legacy-provider", {
...providerConfig("https://provider.test/v1", [{ id: "demo-model" }], "openai-completions"),
apiKey: "CUSTOM_NAME",
});
expect(await registry.getApiKeyForProvider("legacy-provider")).toBe("legacy-env-key");
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Pass "$CUSTOM_NAME" instead'));
} finally {
if (originalEnv === undefined) {
delete process.env.CUSTOM_NAME;
} else {
process.env.CUSTOM_NAME = originalEnv;
}
}
});
test("failed registerProvider does not persist invalid streamSimple config", () => {
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
@@ -911,7 +939,7 @@ describe("ModelRegistry", () => {
registry.registerProvider("demo-provider", {
baseUrl: "https://provider.test/v1",
apiKey: "TEST_KEY",
apiKey: "test-key",
api: "openai-completions",
models: [
{
@@ -931,7 +959,7 @@ describe("ModelRegistry", () => {
expect(() =>
registry.registerProvider("demo-provider", {
baseUrl: "https://provider.test/v2",
apiKey: "TEST_KEY",
apiKey: "test-key",
models: [
{
id: "broken-model",
@@ -1187,7 +1215,117 @@ describe("ModelRegistry", () => {
expect(apiKey).toBeUndefined();
});
test("apiKey as environment variable name resolves to env value", async () => {
test("apiKey with $ prefix resolves to env value", async () => {
const originalEnv = process.env.TEST_API_KEY_12345;
process.env.TEST_API_KEY_12345 = "env-api-key-value";
try {
writeRawModelsJson({
"custom-provider": providerWithApiKey("$TEST_API_KEY_12345"),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const apiKey = await registry.getApiKeyForProvider("custom-provider");
expect(apiKey).toBe("env-api-key-value");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_API_KEY_12345;
} else {
process.env.TEST_API_KEY_12345 = originalEnv;
}
}
});
test("apiKey with braced env syntax resolves to env value", async () => {
const originalEnv = process.env.TEST_BRACED_API_KEY_12345;
process.env.TEST_BRACED_API_KEY_12345 = "braced-env-api-key-value";
const bracedKey = "$" + "{TEST_BRACED_API_KEY_12345}";
try {
writeRawModelsJson({
"custom-provider": providerWithApiKey(bracedKey),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const apiKey = await registry.getApiKeyForProvider("custom-provider");
expect(apiKey).toBe("braced-env-api-key-value");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_BRACED_API_KEY_12345;
} else {
process.env.TEST_BRACED_API_KEY_12345 = originalEnv;
}
}
});
test("apiKey interpolates braced env references inside literals", async () => {
const originalPartA = process.env.TEST_INTERPOLATED_PART_A_12345;
const originalPartB = process.env.TEST_INTERPOLATED_PART_B_12345;
process.env.TEST_INTERPOLATED_PART_A_12345 = "left";
process.env.TEST_INTERPOLATED_PART_B_12345 = "right";
const interpolatedKey = ["$", "{TEST_INTERPOLATED_PART_A_12345}_$", "{TEST_INTERPOLATED_PART_B_12345}"].join(
"",
);
try {
writeRawModelsJson({
"custom-provider": providerWithApiKey(interpolatedKey),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const apiKey = await registry.getApiKeyForProvider("custom-provider");
expect(apiKey).toBe("left_right");
} finally {
if (originalPartA === undefined) {
delete process.env.TEST_INTERPOLATED_PART_A_12345;
} else {
process.env.TEST_INTERPOLATED_PART_A_12345 = originalPartA;
}
if (originalPartB === undefined) {
delete process.env.TEST_INTERPOLATED_PART_B_12345;
} else {
process.env.TEST_INTERPOLATED_PART_B_12345 = originalPartB;
}
}
});
test("apiKey with $$ prefix escapes a leading dollar", async () => {
writeRawModelsJson({
"custom-provider": providerWithApiKey("$$TEST_API_KEY_12345"),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const apiKey = await registry.getApiKeyForProvider("custom-provider");
expect(apiKey).toBe("$TEST_API_KEY_12345");
});
test("apiKey with $! escapes a literal bang and still interpolates later env refs", async () => {
const originalEnv = process.env.TEST_API_KEY_12345;
process.env.TEST_API_KEY_12345 = "env-api-key-value";
try {
writeRawModelsJson({
"custom-provider": providerWithApiKey("$!literal-$TEST_API_KEY_12345"),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const apiKey = await registry.getApiKeyForProvider("custom-provider");
expect(apiKey).toBe("!literal-env-api-key-value");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_API_KEY_12345;
} else {
process.env.TEST_API_KEY_12345 = originalEnv;
}
}
});
test("plain apiKey is used directly even when it matches an env var", async () => {
const originalEnv = process.env.TEST_API_KEY_12345;
process.env.TEST_API_KEY_12345 = "env-api-key-value";
@@ -1199,7 +1337,7 @@ describe("ModelRegistry", () => {
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const apiKey = await registry.getApiKeyForProvider("custom-provider");
expect(apiKey).toBe("env-api-key-value");
expect(apiKey).toBe("TEST_API_KEY_12345");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_API_KEY_12345;
@@ -1318,7 +1456,7 @@ describe("ModelRegistry", () => {
process.env[envVarName] = "status-test-key";
writeRawModelsJson({
"custom-provider": providerWithApiKey(envVarName),
"custom-provider": providerWithApiKey(`$${envVarName}`),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
@@ -1337,6 +1475,41 @@ describe("ModelRegistry", () => {
}
});
test("provider auth status reports interpolated apiKey environment variables", () => {
const envVarNameA = "TEST_API_KEY_STATUS_PART_A_98765";
const envVarNameB = "TEST_API_KEY_STATUS_PART_B_98765";
const originalEnvA = process.env[envVarNameA];
const originalEnvB = process.env[envVarNameB];
process.env[envVarNameA] = "left";
process.env[envVarNameB] = "right";
const interpolatedKey = ["$", "{", envVarNameA, "}_$", "{", envVarNameB, "}"].join("");
try {
writeRawModelsJson({
"custom-provider": providerWithApiKey(interpolatedKey),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
expect(registry.getProviderAuthStatus("custom-provider")).toEqual({
configured: true,
source: "environment",
label: `${envVarNameA}, ${envVarNameB}`,
});
} finally {
if (originalEnvA === undefined) {
delete process.env[envVarNameA];
} else {
process.env[envVarNameA] = originalEnvA;
}
if (originalEnvB === undefined) {
delete process.env[envVarNameB];
} else {
process.env[envVarNameB] = originalEnvB;
}
}
});
test("provider auth status reports non-env apiKey values from models.json as a config key", () => {
writeRawModelsJson({
"custom-provider": providerWithApiKey("literal_api_key_value"),
@@ -1350,6 +1523,29 @@ describe("ModelRegistry", () => {
});
});
test("missing explicit env apiKey keeps provider unavailable", () => {
const envVarName = "TEST_API_KEY_MISSING_TEST_98765";
const originalEnv = process.env[envVarName];
delete process.env[envVarName];
try {
writeRawModelsJson({
"custom-provider": providerWithApiKey(`$${envVarName}`),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
expect(registry.getProviderAuthStatus("custom-provider")).toEqual({ configured: false });
expect(registry.getAvailable().some((model) => model.provider === "custom-provider")).toBe(false);
} finally {
if (originalEnv === undefined) {
delete process.env[envVarName];
} else {
process.env[envVarName] = originalEnv;
}
}
});
test("provider auth status reports command apiKey values from models.json without executing them", () => {
const counterFile = join(tempDir, "status-counter");
writeFileSync(counterFile, "0");
@@ -1376,7 +1572,7 @@ describe("ModelRegistry", () => {
process.env[envVarName] = "first-value";
writeRawModelsJson({
"custom-provider": providerWithApiKey(envVarName),
"custom-provider": providerWithApiKey(`$${envVarName}`),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);