diff --git a/README.md b/README.md index 937a3037..e879e659 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,27 @@ Your configuration will be picked up based on: Check out the Codex docs for more [configuration options](https://developers.openai.com/codex/config-reference). +### Passing Extra Codex CLI Arguments + +If you want the plugin to forward extra arguments to every Codex launch, set the `CODEX_PLUGIN_CC_ARGS` environment variable in your shell. Whatever you put there is parsed like a shell command line and prepended to each `codex` invocation the plugin makes (the app-server runtime and the availability checks). + +This is the easiest way to force a custom provider without editing `config.toml`: + +```bash +export CODEX_PLUGIN_CC_ARGS='-c model_provider=my-provider' +``` + +results in the plugin running `codex -c model_provider=my-provider app-server`. + +You can pass multiple flags, and quoting is supported: + +```bash +export CODEX_PLUGIN_CC_ARGS="-c model_provider=my-provider -c 'base_url=https://example/v1'" +``` + +> [!NOTE] +> The variable is read when a Codex runtime is started. If a shared runtime is already active for the session, change the value and start a fresh session (or cancel the running jobs) so the new arguments take effect. + ### Moving The Work Over To Codex Delegated tasks and any [stop gate](#what-does-the-review-gate-do) run can also be directly resumed inside Codex by running `codex resume` either with the specific session ID you received from running `/codex:result` or `/codex:status` or by selecting it from the list. diff --git a/plugins/codex/scripts/lib/app-server.mjs b/plugins/codex/scripts/lib/app-server.mjs index 72b30a76..87a7e214 100644 --- a/plugins/codex/scripts/lib/app-server.mjs +++ b/plugins/codex/scripts/lib/app-server.mjs @@ -15,6 +15,7 @@ import readline from "node:readline"; import { parseBrokerEndpoint } from "./broker-endpoint.mjs"; import { ensureBrokerSession, loadBrokerSession } from "./broker-lifecycle.mjs"; import { terminateProcessTree } from "./process.mjs"; +import { getCodexPassthroughArgs } from "./args.mjs"; const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url); const PLUGIN_MANIFEST = JSON.parse(fs.readFileSync(PLUGIN_MANIFEST_URL, "utf8")); @@ -187,9 +188,11 @@ class SpawnedCodexAppServerClient extends AppServerClientBase { } async initialize() { - this.proc = spawn("codex", ["app-server"], { + const childEnv = this.options.env ?? process.env; + const passthroughArgs = getCodexPassthroughArgs(childEnv); + this.proc = spawn("codex", [...passthroughArgs, "app-server"], { cwd: this.cwd, - env: this.options.env ?? process.env, + env: childEnv, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32" ? (process.env.SHELL || true) : false, windowsHide: true diff --git a/plugins/codex/scripts/lib/args.mjs b/plugins/codex/scripts/lib/args.mjs index 6b151850..e305e219 100644 --- a/plugins/codex/scripts/lib/args.mjs +++ b/plugins/codex/scripts/lib/args.mjs @@ -73,11 +73,36 @@ export function parseArgs(argv, config = {}) { return { options, positionals }; } +export const CODEX_PLUGIN_ARGS_ENV = "CODEX_PLUGIN_CC_ARGS"; + +/** + * Reads extra codex CLI arguments from the CODEX_PLUGIN_CC_ARGS environment + * variable and returns them as an argv array. These are prepended to every + * codex invocation (e.g. `codex -c model_provider=my-provider app-server`), + * letting users force global config overrides without editing config.toml. + * + * @param {NodeJS.ProcessEnv} [env] + * @returns {string[]} + */ +export function getCodexPassthroughArgs(env = process.env) { + const raw = env?.[CODEX_PLUGIN_ARGS_ENV]; + if (typeof raw !== "string" || !raw.trim()) { + return []; + } + return splitRawArgumentString(raw); +} + +// Inside double quotes a backslash is only special before these characters +// (POSIX); before anything else it stays literal, so `"C:\work\repo"` is kept +// intact while `"a\"b"` still escapes the inner quote. +const DOUBLE_QUOTE_ESCAPABLE = new Set(["\"", "\\", "$", "`"]); + export function splitRawArgumentString(raw) { const tokens = []; let current = ""; let quote = null; let escaping = false; + let doubleQuoteEscaping = false; for (const character of raw) { if (escaping) { @@ -86,13 +111,28 @@ export function splitRawArgumentString(raw) { continue; } - if (character === "\\") { - escaping = true; + // Inside single quotes everything is literal (POSIX semantics), including + // backslashes — so a Windows path like 'C:\work\repo' survives intact. + if (quote === "'") { + if (character === "'") { + quote = null; + } else { + current += character; + } continue; } - if (quote) { - if (character === quote) { + if (quote === "\"") { + if (doubleQuoteEscaping) { + current += DOUBLE_QUOTE_ESCAPABLE.has(character) ? character : `\\${character}`; + doubleQuoteEscaping = false; + continue; + } + if (character === "\\") { + doubleQuoteEscaping = true; + continue; + } + if (character === "\"") { quote = null; } else { current += character; @@ -100,6 +140,11 @@ export function splitRawArgumentString(raw) { continue; } + if (character === "\\") { + escaping = true; + continue; + } + if (character === "'" || character === "\"") { quote = character; continue; @@ -116,6 +161,10 @@ export function splitRawArgumentString(raw) { current += character; } + if (doubleQuoteEscaping) { + current += "\\"; + } + if (escaping) { current += "\\"; } diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc..39ce5b56 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -43,6 +43,7 @@ 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 } from "./process.mjs"; +import { getCodexPassthroughArgs } from "./args.mjs"; const SERVICE_NAME = "claude_code_codex_plugin"; const TASK_THREAD_PREFIX = "Codex Companion Task"; @@ -884,12 +885,13 @@ async function getCodexAuthStatusFromClient(client, cwd) { } export function getCodexAvailability(cwd) { - const versionStatus = binaryAvailable("codex", ["--version"], { cwd }); + const passthroughArgs = getCodexPassthroughArgs(); + const versionStatus = binaryAvailable("codex", [...passthroughArgs, "--version"], { cwd }); if (!versionStatus.available) { return versionStatus; } - const appServerStatus = binaryAvailable("codex", ["app-server", "--help"], { cwd }); + const appServerStatus = binaryAvailable("codex", [...passthroughArgs, "app-server", "--help"], { cwd }); if (!appServerStatus.available) { return { available: false, diff --git a/tests/args.test.mjs b/tests/args.test.mjs new file mode 100644 index 00000000..8054123c --- /dev/null +++ b/tests/args.test.mjs @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + CODEX_PLUGIN_ARGS_ENV, + getCodexPassthroughArgs, + splitRawArgumentString +} from "../plugins/codex/scripts/lib/args.mjs"; + +test("getCodexPassthroughArgs returns [] when the env var is unset", () => { + assert.deepEqual(getCodexPassthroughArgs({}), []); +}); + +test("getCodexPassthroughArgs returns [] for blank values", () => { + assert.deepEqual(getCodexPassthroughArgs({ [CODEX_PLUGIN_ARGS_ENV]: " " }), []); +}); + +test("getCodexPassthroughArgs tokenizes a simple config override", () => { + assert.deepEqual(getCodexPassthroughArgs({ [CODEX_PLUGIN_ARGS_ENV]: "-c model_provider=my-provider" }), [ + "-c", + "model_provider=my-provider" + ]); +}); + +test("getCodexPassthroughArgs honors quotes and multiple flags", () => { + assert.deepEqual( + getCodexPassthroughArgs({ [CODEX_PLUGIN_ARGS_ENV]: `-c model_provider=my-provider -c 'base_url=https://x/v1'` }), + ["-c", "model_provider=my-provider", "-c", "base_url=https://x/v1"] + ); +}); + +test("getCodexPassthroughArgs keeps backslashes literal inside single quotes", () => { + assert.deepEqual(getCodexPassthroughArgs({ [CODEX_PLUGIN_ARGS_ENV]: `--add-dir 'C:\\work\\repo'` }), [ + "--add-dir", + "C:\\work\\repo" + ]); +}); + +test("splitRawArgumentString: single quotes preserve backslashes, double quotes still escape", () => { + assert.deepEqual(splitRawArgumentString(`'C:\\work\\repo'`), ["C:\\work\\repo"]); + assert.deepEqual(splitRawArgumentString(`"a\\"b"`), [`a"b`]); + assert.deepEqual(splitRawArgumentString(`foo\\ bar`), ["foo bar"]); +}); + +test("splitRawArgumentString: double quotes keep backslashes before ordinary chars (POSIX)", () => { + // `\w` and `\r` are not escapable inside double quotes, so the backslash stays. + assert.deepEqual(splitRawArgumentString(`"C:\\work\\repo"`), ["C:\\work\\repo"]); + // `\\` collapses to a single backslash; `\$` and `` \` `` drop the backslash. + assert.deepEqual(splitRawArgumentString(`"a\\\\b"`), ["a\\b"]); + assert.deepEqual(splitRawArgumentString(`"price \\$5"`), ["price $5"]); +}); + +test("getCodexPassthroughArgs keeps backslashes literal inside double quotes", () => { + assert.deepEqual(getCodexPassthroughArgs({ [CODEX_PLUGIN_ARGS_ENV]: `--add-dir "C:\\work\\repo"` }), [ + "--add-dir", + "C:\\work\\repo" + ]); +}); diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0..f7c8c7ce 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -247,12 +247,30 @@ function taskPayload(prompt, resume) { return "Handled the requested task.\\nTask prompt accepted."; } -const args = process.argv.slice(2); -if (args[0] === "--version") { +const rawArgs = process.argv.slice(2); +// Extract positionals, skipping any leading global flags (e.g. \`-c key=value\`) +// that the plugin may prepend from CODEX_PLUGIN_CC_ARGS. +function extractPositionals(tokens) { + const positionals = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token === "-c" || token === "--config") { + i++; + continue; + } + if (token.startsWith("-")) { + continue; + } + positionals.push(token); + } + return positionals; +} +const args = extractPositionals(rawArgs); +if (rawArgs.includes("--version")) { console.log("codex-cli test"); process.exit(0); } -if (args[0] === "app-server" && args[1] === "--help") { +if (args[0] === "app-server" && rawArgs.includes("--help")) { console.log("fake app-server help"); process.exit(0); } @@ -272,6 +290,7 @@ if (args[0] !== "app-server") { } const bootState = loadState(); bootState.appServerStarts = (bootState.appServerStarts || 0) + 1; +bootState.lastBootArgs = rawArgs; saveState(bootState); const rl = readline.createInterface({ input: process.stdin }); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835..b176ed42 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -157,6 +157,30 @@ test("review renders a no-findings result from app-server review/start", () => { assert.match(result.stdout, /No material issues found/); }); +test("CODEX_PLUGIN_CC_ARGS is passed through to the codex app-server launch", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.mkdirSync(path.join(repo, "src")); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); + run("git", ["add", "src/app.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); + + const result = run("node", [SCRIPT, "review"], { + cwd: repo, + env: { + ...buildEnv(binDir), + CODEX_PLUGIN_CC_ARGS: "-c model_provider=my-provider" + } + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.deepEqual(fakeState.lastBootArgs, ["-c", "model_provider=my-provider", "app-server"]); +}); + test("task runs when the active provider does not require OpenAI login", () => { const repo = makeTempDir(); const binDir = makeTempDir();