From da5b8819488576e863898db17c8b7692b7e5c7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=B4=E4=BA=91=E6=9E=AB?= Date: Thu, 2 Jul 2026 19:16:34 +0800 Subject: [PATCH 1/2] feat: forward CODEX_PLUGIN_CC_ARGS to codex launches Add a CODEX_PLUGIN_CC_ARGS environment variable. When set, its value is tokenized (shell-style) and prepended to every codex invocation the plugin makes: the app-server runtime spawn and the availability checks. This lets users force codex global overrides (e.g. a custom provider via `-c model_provider=...` or `--profile`) without editing config.toml, and without a per-command flag. Previously only --model/--effort were forwarded and the app-server was always spawned as `codex app-server`. - args.mjs: getCodexPassthroughArgs(env) + CODEX_PLUGIN_ARGS_ENV - app-server.mjs: prepend args to the `codex app-server` spawn - codex.mjs: prepend args to `--version` / `app-server --help` checks - fake codex fixture: tolerate leading global flags, record boot argv - tests: unit tests for the parser + e2e passthrough assertion - README: document the variable Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 21 +++++++++++++++++++ plugins/codex/scripts/lib/app-server.mjs | 7 +++++-- plugins/codex/scripts/lib/args.mjs | 19 +++++++++++++++++ plugins/codex/scripts/lib/codex.mjs | 6 ++++-- tests/args.test.mjs | 26 ++++++++++++++++++++++++ tests/fake-codex-fixture.mjs | 25 ++++++++++++++++++++--- tests/runtime.test.mjs | 24 ++++++++++++++++++++++ 7 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 tests/args.test.mjs 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..619090b2 100644 --- a/plugins/codex/scripts/lib/args.mjs +++ b/plugins/codex/scripts/lib/args.mjs @@ -73,6 +73,25 @@ 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); +} + export function splitRawArgumentString(raw) { const tokens = []; let 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..eab734ff --- /dev/null +++ b/tests/args.test.mjs @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { CODEX_PLUGIN_ARGS_ENV, getCodexPassthroughArgs } 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"] + ); +}); 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(); From 2f75ad1621af9ed446bba3890b591be01767d10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=B4=E4=BA=91=E6=9E=AB?= Date: Fri, 3 Jul 2026 10:29:17 +0800 Subject: [PATCH 2/2] fix: preserve quoted backslashes in passthrough arg parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit splitRawArgumentString treated `\` as an escape regardless of quote context, so CODEX_PLUGIN_CC_ARGS values with backslashes were mangled — e.g. `--add-dir 'C:\work\repo'` and `--add-dir "C:\work\repo"` both became `C:workrepo`. Apply POSIX quoting semantics: - single quotes: everything literal, including backslashes - double quotes: backslash only escapes " \ $ ` ; before any other char it stays literal (so `"a\"b"` still yields `a"b`) Adds unit tests for single/double-quote backslash handling. Addresses the Codex review comments on #419. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/codex/scripts/lib/args.mjs | 38 ++++++++++++++++++++++++++---- tests/args.test.mjs | 34 +++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/plugins/codex/scripts/lib/args.mjs b/plugins/codex/scripts/lib/args.mjs index 619090b2..e305e219 100644 --- a/plugins/codex/scripts/lib/args.mjs +++ b/plugins/codex/scripts/lib/args.mjs @@ -92,11 +92,17 @@ export function getCodexPassthroughArgs(env = process.env) { 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) { @@ -105,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; @@ -119,6 +140,11 @@ export function splitRawArgumentString(raw) { continue; } + if (character === "\\") { + escaping = true; + continue; + } + if (character === "'" || character === "\"") { quote = character; continue; @@ -135,6 +161,10 @@ export function splitRawArgumentString(raw) { current += character; } + if (doubleQuoteEscaping) { + current += "\\"; + } + if (escaping) { current += "\\"; } diff --git a/tests/args.test.mjs b/tests/args.test.mjs index eab734ff..8054123c 100644 --- a/tests/args.test.mjs +++ b/tests/args.test.mjs @@ -1,7 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { CODEX_PLUGIN_ARGS_ENV, getCodexPassthroughArgs } from "../plugins/codex/scripts/lib/args.mjs"; +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({}), []); @@ -24,3 +28,31 @@ test("getCodexPassthroughArgs honors quotes and multiple flags", () => { ["-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" + ]); +});