diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index 01e742528..a52c74d29 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -19,6 +19,7 @@ describe("cli credentials", () => { vi.useRealTimers(); vi.resetModules(); execSyncMock.mockReset(); + delete process.env.CODEX_HOME; }); it("updates the Claude Code keychain item in place", async () => { @@ -184,4 +185,63 @@ describe("cli credentials", () => { expect(second).toBeTruthy(); expect(execSyncMock).toHaveBeenCalledTimes(2); }); + + it("reads Codex credentials from keychain when available", async () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-")); + process.env.CODEX_HOME = tempHome; + + const accountHash = "cli|"; + + execSyncMock.mockImplementation((command: unknown) => { + const cmd = String(command); + expect(cmd).toContain("Codex Auth"); + expect(cmd).toContain(accountHash); + return JSON.stringify({ + tokens: { + access_token: "keychain-access", + refresh_token: "keychain-refresh", + }, + last_refresh: "2026-01-01T00:00:00Z", + }); + }); + + const { readCodexCliCredentials } = await import("./cli-credentials.js"); + const creds = readCodexCliCredentials(); + + expect(creds).toMatchObject({ + access: "keychain-access", + refresh: "keychain-refresh", + provider: "openai-codex", + }); + }); + + it("falls back to Codex auth.json when keychain is unavailable", async () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-")); + process.env.CODEX_HOME = tempHome; + execSyncMock.mockImplementation(() => { + throw new Error("not found"); + }); + + const authPath = path.join(tempHome, "auth.json"); + fs.mkdirSync(tempHome, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + authPath, + JSON.stringify({ + tokens: { + access_token: "file-access", + refresh_token: "file-refresh", + }, + }), + "utf8", + ); + + const { readCodexCliCredentials } = await import("./cli-credentials.js"); + const creds = readCodexCliCredentials(); + + expect(creds).toMatchObject({ + access: "file-access", + refresh: "file-refresh", + provider: "openai-codex", + }); + }); }); diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index 42ffd5f8d..8d8584817 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -1,4 +1,5 @@ import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -11,7 +12,7 @@ import { resolveUserPath } from "../utils.js"; const log = createSubsystemLogger("agents/auth-profiles"); const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json"; -const CODEX_CLI_AUTH_RELATIVE_PATH = ".codex/auth.json"; +const CODEX_CLI_AUTH_FILENAME = "auth.json"; const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials"; const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code"; @@ -67,7 +68,70 @@ function resolveClaudeCliCredentialsPath(homeDir?: string) { } function resolveCodexCliAuthPath() { - return path.join(resolveUserPath("~"), CODEX_CLI_AUTH_RELATIVE_PATH); + return path.join(resolveCodexHomePath(), CODEX_CLI_AUTH_FILENAME); +} + +function resolveCodexHomePath() { + const configured = process.env.CODEX_HOME; + const home = configured + ? resolveUserPath(configured) + : resolveUserPath("~/.codex"); + try { + return fs.realpathSync.native(home); + } catch { + return home; + } +} + +function computeCodexKeychainAccount(codexHome: string) { + const hash = createHash("sha256").update(codexHome).digest("hex"); + return `cli|${hash.slice(0, 16)}`; +} + +function readCodexKeychainCredentials(): CodexCliCredential | null { + if (process.platform !== "darwin") return null; + + 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 parsed = JSON.parse(secret) as Record; + const tokens = parsed.tokens as Record | undefined; + const accessToken = tokens?.access_token; + const refreshToken = tokens?.refresh_token; + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof refreshToken !== "string" || !refreshToken) return null; + + // No explicit expiry stored; treat as fresh for an hour from last_refresh or now. + const lastRefreshRaw = parsed.last_refresh; + const lastRefresh = + typeof lastRefreshRaw === "string" || typeof lastRefreshRaw === "number" + ? new Date(lastRefreshRaw).getTime() + : Date.now(); + const expires = Number.isFinite(lastRefresh) + ? lastRefresh + 60 * 60 * 1000 + : Date.now() + 60 * 60 * 1000; + + log.info("read codex credentials from keychain", { + source: "keychain", + expires: new Date(expires).toISOString(), + }); + + return { + type: "oauth", + provider: "openai-codex" as OAuthProvider, + access: accessToken, + refresh: refreshToken, + expires, + }; + } catch { + return null; + } } function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null { @@ -290,6 +354,9 @@ export function writeClaudeCliCredentials( } export function readCodexCliCredentials(): CodexCliCredential | null { + const keychain = readCodexKeychainCredentials(); + if (keychain) return keychain; + const authPath = resolveCodexCliAuthPath(); const raw = loadJsonFile(authPath); if (!raw || typeof raw !== "object") return null;