This commit was merged in pull request #802.
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# -E (errtrace): the ERR trap must propagate INTO functions and command
|
||||
# substitutions. Without it the `trap restore_snapshot ERR` set below is dead
|
||||
# code for any failure inside sync_framework_keep() (its whole body runs in a
|
||||
# function) — a mid-sync failure would abort with a half-written target and NO
|
||||
# rollback (#791 B1). Keep -E first so every later function inherits the trap.
|
||||
set -Eeuo pipefail
|
||||
|
||||
# ─── Mosaic Framework Installer ──────────────────────────────────────────────
|
||||
#
|
||||
@@ -19,32 +24,19 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
|
||||
# Files/dirs protected from rsync --delete during sync. NOTE: framework-owned
|
||||
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
|
||||
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
|
||||
# User-created content in these paths survives rsync --delete.
|
||||
#
|
||||
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
|
||||
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
|
||||
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
|
||||
# so no per-file entry is needed; exact preserved roster paths are anchored to
|
||||
# the top level only and do NOT shadow fleet/profiles/*.yaml). The user's
|
||||
# own fleet files MUST
|
||||
# survive `mosaic update` (which runs this sync automatically): the active
|
||||
# rosters (`fleet/roster.yaml` and `fleet/roster.json`), per-agent env
|
||||
# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native
|
||||
# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see
|
||||
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
|
||||
# wipes the operator's fleet AND their backlog. Glob entries are honored by
|
||||
# both the rsync path (`--exclude`) and the glob-aware cp fallback below.
|
||||
#
|
||||
# fleet/roles.local — the persona OVERRIDE layer (H4). Baseline personas in
|
||||
# fleet/roles/ are reseeded normally on every update (delivering new baseline
|
||||
# personas), so any local edit there would be clobbered. User customizations
|
||||
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
|
||||
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
|
||||
# packages/mosaic/src/commands/fleet-personas.ts).
|
||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
|
||||
# Shared framework path-ownership manifest reader (#791). Parity with
|
||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||
# shellcheck source=tools/_lib/manifest.sh
|
||||
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
||||
|
||||
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
||||
# denylist. It is derived from the shared framework-manifest.txt (#791): the
|
||||
# updater only ever creates/overwrites framework-owned paths and only prunes a
|
||||
# retired framework file inside a shipped framework subtree. Everything else —
|
||||
# every operator file, and every path the manifest never anticipated — is
|
||||
# operator-owned by default (fail-safe) and is never written or deleted. See
|
||||
# sync_framework_keep() below and packages/mosaic/src/framework/manifest.ts.
|
||||
|
||||
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
|
||||
# user must not edit them; a divergent copy is backed up once before overwrite).
|
||||
@@ -75,14 +67,45 @@ step() { echo -e "\n${BOLD}$1${RESET}"; }
|
||||
SNAPSHOT_DIR=""
|
||||
make_snapshot() {
|
||||
is_existing_install || return 0
|
||||
# mktemp -d creates the dir 0700 — the snapshot (which mirrors operator config,
|
||||
# possibly including secrets) is never world-readable.
|
||||
SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")"
|
||||
cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true
|
||||
# The snapshot MUST be complete: restore rebuilds the target from it, so a
|
||||
# partial capture (unreadable file, disk-full, I/O error) would silently
|
||||
# discard whatever it missed. If cp -a cannot copy the whole tree, abort NOW —
|
||||
# before the restore trap is armed and before anything is mutated. Fail closed
|
||||
# rather than proceed with a snapshot we cannot trust (#791 blocker-2).
|
||||
if ! cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/"; then
|
||||
fail "Could not capture a complete pre-upgrade snapshot of $TARGET_DIR — aborting before any changes were made (fail-closed)."
|
||||
rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
restore_snapshot() {
|
||||
# Disarm the trap first: restore runs under `set -e`, and a non-zero step
|
||||
# inside it must not re-enter this handler (errtrace makes ERR fire in
|
||||
# functions now). One restore attempt, then let the script exit non-zero.
|
||||
trap - ERR INT TERM
|
||||
[[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0
|
||||
fail "Install interrupted/failed — restoring previous state from snapshot"
|
||||
rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"
|
||||
cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true
|
||||
# Reset the target before rebuilding from the snapshot — but CHECK it. Under
|
||||
# `set -e` (trap already disarmed) a bare `rm -rf; mkdir -p` that fails would
|
||||
# exit the whole script immediately, after `rm` may have deleted part of the
|
||||
# target, WITHOUT ever printing the recovery pointer below — the operator would
|
||||
# be left with a half-removed target and no idea the snapshot survives in /tmp.
|
||||
# Test the reset explicitly (like the cp -a below), and on failure keep the
|
||||
# snapshot and tell the operator where it is (#791 blocker-D2).
|
||||
if ! rm -rf "$TARGET_DIR" || ! mkdir -p "$TARGET_DIR"; then
|
||||
fail "Snapshot restore could not reset $TARGET_DIR. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
|
||||
return 1
|
||||
fi
|
||||
# Surface an incomplete restore instead of swallowing it: the snapshot is the
|
||||
# last good copy, so if cp cannot fully rebuild the target we must NOT delete
|
||||
# the snapshot — point the operator at it for manual recovery (#791 blocker-2).
|
||||
if ! cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/"; then
|
||||
fail "Snapshot restore did not complete cleanly. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }
|
||||
|
||||
@@ -184,63 +207,105 @@ sync_framework() {
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
|
||||
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
# Anchor to the transfer root (leading /) so we preserve the TOP-LEVEL
|
||||
# ~/.config/mosaic/<file> without also excluding defaults/<file> from sync
|
||||
# (reconcile_framework_files needs the freshly-synced defaults/ copies).
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
rsync_args+=(--exclude "/$path")
|
||||
done
|
||||
fi
|
||||
|
||||
rsync "${rsync_args[@]}" "$SOURCE_DIR/" "$TARGET_DIR/"
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
# The `mosaic update` path. Manifest-driven, never-deleting-outside-framework:
|
||||
# operator config is structurally protected (#791). No rsync --delete here.
|
||||
# The manifest is already loaded+validated in main() BEFORE the snapshot/trap
|
||||
# (a fail-closed manifest must abort without ever restoring over operator
|
||||
# files — see the pre-flight in main, #791 blocker-1).
|
||||
sync_framework_keep
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback: cp-based sync. Exact top-level preserved paths mirror the
|
||||
# root-anchored rsync excludes above.
|
||||
local preserve_tmp=""
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
|
||||
local match rel
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
# Unquoted $path lets the glob expand against TARGET_DIR; nullglob makes a
|
||||
# non-matching pattern vanish instead of staying literal.
|
||||
shopt -s nullglob
|
||||
for match in "$TARGET_DIR/"$path; do
|
||||
[[ -e "$match" ]] || continue
|
||||
rel="${match#"$TARGET_DIR/"}"
|
||||
mkdir -p "$preserve_tmp/$(dirname "$rel")"
|
||||
cp -R "$match" "$preserve_tmp/$rel"
|
||||
done
|
||||
shopt -u nullglob
|
||||
done
|
||||
fi
|
||||
# overwrite mode — a full replace, chosen only for a fresh install or when the
|
||||
# operator explicitly asks to replace everything. No operator state to protect.
|
||||
sync_framework_overwrite
|
||||
}
|
||||
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" -exec rm -rf {} +
|
||||
# Enumerate a NUL-delimited file list via `find` into the temp file $1, failing
|
||||
# CLOSED if find errors. We capture to a checked file instead of consuming
|
||||
# `< <(find …)` directly because a process substitution discards the producer's
|
||||
# exit status: an EACCES/I/O failure partway through a scan would truncate the
|
||||
# list yet leave the reading `while` loop exiting 0, so a partial upgrade would
|
||||
# commit and report success and the ERR/restore trap would never fire. Running
|
||||
# find to completion first, then checking its status, turns that silent
|
||||
# truncation into a fail-closed abort that the restore trap can act on (#791
|
||||
# blocker-D1). $1 after the shift is the scan root — named in the error.
|
||||
_scan_or_die() {
|
||||
local out="$1"; shift
|
||||
if ! find "$@" -print0 > "$out"; then
|
||||
fail "Could not enumerate framework files under '$1' — aborting before committing an incomplete sync (fail-closed)."
|
||||
return 1 # D1-GUARD
|
||||
fi
|
||||
}
|
||||
|
||||
# Keep-mode sync: create/refresh framework-owned files and prune only retired
|
||||
# framework files inside shipped framework subtrees. Operator-owned and unknown
|
||||
# paths (fail-safe default) are never written and never deleted — the #791 HARD
|
||||
# GATE. Single code path (no rsync) so it is byte-for-byte parity-testable.
|
||||
sync_framework_keep() {
|
||||
local src="$SOURCE_DIR" dst="$TARGET_DIR" abs rel root list
|
||||
|
||||
# 1) Overlay copy — every framework-owned source file, refreshed only when its
|
||||
# bytes changed (no mtime churn on unchanged files, never on operator files).
|
||||
# The source scan is captured fail-closed (#791 blocker-D1): a find failure
|
||||
# aborts the sync (→ ERR trap → restore) rather than silently truncating it.
|
||||
list="$(mktemp)"
|
||||
_scan_or_die "$list" "$src" -type f || { rm -f "$list"; return 1; }
|
||||
while IFS= read -r -d '' abs; do
|
||||
rel="${abs#"$src"/}"
|
||||
case "$rel" in
|
||||
.git|.git/*|.framework-version|*.pre-constitution.bak) continue ;;
|
||||
esac
|
||||
manifest_is_framework "$rel" || continue
|
||||
if [[ -f "$dst/$rel" ]] && cmp -s "$abs" "$dst/$rel"; then continue; fi
|
||||
[[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}"
|
||||
cp "$abs" "$dst/$rel"
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
|
||||
# 2) Scoped prune — within each shipped framework subtree root, remove
|
||||
# framework-owned target files the current source no longer ships. Operator
|
||||
# carve-outs (e.g. tools/_lib/credentials.json) resolve to operator and are
|
||||
# skipped; unknown paths resolve to operator too — both are unreachable here.
|
||||
# Each subtree scan is captured fail-closed for the same reason as the copy.
|
||||
while IFS= read -r root; do
|
||||
[[ -n "$root" && -d "$dst/$root" ]] || continue
|
||||
list="$(mktemp)"
|
||||
_scan_or_die "$list" "$dst/$root" -type f || { rm -f "$list"; return 1; }
|
||||
while IFS= read -r -d '' abs; do
|
||||
rel="${abs#"$dst"/}"
|
||||
case "$rel" in *.pre-constitution.bak) continue ;; esac
|
||||
[[ -f "$src/$rel" ]] && continue # still shipped
|
||||
manifest_is_framework "$rel" || continue
|
||||
rm -f "$abs"
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
# Drop framework dirs left empty by the prune (never touches a dir that still
|
||||
# holds an operator file — those are never emptied). A genuine find failure
|
||||
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
||||
# the "directory not empty" races we tolerate are ignored via -delete's own
|
||||
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
||||
if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
|
||||
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
||||
fi
|
||||
done < <(manifest_subtree_roots)
|
||||
}
|
||||
|
||||
# Overwrite-mode sync: full replace. Only reached for a fresh install or an
|
||||
# explicit operator "replace everything" choice, so nothing is preserved.
|
||||
sync_framework_overwrite() {
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --delete \
|
||||
--exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak" \
|
||||
"$SOURCE_DIR/" "$TARGET_DIR/"
|
||||
return
|
||||
fi
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \
|
||||
! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" \
|
||||
-exec rm -rf {} +
|
||||
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
|
||||
rm -rf "$TARGET_DIR/.git"
|
||||
|
||||
if [[ -n "$preserve_tmp" ]]; then
|
||||
# Restore by re-globbing the SAME patterns against preserve_tmp, so each
|
||||
# preserved item is restored at its own relative path (e.g. only
|
||||
# fleet/roster.yaml is replaced — the freshly-synced fleet/examples stays).
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
shopt -s nullglob
|
||||
for match in "$preserve_tmp/"$path; do
|
||||
[[ -e "$match" ]] || continue
|
||||
rel="${match#"$preserve_tmp/"}"
|
||||
rm -rf "$TARGET_DIR/$rel"
|
||||
mkdir -p "$TARGET_DIR/$(dirname "$rel")"
|
||||
cp -R "$match" "$TARGET_DIR/$rel"
|
||||
done
|
||||
shopt -u nullglob
|
||||
done
|
||||
rm -rf "$preserve_tmp"
|
||||
fi
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -311,9 +376,23 @@ else
|
||||
ok "Install mode: overwrite"
|
||||
fi
|
||||
|
||||
# Pre-flight (keep mode): load + validate the framework manifest BEFORE taking a
|
||||
# snapshot or arming the restore trap. A fail-closed manifest (missing / empty /
|
||||
# malformed) must abort here WITHOUT deleting or restoring over operator files —
|
||||
# the snapshot/restore path exists only for a genuine mid-sync mutation failure,
|
||||
# not for a validation failure that has touched nothing yet (#791 blocker-1).
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
manifest_load
|
||||
fi
|
||||
|
||||
# Snapshot before any destructive file operation; restore on interrupt/failure.
|
||||
# The trap MUST exit after restoring: a bash INT/TERM handler that merely returns
|
||||
# does NOT terminate the script — execution would resume past the interrupt,
|
||||
# clear the snapshot, and report success, leaving a partial post-interrupt update
|
||||
# (#791 blocker-A). `restore_snapshot; exit 1` guarantees a non-zero exit for
|
||||
# both the errtrace (ERR) and signal (INT/TERM) paths.
|
||||
make_snapshot
|
||||
trap 'restore_snapshot' ERR INT TERM
|
||||
trap 'restore_snapshot; exit 1' ERR INT TERM
|
||||
|
||||
sync_framework
|
||||
|
||||
|
||||
Reference in New Issue
Block a user