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>
181 lines
8.1 KiB
Bash
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"
|