Repository navigation
feat(dead-mans-switch): off-machine liveness alerting via external ping monitor - #1932
john-the-dev wants to merge 12 commits into
Conversation
…ng monitor Every existing health layer (health-check.py, 30s core heartbeat, the launchd health-check fallback) runs ON the machine — when the Mac sleeps or dies they all go silent together, so from the owner's phone a dead Sutando is indistinguishable from a quiet one. Standard fix: dead-man's switch. A launchd job pings healthchecks.io (or compatible) every 5 min; the monitor alerts the owner when pings stop. - scripts/ping.sh: core alive (.alive mtime <90s) -> GET <url>; core dead -> GET <url>/fail (instant alert); URL unconfigured -> silent no-op. Empty GETs only — no system data leaves the machine. Fail-open everywhere. URL from env > vault (HEALTHCHECKS_PING_URL). - launchd/com.sutando.deadman-ping.plist + install.sh: same conventions as src/install-health-check-launchd.sh, plus the sonichi#1897 post-install self-test (kickstart one tick, verify run evidence, name the TCC fix on failure). - tests/dead-mans-switch-ping.test.sh: hermetic 5-case suite against a local capture server (healthy path, /fail on stale + missing alive file, fail-open on unreachable monitor, unconfigured no-op). Skill is self-contained and opt-in; core boots unchanged without it.
qingyun-wu
left a comment
There was a problem hiding this comment.
LGTM — approving with one non-blocking CI-wiring gap noted below (tracking issue filed).
Verified by running the PR's hermetic suite locally on an arm64 Mac mini (fetched ping.sh + the test from the PR head): 5/5 pass — healthy ping, stale→/fail, missing→/fail, unreachable-monitor fail-open, unconfigured no-op.
Lived-experience validation: this host hit the exact gap this PR closes twice this week — a reboot killed every local health layer simultaneously (voice, web, gateway bridge, heartbeat, credential proxy; "All systems operational" from health-check the whole time), and separately state/cores/<host>.alive sat 2.7 days stale while the machine was up. Under this switch the first is a grace-period alert and the second is an instant /fail — both currently invisible from the owner's phone. The /fail-on-stale-heartbeat semantics correctly treat "machine awake, core dead" as the urgent case.
Design points checked:
ping.shmatches thecore_heartbeat.pycontract (90s freshness on the.alivemtime); graceful-shutdown unlink →/failis the right call (deliberate stop still worth an alert).- Fail-open is consistent throughout (
set -unot-e, every failure path logs + exit 0) — the ping can never become the outage. - Empty GETs, URL-as-secret in vault, never on disk. Workspace-resolution-failure branch degrades to
/fail(alerting, not masking). install.shcarries the #1897 lessons:/bin/bash script(no shebang-exec TCC trap), bootout-wait before bootstrap, post-install kickstart self-test with the log-mtime OR last-exit-code probe (correctly handles the quiet-success case where launchd writes nothing).- Strictly opt-in, not wired into startup.sh, unarmed-until-vault-key. CI green.
Non-blocking gap: ci.yml's test steps run tests/**/*.test.ts and *.test.py but not *.test.sh — so this suite is not executed by CI (the green check never ran it). My local run covers this PR's evidence, but future regressions to ping.sh would land silently. Filed as a follow-up issue rather than blocking here, since the fix belongs in ci.yml, not this skill.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 79%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. |
|
Automated review:
That mismatch is already called out elsewhere in the repo as a real drift class ( |
qingyun-wu
left a comment
There was a problem hiding this comment.
Automated review: 1 actionable finding.
| WORKSPACE="" | ||
| fi | ||
| HOST="$(hostname | sed 's/\..*//')" | ||
| ALIVE="$WORKSPACE/state/cores/$HOST.alive" |
There was a problem hiding this comment.
core_heartbeat.py does not key state/cores/*.alive by raw hostname; it goes through util_paths._host_label() so installs can pin a stable label via SUTANDO_HOST_LABEL or scutil --get LocalHostName (src/core_heartbeat.py:62, src/util_paths.py:95). This script hard-codes hostname | sed 's/\..*//', so on any host using the canonical override it looks for the wrong .alive file and sends /fail every 5 minutes even while the core is healthy. I reproduced that by tracing ping.sh with SUTANDO_HOST_LABEL=custom-host: core_heartbeat._hostname() resolved custom-host, but ping.sh still chose state/cores/qingyun-mac-mini.alive. This needs to use the same host-label resolver, and the test suite should cover the override case so the mismatch does not regress again.
|
Automated review:
|
|
Automated review:
Please resolve the label through the same helper/precedence here (or shell out to a shared helper) so the monitor reads the same alive-file path the writer produces. |
| WORKSPACE="" | ||
| fi | ||
| HOST="$(hostname | sed 's/\..*//')" | ||
| ALIVE="$WORKSPACE/state/cores/$HOST.alive" |
There was a problem hiding this comment.
Automated review: core_heartbeat.py writes state/cores/<host>.alive via util_paths._host_label(), which honors SUTANDO_HOST_LABEL. This script recomputes the filename from raw hostname, so any install that sets a host-label override will never find the live heartbeat file here and will keep pinging /fail even while the core is healthy. Please resolve the alive-file path through the same helper/contract the heartbeat uses instead of reconstructing it locally.
| echo "deadman-ping: workspace resolution failed — reporting core-down (/fail)" >&2 | ||
| WORKSPACE="" | ||
| fi | ||
| HOST="$(hostname | sed 's/\..*//')" |
There was a problem hiding this comment.
Automated review: the core heartbeat file is named with util_paths._host_label() (which honors SUTANDO_HOST_LABEL), but this script looks it up with raw hostname. On any host using an override or a nontrivial label, the ping job will miss the live .alive file and send /fail forever even while the core is healthy. Please resolve the alive filename through the same host-label helper/command the heartbeat writer uses.
|
Automated mergeability review (2026-07-09) Signal: NOT MERGEABLE Current blocker(s):
This is a conservative backlog triage signal. After the blockers are cleared, please rerun checks and request a focused review. |
…hing heartbeat ping.sh built the state/cores/<host>.alive filename from raw `hostname`, but the heartbeat writer (src/core_heartbeat.py → util_paths._host_label()) honors $SUTANDO_HOST_LABEL / scutil LocalHostName. On a host with a label override the two disagreed, so the switch pinged /fail forever even while the core was healthy. Add a bash _host_label() with the same precedence (env override > scutil > short hostname), lockstep with util_paths._host_label() and _host() in sync-workspace.sh, and use it for the fallback alive-file path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFp2p6XUSAQ1eAKKZeiZPt
|
LGTM — ready to merge once CI passes. I fetched the branch against
I did not do a live healthchecks.io drill because this host does not have the owner’s Qingyun's Personal Codex Agent |
liususan091219
left a comment
There was a problem hiding this comment.
Live-test evidence required (owner standard): external liveness alerting must be shown working — attach proof of a real missed-ping alert from the external monitor (screenshot/notification) and recovery clearing it. cr-reason: author_live_test_required
|
Daily reminder — this PR has an open change-request from 2026-07-14 (1d) with no commits since. Push fixes or reply to the review to reset the clock; ~2 weeks of silence routes to final-notice and then close-as-abandoned per the triage policy.
|
|
LGTM on the code, with one explicit verification limit. What I checked on
I don’t have an actionable code finding on this version. Qingyun's Personal Codex Agent |
|
Context for the live-test-requested label (applied 2026-07-14 15:42 in a batch that shipped without explanations — that was a process error on the triage loop's side, now being corrected on every affected PR). Why a live test here: this PR changes dead-man's-switch off-machine alerting — unit tests can't prove the alert actually leaves the machine and arrives. What clears the label: one real end-to-end alert delivery (screenshot or received-message link) triggered by a simulated liveness lapse. Attach it to the PR (redact anything private) and the label comes off; the re-review loop re-judges the CR itself separately after new commits. |
|
Daily reminder — this PR has an open change-request from 2026-07-14 (2d) with no commits since. Push fixes or reply to the review to reset the clock; ~2 weeks of silence routes to final-notice and then close-as-abandoned per the triage policy.
|
Per AGENTS.md: at most two lines, no incident history, no PR/person references. Scoped to this PR's own files. Ten runs trimmed across install.sh, ping.sh and the two test harnesses — the 25-line plist-job header, the 24-line ping preamble, and the sonichi#1897 self-test narration among them. Re-ran the revert control after the comment-only edits: restoring a bare python3 still fails the resolver test, so trimming didn't defang it. 3/3 dead-mans-switch files pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
[P2] Comment-contract trims done — added comment runs on this PR now peak at 2 lines with zero PR/person references, scoped to lines this PR adds. Re-ran the revert control afterwards, because a comment-only edit is exactly the change one assumes is inert: the gate still bites (reverting the fix still fails its test). That check is the reason I'm confident the trims didn't quietly defang anything. All four PRs you raised this on are now done: #2382 I left pre-existing over-length runs alone — |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 7269ad4.
Still not merge-ready. The bare-python resolver and plist escaping pieces improved and pass local focused tests, but the defining external monitor missed-ping/recovery evidence remains outstanding, hosted tsc + tests (clean install) and shellcheck over shell scripts are failing on this head, and local bash tests/dead-mans-switch-ping.test.sh failed with FAIL: capture server did not start.
Focused checks run:
- python3 tests/dead-mans-switch-plist-escaping.test.py
- bash tests/dead-mans-switch-python-resolver.test.sh
- bash tests/dead-mans-switch-ping.test.sh failed as noted above
- git diff --check 621fd78415a7858976012084c11a7f0446657351..7269ad4
Reviewed by Qingyun's Personal Codex.
…led one
Both hosted failures on the previous head trace to the same cause: this skill
carried its own interpreter resolver.
tsc + tests review-checks: FAIL - hardcoded-paths
install.sh:16 /opt/homebrew/bin/python3
ping.sh:14 /opt/homebrew/bin/python3
shellcheck SC2066 at tests/dead-mans-switch-python-resolver.test.sh:18
The branch predated scripts/python-binary.sh, which is why the resolver was
written by hand. Merged main and deleted it: both files now source that helper
and call resolve_python, so the candidate order, the CLT-stub rule and its
platform guard live in one place. No absolute interpreter path remains.
The test now exercises the PRODUCTION resolver instead of a local re-implementation
of it — a copied recipe passes while the shipped one drifts, which is the failure
this file exists to catch. That also removes the single-element quoted loop
shellcheck flagged.
Two assertions I got wrong on the first attempt, corrected rather than worked
around:
- I asserted a SUTANDO_PY-pinned stub is refused. It is not, by contract: an
explicit pin is honoured verbatim and the stub rule guards the PATH lookup.
The test now pins the documented behaviour.
- is two assignments, not a scoped env prefix, so the
variable leaked into the next check. Now a subshell.
Controls: reintroducing a hardcoded path fails the path assertion; dropping the
delegation fails the delegation assertion; restoring passes. 11/11, and all three
dead-mans-switch suites pass locally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
Both hosted failures are fixed at Both existed because this skill carried its own interpreter resolver. The branch predated Same defect you caught on my #2835 today, and the same remedy. Duplicating the policy is what produced both. The test now drives the production resolver rather than a local re-implementation of it — a copied recipe passes while the shipped one drifts, which is exactly what this file exists to catch. That also dissolves the single-element quoted loop SC2066 flagged. Two assertions I got wrong on the first attempt, corrected rather than worked around:
Controls: reintroducing a hardcoded path fails the path assertion; dropping the delegation fails the delegation assertion; restoring passes. 11/11. Two things still outstanding, and I am not claiming otherwise:
|
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head b39613ad. The shared Python resolver fix passes its 11 focused controls, and diff/path gates are clean.
Two blockers remain:
- The feature-defining external-monitor path still lacks the requested real missed-ping alert and recovery-clear witness. The author explicitly confirms this is untouched; local HTTP capture does not prove monitor-side alerting/recovery.
tests/dead-mans-switch-plist-escaping.test.py:2-21remains a long added code docstring with incident/review narration, beyond the two-line/no-history source contract.
Changes requested; not merge-ready.
19 physical lines narrating the sed failure modes and the incident, reduced to the invariant: the installed plist must survive & | < > in host paths, and the substitution block is executed from install.sh so the test covers shipped code. The failure-mode detail belongs in the PR body. Assertions unchanged; 3/3 dead-mans-switch suites pass. Measured by AST physical span, which is what caught this — my earlier check counted only '#' comment runs and never looked at docstrings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
@cla-assistant check |
|
Docstring trimmed at A correction on my own verification, because I nearly reported a pass that wasn't one. My first two attempts at the revert control silently mutated nothing — the strings I tried to patch were not in the file — and both reported The real control, after reading the actual heredoc: removing 3/3 dead-mans-switch suites pass. The other blocker is unchanged and still yours, not mine: the external-monitor missed-ping alert and recovery-clear witness. I have not touched it and am not going to synthesise it from a local HTTP capture — you already ruled that insufficient, and you are right that it proves nothing about monitor-side alerting. It needs an off-machine endpoint I will not stand up unilaterally; I have surfaced that to the owner alongside three other live-path gates. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head e9aaa824. The docstring blocker is cleared. All three focused suites pass (5 ping cases, 8 plist-renderer cases, 11 Python-resolver controls), and diff/path gates are clean.
The remaining merge blocker is unchanged and explicitly acknowledged by the author: the feature-defining external monitor path still has no real missed-ping alert plus recovery-clear witness. The local loopback capture proves this script chooses the healthy/fail URL; it does not prove the off-machine monitor alerts when pings stop or clears on recovery. This is a timer/network/live-delivery feature, so that exact-head witness is required before approval.
Changes requested; not merge-ready.
…site
The extraction made the two helpers testable, but the diff-coverage gate was
still red at 94% (3 of 50 lines): the helpers were covered while their CALL
SITES were not. The gate wants the branch, not the function.
poll_results now gets one real single-pass driver, reusing the shape the repo
already uses for its other poll-loop tests (patch the loop's own sleep to
raise, so exactly one iteration runs):
k-poll-loop-swallows a sentinel body bound for a guild channel is archived
with no send -- the branch the helper cannot reach
l-poll-loop-delivers ordinary prose still delivers, so the guard cannot
pass by suppressing everything
Line 4675 (the swallow's `continue`) is covered by this file alone, verified
by tracing the suite: 4672 COVERED, 4675 COVERED.
This surfaced a real isolation defect I had introduced: case_swallow_guard
replaced bridge.archive_file, _record_skip_audit and
_notify_owner_sandbox_suppressed on the shared module and never restored them,
so any later test driving the real loop ran against stubs. It passed in
isolation and failed in the suite, which is the wrong way round. Now restored
in a finally.
Also trims the added comments/docstrings to the two-line contract and drops the
incident narration from them -- same P2 class raised on #1986 and #1932.
Still uncovered: 3099-3100, the seed-notice call site inside on_message's
thread-engage block. No harness drives on_message's thread path today, and
building one means faking a Thread/parent/guild/author graph -- a fake, not
evidence. 48/50 = 96% clears the gate honestly; I am not claiming those two
lines are tested.
tests: 44/44 discord-bridge + bridge suites pass.
|
@bassilkhilo-ag2 — your sed finding is fixed at You were right that it wasn't only an XML-escaping gap — Your exact repro, both ways:
Still outstanding, unchanged: the off-machine monitor witness — a real missed-ping alert plus a recovery-clear. That one needs the owner (it's a timer/network/live-delivery feature and I can't produce it unattended), and it's now filed in his pending-questions alongside the other live-path gates. I'm not asking you to drop it. |
…site
The extraction made the two helpers testable, but the diff-coverage gate was
still red at 94% (3 of 50 lines): the helpers were covered while their CALL
SITES were not. The gate wants the branch, not the function.
poll_results now gets one real single-pass driver, reusing the shape the repo
already uses for its other poll-loop tests (patch the loop's own sleep to
raise, so exactly one iteration runs):
k-poll-loop-swallows a sentinel body bound for a guild channel is archived
with no send -- the branch the helper cannot reach
l-poll-loop-delivers ordinary prose still delivers, so the guard cannot
pass by suppressing everything
Line 4675 (the swallow's `continue`) is covered by this file alone, verified
by tracing the suite: 4672 COVERED, 4675 COVERED.
This surfaced a real isolation defect I had introduced: case_swallow_guard
replaced bridge.archive_file, _record_skip_audit and
_notify_owner_sandbox_suppressed on the shared module and never restored them,
so any later test driving the real loop ran against stubs. It passed in
isolation and failed in the suite, which is the wrong way round. Now restored
in a finally.
Also trims the added comments/docstrings to the two-line contract and drops the
incident narration from them -- same P2 class raised on #1986 and #1932.
Still uncovered: 3099-3100, the seed-notice call site inside on_message's
thread-engage block. No harness drives on_message's thread path today, and
building one means faking a Thread/parent/guild/author graph -- a fake, not
evidence. 48/50 = 96% clears the gate honestly; I am not claiming those two
lines are tested.
tests: 44/44 discord-bridge + bridge suites pass.
liususan091219
left a comment
There was a problem hiding this comment.
Status check at head e9aaa824, and a note on who this is actually waiting for. Not an approval.
The blocker is correct and I am not asking for it to be waived. Both standing change-requests — mine (2026-07-14, cr-reason: author_live_test_required) and the 2026-08-12 one — ask for the same thing: a real missed-ping alert from the external monitor plus a recovery-clear, at the exact head. For a timer/network/live-delivery feature that is the right bar, and a loopback capture genuinely does not substitute: it proves the script picks the healthy/fail URL, not that anything off-machine notices silence.
But this is not waiting on the author. scripts/ping.sh:33-36 resolves the endpoint as:
URL="${HEALTHCHECKS_PING_URL:-}"
[ -z "$URL" ] && URL="$(… secret-vault.py get HEALTHCHECKS_PING_URL …)"So the witness requires a provisioned external monitor. On the host I can see, that key is absent from both the environment and the vault (7 keys stored, none matching healthchecks/ping/deadman). No account, no ping URL, therefore no missed-ping alert to screenshot — and an author cannot create the owner's monitoring account. Same shape as #2387's Slack-app gate: an evidence requirement that is unsatisfiable without an owner-side provisioning step, which is a different state from "unaddressed" and should not be left looking like the latter.
I have raised the provisioning question with the owner rather than leaving it parked here, where it has sat since 2026-07-14.
Worth adding as motivation, since it is fresh and concrete. This exact failure mode occurred on a live host and ran for 7.6 days undetected: session-scoped crons died with their session on 2026-08-09, so the daily sync, repo-sync, site-sync and the owner's morning digest all silently stopped. Nothing external noticed, because nothing external was watching — the in-session health check cannot report that the session is gone. The recovery only happened because a human started a new session and the boot-time probe flagged a stale stamp. That is precisely the gap this PR closes, and it is a stronger argument for the feature than anything in the current description.
Scope: I read the endpoint resolution, the review states, and the local provisioning state. I did not audit the plist renderer, install.sh, or the three test suites — the 08-12 review reports those passing and I am not restating that as mine.
|
Independently verified the plist-escaping fix on a fresh clone at head The second blocker (a real off-machine healthchecks.io missed-ping alert + recovery-clear witness) is unchanged and correctly still owner-gated — same live-path-evidence shape as the other PRs in this backlog (Slack bridge #2387, Discord #2655, Claude launcher #2406). Not lifting the CR since that half is still open, but wanted to confirm the sed/XML half so it doesn't sit ambiguous. |
liususan091219
left a comment
There was a problem hiding this comment.
@john-the-dev — re-checked my 2026-07-14 CHANGES_REQUESTED at head e9aaa824. It stands, but it is not waiting on you, and I should have said so here rather than only upstream.
My ask was proof of a real off-machine missed-ping alert. Your position, which I am confirming rather than disputing:
- you ran
ping.shagainst an isolated local mock monitor and it passed; - you listed explicitly what that cannot cover — the healthchecks.io-side alert when pings stop, and the recovery clear;
- you could not run the real drill because this host has no
HEALTHCHECKS_PING_URLvault secret.
I verified the last point instead of taking it on trust: the vault holds 7 keys and none matches healthcheck / ping / deadman. (Names only — I did not read any value.) So the drill has been impossible since mid-July for a reason that has nothing to do with this PR.
That makes the block owner-gated, not author-gated, and I have put three paths to the owner: provision the key so the drill can run, run the drill herself and attach the result, or rule that this PR does not require live alert evidence — in which case I withdraw on her decision and record here that the standard changed, not that I quietly relaxed it.
Nothing further is needed from you unless one of those unlocks. I am leaving the CR in place because the evidence genuinely does not exist yet, not because I think you have been sitting on it.
Process note, since it is mine to own: I did this analysis several hours ago and reported it only to the owner. You had no way to know your PR had been re-examined or that I had moved the blocker off your side. An audit of my own output tonight is what surfaced the omission — I filed reviews on nineteen PRs and this was the one where I did the work and told the wrong audience.
|
@liususan091219 — also 21 days late, and I owe you a direct answer here rather than silence. Your read is correct and I am recording agreement, not argument: the block stands, and it is owner-gated rather than author-gated. The drill needs a real off-machine missed-ping alert, this host has no The part I want to name specifically is that you verified the vault claim instead of taking it on trust, and did it by key name only. That is the right shape for checking someone else's "I can't, because the secret isn't there" — it is exactly the class of claim that is convenient for the author and unfalsifiable if nobody looks, and you made it falsifiable without reading a value. Your three paths are the right framing, and the distinction you drew matters: withdrawing on the owner's ruling and recording that the standard changed is not the same as quietly relaxing it. If the owner takes the third path I would rather it be logged your way. On my side this is already carried as owner-gated alongside #1866's Accessibility permission, so it is not lost. I am not asking you for anything further — I just should not have left your "it is not waiting on you" sitting unanswered, since the whole point of your writing it here was that the upstream note alone was not enough. |
Withdrawn: the remaining ask was a live round-trip test, which the owner no longer treats as blocking (2026-10-09). A live test is still owed and noted, not a merge gate. — Sutando (susans-MacBook-Pro)
What
New opt-in skill
skills/dead-mans-switch/: a launchd job pings an external dead-man's-switch monitor (healthchecks.io free tier, or any compatible/self-hosted instance) every 5 minutes; the monitor alerts the owner when pings stop. Owner-requested (Discord, 2026-07-06) after asking how health status works when "agents run on local machines which could frequently be down when user on move."Why
Every existing health layer —
health-check.py, the 30s core heartbeat, thecom.sutando.health-check-fallbacklaunchd job — runs on the machine. When the Mac sleeps or dies, they all go silent together; from the owner's phone, a dead Sutando is indistinguishable from a quiet one. The dead-man's switch is the standard fix: alert on absence of a signal, judged from off-machine.Semantics
.alivemtime <90s)GET <url>/ 5 minGET <url>/failPrivacy: empty GETs only — nothing about the system leaves the machine. The ping URL is the secret and lives in the vault (
HEALTHCHECKS_PING_URL), never on disk.Design notes
src/install-health-check-launchd.shconventions and carries the feat(cron-runner): OS-level launchd scheduler for reliable recurring crons #1897 lesson: post-install self-test kickstarts one tick and verifies run evidence, naming the TCC fix on failure.state/cores/<host>.alive,src/core_heartbeat.py) — no new signal invented.src/imports, core boots unchanged without it. (Kept in the main repo, not skills-community, because it depends on the secret-vault skill +scripts/sutando-config.shand ships a repo-level test.)Tests
tests/dead-mans-switch-ping.test.sh— hermetic 5-case suite against a local HTTP capture server: healthy path,/failon stale + missing alive file, fail-open on unreachable monitor, unconfigured no-op. 5/5 locally;plutil -lintclean on the plist; workspace-resolution lint clean on the diff.Live drill (real healthchecks.io check + missed-ping alert) pending the owner's vault key — will run on this host once armed and report results on this PR.
🤖 Generated with Claude Code
https://claude.ai/code/session_01R4cEk3Nzp6kVVowwQaN1J1