row 68 (#1561) round 1 packet: bus host pins MOSAIC_CLAUDE_BIN

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 15:57:01 -05:00
co-authored by Claude Opus 5.5
parent 8e33490f0f
commit ccccc16932
3 changed files with 706 additions and 0 deletions
+133
View File
@@ -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: <path> (<--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?
+560
View File
@@ -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 <role> <text> [--wait SECONDS] [--business <id>]
scripts/mosaic stop <run>
scripts/mosaic launches off | on [--business <id>]
scripts/mosaic launches list [--json]
-scripts/bus-service.sh render | install [--dir DIR] [--no-reload] | uninstall | status <business>
+scripts/bus-service.sh render [--claude PATH] | install [--dir DIR] [--no-reload] [--claude PATH] | uninstall | status <business>
```
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-<UTC stamp>.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/<business>/<run>/` (0700) is the run
directory; `launches/<business>.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 <business>` 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@<business>.
-# 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, "[email protected]"), "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@<business>). 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 <business> 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="[email protected]"
-USAGE="usage: scripts/bus-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <business>"
+USAGE="usage: scripts/bus-service.sh render [--claude PATH] | install [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status <business>"
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 "$@" ;;
@@ -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