diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs index 974038ea3..14441c52b 100644 --- a/packages/harness/src/runner.mjs +++ b/packages/harness/src/runner.mjs @@ -386,30 +386,39 @@ export function toolServer({ path, tools, client, local }) { // #1873 r127: exit 127 is the shell's "command not found". The turn's stderr then holds the // shell's own line for it, the last one nearest the exit: -// bash X: line N: CMD: No such file or directory | X: line N: [exec: ]CMD: [command ]not found +// bash X: line N: [exec: ]CMD: No such file or directory | [command ]not found // dash X: N: [exec: ]CMD: not found -// env /usr/bin/env: 'CMD': No such file or directory +// env [/usr/bin/]env: 'CMD': No such file or directory +// Only those shapes count, so another program's missing file (cat: F: No such file…) doesn't. // The answer is CMD's basename, which the person reads in the failure; never its host path, and -// nothing that isn't a plain program name. -const NOT_FOUND = /(?:^|: )(?:exec: )?['\u2018]?([^\s:'\u2019]+)['\u2019]?: (?:(?:command )?not found|No such file or directory)$/; +// nothing that isn't a plain program name. A run of 16 letters and digits looks like a key, not a +// program, so it stays in the run's private stderr. +const NOT_FOUND = [ + /(?:^|: )line \d+: (?:exec: )?([^\s:]+): (?:(?:command )?not found|No such file or directory)$/, + /: \d+: (?:exec: )?([^\s:]+): not found$/, + /^(?:\S*\/)?env: ['\u2018]([^\s:'\u2019]+)['\u2019]: No such file or directory$/, +]; export function missingCommand(text) { let name = null; - for (const line of String(text).split("\n")) { - const m = NOT_FOUND.exec(line); + for (const line of String(text).split(/\r?\n/)) { + const m = NOT_FOUND.map((re) => re.exec(line)).find(Boolean); if (m) name = basename(m[1]); } - return name && /^[A-Za-z0-9._+-]{1,64}$/.test(name) ? name : null; + return name && /^[A-Za-z0-9._+-]{1,64}$/.test(name) && !/[A-Za-z0-9]{16}/.test(name) ? name : null; } -// The last 16 KiB of a turn's stderr file, or "" when it can't be read. +// The last 16 KiB of a turn's stderr file from a line start, or "" when it can't be read. +const TAIL = 16384; function stderrTail(path) { let fd; try { fd = openSync(path, "r"); - const size = fstatSync(fd).size, length = Math.min(size, 16384); + const size = fstatSync(fd).size, length = Math.min(size, TAIL); const buf = Buffer.alloc(length); readSync(fd, buf, 0, length, size - length); - return buf.toString("utf8"); + const text = buf.toString("utf8"); + // A window that starts mid-file starts mid-line; that partial line is dropped. + return size > TAIL ? text.slice(text.indexOf("\n") + 1) : text; } catch { return ""; } finally { diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs index cd8e51b5f..e732e4cf4 100644 --- a/packages/harness/tests/runner.test.mjs +++ b/packages/harness/tests/runner.test.mjs @@ -354,6 +354,30 @@ test("r127 missingCommand reads the shells' not-found lines and gives a safe bas // A name that isn't a plain program name is left out, never echoed. assert.equal(missingCommand("a: 1: exec: $(rm -rf x): not found\n"), null); assert.equal(missingCommand(`a: 1: exec: ${"x".repeat(80)}: not found\n`), null); + // Review r127 P3-5: tokens without spaces reach the name check, and it refuses them. + assert.equal(missingCommand("a: 1: exec: $(id): not found\n"), null); + assert.equal(missingCommand("a: line 2: x;id: command not found\n"), null); + assert.equal(missingCommand("a: line 2: `id`: command not found\n"), null); + // P3-2: a long run of letters and digits looks like a key, not a program; it stays in stderr. + assert.equal(missingCommand("adapter.sh: line 3: sk-proj-abcDEF1234567890xyz: command not found\n"), null); + assert.equal(missingCommand("adapter.sh: line 3: 0123456789abcdef0123: command not found\n"), null); + // P3-1: only a shell's or env's own line counts; other programs' missing files don't. + assert.equal(missingCommand("cat: /nope/file: No such file or directory\n"), null); + assert.equal(missingCommand("w.sh: line 1: /opt/x/claude: No such file or directory\ncat: /run/state.json: No such file or directory\n"), "claude"); + // P3-4: CRLF lines read the same. + assert.equal(missingCommand("sh: line 1: pi: command not found\r\n"), "pi"); +}); + +test("r127 a not-found line cut by the 16 KiB stderr window is not read as a name", async t => { + const ctx = await setup(t, { session: { harness: 'pi', turnEnd: 'exit' } }); + ctx.inbox = []; + // 52 + 16351 + 1 bytes: the 16 KiB window starts inside "claude", at "aude: No such file…". + writeFileSync(join(ctx.dir, 'adapter.sh'), + `printf 'x: line 1: /opt/x/claude: No such file or directory\\n' >&2\nhead -c 16351 /dev/zero | tr '\\0' 'z' >&2\nprintf '\\n' >&2\nexit 127\n`); + const r = startRunner(ctx.runDir, ctx.cap); + t.after(async () => { r.child.kill('SIGTERM'); await r.exited; }); + const reply = await ask(ctx); + assert.equal(reply.body, "The turn failed: adapter exited 127"); }); // Claude's private stream reader owns model activity; final result stdout alone