diff --git a/agents/rocko/work/queue-72/mutants.sh b/agents/rocko/work/queue-72/mutants.sh new file mode 100755 index 00000000..ebd767b7 --- /dev/null +++ b/agents/rocko/work/queue-72/mutants.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Row 72 (#1566): rerun the six mutants the row 68 reviewers named against a +# tree with the candidate tests. Usage: mutants.sh . Each mutant edits +# one line of bus-service.sh or adapter.sh, runs the pin tests, and restores +# the file from git. Prints "killed" or "SURVIVED" per mutant; exit 1 if any +# mutant survives, a sed doesn't change its file, or the unmutated run fails. +set -euo pipefail +TREE="$(cd "${1:?usage: mutants.sh }" && pwd)" +cd "$TREE" +BS=scripts/bus-service.sh +AD=adapters/claude/adapter.sh +PATTERN='directory pin|symlink default|pin with a space|pins the claude|MOSAIC_CLAUDE_BIN wins' +TESTS=(packages/cli/tests/host.test.mjs packages/harness/tests/claude-session.test.mjs) +run_tests() { node --test --test-name-pattern="$PATTERN" "${TESTS[@]}" >"$1" 2>&1; } + +# name | file | sed expression +MUTANTS=( + 'bs-no-f|'"$BS"'|s/\[ -f "\$bin" \] \&\& \[ -x "\$bin" \]/[ -e "$bin" ] \&\& [ -x "$bin" ]/' + 'bs-x-only|'"$BS"'|s/\[ -f "\$bin" \] \&\& \[ -x "\$bin" \]/[ -x "$bin" ]/' + 'ad-no-f|'"$AD"'|s/\[ -f "\$MOSAIC_CLAUDE_BIN" \] \&\& \[ -x "\$MOSAIC_CLAUDE_BIN" \]/[ -e "$MOSAIC_CLAUDE_BIN" ] \&\& [ -x "$MOSAIC_CLAUDE_BIN" ]/' + 'ad-x-only|'"$AD"'|s/\[ -f "\$MOSAIC_CLAUDE_BIN" \] \&\& \[ -x "\$MOSAIC_CLAUDE_BIN" \]/[ -x "$MOSAIC_CLAUDE_BIN" ]/' + 'bs-resolve-default|'"$BS"'|s/^ bin="\$(command -v claude || true)"$/ bin="$(command -v claude || true)"; [ -z "$bin" ] || bin="$(readlink -f "$bin")"/' + 'ad-unquoted-run|'"$AD"'|s/^"\$CLAUDE" -p /$CLAUDE -p /' +) + +OUT="${OUT:-$TREE/.mutants}" +mkdir -p "$OUT" +git diff --quiet -- "$BS" "$AD" || { echo "refusing: $BS or $AD already modified" >&2; exit 1; } +run_tests "$OUT/baseline.log" || { echo "baseline run failed: $OUT/baseline.log" >&2; exit 1; } +echo "baseline: pass" +status=0 +for m in "${MUTANTS[@]}"; do + IFS='|' read -r name file expr <<<"$m" + sed -i -e "$expr" "$file" + if git diff --quiet -- "$file"; then + echo "$name: NOT APPLIED (sed matched nothing)"; status=1; continue + fi + git diff -- "$file" >"$OUT/$name.diff" + if run_tests "$OUT/$name.log"; then + echo "$name: SURVIVED"; status=1 + else + echo "$name: killed ($(grep -E '^✖' "$OUT/$name.log" | sed 's/ ([0-9.]*ms)$//' | sort -u | tr '\n' ';' ))" + fi + git checkout -q -- "$file" +done +git diff --quiet -- "$BS" "$AD" || { echo "restore failed" >&2; exit 1; } +exit "$status" diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs index 0a5d6825..13a621ae 100644 --- a/packages/cli/tests/host.test.mjs +++ b/packages/cli/tests/host.test.mjs @@ -559,3 +559,55 @@ test("bus-service.sh pins the claude binary, refuses one the unit can't carry, a assert.match(none.stdout, /^# MOSAIC_CLAUDE_BIN unset: no claude on PATH at install time$/m); assert.doesNotMatch(none.stdout, /@CLAUDE_ENV@/); }); + +// Row 72 (#1566): mutants row 68's tests left alive. A directory is -x, so +// only the -f check keeps a directory pin out of the unit; the default pin +// keeps a symlink as given, so a claude that updates through it is followed. +test("bus-service.sh refuses a directory pin and keeps a symlink default unresolved", (t) => { + const dir = tmp(t); + const script = join(REPO, "scripts", "bus-service.sh"); + const pinned = (out) => out.match(/^Environment=MOSAIC_CLAUDE_BIN=(.*)$/m)?.[1]; + const tools = join(dir, "tools"); + mkdirSync(tools); + symlinkSync(process.execPath, join(tools, "node")); + for (const tool of ["dirname", "sed", "readlink"]) { + const at = ["/usr/bin", "/bin"].map((d) => join(d, tool)).find((p) => existsSync(p)); + symlinkSync(at, join(tools, tool)); + } + const bash = ["/usr/bin/bash", "/bin/bash"].find((p) => existsSync(p)); + const run = (args, bin) => spawnSync(bash, [script, ...args], { encoding: "utf8", env: { PATH: `${bin}:${tools}`, HOME: dir } }); + + // --claude DIR: a directory passes -x and fails -f. + const pinDir = join(dir, "claude-dir"); + mkdirSync(pinDir); + chmodSync(pinDir, 0o755); + let r = run(["render", "--claude", pinDir], join(dir, "empty")); + assert.equal(r.status, 1); + assert.match(r.stderr, /not an executable file/); + assert.equal(r.stdout, ""); + + // A directory named claude on PATH: bash's `command -v` never returns a + // directory, so the default finds no claude and pins nothing. It never + // pins the directory. + const dirBin = join(dir, "dir-bin"); + mkdirSync(join(dirBin, "claude"), { recursive: true }); + chmodSync(join(dirBin, "claude"), 0o755); + r = run(["render"], dirBin); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /no claude on PATH; MOSAIC_CLAUDE_BIN left unset/); + assert.equal(pinned(r.stdout), undefined); + assert.ok(!r.stdout.includes(join(dirBin, "claude"))); + + // A symlink named claude on PATH: the pin is the link, not its target. + const real = join(dir, "versions", "claude-9.9.9"); + mkdirSync(dirname(real)); + writeFileSync(real, '#!/bin/sh\necho "9.9.9 (Claude Code)"\n'); + chmodSync(real, 0o755); + const linkBin = join(dir, "link-bin"); + mkdirSync(linkBin); + symlinkSync(real, join(linkBin, "claude")); + r = run(["render"], linkBin); + assert.equal(r.status, 0, r.stderr); + assert.equal(pinned(r.stdout), join(linkBin, "claude")); + assert.ok(!r.stdout.includes(real), "the target never reaches the unit"); +}); diff --git a/packages/harness/tests/claude-session.test.mjs b/packages/harness/tests/claude-session.test.mjs index eb680836..213c811e 100644 --- a/packages/harness/tests/claude-session.test.mjs +++ b/packages/harness/tests/claude-session.test.mjs @@ -215,6 +215,50 @@ test("claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute e } }); +// Row 72 (#1566): a directory pin exits 2 with the adapter's own message, +// not 126 at exec; a pin under a directory whose name has a space runs, +// which needs "$CLAUDE" quoted. +test("claude adapter: a directory pin exits 2 before it runs; a pin with a space runs", (t) => { + const dir = scratch(t); + mkdirSync(join(dir, "bin")); + const stand = (p, word) => { + writeFileSync(p, `#!/bin/sh\necho ${word} > "$WHO_OUT"\necho ok\n`); + chmodSync(p, 0o755); + return p; + }; + stand(join(dir, "bin", "claude"), "path"); + for (const f of ["prompt.md", "settings.json", "mcp.json"]) writeFileSync(join(dir, f), "{}"); + const run = (bin) => spawnSync("/bin/sh", [ADAPTER], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { + PATH: `${join(dir, "bin")}:/usr/bin:/bin`, + WHO_OUT: join(dir, "who"), + MOSAIC_SYSTEM_PROMPT_FILE: join(dir, "prompt.md"), + MOSAIC_REQUEST: "x", + MOSAIC_WORKSPACE: join(dir, "ws"), + MOSAIC_SESSION_DIR: join(dir, "session"), + MOSAIC_MODEL: "claude-sonnet-5-5", + MOSAIC_CLAUDE_SETTINGS: join(dir, "settings.json"), + MOSAIC_CLAUDE_MCP_CONFIG: join(dir, "mcp.json"), + MOSAIC_CLAUDE_BIN: bin, + }, + }); + const pinDir = join(dir, "claude-dir"); + mkdirSync(pinDir); + chmodSync(pinDir, 0o755); + let r = run(pinDir); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /MOSAIC_CLAUDE_BIN is not an executable file/); + assert.equal(existsSync(join(dir, "who")), false); + + mkdirSync(join(dir, "Claude Code")); + const spaced = stand(join(dir, "Claude Code", "claude"), "spaced"); + r = run(spaced); + assert.equal(r.status, 0, r.stderr); + assert.equal(readFileSync(join(dir, "who"), "utf8").trim(), "spaced"); +}); + test("claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do", { skip }, async (t) => { const { dir, workspace, env } = await session(t, []); const slug = realpathSync(workspace).replace(/[/.]/g, "-");