diff --git a/CLAUDE.md b/CLAUDE.md index a906a7c..13b3cbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ Bash, shellcheck-clean (project follows the user's global bash standards in `~/. All slices shipped. Install with `bin/install`; uninstall with `bin/uninstall`. The LaunchAgent auto-starts the watcher on login and writes its log to `~/Library/Logs/lock-sync.log`. Per-slice design docs live under `docs/plans/`. -**Upgrade path:** every machine acting as a Synergy client is a symmetric peer, not just the primary/server — `bin/install` must be re-run on EVERY client machine when upgrading, not only on the machine you're actively developing on. `lock-fanout` invokes `lock-guard` on each client via the absolute path `$HOME/.local/bin/lock-guard`, which only exists once `bin/install` has symlinked it there. Skipping install on any client leaves `lock-guard` missing on that client, and locking fails for it (ssh_exit=127 / command-not-found) until install is re-run there. +**Upgrade path:** only the controller (the machine running `lock-watcher`/`lock-fanout`, i.e. the Synergy server) needs `bin/install` run on it, including on upgrade — `git pull` there is enough to pick up a new `lock-guard`. Clients never run `bin/install` and never clone this repo: `lock-fanout`'s `provision_host` function pushes `bin/lock-guard` to each client over SSH automatically, at lock time, on demand. It compares checksums first (`shasum -a 256`, local vs. `~/.local/bin/lock-guard` on the client) and only pushes when the file is missing or stale, creating `~/.local/bin` with `mkdir -p` for a never-before-provisioned client. If provisioning itself fails for a client (e.g. unreachable, or the client is missing `shasum`), `lock-fanout` logs `warn=provision-failed` for that client and falls back to a direct `pmset displaysleepnow` call for that cycle — the client still locks, it just loses meeting-suppression for that one cycle (self-heals next cycle once the client is reachable again). Watch `~/Library/Logs/lock-sync.log` for `warn=provision-failed` to recognize this case. ## lock-guard: one-time Chrome Automation permission @@ -50,9 +50,11 @@ first time this runs on a client, macOS prompts for Automation permission (System Settings > Privacy & Security > Automation). Because `lock-guard` runs non-interactively over SSH from launchd, there is no interactive session to click "Allow" in — grant this manually once per client, by -running `bin/lock-guard` interactively at a local Terminal on that client -after install, approving the Chrome automation prompt when it appears. If -the permission is never granted, Meet-tab detection silently fails open +running `~/.local/bin/lock-guard` interactively at a local Terminal on that +client (auto-provisioned there by `lock-fanout` on the first lock cycle; no +repo clone or `bin/install` needed on the client — see "Upgrade path" +above), approving the Chrome automation prompt when it appears. If the +permission is never granted, Meet-tab detection silently fails open (treated as "no Meet tab found," never as a fatal error) — the process-list and mic-active checks still work normally. @@ -64,7 +66,7 @@ and mic-active checks still work normally. - `bin/uninstall` — bootout the agent, remove plist and symlinks. Preserves the log file. Idempotent. - `bin/list-clients [path]` — slice (a). Parse Synergy conf, emit `.local` hostnames, resolving real display names via the sibling `db.json` when available (see Key external inputs). No arg → reads `~/Library/Preferences/Synergy/synergy.conf`. - `bin/lock-watcher` — slices (b)+(c1). Subscribe to `com.apple.sessionagent.screenIsLocked`; on each event emit ` locked` and invoke `bin/lock-fanout`. Blocks until signaled. -- `bin/lock-fanout` — slice (c1). Reads hosts from `list-clients`, applies per-host overrides from `~/.config/lock-sync/config`, SSHes `lock-guard` to each client (see the `bin/lock-guard` bullet below), emits one ` client= user= ssh_exit=` line per host. +- `bin/lock-fanout` — slice (c1). Reads hosts from `list-clients`, applies per-host overrides from `~/.config/lock-sync/config`, provisions `lock-guard` on each client on demand over SSH if missing or stale (checksum-compared; see "Upgrade path" above), then SSHes `lock-guard` to each client (see the `bin/lock-guard` bullet below; falls back to bare `pmset displaysleepnow` if provisioning fails), emits one ` client= user= ssh_exit=` line per host (plus a `warn=provision-failed` line when provisioning itself failed for that host). - `bin/lock-guard` — runs on each client (invoked remotely by `lock-fanout` in place of a bare `pmset displaysleepnow`). Skips the lock and logs why if the client looks like it's in a call: a known meeting app is running (`~/.config/lock-sync/guard-processes`), the microphone is actively in use, or a Google Meet room tab is open in Chrome. Emits ` action=sleep` or ` action=suppress reason=`. ## `.claude/` directory diff --git a/README.md b/README.md index e305972..524d5b6 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,14 @@ Absent file uses the built-in default. Present file replaces it entirely ## Chrome Automation permission (one-time setup) `lock-guard`'s Google Meet detection requires macOS Automation permission. -On first install, run `bin/lock-guard` interactively at a Terminal on each -client to grant this permission: +Clients don't need the repo cloned or `bin/install` run on them — `lock-fanout` +pushes `lock-guard` to each client automatically over SSH the first time it +locks that client (see "How clients get `lock-guard`" below). After that's +happened once, grant the Automation permission by running the provisioned +copy interactively at a Terminal on the client: ```sh -bin/lock-guard +~/.local/bin/lock-guard ``` When prompted, navigate to System Settings > Privacy & Security > Automation @@ -85,6 +88,18 @@ and allow Terminal (or your shell's name) to automate Chrome. Without this permission, Meet-tab detection silently skips (the process-list and microphone checks still work). +## How clients get `lock-guard` + +Clients are zero-footprint: no clone, no `bin/install`, no manual copying. +Each time `lock-fanout` runs, it checksums the local `bin/lock-guard` +against the copy at `~/.local/bin/lock-guard` on the client (`shasum -a +256` over SSH) and pushes a fresh copy only if it's missing or stale, +creating `~/.local/bin` on the client if needed. If that provisioning step +fails for a client (unreachable, missing `shasum`, etc.), `lock-fanout` +logs `warn=provision-failed` for that client and falls back to a bare +`pmset displaysleepnow` call for that cycle — the client still locks, just +without meeting-suppression until the client is reachable again. + ## Verify After install, lock the screen (Ctrl+Cmd+Q) and tail the log: @@ -169,7 +184,7 @@ bin/ ├── uninstall # reverse install, preserve log ├── list-clients # parse synergy.conf → .local hostnames ├── lock-watcher # subscribe to Darwin screen-lock notification -├── lock-fanout # ssh lock-guard to each client +├── lock-fanout # provision + ssh lock-guard to each client └── lock-guard # client-side lock suppression (meeting detection) tests/ # bats test suite (`bats tests/` to run) diff --git a/bin/lock-fanout b/bin/lock-fanout index 3146519..e4a17ad 100755 --- a/bin/lock-fanout +++ b/bin/lock-fanout @@ -8,6 +8,8 @@ list_clients_cmd="${LOCK_SYNC_LIST_CLIENTS:-$script_dir/list-clients}" # (e.g. 1Password agents). Overridable via LOCK_SYNC_SSH for tests. ssh_cmd="${LOCK_SYNC_SSH:-/usr/bin/ssh}" default_user="${USER:-$(id -un)}" +local_lock_guard="$script_dir/lock-guard" +shasum_cmd="${LOCK_SYNC_SHASUM:-/usr/bin/shasum}" log() { local ts @@ -24,23 +26,114 @@ lookup_user() { ' "$conf" } +# Ensures $host has a current copy of lock-guard at ~/.local/bin/lock-guard, +# pushing it over SSH if missing or stale, without requiring the client to +# ever clone this repo (see docs/plans/2026-08-12-lock-guard-remote- +# provisioning-design.md — clients have zero footprint by design). +# Returns 0 if the client now has (or already had) a current lock-guard. +# Returns 1 if the SSH session itself failed (client unreachable, auth +# failure, timeout, etc) during the checksum check or the push — callers +# fall back to bare pmset in that case rather than attempting a lock-guard +# call that would fail the same way. A remote lock-guard that simply +# doesn't exist yet (new client) is NOT treated as a failure here: it's +# expected to fall through to the push logic (see the `|| true` comment +# below on the checksum call for why). +provision_host() { + local host="$1" user="$2" local_sha remote_sha rc=0 + + # Fail fast if the local lock-guard is missing/unreadable, before any + # shasum or ssh call. Without this guard, a missing local_lock_guard + # would make local_sha an empty string, which never matches a real + # remote_sha, so the function would still attempt the push below with + # stdin redirected from a nonexistent file — a risk of transferring an + # empty stream and leaving the client with a zero-byte-but-executable + # lock-guard that exits 0 without ever calling pmset (silently never + # locking), instead of cleanly falling back to the pmset path. + [[ -r "$local_lock_guard" ]] || return 1 + + local_sha="$("$shasum_cmd" -a 256 "$local_lock_guard" 2>/dev/null | awk '{print $1}')" + + # Bare `shasum` here (not $shasum_cmd) is intentional, not an oversight: + # $shasum_cmd/LOCK_SYNC_SHASUM is a local-machine override for tests, and + # the remote side is a different machine entirely — there is nothing to + # thread the override to. Relies on stock macOS shipping shasum at + # /usr/bin/shasum on the client's non-interactive SSH PATH; a client + # missing shasum will permanently hit the warn=provision-failed fallback + # below (never regain meeting-suppression) since that log line doesn't + # distinguish "unreachable" from "shasum missing" — a future reader + # should know this rather than assume it's a mystery. + # + # `|| true` on the remote side is deliberate, not a swallowed error: a + # brand-new client that has never had lock-guard installed will have no + # ~/.local/bin/lock-guard, and `shasum` on a missing file exits 1 even + # with stderr redirected to /dev/null. Without `|| true`, that remote + # exit 1 becomes ssh's own exit code, which is indistinguishable here + # from ssh itself failing (unreachable host, auth failure, timeout) — + # the two would both trip `rc != 0` below and return 1, permanently + # stuck falling back to bare pmset and never provisioning new clients. + # `|| true` makes "file not found" exit 0 with empty stdout, so `rc` + # below reflects only "did the SSH session itself succeed" — the actual + # signal this function needs to decide push-vs-fallback. An empty + # remote_sha never equals a real local_sha, so it still falls through to + # the push logic exactly as a checksum mismatch would. + remote_sha="$("$ssh_cmd" -n -o BatchMode=yes \ + -o ConnectTimeout=5 \ + -o StrictHostKeyChecking=accept-new \ + "$user@$host" 'shasum -a 256 ~/.local/bin/lock-guard 2>/dev/null || true' 2>/dev/null)" || rc=$? + if ((rc != 0)); then + return 1 + fi + remote_sha="${remote_sha%% *}" + + if [[ "$remote_sha" == "$local_sha" ]]; then + return 0 + fi + + # Write to a temp file and atomically rename into place, so a truncated + # or dropped transfer never leaves a partial-but-executable lock-guard on + # the client: the client only ever has the old complete file or the new + # complete file, never something in between. The temp filename is a + # fixed literal, not generated via remote `$(mktemp)`, so this whole + # remote script has no `$`-expansions and no ~-in-quotes for the local + # shell to (mis)interpret — it's an inert remote-shell literal end to end. + "$ssh_cmd" -o BatchMode=yes \ + -o ConnectTimeout=5 \ + -o StrictHostKeyChecking=accept-new \ + "$user@$host" 'mkdir -p ~/.local/bin && cat >~/.local/bin/.lock-guard.tmp && chmod +x ~/.local/bin/.lock-guard.tmp && mv -f ~/.local/bin/.lock-guard.tmp ~/.local/bin/lock-guard' \ + <"$local_lock_guard" >/dev/null 2>&1 || rc=$? + if ((rc != 0)); then + return 1 + fi + return 0 +} + fanout_host() { local host="$1" override user rc=0 override=$(lookup_user "$host") user="${override:-$default_user}" - # `-n` redirects ssh's stdin from /dev/null. Required because ssh otherwise - # reads from the caller's stdin, and in `main`'s `while read <<< "$clients"` - # loop that means ssh consumes the remaining hosts, so only the first - # iteration runs. - # Absolute path, with $HOME escaped so it expands on the remote shell, not - # locally: a non-interactive ssh session's PATH (/usr/bin:/bin:/usr/sbin: - # /sbin) does not include ~/.local/bin, where bin/install symlinks - # lock-guard. - "$ssh_cmd" -n -o BatchMode=yes \ - -o ConnectTimeout=5 \ - -o StrictHostKeyChecking=accept-new \ - "$user@$host" "\$HOME/.local/bin/lock-guard" >/dev/null 2>&1 || rc=$? - log "client=$host user=$user ssh_exit=$rc" + + if provision_host "$host" "$user"; then + # `-n` redirects ssh's stdin from /dev/null. Required because ssh otherwise + # reads from the caller's stdin, and in `main`'s `while read <<< "$clients"` + # loop that means ssh consumes the remaining hosts, so only the first + # iteration runs. + # Absolute path, with $HOME escaped so it expands on the remote shell, not + # locally: a non-interactive ssh session's PATH (/usr/bin:/bin:/usr/sbin: + # /sbin) does not include ~/.local/bin, where provision_host (above) just + # placed lock-guard on the client — no client-side bin/install needed. + "$ssh_cmd" -n -o BatchMode=yes \ + -o ConnectTimeout=5 \ + -o StrictHostKeyChecking=accept-new \ + "$user@$host" "\$HOME/.local/bin/lock-guard" >/dev/null 2>&1 || rc=$? + log "client=$host user=$user ssh_exit=$rc" + else + log "client=$host user=$user warn=provision-failed" + "$ssh_cmd" -n -o BatchMode=yes \ + -o ConnectTimeout=5 \ + -o StrictHostKeyChecking=accept-new \ + "$user@$host" "pmset displaysleepnow" >/dev/null 2>&1 || rc=$? + log "client=$host user=$user ssh_exit=$rc" + fi } main() { diff --git a/docs/plans/2026-08-12-lock-guard-remote-provisioning-design.md b/docs/plans/2026-08-12-lock-guard-remote-provisioning-design.md new file mode 100644 index 0000000..90619f6 --- /dev/null +++ b/docs/plans/2026-08-12-lock-guard-remote-provisioning-design.md @@ -0,0 +1,434 @@ +# `lock-fanout` remote provisioning of `lock-guard` + +Date: 2026-08-12 +Status: approved, ready to implement + +## Purpose + +PR #25 (docs/plans/2026-08-12-lock-guard-meeting-suppression-design.md) +changed `lock-fanout` to invoke `lock-guard` on each client over SSH, in +place of a bare `pmset displaysleepnow`. This silently broke the project's +deployment model: before PR #25, clients needed **zero footprint** — no +git clone, no `bin/install`, nothing beyond an SSH key and a stock macOS +`pmset` binary. After PR #25, every client needs `lock-guard` symlinked +into `~/.local/bin` via `bin/install`, which requires cloning this repo +onto every client. The repo +owner does not want clients to ever clone this repo — that was never the +model and isn't going to become the model. + +This plan restores the zero-footprint client property by having the +controller push `lock-guard`'s content directly over SSH, on demand, as +part of the lock flow — no client-side git clone or `bin/install` ever +required. + +## Non-goals reminder + +Per CLAUDE.md: no unlock, no passwords/keychain, no remote wake, no +bidirectional sync. This plan is push-only and lock-triggered — the +controller pushes a file to a client as part of handling its own lock +event; the client never initiates anything back to the controller, and +nothing here reintroduces bidirectional sync or a persistent client-side +agent. This is an extension of the trust model `lock-fanout` already has +(SSH key auth, remote command execution) — not a new one. + +## Design + +### Flow + +```text +1. bin/install runs on the controller (unchanged, today's behavior). +2. Controller locks -> lock-watcher fires -> lock-fanout runs (unchanged). +3. For each client (from list-clients, unchanged): + a. provision_host: SSH a checksum check of the client's + ~/.local/bin/lock-guard against the controller's local copy. + - Missing or mismatched -> push lock-guard's content over SSH, + chmod +x remotely. + - Matches -> no push, nothing to do. + - The checksum-check/push SSH call itself fails (client + unreachable, remote error) -> log a provisioning warning and + fall back to invoking `pmset displaysleepnow` directly for this + client THIS CYCLE ONLY (skip lock-guard's suppression checks + entirely for this client on this lock event) rather than + attempting a lock-guard call that would just fail the same way + #25 did. + b. If provisioning succeeded (pushed or already current): invoke + lock-guard as today (unchanged call, unchanged log line shape). +``` + +### Why fall back to bare `pmset` on provisioning failure, not skip the client + +A client lock-fanout can't provision this cycle is still a client that +should lock (without meeting-suppression) rather than not lock at all — +falling back to the pre-#25 behavior avoids quietly reintroducing #25's +exact failure mode (a client that silently never locks) for any client the +provisioning step itself can't reach. This trades away that client's +meeting-suppression for one cycle in exchange for guaranteeing the client +still locks. + +### Why two SSH round-trips per client, not one combined call + +Keeps each concern (provisioning vs. locking) in its own function/call, +matching the existing separation between `lock-fanout` (orchestration) and +`lock-guard` (client-side decision). A combined single-SSH-call approach +(push+run in one invocation) is more complex to get right and doesn't +meaningfully reduce round-trips in practice (pushing file content and then +executing it are different operations even within one SSH session). The +extra round-trip's latency cost is accepted for this minimal version; +issue #26 tracks reconsidering whether provisioning should move out of the +per-lock critical path entirely (e.g. only at controller boot / on +`bin/install`) as a later optimization — out of scope here. + +### Checksum mechanism + +`shasum -a 256` (already present on stock macOS, no new dependency, +consistent with the project's "minimal footprint" philosophy). The +controller computes its own `bin/lock-guard`'s checksum locally (no SSH +needed — it's the controller's own file) and compares against what the +client reports. + +### Push mechanism + +`ssh client 'cat > ~/.local/bin/lock-guard && chmod +x ~/.local/bin/lock-guard'`, +fed the controller's local `bin/lock-guard` file content via SSH's stdin. +This requires `~/.local/bin` to exist on the client — create it as part of +the same remote command if missing (`mkdir -p ~/.local/bin`), since a +client that's never been provisioned won't have that directory either +(consistent with "clients need zero prior setup"). + +### Failure mode philosophy + +Matches `lock-fanout`'s existing philosophy: log clearly, never let one +client's failure block others, never crash the loop. Every new SSH call +this plan introduces (checksum check, push) follows the same pattern +already used for the `lock-guard`/`pmset` calls: capture `rc=$?`, log it, +move on. + +## Files touched + +- **Modify:** `bin/lock-fanout` — add `provision_host` (checksum check + + conditional push), call it before the existing `lock-guard` invocation + in `fanout_host`, add the bare-`pmset`-fallback path for provisioning + failure. +- **Modify:** `tests/lock-fanout.bats` — new tests for: successful + provisioning push, skip-push-when-checksum-matches, provisioning + failure falls back to bare `pmset displaysleepnow`. + +`bin/lock-guard` itself is unchanged by this plan — it's the payload being +pushed, not modified. + +## Testability + +Following the existing pattern (SSH is stubbed via `LOCK_SYNC_SSH`), the +new `provision_host` logic reuses the same `ssh_cmd` variable and the same +test-stub SSH binary already used by every other `lock-fanout` test. No new +environment-variable overrides are needed — `shasum` is not stubbed; +`provision_host`'s local checksum of the controller's own `bin/lock-guard` +runs unstubbed in tests too, since it's a real, fast, local, no-network +operation reading a real file that's already present in the test +environment (the repo's own `bin/lock-guard`). Only the SSH round-trips to +the "client" are stubbed. + +--- + +## Task 1: `provision_host` — checksum check, conditional push, fallback on failure + +**Files:** + +- Modify: `bin/lock-fanout` +- Modify: `tests/lock-fanout.bats` + +**Interfaces:** + +- Produces: `provision_host(host, user)` function in `bin/lock-fanout`. + Returns 0 if the client now has (or already had) a current + `~/.local/bin/lock-guard`. Returns 1 if provisioning failed (client + unreachable, checksum-check or push SSH call failed) — callers use this + return value to decide whether to invoke `lock-guard` normally or fall + back to bare `pmset displaysleepnow`. +- Consumes: `ssh_cmd`, `log()` (both already defined earlier in + `bin/lock-fanout`, unchanged). + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/lock-fanout.bats`. These tests need the ssh stub to +distinguish between the two different remote commands `provision_host` and +the existing `lock-guard` call will send (a `shasum` check vs. a `cat >` +push vs. the existing `lock-guard` invocation vs. a `pmset` fallback), so +extend `make_ssh_stub` to log every distinct remote command it receives (not +just the last one) to `SSH_LOG`, and to support per-call scripted responses +via env vars. Replace the existing `make_ssh_stub` function with this +version: + +```bash +# Stub for ssh; records EVERY distinct remote command invocation to SSH_LOG +# as "TARGET= CMD=", one line per +# ssh call (not just the last, since provisioning now makes multiple calls +# per client). Exits with code from arg-match. By default exits 0; set +# STUB_SSH_FAIL_HOST to have all calls targeting that host exit 255. +# STUB_SSH_CHECKSUM_HOST/STUB_SSH_CHECKSUM_VALUE let a test script a +# specific shasum stdout for a specific host's checksum-check call. +make_ssh_stub() { + cat >"$STUB_DIR/ssh" <<'STUBEOF' +#!/bin/bash +has_n=0 +target="" +remote_cmd="" +for a in "$@"; do + case "$a" in + -n) has_n=1 ;; + -o|BatchMode=yes|ConnectTimeout=5|StrictHostKeyChecking=accept-new) ;; + *@*) target="$a" ;; + *) [[ -n "$target" ]] && remote_cmd="$a" ;; + esac +done +stdin_marker="" +if (( has_n == 0 )); then + stdin_marker="STDIN=$(cat)" +fi +echo "TARGET=$target CMD=${remote_cmd} ${stdin_marker}" >>"$SSH_LOG" +if [[ -n "${STUB_SSH_FAIL_HOST:-}" && "$target" == *"$STUB_SSH_FAIL_HOST"* ]]; then + exit 255 +fi +if [[ -n "${STUB_SSH_CHECKSUM_HOST:-}" && "$target" == *"$STUB_SSH_CHECKSUM_HOST"* && "$remote_cmd" == *shasum* ]]; then + echo "${STUB_SSH_CHECKSUM_VALUE:-}" +fi +exit 0 +STUBEOF + chmod +x "$STUB_DIR/ssh" +} +``` + +Add these tests: + +```bash +@test "provisioning: checksum mismatch pushes lock-guard content and chmods it" { + make_list_clients_stub "asiago.local" + export STUB_SSH_CHECKSUM_HOST="asiago.local" + export STUB_SSH_CHECKSUM_VALUE="0000000000000000000000000000000000000000000000000000000000000000 lock-guard" + make_ssh_stub + + run "$SCRIPT" + [ "$status" -eq 0 ] + grep -q 'TARGET=.*asiago.local CMD=.*shasum' "$SSH_LOG" + grep -q 'TARGET=.*asiago.local CMD=.*cat >' "$SSH_LOG" + grep -q "TARGET=.*asiago.local CMD=.*chmod +x" "$SSH_LOG" +} + +@test "provisioning: matching checksum skips the push" { + make_list_clients_stub "asiago.local" + local_sha=$(shasum -a 256 "$BATS_TEST_DIRNAME/../bin/lock-guard" | awk '{print $1}') + export STUB_SSH_CHECKSUM_HOST="asiago.local" + export STUB_SSH_CHECKSUM_VALUE="$local_sha lock-guard" + make_ssh_stub + + run "$SCRIPT" + [ "$status" -eq 0 ] + grep -q 'TARGET=.*asiago.local CMD=.*shasum' "$SSH_LOG" + ! grep -q 'CMD=.*cat >' "$SSH_LOG" +} + +@test "provisioning failure falls back to bare pmset displaysleepnow for that client" { + make_list_clients_stub "asiago.local" + export STUB_SSH_FAIL_HOST="asiago.local" + make_ssh_stub + + run "$SCRIPT" + [ "$status" -eq 0 ] + grep -q 'TARGET=.*asiago.local CMD=.*pmset displaysleepnow' "$SSH_LOG" + ! grep -q 'CMD=.*lock-guard' "$SSH_LOG" + [[ "${lines[0]}" == *"client=asiago.local"* ]] + [[ "${lines[0]}" == *"warn=provision-failed"* ]] +} + +@test "successful provisioning still invokes lock-guard normally afterward" { + make_list_clients_stub "asiago.local" + local_sha=$(shasum -a 256 "$BATS_TEST_DIRNAME/../bin/lock-guard" | awk '{print $1}') + export STUB_SSH_CHECKSUM_HOST="asiago.local" + export STUB_SSH_CHECKSUM_VALUE="$local_sha lock-guard" + make_ssh_stub + + run "$SCRIPT" + [ "$status" -eq 0 ] + grep -q 'TARGET=.*asiago.local CMD=.*\$HOME/.local/bin/lock-guard' "$SSH_LOG" + [[ "${lines[0]}" == *"client=asiago.local"* ]] + [[ "${lines[0]}" == *"ssh_exit=0"* ]] +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bats tests/lock-fanout.bats` +Expected: the 4 new tests FAIL (`provision_host` not implemented, and the +old `make_ssh_stub` — before your replacement — didn't log per-call +history). The 9 pre-existing tests may also need re-verification since +`make_ssh_stub` changed shape — the pre-existing `REMOTE_CMD=` assertions +in older tests must be updated to the new `CMD=` log format in this same +step (see Step 3 for the exact replacements), otherwise those tests will +also fail after the stub replacement — that's expected and intentional at +this point, not a new bug. + +Update the pre-existing tests' assertions from `TARGET=...` / `REMOTE_CMD=...` +(two separate lines) to the new single-line `TARGET=... CMD=...` format, +e.g. change: + +```bash + grep -q '^TARGET=adminuser@asiago.local$' "$SSH_LOG" +``` + +to: + +```bash + grep -q 'TARGET=adminuser@asiago.local CMD=' "$SSH_LOG" +``` + +Apply the equivalent transform to every pre-existing test that greps +`SSH_LOG` for `TARGET=` or `REMOTE_CMD=` (there are several — search the +file for both strings and update each). The final +`"remote command is lock-guard's absolute path, not a bare pmset call"` +test's assertion becomes: + +```bash + grep -q 'CMD=\$HOME/.local/bin/lock-guard' "$SSH_LOG" +``` + +This test also needs `STUB_SSH_CHECKSUM_HOST`/`STUB_SSH_CHECKSUM_VALUE` set +to a matching checksum (same pattern as +"successful provisioning still invokes lock-guard normally afterward" +above) so provisioning succeeds and falls through to the lock-guard call — +otherwise, after Step 3's implementation, this test would instead exercise +the bare-`pmset`-fallback path and its old assertion would fail for the +right reason but the wrong test. + +- [ ] **Step 3: Implement `provision_host` in `bin/lock-fanout`** + +Add near the top of the file, alongside the other variable declarations: + +```bash +local_lock_guard="$script_dir/lock-guard" +shasum_cmd="${LOCK_SYNC_SHASUM:-/usr/bin/shasum}" +``` + +Add the `provision_host` function, before `fanout_host`: + +```bash +# Ensures $host has a current copy of lock-guard at ~/.local/bin/lock-guard, +# pushing it over SSH if missing or stale, without requiring the client to +# ever clone this repo (see docs/plans/2026-08-12-lock-guard-remote- +# provisioning-design.md — clients have zero footprint by design). +# Returns 0 if the client now has (or already had) a current lock-guard. +# Returns 1 if the checksum check or push itself failed (client +# unreachable, etc) — callers fall back to bare pmset in that case rather +# than attempting a lock-guard call that would fail the same way. +provision_host() { + local host="$1" user="$2" local_sha remote_sha rc=0 + + local_sha="$("$shasum_cmd" -a 256 "$local_lock_guard" | awk '{print $1}')" + + remote_sha="$("$ssh_cmd" -n -o BatchMode=yes \ + -o ConnectTimeout=5 \ + -o StrictHostKeyChecking=accept-new \ + "$user@$host" 'shasum -a 256 ~/.local/bin/lock-guard 2>/dev/null' 2>/dev/null)" || rc=$? + if ((rc != 0)); then + return 1 + fi + remote_sha="${remote_sha%% *}" + + if [[ "$remote_sha" == "$local_sha" ]]; then + return 0 + fi + + "$ssh_cmd" -o BatchMode=yes \ + -o ConnectTimeout=5 \ + -o StrictHostKeyChecking=accept-new \ + "$user@$host" 'mkdir -p ~/.local/bin && cat > ~/.local/bin/lock-guard && chmod +x ~/.local/bin/lock-guard' \ + <"$local_lock_guard" >/dev/null 2>&1 || rc=$? + if ((rc != 0)); then + return 1 + fi + return 0 +} +``` + +Note: the push call intentionally omits `-n` (unlike every other ssh call +in this file) because it needs to read `bin/lock-guard`'s content from +stdin (`<"$local_lock_guard"`) rather than from `/dev/null` — this is safe +here because `provision_host` is called once per host inside `fanout_host`, +not inside a `while read` loop over stdin the way `main`'s host-list +iteration is, so there's no risk of it consuming a shared stdin stream (see +the existing `-n` comment in `fanout_host` for why that risk exists +elsewhere in this file). + +Update `fanout_host`: + +```bash +fanout_host() { + local host="$1" override user rc=0 + override=$(lookup_user "$host") + user="${override:-$default_user}" + + if provision_host "$host" "$user"; then + # `-n` redirects ssh's stdin from /dev/null. Required because ssh otherwise + # reads from the caller's stdin, and in `main`'s `while read <<< "$clients"` + # loop that means ssh consumes the remaining hosts, so only the first + # iteration runs. + "$ssh_cmd" -n -o BatchMode=yes \ + -o ConnectTimeout=5 \ + -o StrictHostKeyChecking=accept-new \ + "$user@$host" "\$HOME/.local/bin/lock-guard" >/dev/null 2>&1 || rc=$? + log "client=$host user=$user ssh_exit=$rc" + else + log "client=$host user=$user warn=provision-failed" + "$ssh_cmd" -n -o BatchMode=yes \ + -o ConnectTimeout=5 \ + -o StrictHostKeyChecking=accept-new \ + "$user@$host" pmset displaysleepnow >/dev/null 2>&1 || rc=$? + log "client=$host user=$user ssh_exit=$rc" + fi +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bats tests/lock-fanout.bats` +Expected: all tests PASS (4 new + 9 pre-existing with updated assertions = +13 total in this file). + +- [ ] **Step 5: Run the full suite** + +Run: `bats tests/` +Expected: all tests across the whole project PASS (no regressions in +`lock-guard.bats`, `install.bats`, etc — this task only touches +`lock-fanout` and its own tests, but confirm nothing else depended on the +exact prior SSH stub log format, which shouldn't be the case since +`SSH_LOG`/`make_ssh_stub` are local to `tests/lock-fanout.bats`). + +- [ ] **Step 6: shellcheck** + +Run: `shellcheck -S info bin/lock-fanout` +Expected: no findings. Fix any and re-run (no `# shellcheck disable` +directives). + +- [ ] **Step 7: Commit** + +```bash +git add bin/lock-fanout tests/lock-fanout.bats +git commit -m "feat: lock-fanout provisions lock-guard on clients via SSH push, no client-side clone required" +``` + +--- + +## Self-review checklist + +- Spec coverage: checksum check (shasum), conditional push (cat > over + SSH, chmod +x, mkdir -p for first-time clients), fallback to bare pmset + on provisioning failure with a `warn=provision-failed` log line — all + covered by Task 1's tests. +- No placeholders: the implementation and every test are literal, runnable + code. +- Scope: `bin/lock-guard` itself is unchanged; only the delivery mechanism + (`bin/lock-fanout`) changes. Matches the repo owner's explicit + instruction that clients never clone this repo. +- Non-goal check: push-only, lock-triggered, no new persistent client-side + process, no bidirectional signaling back to the controller beyond the + existing SSH-exit-code/checksum-response pattern `lock-fanout` already + relies on for `lock-guard`/`pmset` calls today. diff --git a/tests/lock-fanout.bats b/tests/lock-fanout.bats index 1a5e96e..963a0ac 100755 --- a/tests/lock-fanout.bats +++ b/tests/lock-fanout.bats @@ -34,8 +34,23 @@ make_list_clients_stub() { chmod +x "$LOCK_SYNC_LIST_CLIENTS" } -# Stub for ssh; records the user@host arg to SSH_LOG, exits with code from arg-match. -# By default exits 0; set STUB_SSH_FAIL_HOST env to have that host exit 255. +# Stub for ssh; records EVERY distinct remote command invocation to SSH_LOG +# as "TARGET= CMD=", one line per +# ssh call (not just the last, since provisioning now makes multiple calls +# per client). Exits with code from arg-match. By default exits 0; set +# STUB_SSH_FAIL_HOST to have all calls targeting that host exit 255. +# STUB_SSH_CHECKSUM_HOST/STUB_SSH_CHECKSUM_VALUE let a test script a +# specific shasum stdout for a specific host's checksum-check call. +# STUB_SSH_CHECKSUM_ALL_MATCH, when set, answers every host's shasum call +# with the real local lock-guard checksum, so multi-host tests can exercise +# the steady-state "already current, no push" path for every client at once. +# STUB_SSH_CHECKSUM_EXIT_HOST/STUB_SSH_CHECKSUM_EXIT let a test force the +# stub's shasum-call exit code for a specific host, independent of stdout — +# used to simulate the real remote `shasum ... || true` behavior post-fix +# (exit 0, empty stdout, for a missing remote file) as well as the pre-fix +# buggy behavior (exit 1, empty stdout) that this test file's bug-fix test +# reproduces and confirms is no longer what bin/lock-fanout's own remote +# command string produces. make_ssh_stub() { cat >"$STUB_DIR/ssh" <<'STUBEOF' #!/bin/bash @@ -50,14 +65,23 @@ for a in "$@"; do *) [[ -n "$target" ]] && remote_cmd="$a" ;; esac done +stdin_marker="" if (( has_n == 0 )); then - cat >/dev/null + stdin_marker="STDIN=$(cat)" fi -echo "TARGET=$target" >>"$SSH_LOG" -echo "REMOTE_CMD=$remote_cmd" >>"$SSH_LOG" +echo "TARGET=$target CMD=${remote_cmd} ${stdin_marker}" >>"$SSH_LOG" if [[ -n "${STUB_SSH_FAIL_HOST:-}" && "$target" == *"$STUB_SSH_FAIL_HOST"* ]]; then exit 255 fi +if [[ -n "${STUB_SSH_CHECKSUM_ALL_MATCH:-}" && "$remote_cmd" == *shasum* ]]; then + echo "${STUB_SSH_CHECKSUM_ALL_MATCH} lock-guard" +fi +if [[ -n "${STUB_SSH_CHECKSUM_HOST:-}" && "$target" == *"$STUB_SSH_CHECKSUM_HOST"* && "$remote_cmd" == *shasum* ]]; then + echo "${STUB_SSH_CHECKSUM_VALUE:-}" +fi +if [[ -n "${STUB_SSH_CHECKSUM_EXIT_HOST:-}" && "$target" == *"$STUB_SSH_CHECKSUM_EXIT_HOST"* && "$remote_cmd" == *shasum* ]]; then + exit "${STUB_SSH_CHECKSUM_EXIT:-0}" +fi exit 0 STUBEOF chmod +x "$STUB_DIR/ssh" @@ -78,6 +102,8 @@ STUBEOF make_list_clients_stub "asiago.local tilsit.local mimolette.local" + local_sha=$(shasum -a 256 "$BATS_TEST_DIRNAME/../bin/lock-guard" | awk '{print $1}') + export STUB_SSH_CHECKSUM_ALL_MATCH="$local_sha" make_ssh_stub run "$SCRIPT" @@ -89,6 +115,11 @@ mimolette.local" [[ "${lines[1]}" == *"ssh_exit=0"* ]] [[ "${lines[2]}" == *"client=mimolette.local"* ]] [[ "${lines[2]}" == *"ssh_exit=0"* ]] + # Steady state: every checksum already matches, so no push (cat/chmod/mv) + # should have been attempted for any client — this is the case this test + # exists to cover, distinct from the dedicated push tests below. + run grep -q 'CMD=.*cat >' "$SSH_LOG" + [ "$status" -ne 0 ] } @test "one of three ssh failures still exits 0 with mixed log lines" { @@ -100,13 +131,15 @@ mimolette.local" run "$SCRIPT" [ "$status" -eq 0 ] - [ "${#lines[@]}" -eq 3 ] + [ "${#lines[@]}" -eq 4 ] [[ "${lines[0]}" == *"client=asiago.local"* ]] [[ "${lines[0]}" == *"ssh_exit=0"* ]] [[ "${lines[1]}" == *"client=tilsit.local"* ]] - [[ "${lines[1]}" == *"ssh_exit=255"* ]] - [[ "${lines[2]}" == *"client=mimolette.local"* ]] - [[ "${lines[2]}" == *"ssh_exit=0"* ]] + [[ "${lines[1]}" == *"warn=provision-failed"* ]] + [[ "${lines[2]}" == *"client=tilsit.local"* ]] + [[ "${lines[2]}" == *"ssh_exit=255"* ]] + [[ "${lines[3]}" == *"client=mimolette.local"* ]] + [[ "${lines[3]}" == *"ssh_exit=0"* ]] } @test "host with config override uses override user in ssh target" { @@ -119,7 +152,7 @@ EOF run "$SCRIPT" [ "$status" -eq 0 ] - grep -q '^TARGET=adminuser@asiago.local$' "$SSH_LOG" + grep -q 'TARGET=adminuser@asiago.local CMD=' "$SSH_LOG" [[ "${lines[0]}" == *"user=adminuser"* ]] } @@ -131,7 +164,7 @@ EOF run "$SCRIPT" [ "$status" -eq 0 ] - grep -q '^TARGET=localuser@asiago.local$' "$SSH_LOG" + grep -q 'TARGET=localuser@asiago.local CMD=' "$SSH_LOG" [[ "${lines[0]}" == *"user=localuser"* ]] } @@ -175,8 +208,8 @@ EOF [[ "${lines[0]}" == *"user=admin"* ]] [[ "${lines[1]}" == *"client=tilsit.local"* ]] [[ "${lines[1]}" == *"user=defaultuser"* ]] - grep -q '^TARGET=admin@asiago.local$' "$SSH_LOG" - grep -q '^TARGET=defaultuser@tilsit.local$' "$SSH_LOG" + grep -q 'TARGET=admin@asiago.local CMD=' "$SSH_LOG" + grep -q 'TARGET=defaultuser@tilsit.local CMD=' "$SSH_LOG" } @test "LOCK_SYNC_SSH selects the ssh binary, bypassing PATH wrappers" { @@ -201,9 +234,148 @@ EOF @test "remote command is lock-guard's absolute path, not a bare pmset call" { make_list_clients_stub "asiago.local" + local_sha=$(shasum -a 256 "$BATS_TEST_DIRNAME/../bin/lock-guard" | awk '{print $1}') + export STUB_SSH_CHECKSUM_HOST="asiago.local" + export STUB_SSH_CHECKSUM_VALUE="$local_sha lock-guard" + make_ssh_stub + + run "$SCRIPT" + [ "$status" -eq 0 ] + grep -q 'CMD=\$HOME/.local/bin/lock-guard' "$SSH_LOG" +} + +@test "provisioning: checksum mismatch pushes lock-guard content and chmods it" { + make_list_clients_stub "asiago.local" + export STUB_SSH_CHECKSUM_HOST="asiago.local" + export STUB_SSH_CHECKSUM_VALUE="0000000000000000000000000000000000000000000000000000000000000000 lock-guard" + make_ssh_stub + + run "$SCRIPT" + [ "$status" -eq 0 ] + grep -q 'TARGET=.*asiago.local CMD=.*shasum' "$SSH_LOG" + grep -q 'TARGET=.*asiago.local CMD=.*cat >' "$SSH_LOG" + grep -q "TARGET=.*asiago.local CMD=.*chmod +x" "$SSH_LOG" + grep -q "TARGET=.*asiago.local CMD=.*mv -f" "$SSH_LOG" + + # Verify the push actually carried lock-guard's real content, not just a + # cat>-shaped command. The ssh stub captures stdin verbatim (embedded + # newlines and all) after a "STDIN=" marker, so the distinctive string + # lands on its own line within the captured block rather than on the + # "STDIN=" line itself — confirm the marker is present, then confirm a + # string that's only in bin/lock-guard shows up somewhere after it. + grep -q 'STDIN=' "$SSH_LOG" + awk '/STDIN=/{found=1} found' "$SSH_LOG" | grep -q 'action=suppress' +} + +@test "provisioning: brand-new client with no remote lock-guard gets provisioned (push, not pmset fallback)" { + # Reproduces the exact bug scenario: a genuinely new client's remote + # `shasum ~/.local/bin/lock-guard` finds no file. Post-fix, bin/lock-fanout's + # own remote command string ends in `|| true`, so a real client's ssh call + # would exit 0 with empty stdout in this situation — simulate exactly that + # via the stub (exit 0, no STUB_SSH_CHECKSUM_VALUE set, so stdout is empty) + # and confirm the client gets provisioned rather than falling back to pmset. + make_list_clients_stub "asiago.local" + export STUB_SSH_CHECKSUM_EXIT_HOST="asiago.local" + export STUB_SSH_CHECKSUM_EXIT=0 + make_ssh_stub + + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" != *"warn=provision-failed"* ]] + grep -q 'TARGET=.*asiago.local CMD=.*shasum' "$SSH_LOG" + grep -q 'TARGET=.*asiago.local CMD=.*cat >' "$SSH_LOG" + grep -q "TARGET=.*asiago.local CMD=.*chmod +x" "$SSH_LOG" + grep -q "TARGET=.*asiago.local CMD=.*mv -f" "$SSH_LOG" + + run grep -q 'CMD=.*pmset displaysleepnow' "$SSH_LOG" + [ "$status" -ne 0 ] +} + +@test "provisioning: pre-fix buggy behavior (remote shasum exit 1) correctly falls back to pmset" { + # Documents the OLD buggy remote command's behavior for contrast: if the + # remote checksum call exits 1 (as bin/lock-fanout's remote command did + # before the `|| true` fix, for a missing file), provision_host cannot + # distinguish that from a genuine ssh failure and correctly returns 1 — + # this is why the fix had to change the remote command itself rather than + # provision_host's exit-code handling. + make_list_clients_stub "asiago.local" + export STUB_SSH_CHECKSUM_EXIT_HOST="asiago.local" + export STUB_SSH_CHECKSUM_EXIT=1 + make_ssh_stub + + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"warn=provision-failed"* ]] + grep -q 'TARGET=.*asiago.local CMD=.*pmset displaysleepnow' "$SSH_LOG" + + run grep -q 'CMD=.*cat >' "$SSH_LOG" + [ "$status" -ne 0 ] +} + +@test "provisioning: matching checksum skips the push" { + make_list_clients_stub "asiago.local" + local_sha=$(shasum -a 256 "$BATS_TEST_DIRNAME/../bin/lock-guard" | awk '{print $1}') + export STUB_SSH_CHECKSUM_HOST="asiago.local" + export STUB_SSH_CHECKSUM_VALUE="$local_sha lock-guard" + make_ssh_stub + + run "$SCRIPT" + [ "$status" -eq 0 ] + grep -q 'TARGET=.*asiago.local CMD=.*shasum' "$SSH_LOG" + ! grep -q 'CMD=.*cat >' "$SSH_LOG" +} + +@test "provisioning failure falls back to bare pmset displaysleepnow for that client" { + make_list_clients_stub "asiago.local" + export STUB_SSH_FAIL_HOST="asiago.local" + make_ssh_stub + + run "$SCRIPT" + [ "$status" -eq 0 ] + grep -q 'TARGET=.*asiago.local CMD=.*pmset displaysleepnow' "$SSH_LOG" + [[ "${lines[0]}" == *"client=asiago.local"* ]] + [[ "${lines[0]}" == *"warn=provision-failed"* ]] + + run grep -q 'CMD=.*\$HOME/.local/bin/lock-guard' "$SSH_LOG" + [ "$status" -ne 0 ] +} + +@test "missing local lock-guard fails provisioning fast and falls back to pmset, without any ssh call" { + # local_lock_guard is derived from the script's own directory + # ($script_dir/lock-guard, via BASH_SOURCE), so to exercise the + # missing-file guard we run a copy of lock-fanout from an isolated bin/ + # fixture dir that deliberately omits lock-guard, rather than touching + # the real bin/lock-guard in this repo. + FIXTURE_BIN="$TMPDIR/fixture-bin" + mkdir -p "$FIXTURE_BIN" + cp "$BATS_TEST_DIRNAME/../bin/lock-fanout" "$FIXTURE_BIN/lock-fanout" + # No lock-guard copied into $FIXTURE_BIN — that's the point of this test. + + make_list_clients_stub "asiago.local" + make_ssh_stub + + run "$FIXTURE_BIN/lock-fanout" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"client=asiago.local"* ]] + [[ "${lines[0]}" == *"warn=provision-failed"* ]] + + # No shasum/cat/chmod ssh calls should have been attempted at all — the + # guard must return before any ssh invocation, not just before the push. + run grep -q 'CMD=.*shasum' "$SSH_LOG" + [ "$status" -ne 0 ] + grep -q 'TARGET=.*asiago.local CMD=.*pmset displaysleepnow' "$SSH_LOG" +} + +@test "successful provisioning still invokes lock-guard normally afterward" { + make_list_clients_stub "asiago.local" + local_sha=$(shasum -a 256 "$BATS_TEST_DIRNAME/../bin/lock-guard" | awk '{print $1}') + export STUB_SSH_CHECKSUM_HOST="asiago.local" + export STUB_SSH_CHECKSUM_VALUE="$local_sha lock-guard" make_ssh_stub run "$SCRIPT" [ "$status" -eq 0 ] - grep -q '^REMOTE_CMD=\$HOME/.local/bin/lock-guard$' "$SSH_LOG" + grep -q 'TARGET=.*asiago.local CMD=.*\$HOME/.local/bin/lock-guard' "$SSH_LOG" + [[ "${lines[0]}" == *"client=asiago.local"* ]] + [[ "${lines[0]}" == *"ssh_exit=0"* ]] }