feat(bus): the bus host runs an explicit Claude Code binary (#1561, row 68)

MOSAIC_CLAUDE_BIN names the claude binary by absolute path instead of
finding it on PATH. scripts/bus-service.sh render|install --claude PATH
writes it into the unit (default: command -v claude in the installing
shell); the claude adapter, the launcher's version read and the seat
session environment all use it. Mos's Q18 ask: the unit's PATH found
/usr/bin/claude 2.1.259, which refuses claude-opus-5-5.

Candidate: round 2 manifest 21c1ab05, round 1 (a2008d8b) rebased onto
f2f2861b after row 55 changed docs/TOOLS.md and packages/cli/README.md;
the changed lines are byte-identical. Approved by Filbert (27349, 27374)
and Dewey (27350, 27375). Sage's gate on f2f2861b plus the patch: cli
112, seat 27, harness 57, nine scripts/test-*.sh 0 failed, Docker on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 16:42:23 -05:00
co-authored by Claude Opus 5.5
parent 6ee63518a3
commit 11dae0c7d8
13 changed files with 248 additions and 25 deletions
+23 -3
View File
@@ -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
+3 -2
View File
@@ -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;
}
+6 -2
View File
@@ -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
+68 -3
View File
@@ -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@/);
});
+19 -2
View File
@@ -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);
});
+49 -1
View File
@@ -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, "-");
+1
View File
@@ -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
+2
View File
@@ -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) {
+2
View File
@@ -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) => {