Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/secondmate-provisioning/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,10 @@ Explicit per-spawn `--backend` and `FM_BACKEND` remain stronger than every home'
`data/captain-shared.md` is main-authoritative in the primary home and read-only in secondmate homes.
Its primary file header must state that the file is main-authoritative, read-only in secondmate homes, must not be edited there, and that new captain-preference discoveries are routed to the main firstmate through marked status or a document pointer.
Every propagation point converges the secondmate copy to the primary bytes; when the primary file is absent, any existing secondmate copy is quarantined and removed so absence converges too.
Both the local helper and the remote receiver compare the destination against the generation each last published there, so an untouched inherited copy is replaced quietly instead of being reported as drift.
A destination matching neither the primary bytes nor that recorded generation is quarantined to a collision-safe private dated sibling file before replacement, with a `SECONDMATE_SYNC:` diagnostic naming the home and quarantine artifact on the local route, so genuine local edits and interrupted publication keep a recovery copy.
The helper rejects unsafe directories, symlinked or nonordinary source or destination artifacts, and hardlinked destination files.
Between propagation runs, the secondmate copy is filesystem read-only; the helper may make its owned destination writable only around a guarded update and restores read-only mode on success, unchanged bytes, and recoverable failure paths.
Before replacing divergent secondmate bytes, the helper hash-compares source and destination, quarantines the secondmate-local version to a collision-safe private dated sibling file, and emits a `SECONDMATE_SYNC:` diagnostic naming the home and quarantine artifact.
Never copy any secondmate `data/captain-shared.md` back into the primary.
Keep each home's `data/captain.md` domain-local.
After first propagation to an existing home, trim that home's local `data/captain.md` by hand to domain-specific content plus pointers to `data/captain-shared.md`; do not automate or silently delete private content.
Expand Down
118 changes: 103 additions & 15 deletions bin/fm-config-inherit-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
# It also pushes
# the one primary-authoritative shared captain-preference file,
# data/captain-shared.md, into each secondmate home's data/ as a read-only copy.
# Shared-captain convergence records the SHA-256 of the last successfully
# published destination generation beside that copy. A destination whose bytes
# still match that receipt is replaced quietly when the primary source advances.
# A destination that differs from the receipt, or that has no usable receipt, is
# quarantined before replacement so genuine local edits and interrupted
# publication keep a recovery copy, and primary absence always quarantines
# before removing. The receipt is written only after the destination file
# matches the intended generation.
#
# Usage: . bin/fm-config-inherit-lib.sh (no FM_* setup required)
#
Expand Down Expand Up @@ -131,13 +139,16 @@ fm_inherit_file_link_count() {
}

fm_inherit_sha256() {
local digest
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
digest=$(shasum -a 256 "$1" 2>/dev/null | awk '{print $1}')
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" 2>/dev/null | awk '{print $1}'
digest=$(sha256sum "$1" 2>/dev/null | awk '{print $1}')
else
return 1
fi
[ -n "$digest" ] || return 1
printf '%s\n' "$digest"
}

