feat(coding-agent): replace model registry with model runtime
Move provider auth and OAuth flows onto pi-ai Models, compose models.json and extension overlays through ModelRuntime, and retain ModelRegistry as an extension compatibility facade.
This commit is contained in:
@@ -1,17 +1,14 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
||||
import { createModels, type Provider } from "@earendil-works/pi-ai";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { clearConfigValueCache, resolveConfigValueUncached } from "../src/core/resolve-config-value.ts";
|
||||
import * as shellModule from "../src/utils/shell.ts";
|
||||
|
||||
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)}`);
|
||||
@@ -20,680 +17,201 @@ describe("AuthStorage", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
clearConfigValueCache();
|
||||
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function writeAuthJson(data: Record<string, unknown>) {
|
||||
function writeAuthJson(data: Record<string, unknown>): void {
|
||||
writeFileSync(authJsonPath, JSON.stringify(data));
|
||||
}
|
||||
|
||||
function toShPath(value: string): string {
|
||||
return value.replace(/\\/g, "/").replace(/"/g, '\\"');
|
||||
}
|
||||
test("reads and resolves stored API-key credentials", async () => {
|
||||
const original = process.env.TEST_AUTH_STORAGE_KEY;
|
||||
process.env.TEST_AUTH_STORAGE_KEY = "environment-key";
|
||||
try {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "$TEST_AUTH_STORAGE_KEY" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "environment-key" });
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.TEST_AUTH_STORAGE_KEY;
|
||||
else process.env.TEST_AUTH_STORAGE_KEY = original;
|
||||
}
|
||||
});
|
||||
|
||||
describe("API key resolution", () => {
|
||||
test("literal API key is returned directly", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "sk-ant-literal-key" },
|
||||
});
|
||||
test("resolves command-backed API-key credentials", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "!printf 'command-key'" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "command-key" });
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
test("returns OAuth credentials unchanged", async () => {
|
||||
const credential = {
|
||||
type: "oauth" as const,
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
};
|
||||
const storage = AuthStorage.inMemory({ anthropic: credential });
|
||||
expect(await storage.read("anthropic")).toEqual(credential);
|
||||
});
|
||||
|
||||
expect(apiKey).toBe("sk-ant-literal-key");
|
||||
test("credential-scoped env takes precedence and remains inspectable", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: {
|
||||
type: "api_key",
|
||||
key: "$SCOPED_KEY",
|
||||
env: { SCOPED_KEY: "scoped-value", REGION: "test-region" },
|
||||
},
|
||||
});
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
expect(await storage.read("anthropic")).toMatchObject({
|
||||
key: "scoped-value",
|
||||
env: { SCOPED_KEY: "scoped-value", REGION: "test-region" },
|
||||
});
|
||||
});
|
||||
|
||||
test("modify persists a credential while preserving unrelated external edits", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "old" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "old" },
|
||||
openai: { type: "api_key", key: "external" },
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix executes command and uses stdout", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!echo test-api-key-from-command" },
|
||||
});
|
||||
await storage.modify("anthropic", async () => ({ type: "api_key", key: "new" }));
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
|
||||
anthropic: { type: "api_key", key: "new" },
|
||||
openai: { type: "api_key", key: "external" },
|
||||
});
|
||||
});
|
||||
|
||||
expect(apiKey).toBe("test-api-key-from-command");
|
||||
test("modify with undefined leaves the current credential unchanged", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
expect(await storage.modify("anthropic", async () => undefined)).toEqual({ type: "api_key", key: "stored" });
|
||||
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "stored" });
|
||||
});
|
||||
|
||||
test("serializes concurrent modifications", async () => {
|
||||
writeAuthJson({});
|
||||
const first = AuthStorage.create(authJsonPath);
|
||||
const second = AuthStorage.create(authJsonPath);
|
||||
await Promise.all([
|
||||
first.modify("anthropic", async () => ({ type: "api_key", key: "anthropic-key" })),
|
||||
second.modify("openai", async () => ({ type: "api_key", key: "openai-key" })),
|
||||
]);
|
||||
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
});
|
||||
});
|
||||
|
||||
test("delete removes one credential while preserving others", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
});
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
google: { type: "api_key", key: "external-key" },
|
||||
});
|
||||
await storage.delete("anthropic");
|
||||
await expect(storage.list()).resolves.toEqual([
|
||||
{ providerId: "openai", type: "api_key" },
|
||||
{ providerId: "google", type: "api_key" },
|
||||
]);
|
||||
expect(await storage.read("anthropic")).toBeUndefined();
|
||||
expect(await storage.read("openai")).toEqual({ type: "api_key", key: "openai-key" });
|
||||
expect(await storage.read("google")).toEqual({ type: "api_key", key: "external-key" });
|
||||
});
|
||||
|
||||
test("in-memory storage implements the same credential-store behavior", async () => {
|
||||
const storage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "initial" } });
|
||||
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "initial" });
|
||||
await storage.modify("anthropic", async () => ({ type: "api_key", key: "updated" }));
|
||||
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "updated" });
|
||||
await storage.delete("anthropic");
|
||||
await expect(storage.list()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("does not write after lock acquisition failure and recovers on retry", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
const lockSpy = vi.spyOn(lockfile, "lock").mockRejectedValueOnce(new Error("lock unavailable"));
|
||||
|
||||
await expect(storage.modify("openai", async () => ({ type: "api_key", key: "new" }))).rejects.toThrow(
|
||||
"lock unavailable",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
|
||||
anthropic: { type: "api_key", key: "stored" },
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix trims whitespace from command output", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!echo ' spaced-key '" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("spaced-key");
|
||||
lockSpy.mockRestore();
|
||||
await storage.modify("openai", async () => ({ type: "api_key", key: "new" }));
|
||||
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
|
||||
anthropic: { type: "api_key", key: "stored" },
|
||||
openai: { type: "api_key", key: "new" },
|
||||
});
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix handles multiline output (uses trimmed result)", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!printf 'line1\\nline2'" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("line1\nline2");
|
||||
test("surfaces a compromised OAuth refresh lock and allows a later retry", async () => {
|
||||
const providerId = "oauth-provider";
|
||||
writeAuthJson({
|
||||
[providerId]: {
|
||||
type: "oauth",
|
||||
access: "expired-access",
|
||||
refresh: "refresh-token",
|
||||
expires: 0,
|
||||
},
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix returns undefined on command failure", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!exit 1" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(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 = AuthStorage.create(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 = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
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" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(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 env bag takes precedence over process.env", async () => {
|
||||
const originalEnv = process.env.TEST_AUTH_SCOPED_API_KEY_12345;
|
||||
process.env.TEST_AUTH_SCOPED_API_KEY_12345 = "process-env-value";
|
||||
|
||||
try {
|
||||
writeAuthJson({
|
||||
anthropic: {
|
||||
type: "api_key",
|
||||
key: "$TEST_AUTH_SCOPED_API_KEY_12345",
|
||||
env: { TEST_AUTH_SCOPED_API_KEY_12345: "credential-env-value" },
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
const provider: Provider = {
|
||||
id: providerId,
|
||||
name: "OAuth Provider",
|
||||
auth: {
|
||||
oauth: {
|
||||
name: "OAuth",
|
||||
login: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("credential-env-value");
|
||||
expect(authStorage.getProviderEnv("anthropic")).toEqual({
|
||||
TEST_AUTH_SCOPED_API_KEY_12345: "credential-env-value",
|
||||
});
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.TEST_AUTH_SCOPED_API_KEY_12345;
|
||||
} else {
|
||||
process.env.TEST_AUTH_SCOPED_API_KEY_12345 = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "literal_api_key_value" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(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 = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("hello-world");
|
||||
});
|
||||
|
||||
test("command config uses stdin when configured shell requires it", () => {
|
||||
if (process.platform === "win32") return;
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
vi.spyOn(shellModule, "getShellConfig").mockReturnValue({
|
||||
shell: "/bin/bash",
|
||||
args: ["-s"],
|
||||
commandTransport: "stdin",
|
||||
});
|
||||
|
||||
try {
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: "win32",
|
||||
});
|
||||
const nameExpansion = "$" + "{name}";
|
||||
|
||||
expect(resolveConfigValueUncached(`!name='World'; echo "Hello, ${nameExpansion}!"`)).toBe("Hello, World!");
|
||||
} finally {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: command },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(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 counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: command },
|
||||
});
|
||||
|
||||
// Create multiple AuthStorage instances
|
||||
const storage1 = AuthStorage.create(authJsonPath);
|
||||
await storage1.getApiKey("anthropic");
|
||||
|
||||
const storage2 = AuthStorage.create(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 counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: command },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(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 = AuthStorage.create(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 counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; exit 1'`;
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: command },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(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 = AuthStorage.create(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("oauth lock compromise handling", () => {
|
||||
test("returns undefined on compromised lock and allows a later retry", async () => {
|
||||
const providerId = `test-oauth-provider-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
registerOAuthProvider({
|
||||
id: providerId,
|
||||
name: "Test OAuth Provider",
|
||||
async login() {
|
||||
throw new Error("Not used in this test");
|
||||
},
|
||||
async refreshToken(credentials) {
|
||||
return {
|
||||
...credentials,
|
||||
access: "refreshed-access-token",
|
||||
refresh: async (credential) => ({
|
||||
...credential,
|
||||
access: "refreshed-access",
|
||||
expires: Date.now() + 60_000,
|
||||
};
|
||||
}),
|
||||
toAuth: async (credential) => ({ apiKey: credential.access }),
|
||||
},
|
||||
getApiKey(credentials) {
|
||||
return `Bearer ${credentials.access}`;
|
||||
},
|
||||
});
|
||||
},
|
||||
getModels: () => [],
|
||||
stream: () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
streamSimple: () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
};
|
||||
const models = createModels({ credentials: storage });
|
||||
models.setProvider(provider);
|
||||
|
||||
writeAuthJson({
|
||||
[providerId]: {
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
access: "expired-access-token",
|
||||
expires: Date.now() - 10_000,
|
||||
},
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
|
||||
const realLock = lockfile.lock.bind(lockfile);
|
||||
const lockSpy = vi.spyOn(lockfile, "lock");
|
||||
lockSpy.mockImplementationOnce(async (file, options) => {
|
||||
options?.onCompromised?.(new Error("Unable to update lock within the stale threshold"));
|
||||
return realLock(file, options);
|
||||
});
|
||||
|
||||
const firstTry = await authStorage.getApiKey(providerId);
|
||||
expect(firstTry).toBeUndefined();
|
||||
|
||||
lockSpy.mockRestore();
|
||||
|
||||
const secondTry = await authStorage.getApiKey(providerId);
|
||||
expect(secondTry).toBe("Bearer refreshed-access-token");
|
||||
const realLock = lockfile.lock.bind(lockfile);
|
||||
const lockSpy = vi.spyOn(lockfile, "lock").mockImplementationOnce(async (file, options) => {
|
||||
options?.onCompromised?.(new Error("lock compromised"));
|
||||
return realLock(file, options);
|
||||
});
|
||||
await expect(models.getAuth(providerId)).rejects.toMatchObject({ code: "auth" });
|
||||
|
||||
lockSpy.mockRestore();
|
||||
await expect(models.getAuth(providerId)).resolves.toMatchObject({ auth: { apiKey: "refreshed-access" } });
|
||||
});
|
||||
|
||||
describe("persistence semantics", () => {
|
||||
test("set preserves unrelated external edits", () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "old-anthropic" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
|
||||
// Simulate external edit while process is running
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "old-anthropic" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
google: { type: "api_key", key: "google-key" },
|
||||
});
|
||||
|
||||
authStorage.set("anthropic", { type: "api_key", key: "new-anthropic" });
|
||||
|
||||
const updated = JSON.parse(readFileSync(authJsonPath, "utf-8")) as Record<string, { key: string }>;
|
||||
expect(updated.anthropic.key).toBe("new-anthropic");
|
||||
expect(updated.openai.key).toBe("openai-key");
|
||||
expect(updated.google.key).toBe("google-key");
|
||||
});
|
||||
|
||||
test("remove preserves unrelated external edits", () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
|
||||
// Simulate external edit while process is running
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
google: { type: "api_key", key: "google-key" },
|
||||
});
|
||||
|
||||
authStorage.remove("anthropic");
|
||||
|
||||
const updated = JSON.parse(readFileSync(authJsonPath, "utf-8")) as Record<string, { key: string }>;
|
||||
expect(updated.anthropic).toBeUndefined();
|
||||
expect(updated.openai.key).toBe("openai-key");
|
||||
expect(updated.google.key).toBe("google-key");
|
||||
});
|
||||
|
||||
test("throws and does not overwrite malformed auth file after load error", () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
writeFileSync(authJsonPath, "{invalid-json", "utf-8");
|
||||
|
||||
authStorage.reload();
|
||||
expect(() => authStorage.set("openai", { type: "api_key", key: "openai-key" })).toThrow(
|
||||
"Cannot update auth storage because it could not be loaded",
|
||||
);
|
||||
|
||||
const raw = readFileSync(authJsonPath, "utf-8");
|
||||
expect(raw).toBe("{invalid-json");
|
||||
expect(authStorage.has("openai")).toBe(false);
|
||||
});
|
||||
|
||||
test("throws when a stale auth lock prevents persistence", () => {
|
||||
writeAuthJson({});
|
||||
writeFileSync(`${authJsonPath}.lock`, "", "utf-8");
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
expect(() => authStorage.set("github-copilot", { type: "api_key", key: "copilot-key" })).toThrow(
|
||||
"Cannot update auth storage because it could not be loaded",
|
||||
);
|
||||
|
||||
expect(readFileSync(authJsonPath, "utf-8")).toBe("{}");
|
||||
expect(authStorage.has("github-copilot")).toBe(false);
|
||||
});
|
||||
|
||||
test("recovers from an earlier load error before persisting", () => {
|
||||
writeAuthJson({});
|
||||
const lockPath = `${authJsonPath}.lock`;
|
||||
writeFileSync(lockPath, "", "utf-8");
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
rmSync(lockPath);
|
||||
authStorage.set("github-copilot", { type: "api_key", key: "copilot-key" });
|
||||
|
||||
const updated = JSON.parse(readFileSync(authJsonPath, "utf-8")) as Record<string, { key: string }>;
|
||||
expect(updated["github-copilot"].key).toBe("copilot-key");
|
||||
expect(authStorage.has("github-copilot")).toBe(true);
|
||||
});
|
||||
|
||||
test("reload records parse errors and drainErrors clears buffer", () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
writeFileSync(authJsonPath, "{invalid-json", "utf-8");
|
||||
|
||||
authStorage.reload();
|
||||
|
||||
// Keeps previous in-memory data on reload failure
|
||||
expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "anthropic-key" });
|
||||
|
||||
const firstDrain = authStorage.drainErrors();
|
||||
expect(firstDrain.length).toBeGreaterThan(0);
|
||||
expect(firstDrain[0]).toBeInstanceOf(Error);
|
||||
|
||||
const secondDrain = authStorage.drainErrors();
|
||||
expect(secondDrain).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth status", () => {
|
||||
test("does not expose stored API keys or OAuth tokens", () => {
|
||||
authStorage = AuthStorage.inMemory({
|
||||
anthropic: { type: "api_key", key: "secret-api-key" },
|
||||
openai: {
|
||||
type: "oauth",
|
||||
access: "secret-access-token",
|
||||
refresh: "secret-refresh-token",
|
||||
expires: Date.now() + 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(authStorage.getAuthStatus("anthropic")).toEqual({ configured: true, source: "stored" });
|
||||
expect(authStorage.getAuthStatus("openai")).toEqual({ configured: true, source: "stored" });
|
||||
expect(JSON.stringify(authStorage.getAuthStatus("anthropic"))).not.toContain("secret-api-key");
|
||||
expect(JSON.stringify(authStorage.getAuthStatus("openai"))).not.toContain("secret-access-token");
|
||||
expect(JSON.stringify(authStorage.getAuthStatus("openai"))).not.toContain("secret-refresh-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime overrides", () => {
|
||||
test("runtime override takes priority over auth.json", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!echo stored-key" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(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 = AuthStorage.create(authJsonPath);
|
||||
authStorage.setRuntimeApiKey("anthropic", "runtime-key");
|
||||
authStorage.removeRuntimeApiKey("anthropic");
|
||||
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("stored-key");
|
||||
});
|
||||
test("does not overwrite malformed auth files", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
writeFileSync(authJsonPath, "{invalid-json", "utf8");
|
||||
await expect(storage.modify("openai", async () => ({ type: "api_key", key: "new" }))).rejects.toThrow();
|
||||
expect(readFileSync(authJsonPath, "utf8")).toBe("{invalid-json");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user