diff --git a/.opencode/plugins/anthropic-token-bridge.js b/.opencode/plugins/anthropic-token-bridge.js index 4978d403..70ec703f 100644 --- a/.opencode/plugins/anthropic-token-bridge.js +++ b/.opencode/plugins/anthropic-token-bridge.js @@ -1,12 +1,13 @@ -// lib/file-logger.ts -import { appendFileSync, existsSync, mkdirSync } from "node:fs"; +// .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 +15,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 +33,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 +55,26 @@ function readAuthFile() { } } function writeAuthFile(authData) { + 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) + ` +`; + fs.writeFileSync(tmpFile, content, { mode: 384 }); + fs.renameSync(tmpFile, AUTH_FILE); return true; } catch (err) { error("Failed to write auth.json", { error: String(err) }); + try { + fs.unlinkSync(tmpFile); + } catch {} 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 +94,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 +177,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 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() { + 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 +208,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); @@ -197,7 +246,6 @@ async function extractFromKeychain() { } 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 +255,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,19 +270,87 @@ 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(oauthToken, 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: oauthToken, + 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(oauthToken, 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(oauthToken, 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"); @@ -284,10 +401,31 @@ 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); + 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 refresh_token found in auth.json, 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); + let keychainExpiresIn; + if (keychainTokens.expiresAt && keychainTokens.expiresAt > Date.now()) { + keychainExpiresIn = Math.floor((keychainTokens.expiresAt - Date.now()) / 1000); + } else { + keychainExpiresIn = 28800; + } + const success2 = updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, keychainExpiresIn); if (success2) { info("Token refresh successful via Keychain"); return true; @@ -318,72 +456,208 @@ 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 auth = readAuthFile(); + 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); + }, 5 * 60 * 1000); +} 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 / (60 * 1000)); + 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 (!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); + }); + } else { + fileLog("Config hook: Refresh already in progress, skipping", "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 / (60 * 1000)); + const needsRefresh = !status.valid || status.expiresSoon; + 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 / (60 * 60 * 1000)); + fileLog(`Token valid for ${hoursRemaining}h at session start (fast-path, no Keychain sync needed)`, "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"); + fileLog(`Token valid for ${hoursRemaining}h at session start (after fallback check)`, "info"); } } catch (err) { fileLogError("Error in system.transform handler", err); @@ -391,6 +665,7 @@ async function AnthropicTokenBridge() { } }; } +var anthropic_token_bridge_default = AnthropicTokenBridge; export { - AnthropicTokenBridge as default + anthropic_token_bridge_default as default }; diff --git a/.opencode/plugins/anthropic-token-bridge.ts b/.opencode/plugins/anthropic-token-bridge.ts index aec82eb0..a5bde504 100644 --- a/.opencode/plugins/anthropic-token-bridge.ts +++ b/.opencode/plugins/anthropic-token-bridge.ts @@ -1,14 +1,136 @@ import { fileLog, fileLogError } from "./lib/file-logger.ts"; -import { isRefreshInProgress, refreshAnthropicToken } from "./lib/refresh-manager.ts"; -import { checkAnthropicToken } from "./lib/token-utils.ts"; +import { extractFromKeychain, isRefreshInProgress, refreshAnthropicToken } from "./lib/refresh-manager.ts"; +import { checkAnthropicToken, readAuthFile, updateAnthropicTokens } from "./lib/token-utils.ts"; const CHECK_INTERVAL_MESSAGES = 5; +const PROACTIVE_REFRESH_THRESHOLD_MS = 60 * 60 * 1000; // Refresh when < 60 minutes remaining (proactive) +const KEEPALIVE_INTERVAL_MS = 30 * 60 * 1000; // Ping every 30 minutes to keep token alive let messageCount = 0; +let keepaliveTimer: ReturnType | null = null; + +// Keep-alive ping to prevent token expiration +// Uses the /api/oauth/usage endpoint which is lightweight and doesn't consume tokens +async function keepAlivePing() { + try { + const status = checkAnthropicToken(); + if (!status.exists || !status.valid) { + fileLog("Keep-alive: No valid token, skipping ping", "debug"); + return; + } + + // Get the access token via readAuthFile (reuses standardized error handling) + const auth = readAuthFile(); + const accessToken = auth?.anthropic?.access; + + if (!accessToken) { + fileLog("Keep-alive: No access token found", "debug"); + return; + } + + fileLog("Keep-alive: Pinging Anthropic usage endpoint", "info"); + + // Ping the usage endpoint - this keeps the token active without consuming tokens + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + 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() as { five_hour?: { utilization?: number } }; + fileLog(`Keep-alive: Ping successful (5h usage: ${data.five_hour?.utilization ?? 'unknown'}%)`, "info"); + } else if (response.status === 429) { + // Rate limited on usage endpoint - not critical, just log it + 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); + } +} + +// Start the keep-alive timer +function startKeepAlive() { + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + } + + fileLog(`Starting keep-alive timer (interval: ${KEEPALIVE_INTERVAL_MS / 60000} minutes)`, "info"); + + // First ping after 5 minutes (to let everything settle after startup) + setTimeout(() => { + keepAlivePing(); + // Then ping every 30 minutes + keepaliveTimer = setInterval(keepAlivePing, KEEPALIVE_INTERVAL_MS); + }, 5 * 60 * 1000); +} async function AnthropicTokenBridge() { fileLog("AnthropicTokenBridge plugin loaded", "info"); return { + // EARLIEST HOOK: Config runs during plugin initialization, before any auth checks + // This is our chance to refresh the token BEFORE OpenCode checks it + async config(_config: unknown) { + try { + fileLog("Plugin config hook running - early token refresh opportunity", "info"); + + // Check token status immediately during plugin load + const status = checkAnthropicToken(); + if (!status.exists) { + fileLog("No Anthropic token found during config hook, skipping early refresh", "info"); + // Still start keep-alive in case token gets added later + startKeepAlive(); + return; + } + + const minutesRemaining = Math.floor(status.timeRemainingMs / (60 * 1000)); + fileLog(`Config hook: Token has ${minutesRemaining} minutes remaining`, "info"); + + // If token is expired or expiring soon, kick off a non-blocking refresh. + // expiresSoon is true when timeRemainingMs < REFRESH_THRESHOLD_MS (both 1h), + // so checking expiresSoon is sufficient — no need to repeat the ms comparison. + if (!status.valid || status.expiresSoon) { + fileLog(`Config hook: Token needs refresh (${minutesRemaining}m left), attempting early refresh`, "warn"); + + if (!isRefreshInProgress()) { + // Fire-and-forget: do NOT await so plugin init 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: unknown) => { + fileLogError("Config hook: Unexpected error during refresh", err); + }); + } else { + fileLog("Config hook: Refresh already in progress, skipping", "info"); + } + } else { + fileLog(`Config hook: Token valid for ${minutesRemaining}m, no early refresh needed`, "info"); + } + + // Start the keep-alive timer regardless of whether we refreshed + // This keeps the token active by periodically pinging the usage endpoint + startKeepAlive(); + } catch (err) { + fileLogError("Error in config hook", err); + } + }, + // Check token every N user messages, refresh automatically if expiring async "chat.message"(_input: unknown, output: unknown) { const msg = (output as { message?: { role?: string } })?.message; @@ -21,70 +143,133 @@ async function AnthropicTokenBridge() { 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"); + // PROACTIVE REFRESH: expiresSoon is true when timeRemainingMs < REFRESH_THRESHOLD_MS (1h), + // which equals PROACTIVE_REFRESH_THRESHOLD_MS — no need to repeat the ms comparison. + const minutesRemaining = Math.floor(status.timeRemainingMs / (60 * 1000)); + const needsRefresh = !status.valid || status.expiresSoon; + + 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"); + 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: unknown) => { - fileLogError("Unexpected error during refresh", err); - }); + 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: unknown) => { + fileLogError("Unexpected error during refresh", err); + }); } catch (err) { fileLogError("Error in chat.message handler", err); } }, - // At session start: check token and schedule early refresh if expiring soon + // At session start: check auth.json first. Only sync from Keychain if token is + // expired/missing — Keychain is often stale (Claude Code refreshes in-memory only). async "experimental.chat.system.transform"(_input: unknown, _output: unknown) { try { - fileLog("Session started, checking initial token status", "info"); - const status = checkAnthropicToken(); + fileLog("Session started, syncing token from Keychain", "info"); + + // Fast path: if auth.json token is already valid with >60min remaining, skip Keychain sync + 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"); + return; + } + + // Step 1: Only attempt Keychain → auth.json sync if token is expired/expiring + fileLog("Token expired or expiring soon, attempting Keychain sync", "warn"); + try { + const keychainTokens = await extractFromKeychain(); + if (keychainTokens) { + const { accessToken, refreshToken, expiresAt } = keychainTokens; - if (!status.exists) { - fileLog("No Anthropic token configured at session start", "debug"); + // Check if Keychain token differs from auth.json (for logging) + 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"); + } + + // Always write Keychain token to auth.json (it's the source of truth) + let expiresInSeconds: number; + if (expiresAt === undefined || expiresAt === null) { + expiresInSeconds = 28800; // 8h default + } else if (expiresAt > Date.now()) { + expiresInSeconds = Math.round((expiresAt - Date.now()) / 1000); + } else { + // Keychain token is also expired — fall through to setup-token refresh + 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; // Done — fresh token written, no further refresh needed + } 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: unknown) { + fileLogError("Error during Keychain sync at session start", keychainErr); + } + + // Step 2: Fallback — check auth.json and refresh if expired/expiring + const status = checkAnthropicToken(); + 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: unknown) => { - 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"); + fileLog(`Token valid for ${hoursRemaining}h at session start (after fallback check)`, "info"); } } catch (err) { fileLogError("Error in system.transform handler", err); diff --git a/.opencode/plugins/lib/refresh-manager.js b/.opencode/plugins/lib/refresh-manager.js new file mode 100644 index 00000000..9e0c940c --- /dev/null +++ b/.opencode/plugins/lib/refresh-manager.js @@ -0,0 +1,377 @@ +// .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/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} +`; + const dir = dirname(LOG_PATH); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + appendFileSync(LOG_PATH, logLine); + } catch {} +} +function info(message, meta) { + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ""; + fileLog(`${message}${metaStr}`, "info"); +} +function warn(message, meta) { + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ""; + fileLog(`${message}${metaStr}`, "warn"); +} +function error(message, meta) { + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ""; + fileLog(`${message}${metaStr}`, "error"); +} + +// .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 = 60 * 60 * 1000; +function readAuthFile() { + try { + const content = fs.readFileSync(AUTH_FILE, "utf8"); + return JSON.parse(content); + } catch (err) { + error("Failed to read auth.json", { error: String(err) }); + return null; + } +} +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); + return true; + } catch (err) { + error("Failed to write auth.json", { error: String(err) }); + try { + fs.unlinkSync(tmpFile); + } catch {} + return false; + } +} +function maskToken(token) { + if (!token || token.length < 20) + return "***"; + return `${token.slice(0, 8)}...${token.slice(-4)}`; +} +function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds) { + const auth = readAuthFile(); + if (!auth) { + error("Cannot update tokens: auth.json not readable"); + return false; + } + const expiresAt = Date.now() + expiresInSeconds * 1000; + auth.anthropic = { + type: "oauth", + access: accessToken, + refresh: refreshToken, + expires: expiresAt + }; + const success = writeAuthFile(auth); + if (success) { + info("Updated anthropic tokens", { + expiresAt: new Date(expiresAt).toISOString(), + maskedAccess: maskToken(accessToken) + }); + } + return success; +} + +// .opencode/plugins/lib/refresh-manager.ts +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() { + 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; + return Date.now() - lastRefreshAttempt < REFRESH_COOLDOWN_MS; +} +function execCommand(command, args) { + return new Promise((resolve) => { + const child = spawn(command, args); + let stdout = ""; + let stderr = ""; + let settled = false; + const settle = (result) => { + if (settled) + return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + child.kill(); + settle({ stdout, stderr: "timeout", exitCode: 1 }); + }, EXEC_TIMEOUT_MS); + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + child.on("close", (exitCode) => { + settle({ stdout, stderr, exitCode: exitCode ?? 0 }); + }); + child.on("error", (err) => { + settle({ stdout, stderr: String(err), exitCode: 1 }); + }); + }); +} +async function extractFromKeychain() { + try { + const { stdout, exitCode } = await execCommand("security", [ + "find-generic-password", + "-s", + "Claude Code-credentials", + "-w" + ]); + if (exitCode !== 0) { + error("Failed to extract from Keychain", { exitCode, stderr: stdout }); + return null; + } + const credentials = JSON.parse(stdout.trim()); + const oauth = credentials.claudeAiOauth; + const accessToken = oauth?.accessToken ?? credentials.accessToken ?? credentials.access_token; + const refreshToken = oauth?.refreshToken ?? credentials.refreshToken ?? credentials.refresh_token; + if (!accessToken || !refreshToken) { + error("Invalid credentials format from Keychain", { + hasAccess: !!accessToken, + hasRefresh: !!refreshToken + }); + return null; + } + const expiresAt = oauth?.expiresAt; + return { accessToken, refreshToken, expiresAt }; + } catch (err) { + error("Exception extracting from Keychain", { error: String(err) }); + return null; + } +} +async function generateSetupToken() { + try { + info("Generating new setup token via Claude Code"); + const { stdout, exitCode, stderr } = await execCommand("claude", ["setup-token"]); + if (exitCode !== 0) { + error("claude setup-token failed", { exitCode, stderr }); + return null; + } + 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]; + } 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(oauthToken, 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: oauthToken, + 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(oauthToken, 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(oauthToken, 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 response = await fetch("https://api.anthropic.com/v1/oauth/setup_token/exchange", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${setupToken}`, + "anthropic-version": "2023-06-01" + }, + body: JSON.stringify({ grant_type: "setup_token" }) + }); + 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 + }; + } catch (err) { + error("Exception exchanging setup token", { error: String(err) }); + return null; + } +} +async function refreshAnthropicToken() { + if (isRefreshing) { + info("Refresh already in progress, skipping"); + return false; + } + const timeSinceLastAttempt = Date.now() - lastRefreshAttempt; + if (timeSinceLastAttempt < REFRESH_COOLDOWN_MS) { + info("Refresh on cooldown", { + minutesRemaining: Math.ceil((REFRESH_COOLDOWN_MS - timeSinceLastAttempt) / 60000) + }); + return false; + } + isRefreshing = true; + 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); + 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 refresh_token found in auth.json, 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); + } else { + keychainExpiresIn = 28800; + } + const success2 = updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, keychainExpiresIn); + if (success2) { + info("Token refresh successful via Keychain"); + return true; + } + } + info("Keychain extraction failed, generating new setup token"); + const setupToken = await generateSetupToken(); + if (!setupToken) { + error("Failed to generate setup token - is Claude Code authenticated?"); + return false; + } + const oauthTokens = await exchangeSetupToken(setupToken); + if (!oauthTokens) { + error("Failed to exchange setup token"); + return false; + } + const success = updateAnthropicTokens(oauthTokens.accessToken, oauthTokens.refreshToken, oauthTokens.expiresIn); + if (success) { + info("Token refresh successful via setup token exchange"); + return true; + } + return false; + } catch (err) { + error("Unexpected error during refresh", { error: String(err) }); + return false; + } finally { + isRefreshing = false; + } +} +export { + refreshAnthropicToken, + isRefreshInProgress, + extractFromKeychain +}; diff --git a/.opencode/plugins/lib/refresh-manager.ts b/.opencode/plugins/lib/refresh-manager.ts index c97ab377..120f0408 100644 --- a/.opencode/plugins/lib/refresh-manager.ts +++ b/.opencode/plugins/lib/refresh-manager.ts @@ -1,14 +1,34 @@ import { spawn } from "node:child_process"; -import { error, info } from "./file-logger.ts"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { error, info, warn } from "./file-logger.ts"; import { updateAnthropicTokens } from "./token-utils.ts"; const EXEC_TIMEOUT_MS = 15_000; // 15 seconds — prevents execCommand hanging indefinitely const REFRESH_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes +const AUTH_FILE = path.join(os.homedir(), ".local", "share", "opencode", "auth.json"); + let isRefreshing = false; let lastRefreshAttempt = 0; +function getExistingRefreshToken(): string | null { + try { + const content = fs.readFileSync(AUTH_FILE, "utf8"); + const auth = JSON.parse(content) as { + anthropic?: { refresh?: string; type?: string }; + }; + if (auth.anthropic?.type === "oauth" && auth.anthropic.refresh) { + return auth.anthropic.refresh; + } + return null; + } catch { + return null; + } +} + export function isRefreshInProgress(): boolean { if (isRefreshing) return true; return Date.now() - lastRefreshAttempt < REFRESH_COOLDOWN_MS; @@ -57,9 +77,10 @@ function execCommand(command: string, args: string[]): Promise { interface KeychainTokens { accessToken: string; refreshToken: string; + expiresAt?: number; // Unix timestamp in ms (from claudeAiOauth.expiresAt) } -async function extractFromKeychain(): Promise { +export async function extractFromKeychain(): Promise { try { const { stdout, exitCode } = await execCommand("security", [ "find-generic-password", @@ -100,7 +121,8 @@ async function extractFromKeychain(): Promise { 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; @@ -138,6 +160,102 @@ interface OAuthTokens { expiresIn: number; } +const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const ANTHROPIC_TOKEN_ENDPOINT = "https://console.anthropic.com/v1/oauth/token"; + +async function refreshWithOAuthToken(oauthToken: string, attempt = 1): Promise { // pragma: allowlist secret — param is a runtime token, not a hardcoded secret + const MAX_RETRIES = 3; + const BASE_DELAY_MS = 2000; // Start with 2 second delay + + try { + info(`Refreshing OAuth token via Anthropic token endpoint (attempt ${attempt}/${MAX_RETRIES})`); + + // AbortController timeout to prevent hanging + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + // OAuth2 refresh token flow + // Note: Do NOT include scope parameter - Anthropic rejects it on refresh + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: oauthToken, // pragma: allowlist secret — runtime value + 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(); + // Safe parse: non-JSON bodies (e.g. HTML error pages) must not crash the flow + let errorData: { error?: { type?: string; message?: string } } = {}; + try { + errorData = JSON.parse(errorText) as { error?: { type?: string; message?: string } }; + } catch { + // Leave errorData as {} and preserve errorText for logging below + } + + // Handle rate limiting with exponential backoff + if (response.status === 429 && attempt < MAX_RETRIES) { + const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); // 2s, 4s, 8s + 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); + } + + // Handle invalid_grant (refresh token revoked/expired) - don't retry + 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()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + 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) { + // Network errors - retry with backoff + 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); + } + error("Exception during OAuth refresh (max retries exceeded)", { error: String(err) }); + return null; + } +} + async function exchangeSetupToken(setupToken: string): Promise { try { info("Exchanging setup token for OAuth credentials"); @@ -201,14 +319,43 @@ export async function refreshAnthropicToken(): Promise { try { info("Starting token refresh process"); - // Strategy 1: Extract from macOS Keychain + // Strategy 1: Use stored refresh_token to get new tokens via OAuth API + // This is the proper OAuth2 flow and should work silently without browser + const oauthRefreshValue = getExistingRefreshToken(); // pragma: allowlist secret — runtime value from auth.json, not hardcoded + if (oauthRefreshValue) { + info("Attempting OAuth refresh with stored refresh_token"); + const refreshedTokens = await refreshWithOAuthToken(oauthRefreshValue); + if (refreshedTokens) { + const success = updateAnthropicTokens( + refreshedTokens.accessToken, + refreshedTokens.refreshToken, + refreshedTokens.expiresIn, + ); + if (success) { + info("Token refresh successful via OAuth refresh_token API"); + return true; + } + } + info("OAuth refresh failed, falling back to Keychain"); + } else { + info("No refresh_token found in auth.json, skipping OAuth refresh"); + } + + // Strategy 2: Extract from macOS Keychain (Claude Code may have fresh tokens) const keychainTokens = await extractFromKeychain(); if (keychainTokens) { info("Found tokens in Keychain, updating auth.json"); + // Compute real expiresIn from Keychain's expiresAt; fall back to 8h default + let keychainExpiresIn: number; + if (keychainTokens.expiresAt && keychainTokens.expiresAt > Date.now()) { + keychainExpiresIn = Math.floor((keychainTokens.expiresAt - Date.now()) / 1000); + } else { + keychainExpiresIn = 28800; // 8h default when expiresAt is missing or already past + } const success = updateAnthropicTokens( keychainTokens.accessToken, keychainTokens.refreshToken, - 28800, + keychainExpiresIn, ); if (success) { info("Token refresh successful via Keychain"); @@ -216,7 +363,7 @@ export async function refreshAnthropicToken(): Promise { } } - // Strategy 2: Generate new setup token via Claude Code CLI + // Strategy 3: Generate new setup token via Claude Code CLI (may trigger browser) info("Keychain extraction failed, generating new setup token"); const setupToken = await generateSetupToken(); if (!setupToken) { @@ -224,7 +371,7 @@ export async function refreshAnthropicToken(): Promise { return false; } - // Strategy 3: Exchange setup token for OAuth credentials + // Strategy 3 (continued): Exchange the setup token for OAuth credentials const oauthTokens = await exchangeSetupToken(setupToken); if (!oauthTokens) { error("Failed to exchange setup token"); diff --git a/.opencode/plugins/lib/token-utils.js b/.opencode/plugins/lib/token-utils.js new file mode 100644 index 00000000..893c6915 --- /dev/null +++ b/.opencode/plugins/lib/token-utils.js @@ -0,0 +1,176 @@ +// .opencode/plugins/lib/token-utils.ts +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +// .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} +`; + const dir = dirname(LOG_PATH); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + appendFileSync(LOG_PATH, logLine); + } catch {} +} +function info(message, meta) { + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ""; + fileLog(`${message}${metaStr}`, "info"); +} +function warn(message, meta) { + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ""; + fileLog(`${message}${metaStr}`, "warn"); +} +function error(message, meta) { + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ""; + fileLog(`${message}${metaStr}`, "error"); +} + +// .opencode/plugins/lib/token-utils.ts +var AUTH_FILE = path.join(os.homedir(), ".local", "share", "opencode", "auth.json"); +var REFRESH_THRESHOLD_MS = 60 * 60 * 1000; +function readAuthFile() { + try { + const content = fs.readFileSync(AUTH_FILE, "utf8"); + return JSON.parse(content); + } catch (err) { + error("Failed to read auth.json", { error: String(err) }); + return null; + } +} +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); + return true; + } catch (err) { + error("Failed to write auth.json", { error: String(err) }); + try { + fs.unlinkSync(tmpFile); + } catch {} + 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) { + return { valid: false, exists: false, expiresSoon: false, timeRemainingMs: 0, reason: "auth_file_not_readable" }; + } + const anthropic = auth.anthropic; + if (!anthropic) { + return { valid: false, exists: false, expiresSoon: false, timeRemainingMs: 0, reason: "no_anthropic_config" }; + } + if (anthropic.type !== "oauth") { + return { + valid: false, + exists: true, + expiresSoon: false, + timeRemainingMs: 0, + reason: "not_oauth_type", + 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; + const timeRemainingMs = expires - now; + if (timeRemainingMs <= 0) { + warn("Anthropic token expired", { + expiredAt: new Date(expires).toISOString(), + maskedAccess: maskToken(anthropic.access) + }); + return { + valid: false, + exists: true, + expiresSoon: true, + timeRemainingMs: 0, + expiresAt: new Date(expires), + maskedAccess: maskToken(anthropic.access), + reason: "token_expired" + }; + } + const expiresSoon = timeRemainingMs < REFRESH_THRESHOLD_MS; + const hoursRemaining = Math.floor(timeRemainingMs / (60 * 60 * 1000)); + if (expiresSoon) { + warn("Anthropic token expires soon", { + hoursRemaining, + expiresAt: new Date(expires).toISOString(), + maskedAccess: maskToken(anthropic.access) + }); + } else { + info("Anthropic token valid", { + hoursRemaining, + expiresAt: new Date(expires).toISOString() + }); + } + return { + valid: true, + exists: true, + expiresSoon, + timeRemainingMs, + expiresAt: new Date(expires), + maskedAccess: maskToken(anthropic.access), + reason: expiresSoon ? "expires_soon" : "valid" + }; +} +function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds) { + const auth = readAuthFile(); + if (!auth) { + error("Cannot update tokens: auth.json not readable"); + return false; + } + const expiresAt = Date.now() + expiresInSeconds * 1000; + auth.anthropic = { + type: "oauth", + access: accessToken, + refresh: refreshToken, + expires: expiresAt + }; + const success = writeAuthFile(auth); + if (success) { + info("Updated anthropic tokens", { + expiresAt: new Date(expiresAt).toISOString(), + maskedAccess: maskToken(accessToken) + }); + } + return success; +} +export { + updateAnthropicTokens, + readAuthFile, + maskToken, + checkAnthropicToken +}; diff --git a/.opencode/plugins/lib/token-utils.ts b/.opencode/plugins/lib/token-utils.ts index 633c5e2c..175f5c03 100644 --- a/.opencode/plugins/lib/token-utils.ts +++ b/.opencode/plugins/lib/token-utils.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { error, info, warn } from "./file-logger.ts"; const AUTH_FILE = path.join(os.homedir(), ".local", "share", "opencode", "auth.json"); -const REFRESH_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours +const REFRESH_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour - refresh when < 60 min remaining (proactive) export interface TokenStatus { valid: boolean; @@ -16,7 +16,7 @@ export interface TokenStatus { reason: string; } -interface AuthFile { +export interface AuthFile { anthropic?: { type: string; access?: string; @@ -26,7 +26,7 @@ interface AuthFile { [key: string]: unknown; } -function readAuthFile(): AuthFile | null { +export function readAuthFile(): AuthFile | null { try { const content = fs.readFileSync(AUTH_FILE, "utf8"); return JSON.parse(content) as AuthFile; @@ -37,11 +37,18 @@ function readAuthFile(): AuthFile | null { } function writeAuthFile(authData: AuthFile): boolean { + // Atomic write: write to a temp file then rename over the target. + // This 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) }); + // Clean up temp file if rename failed + try { fs.unlinkSync(tmpFile); } catch { /* already gone or never created */ } return false; } } @@ -73,8 +80,30 @@ export function checkAnthropicToken(): TokenStatus { }; } + // Validate required fields before computing time + 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) { diff --git a/contrib/anthropic-max-bridge/README.md b/contrib/anthropic-max-bridge/README.md index c081a152..737d52fb 100644 --- a/contrib/anthropic-max-bridge/README.md +++ b/contrib/anthropic-max-bridge/README.md @@ -85,9 +85,13 @@ You'll see **$0 input / $0 output** cost because it uses your subscription. Anthropic OAuth tokens expire after **8–12 hours**. -**Auto-refresh (default):** The `anthropic-token-bridge` plugin checks your token every 5 messages and refreshes it automatically from the macOS Keychain — no action needed. +**Auto-refresh (fully silent):** The `anthropic-token-bridge` plugin refreshes your token automatically — no browser, no manual steps required. -**Manual refresh (fallback):** If auto-refresh fails for any reason, run: +- 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 + +**Manual refresh (fallback):** If auto-refresh fails for any reason (e.g. refresh token revoked), run: ```bash bash refresh-token.sh ``` @@ -95,8 +99,8 @@ bash refresh-token.sh Then restart OpenCode. > [!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. +> 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. --- diff --git a/contrib/anthropic-max-bridge/TECHNICAL.md b/contrib/anthropic-max-bridge/TECHNICAL.md index 3a2a415c..bd4ef87e 100644 --- a/contrib/anthropic-max-bridge/TECHNICAL.md +++ b/contrib/anthropic-max-bridge/TECHNICAL.md @@ -166,9 +166,14 @@ The gonzalosr Gist and other community approaches added all of these. None of th - Access tokens expire after **~8–12 hours** - The `anthropic-token-bridge` plugin handles refresh automatically: - - Checks token status every 5 user messages - - Triggers async refresh when <2 hours remaining - - 3 strategies in order: Keychain → `claude setup-token` → setup token exchange API + - **config hook**: refreshes silently at OpenCode startup before any auth check → prevents browser popup + - **keep-alive ping**: hits `/api/oauth/usage` every 30 min to prevent inactivity expiry + - Checks token status every 5 user messages (proactive: triggers when <1 hour remaining) + - 3 strategies in order: + 1. **OAuth `refresh_token` API** (silent, no browser) — `POST /v1/oauth/token` with `User-Agent: claude-cli/2.0` + 2. Keychain sync (if OAuth refresh fails) + 3. `claude setup-token` exchange (last resort, may trigger browser) + - 3× retry with exponential backoff (2s, 4s, 8s) for 429 rate-limit responses - 5-minute cooldown between refresh attempts - All log output goes to `/tmp/pai-opencode-debug.log` (TUI-safe) - Manual fallback: re-run `refresh-token.sh` if auto-refresh fails, then restart OpenCode