fix: read codex keychain credentials
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user