fix: read codex keychain credentials

This commit is contained in:
Peter Steinberger
2026-01-11 23:38:59 +00:00
parent 3a8bfc0a5d
commit 1f95d7fc8b
2 changed files with 129 additions and 2 deletions

View File

@@ -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",
});
});
});

View File

@@ -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<string, unknown>;
const tokens = parsed.tokens as Record<string, unknown> | 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;