From e472ee7eff37aed9075008dbf749d111257f2e17 Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:53:31 -0400 Subject: [PATCH 1/4] fix(oauth): sync full token-bridge with OAuth2 refresh strategy to .opencode and contrib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anthropic-token-bridge.js in both .opencode/plugins and contrib/anthropic-max-bridge/plugins was an older stripped-down version (504 lines) missing critical functionality present in the working jeremy-opencode implementation. Missing features now restored: - OAuth2 refresh_token strategy (calls console.anthropic.com/v1/oauth/token directly — silent refresh, no browser, no claude CLI dependency) - config() startup hook for early token check on plugin load - startKeepAlive() — 30-minute pings to prevent token expiry - REFRESH_THRESHOLD_MS corrected from 2h to 1h (proactive refresh) Also updates contrib/anthropic-max-bridge/README.md: - Explains the 3 auto-refresh strategies in order - Adds Day 2+ usage instructions (run claude first, then refresh-token.sh) - Rewrites Troubleshooting section with step-by-step fixes and debug log tip Root cause of Weston/Ben failures: without the OAuth2 refresh_token strategy, auto-refresh fell through to claude setup-token which failed if Claude Code CLI was not authenticated or not in PATH. --- .opencode/plugins/anthropic-token-bridge.js | 106 +++-- contrib/anthropic-max-bridge/README.md | 58 ++- .../plugins/anthropic-token-bridge.js | 417 +++++++++++++++--- 3 files changed, 449 insertions(+), 132 deletions(-) diff --git a/.opencode/plugins/anthropic-token-bridge.js b/.opencode/plugins/anthropic-token-bridge.js index 70ec703f..da717b6f 100644 --- a/.opencode/plugins/anthropic-token-bridge.js +++ b/.opencode/plugins/anthropic-token-bridge.js @@ -1,3 +1,6 @@ +import { createRequire } from "node:module"; +var __require = /* @__PURE__ */ createRequire(import.meta.url); + // .opencode/plugins/lib/file-logger.ts import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; @@ -55,18 +58,12 @@ function readAuthFile() { } } function writeAuthFile(authData) { - const tmpFile = `${AUTH_FILE}.tmp.${process.pid}.${Date.now()}`; try { - const content = JSON.stringify(authData, null, 2) + ` -`; - fs.writeFileSync(tmpFile, content, { mode: 384 }); - fs.renameSync(tmpFile, AUTH_FILE); + fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2) + ` +`, { mode: 384 }); return true; } catch (err) { error("Failed to write auth.json", { error: String(err) }); - try { - fs.unlinkSync(tmpFile); - } catch {} return false; } } @@ -179,9 +176,9 @@ function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds) { } // .opencode/plugins/lib/refresh-manager.ts +var AUTH_FILE2 = path2.join(os2.homedir(), ".local", "share", "opencode", "auth.json"); var EXEC_TIMEOUT_MS = 15000; var REFRESH_COOLDOWN_MS = 5 * 60 * 1000; -var AUTH_FILE2 = path2.join(os2.homedir(), ".local", "share", "opencode", "auth.json"); var isRefreshing = false; var lastRefreshAttempt = 0; function getExistingRefreshToken() { @@ -234,14 +231,14 @@ function execCommand(command, args) { } async function extractFromKeychain() { try { - const { stdout, exitCode } = await execCommand("security", [ + const { stdout, stderr, exitCode } = await execCommand("security", [ "find-generic-password", "-s", "Claude Code-credentials", "-w" ]); if (exitCode !== 0) { - error("Failed to extract from Keychain", { exitCode, stderr: stdout }); + error("Failed to extract from Keychain", { exitCode, stderr }); return null; } const credentials = JSON.parse(stdout.trim()); @@ -284,7 +281,7 @@ async function generateSetupToken() { } var ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; var ANTHROPIC_TOKEN_ENDPOINT = "https://console.anthropic.com/v1/oauth/token"; -async function refreshWithOAuthToken(oauthToken, attempt = 1) { +async function refreshWithOAuthToken(existingRefreshToken, attempt = 1) { const MAX_RETRIES = 3; const BASE_DELAY_MS = 2000; try { @@ -293,7 +290,7 @@ async function refreshWithOAuthToken(oauthToken, attempt = 1) { const timeout = setTimeout(() => controller.abort(), 1e4); const params = new URLSearchParams({ grant_type: "refresh_token", - refresh_token: oauthToken, + refresh_token: existingRefreshToken, client_id: ANTHROPIC_OAUTH_CLIENT_ID }); const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, { @@ -317,7 +314,7 @@ async function refreshWithOAuthToken(oauthToken, attempt = 1) { const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); warn(`OAuth refresh rate limited (429), retrying in ${delayMs}ms (attempt ${attempt}/${MAX_RETRIES})`); await new Promise((resolve) => setTimeout(resolve, delayMs)); - return refreshWithOAuthToken(oauthToken, attempt + 1); + return refreshWithOAuthToken(existingRefreshToken, attempt + 1); } if (errorData.error?.type === "invalid_grant") { error("OAuth refresh failed - refresh token invalid or revoked", { status: response.status, error: errorText }); @@ -345,7 +342,7 @@ async function refreshWithOAuthToken(oauthToken, attempt = 1) { const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); warn(`OAuth refresh network error, retrying in ${delayMs}ms (attempt ${attempt}/${MAX_RETRIES})`, { error: String(err) }); await new Promise((resolve) => setTimeout(resolve, delayMs)); - return refreshWithOAuthToken(oauthToken, attempt + 1); + return refreshWithOAuthToken(existingRefreshToken, attempt + 1); } error("Exception during OAuth refresh (max retries exceeded)", { error: String(err) }); return null; @@ -354,6 +351,8 @@ async function refreshWithOAuthToken(oauthToken, attempt = 1) { async function exchangeSetupToken(setupToken) { try { info("Exchanging setup token for OAuth credentials"); + const controller = new AbortController; + const timeout = setTimeout(() => controller.abort(), 1e4); const response = await fetch("https://api.anthropic.com/v1/oauth/setup_token/exchange", { method: "POST", headers: { @@ -361,8 +360,10 @@ async function exchangeSetupToken(setupToken) { Authorization: `Bearer ${setupToken}`, "anthropic-version": "2023-06-01" }, - body: JSON.stringify({ grant_type: "setup_token" }) + body: JSON.stringify({ grant_type: "setup_token" }), + signal: controller.signal }); + clearTimeout(timeout); if (!response.ok) { error("Token exchange failed", { status: response.status }); return null; @@ -401,10 +402,10 @@ async function refreshAnthropicToken() { lastRefreshAttempt = Date.now(); try { info("Starting token refresh process"); - const oauthRefreshValue = getExistingRefreshToken(); - if (oauthRefreshValue) { - info("Attempting OAuth refresh with stored refresh_token"); - const refreshedTokens = await refreshWithOAuthToken(oauthRefreshValue); + const existingRefreshToken = getExistingRefreshToken(); + if (existingRefreshToken) { + info("Attempting OAuth refresh with existing refresh_token"); + const refreshedTokens = await refreshWithOAuthToken(existingRefreshToken); if (refreshedTokens) { const success2 = updateAnthropicTokens(refreshedTokens.accessToken, refreshedTokens.refreshToken, refreshedTokens.expiresIn); if (success2) { @@ -414,21 +415,26 @@ async function refreshAnthropicToken() { } info("OAuth refresh failed, falling back to Keychain"); } else { - info("No refresh_token found in auth.json, skipping OAuth refresh"); + info("No existing refresh_token found, skipping OAuth refresh"); } const keychainTokens = await extractFromKeychain(); if (keychainTokens) { info("Found tokens in Keychain, updating auth.json"); - let keychainExpiresIn; - if (keychainTokens.expiresAt && keychainTokens.expiresAt > Date.now()) { - keychainExpiresIn = Math.floor((keychainTokens.expiresAt - Date.now()) / 1000); + let expiresInSeconds; + if (keychainTokens.expiresAt === undefined || keychainTokens.expiresAt === null) { + expiresInSeconds = 28800; + } else if (keychainTokens.expiresAt > Date.now()) { + expiresInSeconds = Math.round((keychainTokens.expiresAt - Date.now()) / 1000); } else { - keychainExpiresIn = 28800; + info("Keychain token is expired, falling through to setup-token exchange"); + expiresInSeconds = 0; } - const success2 = updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, keychainExpiresIn); - if (success2) { - info("Token refresh successful via Keychain"); - return true; + if (expiresInSeconds > 0) { + const success2 = updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, expiresInSeconds); + if (success2) { + info("Token refresh successful via Keychain"); + return true; + } } } info("Keychain extraction failed, generating new setup token"); @@ -469,8 +475,13 @@ async function keepAlivePing() { fileLog("Keep-alive: No valid token, skipping ping", "debug"); return; } - const auth = readAuthFile(); - const accessToken = auth?.anthropic?.access; + const fs3 = await import("node:fs"); + const path3 = await import("node:path"); + const os3 = await import("node:os"); + const authFile = path3.join(os3.homedir(), ".local", "share", "opencode", "auth.json"); + const content = fs3.readFileSync(authFile, "utf8"); + const auth = JSON.parse(content); + const accessToken = auth.anthropic?.access; if (!accessToken) { fileLog("Keep-alive: No access token found", "debug"); return; @@ -508,7 +519,7 @@ function startKeepAlive() { setTimeout(() => { keepAlivePing(); keepaliveTimer = setInterval(keepAlivePing, KEEPALIVE_INTERVAL_MS); - }, 5 * 60 * 1000); + }, 300000); } async function AnthropicTokenBridge() { fileLog("AnthropicTokenBridge plugin loaded", "info"); @@ -522,22 +533,19 @@ async function AnthropicTokenBridge() { startKeepAlive(); return; } - const minutesRemaining = Math.floor(status.timeRemainingMs / (60 * 1000)); + const minutesRemaining = Math.floor(status.timeRemainingMs / 60000); fileLog(`Config hook: Token has ${minutesRemaining} minutes remaining`, "info"); - if (!status.valid || status.expiresSoon) { + if (!status.valid || status.expiresSoon || status.timeRemainingMs < PROACTIVE_REFRESH_THRESHOLD_MS) { fileLog(`Config hook: Token needs refresh (${minutesRemaining}m left), attempting early refresh`, "warn"); if (!isRefreshInProgress()) { - refreshAnthropicToken().then((success) => { - if (success) { - fileLog("Config hook: Early token refresh successful - browser popup should be avoided", "info"); - } else { - fileLog("Config hook: Early refresh failed - OpenCode may open browser", "error"); - } - }).catch((err) => { - fileLogError("Config hook: Unexpected error during refresh", err); - }); + const success = await refreshAnthropicToken(); + if (success) { + fileLog("Config hook: Early token refresh successful - browser popup should be avoided", "info"); + } else { + fileLog("Config hook: Early refresh failed - OpenCode may open browser", "error"); + } } else { - fileLog("Config hook: Refresh already in progress, skipping", "info"); + fileLog("Config hook: Refresh already in progress, waiting", "info"); } } else { fileLog(`Config hook: Token valid for ${minutesRemaining}m, no early refresh needed`, "info"); @@ -561,8 +569,8 @@ async function AnthropicTokenBridge() { fileLog("auth.json not readable, skipping", "debug"); return; } - const minutesRemaining = Math.floor(status.timeRemainingMs / (60 * 1000)); - const needsRefresh = !status.valid || status.expiresSoon; + const minutesRemaining = Math.floor(status.timeRemainingMs / 60000); + const needsRefresh = !status.valid || status.expiresSoon || status.timeRemainingMs < PROACTIVE_REFRESH_THRESHOLD_MS; if (!needsRefresh) { fileLog(`Token valid for ${minutesRemaining}m, no refresh needed`, "debug"); return; @@ -591,8 +599,8 @@ async function AnthropicTokenBridge() { fileLog("Session started, syncing token from Keychain", "info"); const quickCheck = checkAnthropicToken(); if (quickCheck.valid && !quickCheck.expiresSoon) { - const hoursRemaining = Math.floor(quickCheck.timeRemainingMs / (60 * 60 * 1000)); - fileLog(`Token valid for ${hoursRemaining}h at session start (fast-path, no Keychain sync needed)`, "info"); + const hoursRemaining = Math.floor(quickCheck.timeRemainingMs / 3600000); + fileLog(`Token valid for ${hoursRemaining}h at session start (after fallback check)`, "info"); return; } fileLog("Token expired or expiring soon, attempting Keychain sync", "warn"); @@ -656,7 +664,7 @@ async function AnthropicTokenBridge() { fileLogError("Error during immediate token refresh", err); } } else { - const hoursRemaining = Math.floor(status.timeRemainingMs / (60 * 60 * 1000)); + const hoursRemaining = Math.floor(status.timeRemainingMs / 3600000); fileLog(`Token valid for ${hoursRemaining}h at session start (after fallback check)`, "info"); } } catch (err) { diff --git a/contrib/anthropic-max-bridge/README.md b/contrib/anthropic-max-bridge/README.md index 737d52fb..ad4a1a06 100644 --- a/contrib/anthropic-max-bridge/README.md +++ b/contrib/anthropic-max-bridge/README.md @@ -85,28 +85,37 @@ You'll see **$0 input / $0 output** cost because it uses your subscription. Anthropic OAuth tokens expire after **8–12 hours**. -**Auto-refresh (fully silent):** The `anthropic-token-bridge` plugin refreshes your token automatically — no browser, no manual steps required. +**Auto-refresh (default):** The `anthropic-token-bridge` plugin handles refresh automatically using 3 strategies in order: +1. **Direct OAuth2 refresh** — calls the Anthropic token endpoint with the saved refresh_token (silent, no browser) +2. **Keychain sync** — reads the latest token from macOS Keychain (works if you've used `claude` recently) +3. **Setup token exchange** — calls `claude setup-token` and exchanges it (last resort) -- At **startup**: refreshes using the stored `refresh_token` (OAuth2 flow) before OpenCode checks auth -- Every **30 minutes**: keep-alive ping keeps the token from expiring due to inactivity -- Every **5 messages**: proactively refreshes when <1 hour remaining +No action needed in normal use. -**Manual refresh (fallback):** If auto-refresh fails for any reason (e.g. refresh token revoked), run: +**Manual refresh (fallback):** If auto-refresh fails for any reason, run: ```bash +claude # ← IMPORTANT: run this FIRST to ensure Keychain has a fresh token bash refresh-token.sh ``` Then restart OpenCode. +> [!important] +> **Day 2+ usage:** Tokens expire while you sleep. When you return the next morning: +> 1. Run `claude` once in Terminal (takes 2 seconds, refreshes the Keychain token) +> 2. If OpenCode is already running, run `bash refresh-token.sh` and restart it +> +> The auto-refresh plugin handles this silently if you use `claude` regularly. + > [!tip] -> The plugin uses the OAuth2 `refresh_token` flow directly — it does **not** require Claude Code to be running -> and does **not** open a browser. Silent background refresh, just like Claude Code itself does. +> Claude Code silently refreshes its own token in the background whenever you use it. +> So the Keychain always has a fresh token after any `claude` use — which is what the auto-refresh plugin pulls from. --- ## File Reference -``` +```text contrib/anthropic-max-bridge/ ├── README.md ← You are here ├── install.sh ← One-time setup (run this first) @@ -154,23 +163,40 @@ Send them this folder and have them follow **Quick Start** above. ## Troubleshooting ### "No credentials found in Keychain" -→ Run `claude` and log in first. +→ Run `claude` in Terminal and log in first. This authenticates Claude Code and stores the token in Keychain. -### "Token has already expired" -→ Run `claude` (to refresh Claude Code's own token), then `bash refresh-token.sh`. - -### HTTP 401 in OpenCode -→ Token expired and auto-refresh failed. Run `bash refresh-token.sh`, then restart OpenCode. +### "Token has already expired" / HTTP 401 in OpenCode +The token expired (happens after ~8–12 hours). Fix: +```bash +claude # Step 1: refresh the Keychain token (REQUIRED first) +bash refresh-token.sh # Step 2: write it to auth.json +# Then restart OpenCode +``` +**Do not skip Step 1.** `refresh-token.sh` reads from Keychain — if the Keychain token is also expired, the script will fail with "Keychain token is already expired". ### HTTP 400 in OpenCode -→ Plugin not loaded. Check that `~/.opencode/plugins/anthropic-max-bridge.js` exists. +→ The `anthropic-max-bridge` plugin is not loaded. Check that `~/.opencode/plugins/anthropic-max-bridge.js` exists. Re-run `install.sh` if needed. + +### Auto-refresh not working (token keeps expiring) +→ Check the debug log: `cat /tmp/pai-opencode-debug.log` +→ If it shows "claude setup-token failed", make sure `claude` is in your PATH and authenticated. +→ If it shows "OAuth refresh failed — invalid_grant", your refresh token was revoked. Run `claude` to re-authenticate, then `bash refresh-token.sh`. ### Model shows a non-zero cost -→ The plugin may not be active. Check OpenCode logs or re-run `install.sh`. +→ The `anthropic-max-bridge` plugin may not be active, or you're using an API key instead of OAuth. Re-run `install.sh`, restart OpenCode, and verify `auth.json` has `"type": "oauth"`: +```bash +cat ~/.local/share/opencode/auth.json | python3 -m json.tool | grep type +``` ### I don't see `claude-sonnet-4-6` in the model list → In OpenCode settings, make sure `anthropic` is an enabled provider. +### How to check debug logs +All plugin activity is logged to `/tmp/pai-opencode-debug.log`: +```bash +tail -f /tmp/pai-opencode-debug.log +``` + --- ## Disclaimer diff --git a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js index 4978d403..da717b6f 100644 --- a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js +++ b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js @@ -1,12 +1,16 @@ -// lib/file-logger.ts -import { appendFileSync, existsSync, mkdirSync } from "node:fs"; +import { createRequire } from "node:module"; +var __require = /* @__PURE__ */ createRequire(import.meta.url); + +// .opencode/plugins/lib/file-logger.ts +import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; var LOG_PATH = "/tmp/pai-opencode-debug.log"; function fileLog(message, level = "info") { try { const timestamp = new Date().toISOString(); const levelPrefix = level.toUpperCase().padEnd(5); - const logLine = `[${timestamp}] [${levelPrefix}] ${message}\n`; + const logLine = `[${timestamp}] [${levelPrefix}] ${message} +`; const dir = dirname(LOG_PATH); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); @@ -14,8 +18,9 @@ function fileLog(message, level = "info") { appendFileSync(LOG_PATH, logLine); } catch {} } -function fileLogError(message, error2) { - const errorMessage = error2 instanceof Error ? `${error2.message}\n${error2.stack || ""}` : String(error2); +function fileLogError(message, error) { + const errorMessage = error instanceof Error ? `${error.message} +${error.stack || ""}` : String(error); fileLog(`${message}: ${errorMessage}`, "error"); } function info(message, meta) { @@ -31,12 +36,18 @@ function error(message, meta) { fileLog(`${message}${metaStr}`, "error"); } -// lib/token-utils.ts +// .opencode/plugins/lib/refresh-manager.ts +import { spawn } from "node:child_process"; +import * as fs2 from "node:fs"; +import * as os2 from "node:os"; +import * as path2 from "node:path"; + +// .opencode/plugins/lib/token-utils.ts import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; var AUTH_FILE = path.join(os.homedir(), ".local", "share", "opencode", "auth.json"); -var REFRESH_THRESHOLD_MS = 2 * 60 * 60 * 1000; +var REFRESH_THRESHOLD_MS = 60 * 60 * 1000; function readAuthFile() { try { const content = fs.readFileSync(AUTH_FILE, "utf8"); @@ -48,13 +59,19 @@ function readAuthFile() { } function writeAuthFile(authData) { try { - fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2) + "\n"); + fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2) + ` +`, { mode: 384 }); return true; } catch (err) { error("Failed to write auth.json", { error: String(err) }); return false; } } +function maskToken(token) { + if (!token || token.length < 20) + return "***"; + return `${token.slice(0, 8)}...${token.slice(-4)}`; +} function checkAnthropicToken() { const auth = readAuthFile(); if (!auth) { @@ -74,8 +91,27 @@ function checkAnthropicToken() { maskedAccess: maskToken(anthropic.access) }; } + if (!anthropic.access) { + return { + valid: false, + exists: true, + expiresSoon: false, + timeRemainingMs: 0, + reason: "missing_access_token" + }; + } + if (typeof anthropic.expires !== "number" || !Number.isFinite(anthropic.expires)) { + return { + valid: false, + exists: true, + expiresSoon: false, + timeRemainingMs: 0, + reason: "invalid_expires", + maskedAccess: maskToken(anthropic.access) + }; + } const now = Date.now(); - const expires = anthropic.expires ?? 0; + const expires = anthropic.expires; const timeRemainingMs = expires - now; if (timeRemainingMs <= 0) { warn("Anthropic token expired", { @@ -138,19 +174,28 @@ function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds) { } return success; } -function maskToken(token) { - if (!token || token.length < 20) return "***"; - return `${token.slice(0, 8)}...${token.slice(-4)}`; -} -// lib/refresh-manager.ts -import { spawn } from "node:child_process"; +// .opencode/plugins/lib/refresh-manager.ts +var AUTH_FILE2 = path2.join(os2.homedir(), ".local", "share", "opencode", "auth.json"); var EXEC_TIMEOUT_MS = 15000; var REFRESH_COOLDOWN_MS = 5 * 60 * 1000; var isRefreshing = false; var lastRefreshAttempt = 0; +function getExistingRefreshToken() { + try { + const content = fs2.readFileSync(AUTH_FILE2, "utf8"); + const auth = JSON.parse(content); + if (auth.anthropic?.type === "oauth" && auth.anthropic.refresh) { + return auth.anthropic.refresh; + } + return null; + } catch { + return null; + } +} function isRefreshInProgress() { - if (isRefreshing) return true; + if (isRefreshing) + return true; return Date.now() - lastRefreshAttempt < REFRESH_COOLDOWN_MS; } function execCommand(command, args) { @@ -160,7 +205,8 @@ function execCommand(command, args) { let stderr = ""; let settled = false; const settle = (result) => { - if (settled) return; + if (settled) + return; settled = true; clearTimeout(timer); resolve(result); @@ -185,19 +231,18 @@ function execCommand(command, args) { } async function extractFromKeychain() { try { - const { stdout, exitCode } = await execCommand("security", [ + const { stdout, stderr, exitCode } = await execCommand("security", [ "find-generic-password", "-s", "Claude Code-credentials", "-w" ]); if (exitCode !== 0) { - error("Failed to extract from Keychain", { exitCode, stderr: stdout }); + error("Failed to extract from Keychain", { exitCode, stderr }); return null; } const credentials = JSON.parse(stdout.trim()); const oauth = credentials.claudeAiOauth; - // pragma: allowlist secret — runtime extraction from macOS Keychain const accessToken = oauth?.accessToken ?? credentials.accessToken ?? credentials.access_token; const refreshToken = oauth?.refreshToken ?? credentials.refreshToken ?? credentials.refresh_token; if (!accessToken || !refreshToken) { @@ -207,7 +252,8 @@ async function extractFromKeychain() { }); return null; } - return { accessToken, refreshToken }; + const expiresAt = oauth?.expiresAt; + return { accessToken, refreshToken, expiresAt }; } catch (err) { error("Exception extracting from Keychain", { error: String(err) }); return null; @@ -221,22 +267,92 @@ async function generateSetupToken() { error("claude setup-token failed", { exitCode, stderr }); return null; } - // Validate token presence without logging the raw value const tokenMatch = stdout.match(/sk-ant-[a-z0-9-]+/i); if (!tokenMatch) { error("Could not find token in Claude output"); return null; } info("Successfully generated setup token"); - return tokenMatch[0]; // pragma: allowlist secret — runtime value from claude CLI, not hardcoded + return tokenMatch[0]; } catch (err) { error("Exception generating setup token", { error: String(err) }); return null; } } +var ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +var ANTHROPIC_TOKEN_ENDPOINT = "https://console.anthropic.com/v1/oauth/token"; +async function refreshWithOAuthToken(existingRefreshToken, attempt = 1) { + const MAX_RETRIES = 3; + const BASE_DELAY_MS = 2000; + try { + info(`Refreshing OAuth token via Anthropic token endpoint (attempt ${attempt}/${MAX_RETRIES})`); + const controller = new AbortController; + const timeout = setTimeout(() => controller.abort(), 1e4); + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: existingRefreshToken, + client_id: ANTHROPIC_OAUTH_CLIENT_ID + }); + const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "anthropic-version": "2023-06-01", + "User-Agent": "claude-cli/2.0 (OpenCode Token Bridge)" + }, + body: params.toString(), + signal: controller.signal + }); + clearTimeout(timeout); + if (!response.ok) { + const errorText = await response.text(); + let errorData = {}; + try { + errorData = JSON.parse(errorText); + } catch {} + if (response.status === 429 && attempt < MAX_RETRIES) { + const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); + warn(`OAuth refresh rate limited (429), retrying in ${delayMs}ms (attempt ${attempt}/${MAX_RETRIES})`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return refreshWithOAuthToken(existingRefreshToken, attempt + 1); + } + if (errorData.error?.type === "invalid_grant") { + error("OAuth refresh failed - refresh token invalid or revoked", { status: response.status, error: errorText }); + return null; + } + error("OAuth refresh failed", { status: response.status, error: errorText }); + return null; + } + const data = await response.json(); + if (!data.access_token || !data.refresh_token) { + error("Invalid OAuth refresh response", { + hasAccess: !!data.access_token, + hasRefresh: !!data.refresh_token + }); + return null; + } + info("OAuth refresh successful - received new access and refresh tokens"); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in ?? 28800 + }; + } catch (err) { + if (attempt < MAX_RETRIES) { + const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); + warn(`OAuth refresh network error, retrying in ${delayMs}ms (attempt ${attempt}/${MAX_RETRIES})`, { error: String(err) }); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return refreshWithOAuthToken(existingRefreshToken, attempt + 1); + } + error("Exception during OAuth refresh (max retries exceeded)", { error: String(err) }); + return null; + } +} async function exchangeSetupToken(setupToken) { try { info("Exchanging setup token for OAuth credentials"); + const controller = new AbortController; + const timeout = setTimeout(() => controller.abort(), 1e4); const response = await fetch("https://api.anthropic.com/v1/oauth/setup_token/exchange", { method: "POST", headers: { @@ -244,8 +360,10 @@ async function exchangeSetupToken(setupToken) { Authorization: `Bearer ${setupToken}`, "anthropic-version": "2023-06-01" }, - body: JSON.stringify({ grant_type: "setup_token" }) + body: JSON.stringify({ grant_type: "setup_token" }), + signal: controller.signal }); + clearTimeout(timeout); if (!response.ok) { error("Token exchange failed", { status: response.status }); return null; @@ -284,13 +402,39 @@ async function refreshAnthropicToken() { lastRefreshAttempt = Date.now(); try { info("Starting token refresh process"); + const existingRefreshToken = getExistingRefreshToken(); + if (existingRefreshToken) { + info("Attempting OAuth refresh with existing refresh_token"); + const refreshedTokens = await refreshWithOAuthToken(existingRefreshToken); + if (refreshedTokens) { + const success2 = updateAnthropicTokens(refreshedTokens.accessToken, refreshedTokens.refreshToken, refreshedTokens.expiresIn); + if (success2) { + info("Token refresh successful via OAuth refresh_token API"); + return true; + } + } + info("OAuth refresh failed, falling back to Keychain"); + } else { + info("No existing refresh_token found, skipping OAuth refresh"); + } const keychainTokens = await extractFromKeychain(); if (keychainTokens) { info("Found tokens in Keychain, updating auth.json"); - const success2 = updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, 28800); - if (success2) { - info("Token refresh successful via Keychain"); - return true; + let expiresInSeconds; + if (keychainTokens.expiresAt === undefined || keychainTokens.expiresAt === null) { + expiresInSeconds = 28800; + } else if (keychainTokens.expiresAt > Date.now()) { + expiresInSeconds = Math.round((keychainTokens.expiresAt - Date.now()) / 1000); + } else { + info("Keychain token is expired, falling through to setup-token exchange"); + expiresInSeconds = 0; + } + if (expiresInSeconds > 0) { + const success2 = updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, expiresInSeconds); + if (success2) { + info("Token refresh successful via Keychain"); + return true; + } } } info("Keychain extraction failed, generating new setup token"); @@ -318,72 +462,210 @@ async function refreshAnthropicToken() { } } -// anthropic-token-bridge.ts +// .opencode/plugins/anthropic-token-bridge.ts var CHECK_INTERVAL_MESSAGES = 5; +var PROACTIVE_REFRESH_THRESHOLD_MS = 60 * 60 * 1000; +var KEEPALIVE_INTERVAL_MS = 30 * 60 * 1000; var messageCount = 0; +var keepaliveTimer = null; +async function keepAlivePing() { + try { + const status = checkAnthropicToken(); + if (!status.exists || !status.valid) { + fileLog("Keep-alive: No valid token, skipping ping", "debug"); + return; + } + const fs3 = await import("node:fs"); + const path3 = await import("node:path"); + const os3 = await import("node:os"); + const authFile = path3.join(os3.homedir(), ".local", "share", "opencode", "auth.json"); + const content = fs3.readFileSync(authFile, "utf8"); + const auth = JSON.parse(content); + const accessToken = auth.anthropic?.access; + if (!accessToken) { + fileLog("Keep-alive: No access token found", "debug"); + return; + } + fileLog("Keep-alive: Pinging Anthropic usage endpoint", "info"); + const controller = new AbortController; + const timeout = setTimeout(() => controller.abort(), 1e4); + const response = await fetch("https://api.anthropic.com/api/oauth/usage", { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "anthropic-version": "2023-06-01", + "User-Agent": "claude-cli/2.0 (OpenCode Token Bridge)" + }, + signal: controller.signal + }); + clearTimeout(timeout); + if (response.ok) { + const data = await response.json(); + fileLog(`Keep-alive: Ping successful (5h usage: ${data.five_hour?.utilization ?? "unknown"}%)`, "info"); + } else if (response.status === 429) { + fileLog("Keep-alive: Rate limited on usage endpoint (non-critical)", "warn"); + } else { + fileLog(`Keep-alive: Usage endpoint returned ${response.status}`, "warn"); + } + } catch (err) { + fileLogError("Keep-alive: Error during ping", err); + } +} +function startKeepAlive() { + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + } + fileLog(`Starting keep-alive timer (interval: ${KEEPALIVE_INTERVAL_MS / 60000} minutes)`, "info"); + setTimeout(() => { + keepAlivePing(); + keepaliveTimer = setInterval(keepAlivePing, KEEPALIVE_INTERVAL_MS); + }, 300000); +} async function AnthropicTokenBridge() { fileLog("AnthropicTokenBridge plugin loaded", "info"); return { + async config(_config) { + try { + fileLog("Plugin config hook running - early token refresh opportunity", "info"); + const status = checkAnthropicToken(); + if (!status.exists) { + fileLog("No Anthropic token found during config hook, skipping early refresh", "info"); + startKeepAlive(); + return; + } + const minutesRemaining = Math.floor(status.timeRemainingMs / 60000); + fileLog(`Config hook: Token has ${minutesRemaining} minutes remaining`, "info"); + if (!status.valid || status.expiresSoon || status.timeRemainingMs < PROACTIVE_REFRESH_THRESHOLD_MS) { + fileLog(`Config hook: Token needs refresh (${minutesRemaining}m left), attempting early refresh`, "warn"); + if (!isRefreshInProgress()) { + const success = await refreshAnthropicToken(); + if (success) { + fileLog("Config hook: Early token refresh successful - browser popup should be avoided", "info"); + } else { + fileLog("Config hook: Early refresh failed - OpenCode may open browser", "error"); + } + } else { + fileLog("Config hook: Refresh already in progress, waiting", "info"); + } + } else { + fileLog(`Config hook: Token valid for ${minutesRemaining}m, no early refresh needed`, "info"); + } + startKeepAlive(); + } catch (err) { + fileLogError("Error in config hook", err); + } + }, async "chat.message"(_input, output) { const msg = output?.message; - if (!msg?.role || msg.role !== "user") return; + if (!msg?.role || msg.role !== "user") + return; messageCount++; - if (messageCount % CHECK_INTERVAL_MESSAGES !== 0) return; + if (messageCount % CHECK_INTERVAL_MESSAGES !== 0) + return; try { fileLog(`Checking token status (message #${messageCount})`, "debug"); const status = checkAnthropicToken(); - if (!status.exists) { - fileLog("No Anthropic OAuth token configured", "debug"); + if (!status.exists && status.reason === "auth_file_not_readable") { + fileLog("auth.json not readable, skipping", "debug"); return; } - if (status.valid && !status.expiresSoon) { - fileLog("Token valid, no refresh needed", "debug"); + const minutesRemaining = Math.floor(status.timeRemainingMs / 60000); + const needsRefresh = !status.valid || status.expiresSoon || status.timeRemainingMs < PROACTIVE_REFRESH_THRESHOLD_MS; + if (!needsRefresh) { + fileLog(`Token valid for ${minutesRemaining}m, no refresh needed`, "debug"); return; } - if (status.expiresSoon || !status.valid) { - const hoursRemaining = Math.floor(status.timeRemainingMs / (60 * 60 * 1000)); - fileLog(`Token expires in ${hoursRemaining}h, triggering refresh`, "warn"); - if (isRefreshInProgress()) { - fileLog("Refresh already in progress, skipping", "info"); - return; - } - fileLog("Starting async token refresh", "info"); - refreshAnthropicToken().then((success) => { - if (success) { - fileLog("Token refresh completed successfully", "info"); - } else { - fileLog("Token refresh failed - will retry on next check", "error"); - } - }).catch((err) => { - fileLogError("Unexpected error during refresh", err); - }); + fileLog(`Token expires in ${minutesRemaining}m, triggering refresh`, "warn"); + if (isRefreshInProgress()) { + fileLog("Refresh already in progress, skipping", "info"); + return; } + fileLog("Starting async token refresh", "info"); + refreshAnthropicToken().then((success) => { + if (success) { + fileLog("Token refresh completed successfully", "info"); + } else { + fileLog("Token refresh failed - will retry on next check", "error"); + } + }).catch((err) => { + fileLogError("Unexpected error during refresh", err); + }); } catch (err) { fileLogError("Error in chat.message handler", err); } }, async "experimental.chat.system.transform"(_input, _output) { try { - fileLog("Session started, checking initial token status", "info"); + fileLog("Session started, syncing token from Keychain", "info"); + const quickCheck = checkAnthropicToken(); + if (quickCheck.valid && !quickCheck.expiresSoon) { + const hoursRemaining = Math.floor(quickCheck.timeRemainingMs / 3600000); + fileLog(`Token valid for ${hoursRemaining}h at session start (after fallback check)`, "info"); + return; + } + fileLog("Token expired or expiring soon, attempting Keychain sync", "warn"); + try { + const keychainTokens = await extractFromKeychain(); + if (keychainTokens) { + const { accessToken, refreshToken, expiresAt } = keychainTokens; + const currentStatus = checkAnthropicToken(); + const currentAccess = currentStatus.maskedAccess; + const keychainMasked = `${accessToken.slice(0, 8)}...${accessToken.slice(-4)}`; + if (currentAccess !== keychainMasked) { + fileLog(`Keychain token differs from auth.json — syncing (auth:${currentAccess} keychain:${keychainMasked})`, "warn"); + } else { + fileLog("Keychain token matches auth.json", "debug"); + } + let expiresInSeconds; + if (expiresAt === undefined || expiresAt === null) { + expiresInSeconds = 28800; + } else if (expiresAt > Date.now()) { + expiresInSeconds = Math.round((expiresAt - Date.now()) / 1000); + } else { + expiresInSeconds = 0; + } + if (expiresInSeconds > 0) { + const synced = updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds); + if (synced) { + const hoursRemaining = Math.floor(expiresInSeconds / 3600); + fileLog(`Keychain→auth.json sync successful, token valid for ${hoursRemaining}h`, "info"); + return; + } else { + fileLog("Keychain→auth.json sync failed (write error), falling back to refresh", "error"); + } + } else { + fileLog("Keychain token is also expired, triggering full refresh", "warn"); + } + } else { + fileLog("Could not read Keychain, falling back to auth.json check", "warn"); + } + } catch (keychainErr) { + fileLogError("Error during Keychain sync at session start", keychainErr); + } const status = checkAnthropicToken(); - if (!status.exists) { - fileLog("No Anthropic token configured at session start", "debug"); + if (!status.exists && status.reason === "auth_file_not_readable") { + fileLog("auth.json not readable at session start", "debug"); return; } - if (status.expiresSoon || !status.valid) { - fileLog("Token expires soon at session start, scheduling refresh", "warn"); - setTimeout(() => { + if (!status.exists || status.expiresSoon || !status.valid) { + fileLog("Token expired or expiring soon, triggering full refresh", "warn"); + try { if (!isRefreshInProgress()) { - refreshAnthropicToken().then((success) => { - if (success) fileLog("Session-start refresh successful", "info"); - }).catch((err) => { - fileLogError("Session-start refresh error", err); - }); + fileLog("Starting immediate synchronous token refresh", "info"); + const success = await refreshAnthropicToken(); + if (success) { + fileLog("Session-start token refresh successful", "info"); + } else { + fileLog("Session-start token refresh failed - will retry async", "error"); + setTimeout(() => refreshAnthropicToken(), 30000); + } } - }, 5000); + } catch (err) { + fileLogError("Error during immediate token refresh", err); + } } else { - const hoursRemaining = Math.floor(status.timeRemainingMs / (60 * 60 * 1000)); - fileLog(`Token valid for ${hoursRemaining}h at session start`, "info"); + const hoursRemaining = Math.floor(status.timeRemainingMs / 3600000); + fileLog(`Token valid for ${hoursRemaining}h at session start (after fallback check)`, "info"); } } catch (err) { fileLogError("Error in system.transform handler", err); @@ -391,6 +673,7 @@ async function AnthropicTokenBridge() { } }; } +var anthropic_token_bridge_default = AnthropicTokenBridge; export { - AnthropicTokenBridge as default + anthropic_token_bridge_default as default }; From 03bb3319bafbc5c0e01c9e89277e54d85659f8dc Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:14:38 -0400 Subject: [PATCH 2/4] fix(oauth): address CodeRabbit review findings on token-bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - writeAuthFile: implement atomic tmp+rename write (matches token-utils.ts source) to prevent partial reads on interrupted writes; also correct file mode from octal literal 384 (0o600) to explicit 0o600 - CI secret scanner: add pragma allowlist comment to refresh_token URLSearchParams assignment (line 293) — value is a runtime variable read from auth.json, not a hardcoded credential - config() hook: make refreshAnthropicToken() fire-and-forget (no await) so plugin startup is not blocked; logging paths preserved inside .then() - README: remove blank line between [!important] and [!tip] callouts (MD028 lint rule — adjacent callouts must not have blank lines between) Both .opencode/plugins/ and contrib/ copies updated identically. --- .opencode/plugins/anthropic-token-bridge.js | 29 ++++++++++++------- contrib/anthropic-max-bridge/README.md | 1 - .../plugins/anthropic-token-bridge.js | 29 ++++++++++++------- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/.opencode/plugins/anthropic-token-bridge.js b/.opencode/plugins/anthropic-token-bridge.js index da717b6f..cefeab07 100644 --- a/.opencode/plugins/anthropic-token-bridge.js +++ b/.opencode/plugins/anthropic-token-bridge.js @@ -58,12 +58,17 @@ function readAuthFile() { } } function writeAuthFile(authData) { + // Atomic write: write to a temp file then rename over the target. + // Prevents partial/corrupt reads if the process is interrupted mid-write. + const tmpFile = `${AUTH_FILE}.tmp.${process.pid}.${Date.now()}`; try { - fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2) + ` -`, { mode: 384 }); + const content = JSON.stringify(authData, null, 2) + "\n"; + fs.writeFileSync(tmpFile, content, { mode: 0o600 }); + fs.renameSync(tmpFile, AUTH_FILE); // atomic on POSIX return true; } catch (err) { error("Failed to write auth.json", { error: String(err) }); + try { fs.unlinkSync(tmpFile); } catch { /* already gone or never created */ } return false; } } @@ -290,7 +295,7 @@ async function refreshWithOAuthToken(existingRefreshToken, attempt = 1) { const timeout = setTimeout(() => controller.abort(), 1e4); const params = new URLSearchParams({ grant_type: "refresh_token", - refresh_token: existingRefreshToken, + refresh_token: existingRefreshToken, // pragma: allowlist secret — runtime value from auth.json, not hardcoded client_id: ANTHROPIC_OAUTH_CLIENT_ID }); const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, { @@ -536,14 +541,18 @@ async function AnthropicTokenBridge() { const minutesRemaining = Math.floor(status.timeRemainingMs / 60000); fileLog(`Config hook: Token has ${minutesRemaining} minutes remaining`, "info"); if (!status.valid || status.expiresSoon || status.timeRemainingMs < PROACTIVE_REFRESH_THRESHOLD_MS) { - fileLog(`Config hook: Token needs refresh (${minutesRemaining}m left), attempting early refresh`, "warn"); + fileLog(`Config hook: Token needs refresh (${minutesRemaining}m left), starting background refresh`, "warn"); if (!isRefreshInProgress()) { - const success = await refreshAnthropicToken(); - if (success) { - fileLog("Config hook: Early token refresh successful - browser popup should be avoided", "info"); - } else { - fileLog("Config hook: Early refresh failed - OpenCode may open browser", "error"); - } + // Fire-and-forget — do not await so plugin startup is not blocked + refreshAnthropicToken().then((success) => { + if (success) { + fileLog("Config hook: Early token refresh successful - browser popup should be avoided", "info"); + } else { + fileLog("Config hook: Early refresh failed - OpenCode may open browser", "error"); + } + }).catch((err) => { + fileLogError("Config hook: Unexpected error during background refresh", err); + }); } else { fileLog("Config hook: Refresh already in progress, waiting", "info"); } diff --git a/contrib/anthropic-max-bridge/README.md b/contrib/anthropic-max-bridge/README.md index ad4a1a06..63d13f8d 100644 --- a/contrib/anthropic-max-bridge/README.md +++ b/contrib/anthropic-max-bridge/README.md @@ -106,7 +106,6 @@ Then restart OpenCode. > 2. If OpenCode is already running, run `bash refresh-token.sh` and restart it > > The auto-refresh plugin handles this silently if you use `claude` regularly. - > [!tip] > Claude Code silently refreshes its own token in the background whenever you use it. > So the Keychain always has a fresh token after any `claude` use — which is what the auto-refresh plugin pulls from. diff --git a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js index da717b6f..cefeab07 100644 --- a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js +++ b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js @@ -58,12 +58,17 @@ function readAuthFile() { } } function writeAuthFile(authData) { + // Atomic write: write to a temp file then rename over the target. + // Prevents partial/corrupt reads if the process is interrupted mid-write. + const tmpFile = `${AUTH_FILE}.tmp.${process.pid}.${Date.now()}`; try { - fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2) + ` -`, { mode: 384 }); + const content = JSON.stringify(authData, null, 2) + "\n"; + fs.writeFileSync(tmpFile, content, { mode: 0o600 }); + fs.renameSync(tmpFile, AUTH_FILE); // atomic on POSIX return true; } catch (err) { error("Failed to write auth.json", { error: String(err) }); + try { fs.unlinkSync(tmpFile); } catch { /* already gone or never created */ } return false; } } @@ -290,7 +295,7 @@ async function refreshWithOAuthToken(existingRefreshToken, attempt = 1) { const timeout = setTimeout(() => controller.abort(), 1e4); const params = new URLSearchParams({ grant_type: "refresh_token", - refresh_token: existingRefreshToken, + refresh_token: existingRefreshToken, // pragma: allowlist secret — runtime value from auth.json, not hardcoded client_id: ANTHROPIC_OAUTH_CLIENT_ID }); const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, { @@ -536,14 +541,18 @@ async function AnthropicTokenBridge() { const minutesRemaining = Math.floor(status.timeRemainingMs / 60000); fileLog(`Config hook: Token has ${minutesRemaining} minutes remaining`, "info"); if (!status.valid || status.expiresSoon || status.timeRemainingMs < PROACTIVE_REFRESH_THRESHOLD_MS) { - fileLog(`Config hook: Token needs refresh (${minutesRemaining}m left), attempting early refresh`, "warn"); + fileLog(`Config hook: Token needs refresh (${minutesRemaining}m left), starting background refresh`, "warn"); if (!isRefreshInProgress()) { - const success = await refreshAnthropicToken(); - if (success) { - fileLog("Config hook: Early token refresh successful - browser popup should be avoided", "info"); - } else { - fileLog("Config hook: Early refresh failed - OpenCode may open browser", "error"); - } + // Fire-and-forget — do not await so plugin startup is not blocked + refreshAnthropicToken().then((success) => { + if (success) { + fileLog("Config hook: Early token refresh successful - browser popup should be avoided", "info"); + } else { + fileLog("Config hook: Early refresh failed - OpenCode may open browser", "error"); + } + }).catch((err) => { + fileLogError("Config hook: Unexpected error during background refresh", err); + }); } else { fileLog("Config hook: Refresh already in progress, waiting", "info"); } From 1329ee79981937626a133c0e339c80cf2490e839 Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:36:13 -0400 Subject: [PATCH 3/4] fix(oauth): address second round of CodeRabbit review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startKeepAlive — stacking timeouts: Introduced keepaliveInitialTimeout to store the initial setTimeout handle. startKeepAlive() now clears both keepaliveInitialTimeout and keepaliveTimer at the top before creating new ones, so repeated calls cannot stack pending timeouts or duplicate intervals. clearTimeout placement — body-read protection: Moved clearTimeout(timeout) from immediately after fetch() into a finally block that wraps the full response body parsing in three functions: refreshWithOAuthToken, exchangeSetupToken, keepAlivePing. The AbortController signal now remains active during response.text() and response.json() so stalled body reads are aborted after 10s. Both .opencode/plugins/ and contrib/ copies updated identically. --- .opencode/plugins/anthropic-token-bridge.js | 151 ++++++++++-------- .../plugins/anthropic-token-bridge.js | 151 ++++++++++-------- 2 files changed, 172 insertions(+), 130 deletions(-) diff --git a/.opencode/plugins/anthropic-token-bridge.js b/.opencode/plugins/anthropic-token-bridge.js index cefeab07..542c46bb 100644 --- a/.opencode/plugins/anthropic-token-bridge.js +++ b/.opencode/plugins/anthropic-token-bridge.js @@ -308,40 +308,44 @@ async function refreshWithOAuthToken(existingRefreshToken, attempt = 1) { body: params.toString(), signal: controller.signal }); - clearTimeout(timeout); - if (!response.ok) { - const errorText = await response.text(); - let errorData = {}; - try { - errorData = JSON.parse(errorText); - } catch {} - if (response.status === 429 && attempt < MAX_RETRIES) { - const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); - warn(`OAuth refresh rate limited (429), retrying in ${delayMs}ms (attempt ${attempt}/${MAX_RETRIES})`); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - return refreshWithOAuthToken(existingRefreshToken, attempt + 1); + // clearTimeout deferred to finally so AbortController still protects body reads + try { + if (!response.ok) { + const errorText = await response.text(); + let errorData = {}; + try { + errorData = JSON.parse(errorText); + } catch {} + if (response.status === 429 && attempt < MAX_RETRIES) { + const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); + warn(`OAuth refresh rate limited (429), retrying in ${delayMs}ms (attempt ${attempt}/${MAX_RETRIES})`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return refreshWithOAuthToken(existingRefreshToken, attempt + 1); + } + if (errorData.error?.type === "invalid_grant") { + error("OAuth refresh failed - refresh token invalid or revoked", { status: response.status, error: errorText }); + return null; + } + error("OAuth refresh failed", { status: response.status, error: errorText }); + return null; } - if (errorData.error?.type === "invalid_grant") { - error("OAuth refresh failed - refresh token invalid or revoked", { status: response.status, error: errorText }); + const data = await response.json(); + if (!data.access_token || !data.refresh_token) { + error("Invalid OAuth refresh response", { + hasAccess: !!data.access_token, + hasRefresh: !!data.refresh_token + }); return null; } - error("OAuth refresh failed", { status: response.status, error: errorText }); - return null; + info("OAuth refresh successful - received new access and refresh tokens"); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in ?? 28800 + }; + } finally { + clearTimeout(timeout); } - const data = await response.json(); - if (!data.access_token || !data.refresh_token) { - error("Invalid OAuth refresh response", { - hasAccess: !!data.access_token, - hasRefresh: !!data.refresh_token - }); - return null; - } - info("OAuth refresh successful - received new access and refresh tokens"); - return { - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresIn: data.expires_in ?? 28800 - }; } catch (err) { if (attempt < MAX_RETRIES) { const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); @@ -368,24 +372,28 @@ async function exchangeSetupToken(setupToken) { body: JSON.stringify({ grant_type: "setup_token" }), signal: controller.signal }); - clearTimeout(timeout); - if (!response.ok) { - error("Token exchange failed", { status: response.status }); - return null; - } - const data = await response.json(); - if (!data.access_token || !data.refresh_token) { - error("Invalid exchange response", { - hasAccess: !!data.access_token, - hasRefresh: !!data.refresh_token - }); - return null; + // clearTimeout deferred to finally so AbortController still protects body reads + try { + if (!response.ok) { + error("Token exchange failed", { status: response.status }); + return null; + } + const data = await response.json(); + if (!data.access_token || !data.refresh_token) { + error("Invalid exchange response", { + hasAccess: !!data.access_token, + hasRefresh: !!data.refresh_token + }); + return null; + } + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in ?? 28800 + }; + } finally { + clearTimeout(timeout); } - return { - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresIn: data.expires_in ?? 28800 - }; } catch (err) { error("Exception exchanging setup token", { error: String(err) }); return null; @@ -473,6 +481,7 @@ var PROACTIVE_REFRESH_THRESHOLD_MS = 60 * 60 * 1000; var KEEPALIVE_INTERVAL_MS = 30 * 60 * 1000; var messageCount = 0; var keepaliveTimer = null; +var keepaliveInitialTimeout = null; async function keepAlivePing() { try { const status = checkAnthropicToken(); @@ -494,34 +503,46 @@ async function keepAlivePing() { fileLog("Keep-alive: Pinging Anthropic usage endpoint", "info"); const controller = new AbortController; const timeout = setTimeout(() => controller.abort(), 1e4); - const response = await fetch("https://api.anthropic.com/api/oauth/usage", { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "anthropic-version": "2023-06-01", - "User-Agent": "claude-cli/2.0 (OpenCode Token Bridge)" - }, - signal: controller.signal - }); - clearTimeout(timeout); - if (response.ok) { - const data = await response.json(); - fileLog(`Keep-alive: Ping successful (5h usage: ${data.five_hour?.utilization ?? "unknown"}%)`, "info"); - } else if (response.status === 429) { - fileLog("Keep-alive: Rate limited on usage endpoint (non-critical)", "warn"); - } else { - fileLog(`Keep-alive: Usage endpoint returned ${response.status}`, "warn"); + let response; + try { + response = await fetch("https://api.anthropic.com/api/oauth/usage", { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "anthropic-version": "2023-06-01", + "User-Agent": "claude-cli/2.0 (OpenCode Token Bridge)" + }, + signal: controller.signal + }); + if (response.ok) { + const data = await response.json(); + fileLog(`Keep-alive: Ping successful (5h usage: ${data.five_hour?.utilization ?? "unknown"}%)`, "info"); + } else if (response.status === 429) { + fileLog("Keep-alive: Rate limited on usage endpoint (non-critical)", "warn"); + } else { + fileLog(`Keep-alive: Usage endpoint returned ${response.status}`, "warn"); + } + } finally { + clearTimeout(timeout); } } catch (err) { fileLogError("Keep-alive: Error during ping", err); } } function startKeepAlive() { + // Clear both the pending initial timeout and any running interval + // so repeated startKeepAlive() calls don't stack timeouts or intervals + if (keepaliveInitialTimeout) { + clearTimeout(keepaliveInitialTimeout); + keepaliveInitialTimeout = null; + } if (keepaliveTimer) { clearInterval(keepaliveTimer); + keepaliveTimer = null; } fileLog(`Starting keep-alive timer (interval: ${KEEPALIVE_INTERVAL_MS / 60000} minutes)`, "info"); - setTimeout(() => { + keepaliveInitialTimeout = setTimeout(() => { + keepaliveInitialTimeout = null; keepAlivePing(); keepaliveTimer = setInterval(keepAlivePing, KEEPALIVE_INTERVAL_MS); }, 300000); diff --git a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js index cefeab07..542c46bb 100644 --- a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js +++ b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js @@ -308,40 +308,44 @@ async function refreshWithOAuthToken(existingRefreshToken, attempt = 1) { body: params.toString(), signal: controller.signal }); - clearTimeout(timeout); - if (!response.ok) { - const errorText = await response.text(); - let errorData = {}; - try { - errorData = JSON.parse(errorText); - } catch {} - if (response.status === 429 && attempt < MAX_RETRIES) { - const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); - warn(`OAuth refresh rate limited (429), retrying in ${delayMs}ms (attempt ${attempt}/${MAX_RETRIES})`); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - return refreshWithOAuthToken(existingRefreshToken, attempt + 1); + // clearTimeout deferred to finally so AbortController still protects body reads + try { + if (!response.ok) { + const errorText = await response.text(); + let errorData = {}; + try { + errorData = JSON.parse(errorText); + } catch {} + if (response.status === 429 && attempt < MAX_RETRIES) { + const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); + warn(`OAuth refresh rate limited (429), retrying in ${delayMs}ms (attempt ${attempt}/${MAX_RETRIES})`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return refreshWithOAuthToken(existingRefreshToken, attempt + 1); + } + if (errorData.error?.type === "invalid_grant") { + error("OAuth refresh failed - refresh token invalid or revoked", { status: response.status, error: errorText }); + return null; + } + error("OAuth refresh failed", { status: response.status, error: errorText }); + return null; } - if (errorData.error?.type === "invalid_grant") { - error("OAuth refresh failed - refresh token invalid or revoked", { status: response.status, error: errorText }); + const data = await response.json(); + if (!data.access_token || !data.refresh_token) { + error("Invalid OAuth refresh response", { + hasAccess: !!data.access_token, + hasRefresh: !!data.refresh_token + }); return null; } - error("OAuth refresh failed", { status: response.status, error: errorText }); - return null; + info("OAuth refresh successful - received new access and refresh tokens"); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in ?? 28800 + }; + } finally { + clearTimeout(timeout); } - const data = await response.json(); - if (!data.access_token || !data.refresh_token) { - error("Invalid OAuth refresh response", { - hasAccess: !!data.access_token, - hasRefresh: !!data.refresh_token - }); - return null; - } - info("OAuth refresh successful - received new access and refresh tokens"); - return { - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresIn: data.expires_in ?? 28800 - }; } catch (err) { if (attempt < MAX_RETRIES) { const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); @@ -368,24 +372,28 @@ async function exchangeSetupToken(setupToken) { body: JSON.stringify({ grant_type: "setup_token" }), signal: controller.signal }); - clearTimeout(timeout); - if (!response.ok) { - error("Token exchange failed", { status: response.status }); - return null; - } - const data = await response.json(); - if (!data.access_token || !data.refresh_token) { - error("Invalid exchange response", { - hasAccess: !!data.access_token, - hasRefresh: !!data.refresh_token - }); - return null; + // clearTimeout deferred to finally so AbortController still protects body reads + try { + if (!response.ok) { + error("Token exchange failed", { status: response.status }); + return null; + } + const data = await response.json(); + if (!data.access_token || !data.refresh_token) { + error("Invalid exchange response", { + hasAccess: !!data.access_token, + hasRefresh: !!data.refresh_token + }); + return null; + } + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in ?? 28800 + }; + } finally { + clearTimeout(timeout); } - return { - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresIn: data.expires_in ?? 28800 - }; } catch (err) { error("Exception exchanging setup token", { error: String(err) }); return null; @@ -473,6 +481,7 @@ var PROACTIVE_REFRESH_THRESHOLD_MS = 60 * 60 * 1000; var KEEPALIVE_INTERVAL_MS = 30 * 60 * 1000; var messageCount = 0; var keepaliveTimer = null; +var keepaliveInitialTimeout = null; async function keepAlivePing() { try { const status = checkAnthropicToken(); @@ -494,34 +503,46 @@ async function keepAlivePing() { fileLog("Keep-alive: Pinging Anthropic usage endpoint", "info"); const controller = new AbortController; const timeout = setTimeout(() => controller.abort(), 1e4); - const response = await fetch("https://api.anthropic.com/api/oauth/usage", { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "anthropic-version": "2023-06-01", - "User-Agent": "claude-cli/2.0 (OpenCode Token Bridge)" - }, - signal: controller.signal - }); - clearTimeout(timeout); - if (response.ok) { - const data = await response.json(); - fileLog(`Keep-alive: Ping successful (5h usage: ${data.five_hour?.utilization ?? "unknown"}%)`, "info"); - } else if (response.status === 429) { - fileLog("Keep-alive: Rate limited on usage endpoint (non-critical)", "warn"); - } else { - fileLog(`Keep-alive: Usage endpoint returned ${response.status}`, "warn"); + let response; + try { + response = await fetch("https://api.anthropic.com/api/oauth/usage", { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "anthropic-version": "2023-06-01", + "User-Agent": "claude-cli/2.0 (OpenCode Token Bridge)" + }, + signal: controller.signal + }); + if (response.ok) { + const data = await response.json(); + fileLog(`Keep-alive: Ping successful (5h usage: ${data.five_hour?.utilization ?? "unknown"}%)`, "info"); + } else if (response.status === 429) { + fileLog("Keep-alive: Rate limited on usage endpoint (non-critical)", "warn"); + } else { + fileLog(`Keep-alive: Usage endpoint returned ${response.status}`, "warn"); + } + } finally { + clearTimeout(timeout); } } catch (err) { fileLogError("Keep-alive: Error during ping", err); } } function startKeepAlive() { + // Clear both the pending initial timeout and any running interval + // so repeated startKeepAlive() calls don't stack timeouts or intervals + if (keepaliveInitialTimeout) { + clearTimeout(keepaliveInitialTimeout); + keepaliveInitialTimeout = null; + } if (keepaliveTimer) { clearInterval(keepaliveTimer); + keepaliveTimer = null; } fileLog(`Starting keep-alive timer (interval: ${KEEPALIVE_INTERVAL_MS / 60000} minutes)`, "info"); - setTimeout(() => { + keepaliveInitialTimeout = setTimeout(() => { + keepaliveInitialTimeout = null; keepAlivePing(); keepaliveTimer = setInterval(keepAlivePing, KEEPALIVE_INTERVAL_MS); }, 300000); From a9c97e6c67b2ccd1202d354b020f9a41a5b7a0e3 Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:59:23 -0400 Subject: [PATCH 4/4] fix(ci): add pragma allowlist comment to existingRefreshToken assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI secret scanner regex matches 'token = getExistingRefreshToken()' because 'getExistingRefreshToken()' is 25 chars matching [a-zA-Z0-9._-]{20,}. Value is a runtime function call reading from auth.json — not a hardcoded credential. Added pragma allowlist comment to suppress the false positive, consistent with the same treatment on the refresh_token URLSearchParams assignment (line 293). Both .opencode/plugins/ and contrib/ copies updated identically. --- .opencode/plugins/anthropic-token-bridge.js | 2 +- contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.opencode/plugins/anthropic-token-bridge.js b/.opencode/plugins/anthropic-token-bridge.js index 542c46bb..710904ae 100644 --- a/.opencode/plugins/anthropic-token-bridge.js +++ b/.opencode/plugins/anthropic-token-bridge.js @@ -415,7 +415,7 @@ async function refreshAnthropicToken() { lastRefreshAttempt = Date.now(); try { info("Starting token refresh process"); - const existingRefreshToken = getExistingRefreshToken(); + const existingRefreshToken = getExistingRefreshToken(); // pragma: allowlist secret — runtime value read from auth.json, not hardcoded if (existingRefreshToken) { info("Attempting OAuth refresh with existing refresh_token"); const refreshedTokens = await refreshWithOAuthToken(existingRefreshToken); diff --git a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js index 542c46bb..710904ae 100644 --- a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js +++ b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js @@ -415,7 +415,7 @@ async function refreshAnthropicToken() { lastRefreshAttempt = Date.now(); try { info("Starting token refresh process"); - const existingRefreshToken = getExistingRefreshToken(); + const existingRefreshToken = getExistingRefreshToken(); // pragma: allowlist secret — runtime value read from auth.json, not hardcoded if (existingRefreshToken) { info("Attempting OAuth refresh with existing refresh_token"); const refreshedTokens = await refreshWithOAuthToken(existingRefreshToken);