diff --git a/scripts/purge-agenttrash.sh b/scripts/purge-agenttrash.sh new file mode 100755 index 0000000..0e0af61 --- /dev/null +++ b/scripts/purge-agenttrash.sh @@ -0,0 +1,335 @@ +#!/bin/sh +# Deterministically empty every .AGENTTRASH directory under the given roots. +# +# PURPOSE +# The fleet's deletion policy parks deleted files in .AGENTTRASH/ — in-repo +# beside the item, elsewhere in $HOME as .AGENTTRASH/YYYYMMDD-topic — and +# waits for approval to purge. This script is that purge: given a machine, +# it clears .AGENTTRASH directories and nothing else. +# +# CONTRACT +# Default dry-run: the plan prints and nothing is deleted; --execute deletes. +# Every line the script can ever print for a candidate is decided at plan time +# and stored in the plan table, so a dry run prints exactly what --execute +# will print. A .AGENTTRASH that is a root's own trash (a repo's +# .AGENTTRASH, a machine's ~/.AGENTTRASH) prints `plan empty `: the +# directory is the convention and survives, its staged contents are cleared. +# A .AGENTTRASH deeper than that is content and prints `plan remove ` +# plus, once executed, `removed `; its own nested .AGENTTRASH entries +# belong to that tree and are never planned again. Candidates are found with +# `find -xdev` per root, so a mounted tree can never be walked by accident; +# ordering is lexicographic (LC_ALL=C). `/`, symlinked, and foreign-owned +# candidates are refused and reported (exit 1), never touched. +# A summary line `removed= deleted= refused=` closes +# every run and one dated line is appended to the log. +# Exit: 0 success, 1 operational failure, 2 usage error. A second run over +# the same tree plans nothing further and exits 0. +# +# PORTABILITY +# POSIX sh, clean under `shellcheck -S style`. Externals: find (must support +# -delete), stat, rm, date, mkdir. No bashisms, no GNU-only flags, no arrays, +# no `local`. Paths containing whitespace are rejected at parse time — the +# same rule applied to roots and candidates — so the plan can be walked with +# plain word splitting and nothing can be silently mis-split. +# +# USAGE +# purge-agenttrash [--execute] [--quiet] [--log PATH] [PATH...] +# --execute delete; without it the run is a plan and deletes nothing +# --quiet suppress per-line output; errors and the summary still print +# --log PATH log destination (default: $HOME/.AGENTTRASH-cleanup.log) +# PATH... roots to search (default: $HOME) +# +# Everything else is fixed by design: the root default, the ordering, the +# guards. There is no configuration file and no environment override. + +set -eu + +usage() { + sed -n '3,44p' "$0" | sed 's/^# \{0,1\}//' +} + +die() { + printf '%s: %s\n' 'purge-agenttrash' "$1" >&2 + exit "${2:-1}" +} + +# ------------------------------------------------------------------ arg parser +# Roots accumulate as one space-separated string: a root containing whitespace is +# rejected at parse time rather than silently split later. That leaves the plan +# iteratable with plain `$@`-free word splitting, the only portable construct +# that cannot corrupt a path it has already vetted. +MODE=plan +QUIET=0 +LOGPATH= +ROOTS= +while [ "$#" -gt 0 ]; do + case "$1" in + --help|-h) + usage + exit 0 + ;; + --execute) + MODE=execute + ;; + --quiet) + QUIET=1 + ;; + --log) + [ "$#" -ge 2 ] || die '--log needs a value' 2 + LOGPATH=$2 + shift + ;; + --log=*) + LOGPATH=${1#--log=} + ;; + -*) + die "unknown option: $1" 2 + ;; + *) + case $1 in + *' '*) die "root contains whitespace: $1" 2 ;; + esac + if [ -z "$ROOTS" ]; then + ROOTS=$1 + else + ROOTS="$ROOTS $1" + fi + ;; + esac + shift +done + +[ -n "${HOME:-}" ] || die 'HOME is not set; pass roots explicitly or set HOME' 2 +[ -n "$LOGPATH" ] || LOGPATH=$HOME/.AGENTTRASH-cleanup.log + +# ------------------------------------------------------------ tool availability +require() { + command -v "$1" >/dev/null 2>&1 || die "required tool not found: $1" 1 +} +require find +require stat +require rm +require date +require mkdir + +# `find -delete` backs the in-place emptying. Probe it rather than trusting +# `find --version`: a shell function named `find` must not be able to fake it. +if ! find . -maxdepth 0 -name '.purge-probe-does-not-exist' -delete >/dev/null 2>&1; then + die 'find does not support -delete; refusing to guess at deletion' 1 +fi + +UID_SELF=$(id -u) + +# ------------------------------------------------------------------- locking +# The lock dir also hosts this run's plan table, so both live and die together: +# the exit trap removes the whole directory and only the log survives the run. +LOCKDIR=$HOME/.agenttrash-purge.lock +PLANFILE=$LOCKDIR/plan + +acquire_lock() { + if ! mkdir "$LOCKDIR" 2>/dev/null; then + held_pid=$(cat "$LOCKDIR/pid" 2>/dev/null || printf '%s\n' '') + if [ -n "$held_pid" ] && kill -0 "$held_pid" 2>/dev/null; then + die "another purge holds $LOCKDIR (pid $held_pid)" 1 + fi + # Owner died mid-run: drop its pid, then retry exactly once. An + # unremovable/nonempty lock dir is not ours to reclaim. + rm -f "$LOCKDIR/pid" 2>/dev/null || true + rmdir "$LOCKDIR" 2>/dev/null || die "$LOCKDIR exists and is not ours to reclaim" 1 + mkdir "$LOCKDIR" 2>/dev/null || die "cannot reacquire $LOCKDIR" 1 + fi + printf '%s\n' "$$" >"$LOCKDIR/pid" || die "cannot stamp $LOCKDIR/pid" 1 + : >"$PLANFILE" || die "cannot write $PLANFILE" 1 + trap 'rm -rf "$LOCKDIR"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM +} + +# ------------------------------------------------------------------ predicates +is_dir() { # true only for a real directory, never a symlink to one + [ -d "$1" ] && [ ! -L "$1" ] +} + +is_ours() { # true only when the invoking user owns the path + owner=$(stat -c '%u' "$1" 2>/dev/null) || return 1 + [ "$owner" = "$UID_SELF" ] +} + +refuse() { # refuse — reported, never deleted + printf '%s: refused %s (%s)\n' 'purge-agenttrash' "$1" "$2" >&2 + REFUSED=$((REFUSED + 1)) +} + +emit() { # emit — silenced by --quiet + [ "$QUIET" -eq 1 ] || printf '%s\n' "$1" +} + +# --------------------------------------------------------------- the plan table +# One row per candidate: ` `. The first two fields tell +# `act_on` what to do; the rest of the row is EXACTLY what the run prints for +# that candidate — in plan mode verbatim, in execute mode as the first line of +# its report. The table is a file, so no shell quoting rule (quoted-for, +# heredoc, IFS games) can corrupt an approved plan, and the two modes can +# never drift apart. A removal's second line (`removed `) is derived from +# the row's action, so it is also fixed at plan time. +CANDIDATES=0 +REFUSED=0 + +record() { # record — append one row to the plan table + printf '%s %s %s\n' "$1" "$2" "$3" >>"$PLANFILE" + CANDIDATES=$((CANDIDATES + 1)) +} + +# ------------------------------------------------------------- candidate rule +# The whole contract in one place: +# `/` is refused outright (never a candidate, never walked); +# a root that is a symlink or a plain file is refused, never followed; +# a root (or any path) that IS a real .AGENTTRASH prints `plan empty `: +# the convention persists, its staged contents are what gets cleared; +# otherwise the root's DIRECT .AGENTTRASH child is also `empty` — it is that +# level's own trash ($HOME/.AGENTTRASH, x1/.AGENTTRASH, repo-a/.AGENTTRASH); +# every .AGENTTRASH deeper than that is content: `remove`, printed as +# `plan remove ` (+ `removed ` when executed), unless it lives +# inside a tree already claimed (then it dies with that tree, never planned +# twice) or is foreign-owned/symlinked (then refused). +collect_root() { # collect_root -> 0 ok, 1 refused + target=$1 + if [ "$target" = / ]; then + refuse / 'root refusal' + return 1 + fi + if [ -L "$target" ] || ! [ -d "$target" ]; then + refuse "$target" 'not a real directory' + return 1 + fi + if ! is_ours "$target"; then + refuse "$target" "not owned by uid $UID_SELF" + return 1 + fi + if [ "${target##*/}" = .AGENTTRASH ]; then + record empty "$target" "plan empty $target" + return 0 + fi + # `own` is the root's own .AGENTTRASH once handled; hits below it belong to + # that tree and must never be planned a second time. + own= + if [ -e "$target/.AGENTTRASH" ]; then + if ! is_ours "$target/.AGENTTRASH"; then + refuse "$target/.AGENTTRASH" "not owned by uid $UID_SELF" + elif ! is_dir "$target/.AGENTTRASH"; then + refuse "$target/.AGENTTRASH" 'not a real directory' + else + record empty "$target/.AGENTTRASH" "plan empty $target/.AGENTTRASH" + own=$target/.AGENTTRASH + fi + fi + # No `-type d`: implementations disagree on whether that matches a + # symlink-to-directory (GNU find says no, others yes), so every entry named + # .AGENTTRASH is collected and classified here instead of by `find`. + if ! found=$(find "$target" -xdev -name .AGENTTRASH 2>/dev/null); then + refuse "$target" 'find failed' + return 1 + fi + previous= + for entry in $found; do + [ -n "$entry" ] || continue + if [ -n "$own" ]; then + case $entry in + "$own"|"$own"/*) continue ;; # already emptied in place + esac + fi + if [ -n "$previous" ]; then + case $entry in + "$previous"/*) continue ;; # nested: dies with its parent + esac + fi + previous=$entry + if ! is_ours "$entry"; then + refuse "$entry" "not owned by uid $UID_SELF" + continue + fi + if ! is_dir "$entry"; then + refuse "$entry" 'not a real directory' + continue + fi + record remove "$entry" "plan remove $entry" + done + return 0 +} + +# ------------------------------------------------------------------ execution +remove_tree() { # rm -rf a real directory; symlinks inside die as entries + is_dir "$1" || return 1 + rm -rf -- "$1" +} + +empty_dir() { # empty a real directory in place, never following symlinks + is_dir "$1" || return 1 + find "$1" -mindepth 1 -xdev -delete +} + +# act_on -> 0 when the action succeeded. +# remove: prints the stored line (`plan remove `) then `removed `. +# empty: prints the stored line (`plan empty `) and nothing else — the +# directory survives, so the plan line IS the outcome. +act_on() { # act_on -> 0 when the action succeeded + if [ "$1" = remove ]; then + if remove_tree "$2" 2>/dev/null; then + emit "$3" + emit "removed $2" + return 0 + fi + printf '%s: failed to remove %s\n' 'purge-agenttrash' "$2" >&2 + return 1 + fi + if empty_dir "$2" 2>/dev/null; then + emit "$3" + return 0 + fi + printf '%s: failed to empty %s\n' 'purge-agenttrash' "$2" >&2 + return 1 +} + +# Walk the plan table once: plan mode prints every row's line verbatim, execute +# mode acts on each row and counts failures separately from plan-time refusals. +walk_plan() { + while read -r action path line; do + [ -n "$action" ] || continue + if [ "$MODE" = plan ]; then + emit "$line" + elif act_on "$action" "$path" "$line"; then + REMOVED=$((REMOVED + 1)) + else + EXEC_REFUSED=$((EXEC_REFUSED + 1)) + fi + done <"$PLANFILE" +} + +# ----------------------------------------------------------------------- main +[ -n "$ROOTS" ] || ROOTS=$HOME + +acquire_lock + +for root in $ROOTS; do + collect_root "$root" || true +done + +REMOVED=0 +EXEC_REFUSED=0 +walk_plan + +STATUS=0 +[ "$REFUSED" -eq 0 ] || STATUS=1 +[ "$EXEC_REFUSED" -eq 0 ] || STATUS=1 + +printf 'removed=%s deleted=%s refused=%s\n' \ + "$CANDIDATES" "$REMOVED" "$((REFUSED + EXEC_REFUSED))" + +if ! printf '%s\tplan\tcandidates=%s\tremoved=%s\trefused=%s\n' \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$CANDIDATES" "$REMOVED" \ + "$((REFUSED + EXEC_REFUSED))" >>"$LOGPATH" 2>/dev/null; then + printf '%s: cannot write log %s\n' 'purge-agenttrash' "$LOGPATH" >&2 + STATUS=1 +fi + +exit "$STATUS" diff --git a/serverAppConfigs-docs/Operations/Payload and Disk Reclaim.md b/serverAppConfigs-docs/Operations/Payload and Disk Reclaim.md index 9aa3840..7da9e48 100644 --- a/serverAppConfigs-docs/Operations/Payload and Disk Reclaim.md +++ b/serverAppConfigs-docs/Operations/Payload and Disk Reclaim.md @@ -107,6 +107,23 @@ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:7770/health Report: staged items and sizes, what was kept and why, what is **still not freed**, remaining candidates untouched. Purge only on explicit approval. +## 6. The approved purge — `scripts/purge-agenttrash.sh` + +Approval turns into one command. The script is POSIX sh, dry-run by default, +and touches nothing but `.AGENTTRASH` directories: pass it the machine's roots +(default `$HOME`) and it prints the plan; `--execute` performs it and appends +one dated line to `$HOME/.AGENTTRASH-cleanup.log`. A repo's (or the home's own) +`.AGENTTRASH` is **emptied in place** — the directory is the convention and +survives — while `.AGENTTRASH` trees that are themselves inside staged content +are removed with their parent. Foreign-owned and symlinked candidates are +refused, never followed; `rm -rf` deletes symlink entries but never traverses +them, so a planted world-writable trash cannot reach outside itself. + +```sh +scripts/purge-agenttrash.sh /home/dank # plan +scripts/purge-agenttrash.sh --execute /home/dank # purge +``` + ## Repo-flow gotcha: do NOT push before you deploy `scripts/deploy.sh` sets `BASE_REVISION=$(git rev-parse origin/main)`. If you diff --git a/tests/purge_agenttrash.bats b/tests/purge_agenttrash.bats new file mode 100755 index 0000000..2dc2d60 --- /dev/null +++ b/tests/purge_agenttrash.bats @@ -0,0 +1,166 @@ +#!/usr/bin/env bats +# Seam: the agent-trash purger. +# Behavioural contract only — what an operator sees (stdout plan lines, summary +# line, exit code, the log line it leaves behind) and what survives on disk. +# The script's own choice of implementation is not asserted. + +load helpers + +PURGE_SH="$REPO_ROOT/scripts/purge-agenttrash.sh" + +# fixture_home — a fake $HOME holding every shape the purger must handle: +# the home's own .AGENTTRASH (the dated-staging convention, emptied in place) +# and one populated plus one flat per-repo .AGENTTRASH (removed outright), plus +# a file that is not trash and must survive. +fixture_home() { + mkdir -p "$1/repo-a/.AGENTTRASH/bucket" "$1/repo-b/.AGENTTRASH" \ + "$1/.AGENTTRASH/20260101-topic/.AGENTTRASH/inner" + : > "$1/repo-a/.AGENTTRASH/bucket/staged.txt" + : > "$1/repo-b/.AGENTTRASH/loose.md" + : > "$1/.AGENTTRASH/20260101-topic/staged.txt" + : > "$1/.AGENTTRASH/20260101-topic/.AGENTTRASH/inner/deep.txt" + : > "$1/repo-a/keep.txt" +} + +@test "script exists and is executable" { + [ -f "$PURGE_SH" ] + [ -x "$PURGE_SH" ] +} + +@test "usage lists every documented option" { + run "$PURGE_SH" --help + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -q -- '--execute' + printf '%s\n' "$output" | grep -q -- '--quiet' + printf '%s\n' "$output" | grep -q -- '--log' +} + +@test "unknown option is a usage error" { + run bash -c 'export HOME="${1:?}"; mkdir -p "$HOME"; exec "$2" --bogus' \ + _ "$BATS_TEST_TMPDIR" "$PURGE_SH" + [ "$status" -eq 2 ] +} + +@test "every positional root is honoured, not just the first" { + home="$BATS_TEST_TMPDIR/home" + mkdir -p "$home" + mkdir -p "$home/x1/.AGENTTRASH" "$home/x2/.AGENTTRASH" + : > "$home/x1/.AGENTTRASH/a" + : > "$home/x2/.AGENTTRASH/b" + run "$PURGE_SH" --log "$home/purge.log" "$home/x1" "$home/x2" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -qxF "plan empty $home/x1/.AGENTTRASH" + printf '%s\n' "$output" | grep -qxF "plan empty $home/x2/.AGENTTRASH" + printf '%s\n' "$output" | grep -qxF 'removed=2 deleted=0 refused=0' +} + +@test "--log=FORM accepts its value in one word" { + home="$BATS_TEST_TMPDIR/home" + mkdir -p "$home" + run "$PURGE_SH" "--log=$home/purge.log" "$home" + [ "$status" -eq 0 ] + [ -f "$home/purge.log" ] +} + +@test "dry run plans the removals and deletes nothing" { + home="$BATS_TEST_TMPDIR/home" + fixture_home "$home" + run "$PURGE_SH" --log "$home/purge.log" "$home" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -qxF "plan empty $home/.AGENTTRASH" + printf '%s\n' "$output" | grep -qxF "plan remove $home/repo-a/.AGENTTRASH" + printf '%s\n' "$output" | grep -qxF "plan remove $home/repo-b/.AGENTTRASH" + [ "$(printf '%s\n' "$output" | grep -c '^plan ')" -eq 3 ] + printf '%s\n' "$output" | grep -qxF 'removed=3 deleted=0 refused=0' + [ -f "$home/repo-a/.AGENTTRASH/bucket/staged.txt" ] + [ -f "$home/repo-b/.AGENTTRASH/loose.md" ] + [ -f "$home/.AGENTTRASH/20260101-topic/staged.txt" ] + [ -f "$home/purge.log" ] +} + +@test "execute empties the home trash and removes the repo trees" { + home="$BATS_TEST_TMPDIR/home" + fixture_home "$home" + run "$PURGE_SH" --execute --log "$home/purge.log" "$home" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -qxF 'removed=3 deleted=3 refused=0' + # the home trash is emptied in place: it survives, empty — including a + # .AGENTTRASH nested inside it, which dies with its parent. + printf '%s\n' "$output" | grep -qxF "plan empty $home/.AGENTTRASH" + [ -d "$home/.AGENTTRASH" ] + [ -z "$(ls -A "$home/.AGENTTRASH")" ] + # the per-repo trees are removed outright, their content is what was staged. + printf '%s\n' "$output" | grep -qxF "plan remove $home/repo-a/.AGENTTRASH" + printf '%s\n' "$output" | grep -qxF "removed $home/repo-a/.AGENTTRASH" + printf '%s\n' "$output" | grep -qxF "removed $home/repo-b/.AGENTTRASH" + [ ! -e "$home/repo-a/.AGENTTRASH" ] + [ ! -e "$home/repo-b/.AGENTTRASH" ] + [ -f "$home/repo-a/keep.txt" ] + [ -f "$home/purge.log" ] +} + +@test "execute leaves no lock behind" { + home="$BATS_TEST_TMPDIR/home" + fixture_home "$home" + "$PURGE_SH" --quiet --execute --log "$home/purge.log" "$home" + [ ! -e "$home/.agenttrash-purge.lock" ] +} + +@test "the second execute is a no-op with an empty plan" { + home="$BATS_TEST_TMPDIR/home" + fixture_home "$home" + "$PURGE_SH" --quiet --execute --log "$home/purge.log" "$home" + run "$PURGE_SH" --execute --log "$home/purge.log" "$home" + [ "$status" -eq 0 ] +} + +@test "a symlinked trash dir is refused, its target survives" { + home="$BATS_TEST_TMPDIR/home" + mkdir -p "$home/live" + : > "$home/live/keep.txt" + ln -s "$home/live" "$home/.AGENTTRASH" + run "$PURGE_SH" --execute --log "$home/purge.log" "$home" + [ "$status" -eq 1 ] + printf '%s\n' "$output" | grep -q 'refused' + [ -L "$home/.AGENTTRASH" ] + [ -f "$home/live/keep.txt" ] +} + +@test "/ is refused as a root without touching anything else" { + home="$BATS_TEST_TMPDIR/home" + fixture_home "$home" + run "$PURGE_SH" --execute --log "$home/purge.log" / "$home" + [ "$status" -eq 1 ] + printf '%s\n' "$output" | grep -qxF 'purge-agenttrash: refused / (root refusal)' + printf '%s\n' "$output" | grep -qxF 'removed=3 deleted=3 refused=1' +} + +@test "a root that is a plain file is refused and exits nonzero" { + home="$BATS_TEST_TMPDIR/home" + mkdir -p "$home" + : > "$home/stray.txt" + run "$PURGE_SH" --execute --log "$home/purge.log" "$home/stray.txt" + [ "$status" -eq 1 ] + printf '%s\n' "$output" | grep -qxF "purge-agenttrash: refused $home/stray.txt (not a real directory)" + printf '%s\n' "$output" | grep -qxF 'removed=0 deleted=0 refused=1' + [ -f "$home/stray.txt" ] +} + +@test "a root path containing whitespace is a usage error" { + home="$BATS_TEST_TMPDIR/home" + mkdir -p "$home" + run "$PURGE_SH" --log "$home/purge.log" "my project" + [ "$status" -eq 2 ] + printf '%s\n' "$output" | grep -q 'root contains whitespace' +} + +@test "an empty trash dir yields an empty plan and an empty log line" { + home="$BATS_TEST_TMPDIR/home" + mkdir -p "$home" + run "$PURGE_SH" --log "$home/purge.log" "$home" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -qxF 'removed=0 deleted=0 refused=0' + printf '%s\n' "$output" >"$BATS_TEST_TMPDIR/out.txt" + [ "$(grep -c . "$BATS_TEST_TMPDIR/out.txt")" -eq 1 ] + grep -q 'plan' "$home/purge.log" +}