Problem class: duckbrain-auth-registry-stale-daemon-cache
Problem class: duckbrain-auth-registry-stale-daemon-cache
Symptom: HTTP 401 {"error":"Unauthorized: Invalid API key"} on /api/keys/flat even though the consumer's named plaintext sidecar hashed (trimmed SHA-256) to a keyHash present in the on-disk auth.json.
duckbrain-http.service loads the authorization registry (auth.json) once at process start and keeps it in memory. The registry is not re-read, watched, or invalidated when the file changes. In the failing incident:
| Event | Time |
|---|---|
duckbrain-http.service starts, loads registry |
07:30 |
auth.json is rewritten with new/rotated grants |
09:02 |
The daemon authenticates against the 07:30 snapshot. A token whose trimmed SHA-256 matches a keyHash written at 09:02 is still rejected, because that hash does not exist in the daemon's in-memory map. This is a process-level cache invalidation bug, not a token or hash-computation bug. Restarting the service forces a reload of the 09:02 registry; the same token then returns 200.
Two properties make it look like a bad key:
keyHash matches), so the token "should" work.401 Invalid API key is indistinguishable from a genuinely wrong key, so the consumer keeps rotating/re-checking the token instead of the daemon.The trigger is any out-of-band auth.json rewrite: key rotation, grant addition, grant revocation, or restore from backup.
Confirm the two independent facts: the daemon is older than the registry, and the sidecar hash is present in the registry.
SERVICE=duckbrain-http.service
AUTH_JSON="${DB_AUTH_JSON:-$HOME/.local/share/duckbrain/auth.json}" # adjust to checkout
SECRETS="${DB_SECRETS:-$(dirname "$AUTH_JSON")/secrets}"
# 2a. Daemon start time vs registry mtime. If start < mtime -> stale cache.
systemctl --user show "$SERVICE" -p ActiveEnterTimestamp --value
stat -c '%y %n' "$AUTH_JSON"
# 2b. Does the sidecar's trimmed SHA-256 appear as a keyHash? (no token echoed)
for f in "$SECRETS"/*; do
[ -f "$f" ] || continue
s=$(cat -- "$f"); s=${s#"${s%%[![:space:]]*}"}; s=${s%"${s##*[![:space:]]}"}
h=$(printf '%s' "$s" | sha256sum | awk '{print $1}')
name=$(jq -r --arg h "$h" '(.keys // .)
| (if type=="array" then .[] else to_entries[].value end)
| select(.keyHash == $h) | .name' "$AUTH_JSON")
printf '%-24s sha256=%s grant=%s\n' "$(basename "$f")" "${h:0:12}…" "${name:-<none>}"
unset s
done
# 2c. Live probe with a sidecar known to match the registry.
token=$(cat "$SECRETS/<matching-sidecar>.token")
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $token" http://localhost:3000/api/keys/flat
unset token
Stale-cache signature: 2b prints a non-empty grant, but 2c prints 401. That combination — hash present on disk, rejected by the live daemon — is the proof.
The full ready-to-run checker is
duckbrain-auth-doctor.shin §6. Run it without--fixfirst.
systemctl --user restart duckbrain-http.service
systemctl --user is-active duckbrain-http.service # -> active
Then re-probe and re-run the project smoke canary:
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $(cat "$SECRETS/<matching-sidecar>.token")" \
http://localhost:3000/api/keys/flat
./scripts/smoke-canary.sh # substitute the project's actual canary command
Expected recovery: canary goes 13 PASS / 2 WARN / 1 FAIL → 14 PASS / 2 WARN / 0 FAIL.
A restart is only a stop-gap: the next auth.json write re-introduces the bug.
auth.json changesBecause the environment is a systemd --user service and the problem is a missing invalidation edge, wire that edge into systemd with a path unit. This needs no code change to DuckBrain and survives reboots.
Edit ~/.config/systemd/user/duckbrain-http.service (locate it with systemctl --user cat duckbrain-http.service) and add:
[Service]
# If the daemon reloads auth.json on SIGHUP, use a true reload:
ExecReload=/bin/kill -HUP $MAINPID
# Otherwise leave the line out and the reload helper below will restart instead.
~/.local/bin/duckbrain-reload-auth.sh:
#!/usr/bin/env bash
set -eu
SERVICE=duckbrain-http.service
if systemctl --user show "$SERVICE" -p CanReload --value 2>/dev/null | grep -q yes; then
systemctl --user reload "$SERVICE"
else
systemctl --user restart "$SERVICE"
fi
chmod +x ~/.local/bin/duckbrain-reload-auth.sh
~/.config/systemd/user/duckbrain-auth-reload.service:
[Unit]
Description=Reload DuckBrain auth registry after auth.json changes
[Service]
Type=oneshot
ExecStart=%h/.local/bin/duckbrain-reload-auth.sh
~/.config/systemd/user/duckbrain-auth-reload.path:
[Unit]
Description=Watch DuckBrain auth.json for changes
[Path]
PathChanged=%h/.local/share/duckbrain/auth.json
Unit=duckbrain-auth-reload.service
[Install]
WantedBy=default.target
PathChanged= fires on rename/replace, which is what an atomic registry rewrite does. Point the path at the real AUTH_JSON location (systemctl --user cat duckbrain-http.service, or the DB_AUTH_JSON value).
Enable and test:
systemctl --user daemon-reload
systemctl --user enable --now duckbrain-auth-reload.path
systemctl --user list-units 'duckbrain*'
auth.json atomicallySo watchers never observe a half-written registry:
tmp=$(mktemp "${AUTH_JSON}.XXXXXX")
# ...write new registry to "$tmp"...
jq empty "$tmp" && chmod 600 "$tmp" && mv -f "$tmp" "$AUTH_JSON"
Pick one and remove the reliance on external reloads:
st_mtime/st_ino of auth.json; if it changed, re-read and atomically swap the in-memory map before authorizing.process.on('SIGHUP', reloadRegistry) and validate the new registry before swapping.fs.watch/chokidar on the file, debounced reload.start=$(systemctl --user show duckbrain-http.service -p ActiveEnterTimestamp --value)
s=$(date -d "$start" +%s); a=$(stat -c %Y "$AUTH_JSON")
[ "$s" -ge "$a" ] && echo "OK: daemon started at/after auth.json mtime" \
|| echo "STALE: restart or reload needed"
./duckbrain-auth-doctor.sh --fix --service duckbrain-http.service \
--auth "$AUTH_JSON" --secrets "$SECRETS" --url http://localhost:3000
A healthy run prints MATCH … for every intended grant, HTTP 200 on the probe, and exits 0.
Prove the durable fix actually reloads. Rewrite auth.json without restarting duckbrain-http.service, then let the path unit fire:
T="token-$(date +%s)"; H=$(printf '%s' "$T" | sha256sum | awk '{print $1}')
printf '%s\n' "$T" > "$SECRETS/new.token"
tmp=$(mktemp); jq --arg h "$H" '.keys += [{"name":"new","keyHash":$h}]' "$AUTH_JSON" > "$tmp"
mv -f "$tmp" "$AUTH_JSON" # atomic replace triggers duckbrain-auth-reload.path
sleep 2 # debounce for the path unit
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" \
http://localhost:3000/api/keys/flat # -> 200 without a manual restart
./scripts/smoke-canary.sh
Target: 14 PASS / 2 WARN / 0 FAIL (was 13 PASS / 2 WARN / 1 FAIL).
A faithful fixture that loads auth.json once and exposes /api/keys/flat, exactly as the daemon does, demonstrates the 401 before restart and the 200 after:
bash /workspace/repro/run_repro.sh
Observed:
== 3. same token now on disk, but daemon still serves stale registry ==
new-grant token (on disk!) -> HTTP 401 {"error": "Unauthorized: Invalid API key"}
old-grant token (removed) -> HTTP 200 {"grant": "old-grant", ...}
== 4. FIX: restart the daemon so it reloads the registry ==
new-grant token (after reload) -> HTTP 200 {"grant": "new-grant", ...}
old-grant token (after reload) -> HTTP 401 {"error": "Unauthorized: Invalid API key"}
And the doctor script, pointed at the same fixture with the registry rewritten under a running daemon, reproduces the exact incident signature (MATCH on disk + live HTTP 401) and exits 1:
== sidecar keyHash verification (tokens never printed) ==
MATCH stale.token sha256=5471ec7bcff5… grant=stale
== authenticated probe http://<ip-address>:3999/api/keys/flat ==
FAIL: HTTP 401 stale.token (grant=stale) — daemon cache likely stale
FAIL: stale registry detected — re-run with --fix
Raw evidence is captured in evidence.md.
Save as duckbrain-auth-doctor.sh, chmod +x, then run it without --fix to diagnose and with --fix to restart. It never prints a token; it prints only a 12-hex-character hash prefix.
#!/usr/bin/env bash
# duckbrain-auth-doctor.sh — diagnose and fix stale DuckBrain auth registry.
#
# Usage:
# ./duckbrain-auth-doctor.sh [--fix] [--service NAME] [--url URL]
# [--auth PATH] [--secrets DIR]
set -u
SERVICE="${DB_SERVICE:-duckbrain-http.service}"
BASE_URL="${DB_URL:-http://localhost:3000}"
AUTH_JSON="${DB_AUTH_JSON:-}"
SECRETS_DIR="${DB_SECRETS:-}"
ENDPOINT="${DB_ENDPOINT:-/api/keys/flat}"
FIX=0
while [ $# -gt 0 ]; do
case "$1" in
--fix) FIX=1 ;;
--service) SERVICE=$2; shift ;;
--url) BASE_URL=$2; shift ;;
--auth) AUTH_JSON=$2; shift ;;
--secrets) SECRETS_DIR=$2; shift ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
shift
done
say() { printf '%s\n' "$*"; }
fail() { printf 'FAIL: %s\n' "$*" >&2; }
ok() { printf ' %s\n' "$*"; }
if [ -z "$AUTH_JSON" ]; then
for c in \
"$HOME/.local/share/duckbrain/auth.json" \
"$HOME/duckbrain/auth.json" \
"$HOME/duckbrain-http/auth.json" \
/opt/duckbrain/auth.json \
/srv/duckbrain/auth.json; do
[ -f "$c" ] && { AUTH_JSON=$c; break; }
done
fi
[ -n "$AUTH_JSON" ] && [ -f "$AUTH_JSON" ] || { fail "auth.json not found (set DB_AUTH_JSON)"; exit 2; }
if [ -z "$SECRETS_DIR" ]; then
for c in "$(dirname "$AUTH_JSON")/secrets" "$HOME/.local/share/duckbrain/secrets"; do
[ -d "$c" ] && { SECRETS_DIR=$c; break; }
done
fi
sidecar_hash() {
local f=$1 s
s=$(cat -- "$f")
s=${s#"${s%%[![:space:]]*}"} # ltrim
s=${s%"${s##*[![:space:]]}"} # rtrim
printf '%s' "$s" | sha256sum | awk '{print $1}'
}
registry_tsv() {
jq -r '(.keys // .)
| (if type=="array" then .[] else (to_entries[] | .value) end)
| [(.name // .id // .key // "unknown"), (.keyHash // .hash // .sha256)]
| @tsv' "$AUTH_JSON"
}
say "== auth registry freshness =="
say "auth.json : $AUTH_JSON"
say " mtime : $(stat -c '%y' "$AUTH_JSON")"
STALE=0
if command -v systemctl >/dev/null 2>&1; then
START_TS=$(systemctl --user show "$SERVICE" -p ActiveEnterTimestamp --value 2>/dev/null || true)
if [ -n "$START_TS" ] && [ "$START_TS" != "n/a" ]; then
say "service : $SERVICE"
say " started : $START_TS"
s_epoch=$(date -d "$START_TS" +%s 2>/dev/null || echo 0)
a_epoch=$(stat -c %Y "$AUTH_JSON")
if [ "$s_epoch" -lt "$a_epoch" ]; then
STALE=1
fail "registry changed AFTER daemon start — daemon holds a STALE in-memory registry"
say " -> start $(date -d @$s_epoch '+%F %T') < auth $(date -d @$a_epoch '+%F %T')"
else
ok "daemon started at/after auth.json mtime (registry not stale)"
fi
else
ok "could not read systemd service timestamp (skipping freshness check)"
fi
fi
say
say "== sidecar keyHash verification (tokens never printed) =="
if [ -z "$SECRETS_DIR" ] || [ ! -d "$SECRETS_DIR" ]; then
say " (no secrets dir; set DB_SECRETS to verify sidecars)"
else
tmp_reg=$(mktemp); registry_tsv > "$tmp_reg"
declare -A HASH_NAME=()
while IFS=$'\t' read -r name hash; do
[ -n "${hash:-}" ] && HASH_NAME["$hash"]="$name"
done < "$tmp_reg"
rm -f "$tmp_reg"
for sc in "$SECRETS_DIR"/*; do
[ -f "$sc" ] || continue
h=$(sidecar_hash "$sc")
n=${HASH_NAME[$h]:-}
if [ -n "$n" ]; then
ok "MATCH $(basename "$sc") sha256=${h:0:12}… grant=$n"
else
fail "MISMATCH $(basename "$sc") sha256=${h:0:12}… (not in registry)"
fi
done
fi
say
say "== authenticated probe $BASE_URL$ENDPOINT =="
if [ -z "$SECRETS_DIR" ] || [ ! -d "$SECRETS_DIR" ]; then
ok "(skipped; no secrets dir)"
else
for sc in "$SECRETS_DIR"/*; do
[ -f "$sc" ] || continue
h=$(sidecar_hash "$sc")
n=${HASH_NAME[$h]:-}
[ -n "$n" ] || continue
token=$(cat -- "$sc")
code=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $token" "$BASE_URL$ENDPOINT")
if [ "$code" = "200" ]; then
ok "HTTP $code $(basename "$sc") (grant=$n)"
else
fail "HTTP $code $(basename "$sc") (grant=$n) — daemon cache likely stale"
STALE=1
fi
unset token
done
fi
say
if [ "$FIX" = "1" ] && [ "$STALE" = "1" ]; then
say "== --fix: restarting $SERVICE to reload registry =="
systemctl --user restart "$SERVICE"
systemctl --user is-active --quiet "$SERVICE" && ok "service active"
say " re-run without --fix to confirm HTTP 200"
elif [ "$STALE" = "1" ]; then
fail "stale registry detected — re-run with --fix (or: systemctl --user restart $SERVICE)"
exit 1
else
ok "no stale-registry condition detected"
fi
| Cause | Registry loaded once at daemon start; auth.json rewritten later; no reload edge. |
| Proof | Sidecar trimmed SHA-256 matches on-disk keyHash, but live daemon returns 401; daemon start time < auth.json mtime. |
| Immediate fix | systemctl --user restart duckbrain-http.service. |
| Durable fix | systemd .path unit on auth.json → reload/restart helper; optionally per-request stat check in the daemon. |
| Verification | doctor script exits 0 with MATCH + HTTP 200; canary reaches 14 PASS / 2 WARN / 0 FAIL; offline repro demonstrates 401→200 across restart. |
| Prevention | Write auth.json atomically; alert when auth.json mtime > service start time. |
No real DuckBrain checkout was present, so I built a faithful fixture (/workspace/repro/) that loads auth.json once at startup and serves /api/keys/flat, then ran two independent verifications:
HTTP 401 for a token that is on disk; a restart returns HTTP 200.MATCH, HTTP 200, exit 0.MATCH on disk but live HTTP 401, exit 1 — the exact incident signature.The --fix path runs systemctl --user restart, which needs a user D-Bus session unavailable in this sandbox; its restart semantics are identical to E1 §4, which was executed successfully. Raw output is in evidence.md.
# Evidence - Problem class: duckbrain-auth-registry-stale-daemon-cache - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-11T23:52:05.737Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "DuckBrain HTTP returned 401 Invalid API key even though the consumer resolved a token sidecar whose trimmed SHA-256 exactly matched auth.json keyHash. Root cause: duckbrain-http.service started at 07:30 local, auth.json was rewritten at 09:02, and the daemon never reloaded the new authorization registry. Restarting duckbrain-http.service loaded the registry; the same token then returned HTTP 200 from /api/keys/flat and the project smoke canary recovered from 13 PASS/2 WARN/1 FAIL to 14 PASS/2 WARN/0 FAIL.", "environment": "Linux systemd --user service, localhost:3000, keyHash-only auth registry with named plaintext sidecars", "language": "shell", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "duckbrain-auth-registry-stale-daemon-cache", "provider": "openrouter", "solved_at": "2026-09-11T23:52:05.737Z", "version": "DuckBrain HTTP current local checkout"}Problem class: duckbrain-auth-registry-stale-daemon-cache
Symptom: HTTP 401 {"error":"Unauthorized: Invalid API key"} on /api/keys/flat even though the consumer's named plaintext sidecar hashed (trimmed SHA-256) to a keyHash present in the on-disk auth.json.
duckbrain-http.service loads the authorization registry (auth.json) once at process start and keeps it in memory. The registry is not re-read, watched, or invalidated when the file changes. In the failing incident:
| Event | Time |
|---|---|
duckbrain-http.service starts, loads registry |
07:30 |
auth.json is rewritten with new/rotated grants |
09:02 |
The daemon authenticates against the 07:30 snapshot. A token whose trimmed SHA-256 matches a keyHash written at 09:02 is still rejected, because that hash does not exist in the daemon's in-memory map. This is a process-level cache invalidation bug, not a token or hash-computation bug. Restarting the service forces a reload of the 09:02 registry; the same token then returns 200.
Two properties make it look like a bad key:
keyHash matches), so the token "should" work.401 Invalid API key is indistinguishable from a genuinely wrong key, so the consumer keeps rotating/re-checking the token instead of the daemon.The trigger is any out-of-band auth.json rewrite: key rotation, grant addition, grant revocation, or restore from backup.
Confirm the two independent facts: the daemon is older than the registry, and the sidecar hash is present in the registry.
SERVICE=duckbrain-http.service
AUTH_JSON="${DB_AUTH_JSON:-$HOME/.local/share/duckbrain/auth.json}" # adjust to checkout
SECRETS="${DB_SECRETS:-$(dirname "$AUTH_JSON")/secrets}"
# 2a. Daemon start time vs registry mtime. If start < mtime -> stale cache.
systemctl --user show "$SERVICE" -p ActiveEnterTimestamp --value
stat -c '%y %n' "$AUTH_JSON"
# 2b. Does the sidecar's trimmed SHA-256 appear as a keyHash? (no token echoed)
for f in "$SECRETS"/*; do
[ -f "$f" ] || continue
s=$(cat -- "$f"); s=${s#"${s%%[![:space:]]*}"}; s=${s%"${s##*[![:space:]]}"}
h=$(printf '%s' "$s" | sha256sum | awk '{print $1}')
name=$(jq -r --arg h "$h" '(.keys // .)
| (if type=="array" then .[] else to_entries[].value end)
| select(.keyHash == $h) | .name' "$AUTH_JSON")
printf '%-24s sha256=%s grant=%s\n' "$(basename "$f")" "${h:0:12}…" "${name:-<none>}"
unset s
done
# 2c. Live probe with a sidecar known to match the registry.
token=$(cat "$SECRETS/<matching-sidecar>.token")
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $token" http://localhost:3000/api/keys/flat
unset token
Stale-cache signature: 2b prints a non-empty grant, but 2c prints 401. That combination — hash present on disk, rejected by the live daemon — is the proof.
The full ready-to-run checker is
duckbrain-auth-doctor.shin §6. Run it without--fixfirst.
systemctl --user restart duckbrain-http.service
systemctl --user is-active duckbrain-http.service # -> active
Then re-probe and re-run the project smoke canary:
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $(cat "$SECRETS/<matching-sidecar>.token")" \
http://localhost:3000/api/keys/flat
./scripts/smoke-canary.sh # substitute the project's actual canary command
Expected recovery: canary goes 13 PASS / 2 WARN / 1 FAIL → 14 PASS / 2 WARN / 0 FAIL.
A restart is only a stop-gap: the next auth.json write re-introduces the bug.
auth.json changesBecause the environment is a systemd --user service and the problem is a missing invalidation edge, wire that edge into systemd with a path unit. This needs no code change to DuckBrain and survives reboots.
Edit ~/.config/systemd/user/duckbrain-http.service (locate it with systemctl --user cat duckbrain-http.service) and add:
[Service]
# If the daemon reloads auth.json on SIGHUP, use a true reload:
ExecReload=/bin/kill -HUP $MAINPID
# Otherwise leave the line out and the reload helper below will restart instead.
~/.local/bin/duckbrain-reload-auth.sh:
#!/usr/bin/env bash
set -eu
SERVICE=duckbrain-http.service
if systemctl --user show "$SERVICE" -p CanReload --value 2>/dev/null | grep -q yes; then
systemctl --user reload "$SERVICE"
else
systemctl --user restart "$SERVICE"
fi
chmod +x ~/.local/bin/duckbrain-reload-auth.sh
~/.config/systemd/user/duckbrain-auth-reload.service:
[Unit]
Description=Reload DuckBrain auth registry after auth.json changes
[Service]
Type=oneshot
ExecStart=%h/.local/bin/duckbrain-reload-auth.sh
~/.config/systemd/user/duckbrain-auth-reload.path:
[Unit]
Description=Watch DuckBrain auth.json for changes
[Path]
PathChanged=%h/.local/share/duckbrain/auth.json
Unit=duckbrain-auth-reload.service
[Install]
WantedBy=default.target
PathChanged= fires on rename/replace, which is what an atomic registry rewrite does. Point the path at the real AUTH_JSON location (systemctl --user cat duckbrain-http.service, or the DB_AUTH_JSON value).
Enable and test:
systemctl --user daemon-reload
systemctl --user enable --now duckbrain-auth-reload.path
systemctl --user list-units 'duckbrain*'
auth.json atomicallySo watchers never observe a half-written registry:
tmp=$(mktemp "${AUTH_JSON}.XXXXXX")
# ...write new registry to "$tmp"...
jq empty "$tmp" && chmod 600 "$tmp" && mv -f "$tmp" "$AUTH_JSON"
Pick one and remove the reliance on external reloads:
st_mtime/st_ino of auth.json; if it changed, re-read and atomically swap the in-memory map before authorizing.process.on('SIGHUP', reloadRegistry) and validate the new registry before swapping.fs.watch/chokidar on the file, debounced reload.start=$(systemctl --user show duckbrain-http.service -p ActiveEnterTimestamp --value)
s=$(date -d "$start" +%s); a=$(stat -c %Y "$AUTH_JSON")
[ "$s" -ge "$a" ] && echo "OK: daemon started at/after auth.json mtime" \
|| echo "STALE: restart or reload needed"
./duckbrain-auth-doctor.sh --fix --service duckbrain-http.service \
--auth "$AUTH_JSON" --secrets "$SECRETS" --url http://localhost:3000
A healthy run prints MATCH … for every intended grant, HTTP 200 on the probe, and exits 0.
Prove the durable fix actually reloads. Rewrite auth.json without restarting duckbrain-http.service, then let the path unit fire:
T="token-$(date +%s)"; H=$(printf '%s' "$T" | sha256sum | awk '{print $1}')
printf '%s\n' "$T" > "$SECRETS/new.token"
tmp=$(mktemp); jq --arg h "$H" '.keys += [{"name":"new","keyHash":$h}]' "$AUTH_JSON" > "$tmp"
mv -f "$tmp" "$AUTH_JSON" # atomic replace triggers duckbrain-auth-reload.path
sleep 2 # debounce for the path unit
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" \
http://localhost:3000/api/keys/flat # -> 200 without a manual restart
./scripts/smoke-canary.sh
Target: 14 PASS / 2 WARN / 0 FAIL (was 13 PASS / 2 WARN / 1 FAIL).
A faithful fixture that loads auth.json once and exposes /api/keys/flat, exactly as the daemon does, demonstrates the 401 before restart and the 200 after:
bash /workspace/repro/run_repro.sh
Observed:
== 3. same token now on disk, but daemon still serves stale registry ==
new-grant token (on disk!) -> HTTP 401 {"error": "Unauthorized: Invalid API key"}
old-grant token (removed) -> HTTP 200 {"grant": "old-grant", ...}
== 4. FIX: restart the daemon so it reloads the registry ==
new-grant token (after reload) -> HTTP 200 {"grant": "new-grant", ...}
old-grant token (after reload) -> HTTP 401 {"error": "Unauthorized: Invalid API key"}
And the doctor script, pointed at the same fixture with the registry rewritten under a running daemon, reproduces the exact incident signature (MATCH on disk + live HTTP 401) and exits 1:
== sidecar keyHash verification (tokens never printed) ==
MATCH stale.token sha256=5471ec7bcff5… grant=stale
== authenticated probe http://<ip-address>:3999/api/keys/flat ==
FAIL: HTTP 401 stale.token (grant=stale) — daemon cache likely stale
FAIL: stale registry detected — re-run with --fix
Raw evidence is captured in evidence.md.
Save as duckbrain-auth-doctor.sh, chmod +x, then run it without --fix to diagnose and with --fix to restart. It never prints a token; it prints only a 12-hex-character hash prefix.
#!/usr/bin/env bash
# duckbrain-auth-doctor.sh — diagnose and fix stale DuckBrain auth registry.
#
# Usage:
# ./duckbrain-auth-doctor.sh [--fix] [--service NAME] [--url URL]
# [--auth PATH] [--secrets DIR]
set -u
SERVICE="${DB_SERVICE:-duckbrain-http.service}"
BASE_URL="${DB_URL:-http://localhost:3000}"
AUTH_JSON="${DB_AUTH_JSON:-}"
SECRETS_DIR="${DB_SECRETS:-}"
ENDPOINT="${DB_ENDPOINT:-/api/keys/flat}"
FIX=0
while [ $# -gt 0 ]; do
case "$1" in
--fix) FIX=1 ;;
--service) SERVICE=$2; shift ;;
--url) BASE_URL=$2; shift ;;
--auth) AUTH_JSON=$2; shift ;;
--secrets) SECRETS_DIR=$2; shift ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
shift
done
say() { printf '%s\n' "$*"; }
fail() { printf 'FAIL: %s\n' "$*" >&2; }
ok() { printf ' %s\n' "$*"; }
if [ -z "$AUTH_JSON" ]; then
for c in \
"$HOME/.local/share/duckbrain/auth.json" \
"$HOME/duckbrain/auth.json" \
"$HOME/duckbrain-http/auth.json" \
/opt/duckbrain/auth.json \
/srv/duckbrain/auth.json; do
[ -f "$c" ] && { AUTH_JSON=$c; break; }
done
fi
[ -n "$AUTH_JSON" ] && [ -f "$AUTH_JSON" ] || { fail "auth.json not found (set DB_AUTH_JSON)"; exit 2; }
if [ -z "$SECRETS_DIR" ]; then
for c in "$(dirname "$AUTH_JSON")/secrets" "$HOME/.local/share/duckbrain/secrets"; do
[ -d "$c" ] && { SECRETS_DIR=$c; break; }
done
fi
sidecar_hash() {
local f=$1 s
s=$(cat -- "$f")
s=${s#"${s%%[![:space:]]*}"} # ltrim
s=${s%"${s##*[![:space:]]}"} # rtrim
printf '%s' "$s" | sha256sum | awk '{print $1}'
}
registry_tsv() {
jq -r '(.keys // .)
| (if type=="array" then .[] else (to_entries[] | .value) end)
| [(.name // .id // .key // "unknown"), (.keyHash // .hash // .sha256)]
| @tsv' "$AUTH_JSON"
}
say "== auth registry freshness =="
say "auth.json : $AUTH_JSON"
say " mtime : $(stat -c '%y' "$AUTH_JSON")"
STALE=0
if command -v systemctl >/dev/null 2>&1; then
START_TS=$(systemctl --user show "$SERVICE" -p ActiveEnterTimestamp --value 2>/dev/null || true)
if [ -n "$START_TS" ] && [ "$START_TS" != "n/a" ]; then
say "service : $SERVICE"
say " started : $START_TS"
s_epoch=$(date -d "$START_TS" +%s 2>/dev/null || echo 0)
a_epoch=$(stat -c %Y "$AUTH_JSON")
if [ "$s_epoch" -lt "$a_epoch" ]; then
STALE=1
fail "registry changed AFTER daemon start — daemon holds a STALE in-memory registry"
say " -> start $(date -d @$s_epoch '+%F %T') < auth $(date -d @$a_epoch '+%F %T')"
else
ok "daemon started at/after auth.json mtime (registry not stale)"
fi
else
ok "could not read systemd service timestamp (skipping freshness check)"
fi
fi
say
say "== sidecar keyHash verification (tokens never printed) =="
if [ -z "$SECRETS_DIR" ] || [ ! -d "$SECRETS_DIR" ]; then
say " (no secrets dir; set DB_SECRETS to verify sidecars)"
else
tmp_reg=$(mktemp); registry_tsv > "$tmp_reg"
declare -A HASH_NAME=()
while IFS=$'\t' read -r name hash; do
[ -n "${hash:-}" ] && HASH_NAME["$hash"]="$name"
done < "$tmp_reg"
rm -f "$tmp_reg"
for sc in "$SECRETS_DIR"/*; do
[ -f "$sc" ] || continue
h=$(sidecar_hash "$sc")
n=${HASH_NAME[$h]:-}
if [ -n "$n" ]; then
ok "MATCH $(basename "$sc") sha256=${h:0:12}… grant=$n"
else
fail "MISMATCH $(basename "$sc") sha256=${h:0:12}… (not in registry)"
fi
done
fi
say
say "== authenticated probe $BASE_URL$ENDPOINT =="
if [ -z "$SECRETS_DIR" ] || [ ! -d "$SECRETS_DIR" ]; then
ok "(skipped; no secrets dir)"
else
for sc in "$SECRETS_DIR"/*; do
[ -f "$sc" ] || continue
h=$(sidecar_hash "$sc")
n=${HASH_NAME[$h]:-}
[ -n "$n" ] || continue
token=$(cat -- "$sc")
code=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $token" "$BASE_URL$ENDPOINT")
if [ "$code" = "200" ]; then
ok "HTTP $code $(basename "$sc") (grant=$n)"
else
fail "HTTP $code $(basename "$sc") (grant=$n) — daemon cache likely stale"
STALE=1
fi
unset token
done
fi
say
if [ "$FIX" = "1" ] && [ "$STALE" = "1" ]; then
say "== --fix: restarting $SERVICE to reload registry =="
systemctl --user restart "$SERVICE"
systemctl --user is-active --quiet "$SERVICE" && ok "service active"
say " re-run without --fix to confirm HTTP 200"
elif [ "$STALE" = "1" ]; then
fail "stale registry detected — re-run with --fix (or: systemctl --user restart $SERVICE)"
exit 1
else
ok "no stale-registry condition detected"
fi
| Cause | Registry loaded once at daemon start; auth.json rewritten later; no reload edge. |
| Proof | Sidecar trimmed SHA-256 matches on-disk keyHash, but live daemon returns 401; daemon start time < auth.json mtime. |
| Immediate fix | systemctl --user restart duckbrain-http.service. |
| Durable fix | systemd .path unit on auth.json → reload/restart helper; optionally per-request stat check in the daemon. |
| Verification | doctor script exits 0 with MATCH + HTTP 200; canary reaches 14 PASS / 2 WARN / 0 FAIL; offline repro demonstrates 401→200 across restart. |
| Prevention | Write auth.json atomically; alert when auth.json mtime > service start time. |
No real DuckBrain checkout was present, so I built a faithful fixture (/workspace/repro/) that loads auth.json once at startup and serves /api/keys/flat, then ran two independent verifications:
HTTP 401 for a token that is on disk; a restart returns HTTP 200.MATCH, HTTP 200, exit 0.MATCH on disk but live HTTP 401, exit 1 — the exact incident signature.The --fix path runs systemctl --user restart, which needs a user D-Bus session unavailable in this sandbox; its restart semantics are identical to E1 §4, which was executed successfully. Raw output is in evidence.md.
# Evidence - Problem class: duckbrain-auth-registry-stale-daemon-cache - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-11T23:52:05.737Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "DuckBrain HTTP returned 401 Invalid API key even though the consumer resolved a token sidecar whose trimmed SHA-256 exactly matched auth.json keyHash. Root cause: duckbrain-http.service started at 07:30 local, auth.json was rewritten at 09:02, and the daemon never reloaded the new authorization registry. Restarting duckbrain-http.service loaded the registry; the same token then returned HTTP 200 from /api/keys/flat and the project smoke canary recovered from 13 PASS/2 WARN/1 FAIL to 14 PASS/2 WARN/0 FAIL.", "environment": "Linux systemd --user service, localhost:3000, keyHash-only auth registry with named plaintext sidecars", "language": "shell", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "duckbrain-auth-registry-stale-daemon-cache", "provider": "openrouter", "solved_at": "2026-09-11T23:52:05.737Z", "version": "DuckBrain HTTP current local checkout"}