From 61371adb1031dc399865ad5e860bc19e3a43eb39 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 14 Nov 2026 21:07:14 -0600 Subject: [PATCH 1/2] fix(harness): an adapter exit 127 names the program that wasn't found (#1873, lane r127) A claude-code turn on a Console started without MOSAIC_CLAUDE_BIN failed with a bare "adapter exited 127". The sandbox binds /usr, so the wrapper /usr/bin/claude is visible, but its exec target /opt/claude-code/bin/claude isn't bound, and the shell exits 127. On exit 127 the runner now reads the tail of the turn's stderr, finds the shell's "not found" line (bash, dash, env) and adds the program's basename: "adapter exited 127 (command not found: claude)". It never shows a host path or anything that isn't a plain program name; a 127 without such a line keeps the bare reason. The deploy side (MOSAIC_CLAUDE_BIN on the Console unit) is a Lead-owned script change, written up in the lane report. --- packages/harness/src/runner.mjs | 44 +++++++++++++++++-- packages/harness/tests/runner.test.mjs | 60 +++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs index 8db1615b9..974038ea3 100644 --- a/packages/harness/src/runner.mjs +++ b/packages/harness/src/runner.mjs @@ -36,9 +36,9 @@ import { refusalGuidance } from "../../bus/src/refusals.mjs"; import { spawn } from "node:child_process"; import { setTimeout as delay } from "node:timers/promises"; -import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, closeSync } from "node:fs"; +import { chmodSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, rmSync, closeSync } from "node:fs"; import { createServer, connect } from "node:net"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Client } from "../../bus/src/client.mjs"; import { LENGTH_FAILURE_NOTICE_PREFIX } from "./failure-notice.mjs"; @@ -384,13 +384,47 @@ 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 +// dash X: N: [exec: ]CMD: not found +// env /usr/bin/env: 'CMD': No such file or directory +// 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)$/; +export function missingCommand(text) { + let name = null; + for (const line of String(text).split("\n")) { + const m = NOT_FOUND.exec(line); + if (m) name = basename(m[1]); + } + return name && /^[A-Za-z0-9._+-]{1,64}$/.test(name) ? name : null; +} + +// The last 16 KiB of a turn's stderr file, or "" when it can't be read. +function stderrTail(path) { + let fd; + try { + fd = openSync(path, "r"); + const size = fstatSync(fd).size, length = Math.min(size, 16384); + const buf = Buffer.alloc(length); + readSync(fd, buf, 0, length, size - length); + return buf.toString("utf8"); + } catch { + return ""; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + // Run one turn. Resolves { ok, text, reason }. function runTurn({ session, request, n, env, onChild }) { const marker = session.turnMarker; rmSync(marker, { force: true }); const turns = join(session.runDir, "turns"); mkdirSync(turns, { recursive: true, mode: 0o700 }); - const errFd = openSync(join(turns, `${String(n).padStart(4, "0")}.stderr`), "wx", 0o600); + const errPath = join(turns, `${String(n).padStart(4, "0")}.stderr`); + const errFd = openSync(errPath, "wx", 0o600); // Adapters are 0644 in the repository (the image sets the mode), so the // host runs them through sh. const child = spawn("/bin/sh", [session.adapter], { @@ -461,6 +495,10 @@ function runTurn({ session, request, n, env, onChild }) { if (status?.stopReason === "length" && status.hasText === false && status.hasToolCall === false) return resolve({ ok: false, emptyLength: true, reason: "output limit reached while thinking before any answer or tool call" }); } + if (code === 127) { + const missing = missingCommand(stderrTail(errPath)); + return resolve({ ok: false, reason: `adapter exited 127${missing ? ` (command not found: ${missing})` : ""}` }); + } if (code !== 0) return resolve({ ok: false, reason: signal ? `adapter killed by ${signal}` : `adapter exited ${code}` }); // A "marker" harness (row 65: the module's turnEnd()) writes the marker when its turn ends. if (session.turnEnd === "marker" && !existsSync(marker)) return resolve({ ok: false, reason: `${session.harness} exited before the turn ended (no turn marker)` }); diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs index 466f97676..cd8e51b5f 100644 --- a/packages/harness/tests/runner.test.mjs +++ b/packages/harness/tests/runner.test.mjs @@ -13,7 +13,7 @@ import { join } from "node:path"; import { Store } from "../../bus/src/store.mjs"; import { Broker, BusError } from "../../bus/src/broker.mjs"; import { serve } from "../../bus/src/server.mjs"; -import { EXIT, founderCheck, landingRequest, stateBlock, turnRequest, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs"; +import { EXIT, founderCheck, landingRequest, missingCommand, stateBlock, turnRequest, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs"; import { typedTools } from "../src/tools.mjs"; import { resolvedFor, scratch } from "./helpers.mjs"; @@ -298,6 +298,64 @@ test("SIGKILL of the model child keeps base failure receipts through the real ad assert.equal(records[1].body, records[0].body); }); +// #1873 r127: on the dev instance /usr/bin/claude is a wrapper that execs a binary outside /usr. +// The sandbox binds /usr only, so the wrapper starts and its target is missing: the shell exits +// 127 and the person read only "adapter exited 127". The reason now names the missing program. +test("r127 an adapter exit 127 names the program that wasn't found, through the real claude adapter", async t => { + const ctx = await setup(t, {session:{harness:"claude-code", turnEnd:"exit", adapter:new URL("../../../adapters/claude/adapter.sh", import.meta.url).pathname}}); + const sessionFile = join(ctx.runDir, "session.json"); + const session = JSON.parse(readFileSync(sessionFile, "utf8")); + const wrapper = join(ctx.dir, "bin", "claude"); + mkdirSync(join(ctx.dir, "bin")); + writeFileSync(wrapper, `#!/bin/sh\nexport DISABLE_UPDATES=1\nexec ${join(ctx.dir, "not-mounted", "claude-code", "bin", "claude")} "$@"\n`, {mode:0o755}); + session.harnessEnv = {MOSAIC_CLAUDE_BIN:wrapper, MOSAIC_THINKING:"low", MOSAIC_CLAUDE_SETTINGS:session.bundle.policy, MOSAIC_CLAUDE_MCP_CONFIG:session.bundle.tools}; + writeFileSync(sessionFile, JSON.stringify(session)); + ctx.inbox = []; + const r = startRunner(ctx.runDir, ctx.cap); + t.after(() => r.child.kill("SIGKILL")); + const sent = ctx.call(ctx.pm, "message.send", {to:"coder", body:"hello", class:"REQUEST"}); + const [reply] = await until(replies(ctx)); + await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id)); + assert.equal(reply.body, "The turn failed: adapter exited 127 (command not found: claude)"); + assert.doesNotMatch(reply.body, /not-mounted|\/bin\//, "the reason names the program, never its host path"); + assert.match(r.stderr(), /failed: adapter exited 127 \(command not found: claude\)/); + r.child.kill("SIGTERM"); await r.exited; +}); + +test("r127 an adapter whose own command is missing names it; a 127 with no such line keeps the bare reason", async t => { + const ctx = await setup(t); + const adapter = join(ctx.dir, "adapter.sh"); + writeFileSync(adapter, `case "$MOSAIC_REQUEST" in *QUIET*) echo "nothing useful" >&2; exit 127 ;; esac\nexec mosaic-r127-no-such-harness -p "$MOSAIC_REQUEST"\n`); + ctx.inbox = []; + const r = startRunner(ctx.runDir, ctx.cap); + t.after(() => r.child.kill("SIGKILL")); + ctx.call(ctx.pm, "message.send", {to:"coder", body:"one"}); + ctx.call(ctx.pm, "message.send", {to:"coder", body:"QUIET"}); + await until(() => replies(ctx)() && ctx.inbox.length >= 2); + assert.deepEqual(ctx.inbox.map(m => m.body), [ + "The turn failed: adapter exited 127 (command not found: mosaic-r127-no-such-harness)", + "The turn failed: adapter exited 127", + ]); + r.child.kill("SIGTERM"); await r.exited; +}); + +test("r127 missingCommand reads the shells' not-found lines and gives a safe basename only", () => { + assert.equal(missingCommand("/usr/bin/claude: line 4: /opt/claude-code/bin/claude: No such file or directory\nclaude adapter: invalid or incomplete model stream\n"), "claude"); + assert.equal(missingCommand("/usr/bin/claude: 4: exec: /opt/claude-code/bin/claude: not found\n"), "claude"); + assert.equal(missingCommand("/x/adapters/pi/adapter.sh: 140: exec: pi: not found\n"), "pi"); + assert.equal(missingCommand("/x/adapter.sh: line 9: exec: pi: not found\n"), "pi"); + assert.equal(missingCommand("sh: line 1: pi: command not found\n"), "pi"); + assert.equal(missingCommand("/usr/bin/env: 'node': No such file or directory\n"), "node"); + assert.equal(missingCommand("/usr/bin/env: ‘node’: No such file or directory\n"), "node"); + // The last such line is the one nearest the exit. + assert.equal(missingCommand("a: 1: exec: first: not found\nb: 2: exec: second: not found\n"), "second"); + assert.equal(missingCommand(""), null); + assert.equal(missingCommand("error: model refused\n"), null); + // 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); +}); + // Claude's private stream reader owns model activity; final result stdout alone // (even a synthetic success) must not cancel the model watchdog. Other adapters // still need the stdout fallback when no streaming producer is installed. -- 2.54.0 From 387f73a7a4eab0d3b035facfa3ffc56e00884042 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 14 Nov 2026 21:19:02 -0600 Subject: [PATCH 2/2] fix(harness): r127 review round, only a shell's own not-found line names a program (#1873, lane r127) Review P3-1: a "No such file or directory" line from another program (cat, ls) no longer counts. Only bash's "line N:", dash's "N:" and env's quoted form do. P3-2: a name with a run of 16 letters and digits looks like a key, so it stays in the run's private stderr instead of the reply. P3-3: a 16 KiB stderr window that starts mid-file drops its partial first line, so a cut name ("aude") isn't reported. P3-4: CRLF lines read the same as LF lines. P3-5: tests for the name check (tokens without spaces, key-shaped names), for a cat line, and for the window boundary through the runner. --- packages/harness/src/runner.mjs | 29 +++++++++++++++++--------- packages/harness/tests/runner.test.mjs | 24 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 10 deletions(-) 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 -- 2.54.0