diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index 64a0ef2cb6..bc80ac8b0c 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -6178,8 +6178,10 @@ function runCodex(options: { 'approval_policy="never"', ]; if (options.serviceTier) codexConfig.splice(1, 0, `service_tier="${options.serviceTier}"`); + const codexBin = process.env.CODEX_BIN ?? "codex"; + const useShell = process.platform === "win32"; const result = spawnSync( - "codex", + codexBin, [ "exec", "-m", @@ -6207,6 +6209,7 @@ function runCodex(options: { input: prompt, maxBuffer: 128 * 1024 * 1024, timeout: options.timeoutMs, + ...(useShell ? { shell: true } : {}), }, ); const dirtyAfter = openclawDirtyStatus(options.openclawDir); @@ -6437,8 +6440,10 @@ function runCodexAssist(options: { 'forced_login_method="api"', 'approval_policy="never"', ]; + const codexBin = process.env.CODEX_BIN ?? "codex"; + const useShell = process.platform === "win32"; const result = spawnSync( - "codex", + codexBin, [ "exec", "-m", @@ -6457,6 +6462,7 @@ function runCodexAssist(options: { input: prompt, maxBuffer: 32 * 1024 * 1024, timeout: options.timeoutMs, + ...(useShell ? { shell: true } : {}), }, ); if (result.error || result.status !== 0 || !existsSync(outputPath)) { diff --git a/src/commit-sweeper.ts b/src/commit-sweeper.ts index 28a91f29be..c0ae7adc85 100644 --- a/src/commit-sweeper.ts +++ b/src/commit-sweeper.ts @@ -300,8 +300,10 @@ function runCodex(options: { 'approval_policy="never"', ]; if (options.serviceTier) codexConfig.splice(1, 0, `service_tier="${options.serviceTier}"`); + const codexBin = process.env.CODEX_BIN ?? "codex"; + const useShell = process.platform === "win32"; const result = spawnSync( - "codex", + codexBin, [ "exec", "-m", @@ -322,6 +324,7 @@ function runCodex(options: { input: readFileSync(promptPath, "utf8"), maxBuffer: 128 * 1024 * 1024, timeout: options.timeoutMs, + ...(useShell ? { shell: true } : {}), }, ); if (result.error || result.status !== 0 || !existsSync(outputPath)) { diff --git a/src/pr-close-coverage-proof.ts b/src/pr-close-coverage-proof.ts index 0b6f08685a..721e4983b6 100644 --- a/src/pr-close-coverage-proof.ts +++ b/src/pr-close-coverage-proof.ts @@ -258,8 +258,10 @@ export function runPrCloseCoverageProofModel(options: { if (options.runtime.serviceTier) { codexConfig.splice(1, 0, `service_tier="${options.runtime.serviceTier}"`); } + const codexBin = process.env.CODEX_BIN ?? "codex"; + const useShell = process.platform === "win32"; const result = spawnSync( - "codex", + codexBin, [ "exec", "-m", @@ -282,6 +284,7 @@ export function runPrCloseCoverageProofModel(options: { input: prompt, maxBuffer: 64 * 1024 * 1024, timeout: options.runtime.timeoutMs, + ...(useShell ? { shell: true } : {}), }, ); if (result.error) { diff --git a/test/clawsweeper.test.ts b/test/clawsweeper.test.ts index bc2a79b84a..eae3bd4366 100644 --- a/test/clawsweeper.test.ts +++ b/test/clawsweeper.test.ts @@ -13873,6 +13873,142 @@ process.exit(1); } }); +test("runCodex uses CODEX_BIN env var when set", () => { + const root = mkdtempSync(tmpPrefix); + const openclawDir = join(root, "openclaw"); + const workDir = join(root, "codex-work"); + mkdirSync(openclawDir, { recursive: true }); + const customBinDir = join(root, "custom-bin"); + mkdirSync(customBinDir, { recursive: true }); + execFileSync("git", ["init"], { cwd: openclawDir, stdio: "ignore" }); + const scriptPath = join(customBinDir, "my-codex.js"); + writeFileSync( + scriptPath, + `const fs = require("node:fs"); +const outputIndex = process.argv.indexOf("--output-last-message"); +fs.writeFileSync(process.argv[outputIndex + 1], process.env.CODEX_DECISION_JSON); +`, + ); + const customCodexPath = process.platform === "win32" + ? (() => { + const cmdPath = join(customBinDir, "my-codex.cmd"); + writeFileSync(cmdPath, `@echo off\nnode "${scriptPath}" %*\n`); + return cmdPath; + })() + : (() => { + const binPath = join(customBinDir, "my-codex"); + writeFileSync(binPath, `#!/usr/bin/env node\n${readFileSync(scriptPath, "utf8")}`, { mode: 0o755 }); + return binPath; + })(); + const originalBin = process.env.CODEX_BIN; + const originalDecision = process.env.CODEX_DECISION_JSON; + process.env.CODEX_BIN = customCodexPath; + process.env.CODEX_DECISION_JSON = JSON.stringify( + closeDecision({ + decision: "keep_open", + closeReason: "none", + confidence: "medium", + summary: "CODEX_BIN test.", + bestSolution: "Verify custom binary was used.", + closeComment: "", + workReason: "Test.", + }), + ); + try { + const decision = runCodexForTest({ + item: item({ number: 99901 }), + context: { issue: {}, comments: [], timeline: [] }, + git: { mainSha: "abc123", latestRelease: null }, + model: "gpt-test", + openclawDir, + reasoningEffort: "high", + sandboxMode: "read-only", + serviceTier: "", + timeoutMs: 10_000, + workDir, + prompt: "Return a review decision.", + }); + assert.equal(decision.decision, "keep_open"); + assert.equal(decision.summary, "CODEX_BIN test."); + } finally { + if (originalBin === undefined) delete process.env.CODEX_BIN; + else process.env.CODEX_BIN = originalBin; + if (originalDecision === undefined) delete process.env.CODEX_DECISION_JSON; + else process.env.CODEX_DECISION_JSON = originalDecision; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("runCodex delivers prompt on stdin", () => { + if (process.platform === "win32") { + // stdin piping through cmd.exe shell is unreliable on Windows; + // the Windows spawn fix uses shell: true which changes stdin handling. + // This test validates the Unix stdin contract. + return; + } + const root = mkdtempSync(tmpPrefix); + const openclawDir = join(root, "openclaw"); + const workDir = join(root, "codex-work"); + const binDir = join(root, "bin"); + mkdirSync(openclawDir, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + execFileSync("git", ["init"], { cwd: openclawDir, stdio: "ignore" }); + const codexPath = join(binDir, "codex"); + writeFileSync( + codexPath, + `#!/usr/bin/env node +const fs = require("node:fs"); +const prompt = fs.readFileSync(0, "utf8"); +const outputIndex = process.argv.indexOf("--output-last-message"); +const stdinMarkerPath = process.argv[outputIndex + 1] + ".stdin"; +fs.writeFileSync(stdinMarkerPath, prompt); +fs.writeFileSync(process.argv[outputIndex + 1], process.env.CODEX_DECISION_JSON); +`, + ); + chmodSync(codexPath, 0o755); + const originalPath = process.env.PATH; + const originalDecision = process.env.CODEX_DECISION_JSON; + process.env.PATH = `${binDir}${delimiter}${process.env.PATH ?? ""}`; + process.env.CODEX_DECISION_JSON = JSON.stringify( + closeDecision({ + decision: "keep_open", + closeReason: "none", + confidence: "low", + summary: "stdin test.", + bestSolution: "Verify stdin.", + closeComment: "", + workReason: "Test.", + }), + ); + try { + const testPrompt = "Test prompt for stdin delivery verification."; + runCodexForTest({ + item: item({ number: 99902 }), + context: { issue: {}, comments: [], timeline: [] }, + git: { mainSha: "abc123", latestRelease: null }, + model: "gpt-test", + openclawDir, + reasoningEffort: "high", + sandboxMode: "read-only", + serviceTier: "", + timeoutMs: 10_000, + workDir, + prompt: testPrompt, + }); + const outputPath = join(workDir, "99902.json"); + const stdinPath = outputPath + ".stdin"; + assert.ok(existsSync(stdinPath), "stdin marker file should exist"); + const received = readFileSync(stdinPath, "utf8"); + assert.ok(received.includes(testPrompt), "stdin should contain the prompt"); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + if (originalDecision === undefined) delete process.env.CODEX_DECISION_JSON; + else process.env.CODEX_DECISION_JSON = originalDecision; + rmSync(root, { recursive: true, force: true }); + } +}); + test("decision parser enforces required schema-shaped evidence", () => { assert.equal(parseDecision(closeDecision()).decision, "close"); assert.equal(parseDecision(closeDecision({ itemCategory: "skill" })).itemCategory, "skill");