cloverleaf-larry/lib/oauth.sh
Bryan Johnson af3f034337 v0.5.4: pipe files to jq via stdin (MobaXterm Windows-jq path-translation fix)
Symptom: OAuth login succeeded on the work box but cmd_status emitted three
'jq: error: Could not open file' lines and showed empty fields. Same pattern
would have hit every subsequent chat turn via larry.sh's MESSAGES_FILE reads.

Root cause: install-larry.sh fetches a Windows-native jq.exe on cygwin/
mobaxterm platforms. Windows jq can't resolve Cygwin paths like
/home/mobaxterm/.larry/.oauth.json when they come in as argv arguments
(it interprets the leading slash as a Windows root). Bash's `>` redirection
worked because bash itself does the path open and hands jq an fd — the
read-side calls were passing the path string directly.

Fix: every read-side jq call now uses stdin redirection (`jq '...' < file`),
where bash does the open. Universal:
- Linux/macOS native jq: identical behavior (was already file-open-from-bash)
- MobaXterm/Cygwin/Git Bash with Windows jq.exe: now works
- WSL: works (Linux-native jq, same as Linux)
- Native PowerShell/cmd: doesn't apply — larry-anywhere is a bash script

Changes:
- lib/oauth.sh: new jqf() helper; 10 sites converted. Refactored cmd_refresh
  to drop --slurpfile (which can only take a path) — pre-reads the previous
  refresh_token, then uses --arg.
- larry.sh: add_user_text / add_assistant_blocks / add_user_tool_results
  now pipe $MESSAGES_FILE via stdin too.

Verified: cmd_status against a real token file produces clean output, no
jq errors. Syntax check passes both files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:47:06 -07:00

