diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index bf7e8c8..1882aaf 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -701,6 +701,34 @@ export function getCodexLoginStatus(cwd) { }; } + // On macOS, `codex login status` can panic when run inside a sandboxed + // environment (e.g. Claude Code) because the Rust `system-configuration` + // crate attempts to access SCDynamicStore to update PATH. When the sandbox + // blocks that access, `SCDynamicStoreCreate` returns NULL and the crate + // panics with "Attempted to create a NULL object." + // + // To avoid this, try reading ~/.codex/auth.json directly first. The file + // is written by `codex login` and contains the tokens that prove the user + // has already authenticated. Only fall back to the binary call if the file + // is missing or unreadable. + try { + const home = process.env.HOME || process.env.USERPROFILE || ""; + if (home) { + const authData = readJsonFile(`${home}/.codex/auth.json`); + const tokens = authData.tokens; + if (tokens && typeof tokens === "object" && tokens.access_token) { + const mode = authData.auth_mode || "api-key"; + const label = mode === "chatgpt" ? "Logged in using ChatGPT" : `Logged in (${mode})`; + return { available: true, loggedIn: true, detail: label }; + } + if (authData.OPENAI_API_KEY) { + return { available: true, loggedIn: true, detail: "Logged in using API key" }; + } + } + } catch (_) { + // auth.json missing or unparseable — fall through to binary check. + } + const result = runCommand("codex", ["login", "status"], { cwd }); if (result.error) { return { diff --git a/tests/auth-file-fallback.test.mjs b/tests/auth-file-fallback.test.mjs new file mode 100644 index 0000000..e61099b --- /dev/null +++ b/tests/auth-file-fallback.test.mjs @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { getCodexLoginStatus } from "../plugins/codex/scripts/lib/codex.mjs"; + +/** + * Creates a temporary HOME with a fake ~/.codex/auth.json so + * getCodexLoginStatus can read the token file directly without + * spawning the codex binary (which panics on macOS in sandboxed + * environments due to SCDynamicStore access being blocked). + */ +function withTempAuthHome(authPayload, fn) { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-auth-test-")); + const codexDir = path.join(tmpHome, ".codex"); + fs.mkdirSync(codexDir, { recursive: true }); + fs.writeFileSync( + path.join(codexDir, "auth.json"), + JSON.stringify(authPayload), + "utf8" + ); + + const origHome = process.env.HOME; + const origUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = tmpHome; + delete process.env.USERPROFILE; + return fn(tmpHome); + } finally { + process.env.HOME = origHome; + if (origUserProfile !== undefined) { + process.env.USERPROFILE = origUserProfile; + } + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +} + +test("getCodexLoginStatus detects ChatGPT auth from auth.json tokens", () => { + const result = withTempAuthHome( + { + auth_mode: "chatgpt", + tokens: { + access_token: "fake-access-token", + refresh_token: "fake-refresh-token", + id_token: "fake-id-token" + }, + last_refresh: "2026-01-01T00:00:00Z" + }, + () => getCodexLoginStatus(process.cwd()) + ); + + // The file-based check should return loggedIn: true without + // ever calling `codex login status`. + assert.equal(result.loggedIn, true); + assert.equal(result.detail, "Logged in using ChatGPT"); +}); + +test("getCodexLoginStatus detects API key auth from auth.json", () => { + const result = withTempAuthHome( + { + OPENAI_API_KEY: "sk-fake-key-for-test" + }, + () => getCodexLoginStatus(process.cwd()) + ); + + assert.equal(result.loggedIn, true); + assert.equal(result.detail, "Logged in using API key"); +}); + +test("getCodexLoginStatus falls through when auth.json has no tokens", () => { + const result = withTempAuthHome( + { auth_mode: "chatgpt", tokens: {} }, + () => getCodexLoginStatus(process.cwd()) + ); + + // Without valid tokens, the function should fall through to the + // binary check. Since codex may or may not be installed in the + // test environment, we just verify it did NOT return loggedIn: true + // from the file-based path. + assert.equal(result.loggedIn, false); +});