diff --git a/.opencode/plugins/anthropic-token-bridge.js b/.opencode/plugins/anthropic-token-bridge.js new file mode 100644 index 00000000..4978d403 --- /dev/null +++ b/.opencode/plugins/anthropic-token-bridge.js @@ -0,0 +1,396 @@ +// lib/file-logger.ts +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}\n`; + const dir = dirname(LOG_PATH); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + appendFileSync(LOG_PATH, logLine); + } catch {} +} +function fileLogError(message, error2) { + const errorMessage = error2 instanceof Error ? `${error2.message}\n${error2.stack || ""}` : String(error2); + 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 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; +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) { + 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; + } +} +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) + }; + } + 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" + }; +} +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; +} +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"; +var EXEC_TIMEOUT_MS = 15000; +var REFRESH_COOLDOWN_MS = 5 * 60 * 1000; +var isRefreshing = false; +var lastRefreshAttempt = 0; +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; + // 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, + hasRefresh: !!refreshToken + }); + return null; + } + return { accessToken, refreshToken }; + } 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; + } + // 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 + } catch (err) { + error("Exception generating setup token", { 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 keychainTokens = await extractFromKeychain(); + if (keychainTokens) { + info("Found tokens in Keychain, updating auth.json"); + const success2 = updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, 28800); + if (success2) { + info("Token refresh successful via Keychain"); + return true; + } + } + 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; + } +} + +// 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) { + const msg = output?.message; + if (!msg?.role || msg.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) => { + 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 = 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) => { + 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..aec82eb0 --- /dev/null +++ b/.opencode/plugins/anthropic-token-bridge.ts @@ -0,0 +1,96 @@ +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: 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; + + 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..c97ab377 --- /dev/null +++ b/.opencode/plugins/lib/refresh-manager.ts @@ -0,0 +1,251 @@ +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; +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 = ""; + 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(); + }); + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + child.on("close", (exitCode: number | null) => { + settle({ stdout, stderr, exitCode: exitCode ?? 0 }); + }); + child.on("error", (err: Error) => { + settle({ 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 { + claudeAiOauth?: { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + }; + // Legacy fallback for direct storage (rare) + accessToken?: string; + refreshToken?: string; + access_token?: string; + refresh_token?: string; + }; + + // 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", { + 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; + } + + // 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 + } 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..633c5e2c --- /dev/null +++ b/.opencode/plugins/lib/token-utils.ts @@ -0,0 +1,150 @@ +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(os.homedir(), ".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/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 --- 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..4978d403 --- /dev/null +++ b/contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js @@ -0,0 +1,396 @@ +// lib/file-logger.ts +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}\n`; + const dir = dirname(LOG_PATH); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + appendFileSync(LOG_PATH, logLine); + } catch {} +} +function fileLogError(message, error2) { + const errorMessage = error2 instanceof Error ? `${error2.message}\n${error2.stack || ""}` : String(error2); + 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 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; +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) { + 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; + } +} +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) + }; + } + 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" + }; +} +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; +} +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"; +var EXEC_TIMEOUT_MS = 15000; +var REFRESH_COOLDOWN_MS = 5 * 60 * 1000; +var isRefreshing = false; +var lastRefreshAttempt = 0; +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; + // 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, + hasRefresh: !!refreshToken + }); + return null; + } + return { accessToken, refreshToken }; + } 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; + } + // 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 + } catch (err) { + error("Exception generating setup token", { 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 keychainTokens = await extractFromKeychain(); + if (keychainTokens) { + info("Found tokens in Keychain, updating auth.json"); + const success2 = updateAnthropicTokens(keychainTokens.accessToken, keychainTokens.refreshToken, 28800); + if (success2) { + info("Token refresh successful via Keychain"); + return true; + } + } + 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; + } +} + +// 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) { + const msg = output?.message; + if (!msg?.role || msg.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) => { + 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 = 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) => { + 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 +};