feat(mosaic): manifest-owned upgrade guard so updates never wipe operator config (#791)
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>
This commit is contained in:
Hermes Agent
2026-07-16 15:47:28 -05:00
parent 87e21fd933
commit 34e55d4a2e
15 changed files with 1160 additions and 142 deletions

View File

@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# Shared bash reader for framework-manifest.txt (#791).
#
# This is the bash half of the SSOT ownership resolver; the TypeScript half is
# packages/mosaic/src/framework/manifest.ts. BOTH read the same
# framework-manifest.txt and MUST resolve identical ownership for any path — the
# parity test (manifest-parity.spec.ts) invokes this file's `resolve` CLI and
# compares it against the TS resolver, so the two can never drift (the #631
# two-copies failure class this closes).
#
# Ownership resolution (deny-wins / fail-safe):
# 1. operator glob matches -> operator
# 2. else framework glob -> framework
# 3. else -> operator (UNKNOWN defaults to operator, #791)
#
# Globs are compiled once at load into exact-prefix checks or POSIX EREs, so the
# hot resolver (manifest_is_framework) forks no subprocesses — the installer
# calls it once per file across the whole tree.
#
# Usage as a library (source it, then):
# manifest_load [manifest-file] # populates + compiles the manifest
# manifest_is_framework <rel-path> # rc 0 = framework-owned, rc 1 = operator
# manifest_resolve <rel-path> # echoes: framework | operator
# manifest_subtree_roots # echoes shipped framework `dir/**` roots
#
# Usage as a CLI (parity harness):
# bash manifest.sh resolve <rel-path>
# bash manifest.sh subtree-roots
# bash manifest.sh classify # reads paths on stdin -> "<own>\t<path>"
MANIFEST_FRAMEWORK=()
MANIFEST_OPERATOR=()
# Compiled forms (parallel arrays). _*_KIND[i] is "exact" or "re".
_MF_KIND=(); _MF_EXACT=(); _MF_RE=()
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
_MF_ROOTS=()
_manifest_default_root() { cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd; }
# Normalize a path/glob: backslashes -> slashes, strip leading ./ and /, strip
# trailing / (mirrors normalizeRel in manifest.ts).
_manifest_norm() {
local p="$1"
p="${p//\\//}"
p="${p#./}"
while [[ "$p" == /* ]]; do p="${p#/}"; done
while [[ "$p" == */ ]]; do p="${p%/}"; done
printf '%s' "$p"
}
# Translate a normalized glob into a POSIX ERE body (mirrors globToRegExpBody).
_manifest_glob_to_ere() {
local pattern; pattern="$(_manifest_norm "$1")"
local out="" c n i len=${#pattern} trailing
for (( i = 0; i < len; i++ )); do
c="${pattern:i:1}"
if [[ "$c" == "*" ]]; then
n="${pattern:i+1:1}"
if [[ "$n" == "*" ]]; then
i=$((i + 1))
trailing=0
if [[ "${pattern:i+1:1}" == "/" ]]; then i=$((i + 1)); trailing=1; fi
if [[ "$out" == */ ]]; then
out="${out%/}(/.*)?"
elif [[ "$trailing" -eq 1 ]]; then
out="$out(.*/)?"
else
out="$out.*"
fi
else
out="$out[^/]*"
fi
else
case "$c" in
.|+|\?|^|\$|\{|\}|\(|\)|\||\[|\]|\\) out="$out\\$c" ;;
*) out="$out$c" ;;
esac
fi
done
printf '%s' "$out"
}
# Compile one raw glob into (kind, exact, re) appended to the given section.
# $1 = raw glob, $2 = section letter (F|O).
_manifest_compile_one() {
local norm; norm="$(_manifest_norm "$1")"
[[ -n "$norm" ]] || return 0
if [[ "$norm" == *"*"* ]]; then
local re="^$(_manifest_glob_to_ere "$norm")\$"
if [[ "$2" == F ]]; then
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
else
_MO_KIND+=(re); _MO_EXACT+=(""); _MO_RE+=("$re")
fi
else
if [[ "$2" == F ]]; then
_MF_KIND+=(exact); _MF_EXACT+=("$norm"); _MF_RE+=("")
else
_MO_KIND+=(exact); _MO_EXACT+=("$norm"); _MO_RE+=("")
fi
fi
[[ "$2" == F && "$norm" == */"**" ]] && _MF_ROOTS+=("${norm%/**}")
return 0
}
_manifest_compile() {
_MF_KIND=(); _MF_EXACT=(); _MF_RE=(); _MF_ROOTS=()
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
local g
for g in "${MANIFEST_FRAMEWORK[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" F; done
for g in "${MANIFEST_OPERATOR[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" O; done
}
# Load + compile the manifest. Rejects a malformed file the same way
# parseManifest() does (entry before a section header / unknown header).
manifest_load() {
local file="${1:-}"
[[ -n "$file" ]] || file="$(_manifest_default_root)/framework-manifest.txt"
MANIFEST_FRAMEWORK=()
MANIFEST_OPERATOR=()
local section="" line
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line#"${line%%[![:space:]]*}"}" # ltrim
line="${line%"${line##*[![:space:]]}"}" # rtrim
[[ -z "$line" || "${line:0:1}" == "#" ]] && continue
case "$line" in
"[framework]") section=framework; continue ;;
"[operator]") section=operator; continue ;;
"["*) echo "manifest: unknown section header: $line" >&2; return 1 ;;
esac
if [[ -z "$section" ]]; then
echo "manifest: entry before any [section] header: $line" >&2
return 1
fi
if [[ "$section" == framework ]]; then
MANIFEST_FRAMEWORK+=("$line")
else
MANIFEST_OPERATOR+=("$line")
fi
done < "$file"
_manifest_compile
}
# Fork-free: does $1 (a mosaic-home-relative path) match an operator glob?
_mo_matches() {
local path="$1" i n=${#_MO_KIND[@]} re pat
for (( i = 0; i < n; i++ )); do
if [[ "${_MO_KIND[i]}" == exact ]]; then
pat="${_MO_EXACT[i]}"
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
else
re="${_MO_RE[i]}"
[[ "$path" =~ $re ]] && return 0
fi
done
return 1
}
# Fork-free: does $1 match a framework glob?
_mf_matches() {
local path="$1" i n=${#_MF_KIND[@]} re pat
for (( i = 0; i < n; i++ )); do
if [[ "${_MF_KIND[i]}" == exact ]]; then
pat="${_MF_EXACT[i]}"
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
else
re="${_MF_RE[i]}"
[[ "$path" =~ $re ]] && return 0
fi
done
return 1
}
# The installer hot path — no subshell. rc 0 = framework-owned, rc 1 = operator
# (deny-wins / fail-safe). Assumes an already-clean POSIX relative path.
manifest_is_framework() {
_mo_matches "$1" && return 1
_mf_matches "$1" && return 0
return 1
}
# Echo the ownership of a path: framework | operator. Normalizes first, so it is
# safe for CLI / test callers passing unnormalized input.
manifest_resolve() {
local path; path="$(_manifest_norm "$1")"
if manifest_is_framework "$path"; then echo framework; else echo operator; fi
}
# Echo each shipped framework subtree root (a `dir/**` entry, without the /**).
manifest_subtree_roots() {
local r
for r in "${_MF_ROOTS[@]:-}"; do [[ -n "$r" ]] && printf '%s\n' "$r"; done
}
# CLI dispatch — only when executed directly, never when sourced.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
set -o pipefail
manifest_load "${MANIFEST_FILE:-}"
cmd="${1:-}"
case "$cmd" in
resolve) manifest_resolve "${2:?path required}" ;;
subtree-roots) manifest_subtree_roots ;;
classify)
while IFS= read -r p; do
[[ -z "$p" ]] && continue
printf '%s\t%s\n' "$(manifest_resolve "$p")" "$p"
done
;;
*)
echo "usage: manifest.sh {resolve <path>|subtree-roots|classify}" >&2
exit 2
;;
esac
fi