From 08ba7101acb1f341e0fb98a578eb323e8b1ac44b Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 16 Jul 2026 19:10:22 +0200 Subject: [PATCH] fix(script-tool): force color off in captured script output (deterministic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script tool's subprocess stdout is CAPTURED — parsed by the loop and matched in tests — never shown live in a terminal, so ANSI colorization is corruption, not presentation. Bun colorizes console.log values (a numeric `403` becomes \x1b[33m403\x1b[0m) whenever it detects color support, so captured output differed between CI (no TTY, plain) and a local TTY (colored): script-tool's wrong-token test asserts a literal 'STATUS 403' and passed in CI but broke under the local pre-push hook. Set NO_COLOR=1 / FORCE_COLOR=0 in the script subprocess env so captured output is plain and stable everywhere. --- packages/core/src/loop/tools/script-tool.ts | 9 ++++ packages/core/tests/script-tool.test.ts | 47 +++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/core/src/loop/tools/script-tool.ts b/packages/core/src/loop/tools/script-tool.ts index 8f0ea5e9..9be841fc 100644 --- a/packages/core/src/loop/tools/script-tool.ts +++ b/packages/core/src/loop/tools/script-tool.ts @@ -313,6 +313,15 @@ export async function doScript( ctx.cwd, { ...process.env, + // The script's stdout is CAPTURED (parsed by the loop, matched in tests), + // never shown live in a terminal — so any ANSI colorization is corruption, + // not presentation. Bun colorizes console.log values (e.g. a numeric + // `403` → `\x1b[33m403\x1b[0m`) whenever it detects color support, which + // makes captured output non-deterministic across TTY/no-TTY (a literal + // "STATUS 403" match passes in CI but breaks under the local pre-push). + // Force color OFF so captured output is plain and stable everywhere. + NO_COLOR: "1", + FORCE_COLOR: "0", TSFORGE_RPC_URL: server.url, TSFORGE_RPC_TOKEN: server.token, }, diff --git a/packages/core/tests/script-tool.test.ts b/packages/core/tests/script-tool.test.ts index 07f66ed0..e355134e 100644 --- a/packages/core/tests/script-tool.test.ts +++ b/packages/core/tests/script-tool.test.ts @@ -435,6 +435,24 @@ test("the RPC server rejects a request with a wrong token", async () => { expect(calls).toHaveLength(0); }); +test("captured script output is PLAIN — no ANSI color on logged values", async () => { + // The script's stdout is captured (parsed by the loop, matched in tests), never + // shown live — so Bun's console.log colorization (a number `403` → + // `\x1b[33m403\x1b[0m` under a TTY) is corruption that made captured output + // differ CI-vs-local. runScript sets NO_COLOR/FORCE_COLOR so output is stable; + // this locks that in — a regression re-enabling color would fail here. + const events: ILoopEvent[] = []; + const code = 'console.log("VAL", 403, true, { a: 1 });'; + const out = await doScript({ code }, makeCtx({}, events), { + execute: recordingExecute([]), + }); + + // No ANSI escape sequence anywhere in the captured output. + expect(out).not.toContain("["); + // And the plain values survive verbatim (proves it's off, not stripped-after). + expect(out).toContain("VAL 403 true"); +}); + test("the RPC server refuses `script` (no recursion) and non-exposable tools", async () => { const events: ILoopEvent[] = []; const code = [ @@ -498,3 +516,32 @@ test("empty `code` is rejected without spawning anything", async () => { expect(out).toContain("must be a non-empty"); expect(calls).toHaveLength(0); }); + +test("captured script output is ANSI-free even when the parent env forces color", async () => { + // The subprocess stdout is CAPTURED (parsed by the loop, matched in tests), + // never shown live — so Bun colorizing a console.log'd value (a numeric 403 → + // \x1b[33m403\x1b[0m under a color-enabled parent) is corruption that made + // captured output differ between CI (plain) and a local TTY (colored). The tool + // sets NO_COLOR/FORCE_COLOR=0 in the child env; the captured text must be plain. + const events: ILoopEvent[] = []; + const savedForce = process.env.FORCE_COLOR; + + process.env.FORCE_COLOR = "1"; // simulate a color-enabled parent (local TTY) + + try { + const out = await doScript( + { code: "console.log(403);" }, + makeCtx({}, events), + { execute: recordingExecute([]) } + ); + + expect(out).not.toMatch(new RegExp(String.fromCharCode(27))); + expect(out).toContain("403"); + } finally { + if (savedForce === undefined) { + Reflect.deleteProperty(process.env, "FORCE_COLOR"); + } else { + process.env.FORCE_COLOR = savedForce; + } + } +});