265 lines
9.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# oauth.sh — OAuth login flow against Claude.ai for Larry-Anywhere.
#
# Uses the same OAuth client/flow Anthropic's Claude Code CLI uses, so calls
# bill against your Claude Max / Pro subscription quota instead of pay-as-you-go
# API metering. Public client_id; PKCE; out-of-band code paste (no localhost
# server required, works behind any firewall).
#
# Subcommands:
# login start the auth flow; print URL; prompt for code
# 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
# logout delete the stored tokens
#
# Storage: $LARRY_HOME/.oauth.json (mode 0600)
#
# 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.
set -u
set -o pipefail
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
OAUTH_FILE="$LARRY_HOME/.oauth.json"
# Anthropic Claude Code's publicly-visible OAuth client_id. Used by claude-code
# and several community CLI tools (droidrun/mobilerun, motiful/cc-gateway, ...).
#
# Endpoints migrated 2025: claude.ai/oauth/authorize → claude.com/cai/oauth/authorize,
# console.anthropic.com/v1/oauth/token → platform.claude.com/v1/oauth/token,
# console.anthropic.com/oauth/code/callback → platform.claude.com/oauth/code/callback.
# The OLD endpoints return a misleading "rate_limit_error" for any request.
# Scopes also expanded with user:sessions:claude_code, user:mcp_servers,
# user:file_upload — required by the new flow.
CLIENT_ID="${LARRY_OAUTH_CLIENT_ID:-9d1c250a-e61b-44d9-88ed-5944d1962f5e}"
AUTHORIZE_URL="${LARRY_OAUTH_AUTHORIZE_URL:-https://claude.com/cai/oauth/authorize}"
TOKEN_URL="${LARRY_OAUTH_TOKEN_URL:-https://platform.claude.com/v1/oauth/token}"
REDIRECT_URI="${LARRY_OAUTH_REDIRECT_URI:-https://platform.claude.com/oauth/code/callback}"
SCOPE="${LARRY_OAUTH_SCOPE:-org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload}"
die() { printf 'oauth: %s\n' "$*" >&2; exit 1; }
# Dependency check
command -v curl >/dev/null 2>&1 || die "curl required"
command -v jq >/dev/null 2>&1 || die "jq required"
command -v openssl >/dev/null 2>&1 || die "openssl required (for PKCE sha256)"
b64url() { base64 | tr '/+' '_-' | tr -d '=' | tr -d '\n'; }
# jqf — run jq against a file, but pipe the file via stdin so bash handles
# the path translation. Needed because on MobaXterm/Cygwin the bundled jq
# may be a Windows-native binary that doesn't understand Cygwin paths like
# /home/mobaxterm/... when they come in as argv. Stdin redirection always
# works because bash does the open() itself.
# Usage: jqf <file> <jq-args...>
jqf() {
local file="$1"; shift
jq "$@" < "$file"
}
urlenc() {
# Minimal RFC3986-ish URL encoder for the bits we need (spaces, /, :)
local s="$1"
s="${s// /%20}"
s="${s//:/%3A}"
s="${s//\//%2F}"
printf '%s' "$s"
}
gen_pkce() {
local verifier challenge
verifier=$(LC_ALL=C tr -dc 'a-zA-Z0-9-._~' </dev/urandom | head -c 64)
challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | b64url)
printf '%s|%s' "$verifier" "$challenge"
}
cmd_login() {
mkdir -p "$LARRY_HOME"
local pkce verifier challenge state
pkce=$(gen_pkce)
verifier="${pkce%%|*}"
challenge="${pkce##*|}"
state=$(LC_ALL=C tr -dc 'a-zA-Z0-9' </dev/urandom | head -c 32)
local url
url="${AUTHORIZE_URL}?code=true"
url="${url}&client_id=${CLIENT_ID}"
url="${url}&response_type=code"
url="${url}&redirect_uri=$(urlenc "$REDIRECT_URI")"
url="${url}&scope=$(urlenc "$SCOPE")"
url="${url}&code_challenge=${challenge}"
url="${url}&code_challenge_method=S256"
url="${url}&state=${state}"
cat <<EOF
=== Larry-Anywhere — Claude subscription login ===
This binds Larry to your Claude.ai Max/Pro subscription quota (same flow
Claude Code uses). No API key needed.
1. Open this URL in any browser on any device:
${url}
2. Sign in with your Claude account.
3. Approve the app. You'll land on a page that displays a string in the form
<CODE>#<STATE>
(Anthropic uses a URL fragment, not a query param, to deliver them.)
Copy the WHOLE string — both halves and the '#' between them.
4. Paste it here:
EOF
printf 'authorization code (CODE#STATE): '
read -r code_input
[ -z "$code_input" ] && die "no code entered"
# Split CODE#STATE. If the user pasted only the code (no '#'), keep the
# state we generated; otherwise verify the returned state matches.
local code returned_state
if [[ "$code_input" == *"#"* ]]; then
code="${code_input%%#*}"
returned_state="${code_input#*#}"
if [ -n "$returned_state" ] && [ "$returned_state" != "$state" ]; then
die "state mismatch — got '$returned_state', expected '$state' (possible CSRF or stale URL; rerun login)"
fi
else
code="$code_input"
fi
local resp
resp=$(curl -sS -X POST "$TOKEN_URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "anthropic-beta: oauth-2025-04-20" \
-H "User-Agent: claude-cli/2.1.85 (larry-anywhere)" \
-d "$(jq -n \
--arg cid "$CLIENT_ID" \
--arg code "$code" \
--arg verifier "$verifier" \
--arg redirect "$REDIRECT_URI" \
--arg state "$state" \
'{client_id:$cid, grant_type:"authorization_code", code:$code, code_verifier:$verifier, redirect_uri:$redirect, state:$state}')")
if ! printf '%s' "$resp" | jq -e '.access_token' >/dev/null 2>&1; then
printf '\nauth failed. server response:\n' >&2
printf '%s\n' "$resp" | jq . >&2 2>/dev/null || printf '%s\n' "$resp" >&2
cat >&2 <<EOF
Hints:
- The callback delivers the code as CODE#STATE (fragment, not query).
Paste the WHOLE string including '#'. Just CODE alone also works.
- The code is single-use; if you used it already (even on a failed attempt),
run 'larry-auth.sh login' again to get a fresh URL.
- 'rate_limit_error' on a fresh code is the server's misleading mask for
'malformed/used code' OR 'dead endpoint'. If you JUST upgraded and saw
that error, double-check TOKEN_URL points at platform.claude.com — old
console.anthropic.com URLs return rate_limit_error for everything.
Current (as of 2026-05): https://platform.claude.com/v1/oauth/token
- If OAuth is genuinely broken, fall back to the API key by deleting any
oauth file and creating $LARRY_HOME/.env with ANTHROPIC_API_KEY=sk-ant-...
EOF
exit 1
fi
local now; now=$(date +%s)
umask 077
printf '%s' "$resp" | jq --arg now "$now" '. + {fetched_at: ($now | tonumber)}' > "$OAUTH_FILE"
chmod 600 "$OAUTH_FILE"
printf '\n✓ logged in. Tokens saved to %s (mode 0600).\n' "$OAUTH_FILE"
cmd_status
}
cmd_refresh() {
[ -f "$OAUTH_FILE" ] || die "no oauth file at $OAUTH_FILE — run 'larry-auth.sh login' first"
local refresh_token; refresh_token=$(jqf "$OAUTH_FILE" -r '.refresh_token // empty')
[ -n "$refresh_token" ] || die "no refresh_token in $OAUTH_FILE — please run login again"
local resp
resp=$(curl -sS -X POST "$TOKEN_URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "anthropic-beta: oauth-2025-04-20" \
-H "User-Agent: claude-cli/2.1.85 (larry-anywhere)" \
-d "$(jq -n --arg cid "$CLIENT_ID" --arg rt "$refresh_token" \
'{client_id:$cid, grant_type:"refresh_token", refresh_token:$rt}')")
if ! printf '%s' "$resp" | jq -e '.access_token' >/dev/null 2>&1; then
printf 'refresh failed:\n%s\n' "$resp" >&2
return 1
fi
local now; now=$(date +%s)
# 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" \
| jq --arg now "$now" --arg prev "$prev_refresh" \
'. + {fetched_at: ($now|tonumber), refresh_token: (.refresh_token // $prev)}' \
> "$OAUTH_FILE.new"
mv "$OAUTH_FILE.new" "$OAUTH_FILE"
chmod 600 "$OAUTH_FILE"
jqf "$OAUTH_FILE" -r '.access_token'
}
cmd_ensure() {
[ -f "$OAUTH_FILE" ] || return 1
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))
if [ "$now" -ge $((expires_at - 300)) ]; then
cmd_refresh >/dev/null 2>&1 || return 1
jqf "$OAUTH_FILE" -r '.access_token'
else
jqf "$OAUTH_FILE" -r '.access_token'
fi
}
cmd_status() {
if [ ! -f "$OAUTH_FILE" ]; then
echo "OAuth: not authenticated (no $OAUTH_FILE)"
return 1
fi
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 // "(unknown)"')
local now; now=$(date +%s)
local expires_at=$((fetched_at + expires_in))
local left=$((expires_at - now))
printf 'OAuth status:\n'
printf ' file: %s\n' "$OAUTH_FILE"
printf ' scope: %s\n' "$scope"
printf ' fetched_at: %s\n' "$(date -r "$fetched_at" 2>/dev/null || date -d "@$fetched_at" 2>/dev/null)"
printf ' expires_in: %d s\n' "$expires_in"
if [ "$left" -le 0 ]; then
printf ' state: EXPIRED (%ds ago) — will auto-refresh on next call\n' "$((-left))"
else
printf ' state: valid for %d more seconds (~%d min)\n' "$left" "$((left/60))"
fi
}
cmd_logout() {
if [ -f "$OAUTH_FILE" ]; then
rm -f "$OAUTH_FILE"
echo "logged out (removed $OAUTH_FILE)"
else
echo "no token file to remove"
fi
}
case "${1:-status}" in
login) cmd_login ;;
refresh) cmd_refresh ;;
ensure) cmd_ensure ;;
status) cmd_status ;;
logout) cmd_logout ;;
-h|--help|help) sed -n '2,25p' "$0" ;;
*) die "unknown subcommand: ${1:-} (try 'login|refresh|ensure|status|logout')" ;;
esac