#!/usr/bin/env bash # Regression harness for issue #869 Point-1 C2 — the install-ordering guard # wired into mosaic-link-runtime-assets. # # Root cause under test: mosaic-link-runtime-assets copies # runtime/claude/settings.json (which embeds the PreToolUse mutator-gate.py # hook and the Stop receipt-observer-client.py hook) straight into # ~/.claude/settings.json, unconditionally. If the lease-broker activation # half cannot be confirmed on this host, wiring those hooks bricks it with a # fail-closed gate that can never be satisfied. # # This harness never invokes a real `mosaic` CLI build — it stubs the # `__link-claude-settings` contract with a fake `mosaic` on PATH so the shell # WIRING (does mosaic-link-runtime-assets call out correctly? does it # propagate a degraded outcome? does it still copy every other runtime file? # does --allow-inactive-enforcement forward through?) is exercised # independently of the TS guard's own logic (already covered by # install-ordering-guard.spec.ts). It also exercises the no-mosaic-on-PATH # python3 fallback directly. # # Scenarios: # 1. probe=true (fake mosaic exits 0) -> settings.json copied, script exits 0. # 2. probe=false (fake mosaic exits 1) -> script exits 1 (guard_degraded # propagated), but every OTHER runtime file is still copied. # 3. probe=false + --allow-inactive-enforcement -> the flag is forwarded to # the fake mosaic stub. # 4. No `mosaic` on PATH at all (activation unconfirmable) -> the python3 # fallback strips the enforcement hooks itself and the script exits 1. # 5. No `mosaic` on PATH + --allow-inactive-enforcement -> the python3 # fallback wires the hooks AS-IS and the script exits 0. set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LINK_SCRIPT="$SCRIPT_DIR/mosaic-link-runtime-assets" TMP_ROOT=$(mktemp -d) trap 'rm -rf "$TMP_ROOT"' EXIT fail=0 fail_msg() { echo "FAIL: $*" >&2 fail=1 } FIXTURE_SETTINGS='{ "model": "opus", "hooks": { "PreToolUse": [ { "matcher": ".*", "hooks": [ { "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude" } ] }, { "matcher": "Write|Edit|MultiEdit", "hooks": [ { "type": "command", "command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude" }, { "type": "command", "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh" } ] } ] } }' # Sets up a fresh $MOSAIC_HOME/runtime/claude/{settings.json,CLAUDE.md, # hooks-config.json,context7-integration.md} + fresh $HOME, echoes both paths # space-separated for the caller to `read`. new_scenario_dirs() { local scenario="$1" local base="$TMP_ROOT/$scenario" local mosaic_home="$base/mosaic-home" local home="$base/home" mkdir -p "$mosaic_home/runtime/claude" "$home" printf '%s' "$FIXTURE_SETTINGS" > "$mosaic_home/runtime/claude/settings.json" echo "claude.md fixture" > "$mosaic_home/runtime/claude/CLAUDE.md" echo '{"hooks":{}}' > "$mosaic_home/runtime/claude/hooks-config.json" echo "context7 fixture" > "$mosaic_home/runtime/claude/context7-integration.md" echo "$mosaic_home" "$home" } settings_has_marker() { local file="$1" marker="$2" [[ -f "$file" ]] && grep -q "$marker" "$file" } # A fake `mosaic` binary implementing only the __link-claude-settings contract # this harness needs: writes dest verbatim (fixture is unmodified either way — # this stub only exercises the CALL CONTRACT, not the TS strip logic, which # has its own vitest coverage) and exits with the code the scenario wants. # Records the args it was called with so the harness can assert forwarding. make_fake_mosaic() { local bin_dir="$1" exit_code="$2" mkdir -p "$bin_dir" cat > "$bin_dir/mosaic" < "$bin_dir/mosaic.args" if [[ "\$1" == "__link-claude-settings" ]]; then cp "\$2" "\$3" exit $exit_code fi exit 0 EOF chmod +x "$bin_dir/mosaic" } # --- Scenario 1: probe=true (fake mosaic exits 0) --------------------------- read -r MOSAIC_HOME_1 HOME_1 < <(new_scenario_dirs scenario1) BIN_1="$TMP_ROOT/scenario1/bin" make_fake_mosaic "$BIN_1" 0 OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_1" HOME="$HOME_1" PATH="$BIN_1:$PATH" "$LINK_SCRIPT" 2>&1) STATUS=$? [[ "$STATUS" -eq 0 ]] || fail_msg "scenario1 (probe=true): expected exit 0, got $STATUS. Output: $OUTPUT" [[ -f "$HOME_1/.claude/settings.json" ]] || fail_msg "scenario1: settings.json was not copied" # --- Scenario 2: probe=false (fake mosaic exits 1) -------------------------- read -r MOSAIC_HOME_2 HOME_2 < <(new_scenario_dirs scenario2) BIN_2="$TMP_ROOT/scenario2/bin" make_fake_mosaic "$BIN_2" 1 OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_2" HOME="$HOME_2" PATH="$BIN_2:$PATH" "$LINK_SCRIPT" 2>&1) STATUS=$? [[ "$STATUS" -ne 0 ]] || fail_msg "scenario2 (probe=false, default): expected non-zero exit, got 0. Output: $OUTPUT" [[ -f "$HOME_2/.claude/CLAUDE.md" ]] || fail_msg "scenario2: CLAUDE.md was NOT copied even though it is independent of the settings.json guard" [[ -f "$HOME_2/.claude/hooks-config.json" ]] || fail_msg "scenario2: hooks-config.json was NOT copied" [[ -f "$HOME_2/.claude/context7-integration.md" ]] || fail_msg "scenario2: context7-integration.md was NOT copied" case "$OUTPUT" in *"NOT be wired"*|*"NOT wired"*) ;; *) fail_msg "scenario2: expected an actionable degraded-wiring message in output, got: $OUTPUT" ;; esac # --- Scenario 3: probe=false + --allow-inactive-enforcement forwards the flag read -r MOSAIC_HOME_3 HOME_3 < <(new_scenario_dirs scenario3) BIN_3="$TMP_ROOT/scenario3/bin" make_fake_mosaic "$BIN_3" 0 MOSAIC_HOME="$MOSAIC_HOME_3" HOME="$HOME_3" PATH="$BIN_3:$PATH" "$LINK_SCRIPT" --allow-inactive-enforcement >/dev/null 2>&1 RECORDED_ARGS="$(cat "$BIN_3/mosaic.args" 2>/dev/null || true)" case "$RECORDED_ARGS" in *"--allow-inactive-enforcement"*) ;; *) fail_msg "scenario3: --allow-inactive-enforcement was not forwarded to the mosaic CLI invocation (got: '$RECORDED_ARGS')" ;; esac # --- Scenario 4: no `mosaic` on PATH at all -> python3 fallback strips hooks read -r MOSAIC_HOME_4 HOME_4 < <(new_scenario_dirs scenario4) EMPTY_BIN="$TMP_ROOT/scenario4/empty-bin" mkdir -p "$EMPTY_BIN" # A PATH containing only python3 (for the fallback) + core utils, no mosaic. FALLBACK_PATH="$EMPTY_BIN:/usr/bin:/bin" OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_4" HOME="$HOME_4" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" 2>&1) STATUS=$? [[ "$STATUS" -ne 0 ]] || fail_msg "scenario4 (no mosaic on PATH, default): expected non-zero exit, got 0. Output: $OUTPUT" if settings_has_marker "$HOME_4/.claude/settings.json" "mutator-gate.py"; then fail_msg "scenario4: mutator-gate.py hook was wired even though mosaic could not be resolved (activation unconfirmable)" fi if settings_has_marker "$HOME_4/.claude/settings.json" "receipt-observer-client.py"; then fail_msg "scenario4: receipt-observer-client.py hook was wired even though mosaic could not be resolved" fi if ! settings_has_marker "$HOME_4/.claude/settings.json" "prevent-memory-write.sh"; then fail_msg "scenario4: the unrelated prevent-memory-write.sh hook was incorrectly dropped too" fi # --- Scenario 5: no `mosaic` on PATH + --allow-inactive-enforcement -------- read -r MOSAIC_HOME_5 HOME_5 < <(new_scenario_dirs scenario5) OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_5" HOME="$HOME_5" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" --allow-inactive-enforcement 2>&1) STATUS=$? [[ "$STATUS" -eq 0 ]] || fail_msg "scenario5 (no mosaic, opt-out): expected exit 0, got $STATUS. Output: $OUTPUT" if ! settings_has_marker "$HOME_5/.claude/settings.json" "mutator-gate.py"; then fail_msg "scenario5: mutator-gate.py hook should have been wired (explicit opt-out set)" fi case "$OUTPUT" in *"WARNING"*"--allow-inactive-enforcement"*) ;; *) fail_msg "scenario5: expected a loud WARNING mentioning --allow-inactive-enforcement, got: $OUTPUT" ;; esac if [[ "$fail" -eq 0 ]]; then echo "install-ordering-guard regression passed (5/5 scenarios)" fi exit "$fail"