diff --git a/src/agents/auth-profiles/constants.ts b/src/agents/auth-profiles/constants.ts index 3143b632f..65c2f7a54 100644 --- a/src/agents/auth-profiles/constants.ts +++ b/src/agents/auth-profiles/constants.ts @@ -1,4 +1,4 @@ -import { createSubsystemLogger } from "../../logging.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; export const AUTH_STORE_VERSION = 1; export const AUTH_PROFILE_FILENAME = "auth-profiles.json"; diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index 23e29bea6..712058af4 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -4,22 +4,19 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const execSyncMock = vi.hoisted(() => vi.fn()); - -vi.mock("node:child_process", () => ({ - execSync: execSyncMock, -})); +const execSyncMock = vi.fn(); describe("cli credentials", () => { beforeEach(() => { vi.useFakeTimers(); }); - afterEach(() => { + afterEach(async () => { vi.useRealTimers(); - vi.resetModules(); execSyncMock.mockReset(); delete process.env.CODEX_HOME; + const { resetCliCredentialCachesForTest } = await import("./cli-credentials.js"); + resetCliCredentialCachesForTest(); }); it("updates the Claude Code keychain item in place", async () => { @@ -44,11 +41,14 @@ describe("cli credentials", () => { const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); - const ok = writeClaudeCliKeychainCredentials({ - access: "new-access", - refresh: "new-refresh", - expires: Date.now() + 60_000, - }); + const ok = writeClaudeCliKeychainCredentials( + { + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60_000, + }, + { execSync: execSyncMock }, + ); expect(ok).toBe(true); expect(commands.some((cmd) => cmd.includes("delete-generic-password"))).toBe(false); @@ -130,11 +130,13 @@ describe("cli credentials", () => { allowKeychainPrompt: true, ttlMs: 15 * 60 * 1000, platform: "darwin", + execSync: execSyncMock, }); const second = readClaudeCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 15 * 60 * 1000, platform: "darwin", + execSync: execSyncMock, }); expect(first).toBeTruthy(); @@ -161,6 +163,7 @@ describe("cli credentials", () => { allowKeychainPrompt: true, ttlMs: 15 * 60 * 1000, platform: "darwin", + execSync: execSyncMock, }); vi.advanceTimersByTime(15 * 60 * 1000 + 1); @@ -169,6 +172,7 @@ describe("cli credentials", () => { allowKeychainPrompt: true, ttlMs: 15 * 60 * 1000, platform: "darwin", + execSync: execSyncMock, }); expect(first).toBeTruthy(); @@ -196,7 +200,7 @@ describe("cli credentials", () => { }); const { readCodexCliCredentials } = await import("./cli-credentials.js"); - const creds = readCodexCliCredentials({ platform: "darwin" }); + const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock }); expect(creds).toMatchObject({ access: "keychain-access", @@ -226,7 +230,7 @@ describe("cli credentials", () => { ); const { readCodexCliCredentials } = await import("./cli-credentials.js"); - const creds = readCodexCliCredentials(); + const creds = readCodexCliCredentials({ execSync: execSyncMock }); expect(creds).toMatchObject({ access: "file-access", diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index 54c417d7d..f224e0238 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -6,7 +6,7 @@ import path from "node:path"; import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; -import { createSubsystemLogger } from "../logging.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; const log = createSubsystemLogger("agents/auth-profiles"); @@ -28,6 +28,12 @@ let claudeCliCache: CachedValue | null = null; let codexCliCache: CachedValue | null = null; let qwenCliCache: CachedValue | null = null; +export function resetCliCredentialCachesForTest(): void { + claudeCliCache = null; + codexCliCache = null; + qwenCliCache = null; +} + export type ClaudeCliCredential = | { type: "oauth"; @@ -69,6 +75,8 @@ type ClaudeCliWriteOptions = ClaudeCliFileOptions & { writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean; }; +type ExecSyncFn = typeof execSync; + function resolveClaudeCliCredentialsPath(homeDir?: string) { const baseDir = homeDir ?? resolveUserPath("~"); return path.join(baseDir, CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH); @@ -100,19 +108,24 @@ function computeCodexKeychainAccount(codexHome: string) { function readCodexKeychainCredentials(options?: { platform?: NodeJS.Platform; + execSync?: ExecSyncFn; }): CodexCliCredential | null { const platform = options?.platform ?? process.platform; if (platform !== "darwin") return null; + const execSyncImpl = options?.execSync ?? execSync; const codexHome = resolveCodexHomePath(); const account = computeCodexKeychainAccount(codexHome); try { - const secret = execSync(`security find-generic-password -s "Codex Auth" -a "${account}" -w`, { - encoding: "utf8", - timeout: 5000, - stdio: ["pipe", "pipe", "pipe"], - }).trim(); + const secret = execSyncImpl( + `security find-generic-password -s "Codex Auth" -a "${account}" -w`, + { + encoding: "utf8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }, + ).trim(); const parsed = JSON.parse(secret) as Record; const tokens = parsed.tokens as Record | undefined; @@ -170,9 +183,11 @@ function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredenti }; } -function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null { +function readClaudeCliKeychainCredentials( + execSyncImpl: ExecSyncFn = execSync, +): ClaudeCliCredential | null { try { - const result = execSync( + const result = execSyncImpl( `security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, ); @@ -213,10 +228,11 @@ export function readClaudeCliCredentials(options?: { allowKeychainPrompt?: boolean; platform?: NodeJS.Platform; homeDir?: string; + execSync?: ExecSyncFn; }): ClaudeCliCredential | null { const platform = options?.platform ?? process.platform; if (platform === "darwin" && options?.allowKeychainPrompt !== false) { - const keychainCreds = readClaudeCliKeychainCredentials(); + const keychainCreds = readClaudeCliKeychainCredentials(options?.execSync); if (keychainCreds) { log.info("read anthropic credentials from claude cli keychain", { type: keychainCreds.type, @@ -263,6 +279,7 @@ export function readClaudeCliCredentialsCached(options?: { ttlMs?: number; platform?: NodeJS.Platform; homeDir?: string; + execSync?: ExecSyncFn; }): ClaudeCliCredential | null { const ttlMs = options?.ttlMs ?? 0; const now = Date.now(); @@ -279,6 +296,7 @@ export function readClaudeCliCredentialsCached(options?: { allowKeychainPrompt: options?.allowKeychainPrompt, platform: options?.platform, homeDir: options?.homeDir, + execSync: options?.execSync, }); if (ttlMs > 0) { claudeCliCache = { value, readAt: now, cacheKey }; @@ -286,9 +304,13 @@ export function readClaudeCliCredentialsCached(options?: { return value; } -export function writeClaudeCliKeychainCredentials(newCredentials: OAuthCredentials): boolean { +export function writeClaudeCliKeychainCredentials( + newCredentials: OAuthCredentials, + options?: { execSync?: ExecSyncFn }, +): boolean { + const execSyncImpl = options?.execSync ?? execSync; try { - const existingResult = execSync( + const existingResult = execSyncImpl( `security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w 2>/dev/null`, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, ); @@ -308,7 +330,7 @@ export function writeClaudeCliKeychainCredentials(newCredentials: OAuthCredentia const newValue = JSON.stringify(existingData); - execSync( + execSyncImpl( `security add-generic-password -U -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -a "${CLAUDE_CLI_KEYCHAIN_ACCOUNT}" -w '${newValue.replace(/'/g, "'\"'\"'")}'`, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, ); @@ -385,9 +407,11 @@ export function writeClaudeCliCredentials( export function readCodexCliCredentials(options?: { platform?: NodeJS.Platform; + execSync?: ExecSyncFn; }): CodexCliCredential | null { const keychain = readCodexKeychainCredentials({ platform: options?.platform, + execSync: options?.execSync, }); if (keychain) return keychain; @@ -425,6 +449,7 @@ export function readCodexCliCredentials(options?: { export function readCodexCliCredentialsCached(options?: { ttlMs?: number; platform?: NodeJS.Platform; + execSync?: ExecSyncFn; }): CodexCliCredential | null { const ttlMs = options?.ttlMs ?? 0; const now = Date.now(); @@ -437,7 +462,10 @@ export function readCodexCliCredentialsCached(options?: { ) { return codexCliCache.value; } - const value = readCodexCliCredentials({ platform: options?.platform }); + const value = readCodexCliCredentials({ + platform: options?.platform, + execSync: options?.execSync, + }); if (ttlMs > 0) { codexCliCache = { value, readAt: now, cacheKey }; }