row 68 (#1561): round 2 packet, round 1 rebased onto f2f2861b after row 55

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 16:37:32 -05:00
co-authored by Claude Opus 5.5
parent 21122c3b0e
commit b31cc8a90c
3 changed files with 622 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# Row 68 (#1561): the bus host runs an explicit Claude Code binary, round 2
Sage, 2026-10-11. Round 2 is round 1 rebased, nothing else.
Round 1 (manifest a2008d8b, base a86de1f1) has both approvals: Dewey 27350
and Filbert 27349. It can't land as approved. Row 55 (79d9fc2d) landed
after a86de1f1 and changed two of the 13 candidate paths,
`docs/TOOLS.md` and `packages/cli/README.md`, so no commit on `refactor`
can hash to round 1's manifest.
Base f2f2861b. `build.patch` is round 1's patch applied there with
`git apply --index --3way` (every file applied cleanly) and taken back out
with `git diff --cached`. `candidate-manifest.sha256` pins the same 13
paths in round 1's order.
## What changed from round 1
Only context. The `+` and `-` lines of the two patches, with the file
headers dropped, are byte-identical:
grep -E '^[+-]' PATCH | grep -vE '^(\+\+\+|---) '
gives the same output for `../build.patch` and `build.patch`. Eleven of
the 13 hashes are round 1's. The two that differ are the two files row 55
changed, and row 55's additions there say nothing about the claude
binary, `MOSAIC_CLAUDE_BIN`, `bus-service.sh` or systemd.
To check it: in a worktree of f2f2861b, `git apply --index build.patch`,
then `sha256sum -c candidate-manifest.sha256`; and
`git diff a86de1f1 f2f2861b -- docs/TOOLS.md packages/cli/README.md` is
row 55's change alone.
## Round 1 notes
Carried, not addressed here. After landing, the install names the binary
(`install --claude /home/jwoltje/.local/bin/claude`) and the version is
checked at or above 2.1.280. The surviving mutants (a directory pin,
`bs-status-no-env`, `bs-resolve-default`, the x-only checks) go to a
follow-up row.
## Gate
Sage's gate, a detached worktree of f2f2861b with `build.patch` applied,
sequential, Docker on, finished 2026-10-11T21:37:18Z: cli 112, seat 27 and
harness 57 node tests, 0 failed (cli and harness have more tests than
round 1's gate because row 55's are in this base). test-auth 15,
test-conductor 17, test-config 24, test-discord 66, test-extension-package
18, test-foundation 44, test-queue 27, test-release 14, test-task 98, all
0 failed. The manifest checked OK before and after.
+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 5a675611..9a28a090 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -202,7 +202,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
@@ -215,6 +215,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 8114753c..72fe2669 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -238,7 +238,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,
@@ -287,9 +289,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:
@@ -298,6 +304,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
b8715347f13ae77507356fdd8de955271d63e7eecb627adc3ab72bae2ba27936 docs/TOOLS.md
76f80e882d3122944c38721baa2261ab1a80057260a68df01309f86237a94a5c 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