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
219 changes: 219 additions & 0 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,15 @@ import { readJsonFile } from "./fs.mjs";
import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs";
import { loadBrokerSession } from "./broker-lifecycle.mjs";
import { binaryAvailable, runCommand } from "./process.mjs";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const SERVICE_NAME = "claude_code_codex_plugin";
const TASK_THREAD_PREFIX = "Codex Companion Task";
const DEFAULT_CONTINUE_PROMPT =
"Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved.";
const LOGIN_FREE_PROVIDER_NAMES = new Set(["oss", "ollama", "lmstudio"]);

function cleanCodexStderr(stderr) {
return stderr
Expand Down Expand Up @@ -652,6 +656,208 @@ function buildResultStatus(turnState) {
return turnState.finalTurn?.status === "completed" ? 0 : 1;
}

function parseTomlPath(rawPath) {
const source = String(rawPath ?? "").trim();
if (!source) {
return [];
}

const segments = [];
let current = "";
let quote = null;

for (const character of source) {
if (quote) {
if (character === quote) {
quote = null;
} else {
current += character;
}
continue;
}

if (character === "\"" || character === "'") {
quote = character;
continue;
}

if (character === ".") {
if (current.trim()) {
segments.push(current.trim());
}
current = "";
continue;
}

current += character;
}

if (current.trim()) {
segments.push(current.trim());
}

return segments;
}

function parseTomlScalar(rawValue) {
const source = String(rawValue ?? "").trim();
if (!source) {
return null;
}

const commentIndex = source.indexOf("#");
const value = commentIndex === -1 ? source : source.slice(0, commentIndex).trimEnd();
const quoted = value.match(/^"(.*)"$/) ?? value.match(/^'(.*)'$/);
if (quoted) {
return quoted[1];
}
return value || null;
}

function parseProviderSelectionFromToml(rawToml) {
const providerSections = new Map();
let modelProvider = null;
let currentSection = [];

for (const rawLine of String(rawToml ?? "").split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}

const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
currentSection = parseTomlPath(sectionMatch[1]);
continue;
}

const equalsIndex = line.indexOf("=");
if (equalsIndex === -1) {
continue;
}

const key = line.slice(0, equalsIndex).trim();
const value = line.slice(equalsIndex + 1).trim();

if (currentSection.length === 0 && key === "model_provider") {
const parsedProvider = parseTomlScalar(value);
if (parsedProvider) {
modelProvider = parsedProvider;
}
continue;
}

if (currentSection[0] !== "model_providers" || currentSection.length < 2) {
continue;
}

const providerName = currentSection[1].toLowerCase();
if (!providerName) {
continue;
}

const existing = providerSections.get(providerName) ?? {
keyCount: 0,
hasAuthHint: false
};
existing.keyCount += 1;
if (key === "http_headers" || key === "base_url" || /api[_-]?key/i.test(key) || /token/i.test(key)) {
existing.hasAuthHint = true;
}
providerSections.set(providerName, existing);
}

return {
modelProvider,
providerSections
};
}

function listProjectConfigFiles(cwd) {
const files = [];
let current = path.resolve(cwd);

while (true) {
const candidate = path.join(current, ".codex", "config.toml");
if (fs.existsSync(candidate)) {
files.push(candidate);
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}

return files.reverse();
}

function resolveUserConfigFile(env = process.env) {
const homeDir = env.HOME || os.homedir();
if (!homeDir) {
return null;
}
return path.join(homeDir, ".codex", "config.toml");
}

function getModelProviderSelection(cwd, env = process.env) {
const candidates = [];
const userConfig = resolveUserConfigFile(env);
if (userConfig) {
candidates.push(userConfig);
}
candidates.push(...listProjectConfigFiles(cwd));
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 Respect project trust before using project config for auth

Project config files are always added to provider resolution, but the plugin docs explicitly note project overrides only apply when the project is trusted. Because this logic has no trust check, an untrusted repo’s .codex/config.toml can force a non-OpenAI provider and make auth appear ready even though Codex CLI may ignore that config and still require OpenAI login. This creates false-positive readiness in /codex:setup and gating flows.

Useful? React with 👍 / 👎.


let selectedProvider = null;
let selectedSource = null;
const providerSections = new Map();

for (const configPath of candidates) {
if (!fs.existsSync(configPath)) {
continue;
}

let rawToml = "";
try {
rawToml = fs.readFileSync(configPath, "utf8");
} catch {
continue;
}

const parsed = parseProviderSelectionFromToml(rawToml);
if (parsed.modelProvider) {
selectedProvider = parsed.modelProvider;
selectedSource = configPath;
}

for (const [providerName, sectionState] of parsed.providerSections.entries()) {
providerSections.set(providerName, {
keyCount: (providerSections.get(providerName)?.keyCount ?? 0) + sectionState.keyCount,
hasAuthHint: (providerSections.get(providerName)?.hasAuthHint ?? false) || sectionState.hasAuthHint,
source: configPath
});
}
}

if (!selectedProvider) {
return null;
}

const normalizedProvider = selectedProvider.trim().toLowerCase();
if (!normalizedProvider || normalizedProvider === "openai") {
return null;
}

const sectionState = providerSections.get(normalizedProvider) ?? null;
return {
provider: normalizedProvider,
source: sectionState?.source ?? selectedSource ?? null,
hasProviderConfig:
Boolean(sectionState && (sectionState.keyCount > 0 || sectionState.hasAuthHint)) ||
LOGIN_FREE_PROVIDER_NAMES.has(normalizedProvider)
};
}

export function getCodexAvailability(cwd) {
const versionStatus = binaryAvailable("codex", ["--version"], { cwd });
if (!versionStatus.available) {
Expand Down Expand Up @@ -701,6 +907,19 @@ export function getCodexLoginStatus(cwd) {
};
}

const selectedProvider = getModelProviderSelection(cwd);
if (selectedProvider) {
const sourceDetail = selectedProvider.source ? ` via ${selectedProvider.source}` : "";
const readinessDetail = selectedProvider.hasProviderConfig
? `configured model_provider "${selectedProvider.provider}"${sourceDetail}; login status is not required for this provider`
: `model_provider "${selectedProvider.provider}" selected${sourceDetail}; login status is not required for this provider`;
return {
available: true,
Comment on lines +913 to +917
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 Don't treat unconfigured providers as authenticated

This branch marks loggedIn: true for every non-OpenAI model_provider, including cases where hasProviderConfig is false (for example, a typo like model_provider = "litelmm" or a missing [model_providers.*] block). In those scenarios /codex:setup reports ready and skips login guidance, but review/task runs will still fail when Codex validates provider setup. Only bypass codex login status when the provider is known login-free or has usable provider config; otherwise fall back to the existing login check.

Useful? React with 👍 / 👎.

loggedIn: true,
detail: readinessDetail
};
}

const result = runCommand("codex", ["login", "status"], { cwd });
if (result.error) {
return {
Expand Down
114 changes: 114 additions & 0 deletions tests/auth-provider.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";

import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs";
import { makeTempDir, run } from "./helpers.mjs";

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SCRIPT = path.join(ROOT, "plugins", "codex", "scripts", "codex-companion.mjs");

function writeUserConfig(homeDir, contents) {
const configDir = path.join(homeDir, ".codex");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, "config.toml"), `${contents.trim()}\n`, "utf8");
}

function writeProjectConfig(projectDir, contents) {
const configDir = path.join(projectDir, ".codex");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, "config.toml"), `${contents.trim()}\n`, "utf8");
}

