fix: read codex keychain credentials
This commit is contained in:
@@ -19,6 +19,7 @@ describe("cli credentials", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
execSyncMock.mockReset();
|
execSyncMock.mockReset();
|
||||||
|
delete process.env.CODEX_HOME;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the Claude Code keychain item in place", async () => {
|
it("updates the Claude Code keychain item in place", async () => {
|
||||||
@@ -184,4 +185,63 @@ describe("cli credentials", () => {
|
|||||||
expect(second).toBeTruthy();
|
expect(second).toBeTruthy();
|
||||||
expect(execSyncMock).toHaveBeenCalledTimes(2);
|
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 { execSync } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ import { resolveUserPath } from "../utils.js";
|
|||||||
const log = createSubsystemLogger("agents/auth-profiles");
|
const log = createSubsystemLogger("agents/auth-profiles");
|
||||||
|
|
||||||
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
|
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_SERVICE = "Claude Code-credentials";
|
||||||
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
|
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
|
||||||
@@ -67,7 +68,70 @@ function resolveClaudeCliCredentialsPath(homeDir?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveCodexCliAuthPath() {
|
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 {
|
function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null {
|
||||||
@@ -290,6 +354,9 @@ export function writeClaudeCliCredentials(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function readCodexCliCredentials(): CodexCliCredential | null {
|
export function readCodexCliCredentials(): CodexCliCredential | null {
|
||||||
|
const keychain = readCodexKeychainCredentials();
|
||||||
|
if (keychain) return keychain;
|
||||||
|
|
||||||
const authPath = resolveCodexCliAuthPath();
|
const authPath = resolveCodexCliAuthPath();
|
||||||
const raw = loadJsonFile(authPath);
|
const raw = loadJsonFile(authPath);
|
||||||
if (!raw || typeof raw !== "object") return null;
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user