copy_inheritable_file() {
Expand Down Expand Up @@ -258,6 +269,69 @@ restore_shared_captain_readonly() {
chmod "$FM_SHARED_CAPTAIN_MODE" "$dest" 2>/dev/null || return 1
}

shared_captain_inherited_receipt_path() {
printf '%s/.%s.inherited\n' "$1" "$FM_SHARED_CAPTAIN_FILE"
}

# Prints the recorded SHA-256 when the receipt is a safe ordinary file containing
# exactly one 64-hex digest. Returns 1 for every other receipt state, which the
# callers treat as "no usable receipt" and answer by quarantining first.
shared_captain_read_inherited_hash() {
local parent=$1 path hash
path=$(shared_captain_inherited_receipt_path "$parent")
if [ ! -e "$path" ] && [ ! -L "$path" ]; then
return 1
fi
shared_captain_file_safe_existing "$path" || return 1
hash=$(awk '
NR == 1 { digest = $0; next }
{ extra = 1 }
END { if (extra || NR != 1) exit 1; print digest }
' "$path" 2>/dev/null) || return 1
case "$hash" in
*[!a-f0-9]*) return 1 ;;
esac
[ "${#hash}" -eq 64 ] || return 1
printf '%s\n' "$hash"
}

shared_captain_write_inherited_hash() {
local parent=$1 hash=$2 path tmp
shared_captain_dir_safe "$parent" || return 1
path=$(shared_captain_inherited_receipt_path "$parent")
tmp=$(mktemp "$parent/.fm-captain-shared-inherited.XXXXXX" 2>/dev/null) || return 1
if ! printf '%s\n' "$hash" > "$tmp"; then
rm -f "$tmp" 2>/dev/null || true
return 1
fi
chmod 0600 "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null || true; return 1; }
shared_captain_file_safe_existing "$tmp" || { rm -f "$tmp" 2>/dev/null || true; return 1; }
if mv -f -- "$tmp" "$path" 2>/dev/null; then
shared_captain_file_safe_existing "$path" || return 1
return 0
fi
rm -f "$tmp" 2>/dev/null || true
return 1
}

shared_captain_remove_inherited_receipt() {
local parent=$1 path
path=$(shared_captain_inherited_receipt_path "$parent")
[ -e "$path" ] || [ -L "$path" ] || return 0
shared_captain_file_safe_existing "$path" || return 1
rm -f -- "$path" 2>/dev/null
}

# Record hash after the destination already matches that generation. Skip a
# rewrite when the receipt already names the same digest.
shared_captain_record_inherited_hash() {
local parent=$1 hash=$2 current
if current=$(shared_captain_read_inherited_hash "$parent" 2>/dev/null); then
[ "$current" = "$hash" ] && return 0
fi
shared_captain_write_inherited_hash "$parent" "$hash"
}

shared_captain_quarantine_existing_for_hash() {
local parent=$1 hash=$2 artifact artifact_hash
for artifact in "$parent"/."$FM_SHARED_CAPTAIN_FILE".quarantine.*."$hash" "$parent"/."$FM_SHARED_CAPTAIN_FILE".quarantine.*."$hash".[0-9]*; do
Expand Down Expand Up @@ -330,7 +404,8 @@ copy_shared_captain_file() {
}

propagate_shared_captain_preferences() {
local src_data=$1 dest_data=$2 src dest src_hash dest_hash dest_parent dest_home quarantine reason rc missing
local src_data=$1 dest_data=$2 src dest src_hash dest_hash dest_parent dest_home
local quarantine inherited_hash reason rc missing
[ -n "$src_data" ] || return 1
[ -n "$dest_data" ] || return 1
src="$src_data/$FM_SHARED_CAPTAIN_FILE"
Expand Down Expand Up @@ -373,12 +448,14 @@ propagate_shared_captain_preferences() {
restore_shared_captain_readonly "$dest" || true
return 1
}
inherited_hash=$(shared_captain_read_inherited_hash "$dest_parent" 2>/dev/null) || inherited_hash=
if [ "$src_hash" = "$dest_hash" ]; then
if restore_shared_captain_readonly "$dest"; then
if restore_shared_captain_readonly "$dest" \
&& shared_captain_record_inherited_hash "$dest_parent" "$dest_hash"; then
record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" unchanged ""
return 0
fi
reason="failed to restore read-only mode"
reason="failed to restore read-only mode or record inherited generation"
warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest" "$reason"
record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason"
return 1
Expand All @@ -390,25 +467,34 @@ propagate_shared_captain_preferences() {
restore_shared_captain_readonly "$dest" || true
return 1
fi
if ! quarantine=$(quarantine_shared_captain_dest "$dest" "$dest_parent"); then
reason="failed to quarantine divergent destination"
warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest" "$reason"
record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason"
restore_shared_captain_readonly "$dest" || true
return 1
if [ "$dest_hash" != "$inherited_hash" ]; then
if ! quarantine=$(quarantine_shared_captain_dest "$dest" "$dest_parent"); then
reason="failed to quarantine divergent destination"
warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest" "$reason"
record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason"
restore_shared_captain_readonly "$dest" || true
return 1
fi
printf 'SECONDMATE_SYNC: secondmate home %s: quarantined %s drift at %s\n' "$dest_home" "$FM_SHARED_CAPTAIN_REL" "$quarantine"
fi
printf 'SECONDMATE_SYNC: secondmate home %s: quarantined %s drift at %s\n' "$dest_home" "$FM_SHARED_CAPTAIN_REL" "$quarantine"
elif ! shared_captain_dir_safe "$dest_parent"; then
reason="unsafe destination directory"
warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest_parent" "$reason"
record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason"
return 1
fi
if copy_shared_captain_file "$src" "$dest"; then
if [ -n "${quarantine:-}" ]; then
record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" pushed "quarantined local drift at $quarantine"
if shared_captain_record_inherited_hash "$dest_parent" "$src_hash"; then
if [ -n "${quarantine:-}" ]; then
record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" pushed "quarantined local drift at $quarantine"
else
record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" pushed ""
fi
else
record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" pushed ""
reason="failed to record inherited generation"
warn_inheritable_config_error "$FM_SHARED_CAPTAIN_REL" "$dest" "$reason"
record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" error "$reason"
rc=1
fi
else
reason="failed to copy"
Expand All @@ -431,6 +517,7 @@ propagate_shared_captain_preferences() {
return 1
fi
if quarantine=$(quarantine_shared_captain_dest "$dest" "$dest_parent"); then
shared_captain_remove_inherited_receipt "$dest_parent" || true
printf 'SECONDMATE_SYNC: secondmate home %s: quarantined %s drift at %s\n' "$dest_home" "$FM_SHARED_CAPTAIN_REL" "$quarantine"
record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" pushed "mirrored primary absence after quarantining local copy at $quarantine"
else
Expand All @@ -441,6 +528,7 @@ propagate_shared_captain_preferences() {
rc=1
fi
else
shared_captain_remove_inherited_receipt "$dest_parent" || true
record_inheritable_config_result "$FM_SHARED_CAPTAIN_REL" unchanged ""
fi
return "$rc"
Expand Down
19 changes: 16 additions & 3 deletions bin/fm-remote-inherit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
# fm-remote-inherit.sh absent <allowlisted-relative-path> 0 <empty-sha256> <generation>
#
# Only the inherited-material allowlist is writable or removable. Writes are
# atomic ordinary-file replacements. Divergent data/captain-shared.md bytes are
# quarantined before replacement or removal and its converged copy is read-only.
# atomic ordinary-file replacements. data/captain-shared.md is read-only and is
# quarantined before removal or before replacing bytes not last published here.
set -eu

FM_HOME=${FM_HOME:?FM_HOME is required}
Expand Down Expand Up @@ -78,6 +78,9 @@ GENERATION_FILE="$PARENT_REAL/.fm-inherit-$BASE.generation"
fm_lock_acquire_wait "$LOCK" || die "cannot lock inherited destination"
TMP=
GENERATION_TMP=
# Digest this receiver last published to DEST, captured before commit_generation
# overwrites the record. Empty when no put generation has been committed here.
LAST_PUBLISHED_HASH=
cleanup() {
[ -z "$TMP" ] || rm -f -- "$TMP"
[ -z "$GENERATION_TMP" ] || rm -f -- "$GENERATION_TMP"
Expand All @@ -102,6 +105,7 @@ commit_generation() {
case "$existing_hash" in ''|*[!A-Fa-f0-9]*) die "inheritance generation record is malformed" ;; esac
[ "${#existing_hash}" -eq 64 ] || die "inheritance generation record is malformed"
case "$existing_command" in put|absent) ;; *) die "inheritance generation record is malformed" ;; esac
[ "$existing_command" != put ] || LAST_PUBLISHED_HASH=$(printf '%s' "$existing_hash" | tr 'A-F' 'a-f')
if [ "$existing_generation" -gt "$GENERATION" ]; then
die "inheritance write generation is superseded"
fi
Expand All @@ -122,6 +126,15 @@ commit_generation() {
GENERATION_TMP=
}

# True when the destination still holds the bytes this receiver last published,
# so replacing it is ordinary convergence rather than destination drift.
dest_matches_last_published() {
local actual
[ -n "$LAST_PUBLISHED_HASH" ] && [ -f "$DEST" ] || return 1
actual=$(sha256_file "$DEST") || return 1
[ "$actual" = "$LAST_PUBLISHED_HASH" ]
}

quarantine_shared() {
local reason=$1 quarantine stamp base n=0
[ "$REL" = data/captain-shared.md ] && [ -f "$DEST" ] || return 0
Expand Down Expand Up @@ -152,7 +165,7 @@ case "$COMMAND" in
printf 'unchanged: %s\n' "$REL"
exit 0
fi
quarantine_shared replaced
dest_matches_last_published || quarantine_shared replaced
chmod 600 "$TMP" || die "cannot secure inherited material"
mv -f -- "$TMP" "$DEST" || die "cannot publish inherited material"
TMP=
Expand Down
Loading
Loading