From ee70a25fc59c80bfa4b6ab69c044bf63168ea8f6 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Fri, 23 Oct 2026 15:19:30 -0700 Subject: [PATCH 1/4] feat(disk-cleanup): weekly npm/uv/simctl cache cleanup Weekly LaunchAgent (Sunday 04:00) that runs npm cache clean --force, uv cache prune and xcrun simctl delete unavailable (under timeout). A missing tool is a logged skip; a failed step does not stop the others but makes the run exit 1. Free space is logged before and after. The script deletes no path itself. An earlier, never-committed version had a Puppeteer step with its own rm -rf behind a denylist path guard. On 2026-10-22 a red-phase test passed `//` to that guard before the fix existed, and the loop removed ~40 apps from /Applications. That step is gone, and a guard test now fails if a code line calls rm, rmdir, mv, unlink, shred or -delete. The tests use BATS_TEST_TMPDIR, so the test file has no rm either. The design doc records the incident. README: describe disk-cleanup.sh; correct the workflows line, which listed a workflow that no longer exists and said ralph-burndown runs on mimolette (it is no longer in operation). --- LaunchAgents/com.asiago.disk-cleanup.plist | 34 +++ README.md | 3 +- disk-cleanup.sh | 147 +++++++++++ .../plans/2026-10-22-disk-space-automation.md | 121 +++++++++ tests/disk-cleanup.bats | 235 ++++++++++++++++++ 5 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 LaunchAgents/com.asiago.disk-cleanup.plist create mode 100755 disk-cleanup.sh create mode 100644 docs/plans/2026-10-22-disk-space-automation.md create mode 100755 tests/disk-cleanup.bats diff --git a/LaunchAgents/com.asiago.disk-cleanup.plist b/LaunchAgents/com.asiago.disk-cleanup.plist new file mode 100644 index 0000000..225a8f4 --- /dev/null +++ b/LaunchAgents/com.asiago.disk-cleanup.plist @@ -0,0 +1,34 @@ + + + + + Label + com.asiago.disk-cleanup + ProgramArguments + + /Users/andrewrich/.local/bin/disk-cleanup.sh + + StartCalendarInterval + + Weekday + 0 + Hour + 4 + Minute + 0 + + RunAtLoad + + LimitLoadToSessionType + Aqua + StandardOutPath + /Users/andrewrich/.local/state/disk-cleanup.out + StandardErrorPath + /Users/andrewrich/.local/state/disk-cleanup.out + EnvironmentVariables + + PATH + /opt/homebrew/bin:/Users/andrewrich/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + diff --git a/README.md b/README.md index 9a1a287..6664ac3 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Internal repo; not intended for public consumption. | `reset-tccd.sh` | Resets the macOS TCC daemon (Transparency, Consent, and Control) and waits for it to respawn. Used when privacy-permission state gets wedged. | | `local-macs.sh` | Lists ssh-able Macs on the LAN (Bonjour `_ssh._tcp`, 3-second browse), excluding this host and any name with a space. | | `allup-fleet.sh` | Runs `allup` on every host from `local-macs` at once, one tmux pane per host via `ssh -t` — so each remote run stays interactive (casks, macOS updates, sudo prompts) and its output stays separate. `-d` starts without attaching; any other arguments replace `allup` as the remote command. Tests in `tests/allup-fleet.bats`. | +| `disk-cleanup.sh` | Weekly cache cleanup for the data volume. It runs `npm cache clean --force`, `uv cache prune` and `xcrun simctl delete unavailable`. The simctl step runs under `timeout` (120 s) and skips if there is no `timeout` or `gtimeout`. The script deletes no path itself; each tool cleans its own cache. A missing tool is a logged skip. A failed step does not stop the others, but the run then exits 1. It logs free space before and after. Runs under `LaunchAgents/com.asiago.disk-cleanup.plist` (Sunday 04:00) and logs to `~/.local/state/disk-cleanup.out`. Design: [`docs/plans/2026-10-22-disk-space-automation.md`](docs/plans/2026-10-22-disk-space-automation.md). Tests in `tests/disk-cleanup.bats`. | | `tm-caffeinate.sh` | Keeps the machine awake while Time Machine is backing up. LaunchAgent-driven: re-execs under `caffeinate` and polls until the backup completes, then exits. | ### Development workflow @@ -92,7 +93,7 @@ Internal repo; not intended for public consumption. - `docs/plans/` — design docs and specs for non-trivial scripts. - `tests/` — `bats` test suites and fixtures (`ssh-wrapper.bats`). - `LaunchAgents/` — plists for scripts in this repo, symlinked into `~/Library/LaunchAgents/` (see `CLAUDE.md`). -- `.github/workflows/` — `claude-blocking-review.yml`, `dependabot-auto-merge.yml` (PR-review automation and dependabot auto-merge). Scheduled ralph-burndown automation moved to a LaunchAgent on mimolette.local. +- `.github/workflows/` — `bats.yml` (bats suites and shellcheck), `standards-check.yml`, `auto-tag.yml` (tags each push to main) and `dependabot-auto-merge.yml`. The scheduled ralph-burndown automation is no longer in operation. ## Standards diff --git a/disk-cleanup.sh b/disk-cleanup.sh new file mode 100755 index 0000000..8d88d95 --- /dev/null +++ b/disk-cleanup.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Weekly cache cleanup. Each tool deletes its own cache; never add rm here. +# Why: docs/plans/2026-10-22-disk-space-automation.md (Incident). + +# Each tool is a variable so tests can stub it. Empty or not executable = missing. +NPM_BIN="${NPM_BIN:-$(command -v npm || true)}" +UV_BIN="${UV_BIN:-$(command -v uv || true)}" +XCRUN_BIN="${XCRUN_BIN:-$(command -v xcrun || true)}" +DF_BIN="${DF_BIN:-$(command -v df || true)}" + +# simctl hung past 300 s unwrapped on 2026-10-22. timeout is from Homebrew +# coreutils; without it the step skips. +TIMEOUT_BIN="${TIMEOUT_BIN:-$(command -v timeout || command -v gtimeout || true)}" +SIMCTL_TIMEOUT_SECONDS="${SIMCTL_TIMEOUT_SECONDS:-120}" + +# Free space is logged for the Data volume; / is the read-only system volume. +DATA_VOLUME="${DATA_VOLUME:-/System/Volumes/Data}" + +# Step results. +STEP_OK=0 +STEP_FAILED=1 +STEP_SKIPPED=2 + +# ========================================================= +# FUNCTIONS +# ========================================================= +function log_info() { + local timestamp + timestamp="$(date '+%Y-%m-%d %H:%M:%S')" + echo "[${timestamp}] [INFO] $*" +} + +function log_error() { + local timestamp + timestamp="$(date '+%Y-%m-%d %H:%M:%S')" + echo "[${timestamp}] [ERROR] $*" >&2 +} + +# True when $1 names an executable file. An empty value is "missing". +function have_tool() { + [[ -n "${1:-}" && -x "${1}" ]] +} + +# Report only: a df failure is logged but never changes the exit status. +function log_free_space() { + local label="$1" + local avail_kib + + if ! have_tool "${DF_BIN}"; then + log_info "Free space ${label}: unknown (df not found)" + return 0 + fi + + # -P forces one line per filesystem on both macOS and Linux; Available is + # field 4 in both. + if ! avail_kib="$("${DF_BIN}" -Pk "${DATA_VOLUME}" 2>/dev/null | awk 'NR == 2 { print $4 }')" \ + || [[ ! "${avail_kib}" =~ ^[0-9]+$ ]]; then + log_error "Free space ${label}: could not read df for ${DATA_VOLUME}" + return 0 + fi + + log_info "Free space ${label}: $((avail_kib / 1024 / 1024)) GiB (${avail_kib} KiB) on ${DATA_VOLUME}" +} + +function step_npm() { + if ! have_tool "${NPM_BIN}"; then + log_info "npm: not found, skipping" + return "${STEP_SKIPPED}" + fi + log_info "npm: cache clean --force" + if ! "${NPM_BIN}" cache clean --force; then + log_error "npm: cache clean failed" + return "${STEP_FAILED}" + fi + return "${STEP_OK}" +} + +# `prune`, not `clean`: prune keeps cache entries still in use. +function step_uv() { + if ! have_tool "${UV_BIN}"; then + log_info "uv: not found, skipping" + return "${STEP_SKIPPED}" + fi + log_info "uv: cache prune" + if ! "${UV_BIN}" cache prune; then + log_error "uv: cache prune failed" + return "${STEP_FAILED}" + fi + return "${STEP_OK}" +} + +function step_simctl() { + if ! have_tool "${XCRUN_BIN}"; then + log_info "simctl: xcrun not found, skipping" + return "${STEP_SKIPPED}" + fi + if ! have_tool "${TIMEOUT_BIN}"; then + log_info "simctl: timeout/gtimeout not found, skipping rather than running simctl unbounded" + return "${STEP_SKIPPED}" + fi + log_info "simctl: delete unavailable (timeout ${SIMCTL_TIMEOUT_SECONDS}s)" + local rc=0 + "${TIMEOUT_BIN}" "${SIMCTL_TIMEOUT_SECONDS}" "${XCRUN_BIN}" simctl delete unavailable || rc=$? + if ((rc == 124)); then + log_error "simctl: timed out after ${SIMCTL_TIMEOUT_SECONDS}s" + return "${STEP_FAILED}" + elif ((rc != 0)); then + log_error "simctl: delete unavailable failed (exit ${rc})" + return "${STEP_FAILED}" + fi + return "${STEP_OK}" +} + +# ========================================================= +# MAIN EXECUTION +# ========================================================= +function main() { + local failures=0 rc step + + log_info "disk-cleanup started" + log_free_space "before" + + for step in step_npm step_uv step_simctl; do + rc=0 + "${step}" || rc=$? + if ((rc == STEP_FAILED)); then + ((failures += 1)) + fi + done + + log_free_space "after" + + if ((failures > 0)); then + log_error "disk-cleanup finished with ${failures} failed step(s)" + return 1 + fi + log_info "disk-cleanup finished" + return 0 +} + +# Skip main when sourced (e.g., by bats tests loading function defs). +# Mirrors the guard in break-watcher.sh. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/docs/plans/2026-10-22-disk-space-automation.md b/docs/plans/2026-10-22-disk-space-automation.md new file mode 100644 index 0000000..17be570 --- /dev/null +++ b/docs/plans/2026-10-22-disk-space-automation.md @@ -0,0 +1,121 @@ +# Disk space automation + +Date: 2026-10-22, revised 2026-10-23. Branch: +`claude/feat-disk-space-automation-20261022`. + +## Why + +On 2026-10-22 ASIAGO's data volume reached 92% used (37 GiB free of 460 GiB). +Manual cleanup recovered 145 GiB. Where it went: + +| Source | Freed | +| --- | --- | +| `~/.npm` | 24 GiB | +| iOS simulator runtimes and devices | ~23 GiB | +| Android emulators and SDK | ~21 GiB | +| `~/.cache/uv` | 13 GiB | +| Puppeteer browsers, Gradle caches | ~16 GiB | +| Time Machine local snapshots | 8 GiB or more | + +Two jobs existed and neither caught it: + +- `thin-timemachine-snapshots` (LaunchDaemon, source in `~/Developer/LaunchAgents`, + a local repo with no remote) runs weekly with a 50 GiB target. It last ran at + boot on 2026-10-21 with 52 GiB free, did nothing (correctly), and was not due + again until Sunday 2026-10-25. Free space fell to 37 GiB in between. It can + only thin snapshots; it cannot touch caches. +- `updates` (dotfiles, nightly) runs `brew cleanup --prune=all`, which works. + Nothing cleans npm, uv or simulator caches. + +On 2026-10-23, after the cleanup, the volume had 135 GiB free: `~/.npm` was +1.8 GiB, `~/.cache/uv` was empty, `~/.cache/puppeteer` was gone and +CoreSimulator held 8 KiB. Most of the 145 GiB was one-time (mobile work moved +to another server). npm and uv grow back, so a small weekly job and an alert +are still worth having. + +## Incident, 2026-10-22 + +The first version of this branch had a Puppeteer step that ran its own +`rm -rf`: in each `//` it kept the newest version directory by +`sort -V` and removed the rest. Its path guard was a denylist. It refused an +empty path, a relative path and exactly `$HOME`, after stripping one trailing +slash. + +A red-phase bats test passed `//` to that guard before the fix for it +existed. `//` became `/` and passed. The loop then treated every top-level +directory as a "browser" and removed all but one child of each. About 40 apps +in `/Applications` were deleted (user-owned ones; root-owned bundles resisted). +Backups restored everything within a few hours. The second guard +(`entry == cache_dir/*/*`) was a glob and matched `//Applications/Foo.app`, so +it failed on the same input. + +What changed as a result: + +- The Puppeteer step is gone. No project under `~/Developer` uses Puppeteer, + and the cache no longer exists. +- `disk-cleanup.sh` deletes nothing itself. Every cache is cleaned by the tool + that owns it. A guard test fails if a code line calls `rm`, `rmdir`, `mv`, + `unlink`, `shred` or `-delete`. +- The test file has no `rm` either: it uses `BATS_TEST_TMPDIR`, which bats + creates and removes. +- `~/.claude/CLAUDE.md` gained a "Destructive code" rule in Protocol 3 + (claude-config PR 778): stub every destructive command, keep real + dangerous paths out of safety tests, and run red-phase safety tests only in + a disposable container or VM. +- Claude Code sessions here now run Bash under the macOS sandbox. + `/Applications` is outside its write allowlist. + +## What + +Two commits, one PR. + +### 1. `disk-cleanup.sh` + `LaunchAgents/com.asiago.disk-cleanup.plist` + +Weekly LaunchAgent (Sunday 04:00). Each step skips with a log line when its +tool is absent, and one failing step does not stop the others: + +- `npm cache clean --force` +- `uv cache prune` (keeps entries still in use, unlike `uv cache clean`) +- `xcrun simctl delete unavailable`, wrapped in `timeout` (an unwrapped + `simctl` call hung past 300 s during the 2026-10-22 cleanup). Kept although + CoreSimulator is empty now: `simctl` decides what to delete, and the step + costs nothing when there is nothing to delete. + +Logs to `~/.local/state/disk-cleanup.out` with free space before and after. + +### 2. `disk-space-alert.sh` + `LaunchAgents/com.asiago.disk-space-alert.plist` + +Daily LaunchAgent at 09:00. If free space on `/System/Volumes/Data` is below +the threshold (default 40 GiB, first argument overrides), post a macOS +notification with `osascript`. 40 GiB, not 50, so the alert does not fire on a +shortfall the 50 GiB snapshot thinner clears on its own. It deletes nothing. + +## Later, separate PR + +### Move `thin-timemachine-snapshots` into this repo, run it daily + +Split out because it installs a root LaunchDaemon, which this branch's +sandboxed sessions cannot do, and because it changes system state +(`tmutil`), so it falls under the destructive-code rule. + +- `thin-timemachine-snapshots.sh` moves here from `~/Developer/LaunchAgents`. +- Its plist goes in a new `LaunchDaemons/` directory with + `StartCalendarInterval` changed from weekly (Sunday 03:00) to daily (03:00). +- A LaunchDaemon plist and the script it runs as root must be root-owned + regular files, so the repo's symlink rule cannot apply. + `install-thin-timemachine-snapshots.sh` copies both into place with the + right owner and mode and bootstraps the daemon; `--uninstall` reverses it. + This is the repo's "generated by a tracked installer" pattern. +- `CLAUDE.md` gains a LaunchDaemons clause in the placement rule. +- `tmutil` is injected and stubbed in tests. Any test that needs the real + `tmutil` runs in a disposable macOS VM (see + `2026-10-23-mimolette-vm-test-runner.md`). +- After it merges and the installer has run, the old copies in + `~/Developer/LaunchAgents` are removed in a separate local commit there. + +## Not doing + +- No Puppeteer cleanup (see Incident). +- No cleanup of Gradle, Xcode DerivedData or Android files. Mobile development + moved to another server, and those were removed by hand. +- No change to the `updates` job in dotfiles. diff --git a/tests/disk-cleanup.bats b/tests/disk-cleanup.bats new file mode 100755 index 0000000..e4f6f91 --- /dev/null +++ b/tests/disk-cleanup.bats @@ -0,0 +1,235 @@ +#!/usr/bin/env bats +# bats tests for disk-cleanup.sh +# +# Run from repo root: bats tests/disk-cleanup.bats +# +# disk-cleanup.sh deletes caches, so these tests must never reach a real +# tool. Every external it calls is a *_BIN variable; setup() points each one +# at a stub in the per-test temp dir that records its argv to CALLS and +# exits with the code in .rc (default 0). A "missing" tool is a path +# that does not exist. DATA_VOLUME also points into the temp dir. +# +# The script itself never deletes a path (a guard test below checks this), +# so no test here can reach a real rm. The temp dir is BATS_TEST_TMPDIR, +# which bats creates and removes; this file has no rm either. +# Nothing here assumes macOS, so the suite runs on the ubuntu CI runner. + +setup() { + REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:?must be run via bats}")/.." && pwd)" + TMPDIR_T="${BATS_TEST_TMPDIR:?bats 1.4 or later is required}" + STUBS="${TMPDIR_T}/stubs" + mkdir -p "${STUBS}" + export CALLS="${TMPDIR_T}/calls" + : >"${CALLS}" + + local tool + for tool in npm uv xcrun df; do + _stub "${tool}" + done + # df prints a fixed two-line block, as `df -Pk` does. + cat >"${STUBS}/df" <<'EOF' +#!/usr/bin/env bash +echo "df $*" >>"${CALLS}" +echo "Filesystem 1024-blocks Used Available Capacity Mounted on" +echo "/dev/disk3s5 482797652 300000000 41943040 88% /System/Volumes/Data" +EOF + chmod +x "${STUBS}/df" + + # timeout stub: records its argv, then runs the wrapped command, so the + # xcrun stub is still invoked through it. + cat >"${STUBS}/timeout" <<'EOF' +#!/usr/bin/env bash +echo "timeout $*" >>"${CALLS}" +rc_file="$(dirname "$0")/timeout.rc" +if [[ -f "${rc_file}" ]]; then exit "$(cat "${rc_file}")"; fi +shift +exec "$@" +EOF + chmod +x "${STUBS}/timeout" + + # Exported BEFORE sourcing: the script reads these as defaults at load + # time, so even if the source guard regressed and main() ran, it would + # reach only the stubs and the temp dir, never a real cache. + export NPM_BIN="${STUBS}/npm" + export UV_BIN="${STUBS}/uv" + export XCRUN_BIN="${STUBS}/xcrun" + export DF_BIN="${STUBS}/df" + export TIMEOUT_BIN="${STUBS}/timeout" + export SIMCTL_TIMEOUT_SECONDS=7 + export DATA_VOLUME="${TMPDIR_T}" + + # The source guard keeps main() from running when we load the functions. + source "${REPO_ROOT}/disk-cleanup.sh" +} + +# Writes a stub that appends " " to CALLS and exits with the +# value in .rc if that file exists. +_stub() { + local name="$1" + cat >"${STUBS}/${name}" <>"\${CALLS}" +rc_file="\$(dirname "\$0")/${name}.rc" +if [[ -f "\${rc_file}" ]]; then exit "\$(cat "\${rc_file}")"; fi +exit 0 +EOF + chmod +x "${STUBS}/${name}" +} + +_fail() { + printf '%s\n' "${2:-1}" >"${STUBS}/$1.rc" +} + +# --------------------------------------------------------------------------- +# Whole run +# --------------------------------------------------------------------------- + +@test "a clean run calls every tool with the expected arguments and exits 0" { + run main + [ "${status}" -eq 0 ] + grep -qx "npm cache clean --force" "${CALLS}" + grep -qx "uv cache prune" "${CALLS}" + grep -qx "xcrun simctl delete unavailable" "${CALLS}" +} + +@test "free space is logged before and after, from DATA_VOLUME" { + run main + [ "${status}" -eq 0 ] + [[ "${output}" == *"Free space before: 40 GiB (41943040 KiB) on ${TMPDIR_T}"* ]] + [[ "${output}" == *"Free space after: 40 GiB"* ]] + [ "$(grep -c "^df -Pk ${TMPDIR_T}\$" "${CALLS}")" -eq 2 ] +} + +@test "log lines carry a timestamp" { + run main + [[ "${lines[0]}" =~ ^\[[0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}:[0-9]{2}\]\ \[INFO\] ]] +} + +@test "a df failure is logged but does not fail the run" { + # Swap the fixed-output df for the generic stub, made to exit 1. + _stub df + _fail df + run main + [ "${status}" -eq 0 ] + [[ "${output}" == *"could not read df"* ]] +} + +# --------------------------------------------------------------------------- +# Missing tools skip; they are not failures +# --------------------------------------------------------------------------- + +@test "missing npm is a logged skip, not a failure" { + NPM_BIN="${TMPDIR_T}/does-not-exist/npm" + run main + [ "${status}" -eq 0 ] + [[ "${output}" == *"npm: not found, skipping"* ]] + grep -qx "uv cache prune" "${CALLS}" +} + +@test "missing uv is a logged skip, not a failure" { + UV_BIN="${TMPDIR_T}/does-not-exist/uv" + run main + [ "${status}" -eq 0 ] + [[ "${output}" == *"uv: not found, skipping"* ]] +} + +@test "missing xcrun is a logged skip, not a failure (the Linux case)" { + XCRUN_BIN="" + run main + [ "${status}" -eq 0 ] + [[ "${output}" == *"simctl: xcrun not found, skipping"* ]] + [ "$(grep -c "^timeout" "${CALLS}")" -eq 0 ] +} + +@test "missing df logs unknown free space, not a failure" { + DF_BIN="" + run main + [ "${status}" -eq 0 ] + [[ "${output}" == *"Free space before: unknown"* ]] +} + +@test "every tool missing at once still exits 0" { + NPM_BIN="" UV_BIN="" XCRUN_BIN="" DF_BIN="" TIMEOUT_BIN="" + run main + [ "${status}" -eq 0 ] + [ ! -s "${CALLS}" ] +} + +# --------------------------------------------------------------------------- +# Failure isolation +# --------------------------------------------------------------------------- + +@test "a failing npm step does not stop the other steps, and the run exits 1" { + _fail npm + run main + [ "${status}" -eq 1 ] + [[ "${output}" == *"npm: cache clean failed"* ]] + grep -qx "uv cache prune" "${CALLS}" + grep -qx "xcrun simctl delete unavailable" "${CALLS}" + [[ "${output}" == *"Free space after"* ]] + [[ "${output}" == *"1 failed step(s)"* ]] +} + +@test "two failing steps are both counted" { + _fail uv + _fail xcrun 3 + run main + [ "${status}" -eq 1 ] + [[ "${output}" == *"simctl: delete unavailable failed (exit 3)"* ]] + [[ "${output}" == *"2 failed step(s)"* ]] +} + +# --------------------------------------------------------------------------- +# simctl runs under timeout +# --------------------------------------------------------------------------- + +@test "simctl is wrapped in timeout with SIMCTL_TIMEOUT_SECONDS" { + run step_simctl + [ "${status}" -eq 0 ] + grep -qx "timeout 7 ${STUBS}/xcrun simctl delete unavailable" "${CALLS}" + # The timeout call comes first: xcrun ran through it, not beside it. + [ "$(head -n 1 "${CALLS}")" = "timeout 7 ${STUBS}/xcrun simctl delete unavailable" ] +} + +@test "a simctl timeout (exit 124) is a failure that says it timed out" { + _fail timeout 124 + run step_simctl + [ "${status}" -eq "${STEP_FAILED}" ] + [[ "${output}" == *"simctl: timed out after 7s"* ]] +} + +@test "no timeout binary skips simctl rather than running it unbounded" { + TIMEOUT_BIN="" + run main + [ "${status}" -eq 0 ] + [[ "${output}" == *"timeout/gtimeout not found, skipping"* ]] + [ "$(grep -c "xcrun" "${CALLS}")" -eq 0 ] +} + +# --------------------------------------------------------------------------- +# Script shape +# --------------------------------------------------------------------------- + +@test "the script never deletes or moves a path itself" { + # Cache deletion belongs to the tool that owns the cache. A hand-written + # rm -rf in this script removed ~40 apps from /Applications on + # 2026-10-22. Comment lines are ignored; any code line that calls one of + # these fails the test. The word boundaries are spelled out because \b + # and [[:<:]] differ between BSD and GNU grep. + run grep -nE '^([^#]*[^[:alnum:]_-])?(rm|rmdir|mv|unlink|shred)([^[:alnum:]_-]|$)|^[^#]*[[:space:]]-delete([^[:alnum:]_]|$)' \ + "${REPO_ROOT}/disk-cleanup.sh" + [ "${status}" -eq 1 ] +} + +@test "sourcing the script does not run main" { + # setup() sourced it with every tool pointed at a recording stub; if the + # guard regressed, main() would have run and CALLS would not be empty. + [ "$(type -t main)" = "function" ] + [ ! -s "${CALLS}" ] +} + +@test "the default simctl timeout is a positive integer" { + run env -u SIMCTL_TIMEOUT_SECONDS bash -c \ + "source '${REPO_ROOT}/disk-cleanup.sh'; echo \"\${SIMCTL_TIMEOUT_SECONDS}\"" + [[ "${output}" =~ ^[1-9][0-9]*$ ]] +} From 9c7eb4f46b4bf27b0502c19fa62b682cb1300e7d Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Fri, 23 Oct 2026 15:20:09 -0700 Subject: [PATCH 2/4] docs(plans): design a disposable macOS VM test runner on mimolette Protocol 3's new destructive-code rule (claude-config PR 778) sends tests that may reach a real destructive command, and every red-phase safety test, to a disposable environment. A Linux container cannot run tmutil, diskutil, launchctl or simctl, and a GitHub macOS runner costs about 10x a Linux one. mimolette is an always-on arm64 Mac on macOS 27.0.1 with 158 GiB free. The design: a non-admin `claude` account, a forced-command ssh key, Tart VMs cloned from a clean base with no shared folders, code sent in as a git bundle on stdin, results out as text only, and every VM deleted after its run. It lists a known-bad validation run to do before first use and the open questions (headless start, base image, clone cost, guest network). Nothing on mimolette changes until it is approved. --- .../2026-10-23-mimolette-vm-test-runner.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/plans/2026-10-23-mimolette-vm-test-runner.md diff --git a/docs/plans/2026-10-23-mimolette-vm-test-runner.md b/docs/plans/2026-10-23-mimolette-vm-test-runner.md new file mode 100644 index 0000000..c31adeb --- /dev/null +++ b/docs/plans/2026-10-23-mimolette-vm-test-runner.md @@ -0,0 +1,148 @@ +# Disposable macOS VM test runner on mimolette + +Date: 2026-10-23. Status: design, not built. Nothing on mimolette changes +until this is approved. + +## Why + +`~/.claude/CLAUDE.md` Protocol 3 ("Destructive code", claude-config PR 778) +says a test that may reach a real destructive command, and the first run of +any new safety test, must run in a disposable environment. The trigger was +the 2026-10-22 incident in `2026-10-22-disk-space-automation.md`. + +A Linux container covers code that is portable or fully stubbed. It cannot +run `tmutil`, `diskutil`, `launchctl`, `osascript` or `simctl`. A GitHub macOS +runner can, at about 10 times the Linux per-minute price. mimolette is an +always-on Apple silicon Mac with room to spare, so it hosts throwaway macOS +VMs instead. + +## Facts checked on 2026-10-23 + +| Item | Value | +| --- | --- | +| Chip | arm64 | +| macOS | 27.0.1 (same as ASIAGO) | +| Free on `/System/Volumes/Data` | 158 GiB of 460 GiB | +| Access | passwordless ssh as `andrewrich@`, always on | + +## Threat model + +The thing under test is assumed hostile by accident: it may run `rm -rf /` +inside the guest. The runner must make sure that: + +1. Nothing the guest does reaches mimolette's files, ASIAGO's files or the + `Workspaces` share that mimolette serves. +2. A session on ASIAGO cannot use the runner as a general shell on + mimolette. On ASIAGO, `ssh *` is in the Claude Code sandbox's + `excludedCommands`, so any ssh command runs outside the sandbox. +3. Every run starts from a known-clean image, and nothing from one run + survives into the next. + +## Design + +### Account + +- A standard (non-admin) macOS user `claude` on mimolette. No sudo. +- All VM images, clones and logs live under `/Users/claude`. Deleting that + account removes all of it. +- Check at setup: `ls -ld /Users/andrewrich` and its subfolders. New users + join group `staff`, and home folders may be group-readable. Lock them down + if `claude` can list them. + +### SSH access is one command, not a shell + +- ASIAGO gets a new key, used only for this. +- `/Users/claude/.ssh/authorized_keys` on mimolette holds that key with + `command="/Users/claude/bin/vm-test-run",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-user-rc`. +- Whatever the client asks to run arrives only as `SSH_ORIGINAL_COMMAND`, + which `vm-test-run` parses against a strict allowlist. It never passes it + to a shell. + +### Tool + +- Tart (Cirrus Labs), installed by `andrewrich` with Homebrew. `claude` only + runs it. Tart uses Apple's Virtualization framework; no root is needed. +- One base image, `macos-base`, built once and kept read-only in practice: + macOS 27, Homebrew, bats, shellcheck, coreutils, git. No Apple ID, no + secrets, no keys except the one that lets the runner ssh into the guest. + +### Code goes in as data + +The code under test is often unpushed (a red-phase test should not need a +push, and pushes cost CI money). So the client sends a git bundle on stdin: + +```text +git bundle create - HEAD | ssh -i claude@mimolette run +``` + +`vm-test-run`: + +1. Reads the bundle from stdin into a fresh temp dir under `/Users/claude`. + Caps its size. +2. Checks `` against `^tests/[A-Za-z0-9._-]+\.bats$`. +3. Takes a lock, so only one run is active at a time. Apple's licence allows + at most 2 extra macOS VMs per Mac, and one at a time keeps it simple. +4. `tart clone macos-base run-`, then `tart run --no-graphics run-` + with **no `--dir` option**, so no host folder is shared. +5. Waits for `tart ip run-`, copies the bundle into the guest with `scp`, + clones from it there, and runs `bats --print-output-on-failure ` + inside the guest. +6. Streams the guest's output to the client as text and exits with the + suite's exit code. +7. Always stops and deletes `run-` and the temp dir, from a `trap`, on + success, failure or timeout. +8. Bounds the whole run with `timeout` (default 15 minutes). + +### Results come out as text + +Only stdout, stderr and the exit code go back. No files are copied out of +the guest. + +### Client side, on ASIAGO + +A small `vm-test` wrapper in this repo builds the bundle and calls ssh with +the dedicated key. That is the only supported way in. Protocol 3's "where +suites run" rule points destructive suites here. + +## Validation before first real use + +A clean result from an unvalidated sandbox proves nothing. Before the runner +counts as "disposable", run a known-bad suite through it: + +1. A bats test that runs `rm -rf /` inside the guest (and, separately, tries + to reach `/Volumes`, the host's IP and mimolette's `Workspaces` share). +2. Confirm on mimolette: `/Users/claude` and `/Users/andrewrich` unchanged, + the share unchanged, `run-` gone, `macos-base` unchanged. +3. Confirm the next run starts clean. +4. Confirm a plain `ssh -i claude@mimolette` gets no shell, and that + `run '; id'` or a path outside `tests/` is refused. + +## Open questions + +1. **Headless start.** Does `tart run --no-graphics` start a macOS guest + from an ssh login with no GUI session for `claude`? Not confirmed. If it + does not, `claude` may need an auto-login GUI session, which changes the + setup. +2. **Base image source.** Pull a Cirrus Labs macOS 27 image if one exists, + or build from an IPSW with `tart create --from-ipsw`. Not checked. +3. **Image size.** I expect tens of GB for the base. Whether `tart clone` is + a cheap APFS copy-on-write clone is not confirmed. +4. **Guest network.** With the bundle approach the guest needs no network + during a run. Whether Tart can cut the guest off from the LAN (and so + from the `Workspaces` share) is not confirmed. Until it is, step 1 of the + validation is the evidence. +5. **Linux layer.** Apple's `container` (Homebrew, 1.6.0) could run the + portable suites the same way, on ASIAGO or mimolette. Not part of this + design; add it if the VM start time proves too slow. + +## Rollout + +Each step is a checkpoint; stop and report if one fails. + +1. Andrew creates the `claude` account and installs Tart. +2. Build `macos-base`; answer open questions 1 to 4. +3. Write `vm-test-run` and `vm-test`, both tracked in this repo, with bats + tests that stub `tart` and `ssh`. `vm-test-run` reaches + `/Users/claude/bin` on mimolette through a tracked installer, not by hand. +4. Run the validation above. +5. Update Protocol 3 if anything here changed what it promises. From c339971bbfc73ffdd96077c84033bc50dbe7f3e6 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Fri, 23 Oct 2026 16:15:04 -0700 Subject: [PATCH 3/4] feat(disk-space-alert): daily notification below 40 GiB free On 2026-10-22 free space fell from 52 to 37 GiB between two weekly runs of the snapshot thinner, and nothing said so. This LaunchAgent (daily 09:00) reads free space on /System/Volumes/Data and posts a macOS notification when it is below 40 GiB; the first argument overrides the threshold. 40, not 50, so it stays quiet on a shortfall the 50 GiB thinner clears. It deletes nothing. An unreadable volume also posts a notification and exits 1. The title and message go to AppleScript as argv, never as script text. osascript exits 0 even when the notification service is unreachable (seen inside the Claude Code sandbox), so the log line is the record. --- .../com.asiago.disk-space-alert.plist | 32 +++ README.md | 1 + disk-space-alert.sh | 100 ++++++++++ .../plans/2026-10-22-disk-space-automation.md | 8 + tests/disk-space-alert.bats | 188 ++++++++++++++++++ 5 files changed, 329 insertions(+) create mode 100644 LaunchAgents/com.asiago.disk-space-alert.plist create mode 100755 disk-space-alert.sh create mode 100755 tests/disk-space-alert.bats diff --git a/LaunchAgents/com.asiago.disk-space-alert.plist b/LaunchAgents/com.asiago.disk-space-alert.plist new file mode 100644 index 0000000..1bee326 --- /dev/null +++ b/LaunchAgents/com.asiago.disk-space-alert.plist @@ -0,0 +1,32 @@ + + + + + Label + com.asiago.disk-space-alert + ProgramArguments + + /Users/andrewrich/.local/bin/disk-space-alert.sh + + StartCalendarInterval + + Hour + 9 + Minute + 0 + + RunAtLoad + + LimitLoadToSessionType + Aqua + StandardOutPath + /Users/andrewrich/.local/state/disk-space-alert.out + StandardErrorPath + /Users/andrewrich/.local/state/disk-space-alert.out + EnvironmentVariables + + PATH + /opt/homebrew/bin:/Users/andrewrich/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + diff --git a/README.md b/README.md index 6664ac3..4dab42c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Internal repo; not intended for public consumption. | `local-macs.sh` | Lists ssh-able Macs on the LAN (Bonjour `_ssh._tcp`, 3-second browse), excluding this host and any name with a space. | | `allup-fleet.sh` | Runs `allup` on every host from `local-macs` at once, one tmux pane per host via `ssh -t` — so each remote run stays interactive (casks, macOS updates, sudo prompts) and its output stays separate. `-d` starts without attaching; any other arguments replace `allup` as the remote command. Tests in `tests/allup-fleet.bats`. | | `disk-cleanup.sh` | Weekly cache cleanup for the data volume. It runs `npm cache clean --force`, `uv cache prune` and `xcrun simctl delete unavailable`. The simctl step runs under `timeout` (120 s) and skips if there is no `timeout` or `gtimeout`. The script deletes no path itself; each tool cleans its own cache. A missing tool is a logged skip. A failed step does not stop the others, but the run then exits 1. It logs free space before and after. Runs under `LaunchAgents/com.asiago.disk-cleanup.plist` (Sunday 04:00) and logs to `~/.local/state/disk-cleanup.out`. Design: [`docs/plans/2026-10-22-disk-space-automation.md`](docs/plans/2026-10-22-disk-space-automation.md). Tests in `tests/disk-cleanup.bats`. | +| `disk-space-alert.sh` | Daily low-disk alert. If free space on `/System/Volumes/Data` is below 40 GiB (the first argument overrides it), it posts a macOS notification with `osascript`. It also posts one if it cannot read free space. It deletes nothing. `osascript` can exit 0 when the notification is not shown, so the log is the record. Runs under `LaunchAgents/com.asiago.disk-space-alert.plist` (daily 09:00) and logs to `~/.local/state/disk-space-alert.out`. Tests in `tests/disk-space-alert.bats`. | | `tm-caffeinate.sh` | Keeps the machine awake while Time Machine is backing up. LaunchAgent-driven: re-execs under `caffeinate` and polls until the backup completes, then exits. | ### Development workflow diff --git a/disk-space-alert.sh b/disk-space-alert.sh new file mode 100755 index 0000000..6ac6edb --- /dev/null +++ b/disk-space-alert.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Daily low-disk alert. Posts a notification; deletes nothing. +# Why: docs/plans/2026-10-22-disk-space-automation.md + +# Each tool is a variable so tests can stub it. Empty or not executable = missing. +DF_BIN="${DF_BIN:-$(command -v df || true)}" +OSASCRIPT_BIN="${OSASCRIPT_BIN:-/usr/bin/osascript}" + +# Free space is read for the Data volume; / is the read-only system volume. +DATA_VOLUME="${DATA_VOLUME:-/System/Volumes/Data}" + +# 40, not 50: the snapshot thinner targets 50 GiB and clears that on its own. +DEFAULT_THRESHOLD_GIB=40 + +# ========================================================= +# FUNCTIONS +# ========================================================= +function log_info() { + local timestamp + timestamp="$(date '+%Y-%m-%d %H:%M:%S')" + echo "[${timestamp}] [INFO] $*" +} + +function log_error() { + local timestamp + timestamp="$(date '+%Y-%m-%d %H:%M:%S')" + echo "[${timestamp}] [ERROR] $*" >&2 +} + +# True when $1 names an executable file. An empty value is "missing". +function have_tool() { + [[ -n "${1:-}" && -x "${1}" ]] +} + +# Prints whole GiB free on DATA_VOLUME, rounded down. Returns 1 if unknown. +function free_gib() { + local avail_kib + + have_tool "${DF_BIN}" || return 1 + # -P forces one line per filesystem on both macOS and Linux; Available is + # field 4 in both. + avail_kib="$("${DF_BIN}" -Pk "${DATA_VOLUME}" 2>/dev/null | awk 'NR == 2 { print $4 }')" || return 1 + [[ "${avail_kib}" =~ ^[0-9]+$ ]] || return 1 + echo "$((avail_kib / 1024 / 1024))" +} + +# The title and message go in as argv, never into the AppleScript source, so +# no value can change the script. +function notify() { + local title="$1" message="$2" + + if ! have_tool "${OSASCRIPT_BIN}"; then + log_error "osascript not found; could not post: ${title}: ${message}" + return 1 + fi + if ! "${OSASCRIPT_BIN}" \ + -e 'on run argv' \ + -e 'display notification (item 1 of argv) with title (item 2 of argv)' \ + -e 'end run' \ + "${message}" "${title}"; then + log_error "osascript failed; could not post: ${title}: ${message}" + return 1 + fi +} + +# ========================================================= +# MAIN EXECUTION +# ========================================================= +function main() { + local threshold="${1:-${DEFAULT_THRESHOLD_GIB}}" free + + if [[ ! "${threshold}" =~ ^[1-9][0-9]*$ ]]; then + log_error "threshold must be a whole number of GiB above 0, got '${threshold}'" + return 2 + fi + + # An alert that fails silently is worse than none, so an unreadable + # volume is reported too. + if ! free="$(free_gib)"; then + log_error "could not read free space on ${DATA_VOLUME}" + notify "Disk space check failed" "Could not read free space on ${DATA_VOLUME}." || true + return 1 + fi + + if ((free < threshold)); then + log_info "LOW: ${free} GiB free on ${DATA_VOLUME}, below ${threshold} GiB" + notify "Low disk space" "${free} GiB free on ${DATA_VOLUME} (alert below ${threshold} GiB)." || return 1 + return 0 + fi + + log_info "OK: ${free} GiB free on ${DATA_VOLUME} (threshold ${threshold} GiB)" +} + +# Skip main when sourced (e.g., by bats tests loading function defs). +# Mirrors the guard in break-watcher.sh. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/docs/plans/2026-10-22-disk-space-automation.md b/docs/plans/2026-10-22-disk-space-automation.md index 17be570..8d0c9c3 100644 --- a/docs/plans/2026-10-22-disk-space-automation.md +++ b/docs/plans/2026-10-22-disk-space-automation.md @@ -90,6 +90,14 @@ the threshold (default 40 GiB, first argument overrides), post a macOS notification with `osascript`. 40 GiB, not 50, so the alert does not fire on a shortfall the 50 GiB snapshot thinner clears on its own. It deletes nothing. +- An unreadable volume also posts a notification and exits 1, so the alert + cannot fail silently on its own input. +- The title and message reach AppleScript as `argv`, not as script text, so + no value can change the script. +- `osascript` exits 0 even when the notification service is unreachable (seen + inside the Claude Code sandbox on 2026-10-23), so a missing notification + is not always detected. The log line is the record. + ## Later, separate PR ### Move `thin-timemachine-snapshots` into this repo, run it daily diff --git a/tests/disk-space-alert.bats b/tests/disk-space-alert.bats new file mode 100755 index 0000000..4e229d9 --- /dev/null +++ b/tests/disk-space-alert.bats @@ -0,0 +1,188 @@ +#!/usr/bin/env bats +# bats tests for disk-space-alert.sh +# +# Run from repo root: bats tests/disk-space-alert.bats +# +# The script reads df and posts a notification with osascript. Both are +# *_BIN variables; setup() points each at a stub in the per-test temp dir. +# The df stub reports the KiB value in df.kib; the osascript stub records +# one argv item per line to NOTIFY and exits with the code in osascript.rc. +# The script deletes nothing, and the temp dir is BATS_TEST_TMPDIR, which +# bats removes. Nothing here assumes macOS, so it runs on the ubuntu runner. + +setup() { + REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:?must be run via bats}")/.." && pwd)" + TMPDIR_T="${BATS_TEST_TMPDIR:?bats 1.4 or later is required}" + STUBS="${TMPDIR_T}/stubs" + mkdir -p "${STUBS}" + export NOTIFY="${TMPDIR_T}/notify" + export DF_CALLS="${TMPDIR_T}/df-calls" + + cat >"${STUBS}/df" <<'EOF' +#!/usr/bin/env bash +echo "df $*" >>"${DF_CALLS}" +dir="$(dirname "$0")" +if [[ -f "${dir}/df.rc" ]]; then exit "$(cat "${dir}/df.rc")"; fi +echo "Filesystem 1024-blocks Used Available Capacity Mounted on" +echo "/dev/disk3s5 482797652 300000000 $(cat "${dir}/df.kib") 88% /System/Volumes/Data" +EOF + chmod +x "${STUBS}/df" + + cat >"${STUBS}/osascript" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" >"${NOTIFY}" +rc_file="$(dirname "$0")/osascript.rc" +if [[ -f "${rc_file}" ]]; then exit "$(cat "${rc_file}")"; fi +exit 0 +EOF + chmod +x "${STUBS}/osascript" + + # Exported BEFORE sourcing: the script reads these as defaults at load + # time, so even if the source guard regressed, only stubs would run. + export DF_BIN="${STUBS}/df" + export OSASCRIPT_BIN="${STUBS}/osascript" + export DATA_VOLUME="${TMPDIR_T}" + + _free_gib 100 + source "${REPO_ROOT}/disk-space-alert.sh" +} + +# Sets the df stub's Available column to $1 GiB (as KiB). +_free_gib() { + echo "$(($1 * 1024 * 1024))" >"${STUBS}/df.kib" +} + +# --------------------------------------------------------------------------- +# Threshold +# --------------------------------------------------------------------------- + +@test "plenty of free space logs OK and posts nothing" { + run main + [ "${status}" -eq 0 ] + [[ "${output}" == *"OK: 100 GiB free on ${TMPDIR_T} (threshold 40 GiB)"* ]] + [ ! -e "${NOTIFY}" ] +} + +@test "free space below the default 40 GiB posts a notification" { + _free_gib 37 + run main + [ "${status}" -eq 0 ] + [[ "${output}" == *"LOW: 37 GiB free"* ]] + grep -qx "Low disk space" "${NOTIFY}" + grep -qx "37 GiB free on ${TMPDIR_T} (alert below 40 GiB)." "${NOTIFY}" +} + +@test "free space exactly at the threshold does not alert" { + _free_gib 40 + run main + [ "${status}" -eq 0 ] + [ ! -e "${NOTIFY}" ] +} + +@test "a partial GiB rounds down, so 39.9 GiB alerts" { + echo "$((40 * 1024 * 1024 - 1))" >"${STUBS}/df.kib" + run main + [ "${status}" -eq 0 ] + [[ "${output}" == *"LOW: 39 GiB free"* ]] + [ -e "${NOTIFY}" ] +} + +@test "the first argument overrides the threshold" { + _free_gib 60 + run main 75 + [ "${status}" -eq 0 ] + grep -qx "60 GiB free on ${TMPDIR_T} (alert below 75 GiB)." "${NOTIFY}" +} + +@test "a non-numeric, zero or negative threshold exits 2 and posts nothing" { + local bad + for bad in abc 0 -5 4.5 "" " 40"; do + run main "${bad}" + if [[ -z "${bad}" ]]; then + # An empty first argument falls back to the default. + [ "${status}" -eq 0 ] + continue + fi + [ "${status}" -eq 2 ] + [[ "${output}" == *"threshold must be a whole number"* ]] + [ ! -e "${NOTIFY}" ] + done +} + +# --------------------------------------------------------------------------- +# Failures are reported, not silent +# --------------------------------------------------------------------------- + +@test "a df failure posts a check-failed notification and exits 1" { + echo 1 >"${STUBS}/df.rc" + run main + [ "${status}" -eq 1 ] + [[ "${output}" == *"could not read free space on ${TMPDIR_T}"* ]] + grep -qx "Disk space check failed" "${NOTIFY}" +} + +@test "unparseable df output is treated as a failure" { + echo "n/a" >"${STUBS}/df.kib" + run main + [ "${status}" -eq 1 ] + grep -qx "Disk space check failed" "${NOTIFY}" +} + +@test "missing df is a failure, not a pass" { + DF_BIN="" + run main + [ "${status}" -eq 1 ] + grep -qx "Disk space check failed" "${NOTIFY}" +} + +@test "an osascript failure on a low reading exits 1 and logs the alert" { + _free_gib 10 + echo 1 >"${STUBS}/osascript.rc" + run main + [ "${status}" -eq 1 ] + [[ "${output}" == *"osascript failed; could not post: Low disk space"* ]] +} + +@test "missing osascript on a low reading exits 1" { + _free_gib 10 + OSASCRIPT_BIN="${TMPDIR_T}/does-not-exist/osascript" + run main + [ "${status}" -eq 1 ] + [[ "${output}" == *"osascript not found"* ]] +} + +@test "df is asked about DATA_VOLUME in POSIX KiB form" { + run main + grep -qx "df -Pk ${TMPDIR_T}" "${DF_CALLS}" +} + +# --------------------------------------------------------------------------- +# Notification safety +# --------------------------------------------------------------------------- + +@test "title and message reach osascript as argv, not as script source" { + DATA_VOLUME='/Vol "quoted" & end tell' + _free_gib 5 + run main + [ "${status}" -eq 0 ] + # The three -e lines are fixed; the volume name appears only in argv. + [ "$(sed -n '1,6p' "${NOTIFY}" | grep -c 'quoted')" -eq 0 ] + grep -qx '5 GiB free on /Vol "quoted" & end tell (alert below 40 GiB).' "${NOTIFY}" +} + +# --------------------------------------------------------------------------- +# Script shape +# --------------------------------------------------------------------------- + +@test "the script never deletes or moves a path itself" { + # Same guard as tests/disk-cleanup.bats. Comment lines are ignored. + run grep -nE '^([^#]*[^[:alnum:]_-])?(rm|rmdir|mv|unlink|shred)([^[:alnum:]_-]|$)|^[^#]*[[:space:]]-delete([^[:alnum:]_]|$)' \ + "${REPO_ROOT}/disk-space-alert.sh" + [ "${status}" -eq 1 ] +} + +@test "sourcing the script does not run main" { + [ "$(type -t main)" = "function" ] + [ ! -e "${NOTIFY}" ] + [ ! -e "${DF_CALLS}" ] +} From 2e1bc0a2a682c55f3e613200ca5abab146ae5521 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Fri, 23 Oct 2026 16:41:44 -0700 Subject: [PATCH 4/4] fix(disk-cleanup): SIGKILL simctl 10 s after its timeout simctl hung past 300 s during the 2026-10-22 cleanup. timeout sends only SIGTERM by default, so a simctl that ignores it would still hang the run. `timeout -k 10s` follows with SIGKILL, capping the step at timeout + 10 s. GNU timeout exits 137 after that SIGKILL, and also when the child is SIGKILLed from outside before any timeout (checked with coreutils 9.12). The 137 log line names both causes rather than claiming a timeout. --- disk-cleanup.sh | 9 +++++++-- tests/disk-cleanup.bats | 14 +++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/disk-cleanup.sh b/disk-cleanup.sh index 8d88d95..28dc27c 100755 --- a/disk-cleanup.sh +++ b/disk-cleanup.sh @@ -14,6 +14,7 @@ DF_BIN="${DF_BIN:-$(command -v df || true)}" # coreutils; without it the step skips. TIMEOUT_BIN="${TIMEOUT_BIN:-$(command -v timeout || command -v gtimeout || true)}" SIMCTL_TIMEOUT_SECONDS="${SIMCTL_TIMEOUT_SECONDS:-120}" +SIMCTL_KILL_GRACE_SECONDS=10 # Free space is logged for the Data volume; / is the read-only system volume. DATA_VOLUME="${DATA_VOLUME:-/System/Volumes/Data}" @@ -100,12 +101,16 @@ function step_simctl() { log_info "simctl: timeout/gtimeout not found, skipping rather than running simctl unbounded" return "${STEP_SKIPPED}" fi - log_info "simctl: delete unavailable (timeout ${SIMCTL_TIMEOUT_SECONDS}s)" + log_info "simctl: delete unavailable (timeout ${SIMCTL_TIMEOUT_SECONDS}s, kill after ${SIMCTL_KILL_GRACE_SECONDS}s grace)" local rc=0 - "${TIMEOUT_BIN}" "${SIMCTL_TIMEOUT_SECONDS}" "${XCRUN_BIN}" simctl delete unavailable || rc=$? + "${TIMEOUT_BIN}" -k "${SIMCTL_KILL_GRACE_SECONDS}s" "${SIMCTL_TIMEOUT_SECONDS}" "${XCRUN_BIN}" simctl delete unavailable || rc=$? if ((rc == 124)); then log_error "simctl: timed out after ${SIMCTL_TIMEOUT_SECONDS}s" return "${STEP_FAILED}" + elif ((rc == 137)); then + # 137 is SIGKILL: from timeout -k, or from outside (OOM). GNU timeout gives 137 for both. + log_error "simctl: killed (exit 137): timed out after ${SIMCTL_TIMEOUT_SECONDS}s and ignored SIGTERM for ${SIMCTL_KILL_GRACE_SECONDS}s, or killed from outside" + return "${STEP_FAILED}" elif ((rc != 0)); then log_error "simctl: delete unavailable failed (exit ${rc})" return "${STEP_FAILED}" diff --git a/tests/disk-cleanup.bats b/tests/disk-cleanup.bats index e4f6f91..92dc75d 100755 --- a/tests/disk-cleanup.bats +++ b/tests/disk-cleanup.bats @@ -42,6 +42,7 @@ EOF echo "timeout $*" >>"${CALLS}" rc_file="$(dirname "$0")/timeout.rc" if [[ -f "${rc_file}" ]]; then exit "$(cat "${rc_file}")"; fi +if [[ "${1}" == "-k" ]]; then shift 2; fi shift exec "$@" EOF @@ -183,12 +184,12 @@ _fail() { # simctl runs under timeout # --------------------------------------------------------------------------- -@test "simctl is wrapped in timeout with SIMCTL_TIMEOUT_SECONDS" { +@test "simctl gets a 10-second SIGKILL grace period after its timeout" { run step_simctl [ "${status}" -eq 0 ] - grep -qx "timeout 7 ${STUBS}/xcrun simctl delete unavailable" "${CALLS}" + grep -qx "timeout -k 10s 7 ${STUBS}/xcrun simctl delete unavailable" "${CALLS}" # The timeout call comes first: xcrun ran through it, not beside it. - [ "$(head -n 1 "${CALLS}")" = "timeout 7 ${STUBS}/xcrun simctl delete unavailable" ] + [ "$(head -n 1 "${CALLS}")" = "timeout -k 10s 7 ${STUBS}/xcrun simctl delete unavailable" ] } @test "a simctl timeout (exit 124) is a failure that says it timed out" { @@ -198,6 +199,13 @@ _fail() { [[ "${output}" == *"simctl: timed out after 7s"* ]] } +@test "a simctl SIGKILL (exit 137) is a failure that names both causes" { + _fail timeout 137 + run step_simctl + [ "${status}" -eq "${STEP_FAILED}" ] + [[ "${output}" == *"simctl: killed (exit 137): timed out after 7s and ignored SIGTERM for 10s, or killed from outside"* ]] +} + @test "no timeout binary skips simctl rather than running it unbounded" { TIMEOUT_BIN="" run main