Skip to content

Commit 93774b5

Browse files
authored
fix: preserve tty when forwarding codex
Preserve inherited stdout/stderr for terminal-attached forwarded Codex runs while keeping output capture for non-TTY fallback and retry handling. Fixes #436
1 parent 326c054 commit 93774b5

2 files changed

Lines changed: 127 additions & 18 deletions

File tree

scripts/codex.js

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -253,11 +253,30 @@ function replaceRequestedModel(args, nextModel) {
253253
return nextArgs;
254254
}
255255

256-
function forwardToRealCodexOnce(codexBin, args, env = process.env, cleanup) {
256+
function shouldCaptureForwardedCodexOutput(env = process.env) {
257+
const override = (env.CODEX_MULTI_AUTH_CAPTURE_FORWARD_OUTPUT ?? "").trim();
258+
if (override === "1") {
259+
return true;
260+
}
261+
if (override === "0") {
262+
return false;
263+
}
264+
// Windows child processes can report undefined isTTY; treat that as non-TTY so retry capture remains available.
265+
return process.stdout.isTTY !== true || process.stderr.isTTY !== true;
266+
}
267+
268+
function forwardToRealCodexOnce(
269+
codexBin,
270+
args,
271+
env = process.env,
272+
cleanup,
273+
options = {},
274+
) {
257275
return new Promise((resolve) => {
258276
let settled = false;
259277
let stdout = "";
260278
let stderr = "";
279+
const captureOutput = options.captureOutput === true;
261280
const finalize = (exitCode) => {
262281
if (settled) {
263282
return;
@@ -270,33 +289,46 @@ function forwardToRealCodexOnce(codexBin, args, env = process.env, cleanup) {
270289
}
271290
resolve({
272291
exitCode,
292+
// No-capture output stays empty by design so retry parsing cannot
293+
// reintroduce pipes that break terminal passthrough.
273294
output: `${stdout}\n${stderr}`.trim(),
274295
});
275296
};
276297

277298
const command = codexBin.launchWithNode ? process.execPath : codexBin.path;
278299
const commandArgs = codexBin.launchWithNode ? [codexBin.path, ...args] : args;
279-
const child = spawn(command, commandArgs, {
280-
stdio: ["inherit", "pipe", "pipe"],
281-
env,
282-
});
283-
284-
child.stdout?.on("data", (chunk) => {
285-
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
286-
stdout += text;
287-
process.stdout.write(chunk);
288-
});
289-
child.stderr?.on("data", (chunk) => {
290-
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
291-
stderr += text;
292-
process.stderr.write(chunk);
293-
});
294-
295-
child.once("error", (error) => {
300+
let child;
301+
const failLaunch = (error) => {
296302
const message = `Failed to launch real Codex CLI: ${String(error)}`;
297303
stderr += `${stderr ? "\n" : ""}${message}`;
298304
console.error(message);
299305
finalize(1);
306+
};
307+
try {
308+
child = spawn(command, commandArgs, {
309+
stdio: captureOutput ? ["inherit", "pipe", "pipe"] : "inherit",
310+
env,
311+
});
312+
} catch (error) {
313+
failLaunch(error);
314+
return;
315+
}
316+
317+
if (captureOutput) {
318+
child.stdout?.on("data", (chunk) => {
319+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
320+
stdout += text;
321+
process.stdout.write(chunk);
322+
});
323+
child.stderr?.on("data", (chunk) => {
324+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
325+
stderr += text;
326+
process.stderr.write(chunk);
327+
});
328+
}
329+
330+
child.once("error", (error) => {
331+
failLaunch(error);
300332
});
301333

302334
child.once("close", (code, signal) => {
@@ -333,6 +365,9 @@ async function forwardToRealCodex(codexBin, rawArgs, baseEnv = process.env) {
333365
compatibility.args,
334366
compatibility.env,
335367
compatibility.cleanup,
368+
{
369+
captureOutput: shouldCaptureForwardedCodexOutput(compatibility.env),
370+
},
336371
);
337372
lastExitCode = result.exitCode;
338373
if (result.exitCode === 0) {

test/codex-bin-wrapper.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,6 +1231,80 @@ describe("codex bin wrapper", () => {
12311231
);
12321232
});
12331233

1234+
it("honors explicit capture output override for unsupported-model retries", () => {
1235+
const fixtureRoot = createWrapperFixture();
1236+
const stateDir = join(fixtureRoot, "retry-state-capture-override");
1237+
mkdirSync(stateDir, { recursive: true });
1238+
const fakeBin = createCustomFakeCodexBin(fixtureRoot, [
1239+
"const fs = require('node:fs');",
1240+
"const path = require('node:path');",
1241+
"const counterPath = path.join(process.env.CODEX_MULTI_AUTH_TEST_STATE_DIR, 'attempt.txt');",
1242+
"const attempt = fs.existsSync(counterPath) ? Number(fs.readFileSync(counterPath, 'utf8')) : 0;",
1243+
"fs.writeFileSync(counterPath, String(attempt + 1), 'utf8');",
1244+
"const args = process.argv.slice(2);",
1245+
"const modelIndex = args.indexOf('--model');",
1246+
"const requestedModel = modelIndex >= 0 ? args[modelIndex + 1] : 'unknown-model';",
1247+
"if (attempt === 0) {",
1248+
` console.error("ERROR: {\\\"type\\\":\\\"error\\\",\\\"status\\\":400,\\\"error\\\":{\\\"type\\\":\\\"invalid_request_error\\\",\\\"message\\\":\\\"The '" + requestedModel + "' model is not supported when using Codex with a ChatGPT account.\\\"}}");`,
1249+
" process.exit(1);",
1250+
"}",
1251+
"console.log(`FORWARDED:${args.join(' ')}`);",
1252+
"process.exit(0);",
1253+
]);
1254+
const originalHome = join(fixtureRoot, "codex-home");
1255+
mkdirSync(originalHome, { recursive: true });
1256+
writeFileSync(join(originalHome, "auth.json"), "{}\n", "utf8");
1257+
writeFileSync(join(originalHome, "config.toml"), "", "utf8");
1258+
1259+
const result = runWrapper(fixtureRoot, ["exec", "status", "--model", "gpt-5.5"], {
1260+
CODEX_MULTI_AUTH_CAPTURE_FORWARD_OUTPUT: "1",
1261+
CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin,
1262+
CODEX_MULTI_AUTH_TEST_STATE_DIR: stateDir,
1263+
CODEX_HOME: originalHome,
1264+
});
1265+
1266+
const output = combinedOutput(result);
1267+
expect(result.status).toBe(0);
1268+
expect(readFileSync(join(stateDir, "attempt.txt"), "utf8")).toBe("2");
1269+
expect(output).toContain("Retrying with gpt-5.4");
1270+
expect(output).toContain(
1271+
'FORWARDED:exec status --model gpt-5.4 -c cli_auth_credentials_store="file"',
1272+
);
1273+
});
1274+
1275+
it("can forward without capturing child stdio for terminal-sensitive Codex runs", () => {
1276+
const fixtureRoot = createWrapperFixture();
1277+
const stateDir = join(fixtureRoot, "no-capture-state");
1278+
mkdirSync(stateDir, { recursive: true });
1279+
const originalHome = join(fixtureRoot, "codex-home");
1280+
mkdirSync(originalHome, { recursive: true });
1281+
writeFileSync(join(originalHome, "config.toml"), "", "utf8");
1282+
const fakeBin = createCustomFakeCodexBin(fixtureRoot, [
1283+
"const fs = require('node:fs');",
1284+
"const path = require('node:path');",
1285+
"const counterPath = path.join(process.env.CODEX_MULTI_AUTH_TEST_STATE_DIR, 'attempt.txt');",
1286+
"const attempt = fs.existsSync(counterPath) ? Number(fs.readFileSync(counterPath, 'utf8')) : 0;",
1287+
"fs.writeFileSync(counterPath, String(attempt + 1), 'utf8');",
1288+
"console.error(\"The 'gpt-5.5' model is not supported when using Codex with a ChatGPT account.\");",
1289+
"process.exit(1);",
1290+
]);
1291+
1292+
const result = runWrapper(fixtureRoot, ["exec", "status", "--model", "gpt-5.5"], {
1293+
CODEX_MULTI_AUTH_CAPTURE_FORWARD_OUTPUT: "0",
1294+
CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin,
1295+
CODEX_MULTI_AUTH_TEST_STATE_DIR: stateDir,
1296+
CODEX_HOME: originalHome,
1297+
});
1298+
1299+
const output = combinedOutput(result);
1300+
expect(result.status).toBe(1);
1301+
expect(readFileSync(join(stateDir, "attempt.txt"), "utf8")).toBe("1");
1302+
expect(output).toContain(
1303+
"The 'gpt-5.5' model is not supported when using Codex with a ChatGPT account.",
1304+
);
1305+
expect(output).not.toContain("Retrying with gpt-5.4");
1306+
});
1307+
12341308
it("retries GPT-5.5 after access-denied style model errors", () => {
12351309
const fixtureRoot = createWrapperFixture();
12361310
const stateDir = join(fixtureRoot, "retry-state-access");

0 commit comments

Comments
 (0)