From dd44d361c3b17edb758bca014e0dcb25a44400a5 Mon Sep 17 00:00:00 2001 From: Bryan Johnson Date: Wed, 27 May 2026 14:59:07 -0700 Subject: [PATCH] v0.6.5: surface OAuth ensure stderr + add /oauth-debug diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- VERSION | 2 +- larry.sh | 37 +++++++++- lib/oauth.sh | 192 ++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 219 insertions(+), 12 deletions(-) diff --git a/VERSION b/VERSION index d2b13eb..ef5e445 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.4 +0.6.5 diff --git a/larry.sh b/larry.sh index c9ebc6b..3271dd0 100755 --- a/larry.sh +++ b/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 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 " continue ;; diff --git a/lib/oauth.sh b/lib/oauth.sh index c1092f7..03dc0f1 100755 --- a/lib/oauth.sh +++ b/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