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/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 9a1a287..4dab42c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ 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`. | +| `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 @@ -92,7 +94,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..28dc27c --- /dev/null +++ b/disk-cleanup.sh @@ -0,0 +1,152 @@ +#!/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}" +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}" + +# 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, kill after ${SIMCTL_KILL_GRACE_SECONDS}s grace)" + local rc=0 + "${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}" + 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/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 new file mode 100644 index 0000000..8d0c9c3 --- /dev/null +++ b/docs/plans/2026-10-22-disk-space-automation.md @@ -0,0 +1,129 @@ +# 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. + +- 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 + +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/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. diff --git a/tests/disk-cleanup.bats b/tests/disk-cleanup.bats new file mode 100755 index 0000000..92dc75d --- /dev/null +++ b/tests/disk-cleanup.bats @@ -0,0 +1,243 @@ +#!/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 +if [[ "${1}" == "-k" ]]; then shift 2; 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 gets a 10-second SIGKILL grace period after its timeout" { + run step_simctl + [ "${status}" -eq 0 ] + 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 -k 10s 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 "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 + [ "${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]*$ ]] +} 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}" ] +}