diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index bf7e8c8..9426cfe 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -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 @@ -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)); + + 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) { @@ -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, + loggedIn: true, + detail: readinessDetail + }; + } + const result = runCommand("codex", ["login", "status"], { cwd }); if (result.error) { return { diff --git a/tests/auth-provider.test.mjs b/tests/auth-provider.test.mjs new file mode 100644 index 0000000..ed26704 --- /dev/null +++ b/tests/auth-provider.test.mjs @@ -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/); +}); diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index a00e8dd..62bea9b 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -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) { diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index ac7f084..e51c727 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { writeExecutable } from "./helpers.mjs"; @@ -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); @@ -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 }; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 43ed17c..93e36b6 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -3,13 +3,14 @@ import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; import { loadBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs"; -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"); const SCRIPT = path.join(PLUGIN_ROOT, "scripts", "codex-companion.mjs"); const STOP_HOOK = path.join(PLUGIN_ROOT, "scripts", "stop-review-gate-hook.mjs"); @@ -553,7 +554,7 @@ test("task --background enqueues a detached worker and exposes per-job status", const waitedStatus = run( "node", - [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "5000", "--json"], + [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"], { cwd: repo, env: buildEnv(binDir) @@ -1275,7 +1276,7 @@ test("cancel sends turn interrupt to the shared app-server before killing a brok return job; } return null; - }); + }, { timeoutMs: 20000 }); const cancelResult = run("node", [SCRIPT, "cancel", jobId, "--json"], { cwd: repo,