grub-basket_SP/scripts/obs-test-guard

259 lines
12 KiB
Bash
Executable file

#!/bin/sh
# In-Obsidian "Claude is testing" banner + countdown, so the user gets a loud,
# in-app heads-up even when they're not watching the Claude chat — AND a
# machine-readable lock so a SECOND Claude session can detect that the vault is
# already being driven and back off instead of colliding.
#
# Why: automated Obsidian looks identical to a normal one. Two risks:
# (a) the USER pokes the UI mid-test → chat 🔴/✅ + the loud banner cover this.
# (b) ANOTHER Claude chat drives the same instance at the same time → the
# banner alone doesn't stop that (a human isn't reading it). So the banner
# now has a data twin: window.__claudeVaultLock, an advisory mutex every
# driving session checks first. Banner = human-facing; lock = machine-facing.
#
# Usage:
# scripts/obs-test-guard start [seconds] # claim lock, show banner, wait N s, then "testing now"
# scripts/obs-test-guard ensure # INSTANT re-claim + banner (no wait) before a driving eval
# scripts/obs-test-guard end # release MY lock, clear banner, show "safe to use"
# scripts/obs-test-guard status # read-only: who (if anyone) holds the lock right now
# scripts/obs-test-guard steal # force-claim a lock you're SURE is stale (crashed session)
# scripts/obs-test-guard note 'message' # ad-hoc persistent banner with a custom message
#
# PROTOCOL: run `start` ONCE at the beginning of a testing session and `end`
# ONLY when you're completely done and handing control back. Keep the banner up
# across EVERY intervening eval/screenshot/reload — do NOT `end` then keep
# testing. If you already ended (or aren't sure), call `ensure` (instant, no
# countdown — the user was already warned) before the next driving eval.
#
# THE LOCK (layer 1 — same-instance advisory mutex):
# window.__claudeVaultLock = { owner, label, startedAt, expiresAt }
# * owner = $CLAUDE_CODE_SESSION_ID — stable per chat, unique across chats.
# * `start`/`ensure`/`steal` refresh expiresAt = now + TTL (default 300s,
# override with $OBS_GUARD_TTL). A crashed/forgotten session's lock therefore
# self-expires within TTL and the next session can claim it cleanly.
# * `start`/`ensure` ABORT (exit 3) if a DIFFERENT, non-expired owner holds it.
# * `end` clears the lock ONLY if it's mine — it never clobbers another chat's.
# Scope + caveats: this coordinates multiple drivers of the SAME renderer
# (e.g. two chats both hitting the shared obs-dev / CLI instance) — the real
# collision case. It canNOT see a different Obsidian instance (obs-dev has its
# own renderer, isolated by design) or sense the user's own clicks; the banner
# + chat handshake still cover those. It's advisory, not a hard OS lock.
#
# The banner Notice is persistent (duration 0) and tracked on
# window.__claudeTestBanner so `end` can dismiss it. `start` does the wait in the
# SHELL (not inside eval — obs eval has a ~1s ceiling).
DIR="$(cd "$(dirname "$0")" && pwd)"
OBS="$DIR/obs"
# Owner identity: the chat's session id (stable across this script's separate
# start/ensure/end invocations, distinct from any other chat). Fall back to a
# per-process token if the harness ever doesn't export it (degrades gracefully —
# worst case a session fails to recognize its own lock and refuses, i.e. safe).
OWNER="${CLAUDE_CODE_SESSION_ID:-nosession-$$}"
LOCK_TTL="${OBS_GUARD_TTL:-300}"
show() {
# show <js-string-literal-message> — (re)uses the single tracked banner Notice.
"$OBS" eval code="(() => {
const msg = $1;
if (window.__claudeTestBanner && typeof window.__claudeTestBanner.setMessage === 'function') {
window.__claudeTestBanner.setMessage(msg);
} else {
window.__claudeTestBanner = new Notice(msg, 0);
}
return 'shown';
})()" >/dev/null 2>&1
}
# claim <label> — try to acquire/refresh the lock for OWNER. Echoes JSON:
# {"ok":true,...} acquired or refreshed (it was free/mine/stale)
# {"ok":false,"owner":..,"label":.., held by a DIFFERENT live session — caller must abort
# "heldForSec":N,"expiresInSec":N}
claim() {
"$OBS" eval code="(() => {
const now = Date.now();
const mine = '$OWNER';
const ttl = ${LOCK_TTL} * 1000;
const cur = window.__claudeVaultLock;
if (cur && cur.owner && cur.owner !== mine && cur.expiresAt > now) {
return JSON.stringify({
ok: false, owner: cur.owner, label: cur.label || '',
heldForSec: Math.round((now - (cur.startedAt || now)) / 1000),
expiresInSec: Math.round((cur.expiresAt - now) / 1000)
});
}
const startedAt = (cur && cur.owner === mine && cur.startedAt) ? cur.startedAt : now;
window.__claudeVaultLock = { owner: mine, label: '$1', startedAt: startedAt, expiresAt: now + ttl };
return JSON.stringify({ ok: true });
})()" 2>/dev/null
}
# release — clear the lock, but only if it's mine (never clobber another session).
release() {
"$OBS" eval code="(() => {
const mine = '$OWNER';
const cur = window.__claudeVaultLock;
if (cur && cur.owner && cur.owner !== mine) {
return JSON.stringify({ cleared: false, owner: cur.owner });
}
window.__claudeVaultLock = null;
return JSON.stringify({ cleared: true });
})()" 2>/dev/null
}
# jsonstr <field> — pull a string field's value from the last JSON blob on stdin.
jsonstr() { sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p"; }
# jsonnum <field> — pull a numeric field's value.
jsonnum() { sed -n "s/.*\"$1\":\([0-9-]*\).*/\1/p"; }
# abort_if_held <claim-output> — print a loud abort + exit 3 if the claim was refused.
abort_if_held() {
case "$1" in
*'"ok":true'*) return 0 ;;
*)
own=$(printf '%s' "$1" | jsonstr owner)
lbl=$(printf '%s' "$1" | jsonstr label)
held=$(printf '%s' "$1" | jsonnum heldForSec)
exp=$(printf '%s' "$1" | jsonnum expiresInSec)
if [ -z "$own" ]; then
echo "✋ GUARD ABORT: couldn't read the vault lock (no response from the CLI)." >&2
echo " Is an Obsidian instance running? Fix that before driving." >&2
else
echo "✋ GUARD ABORT: another Claude session is already testing this vault." >&2
echo " Held by session ${own} (${lbl:-no label}), started ~${held:-?}s ago; lock expires in ~${exp:-?}s." >&2
echo " Do NOT drive. Coordinate with that chat, wait for its all-clear, or —" >&2
echo " if you're CERTAIN it crashed/was abandoned — run: scripts/obs-test-guard steal" >&2
fi
exit 3 ;;
esac
}
# verify — confirm the CLI is talking to the THROWAWAY dev vault before any
# driving/destructive test, so a misrouted CLI (wrong window focused, multiple
# vaults open) can never run against a real vault. Two independent checks:
# 1. vault name === "Claude Dev Vault"
# 2. the sentinel note exists AND carries its unique token
# The sentinel makes this robust even if the vault is renamed again — a real
# vault would have to deliberately contain the token to be mistaken for this one.
# Prints "verify: OK" + exits 0 on match; LOUD error + exits 1 otherwise.
EXPECT_VAULT="Claude Dev Vault"
SENTINEL_TOKEN="claude-dev-vault-7f3a9c2e-do-not-delete"
verify() {
out="$("$OBS" eval code="(async () => {
const name = app.vault.getName();
let token = '';
try { token = await app.vault.adapter.read('CLAUDE-DEV-VAULT-SENTINEL.md'); } catch (e) {}
const ok = name === '$EXPECT_VAULT' && token.indexOf('$SENTINEL_TOKEN') !== -1;
return JSON.stringify({ ok, name, sentinel: token.indexOf('$SENTINEL_TOKEN') !== -1 });
})()" 2>/dev/null)"
case "$out" in
*'"ok":true'*) echo "verify: OK — $EXPECT_VAULT (name + sentinel confirmed)"; return 0 ;;
*)
echo "✋ GUARD ABORT: the running Obsidian is NOT the throwaway dev vault." >&2
echo " Expected \"$EXPECT_VAULT\" + sentinel note; got: ${out:-<no response from CLI>}" >&2
echo " Refusing to drive — focus the Claude Dev Vault window (or restore the sentinel) and retry." >&2
return 1 ;;
esac
}
case "$1" in
verify)
verify; exit $?
;;
status)
# Read-only: report the current lock holder without touching it. Safe to run
# from any chat BEFORE deciding to start, so you can see if someone's testing.
out="$("$OBS" eval code="(() => {
const now = Date.now();
const cur = window.__claudeVaultLock;
if (!cur || !cur.owner || cur.expiresAt <= now) return JSON.stringify({ held: false });
return JSON.stringify({
held: true, owner: cur.owner, label: cur.label || '', mine: cur.owner === '$OWNER',
heldForSec: Math.round((now - (cur.startedAt || now)) / 1000),
expiresInSec: Math.round((cur.expiresAt - now) / 1000)
});
})()" 2>/dev/null)"
case "$out" in
*'"held":false'*) echo "lock: FREE — no session is testing this vault." ;;
*'"held":true'*)
own=$(printf '%s' "$out" | jsonstr owner)
lbl=$(printf '%s' "$out" | jsonstr label)
held=$(printf '%s' "$out" | jsonnum heldForSec)
exp=$(printf '%s' "$out" | jsonnum expiresInSec)
case "$out" in
*'"mine":true'*) whose="ME (this chat)" ;;
*) whose="another chat" ;;
esac
echo "lock: HELD by ${whose} — session ${own} (${lbl:-no label}), ~${held:-?}s ago, expires in ~${exp:-?}s." ;;
*)
echo "lock: UNKNOWN — no response from the CLI (is an Obsidian instance running?)." >&2
exit 1 ;;
esac
;;
start)
verify || exit 1
out="$(claim 'start')"
abort_if_held "$out"
secs="${2:-5}"
show "'🤖 Claude is about to run an automated Stashpad test.\n✋ Please STOP interacting — starting in about ${secs} seconds…'"
i="$secs"
while [ "$i" -gt 0 ]; do
sleep 1
i=$((i - 1))
show "'🤖 Claude is testing Stashpad.\n✋ Please don\\'t touch — starting in ${i}s…'"
done
show "'🤖 Claude is TESTING Stashpad now.\n✋ Please don\\'t touch until you see the all-clear.'"
echo "guard: lock acquired (owner ${OWNER}), banner up, ${secs}s grace elapsed — safe to drive"
;;
ensure)
# Instant re-raise (no countdown) — for follow-up driving evals when the
# banner may have been ended. The user was already warned by the session's
# initial `start`, so no grace period is needed. Re-verifies the vault AND
# re-claims/refreshes the lock (aborts if another chat grabbed it meanwhile).
verify || exit 1
out="$(claim 'ensure')"
abort_if_held "$out"
show "'🤖 Claude is TESTING Stashpad now.\n✋ Please don\\'t touch until you see the all-clear.'"
echo "guard: lock refreshed, banner ensured (instant)"
;;
steal)
# Force-claim a lock you're CERTAIN is stale (the holding chat crashed or was
# abandoned and you don't want to wait out the TTL). Overwrites ownership.
verify || exit 1
"$OBS" eval code="(() => {
const now = Date.now();
window.__claudeVaultLock = { owner: '$OWNER', label: 'steal', startedAt: now, expiresAt: now + ${LOCK_TTL} * 1000 };
return 'stolen';
})()" >/dev/null 2>&1
show "'🤖 Claude is TESTING Stashpad now.\n✋ Please don\\'t touch until you see the all-clear.'"
echo "guard: lock forcibly claimed (steal), banner up"
;;
end)
rel="$(release)"
case "$rel" in
*'"cleared":false'*)
echo "guard: NOTE — the lock is held by another session; left it alone (only cleared the banner)." >&2 ;;
esac
"$OBS" eval code="(() => {
if (window.__claudeTestBanner && typeof window.__claudeTestBanner.hide === 'function') {
try { window.__claudeTestBanner.hide(); } catch (e) {}
}
window.__claudeTestBanner = null;
new Notice('✅ Safe to use Stashpad again — Claude has finished testing.', 0);
return 'cleared';
})()" >/dev/null 2>&1
echo "guard: lock released, banner cleared, all-clear shown"
;;
note)
[ -n "$2" ] || { echo "usage: obs-test-guard note 'message'" >&2; exit 1; }
# Escape single quotes in the user message for the JS string literal.
esc="$(printf '%s' "$2" | sed "s/'/\\\\'/g")"
show "'$esc'"
echo "guard: custom banner shown"
;;
*)
echo "usage: obs-test-guard {verify|status|start [seconds]|ensure|steal|end|note 'message'}" >&2
exit 1
;;
esac