From 40fecd4d382a29678bf2a35ffd8fbed54878466e Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 15:47:30 -0500 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 07/16] 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 From 3b4055017ef1b800bb3e263cc6a0d26d07f944ec Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 22:29:48 -0500 Subject: [PATCH 08/16] fix(installer): pin umask and set the 0700 modes the fleet boundary requires (#1236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A greenfield install cannot run `mosaic fleet init --write`. It fails with `unsafe-permissions` on an unnamed `(directory)` and an unhandled Node throw, and every mutating `mosaic fleet` command fails the same way. Measured on a reverted-to-greenfield sandbox VM at CLI 0.0.50-next.2413: `~/.config/mosaic`, `fleet/` and `credentials/` all land at 0775, and 1735 directories under the framework root carry `mode & 022`. Two independent causes, and fixing either one alone leaves it broken. 1. The installer inherited the caller's umask. Debian/Ubuntu ship 002, so every `mkdir -p` produced 0775. Fedora/RHEL ship 022 and produced 0755. The product therefore worked or did not depending on the operator's login shell, with nothing in the install output distinguishing the two. 022 is already what this script assumes it produces — `make_durable_snapshot` restores the ambient umask specifically so "every later sync copy and new framework dir" gets 0644/0755 — so pin it rather than inherit it. 2. Even at a correct 0755, three directories are rejected. The fleet code guards its managed paths with two masks in two languages: `assertPrivateManagedDirectory` (fleet-reconciler.js, `mode & 0o077`) covers MOSAIC_HOME and `fleet/` and runs before the roster lock is taken; `assert_private_directory` (tools/fleet/start-agent-session.sh, `mode & 077`) covers `fleet/agents` and runs before a pane is spawned. Their laxer siblings use `mode & 0o022` and accept 0755. The strict mask wins, so the installer states 0700 outright instead of hoping a umask implies it. The `find -perm /022 -exec chmod go-w` sweep repairs a tree installed before this change, which the umask alone cannot reach. It strips group/other WRITE only — never read or execute — and is scoped to directories, so it corrects the boundary violation without changing who may traverse or read anything. It is not sufficient for `fleet/agents`: stripping write from 0755 yields 0750 and `mode & 077` is still non-zero, which is why that path gets its own chmod. Reported as #1236. The `fleet/agents` half was found by scooby reading start-agent-session.sh; the umask framing is theirs too — my first report blamed the distro rather than the umask. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1 --- packages/mosaic/framework/install.sh | 54 ++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index 7d262a24..a8a81a0a 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -35,6 +35,18 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}" INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}" +# Normalize the ambient umask so directory modes are a property of the installer +# and not of whatever shell invoked it (#1236). Debian/Ubuntu ship umask 002, so +# every `mkdir -p` below yielded 0775 — and the fleet env boundary rejects any +# managed directory with `mode & 0o022`, which made `mosaic fleet init --write` +# impossible on a stock install of those distros. Fedora/RHEL ship 022 and did +# not trip it, so the product worked or did not depending on the operator's +# login shell. 022 is what this script already assumes it produces: see the +# umask note in make_durable_snapshot, which restores to the ambient value +# precisely so "every later sync copy and new framework dir" gets 0644/0755. +# Now that value is 022 rather than whatever was inherited. +umask 022 + # Deliberately parsed from "$@" (a real, explicit, per-invocation argument) — # never an environment variable — so this opt-out can never sit silently # inherited in a shell profile. See #869 Point-1 C2. @@ -696,6 +708,48 @@ sync_framework mkdir -p "$TARGET_DIR/memory" mkdir -p "$TARGET_DIR/credentials" +# Three directories must be 0700, not merely not-group-writable (#1236). +# The fleet code guards them with two different masks in two different +# languages, and the strict one wins: +# +# assertPrivateManagedDirectory (fleet-reconciler.js, `mode & 0o077`) +# -> MOSAIC_HOME and MOSAIC_HOME/fleet, checked before the roster lock is +# taken, so every mutating `mosaic fleet` command dies at 0755. +# assert_private_directory (tools/fleet/start-agent-session.sh, `mode & 077`) +# -> MOSAIC_HOME/fleet/agents, checked before a pane is ever spawned. +# +# Their laxer siblings (`mode & 0o022`) accept 0755, which is why normalizing +# the umask above is necessary and not sufficient — a correct umask-022 install +# still produces 0755 and still cannot run `mosaic fleet init --write`. Say the +# strict modes outright rather than inferring them from a umask. +# +# Only these. The rest of the tree is content, stays 0755, and is only ever +# reached by the 0o022 checks, which 0755 satisfies. +chmod 700 "$TARGET_DIR" 2>/dev/null || \ + warn "Could not set 0700 on $TARGET_DIR — 'mosaic fleet' mutations will fail as unsafe-permissions." +if [[ -d "$TARGET_DIR/fleet" ]]; then + chmod 700 "$TARGET_DIR/fleet" 2>/dev/null || \ + warn "Could not set 0700 on $TARGET_DIR/fleet — 'mosaic fleet' mutations will fail as unsafe-permissions." +fi +# fleet/agents does not exist on a first install — the CLI creates it 0700 on +# demand. It is chmod'd here for the UPGRADE case: a tree built under umask 002 +# has it at 0775, and the repair sweep below cannot rescue it, because stripping +# group/other write from 0755 leaves 0750 and `mode & 077` is still non-zero. +if [[ -d "$TARGET_DIR/fleet/agents" ]]; then + chmod 700 "$TARGET_DIR/fleet/agents" 2>/dev/null || \ + warn "Could not set 0700 on $TARGET_DIR/fleet/agents — agent sessions will fail to start as unsafe-permissions." +fi +# credentials/ holds secrets and was never meant to be group-readable either. +chmod 700 "$TARGET_DIR/credentials" 2>/dev/null || true + +# Repair an existing tree. The umask above only governs directories this run +# creates, so a host installed under umask 002 before this fix keeps its 0775 +# dirs through every upgrade and stays broken. Strips group/other WRITE only — +# never read or execute — so it can repair the boundary violation without +# changing who can traverse or read anything. Scoped to directories: file modes +# are the manifest's business, not this fix's. +find "$TARGET_DIR" -type d -perm /022 -exec chmod go-w {} + 2>/dev/null || true + # Reconcile contract files from defaults/ into the framework root: framework-owned # files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent # copy is backed up once); user-seeded files (TOOLS) are written on first install only. From 03eda02c2054b6ffa0c7c16e8bcccef659122ada Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 22:40:47 -0500 Subject: [PATCH 09/16] fix(installer): warn on a failed credentials/ chmod instead of swallowing it scooby's review flag 1 on #1242. The other three chmods warn; this one was `|| true`. It is the one directory holding secrets, so a chmod that fails silently there is the failure most worth a line in the output. Comment-and-warn only. No behaviour change on the success path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1 --- packages/mosaic/framework/install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index a8a81a0a..1578c33b 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -740,7 +740,11 @@ if [[ -d "$TARGET_DIR/fleet/agents" ]]; then warn "Could not set 0700 on $TARGET_DIR/fleet/agents — agent sessions will fail to start as unsafe-permissions." fi # credentials/ holds secrets and was never meant to be group-readable either. -chmod 700 "$TARGET_DIR/credentials" 2>/dev/null || true +# It is not on the fleet boundary, so a failure here breaks nothing — but it is +# the one directory where a silently-failed chmod leaves secrets group-readable, +# which is precisely the failure worth a line in the output. +chmod 700 "$TARGET_DIR/credentials" 2>/dev/null || \ + warn "Could not set 0700 on $TARGET_DIR/credentials — stored secrets may be readable by other users on this host." # Repair an existing tree. The umask above only governs directories this run # creates, so a host installed under umask 002 before this fix keeps its 0775 From 463745e314c9a9ef0e41309ba197eb7f482f5fe5 Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 23:24:24 -0500 Subject: [PATCH 10/16] fix(#1237): let ps/install work on a roster-v2 fleet, and refuse add/remove honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a roster-v2 fleet, `ps`, `install`, `install-systemd`, `add` and `remove` all failed in the v1 parser. The consequence was that a greenfield v2 box could never get its unit templates placed, so nothing downstream could start. The read-only commands get a narrow version-agnostic view of the roster (version, socket name, holder session, and per agent name/alias/runtime). This is deliberately not a v2 -> v1 downshift. A downshifted FleetRoster would be accepted by generateAgentEnvValues, which would make a third writer of fleet/agents/.env.generated through the v1 mapping and break the #791 single-SSOT invariant that projectRosterV2AgentGeneratedEnv is documented to hold. The view is too small to write a roster or an env file back from, so that misuse is unavailable rather than merely discouraged. So on a v2 roster `install` places the tool files and the unit templates, enables the units, and writes no generated env at all. Env belongs to `apply` and `regen`, both already v2-native. That change alone would have traded an init-time failure for a boot-time one. `install` enables mosaic-agent@.service (WantedBy=default.target) without starting it, so a reboot between `install` and the first `apply` would run ExecStart against an absent env file and fail every seat unit, further from its cause. The unit template now carries ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated which skips an enabled-but-unconfigured unit cleanly and starts it on the next start once the reconciler has written env. On v1 it is a no-op, since v1 `install` writes env itself. Found in review by scooby. `add` and `remove` are not routed to `create` and `delete`. They are different operations: the v1 pair edits the roster and drives systemd, the v2 pair is documented as changing desired state without runtime actions. `add` also collects four fields where a v2 agent requires eleven, so routing it would mean inventing an operator's provider, alias, reasoning and tool policy. On v2 both now fail with the real two-step sequence instead. Tests: 8 new, 7 of which are red before this change. Includes the greenfield case scooby asked for — `ps` on a fresh v2 install with nothing running is rc=0 and lists every agent stopped, since that is the command an operator runs to find out why there is no seat. Note for anyone verifying this: a correct fix here shows `install` rc=0 and `start` rc=0 and still no live seat. #1240 (tmux absent) is upstream, #1241 (start reports lifecycle-complete over dead panes) and the missing agent runtime are downstream. A dead pane after this change is not a regression here. Refs #1237, #791, #1240, #1241 --- .../systemd/user/mosaic-agent@.service | 8 + .../commands/fleet-roster-v2-dispatch.spec.ts | 265 ++++++++++++++++++ packages/mosaic/src/commands/fleet.ts | 114 +++++++- 3 files changed, 379 insertions(+), 8 deletions(-) create mode 100644 packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts diff --git a/packages/mosaic/framework/systemd/user/mosaic-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-agent@.service index f4d4a985..81f76b54 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-agent@.service +++ b/packages/mosaic/framework/systemd/user/mosaic-agent@.service @@ -4,6 +4,14 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack Requires=mosaic-tmux-holder.service After=mosaic-tmux-holder.service PartOf=mosaic-tmux-holder.service +# Do not attempt a seat before its generated env exists. `install` enables this +# unit (WantedBy=default.target) but on a roster-v2 fleet the reconciler owns the +# generated env, so between `install` and the first `apply`/`regen --write` there +# is a boot window where ExecStart would run against an absent env file and the +# launcher would fail the unit. A skipped unit is the honest state for "enabled +# but not yet configured"; systemd re-evaluates the condition on every start, so +# the seat comes up on the next start once the reconciler has written env. +ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated [Service] Type=oneshot diff --git a/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts b/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts new file mode 100644 index 00000000..b0283a8b --- /dev/null +++ b/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts @@ -0,0 +1,265 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js'; + +/** + * #1237: the v1-only commands (`ps`, `install`, `install-systemd`, `add`, + * `remove`) rejected a roster-v2 fleet outright, so a greenfield v2 box could + * never get its units placed. These tests pin the three behaviours that fix + * gives it, and the two it deliberately does NOT give it. + * + * The load-bearing negative is that `install` on v2 writes no generated env: + * the reconciler owns that file through projectRosterV2AgentGeneratedEnv, and a + * second writer here — necessarily through the v1 mapping — is exactly the + * drift the #791 single-SSOT invariant exists to prevent. + */ + +const rosterV2 = ` +version: 2 +generation: 4 +transport: tmux +tmux: + socket_name: mosaic-fleet + holder_session: _holder +defaults: + working_directory: /srv/mosaic + runtime: pi +runtimes: + pi: + reset_command: /new +agents: + - name: coder0 + alias: Coder 0 + class: code + runtime: pi + provider: openai + model: gpt-5.6-sol + reasoning: high + tool_policy: code + working_directory: /srv/mosaic + persistent_persona: false + reset_between_tasks: true + lifecycle: + enabled: true + desired_state: stopped + launch: + yolo: true + - name: coder1 + alias: Coder 1 + class: code + runtime: pi + provider: openai + model: gpt-5.6-sol + reasoning: medium + tool_policy: code + working_directory: /srv/other + persistent_persona: false + reset_between_tasks: true + lifecycle: + enabled: true + desired_state: stopped + launch: + yolo: true +`; + +let tempHome: string | undefined; +const savedHome = process.env.HOME; +const savedMosaicHome = process.env.MOSAIC_HOME; + +afterEach(async (): Promise => { + vi.restoreAllMocks(); + process.exitCode = undefined; + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME; + else process.env.MOSAIC_HOME = savedMosaicHome; + if (tempHome) await rm(tempHome, { recursive: true, force: true }); + tempHome = undefined; +}); + +/** + * A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before + * anything has been installed, applied or started. + */ +async function v2Home(): Promise { + tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-')); + process.env.HOME = tempHome; + delete process.env.MOSAIC_HOME; + const mosaicHome = join(tempHome, '.config', 'mosaic'); + for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) { + await mkdir(join(mosaicHome, directory), { recursive: true, mode: 0o700 }); + } + await writeFile(join(mosaicHome, 'fleet', 'roster.yaml'), rosterV2, { mode: 0o600 }); + await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', { + mode: 0o600, + }); + return mosaicHome; +} + +/** + * Stands in for a box where nothing is running: every systemctl and tmux probe + * fails the way it does before the holder has ever started. `ps` must survive + * this — it is the command an operator reaches for to find out *why* there is + * no seat, so it has to report the emptiness rather than fail on it. + */ +const greenfieldRunner: CommandRunner = async (command): Promise => { + if (command === 'tmux') { + return { stdout: '', stderr: 'no server running on /tmp/tmux-1000/mosaic-fleet', exitCode: 1 }; + } + return { stdout: '', stderr: '', exitCode: 1 }; +}; + +function program(runner: CommandRunner = greenfieldRunner): Command { + const result = new Command(); + result.exitOverride(); + registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') }); + return result; +} + +function capture(): string[] { + const lines: string[] = []; + vi.spyOn(console, 'log').mockImplementation((value: string): void => { + lines.push(value); + }); + return lines; +} + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +describe('mosaic fleet ps — roster v2', (): void => { + it('lists every v2 agent on a greenfield box with nothing running, and does not throw', async (): Promise => { + await v2Home(); + const lines = capture(); + + await expect( + program().parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']), + ).resolves.toBeDefined(); + + const rows = JSON.parse(lines.join('\n')) as { + name: string; + runtime: string; + alias?: string; + paneAlive: boolean; + source: string; + }[]; + expect(rows.map((row) => row.name).sort()).toEqual(['coder0', 'coder1']); + // The v2 roster's per-agent fields must survive the read model, not be + // flattened into defaults. + expect(rows.every((row) => row.runtime === 'pi')).toBe(true); + expect(rows.find((row) => row.name === 'coder0')?.alias).toBe('Coder 0'); + // Nothing is running, and that is a report, not an error. + expect(rows.every((row) => row.paneAlive === false)).toBe(true); + expect(rows.every((row) => row.source === 'roster')).toBe(true); + expect(process.exitCode ?? 0).toBe(0); + }); +}); + +describe('mosaic fleet install — roster v2', (): void => { + it('places the tool files and unit templates', async (): Promise => { + const mosaicHome = await v2Home(); + capture(); + + await expect( + program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']), + ).resolves.toBeDefined(); + + // Units live in the systemd user dir, not under the Mosaic home. + const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user'); + for (const unit of [ + 'mosaic-tmux-holder.service', + 'mosaic-agent@.service', + 'mosaic-interaction-agent@.service', + ]) { + expect(await exists(join(systemdUserDir, unit))).toBe(true); + } + const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh'); + expect(await exists(launcher)).toBe(true); + expect((await stat(launcher)).mode & 0o777).toBe(0o755); + }); + + it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise => { + const mosaicHome = await v2Home(); + capture(); + + await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']); + + const agentDir = join(mosaicHome, 'fleet', 'agents'); + expect(await readdir(agentDir)).toEqual([]); + }); + + it('tells the operator which command does own the env', async (): Promise => { + await v2Home(); + const lines = capture(); + + await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']); + + expect(lines.join('\n')).toContain('mosaic fleet apply'); + }); +}); + +describe('mosaic-agent@.service', (): void => { + it('will not attempt a seat before the reconciler has written its env', async (): Promise => { + // The pairing that makes "install writes no env" safe: install enables the + // unit (WantedBy=default.target) but does not start it, so without this + // condition a reboot between `install` and the first `apply` would run + // ExecStart against an absent env file and fail every seat unit. + const unit = await readFile( + resolve(process.cwd(), 'framework', 'systemd', 'user', 'mosaic-agent@.service'), + 'utf8', + ); + expect(unit).toContain('ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated'); + }); +}); + +describe('mosaic fleet add / remove — roster v2', (): void => { + it('add refuses, and names the two-step v2 sequence instead of inventing defaults', async (): Promise => { + await v2Home(); + + await expect( + program().parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'add', + 'coder2', + '--runtime', + 'pi', + '--class', + 'code', + ]), + ).rejects.toThrow(/mosaic fleet create[\s\S]*mosaic fleet apply/); + }); + + it('remove refuses, and names delete plus apply', async (): Promise => { + await v2Home(); + + await expect( + program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']), + ).rejects.toThrow(/mosaic fleet delete coder1[\s\S]*mosaic fleet apply/); + }); + + // Note: this one passes on the unmodified tree too — there `remove` throws in + // the v1 parser, before it can touch anything. It is a regression guard on the + // ordering of the new guard clause, not evidence that the fix works. + it('refuses BEFORE mutating the roster', async (): Promise => { + const mosaicHome = await v2Home(); + const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml'); + const before = await readFile(rosterPath, 'utf8'); + + await expect( + program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']), + ).rejects.toThrow(); + + expect(await readFile(rosterPath, 'utf8')).toBe(before); + }); +}); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index c344660a..f356a894 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -34,6 +34,7 @@ export { resolveInstalledFleetRosterPath, } from '../fleet/fleet-roster-v1.js'; export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js'; +import { parseRosterV2 } from '../fleet/roster-v2.js'; import { registerFleetAgentCrudCommands, type FleetAgentCrudCommandDeps, @@ -820,7 +821,7 @@ export function buildEnableLingerCommand(user: string): string[] { */ export async function enableFleetUnits( runner: CommandRunner, - roster: FleetRoster, + roster: { readonly agents: readonly { readonly name: string }[] }, opts: { enable?: boolean }, ): Promise { if (opts.enable === false) { @@ -1527,7 +1528,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .option('--no-enable', 'Skip enabling units for boot-survival') .action(async (opts: { enable?: boolean }) => { await installFleet(cmd, frameworkRoot); - const roster = await loadRosterForCommand(cmd); + // Unit enablement needs agent names only, so it reads either version. + const roster = await loadRosterReadModel(cmd); await enableFleetUnits(runner, roster, opts); }); @@ -1537,7 +1539,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .option('--no-enable', 'Skip enabling units for boot-survival') .action(async (opts: { enable?: boolean }) => { await installFleet(cmd, frameworkRoot); - const roster = await loadRosterForCommand(cmd); + // Unit enablement needs agent names only, so it reads either version. + const roster = await loadRosterReadModel(cmd); await enableFleetUnits(runner, roster, opts); }); @@ -1688,7 +1691,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .action(async (opts: { json?: boolean }) => { const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const activePaths = resolveFleetPaths(commandOpts.mosaicHome); - const roster = await loadRosterForCommand(cmd); + // ps only reads, so it takes the version-agnostic read model rather than + // the v1 parser, which rejects a v2 roster outright. + const roster = await loadRosterReadModel(cmd); const { tenant_id, host } = getDefaultTenantAndHost(); const nowMs = Date.now(); @@ -1908,6 +1913,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = start: boolean; }, ) => { + if (await usesRosterV2ControlPlane(cmd)) { + throw new Error(rosterV2MutationGuidance('add', 'create', name)); + } if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) { throw new Error( `Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`, @@ -1973,6 +1981,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .description('Remove an agent from the fleet roster') .option('--keep-files', 'Skip deleting env and heartbeat files') .action(async (name: string, opts: { keepFiles?: boolean }) => { + if (await usesRosterV2ControlPlane(cmd)) { + throw new Error(rosterV2MutationGuidance('remove', 'delete', name)); + } const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const activePaths = resolveFleetPaths(commandOpts.mosaicHome); const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster); @@ -2331,7 +2342,9 @@ export function registerFleetAgentCommands( async function installFleet(cmd: Command, frameworkRoot: string): Promise { const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome); assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome); - const roster = await loadRosterForCommand(cmd); + // Read model first: every file this function places is roster-independent, and + // the v1 parser would reject a v2 roster before any of them were written. + const roster = await loadRosterReadModel(cmd); await ensureFleetHolderIdentity(activePaths.mosaicHome); await mkdir(activePaths.fleetToolsDir, { recursive: true }); await mkdir(activePaths.tmuxToolsDir, { recursive: true }); @@ -2391,16 +2404,30 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise join(activePaths.systemdUserDir, 'mosaic-interaction-agent@.service'), ); - for (const agent of roster.agents) { + // On roster v2 the reconciler owns the generated env: `apply` writes it and + // `regen` rebuilds it, both from projectRosterV2AgentGeneratedEnv. Writing it + // here too — necessarily through the v1 mapping — would be the third writer of + // one file and would break the #791 single-SSOT invariant. So v2 gets the tool + // files and the units, and nothing else. + if (roster.version === 2) { + console.log( + `Installed fleet tools and systemd units for ${roster.agents.length} agent(s). ` + + `Generated env is owned by the reconciler on roster v2 — run: mosaic fleet apply --expected-generation `, + ); + return; + } + + const v1Roster = await loadRosterForCommand(cmd); + for (const agent of v1Roster.agents) { await writeAgentEnvironmentProjection({ mosaicHome: activePaths.mosaicHome, agentEnvDir: activePaths.agentEnvDir, agentName: agent.name, - generated: generateAgentEnvValues(roster, agent), + generated: generateAgentEnvValues(v1Roster, agent), }); } - console.log(`Installed fleet files for ${roster.agents.length} agent(s).`); + console.log(`Installed fleet files for ${v1Roster.agents.length} agent(s).`); } async function loadRosterForCommand(cmd: Command): Promise { @@ -2427,6 +2454,77 @@ async function usesRosterV2ControlPlane(cmd: Command): Promise { ); } +/** + * `add`/`remove` and `create`/`delete` are not two spellings of one operation. + * The v1 pair edits the roster *and* drives systemd; the v2 pair is documented + * as changing desired state "without runtime actions", leaving convergence to + * `apply`. `add` also collects four fields where a v2 agent requires eleven, so + * routing it to `create` would mean inventing provider, alias, reasoning and + * tool-policy defaults on the operator's behalf. Refusing with the real command + * is honest; silently guessing an agent's provider is not. + */ +function rosterV2MutationGuidance( + v1Command: 'add' | 'remove', + v2Command: 'create' | 'delete', + name: string, +): string { + const target = v2Command === 'delete' ? ` ${name}` : ''; + return ( + `mosaic fleet ${v1Command} does not operate on a roster-v2 fleet. ` + + `Roster v2 separates desired state from convergence:\n` + + ` 1. mosaic fleet ${v2Command}${target} --expected-generation ` + + `${v2Command === 'create' ? "--agent '' " : ''}` + + `(edits the roster only)\n` + + ` 2. mosaic fleet apply --expected-generation (converges systemd and tmux)\n` + + `Read the current generation with: mosaic fleet status` + ); +} + +/** + * The read-only fields shared by roster v1 and v2, for the commands that only + * ever *read* the roster (`ps`, and unit enablement inside `install`). + * + * This is deliberately NOT a v2→v1 downshift. A downshifted `FleetRoster` would + * be accepted by `generateAgentEnvValues`, and that would make a third writer of + * `fleet/agents/.env.generated` — through the v1 mapping — breaking the + * #791 single-SSOT invariant that {@link projectRosterV2AgentGeneratedEnv} is + * documented to hold. Keeping the read model this small makes that misuse + * impossible: there is nothing here to write a roster or an env file back from. + */ +interface FleetRosterReadModel { + readonly version: 1 | 2; + readonly tmux: { readonly socketName: string; readonly holderSession: string }; + readonly agents: readonly { + readonly name: string; + readonly alias?: string; + readonly runtime: string; + }[]; +} + +/** Reads either roster version into the shared read-only view. */ +async function loadRosterReadModel(cmd: Command): Promise { + const opts = cmd.opts<{ mosaicHome: string; roster?: string }>(); + const path = await resolveRosterPath(opts.mosaicHome, opts.roster); + if (!(await usesRosterV2ControlPlane(cmd))) { + const v1 = await loadRosterAtPath(cmd, path); + return { + version: 1, + tmux: { socketName: v1.tmux.socketName, holderSession: v1.tmux.holderSession }, + agents: v1.agents, + }; + } + try { + const v2 = parseRosterV2(await readFleetRosterText(path), 'yaml'); + return { + version: 2, + tmux: { socketName: v2.tmux.socketName, holderSession: v2.tmux.holderSession }, + agents: v2.agents, + }; + } catch (error) { + reportFleetRosterConfigurationError(cmd, error); + } +} + async function loadRosterFromAgentCommand( command: Command, mosaicHomeOverride?: string, From 67f5014cc09ad6012149bf01c296ddad673b2fbf Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 23:34:35 -0500 Subject: [PATCH 11/16] fix(fleet): refuse v2 add/remove cleanly, and pin the Condition's effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the canary red->green run and scooby's review. 1. The v2 refusal in `add`/`remove` was a bare `throw`, which reaches the CLI top level uncaught and prints the guidance under a Node stack trace. The message *is* the point of the refusal, so it now goes through `command.error()` — the same clean path the roster-config error uses. Caught on canary, not in review: the unit tests asserted the message text and passed either way. 2. The unit-template test asserted only that ConditionPathExists is present. Presence is not effect. Added two tests for the parts that can drift in code while that assertion still passes: the condition resolving to exactly the file the fleet writes (%h/%i rendered against a real install), and the launcher genuinely failing on an absent generated env (exit 64, `missing-file`) — which is what makes the condition load-bearing rather than decorative. systemd is not available in the suite, so the effect itself was measured on canary (2026-08-16), roster v2 generation 3: with the condition: start rc=0, Result=success, ConditionResult=no, journal "skipped, unmet condition check" condition removed by drop-in, nothing else: start rc=1, Result=exit-code, ExecMainStatus=64, unit failed, "agent environment rejected: missing-file" Canary red->green for the three commands, same v2 roster, side by side: fleet ps 0.0.50-next.2413 rc=1 -> branch rc=0 (3 agents listed) fleet install 0.0.50-next.2413 rc=1 -> branch rc=0 fleet remove 0.0.50-next.2413 rc=1 -> branch rc=1, refusal naming delete + apply All three previously failed with "Fleet roster has unknown field(s): generation." The #791 negative was measured too: the six existing *.env.generated files were untouched by `install` (mtimes 20+ minutes older than the run). Gates: typecheck 0, eslint 0, prettier clean, fleet specs 382 passed, new spec 10/10 with the fix and 9/10 red against origin/next (the 10th passes there for an unrelated reason and is annotated as such). Full suite: only mutator-gate.acceptance.spec.ts fails, pre-existing on origin/next. Still true and still worth saying: a correct fix here shows install rc=0 and start rc=0 and STILL no live seat. #1240 (tmux absent) is upstream, #1241 (start reports lifecycle-complete over dead panes) and the missing agent runtime are downstream. Refs #1237 Reviewed-by: scooby (by git comms; cannot file a Gitea review from fomo-lin) --- .../systemd/user/mosaic-agent@.service | 8 --- .../commands/fleet-roster-v2-dispatch.spec.ts | 68 +++++++++++++++++-- packages/mosaic/src/commands/fleet.ts | 14 +++- 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/packages/mosaic/framework/systemd/user/mosaic-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-agent@.service index 81f76b54..f4d4a985 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-agent@.service +++ b/packages/mosaic/framework/systemd/user/mosaic-agent@.service @@ -4,14 +4,6 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack Requires=mosaic-tmux-holder.service After=mosaic-tmux-holder.service PartOf=mosaic-tmux-holder.service -# Do not attempt a seat before its generated env exists. `install` enables this -# unit (WantedBy=default.target) but on a roster-v2 fleet the reconciler owns the -# generated env, so between `install` and the first `apply`/`regen --write` there -# is a boot window where ExecStart would run against an absent env file and the -# launcher would fail the unit. A skipped unit is the honest state for "enabled -# but not yet configured"; systemd re-evaluates the condition on every start, so -# the seat comes up on the next start once the reconciler has written env. -ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated [Service] Type=oneshot diff --git a/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts b/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts index b0283a8b..12d039a0 100644 --- a/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts +++ b/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts @@ -1,3 +1,4 @@ +import { execFile } from 'node:child_process'; import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -208,16 +209,73 @@ describe('mosaic fleet install — roster v2', (): void => { }); describe('mosaic-agent@.service', (): void => { + const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', 'mosaic-agent@.service'); + + /** The single `ConditionPathExists=` value declared by the unit template. */ + async function conditionPath(): Promise { + const unit = await readFile(unitPath, 'utf8'); + const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? []; + expect(matches).toHaveLength(1); + return matches[0]!.slice('ConditionPathExists='.length).trim(); + } + it('will not attempt a seat before the reconciler has written its env', async (): Promise => { // The pairing that makes "install writes no env" safe: install enables the // unit (WantedBy=default.target) but does not start it, so without this // condition a reboot between `install` and the first `apply` would run // ExecStart against an absent env file and fail every seat unit. - const unit = await readFile( - resolve(process.cwd(), 'framework', 'systemd', 'user', 'mosaic-agent@.service'), - 'utf8', - ); - expect(unit).toContain('ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated'); + expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated'); + }); + + /** + * The two halves of the guard's *effect*, which no assertion on the literal + * string can cover on its own. + * + * Measured end to end on a real box (canary, 2026-08-16) rather than inferred: + * with the condition, `systemctl --user start mosaic-agent@` on an agent + * with no generated env returns rc=0, `Result=success`, `ConditionResult=no`, + * and journals "skipped, unmet condition check". With the condition removed by + * drop-in and nothing else changed, the same start returns rc=1, + * `Result=exit-code`, `ExecMainStatus=64`, and the unit enters `failed`. + * + * systemd is not available in this suite, so these two tests pin the parts + * that can drift in code: the condition naming a *different* file than the one + * the fleet actually writes, and the launcher quietly becoming tolerant of an + * absent env — either of which turns the condition into decoration while the + * literal-string assertion above still passes. + */ + it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise => { + const mosaicHome = await v2Home(); + const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0'); + + // The path an installed fleet actually places for this agent. + expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated')); + }); + + it('guards a real failure — the launcher rejects an absent generated env', async (): Promise => { + await v2Home(); + await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']); + + // Exactly what ExecStart runs, against the state the condition exists to + // catch: unit enabled, reconciler has not written env yet. + const launched = await new Promise<{ code: number | null; stderr: string }>((settle) => { + const child = execFile( + '/bin/bash', + [ + '--noprofile', + '--norc', + join(tempHome!, '.config', 'mosaic', 'tools', 'fleet', 'start-agent-session.sh'), + 'coder0', + ], + { env: { HOME: tempHome!, MOSAIC_AGENT_NAME: 'coder0', PATH: '/usr/bin:/bin' } }, + (_error, _stdout, stderr) => { + settle({ code: child.exitCode, stderr }); + }, + ); + }); + + expect(launched.code).not.toBe(0); + expect(launched.stderr).toContain('missing-file'); }); }); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index f356a894..b77c6e3a 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -1914,7 +1914,14 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = }, ) => { if (await usesRosterV2ControlPlane(cmd)) { - throw new Error(rosterV2MutationGuidance('add', 'create', name)); + // command.error, not a bare throw: this is operator guidance, and a + // bare throw reaches the top level uncaught and prints it under a Node + // stack trace. Measured on canary — the message is the whole point of + // the refusal, so it has to arrive readable. + cmd.error(rosterV2MutationGuidance('add', 'create', name), { + code: 'fleet.roster-v2', + exitCode: 1, + }); } if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) { throw new Error( @@ -1982,7 +1989,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .option('--keep-files', 'Skip deleting env and heartbeat files') .action(async (name: string, opts: { keepFiles?: boolean }) => { if (await usesRosterV2ControlPlane(cmd)) { - throw new Error(rosterV2MutationGuidance('remove', 'delete', name)); + cmd.error(rosterV2MutationGuidance('remove', 'delete', name), { + code: 'fleet.roster-v2', + exitCode: 1, + }); } const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const activePaths = resolveFleetPaths(commandOpts.mosaicHome); From 6f5b4c3dc1a4425c2e317f7ada174d710f00062b Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 23:35:54 -0500 Subject: [PATCH 12/16] fix(fleet): restore ConditionPathExists dropped by my own red-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-inflicted and worth recording rather than quietly amending. To prove the new tests were red without the fix I ran `git checkout origin/next -- `. That writes the *index*, not just the working tree. Copying my versions back afterwards restored the working tree only, so the unit file sat staged-as-origin/next and modified-in-tree, and the next commit (67f5014c) committed the index — silently removing the ConditionPathExists line that 463745e3 had added. Nothing caught it. The spec reads the file from the working tree, so it stayed 10/10 green against a HEAD that no longer had the guard. Found by reading `git status` after the push, not by any gate. Verified by content, not by assumption: origin/next 0 occurrences 463745e3 1 67f5014c 0 <- the regression this commit 1 Refs #1237 --- .../mosaic/framework/systemd/user/mosaic-agent@.service | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/mosaic/framework/systemd/user/mosaic-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-agent@.service index f4d4a985..81f76b54 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-agent@.service +++ b/packages/mosaic/framework/systemd/user/mosaic-agent@.service @@ -4,6 +4,14 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack Requires=mosaic-tmux-holder.service After=mosaic-tmux-holder.service PartOf=mosaic-tmux-holder.service +# Do not attempt a seat before its generated env exists. `install` enables this +# unit (WantedBy=default.target) but on a roster-v2 fleet the reconciler owns the +# generated env, so between `install` and the first `apply`/`regen --write` there +# is a boot window where ExecStart would run against an absent env file and the +# launcher would fail the unit. A skipped unit is the honest state for "enabled +# but not yet configured"; systemd re-evaluates the condition on every start, so +# the seat comes up on the next start once the reconciler has written env. +ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated [Service] Type=oneshot From 61a907a12f20947e895c898028c66b3639caf244 Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 23:56:53 -0500 Subject: [PATCH 13/16] fix(fleet): fail the agent launcher when the pane cannot survive (#1241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mosaic fleet start` returned 0 over three dead panes. The launcher knew, and said the wrong thing at the wrong severity to the wrong layer. The pane runs `mosaic yolo ` under PANE_PATH with a cleared environment. When that binary is absent the pane dies in under a second, tmux destroys the session, and the diagnostic goes with it. The launcher then found no PANE_PID, printed a WARNING about the *heartbeat sidecar*, and exited 0 — so systemd logged "Finished ... successfully" and `fleet start` reported success. `fleet ps` was the only component telling the truth. Two changes, both in start-agent-session.sh: 1. Before any effect, resolve `mosaic` and the roster's runtime against PANE_PATH — the pane's own view of the path, not the launcher's. `mosaic yolo ` calls checkRuntime(runtime) and looks for a binary named exactly like the runtime, so this asks the same question the pane will ask a moment later, while an operator can still see the answer. Absent binary -> exit 69, code=missing-binary, no session created. 2. Replace the dead-pane WARNING+exit-0. An absent session one second after new-session is a runtime that died on startup, not a heartbeat problem -> exit 69, code=pane-did-not-survive, with the command to run by hand to see why. A present session with no pane PID after five attempts -> code=pane-pid-unresolved. Neither branch kills the session; destroying a possibly-live pane on a guess is worse than leaving it for inspection. Exit 69 (EX_UNAVAILABLE) is deliberate: the 64s already in this file mean the projection was bad, and here the data is fine and the host is not ready. Callers separate the cases by `code=`, the same way fail_env's codes share 64. This propagates for free. `fleet start` calls runChecked() for the holder and each agent, and runChecked throws on non-zero, so layers 4 and 5 stop lying without a TypeScript change. Two adjacent defects are left for a follow-up issue rather than widened into this diff: the per-agent loop aborts on the first failure instead of attempting all and reporting an aggregate, and runChecked's bare throw surfaces the launcher's message under a Node unhandled-rejection stack trace because program.parse() is synchronous. Tests: - test-start-agent-session.sh gains three cases: `mosaic` absent from the pane path, the runtime absent from the pane path, and a pane that does not survive. Each was verified individually red against the unmodified origin/next launcher. - The two cases asserting a valid launch now supply a pane PID. Until now the suite's one success path was itself a dead pane the launcher reported as fine. - The harness fakes `npm` so PANE_PATH stops depending on whatever the host has installed, and fails loudly if the host provides `mosaic` or `pi` in the system path, where the missing-binary cases would not be measurable at all. - test-fleet-units.sh gains a `pi` shim in its runtime bin. The real-tmux harness named `pi` in its roster and never installed it; the new preflight caught it. Refs #1241 --- .../systemd/user/test-fleet-units.sh | 8 ++ .../tools/fleet/start-agent-session.sh | 45 +++++++++- .../tools/fleet/test-start-agent-session.sh | 85 ++++++++++++++++++- 3 files changed, 136 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/framework/systemd/user/test-fleet-units.sh b/packages/mosaic/framework/systemd/user/test-fleet-units.sh index 6973a9ce..ceb4ff70 100755 --- a/packages/mosaic/framework/systemd/user/test-fleet-units.sh +++ b/packages/mosaic/framework/systemd/user/test-fleet-units.sh @@ -128,6 +128,14 @@ EOF sleep 30 EOF chmod 700 "$AGENT_BIN/mosaic" + # The launcher resolves the roster's runtime against PANE_PATH before it + # spawns anything (#1241), so the runtime this projection names has to be + # present here even though the fake `mosaic` above never execs it. + cat > "$AGENT_BIN/pi" <<'EOF' +#!/bin/sh +sleep 30 +EOF + chmod 700 "$AGENT_BIN/pi" server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort) server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort) if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \ diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index 60e73d78..6faffa38 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -286,6 +286,36 @@ _build_runtime_bin_prefix() { MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix) PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin +# #1241. The pane runs `mosaic yolo ` under PANE_PATH with a cleared +# environment. A binary missing from *that* path is a pane that dies in under a +# second, inside a session nobody is attached to, with its diagnostic scrolled +# into a pane tmux then destroys. Resolve both here, before any effect, where +# the failure is still attributable to the thing that caused it. +# +# `mosaic yolo ` runs checkRuntime(runtime) and the binary it looks for +# is named exactly like the runtime, so resolving the runtime name is the same +# question the pane will ask a moment later — asked while an operator can still +# see the answer. +_resolve_in_pane_path() { + PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null +} + +# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the +# 64 (EX_USAGE) rejections above, which mean the projection itself was bad — +# here the data is fine and the host is not ready. Callers tell the individual +# cases apart by `code=`, the same way fail_env's many codes share exit 64. +fail_launch() { + local code="$1" + shift + echo "ERROR: agent launch aborted: code=${code} agent=${AGENT_NAME} $*" >&2 + exit 69 +} + +for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do + _resolve_in_pane_path "$required_binary" >/dev/null || + fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})" +done + _ensure_claude_workdir_trusted() { local workdir="$1" local resolved @@ -384,6 +414,19 @@ if [ -n "$PANE_PID" ]; then _start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \ "$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \ echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2 +elif _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then + # #1241. Session present, no pane PID after a second of retries. Whatever this + # is, it is not a seat an operator can use, so it is not a success either. + fail_launch pane-pid-unresolved \ + "tmux reports the session but no pane PID after 5 attempts" else - echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2 + # #1241. This branch used to print a WARNING about the heartbeat sidecar and + # exit 0. It is not a heartbeat problem: tmux destroys a session when its pane + # command exits, so an absent session one second after new-session means the + # runtime died on startup. Reporting it as success is what let `fleet start` + # return 0 over three dead panes — the launcher knew, and said the wrong thing + # at the wrong severity to the wrong layer. + fail_launch pane-did-not-survive \ + "the pane exited immediately and tmux destroyed the session;" \ + "run 'mosaic yolo ${MOSAIC_AGENT_RUNTIME}' in ${MOSAIC_AGENT_WORKDIR} to see why" fi diff --git a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh index 5d1c27d6..67f4911b 100755 --- a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh @@ -62,6 +62,30 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment" SHIM chmod +x "$FAKE_BIN/mosaic" +# The runtime the rosters below name. The launcher resolves it against PANE_PATH +# before spawning (#1241), so it has to exist somewhere the pane would find it — +# not merely on the launcher's own PATH. +printf '#!/usr/bin/env bash\nexit 0\n' > "$FAKE_BIN/pi" +chmod +x "$FAKE_BIN/pi" + +# PANE_PATH is derived partly from `npm config get prefix`. Left to the real npm +# it would splice whatever the host has installed into the path under test, and +# the missing-binary cases below would pass or fail by accident of the machine. +cat > "$FAKE_BIN/npm" <<'SHIM' +#!/usr/bin/env bash +printf '%s\n' "${MOSAIC_TEST_NPM_PREFIX:-/nonexistent}" +SHIM +chmod +x "$FAKE_BIN/npm" + +# PANE_PATH always ends in the system path. A host that installs these there can +# not measure the missing-binary cases at all, and a green run would mean +# nothing — so say so instead of passing. +for host_binary in mosaic pi; do + if PATH=/usr/local/bin:/usr/bin:/bin command -v "$host_binary" >/dev/null 2>&1; then + fail "host provides '$host_binary' in the system path; missing-binary cases are not measurable here" + fi +done + write_generated() { local home="$1" local agent="$2" @@ -81,6 +105,19 @@ MOSAIC_TMUX_SOCKET=mosaic-test EOF chmod 600 "$home/fleet/agents/$agent.env.generated" mkdir -p "$home/work" + install_pane_binaries "$home" +} + +# `$PANE_HOME/.npm-global/bin` is one of the prefixes the launcher folds into +# PANE_PATH, so this is the pane's own view of "installed", distinct from the +# launcher's PATH. Tests that need a binary *absent* remove it from here. +install_pane_binaries() { + local pane_home="$1" + mkdir -p "$pane_home/.npm-global/bin" + local binary + for binary in mosaic pi; do + ln -sf "$FAKE_BIN/$binary" "$pane_home/.npm-global/bin/$binary" + done } run_start() { @@ -98,7 +135,10 @@ run_start() { HOME_VALID="$ROOT/valid" AGENT_VALID="coder0" write_generated "$HOME_VALID" "$AGENT_VALID" -run_start "$HOME_VALID" "$AGENT_VALID" +# A live pane PID is part of what "valid launch" means. Until #1241 this case +# ran with none, so the suite's one success path was itself a dead pane the +# launcher reported as fine. +MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_VALID" "$AGENT_VALID" valid_args=$(tr '\0' '\n' < "$TMUX_CALLS") echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux" echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing" @@ -258,6 +298,7 @@ PATH="$PANE_STALE_PATH" \ "MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_TEST_EXECUTE_PANE=1 \ + "MOSAIC_TEST_PANE_PID=$$" \ "$START" coder-pane-boundary pane_args=$(tr '\0' '\n' < "$TMUX_CALLS") echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \ @@ -392,6 +433,48 @@ echo "$interaction_policy_args" | grep -qF 'new-session' && \ echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \ fail "interaction pinned-policy check did not follow strict parsing" +# #1241. The pane runs `mosaic yolo ` against PANE_PATH. A binary +# missing from that path is a launch failure, and it has to be named before the +# session is created — after it, the diagnostic dies with the pane. +assert_missing_pane_binary_rejected() { + local binary="$1" + local home="$ROOT/missing-$binary" + local agent="coder-missing-$binary" + write_generated "$home" "$agent" + rm -f "$home/.npm-global/bin/$binary" + + : > "$TMUX_CALLS" + local output + if output=$(MOSAIC_TEST_PANE_PID=$$ run_start "$home" "$agent" 2>&1); then + fail "launch succeeded with '$binary' absent from the pane PATH" + fi + echo "$output" | grep -qF 'code=missing-binary' || fail "missing '$binary' diagnostic missing" + echo "$output" | grep -qF "'$binary'" || fail "missing-binary diagnostic did not name $binary" + if tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session; then + fail "launcher created a session it knew would die ($binary absent)" + fi +} + +assert_missing_pane_binary_rejected mosaic +assert_missing_pane_binary_rejected pi + +# #1241. tmux destroys a session when its pane command exits, so no pane PID a +# second after new-session means the runtime died on startup. This used to be a +# WARNING about the heartbeat sidecar followed by exit 0 — three layers above it +# then reported a fleet that was not running. +: > "$TMUX_CALLS" +HOME_DEAD_PANE="$ROOT/dead-pane" +write_generated "$HOME_DEAD_PANE" "coder-dead-pane" +if output=$(MOSAIC_TEST_PANE_PID='' run_start "$HOME_DEAD_PANE" coder-dead-pane 2>&1); then + fail "launcher reported success over a pane that did not survive" +fi +echo "$output" | grep -qF 'code=pane-did-not-survive' || fail "dead-pane diagnostic missing" +if echo "$output" | grep -qiF 'heartbeat'; then + fail "dead pane is still being reported as a heartbeat-sidecar problem" +fi +tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session || \ + fail "dead-pane case did not reach the launch it is measuring" + # Exact stop derives the socket exclusively from the validated generated # projection and ignores an ambient socket supplied by the caller. : > "$TMUX_CALLS" From 10a1f82031808402d083ccf28652b2c394deda8f Mon Sep 17 00:00:00 2001 From: fred Date: Sun, 16 Aug 2026 00:19:25 -0500 Subject: [PATCH 14/16] test(fleet): cover the pane-pid-unresolved branch this PR shipped (#1241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding from scooby: this PR added a failure branch the harness structurally could not reach. The fake tmux answered `has-session` only for `=_holder:0.0`, so every non-holder agent landed in the session-is-gone branch no matter what — the `elif` (tmux still reports the session, no pane PID after the retries) had zero coverage and no way to get any. That is the same shape as the bug this PR exists to fix, one layer down: a code path shipped green where the gate that should measure it cannot. Less severe, because the branch fails closed at exit 69 rather than reporting success — but "the harness can't reach it" is the sentence that precedes the next silent regression, so it gets closed here rather than filed. `MOSAIC_TEST_HELD_SESSIONS` lets a case name targets the shim should also answer for. It answers them only AFTER `new-session`, and that detail is the whole trick: the launcher asks `has-session` about the same name twice — once at line 255 where a yes means "already running, exit 0", and once at 417 where a yes means "the session survived". A shim answering yes to both short-circuits at the first and never reaches the branch under test. It would have looked like coverage while measuring the idempotency path. Both failure modes were measured, not reasoned about: - toggle absent (the old shim): `code=pane-did-not-survive` — the case lands on the wrong branch, which is exactly the unreachability being reported. - toggle answering unconditionally: launcher exits 0 via the idempotency short-circuit — "launcher reported success over a session with no resolvable pane PID". - toggle gated on new-session: `code=pane-pid-unresolved`, exit 69. The case also asserts the diagnostic is not `pane-did-not-survive` and does not mention the heartbeat, so the two pane faults cannot collapse into one message. Gates: bash -n · launcher harness rc=0 · test-fleet-units.sh (real tmux) rc=0 · fleet specs 342 passed. Refs #1241. --- .../tools/fleet/test-start-agent-session.sh | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh index 67f4911b..eca95fdf 100755 --- a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh @@ -23,8 +23,26 @@ index=0 if [ "${args[0]:-}" = -L ]; then index=2; fi case "${args[$index]:-}" in has-session) + # The holder always answers. MOSAIC_TEST_HELD_SESSIONS lets a case add + # other targets that should answer too — without it there is no way to + # model "tmux still reports the session" for a non-holder agent, and the + # launcher's pane-pid-unresolved branch is unreachable from this harness. + # + # A listed target answers only AFTER new-session, because the launcher asks + # this question twice about the same name: once before launching, where a + # yes means "already running, nothing to do, exit 0", and once after, where + # a yes means "the session survived". A shim that answered yes to both + # would short-circuit at the first and never reach the branch under test — + # it would look like coverage and measure the idempotency path instead. for argument in "${args[@]}"; do [ "$argument" = '=_holder:0.0' ] && exit 0 + case " ${MOSAIC_TEST_HELD_SESSIONS:-} " in + *" $argument "*) + if tr '\0' '\n' < "${MOSAIC_TEST_TMUX_CALLS:?}" | grep -qxF new-session; then + exit 0 + fi + ;; + esac done exit 1 ;; @@ -125,6 +143,7 @@ run_start() { local agent="$2" HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \ MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \ + MOSAIC_TEST_HELD_SESSIONS="${MOSAIC_TEST_HELD_SESSIONS:-}" \ MOSAIC_TEST_HOME="$home" \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_HOME="$home" "$START" "$agent" @@ -475,6 +494,33 @@ fi tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session || \ fail "dead-pane case did not reach the launch it is measuring" +# #1241, the other way a pane fails. Above, tmux destroyed the session and +# has-session said so. Here the session is still there and no PID comes back +# after the retries — a different fault (the pane is alive but unusable, or +# tmux is answering inconsistently) that an operator has to be told apart from +# a runtime that died on startup. +# +# This case exists because the branch that handles it shipped with nothing able +# to reach it: the shim answered has-session only for the holder, so every +# non-holder agent landed in the session-is-gone branch no matter what. A +# defensive branch nothing exercises is the same shape as the bug this whole +# change is about, one layer down. +: > "$TMUX_CALLS" +HOME_NO_PID="$ROOT/pane-no-pid" +write_generated "$HOME_NO_PID" "coder-no-pid" +if output=$(MOSAIC_TEST_PANE_PID='' MOSAIC_TEST_HELD_SESSIONS='=coder-no-pid:0.0' \ + run_start "$HOME_NO_PID" coder-no-pid 2>&1); then + fail "launcher reported success over a session with no resolvable pane PID" +fi +echo "$output" | grep -qF 'code=pane-pid-unresolved' || \ + fail "session-present/no-PID was not reported as pane-pid-unresolved: $output" +if echo "$output" | grep -qF 'code=pane-did-not-survive'; then + fail "a session tmux still reports was diagnosed as a destroyed session" +fi +if echo "$output" | grep -qiF 'heartbeat'; then + fail "an unresolvable pane PID is still being reported as a heartbeat-sidecar problem" +fi + # Exact stop derives the socket exclusively from the validated generated # projection and ignores an ambient socket supplied by the caller. : > "$TMUX_CALLS" From 5c35a250de82f5d8a3057ec19d529400c67a17e7 Mon Sep 17 00:00:00 2001 From: fred Date: Sun, 16 Aug 2026 00:21:13 -0500 Subject: [PATCH 15/16] test(fleet): name what the pane-boundary case's binary check rides on (#1241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding from scooby. This case does not use run_start, so install_pane_binaries' symlinks land under a home its launcher never consults (HOME is the trusted parent here). It resolves mosaic and pi through MOSAIC_RUNTIME_BIN=$FAKE_BIN instead. Valid path, valid green — and a trap for anyone who later drops that env var believing the symlinks cover it, which would break the #1241 binary check rather than exercise it. Comment only; no behavior change. Harness rc=0. Refs #1241. --- .../framework/tools/fleet/test-start-agent-session.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh index eca95fdf..e7d8e94f 100755 --- a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh @@ -304,6 +304,13 @@ PANE_BASH_ENV="$ROOT/pane-boundary.bash-env" printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \ "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local" chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local" +# This case does not go through run_start, so its pane binaries come from +# MOSAIC_RUNTIME_BIN=$FAKE_BIN in the env.local written above — not from the +# symlinks install_pane_binaries planted under the generated home, which this +# launcher never consults because HOME here is the trusted parent. That is a +# legitimate resolution path, but it means dropping MOSAIC_RUNTIME_BIN from +# this case on the belief that the symlinks cover it would break the #1241 +# binary check rather than exercise it. LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \ BASH_ENV="$PANE_BASH_ENV" \ MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \ From c56483eb1bf59b564407ffada018c661d827cc0e Mon Sep 17 00:00:00 2001 From: fred Date: Sun, 16 Aug 2026 01:14:03 -0500 Subject: [PATCH 16/16] test(#1017): wire in four CI-fit shell suites, drop their signed exclusions check-test-enumeration.sh signed four suites as 'likely CI-fit; #1017 burndown'. Measured all four: each passes standing alone, and each still passes with tmux removed from PATH entirely (test-start-agent-session.sh writes its own tmux shim into a fake bin dir, so it never needed the real binary). Red-first: removing the four exclusion lines makes the guard report exactly four UNENUMERATED failures. Appending the four to test:framework-shell returns it to OK, with in-population enumerated going 32 -> 36 and signed exclusions 19 -> 15. Refs #1017 --- .../framework/tools/quality/test-enumeration-exclusions.txt | 4 ---- packages/mosaic/package.json | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt b/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt index 49186d6f..54f6ffe5 100644 --- a/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt +++ b/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt @@ -32,10 +32,6 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling) # --- single-suite directories: unmeasured in CI --- -packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown -packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown -packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown -packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown # --- naming-boundary files the strict test-*.sh prefix cannot even name --- # (#1017: three independent censuses handled the microtest file three different diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 4a2b8837..e1005a49 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh" + "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*",