diff --git a/.opencode/plugins/anthropic-token-bridge.js b/.opencode/plugins/anthropic-token-bridge.js index 70ec703f..710904ae 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,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 { - const content = JSON.stringify(authData, null, 2) + ` -`; - fs.writeFileSync(tmpFile, content, { mode: 384 }); - fs.renameSync(tmpFile, AUTH_FILE); + 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 {} + try { fs.unlinkSync(tmpFile); } catch { /* already gone or never created */ } return false; } } @@ -179,9 +181,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 +236,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 +286,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 +295,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, // pragma: allowlist secret — runtime value from auth.json, not hardcoded client_id: ANTHROPIC_OAUTH_CLIENT_ID }); const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, { @@ -306,46 +308,50 @@ async function refreshWithOAuthToken(oauthToken, 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(oauthToken, 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); 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 +360,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,25 +369,31 @@ 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 }); - 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; @@ -401,10 +415,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(); // 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); if (refreshedTokens) { const success2 = updateAnthropicTokens(refreshedTokens.accessToken, refreshedTokens.refreshToken, refreshedTokens.expiresIn); if (success2) { @@ -414,21 +428,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"); @@ -462,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(); @@ -469,8 +489,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; @@ -478,37 +503,49 @@ 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); - }, 5 * 60 * 1000); + }, 300000); } async function AnthropicTokenBridge() { fileLog("AnthropicTokenBridge plugin loaded", "info"); @@ -522,11 +559,12 @@ 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) { - fileLog(`Config hook: Token needs refresh (${minutesRemaining}m left), attempting early refresh`, "warn"); + if (!status.valid || status.expiresSoon || status.timeRemainingMs < PROACTIVE_REFRESH_THRESHOLD_MS) { + fileLog(`Config hook: Token needs refresh (${minutesRemaining}m left), starting background refresh`, "warn"); if (!isRefreshInProgress()) { + // 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"); @@ -534,10 +572,10 @@ async function AnthropicTokenBridge() { fileLog("Config hook: Early refresh failed - OpenCode may open browser", "error"); } }).catch((err) => { - fileLogError("Config hook: Unexpected error during refresh", err); + fileLogError("Config hook: Unexpected error during background refresh", err); }); } 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 +599,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 +629,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 +694,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..63d13f8d 100644 --- a/contrib/anthropic-max-bridge/README.md +++ b/contrib/anthropic-max-bridge/README.md @@ -85,28 +85,36 @@ 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 +162,40 @@ Send them this folder and have them follow **Quick Start** above. ## Troubleshooting ### "No credentials found in Keychain" -→ Run `claude` and log in first. - -### "Token has already expired" -→ Run `claude` (to refresh Claude Code's own token), then `bash refresh-token.sh`. +→ Run `claude` in Terminal and log in first. This authenticates Claude Code and stores the token in Keychain. -### 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..710904ae 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"); @@ -47,14 +58,25 @@ 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) + "\n"); + 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; } } +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 +96,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 +179,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 +210,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 +236,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 +257,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 +272,96 @@ 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, // pragma: allowlist secret — runtime value from auth.json, not hardcoded + 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 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; + } + 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 + }; + } finally { + clearTimeout(timeout); + } + } 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,25 +369,31 @@ 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 }); - 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; @@ -284,13 +415,39 @@ async function refreshAnthropicToken() { lastRefreshAttempt = Date.now(); try { info("Starting token refresh process"); + 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); + 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 +475,227 @@ 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; +var keepaliveInitialTimeout = 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); + 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"); + keepaliveInitialTimeout = setTimeout(() => { + keepaliveInitialTimeout = null; + 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), starting background refresh`, "warn"); + if (!isRefreshInProgress()) { + // 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"); + } + } 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 +703,7 @@ async function AnthropicTokenBridge() { } }; } +var anthropic_token_bridge_default = AnthropicTokenBridge; export { - AnthropicTokenBridge as default + anthropic_token_bridge_default as default };