fix(plugin): every hook's credential + URL encoding was broken
Three defects, each of which independently made a hook a no-op, and all three failing silently — which is why the whole dynamic side of the plugin looked "shipped" while doing nothing. 1. Wrong env var case. Claude Code exports userConfig to hooks as CLAUDE_PLUGIN_OPTION_<KEY> with the key UPPERCASED. All four hooks read CLAUDE_PLUGIN_OPTION_api_endpoint / _api_token, so both values were always empty. That killed the SessionStart dynamic tier, process sync, prompt auto-inject, and the write-path prior-art trigger at once. 2. jq -rR is line-oriented. `@uri` under -R encodes input LINE BY LINE, so a multi-line payload came back as several encoded lines joined by raw newlines — an invalid URL, curl fails, hook exits 0 in silence. Now -sRr. This one hid behind (1): auto-inject only ever worked for single-line prompts, and prior-art (which posts code, always multi-line) could never have worked at all. 3. cut -c1-1200 caps each LINE, not the payload, so the prior-art code budget wasn't a budget. Now head -c 1200. Also widens the SessionStart warning: "neither URL nor token arrived" used to be treated as a benign unconfigured install and stayed quiet. That is exactly the state defect (1) produced, so the one install state that most needed a signal was the only one that emitted none. It now says so, and names the two other features it silently disables. Verified against the live instance: dynamic rules + project context load, auto-inject surfaces #2192 on a multi-line prompt, and the write-path trigger returns the [here] place-arm hit on a multi-line edit with session dedup suppressing the repeat. Refs #2198, #2082 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -11,9 +11,12 @@
|
|||||||
# static floor here. If the instance is unconfigured/unreachable, or anything
|
# static floor here. If the instance is unconfigured/unreachable, or anything
|
||||||
# fails, the hook stays SILENT and exits 0 — it must never block a prompt.
|
# fails, the hook stays SILENT and exits 0 — it must never block a prompt.
|
||||||
#
|
#
|
||||||
# Config (same as scribe_session_context.sh), exported to the hook by Claude Code:
|
# Config (same as scribe_session_context.sh), exported to the hook by Claude Code
|
||||||
# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash
|
# with the userConfig key UPPERCASED (see #2198 — reading the lowercase spelling
|
||||||
# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive)
|
# silently disables this hook, and silence is indistinguishable from "nothing
|
||||||
|
# cleared the threshold"):
|
||||||
|
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||||
|
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||||
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||||
#
|
#
|
||||||
# Session dedup: each surfaced note id is remembered in a per-session file so a
|
# Session dedup: each surfaced note id is remembered in a per-session file so a
|
||||||
@@ -32,8 +35,8 @@ event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_c
|
|||||||
# Nothing to retrieve against.
|
# Nothing to retrieve against.
|
||||||
[ -n "$prompt" ] || exit 0
|
[ -n "$prompt" ] || exit 0
|
||||||
|
|
||||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
|
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
|
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||||
case "$url" in *'${'*) url="" ;; esac
|
case "$url" in *'${'*) url="" ;; esac
|
||||||
case "$token" in *'${'*) token="" ;; esac
|
case "$token" in *'${'*) token="" ;; esac
|
||||||
@@ -42,14 +45,18 @@ case "$token" in *'${'*) token="" ;; esac
|
|||||||
|
|
||||||
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
|
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
|
||||||
q=$(printf '%s' "$prompt" | cut -c1-2000)
|
q=$(printf '%s' "$prompt" | cut -c1-2000)
|
||||||
q_enc=$(printf '%s' "$q" | jq -rR '@uri' 2>/dev/null) || exit 0
|
# `-sRr`, not `-rR`: jq -R reads LINE BY LINE, so a multi-line prompt encoded as
|
||||||
|
# several lines joined by raw newlines and the request died. Single-line prompts
|
||||||
|
# worked, which is why this looked healthy — the long, substantial prompts most
|
||||||
|
# worth retrieving against were exactly the ones silently dropped. -s slurps.
|
||||||
|
q_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
||||||
|
|
||||||
# Resolve the working repo's remote so the server can scope to the bound project.
|
# Resolve the working repo's remote so the server can scope to the bound project.
|
||||||
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||||
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
||||||
repo_q=""
|
repo_q=""
|
||||||
if [ -n "$repo" ]; then
|
if [ -n "$repo" ]; then
|
||||||
enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc=""
|
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
# Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A
|
# Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A
|
||||||
# recall aid must not be able to stop the operator's work.
|
# recall aid must not be able to stop the operator's work.
|
||||||
#
|
#
|
||||||
# Config (same as the other hooks), exported to the hook by Claude Code:
|
# Config (same as the other hooks), exported to the hook by Claude Code with the
|
||||||
# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash
|
# userConfig key UPPERCASED (see #2198 — the lowercase spelling reads as empty
|
||||||
# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive)
|
# and this hook then exits 0 in silence, looking exactly like "no prior art"):
|
||||||
|
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||||
|
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||||
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
@@ -46,8 +48,8 @@ case "$file_path" in
|
|||||||
exit 0 ;;
|
exit 0 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
|
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
|
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||||
case "$url" in *'${'*) url="" ;; esac
|
case "$url" in *'${'*) url="" ;; esac
|
||||||
case "$token" in *'${'*) token="" ;; esac
|
case "$token" in *'${'*) token="" ;; esac
|
||||||
@@ -70,16 +72,24 @@ fi
|
|||||||
# token limit well before this, so a bigger slice buys no extra signal — and the
|
# token limit well before this, so a bigger slice buys no extra signal — and the
|
||||||
# payload has to stay a GET (a read-scoped API key cannot POST, and every other
|
# payload has to stay a GET (a read-scoped API key cannot POST, and every other
|
||||||
# plugin hook works with a read key).
|
# plugin hook works with a read key).
|
||||||
q=$(printf '%s' "$code" | cut -c1-1200)
|
# `head -c`, not `cut -c1-1200`: cut is line-oriented and caps each line
|
||||||
|
# separately, so a 400-line edit sailed past the "1200 char" budget entirely and
|
||||||
|
# built a URL from the whole payload. head -c caps the total, which is the point.
|
||||||
|
q=$(printf '%s' "$code" | head -c 1200)
|
||||||
|
|
||||||
path_enc=$(printf '%s' "$rel_path" | jq -rR '@uri' 2>/dev/null) || exit 0
|
# `-sRr`, not `-rR`: jq -R reads input LINE BY LINE, so a multi-line payload came
|
||||||
code_enc=$(printf '%s' "$q" | jq -rR '@uri' 2>/dev/null) || code_enc=""
|
# back as several separately-encoded lines joined by raw newlines — an invalid
|
||||||
|
# URL that made curl fail, and this hook then exited 0 in silence. -s slurps the
|
||||||
|
# whole input into one string first. Newlines are exactly what code contains, so
|
||||||
|
# this hook could never have worked without it (issue #2198 / #2082).
|
||||||
|
path_enc=$(printf '%s' "$rel_path" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
||||||
|
code_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || code_enc=""
|
||||||
|
|
||||||
# Resolve the working repo's remote so the server can scope to the bound project.
|
# Resolve the working repo's remote so the server can scope to the bound project.
|
||||||
repo=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true)
|
repo=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true)
|
||||||
repo_q=""
|
repo_q=""
|
||||||
if [ -n "$repo" ]; then
|
if [ -n "$repo" ]; then
|
||||||
enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc=""
|
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,18 @@
|
|||||||
# Tier 1 (STATIC, always fires, no auth, no network): injects a bundled
|
# Tier 1 (STATIC, always fires, no auth, no network): injects a bundled
|
||||||
# behavioral mandate (scribe_static_context.md) so a fresh session knows to
|
# behavioral mandate (scribe_static_context.md) so a fresh session knows to
|
||||||
# reach for Scribe — record work, recall before acting — even when the instance
|
# reach for Scribe — record work, recall before acting — even when the instance
|
||||||
# is unreachable OR the API token never reached this hook. The latter is a known
|
# is unreachable or unconfigured. The static tier is the load-bearing floor that
|
||||||
# Claude Code gap: sensitive userConfig values aren't always exported to the
|
# does not depend on the key or the network.
|
||||||
# hook subprocess, so the dynamic tier can silently get nothing. The static tier
|
|
||||||
# is the load-bearing floor that does not depend on the key or the network.
|
|
||||||
#
|
#
|
||||||
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance
|
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance
|
||||||
# for always-on rules + active-project context and appends it. Config comes from
|
# for always-on rules + active-project context and appends it. Config comes from
|
||||||
# the plugin's userConfig, exported to hooks as:
|
# the plugin's userConfig, exported to hooks as:
|
||||||
# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash
|
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||||
# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive)
|
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||||
|
# NOTE THE CASE: Claude Code uppercases the userConfig key when exporting it, so
|
||||||
|
# the `api_token` option arrives as CLAUDE_PLUGIN_OPTION_API_TOKEN. Reading the
|
||||||
|
# lowercase spelling silently yields nothing — that was issue #2198, and it
|
||||||
|
# disabled the dynamic tier, auto-inject, and the write-path trigger at once.
|
||||||
# The active project is resolved server-side from the working repo's git remote
|
# The active project is resolved server-side from the working repo's git remote
|
||||||
# (see services/repo_bindings); bind each repo once with the bind_repo MCP tool.
|
# (see services/repo_bindings); bind each repo once with the bind_repo MCP tool.
|
||||||
#
|
#
|
||||||
@@ -25,16 +27,16 @@
|
|||||||
# can't make the model flush, and can't know the in-flight task ids; the durable
|
# can't make the model flush, and can't know the in-flight task ids; the durable
|
||||||
# path is record-as-you-go + this post-compaction reload.)
|
# path is record-as-you-go + this post-compaction reload.)
|
||||||
#
|
#
|
||||||
# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in
|
# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in a
|
||||||
# hooks.json — sensitive values are kept in the keychain and never spliced into
|
# shell-form hooks.json command — Claude Code rejects that outright (splicing a
|
||||||
# a hook command line, so the placeholder arrives unexpanded. The harness env
|
# configured value into a shell command line would let the shell run whatever it
|
||||||
# vars above are the supported channel; SCRIBE_URL / SCRIBE_TOKEN override for
|
# contains). The env vars above are the supported channel; SCRIBE_URL /
|
||||||
# the settings.json dogfooding path.
|
# SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||||
#
|
#
|
||||||
# FAIL-OPEN, BUT NOT SILENT: the dynamic tier never blocks a session. A *failed*
|
# FAIL-OPEN, BUT NOT SILENT: the dynamic tier never blocks a session, and every
|
||||||
# dynamic fetch is surfaced as a short status line (not swallowed). A fully
|
# way it can come up empty produces a short status line — failed fetch, missing
|
||||||
# unconfigured install (no url AND no token) is the intended static-only mode
|
# token, and missing-everything alike. Nothing about the credential path is
|
||||||
# and stays quiet.
|
# allowed to fail quietly; see the #2198 comment at the status block below.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely
|
command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely
|
||||||
@@ -55,8 +57,8 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"
|
|||||||
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
|
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
|
||||||
|
|
||||||
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
|
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
|
||||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
|
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
|
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||||
|
|
||||||
# Guard against an unexpanded `${...}` placeholder reaching us as a literal — it
|
# Guard against an unexpanded `${...}` placeholder reaching us as a literal — it
|
||||||
# would otherwise be sent as a garbage Bearer token and 401. Treat as unset.
|
# would otherwise be sent as a garbage Bearer token and 401. Treat as unset.
|
||||||
@@ -71,7 +73,7 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
|||||||
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
||||||
q=""
|
q=""
|
||||||
if [ -n "$repo" ]; then
|
if [ -n "$repo" ]; then
|
||||||
enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc=""
|
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||||
[ -n "$enc" ] && q="?repo=${enc}"
|
[ -n "$enc" ] && q="?repo=${enc}"
|
||||||
fi
|
fi
|
||||||
body=$(curl -fsS --max-time 8 \
|
body=$(curl -fsS --max-time 8 \
|
||||||
@@ -80,9 +82,16 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
|||||||
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
||||||
[ -z "$dyn" ] && status="> ⚠️ Scribe: live rules/project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\` as needed."
|
[ -z "$dyn" ] && status="> ⚠️ Scribe: live rules/project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\` as needed."
|
||||||
elif [ -n "$url" ] && [ -z "$token" ]; then
|
elif [ -n "$url" ] && [ -z "$token" ]; then
|
||||||
# Endpoint configured but token absent: the signature of the known Claude Code
|
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
||||||
# userConfig export gap (sensitive values not always reaching the hook).
|
elif [ -z "$url" ] && [ -z "$token" ]; then
|
||||||
status="> ⚠️ Scribe: live context disabled this session — the API token did not reach this hook (a known Claude Code plugin-config gap). Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
# NEITHER value arrived. Previously this case stayed silent as "an unconfigured
|
||||||
|
# install", which made issue #2198 invisible for weeks: a *casing* bug here
|
||||||
|
# (reading CLAUDE_PLUGIN_OPTION_api_token when Claude Code exports the key
|
||||||
|
# UPPERCASED) looks identical to never having configured the plugin, and
|
||||||
|
# silently disabled auto-inject and the write-path trigger too. It is not a
|
||||||
|
# benign state — the plugin prompts for both values at enable time, so if
|
||||||
|
# neither reached the hook, something is wrong. Say so.
|
||||||
|
status="> ⚠️ Scribe: live context disabled this session — neither the Scribe base URL nor the API key reached this hook. Configure the plugin (\`/plugin\` → Scribe), or export SCRIBE_URL + SCRIBE_TOKEN. Note this also disables prompt auto-inject and the write-path prior-art trigger. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[ -n "$dyn" ] && append "$dyn"
|
[ -n "$dyn" ] && append "$dyn"
|
||||||
|
|||||||
@@ -18,14 +18,16 @@
|
|||||||
# FAIL-OPEN & SILENT: never blocks a session; emits NOTHING on stdout (so it's
|
# FAIL-OPEN & SILENT: never blocks a session; emits NOTHING on stdout (so it's
|
||||||
# safe as a second SessionStart hook). On any fetch failure it exits without
|
# safe as a second SessionStart hook). On any fetch failure it exits without
|
||||||
# touching existing stubs — a transient outage must not wipe the user's skills.
|
# touching existing stubs — a transient outage must not wipe the user's skills.
|
||||||
# Config mirrors the context hook (CLAUDE_PLUGIN_OPTION_* / SCRIBE_* override).
|
# Config mirrors the context hook: CLAUDE_PLUGIN_OPTION_API_ENDPOINT /
|
||||||
|
# CLAUDE_PLUGIN_OPTION_API_TOKEN (userConfig key UPPERCASED by Claude Code — see
|
||||||
|
# #2198), with SCRIBE_URL / SCRIBE_TOKEN as the override.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
command -v jq >/dev/null 2>&1 || exit 0
|
command -v jq >/dev/null 2>&1 || exit 0
|
||||||
command -v curl >/dev/null 2>&1 || exit 0
|
command -v curl >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
|
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
|
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||||
# Guard against an unexpanded `${...}` placeholder arriving as a literal.
|
# Guard against an unexpanded `${...}` placeholder arriving as a literal.
|
||||||
case "$url" in *'${'*) url="" ;; esac
|
case "$url" in *'${'*) url="" ;; esac
|
||||||
case "$token" in *'${'*) token="" ;; esac
|
case "$token" in *'${'*) token="" ;; esac
|
||||||
|
|||||||
Reference in New Issue
Block a user