mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
fix(gstack-memorable): canonical hook path, no vendor consent, --timeout 5, identity-based status, verified disable, lifecycle lock
enable used to bake the hook path from whatever tree the CLI ran in and to run the vendor's own `memorable enable` (its consent for storing AND uploading session traces) before registering anything. It now resolves the canonical install like setup does and refuses when that install does not carry this bridge (version and hook-twin check), registers through the canonical hook manager with --timeout 5, records gstack's own consent in memorable_recall, never executes the vendor, and restores the captured prior state if consent cannot be recorded. disable flips the gate first, removes the entry by identity (tag or no tag), verifies both states and reports partial failure instead of a blended success. status reads only: resolution path, gate, registration by identity (gstack / vendor-own / both / unknown), mismatch lines, receipt count, recent hook errors. enable and disable serialise under a lock with stale takeover. Windows is refused (TODOS.md D21). Exit codes mirror the hook manager (3/4/5). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d4dbeb6d42
commit
05b97dbe6d
+297
-127
@@ -1,177 +1,347 @@
|
||||
#!/usr/bin/env bash
|
||||
# Opt-in wiring between gstack's Claude hook manager and Memorable.
|
||||
set -u
|
||||
# gstack-memorable — enable | disable | status for the Memorable recall bridge
|
||||
# (hosts/claude/hooks/memorable-user-prompt-hook, a Claude Code UserPromptSubmit
|
||||
# hook that hands each prompt to the third-party `memorable` CLI under gstack's
|
||||
# consent key, receipts and trust envelope).
|
||||
#
|
||||
# Two independent facts make up the bridge's state, and this CLI is the only
|
||||
# writer of both:
|
||||
#
|
||||
# registration (settings.json) gate (config.yaml memorable_recall)
|
||||
# NONE ──enable──▶ GSTACK ──┐ off ──enable──▶ on
|
||||
# ▲ │ Claude Code strips ▲ │
|
||||
# └──── disable ──────────┘ the tag: still └─── disable ────┘
|
||||
# (identity) GSTACK by identity
|
||||
# VENDOR-OWN: `memorable install-hooks` registered its own hook. enable
|
||||
# refuses (two entries would run the hook twice per prompt).
|
||||
# Mismatches are reported by `status`, never silently repaired:
|
||||
# gate on + NONE -> "gate on, no hook" (enable to fix)
|
||||
# gate off + GSTACK -> "hook is inert" (disable removes it)
|
||||
#
|
||||
# What each verb hands to the vendor binary: nothing. enable/disable/status
|
||||
# never execute `memorable`; they only check that it exists. The vendor's
|
||||
# own consent (`memorable enable` / `disable` / `forget`) is yours to run.
|
||||
#
|
||||
# Style: `set -uo pipefail` WITHOUT -e (like bin/gstack-verify-gate). Every
|
||||
# external call is checked explicitly with `|| return N`, so a failure is
|
||||
# reported where it happens and partial states are never reported as success.
|
||||
#
|
||||
# Exit codes: 0 ok · 1 refused / usage · 3 settings.json unparseable ·
|
||||
# 4 unexpected settings shape · 5 could not acquire the lock
|
||||
# (the hook manager's own codes, passed through).
|
||||
# Heredoc delivery guard (see bin/gstack-settings-hook for the rationale).
|
||||
BASH_COMPAT=50
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SETTINGS_HOOK="$SCRIPT_DIR/gstack-settings-hook"
|
||||
GSTACK_CONFIG="$SCRIPT_DIR/gstack-config"
|
||||
STATE_DIR="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}}"
|
||||
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}"
|
||||
MEMORABLE_HOOK="$ROOT_DIR/hosts/claude/hooks/memorable-user-prompt-hook"
|
||||
|
||||
HOOK_SOURCE="gstack-memorable"
|
||||
CONFIG_KEY="memorable_recall"
|
||||
SINK="memorable-recall"
|
||||
HOOK_REL="hosts/claude/hooks/memorable-user-prompt-hook"
|
||||
# JavaScript RegExp (applied by gstack-settings-hook list-items to items no
|
||||
# KNOWN_HOOKS row owns). Matches the vendor installer's own registration,
|
||||
# verified against memorable-cli 0.5.18: "<HOME>/.memorable/bin/memorable" hook user-prompt
|
||||
VENDOR_OWN_RE='[Mm]emorable.*hook\s+user-prompt'
|
||||
|
||||
# Canonical install root — the hook command MUST point at the stable install,
|
||||
# never at the tree this CLI happens to run from (setup's phantom-hooks rule).
|
||||
# Copied from setup:2481-2489; TODO D24 extracts a shared helper.
|
||||
CANONICAL_GSTACK_ROOT="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack"
|
||||
if [ ! -x "$CANONICAL_GSTACK_ROOT/bin/gstack-session-update" ] \
|
||||
&& [ -x "$HOME/.claude/skills/gstack/bin/gstack-session-update" ]; then
|
||||
CANONICAL_GSTACK_ROOT="$HOME/.claude/skills/gstack"
|
||||
fi
|
||||
HOOK_CMD_PATH="$CANONICAL_GSTACK_ROOT/$HOOK_REL"
|
||||
# Mutations go through the CANONICAL hook manager so the code that registers
|
||||
# is the code that will run; status falls back to this tree's copy for reads.
|
||||
SETTINGS_HOOK="$CANONICAL_GSTACK_ROOT/bin/gstack-settings-hook"
|
||||
[ -x "$SETTINGS_HOOK" ] || SETTINGS_HOOK="$SCRIPT_DIR/gstack-settings-hook"
|
||||
EGRESS_BIN="$CANONICAL_GSTACK_ROOT/bin/gstack-egress"
|
||||
[ -x "$EGRESS_BIN" ] || EGRESS_BIN="$SCRIPT_DIR/gstack-egress"
|
||||
|
||||
# Platform detection copied from setup:76-79 (TODO D24). Windows support for
|
||||
# this bridge is deferred whole (no process groups to contain the vendor).
|
||||
IS_WINDOWS=0
|
||||
case "${GSTACK_MEMORABLE_TEST_UNAME:-$(uname -s)}" in
|
||||
MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;;
|
||||
esac
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
cat <<USAGE
|
||||
Usage: gstack-memorable <enable|disable|status>
|
||||
|
||||
enable Enable Memorable, then register its Claude UserPromptSubmit hook
|
||||
disable Remove the gstack hook, then disable Memorable
|
||||
status Report Memorable CLI availability and hook registration
|
||||
EOF
|
||||
enable Register gstack's Memorable UserPromptSubmit hook (canonical path,
|
||||
timeout 5) and set memorable_recall=on. Never runs \`memorable enable\`.
|
||||
disable Set memorable_recall=off, remove gstack's hook entry (by identity,
|
||||
tag or no tag), verify both. Never runs \`memorable disable\`.
|
||||
status Read-only: vendor CLI, gate, registration, receipts, recent errors.
|
||||
|
||||
Vendor CLI resolution: GSTACK_MEMORABLE_BIN, MEMORABLE_BIN, ~/.memorable/bin/memorable, PATH.
|
||||
USAGE
|
||||
}
|
||||
|
||||
_err() { printf 'gstack-memorable: %s\n' "$*" >&2; }
|
||||
|
||||
# ─── lock: one lifecycle transition at a time ────────────────────────────
|
||||
LOCK_DIR="$STATE_DIR/locks/memorable-bridge.lock"
|
||||
LOCK_HELD=0
|
||||
_lock_release() {
|
||||
[ "$LOCK_HELD" -eq 1 ] || return 0
|
||||
if [ "$(cat "$LOCK_DIR/owner" 2>/dev/null)" = "$$" ]; then rm -rf "$LOCK_DIR"; fi
|
||||
LOCK_HELD=0
|
||||
}
|
||||
_lock_acquire() {
|
||||
mkdir -p "$STATE_DIR/locks" 2>/dev/null || { _err "cannot create $STATE_DIR/locks"; return 5; }
|
||||
local tries=0 owner_ts now
|
||||
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
|
||||
tries=$((tries + 1))
|
||||
owner_ts="$(cat "$LOCK_DIR/ts" 2>/dev/null || echo 0)"
|
||||
now="$(date +%s)"
|
||||
if [ $((now - owner_ts)) -gt 30 ]; then rm -rf "$LOCK_DIR"; continue; fi # stale (30 s): a crashed writer
|
||||
if [ "$tries" -ge 50 ]; then _err "another gstack-memorable is running (lock $LOCK_DIR); try again"; return 5; fi
|
||||
sleep 0.1
|
||||
done
|
||||
printf '%s\n' "$$" > "$LOCK_DIR/owner"
|
||||
date +%s > "$LOCK_DIR/ts"
|
||||
LOCK_HELD=1
|
||||
trap _lock_release EXIT
|
||||
}
|
||||
|
||||
# ─── probes (read-only) ──────────────────────────────────────────────────
|
||||
resolve_memorable() {
|
||||
if [ -n "${MEMORABLE_BIN:-}" ]; then
|
||||
[ -f "$MEMORABLE_BIN" ] && [ -x "$MEMORABLE_BIN" ] || return 1
|
||||
printf '%s\n' "$MEMORABLE_BIN"
|
||||
return 0
|
||||
local override="${GSTACK_MEMORABLE_BIN:-${MEMORABLE_BIN:-}}"
|
||||
if [ -n "$override" ]; then
|
||||
override="${override%\"}"; override="${override#\"}"
|
||||
case "$override" in
|
||||
/*) [ -f "$override" ] && [ -x "$override" ] && { printf '%s\n' "$override"; return 0; } ;;
|
||||
*) command -v "$override" 2>/dev/null && return 0 ;;
|
||||
esac
|
||||
return 1 # an explicit override that does not resolve is an error, never a fall-through
|
||||
fi
|
||||
|
||||
if [ -n "${HOME:-}" ] && [ -f "$HOME/.memorable/bin/memorable" ] && [ -x "$HOME/.memorable/bin/memorable" ]; then
|
||||
printf '%s\n' "$HOME/.memorable/bin/memorable"
|
||||
return 0
|
||||
printf '%s\n' "$HOME/.memorable/bin/memorable"; return 0
|
||||
fi
|
||||
|
||||
command -v memorable 2>/dev/null
|
||||
}
|
||||
|
||||
require_memorable() {
|
||||
MEMORABLE_CLI="$(resolve_memorable 2>/dev/null)" || {
|
||||
echo "gstack-memorable: Memorable CLI not found; install memorable-cli or set MEMORABLE_BIN." >&2
|
||||
# Gate value or "unknown" (gstack-config missing/failed).
|
||||
gate_value() {
|
||||
local v
|
||||
v="$("$GSTACK_CONFIG" get "$CONFIG_KEY" 2>/dev/null)" || { echo unknown; return 0; }
|
||||
printf '%s\n' "${v:-off}"
|
||||
}
|
||||
|
||||
# Registration state via the hook manager's identity view. Sets:
|
||||
# REG_STATE none | gstack | vendor | both | unparseable | shape | unreadable
|
||||
# REG_GSTACK newline-separated JSON string literals of gstack-owned commands
|
||||
# REG_VENDOR newline-separated JSON string literals of the vendor's own commands
|
||||
REG_STATE=""; REG_GSTACK=""; REG_VENDOR=""
|
||||
registration_state() {
|
||||
local rc
|
||||
REG_GSTACK="$("$SETTINGS_HOOK" list-items --event UserPromptSubmit --owned-by "$HOOK_SOURCE" 2>/dev/null)"; rc=$?
|
||||
case "$rc" in
|
||||
0) ;;
|
||||
3) REG_STATE="unparseable"; return 0 ;;
|
||||
4) REG_STATE="shape"; return 0 ;;
|
||||
*) REG_STATE="unreadable"; return 0 ;;
|
||||
esac
|
||||
REG_VENDOR="$("$SETTINGS_HOOK" list-items --event UserPromptSubmit --command-regex "$VENDOR_OWN_RE" 2>/dev/null)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || { REG_STATE="unreadable"; return 0; }
|
||||
if [ -n "$REG_GSTACK" ] && [ -n "$REG_VENDOR" ]; then REG_STATE="both"
|
||||
elif [ -n "$REG_GSTACK" ]; then REG_STATE="gstack"
|
||||
elif [ -n "$REG_VENDOR" ]; then REG_STATE="vendor"
|
||||
else REG_STATE="none"; fi
|
||||
}
|
||||
_reg_exit_code() {
|
||||
case "$REG_STATE" in unparseable) echo 3 ;; shape) echo 4 ;; *) echo 1 ;; esac
|
||||
}
|
||||
_reg_problem_text() {
|
||||
case "$REG_STATE" in
|
||||
unparseable) echo "$SETTINGS_FILE is not valid JSON (fix or restore it; see gstack-settings-hook rollback)" ;;
|
||||
shape) echo "$SETTINGS_FILE has an unexpected shape under hooks.UserPromptSubmit (not an array)" ;;
|
||||
unreadable) echo "the hook manager could not read $SETTINGS_FILE" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The canonical install must carry THIS bridge: a worktree CLI registering an
|
||||
# older hook at the stable path would run code without the gate or receipts.
|
||||
compat_check() {
|
||||
[ -x "$HOOK_CMD_PATH" ] || { _err "no stable install carries the bridge hook at $HOOK_CMD_PATH; run ./setup (or /gstack-upgrade) first"; return 1; }
|
||||
[ -f "$HOOK_CMD_PATH.ts" ] || { _err "the stable install at $CANONICAL_GSTACK_ROOT predates this bridge (no memorable-user-prompt-hook.ts); run ./setup first"; return 1; }
|
||||
local here there
|
||||
here="$(cat "$ROOT_DIR/VERSION" 2>/dev/null)"; there="$(cat "$CANONICAL_GSTACK_ROOT/VERSION" 2>/dev/null)"
|
||||
if [ -n "$here" ] && [ "$here" != "$there" ]; then
|
||||
_err "the stable install at $CANONICAL_GSTACK_ROOT is version '${there:-unknown}' but this tree is '$here'; run ./setup so the registered hook is the code that will run"
|
||||
return 1
|
||||
}
|
||||
[ -n "$MEMORABLE_CLI" ] || return 1
|
||||
}
|
||||
|
||||
# Memorable's own installer registers the SAME UserPromptSubmit hook, under
|
||||
# its own name and outside gstack's table. `memorable start`, `memorable setup`
|
||||
# and `memorable install-hooks` all do it, and that is the documented way to
|
||||
# install the CLI — so on most machines it is already there before gstack is
|
||||
# asked. Registering ours beside it runs the same command twice on every
|
||||
# prompt: context injected twice, and the session captured twice against the
|
||||
# user's own extraction allowance.
|
||||
#
|
||||
# Matched on the command, not on a tag, for the same reason the hook table in
|
||||
# gstack-settings-hook matches on command: Claude Code rewrites settings and
|
||||
# private tags do not survive it.
|
||||
memorable_own_hook() {
|
||||
[ -f "$SETTINGS_FILE" ] || return 1
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e '
|
||||
const fs = require("fs");
|
||||
let s = {};
|
||||
try { s = JSON.parse(fs.readFileSync(process.env.GSTACK_SETTINGS_PATH, "utf8")); } catch { process.exit(1); }
|
||||
const groups = (s.hooks && s.hooks.UserPromptSubmit) || [];
|
||||
const ours = process.env.GSTACK_MEMORABLE_HOOK || "";
|
||||
for (const g of groups) {
|
||||
for (const h of (g.hooks || [])) {
|
||||
const c = String(h.command || "");
|
||||
if (c === ours || c.includes("memorable-user-prompt-hook")) continue;
|
||||
if (/memorable/i.test(c) && /hook\s+user-prompt/.test(c)) { console.log(c); process.exit(0); }
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
hook_present() {
|
||||
[ -x "$SETTINGS_HOOK" ] || return 1
|
||||
if "$SETTINGS_HOOK" list-sources 2>/dev/null |
|
||||
awk -F '\t' -v source="$HOOK_SOURCE" '
|
||||
$1 == "UserPromptSubmit" && $2 == source { found = 1 }
|
||||
END { exit(found ? 0 : 1) }
|
||||
'; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Claude Code may strip the private source tag when it rewrites settings.
|
||||
# A no-op diff still proves that the canonical command itself is present.
|
||||
"$SETTINGS_HOOK" diff-event \
|
||||
--event UserPromptSubmit \
|
||||
--command "$MEMORABLE_HOOK" \
|
||||
--source "$HOOK_SOURCE" 2>/dev/null |
|
||||
awk '
|
||||
/^--- BEFORE$/ { section = 1; saw_before = 1; next }
|
||||
/^--- AFTER$/ { section = 2; saw_after = 1; next }
|
||||
section == 1 { before = before $0 "\n" }
|
||||
section == 2 { after = after $0 "\n" }
|
||||
END { exit(saw_before && saw_after && before == after ? 0 : 1) }
|
||||
'
|
||||
if "$SETTINGS_HOOK" list-items 2>&1 | grep -q "Unknown action"; then
|
||||
_err "the stable install's hook manager does not know list-items; run ./setup first"; return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
enable_memorable() {
|
||||
local existing
|
||||
require_memorable || return 1
|
||||
[ -x "$SETTINGS_HOOK" ] || {
|
||||
echo "gstack-memorable: missing hook manager: $SETTINGS_HOOK" >&2
|
||||
# ─── enable ──────────────────────────────────────────────────────────────
|
||||
enable_bridge() {
|
||||
local vendor prior_gate ensure_out ensure_rc verb
|
||||
_lock_acquire || return $?
|
||||
[ -x "$SETTINGS_HOOK" ] || { _err "missing hook manager: $SETTINGS_HOOK"; return 1; }
|
||||
[ -x "$GSTACK_CONFIG" ] || { _err "missing $GSTACK_CONFIG"; return 1; }
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
_err "Windows is not supported by the Memorable bridge yet (no way to contain the vendor process); tracked in TODOS.md D21"
|
||||
return 1
|
||||
}
|
||||
[ -x "$MEMORABLE_HOOK" ] || {
|
||||
echo "gstack-memorable: missing executable hook: $MEMORABLE_HOOK" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
vendor="$(resolve_memorable)" || { _err "Memorable CLI not found (checked GSTACK_MEMORABLE_BIN, MEMORABLE_BIN, ~/.memorable/bin/memorable, PATH). Install it yourself: npm i -g memorable-cli. gstack never installs it."; return 1; }
|
||||
compat_check || return 1
|
||||
|
||||
existing="$(GSTACK_MEMORABLE_HOOK="$MEMORABLE_HOOK" memorable_own_hook)" && {
|
||||
cat >&2 <<EOF
|
||||
prior_gate="$(gate_value)"
|
||||
registration_state
|
||||
case "$REG_STATE" in
|
||||
unparseable|shape|unreadable) _err "cannot read the current registration: $(_reg_problem_text)"; return "$(_reg_exit_code)" ;;
|
||||
vendor|both)
|
||||
cat >&2 <<REFUSE
|
||||
gstack-memorable: Memorable already registers this hook itself:
|
||||
$existing
|
||||
$(printf '%s\n' "$REG_VENDOR" | sed 's/^/ /')
|
||||
|
||||
Registering gstack's as well would run it twice on every prompt: injected
|
||||
twice, and the session captured twice against your extraction allowance.
|
||||
Registering gstack's as well would run the hook twice on every prompt:
|
||||
injected twice, and the session captured twice against your allowance.
|
||||
|
||||
Keep the one you have, or hand it to gstack: delete that entry from
|
||||
$SETTINGS_FILE
|
||||
and run this again. Memorable has no command to remove its own hook.
|
||||
EOF
|
||||
and run this again. Memorable has no command that removes its own hook.
|
||||
REFUSE
|
||||
return 1 ;;
|
||||
esac
|
||||
|
||||
ensure_out="$("$SETTINGS_HOOK" ensure-event --event UserPromptSubmit --command "$HOOK_CMD_PATH" --source "$HOOK_SOURCE" --timeout 5 2>&1)"; ensure_rc=$?
|
||||
if [ "$ensure_rc" -ne 0 ]; then
|
||||
_err "warning: settings hook update failed: $(printf '%s\n' "$ensure_out" | head -1): run $SETTINGS_HOOK manually"
|
||||
return "$ensure_rc" # nothing changed: the gate is still '$prior_gate'
|
||||
fi
|
||||
case "$ensure_out" in
|
||||
*unchanged*) verb="unchanged" ;;
|
||||
*re-pointed*) verb="re-pointed" ;;
|
||||
*) verb="registered" ;;
|
||||
esac
|
||||
|
||||
if ! "$GSTACK_CONFIG" set "$CONFIG_KEY" on >/dev/null 2>&1; then
|
||||
# Restore the CAPTURED prior state, never an assumed one: a registration
|
||||
# that predates this run stays; the gate goes back to what it was.
|
||||
if [ "$verb" = "registered" ] && [ "$REG_STATE" = "none" ]; then
|
||||
"$SETTINGS_HOOK" remove-source --source "$HOOK_SOURCE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
case "$prior_gate" in on|off) "$GSTACK_CONFIG" set "$CONFIG_KEY" "$prior_gate" >/dev/null 2>&1 || true ;; esac
|
||||
_err "could not record consent (gstack-config set $CONFIG_KEY on failed); hook registration restored to its prior state, gate is '$prior_gate'"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
"$MEMORABLE_CLI" enable || return $?
|
||||
"$SETTINGS_HOOK" ensure-event \
|
||||
--event UserPromptSubmit \
|
||||
--command "$MEMORABLE_HOOK" \
|
||||
--source "$HOOK_SOURCE"
|
||||
cat <<DONE
|
||||
gstack-memorable: enabled.
|
||||
hook: $verb ($HOOK_CMD_PATH, timeout 5 s, source $HOOK_SOURCE)
|
||||
consent: $CONFIG_KEY=on (gstack's gate; revoke: gstack-memorable disable)
|
||||
vendor: $vendor
|
||||
|
||||
What gstack hands to that binary on every prompt: Claude Code's UserPromptSubmit
|
||||
JSON (session_id, cwd, transcript_path, prompt), unless it carries a HIGH-tier
|
||||
credential shape or the repo's trust policy is deny/read-only. The binary runs
|
||||
with your privileges in an allowlisted environment and its own process group.
|
||||
Each hand-off is receipted first: gstack-egress list --sink $SINK
|
||||
What the binary then sends is Memorable's claim, not gstack's.
|
||||
|
||||
Claude Code picks up the new hook automatically within a few seconds; if it does
|
||||
not fire, restart the session. Verify with: gstack-memorable status
|
||||
Memorable's own capture consent is separate and yours to run or inspect:
|
||||
memorable status | memorable enable | memorable disable | memorable forget
|
||||
DONE
|
||||
}
|
||||
|
||||
disable_memorable() {
|
||||
local hook_rc=0 cli_rc=0
|
||||
|
||||
# ─── disable ─────────────────────────────────────────────────────────────
|
||||
disable_bridge() {
|
||||
local gate_rc=0 remove_rc=0 remove_out="" gate_after
|
||||
_lock_acquire || return $?
|
||||
[ -x "$GSTACK_CONFIG" ] || { _err "missing $GSTACK_CONFIG"; return 1; }
|
||||
# Gate first: the hook reads it on every prompt, so consent is revoked
|
||||
# immediately even if the registration removal below fails.
|
||||
"$GSTACK_CONFIG" set "$CONFIG_KEY" off >/dev/null 2>&1 || gate_rc=$?
|
||||
if [ -x "$SETTINGS_HOOK" ]; then
|
||||
"$SETTINGS_HOOK" remove-source --source "$HOOK_SOURCE" || hook_rc=$?
|
||||
remove_out="$("$SETTINGS_HOOK" remove-source --source "$HOOK_SOURCE" 2>&1)" || remove_rc=$?
|
||||
else
|
||||
echo "gstack-memorable: missing hook manager: $SETTINGS_HOOK" >&2
|
||||
hook_rc=1
|
||||
_err "missing hook manager: $SETTINGS_HOOK"; remove_rc=1
|
||||
fi
|
||||
|
||||
if require_memorable; then
|
||||
"$MEMORABLE_CLI" disable || cli_rc=$?
|
||||
# Verify BOTH resulting states; report each, never a blended "done".
|
||||
gate_after="$(gate_value)"
|
||||
registration_state
|
||||
local ok=0
|
||||
if [ "$gate_rc" -eq 0 ] && [ "$gate_after" = "off" ]; then
|
||||
echo "consent: $CONFIG_KEY=off"
|
||||
else
|
||||
cli_rc=1
|
||||
_err "consent: could not set $CONFIG_KEY=off (gstack-config exit $gate_rc, value now '$gate_after')"; ok=1
|
||||
fi
|
||||
|
||||
[ "$hook_rc" -eq 0 ] && [ "$cli_rc" -eq 0 ]
|
||||
case "$REG_STATE" in
|
||||
none|vendor)
|
||||
if [ "$remove_rc" -eq 0 ]; then echo "hook: removed (${remove_out##*OK: })"; else _err "hook: removal reported exit $remove_rc but no gstack entry remains"; fi ;;
|
||||
gstack|both)
|
||||
_err "hook: a gstack-owned entry survived in $SETTINGS_FILE:"; printf '%s\n' "$REG_GSTACK" | sed 's/^/ /' >&2; ok=1 ;;
|
||||
*) _err "hook: cannot verify removal: $(_reg_problem_text)"; ok=$(_reg_exit_code) ;;
|
||||
esac
|
||||
[ "$remove_rc" -eq 0 ] || { [ "$remove_rc" -ge 3 ] && ok=$remove_rc; }
|
||||
if resolve_memorable >/dev/null 2>&1; then
|
||||
echo "Memorable's own consent is unchanged; to stop or erase capture: memorable disable | memorable forget"
|
||||
else
|
||||
echo "Memorable CLI not found: nothing of the vendor's to revoke here (gstack's hook entry is gone)"
|
||||
fi
|
||||
echo "In-flight prompts that already passed the gate complete; the next prompt is off."
|
||||
return "$ok"
|
||||
}
|
||||
|
||||
status_memorable() {
|
||||
local existing
|
||||
if MEMORABLE_CLI="$(resolve_memorable 2>/dev/null)" && [ -n "$MEMORABLE_CLI" ]; then
|
||||
printf 'Memorable CLI: available (%s)\n' "$MEMORABLE_CLI"
|
||||
else
|
||||
echo "Memorable CLI: unavailable"
|
||||
# ─── status (read-only; never executes the vendor) ───────────────────────
|
||||
status_bridge() {
|
||||
local vendor gate n
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo "bun: missing (the hook manager and the hook itself need bun; install bun first)"
|
||||
fi
|
||||
|
||||
if hook_present; then
|
||||
echo "Claude UserPromptSubmit hook: registered by gstack"
|
||||
elif existing="$(GSTACK_MEMORABLE_HOOK="$MEMORABLE_HOOK" memorable_own_hook)"; then
|
||||
printf 'Claude UserPromptSubmit hook: registered by Memorable itself (%s)\n' "$existing"
|
||||
echo " gstack is not managing it; 'gstack-memorable enable' would double it."
|
||||
if vendor="$(resolve_memorable)"; then
|
||||
echo "Memorable CLI: available ($vendor); tested against the memorable-cli 0.5.18 hook contract"
|
||||
else
|
||||
echo "Claude UserPromptSubmit hook: not registered"
|
||||
echo "Memorable CLI: not found (checked GSTACK_MEMORABLE_BIN, MEMORABLE_BIN, ~/.memorable/bin/memorable, PATH)"
|
||||
fi
|
||||
gate="$(gate_value)"
|
||||
echo "memorable_recall: $gate"
|
||||
registration_state
|
||||
case "$REG_STATE" in
|
||||
none) echo "Claude UserPromptSubmit hook: not registered" ;;
|
||||
gstack) echo "Claude UserPromptSubmit hook: registered by gstack"; printf '%s\n' "$REG_GSTACK" | sed 's/^/ /' ;;
|
||||
vendor) echo "Claude UserPromptSubmit hook: registered by Memorable itself"; printf '%s\n' "$REG_VENDOR" | sed 's/^/ /'
|
||||
echo " gstack is not managing it; 'gstack-memorable enable' would refuse (it would double the hook)." ;;
|
||||
both) echo "Claude UserPromptSubmit hook: registered by BOTH gstack and Memorable (the hook runs twice per prompt; remove one)"
|
||||
printf '%s\n' "$REG_GSTACK" "$REG_VENDOR" | sed 's/^/ /' ;;
|
||||
*) echo "Claude UserPromptSubmit hook: unknown ($(_reg_problem_text))" ;;
|
||||
esac
|
||||
if [ "$gate" = "on" ] && [ "$REG_STATE" = "none" ]; then echo "mismatch: gate on, no hook registered (run: gstack-memorable enable)"; fi
|
||||
if [ "$gate" != "on" ] && { [ "$REG_STATE" = "gstack" ] || [ "$REG_STATE" = "both" ]; }; then echo "mismatch: hook registered but gate is '$gate' (hook is inert; run: gstack-memorable disable to remove it)"; fi
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then echo "platform: Windows is not supported by this bridge yet (TODOS.md D21)"; fi
|
||||
if [ -x "$EGRESS_BIN" ] && command -v bun >/dev/null 2>&1; then
|
||||
n="$("$EGRESS_BIN" list --sink "$SINK" --json 2>/dev/null | grep -c '"sink": *"'"$SINK"'"' || true)"
|
||||
echo "receipts: ${n:-0} for sink $SINK (gstack-egress list --sink $SINK)"
|
||||
fi
|
||||
if [ -f "$STATE_DIR/hook-errors.log" ]; then
|
||||
n="$(grep -c 'memorable-user-prompt-hook' "$STATE_DIR/hook-errors.log" 2>/dev/null || true)"
|
||||
if [ "${n:-0}" -gt 0 ]; then
|
||||
echo "recent hook errors ($STATE_DIR/hook-errors.log):"
|
||||
grep 'memorable-user-prompt-hook' "$STATE_DIR/hook-errors.log" | tail -3 | sed 's/^/ /'
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
enable) enable_memorable ;;
|
||||
disable) disable_memorable ;;
|
||||
status) status_memorable ;;
|
||||
enable) enable_bridge ;;
|
||||
disable) disable_bridge ;;
|
||||
status) status_bridge ;;
|
||||
-h|--help|help) usage ;;
|
||||
*) usage >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
+315
-96
@@ -1,113 +1,332 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join, resolve } from 'path';
|
||||
/**
|
||||
* bin/gstack-memorable — the enable/disable/status CLI of the Memorable
|
||||
* recall bridge. Free tier; the vendor is a fake sh script that only logs
|
||||
* its argv (these verbs must never execute it).
|
||||
*
|
||||
* Isolation per test: HOME, GSTACK_HOME/STATE_ROOT/STATE_DIR (config +
|
||||
* lock), GSTACK_SETTINGS_FILE, and CLAUDE_CONFIG_DIR whose skills/gstack is
|
||||
* a symlink to this repo, so the canonical resolver finds THIS tree's hook
|
||||
* (and VERSION matches). GSTACK_MEMORABLE_BIN names the fake.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const COMMAND = join(ROOT, 'bin', 'gstack-memorable');
|
||||
const HOOK = join(ROOT, 'hosts', 'claude', 'hooks', 'memorable-user-prompt-hook');
|
||||
const homes: string[] = [];
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-memorable');
|
||||
const CONFIG = path.join(ROOT, 'bin', 'gstack-config');
|
||||
const HOOK_REL = 'hosts/claude/hooks/memorable-user-prompt-hook';
|
||||
|
||||
afterEach(() => {
|
||||
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
let home: string;
|
||||
let env: Record<string, string>;
|
||||
let settings: string;
|
||||
let canonical: string;
|
||||
|
||||
function fixture() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'gstack-memorable-'));
|
||||
homes.push(home);
|
||||
const claude = join(home, '.claude');
|
||||
mkdirSync(claude, { recursive: true });
|
||||
const settings = join(claude, 'settings.json');
|
||||
const log = join(home, 'calls.log');
|
||||
const fake = join(home, 'memorable');
|
||||
writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${log}"\nif [ "$1" = hook ]; then printf '%s' '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"remembered"}}'; fi\n`);
|
||||
chmodSync(fake, 0o700);
|
||||
return { home, settings, log, fake };
|
||||
}
|
||||
|
||||
function envFor(f: ReturnType<typeof fixture>) {
|
||||
return {
|
||||
...process.env,
|
||||
HOME: f.home,
|
||||
GSTACK_SETTINGS_FILE: f.settings,
|
||||
MEMORABLE_BIN: f.fake,
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memorable-bin-'));
|
||||
const claude = path.join(home, '.claude');
|
||||
fs.mkdirSync(path.join(claude, 'skills'), { recursive: true });
|
||||
canonical = path.join(claude, 'skills', 'gstack');
|
||||
fs.symlinkSync(ROOT, canonical);
|
||||
settings = path.join(claude, 'settings.json');
|
||||
const fake = path.join(home, 'memorable');
|
||||
fs.writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$*" >> "$HOME/calls.log"\n`, { mode: 0o755 });
|
||||
env = {
|
||||
PATH: process.env.PATH ?? '',
|
||||
HOME: home,
|
||||
CLAUDE_CONFIG_DIR: claude,
|
||||
GSTACK_SETTINGS_FILE: settings,
|
||||
GSTACK_HOME: path.join(home, '.gstack'),
|
||||
GSTACK_STATE_ROOT: path.join(home, '.gstack'),
|
||||
GSTACK_STATE_DIR: path.join(home, '.gstack'),
|
||||
GSTACK_MEMORABLE_BIN: fake,
|
||||
};
|
||||
});
|
||||
afterEach(() => { fs.rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
function run(args: string[], extra: Record<string, string> = {}) {
|
||||
const r = spawnSync('bash', [BIN, ...args], { env: { ...env, ...extra }, encoding: 'utf8', timeout: 30_000 });
|
||||
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
||||
}
|
||||
const readSettings = (): any => JSON.parse(fs.readFileSync(settings, 'utf8'));
|
||||
const gate = () => spawnSync('bash', [CONFIG, 'get', 'memorable_recall'], { env, encoding: 'utf8', timeout: 20_000 }).stdout.trim();
|
||||
const setGate = (v: string) => spawnSync('bash', [CONFIG, 'set', 'memorable_recall', v], { env, encoding: 'utf8', timeout: 20_000 });
|
||||
const vendorCalled = () => fs.existsSync(path.join(home, 'calls.log'));
|
||||
const vendorOwn = () => `"${path.join(home, '.memorable', 'bin', 'memorable')}" hook user-prompt`;
|
||||
const writeSettings = (obj: unknown) => fs.writeFileSync(settings, JSON.stringify(obj, null, 2));
|
||||
const commands = () => readSettings().hooks.UserPromptSubmit.flatMap((e: any) => e.hooks.map((h: any) => h.command));
|
||||
|
||||
describe('gstack-memorable', () => {
|
||||
test('enable registers the hook; disable removes it without deleting foreign hooks', () => {
|
||||
const f = fixture();
|
||||
writeFileSync(f.settings, JSON.stringify({
|
||||
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/foreign/hook' }] }] },
|
||||
}));
|
||||
|
||||
const enabled = spawnSync(COMMAND, ['enable'], { env: envFor(f), encoding: 'utf8' });
|
||||
expect(enabled.status).toBe(0);
|
||||
expect(readFileSync(f.log, 'utf8')).toContain('enable');
|
||||
let settings = JSON.parse(readFileSync(f.settings, 'utf8'));
|
||||
const commands = settings.hooks.UserPromptSubmit.flatMap((e: any) => e.hooks.map((h: any) => h.command));
|
||||
expect(commands).toContain('/foreign/hook');
|
||||
expect(commands).toContain(HOOK);
|
||||
|
||||
const disabled = spawnSync(COMMAND, ['disable'], { env: envFor(f), encoding: 'utf8' });
|
||||
expect(disabled.status).toBe(0);
|
||||
expect(readFileSync(f.log, 'utf8')).toContain('disable');
|
||||
settings = JSON.parse(readFileSync(f.settings, 'utf8'));
|
||||
const remaining = settings.hooks.UserPromptSubmit.flatMap((e: any) => e.hooks.map((h: any) => h.command));
|
||||
expect(remaining).toEqual(['/foreign/hook']);
|
||||
describe('enable', () => {
|
||||
test('registers the CANONICAL hook path with timeout 5, sets the gate on, never runs the vendor, explains the hand-off', () => {
|
||||
const r = run(['enable']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toBe('');
|
||||
const entries = readSettings().hooks.UserPromptSubmit;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]._gstack_source).toBe('gstack-memorable');
|
||||
expect(entries[0].hooks).toEqual([{ type: 'command', command: `${canonical}/${HOOK_REL}`, timeout: 5 }]);
|
||||
expect(entries[0].hooks[0].command.startsWith(env.CLAUDE_CONFIG_DIR)).toBe(true); // canonical, not ROOT
|
||||
expect(gate()).toBe('on');
|
||||
expect(vendorCalled()).toBe(false);
|
||||
for (const s of ['registered', 'memorable_recall=on', 'gstack-egress list --sink memorable-recall', 'gstack-memorable disable',
|
||||
'within a few seconds', 'gstack-memorable status', 'memorable enable', 'memorable forget', 'HIGH-tier'] ) {
|
||||
expect(r.stdout).toContain(s);
|
||||
}
|
||||
});
|
||||
|
||||
test('enable refuses when Memorable already registered the hook itself', () => {
|
||||
// Memorable's own installer (`memorable start`, `setup`, `install-hooks`)
|
||||
// writes this same UserPromptSubmit hook under its own name, and that is
|
||||
// the documented way to install the CLI. Registering ours beside it runs
|
||||
// the command twice per prompt: injected twice, captured twice against the
|
||||
// user's own allowance.
|
||||
const f = fixture();
|
||||
const theirs = `"${join(f.home, '.memorable', 'bin', 'memorable')}" hook user-prompt`;
|
||||
writeFileSync(f.settings, JSON.stringify({
|
||||
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: theirs }] }] },
|
||||
}));
|
||||
|
||||
const enabled = spawnSync(COMMAND, ['enable'], { env: envFor(f), encoding: 'utf8' });
|
||||
expect(enabled.status).not.toBe(0);
|
||||
expect(enabled.stderr).toContain('already registers this hook itself');
|
||||
// It refused before doing anything: no consent recorded, settings untouched.
|
||||
expect(existsSync(f.log)).toBe(false);
|
||||
const after = JSON.parse(readFileSync(f.settings, 'utf8'));
|
||||
const commands = after.hooks.UserPromptSubmit.flatMap((e: any) => e.hooks.map((h: any) => h.command));
|
||||
expect(commands).toEqual([theirs]);
|
||||
test('twice: unchanged, one entry; over a stale worktree path: re-pointed', () => {
|
||||
expect(run(['enable']).stdout).toContain('registered');
|
||||
const again = run(['enable']);
|
||||
expect(again.status).toBe(0);
|
||||
expect(again.stdout).toContain('unchanged');
|
||||
expect(readSettings().hooks.UserPromptSubmit).toHaveLength(1);
|
||||
writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `/dead/worktree/${HOOK_REL}`, timeout: 5 }] }] } });
|
||||
const rp = run(['enable']);
|
||||
expect(rp.status).toBe(0);
|
||||
expect(rp.stdout).toContain('re-pointed');
|
||||
expect(commands()).toEqual([`${canonical}/${HOOK_REL}`]);
|
||||
});
|
||||
|
||||
test('status names Memorable\'s own registration rather than reporting none', () => {
|
||||
const f = fixture();
|
||||
const theirs = `"${join(f.home, '.memorable', 'bin', 'memorable')}" hook user-prompt`;
|
||||
writeFileSync(f.settings, JSON.stringify({
|
||||
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: theirs }] }] },
|
||||
}));
|
||||
|
||||
const status = spawnSync(COMMAND, ['status'], { env: envFor(f), encoding: 'utf8' });
|
||||
expect(status.status).toBe(0);
|
||||
expect(status.stdout).toContain('registered by Memorable itself');
|
||||
expect(status.stdout).not.toContain('not registered');
|
||||
test('refuses without a stable install (no canonical tree), writes nothing', () => {
|
||||
fs.rmSync(canonical);
|
||||
const r = run(['enable']);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('run ./setup');
|
||||
expect(fs.existsSync(settings)).toBe(false);
|
||||
expect(gate()).toBe('off');
|
||||
});
|
||||
|
||||
test('a foreign UserPromptSubmit hook is not mistaken for Memorable\'s', () => {
|
||||
const f = fixture();
|
||||
writeFileSync(f.settings, JSON.stringify({
|
||||
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/foreign/hook' }] }] },
|
||||
}));
|
||||
const enabled = spawnSync(COMMAND, ['enable'], { env: envFor(f), encoding: 'utf8' });
|
||||
expect(enabled.status).toBe(0);
|
||||
test('refuses a mixed-version stable install (old hook without the .ts twin, different VERSION)', () => {
|
||||
fs.rmSync(canonical);
|
||||
fs.mkdirSync(path.join(canonical, 'hosts', 'claude', 'hooks'), { recursive: true });
|
||||
fs.mkdirSync(path.join(canonical, 'bin'), { recursive: true });
|
||||
for (const rel of ['bin/gstack-session-update', HOOK_REL]) fs.writeFileSync(path.join(canonical, rel), '#!/bin/sh\n', { mode: 0o755 });
|
||||
fs.copyFileSync(path.join(ROOT, 'bin', 'gstack-settings-hook'), path.join(canonical, 'bin', 'gstack-settings-hook'));
|
||||
fs.chmodSync(path.join(canonical, 'bin', 'gstack-settings-hook'), 0o755);
|
||||
fs.writeFileSync(path.join(canonical, 'VERSION'), '0.0.0.0\n');
|
||||
const r = run(['enable']);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/predates this bridge|is version '0.0.0.0'/);
|
||||
expect(fs.existsSync(settings)).toBe(false);
|
||||
expect(gate()).toBe('off');
|
||||
});
|
||||
|
||||
test('status is read-only and reports both dependencies', () => {
|
||||
const f = fixture();
|
||||
const status = spawnSync(COMMAND, ['status'], { env: envFor(f), encoding: 'utf8' });
|
||||
expect(status.status).toBe(0);
|
||||
expect(status.stdout).toContain('Memorable CLI: available');
|
||||
expect(status.stdout).toContain('Claude UserPromptSubmit hook: not registered');
|
||||
expect(existsSync(f.log)).toBe(false);
|
||||
test('refuses when the vendor CLI is absent; never installs anything', () => {
|
||||
const r = run(['enable'], { GSTACK_MEMORABLE_BIN: path.join(home, 'nope') });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('npm i -g memorable-cli');
|
||||
expect(fs.existsSync(settings)).toBe(false);
|
||||
expect(gate()).toBe('off');
|
||||
});
|
||||
|
||||
test("refuses when Memorable registered the hook itself (the real 0.5.18 installer string); settings and gate untouched", () => {
|
||||
writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: vendorOwn() }] }] } });
|
||||
const before = fs.readFileSync(settings, 'utf8');
|
||||
const r = run(['enable']);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('already registers this hook itself');
|
||||
expect(r.stderr).toContain(settings);
|
||||
expect(fs.readFileSync(settings, 'utf8')).toBe(before);
|
||||
expect(gate()).toBe('off');
|
||||
expect(vendorCalled()).toBe(false);
|
||||
});
|
||||
|
||||
test('a foreign UserPromptSubmit hook is not mistaken for the vendor: enable proceeds beside it', () => {
|
||||
writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/foreign/hook' }] }] } });
|
||||
expect(run(['enable']).status).toBe(0);
|
||||
expect(commands()).toEqual(['/foreign/hook', `${canonical}/${HOOK_REL}`]);
|
||||
});
|
||||
|
||||
test('corrupt settings.json: exit 3, gate stays off; unexpected shape: exit 4', () => {
|
||||
fs.writeFileSync(settings, '{not json');
|
||||
let r = run(['enable']);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('not valid JSON');
|
||||
expect(gate()).toBe('off');
|
||||
writeSettings({ hooks: { UserPromptSubmit: {} } });
|
||||
r = run(['enable']);
|
||||
expect(r.status).toBe(4);
|
||||
expect(gate()).toBe('off');
|
||||
});
|
||||
|
||||
test('refuses on Windows (deferred whole, D21) without touching anything', () => {
|
||||
const r = run(['enable'], { GSTACK_MEMORABLE_TEST_UNAME: 'MINGW64_NT-10.0' });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('Windows is not supported');
|
||||
expect(fs.existsSync(settings)).toBe(false);
|
||||
});
|
||||
|
||||
test('when recording consent fails, prior state is restored: a fresh registration is removed, a pre-existing one kept', () => {
|
||||
// make config.yaml unwritable AFTER the gate was read: point the state dir at a read-only file
|
||||
const roState = path.join(home, 'ro-state');
|
||||
fs.mkdirSync(roState);
|
||||
fs.writeFileSync(path.join(roState, 'config.yaml'), 'telemetry: off\n', { mode: 0o444 });
|
||||
fs.chmodSync(roState, 0o555);
|
||||
const ro = { GSTACK_HOME: roState, GSTACK_STATE_ROOT: roState, GSTACK_STATE_DIR: roState };
|
||||
const r = run(['enable'], ro);
|
||||
fs.chmodSync(roState, 0o755);
|
||||
if (r.status === 0) {
|
||||
// running as a user that ignores file modes (root in CI): the write succeeded, nothing to assert on rollback
|
||||
expect(r.stdout).toContain('enabled');
|
||||
return;
|
||||
}
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('could not record consent');
|
||||
expect(fs.existsSync(settings) ? (readSettings().hooks ?? {}).UserPromptSubmit : undefined).toBeUndefined(); // fresh registration rolled back
|
||||
});
|
||||
});
|
||||
|
||||
describe('disable', () => {
|
||||
test('removes a TAG-STRIPPED registration by identity, sets the gate off, keeps the foreign sibling, exit 0', () => {
|
||||
setGate('on');
|
||||
writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [
|
||||
{ type: 'command', command: '/foreign/hook' },
|
||||
{ type: 'command', command: `${canonical}/${HOOK_REL}`, timeout: 5 },
|
||||
] }] } });
|
||||
const r = run(['disable']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('consent: memorable_recall=off');
|
||||
expect(r.stdout).toContain('hook: removed');
|
||||
expect(r.stdout).toContain('In-flight prompts');
|
||||
expect(commands()).toEqual(['/foreign/hook']);
|
||||
expect(gate()).toBe('off');
|
||||
expect(vendorCalled()).toBe(false);
|
||||
});
|
||||
|
||||
test('vendor CLI absent: still exit 0, gate off, says there is nothing of the vendor to revoke', () => {
|
||||
run(['enable']);
|
||||
const r = run(['disable'], { GSTACK_MEMORABLE_BIN: path.join(home, 'nope') });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('nothing of the vendor');
|
||||
expect(gate()).toBe('off');
|
||||
expect(readSettings().hooks).toBeUndefined();
|
||||
});
|
||||
|
||||
test('never runs memorable disable; tells the user the vendor consent is separate', () => {
|
||||
run(['enable']);
|
||||
const r = run(['disable']);
|
||||
expect(r.stdout).toContain('memorable disable | memorable forget');
|
||||
expect(vendorCalled()).toBe(false);
|
||||
});
|
||||
|
||||
test('corrupt settings.json: the gate goes off FIRST, the failure is reported, exit non-zero', () => {
|
||||
setGate('on');
|
||||
fs.writeFileSync(settings, '{not json');
|
||||
const r = run(['disable']);
|
||||
expect(r.status).toBe(3);
|
||||
expect(gate()).toBe('off');
|
||||
expect(r.stdout).toContain('consent: memorable_recall=off');
|
||||
expect(r.stderr).toContain('not valid JSON');
|
||||
});
|
||||
|
||||
test('nothing registered: idempotent, exit 0', () => {
|
||||
const r = run(['disable']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('hook: removed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('status (read-only)', () => {
|
||||
test('fresh: vendor found, gate off, not registered; writes nothing, never runs the vendor', () => {
|
||||
const r = run(['status']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('Memorable CLI: available');
|
||||
expect(r.stdout).toContain('memorable_recall: off');
|
||||
expect(r.stdout).toContain('not registered');
|
||||
expect(fs.existsSync(settings)).toBe(false);
|
||||
expect(vendorCalled()).toBe(false);
|
||||
});
|
||||
|
||||
test('tag-stripped gstack registration, plain and bash-prefixed quoted: "registered by gstack"', () => {
|
||||
setGate('on');
|
||||
for (const cmd of [`${canonical}/${HOOK_REL}`, `bash "${canonical}/${HOOK_REL}"`]) {
|
||||
writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: cmd }] }] } });
|
||||
const r = run(['status']);
|
||||
expect(r.stdout).toContain('registered by gstack');
|
||||
expect(r.stdout).not.toContain('not registered');
|
||||
expect(r.stdout).not.toContain('mismatch');
|
||||
}
|
||||
});
|
||||
|
||||
test("vendor-own registration: 'registered by Memorable itself'", () => {
|
||||
writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: vendorOwn() }] }] } });
|
||||
const r = run(['status']);
|
||||
expect(r.stdout).toContain('registered by Memorable itself');
|
||||
expect(r.stdout).toContain('would refuse');
|
||||
});
|
||||
|
||||
test('mismatch lines: gate on with no hook; hook present with gate off; both registered', () => {
|
||||
setGate('on');
|
||||
expect(run(['status']).stdout).toContain('mismatch: gate on, no hook');
|
||||
setGate('off');
|
||||
writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `${canonical}/${HOOK_REL}` }] }, { hooks: [{ type: 'command', command: vendorOwn() }] }] } });
|
||||
const r = run(['status']);
|
||||
expect(r.stdout).toContain('registered by BOTH');
|
||||
expect(r.stdout).toContain('hook is inert');
|
||||
});
|
||||
|
||||
test('unparseable settings and bun missing are named, exit 0', () => {
|
||||
fs.writeFileSync(settings, '{bad');
|
||||
expect(run(['status']).stdout).toContain('unknown (');
|
||||
const r = run(['status'], { PATH: '/usr/bin:/bin' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('bun: missing');
|
||||
});
|
||||
|
||||
test('tails recent hook errors and counts receipts for the sink', () => {
|
||||
fs.mkdirSync(env.GSTACK_HOME, { recursive: true });
|
||||
fs.writeFileSync(path.join(env.GSTACK_HOME, 'hook-errors.log'), '2026-09-08T00:00:00Z memorable-user-prompt-hook: vendor timeout\n');
|
||||
const r = run(['status']);
|
||||
expect(r.stdout).toContain('recent hook errors');
|
||||
expect(r.stdout).toContain('vendor timeout');
|
||||
expect(r.stdout).toMatch(/receipts: \d+ for sink memorable-recall/);
|
||||
});
|
||||
|
||||
test('vendor resolution precedence: GSTACK_MEMORABLE_BIN > MEMORABLE_BIN > ~/.memorable/bin/memorable > PATH; an unresolvable override is an error', () => {
|
||||
const mk = (p: string) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, '#!/bin/sh\n', { mode: 0o755 }); return p; };
|
||||
const a = mk(path.join(home, 'a', 'memorable'));
|
||||
const b = mk(path.join(home, 'b', 'memorable'));
|
||||
const pinned = mk(path.join(home, '.memorable', 'bin', 'memorable'));
|
||||
const onPath = mk(path.join(home, 'pathdir', 'memorable'));
|
||||
const base = { GSTACK_MEMORABLE_BIN: '', MEMORABLE_BIN: '', PATH: `${path.join(home, 'pathdir')}:${env.PATH}` };
|
||||
expect(run(['status'], { ...base, GSTACK_MEMORABLE_BIN: a, MEMORABLE_BIN: b }).stdout).toContain(`available (${a})`);
|
||||
expect(run(['status'], { ...base, MEMORABLE_BIN: b }).stdout).toContain(`available (${b})`);
|
||||
expect(run(['status'], base).stdout).toContain(`available (${pinned})`);
|
||||
fs.rmSync(pinned);
|
||||
expect(run(['status'], base).stdout).toContain(`available (${onPath})`);
|
||||
expect(run(['status'], { ...base, GSTACK_MEMORABLE_BIN: path.join(home, 'missing') }).stdout).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle lock and static pins', () => {
|
||||
test('two concurrent enables serialise: one entry, gate on, both exit 0', async () => {
|
||||
const kids = [0, 1].map(() => Bun.spawn(['bash', BIN, 'enable'], { env, stdout: 'pipe', stderr: 'pipe' }));
|
||||
const codes = await Promise.all(kids.map((k) => k.exited));
|
||||
expect(codes).toEqual([0, 0]);
|
||||
expect(readSettings().hooks.UserPromptSubmit).toHaveLength(1);
|
||||
expect(gate()).toBe('on');
|
||||
expect(fs.existsSync(path.join(env.GSTACK_HOME, 'locks', 'memorable-bridge.lock'))).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
test('a stale lock (older than 30 s) is taken over, a fresh one is waited on', () => {
|
||||
const lock = path.join(env.GSTACK_HOME, 'locks', 'memorable-bridge.lock');
|
||||
fs.mkdirSync(lock, { recursive: true });
|
||||
fs.writeFileSync(path.join(lock, 'ts'), String(Math.floor(Date.now() / 1000) - 120));
|
||||
fs.writeFileSync(path.join(lock, 'owner'), '999999');
|
||||
expect(run(['enable']).status).toBe(0);
|
||||
expect(fs.existsSync(lock)).toBe(false);
|
||||
});
|
||||
|
||||
test('source pins: canonical-only command, Windows refusal, no vendor invocation, explicit-status style', () => {
|
||||
const src = fs.readFileSync(BIN, 'utf8');
|
||||
expect(src).toContain('IS_WINDOWS');
|
||||
expect(src).not.toMatch(/--command "\$ROOT_DIR/);
|
||||
expect(src).toContain('CANONICAL_GSTACK_ROOT');
|
||||
expect(src).not.toMatch(/"\$vendor" (enable|disable|status)/);
|
||||
expect(src).toContain('set -uo pipefail');
|
||||
expect(src).not.toContain('set -euo');
|
||||
expect(src).toContain('BASH_COMPAT=50');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user