Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
83 changes: 83 additions & 0 deletions tests/auth-file-fallback.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Decouple auth-file tests from host Codex availability

getCodexLoginStatus() checks getCodexAvailability() before it reads ~/.codex/auth.json, so this assertion only passes when a real codex binary is installed and runnable on PATH. In CI/local environments without Codex, result.loggedIn remains false, causing the new positive-path tests to fail even if the auth-file parsing logic is correct. Mock or fixture the Codex availability check so the test validates the new file-based behavior instead of host tooling state.

Useful? React with 👍 / 👎.

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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid depending on host login state in fallback test

This assertion is nondeterministic because the code path intentionally falls through to codex login status; if the machine running tests is already authenticated, result.loggedIn will be true and the test fails. That makes this test depend on external account state rather than the auth-file fallback behavior it is trying to verify. Stub the binary call (or assert a branch-specific signal) to keep the test deterministic.

Useful? React with 👍 / 👎.

});