Files
stack/packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh
jason.woltje d351caad36
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
feat(869-c2): install-ordering enforcement-hook guard (Part of #869)
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @b6f36564 (8/8, verified vs real production settings template), author id2 != approver id11, clean mosaic-coder author, CI green wp1988.

Co-authored-by: jason.woltje <jason@diversecanvas.com>
Co-committed-by: jason.woltje <jason@diversecanvas.com>
2026-07-23 18:48:33 +00:00

181 lines
8.1 KiB
Bash

#!/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" <<EOF
#!/usr/bin/env bash
set -euo pipefail
echo "\$@" > "$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"