fix(fleet): harden #791 upgrade rollback against find/reset failures
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

Second- and third-round independent-review reliability fixes on the keep-mode
upgrade rollback path, plus accurate abort messaging. All fixed red-first with
self-verifying controls in the rollback gate.

Round 2 (blockers A/B, should-fix C):
- install.sh: `trap 'restore_snapshot; exit 1' ERR INT TERM` so an INT/TERM
  mid-sync terminates instead of resuming past the interrupt and reporting
  success (a bash signal handler that only returns does not terminate).
- manifest.{ts,sh}: reject a degenerate [framework] section whose entries are
  all empty or bare-dot (`/`, `./`, `.`, `..`) — it passed the non-empty guard
  yet yielded zero usable globs, silently resolving everything to operator.
  Parity via a shared `[^/.]` usable-glob test; TS throws ManifestError.
- finalize.ts: classify the sync-abort message — a ManifestError is a pre-sync
  validation abort ("no files were changed"); any other error may be partial.

Round 3 (blockers D1, D2):
- install.sh: enumerate framework files with a checked temp file (_scan_or_die)
  instead of `< <(find …)` — process substitution discards find's exit status,
  so an EACCES/I/O failure mid-scan would truncate the file list yet leave the
  loop exiting 0, committing a partial upgrade as success (ERR trap never fires).
- install.sh: guard the `rm -rf; mkdir -p` target reset inside restore_snapshot
  — a bare reset failing under set -e exits silently after partial deletion,
  never printing the snapshot-recovery pointer. Now checked like the cp -a
  restore: on failure it preserves the snapshot and tells the operator where.

Tests: rollback gate 14→28 (Parts C/D/E with disabled-guard controls);
new finalize-sync-abort.spec.ts (3). No secret value is ever emitted; snapshots
stay 0700. Gates green: typecheck, lint, format:check, full mosaic vitest 1094,
HARD GATE 193, rollback 28, migration 21.

Refs #791

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hermes Agent
2026-07-16 17:30:19 -05:00
parent 0a5e703a70
commit af627e7583
11 changed files with 1003 additions and 26 deletions

View File

@@ -110,6 +110,11 @@ _manifest_compile() {
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
# Explicit success: an empty operator array makes the final `[[ -n "" ]] && …`
# short-circuit to rc 1, which would otherwise become this function's (and
# manifest_load's) return code — a spurious failure (#791 B2). Never rely on
# the last loop's exit status here.
return 0
}
# Load + compile the manifest. Rejects a malformed file the same way
@@ -117,6 +122,13 @@ _manifest_compile() {
manifest_load() {
local file="${1:-}"
[[ -n "$file" ]] || file="$(_manifest_default_root)/framework-manifest.txt"
# Fail CLOSED on a missing/unreadable manifest. Without this, `done < "$file"`
# aborts on a raw redirection error with no explanation; downstream that reads
# as "no framework paths" and an upgrade could no-op silently (#791 B2/B3).
if [[ ! -r "$file" ]]; then
echo "manifest: cannot read manifest file: $file — refusing to sync (fail-closed)." >&2
return 1
fi
MANIFEST_FRAMEWORK=()
MANIFEST_OPERATOR=()
local section="" line
@@ -139,7 +151,30 @@ manifest_load() {
MANIFEST_OPERATOR+=("$line")
fi
done < "$file"
# An empty or comment-only manifest defines NO framework-owned paths. Treating
# that as valid would make every path resolve operator and an upgrade prune
# nothing / write nothing — a silent no-op indistinguishable from success.
# Fail loud instead, mirroring parseManifest()'s throw in manifest.ts (#791 B2).
if [[ ${#MANIFEST_FRAMEWORK[@]} -eq 0 ]]; then
echo "manifest: no [framework] entries in $file — refusing to sync (empty or malformed manifest)." >&2
return 1
fi
# An entry like `/` or `./` normalizes to nothing and compiles to a glob that
# matches no path — so a manifest whose only [framework] entries are degenerate
# passes the count guard above but leaves the framework matcher empty: every
# path resolves operator, the exact silent no-op we fail closed against. Require
# at least one entry with a real (non-slash, non-dot) character. Mirrors
# parseManifest()'s `isUsableFrameworkGlob` `/[^/.]/` test in manifest.ts (#791 blocker-B).
local _g _usable=0
for _g in "${MANIFEST_FRAMEWORK[@]:-}"; do
if [[ "$(_manifest_norm "$_g")" =~ [^/.] ]]; then _usable=1; break; fi
done
if [[ "$_usable" -eq 0 ]]; then
echo "manifest: no usable [framework] entries in $file (every entry is empty or a bare dot segment) — refusing to sync (malformed manifest)." >&2
return 1
fi
_manifest_compile
return 0
}
# Fork-free: does $1 (a mosaic-home-relative path) match an operator glob?
@@ -196,7 +231,10 @@ manifest_subtree_roots() {
# CLI dispatch — only when executed directly, never when sourced.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
set -o pipefail
manifest_load "${MANIFEST_FILE:-}"
# Propagate a fail-closed manifest_load (missing/empty/malformed) as a non-zero
# exit instead of continuing to resolve against empty compiled arrays — that is
# what lets the parity test assert bash and TS reject the same bad inputs (#791 B2).
manifest_load "${MANIFEST_FILE:-}" || exit 1
cmd="${1:-}"
case "$cmd" in
resolve) manifest_resolve "${2:?path required}" ;;