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.
This commit is contained in:
jason.woltje committed 2026-11-14 21:07:14 -06:00
1 parent b913f0f4c8
commit 61371adb10
2 files changed
+100 -4

No files matched your search

+41 -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,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)` });
+59 -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,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.