diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-doctor b/packages/mosaic/framework/tools/_scripts/mosaic-doctor index 282adf01..9a1ddd01 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-doctor +++ b/packages/mosaic/framework/tools/_scripts/mosaic-doctor @@ -225,6 +225,54 @@ else warn "mosaic-ensure-sequential-thinking helper missing" fi +# Fleet transport binary (#1240). +# +# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every +# roster the CLI scaffolds sets `transport: tmux`, but nothing in the install +# path provides tmux and, until now, nothing here noticed it was absent. On a +# greenfield host that produced a fleet which installed clean, started clean, +# and had no live seat; `mosaic fleet ps` was the operator's first and only +# signal that anything was wrong. +# +# The roster's own `transport:` is read rather than assumed, so a host that +# declares something other than tmux is told about the binary it actually +# needs. Absent a roster the check still runs — `mosaic fleet init` will +# scaffold a tmux fleet on this host, and finding out beforehand is the point. +# +# `tools/install.sh` carries a deliberately parallel check at the end of its +# summary. The two are separate because the installer must be able to say this +# before the framework's own scripts are guaranteed to be on disk; keep their +# wording in step. +fleet_declared_transport() { + local roster="$MOSAIC_HOME/fleet/roster.yaml" + local declared="" + + if [[ -f "$roster" ]]; then + declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 | + tr -d '"'\''' | tr -d '\r' | awk '{print $1}')" + fi + + printf '%s\n' "${declared:-tmux}" +} + +check_fleet_transport() { + local transport + transport="$(fleet_declared_transport)" + + if command -v "$transport" >/dev/null 2>&1; then + pass "Fleet transport available: $transport" + return + fi + + if [[ -f "$MOSAIC_HOME/fleet/roster.yaml" ]]; then + warn "Fleet transport '$transport' is not installed — this host has a roster and no seat can launch. Install it (e.g. sudo apt-get install -y $transport), then 'mosaic fleet start'." + else + warn "Fleet transport '$transport' is not installed — 'mosaic fleet' cannot run seats here. Install it (e.g. sudo apt-get install -y $transport) before 'mosaic fleet init'." + fi +} + +check_fleet_transport + # Legacy migration surfaces should no longer contain symlink trees. legacy_paths=( "$HOME/.claude/agent-guides" diff --git a/packages/mosaic/framework/tools/_scripts/test-fleet-transport-check.sh b/packages/mosaic/framework/tools/_scripts/test-fleet-transport-check.sh new file mode 100755 index 00000000..35420266 --- /dev/null +++ b/packages/mosaic/framework/tools/_scripts/test-fleet-transport-check.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# Covers the #1240 fleet-transport checks in `mosaic-doctor` and in +# `tools/install.sh`. +# +# Both checks answer the same question — "can a seat actually launch on this +# host?" — from two different places, because the installer has to be able to +# answer it before the framework's own scripts are guaranteed to be on disk. +# Two implementations of one rule is exactly the shape that drifts, so this +# harness drives BOTH, in one file, from the same table of cases. +# +# The functions are extracted from the shipped scripts rather than copied here. +# A test that carries its own copy of the logic is a test that keeps passing +# after the shipped copy changes — the failure mode this whole change is about. +# Extraction is by exact function header and a closing brace in column one; if +# either script is reshaped so that stops matching, the extraction yields +# nothing and this fails loudly instead of silently measuring an empty string. +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) +DOCTOR="$SCRIPT_DIR/mosaic-doctor" +# framework/tools/_scripts -> framework/tools -> framework -> mosaic -> packages -> repo +INSTALLER=$(cd -- "$SCRIPT_DIR/../../../../.." && pwd)/tools/install.sh + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR" +[ -f "$INSTALLER" ] || fail "missing install.sh at $INSTALLER" + +ROOT=$(mktemp -d) +trap 'rm -rf "$ROOT"' EXIT + +# The cases below run with PATH set to a directory that deliberately does not +# contain a shell, and a PATH assignment on a command also governs how that +# command is looked up — so bash has to be named absolutely or it becomes the +# thing that is missing. +BASH_BIN=$(command -v bash) || fail "host is missing 'bash'" + +# A PATH containing exactly the utilities these functions use and nothing else. +# The absent-transport cases are only meaningful on a PATH where the transport +# is genuinely unresolvable, and this host (like most) has tmux in /usr/bin — +# so the system path cannot be part of the path under test. +FAKE_BIN="$ROOT/bin" +mkdir -p "$FAKE_BIN" +for utility in sed head tr awk; do + utility_path=$(command -v "$utility") || fail "host is missing '$utility'" + ln -s "$utility_path" "$FAKE_BIN/$utility" +done + +if PATH="$FAKE_BIN" command -v tmux >/dev/null 2>&1; then + fail "'tmux' is resolvable on the minimal test path; absent-transport cases are not measurable" +fi + +# Extract a function by its exact header, up to a closing brace in column one. +extract_function() { + local source_file="$1" + local function_name="$2" + local destination="$3" + + awk -v name="$function_name" ' + $0 == name "() {" { collecting = 1 } + collecting { print } + collecting && $0 == "}" { exit } + ' "$source_file" > "$destination" + + grep -qF "$function_name() {" "$destination" || + fail "could not extract '$function_name' from $source_file — has it been renamed or reshaped?" + # An unterminated extraction would be a syntax error the moment it is sourced, + # but saying so here names the cause instead of leaving a bash parse error. + bash -n "$destination" || + fail "extracted '$function_name' does not parse; the closing brace was probably not found" +} + +extract_function "$DOCTOR" fleet_declared_transport "$ROOT/doctor-declared.sh" +extract_function "$DOCTOR" check_fleet_transport "$ROOT/doctor-check.sh" +extract_function "$INSTALLER" check_fleet_transport "$ROOT/installer-check.sh" + +# Build a MOSAIC_HOME, optionally with a roster declaring a transport. +make_home() { + local home="$ROOT/$1" + local declared="${2-}" + + rm -rf "$home" + mkdir -p "$home" + if [ -n "$declared" ]; then + mkdir -p "$home/fleet" + cat > "$home/fleet/roster.yaml" <&1 +} + +run_installer_check() { + local home="$1" + local path="$2" + + MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c ' + set -euo pipefail + warn() { echo "[WARN] $*"; } + C="" RESET="" + MOSAIC_HOME="$1" + source "$2" + check_fleet_transport + ' _ "$home" "$ROOT/installer-check.sh" 2>&1 +} + +# A transport that exists. Named tmux because that is what the default roster +# declares; the binary never runs, it only has to resolve. +PRESENT_BIN="$ROOT/present-bin" +mkdir -p "$PRESENT_BIN" +printf '#!/usr/bin/env bash\nexit 0\n' > "$PRESENT_BIN/tmux" +chmod +x "$PRESENT_BIN/tmux" +PATH_WITH_TMUX="$PRESENT_BIN:$FAKE_BIN" + +# ── absent, no roster ──────────────────────────────────────────────────────── +# Nothing has been configured yet, so the honest thing to point at is `init`. +home=$(make_home no-roster) + +output=$(run_doctor_check "$home" "$FAKE_BIN") +echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn when tmux was absent" +echo "$output" | grep -qF 'tmux' || fail "doctor warning did not name the transport" +echo "$output" | grep -qF 'mosaic fleet init' || fail "doctor did not point a rosterless host at init" + +output=$(run_installer_check "$home" "$FAKE_BIN") +echo "$output" | grep -qF '[WARN]' || fail "installer did not warn when tmux was absent" +echo "$output" | grep -qF 'reports success and no seat comes up' || + fail "installer warning did not say what the missing transport actually breaks" + +# ── absent, roster present ─────────────────────────────────────────────────── +# A configured fleet that cannot launch is a stronger statement than a +# hypothetical one, and the message says so. +home=$(make_home with-roster tmux) + +output=$(run_doctor_check "$home" "$FAKE_BIN") +echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn with a roster present and tmux absent" +echo "$output" | grep -qF 'roster' || fail "doctor did not mention the roster it found" +echo "$output" | grep -qF 'mosaic fleet start' || fail "doctor did not point a configured host at start" + +# ── present ────────────────────────────────────────────────────────────────── +# Silence from the installer, and a pass (not a warning) from the audit. +for home_name in no-roster with-roster; do + home="$ROOT/$home_name" + + output=$(run_doctor_check "$home" "$PATH_WITH_TMUX") + if echo "$output" | grep -qF '[WARN]'; then + fail "doctor warned about the transport while tmux was present ($home_name)" + fi + echo "$output" | grep -qF '[OK]' || fail "doctor did not record a pass with tmux present ($home_name)" + + output=$(run_installer_check "$home" "$PATH_WITH_TMUX") + if [ -n "$output" ]; then + fail "installer was not silent with tmux present ($home_name): $output" + fi +done + +# ── the roster declares something other than tmux ──────────────────────────── +# The roster is read, not assumed. A host that declares a different transport +# is told about the binary it actually needs, and never about tmux — being sent +# to install the wrong package is worse than no advice at all. +home=$(make_home other-transport zellij) + +output=$(run_doctor_check "$home" "$PATH_WITH_TMUX") +echo "$output" | grep -qF 'zellij' || fail "doctor ignored the roster's declared transport" +if echo "$output" | grep -qF 'tmux'; then + fail "doctor named tmux for a host whose roster declares zellij" +fi + +output=$(run_installer_check "$home" "$PATH_WITH_TMUX") +echo "$output" | grep -qF 'zellij' || fail "installer ignored the roster's declared transport" +if echo "$output" | grep -qF 'tmux'; then + fail "installer named tmux for a host whose roster declares zellij" +fi + +# ── a quoted or trailing-comment transport value ───────────────────────────── +# YAML permits both and neither is exotic; a check that installs `tmux"` or +# reads `tmux # default` as a binary name would send the operator nowhere. +home=$(make_home quoted-transport '"tmux" # the only transport today') + +output=$(run_doctor_check "$home" "$PATH_WITH_TMUX") +echo "$output" | grep -qF '[OK] Fleet transport available: tmux' || + fail "doctor did not parse a quoted/commented transport value: $output" + +output=$(run_installer_check "$home" "$PATH_WITH_TMUX") +if [ -n "$output" ]; then + fail "installer did not parse a quoted/commented transport value: $output" +fi + +echo "ok - fleet transport checks (mosaic-doctor + install.sh)" diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 4a2b8837..066aa6b8 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/_scripts/test-fleet-transport-check.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" }, "dependencies": { "@mosaicstack/brain": "workspace:*", diff --git a/tools/install.sh b/tools/install.sh index bd76d265..5d28f773 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -390,6 +390,43 @@ ensure_prefix_on_path() { dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)" } +# Fleet transport binary (#1240). +# +# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every +# roster the CLI scaffolds sets `transport: tmux`, but nothing in this script +# provides tmux and, until now, nothing in it mentioned tmux at all. A +# greenfield host came out of this installer able to install a fleet, start a +# fleet, and run no seat — the operator's first signal was `mosaic fleet ps`. +# +# Not a `require_cmd`: tmux is required by the fleet, not by mosaic. Plenty of +# hosts install this to run `mosaic claude` and will never scaffold a roster, +# and failing their install over a binary they do not need would be wrong. It +# is a warning that names precisely what it blocks. +# +# `tools/_scripts/mosaic-doctor` carries a deliberately parallel check, so the +# same host state gets the same answer from an audit as from an install. They +# are separate implementations because this one has to work before the +# framework's scripts are guaranteed to be on disk; keep their wording in step. +check_fleet_transport() { + local transport=tmux + local roster="$MOSAIC_HOME/fleet/roster.yaml" + local declared="" + + if [[ -f "$roster" ]]; then + declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 | + tr -d '"'\''' | tr -d '\r' | awk '{print $1}')" + [[ -n "$declared" ]] && transport="$declared" + fi + + command -v "$transport" &>/dev/null && return 0 + + warn "Fleet transport '$transport' is not installed." + echo " The Mosaic fleet runs its agent seats inside $transport. Without it," + echo " ${C}mosaic fleet start${RESET} reports success and no seat comes up." + echo " Install it before using the fleet, e.g. ${C}sudo apt-get install -y $transport${RESET}" + echo " (this does not affect ${C}mosaic claude${RESET} or the other single-runtime commands)." +} + installed_cli_version() { local json json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true @@ -1106,6 +1143,11 @@ if [[ "$FLAG_CHECK" == "false" ]]; then ok "Done." fi +# Fleet readiness (#1240). Runs in both normal and --check mode: "what is the +# state of this host" is exactly the question --check is asked, and a host that +# cannot run a seat should not have to discover it from `fleet ps`. +check_fleet_transport + } # end main main "$@"