function runSetupJson(cwd, env) {
const result = run(process.execPath, [SCRIPT, "setup", "--json"], { cwd, env });
assert.equal(result.status, 0, result.stderr);
return JSON.parse(result.stdout);
}

test("setup treats litellm provider config as authenticated without codex login", () => {
const cwd = makeTempDir();
const home = makeTempDir();
const binDir = makeTempDir();

installFakeCodex(binDir, "logged-out");
writeUserConfig(
home,
`
model_provider = "litellm"
[model_providers.litellm]
name = "litellm"
base_url = "https://example.invalid/v1"
http_headers = { "Authorization" = "Bearer test-key" }
`
);

const env = {
...buildEnv(binDir),
HOME: home
};
const payload = runSetupJson(cwd, env);

assert.equal(payload.codex.available, true);
assert.equal(payload.auth.loggedIn, true);
assert.match(payload.auth.detail, /model_provider "litellm"/i);
assert.doesNotMatch(payload.auth.detail, /not logged in/i);
});

test("setup still reports unauthenticated when no custom provider is configured", () => {
const cwd = makeTempDir();
const home = makeTempDir();
const binDir = makeTempDir();

installFakeCodex(binDir, "logged-out");
writeUserConfig(
home,
`
model = "gpt-5.4"
`
);

const env = {
...buildEnv(binDir),
HOME: home
};
const payload = runSetupJson(cwd, env);

assert.equal(payload.auth.loggedIn, false);
assert.match(payload.auth.detail, /not authenticated|not logged in/i);
});

test("project config model_provider overrides user config for auth gating", () => {
const cwd = makeTempDir();
const home = makeTempDir();
const binDir = makeTempDir();

installFakeCodex(binDir, "logged-out");
writeUserConfig(
home,
`
model_provider = "openai"
`
);
writeProjectConfig(
cwd,
`
model_provider = "litellm"
[model_providers.litellm]
name = "litellm"
base_url = "https://example.invalid/v1"
`
);

const env = {
...buildEnv(binDir),
HOME: home
};
const payload = runSetupJson(cwd, env);

assert.equal(payload.auth.loggedIn, true);
assert.match(payload.auth.detail, /model_provider "litellm"/i);
assert.match(payload.auth.detail, /\.codex\/config\.toml/);
});
3 changes: 2 additions & 1 deletion tests/commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";

const ROOT = "/Users/dkundel/code/codex-plugin";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex");

function read(relativePath) {
Expand Down
9 changes: 7 additions & 2 deletions tests/fake-codex-fixture.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fs from "node:fs";
import path from "node:path";

import { writeExecutable } from "./helpers.mjs";
Expand Down Expand Up @@ -465,7 +466,7 @@ rl.on("line", (line) => {
}
}
send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } });
}, 400);
}, 5000);
interruptibleTurns.set(turnId, { threadId: thread.id, timer });
} else if (BEHAVIOR === "slow-task") {
emitTurnCompletedLater(thread.id, turnId, items, 400);
Expand Down Expand Up @@ -510,8 +511,12 @@ rl.on("line", (line) => {
}

export function buildEnv(binDir) {
const homeDir = path.join(binDir, "home");
fs.mkdirSync(homeDir, { recursive: true });
return {
...process.env,
PATH: `${binDir}:${process.env.PATH}`
PATH: `${binDir}:${process.env.PATH}`,
HOME: homeDir,
USERPROFILE: homeDir
};
}
Loading