From ccccc16932e4b4384f6f6f6a1316f74b82dc2732 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 11 Oct 2026 15:57:01 -0500 Subject: [PATCH] row 68 (#1561) round 1 packet: bus host pins MOSAIC_CLAUDE_BIN Co-Authored-By: Claude Opus 5.5 --- agents/sage/work/queue-68/build.md | 133 +++++ agents/sage/work/queue-68/build.patch | 560 ++++++++++++++++++ .../work/queue-68/candidate-manifest.sha256 | 13 + 3 files changed, 706 insertions(+) create mode 100644 agents/sage/work/queue-68/build.md create mode 100644 agents/sage/work/queue-68/build.patch create mode 100644 agents/sage/work/queue-68/candidate-manifest.sha256 diff --git a/agents/sage/work/queue-68/build.md b/agents/sage/work/queue-68/build.md new file mode 100644 index 000000000..4f8341fd2 --- /dev/null +++ b/agents/sage/work/queue-68/build.md @@ -0,0 +1,133 @@ +# Row 68 (#1561): the bus host runs an explicit Claude Code binary, round 1 + +Sage, 2026-10-11. Brief: +`docs/plans/2026-10-11_bus-claude-binary.md`, "Bus host runs an explicit +Claude Code binary" (e1b90ed6). Mos's Q18 ask: the bus finds +`/usr/bin/claude` 2.1.259 (pacman `claude-code`), which refuses +`claude-opus-5-5`. Jason runs the native `~/.local/bin/claude` (2.1.296). +Filbert and Dewey review. Sage commits after the gate; nothing is +committed, staged or pushed yet. + +Base a86de1f1. `build.patch` applies there with `git apply --index`; +`candidate-manifest.sha256` pins all 13 files after the patch. + +## Design + +The binary is named, not found. `MOSAIC_CLAUDE_BIN` is an absolute path: + +1. The unit sets it. `scripts/bus-service.sh render|install --claude PATH`; + without `--claude`, `command -v claude` in the installing shell. The + path is kept as given, not resolved: `~/.local/bin/claude` is a symlink + the native installer moves to each new version, so the unit follows + updates without a reinstall. +2. `ENV_ALLOW` passes it to the session. It's a path, not a credential. +3. `adapters/claude/adapter.sh` runs `$MOSAIC_CLAUDE_BIN` when it is set + and nonempty, else `claude` as before. +4. `claudeVersion`, which fills the manifest's `claudeVersion`, asks the + same binary, so the manifest records what actually ran. + +Rejected: prepending `~/.local/bin` to the unit's `PATH`. Every binary +there (pip and npm installs, personal scripts) would then shadow the +system's, for the host and for every session. Also rejected: a field in +the business file or system config. Which binary runs is a property of the +host install, like node's directory, which the unit already carries. + +## Refusals + +| Where | Input | Result | +|---|---|---| +| `bus-service.sh` | `--claude` relative | exit 1, "must be absolute", nothing written | +| `bus-service.sh` | not an executable file, or missing | exit 1, "not an executable file" | +| `bus-service.sh` | a character outside `A-Za-z0-9/._+-` (`%`, space, ...) | exit 1, "cannot carry". systemd expands `%` and splits `Environment=` on spaces. | +| `bus-service.sh` | no `--claude` and no claude on `PATH` | exit 0; the unit gets `# MOSAIC_CLAUDE_BIN unset: ...` and stderr says so. A pi-only host installs as before. | +| `bus-service.sh` | `uninstall --claude`, `render --dir` | exit 4 | +| adapter | `MOSAIC_CLAUDE_BIN` relative | exit 2 before claude runs | +| adapter | not an executable file, or missing | exit 2 before claude runs | +| adapter | unset or empty | `claude` from `PATH`, as today | + +`install` now renders into a variable before `mktemp`, so a refused pin +leaves no temp file in the unit directory. Calling `render` inside +`|| { ... }` would have switched `set -e` off in the function and written +a unit with an empty line. `claude_line` failing is handled with an +explicit `|| exit 1`. + +`install` prints `claude: (<--version>)` or the unset line. +`status` adds `-p Environment` to its `systemctl show`, which shows `PATH` +and the pin. The unit holds no secret. + +## Files + +- `packages/cli/systemd/mosaic-bus.service.in`: a comment and an + `@CLAUDE_ENV@` line after `Environment=PATH=`. The header names "the + claude line below" rather than the token, because header tokens render + into the comment (as `@REPO@` and `@PATH@` already do) and the claude + substitution only matches its own line. +- `scripts/bus-service.sh`: `claude_line`, `--claude` for render and + install, the install print, `status` shows `Environment`, usage. +- `adapters/claude/adapter.sh`: the `CLAUDE` variable and its checks. +- `packages/seat/src/session.mjs`: `MOSAIC_CLAUDE_BIN` in `ENV_ALLOW`. +- `packages/cli/src/launcher.mjs`: `claudeVersion` asks + `env.MOSAIC_CLAUDE_BIN || "claude"`. +- Docs: `packages/cli/README.md` ("Unit" and the manifest note), + `adapters/README.md` (adapter list and variable table), + `packages/seat/README.md` (`sessionEnv`), `docs/TOOLS.md` (usage line + and one paragraph). The brief didn't list `docs/TOOLS.md`. I added it + because its usage line would otherwise be wrong, and it is my file. +- Tests: + - `host.test.mjs`, new test "bus-service.sh pins the claude binary...": + - the pin found on `PATH`, from `--claude`, and through a symlink + kept unresolved + - five refusals with exit 1 and empty stdout + - install printing the pin and the stand-in's version + - a refused install writing nothing + - `uninstall --claude` and `render --dir` exiting 4 + - no claude anywhere: `PATH` is a directory holding only node, + dirname and sed, and the unit gets the comment line + - The existing render test also asserts no `@CLAUDE_ENV@`. + - `launcher.test.mjs`: `claudeVersion` with the pin, from `PATH`, and + with none (null). + - `session.test.mjs`: `sessionEnv` passes `MOSAIC_CLAUDE_BIN`. + - `claude-session.test.mjs`, new test: + - the pin wins over a `claude` on `PATH` + - unset or empty uses `PATH` + - relative, missing and non-executable pins exit 2 without running + anything + +Each new test fails against base: unpinned `claudeVersion` returns the +`PATH` stand-in's 1.0.0, the base adapter runs the `PATH` stand-in, base +`sessionEnv` drops the variable, and base `render` rejects `--claude` with +exit 4. + +## Gate (my run on the candidate) + +| Suite | Result | +|---|---| +| packages/cli | 85/0 | +| packages/seat | 27/0 | +| packages/harness | 57/0, nothing skipped. The live claude tests ran against the 2.1.296 on my PATH. | +| scripts/test-*.sh | all nine exit 0: auth 15, conductor 17, config 24, discord 66, extension-package 18, foundation 44, queue 27, release 14, task 98 (Docker on) | + +Logs: `/tmp/sage-r68-out/` (not kept). + +## After landing (not in the candidate) + +1. `scripts/bus-service.sh install` from my shell, which resolves + `/home/jwoltje/.local/bin/claude`. `install` prints the pin and its + version. +2. The opus probe: the adapter's flags with `MOSAIC_CLAUDE_BIN` and `PATH` + taken from the installed unit, as decision 89's probe did. +3. Report the path and version to Mos. + +The running host (MainPID 1242258) keeps its old environment until it +restarts. That restart is the Q18 `--pm` start, which waits for Mos's +watched window. Nothing here restarts it. + +## Questions for reviewers + +- Is keeping the symlink, not its target, right? The other choice pins an + exact version and needs a reinstall per update. I chose to follow + updates because that's what Jason's own sessions do. +- With no claude found, `install` succeeds with a comment rather than + refusing, because a pi-only business needs no claude. Is that the right + default, or should `install` refuse unless the business's roles are all + pi? diff --git a/agents/sage/work/queue-68/build.patch b/agents/sage/work/queue-68/build.patch new file mode 100644 index 000000000..55810c09a --- /dev/null +++ b/agents/sage/work/queue-68/build.patch @@ -0,0 +1,560 @@ +diff --git a/adapters/README.md b/adapters/README.md +index a9460c3f..19a82629 100644 +--- a/adapters/README.md ++++ b/adapters/README.md +@@ -55,7 +55,8 @@ Optional, adapter-specific (documented per adapter): + print mode (`-p`), ambient discovery disabled, stdin detached. + - `mock` — deterministic echo of `MOSAIC_MOCK_RESPONSE`. Test-only: never use + it where a real model response is required. +-- `claude` — the host's `claude` CLI, for managed sessions only (below). ++- `claude` — the host's `claude` CLI, for managed sessions only (below): ++ `MOSAIC_CLAUDE_BIN` when set, else `claude` on `PATH`. + Not a worker adapter: the image has no `claude`, and the adapter refuses + without the bundle's hook and MCP files. + +@@ -76,6 +77,7 @@ session's workspace and its own process group. Same contract, plus: + | `MOSAIC_TURN_MARKER` | `pi`: the extension writes it at `agent_end`; a pi turn that exits 0 without it failed. | + | `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. | + | `MOSAIC_CLAUDE_SETTINGS`, `MOSAIC_CLAUDE_MCP_CONFIG` | `claude` only: the bundle's gate hook and MCP server; required. | ++| `MOSAIC_CLAUDE_BIN` | `claude` only: absolute path of the CLI to run, from the host's environment (the bus unit pins it). Unset or empty runs `claude` from `PATH`; a relative path or a non-executable file exits 2. | + + The layers each adapter relies on, and what they don't cover, are in + `packages/harness/README.md`. +diff --git a/adapters/claude/adapter.sh b/adapters/claude/adapter.sh +index 8a259540..a0e04dae 100644 +--- a/adapters/claude/adapter.sh ++++ b/adapters/claude/adapter.sh +@@ -1,6 +1,7 @@ + #!/bin/sh + # Claude Code adapter: implements the Mosaic adapter contract for the host's +-# `claude` CLI, for managed sessions (slice 1 S6). Headless only. ++# `claude` CLI, for managed sessions (slice 1 S6). Headless only. The CLI is ++# MOSAIC_CLAUDE_BIN when set (the bus unit pins it), else `claude` on PATH. + # + # Contract: see adapters/README.md. + # stdout = the turn's answer only; stderr = diagnostics; exit 0 on success. +@@ -26,6 +27,15 @@ for f in "$MOSAIC_SYSTEM_PROMPT_FILE" "$MOSAIC_CLAUDE_SETTINGS" "$MOSAIC_CLAUDE_ + [ -r "$f" ] || { echo "claude adapter: not readable: $f" >&2; exit 2; } + done + [ "${MOSAIC_INTERACTIVE:-}" != "1" ] || { echo "claude adapter: interactive mode isn't supported" >&2; exit 2; } ++CLAUDE=claude ++if [ -n "${MOSAIC_CLAUDE_BIN:-}" ]; then ++ case "$MOSAIC_CLAUDE_BIN" in ++ /*) ;; ++ *) echo "claude adapter: MOSAIC_CLAUDE_BIN must be an absolute path: $MOSAIC_CLAUDE_BIN" >&2; exit 2 ;; ++ esac ++ [ -f "$MOSAIC_CLAUDE_BIN" ] && [ -x "$MOSAIC_CLAUDE_BIN" ] || { echo "claude adapter: MOSAIC_CLAUDE_BIN is not an executable file: $MOSAIC_CLAUDE_BIN" >&2; exit 2; } ++ CLAUDE=$MOSAIC_CLAUDE_BIN ++fi + + mkdir -p "$MOSAIC_WORKSPACE" "$MOSAIC_SESSION_DIR" + cd "$MOSAIC_WORKSPACE" +@@ -63,7 +73,7 @@ fi + ALLOWED=mcp__mosaic + [ -z "${MOSAIC_TOOLS:-}" ] || ALLOWED="$MOSAIC_TOOLS,mcp__mosaic" + PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")" +-claude -p "$MOSAIC_REQUEST" \ ++"$CLAUDE" -p "$MOSAIC_REQUEST" \ + --output-format text \ + --system-prompt "$PROMPT_CONTENT" \ + --model "$MOSAIC_MODEL" \ +diff --git a/docs/TOOLS.md b/docs/TOOLS.md +index b96e9012..508c69ab 100644 +--- a/docs/TOOLS.md ++++ b/docs/TOOLS.md +@@ -201,7 +201,7 @@ scripts/mosaic talk [--wait SECONDS] [--business ] + scripts/mosaic stop + scripts/mosaic launches off | on [--business ] + scripts/mosaic launches list [--json] +-scripts/bus-service.sh render | install [--dir DIR] [--no-reload] | uninstall | status ++scripts/bus-service.sh render [--claude PATH] | install [--dir DIR] [--no-reload] [--claude PATH] | uninstall | status + ``` + + The human commands read and resolve through the broker's human transport +@@ -214,6 +214,10 @@ directory must be 0700: it also holds the notifier's journal `sent.jsonl` + `torn-.bin` and truncates the journal to its last newline; a + malformed complete line refuses with exit 3. + ++`bus-service.sh` pins the Claude Code binary managed sessions run as the ++unit's `MOSAIC_CLAUDE_BIN`, from `--claude PATH` or else `command -v claude` ++(`packages/cli/README.md`, "Unit"). ++ + `bus start` also serves the session launcher (slice 1 S6): a PM session's + `launch` tool starts role sessions through it, each a runner under its own + PID namespace with a generated bundle, and `--pm` launches the business's +diff --git a/packages/cli/README.md b/packages/cli/README.md +index 32977afa..3e0207d5 100644 +--- a/packages/cli/README.md ++++ b/packages/cli/README.md +@@ -166,7 +166,9 @@ end reason from its exit code (`packages/harness/README.md`), which writes + + The manifest records the Pi version from the repository's pin (the + `@earendil-works/pi-coding-agent` dependency in the root `package.json`) +-and, for Claude Code, `claude --version`. ++and, for Claude Code, `--version` from the binary sessions run: ++`MOSAIC_CLAUDE_BIN` when set (the unit pins it, below), else `claude` on ++`PATH`. + + On disk, under the data root: `launches///` (0700) is the run + directory; `launches/.jsonl` (0600, append-only) logs every launch, +@@ -215,9 +217,13 @@ template `systemd/mosaic-bus.service.in` as the systemd user unit + The template's file name has no `@` because queue candidate manifests refuse + it. It is modelled on `scripts/discord-service.sh`: + +-- `install [--dir DIR] [--no-reload]` writes the unit through a temp file and +- a rename, then runs `systemctl --user daemon-reload`. ++- `install [--dir DIR] [--no-reload] [--claude PATH]` writes the unit through ++ a temp file and a rename, then runs `systemctl --user daemon-reload`. It ++ prints the pinned claude and its `--version`. ++- `render [--claude PATH]` prints the unit without writing it. + - `uninstall` refuses while an instance is active. ++- `status ` shows the unit's state and its `Environment`, which ++ includes the pinned claude. + + The unit settings: + +@@ -226,6 +232,20 @@ The unit settings: + - `RestartPreventExitStatus=2 3 4`, so refusals are never retried + - `KillSignal=SIGTERM` + - `TimeoutStopSec=60` ++- `Environment=PATH=`: `/usr/local/bin:/usr/bin:/bin`, with node's directory ++ first when it isn't one of those ++- `Environment=MOSAIC_CLAUDE_BIN=`: the Claude Code binary that managed ++ sessions and the manifest's version run ++ ++`MOSAIC_CLAUDE_BIN` comes from `--claude PATH`, or else `command -v claude` ++in the installing shell. The path is kept as given, so the native install's ++`~/.local/bin/claude`, a symlink into its versions directory, keeps ++following its updates; reinstall to change it. It must be absolute, an ++executable file, and only `A-Z a-z 0-9 / . _ + -`, because systemd expands ++`%` and splits `Environment=` on spaces; anything else refuses with exit 1 ++and writes nothing. With no claude found, the unit gets a comment line ++instead and sessions run `claude` from the unit's `PATH`. A running host ++keeps the environment it started with until it restarts. + + The unit has no `network-online.target` ordering, since a user unit cannot + order on that system target. The notifier needs no network at start: a +diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs +index b07ae333..9ef08fcd 100644 +--- a/packages/cli/src/launcher.mjs ++++ b/packages/cli/src/launcher.mjs +@@ -76,9 +76,10 @@ export function piVersion(repo = REPO) { + } + } + +-// `claude --version`, or null when it doesn't answer. ++// `claude --version` from the binary sessions run (MOSAIC_CLAUDE_BIN, else ++// `claude` on PATH), or null when it doesn't answer. + export function claudeVersion(env = process.env) { +- const r = spawnSync("claude", ["--version"], { encoding: "utf8", timeout: 10000, env }); ++ const r = spawnSync(env.MOSAIC_CLAUDE_BIN || "claude", ["--version"], { encoding: "utf8", timeout: 10000, env }); + return r.status === 0 ? r.stdout.trim().split("\n")[0] || null : null; + } + +diff --git a/packages/cli/systemd/mosaic-bus.service.in b/packages/cli/systemd/mosaic-bus.service.in +index 896f015d..8f82f933 100644 +--- a/packages/cli/systemd/mosaic-bus.service.in ++++ b/packages/cli/systemd/mosaic-bus.service.in +@@ -1,6 +1,6 @@ + # Mosaic bus host, one instance per business: mosaic-bus@. +-# Rendered by scripts/bus-service.sh from packages/cli/systemd/; @REPO@ and +-# @PATH@ are filled in at install time. Edit the template and reinstall; do ++# Rendered by scripts/bus-service.sh from packages/cli/systemd/; @REPO@, ++# @PATH@ and the claude line below are filled in at install time. Edit the template and reinstall; do + # not edit the installed copy. The bus store allows one broker per data + # root, so run one instance per data root (packages/cli/README.md). + [Unit] +@@ -13,6 +13,10 @@ StartLimitBurst=5 + Type=simple + WorkingDirectory=@REPO@ + Environment=PATH=@PATH@ ++# The Claude Code binary that managed sessions and the manifest's ++# claudeVersion run, pinned at install (`--claude PATH`, default ++# `command -v claude`). Without it they run `claude` from PATH above. ++@CLAUDE_ENV@ + # The host reads the system config and the business file, boots the broker + # (packages/bus/src/process.mjs) and starts the notifier. Config problems and + # refusals exit 3, usage 4, invalid input 2: none of those is retried. A +diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs +index 50db2473..0a5d6825 100644 +--- a/packages/cli/tests/host.test.mjs ++++ b/packages/cli/tests/host.test.mjs +@@ -4,8 +4,8 @@ import { spawn, spawnSync } from "node:child_process"; + import { subscribe, unsubscribe } from "node:diagnostics_channel"; + import { createServer } from "node:http"; + import { once } from "node:events"; +-import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs"; +-import { join } from "node:path"; ++import { chmodSync, existsSync, readFileSync, readdirSync, statSync, symlinkSync, writeFileSync, mkdirSync } from "node:fs"; ++import { dirname, join } from "node:path"; + import { fileURLToPath } from "node:url"; + import { Client } from "../../bus/src/client.mjs"; + import { bootConfig, loadSystem, REPO } from "../src/config.mjs"; +@@ -482,7 +482,7 @@ test("bus-service.sh renders the unit and installs it into a given directory", ( + assert.equal(render.status, 0, render.stderr); + assert.match(render.stdout, new RegExp(`ExecStart=${REPO.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/scripts/mosaic bus start %i`)); + assert.match(render.stdout, /RestartPreventExitStatus=2 3 4/); +- assert.doesNotMatch(render.stdout, /@REPO@|@PATH@/); ++ assert.doesNotMatch(render.stdout, /@REPO@|@PATH@|@CLAUDE_ENV@/); + // A user unit cannot order on a system target (Darkwing F3). + assert.doesNotMatch(render.stdout, /network-online/); + const first = spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8" }); +@@ -494,3 +494,68 @@ test("bus-service.sh renders the unit and installs it into a given directory", ( + assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8" }).stdout, /removed: /); + assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8" }).status, 4); + }); ++ ++// Row 68: the unit pins the Claude Code binary as MOSAIC_CLAUDE_BIN, from ++// --claude or else `command -v claude`, kept as given (not resolved). ++test("bus-service.sh pins the claude binary, refuses one the unit can't carry, and renders without one", (t) => { ++ const dir = tmp(t); ++ const script = join(REPO, "scripts", "bus-service.sh"); ++ const stand = (p, mode = 0o755) => { ++ writeFileSync(p, '#!/bin/sh\necho "9.9.9 (Claude Code)"\n'); ++ chmodSync(p, mode); ++ return p; ++ }; ++ mkdirSync(join(dir, "bin")); ++ const onPath = stand(join(dir, "bin", "claude")); ++ const named = stand(join(dir, "claude-named")); ++ symlinkSync(named, join(dir, "claude-link")); ++ const env = { ...process.env, PATH: `${join(dir, "bin")}:${dirname(process.execPath)}:/usr/bin:/bin` }; ++ const run = (args, e = env) => spawnSync(script, args, { encoding: "utf8", env: e }); ++ const pinned = (out) => out.match(/^Environment=MOSAIC_CLAUDE_BIN=(.*)$/m)?.[1]; ++ ++ assert.equal(pinned(run(["render"]).stdout), onPath); ++ assert.equal(pinned(run(["render", "--claude", named]).stdout), named); ++ assert.equal(pinned(run(["render", "--claude", join(dir, "claude-link")]).stdout), join(dir, "claude-link")); ++ ++ const refusals = [ ++ ["claude", /must be absolute/], ++ [stand(join(dir, "claude-noexec"), 0o644), /not an executable file/], ++ [join(dir, "missing"), /not an executable file/], ++ [stand(join(dir, "claude%h")), /cannot carry/], ++ [stand(join(dir, "claude x")), /cannot carry/], ++ ]; ++ for (const [path, why] of refusals) { ++ const r = run(["render", "--claude", path]); ++ assert.equal(r.status, 1, path); ++ assert.match(r.stderr, why, path); ++ assert.equal(r.stdout, "", path); ++ } ++ ++ // install prints the pin and its version; a refused pin writes nothing. ++ const units = join(dir, "units"); ++ const bad = run(["install", "--dir", units, "--no-reload", "--claude", "claude"]); ++ assert.equal(bad.status, 1); ++ assert.deepEqual(readdirSync(units), []); ++ const ok = run(["install", "--dir", units, "--no-reload", "--claude", named]); ++ assert.equal(ok.status, 0, ok.stderr); ++ assert.match(ok.stdout, new RegExp(`^claude: ${named.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\(9\\.9\\.9 \\(Claude Code\\)\\)$`, "m")); ++ assert.equal(pinned(readFileSync(join(units, "mosaic-bus@.service"), "utf8")), named); ++ assert.equal(run(["uninstall", "--dir", units, "--no-reload", "--claude", named]).status, 4); ++ assert.equal(run(["render", "--dir", units]).status, 4); ++ ++ // No claude anywhere on PATH: the unit carries a comment, not the variable. ++ const tools = join(dir, "tools"); ++ mkdirSync(tools); ++ symlinkSync(process.execPath, join(tools, "node")); ++ for (const tool of ["dirname", "sed"]) { ++ 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 none = spawnSync(bash, [script, "render"], { encoding: "utf8", env: { PATH: tools, HOME: dir } }); ++ assert.equal(none.status, 0, none.stderr); ++ assert.match(none.stderr, /no claude on PATH; MOSAIC_CLAUDE_BIN left unset/); ++ assert.equal(pinned(none.stdout), undefined); ++ assert.match(none.stdout, /^# MOSAIC_CLAUDE_BIN unset: no claude on PATH at install time$/m); ++ assert.doesNotMatch(none.stdout, /@CLAUDE_ENV@/); ++}); +diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs +index c9b589e3..15b36fe0 100644 +--- a/packages/cli/tests/launcher.test.mjs ++++ b/packages/cli/tests/launcher.test.mjs +@@ -9,14 +9,14 @@ import { spawn, spawnSync } from "node:child_process"; + import { once } from "node:events"; + import { createInterface } from "node:readline"; + import { connect } from "node:net"; +-import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; ++import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + import { fileURLToPath } from "node:url"; + import { Client } from "../../bus/src/client.mjs"; + import { launchLogFile, launchSocketPath, readSessions, Registry, sessionsFile, UNSHARE } from "../../seat/src/session.mjs"; + import { bootConfig, loadSystem } from "../src/config.mjs"; + import { startHost, startTimeOf } from "../src/host.mjs"; +-import { createLauncher } from "../src/launcher.mjs"; ++import { claudeVersion, createLauncher } from "../src/launcher.mjs"; + import { fixture, tmp } from "./helpers.mjs"; + + const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url)); +@@ -392,3 +392,20 @@ test("a runner that ignores SIGTERM is killed when the host closes", { skip, tim + assert.equal(ends.length, 1); + assert.equal(ends[0].signal, "SIGKILL"); + }); ++ ++// Row 68: the manifest's claudeVersion asks the binary sessions run. ++test("claudeVersion asks MOSAIC_CLAUDE_BIN when set, else claude on PATH", (t) => { ++ const dir = tmp(t); ++ mkdirSync(join(dir, "bin")); ++ const stand = (p, v) => { ++ writeFileSync(p, `#!/bin/sh\necho "${v} (Claude Code)"\n`); ++ chmodSync(p, 0o755); ++ return p; ++ }; ++ stand(join(dir, "bin", "claude"), "1.0.0"); ++ const pinned = stand(join(dir, "pinned"), "9.9.9"); ++ const PATH = join(dir, "bin"); ++ assert.equal(claudeVersion({ PATH, MOSAIC_CLAUDE_BIN: pinned }), "9.9.9 (Claude Code)"); ++ assert.equal(claudeVersion({ PATH }), "1.0.0 (Claude Code)"); ++ assert.equal(claudeVersion({ PATH: join(dir, "none") }), null); ++}); +diff --git a/packages/harness/tests/claude-session.test.mjs b/packages/harness/tests/claude-session.test.mjs +index e45c5d69..eb680836 100644 +--- a/packages/harness/tests/claude-session.test.mjs ++++ b/packages/harness/tests/claude-session.test.mjs +@@ -7,7 +7,7 @@ + import { test } from "node:test"; + import assert from "node:assert/strict"; + import { spawn, spawnSync } from "node:child_process"; +-import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; ++import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; + import { basename, dirname, join } from "node:path"; + import { buildBundle } from "../src/bundle.mjs"; + import { adapterEnv } from "../src/runner.mjs"; +@@ -167,6 +167,54 @@ test("claude adapter: --restricted is always passed", (t) => { + assert.ok(!argv.includes("--bare"), argv.join(" ")); + }); + ++// Row 68: MOSAIC_CLAUDE_BIN, when set, is the CLI the adapter runs, ahead ++// of any claude on PATH; a relative or missing one exits 2 before it runs. ++test("claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable", (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"); ++ const pinned = stand(join(dir, "pinned-claude"), "pinned"); ++ 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"), ++ ...(bin === undefined ? {} : { MOSAIC_CLAUDE_BIN: bin }), ++ }, ++ }); ++ const who = () => readFileSync(join(dir, "who"), "utf8").trim(); ++ let r = run(pinned); ++ assert.equal(r.status, 0, r.stderr); ++ assert.equal(who(), "pinned"); ++ r = run(undefined); ++ assert.equal(r.status, 0, r.stderr); ++ assert.equal(who(), "path"); ++ r = run(""); ++ assert.equal(r.status, 0, r.stderr); ++ assert.equal(who(), "path"); ++ for (const [bin, why] of [["pinned-claude", /must be an absolute path/], [join(dir, "missing"), /not an executable file/], [join(dir, "prompt.md"), /not an executable file/]]) { ++ rmSync(join(dir, "who"), { force: true }); ++ r = run(bin); ++ assert.equal(r.status, 2, bin); ++ assert.match(r.stderr, why, bin); ++ assert.equal(existsSync(join(dir, "who")), false, bin); ++ } ++}); ++ + 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, "-"); +diff --git a/packages/seat/README.md b/packages/seat/README.md +index a9af6517..64700dbd 100644 +--- a/packages/seat/README.md ++++ b/packages/seat/README.md +@@ -164,6 +164,7 @@ launch flow and the on-disk layout. + - `sessionEnv` builds the session's environment from an allowlist + (`ENV_ALLOW`), the repository's `node_modules/.bin` on `PATH`, and + `MOSAIC_RUN_ID`. Founder credential variables never pass. ++ `MOSAIC_CLAUDE_BIN`, the host's pinned Claude Code binary, does. + - `spawnSession` starts `packages/harness/src/runner.mjs` under + `unshare --user --map-current-user --pid --fork --kill-child --mount-proc`, + detached, with stdin open for the capability line and output to +diff --git a/packages/seat/src/session.mjs b/packages/seat/src/session.mjs +index 527e3fdc..a116f578 100644 +--- a/packages/seat/src/session.mjs ++++ b/packages/seat/src/session.mjs +@@ -57,10 +57,12 @@ export function family(model, max) { + // environment passes, so a founder credential variable (GITEA_TOKEN, + // GH_TOKEN, SSH_AUTH_SOCK, ...) never reaches a session (REQ-CRED-2). + // Provider variables pass because the harness needs them to reach a model. ++// MOSAIC_CLAUDE_BIN, the host's pinned Claude Code binary, is a path. + export const ENV_ALLOW = Object.freeze([ + "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TZ", "TERM", + "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN", + "OPENAI_API_KEY", "OPENROUTER_API_KEY", ++ "MOSAIC_CLAUDE_BIN", + ]); + + export function sessionEnv(env, run) { +diff --git a/packages/seat/tests/session.test.mjs b/packages/seat/tests/session.test.mjs +index f0606257..f815245d 100644 +--- a/packages/seat/tests/session.test.mjs ++++ b/packages/seat/tests/session.test.mjs +@@ -59,6 +59,8 @@ test("sessionEnv passes only the allowlist, the repo's bin on PATH, and the run + assert.match(env.PATH, /node_modules\/\.bin:\/usr\/bin$/); + assert.equal(env.MOSAIC_RUN_ID, "r0123456789ab"); + for (const k of ["GITEA_TOKEN", "GH_TOKEN", "SSH_AUTH_SOCK"]) assert.ok(!ENV_ALLOW.includes(k)); ++ // Row 68: the host's pinned Claude Code binary reaches the adapter. ++ assert.equal(sessionEnv({ MOSAIC_CLAUDE_BIN: "/opt/claude" }, "r0123456789ab").MOSAIC_CLAUDE_BIN, "/opt/claude"); + }); + + test("newRun: short ids, 0700 directories, and a refusal when the socket path won't fit", (t) => { +diff --git a/scripts/bus-service.sh b/scripts/bus-service.sh +index a60b3610..f641c479 100755 +--- a/scripts/bus-service.sh ++++ b/scripts/bus-service.sh +@@ -7,24 +7,52 @@ + # business (mosaic-bus@). Nothing here reads a capability, the + # binding or the token. See packages/cli/README.md, "Bus host". + # +-# render print the rendered unit on stdout +-# install [--dir DIR] [--no-reload] ++# render [--claude PATH] print the rendered unit on stdout ++# install [--dir DIR] [--no-reload] [--claude PATH] + # write the unit (write to a temp file, then + # rename) and run `systemctl --user daemon-reload` + # uninstall [--dir DIR] [--no-reload] + # remove the unit; refuses while an instance is active + # status unit state, then `mosaic bus status` + # ++# --claude PATH pins the Claude Code binary the unit's sessions run ++# (MOSAIC_CLAUDE_BIN); without it, `command -v claude` here. The path is ++# kept as given, so a symlink that tracks updates keeps tracking them. ++# + # Exit codes: 0 ok, 1 operation failed, 4 usage. + set -euo pipefail + REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in" + UNIT_NAME="mosaic-bus@.service" +-USAGE="usage: scripts/bus-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status " ++USAGE="usage: scripts/bus-service.sh render [--claude PATH] | install [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status " + + die() { echo "bus-service: $*" >&2; exit 1; } + usage() { echo "$USAGE" >&2; exit 4; } + ++CLAUDE_BIN="" ++CLAUDE_SET=0 ++ ++# The unit's MOSAIC_CLAUDE_BIN line, or a comment when there is no claude to ++# pin. systemd expands % and splits Environment= on spaces, so the path is ++# held to a plain character set. ++claude_line() { ++ local bin ++ if [ "$CLAUDE_SET" = 1 ]; then ++ bin="$CLAUDE_BIN" ++ else ++ bin="$(command -v claude || true)" ++ if [ -z "$bin" ]; then ++ echo "bus-service: no claude on PATH; MOSAIC_CLAUDE_BIN left unset (--claude PATH pins one)" >&2 ++ echo "# MOSAIC_CLAUDE_BIN unset: no claude on PATH at install time" ++ return 0 ++ fi ++ fi ++ case "$bin" in /*) ;; *) die "claude path must be absolute: $bin" ;; esac ++ case "$bin" in *[!A-Za-z0-9/._+-]*) die "claude path contains a character the unit cannot carry: $bin" ;; esac ++ [ -f "$bin" ] && [ -x "$bin" ] || die "claude path is not an executable file: $bin" ++ echo "Environment=MOSAIC_CLAUDE_BIN=$bin" ++} ++ + render() { + [ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE" + local node_dir +@@ -33,7 +61,9 @@ render() { + local path="/usr/local/bin:/usr/bin:/bin" + case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac + case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac +- sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "$TEMPLATE" ++ local claude ++ claude="$(claude_line)" || exit 1 ++ sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" -e "s|^@CLAUDE_ENV@$|$claude|" "$TEMPLATE" + } + + UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +@@ -43,6 +73,7 @@ parse_flags() { + case "$1" in + --dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; shift 2 ;; + --no-reload) RELOAD=0; shift ;; ++ --claude) [ $# -ge 2 ] || usage; CLAUDE_BIN="$2"; CLAUDE_SET=1; shift 2 ;; + *) usage ;; + esac + done +@@ -51,9 +82,10 @@ parse_flags() { + install_unit() { + parse_flags "$@" + mkdir -p "$UNIT_DIR" +- local target="$UNIT_DIR/$UNIT_NAME" tmp ++ local target="$UNIT_DIR/$UNIT_NAME" tmp unit ++ unit="$(render)" || exit 1 + tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")" +- render > "$tmp" ++ printf '%s\n' "$unit" > "$tmp" + chmod 0644 "$tmp" + if [ -f "$target" ] && cmp -s "$tmp" "$target"; then + rm -f "$tmp" +@@ -62,6 +94,13 @@ install_unit() { + mv -f "$tmp" "$target" + echo "written: $target" + fi ++ local bin ++ bin="$(sed -n 's/^Environment=MOSAIC_CLAUDE_BIN=//p' "$target")" ++ if [ -n "$bin" ]; then ++ echo "claude: $bin ($("$bin" --version 2>/dev/null | head -n 1 || true))" ++ else ++ echo "claude: MOSAIC_CLAUDE_BIN unset; sessions run claude from the unit's PATH" ++ fi + if [ "$RELOAD" = 1 ]; then + systemctl --user daemon-reload || die "daemon-reload failed" + echo "daemon-reload done" +@@ -81,6 +120,7 @@ MSG + + uninstall_unit() { + parse_flags "$@" ++ [ "$CLAUDE_SET" = 0 ] || usage + local target="$UNIT_DIR/$UNIT_NAME" + if [ "$RELOAD" = 1 ]; then + local active +@@ -107,14 +147,21 @@ status_unit() { + echo "unit: $unit" + echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)" + echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)" +- systemctl --user show "$unit" -p MainPID -p NRestarts -p ExecMainStartTimestamp -p Result 2>/dev/null | sed 's/^/ /' ++ systemctl --user show "$unit" -p MainPID -p NRestarts -p ExecMainStartTimestamp -p Result -p Environment 2>/dev/null | sed 's/^/ /' + "$REPO/scripts/mosaic" bus status | sed 's/^/ /' || true + } + + [ $# -ge 1 ] || usage + cmd="$1"; shift + case "$cmd" in +- render) [ $# -eq 0 ] || usage; render ;; ++ render) ++ while [ $# -gt 0 ]; do ++ case "$1" in ++ --claude) [ $# -ge 2 ] || usage; CLAUDE_BIN="$2"; CLAUDE_SET=1; shift 2 ;; ++ *) usage ;; ++ esac ++ done ++ render ;; + install) install_unit "$@" ;; + uninstall) uninstall_unit "$@" ;; + status) status_unit "$@" ;; diff --git a/agents/sage/work/queue-68/candidate-manifest.sha256 b/agents/sage/work/queue-68/candidate-manifest.sha256 new file mode 100644 index 000000000..bea549189 --- /dev/null +++ b/agents/sage/work/queue-68/candidate-manifest.sha256 @@ -0,0 +1,13 @@ +d21a79f290757954540fde75f063b436d22546047207e86e78a9259ce435906f adapters/claude/adapter.sh +b1b6fc2e2a42866e0de6f77eb22810538d1659d0a9ff49c577a3a746a94f34d1 adapters/README.md +4b18eae7c3d3b3d037d500d4c6478a832209b6b7b51dcc7f4feb93f4a5c5389f docs/TOOLS.md +529d3209d68f13774ee5b27905423f6e82a4c32a7b1009843472b29d93240f80 packages/cli/README.md +4036811721eb6d2e0f7787ae7a59a6d972d218e5d86df92b61e5901784aee8a4 packages/cli/src/launcher.mjs +015ac12798590299fded4d277f697e058ab68de909cff88d0997801029561fd2 packages/cli/systemd/mosaic-bus.service.in +916539dfb045ded42ec79a1d6ca7d84c4bf123564d7a11d30442099bb1bae694 packages/cli/tests/host.test.mjs +01699be6d7b187ab732e0c1a3e2f39052d2e562870064521ad4246aa9f66c037 packages/cli/tests/launcher.test.mjs +69e38831bb0724289a039812f538745d9a102ef79c4237435b3281dce06f3451 packages/harness/tests/claude-session.test.mjs +c9ce09cec4871738b50ff1b69e10e5f37bf7cb9f299d04b0168c618c4a37976d packages/seat/README.md +778e5df6fa6285fbd13162c7f2fbf4995e7c3161df8e0f438279e20c1cf61e4d packages/seat/src/session.mjs +6169b55f7b23fb2822bb461189b0597a2abc9a50155950d37bd4d20dadbfc3ab packages/seat/tests/session.test.mjs +369306c0136a9f1a25fe8a9eecae282056a3685317d496691b542b5e9293d222 scripts/bus-service.sh