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.