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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand All @@ -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 `<host>.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 `<ISO-8601> 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 `<ISO-8601> client=<host> user=<user> ssh_exit=<rc>` 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 `<ISO-8601> client=<host> user=<user> ssh_exit=<rc>` 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 `<ISO-8601> action=sleep` or `<ISO-8601> action=suppress reason=<reason>`.

## `.claude/` directory
Expand Down
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,33 @@ 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
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:
Expand Down Expand Up @@ -169,7 +184,7 @@ bin/
├── uninstall # reverse install, preserve log
├── list-clients # parse synergy.conf → <short>.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)
Expand Down
119 changes: 106 additions & 13 deletions bin/lock-fanout
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down
Loading
Loading