From 40fecd4d382a29678bf2a35ffd8fbed54878466e Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 15:47:30 -0500 Subject: [PATCH 1/7] fix(installer): put $PREFIX/bin on PATH instead of warning about it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three duplicated PATH blocks in tools/install.sh only warned, so an unattended install finished with rc=0 and left `mosaic: command not found` — there was no operator to read the advice and act on it. Measured on a greenfield Debian 13 sandbox: `--next --yes` installed @mosaicstack/mosaic@0.0.50-next.2413 successfully and the CLI was still unreachable. Replaces all three copies with one ensure_prefix_on_path helper that appends the export to ~/.profile (~/.zshenv under zsh) and is a no-op when the prefix is already on PATH or already in a shell profile. Not ~/.bashrc: Debian's default .bashrc returns early for non-interactive shells, so a line appended there is unreachable to `bash -lc`, systemd units and agent seats — the consumers that need the CLI. --- tools/install.sh | 67 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/tools/install.sh b/tools/install.sh index 46c54f34..f41040e4 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -309,6 +309,55 @@ require_cmd() { fi } +# Persist $PREFIX/bin on PATH instead of only warning about it. +# +# The warning it replaces was the last step of an otherwise successful install, +# so the installer reported success and left `mosaic: command not found` — an +# unattended install had no operator to read the advice and act on it. +# +# Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for +# non-interactive shells, so a PATH line appended to the bottom of it is +# unreachable to `bash -lc`, to systemd units, and to every agent seat — the +# exact consumers that need the CLI. ~/.profile is read by login shells and +# Debian's .profile sources .bashrc for interactive ones, so a single line there +# reaches both. For zsh the always-sourced file is .zshenv, not .zshrc. +ensure_prefix_on_path() { + if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then + return + fi + + local profile rc_files=("$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc") + if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then + profile="$HOME/.zshenv" + else + profile="$HOME/.profile" + fi + + if grep -qslF "$PREFIX/bin" "${rc_files[@]}" 2>/dev/null; then + warn "$PREFIX/bin is in your shell profile but not in this shell" + dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)" + return + fi + + # Probe writability in a subshell. A redirection failure on a special built-in + # aborts the shell it runs in, so it has to be a child; and the redirection on + # the subshell is what silences the "Permission denied" the shell would + # otherwise print ahead of our own message. + if ! ( : >>"$profile" ) 2>/dev/null; then + warn "$PREFIX/bin is not on your PATH and $profile could not be written" + dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" + return + fi + + { + echo "" + echo "# Mosaic CLI" + echo "export PATH=\"$PREFIX/bin:\$PATH\"" + } >>"$profile" + ok "Added $PREFIX/bin to PATH in $profile" + dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)" +} + installed_cli_version() { local json json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true @@ -682,11 +731,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then ensure_monorepo install_cli_from_source - # PATH check for npm prefix - if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then - warn "$PREFIX/bin is not on your PATH" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - fi + ensure_prefix_on_path elif is_next_registry_lane; then info "Next mode — trying fast npm @next install from ${REGISTRY}…" if install_next_cli_from_registry; then @@ -699,11 +744,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 fi - # PATH check for npm prefix - if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then - warn "$PREFIX/bin is not on your PATH" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - fi + ensure_prefix_on_path else if [[ -z "$LATEST" ]]; then warn "Could not reach registry at $REGISTRY — skipping npm CLI." @@ -721,11 +762,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then ok "CLI is at or ahead of registry ($CURRENT ≥ $LATEST)." fi - # PATH check for npm prefix - if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then - warn "$PREFIX/bin is not on your PATH" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - fi + ensure_prefix_on_path fi fi From cc0d24d5c4fbd6e8435d153867d4707c5b49f2ab Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 15:54:09 -0500 Subject: [PATCH 2/7] fix(installer): bootstrap Node.js on a greenfield host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/install.sh required node and npm and installed neither. Measured on a snapshot-reverted Debian 13 image with no node, npm or git: the run stopped at `require_cmd node` with "Required command not found: node", exit 1, nothing installed, and no indication of how to proceed. Adds ensure_node() to preflight. It fetches an official Node.js release into $HOME/.mosaic/node, verifies it against that release's SHASUMS256.txt, and refuses rather than degrades when the entry is missing or the checksum does not match. sha256sum on Linux, shasum on macOS. .tar.gz over the smaller .tar.xz because gzip is universally present and xz is not — a minimal image is the case this exists to handle. No-op when a suitable node is already on PATH, so it never fights an operator's nvm/fnm/distro node. MOSAIC_SKIP_NODE_BOOTSTRAP=1 declines the download and fails with instructions instead. Inlined rather than factored into a sibling file because this script is fetched standalone by curl and has nothing to source. --- tools/install.sh | 143 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/tools/install.sh b/tools/install.sh index f41040e4..d6db3f28 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -565,8 +565,151 @@ install_next_cli_from_registry() { ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}" } +# ─── node bootstrap ─────────────────────────────────────────────────────────── +# +# Nothing on a greenfield host installs Node.js, yet this installer and the CLI +# it installs both hard-require it. Measured on a clean Debian 13 image: the +# installer stopped at `require_cmd node` with "Required command not found" and +# nothing was installed, with no hint of how to proceed. +# +# Inlined rather than factored into a sibling file on purpose: this script is +# fetched standalone by curl and has nothing to source. +# +# No-op when a suitable node is already on PATH, so it never fights an +# operator's nvm/fnm/distro node. + +NODE_ROOT="${MOSAIC_NODE_ROOT:-$HOME/.mosaic/node}" +NODE_BOOTSTRAP_VERSION="${MOSAIC_NODE_VERSION:-v22.23.2}" +NODE_MIN_MAJOR="${MOSAIC_NODE_MIN_MAJOR:-20}" +NODE_DIST_BASE="${MOSAIC_NODE_DIST_BASE:-https://nodejs.org/dist}" + +# Major version of the node at $1, or empty if it will not run. +node_major_of() { + local candidate="$1" version + version="$("$candidate" -e 'process.stdout.write(process.versions.node)' 2>/dev/null)" || return 0 + printf '%s' "${version%%.*}" +} + +node_is_suitable() { + local major + major="$(node_major_of "$1")" + [[ -n "$major" ]] && [[ "$major" -ge "$NODE_MIN_MAJOR" ]] +} + +install_node() { + local node_os node_arch tarball release_url work_dir extracted target node_bin + + case "$(uname -s)" in + Linux) node_os="linux" ;; + Darwin) node_os="darwin" ;; + *) fail "Unsupported OS '$(uname -s)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;; + esac + + case "$(uname -m)" in + x86_64|amd64) node_arch="x64" ;; + aarch64|arm64) node_arch="arm64" ;; + armv7l) node_arch="armv7l" ;; + *) fail "Unsupported architecture '$(uname -m)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;; + esac + + # .tar.gz rather than the smaller .tar.xz: gzip is universally present, xz is + # not, and a minimal image is exactly the case this exists to handle. + tarball="node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}.tar.gz" + release_url="${NODE_DIST_BASE}/${NODE_BOOTSTRAP_VERSION}" + + work_dir="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-XXXXXX")" + + info "Installing Node.js $NODE_BOOTSTRAP_VERSION ($node_os-$node_arch) to $NODE_ROOT…" + + if ! curl -fsSL "${release_url}/${tarball}" -o "$work_dir/$tarball"; then + fail "Download failed: ${release_url}/${tarball}" + rm -rf "$work_dir"; return 1 + fi + + if ! curl -fsSL "${release_url}/SHASUMS256.txt" -o "$work_dir/SHASUMS256.txt"; then + fail "Could not fetch SHASUMS256.txt; refusing to install an unverified runtime." + rm -rf "$work_dir"; return 1 + fi + + # Keep only our artifact's line, so a missing entry is an error not a pass. + if ! grep " ${tarball}\$" "$work_dir/SHASUMS256.txt" >"$work_dir/expected.sha256"; then + fail "$tarball has no entry in SHASUMS256.txt; refusing to install." + rm -rf "$work_dir"; return 1 + fi + + if ! (cd "$work_dir" && verify_sha256 expected.sha256); then + fail "Checksum mismatch for $tarball; refusing to install." + rm -rf "$work_dir"; return 1 + fi + ok "Checksum verified" + + tar xzf "$work_dir/$tarball" -C "$work_dir" + extracted="$work_dir/node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}" + if [[ ! -x "$extracted/bin/node" ]]; then + fail "Extracted archive has no bin/node" + rm -rf "$work_dir"; return 1 + fi + + mkdir -p "$NODE_ROOT" + target="$NODE_ROOT/$NODE_BOOTSTRAP_VERSION" + rm -rf "$target.incoming" + mv "$extracted" "$target.incoming" + rm -rf "$target" + mv "$target.incoming" "$target" + ln -sfn "$NODE_BOOTSTRAP_VERSION" "$NODE_ROOT/current" + rm -rf "$work_dir" + + node_bin="$NODE_ROOT/current/bin" + if ! node_is_suitable "$node_bin/node"; then + fail "Installed node at $node_bin/node did not run" + return 1 + fi + + export PATH="$node_bin:$PATH" + ok "Node.js $(node -v) installed with npm $(npm -v 2>/dev/null || echo '?')" + return 0 +} + +ensure_node() { + if command -v node &>/dev/null && node_is_suitable node; then + return 0 + fi + + # A previous run may have installed one that is not on this shell's PATH. + if node_is_suitable "$NODE_ROOT/current/bin/node"; then + export PATH="$NODE_ROOT/current/bin:$PATH" + return 0 + fi + + if [[ "${MOSAIC_SKIP_NODE_BOOTSTRAP:-0}" == "1" ]]; then + fail "No suitable Node.js and MOSAIC_SKIP_NODE_BOOTSTRAP=1; refusing to download." + echo " Install Node.js >= $NODE_MIN_MAJOR yourself, then re-run this script." + exit 1 + fi + + require_cmd curl + require_cmd tar + + # sha256sum on Linux, shasum on macOS. Verification is not optional: without a + # checksum this would install an unauthenticated runtime. + if command -v sha256sum &>/dev/null; then + verify_sha256() { sha256sum -c --status "$1"; } + elif command -v shasum &>/dev/null; then + verify_sha256() { shasum -a 256 -c --status "$1"; } + else + fail "sha256sum or shasum required to verify the Node.js download" + exit 1 + fi + + if ! install_node; then + fail "Could not bootstrap Node.js. Install Node.js >= $NODE_MIN_MAJOR and re-run." + exit 1 + fi +} + # ─── preflight ──────────────────────────────────────────────────────────────── +ensure_node require_cmd node require_cmd npm From d0c223bdf9f56a362aef8d02d9a569057fbcab8e Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 15:54:10 -0500 Subject: [PATCH 3/7] fix(wizard): write PATH to .profile/.zshenv, never .bashrc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getShellProfilePath() preferred ~/.bashrc when it existed, and ~/.zshrc for zsh. setupPath() in stages/finalize.ts appends the PATH export to whatever it returns. Debian's default ~/.bashrc opens with case $- in *i*) ;; *) return;; esac so a line appended to the bottom of it never runs for 'bash -lc', for systemd units, for 'ssh host cmd', or for any agent seat — precisely the consumers that need the CLI. An install could print its summary and exit 0 while leaving 'mosaic: command not found'. .zshrc has the same problem: zsh only reads it for interactive shells. Now ~/.profile, which login shells read and which Debian's copy sources .bashrc from for interactive shells, so one line covers both. For zsh the always-sourced file is .zshenv. fish and PowerShell are unchanged. __tests__/platform/detect.test.ts pins it, including a case asserting that no shell resolves to an interactive-only rc file. Falsified by inverting the fix: 5 failed / 1 passed; restored 6/6. Full package suite unchanged at 17 files / 4 tests failing, matching clean origin/next. --- .../mosaic/__tests__/platform/detect.test.ts | 74 +++++++++++++++++++ packages/mosaic/src/platform/detect.ts | 14 ++-- 2 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 packages/mosaic/__tests__/platform/detect.test.ts diff --git a/packages/mosaic/__tests__/platform/detect.test.ts b/packages/mosaic/__tests__/platform/detect.test.ts new file mode 100644 index 00000000..540f0e8a --- /dev/null +++ b/packages/mosaic/__tests__/platform/detect.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// homedir/platform are read at call time, so they can be stubbed per case. +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => '/home/tester', + platform: () => mockPlatform, + }; +}); + +let mockPlatform: NodeJS.Platform = 'linux'; + +const { getShellProfilePath, detectShell } = await import('../../src/platform/detect.js'); + +describe('getShellProfilePath', () => { + const originalShell = process.env['SHELL']; + const originalZdotdir = process.env['ZDOTDIR']; + + beforeEach(() => { + mockPlatform = 'linux'; + delete process.env['ZDOTDIR']; + }); + + afterEach(() => { + if (originalShell === undefined) delete process.env['SHELL']; + else process.env['SHELL'] = originalShell; + if (originalZdotdir === undefined) delete process.env['ZDOTDIR']; + else process.env['ZDOTDIR'] = originalZdotdir; + }); + + // The regression this guards: setupPath() in stages/finalize.ts appends the + // PATH export to whatever this returns. A line written to ~/.bashrc is + // unreachable to `bash -lc`, systemd units and agent seats, because Debian's + // default .bashrc returns early for non-interactive shells — so an install + // reported success and left `mosaic: command not found`. Same for .zshrc, + // which zsh only reads for interactive shells. + it('never targets an interactive-only rc file', () => { + for (const shell of ['/bin/bash', '/usr/bin/zsh']) { + process.env['SHELL'] = shell; + const profile = getShellProfilePath(); + expect(profile).not.toMatch(/\.bashrc$/); + expect(profile).not.toMatch(/\.zshrc$/); + } + }); + + it('uses ~/.profile for bash', () => { + process.env['SHELL'] = '/bin/bash'; + expect(getShellProfilePath()).toBe('/home/tester/.profile'); + }); + + it('uses ~/.zshenv for zsh', () => { + process.env['SHELL'] = '/usr/bin/zsh'; + expect(getShellProfilePath()).toBe('/home/tester/.zshenv'); + }); + + it('honours ZDOTDIR for zsh', () => { + process.env['SHELL'] = '/usr/bin/zsh'; + process.env['ZDOTDIR'] = '/custom/zdot'; + expect(getShellProfilePath()).toBe('/custom/zdot/.zshenv'); + }); + + it('falls back to ~/.profile for an unknown shell', () => { + process.env['SHELL'] = '/bin/somethingelse'; + expect(detectShell()).toBe('unknown'); + expect(getShellProfilePath()).toBe('/home/tester/.profile'); + }); + + it('still routes fish to its own config', () => { + process.env['SHELL'] = '/usr/bin/fish'; + expect(getShellProfilePath()).toBe('/home/tester/.config/fish/config.fish'); + }); +}); diff --git a/packages/mosaic/src/platform/detect.ts b/packages/mosaic/src/platform/detect.ts index 59f48173..4abb3129 100644 --- a/packages/mosaic/src/platform/detect.ts +++ b/packages/mosaic/src/platform/detect.ts @@ -1,4 +1,3 @@ -import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir, platform } from 'node:os'; @@ -22,15 +21,18 @@ export function getShellProfilePath(): string | null { const shell = detectShell(); switch (shell) { + // Both of these deliberately avoid the interactive-only rc files. + // Debian's default .bashrc returns early for non-interactive shells, so a + // PATH line appended to it never runs for `bash -lc`, systemd units, or + // agent seats — an install could report success and still leave `mosaic` + // unreachable. .profile is read by login shells and sources .bashrc for + // interactive ones, so one line covers both; .zshenv is zsh's equivalent. case 'zsh': { const zdotdir = process.env['ZDOTDIR'] ?? home; - return join(zdotdir, '.zshrc'); + return join(zdotdir, '.zshenv'); } - case 'bash': { - const bashrc = join(home, '.bashrc'); - if (existsSync(bashrc)) return bashrc; + case 'bash': return join(home, '.profile'); - } case 'fish': return join(home, '.config', 'fish', 'config.fish'); default: From 00bc602f9343abbae11e609766e47e6662882fdf Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 16:07:56 -0500 Subject: [PATCH 4/7] fix(installer): persist the bootstrapped Node on PATH, and stop duplicating PATH lines Two defects found by the second unattended greenfield run on canary (VMID 1125, rolled back to its greenfield snapshot first). 1. ensure_node() exported the Mosaic-managed Node for the installer process and nothing wrote it down. The install finished rc=0, put $PREFIX/bin in ~/.profile, and the next login shell found `mosaic` and then died on env: 'node': No such file or directory The CLI is a Node script, so a CLI on PATH without its runtime is a successful install that produces a broken command. persist_node_on_path() now writes the runtime's bin dir to the same profile, from both the fresh-install and the already-installed-but-not-on-PATH branches. 2. The 'is it already in a shell rc file' guard was a single `grep -qslF "$dir" "${rc_files[@]}"` over four paths, most of which do not exist on a clean host. Handing grep a missing file makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q already matched an earlier file, ugrep 7.5 returns 2 for the missing one regardless. On the 2 path the caller reads 'not present yet' and appends another PATH line, so every re-install grew the profile. Measured: 3 runs produced 3 duplicate entries; with the fix, 1. path_entry_exists() now tests each file for existence and greps it on its own, so the result does not depend on the grep implementation. The profile-writing body is factored into persist_on_path(), shared by the CLI prefix and the Node runtime, since both now need identical treatment. Verified in a scratch $HOME: fresh write, idempotent across three runs, zsh routes to .zshenv, an unwritable profile warns and survives set -e, and an already-on-PATH prefix is a no-op that creates no file. Falsified by restoring the multi-file grep: duplicates return. --- tools/install.sh | 90 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/tools/install.sh b/tools/install.sh index d6db3f28..f6a29833 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -309,52 +309,84 @@ require_cmd() { fi } -# Persist $PREFIX/bin on PATH instead of only warning about it. +# True if any shell rc file already puts $1 on PATH. # -# The warning it replaces was the last step of an otherwise successful install, -# so the installer reported success and left `mosaic: command not found` — an -# unattended install had no operator to read the advice and act on it. +# Each file is tested for existence first and grepped one at a time, rather than +# handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file +# makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q +# matched an earlier file, ugrep 7.5 returns 2 for the missing one regardless. +# On the 2 path the caller reads "not present yet" and appends a duplicate PATH +# line on every single install. +path_entry_exists() { + local dir="$1" rc_file + for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do + if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then + return 0 + fi + done + return 1 +} + +# Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this +# process. An `export` here reaches only the installer; every directory the +# install leaves behind has to be written down somewhere a later shell reads. # # Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for # non-interactive shells, so a PATH line appended to the bottom of it is # unreachable to `bash -lc`, to systemd units, and to every agent seat — the -# exact consumers that need the CLI. ~/.profile is read by login shells and -# Debian's .profile sources .bashrc for interactive ones, so a single line there -# reaches both. For zsh the always-sourced file is .zshenv, not .zshrc. -ensure_prefix_on_path() { - if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then - return +# exact consumers that need these binaries. ~/.profile is read by login shells +# and Debian's .profile sources .bashrc for interactive ones, so a single line +# there reaches both. For zsh the always-sourced file is .zshenv, not .zshrc. +# +# $1 = directory to add, $2 = label for the comment line. +# Returns 1 (having warned) if the profile could not be written. +persist_on_path() { + local dir="$1" label="$2" profile + + if path_entry_exists "$dir"; then + return 0 fi - local profile rc_files=("$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc") if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then profile="$HOME/.zshenv" else profile="$HOME/.profile" fi - if grep -qslF "$PREFIX/bin" "${rc_files[@]}" 2>/dev/null; then - warn "$PREFIX/bin is in your shell profile but not in this shell" - dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)" - return - fi - # Probe writability in a subshell. A redirection failure on a special built-in # aborts the shell it runs in, so it has to be a child; and the redirection on # the subshell is what silences the "Permission denied" the shell would # otherwise print ahead of our own message. if ! ( : >>"$profile" ) 2>/dev/null; then - warn "$PREFIX/bin is not on your PATH and $profile could not be written" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - return + warn "$dir is not on your PATH and $profile could not be written" + dim " Add to your shell rc: export PATH=\"$dir:\$PATH\"" + return 1 fi { echo "" - echo "# Mosaic CLI" - echo "export PATH=\"$PREFIX/bin:\$PATH\"" + echo "# $label" + echo "export PATH=\"$dir:\$PATH\"" } >>"$profile" - ok "Added $PREFIX/bin to PATH in $profile" + ok "Added $dir to PATH in $profile" + return 0 +} + +# Persist $PREFIX/bin on PATH instead of only warning about it. +# +# The warning it replaces was the last step of an otherwise successful install, +# so the installer reported success and left `mosaic: command not found` — an +# unattended install had no operator to read the advice and act on it. +ensure_prefix_on_path() { + if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then + return + fi + + if path_entry_exists "$PREFIX/bin"; then + warn "$PREFIX/bin is in your shell profile but not in this shell" + elif ! persist_on_path "$PREFIX/bin" "Mosaic CLI"; then + return + fi dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)" } @@ -670,6 +702,16 @@ install_node() { return 0 } +# Make the Mosaic-managed Node reachable from the next shell as well as this +# one. Measured on a greenfield canary run: without this the install finished +# rc=0, wrote $PREFIX/bin to ~/.profile, and the next login shell found `mosaic` +# and then died on `env: 'node': No such file or directory` — the CLI is a Node +# script, so a CLI on PATH without its runtime is a successful install that +# produces a broken command. +persist_node_on_path() { + persist_on_path "$NODE_ROOT/current/bin" "Mosaic-managed Node.js" || true +} + ensure_node() { if command -v node &>/dev/null && node_is_suitable node; then return 0 @@ -678,6 +720,7 @@ ensure_node() { # A previous run may have installed one that is not on this shell's PATH. if node_is_suitable "$NODE_ROOT/current/bin/node"; then export PATH="$NODE_ROOT/current/bin:$PATH" + persist_node_on_path return 0 fi @@ -705,6 +748,7 @@ ensure_node() { fail "Could not bootstrap Node.js. Install Node.js >= $NODE_MIN_MAJOR and re-run." exit 1 fi + persist_node_on_path } # ─── preflight ──────────────────────────────────────────────────────────────── From 47e90767b73736a6afdf43922afab2e6b7c4c053 Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 21:38:11 -0500 Subject: [PATCH 5/7] fix(installer): re-link runtime assets after the CLI stage, so greenfield keeps its enforcement hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework's install.sh ends by running mosaic-link-runtime-assets, which asks the `mosaic` CLI whether lease enforcement can be activated before deciding whether to wire the #828 hooks into settings.json. Part 1 (framework) runs before Part 2 (npm CLI), so on a first install there is no CLI to ask. The script takes its fail-safe branch, prints a four-line ERROR, and writes settings.json with mutator-gate.py and receipt-observer-client.py stripped out. Measured on canary 1125, rolled back to greenfield, `--next --yes`, no TTY: framework template ~/.config/mosaic/runtime/claude/settings.json mutator-gate.py 1 occurrence receipt-observer-client.py 1 occurrence installed ~/.claude/settings.json after a clean rc=0 install mutator-gate.py wired: False receipt-observer-client.py wired: False So enforcement ends up off because of the order the two halves install in, not because of anything about the host. Falsified by running the same script by hand once the CLI existed: rc=0, both hooks wired: True. The guard's real verdict on that host was 'activatable' the whole time. This adds one more pass after Part 2. The script is idempotent (unchanged files are skipped), so on an upgrade — CLI already present, first pass already correct — it is a no-op. It deliberately does not pass --allow-inactive-enforcement: Part 1 does not either, and a repair pass must not be more permissive than the pass it corrects. Co-Authored-By: Claude Opus 5 --- tools/install.sh | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tools/install.sh b/tools/install.sh index f6a29833..0d797d5c 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -953,6 +953,39 @@ if [[ "$FLAG_CLI" == "true" ]]; then fi fi +# ═══════════════════════════════════════════════════════════════════════════════ +# Post-install repair: link runtime assets again, now that the CLI exists +# ═══════════════════════════════════════════════════════════════════════════════ +# +# The framework's own install.sh ends by running `mosaic-link-runtime-assets`, +# and that script asks the `mosaic` CLI whether lease enforcement can be +# activated on this host before deciding whether to wire the #828 enforcement +# hooks into settings.json. On a first install there is no CLI to ask — Part 1 +# runs before Part 2 — so it takes its fail-safe branch, prints a four-line +# ERROR, and writes settings.json with the hooks stripped out. +# +# The result is that a greenfield host always ends up with enforcement +# disabled, decided by the order the two halves install in rather than by +# anything about the host. Measured on canary 1125, greenfield, both the +# `--ref` and `--next` lanes. +# +# Running it once more here lets the guard reach its real verdict. The script +# is idempotent — unchanged files are skipped — so on an upgrade, where the CLI +# was already present and the first pass already succeeded, this is a no-op. +# No `--allow-inactive-enforcement`: Part 1 does not pass it either, and this +# pass must not be more permissive than the one it is correcting. +if [[ "$FLAG_CHECK" == "false" && "$FLAG_FRAMEWORK" == "true" && "$FLAG_CLI" == "true" ]]; then + RELINK_SCRIPT="$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" + if [[ -x "$RELINK_SCRIPT" && -x "$PREFIX/bin/mosaic" ]]; then + step "Runtime assets (re-check with CLI present)" + if PATH="$PREFIX/bin:$PATH" "$RELINK_SCRIPT" >/dev/null; then + ok "Runtime assets linked" + else + warn "Runtime asset linking is still degraded — see message above." + fi + fi +fi + # ═══════════════════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════════════════ From fb5bb98a32949fe458ba49dc0d897e7ac89cd8cc Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 21:42:20 -0500 Subject: [PATCH 6/7] Revert "fix(installer): re-link runtime assets after the CLI stage" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts 47e90767. I was wrong: the fix is correct about the cause and makes the outcome worse. The acceptance run passed everything I set out to check — greenfield canary 1125, --next --yes, no TTY, rc=0, node v22.23.2 + CLI 0.0.50-next.2413 from a fresh login shell, and both enforcement hooks wired in ~/.claude/settings.json where before they were stripped. Then `mosaic doctor` on that same host: [ERROR] Lease-enforcement hooks (mutator-gate.py, receipt-observer-client.py) are wired in ~/.claude/settings.json, but broker not healthy (checkBrokerSupervisorHealth() reports unhealthy). Every gated tool call will fail closed and BRICK this agent (see #869). So the change takes a greenfield host from 'enforcement quietly off, agent works' to 'enforcement wired, broker absent, agent bricks on the first gated tool call'. The pre-existing behaviour reaches the safe state for the wrong reason; this reaches the unsafe state for the right one. Safe-for-the-wrong- reason still wins. The real defect is underneath both, and it is not an ordering bug: mosaic __link-claude-settings ... -> rc=0 (leaseEnforcementActivatable: activatable, wire the hooks) mosaic doctor -> ERROR (checkBrokerSupervisorHealth: unhealthy, hooks will brick) Two capability checks, same host, opposite verdicts. And after a complete install there is no broker supervisor to be healthy: no systemd --user unit matching lease/broker, nothing under ~/.mosaic but the bootstrapped node, and no lease or broker script in ~/.config/mosaic/tools/_scripts/. Lease enforcement cannot be activated on a greenfield host at all, so leaseEnforcementActivatable() returning true is the thing that is wrong. Filing that separately. PR #1229 goes back to exactly the four commits scooby reviewed. Co-Authored-By: Claude Opus 5 --- tools/install.sh | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/tools/install.sh b/tools/install.sh index 0d797d5c..f6a29833 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -953,39 +953,6 @@ if [[ "$FLAG_CLI" == "true" ]]; then fi fi -# ═══════════════════════════════════════════════════════════════════════════════ -# Post-install repair: link runtime assets again, now that the CLI exists -# ═══════════════════════════════════════════════════════════════════════════════ -# -# The framework's own install.sh ends by running `mosaic-link-runtime-assets`, -# and that script asks the `mosaic` CLI whether lease enforcement can be -# activated on this host before deciding whether to wire the #828 enforcement -# hooks into settings.json. On a first install there is no CLI to ask — Part 1 -# runs before Part 2 — so it takes its fail-safe branch, prints a four-line -# ERROR, and writes settings.json with the hooks stripped out. -# -# The result is that a greenfield host always ends up with enforcement -# disabled, decided by the order the two halves install in rather than by -# anything about the host. Measured on canary 1125, greenfield, both the -# `--ref` and `--next` lanes. -# -# Running it once more here lets the guard reach its real verdict. The script -# is idempotent — unchanged files are skipped — so on an upgrade, where the CLI -# was already present and the first pass already succeeded, this is a no-op. -# No `--allow-inactive-enforcement`: Part 1 does not pass it either, and this -# pass must not be more permissive than the one it is correcting. -if [[ "$FLAG_CHECK" == "false" && "$FLAG_FRAMEWORK" == "true" && "$FLAG_CLI" == "true" ]]; then - RELINK_SCRIPT="$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" - if [[ -x "$RELINK_SCRIPT" && -x "$PREFIX/bin/mosaic" ]]; then - step "Runtime assets (re-check with CLI present)" - if PATH="$PREFIX/bin:$PATH" "$RELINK_SCRIPT" >/dev/null; then - ok "Runtime assets linked" - else - warn "Runtime asset linking is still degraded — see message above." - fi - fi -fi - # ═══════════════════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════════════════ From 07373ede4dbc3af405076b13667868979a334663 Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 21:50:14 -0500 Subject: [PATCH 7/7] docs(install): record the two trust/portability assumptions in install_node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only, no behaviour change. Both raised by scooby in the #1229 review as non-blocking findings worth writing down rather than fixing here. F-A: the SHASUMS256.txt check gives integrity, not authenticity. TLS to $NODE_DIST_BASE is the whole trust root, and MOSAIC_NODE_DIST_BASE widens it to any mirror with no signature backstop. GPG-verifying SHASUMS256.txt.sig is filed as its own follow-up so it gets its own review. F-C: the uname map pulls the glibc build, so musl hosts fail — visibly, via node_is_suitable, not silently. --- tools/install.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tools/install.sh b/tools/install.sh index f6a29833..bd76d265 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -637,6 +637,11 @@ install_node() { *) fail "Unsupported OS '$(uname -s)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;; esac + # Linux here means glibc. Node's official linux-x64 build is dynamically + # linked against glibc, so on musl (Alpine) the binary will not exec — but it + # fails visibly: node_is_suitable rejects it and ensure_node exits with + # "install Node.js manually". No silent breakage, just a wasted download. + # A musl host needs the unofficial build, which is out of scope here. case "$(uname -m)" in x86_64|amd64) node_arch="x64" ;; aarch64|arm64) node_arch="arm64" ;; @@ -658,6 +663,13 @@ install_node() { rm -rf "$work_dir"; return 1 fi + # Trust assumption, stated so nobody has to infer it: this verifies INTEGRITY + # (the tarball matches the manifest), not AUTHENTICITY (the manifest is + # genuinely Node's). The only thing establishing that is TLS to + # $NODE_DIST_BASE. Node publishes SHASUMS256.txt.sig signed by its release + # keys and we do not check it, which is on par with nvm but means pointing + # MOSAIC_NODE_DIST_BASE at an untrusted mirror has no signature backstop. + # Tracked as a hardening follow-up (raised by scooby in the #1229 review). if ! curl -fsSL "${release_url}/SHASUMS256.txt" -o "$work_dir/SHASUMS256.txt"; then fail "Could not fetch SHASUMS256.txt; refusing to install an unverified runtime." rm -rf "$work_dir"; return 1