diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs index 0a5d68253..13a621ae9 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 eb680836d..213c811e8 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, "-");