From 11dae0c7d81851fe106ab1702631af1977a2538d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 11 Oct 2026 16:42:23 -0500 Subject: [PATCH] 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 --- adapters/README.md | 4 +- adapters/claude/adapter.sh | 14 +++- docs/TOOLS.md | 6 +- packages/cli/README.md | 26 ++++++- packages/cli/src/launcher.mjs | 5 +- packages/cli/systemd/mosaic-bus.service.in | 8 ++- packages/cli/tests/host.test.mjs | 71 ++++++++++++++++++- packages/cli/tests/launcher.test.mjs | 21 +++++- .../harness/tests/claude-session.test.mjs | 50 ++++++++++++- packages/seat/README.md | 1 + packages/seat/src/session.mjs | 2 + packages/seat/tests/session.test.mjs | 2 + scripts/bus-service.sh | 63 +++++++++++++--- 13 files changed, 248 insertions(+), 25 deletions(-) diff --git a/adapters/README.md b/adapters/README.md index a9460c3ff..19a82629f 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 8a259540d..a0e04dae5 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 5a675611f..9a28a090c 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -202,7 +202,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 @@ -215,6 +215,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 8114753cb..72fe2669f 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///` (0700) is the run directory; `launches/.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 ` 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 b07ae333e..9ef08fcd6 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 896f015d4..8f82f9331 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 50db2473f..0a5d68253 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 c9b589e3e..15b36fe01 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 e45c5d699..eb680836d 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 a9af65171..64700dbde 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 527e3fdc5..a116f5788 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 f06062579..f815245d3 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 a60b3610e..f641c4795 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 "$@" ;;