fix(harness): an adapter exit 127 names the program that wasn't found (#1873) #1964

Merged
jason.woltje merged 2 commits from r127 into refactor 2026-11-15 03:29:32 +00:00
2 changed files with 133 additions and 4 deletions

No files matched your search

+50 -3
View File
@@ -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,56 @@ 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: [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
// 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. 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(/\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) && !/[A-Za-z0-9]{16}/.test(name) ? name : null;
}
// 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, TAIL);
const buf = Buffer.alloc(length);
readSync(fd, buf, 0, length, size - length);
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 {
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 +504,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)` });
+83 -1
View File
@@ -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,88 @@ 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);
// 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
// (even a synthetic success) must not cancel the model watchdog. Other adapters
// still need the stdout fallback when no streaming producer is installed.