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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions plugins/codex/scripts/lib/app-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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
Expand Down
57 changes: 53 additions & 4 deletions plugins/codex/scripts/lib/args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

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 Preserve quoted backslashes in passthrough args

When users put a global argument whose value contains literal backslashes in CODEX_PLUGIN_CC_ARGS—for example the documented --add-dir path flag with a Windows path like --add-dir 'C:\work\repo'—this new env path calls splitRawArgumentString(), which treats \ as an escape even inside single quotes; the argv becomes ["--add-dir","C:workrepo"] before spawning Codex. That launches Codex with the wrong directory/config value, so the passthrough should use a parser that preserves quoted backslashes or otherwise handle Windows paths explicitly.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — fixed in f6f1ab7. splitRawArgumentString now handles single-quote context before the escape check, so single-quoted content is literal (POSIX semantics) and --add-dir 'C:\work\repo' survives intact. Double-quote escaping is unchanged. Added unit tests covering single-quote backslashes, double-quote escaping, and unquoted escapes.

}

// 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) {
Expand All @@ -86,20 +111,40 @@ 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;
}
continue;
}

if (character === "\\") {
escaping = true;
continue;
}

if (character === "'" || character === "\"") {
quote = character;
continue;
Expand All @@ -116,6 +161,10 @@ export function splitRawArgumentString(raw) {
current += character;
}

if (doubleQuoteEscaping) {
current += "\\";
}

if (escaping) {
current += "\\";
}
Expand Down
6 changes: 4 additions & 2 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions tests/args.test.mjs
Original file line number Diff line number Diff line change
@@ -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"
]);
});
25 changes: 22 additions & 3 deletions tests/fake-codex-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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 });
Expand Down
24 changes: 24 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down