All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Invert the framework updater from a denylist ("framework owns everything unless
preserved") to an explicit allow-list manifest ("operator owns everything unless
framework"). A path the manifest never anticipated resolves to operator-owned by
the fail-safe default, so it is structurally unreachable by any write or prune.
Root cause (#791): `mosaic update` re-seeds via `install.sh` keep-mode, whose
`rsync -a --delete` + hand-maintained PRESERVE_PATHS denylist wiped operator
paths the denylist forgot (agents/*.conf, policy/*.md, *.local.md, harvester
SOP, tools/_lib/credentials.json, unanticipated fleet files).
- framework-manifest.txt: single SSOT ([framework]/[operator], deny-wins,
UNKNOWN=>operator fail-safe), read by BOTH installers.
- src/framework/manifest.ts: pure resolver (parse/matchGlob/resolveOwnership/
frameworkSubtreeRoots/planPrune) — the testable seam.
- tools/_lib/manifest.sh: bash resolver (compiled globs, fork-free hot path),
sourced by install.sh; parity-tested against the TS resolver.
- install.sh keep mode is now manifest-driven (no --delete): overlay-copy
framework files, scoped-prune only retired framework files inside shipped
subtrees. Operator + unknown paths are never written or deleted.
- file-ops.syncDirectory gains an isOperatorOwned guard; file-adapter derives it
from the shared manifest, replacing the drifted hardcoded preservePaths.
Tests (TDD, red->green):
- HARD GATE test-upgrade-manifest-guard.sh: 10 operator sentinels (incl. an
unanticipated one) survive a keep-mode reseed byte-identical + mtime-unchanged;
retired framework file pruned; secret value absent from output. RED 31 fail on
the old installer -> GREEN 48 pass. Wired merge-blocking into CI.
- manifest-parity.spec.ts (§6.1): bash<->TS agree on 34 paths + subtree roots.
- manifest.spec.ts: 18 tests incl. planPrune property test + shipped-tree
completeness (§6.2).
- test-install-migration.sh F6 flipped: an unanticipated operator fleet file now
MUST survive keep-mode reseed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
122 lines
5.1 KiB
Bash
122 lines
5.1 KiB
Bash
#!/usr/bin/env bash
|
|
# test-upgrade-manifest-guard.sh — the #791 HARD GATE.
|
|
#
|
|
# Proves that a keep-mode framework upgrade (the `mosaic update` path:
|
|
# install.sh with MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1) touches NO path
|
|
# outside the framework-owned manifest. Every operator-owned sentinel — including
|
|
# a deliberately UNANTICIPATED one the manifest never names — must survive
|
|
# byte-identical with an unchanged mtime (not even rewritten). Framework files
|
|
# must still update, and a retired framework file inside a shipped subtree must
|
|
# still be pruned. No operator secret value may appear in installer output.
|
|
#
|
|
# Runs the whole matrix twice: once with rsync available, once forcing the
|
|
# cp-based fallback (both destructive paths in install.sh must obey the manifest).
|
|
#
|
|
# Usage: bash test-upgrade-manifest-guard.sh
|
|
set -uo pipefail
|
|
|
|
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
|
INSTALL="$FW/install.sh"
|
|
|
|
pass=0; fail=0
|
|
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
|
|
|
SECRET='SUPER-SECRET-TOKEN-do-not-log-3f9a'
|
|
|
|
# Seed a throwaway MOSAIC_HOME with an operator sentinel per ownership class.
|
|
seed_home() {
|
|
local H="$1"
|
|
mkdir -p "$H/agents" "$H/policy" "$H/memory" "$H/tools/_lib" \
|
|
"$H/fleet/agents" "$H/harvester" "$H/unknown-operator-dir" "$H/guides"
|
|
printf '# persona\n' > "$H/SOUL.md" # marks a recognized existing install → keep mode
|
|
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
|
|
printf '# operator policy\n' > "$H/policy/custom.md"
|
|
printf '# soul overlay\n' > "$H/SOUL.local.md"
|
|
printf '# operator memory\n' > "$H/memory/note.md"
|
|
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
|
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$H/fleet/agents/coder0.env"
|
|
printf 'version: 2\nagents:\n - name: coder0\n' > "$H/fleet/roster.yaml"
|
|
printf '# harvester SOP\n' > "$H/harvester/sop.md"
|
|
printf 'operator data the manifest never anticipated\n' > "$H/unknown-operator-dir/x"
|
|
printf 'version: 1\nagents:\n - name: mine\n' > "$H/fleet/my-fleet.yaml"
|
|
# A retired framework file inside a shipped subtree (absent from source) — must be pruned.
|
|
printf '# retired guide\n' > "$H/guides/RETIRED-OLD-GUIDE.md"
|
|
echo 3 > "$H/.framework-version"
|
|
}
|
|
|
|
OPERATOR_SENTINELS=(
|
|
"agents/coder0.conf"
|
|
"policy/custom.md"
|
|
"SOUL.local.md"
|
|
"memory/note.md"
|
|
"tools/_lib/credentials.json"
|
|
"fleet/agents/coder0.env"
|
|
"fleet/roster.yaml"
|
|
"harvester/sop.md"
|
|
"unknown-operator-dir/x"
|
|
"fleet/my-fleet.yaml"
|
|
)
|
|
|
|
run_matrix() {
|
|
local label="$1"; shift # extra env / PATH override applied to the run
|
|
local H E OUT rel before_hash after_hash before_mt after_mt
|
|
H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
|
|
seed_home "$H"
|
|
|
|
# Snapshot hash + mtime of every operator sentinel before the upgrade.
|
|
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
|
sha256sum "$H/$rel" | awk '{print $1}' > "$E/$(echo "$rel" | tr / _).hash"
|
|
stat -c %Y "$H/$rel" > "$E/$(echo "$rel" | tr / _).mt"
|
|
done
|
|
|
|
# The upgrade under test (keep + sync-only = the `mosaic update` reseed path).
|
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 "$@" bash "$INSTALL" >"$OUT" 2>&1
|
|
|
|
# HARD GATE: every operator sentinel survives byte-identical AND mtime-unchanged.
|
|
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
|
before_hash=$(cat "$E/$(echo "$rel" | tr / _).hash")
|
|
before_mt=$(cat "$E/$(echo "$rel" | tr / _).mt")
|
|
after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
|
|
after_mt=$(stat -c %Y "$H/$rel" 2>/dev/null || echo MISSING)
|
|
chk "[$label] operator sentinel survives byte-identical: $rel" \
|
|
"[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
|
|
chk "[$label] operator sentinel not rewritten (mtime unchanged): $rel" \
|
|
"[ '$before_mt' = '$after_mt' ]"
|
|
done
|
|
|
|
# Positive controls: the framework tree still updates, retired file pruned.
|
|
chk "[$label] framework file present after upgrade (guides synced)" \
|
|
"[ -f '$H/guides/E2E-DELIVERY.md' ]"
|
|
chk "[$label] retired framework file inside a subtree is pruned" \
|
|
"[ ! -f '$H/guides/RETIRED-OLD-GUIDE.md' ]"
|
|
chk "[$label] manifest itself is installed" "[ -f '$H/framework-manifest.txt' ]"
|
|
|
|
# Secret-safety: the operator secret value never appears in installer output.
|
|
chk "[$label] operator secret value absent from installer stdout/stderr" \
|
|
"! grep -q '$SECRET' '$OUT'"
|
|
|
|
rm -rf "$H" "$E" "$OUT"
|
|
}
|
|
|
|
echo "#791 upgrade manifest guard (HARD GATE):"
|
|
|
|
# 1) rsync path (if available on this host).
|
|
if command -v rsync >/dev/null 2>&1; then
|
|
run_matrix "rsync"
|
|
else
|
|
echo " · rsync not installed — skipping rsync-path matrix"
|
|
fi
|
|
|
|
# 2) cp fallback path — hide rsync behind a scratch PATH so install.sh takes the
|
|
# find/rm+cp branch. Provide the coreutils the fallback needs.
|
|
FBIN=$(mktemp -d)
|
|
for t in bash cp find mktemp rm mkdir chmod cmp sed grep cat dirname basename stat sha256sum awk tr; do
|
|
p=$(command -v "$t" 2>/dev/null) && ln -s "$p" "$FBIN/$t"
|
|
done
|
|
run_matrix "cp-fallback" env "PATH=$FBIN"
|
|
rm -rf "$FBIN"
|
|
|
|
echo
|
|
echo "RESULT: $pass passed, $fail failed"
|
|
[ "$fail" -eq 0 ]
|