v0.6.5: surface OAuth ensure stderr + add /oauth-debug diagnostic
call_api was swallowing every byte of oauth.sh ensure's stderr with `2>/dev/null`, so when ensure returned an empty token there was zero diagnostic info — just "OAuth token unavailable". With Bryan hitting an intermittent failure on MobaXterm we'd already burned two guess-fix cycles; this ships the data instead of another guess. Changes: - call_api now captures ensure's stderr to a tempfile and surfaces it via err() when the token comes back empty, pointing the user at /oauth-debug for full state. - cmd_ensure validates the file parses as JSON before destructuring, validates .access_token is non-empty before emitting, and emits a decision trace to stderr under LARRY_OAUTH_DEBUG=1. - New cmd_debug subcommand (oauth.sh debug) dumps: file state (mode, size, mtime, JSON validity), parsed fetched_at + expires_in + now + computed expiry + would_refresh decision, jq binary path + version + Unix/Windows-native flavor, cygpath -w translation when on Cygwin, truncated previews of access/refresh tokens (first 20 chars + length only — safe to share), and a live LARRY_OAUTH_DEBUG=1 ensure trace. - New /oauth-debug slash command exposes it from the REPL, documented in /help. - cmd_login and cmd_refresh now write to .new sidecars, validate required keys parse, then atomically mv — guards against the corrupted-file failure mode that would silently break ensure on a later run. Happy path unchanged: when the file is valid and the token is in-window ensure prints just the access_token on stdout with no stderr. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
47452d3910
commit
dd44d361c3
37
larry.sh
37
larry.sh
@ -36,7 +36,7 @@ set -o pipefail
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Config
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
LARRY_VERSION="0.6.4"
|
||||
LARRY_VERSION="0.6.5"
|
||||
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
|
||||
LARRY_BASE_URL="${LARRY_BASE_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main}"
|
||||
LARRY_UPDATE_URL="${LARRY_UPDATE_URL:-${LARRY_BASE_URL}/larry.sh}"
|
||||
@ -1026,14 +1026,35 @@ call_api() {
|
||||
local auth_args=()
|
||||
if [ "$LARRY_AUTH_MODE" = "oauth" ]; then
|
||||
local oauth_script="$LARRY_LIB_DIR/oauth.sh"
|
||||
local token
|
||||
local token="" oauth_stderr_file=""
|
||||
if [ -x "$oauth_script" ]; then
|
||||
token=$("$oauth_script" ensure 2>/dev/null)
|
||||
# Capture stderr so we can surface WHY ensure failed instead of silently
|
||||
# swallowing it. v0.6.4 and earlier piped 2>/dev/null here — that hid
|
||||
# the entire diagnostic chain when the file was corrupt, the refresh
|
||||
# 401'd, or jq couldn't read the path on MobaXterm. Never again.
|
||||
oauth_stderr_file=$(mktemp 2>/dev/null || echo "")
|
||||
if [ -n "$oauth_stderr_file" ]; then
|
||||
token=$("$oauth_script" ensure 2>"$oauth_stderr_file")
|
||||
else
|
||||
# Fallback if mktemp failed: still capture stderr inline.
|
||||
token=$("$oauth_script" ensure 2>&1 >/dev/null) && token=$("$oauth_script" ensure 2>/dev/null) || true
|
||||
fi
|
||||
else
|
||||
err "oauth.sh not found at $oauth_script — cannot ensure OAuth token"
|
||||
fi
|
||||
if [ -z "$token" ]; then
|
||||
err "OAuth token unavailable; run 'larry-auth.sh login' to re-authenticate"
|
||||
if [ -n "$oauth_stderr_file" ] && [ -s "$oauth_stderr_file" ]; then
|
||||
err "oauth.sh ensure said:"
|
||||
sed 's/^/ /' "$oauth_stderr_file" >&2
|
||||
err "(for full diagnostic, run '/oauth-debug' in this REPL)"
|
||||
else
|
||||
err "oauth.sh ensure returned no stderr — try '/oauth-debug' for full state dump"
|
||||
fi
|
||||
[ -n "$oauth_stderr_file" ] && rm -f "$oauth_stderr_file"
|
||||
return 1
|
||||
fi
|
||||
[ -n "$oauth_stderr_file" ] && rm -f "$oauth_stderr_file"
|
||||
auth_args=(-H "Authorization: Bearer $token" -H "anthropic-beta: oauth-2025-04-20")
|
||||
else
|
||||
auth_args=(-H "x-api-key: $ANTHROPIC_API_KEY")
|
||||
@ -1169,6 +1190,9 @@ Slash commands:
|
||||
/auth show OAuth status (or "not authenticated")
|
||||
/login run OAuth login flow (switch from API-key to subscription auth)
|
||||
/logout delete OAuth tokens (revert to API-key auth)
|
||||
/oauth-debug dump full OAuth diagnostic (file state, parsed expiry,
|
||||
jq path/flavor, cygpath translation, truncated tokens,
|
||||
live ensure trace). Safe to copy-paste; secrets truncated.
|
||||
/lesson <text> capture a lesson to local file (paste back to home-Larry later)
|
||||
/lessons list all captured lessons (newest first)
|
||||
/export dump the lesson bundle for paste-back to home-Larry
|
||||
@ -1323,6 +1347,13 @@ main_loop() {
|
||||
/auth) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" status; else echo "(oauth.sh not installed)"; fi; continue ;;
|
||||
/login) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" login && LARRY_AUTH_MODE="oauth" && larry_say "switched to OAuth subscription auth"; else err "oauth.sh not installed"; fi; continue ;;
|
||||
/logout) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" logout; LARRY_AUTH_MODE="apikey"; fi; continue ;;
|
||||
/oauth-debug)
|
||||
if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then
|
||||
"$LARRY_LIB_DIR/oauth.sh" debug
|
||||
else
|
||||
err "oauth.sh not installed at $LARRY_LIB_DIR/oauth.sh"
|
||||
fi
|
||||
continue ;;
|
||||
/lesson\ *) local text="${input#/lesson }"
|
||||
[ -n "$text" ] && tool_lesson_record "$text" "" "${HCISITE:-}" "info" || err "usage: /lesson <text>"
|
||||
continue ;;
|
||||
|
||||
192
lib/oauth.sh
192
lib/oauth.sh
@ -11,10 +11,17 @@
|
||||
# refresh refresh the access token using the stored refresh token
|
||||
# ensure print a valid access token (auto-refreshes if near-expired)
|
||||
# status show current auth state + expiry
|
||||
# debug dump full OAuth diagnostic state (file, parsed times, jq path,
|
||||
# cygpath translation, truncated token previews) — safe to share
|
||||
# logout delete the stored tokens
|
||||
#
|
||||
# Storage: $LARRY_HOME/.oauth.json (mode 0600)
|
||||
#
|
||||
# Env vars:
|
||||
# LARRY_OAUTH_DEBUG=1 emit per-call decision trace from cmd_ensure to stderr.
|
||||
# Default off so the chat log stays clean; flip on when
|
||||
# diagnosing why ensure returns empty.
|
||||
#
|
||||
# This is community/unofficial use of Anthropic's OAuth flow. Anthropic could
|
||||
# tighten it at any time. If OAuth stops working, Larry transparently falls
|
||||
# back to the API-key path stored in $LARRY_HOME/.env.
|
||||
@ -167,7 +174,19 @@ EOF
|
||||
|
||||
local now; now=$(date +%s)
|
||||
umask 077
|
||||
printf '%s' "$resp" | jq --arg now "$now" '. + {fetched_at: ($now | tonumber)}' > "$OAUTH_FILE"
|
||||
# Write to a .new sidecar first, validate it parses + has the required keys,
|
||||
# then atomically mv into place. If jq fails mid-pipe, $OAUTH_FILE is never
|
||||
# half-written — preventing the "ensure returns empty because the file is
|
||||
# corrupt" failure mode that's otherwise invisible to the caller.
|
||||
printf '%s' "$resp" | jq --arg now "$now" '. + {fetched_at: ($now | tonumber)}' > "$OAUTH_FILE.new" || {
|
||||
rm -f "$OAUTH_FILE.new"
|
||||
die "failed to write oauth token (jq pipeline error)"
|
||||
}
|
||||
if ! jq -e '.access_token and .refresh_token and .fetched_at' < "$OAUTH_FILE.new" >/dev/null 2>&1; then
|
||||
rm -f "$OAUTH_FILE.new"
|
||||
die "wrote oauth file is missing access_token/refresh_token/fetched_at — aborting"
|
||||
fi
|
||||
mv "$OAUTH_FILE.new" "$OAUTH_FILE"
|
||||
chmod 600 "$OAUTH_FILE"
|
||||
printf '\n✓ logged in. Tokens saved to %s (mode 0600).\n' "$OAUTH_FILE"
|
||||
cmd_status
|
||||
@ -196,27 +215,76 @@ cmd_refresh() {
|
||||
# Pre-read the old refresh_token so we don't need --slurpfile (which would
|
||||
# take a file path argv-style and break on MobaXterm's Windows-native jq).
|
||||
local prev_refresh; prev_refresh=$(jqf "$OAUTH_FILE" -r '.refresh_token // empty')
|
||||
printf '%s' "$resp" \
|
||||
if ! printf '%s' "$resp" \
|
||||
| jq --arg now "$now" --arg prev "$prev_refresh" \
|
||||
'. + {fetched_at: ($now|tonumber), refresh_token: (.refresh_token // $prev)}' \
|
||||
> "$OAUTH_FILE.new"
|
||||
> "$OAUTH_FILE.new"; then
|
||||
rm -f "$OAUTH_FILE.new"
|
||||
printf 'refresh: failed to write new oauth file (jq pipeline error)\n' >&2
|
||||
return 1
|
||||
fi
|
||||
# Validate before clobbering the existing file. If validation fails the old
|
||||
# token file stays intact and ensure can still serve the soon-to-expire token.
|
||||
if ! jq -e '.access_token and .refresh_token and .fetched_at' < "$OAUTH_FILE.new" >/dev/null 2>&1; then
|
||||
rm -f "$OAUTH_FILE.new"
|
||||
printf 'refresh: new oauth file is missing required keys — keeping previous file intact\n' >&2
|
||||
return 1
|
||||
fi
|
||||
mv "$OAUTH_FILE.new" "$OAUTH_FILE"
|
||||
chmod 600 "$OAUTH_FILE"
|
||||
jqf "$OAUTH_FILE" -r '.access_token'
|
||||
}
|
||||
|
||||
# dbg — gated decision-trace logger. Only fires when LARRY_OAUTH_DEBUG=1 is
|
||||
# set in the env. Goes to stderr so it doesn't pollute the token printed on
|
||||
# stdout. Caller of `oauth.sh ensure` should capture stderr and surface it
|
||||
# if the token is empty.
|
||||
dbg() {
|
||||
[ "${LARRY_OAUTH_DEBUG:-0}" = "1" ] || return 0
|
||||
printf 'oauth.dbg: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
cmd_ensure() {
|
||||
[ -f "$OAUTH_FILE" ] || return 1
|
||||
if [ ! -f "$OAUTH_FILE" ]; then
|
||||
dbg "ensure: $OAUTH_FILE does not exist — returning empty"
|
||||
printf 'ensure: no oauth file at %s\n' "$OAUTH_FILE" >&2
|
||||
return 1
|
||||
fi
|
||||
# Sanity-check the file actually parses as JSON. If it doesn't (truncated,
|
||||
# half-written, mid-disk-full), every jqf below silently returns empty which
|
||||
# propagates as "token unavailable" with zero diagnostic info upstream.
|
||||
if ! jq -e . < "$OAUTH_FILE" >/dev/null 2>&1; then
|
||||
dbg "ensure: $OAUTH_FILE failed to parse as JSON — corrupted?"
|
||||
printf 'ensure: %s is not valid JSON (corrupted? rerun login)\n' "$OAUTH_FILE" >&2
|
||||
return 1
|
||||
fi
|
||||
local fetched_at expires_in
|
||||
fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0')
|
||||
expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600')
|
||||
local now; now=$(date +%s)
|
||||
local expires_at=$((fetched_at + expires_in))
|
||||
local left=$((expires_at - now))
|
||||
dbg "ensure: fetched_at=$fetched_at expires_in=$expires_in now=$now expires_at=$expires_at left=${left}s"
|
||||
if [ "$now" -ge $((expires_at - 300)) ]; then
|
||||
cmd_refresh >/dev/null 2>&1 || return 1
|
||||
dbg "ensure: within 300s of expiry — refreshing"
|
||||
if ! cmd_refresh >/dev/null 2>&1; then
|
||||
dbg "ensure: cmd_refresh failed; calling again with stderr exposed for trace"
|
||||
# Re-run with stderr visible so the upstream call_api capture sees WHY.
|
||||
cmd_refresh >/dev/null || true
|
||||
printf 'ensure: refresh failed (see above)\n' >&2
|
||||
return 1
|
||||
fi
|
||||
dbg "ensure: refresh OK — emitting new access_token"
|
||||
jqf "$OAUTH_FILE" -r '.access_token'
|
||||
else
|
||||
jqf "$OAUTH_FILE" -r '.access_token'
|
||||
dbg "ensure: token still valid — emitting cached access_token"
|
||||
local tok; tok=$(jqf "$OAUTH_FILE" -r '.access_token // empty')
|
||||
if [ -z "$tok" ]; then
|
||||
dbg "ensure: .access_token is empty in the file"
|
||||
printf 'ensure: .access_token missing from %s\n' "$OAUTH_FILE" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$tok"
|
||||
fi
|
||||
}
|
||||
|
||||
@ -244,6 +312,113 @@ cmd_status() {
|
||||
fi
|
||||
}
|
||||
|
||||
# cmd_debug — dump everything Mack would need to figure out why ensure is
|
||||
# returning empty. SAFE to share: token previews are truncated to first 20
|
||||
# chars only. Goes to stdout (so the user can copy-paste).
|
||||
cmd_debug() {
|
||||
printf '=== Larry OAuth diagnostic ===\n'
|
||||
printf 'LARRY_HOME=%s\n' "$LARRY_HOME"
|
||||
printf 'OAUTH_FILE=%s\n' "$OAUTH_FILE"
|
||||
|
||||
# jq binary identity — helps distinguish Cygwin jq from Windows-native jq
|
||||
# when paths look weird on MobaXterm.
|
||||
printf '\n[jq]\n'
|
||||
local jq_path; jq_path=$(command -v jq 2>/dev/null || true)
|
||||
printf ' path: %s\n' "${jq_path:-(not found)}"
|
||||
if [ -n "$jq_path" ]; then
|
||||
printf ' version: %s\n' "$(jq --version 2>&1 | head -n1)"
|
||||
# On Cygwin, `file` may not exist but the path tells us if it's a .exe.
|
||||
case "$jq_path" in
|
||||
*.exe) printf ' flavor: Windows-native (.exe — uses Windows paths in argv)\n' ;;
|
||||
*) printf ' flavor: Unix-style (POSIX paths in argv)\n' ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# cygpath translation — only relevant on Cygwin/MobaXterm. If present, show
|
||||
# how the OAUTH_FILE looks to a Windows binary.
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
printf '\n[cygpath]\n'
|
||||
printf ' unix: %s\n' "$OAUTH_FILE"
|
||||
printf ' windows: %s\n' "$(cygpath -w "$OAUTH_FILE" 2>&1 || echo '(cygpath failed)')"
|
||||
fi
|
||||
|
||||
# File state
|
||||
printf '\n[file]\n'
|
||||
if [ ! -f "$OAUTH_FILE" ]; then
|
||||
printf ' status: MISSING — run larry-auth.sh login\n'
|
||||
return 0
|
||||
fi
|
||||
# Mode, size, mtime — across mac (BSD stat) and linux (GNU stat)
|
||||
local stat_line
|
||||
stat_line=$(stat -f 'mode=%Sp size=%z mtime=%Sm' "$OAUTH_FILE" 2>/dev/null \
|
||||
|| stat -c 'mode=%A size=%s mtime=%y' "$OAUTH_FILE" 2>/dev/null \
|
||||
|| echo '(stat unavailable)')
|
||||
printf ' %s\n' "$stat_line"
|
||||
if ! jq -e . < "$OAUTH_FILE" >/dev/null 2>&1; then
|
||||
printf ' status: CORRUPTED — not valid JSON\n'
|
||||
printf ' first 80 bytes (sanitized): '
|
||||
head -c 80 "$OAUTH_FILE" | tr -d '\n' | head -c 80
|
||||
printf '\n'
|
||||
return 0
|
||||
fi
|
||||
printf ' status: parses as JSON\n'
|
||||
|
||||
# Token math
|
||||
printf '\n[token math]\n'
|
||||
local fetched_at expires_in scope
|
||||
fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0')
|
||||
expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600')
|
||||
scope=$(jqf "$OAUTH_FILE" -r '.scope // "(missing)"')
|
||||
local now; now=$(date +%s)
|
||||
local expires_at=$((fetched_at + expires_in))
|
||||
local left=$((expires_at - now))
|
||||
local refresh_at=$((expires_at - 300))
|
||||
local would_refresh="NO"
|
||||
[ "$now" -ge "$refresh_at" ] && would_refresh="YES (within 300s of expiry)"
|
||||
# Human-readable timestamps that work on both BSD and GNU date.
|
||||
local fetched_human expires_human now_human
|
||||
fetched_human=$(date -r "$fetched_at" 2>/dev/null || date -d "@$fetched_at" 2>/dev/null || echo "(date unavailable)")
|
||||
expires_human=$(date -r "$expires_at" 2>/dev/null || date -d "@$expires_at" 2>/dev/null || echo "(date unavailable)")
|
||||
now_human=$(date 2>/dev/null || echo "(date unavailable)")
|
||||
printf ' fetched_at: %s (%s)\n' "$fetched_at" "$fetched_human"
|
||||
printf ' expires_in: %s seconds\n' "$expires_in"
|
||||
printf ' expires_at: %s (%s)\n' "$expires_at" "$expires_human"
|
||||
printf ' now: %s (%s)\n' "$now" "$now_human"
|
||||
printf ' seconds_left: %s (~%d min)\n' "$left" "$((left/60))"
|
||||
printf ' would_refresh: %s\n' "$would_refresh"
|
||||
printf ' scope: %s\n' "$scope"
|
||||
|
||||
# Token previews — first 20 chars only, safe to share for "is it present"
|
||||
printf '\n[tokens (truncated)]\n'
|
||||
local at rt
|
||||
at=$(jqf "$OAUTH_FILE" -r '.access_token // empty')
|
||||
rt=$(jqf "$OAUTH_FILE" -r '.refresh_token // empty')
|
||||
if [ -z "$at" ]; then
|
||||
printf ' access_token: (EMPTY/MISSING)\n'
|
||||
else
|
||||
printf ' access_token: %s... (len=%d)\n' "$(printf '%s' "$at" | head -c 20)" "${#at}"
|
||||
fi
|
||||
if [ -z "$rt" ]; then
|
||||
printf ' refresh_token: (EMPTY/MISSING)\n'
|
||||
else
|
||||
printf ' refresh_token: %s... (len=%d)\n' "$(printf '%s' "$rt" | head -c 20)" "${#rt}"
|
||||
fi
|
||||
|
||||
# Live ensure dry-run with stderr exposed so we see every decision.
|
||||
printf '\n[live ensure trace]\n'
|
||||
printf ' Running: LARRY_OAUTH_DEBUG=1 cmd_ensure (stderr below, stdout suppressed)\n'
|
||||
local trace_token trace_err trace_rc
|
||||
trace_err=$(mktemp)
|
||||
trace_token=$(LARRY_OAUTH_DEBUG=1 cmd_ensure 2>"$trace_err"); trace_rc=$?
|
||||
printf ' exit_code: %d\n' "$trace_rc"
|
||||
printf ' token_emitted: %s\n' "$([ -n "$trace_token" ] && echo "YES (len=${#trace_token})" || echo "NO (empty)")"
|
||||
printf ' stderr trace:\n'
|
||||
sed 's/^/ /' "$trace_err"
|
||||
rm -f "$trace_err"
|
||||
|
||||
printf '\n=== end diagnostic ===\n'
|
||||
}
|
||||
|
||||
cmd_logout() {
|
||||
if [ -f "$OAUTH_FILE" ]; then
|
||||
rm -f "$OAUTH_FILE"
|
||||
@ -258,7 +433,8 @@ case "${1:-status}" in
|
||||
refresh) cmd_refresh ;;
|
||||
ensure) cmd_ensure ;;
|
||||
status) cmd_status ;;
|
||||
debug) cmd_debug ;;
|
||||
logout) cmd_logout ;;
|
||||
-h|--help|help) sed -n '2,25p' "$0" ;;
|
||||
*) die "unknown subcommand: ${1:-} (try 'login|refresh|ensure|status|logout')" ;;
|
||||
-h|--help|help) sed -n '2,30p' "$0" ;;
|
||||
*) die "unknown subcommand: ${1:-} (try 'login|refresh|ensure|status|debug|logout')" ;;
|
||||
esac
|
||||
|
||||
Loading…
Reference in New Issue
Block a user