From 453c0d0175e7c414045338436c987dc934f70087 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Tue, 14 Jul 2026 14:28:54 -0700 Subject: [PATCH 1/3] docs: design db.json hostname resolution for list-clients Root cause of the arich-mac lock failure: Synergy sanitizes the internal hyphen out of the screen name before writing synergy.conf, so the conf already reads arichmac-3181d4b4. The list-clients suffix-strip regex is innocent. The real display name lives in the sibling db.json, joinable on the last 8 hex of its id field. Resolve via the name field (not hostname, which diverges and would break mimolette), with jq + fallback to today's heuristic. --- ...7-14-db-json-hostname-resolution-design.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/plans/2026-07-14-db-json-hostname-resolution-design.md diff --git a/docs/plans/2026-07-14-db-json-hostname-resolution-design.md b/docs/plans/2026-07-14-db-json-hostname-resolution-design.md new file mode 100644 index 0000000..cd24b73 --- /dev/null +++ b/docs/plans/2026-07-14-db-json-hostname-resolution-design.md @@ -0,0 +1,152 @@ +# `list-clients` db.json hostname resolution + +Date: 2026-07-14 +Status: approved, ready to implement + +## Purpose + +Fix a client that fails to lock because its derived hostname is wrong. + +A Mac whose real `.local` name is `arich-mac.local` shows up in `synergy.conf` +as the screen `arichmac-3181d4b4` — Synergy dropped the internal hyphen when it +generated the screen name. `list-clients` strips the `-3181d4b4` instance +suffix and emits `arichmac.local`, which does not resolve. SSH to it fails, so +the machine never locks. + +## Root cause (what is NOT the bug) + +The suffix-strip regex `-[0-9a-fA-F]{8}$` in `bin/list-clients` is **not** +responsible. It only removes a trailing hyphen plus exactly eight hex +characters. A bare `arich-mac` does not match it (`-mac` is three characters +and `m` is not hex), so the regex cannot turn `arich-mac` into `arichmac`. + +The hyphen is gone before `list-clients` ever runs. Synergy sanitizes the +host's name into a screen identifier and writes the hyphenless form to +`synergy.conf`. The information needed to reverse that — the real display name +— is not in `synergy.conf`. It lives in a sibling file, `db.json`: + +```json +{ + "id": "c62aa4f29f078b74d81a30b352495a06a68b16f85a98ec7247c51f033181d4b4", + "name": "arich-mac", + "hostname": "arich-mac" +} +``` + +The last eight hex characters of `id` (`3181d4b4`) equal the suffix Synergy +appends to the screen name (`arichmac-3181d4b4`). That is the join key. + +### Why `name`, not `hostname` + +`db.json` carries both `name` (the user-facing display name) and `hostname` +(macOS's default network name). They diverge. On the current machine: + +| conf suffix | db `name` | db `hostname` | today's output | +| ----------- | --------- | ---------------------- | -------------- | +| 995cd2f5 | asiago | ASIAGO.local | asiago.local | +| 7681d488 | MIMOLETTE | Andrews-Mac-mini.local | mimolette.local| +| cb476f5e | TILSIT | TILSIT | tilsit.local | +| 3181d4b4 | arich-mac | arich-mac | arichmac.local | + +`mimolette` is a working client today at `mimolette.local`. Its `hostname` +field is `Andrews-Mac-mini.local` — resolving by `hostname` would break it. +Resolving by `name` reproduces every current output (case aside, and `.local` +resolution is case-insensitive) and additionally fixes `arich-mac`. + +## Contract changes + +`bin/list-clients` gains one input and no new output shape. + +- **New input:** `db.json`, located at `${LOCK_SYNC_DB:-/db.json}`. + The `LOCK_SYNC_DB` override mirrors the existing `LOCK_SYNC_CONFIG` pattern + and keeps tests isolated. +- **Output:** unchanged — one `.local` per line, in `section: screens` + order. Exit codes unchanged (`0` ok, `1` conf unreadable, `2` conf malformed). + +`synergy.conf` remains the source of truth for *which* hosts to lock. `db.json` +only refines *what each host is named*. + +## Resolution algorithm + +1. Build a suffix→name map, once, only when `jq` is on PATH **and** `db.json` + is readable: + + ```bash + jq -r '.data[]? | select(.id and .name) | "\(.id[-8:])\t\(.name)"' "$db" + ``` + + Load into `declare -A dbname`. If `jq` is absent, `db.json` is missing, or + `jq` exits non-zero (malformed JSON), the map stays empty. + +2. The awk stage keeps its section parsing (including the "screens not closed" + → exit 2 guard) but emits the **raw** screen label, e.g. `arichmac-3181d4b4`. + +3. Bash resolves each label: + - If it ends in `-<8 hex>` and that suffix is a key in `dbname`, use + `dbname[suffix]`, lowercased. + - Otherwise strip a trailing `-<8 hex>` (today's heuristic) and keep the rest. + - Append `.local` unless the name already ends in `.local`. + +Result: `arichmac-3181d4b4` → `arich-mac` → `arich-mac.local`. When no `db.json` +sits beside the conf, every screen takes the heuristic path — identical to +today. + +## Why this shape + +- **Fallback, never regression:** the resolver degrades to the current + behavior when jq or `db.json` is unavailable, so machines without jq (or an + older Synergy without `db.json`) are no worse off than today. +- **`jq` over hand-rolled JSON parsing:** `db.json` is minified; extracting + fields with awk/grep is fragile against reordering. jq is already installed + on the server Mac; `bin/install` should note it as a soft dependency. +- **Map built once, not per screen:** one `jq` invocation, then O(1) lookups. +- **Suffix as key:** the eight-hex tail is a truncated sha256; a collision is + astronomically unlikely. First match wins; not worth guarding. + +## Deliberately NOT handled (YAGNI) + +- Resolving by `hostname` (breaks `mimolette`; see above). +- Mocking `jq` absence in tests — gated on `command -v jq`, so its absence is + just the empty-map branch. Documented, not simulated. +- Rewriting hostnames via the per-client override config — that file maps + ` ` and is the wrong layer for name repair. + +## Tests + +`tests/list-clients.bats`, same tmpdir pattern (write `conf` + sibling +`db.json`, run, assert). + +Happy paths: + +1. `db.json` maps a hyphenless screen back to its display name + (`arichmac-3181d4b4` → `arich-mac.local`). Regression test for this bug. +2. db `name` wins over the stripped conf label. +3. Case normalization (`MIMOLETTE` → `mimolette.local`). +4. Multiple screens, mixed — each resolved from db. + +Fallback / anti-regression: + +5. Suffix present in conf but absent from `db.json` → heuristic for that screen, + others still resolved from db. +6. `db.json` present but malformed (jq errors) → whole run falls back, exit 0. +7. No `db.json` sibling → heuristic. Already covered by the existing fixture + tests, which must stay green **unmodified** — the real proof of + non-regression. + +## CI + +The new tests need `jq`. GitHub-hosted `ubuntu-latest` ships jq, so no workflow +change is required; confirm during verification. If a future runner lacks it, +the tests that assert db resolution would fail (not fall back silently), which +is the desired signal. + +## Implementation order (TDD) + +1. Add the new bats tests. +2. Run `bats tests/` — confirm the new tests fail, existing ones pass. +3. Implement the resolver in `bin/list-clients`. +4. Run `bats tests/` — all green. +5. `shellcheck -S info bin/*` clean. +6. Run against the real conf — confirm `arich-mac.local`. +7. Update `CLAUDE.md` (db.json is now a key external input; note jq soft dep). +8. Commit, push, PR. From a78a61e592f1e2f997adfa0fa080be35bdd194dd Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Tue, 14 Jul 2026 14:45:39 -0700 Subject: [PATCH 2/3] fix(list-clients): resolve real hostnames from Synergy db.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client whose real name is arich-mac.local never locked: Synergy sanitizes the hyphen out of the screen name before writing synergy.conf, so the conf reads 'arichmac-3181d4b4' and list-clients emitted the unresolvable 'arichmac.local'. The suffix-strip regex was innocent — the hyphen was gone upstream, in Synergy. Recover the true display name from the sibling db.json, joining the conf's 8-hex instance suffix against the last 8 hex of each computers[].id. Use the 'name' field, not 'hostname' (they diverge; hostname can be the default 'Andrews-Mac-mini.local' and would break the working mimolette client). Runtime-hardened for the LaunchAgent: kept bash-3.2 compatible (glob with explicit character classes, no BASH_REMATCH or {8} interval, no assoc arrays) since launchd's minimal PATH selects system bash 3.2, and resolve jq by absolute path (probing Homebrew locations) since launchd's PATH excludes Homebrew. Degrades to the old suffix-strip heuristic when jq or db.json is absent, so no host is ever worse off than before. db.json path overridable via LOCK_SYNC_DB. Verified against the real conf and under a launchd-equivalent runtime (minimal PATH + /bin/bash 3.2.57). Adds 7 bats cases; suite 40/40, shellcheck clean. --- CLAUDE.md | 3 +- bin/list-clients | 73 ++++++++- ...7-14-db-json-hostname-resolution-design.md | 5 +- tests/list-clients.bats | 146 ++++++++++++++++++ 4 files changed, 220 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a279e6a..8d5ece5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ SSH-to-self is a no-op on an already-locked Mac — do not special-case it. ## Key external inputs - **Synergy client list:** `~/Library/Preferences/Synergy/synergy.conf`. This is the source of truth for which hosts to lock — do not maintain a parallel client list. +- **Synergy display names:** `~/Library/Preferences/Synergy/db.json` (sibling of the conf; overridable via `LOCK_SYNC_DB`). Synergy sanitizes screen names before writing the conf (e.g. it drops hyphens: `arich-mac` becomes the screen `arichmac-3181d4b4`), so the conf alone cannot reproduce a client's real hostname. `list-clients` recovers the true name from db.json's `computers[].name`, joined on the last 8 hex of `id` matching the conf's instance suffix. Read via `jq` (a soft dependency — macOS ships `/usr/bin/jq`; Homebrew paths are probed as a fallback). When jq or db.json is unavailable, it degrades to stripping the suffix. Use `name`, not `hostname` — the two diverge (`hostname` can be the default `Andrews-Mac-mini.local`). - **Per-client username overrides:** `~/.config/lock-sync/config` (overridable via `LOCK_SYNC_CONFIG`). Plain text, whitespace-separated ` `, `#` line comments. Unlisted hosts use `$USER`. Absent or empty file is a valid state. ## Runtime @@ -45,7 +46,7 @@ All slices shipped. Install with `bin/install`; uninstall with `bin/uninstall`. - `shellcheck -S info bin/*` — lint shell scripts (matches CI and the global bash standard). - `bin/install` — symlink binaries into `~/.local/bin/`, write the LaunchAgent plist, bootstrap into launchd. Idempotent. - `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. No arg → reads `~/Library/Preferences/Synergy/synergy.conf`. +- `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 `pmset displaysleepnow` per host, emits one ` client= user= ssh_exit=` line per host. diff --git a/bin/list-clients b/bin/list-clients index 5a5a835..cb63a06 100755 --- a/bin/list-clients +++ b/bin/list-clients @@ -8,21 +8,84 @@ if [[ ! -f "$conf" ]] || [[ ! -r "$conf" ]]; then exit 1 fi -awk ' +# Synergy sanitizes screen names (e.g. drops hyphens) before writing them to +# synergy.conf, so the conf alone cannot reproduce a client's real hostname. +# The sibling db.json holds the true display name, joinable on the last 8 hex +# of its `id` field. Build a suffix -> name map from it when jq is available; +# otherwise fall back to stripping the Synergy instance suffix. +db="${LOCK_SYNC_DB:-$(dirname "$conf")/db.json}" + +# Locate jq. launchd runs this agent with a minimal PATH that excludes +# Homebrew, so probe the common absolute locations too (cf. lock-fanout's +# hardcoded /usr/bin/ssh for the same reason). +jq_bin="" +if command -v jq >/dev/null 2>&1; then + jq_bin="$(command -v jq)" +else + for cand in /opt/homebrew/bin/jq /usr/local/bin/jq; do + if [[ -x "$cand" ]]; then + jq_bin="$cand" + break + fi + done +fi + +# suffixname lines. Empty when jq or db.json is unavailable or the JSON is +# malformed, which sends every screen down the suffix-strip fallback path. +db_map="" +if [[ -n "$jq_bin" && -r "$db" ]]; then + db_map="$("$jq_bin" -r '.data.computers[]? | select(.id and .name) | "\(.id[-8:])\t\(.name)"' "$db" 2>/dev/null || true)" +fi + +# Turn a raw Synergy screen label into an SSH-ready .local name. +resolve() { + local label="$1" + local suffix="" base="$label" name="" + + # Match a trailing -<8 hex> Synergy instance suffix using a glob with + # explicit character classes. Avoids [[ =~ ]] with a {8} interval, whose + # support varies across bash builds, and BASH_REMATCH — keeping this runnable + # under the system bash 3.2 that launchd's minimal PATH selects. + local hex='[0-9a-fA-F]' + if [[ "$label" == *-$hex$hex$hex$hex$hex$hex$hex$hex ]]; then + suffix="${label##*-}" + base="${label%-*}" + fi + + if [[ -n "$suffix" && -n "$db_map" ]]; then + name="$(printf '%s\n' "$db_map" \ + | awk -F'\t' -v s="$suffix" 'tolower($1) == tolower(s) { print $2; exit }')" + fi + + # No db match (or no suffix): keep the suffix-stripped label, as before. + [[ -n "$name" ]] || name="$base" + name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + [[ "$name" == *.local ]] || name="$name.local" + printf '%s\n' "$name" +} + +# awk extracts raw screen labels (still guarding section boundaries and the +# unterminated-section error); bash resolves each against db.json. Capturing to +# a variable lets awk's exit 2 propagate via set -e before anything is printed. +labels="$(awk ' /^section: screens[[:space:]]*$/ { in_screens = 1; next } in_screens && /^end[[:space:]]*$/ { in_screens = 0; next } in_screens && /^\t[^ \t].*:[[:space:]]*$/ { name = $0 sub(/^\t/, "", name) sub(/:[[:space:]]*$/, "", name) - sub(/-[0-9a-fA-F]{8}$/, "", name) - names[++n] = name ".local" + print name } END { if (in_screens) { print "list-clients: section: screens not closed" > "/dev/stderr" exit 2 } - for (i = 1; i <= n; i++) print names[i] } -' "$conf" +' "$conf")" + +[[ -n "$labels" ]] || exit 0 +while IFS= read -r label; do + [[ -n "$label" ]] || continue + resolve "$label" +done <<<"$labels" diff --git a/docs/plans/2026-07-14-db-json-hostname-resolution-design.md b/docs/plans/2026-07-14-db-json-hostname-resolution-design.md index cd24b73..781a234 100644 --- a/docs/plans/2026-07-14-db-json-hostname-resolution-design.md +++ b/docs/plans/2026-07-14-db-json-hostname-resolution-design.md @@ -72,9 +72,12 @@ only refines *what each host is named*. is readable: ```bash - jq -r '.data[]? | select(.id and .name) | "\(.id[-8:])\t\(.name)"' "$db" + jq -r '.data.computers[]? | select(.id and .name) | "\(.id[-8:])\t\(.name)"' "$db" ``` + (Machine entries live at `.data.computers[]` — `.data` is an object of many + keys, not the machine array.) + Load into `declare -A dbname`. If `jq` is absent, `db.json` is missing, or `jq` exits non-zero (malformed JSON), the map stays empty. diff --git a/tests/list-clients.bats b/tests/list-clients.bats index c40a3aa..499e24c 100755 --- a/tests/list-clients.bats +++ b/tests/list-clients.bats @@ -189,3 +189,149 @@ EOF [ -z "$stdout" ] [[ "$stderr" == *"not closed"* ]] } + +# --- db.json display-name resolution ------------------------------------- +# Synergy strips characters (e.g. hyphens) out of the screen name it writes to +# synergy.conf, so the conf alone cannot reproduce the real .local hostname. +# The real display name lives in the sibling db.json, joinable on the last 8 +# hex of its `id` field. These tests cover that resolution and its fallback. +# db.json is read from /db.json, overridable via LOCK_SYNC_DB. + +@test "db.json resolves a hyphenless screen back to its display name" { + # Regression: Synergy wrote 'arichmac-3181d4b4'; the real host is arich-mac. + conf="$TMPDIR/conf" + cat >"$conf" <<'EOF' +section: screens + arichmac-3181d4b4: + alt = alt +end +EOF + cat >"$TMPDIR/db.json" <<'EOF' +{"version":1,"data":{"computers":[ + {"id":"c62aa4f29f078b74d81a30b352495a06a68b16f85a98ec7247c51f033181d4b4", + "name":"arich-mac","hostname":"arich-mac"} +]}} +EOF + run "$SCRIPT" "$conf" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 1 ] + [ "${lines[0]}" = "arich-mac.local" ] +} + +@test "db.json name wins over the suffix-stripped conf label" { + # Heuristic alone would yield 'foobar'; db.json says the name is foo-bar-baz. + conf="$TMPDIR/conf" + cat >"$conf" <<'EOF' +section: screens + foobar-aabbccdd: + alt = alt +end +EOF + cat >"$TMPDIR/db.json" <<'EOF' +{"version":1,"data":{"computers":[ + {"id":"deadbeefaabbccdd","name":"foo-bar-baz","hostname":"whatever"} +]}} +EOF + run "$SCRIPT" "$conf" + [ "$status" -eq 0 ] + [ "${lines[0]}" = "foo-bar-baz.local" ] +} + +@test "db.json display names are lowercased" { + conf="$TMPDIR/conf" + cat >"$conf" <<'EOF' +section: screens + node-11223344: + alt = alt +end +EOF + cat >"$TMPDIR/db.json" <<'EOF' +{"version":1,"data":{"computers":[ + {"id":"feed11223344","name":"Big-NODE","hostname":"Big-NODE"} +]}} +EOF + run "$SCRIPT" "$conf" + [ "$status" -eq 0 ] + [ "${lines[0]}" = "big-node.local" ] +} + +@test "multiple screens each resolve from db.json in conf order" { + conf="$TMPDIR/conf" + cat >"$conf" <<'EOF' +section: screens + arichmac-3181d4b4: + alt = alt + otherbox-99887766: + alt = alt +end +EOF + cat >"$TMPDIR/db.json" <<'EOF' +{"version":1,"data":{"computers":[ + {"id":"aaaa3181d4b4","name":"arich-mac","hostname":"arich-mac"}, + {"id":"bbbb99887766","name":"other-box","hostname":"other-box"} +]}} +EOF + run "$SCRIPT" "$conf" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 2 ] + [ "${lines[0]}" = "arich-mac.local" ] + [ "${lines[1]}" = "other-box.local" ] +} + +@test "screen whose suffix is absent from db.json falls back to the heuristic" { + conf="$TMPDIR/conf" + cat >"$conf" <<'EOF' +section: screens + arichmac-3181d4b4: + alt = alt + stale-12345678: + alt = alt +end +EOF + # db.json knows arichmac but not the stale screen. + cat >"$TMPDIR/db.json" <<'EOF' +{"version":1,"data":{"computers":[ + {"id":"aaaa3181d4b4","name":"arich-mac","hostname":"arich-mac"} +]}} +EOF + run "$SCRIPT" "$conf" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 2 ] + [ "${lines[0]}" = "arich-mac.local" ] + [ "${lines[1]}" = "stale.local" ] +} + +@test "malformed db.json falls back to the heuristic with exit 0" { + conf="$TMPDIR/conf" + cat >"$conf" <<'EOF' +section: screens + arichmac-3181d4b4: + alt = alt +end +EOF + printf 'not json {{{\n' >"$TMPDIR/db.json" + run "$SCRIPT" "$conf" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 1 ] + [ "${lines[0]}" = "arichmac.local" ] +} + +@test "LOCK_SYNC_DB overrides the db.json location" { + conf="$TMPDIR/conf" + cat >"$conf" <<'EOF' +section: screens + arichmac-3181d4b4: + alt = alt +end +EOF + db="$TMPDIR/elsewhere/db.json" + mkdir -p "$TMPDIR/elsewhere" + cat >"$db" <<'EOF' +{"version":1,"data":{"computers":[ + {"id":"aaaa3181d4b4","name":"arich-mac","hostname":"arich-mac"} +]}} +EOF + run env LOCK_SYNC_DB="$db" "$SCRIPT" "$conf" + [ "$status" -eq 0 ] + [ "${lines[0]}" = "arich-mac.local" ] +} From 45ce5fccfdbd2b46176b0b2afe1663a0670f0e25 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 15 Jul 2026 08:09:20 -0700 Subject: [PATCH 3/3] docs: fix MD029 ordered-list numbering in db.json design doc The "Fallback / anti-regression:" test cases restarted the ordered list but continued numbering 5-7, tripping markdownlint MD029 (style 1/2/3). Renumber the second list to 1-3. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/2026-07-14-db-json-hostname-resolution-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-07-14-db-json-hostname-resolution-design.md b/docs/plans/2026-07-14-db-json-hostname-resolution-design.md index 781a234..cd62850 100644 --- a/docs/plans/2026-07-14-db-json-hostname-resolution-design.md +++ b/docs/plans/2026-07-14-db-json-hostname-resolution-design.md @@ -129,10 +129,10 @@ Happy paths: Fallback / anti-regression: -5. Suffix present in conf but absent from `db.json` → heuristic for that screen, +1. Suffix present in conf but absent from `db.json` → heuristic for that screen, others still resolved from db. -6. `db.json` present but malformed (jq errors) → whole run falls back, exit 0. -7. No `db.json` sibling → heuristic. Already covered by the existing fixture +2. `db.json` present but malformed (jq errors) → whole run falls back, exit 0. +3. No `db.json` sibling → heuristic. Already covered by the existing fixture tests, which must stay green **unmodified** — the real proof of non-regression.