From 11e5e882956346f8e1c4c2856a287a9e5eb07469 Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:59:18 -0400 Subject: [PATCH 1/4] feat(oauth): add runtime auto-refresh token bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add anthropic-token-bridge.js/.ts: checks token every 5 messages, auto-refreshes from macOS Keychain when <2h remaining - Add lib/token-utils.ts + lib/refresh-manager.ts: token lifecycle logic (3 strategies: Keychain → claude setup-token → exchange API) - Add info()/warn()/error() wrappers to file-logger.ts (needed by token bridge) - Add anthropic-token-bridge.js to contrib/anthropic-max-bridge/plugins/ - Update contrib/install.sh: copies both plugins, updates token refresh note --- .opencode/plugins/anthropic-token-bridge.js | 405 ++++++++++++++++++ .opencode/plugins/anthropic-token-bridge.ts | 95 ++++ .opencode/plugins/lib/file-logger.ts | 24 ++ .opencode/plugins/lib/refresh-manager.ts | 228 ++++++++++ .opencode/plugins/lib/token-utils.ts | 149 +++++++ contrib/anthropic-max-bridge/install.sh | 9 +- .../plugins/anthropic-token-bridge.js | 405 ++++++++++++++++++ 7 files changed, 1312 insertions(+), 3 deletions(-) create mode 100644 .opencode/plugins/anthropic-token-bridge.js create mode 100644 .opencode/plugins/anthropic-token-bridge.ts create mode 100644 .opencode/plugins/lib/refresh-manager.ts create mode 100644 .opencode/plugins/lib/token-utils.ts create mode 100644 contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js diff --git a/.opencode/plugins/anthropic-token-bridge.js b/.opencode/plugins/anthropic-token-bridge.js new file mode 100644 index 00000000..da91f427 --- /dev/null +++ b/.opencode/plugins/anthropic-token-bridge.js @@ -0,0 +1,405 @@ +// lib/file-logger.ts +import { appendFileSync, mkdirSync, existsSync, writeFileSync } from "fs"; +import { dirname } from "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 fileLogError(message, error) { + const errorMessage = error instanceof Error ? `${error.message} +${error.stack || ""}` : String(error); + fileLog(`${message}: ${errorMessage}`, "error"); +} +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"); +} + +// lib/token-utils.ts +import * as fs from "node:fs"; +import * as path from "node:path"; +var AUTH_FILE = path.join(process.env.HOME || "~", ".local", "share", "opencode", "auth.json"); +var REFRESH_THRESHOLD_MS = 2 * 60 * 60 * 1000; +async function readAuthFile() { + try { + const content = fs.readFileSync(AUTH_FILE, "utf8"); + return JSON.parse(content); + } catch (err) { + await error("Failed to read auth.json", { error: String(err) }); + return null; + } +} +async function writeAuthFile(authData) { + try { + fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2) + ` +`); + return true; + } catch (err) { + await error("Failed to write auth.json", { error: String(err) }); + return false; + } +} +async function checkAnthropicToken() { + const auth = await 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) + }; + } + const now = Date.now(); + const expires = anthropic.expires; + const timeRemainingMs = expires - now; + if (timeRemainingMs <= 0) { + await 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) { + await warn("Anthropic token expires soon", { + hoursRemaining, + expiresAt: new Date(expires).toISOString(), + maskedAccess: maskToken(anthropic.access) + }); + } else { + await 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" + }; +} +async function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds) { + const auth = await readAuthFile(); + if (!auth) { + await 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 = await writeAuthFile(auth); + if (success) { + await info("Updated anthropic tokens", { + expiresAt: new Date(expiresAt).toISOString(), + maskedAccess: maskToken(accessToken) + }); + } + 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 "child_process"; +import { promisify } from "util"; +var exec = promisify(spawn); +var isRefreshing = false; +var lastRefreshAttempt = 0; +var REFRESH_COOLDOWN_MS = 5 * 60 * 1000; +function isRefreshInProgress() { + if (isRefreshing) + return true; + const timeSinceLastAttempt = Date.now() - lastRefreshAttempt; + return timeSinceLastAttempt < REFRESH_COOLDOWN_MS; +} +async function execCommand(command, args) { + return new Promise((resolve) => { + const child = spawn(command, args); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + child.on("close", (exitCode) => { + resolve({ stdout, stderr, exitCode: exitCode ?? 0 }); + }); + child.on("error", (err) => { + resolve({ 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) { + await error("Failed to extract from Keychain", { exitCode, stderr: stdout }); + return null; + } + const credentials = JSON.parse(stdout.trim()); + const accessToken = credentials.accessToken || credentials.access_token; + const refreshToken = credentials.refreshToken || credentials.refresh_token; + if (!accessToken || !refreshToken) { + await error("Invalid credentials format from Keychain", { + hasAccess: !!accessToken, + hasRefresh: !!refreshToken + }); + return null; + } + return { + accessToken, + refreshToken + }; + } catch (err) { + await error("Exception extracting from Keychain", { error: String(err) }); + return null; + } +} +async function generateSetupToken() { + try { + await info("Generating new setup token via Claude Code"); + const { stdout, exitCode, stderr } = await execCommand("claude", ["setup-token"]); + if (exitCode !== 0) { + await error("claude setup-token failed", { exitCode, stderr }); + return null; + } + const tokenMatch = stdout.match(/sk-ant-[a-z0-9-]+/i); + if (!tokenMatch) { + await error("Could not find token in Claude output", { output: stdout.slice(0, 200) }); + return null; + } + await info("Successfully generated setup token"); + return tokenMatch[0]; + } catch (err) { + await error("Exception generating setup token", { error: String(err) }); + return null; + } +} +async function exchangeSetupToken(setupToken) { + try { + await 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) { + await error("Token exchange failed", { status: response.status }); + return null; + } + const data = await response.json(); + if (!data.access_token || !data.refresh_token) { + await 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) { + await error("Exception exchanging setup token", { error: String(err) }); + return null; + } +} +async function refreshAnthropicToken() { + if (isRefreshing) { + await info("Refresh already in progress, skipping"); + return false; + } + const timeSinceLastAttempt = Date.now() - lastRefreshAttempt; + if (timeSinceLastAttempt < REFRESH_COOLDOWN_MS) { + await info("Refresh on cooldown", { minutesRemaining: Math.ceil((REFRESH_COOLDOWN_MS - timeSinceLastAttempt) / 60000) }); + return false; + } + isRefreshing = true; + lastRefreshAttempt = Date.now(); + try { + await info("Starting token refresh process"); + const keychainTokens = await extractFromKeychain(); + if (keychainTokens) { + await info("Found tokens in Keychain, updating auth.json"); + const success2 = await updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, 28800); + if (success2) { + await info("Token refresh successful via Keychain"); + return true; + } + } + await info("Keychain extraction failed, generating new setup token"); + const setupToken = await generateSetupToken(); + if (!setupToken) { + await error("Failed to generate setup token - is Claude Code authenticated?"); + return false; + } + const oauthTokens = await exchangeSetupToken(setupToken); + if (!oauthTokens) { + await error("Failed to exchange setup token"); + return false; + } + const success = await updateAnthropicTokens(oauthTokens.accessToken, oauthTokens.refreshToken, oauthTokens.expiresIn); + if (success) { + await info("Token refresh successful via setup token exchange"); + return true; + } + return false; + } catch (err) { + await error("Unexpected error during refresh", { error: String(err) }); + return false; + } finally { + isRefreshing = false; + } +} + +// anthropic-token-bridge.ts +var CHECK_INTERVAL_MESSAGES = 5; +var messageCount = 0; +async function AnthropicTokenBridge() { + fileLog("AnthropicTokenBridge plugin loaded", "info"); + return { + async "chat.message"(input, output) { + if (input.role !== "user") { + return; + } + messageCount++; + if (messageCount % CHECK_INTERVAL_MESSAGES !== 0) { + return; + } + try { + fileLog(`Checking token status (message #${messageCount})`, "debug"); + const status = await checkAnthropicToken(); + if (!status.exists) { + fileLog("No Anthropic OAuth token configured", "debug"); + return; + } + if (status.valid && !status.expiresSoon) { + fileLog("Token valid, no refresh needed", "debug"); + return; + } + if (status.expiresSoon) { + 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); + }); + } + } 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"); + const status = await checkAnthropicToken(); + if (!status.exists) { + fileLog("No Anthropic token configured at session start", "debug"); + return; + } + if (status.expiresSoon) { + fileLog("Token expires soon at session start, scheduling refresh", "warn"); + setTimeout(() => { + if (!isRefreshInProgress()) { + refreshAnthropicToken().then((success) => { + if (success) { + fileLog("Session-start refresh successful", "info"); + } + }).catch((err) => { + fileLogError("Session-start refresh error", err); + }); + } + }, 5000); + } else { + const hoursRemaining = Math.floor(status.timeRemainingMs / (60 * 60 * 1000)); + fileLog(`Token valid for ${hoursRemaining}h at session start`, "info"); + } + } catch (err) { + fileLogError("Error in system.transform handler", err); + } + } + }; +} +export { + AnthropicTokenBridge as default +}; diff --git a/.opencode/plugins/anthropic-token-bridge.ts b/.opencode/plugins/anthropic-token-bridge.ts new file mode 100644 index 00000000..0264cfcb --- /dev/null +++ b/.opencode/plugins/anthropic-token-bridge.ts @@ -0,0 +1,95 @@ +import { fileLog, fileLogError } from "./lib/file-logger.ts"; +import { isRefreshInProgress, refreshAnthropicToken } from "./lib/refresh-manager.ts"; +import { checkAnthropicToken } from "./lib/token-utils.ts"; + +const CHECK_INTERVAL_MESSAGES = 5; +let messageCount = 0; + +async function AnthropicTokenBridge() { + fileLog("AnthropicTokenBridge plugin loaded", "info"); + + return { + // Check token every N user messages, refresh automatically if expiring + async "chat.message"(input: { role: string }, _output: unknown) { + if (input.role !== "user") return; + + messageCount++; + 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"); + return; + } + + if (status.valid && !status.expiresSoon) { + fileLog("Token valid, 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: 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 + async "experimental.chat.system.transform"(_input: unknown, _output: unknown) { + try { + fileLog("Session started, checking initial token status", "info"); + const status = checkAnthropicToken(); + + if (!status.exists) { + fileLog("No Anthropic token configured at session start", "debug"); + return; + } + + if (status.expiresSoon || !status.valid) { + fileLog("Token expires soon at session start, scheduling refresh", "warn"); + setTimeout(() => { + if (!isRefreshInProgress()) { + refreshAnthropicToken() + .then((success) => { + if (success) fileLog("Session-start refresh successful", "info"); + }) + .catch((err: unknown) => { + fileLogError("Session-start refresh error", err); + }); + } + }, 5000); + } else { + const hoursRemaining = Math.floor(status.timeRemainingMs / (60 * 60 * 1000)); + fileLog(`Token valid for ${hoursRemaining}h at session start`, "info"); + } + } catch (err) { + fileLogError("Error in system.transform handler", err); + } + }, + }; +} + +export default AnthropicTokenBridge; diff --git a/.opencode/plugins/lib/file-logger.ts b/.opencode/plugins/lib/file-logger.ts index 6f551e8b..be73dd24 100644 --- a/.opencode/plugins/lib/file-logger.ts +++ b/.opencode/plugins/lib/file-logger.ts @@ -75,3 +75,27 @@ export function clearLog(): void { // Silent fail } } + +/** + * Info level wrapper + */ +export function info(message: string, meta?: Record): void { + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ""; + fileLog(`${message}${metaStr}`, "info"); +} + +/** + * Warn level wrapper + */ +export function warn(message: string, meta?: Record): void { + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ""; + fileLog(`${message}${metaStr}`, "warn"); +} + +/** + * Error level wrapper + */ +export function error(message: string, meta?: Record): void { + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ""; + fileLog(`${message}${metaStr}`, "error"); +} diff --git a/.opencode/plugins/lib/refresh-manager.ts b/.opencode/plugins/lib/refresh-manager.ts new file mode 100644 index 00000000..603efed4 --- /dev/null +++ b/.opencode/plugins/lib/refresh-manager.ts @@ -0,0 +1,228 @@ +import { spawn } from "node:child_process"; +import { error, info } from "./file-logger.ts"; +import { updateAnthropicTokens } from "./token-utils.ts"; + +const REFRESH_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes + +let isRefreshing = false; +let lastRefreshAttempt = 0; + +export function isRefreshInProgress(): boolean { + if (isRefreshing) return true; + return Date.now() - lastRefreshAttempt < REFRESH_COOLDOWN_MS; +} + +interface ExecResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function execCommand(command: string, args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn(command, args); + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + child.on("close", (exitCode: number | null) => { + resolve({ stdout, stderr, exitCode: exitCode ?? 0 }); + }); + child.on("error", (err: Error) => { + resolve({ stdout, stderr: String(err), exitCode: 1 }); + }); + }); +} + +interface KeychainTokens { + accessToken: string; + refreshToken: string; +} + +async function extractFromKeychain(): Promise { + 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()) as { + // Keychain stores camelCase (Claude Code CLI format) + accessToken?: string; + refreshToken?: string; + // Fallback for snake_case (older format) + access_token?: string; + refresh_token?: string; + }; + + const accessToken = credentials.accessToken ?? credentials.access_token; + const refreshToken = credentials.refreshToken ?? credentials.refresh_token; + + if (!accessToken || !refreshToken) { + error("Invalid credentials format from Keychain", { + hasAccess: !!accessToken, + hasRefresh: !!refreshToken, + }); + return null; + } + + return { accessToken, refreshToken }; + } catch (err) { + error("Exception extracting from Keychain", { error: String(err) }); + return null; + } +} + +async function generateSetupToken(): Promise { + 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", { output: stdout.slice(0, 200) }); + return null; + } + + info("Successfully generated setup token"); + return tokenMatch[0]; + } catch (err) { + error("Exception generating setup token", { error: String(err) }); + return null; + } +} + +interface OAuthTokens { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +async function exchangeSetupToken(setupToken: string): Promise { + 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()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + 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; + } +} + +export async function refreshAnthropicToken(): Promise { + 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"); + + // Strategy 1: Extract from macOS Keychain + const keychainTokens = await extractFromKeychain(); + if (keychainTokens) { + info("Found tokens in Keychain, updating auth.json"); + const success = updateAnthropicTokens( + keychainTokens.accessToken, + keychainTokens.refreshToken, + 28800, + ); + if (success) { + info("Token refresh successful via Keychain"); + return true; + } + } + + // Strategy 2: Generate new setup token via Claude Code CLI + 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; + } + + // Strategy 3: Exchange setup token for OAuth credentials + 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; + } +} diff --git a/.opencode/plugins/lib/token-utils.ts b/.opencode/plugins/lib/token-utils.ts new file mode 100644 index 00000000..73b8f534 --- /dev/null +++ b/.opencode/plugins/lib/token-utils.ts @@ -0,0 +1,149 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { error, info, warn } from "./file-logger.ts"; + +const AUTH_FILE = path.join(process.env.HOME || "~", ".local", "share", "opencode", "auth.json"); +const REFRESH_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours + +export interface TokenStatus { + valid: boolean; + exists: boolean; + expiresSoon: boolean; + timeRemainingMs: number; + expiresAt?: Date; + maskedAccess?: string; + reason: string; +} + +interface AuthFile { + anthropic?: { + type: string; + access?: string; + refresh?: string; + expires?: number; + }; + [key: string]: unknown; +} + +function readAuthFile(): AuthFile | null { + try { + const content = fs.readFileSync(AUTH_FILE, "utf8"); + return JSON.parse(content) as AuthFile; + } catch (err) { + error("Failed to read auth.json", { error: String(err) }); + return null; + } +} + +function writeAuthFile(authData: AuthFile): boolean { + try { + fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2) + "\n"); + return true; + } catch (err) { + error("Failed to write auth.json", { error: String(err) }); + return false; + } +} + +export function maskToken(token: string | undefined): string { + if (!token || token.length < 20) return "***"; + return `${token.slice(0, 8)}...${token.slice(-4)}`; +} + +export function checkAnthropicToken(): TokenStatus { + 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), + }; + } + + const now = Date.now(); + const expires = anthropic.expires ?? 0; + 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", + }; +} + +export function updateAnthropicTokens( + accessToken: string, + refreshToken: string, + expiresInSeconds: number, +): boolean { + 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; +} diff --git a/contrib/anthropic-max-bridge/install.sh b/contrib/anthropic-max-bridge/install.sh index d173d7b3..811a46ca 100644 --- a/contrib/anthropic-max-bridge/install.sh +++ b/contrib/anthropic-max-bridge/install.sh @@ -130,8 +130,11 @@ info "Installing plugin to $PLUGIN_DIR ..." mkdir -p "$PLUGIN_DIR" cp "$SCRIPT_DIR/plugins/anthropic-max-bridge.js" "$PLUGIN_DIR/anthropic-max-bridge.js" +cp "$SCRIPT_DIR/plugins/anthropic-token-bridge.js" "$PLUGIN_DIR/anthropic-token-bridge.js" -ok "Plugin installed: $PLUGIN_DIR/anthropic-max-bridge.js" +ok "Plugins installed:" +ok " $PLUGIN_DIR/anthropic-max-bridge.js" +ok " $PLUGIN_DIR/anthropic-token-bridge.js" # ── Step 4: Write token to auth.json ───────────────────────── @@ -213,8 +216,8 @@ echo " (or any other claude-* model)" echo "" echo "Token refresh:" echo " Tokens expire after ~8-12 hours." -echo " When expired, run: bash refresh-token.sh" -echo " (Claude Code auto-refreshes its own token in the background)" +echo " The token-bridge plugin refreshes automatically every 5 messages." +echo " If needed, you can also refresh manually: bash refresh-token.sh" echo "" echo -e "${YELLOW}Note:${RESET} This uses your Max subscription quota." echo " All models show \$0 cost in the UI (already paid for)." diff --git a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js new file mode 100644 index 00000000..da91f427 --- /dev/null +++ b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js @@ -0,0 +1,405 @@ +// lib/file-logger.ts +import { appendFileSync, mkdirSync, existsSync, writeFileSync } from "fs"; +import { dirname } from "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 fileLogError(message, error) { + const errorMessage = error instanceof Error ? `${error.message} +${error.stack || ""}` : String(error); + fileLog(`${message}: ${errorMessage}`, "error"); +} +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"); +} + +// lib/token-utils.ts +import * as fs from "node:fs"; +import * as path from "node:path"; +var AUTH_FILE = path.join(process.env.HOME || "~", ".local", "share", "opencode", "auth.json"); +var REFRESH_THRESHOLD_MS = 2 * 60 * 60 * 1000; +async function readAuthFile() { + try { + const content = fs.readFileSync(AUTH_FILE, "utf8"); + return JSON.parse(content); + } catch (err) { + await error("Failed to read auth.json", { error: String(err) }); + return null; + } +} +async function writeAuthFile(authData) { + try { + fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2) + ` +`); + return true; + } catch (err) { + await error("Failed to write auth.json", { error: String(err) }); + return false; + } +} +async function checkAnthropicToken() { + const auth = await 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) + }; + } + const now = Date.now(); + const expires = anthropic.expires; + const timeRemainingMs = expires - now; + if (timeRemainingMs <= 0) { + await 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) { + await warn("Anthropic token expires soon", { + hoursRemaining, + expiresAt: new Date(expires).toISOString(), + maskedAccess: maskToken(anthropic.access) + }); + } else { + await 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" + }; +} +async function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds) { + const auth = await readAuthFile(); + if (!auth) { + await 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 = await writeAuthFile(auth); + if (success) { + await info("Updated anthropic tokens", { + expiresAt: new Date(expiresAt).toISOString(), + maskedAccess: maskToken(accessToken) + }); + } + 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 "child_process"; +import { promisify } from "util"; +var exec = promisify(spawn); +var isRefreshing = false; +var lastRefreshAttempt = 0; +var REFRESH_COOLDOWN_MS = 5 * 60 * 1000; +function isRefreshInProgress() { + if (isRefreshing) + return true; + const timeSinceLastAttempt = Date.now() - lastRefreshAttempt; + return timeSinceLastAttempt < REFRESH_COOLDOWN_MS; +} +async function execCommand(command, args) { + return new Promise((resolve) => { + const child = spawn(command, args); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + child.on("close", (exitCode) => { + resolve({ stdout, stderr, exitCode: exitCode ?? 0 }); + }); + child.on("error", (err) => { + resolve({ 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) { + await error("Failed to extract from Keychain", { exitCode, stderr: stdout }); + return null; + } + const credentials = JSON.parse(stdout.trim()); + const accessToken = credentials.accessToken || credentials.access_token; + const refreshToken = credentials.refreshToken || credentials.refresh_token; + if (!accessToken || !refreshToken) { + await error("Invalid credentials format from Keychain", { + hasAccess: !!accessToken, + hasRefresh: !!refreshToken + }); + return null; + } + return { + accessToken, + refreshToken + }; + } catch (err) { + await error("Exception extracting from Keychain", { error: String(err) }); + return null; + } +} +async function generateSetupToken() { + try { + await info("Generating new setup token via Claude Code"); + const { stdout, exitCode, stderr } = await execCommand("claude", ["setup-token"]); + if (exitCode !== 0) { + await error("claude setup-token failed", { exitCode, stderr }); + return null; + } + const tokenMatch = stdout.match(/sk-ant-[a-z0-9-]+/i); + if (!tokenMatch) { + await error("Could not find token in Claude output", { output: stdout.slice(0, 200) }); + return null; + } + await info("Successfully generated setup token"); + return tokenMatch[0]; + } catch (err) { + await error("Exception generating setup token", { error: String(err) }); + return null; + } +} +async function exchangeSetupToken(setupToken) { + try { + await 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) { + await error("Token exchange failed", { status: response.status }); + return null; + } + const data = await response.json(); + if (!data.access_token || !data.refresh_token) { + await 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) { + await error("Exception exchanging setup token", { error: String(err) }); + return null; + } +} +async function refreshAnthropicToken() { + if (isRefreshing) { + await info("Refresh already in progress, skipping"); + return false; + } + const timeSinceLastAttempt = Date.now() - lastRefreshAttempt; + if (timeSinceLastAttempt < REFRESH_COOLDOWN_MS) { + await info("Refresh on cooldown", { minutesRemaining: Math.ceil((REFRESH_COOLDOWN_MS - timeSinceLastAttempt) / 60000) }); + return false; + } + isRefreshing = true; + lastRefreshAttempt = Date.now(); + try { + await info("Starting token refresh process"); + const keychainTokens = await extractFromKeychain(); + if (keychainTokens) { + await info("Found tokens in Keychain, updating auth.json"); + const success2 = await updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, 28800); + if (success2) { + await info("Token refresh successful via Keychain"); + return true; + } + } + await info("Keychain extraction failed, generating new setup token"); + const setupToken = await generateSetupToken(); + if (!setupToken) { + await error("Failed to generate setup token - is Claude Code authenticated?"); + return false; + } + const oauthTokens = await exchangeSetupToken(setupToken); + if (!oauthTokens) { + await error("Failed to exchange setup token"); + return false; + } + const success = await updateAnthropicTokens(oauthTokens.accessToken, oauthTokens.refreshToken, oauthTokens.expiresIn); + if (success) { + await info("Token refresh successful via setup token exchange"); + return true; + } + return false; + } catch (err) { + await error("Unexpected error during refresh", { error: String(err) }); + return false; + } finally { + isRefreshing = false; + } +} + +// anthropic-token-bridge.ts +var CHECK_INTERVAL_MESSAGES = 5; +var messageCount = 0; +async function AnthropicTokenBridge() { + fileLog("AnthropicTokenBridge plugin loaded", "info"); + return { + async "chat.message"(input, output) { + if (input.role !== "user") { + return; + } + messageCount++; + if (messageCount % CHECK_INTERVAL_MESSAGES !== 0) { + return; + } + try { + fileLog(`Checking token status (message #${messageCount})`, "debug"); + const status = await checkAnthropicToken(); + if (!status.exists) { + fileLog("No Anthropic OAuth token configured", "debug"); + return; + } + if (status.valid && !status.expiresSoon) { + fileLog("Token valid, no refresh needed", "debug"); + return; + } + if (status.expiresSoon) { + 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); + }); + } + } 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"); + const status = await checkAnthropicToken(); + if (!status.exists) { + fileLog("No Anthropic token configured at session start", "debug"); + return; + } + if (status.expiresSoon) { + fileLog("Token expires soon at session start, scheduling refresh", "warn"); + setTimeout(() => { + if (!isRefreshInProgress()) { + refreshAnthropicToken().then((success) => { + if (success) { + fileLog("Session-start refresh successful", "info"); + } + }).catch((err) => { + fileLogError("Session-start refresh error", err); + }); + } + }, 5000); + } else { + const hoursRemaining = Math.floor(status.timeRemainingMs / (60 * 60 * 1000)); + fileLog(`Token valid for ${hoursRemaining}h at session start`, "info"); + } + } catch (err) { + fileLogError("Error in system.transform handler", err); + } + } + }; +} +export { + AnthropicTokenBridge as default +}; From 050eed774d7c79e0dd7d00a8d33d1b9e0889bb56 Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:05:42 -0400 Subject: [PATCH 2/4] docs(contrib): update README + TECHNICAL for auto-refresh token bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: Token Expiry → auto-refresh primary, manual fallback secondary - README: File Reference → add anthropic-token-bridge.js entry - README: install.sh step → mention both plugins copied - README: token flow diagram → show both plugins - README: HTTP 401 troubleshooting → mention auto-refresh failed - TECHNICAL: Token lifetime → replace manual-only with auto-refresh details --- contrib/anthropic-max-bridge/README.md | 20 ++++++++++++-------- contrib/anthropic-max-bridge/TECHNICAL.md | 10 +++++++--- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/contrib/anthropic-max-bridge/README.md b/contrib/anthropic-max-bridge/README.md index 6e428c2a..c081a152 100644 --- a/contrib/anthropic-max-bridge/README.md +++ b/contrib/anthropic-max-bridge/README.md @@ -63,7 +63,7 @@ bash install.sh That's it. The script will: 1. Extract your OAuth token from the macOS Keychain -2. Copy the plugin to `~/.opencode/plugins/anthropic-max-bridge.js` +2. Copy both plugins to `~/.opencode/plugins/` 3. Write the token into `~/.local/share/opencode/auth.json` 4. Tell you how long the token is valid for @@ -85,7 +85,9 @@ You'll see **$0 input / $0 output** cost because it uses your subscription. Anthropic OAuth tokens expire after **8–12 hours**. -When you get an auth error, run: +**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. + +**Manual refresh (fallback):** If auto-refresh fails for any reason, run: ```bash bash refresh-token.sh ``` @@ -94,8 +96,7 @@ Then restart OpenCode. > [!tip] > Claude Code silently refreshes its own token in the background whenever you use it. -> So if you use the `claude` CLI regularly, your token in the Keychain is usually fresh. -> Just re-run `refresh-token.sh` to pull it into OpenCode. +> So the Keychain always has a fresh token after any `claude` use — which is what the auto-refresh plugin pulls from. --- @@ -105,10 +106,11 @@ Then restart OpenCode. contrib/anthropic-max-bridge/ ├── README.md ← You are here ├── install.sh ← One-time setup (run this first) -├── refresh-token.sh ← Run when token expires +├── refresh-token.sh ← Manual fallback token refresh ├── TECHNICAL.md ← Deep dive: how the API fix works └── plugins/ - └── anthropic-max-bridge.js ← The OpenCode plugin + ├── anthropic-max-bridge.js ← API fix plugin (3 OAuth fixes) + └── anthropic-token-bridge.js ← Auto-refresh plugin (every 5 messages) ``` --- @@ -127,7 +129,9 @@ install.sh / refresh-token.sh OpenCode └─ reads auth.json on startup - └─ plugin applies 3 API fixes on every request + └─ anthropic-max-bridge: 3 API fixes on every request + └─ anthropic-token-bridge: checks token every 5 messages, + auto-refreshes from Keychain if expiring └─ Anthropic API accepts → response streams back ``` @@ -152,7 +156,7 @@ Send them this folder and have them follow **Quick Start** above. → Run `claude` (to refresh Claude Code's own token), then `bash refresh-token.sh`. ### HTTP 401 in OpenCode -→ Token expired. Run `bash refresh-token.sh`, then restart OpenCode. +→ Token expired and auto-refresh failed. Run `bash refresh-token.sh`, then restart OpenCode. ### HTTP 400 in OpenCode → Plugin not loaded. Check that `~/.opencode/plugins/anthropic-max-bridge.js` exists. diff --git a/contrib/anthropic-max-bridge/TECHNICAL.md b/contrib/anthropic-max-bridge/TECHNICAL.md index 9fb2ff38..3a2a415c 100644 --- a/contrib/anthropic-max-bridge/TECHNICAL.md +++ b/contrib/anthropic-max-bridge/TECHNICAL.md @@ -165,9 +165,13 @@ The gonzalosr Gist and other community approaches added all of these. None of th ## Token lifetime - Access tokens expire after **~8–12 hours** -- Refresh tokens are longer-lived but not currently used (would require hitting the Anthropic token endpoint) -- The simplest refresh path: re-run `refresh-token.sh` after using `claude` CLI - (Claude Code silently refreshes its own token, so the Keychain always has a fresh one after any `claude` use) +- 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 + - 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 --- From 2eeeb89681e024d54a38ef84aed78de38d15ed2f Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:15:33 -0400 Subject: [PATCH 3/4] fix(oauth): address CI findings in token bridge - token-utils.ts: os.homedir() instead of process.env.HOME (avoids unexpanded ~) - refresh-manager.ts: remove dead promisify(spawn), add execCommand timeout (15s), add pragma: allowlist secret on Keychain extractions, mask token in logs - anthropic-token-bridge.ts/.js: read role from output.message instead of input - Regenerate compiled .js from fixed TS sources (both plugin + contrib copy) --- .opencode/plugins/anthropic-token-bridge.js | 194 +++++++++--------- .opencode/plugins/anthropic-token-bridge.ts | 5 +- .opencode/plugins/lib/refresh-manager.ts | 25 ++- .opencode/plugins/lib/token-utils.ts | 3 +- .../plugins/anthropic-token-bridge.js | 194 +++++++++--------- 5 files changed, 210 insertions(+), 211 deletions(-) diff --git a/.opencode/plugins/anthropic-token-bridge.js b/.opencode/plugins/anthropic-token-bridge.js index da91f427..81db2193 100644 --- a/.opencode/plugins/anthropic-token-bridge.js +++ b/.opencode/plugins/anthropic-token-bridge.js @@ -1,13 +1,12 @@ // lib/file-logger.ts -import { appendFileSync, mkdirSync, existsSync, writeFileSync } from "fs"; -import { dirname } from "path"; +import { appendFileSync, existsSync, mkdirSync } 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 logLine = `[${timestamp}] [${levelPrefix}] ${message}\n`; const dir = dirname(LOG_PATH); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); @@ -15,9 +14,8 @@ function fileLog(message, level = "info") { appendFileSync(LOG_PATH, logLine); } catch {} } -function fileLogError(message, error) { - const errorMessage = error instanceof Error ? `${error.message} -${error.stack || ""}` : String(error); +function fileLogError(message, error2) { + const errorMessage = error2 instanceof Error ? `${error2.message}\n${error2.stack || ""}` : String(error2); fileLog(`${message}: ${errorMessage}`, "error"); } function info(message, meta) { @@ -35,48 +33,36 @@ function error(message, meta) { // 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(process.env.HOME || "~", ".local", "share", "opencode", "auth.json"); +var AUTH_FILE = path.join(os.homedir(), ".local", "share", "opencode", "auth.json"); var REFRESH_THRESHOLD_MS = 2 * 60 * 60 * 1000; -async function readAuthFile() { +function readAuthFile() { try { const content = fs.readFileSync(AUTH_FILE, "utf8"); return JSON.parse(content); } catch (err) { - await error("Failed to read auth.json", { error: String(err) }); + error("Failed to read auth.json", { error: String(err) }); return null; } } -async function writeAuthFile(authData) { +function writeAuthFile(authData) { try { - fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2) + ` -`); + fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2) + "\n"); return true; } catch (err) { - await error("Failed to write auth.json", { error: String(err) }); + error("Failed to write auth.json", { error: String(err) }); return false; } } -async function checkAnthropicToken() { - const auth = await readAuthFile(); +function checkAnthropicToken() { + const auth = readAuthFile(); if (!auth) { - return { - valid: false, - exists: false, - expiresSoon: false, - timeRemainingMs: 0, - reason: "auth_file_not_readable" - }; + 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" - }; + return { valid: false, exists: false, expiresSoon: false, timeRemainingMs: 0, reason: "no_anthropic_config" }; } if (anthropic.type !== "oauth") { return { @@ -89,10 +75,10 @@ async function checkAnthropicToken() { }; } const now = Date.now(); - const expires = anthropic.expires; + const expires = anthropic.expires ?? 0; const timeRemainingMs = expires - now; if (timeRemainingMs <= 0) { - await warn("Anthropic token expired", { + warn("Anthropic token expired", { expiredAt: new Date(expires).toISOString(), maskedAccess: maskToken(anthropic.access) }); @@ -109,13 +95,13 @@ async function checkAnthropicToken() { const expiresSoon = timeRemainingMs < REFRESH_THRESHOLD_MS; const hoursRemaining = Math.floor(timeRemainingMs / (60 * 60 * 1000)); if (expiresSoon) { - await warn("Anthropic token expires soon", { + warn("Anthropic token expires soon", { hoursRemaining, expiresAt: new Date(expires).toISOString(), maskedAccess: maskToken(anthropic.access) }); } else { - await info("Anthropic token valid", { + info("Anthropic token valid", { hoursRemaining, expiresAt: new Date(expires).toISOString() }); @@ -130,10 +116,10 @@ async function checkAnthropicToken() { reason: expiresSoon ? "expires_soon" : "valid" }; } -async function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds) { - const auth = await readAuthFile(); +function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds) { + const auth = readAuthFile(); if (!auth) { - await error("Cannot update tokens: auth.json not readable"); + error("Cannot update tokens: auth.json not readable"); return false; } const expiresAt = Date.now() + expiresInSeconds * 1000; @@ -143,9 +129,9 @@ async function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds refresh: refreshToken, expires: expiresAt }; - const success = await writeAuthFile(auth); + const success = writeAuthFile(auth); if (success) { - await info("Updated anthropic tokens", { + info("Updated anthropic tokens", { expiresAt: new Date(expiresAt).toISOString(), maskedAccess: maskToken(accessToken) }); @@ -153,29 +139,36 @@ async function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds return success; } function maskToken(token) { - if (!token || token.length < 20) - return "***"; + if (!token || token.length < 20) return "***"; return `${token.slice(0, 8)}...${token.slice(-4)}`; } // lib/refresh-manager.ts -import { spawn } from "child_process"; -import { promisify } from "util"; -var exec = promisify(spawn); +import { spawn } from "node:child_process"; +var EXEC_TIMEOUT_MS = 15000; +var REFRESH_COOLDOWN_MS = 5 * 60 * 1000; var isRefreshing = false; var lastRefreshAttempt = 0; -var REFRESH_COOLDOWN_MS = 5 * 60 * 1000; function isRefreshInProgress() { - if (isRefreshing) - return true; - const timeSinceLastAttempt = Date.now() - lastRefreshAttempt; - return timeSinceLastAttempt < REFRESH_COOLDOWN_MS; + if (isRefreshing) return true; + return Date.now() - lastRefreshAttempt < REFRESH_COOLDOWN_MS; } -async function execCommand(command, args) { +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(); }); @@ -183,10 +176,10 @@ async function execCommand(command, args) { stderr += data.toString(); }); child.on("close", (exitCode) => { - resolve({ stdout, stderr, exitCode: exitCode ?? 0 }); + settle({ stdout, stderr, exitCode: exitCode ?? 0 }); }); child.on("error", (err) => { - resolve({ stdout, stderr: String(err), exitCode: 1 }); + settle({ stdout, stderr: String(err), exitCode: 1 }); }); }); } @@ -199,51 +192,50 @@ async function extractFromKeychain() { "-w" ]); if (exitCode !== 0) { - await error("Failed to extract from Keychain", { exitCode, stderr: stdout }); + error("Failed to extract from Keychain", { exitCode, stderr: stdout }); return null; } const credentials = JSON.parse(stdout.trim()); - const accessToken = credentials.accessToken || credentials.access_token; - const refreshToken = credentials.refreshToken || credentials.refresh_token; + // pragma: allowlist secret — runtime extraction from macOS Keychain, not hardcoded values + const accessToken = credentials.accessToken ?? credentials.access_token; + const refreshToken = credentials.refreshToken ?? credentials.refresh_token; if (!accessToken || !refreshToken) { - await error("Invalid credentials format from Keychain", { + error("Invalid credentials format from Keychain", { hasAccess: !!accessToken, hasRefresh: !!refreshToken }); return null; } - return { - accessToken, - refreshToken - }; + return { accessToken, refreshToken }; } catch (err) { - await error("Exception extracting from Keychain", { error: String(err) }); + error("Exception extracting from Keychain", { error: String(err) }); return null; } } async function generateSetupToken() { try { - await info("Generating new setup token via Claude Code"); + info("Generating new setup token via Claude Code"); const { stdout, exitCode, stderr } = await execCommand("claude", ["setup-token"]); if (exitCode !== 0) { - await error("claude setup-token failed", { exitCode, stderr }); + 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) { - await error("Could not find token in Claude output", { output: stdout.slice(0, 200) }); + error("Could not find token in Claude output"); return null; } - await info("Successfully generated setup token"); - return tokenMatch[0]; + info("Successfully generated setup token"); + return tokenMatch[0]; // pragma: allowlist secret — runtime value from claude CLI, not hardcoded } catch (err) { - await error("Exception generating setup token", { error: String(err) }); + error("Exception generating setup token", { error: String(err) }); return null; } } async function exchangeSetupToken(setupToken) { try { - await info("Exchanging setup token for OAuth credentials"); + info("Exchanging setup token for OAuth credentials"); const response = await fetch("https://api.anthropic.com/v1/oauth/setup_token/exchange", { method: "POST", headers: { @@ -251,71 +243,74 @@ 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" }) }); if (!response.ok) { - await error("Token exchange failed", { status: response.status }); + error("Token exchange failed", { status: response.status }); return null; } const data = await response.json(); if (!data.access_token || !data.refresh_token) { - await error("Invalid exchange response", { hasAccess: !!data.access_token, hasRefresh: !!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 + expiresIn: data.expires_in ?? 28800 }; } catch (err) { - await error("Exception exchanging setup token", { error: String(err) }); + error("Exception exchanging setup token", { error: String(err) }); return null; } } async function refreshAnthropicToken() { if (isRefreshing) { - await info("Refresh already in progress, skipping"); + info("Refresh already in progress, skipping"); return false; } const timeSinceLastAttempt = Date.now() - lastRefreshAttempt; if (timeSinceLastAttempt < REFRESH_COOLDOWN_MS) { - await info("Refresh on cooldown", { minutesRemaining: Math.ceil((REFRESH_COOLDOWN_MS - timeSinceLastAttempt) / 60000) }); + info("Refresh on cooldown", { + minutesRemaining: Math.ceil((REFRESH_COOLDOWN_MS - timeSinceLastAttempt) / 60000) + }); return false; } isRefreshing = true; lastRefreshAttempt = Date.now(); try { - await info("Starting token refresh process"); + info("Starting token refresh process"); const keychainTokens = await extractFromKeychain(); if (keychainTokens) { - await info("Found tokens in Keychain, updating auth.json"); - const success2 = await updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, 28800); + info("Found tokens in Keychain, updating auth.json"); + const success2 = updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, 28800); if (success2) { - await info("Token refresh successful via Keychain"); + info("Token refresh successful via Keychain"); return true; } } - await info("Keychain extraction failed, generating new setup token"); + info("Keychain extraction failed, generating new setup token"); const setupToken = await generateSetupToken(); if (!setupToken) { - await error("Failed to generate setup token - is Claude Code authenticated?"); + error("Failed to generate setup token - is Claude Code authenticated?"); return false; } const oauthTokens = await exchangeSetupToken(setupToken); if (!oauthTokens) { - await error("Failed to exchange setup token"); + error("Failed to exchange setup token"); return false; } - const success = await updateAnthropicTokens(oauthTokens.accessToken, oauthTokens.refreshToken, oauthTokens.expiresIn); + const success = updateAnthropicTokens(oauthTokens.accessToken, oauthTokens.refreshToken, oauthTokens.expiresIn); if (success) { - await info("Token refresh successful via setup token exchange"); + info("Token refresh successful via setup token exchange"); return true; } return false; } catch (err) { - await error("Unexpected error during refresh", { error: String(err) }); + error("Unexpected error during refresh", { error: String(err) }); return false; } finally { isRefreshing = false; @@ -328,17 +323,14 @@ var messageCount = 0; async function AnthropicTokenBridge() { fileLog("AnthropicTokenBridge plugin loaded", "info"); return { - async "chat.message"(input, output) { - if (input.role !== "user") { - return; - } + async "chat.message"(_input, output) { + const msg = output?.message; + 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 = await checkAnthropicToken(); + const status = checkAnthropicToken(); if (!status.exists) { fileLog("No Anthropic OAuth token configured", "debug"); return; @@ -347,7 +339,7 @@ async function AnthropicTokenBridge() { fileLog("Token valid, no refresh needed", "debug"); return; } - if (status.expiresSoon) { + 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()) { @@ -369,22 +361,20 @@ async function AnthropicTokenBridge() { fileLogError("Error in chat.message handler", err); } }, - async "experimental.chat.system.transform"(input, output) { + async "experimental.chat.system.transform"(_input, _output) { try { fileLog("Session started, checking initial token status", "info"); - const status = await checkAnthropicToken(); + const status = checkAnthropicToken(); if (!status.exists) { fileLog("No Anthropic token configured at session start", "debug"); return; } - if (status.expiresSoon) { + if (status.expiresSoon || !status.valid) { fileLog("Token expires soon at session start, scheduling refresh", "warn"); setTimeout(() => { if (!isRefreshInProgress()) { refreshAnthropicToken().then((success) => { - if (success) { - fileLog("Session-start refresh successful", "info"); - } + if (success) fileLog("Session-start refresh successful", "info"); }).catch((err) => { fileLogError("Session-start refresh error", err); }); diff --git a/.opencode/plugins/anthropic-token-bridge.ts b/.opencode/plugins/anthropic-token-bridge.ts index 0264cfcb..aec82eb0 100644 --- a/.opencode/plugins/anthropic-token-bridge.ts +++ b/.opencode/plugins/anthropic-token-bridge.ts @@ -10,8 +10,9 @@ async function AnthropicTokenBridge() { return { // Check token every N user messages, refresh automatically if expiring - async "chat.message"(input: { role: string }, _output: unknown) { - if (input.role !== "user") return; + async "chat.message"(_input: unknown, output: unknown) { + const msg = (output as { message?: { role?: string } })?.message; + if (!msg?.role || msg.role !== "user") return; messageCount++; if (messageCount % CHECK_INTERVAL_MESSAGES !== 0) return; diff --git a/.opencode/plugins/lib/refresh-manager.ts b/.opencode/plugins/lib/refresh-manager.ts index 603efed4..732c8c91 100644 --- a/.opencode/plugins/lib/refresh-manager.ts +++ b/.opencode/plugins/lib/refresh-manager.ts @@ -2,6 +2,8 @@ import { spawn } from "node:child_process"; import { error, info } 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 let isRefreshing = false; @@ -23,6 +25,19 @@ function execCommand(command: string, args: string[]): Promise { const child = spawn(command, args); let stdout = ""; let stderr = ""; + let settled = false; + + const settle = (result: ExecResult) => { + 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: Buffer) => { stdout += data.toString(); @@ -31,10 +46,10 @@ function execCommand(command: string, args: string[]): Promise { stderr += data.toString(); }); child.on("close", (exitCode: number | null) => { - resolve({ stdout, stderr, exitCode: exitCode ?? 0 }); + settle({ stdout, stderr, exitCode: exitCode ?? 0 }); }); child.on("error", (err: Error) => { - resolve({ stdout, stderr: String(err), exitCode: 1 }); + settle({ stdout, stderr: String(err), exitCode: 1 }); }); }); } @@ -67,6 +82,7 @@ async function extractFromKeychain(): Promise { refresh_token?: string; }; + // pragma: allowlist secret — runtime extraction from macOS Keychain, not hardcoded values const accessToken = credentials.accessToken ?? credentials.access_token; const refreshToken = credentials.refreshToken ?? credentials.refresh_token; @@ -95,14 +111,15 @@ async function generateSetupToken(): Promise { 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", { output: stdout.slice(0, 200) }); + error("Could not find token in Claude output"); return null; } info("Successfully generated setup token"); - return tokenMatch[0]; + return tokenMatch[0]; // pragma: allowlist secret — runtime value from claude CLI, not hardcoded } catch (err) { error("Exception generating setup token", { error: String(err) }); return null; diff --git a/.opencode/plugins/lib/token-utils.ts b/.opencode/plugins/lib/token-utils.ts index 73b8f534..633c5e2c 100644 --- a/.opencode/plugins/lib/token-utils.ts +++ b/.opencode/plugins/lib/token-utils.ts @@ -1,8 +1,9 @@ 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"; -const AUTH_FILE = path.join(process.env.HOME || "~", ".local", "share", "opencode", "auth.json"); +const AUTH_FILE = path.join(os.homedir(), ".local", "share", "opencode", "auth.json"); const REFRESH_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours export interface TokenStatus { diff --git a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js index da91f427..81db2193 100644 --- a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js +++ b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js @@ -1,13 +1,12 @@ // lib/file-logger.ts -import { appendFileSync, mkdirSync, existsSync, writeFileSync } from "fs"; -import { dirname } from "path"; +import { appendFileSync, existsSync, mkdirSync } 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 logLine = `[${timestamp}] [${levelPrefix}] ${message}\n`; const dir = dirname(LOG_PATH); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); @@ -15,9 +14,8 @@ function fileLog(message, level = "info") { appendFileSync(LOG_PATH, logLine); } catch {} } -function fileLogError(message, error) { - const errorMessage = error instanceof Error ? `${error.message} -${error.stack || ""}` : String(error); +function fileLogError(message, error2) { + const errorMessage = error2 instanceof Error ? `${error2.message}\n${error2.stack || ""}` : String(error2); fileLog(`${message}: ${errorMessage}`, "error"); } function info(message, meta) { @@ -35,48 +33,36 @@ function error(message, meta) { // 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(process.env.HOME || "~", ".local", "share", "opencode", "auth.json"); +var AUTH_FILE = path.join(os.homedir(), ".local", "share", "opencode", "auth.json"); var REFRESH_THRESHOLD_MS = 2 * 60 * 60 * 1000; -async function readAuthFile() { +function readAuthFile() { try { const content = fs.readFileSync(AUTH_FILE, "utf8"); return JSON.parse(content); } catch (err) { - await error("Failed to read auth.json", { error: String(err) }); + error("Failed to read auth.json", { error: String(err) }); return null; } } -async function writeAuthFile(authData) { +function writeAuthFile(authData) { try { - fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2) + ` -`); + fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2) + "\n"); return true; } catch (err) { - await error("Failed to write auth.json", { error: String(err) }); + error("Failed to write auth.json", { error: String(err) }); return false; } } -async function checkAnthropicToken() { - const auth = await readAuthFile(); +function checkAnthropicToken() { + const auth = readAuthFile(); if (!auth) { - return { - valid: false, - exists: false, - expiresSoon: false, - timeRemainingMs: 0, - reason: "auth_file_not_readable" - }; + 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" - }; + return { valid: false, exists: false, expiresSoon: false, timeRemainingMs: 0, reason: "no_anthropic_config" }; } if (anthropic.type !== "oauth") { return { @@ -89,10 +75,10 @@ async function checkAnthropicToken() { }; } const now = Date.now(); - const expires = anthropic.expires; + const expires = anthropic.expires ?? 0; const timeRemainingMs = expires - now; if (timeRemainingMs <= 0) { - await warn("Anthropic token expired", { + warn("Anthropic token expired", { expiredAt: new Date(expires).toISOString(), maskedAccess: maskToken(anthropic.access) }); @@ -109,13 +95,13 @@ async function checkAnthropicToken() { const expiresSoon = timeRemainingMs < REFRESH_THRESHOLD_MS; const hoursRemaining = Math.floor(timeRemainingMs / (60 * 60 * 1000)); if (expiresSoon) { - await warn("Anthropic token expires soon", { + warn("Anthropic token expires soon", { hoursRemaining, expiresAt: new Date(expires).toISOString(), maskedAccess: maskToken(anthropic.access) }); } else { - await info("Anthropic token valid", { + info("Anthropic token valid", { hoursRemaining, expiresAt: new Date(expires).toISOString() }); @@ -130,10 +116,10 @@ async function checkAnthropicToken() { reason: expiresSoon ? "expires_soon" : "valid" }; } -async function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds) { - const auth = await readAuthFile(); +function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds) { + const auth = readAuthFile(); if (!auth) { - await error("Cannot update tokens: auth.json not readable"); + error("Cannot update tokens: auth.json not readable"); return false; } const expiresAt = Date.now() + expiresInSeconds * 1000; @@ -143,9 +129,9 @@ async function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds refresh: refreshToken, expires: expiresAt }; - const success = await writeAuthFile(auth); + const success = writeAuthFile(auth); if (success) { - await info("Updated anthropic tokens", { + info("Updated anthropic tokens", { expiresAt: new Date(expiresAt).toISOString(), maskedAccess: maskToken(accessToken) }); @@ -153,29 +139,36 @@ async function updateAnthropicTokens(accessToken, refreshToken, expiresInSeconds return success; } function maskToken(token) { - if (!token || token.length < 20) - return "***"; + if (!token || token.length < 20) return "***"; return `${token.slice(0, 8)}...${token.slice(-4)}`; } // lib/refresh-manager.ts -import { spawn } from "child_process"; -import { promisify } from "util"; -var exec = promisify(spawn); +import { spawn } from "node:child_process"; +var EXEC_TIMEOUT_MS = 15000; +var REFRESH_COOLDOWN_MS = 5 * 60 * 1000; var isRefreshing = false; var lastRefreshAttempt = 0; -var REFRESH_COOLDOWN_MS = 5 * 60 * 1000; function isRefreshInProgress() { - if (isRefreshing) - return true; - const timeSinceLastAttempt = Date.now() - lastRefreshAttempt; - return timeSinceLastAttempt < REFRESH_COOLDOWN_MS; + if (isRefreshing) return true; + return Date.now() - lastRefreshAttempt < REFRESH_COOLDOWN_MS; } -async function execCommand(command, args) { +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(); }); @@ -183,10 +176,10 @@ async function execCommand(command, args) { stderr += data.toString(); }); child.on("close", (exitCode) => { - resolve({ stdout, stderr, exitCode: exitCode ?? 0 }); + settle({ stdout, stderr, exitCode: exitCode ?? 0 }); }); child.on("error", (err) => { - resolve({ stdout, stderr: String(err), exitCode: 1 }); + settle({ stdout, stderr: String(err), exitCode: 1 }); }); }); } @@ -199,51 +192,50 @@ async function extractFromKeychain() { "-w" ]); if (exitCode !== 0) { - await error("Failed to extract from Keychain", { exitCode, stderr: stdout }); + error("Failed to extract from Keychain", { exitCode, stderr: stdout }); return null; } const credentials = JSON.parse(stdout.trim()); - const accessToken = credentials.accessToken || credentials.access_token; - const refreshToken = credentials.refreshToken || credentials.refresh_token; + // pragma: allowlist secret — runtime extraction from macOS Keychain, not hardcoded values + const accessToken = credentials.accessToken ?? credentials.access_token; + const refreshToken = credentials.refreshToken ?? credentials.refresh_token; if (!accessToken || !refreshToken) { - await error("Invalid credentials format from Keychain", { + error("Invalid credentials format from Keychain", { hasAccess: !!accessToken, hasRefresh: !!refreshToken }); return null; } - return { - accessToken, - refreshToken - }; + return { accessToken, refreshToken }; } catch (err) { - await error("Exception extracting from Keychain", { error: String(err) }); + error("Exception extracting from Keychain", { error: String(err) }); return null; } } async function generateSetupToken() { try { - await info("Generating new setup token via Claude Code"); + info("Generating new setup token via Claude Code"); const { stdout, exitCode, stderr } = await execCommand("claude", ["setup-token"]); if (exitCode !== 0) { - await error("claude setup-token failed", { exitCode, stderr }); + 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) { - await error("Could not find token in Claude output", { output: stdout.slice(0, 200) }); + error("Could not find token in Claude output"); return null; } - await info("Successfully generated setup token"); - return tokenMatch[0]; + info("Successfully generated setup token"); + return tokenMatch[0]; // pragma: allowlist secret — runtime value from claude CLI, not hardcoded } catch (err) { - await error("Exception generating setup token", { error: String(err) }); + error("Exception generating setup token", { error: String(err) }); return null; } } async function exchangeSetupToken(setupToken) { try { - await info("Exchanging setup token for OAuth credentials"); + info("Exchanging setup token for OAuth credentials"); const response = await fetch("https://api.anthropic.com/v1/oauth/setup_token/exchange", { method: "POST", headers: { @@ -251,71 +243,74 @@ 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" }) }); if (!response.ok) { - await error("Token exchange failed", { status: response.status }); + error("Token exchange failed", { status: response.status }); return null; } const data = await response.json(); if (!data.access_token || !data.refresh_token) { - await error("Invalid exchange response", { hasAccess: !!data.access_token, hasRefresh: !!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 + expiresIn: data.expires_in ?? 28800 }; } catch (err) { - await error("Exception exchanging setup token", { error: String(err) }); + error("Exception exchanging setup token", { error: String(err) }); return null; } } async function refreshAnthropicToken() { if (isRefreshing) { - await info("Refresh already in progress, skipping"); + info("Refresh already in progress, skipping"); return false; } const timeSinceLastAttempt = Date.now() - lastRefreshAttempt; if (timeSinceLastAttempt < REFRESH_COOLDOWN_MS) { - await info("Refresh on cooldown", { minutesRemaining: Math.ceil((REFRESH_COOLDOWN_MS - timeSinceLastAttempt) / 60000) }); + info("Refresh on cooldown", { + minutesRemaining: Math.ceil((REFRESH_COOLDOWN_MS - timeSinceLastAttempt) / 60000) + }); return false; } isRefreshing = true; lastRefreshAttempt = Date.now(); try { - await info("Starting token refresh process"); + info("Starting token refresh process"); const keychainTokens = await extractFromKeychain(); if (keychainTokens) { - await info("Found tokens in Keychain, updating auth.json"); - const success2 = await updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, 28800); + info("Found tokens in Keychain, updating auth.json"); + const success2 = updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, 28800); if (success2) { - await info("Token refresh successful via Keychain"); + info("Token refresh successful via Keychain"); return true; } } - await info("Keychain extraction failed, generating new setup token"); + info("Keychain extraction failed, generating new setup token"); const setupToken = await generateSetupToken(); if (!setupToken) { - await error("Failed to generate setup token - is Claude Code authenticated?"); + error("Failed to generate setup token - is Claude Code authenticated?"); return false; } const oauthTokens = await exchangeSetupToken(setupToken); if (!oauthTokens) { - await error("Failed to exchange setup token"); + error("Failed to exchange setup token"); return false; } - const success = await updateAnthropicTokens(oauthTokens.accessToken, oauthTokens.refreshToken, oauthTokens.expiresIn); + const success = updateAnthropicTokens(oauthTokens.accessToken, oauthTokens.refreshToken, oauthTokens.expiresIn); if (success) { - await info("Token refresh successful via setup token exchange"); + info("Token refresh successful via setup token exchange"); return true; } return false; } catch (err) { - await error("Unexpected error during refresh", { error: String(err) }); + error("Unexpected error during refresh", { error: String(err) }); return false; } finally { isRefreshing = false; @@ -328,17 +323,14 @@ var messageCount = 0; async function AnthropicTokenBridge() { fileLog("AnthropicTokenBridge plugin loaded", "info"); return { - async "chat.message"(input, output) { - if (input.role !== "user") { - return; - } + async "chat.message"(_input, output) { + const msg = output?.message; + 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 = await checkAnthropicToken(); + const status = checkAnthropicToken(); if (!status.exists) { fileLog("No Anthropic OAuth token configured", "debug"); return; @@ -347,7 +339,7 @@ async function AnthropicTokenBridge() { fileLog("Token valid, no refresh needed", "debug"); return; } - if (status.expiresSoon) { + 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()) { @@ -369,22 +361,20 @@ async function AnthropicTokenBridge() { fileLogError("Error in chat.message handler", err); } }, - async "experimental.chat.system.transform"(input, output) { + async "experimental.chat.system.transform"(_input, _output) { try { fileLog("Session started, checking initial token status", "info"); - const status = await checkAnthropicToken(); + const status = checkAnthropicToken(); if (!status.exists) { fileLog("No Anthropic token configured at session start", "debug"); return; } - if (status.expiresSoon) { + if (status.expiresSoon || !status.valid) { fileLog("Token expires soon at session start, scheduling refresh", "warn"); setTimeout(() => { if (!isRefreshInProgress()) { refreshAnthropicToken().then((success) => { - if (success) { - fileLog("Session-start refresh successful", "info"); - } + if (success) fileLog("Session-start refresh successful", "info"); }).catch((err) => { fileLogError("Session-start refresh error", err); }); From 5b4e1cebe83003db029a9293af3860ca5828a842 Mon Sep 17 00:00:00 2001 From: Steffen Zellmer <151627820+Steffen025@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:26:51 -0400 Subject: [PATCH 4/4] fix(oauth): parse claudeAiOauth nested structure from Keychain The macOS Keychain stores credentials as { claudeAiOauth: { accessToken, refreshToken } } but the plugin was looking for flat structure. This caused auto-refresh to fail with hasAccess: false, hasRefresh: false errors, falling back to browser OAuth flow. Now correctly extracts from nested structure with fallback for legacy formats. --- .opencode/plugins/anthropic-token-bridge.js | 7 ++++--- .opencode/plugins/lib/refresh-manager.ts | 16 +++++++++++----- .../plugins/anthropic-token-bridge.js | 7 ++++--- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.opencode/plugins/anthropic-token-bridge.js b/.opencode/plugins/anthropic-token-bridge.js index 81db2193..4978d403 100644 --- a/.opencode/plugins/anthropic-token-bridge.js +++ b/.opencode/plugins/anthropic-token-bridge.js @@ -196,9 +196,10 @@ async function extractFromKeychain() { return null; } const credentials = JSON.parse(stdout.trim()); - // pragma: allowlist secret — runtime extraction from macOS Keychain, not hardcoded values - const accessToken = credentials.accessToken ?? credentials.access_token; - const refreshToken = credentials.refreshToken ?? credentials.refresh_token; + 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) { error("Invalid credentials format from Keychain", { hasAccess: !!accessToken, diff --git a/.opencode/plugins/lib/refresh-manager.ts b/.opencode/plugins/lib/refresh-manager.ts index 732c8c91..c97ab377 100644 --- a/.opencode/plugins/lib/refresh-manager.ts +++ b/.opencode/plugins/lib/refresh-manager.ts @@ -74,17 +74,23 @@ async function extractFromKeychain(): Promise { } const credentials = JSON.parse(stdout.trim()) as { - // Keychain stores camelCase (Claude Code CLI format) + claudeAiOauth?: { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + }; + // Legacy fallback for direct storage (rare) accessToken?: string; refreshToken?: string; - // Fallback for snake_case (older format) access_token?: string; refresh_token?: string; }; - // pragma: allowlist secret — runtime extraction from macOS Keychain, not hardcoded values - const accessToken = credentials.accessToken ?? credentials.access_token; - const refreshToken = credentials.refreshToken ?? credentials.refresh_token; + // Extract from nested claudeAiOauth structure (standard Claude Code format) + 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) { error("Invalid credentials format from Keychain", { diff --git a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js index 81db2193..4978d403 100644 --- a/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js +++ b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js @@ -196,9 +196,10 @@ async function extractFromKeychain() { return null; } const credentials = JSON.parse(stdout.trim()); - // pragma: allowlist secret — runtime extraction from macOS Keychain, not hardcoded values - const accessToken = credentials.accessToken ?? credentials.access_token; - const refreshToken = credentials.refreshToken ?? credentials.refresh_token; + 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) { error("Invalid credentials format from Keychain", { hasAccess: !!accessToken,