fix(harness): an adapter exit 127 names the program that wasn't found (#1873) #1964
No files matched your search
@@ -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)` });
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user