diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index f8892ebbdc8..83cea131f5d 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -67,15 +67,6 @@ export const DialogSettings: Component = () => { - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} ) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 37733caff63..5ccf3f2316e 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -1,9 +1,11 @@ -import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" +import { createMemo, createEffect, on, onCleanup, For, Show, createSignal, createResource } from "solid-js" import type { JSX } from "solid-js" import { useParams } from "@solidjs/router" import { DateTime } from "luxon" import { useSync } from "@/context/sync" import { useLayout } from "@/context/layout" +import { useGlobalSDK } from "@/context/global-sdk" +import { usePlatform } from "@/context/platform" import { checksum } from "@opencode-ai/util/encode" import { findLast } from "@opencode-ai/util/array" import { Icon } from "@opencode-ai/ui/icon" @@ -11,9 +13,28 @@ import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { Code } from "@opencode-ai/ui/code" import { Markdown } from "@opencode-ai/ui/markdown" +import { Spinner } from "@opencode-ai/ui/spinner" import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" +interface AnthropicUsage { + fiveHour?: { utilization: number; resetsAt?: string } + sevenDay?: { utilization: number; resetsAt?: string } + sevenDaySonnet?: { utilization: number; resetsAt?: string } +} + +interface AccountUsage { + id: string + label?: string + isActive?: boolean + health: { successCount: number; failureCount: number; cooldownUntil?: number } +} + +interface ProviderUsageData { + accounts: AccountUsage[] + anthropicUsage?: AnthropicUsage +} + interface SessionContextTabProps { messages: () => Message[] visibleUserMessages: () => UserMessage[] @@ -21,6 +42,203 @@ interface SessionContextTabProps { info: () => ReturnType["session"]["get"]> } +function formatResetTime(resetAt?: string): string { + if (!resetAt) return "" + const reset = new Date(resetAt) + const now = new Date() + const diffMs = reset.getTime() - now.getTime() + if (diffMs <= 0) return "now" + const totalMinutes = Math.floor(diffMs / (1000 * 60)) + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + if (hours > 0) return `${hours}h ${minutes}m` + return `${minutes}m` +} + +function getUsageColor(percent: number): string { + if (percent <= 50) return "var(--syntax-success)" + if (percent <= 80) return "var(--syntax-warning)" + return "var(--syntax-danger)" +} + +function AnthropicUsageSection() { + const globalSDK = useGlobalSDK() + const platform = usePlatform() + const [switching, setSwitching] = createSignal(null) + + const [usage, { refetch, mutate }] = createResource(async () => { + const result = await globalSDK.client.auth.usage({}) + const data = result.data as Record + return data["anthropic"] + }) + + const switchAccount = async (recordID: string) => { + setSwitching(recordID) + try { + const doFetch = platform.fetch ?? fetch + const response = await doFetch(`${globalSDK.url}/auth/active`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerID: "anthropic", recordID }), + }) + if (response.ok) { + const result = await response.json() + const current = usage() + if (current && result.success) { + // Update accounts list to reflect new active status + // and use anthropicUsage from response (fetched for the specific recordID) + mutate({ + ...current, + accounts: current.accounts.map((acc) => ({ + ...acc, + isActive: acc.id === recordID, + })), + anthropicUsage: result.anthropicUsage ?? current.anthropicUsage, + }) + } + } + } catch (e) { + console.error("Failed to switch account:", e) + } finally { + setSwitching(null) + } + } + + const rateLimits = createMemo(() => { + const data = usage() + if (!data?.anthropicUsage) return [] + + const limits: { key: string; label: string; utilization: number; resetsAt?: string; color: string }[] = [] + + if (data.anthropicUsage.fiveHour) { + limits.push({ + key: "5h", + label: "5-Hour", + utilization: data.anthropicUsage.fiveHour.utilization, + resetsAt: data.anthropicUsage.fiveHour.resetsAt, + color: getUsageColor(data.anthropicUsage.fiveHour.utilization), + }) + } + if (data.anthropicUsage.sevenDay) { + limits.push({ + key: "7d", + label: "Weekly (All)", + utilization: data.anthropicUsage.sevenDay.utilization, + resetsAt: data.anthropicUsage.sevenDay.resetsAt, + color: getUsageColor(data.anthropicUsage.sevenDay.utilization), + }) + } + if (data.anthropicUsage.sevenDaySonnet) { + limits.push({ + key: "7d-sonnet", + label: "Weekly (Sonnet)", + utilization: data.anthropicUsage.sevenDaySonnet.utilization, + resetsAt: data.anthropicUsage.sevenDaySonnet.resetsAt, + color: getUsageColor(data.anthropicUsage.sevenDaySonnet.utilization), + }) + } + + return limits + }) + + return ( +
+
Anthropic Rate Limits
+ + +
+ +
+
+ + + {(data) => ( + <> + 0}> + + {(limit) => ( +
+
+
+
+
+
+
{limit.label}
+
{limit.utilization}%
+ +
resets {formatResetTime(limit.resetsAt)}
+
+
+
+ )} + + + + 1}> +
+
Accounts ({data().accounts.length}) - click to switch
+
+ + {(account, index) => { + const isSwitching = () => switching() === account.id + const canSwitch = () => !account.isActive && !isSwitching() + + return ( + + ) + }} + +
+
+
+ + + + )} + + + +
+ No Anthropic OAuth account connected. +
+
+
+ ) +} + export function SessionContextTab(props: SessionContextTabProps) { const params = useParams() const sync = useSync() @@ -410,6 +628,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
+ {/* Anthropic Rate Limits - only show when provider is Anthropic */} + + + + {(prompt) => (
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a0251ed41b6..f0ab9ced488 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,4 +1,4 @@ -import { Component, createMemo, type JSX } from "solid-js" +import { Component, createMemo, createSignal, Show, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Select } from "@opencode-ai/ui/select" @@ -9,6 +9,7 @@ import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" +import { useGlobalSDK } from "@/context/global-sdk" import { playSound, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" @@ -36,6 +37,53 @@ export const SettingsGeneral: Component = () => { const language = useLanguage() const platform = usePlatform() const settings = useSettings() + const globalSDK = useGlobalSDK() + + // YOLO state - wird später vom Server geladen + const [yoloEnabled, setYoloEnabled] = createSignal(false) + const [yoloPersisted, setYoloPersisted] = createSignal(false) + + // Lade YOLO status beim Öffnen - mit kleinem Delay für Stabilität + const loadYoloStatus = () => { + const doFetch = platform.fetch ?? fetch + doFetch(`${globalSDK.url}/config/yolo`) + .then((response) => { + if (response.ok) return response.json() + return null + }) + .then((data) => { + if (data) { + setYoloEnabled(data.enabled === true) + setYoloPersisted(data.persisted === true) + } + }) + .catch(() => { + // Silently ignore errors + }) + } + + // Initialer Load mit kleinem Delay + setTimeout(loadYoloStatus, 100) + + const setYolo = (enabled: boolean, persist: boolean) => { + const doFetch = platform.fetch ?? fetch + doFetch(`${globalSDK.url}/config/yolo`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled, persist }), + }) + .then((response) => { + if (response.ok) return response.json() + return null + }) + .then((data) => { + if (data) { + setYoloEnabled(data.enabled === true) + setYoloPersisted(data.persisted === true) + } + }) + .catch((e) => console.error("Failed to set YOLO:", e)) + } const [store, setStore] = createStore({ checking: false, @@ -416,6 +464,128 @@ export const SettingsGeneral: Component = () => {
+ + {/* YOLO Mode Section */} +
+
+

YOLO Mode

+ + + ACTIVE + + +
+ +

+ Skip ALL permission prompts. OpenCode will execute without asking for confirmation. +

+ + {/* Warning */} +
+

+ Warning: This is dangerous. Only enable if you fully trust OpenCode's + actions. Explicit deny rules in your config will still be respected. +

+
+ + {/* This Session Only Card */} +
+
+
+
+ This Session Only + + ACTIVE + +
+ Resets when you restart OpenCode +
+ setYolo(true, false)} + class="px-3 py-1.5 rounded text-12-medium bg-fill-danger-base text-white hover:bg-fill-danger-strong transition-colors" + > + Enable + + } + > + + +
+
+ + {/* Always Enabled Card */} +
+
+
+
+ Always Enabled + + ACTIVE + Saved + +
+ Persists across restarts (saved in config.json) +
+ setYolo(true, true)} + class="px-3 py-1.5 rounded text-12-medium bg-fill-danger-base text-white hover:bg-fill-danger-strong transition-colors" + > + Save to Config + + } + > + + +
+
+ + {/* CLI Usage */} +
+ CLI Usage +
+
+ opencode --yolo + one session +
+
+ OPENCODE_YOLO=true + env var +
+
+
+
) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 2460534c05c..58f7ac7ea8b 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -1,272 +1,872 @@ -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Component, createMemo, createResource, createSignal, For, Show } from "solid-js" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { Tag } from "@opencode-ai/ui/tag" -import { showToast } from "@opencode-ai/ui/toast" -import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { createMemo, type Component, For, Show } from "solid-js" -import { useLanguage } from "@/context/language" +import type { IconName } from "@opencode-ai/ui/icons/provider" +import { Icon } from "@opencode-ai/ui/icon" +import { Spinner } from "@opencode-ai/ui/spinner" import { useGlobalSDK } from "@/context/global-sdk" -import { useGlobalSync } from "@/context/global-sync" +import { useProviders } from "@/hooks/use-providers" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogConnectProvider } from "./dialog-connect-provider" -import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" -type ProviderSource = "env" | "api" | "config" | "custom" -type ProviderMeta = { source?: ProviderSource } +interface AccountUsage { + id: string + label?: string + isActive?: boolean + health: { + successCount: number + failureCount: number + lastStatusCode?: number + cooldownUntil?: number + } +} -export const SettingsProviders: Component = () => { - const dialog = useDialog() - const language = useLanguage() +interface AnthropicUsage { + fiveHour?: { utilization: number; resetsAt?: string } + sevenDay?: { utilization: number; resetsAt?: string } + sevenDaySonnet?: { utilization: number; resetsAt?: string } +} + +interface ProviderUsage { + accounts: AccountUsage[] + anthropicUsage?: AnthropicUsage +} + +type AuthUsageData = Record + +function formatResetTime(resetAt?: string): string { + if (!resetAt) return "" + const reset = new Date(resetAt) + const now = new Date() + const diffMs = reset.getTime() - now.getTime() + if (diffMs <= 0) return "now" + + const totalMinutes = Math.floor(diffMs / (1000 * 60)) + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + + if (hours > 0) return `${hours}h ${minutes}m` + return `${minutes}m` +} + +function getColorClass(percent: number): string { + if (percent <= 50) return "bg-fill-success-base" + if (percent <= 80) return "bg-fill-warning-base" + return "bg-fill-danger-base" +} + +function UsageBarPercent(props: { label: string; utilization: number; resetsAt?: string }) { + return ( +
+
+ {props.label} + {props.utilization}% used +
+
+
+
+ +
Resets in {formatResetTime(props.resetsAt)}
+
+
+ ) +} + +// Provider OAuth multi-account support status +const OAUTH_MULTI_ACCOUNT_SUPPORT: Record = { + anthropic: { supported: true, note: "Claude Max/Pro subscription" }, + openai: { supported: true, note: "ChatGPT Plus/Pro subscription" }, + "github-copilot": { supported: true, note: "GitHub Copilot subscription" }, + google: { supported: false, note: "Contributions welcome" }, + openrouter: { supported: false, note: "API key only" }, + azure: { supported: false, note: "Service principal auth" }, + "amazon-bedrock": { supported: false, note: "AWS credential chain" }, + mistral: { supported: false, note: "API key only" }, + groq: { supported: false, note: "API key only" }, + xai: { supported: false, note: "API key only" }, + perplexity: { supported: false, note: "API key only" }, + cohere: { supported: false, note: "API key only" }, + deepinfra: { supported: false, note: "API key only" }, + cerebras: { supported: false, note: "API key only" }, + togetherai: { supported: false, note: "API key only" }, + "google-vertex": { supported: false, note: "Service account auth" }, + gitlab: { supported: false, note: "Token auth" }, + vercel: { supported: false, note: "API key only" }, +} + +interface BrowserSessionStatus { + recordId: string + isConfigured: boolean + hasProfile: boolean + lastRefresh?: string +} + +// Provider detail view - shows accounts, usage, switch functionality +function ProviderDetailView(props: { providerID: string; providerName: string; onBack: () => void }) { const globalSDK = useGlobalSDK() - const globalSync = useGlobalSync() - const providers = useProviders() + const platform = usePlatform() + const dialog = useDialog() + const [switching, setSwitching] = createSignal(null) + const [deleting, setDeleting] = createSignal(null) + const [confirmDelete, setConfirmDelete] = createSignal(null) + const [browserSessions, setBrowserSessions] = createSignal>({}) + const [settingUpBrowser, setSettingUpBrowser] = createSignal(null) + const [refreshingBrowser, setRefreshingBrowser] = createSignal(null) + const [rebindingBrowser, setRebindingBrowser] = createSignal(null) + const [removingBrowser, setRemovingBrowser] = createSignal(null) + const [renamingAccount, setRenamingAccount] = createSignal(null) + const [renameInput, setRenameInput] = createSignal("") - const icon = (id: string): IconName => { - if (iconNames.includes(id as IconName)) return id as IconName - return "synthetic" - } + const doFetch = platform.fetch ?? fetch - const connected = createMemo(() => { - return providers - .connected() - .filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)) + const [usage, { refetch, mutate }] = createResource(async () => { + const result = await globalSDK.client.auth.usage({}) + const data = result.data as AuthUsageData + // Also load browser sessions + loadBrowserSessions() + return data[props.providerID] }) - const popular = createMemo(() => { - const connectedIDs = new Set(connected().map((p) => p.id)) - const items = providers - .popular() - .filter((p) => !connectedIDs.has(p.id)) - .slice() - items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)) - return items - }) + // Load browser sessions for all accounts + const loadBrowserSessions = async () => { + try { + const result = await doFetch(`${globalSDK.url}/provider/auth/browser/sessions`) + if (result.ok) { + const sessions = (await result.json()) as BrowserSessionStatus[] + const map: Record = {} + for (const session of sessions) { + map[session.recordId] = session + } + setBrowserSessions(map) + } + } catch { + // Ignore errors + } + } - const source = (item: unknown) => (item as ProviderMeta).source + const setupBrowserSession = async (recordId: string) => { + setSettingUpBrowser(recordId) + try { + const result = await doFetch(`${globalSDK.url}/provider/auth/browser/sessions/${recordId}/setup`, { + method: "POST", + }) + if (result.ok) { + await loadBrowserSessions() + } + } finally { + setSettingUpBrowser(null) + } + } - const type = (item: unknown) => { - const current = source(item) - if (current === "env") return language.t("settings.providers.tag.environment") - if (current === "api") return language.t("provider.connect.method.apiKey") - if (current === "config") { - const id = (item as { id?: string }).id - if (id && isConfigCustom(id)) return language.t("settings.providers.tag.custom") - return language.t("settings.providers.tag.config") + const refreshBrowserSession = async (recordId: string) => { + setRefreshingBrowser(recordId) + try { + const result = await doFetch(`${globalSDK.url}/provider/auth/browser/sessions/${recordId}/refresh`, { + method: "POST", + }) + if (result.ok) { + await refetch() + await loadBrowserSessions() + } else { + const error = await result.json().catch(() => ({ message: "Unknown error" })) + alert(`Refresh failed: ${error.message || "Unknown error"}`) + } + } catch (e) { + alert(`Refresh error: ${e}`) + } finally { + setRefreshingBrowser(null) } - if (current === "custom") return language.t("settings.providers.tag.custom") - return language.t("settings.providers.tag.other") } - const canDisconnect = (item: unknown) => source(item) !== "env" + const rebindBrowserSession = async (recordId: string) => { + setRebindingBrowser(recordId) + try { + const result = await doFetch(`${globalSDK.url}/provider/auth/browser/sessions/${recordId}/setup`, { + method: "POST", + }) + if (result.ok) { + await refetch() + await loadBrowserSessions() + } else { + const error = await result.json().catch(() => ({ message: "Unknown error" })) + alert(`Rebind failed: ${error.message || "Unknown error"}`) + } + } catch (e) { + alert(`Rebind error: ${e}`) + } finally { + setRebindingBrowser(null) + } + } - const isConfigCustom = (providerID: string) => { - const provider = globalSync.data.config.provider?.[providerID] - if (!provider) return false - if (provider.npm !== "@ai-sdk/openai-compatible") return false - if (!provider.models || Object.keys(provider.models).length === 0) return false - return true + const startRename = (recordId: string, currentLabel?: string) => { + setRenamingAccount(recordId) + setRenameInput(currentLabel && currentLabel !== "default" ? currentLabel : "") } - const disableProvider = async (providerID: string, name: string) => { - const before = globalSync.data.config.disabled_providers ?? [] - const next = before.includes(providerID) ? before : [...before, providerID] - globalSync.set("config", "disabled_providers", next) - - await globalSync - .updateConfig({ disabled_providers: next }) - .then(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), - description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), - }) - }) - .catch((err: unknown) => { - globalSync.set("config", "disabled_providers", before) - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) + const cancelRename = () => { + setRenamingAccount(null) + setRenameInput("") } - const disconnect = async (providerID: string, name: string) => { - if (isConfigCustom(providerID)) { - await globalSDK.client.auth.remove({ providerID }).catch(() => undefined) - await disableProvider(providerID, name) + const submitRename = async (recordId: string) => { + const label = renameInput().trim() + if (!label) { + cancelRename() return } - await globalSDK.client.auth - .remove({ providerID }) - .then(async () => { - await globalSDK.client.global.dispose() - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), - description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), - }) + try { + const result = await doFetch(`${globalSDK.url}/provider/auth/account`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerID: props.providerID, recordID: recordId, label }), + }) + if (result.ok) { + await refetch() + } + } finally { + cancelRename() + } + } + + const removeBrowserSession = async (recordId: string) => { + setRemovingBrowser(recordId) + try { + await doFetch(`${globalSDK.url}/provider/auth/browser/sessions/${recordId}`, { + method: "DELETE", + }) + await loadBrowserSessions() + } finally { + setRemovingBrowser(null) + } + } + + const formatTimeAgo = (dateStr: string): string => { + const date = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + if (diffMins < 1) return "just now" + if (diffMins < 60) return `${diffMins}m ago` + const diffHours = Math.floor(diffMins / 60) + if (diffHours < 24) return `${diffHours}h ago` + const diffDays = Math.floor(diffHours / 24) + return `${diffDays}d ago` + } + + const switchAccount = async (recordID: string) => { + setSwitching(recordID) + try { + const response = await doFetch(`${globalSDK.url}/auth/active`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerID: props.providerID, recordID }), }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) + if (response.ok) { + const result = await response.json() + const current = usage() + if (current && result.success) { + // Update accounts list and usage data from response + mutate({ + ...current, + accounts: current.accounts.map((acc) => ({ + ...acc, + isActive: acc.id === recordID, + })), + anthropicUsage: result.anthropicUsage ?? current.anthropicUsage, + }) + } + } + } catch (e) { + console.error("Failed to switch account:", e) + } finally { + setSwitching(null) + } + } + + const deleteAccount = async (recordID: string) => { + setDeleting(recordID) + try { + const response = await doFetch(`${globalSDK.url}/auth/account`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerID: props.providerID, recordID }), }) + if (response.ok) { + const result = await response.json() + if (result.remaining === 0) { + // Provider was disconnected, go back to list + props.onBack() + } else { + await refetch() + } + } + } catch (e) { + console.error("Failed to delete account:", e) + } finally { + setDeleting(null) + setConfirmDelete(null) + } } + const support = OAUTH_MULTI_ACCOUNT_SUPPORT[props.providerID] + const isAnthropic = props.providerID === "anthropic" + return ( - -
-
-

{language.t("settings.providers.title")}

-
+
+
+ + +

{props.providerName}

+ + + Multi-account + +
-
-
-

{language.t("settings.providers.section.connected")}

-
- 0} - fallback={ -
- {language.t("settings.providers.connected.empty")} + +
+ +
+
+ + + {(data) => ( + <> + {/* Anthropic Usage Stats */} + +
+
Rate Limits (Active Account)
+ + + + + + + + + +
+
+ + {/* Account List */} +
+
+
+ Accounts ({data().accounts.length}) + 1 && support?.supported}> + - click to switch +
- } - > - - {(item) => ( -
-
- - {item.name} - {type(item)} -
- - Connected from your environment variables - - } - > - - + 1}> + Auto-rotation enabled + +
+ +
+ + {(account, index) => { + const isInCooldown = () => { + const cooldown = account.health.cooldownUntil + return cooldown && cooldown > Date.now() + } + const cooldownRemaining = () => { + const cooldown = account.health.cooldownUntil + if (!cooldown) return "" + const diff = cooldown - Date.now() + if (diff <= 0) return "" + const secs = Math.ceil(diff / 1000) + return secs > 60 ? `${Math.ceil(secs / 60)}m` : `${secs}s` + } + const isSwitching = () => switching() === account.id + const isDeleting = () => deleting() === account.id + const isConfirming = () => confirmDelete() === account.id + const canSwitch = () => + data().accounts.length > 1 && !account.isActive && !isSwitching() && support?.supported + + return ( +
+ +
canSwitch() && switchAccount(account.id)} + onKeyDown={(e) => e.key === "Enter" && canSwitch() && switchAccount(account.id)} + class="flex-1 flex items-center gap-2 cursor-pointer" + classList={{ + "hover:opacity-80": canSwitch(), + "opacity-60 cursor-default": !canSwitch() && !account.isActive, + }} + > + + + + + {account.label && account.label !== "default" + ? account.label + : `Account ${index() + 1}`} + + + Active + + + + Cooldown {cooldownRemaining()} + + +
+
+ + {account.health.successCount} requests + + +
+
+ } + > +
+ setRenameInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submitRename(account.id) + if (e.key === "Escape") cancelRename() + }} + placeholder={`Account ${index() + 1}`} + class="flex-1 px-2 py-1 text-12-medium bg-fill-ghost-base border border-border-base rounded focus:outline-none focus:border-border-strong" + autofocus + /> + + +
+ + {/* Delete button */} + +
+ + +
+
+ + + +
+ ) + }} +
+
+
+ + {/* Auto-Relogin Section - only for Anthropic */} + +
+
+
+ + Auto-Relogin
- )} - + Experimental +
+

+ Configure browser sessions for automatic token refresh when tokens expire overnight. +

+ +
+ + {(account, index) => { + const session = () => browserSessions()[account.id] + const isSettingUp = () => settingUpBrowser() === account.id + const isRefreshing = () => refreshingBrowser() === account.id + const isRemoving = () => removingBrowser() === account.id + + return ( +
+
+ + {account.label && account.label !== "default" ? account.label : `Account ${index() + 1}`} + + Not configured} + > + Enabled + + + (refreshed {formatTimeAgo(session()!.lastRefresh!)}) + + + +
+
+ setupBrowserSession(account.id)} + disabled={isSettingUp()} + class="text-11-medium text-text-interactive-base hover:text-text-interactive-strong transition-colors disabled:opacity-50" + > + {isSettingUp() ? "Setting up..." : "Setup"} + + } + > + + + + +
+
+ ) + }} +
+
+ +

+ Setup opens a browser window where you log in to claude.ai. Sessions are stored locally. +

+
-
+ + {/* Add Account Button */} + + + {/* Info box for non-Anthropic providers */} + +
+
+ Usage statistics are currently only available for Anthropic. Multi-account switching works for this + provider. Contributions for usage stats are welcome! +
+
+
+ + {/* Refresh button */} + + + )} +
+ + +
+ No account data available.
+
+
+ ) +} + +export const SettingsProviders: Component = () => { + const dialog = useDialog() + const language = useLanguage() + const providers = useProviders() + const [view, setView] = createSignal<"list" | "add" | { detail: string }>("list") + const [search, setSearch] = createSignal("") + + const connected = createMemo(() => + providers + .all() + .filter((p) => providers.connected().some((c) => c.id === p.id)) + .sort((a, b) => a.name.localeCompare(b.name)), + ) -
-

{language.t("settings.providers.section.popular")}

-
- - {(item) => ( -
-
-
- - {item.name} - - {language.t("dialog.provider.tag.recommended")} + const available = createMemo(() => { + const query = search().toLowerCase() + return providers + .all() + .filter((p) => !query || p.name.toLowerCase().includes(query) || p.id.toLowerCase().includes(query)) + .sort((a, b) => { + const aPopular = ["anthropic", "openai", "github-copilot", "google", "openrouter"].includes(a.id) + const bPopular = ["anthropic", "openai", "github-copilot", "google", "openrouter"].includes(b.id) + if (aPopular && !bPopular) return -1 + if (!aPopular && bPopular) return 1 + return a.name.localeCompare(b.name) + }) + }) + + const detailProvider = createMemo(() => { + const v = view() + if (typeof v === "object" && "detail" in v) { + return providers.all().find((p) => p.id === v.detail) + } + return undefined + }) + + return ( +
+
+
+

Providers

+
+
+ + {/* Provider detail view */} + + {(provider) => ( + setView("list")} + /> + )} + + + {/* Add provider view */} + +
+
+ +

Add Provider

+
+ + setSearch(e.currentTarget.value)} + autofocus + /> + +
+ + {(provider) => { + const isConnected = providers.connected().some((c) => c.id === provider.id) + const support = OAUTH_MULTI_ACCOUNT_SUPPORT[provider.id] + return ( +
- -
- )} + + ) + }} +
+
+ -
-
-
- - Custom provider - {language.t("settings.providers.tag.custom")} -
- Add an OpenAI-compatible provider by base URL. + {/* List view (default) */} + +
+

+ Manage your AI provider connections. Click on a provider to view accounts and usage. +

+ + 0} + fallback={ +
+ No providers connected yet. Add a provider to get started.
- + ) }} - > - {language.t("common.connect")} - +
-
+ - + + Add Provider + + + {/* Custom provider section */} +
+
+
+ + Custom provider +
+ Add an OpenAI-compatible provider by base URL. +
+ +
+ +
+
Multi-Account OAuth Rotation
+

+ For supported providers (Anthropic, OpenAI, GitHub Copilot), you can login with multiple accounts. + OpenCode will automatically rotate between them when one account hits rate limits. +

+
-
- + +
) } diff --git a/packages/opencode/src/auth/browser.ts b/packages/opencode/src/auth/browser.ts new file mode 100644 index 00000000000..fd35e8c74ea --- /dev/null +++ b/packages/opencode/src/auth/browser.ts @@ -0,0 +1,828 @@ +import path from "path" +import { Global } from "../global" +import fs from "fs/promises" +import { Log } from "../util/log" +import { generatePKCE } from "@openauthjs/openauth/pkce" + +const log = Log.create({ service: "auth.browser" }) + +// Track if puppeteer-extra has been initialized with stealth plugin +let puppeteerInitialized = false +let cachedPuppeteer: any = null + +/** + * Install puppeteer and download Chromium automatically + */ +async function installPuppeteer(onProgress?: (msg: string) => void): Promise { + const report = onProgress ?? ((msg: string) => log.info(msg)) + + report("Installing puppeteer for browser automation...") + + try { + const dataDir = Global.Path.data + const puppeteerDir = path.join(dataDir, "puppeteer") + await fs.mkdir(puppeteerDir, { recursive: true }) + + // Create a minimal package.json for puppeteer + const pkgPath = path.join(puppeteerDir, "package.json") + await fs.writeFile( + pkgPath, + JSON.stringify( + { + name: "opencode-puppeteer", + private: true, + dependencies: { + puppeteer: "^24.9.0", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", + }, + }, + null, + 2, + ), + ) + + report("Installing puppeteer packages (this may take a moment)...") + + // Install puppeteer using bun or npm + const proc = Bun.spawn(["bun", "install"], { + cwd: puppeteerDir, + stdout: "pipe", + stderr: "pipe", + }) + + const exitCode = await proc.exited + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + log.error("Failed to install puppeteer package", { stderr }) + + // Try with npm as fallback + report("Trying with npm...") + const npmProc = Bun.spawn(["npm", "install"], { + cwd: puppeteerDir, + stdout: "pipe", + stderr: "pipe", + }) + const npmExit = await npmProc.exited + if (npmExit !== 0) { + return false + } + } + + report("Puppeteer installation complete!") + return true + } catch (error) { + log.error("Failed to install puppeteer", { error }) + return false + } +} + +/** + * Get puppeteer-extra with stealth plugin (cached to prevent multiple plugin additions) + */ +async function getPuppeteer(onProgress?: (msg: string) => void) { + // Return cached instance if already initialized + if (puppeteerInitialized && cachedPuppeteer) { + return cachedPuppeteer + } + + // First try to import from normal node_modules + try { + // @ts-ignore - puppeteer-extra is optionally installed at runtime + const puppeteerExtra = await import("puppeteer-extra") + // @ts-ignore - puppeteer-extra-plugin-stealth is optionally installed at runtime + const stealthPlugin = await import("puppeteer-extra-plugin-stealth") + if (!puppeteerInitialized) { + puppeteerExtra.default.use(stealthPlugin.default()) + puppeteerInitialized = true + } + cachedPuppeteer = puppeteerExtra.default + return cachedPuppeteer + } catch { + // Not in normal path + } + + // Try from our custom install location + const puppeteerDir = path.join(Global.Path.data, "puppeteer") + const puppeteerExtraPath = path.join(puppeteerDir, "node_modules", "puppeteer-extra") + const stealthPath = path.join(puppeteerDir, "node_modules", "puppeteer-extra-plugin-stealth") + + try { + const puppeteerExtra = await import(puppeteerExtraPath) + const stealthPlugin = await import(stealthPath) + if (!puppeteerInitialized) { + puppeteerExtra.default.use(stealthPlugin.default()) + puppeteerInitialized = true + } + cachedPuppeteer = puppeteerExtra.default + return cachedPuppeteer + } catch { + // Not installed yet + } + + return null +} + +/** + * Ensure puppeteer is available, installing if necessary + */ +async function ensurePuppeteer(onProgress?: (msg: string) => void) { + let puppeteer = await getPuppeteer(onProgress) + + if (!puppeteer) { + const installed = await installPuppeteer(onProgress) + if (!installed) { + throw new Error( + "Failed to install puppeteer automatically. Please install it manually:\n" + + " npm install puppeteer puppeteer-extra puppeteer-extra-plugin-stealth", + ) + } + + // Try to load again from our install location + const puppeteerDir = path.join(Global.Path.data, "puppeteer") + const puppeteerExtraPath = path.join(puppeteerDir, "node_modules", "puppeteer-extra") + const stealthPath = path.join(puppeteerDir, "node_modules", "puppeteer-extra-plugin-stealth") + + try { + const puppeteerExtra = await import(puppeteerExtraPath) + const stealthPlugin = await import(stealthPath) + if (!puppeteerInitialized) { + puppeteerExtra.default.use(stealthPlugin.default()) + puppeteerInitialized = true + } + cachedPuppeteer = puppeteerExtra.default + puppeteer = cachedPuppeteer + } catch (e) { + throw new Error("Puppeteer was installed but could not be loaded. Please restart and try again.") + } + } + + return puppeteer +} + +const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" +const ANTHROPIC_OAUTH_AUTHORIZE = "https://claude.ai/oauth/authorize" +const ANTHROPIC_OAUTH_TOKEN = "https://console.anthropic.com/v1/oauth/token" +const OAUTH_CALLBACK = "https://console.anthropic.com/oauth/code/callback" +const OAUTH_CALLBACK_ALT = "https://platform.claude.com/oauth/code/callback" + +// Lock to prevent concurrent browser operations on same profile +const browserLocks = new Map>() + +// Timeout for browser launch operations +const BROWSER_LAUNCH_TIMEOUT_MS = 30000 + +/** + * Launch browser with timeout to prevent hanging + */ +async function launchBrowserWithTimeout( + puppeteer: any, + options: any, + timeoutMs: number = BROWSER_LAUNCH_TIMEOUT_MS, +): Promise { + return Promise.race([ + puppeteer.launch(options), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Browser launch timed out after ${timeoutMs}ms`)), timeoutMs), + ), + ]) +} + +/** + * Kill any existing browser processes using the given profile directory. + * This ensures we can launch a new browser even if a previous one crashed or hung. + */ +async function killExistingBrowser(profilePath: string): Promise { + const profileName = path.basename(profilePath) + log.info("killing existing browser for profile", { profileName }) + + // 1. Remove Chrome's SingletonLock file + try { + const lockFile = path.join(profilePath, "SingletonLock") + await fs.rm(lockFile, { force: true }) + log.info("removed SingletonLock file") + } catch { + // Lock file might not exist + } + + // 2. Kill any Chrome/Chromium processes using this profile + try { + const { execSync } = await import("child_process") + const platform = process.platform + + if (platform === "darwin") { + // macOS: Use pkill with pattern matching + execSync(`pkill -9 -f "${profileName}" 2>/dev/null || true`, { stdio: "ignore" }) + } else if (platform === "linux") { + // Linux: Similar approach + execSync(`pkill -9 -f "${profileName}" 2>/dev/null || true`, { stdio: "ignore" }) + } else if (platform === "win32") { + // Windows: Use taskkill + execSync(`taskkill /F /IM chrome.exe /FI "COMMANDLINE eq *${profileName}*" 2>nul || exit 0`, { stdio: "ignore" }) + } + + // Wait a bit for processes to terminate + await new Promise((r) => setTimeout(r, 500)) + } catch { + // pkill/taskkill might fail if no matching processes - that's fine + } + + // 3. Clean up any remaining lock files + try { + const lockFiles = ["SingletonLock", "SingletonCookie", "SingletonSocket"] + for (const file of lockFiles) { + await fs.rm(path.join(profilePath, file), { force: true }).catch(() => {}) + } + } catch { + // Ignore cleanup errors + } +} + +export interface OAuthTokens { + access: string + refresh: string + expires: number +} + +export interface BrowserSessionStatus { + recordId: string + enabled: boolean + profilePath: string + lastRefresh?: number + lastError?: string + isConfigured: boolean +} + +export namespace AuthBrowser { + function getBrowsersDir(): string { + return path.join(Global.Path.data, "browsers", "anthropic") + } + + function getProfilePath(recordId: string): string { + return path.join(getBrowsersDir(), recordId) + } + + async function ensureDir(dir: string): Promise { + await fs.mkdir(dir, { recursive: true }) + } + + /** + * Check if a browser session is configured for a record + */ + export async function isConfigured(recordId: string): Promise { + const profilePath = getProfilePath(recordId) + try { + const stat = await fs.stat(profilePath) + return stat.isDirectory() + } catch { + return false + } + } + + /** + * Get status of browser session for a record + */ + export async function status(recordId: string): Promise { + const profilePath = getProfilePath(recordId) + const configured = await isConfigured(recordId) + + const metaPath = path.join(profilePath, ".opencode-meta.json") + let meta: { lastRefresh?: number; lastError?: string } = {} + + if (configured) { + try { + const raw = await fs.readFile(metaPath, "utf-8") + meta = JSON.parse(raw) + } catch { + // No meta file yet + } + } + + return { + recordId, + enabled: configured, + profilePath, + lastRefresh: meta.lastRefresh, + lastError: meta.lastError, + isConfigured: configured, + } + } + + /** + * Get status of all browser sessions + */ + export async function listAll(): Promise { + const browsersDir = getBrowsersDir() + try { + const entries = await fs.readdir(browsersDir, { withFileTypes: true }) + const sessions: BrowserSessionStatus[] = [] + + for (const entry of entries) { + if (entry.isDirectory()) { + sessions.push(await status(entry.name)) + } + } + + return sessions + } catch { + return [] + } + } + + /** + * Update session metadata + */ + async function updateMeta(recordId: string, update: { lastRefresh?: number; lastError?: string }): Promise { + const profilePath = getProfilePath(recordId) + const metaPath = path.join(profilePath, ".opencode-meta.json") + + let meta: { lastRefresh?: number; lastError?: string } = {} + try { + const raw = await fs.readFile(metaPath, "utf-8") + meta = JSON.parse(raw) + } catch { + // No existing meta + } + + meta = { ...meta, ...update } + await fs.writeFile(metaPath, JSON.stringify(meta, null, 2)) + } + + /** + * Safely close browser, force-killing if necessary + */ + async function closeBrowserSafely(browser: any, profilePath: string): Promise { + if (!browser) return + + try { + // First try to get the browser process for direct kill if needed + const browserProcess = browser.process?.() + + // Set a timeout for browser.close() + await Promise.race([ + browser.close(), + new Promise((_, reject) => setTimeout(() => reject(new Error("Browser close timeout")), 5000)), + ]) + } catch (closeError) { + // Force kill if close fails or times out + log.warn("Browser close failed, force killing", { profilePath, error: String(closeError) }) + + try { + // Try to kill via browser process first (more reliable) + const browserProcess = browser.process?.() + if (browserProcess && !browserProcess.killed) { + browserProcess.kill("SIGKILL") + log.info("Killed browser process via SIGKILL") + } + } catch { + // Process might already be dead + } + + // Also try pkill as backup + try { + const { execSync } = await import("child_process") + // Use more specific matching + execSync(`pkill -9 -f "chrome.*${path.basename(profilePath)}"`, { stdio: "ignore" }) + } catch { + // Ignore pkill errors - process might already be dead + } + + // Clean up SingletonLock file + try { + const lockFile = path.join(profilePath, "SingletonLock") + await fs.rm(lockFile, { force: true }) + } catch { + // Lock file might not exist + } + } + } + + /** + * Setup a new browser session for an account. + * Opens a visible browser for user to log in. + * Returns a promise that resolves when login is complete. + * @param onProgress - Optional callback for progress messages + */ + export async function setup(recordId: string, onProgress?: (msg: string) => void): Promise { + log.info("setting up browser session", { recordId }) + + const profilePath = getProfilePath(recordId) + await ensureDir(profilePath) + + // Kill any existing browser using this profile (prevents "already running" errors) + await killExistingBrowser(profilePath) + + // Ensure puppeteer is available (auto-installs if needed) + const puppeteer = await ensurePuppeteer(onProgress) + + onProgress?.("Opening browser window...") + + let browser + try { + browser = await launchBrowserWithTimeout(puppeteer, { + headless: false, // User needs to see the browser to log in + userDataDir: profilePath, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-blink-features=AutomationControlled", + "--window-size=1280,800", + ], + }) + } catch (launchError) { + const msg = launchError instanceof Error ? launchError.message : String(launchError) + log.error("Browser launch failed", { recordId, error: msg }) + throw new Error(`Failed to launch browser: ${msg}`) + } + + const page = await browser.newPage() + await page.setViewport({ width: 1280, height: 800 }) + + // Generate PKCE for OAuth + const pkce = await generatePKCE() + + // Build authorize URL + const authorizeUrl = new URL(ANTHROPIC_OAUTH_AUTHORIZE) + authorizeUrl.searchParams.set("code", "true") + authorizeUrl.searchParams.set("client_id", CLIENT_ID) + authorizeUrl.searchParams.set("response_type", "code") + authorizeUrl.searchParams.set("redirect_uri", OAUTH_CALLBACK) + authorizeUrl.searchParams.set("scope", "org:create_api_key user:profile user:inference") + authorizeUrl.searchParams.set("code_challenge", pkce.challenge) + authorizeUrl.searchParams.set("code_challenge_method", "S256") + authorizeUrl.searchParams.set("state", pkce.verifier) + + log.info("navigating to authorize URL", { url: authorizeUrl.toString() }) + await page.goto(authorizeUrl.toString(), { waitUntil: "networkidle0", timeout: 60000 }) + + // Wait for user to complete login and be redirected to callback + log.info("waiting for user to complete login...") + + try { + // Poll for URL change - more reliable than waitForFunction across navigations + const startTime = Date.now() + const timeoutMs = 600000 // 10 minutes for user to log in (email verification can take time) + let code: string | null = null + + while (Date.now() - startTime < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, 1000)) // Check every second + + try { + const currentUrl = page.url() + + // Check both callback URLs - console.anthropic.com and platform.claude.com + if ( + currentUrl.includes(OAUTH_CALLBACK) || + currentUrl.includes(OAUTH_CALLBACK_ALT) || + currentUrl.includes("/oauth/code/callback") + ) { + log.info("detected callback URL", { currentUrl }) + + // Code can be in hash or query params + const urlObj = new URL(currentUrl) + if (urlObj.hash) { + const hashParams = new URLSearchParams(urlObj.hash.substring(1)) + code = hashParams.get("code") + } + if (!code) { + code = urlObj.searchParams.get("code") + } + + if (code) { + log.info("found authorization code") + break + } + } + } catch { + // Page might be navigating, ignore errors and retry + } + } + + if (!code) { + throw new Error("Login timed out. Please try again.") + } + + log.info("authorization code received, exchanging for tokens") + + // Exchange code for tokens + const tokens = await exchangeCodeForTokens(code, pkce.verifier) + + await updateMeta(recordId, { lastRefresh: Date.now(), lastError: undefined }) + + log.info("browser session setup complete", { recordId }) + + await closeBrowserSafely(browser, profilePath) + + return tokens + } catch (error) { + await closeBrowserSafely(browser, profilePath) + const message = error instanceof Error ? error.message : String(error) + await updateMeta(recordId, { lastError: message }) + throw error + } + } + + /** + * Refresh tokens using existing browser session (headless). + * Browser must already have a valid session from setup(). + */ + export async function refresh(recordId: string): Promise { + log.info("refreshing tokens via browser session", { recordId }) + + // Wait for any existing operation on this profile to complete + const existingLock = browserLocks.get(recordId) + if (existingLock) { + log.info("waiting for existing browser operation to complete", { recordId }) + try { + await existingLock + } catch { + // Previous operation failed, continue with our attempt + } + } + + // Create our lock + let resolveLock: () => void + const lock = new Promise((resolve) => { + resolveLock = resolve + }) + browserLocks.set(recordId, lock) + + try { + return await doRefresh(recordId) + } finally { + resolveLock!() + browserLocks.delete(recordId) + } + } + + async function doRefresh(recordId: string): Promise { + const configured = await isConfigured(recordId) + if (!configured) { + throw new Error(`No browser session configured for record ${recordId}. Run setup first.`) + } + + const profilePath = getProfilePath(recordId) + + // Kill any existing browser using this profile (prevents "already running" errors) + await killExistingBrowser(profilePath) + + // Ensure puppeteer is available + const puppeteer = await ensurePuppeteer() + + const launchOptions = { + headless: true, // Run headless for auto-refresh + userDataDir: profilePath, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-blink-features=AutomationControlled"], + } + + let browser + try { + browser = await launchBrowserWithTimeout(puppeteer, launchOptions) + } catch (launchError) { + const msg = launchError instanceof Error ? launchError.message : String(launchError) + if (msg.includes("timed out")) { + log.error("Browser launch timed out", { recordId }) + throw new Error(`Browser launch timed out for record ${recordId}. Chrome might be stuck.`) + } + throw launchError + } + + const page = await browser.newPage() + await page.setViewport({ width: 1280, height: 800 }) + + // Generate PKCE for OAuth + const pkce = await generatePKCE() + + // Build authorize URL + const authorizeUrl = new URL(ANTHROPIC_OAUTH_AUTHORIZE) + authorizeUrl.searchParams.set("code", "true") + authorizeUrl.searchParams.set("client_id", CLIENT_ID) + authorizeUrl.searchParams.set("response_type", "code") + authorizeUrl.searchParams.set("redirect_uri", OAUTH_CALLBACK) + authorizeUrl.searchParams.set("scope", "org:create_api_key user:profile user:inference") + authorizeUrl.searchParams.set("code_challenge", pkce.challenge) + authorizeUrl.searchParams.set("code_challenge_method", "S256") + authorizeUrl.searchParams.set("state", pkce.verifier) + + log.info("navigating to authorize URL (headless)", { url: authorizeUrl.toString() }) + + try { + await page.goto(authorizeUrl.toString(), { waitUntil: "networkidle0", timeout: 60000 }) + + // If session is valid, we might land on consent screen - auto-click Authorize + // Poll for URL change and auto-click authorize button if present + const startTime = Date.now() + const timeoutMs = 60000 // 1 minute for auto-redirect + let code: string | null = null + let clickedAuthorize = false + + while (Date.now() - startTime < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, 500)) // Check every 500ms + + try { + const currentUrl = page.url() + + // Check both callback URLs + if ( + currentUrl.includes(OAUTH_CALLBACK) || + currentUrl.includes(OAUTH_CALLBACK_ALT) || + currentUrl.includes("/oauth/code/callback") + ) { + log.info("detected callback URL", { currentUrl }) + + const urlObj = new URL(currentUrl) + if (urlObj.hash) { + const hashParams = new URLSearchParams(urlObj.hash.substring(1)) + code = hashParams.get("code") + } + if (!code) { + code = urlObj.searchParams.get("code") + } + + if (code) break + } + + // Try to dismiss cookie banner first, then click "Authorize" button (language-agnostic) + if (!clickedAuthorize && currentUrl.includes("claude.ai")) { + try { + // Try to dismiss cookie banner if present + const dismissedCookie = await page.evaluate(() => { + // Look for cookie-related containers + const cookieSelectors = [ + '[data-testid*="cookie"]', + '[class*="cookie"]', + '[id*="cookie"]', + '[class*="consent"]', + '[id*="consent"]', + ] + + for (const selector of cookieSelectors) { + const container = document.querySelector(selector) + if (container) { + const buttons = Array.from(container.querySelectorAll("button")) + if (buttons.length > 0) { + // Click the last button (usually "Accept All") + buttons[buttons.length - 1].click() + return true + } + } + } + return false + }) + if (dismissedCookie) { + log.info("dismissed cookie banner") + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + // Try to find and click the authorize button (language-agnostic) + // Strategy: Find the primary button (with solid background) that's not a deny button + const clicked = await page.evaluate(() => { + const mainContent = document.querySelector("main") || document.body + const buttons = Array.from(mainContent.querySelectorAll("button")) + + // Filter visible, enabled buttons + const visibleButtons = buttons.filter((btn) => { + const style = getComputedStyle(btn) + return ( + style.display !== "none" && + style.visibility !== "hidden" && + !btn.disabled && + btn.offsetParent !== null + ) + }) + + // Deny patterns to skip (common words across languages) + const denyPatterns = ["deny", "cancel", "reject", "decline", "no", "nein", "non", "nie"] + + // Find primary button (with background color = filled/primary style) + for (const btn of visibleButtons) { + const bg = getComputedStyle(btn).backgroundColor + const text = btn.textContent?.toLowerCase() || "" + + if (denyPatterns.some((p) => text.includes(p))) continue + + const hasBackground = bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent" + if (hasBackground) { + btn.click() + return true + } + } + + // Fallback: first non-deny button + for (const btn of visibleButtons) { + const text = btn.textContent?.toLowerCase() || "" + if (!denyPatterns.some((p) => text.includes(p))) { + btn.click() + return true + } + } + + return false + }) + if (clicked) { + log.info("clicked authorize button") + clickedAuthorize = true + await new Promise((resolve) => setTimeout(resolve, 2000)) + } + } catch { + // Button not found or click failed, continue polling + } + } + } catch { + // Page might be navigating + } + } + + if (!code) { + // Save debug screenshot for troubleshooting + try { + const screenshotPath = path.join(profilePath, "debug-screenshot.png") + await page.screenshot({ path: screenshotPath, fullPage: true }) + log.info("saved debug screenshot", { screenshotPath }) + } catch { + // Screenshot failed + } + + throw new Error("Session expired or refresh timed out. Please run setup again.") + } + + log.info("authorization code received, exchanging for tokens") + + const tokens = await exchangeCodeForTokens(code, pkce.verifier) + + await updateMeta(recordId, { lastRefresh: Date.now(), lastError: undefined }) + + log.info("token refresh complete", { recordId }) + + await closeBrowserSafely(browser, profilePath) + + return tokens + } catch (error) { + await closeBrowserSafely(browser, profilePath) + const message = error instanceof Error ? error.message : String(error) + log.error("browser refresh failed", { recordId, error: message }) + await updateMeta(recordId, { lastError: message }) + + // If refresh fails, session might be expired - user needs to setup again + if (message.includes("Timeout") || message.includes("timeout")) { + throw new Error( + `Browser session expired for record ${recordId}. Please run 'opencode auth browser setup' again.`, + ) + } + + throw error + } + } + + /** + * Remove browser session for a record + */ + export async function remove(recordId: string): Promise { + log.info("removing browser session", { recordId }) + const profilePath = getProfilePath(recordId) + + try { + await fs.rm(profilePath, { recursive: true, force: true }) + log.info("browser session removed", { recordId }) + } catch (error) { + log.error("failed to remove browser session", { recordId, error }) + throw error + } + } + + /** + * Exchange authorization code for tokens + */ + async function exchangeCodeForTokens(code: string, verifier: string): Promise { + // Handle code that might have state appended (code#state format) + const cleanCode = code.split("#")[0] + + const response = await fetch(ANTHROPIC_OAUTH_TOKEN, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: cleanCode, + state: verifier, + grant_type: "authorization_code", + client_id: CLIENT_ID, + redirect_uri: OAUTH_CALLBACK, + code_verifier: verifier, + }), + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Token exchange failed: ${response.status} - ${text}`) + } + + const json = await response.json() + + return { + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + } + } +} diff --git a/packages/opencode/src/auth/context.ts b/packages/opencode/src/auth/context.ts new file mode 100644 index 00000000000..ee21f02cf06 --- /dev/null +++ b/packages/opencode/src/auth/context.ts @@ -0,0 +1,21 @@ +import { AsyncLocalStorage } from "node:async_hooks" + +type Store = { + oauthRecordByProvider: Map +} + +const storage = new AsyncLocalStorage() + +export function getOAuthRecordID(providerID: string): string | undefined { + return storage.getStore()?.oauthRecordByProvider.get(providerID) +} + +export function withOAuthRecord(providerID: string, recordID: string, fn: () => T): T { + const current = storage.getStore() + const next: Store = { + oauthRecordByProvider: new Map(current?.oauthRecordByProvider ?? []), + } + next.oauthRecordByProvider.set(providerID, recordID) + + return storage.run(next, fn) +} diff --git a/packages/opencode/src/auth/credential-manager.ts b/packages/opencode/src/auth/credential-manager.ts new file mode 100644 index 00000000000..c7b499f953d --- /dev/null +++ b/packages/opencode/src/auth/credential-manager.ts @@ -0,0 +1,61 @@ +import z from "zod" +import { Bus } from "../bus" +import { BusEvent } from "../bus/bus-event" +import { Log } from "../util/log" +import { TuiEvent } from "../cli/cmd/tui/event" + +const log = Log.create({ service: "credential-manager" }) +const DEFAULT_FAILOVER_TOAST_MS = 8000 + +export namespace CredentialManager { + export const Event = { + Failover: BusEvent.define( + "credential.failover", + z.object({ + providerID: z.string(), + fromRecordID: z.string(), + toRecordID: z.string().optional(), + statusCode: z.number(), + message: z.string(), + }), + ), + } + + export async function notifyFailover(input: { + providerID: string + fromRecordID: string + toRecordID?: string + statusCode: number + toastDurationMs?: number + }): Promise { + const isRateLimit = input.statusCode === 429 + const message = isRateLimit + ? `Rate limited on "${input.providerID}". Switching OAuth credential...` + : input.statusCode === 0 + ? `Request failed on "${input.providerID}". Switching OAuth credential...` + : `Auth error on "${input.providerID}". Switching OAuth credential...` + const duration = Math.max(0, input.toastDurationMs ?? DEFAULT_FAILOVER_TOAST_MS) + + log.info("oauth credential failover", { + providerID: input.providerID, + fromRecordID: input.fromRecordID, + toRecordID: input.toRecordID, + statusCode: input.statusCode, + }) + + await Bus.publish(Event.Failover, { + providerID: input.providerID, + fromRecordID: input.fromRecordID, + toRecordID: input.toRecordID, + statusCode: input.statusCode, + message, + }).catch((error) => log.debug("failed to publish credential failover event", { error })) + + await Bus.publish(TuiEvent.ToastShow, { + title: "OAuth Credential Failover", + message, + variant: "warning", + duration, + }).catch((error) => log.debug("failed to show failover toast", { error })) + } +} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index ce948b92ac8..f33065c0d37 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,6 +1,10 @@ import path from "path" import { Global } from "../global" +import fs from "fs/promises" import z from "zod" +import { ulid } from "ulid" +import { getOAuthRecordID } from "./context" +import { Log } from "../util/log" export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" @@ -35,36 +39,747 @@ export namespace Auth { export type Info = z.infer const filepath = path.join(Global.Path.data, "auth.json") + const lockpath = `${filepath}.lock` + const STORE_LOCK_TIMEOUT_MS = 5_000 + const STORE_LOCK_STALE_MS = 30_000 + const STORE_LOCK_RETRY_MS = 25 + const STORE_LOCK_BEST_EFFORT_TIMEOUT_MS = 250 + const STORE_LOCK_BEST_EFFORT_RETRY_MS = 10 - export async function get(providerID: string) { - const auth = await all() - return auth[providerID] + const log = Log.create({ service: "auth.store" }) + + class StoreLockTimeoutError extends Error { + constructor() { + super("Timed out waiting for auth store lock") + this.name = "StoreLockTimeoutError" + } } - export async function all(): Promise> { + const Health = z + .object({ + cooldownUntil: z.number().optional(), + lastStatusCode: z.number().optional(), + lastErrorAt: z.number().optional(), + successCount: z.number().default(0), + failureCount: z.number().default(0), + }) + .strict() + .default(() => ({ successCount: 0, failureCount: 0 })) + type Health = z.infer + + const OAuthRecord = z + .object({ + id: z.string(), + namespace: z.string().default("default"), + label: z.string().optional(), + accountId: z.string().optional(), + enterpriseUrl: z.string().optional(), + refresh: z.string(), + access: z.string(), + expires: z.number(), + createdAt: z.number(), + updatedAt: z.number(), + health: Health, + }) + .strict() + type OAuthRecord = z.infer + + export type OAuthRecordMeta = Omit + + const OAuthProvider = z + .object({ + type: z.literal("oauth"), + active: z.record(z.string(), z.string()).default({}), + order: z.record(z.string(), z.array(z.string())).default({}), + records: z.array(OAuthRecord).default([]), + }) + .strict() + type OAuthProvider = z.infer + + const ApiProvider = z + .object({ + type: z.literal("api"), + key: z.string(), + }) + .strict() + + const WellKnownProvider = z + .object({ + type: z.literal("wellknown"), + key: z.string(), + token: z.string(), + }) + .strict() + + const ProviderEntry = z.union([OAuthProvider, ApiProvider, WellKnownProvider]) + type ProviderEntry = z.infer + + const StoreFile = z + .object({ + version: z.literal(2), + providers: z.record(z.string(), ProviderEntry).default({}), + }) + .strict() + type StoreFile = z.infer + + function toMeta(record: OAuthRecord): OAuthRecordMeta { + const { refresh: _refresh, access: _access, expires: _expires, ...meta } = record + return meta + } + + async function ensureDataDir(): Promise { + await fs.mkdir(path.dirname(filepath), { recursive: true }) + } + + async function withStoreLock( + fn: () => Promise, + options: { timeoutMs?: number; staleMs?: number; retryMs?: number } = {}, + ): Promise { + await ensureDataDir() + const timeoutMs = options.timeoutMs ?? STORE_LOCK_TIMEOUT_MS + const staleMs = options.staleMs ?? STORE_LOCK_STALE_MS + const retryMs = options.retryMs ?? STORE_LOCK_RETRY_MS + const start = Date.now() + while (true) { + try { + const handle = await fs.open(lockpath, "wx") + await handle.close() + break + } catch (error) { + const code = (error as { code?: string }).code + if (code !== "EEXIST") throw error + const stat = await fs.stat(lockpath).catch(() => undefined) + if (stat && Date.now() - stat.mtimeMs > staleMs) { + await fs.rm(lockpath).catch(() => {}) + continue + } + if (Date.now() - start > timeoutMs) { + throw new StoreLockTimeoutError() + } + await Bun.sleep(retryMs + Math.random() * retryMs) + } + } + + try { + return await fn() + } finally { + await fs.rm(lockpath).catch(() => {}) + } + } + + async function writeStoreFile(store: StoreFile): Promise { + await ensureDataDir() + const tempPath = `${filepath}.tmp` + const tempFile = Bun.file(tempPath) + await Bun.write(tempFile, JSON.stringify(store, null, 2)) + await fs.rename(tempPath, filepath) + await fs.chmod(filepath, 0o600).catch(() => {}) + } + + async function readStoreFile(): Promise<{ store: StoreFile; needsWrite: boolean }> { const file = Bun.file(filepath) - const data = await file.json().catch(() => ({}) as Record) - return Object.entries(data).reduce( - (acc, [key, value]) => { - const parsed = Info.safeParse(value) - if (!parsed.success) return acc - acc[key] = parsed.data - return acc - }, - {} as Record, - ) + const exists = await file.exists() + const raw = await file.json().catch(() => undefined) + + const parsed = StoreFile.safeParse(raw) + if (parsed.success) return { store: parsed.data, needsWrite: false } + + const legacyParsed = z.record(z.string(), Info).safeParse(raw) + if (legacyParsed.success) { + const now = Date.now() + const next: StoreFile = { version: 2, providers: {} } + + for (const [providerID, info] of Object.entries(legacyParsed.data)) { + if (info.type === "api") { + next.providers[providerID] = { type: "api", key: info.key } + continue + } + + if (info.type === "wellknown") { + next.providers[providerID] = { type: "wellknown", key: info.key, token: info.token } + continue + } + + const recordID = ulid() + next.providers[providerID] = { + type: "oauth", + active: { default: recordID }, + order: { default: [recordID] }, + records: [ + { + id: recordID, + namespace: "default", + label: "default", + accountId: info.accountId, + enterpriseUrl: info.enterpriseUrl, + refresh: info.refresh, + access: info.access, + expires: info.expires, + createdAt: now, + updatedAt: now, + health: { successCount: 0, failureCount: 0 }, + }, + ], + } + } + + return { store: next, needsWrite: true } + } + + return { store: { version: 2, providers: {} }, needsWrite: exists } + } + + async function loadStoreFile(): Promise { + const result = await readStoreFile() + return result.store + } + + type StoreUpdateResult = { + value: T + changed: boolean + } + + async function updateStoreWithLock( + fn: (store: StoreFile) => Promise> | StoreUpdateResult, + lockOptions?: { timeoutMs?: number; staleMs?: number; retryMs?: number }, + ) { + return withStoreLock(async () => { + const { store, needsWrite } = await readStoreFile() + const result = await fn(store) + if (result.changed || needsWrite) { + await writeStoreFile(store) + } + return result.value + }, lockOptions) + } + + async function updateStore(fn: (store: StoreFile) => Promise> | StoreUpdateResult) { + return updateStoreWithLock(fn) + } + + async function updateStoreBestEffort( + fn: (store: StoreFile) => Promise> | StoreUpdateResult, + ): Promise { + try { + await updateStoreWithLock(fn, { + timeoutMs: STORE_LOCK_BEST_EFFORT_TIMEOUT_MS, + retryMs: STORE_LOCK_BEST_EFFORT_RETRY_MS, + }) + } catch (error) { + if (error instanceof StoreLockTimeoutError) { + log.warn("auth store lock busy, skipping update", { timeoutMs: STORE_LOCK_BEST_EFFORT_TIMEOUT_MS }) + return + } + throw error + } + } + + function ensureOAuthProvider(store: StoreFile, providerID: string): OAuthProvider { + const existing = store.providers[providerID] + if (existing && existing.type === "oauth") return existing + + const next: OAuthProvider = { + type: "oauth", + active: {}, + order: {}, + records: [], + } + store.providers[providerID] = next + return next + } + + function findOAuthRecord(provider: OAuthProvider, recordID: string): OAuthRecord | undefined { + return provider.records.find((record) => record.id === recordID) + } + + function normalizeOrder(ids: string[], order: string[]): string[] { + const ordered: string[] = [] + for (const id of order) { + if (ids.includes(id) && !ordered.includes(id)) ordered.push(id) + } + for (const id of ids) { + if (!ordered.includes(id)) ordered.push(id) + } + return ordered + } + + function recordIDsForNamespace(provider: OAuthProvider, namespace: string): string[] { + const ids = provider.records.filter((record) => record.namespace === namespace).map((record) => record.id) + const order = provider.order[namespace] ?? [] + return normalizeOrder(ids, order) + } + + async function findOAuthRecordIDByRefreshToken(input: { + providerID: string + namespace: string + refresh: string + provider: OAuthProvider + }): Promise { + for (const record of input.provider.records) { + if (record.namespace !== input.namespace) continue + if (record.refresh === input.refresh) return record.id + } + return undefined + } + + export async function get(providerID: string): Promise { + const store = await loadStoreFile() + const entry = store.providers[providerID] + if (!entry) return undefined + + if (entry.type === "api") { + return { type: "api", key: entry.key } + } + + if (entry.type === "wellknown") { + return { type: "wellknown", key: entry.key, token: entry.token } + } + + const namespace = "default" + const contextID = getOAuthRecordID(providerID) + const active = contextID ?? entry.active[namespace] + const ordered = recordIDsForNamespace(entry, namespace) + const recordID = active && ordered.includes(active) ? active : ordered[0] + if (!recordID) return undefined + + const record = findOAuthRecord(entry, recordID) + if (!record) return undefined + return { + type: "oauth", + refresh: record.refresh, + access: record.access, + expires: record.expires, + accountId: record.accountId, + enterpriseUrl: record.enterpriseUrl, + } + } + + export async function all(): Promise> { + const store = await loadStoreFile() + const out: Record = {} + + for (const providerID of Object.keys(store.providers)) { + const info = await get(providerID) + if (!info) continue + out[providerID] = info + } + + return out } export async function set(key: string, info: Info) { - const file = Bun.file(filepath) - const data = await all() - await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2), { mode: 0o600 }) + return updateStore(async (store) => { + if (info.type === "api") { + store.providers[key] = { type: "api", key: info.key } + return { value: undefined, changed: true } + } + + if (info.type === "wellknown") { + store.providers[key] = { type: "wellknown", key: info.key, token: info.token } + return { value: undefined, changed: true } + } + + const namespace = "default" + const provider = ensureOAuthProvider(store, key) + + // First check if we have a context-specific recordID (e.g. from browser refresh) + const contextRecordID = getOAuthRecordID(key) + // Then check if this refresh token already exists (update existing account) + const existingRecordID = await findOAuthRecordIDByRefreshToken({ + providerID: key, + namespace, + refresh: info.refresh, + provider, + }) + + // Only use active/first record if we found a matching refresh token or have explicit context + // Otherwise, this is a NEW account and we should create a new record + const recordID = contextRecordID ?? existingRecordID ?? ulid() + + const now = Date.now() + const existing = findOAuthRecord(provider, recordID) + if (!existing) { + // Generate a label based on existing account count + const existingCount = provider.records.filter((r) => r.namespace === namespace).length + const label = existingCount === 0 ? "default" : `Account ${existingCount + 1}` + + provider.records.push({ + id: recordID, + namespace, + label, + accountId: info.accountId, + enterpriseUrl: info.enterpriseUrl, + refresh: info.refresh, + access: info.access, + expires: info.expires, + createdAt: now, + updatedAt: now, + health: { successCount: 0, failureCount: 0 }, + }) + provider.order[namespace] = [...(provider.order[namespace] ?? []), recordID] + } else { + existing.refresh = info.refresh + existing.access = info.access + existing.expires = info.expires + existing.updatedAt = now + if (info.accountId !== undefined) existing.accountId = info.accountId + if (info.enterpriseUrl !== undefined) existing.enterpriseUrl = info.enterpriseUrl + const order = provider.order[namespace] ?? [] + if (!order.includes(recordID)) { + provider.order[namespace] = [...order, recordID] + } + } + provider.active[namespace] = recordID + + return { value: undefined, changed: true } + }) } export async function remove(key: string) { - const file = Bun.file(filepath) - const data = await all() - delete data[key] - await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 }) + return updateStore((store) => { + const existing = store.providers[key] + if (!existing) return { value: undefined, changed: false } + + delete store.providers[key] + return { value: undefined, changed: true } + }) + } + + export async function addOAuth( + providerID: string, + input: Omit, "type"> & { namespace?: string; label?: string }, + ) { + const namespace = (input.namespace ?? "default").trim() || "default" + return updateStore(async (store) => { + const provider = ensureOAuthProvider(store, providerID) + const now = Date.now() + const existingRecordID = await findOAuthRecordIDByRefreshToken({ + providerID, + namespace, + refresh: input.refresh, + provider, + }) + + if (existingRecordID) { + const existing = findOAuthRecord(provider, existingRecordID) + if (existing) { + existing.refresh = input.refresh + existing.access = input.access + existing.expires = input.expires + existing.updatedAt = now + if (input.accountId !== undefined) existing.accountId = input.accountId + if (input.enterpriseUrl !== undefined) existing.enterpriseUrl = input.enterpriseUrl + if (input.label) existing.label = input.label + } + const order = provider.order[namespace] ?? [] + if (!order.includes(existingRecordID)) { + provider.order[namespace] = [...order, existingRecordID] + } + provider.active[namespace] = existingRecordID + + return { value: { providerID, namespace, recordID: existingRecordID }, changed: true } + } + + const recordID = ulid() + + provider.records.push({ + id: recordID, + namespace, + label: input.label ?? "default", + accountId: input.accountId, + enterpriseUrl: input.enterpriseUrl, + refresh: input.refresh, + access: input.access, + expires: input.expires, + createdAt: now, + updatedAt: now, + health: { successCount: 0, failureCount: 0 }, + }) + + provider.order[namespace] = [...(provider.order[namespace] ?? []), recordID] + provider.active[namespace] = recordID + + return { value: { providerID, namespace, recordID }, changed: true } + }) + } + + export namespace OAuthPool { + export async function snapshot( + providerID: string, + namespace = "default", + ): Promise<{ records: OAuthRecordMeta[]; orderedIDs: string[]; activeID?: string }> { + const store = await loadStoreFile() + const provider = store.providers[providerID] + if (!provider || provider.type !== "oauth") return { records: [], orderedIDs: [] } + + const normalized = namespace.trim() || "default" + const records = provider.records.filter((record) => record.namespace === normalized).map(toMeta) + const orderedIDs = recordIDsForNamespace(provider, normalized) + const activeID = provider.active[normalized] + + return { records, orderedIDs, activeID } + } + + export async function list(providerID: string, namespace = "default"): Promise { + return snapshot(providerID, namespace).then((result) => result.records) + } + + export async function orderedIDs(providerID: string, namespace = "default"): Promise { + return snapshot(providerID, namespace).then((result) => result.orderedIDs) + } + + export async function moveToBack(providerID: string, namespace: string, recordID: string): Promise { + await updateStoreBestEffort((store) => { + const provider = store.providers[providerID] + if (!provider || provider.type !== "oauth") return { value: undefined, changed: false } + const order = recordIDsForNamespace(provider, namespace) + provider.order[namespace] = order.filter((id) => id !== recordID).concat(recordID) + provider.active[namespace] = provider.order[namespace][0] ?? provider.active[namespace] + return { value: undefined, changed: true } + }) + } + + export async function recordOutcome(input: { + providerID: string + recordID: string + statusCode: number + ok: boolean + cooldownUntil?: number + }): Promise { + await updateStoreBestEffort((store) => { + const provider = store.providers[input.providerID] + if (!provider || provider.type !== "oauth") return { value: undefined, changed: false } + + const record = findOAuthRecord(provider, input.recordID) + if (!record) return { value: undefined, changed: false } + + const now = Date.now() + const prevCooldown = + record.health.cooldownUntil && record.health.cooldownUntil > now ? record.health.cooldownUntil : undefined + const cooldownUntil = input.ok ? undefined : (input.cooldownUntil ?? prevCooldown) + + record.health = { + ...record.health, + cooldownUntil, + lastStatusCode: input.statusCode, + lastErrorAt: input.ok ? undefined : now, + successCount: record.health.successCount + (input.ok ? 1 : 0), + failureCount: record.health.failureCount + (input.ok ? 0 : 1), + } + record.updatedAt = now + return { value: undefined, changed: true } + }) + } + + export async function markAccessExpired(providerID: string, namespace: string, recordID: string): Promise { + await updateStoreBestEffort((store) => { + const provider = store.providers[providerID] + if (!provider || provider.type !== "oauth") return { value: undefined, changed: false } + const record = findOAuthRecord(provider, recordID) + if (!record || record.namespace !== namespace) return { value: undefined, changed: false } + record.access = "" + record.expires = 0 + record.updatedAt = Date.now() + return { value: undefined, changed: true } + }) + } + + export async function getUsage( + providerID: string, + namespace = "default", + ): Promise< + Array<{ + id: string + label?: string + isActive: boolean + health: { + successCount: number + failureCount: number + lastStatusCode?: number + cooldownUntil?: number + } + }> + > { + const store = await loadStoreFile() + const provider = store.providers[providerID] + if (!provider || provider.type !== "oauth") return [] + + const orderedIDs = recordIDsForNamespace(provider, namespace) + const now = Date.now() + // Use explicitly set active account if it exists, otherwise fall back to first non-cooldown + const activeID = + provider.active[namespace] ?? + orderedIDs.find((id) => { + const record = provider.records.find((r) => r.id === id) + const cooldownUntil = record?.health.cooldownUntil + return !cooldownUntil || cooldownUntil <= now + }) ?? + orderedIDs[0] + + return provider.records + .filter((record) => record.namespace === namespace) + .map((record) => ({ + id: record.id, + label: record.label, + isActive: record.id === activeID, + health: { + successCount: record.health.successCount, + failureCount: record.health.failureCount, + lastStatusCode: record.health.lastStatusCode, + cooldownUntil: record.health.cooldownUntil, + }, + })) + } + + export async function setActive(providerID: string, namespace: string, recordID: string): Promise { + return updateStore((store) => { + const provider = store.providers[providerID] + if (!provider || provider.type !== "oauth") return { value: false, changed: false } + + const record = findOAuthRecord(provider, recordID) + if (!record || record.namespace !== namespace) return { value: false, changed: false } + + const order = recordIDsForNamespace(provider, namespace) + provider.order[namespace] = [recordID, ...order.filter((id) => id !== recordID)] + provider.active[namespace] = recordID + + return { value: true, changed: true } + }) + } + + export async function updateRecord( + providerID: string, + recordID: string, + namespace: string, + update: { access?: string; refresh?: string; expires?: number; label?: string }, + ): Promise { + return updateStore((store) => { + const provider = store.providers[providerID] + if (!provider || provider.type !== "oauth") return { value: false, changed: false } + + const record = provider.records.find((r) => r.id === recordID && r.namespace === namespace) + if (!record) return { value: false, changed: false } + + if (update.access !== undefined) record.access = update.access + if (update.refresh !== undefined) record.refresh = update.refresh + if (update.expires !== undefined) record.expires = update.expires + if (update.label !== undefined) record.label = update.label + record.updatedAt = Date.now() + + return { value: true, changed: true } + }) + } + + export async function removeRecord( + providerID: string, + recordID: string, + namespace = "default", + ): Promise<{ removed: boolean; remaining: number }> { + return updateStore<{ removed: boolean; remaining: number }>((store) => { + const provider = store.providers[providerID] + if (!provider || provider.type !== "oauth") return { value: { removed: false, remaining: 0 }, changed: false } + + const index = provider.records.findIndex((r) => r.id === recordID && r.namespace === namespace) + if (index === -1) return { value: { removed: false, remaining: provider.records.length }, changed: false } + + // Remove the record + provider.records.splice(index, 1) + + // Update order array + const order = provider.order[namespace] ?? [] + provider.order[namespace] = order.filter((id) => id !== recordID) + + // If the removed record was active, set a new active + if (provider.active[namespace] === recordID) { + const remaining = recordIDsForNamespace(provider, namespace) + provider.active[namespace] = remaining[0] + } + + // If no records left for this namespace, clean up + const remaining = provider.records.filter((r) => r.namespace === namespace).length + if (remaining === 0) { + delete provider.order[namespace] + delete provider.active[namespace] + } + + // If no records left at all, remove the provider entry + if (provider.records.length === 0) { + delete store.providers[providerID] + } + + return { value: { removed: true, remaining }, changed: true } + }) + } + + export async function fetchAnthropicUsage( + providerID: string, + namespace = "default", + recordID?: string, + ): Promise<{ + fiveHour?: { utilization: number; resetsAt?: string } + sevenDay?: { utilization: number; resetsAt?: string } + sevenDaySonnet?: { utilization: number; resetsAt?: string } + } | null> { + if (providerID !== "anthropic") return null + + const store = await loadStoreFile() + const provider = store.providers[providerID] + if (!provider || provider.type !== "oauth") return null + + const orderedIDs = recordIDsForNamespace(provider, namespace) + const now = Date.now() + // Use explicit recordID if provided, otherwise use active account + const activeID = + recordID ?? + provider.active[namespace] ?? + orderedIDs.find((id) => { + const rec = provider.records.find((r) => r.id === id) + const cooldownUntil = rec?.health.cooldownUntil + return !cooldownUntil || cooldownUntil <= now + }) ?? + orderedIDs[0] + const record = provider.records.find((r) => r.id === activeID && r.namespace === namespace) + if (!record?.access) return null + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + + try { + const response = await fetch("https://api.anthropic.com/api/oauth/usage", { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${record.access}`, + "anthropic-beta": "oauth-2025-04-20", + }, + signal: controller.signal, + }) + + if (!response.ok) return null + + const data = (await response.json()) as { + five_hour?: { utilization: number; resets_at?: string } + seven_day?: { utilization: number; resets_at?: string } + seven_day_sonnet?: { utilization: number; resets_at?: string } + } + + return { + fiveHour: data.five_hour + ? { utilization: Math.round(data.five_hour.utilization), resetsAt: data.five_hour.resets_at } + : undefined, + sevenDay: data.seven_day + ? { utilization: Math.round(data.seven_day.utilization), resetsAt: data.seven_day.resets_at } + : undefined, + sevenDaySonnet: data.seven_day_sonnet + ? { utilization: Math.round(data.seven_day_sonnet.utilization), resetsAt: data.seven_day_sonnet.resets_at } + : undefined, + } + } catch { + return null + } finally { + clearTimeout(timeout) + } + } } } diff --git a/packages/opencode/src/auth/rotating-fetch.ts b/packages/opencode/src/auth/rotating-fetch.ts new file mode 100644 index 00000000000..31124ee5cf0 --- /dev/null +++ b/packages/opencode/src/auth/rotating-fetch.ts @@ -0,0 +1,439 @@ +import { Auth } from "./index" +import { withOAuthRecord } from "./context" +import { CredentialManager } from "./credential-manager" +import { Log } from "../util/log" + +const log = Log.create({ service: "rotating-fetch" }) + +const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 30_000 +const DEFAULT_AUTH_FAILURE_COOLDOWN_MS = 5 * 60_000 +const DEFAULT_NETWORK_RETRY_ATTEMPTS = 1 + +function isReadableStream(value: unknown): value is ReadableStream { + return typeof ReadableStream !== "undefined" && value instanceof ReadableStream +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return typeof value === "object" && value !== null && Symbol.asyncIterator in value +} + +function isReplayableBody(body: unknown): boolean { + if (!body) return true + if (isReadableStream(body)) return false + if (isAsyncIterable(body)) return false + return true +} + +function isRequest(value: unknown): value is Request { + return typeof Request !== "undefined" && value instanceof Request +} + +async function drainResponse(response: Response): Promise { + try { + await response.body?.cancel() + } catch {} +} + +function parseRetryAfterMs(response: Response): number | undefined { + const value = response.headers.get("retry-after") ?? response.headers.get("Retry-After") + if (!value) return undefined + + const seconds = Number(value) + if (Number.isFinite(seconds)) return Math.max(0, seconds) * 1000 + + const dateMs = Date.parse(value) + if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now()) + + return undefined +} + +const NETWORK_ERROR_CODES = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "EHOSTUNREACH", + "ENETUNREACH", + "ENOTFOUND", + "EAI_AGAIN", + "ETIMEDOUT", + "ECONNABORTED", + "EPIPE", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", + "UND_ERR_SOCKET", +]) +const NETWORK_ERROR_NAMES = new Set(["AbortError", "TimeoutError", "FetchError"]) + +function extractErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined + const code = (error as { code?: unknown }).code + return typeof code === "string" ? code : undefined +} + +function extractErrorName(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined + const name = (error as { name?: unknown }).name + return typeof name === "string" ? name : undefined +} + +function extractErrorMessage(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined + const message = (error as { message?: unknown }).message + return typeof message === "string" ? message : undefined +} + +function isNetworkError(error: unknown): boolean { + const directCode = extractErrorCode(error) + const cause = typeof error === "object" && error !== null ? (error as { cause?: unknown }).cause : undefined + const causeCode = extractErrorCode(cause) + const code = directCode ?? causeCode + if (code && NETWORK_ERROR_CODES.has(code)) return true + + const name = extractErrorName(error) + if (name && NETWORK_ERROR_NAMES.has(name)) return true + + const message = extractErrorMessage(error)?.toLowerCase() + if (!message) return false + return message.includes("fetch failed") || message.includes("network error") || message.includes("network down") +} + +function isAuthExpiredStatus(status: number): boolean { + return status === 401 || status === 403 +} + +// Timeout for auto-relogin operation (2 minutes) +const AUTO_RELOGIN_TIMEOUT_MS = 120000 + +/** + * Attempt to refresh tokens via browser session. + * Returns true if successful and tokens were updated. + * Has a global timeout to prevent hanging indefinitely. + */ +async function attemptBrowserRelogin(providerID: string, recordID: string, namespace: string): Promise { + try { + // Wrap the entire operation in a timeout to prevent app from hanging + return await Promise.race([ + doAttemptBrowserRelogin(providerID, recordID, namespace), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Auto-relogin timed out")), AUTO_RELOGIN_TIMEOUT_MS), + ), + ]) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log.warn("auto-relogin failed", { providerID, recordID, error: message }) + + // Show failure toast + try { + const { Bus } = await import("../bus") + const { TuiEvent } = await import("../cli/cmd/tui/event") + await Bus.publish(TuiEvent.ToastShow, { + title: "Auto-Relogin Failed", + message: message.includes("timed out") + ? "Browser refresh timed out. Please run setup again." + : "Please run 'opencode auth browser setup' to re-authenticate.", + variant: "error", + duration: 10000, + }).catch(() => {}) + } catch {} + + return false + } +} + +async function doAttemptBrowserRelogin(providerID: string, recordID: string, namespace: string): Promise { + const { AuthBrowser } = await import("./browser") + + const session = await AuthBrowser.status(recordID) + if (!session.isConfigured) { + log.info("no browser session configured for auto-relogin", { providerID, recordID }) + return false + } + + log.info("attempting auto-relogin via browser session", { providerID, recordID }) + + // Show toast notification + const { Bus } = await import("../bus") + const { TuiEvent } = await import("../cli/cmd/tui/event") + await Bus.publish(TuiEvent.ToastShow, { + title: "Auto-Relogin", + message: "Token expired. Attempting automatic refresh...", + variant: "info", + duration: 5000, + }).catch(() => {}) + + const tokens = await AuthBrowser.refresh(recordID) + + // Update the auth store with new tokens + await Auth.OAuthPool.updateRecord(providerID, recordID, namespace, { + access: tokens.access, + refresh: tokens.refresh, + expires: tokens.expires, + }) + + log.info("auto-relogin successful", { providerID, recordID }) + + await Bus.publish(TuiEvent.ToastShow, { + title: "Auto-Relogin", + message: "Token refreshed successfully!", + variant: "success", + duration: 3000, + }).catch(() => {}) + + return true +} + +export function createOAuthRotatingFetch Promise>( + fetchFn: TFetch, + opts: { + providerID: string + namespace?: string + maxAttempts?: number + rateLimitCooldownMs?: number + authFailureCooldownMs?: number + networkRetryAttempts?: number + toastDurationMs?: number + }, +): TFetch { + const namespace = (opts.namespace ?? "default").trim() || "default" + + return (async (input: any, init?: any) => { + const { records, orderedIDs, activeID } = await Auth.OAuthPool.snapshot(opts.providerID, namespace) + if (records.length === 0) return fetchFn(input, init) + + const recordByID = new Map(records.map((record) => [record.id, record])) + // Prefer activeID first, then follow the order + const candidates = + activeID && recordByID.has(activeID) + ? [activeID, ...orderedIDs.filter((id) => id !== activeID && recordByID.has(id))] + : orderedIDs.filter((id) => recordByID.has(id)) + if (candidates.length === 0) return fetchFn(input, init) + const inputIsRequest = isRequest(input) + let allowRetry = + isReplayableBody(init?.body) && (!inputIsRequest || (!input.bodyUsed && !isReadableStream(input.body))) + + const rateLimitCooldownMs = opts.rateLimitCooldownMs ?? DEFAULT_RATE_LIMIT_COOLDOWN_MS + const authFailureCooldownMs = opts.authFailureCooldownMs ?? DEFAULT_AUTH_FAILURE_COOLDOWN_MS + const configuredNetworkRetryAttempts = Math.max(0, opts.networkRetryAttempts ?? DEFAULT_NETWORK_RETRY_ATTEMPTS) + const maxAttemptBudget = opts.maxAttempts ?? candidates.length + let maxAttempts = Math.max(1, maxAttemptBudget) + if (!allowRetry) { + maxAttempts = 1 + } else if (maxAttempts > candidates.length) { + maxAttempts = candidates.length + } + + const attempted = new Set() + const refreshed = new Set() + let lastError: unknown + + const pickNextCandidate = (now: number) => + candidates.find((id) => { + if (attempted.has(id)) return false + const cooldownUntil = recordByID.get(id)?.health.cooldownUntil + return !cooldownUntil || cooldownUntil <= now + }) ?? candidates.find((id) => !attempted.has(id)) + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const now = Date.now() + + const nextID = pickNextCandidate(now) + + if (!nextID) break + attempted.add(nextID) + + const hasMoreAttempts = () => attempt + 1 < maxAttempts + let networkRetryAttempts = allowRetry ? configuredNetworkRetryAttempts : 0 + + const runWithNetworkRetry = async (): Promise => { + for (let networkAttempt = 0; ; networkAttempt++) { + let attemptInput = input + if (inputIsRequest && allowRetry) { + try { + attemptInput = input.clone() + } catch (e) { + lastError = e + allowRetry = false + networkRetryAttempts = 0 + maxAttempts = attempt + 1 + } + } + + try { + return await withOAuthRecord(opts.providerID, nextID, () => fetchFn(attemptInput, init)) + } catch (e) { + lastError = e + + // Check if this is a token refresh failure - attempt auto-relogin (only once per account) + const errorMessage = e instanceof Error ? e.message : String(e) + if ( + errorMessage.includes("Token refresh failed") && + opts.providerID === "anthropic" && + !refreshed.has(nextID) // Prevent infinite relogin attempts + ) { + refreshed.add(nextID) // Mark as attempted + + log.info("token refresh failed, attempting auto-relogin", { + providerID: opts.providerID, + recordID: nextID, + }) + + const reloginSuccess = await attemptBrowserRelogin(opts.providerID, nextID, namespace) + if (reloginSuccess) { + log.info("auto-relogin successful, retrying request", { providerID: opts.providerID, recordID: nextID }) + // Retry with same account after successful relogin + continue + } + } + + await Auth.OAuthPool.recordOutcome({ + providerID: opts.providerID, + recordID: nextID, + statusCode: 0, + ok: false, + }) + const networkError = isNetworkError(e) + if (networkError && allowRetry && networkAttempt < networkRetryAttempts) { + continue + } + throw e + } + } + } + const notifyFailover = async (statusCode: number) => { + const candidate = pickNextCandidate(Date.now()) + if (!candidate) return + await CredentialManager.notifyFailover({ + providerID: opts.providerID, + fromRecordID: nextID, + toRecordID: candidate, + statusCode, + toastDurationMs: opts.toastDurationMs, + }) + } + + let response: Response + try { + response = await runWithNetworkRetry() + } catch (e) { + if (isNetworkError(e)) throw e + + await Auth.OAuthPool.moveToBack(opts.providerID, namespace, nextID) + await notifyFailover(0) + if (!hasMoreAttempts()) throw e + continue + } + + if (response.ok) { + await Auth.OAuthPool.recordOutcome({ + providerID: opts.providerID, + recordID: nextID, + statusCode: response.status, + ok: true, + }) + return response + } + + if (response.status === 429) { + const cooldownMs = parseRetryAfterMs(response) ?? rateLimitCooldownMs + await Auth.OAuthPool.recordOutcome({ + providerID: opts.providerID, + recordID: nextID, + statusCode: response.status, + ok: false, + cooldownUntil: Date.now() + cooldownMs, + }) + await Auth.OAuthPool.moveToBack(opts.providerID, namespace, nextID) + await notifyFailover(response.status) + if (!hasMoreAttempts()) return response + await drainResponse(response) + continue + } + + if (isAuthExpiredStatus(response.status) && !refreshed.has(nextID)) { + refreshed.add(nextID) + + await Auth.OAuthPool.markAccessExpired(opts.providerID, namespace, nextID) + if (!allowRetry) { + const cooldownUntil = Date.now() + authFailureCooldownMs + await Auth.OAuthPool.recordOutcome({ + providerID: opts.providerID, + recordID: nextID, + statusCode: response.status, + ok: false, + cooldownUntil, + }) + await Auth.OAuthPool.moveToBack(opts.providerID, namespace, nextID) + await notifyFailover(response.status) + return response + } + + await drainResponse(response) + + try { + const retry = await runWithNetworkRetry() + if (retry.ok) { + await Auth.OAuthPool.recordOutcome({ + providerID: opts.providerID, + recordID: nextID, + statusCode: retry.status, + ok: true, + }) + return retry + } + + if (retry.status === 429) { + const cooldownMs = parseRetryAfterMs(retry) ?? rateLimitCooldownMs + await Auth.OAuthPool.recordOutcome({ + providerID: opts.providerID, + recordID: nextID, + statusCode: retry.status, + ok: false, + cooldownUntil: Date.now() + cooldownMs, + }) + await Auth.OAuthPool.moveToBack(opts.providerID, namespace, nextID) + await notifyFailover(retry.status) + if (!hasMoreAttempts()) return retry + await drainResponse(retry) + continue + } + + const cooldownUntil = Date.now() + authFailureCooldownMs + await Auth.OAuthPool.recordOutcome({ + providerID: opts.providerID, + recordID: nextID, + statusCode: retry.status, + ok: false, + cooldownUntil, + }) + await Auth.OAuthPool.moveToBack(opts.providerID, namespace, nextID) + await notifyFailover(retry.status) + if (!hasMoreAttempts()) return retry + await drainResponse(retry) + continue + } catch (e) { + if (isNetworkError(e)) throw e + await notifyFailover(0) + if (!hasMoreAttempts()) throw e + } + + await Auth.OAuthPool.moveToBack(opts.providerID, namespace, nextID) + continue + } + + await Auth.OAuthPool.recordOutcome({ + providerID: opts.providerID, + recordID: nextID, + statusCode: response.status, + ok: false, + }) + await Auth.OAuthPool.moveToBack(opts.providerID, namespace, nextID) + await notifyFailover(response.status) + if (!hasMoreAttempts()) return response + await drainResponse(response) + continue + } + + if (lastError) throw lastError + return fetchFn(input, init) + }) as TFetch +} diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 34e2269d0c1..6fa7a947cfc 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -1,4 +1,5 @@ import { Auth } from "../../auth" +import { AuthBrowser } from "../../auth/browser" import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" @@ -163,7 +164,13 @@ export const AuthCommand = cmd({ command: "auth", describe: "manage credentials", builder: (yargs) => - yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(), + yargs + .command(AuthLoginCommand) + .command(AuthLogoutCommand) + .command(AuthListCommand) + .command(AuthBrowserCommand) + .command(AuthRenameCommand) + .demandCommand(), async handler() {}, }) @@ -398,3 +405,261 @@ export const AuthLogoutCommand = cmd({ prompts.outro("Logout successful") }, }) + +// Browser session commands for auto-relogin +export const AuthBrowserCommand = cmd({ + command: "browser", + describe: "manage browser sessions for auto-relogin", + builder: (yargs) => + yargs + .command(AuthBrowserListCommand) + .command(AuthBrowserSetupCommand) + .command(AuthBrowserRefreshCommand) + .command(AuthBrowserRemoveCommand) + .demandCommand(), + async handler() {}, +}) + +export const AuthBrowserListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list browser sessions", + async handler() { + UI.empty() + prompts.intro("Browser Sessions") + const sessions = await AuthBrowser.listAll() + const accounts = await Auth.OAuthPool.list("anthropic", "default") + const accountMap = new Map(accounts.map((a) => [a.id, a])) + + if (sessions.length === 0) { + prompts.log.warn("No browser sessions configured") + prompts.outro("Use 'opencode auth browser setup' to configure one") + return + } + + for (const session of sessions) { + const account = accountMap.get(session.recordId) + const name = account?.label || `Account ${session.recordId.slice(0, 8)}` + const status = session.isConfigured ? UI.Style.TEXT_SUCCESS + "configured" : UI.Style.TEXT_DIM + "not configured" + prompts.log.info(`${name} ${UI.Style.TEXT_DIM}(${session.recordId})${UI.Style.TEXT_NORMAL} - ${status}`) + } + + prompts.outro(`${sessions.length} session(s)`) + }, +}) + +export const AuthBrowserSetupCommand = cmd({ + command: "setup [recordId]", + describe: "setup or rebind a browser session", + builder: (yargs) => + yargs.positional("recordId", { + describe: "account record ID (will prompt if not provided)", + type: "string", + }), + async handler(args) { + UI.empty() + prompts.intro("Browser Session Setup") + + let recordId = args.recordId + if (!recordId) { + const accounts = await Auth.OAuthPool.list("anthropic", "default") + if (accounts.length === 0) { + prompts.log.error("No OAuth accounts found. Add an account first with 'opencode auth login'") + return + } + const selected = await prompts.select({ + message: "Select account", + options: accounts.map((a, i) => ({ + label: a.label || `Account ${i + 1}`, + value: a.id, + hint: a.id.slice(0, 8), + })), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + recordId = selected + } + + const spinner = prompts.spinner() + spinner.start("Opening browser...") + + try { + const tokens = await AuthBrowser.setup(recordId, (msg) => { + spinner.message(msg) + }) + + // Update the auth store with new tokens + await Auth.OAuthPool.updateRecord("anthropic", recordId, "default", { + access: tokens.access, + refresh: tokens.refresh, + expires: tokens.expires, + }) + + spinner.stop("Browser session configured successfully!") + prompts.outro("Done") + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + spinner.stop(`Setup failed: ${message}`, 1) + } + }, +}) + +export const AuthBrowserRefreshCommand = cmd({ + command: "refresh [recordId]", + aliases: ["test"], + describe: "test/refresh tokens via browser session", + builder: (yargs) => + yargs.positional("recordId", { + describe: "account record ID (will prompt if not provided)", + type: "string", + }), + async handler(args) { + UI.empty() + prompts.intro("Browser Session Refresh") + + let recordId = args.recordId + if (!recordId) { + const sessions = await AuthBrowser.listAll() + const configured = sessions.filter((s) => s.isConfigured) + if (configured.length === 0) { + prompts.log.error("No configured browser sessions. Run 'opencode auth browser setup' first.") + return + } + const accounts = await Auth.OAuthPool.list("anthropic", "default") + const accountMap = new Map(accounts.map((a) => [a.id, a])) + + const selected = await prompts.select({ + message: "Select session to refresh", + options: configured.map((s) => { + const account = accountMap.get(s.recordId) + return { + label: account?.label || `Account ${s.recordId.slice(0, 8)}`, + value: s.recordId, + } + }), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + recordId = selected + } + + const spinner = prompts.spinner() + spinner.start("Refreshing tokens...") + + try { + const tokens = await AuthBrowser.refresh(recordId) + + // Update the auth store with new tokens + await Auth.OAuthPool.updateRecord("anthropic", recordId, "default", { + access: tokens.access, + refresh: tokens.refresh, + expires: tokens.expires, + }) + + spinner.stop("Tokens refreshed successfully!") + prompts.outro("Done") + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + spinner.stop(`Refresh failed: ${message}`, 1) + } + }, +}) + +export const AuthBrowserRemoveCommand = cmd({ + command: "remove [recordId]", + aliases: ["rm"], + describe: "remove a browser session", + builder: (yargs) => + yargs.positional("recordId", { + describe: "account record ID (will prompt if not provided)", + type: "string", + }), + async handler(args) { + UI.empty() + prompts.intro("Remove Browser Session") + + let recordId = args.recordId + if (!recordId) { + const sessions = await AuthBrowser.listAll() + if (sessions.length === 0) { + prompts.log.error("No browser sessions found") + return + } + const accounts = await Auth.OAuthPool.list("anthropic", "default") + const accountMap = new Map(accounts.map((a) => [a.id, a])) + + const selected = await prompts.select({ + message: "Select session to remove", + options: sessions.map((s) => { + const account = accountMap.get(s.recordId) + return { + label: account?.label || `Account ${s.recordId.slice(0, 8)}`, + value: s.recordId, + hint: s.isConfigured ? "configured" : "not configured", + } + }), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + recordId = selected + } + + await AuthBrowser.remove(recordId) + prompts.log.success("Browser session removed") + prompts.outro("Done") + }, +}) + +// Rename account command +export const AuthRenameCommand = cmd({ + command: "rename [recordId] [name]", + describe: "rename an OAuth account", + builder: (yargs) => + yargs + .positional("recordId", { + describe: "account record ID (will prompt if not provided)", + type: "string", + }) + .positional("name", { + describe: "new name for the account", + type: "string", + }), + async handler(args) { + UI.empty() + prompts.intro("Rename Account") + + let recordId = args.recordId + if (!recordId) { + const accounts = await Auth.OAuthPool.list("anthropic", "default") + if (accounts.length === 0) { + prompts.log.error("No OAuth accounts found") + return + } + const selected = await prompts.select({ + message: "Select account to rename", + options: accounts.map((a, i) => ({ + label: a.label || `Account ${i + 1}`, + value: a.id, + hint: a.id.slice(0, 8), + })), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + recordId = selected + } + + let name = args.name + if (!name) { + const input = await prompts.text({ + message: "Enter new name", + validate: (x) => (x && x.length > 0 ? undefined : "Name is required"), + }) + if (prompts.isCancel(input)) throw new UI.CancelledError() + name = input + } + + const success = await Auth.OAuthPool.updateRecord("anthropic", recordId, "default", { label: name }) + if (success) { + prompts.log.success(`Account renamed to "${name}"`) + } else { + prompts.log.error("Failed to rename account") + } + prompts.outro("Done") + }, +}) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b0164e8aa86..e5c59670479 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -894,6 +894,26 @@ export namespace Config { }), ) .optional(), + oauth: z + .object({ + rateLimitCooldownMs: z.number().int().positive().optional().describe("Rate limit cooldown in milliseconds"), + authFailureCooldownMs: z + .number() + .int() + .positive() + .optional() + .describe("Auth failure cooldown in milliseconds"), + networkRetryAttempts: z + .number() + .int() + .nonnegative() + .optional() + .describe("Network retry attempts per OAuth credential before failing"), + maxAttempts: z.number().int().positive().optional().describe("Maximum OAuth credential attempts per request"), + toastDurationMs: z.number().int().positive().optional().describe("Failover toast duration in milliseconds"), + }) + .optional() + .describe("OAuth rotation settings"), options: z .object({ apiKey: z.string().optional(), @@ -1088,6 +1108,7 @@ export namespace Config { prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), }) .optional(), + yolo: z.boolean().optional().describe("Enable YOLO mode - skip all permission prompts (dangerous!)"), experimental: z .object({ disable_paste_summary: z.boolean().optional(), diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 64ae801d18f..13b9d592952 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -4,6 +4,7 @@ function truthy(key: string) { } export namespace Flag { + export const OPENCODE_YOLO = truthy("OPENCODE_YOLO") export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE") export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"] export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 2481f104ed1..bbcbad73e79 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -7,6 +7,7 @@ import { Storage } from "@/storage/storage" import { fn } from "@/util/fn" import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" +import { Yolo } from "@/yolo" import os from "os" import z from "zod" @@ -137,6 +138,11 @@ export namespace PermissionNext { if (rule.action === "deny") throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission))) if (rule.action === "ask") { + // YOLO mode auto-approves all "ask" permissions (but respects explicit "deny") + if (Yolo.isEnabled()) { + log.warn("YOLO mode auto-approved", { permission: request.permission, pattern }) + continue + } const id = input.id ?? Identifier.ascending("permission") return new Promise((resolve, reject) => { const info: Request = { diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index efdcaba9909..ea1861f82f8 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -13,9 +13,11 @@ import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" import { Snapshot } from "../snapshot" import { Truncate } from "../tool/truncation" +import { Yolo } from "../yolo" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) + await Yolo.init() await Plugin.init() Share.init() ShareNext.init() diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e01c583ff34..8d4055a70d8 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -9,6 +9,7 @@ import { Plugin } from "../plugin" import { ModelsDev } from "./models" import { NamedError } from "@opencode-ai/util/error" import { Auth } from "../auth" +import { createOAuthRotatingFetch } from "../auth/rotating-fetch" import { Env } from "../env" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" @@ -132,7 +133,6 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { - if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID) return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) }, options: {}, @@ -142,7 +142,6 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { - if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID) return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) }, options: {}, @@ -195,13 +194,11 @@ export namespace Provider { const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID") - // TODO: Using process.env directly because Env.set only updates a process.env shallow copy, - // until the scope of the Env API is clarified (test only or runtime?) const awsBearerToken = iife(() => { - const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK + const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK") if (envToken) return envToken if (auth?.type === "api") { - process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key + Env.set("AWS_BEARER_TOKEN_BEDROCK", auth.key) return auth.key } return undefined @@ -378,19 +375,17 @@ export namespace Provider { }, "sap-ai-core": async () => { const auth = await Auth.get("sap-ai-core") - // TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env), - // until the scope of the Env API is clarified (test only or runtime?) const envServiceKey = iife(() => { - const envAICoreServiceKey = process.env.AICORE_SERVICE_KEY + const envAICoreServiceKey = Env.get("AICORE_SERVICE_KEY") if (envAICoreServiceKey) return envAICoreServiceKey if (auth?.type === "api") { - process.env.AICORE_SERVICE_KEY = auth.key + Env.set("AICORE_SERVICE_KEY", auth.key) return auth.key } return undefined }) - const deploymentId = process.env.AICORE_DEPLOYMENT_ID - const resourceGroup = process.env.AICORE_RESOURCE_GROUP + const deploymentId = Env.get("AICORE_DEPLOYMENT_ID") + const resourceGroup = Env.get("AICORE_RESOURCE_GROUP") return { autoload: !!envServiceKey, @@ -607,7 +602,10 @@ export namespace Provider { api: { id: model.id, url: provider.api!, - npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", + npm: iife(() => { + if (provider.id.startsWith("github-copilot")) return "@ai-sdk/github-copilot" + return model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible" + }), }, status: model.status ?? "active", headers: model.headers ?? {}, @@ -858,8 +856,10 @@ export namespace Provider { if (auth) { const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) const opts = options ?? {} - const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } - mergeProvider(providerID, patch) + const patch: Partial = providers[plugin.auth.provider] + ? { options: opts } + : { source: "custom", options: opts } + mergeProvider(plugin.auth.provider, patch) } // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists @@ -927,8 +927,6 @@ export namespace Provider { ) delete provider.models[modelID] - model.variants = mapValues(ProviderTransform.variants(model), (v) => v) - // Filter out disabled variants from config const configVariants = configProvider?.models?.[modelID]?.variants if (configVariants && model.variants) { @@ -966,6 +964,7 @@ export namespace Provider { providerID: model.providerID, }) const s = await state() + const config = await Config.get() const provider = s.providers[model.providerID] const options = { ...provider.options } @@ -987,7 +986,7 @@ export namespace Provider { const customFetch = options["fetch"] - options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { + const fetchWithTimeout = async (input: any, init?: BunFetchRequestInit) => { // Preserve custom fetch if it exists, wrap it with timeout logic const fetchFn = customFetch ?? fetch const opts = init ?? {} @@ -1027,9 +1026,22 @@ export namespace Provider { }) } - const bundledFn = BUNDLED_PROVIDERS[model.api.npm] + const oauthConfig = config.provider?.[model.providerID]?.oauth + options["fetch"] = createOAuthRotatingFetch(fetchWithTimeout, { + providerID: model.providerID, + maxAttempts: oauthConfig?.maxAttempts, + rateLimitCooldownMs: oauthConfig?.rateLimitCooldownMs, + authFailureCooldownMs: oauthConfig?.authFailureCooldownMs, + networkRetryAttempts: oauthConfig?.networkRetryAttempts, + toastDurationMs: oauthConfig?.toastDurationMs, + }) + + // Special case: google-vertex-anthropic uses a subpath import + const bundledKey = + model.providerID === "google-vertex-anthropic" ? "@ai-sdk/google-vertex/anthropic" : model.api.npm + const bundledFn = BUNDLED_PROVIDERS[bundledKey] if (bundledFn) { - log.info("using bundled provider", { providerID: model.providerID, pkg: model.api.npm }) + log.info("using bundled provider", { providerID: model.providerID, pkg: bundledKey }) const loaded = bundledFn({ name: model.providerID, ...options, diff --git a/packages/opencode/src/server/routes/config.ts b/packages/opencode/src/server/routes/config.ts index 85d28f6aa6b..a0e55a09e5e 100644 --- a/packages/opencode/src/server/routes/config.ts +++ b/packages/opencode/src/server/routes/config.ts @@ -1,8 +1,11 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" +import path from "path" import { Config } from "../../config/config" import { Provider } from "../../provider/provider" +import { Yolo } from "../../yolo" +import { Global } from "../../global" import { mapValues } from "remeda" import { errors } from "../error" import { Log } from "../../util/log" @@ -10,6 +13,22 @@ import { lazy } from "../../util/lazy" const log = Log.create({ service: "server" }) +// Helper to read/write global config for YOLO persistence (uses config.json, not opencode.jsonc) +async function readGlobalConfig(): Promise> { + const filepath = path.join(Global.Path.config, "config.json") + try { + const text = await Bun.file(filepath).text() + return JSON.parse(text) + } catch { + return {} + } +} + +async function writeGlobalConfig(config: Record): Promise { + const filepath = path.join(Global.Path.config, "config.json") + await Bun.write(filepath, JSON.stringify(config, null, 2)) +} + export const ConfigRoutes = lazy(() => new Hono() .get( @@ -88,5 +107,82 @@ export const ConfigRoutes = lazy(() => default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), }) }, + ) + .get( + "/yolo", + describeRoute({ + summary: "Get YOLO mode status", + description: + "Check if YOLO mode is enabled. When enabled, all permission prompts are auto-approved (except explicit deny rules).", + operationId: "config.yolo.get", + responses: { + 200: { + description: "YOLO mode status", + content: { + "application/json": { + schema: resolver(z.object({ enabled: z.boolean(), persisted: z.boolean() })), + }, + }, + }, + }, + }), + async (c) => { + const globalConfig = await readGlobalConfig() + return c.json({ + enabled: Yolo.isEnabled(), + persisted: globalConfig.yolo === true, + }) + }, + ) + .post( + "/yolo", + describeRoute({ + summary: "Set YOLO mode", + description: + "Enable or disable YOLO mode. When enabled, all permission prompts are auto-approved (except explicit deny rules). Use with caution. Set persist=true to save to config file.", + operationId: "config.yolo.set", + responses: { + 200: { + description: "YOLO mode updated", + content: { + "application/json": { + schema: resolver(z.object({ enabled: z.boolean(), persisted: z.boolean() })), + }, + }, + }, + }, + }), + validator("json", z.object({ enabled: z.boolean(), persist: z.boolean().optional() })), + async (c) => { + const { enabled, persist } = c.req.valid("json") + Yolo.set(enabled) + + try { + const globalConfig = await readGlobalConfig() + const wasPersisted = globalConfig.yolo === true + + if (persist) { + // Explicitly save to or remove from config + if (enabled) { + globalConfig.yolo = true + } else { + delete globalConfig.yolo + } + await writeGlobalConfig(globalConfig) + log.info("YOLO mode config updated", { enabled, path: Global.Path.config }) + } else if (wasPersisted && enabled) { + // Downgrade from persistent to session-only: remove from config but keep enabled + delete globalConfig.yolo + await writeGlobalConfig(globalConfig) + log.info("YOLO mode downgraded to session-only", { path: Global.Path.config }) + } + } catch (e) { + log.error("Failed to update YOLO config", { error: e }) + } + + // Return the actual persisted state from config + const finalConfig = await readGlobalConfig() + return c.json({ enabled: Yolo.isEnabled(), persisted: finalConfig.yolo === true }) + }, ), ) diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/routes/provider.ts index 872b48be79d..5d193ae4225 100644 --- a/packages/opencode/src/server/routes/provider.ts +++ b/packages/opencode/src/server/routes/provider.ts @@ -5,6 +5,8 @@ import { Config } from "../../config/config" import { Provider } from "../../provider/provider" import { ModelsDev } from "../../provider/models" import { ProviderAuth } from "../../provider/auth" +import { Auth } from "../../auth" +import { AuthBrowser } from "../../auth/browser" import { mapValues } from "remeda" import { errors } from "../error" import { lazy } from "../../util/lazy" @@ -161,5 +163,393 @@ export const ProviderRoutes = lazy(() => }) return c.json(true) }, + ) + .get( + "/auth/usage", + describeRoute({ + summary: "Get auth usage", + description: "Get rate limit and usage information for authenticated providers.", + operationId: "auth.usage", + responses: { + 200: { + description: "Usage information per provider and account", + content: { + "application/json": { + schema: resolver(z.any().meta({ ref: "AuthUsage" })), + }, + }, + }, + ...errors(400), + }, + }), + async (c) => { + const all = await Auth.all() + const result: Record< + string, + { + accounts: Awaited> + anthropicUsage?: Awaited> + } + > = {} + + for (const [providerID, info] of Object.entries(all)) { + if (info.type === "oauth") { + const accounts = await Auth.OAuthPool.getUsage(providerID) + const anthropicUsage = await Auth.OAuthPool.fetchAnthropicUsage(providerID) + result[providerID] = { accounts, anthropicUsage: anthropicUsage ?? undefined } + } + } + + return c.json(result) + }, + ) + .post( + "/auth/active", + describeRoute({ + summary: "Set active OAuth account", + description: "Switch the active OAuth account for a provider. Returns updated usage data.", + operationId: "auth.setActive", + responses: { + 200: { + description: "Active account switched with updated usage", + content: { + "application/json": { + schema: resolver( + z.object({ + success: z.boolean(), + anthropicUsage: z.any().optional(), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + providerID: z.string(), + recordID: z.string(), + namespace: z.string().optional(), + }), + ), + async (c) => { + const { providerID, recordID, namespace } = c.req.valid("json") + const ns = namespace ?? "default" + const success = await Auth.OAuthPool.setActive(providerID, ns, recordID) + // Fetch updated usage for the newly active account + const anthropicUsage = success ? await Auth.OAuthPool.fetchAnthropicUsage(providerID, ns, recordID) : null + return c.json({ success, anthropicUsage: anthropicUsage ?? undefined }) + }, + ) + .delete( + "/auth/account", + describeRoute({ + summary: "Delete OAuth account", + description: "Remove an OAuth account from a provider.", + operationId: "auth.deleteAccount", + responses: { + 200: { + description: "Account deleted", + content: { + "application/json": { + schema: resolver( + z.object({ + success: z.boolean(), + remaining: z.number(), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + providerID: z.string(), + recordID: z.string(), + }), + ), + async (c) => { + const { providerID, recordID } = c.req.valid("json") + const result = await Auth.OAuthPool.removeRecord(providerID, recordID) + return c.json({ success: result.removed, remaining: result.remaining }) + }, + ) + .patch( + "/auth/account", + describeRoute({ + summary: "Update OAuth account", + description: "Update an OAuth account's label/name.", + operationId: "auth.updateAccount", + responses: { + 200: { + description: "Account updated", + content: { + "application/json": { + schema: resolver( + z.object({ + success: z.boolean(), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + providerID: z.string(), + recordID: z.string(), + namespace: z.string().optional(), + label: z.string().optional(), + }), + ), + async (c) => { + const { providerID, recordID, namespace, label } = c.req.valid("json") + const ns = namespace ?? "default" + const success = await Auth.OAuthPool.updateRecord(providerID, recordID, ns, { label }) + return c.json({ success }) + }, + ) + // Browser session routes for auto-relogin + .get( + "/auth/browser/sessions", + describeRoute({ + summary: "List browser sessions", + description: "Get status of all browser sessions configured for auto-relogin.", + operationId: "provider.browser.sessions", + responses: { + 200: { + description: "List of browser sessions", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + recordId: z.string(), + enabled: z.boolean(), + profilePath: z.string(), + lastRefresh: z.number().optional(), + lastError: z.string().optional(), + isConfigured: z.boolean(), + label: z.string().optional(), + }), + ), + ), + }, + }, + }, + }, + }), + async (c) => { + const sessions = await AuthBrowser.listAll() + const accounts = await Auth.OAuthPool.list("anthropic", "default") + const accountMap = new Map(accounts.map((a) => [a.id, a])) + + const result = sessions.map((s) => ({ + ...s, + label: accountMap.get(s.recordId)?.label, + })) + + return c.json(result) + }, + ) + .get( + "/auth/browser/sessions/:recordId", + describeRoute({ + summary: "Get browser session status", + description: "Get status of a specific browser session.", + operationId: "provider.browser.session.status", + responses: { + 200: { + description: "Browser session status", + content: { + "application/json": { + schema: resolver( + z.object({ + recordId: z.string(), + enabled: z.boolean(), + profilePath: z.string(), + lastRefresh: z.number().optional(), + lastError: z.string().optional(), + isConfigured: z.boolean(), + }), + ), + }, + }, + }, + ...errors(404), + }, + }), + validator( + "param", + z.object({ + recordId: z.string().meta({ description: "OAuth record ID" }), + }), + ), + async (c) => { + const { recordId } = c.req.valid("param") + const session = await AuthBrowser.status(recordId) + return c.json(session) + }, + ) + .post( + "/auth/browser/sessions/:recordId/setup", + describeRoute({ + summary: "Setup browser session", + description: + "Start browser session setup. Opens a visible browser for user to log in. Returns tokens on success.", + operationId: "provider.browser.session.setup", + responses: { + 200: { + description: "Browser session setup successful", + content: { + "application/json": { + schema: resolver( + z.object({ + success: z.boolean(), + message: z.string(), + }), + ), + }, + }, + }, + ...errors(400, 500), + }, + }), + validator( + "param", + z.object({ + recordId: z.string().meta({ description: "OAuth record ID" }), + }), + ), + async (c) => { + const { recordId } = c.req.valid("param") + + // Verify the account exists + const accounts = await Auth.OAuthPool.list("anthropic", "default") + const account = accounts.find((a) => a.id === recordId) + if (!account) { + return c.json({ success: false, message: "Account not found" }, 400) + } + + try { + const tokens = await AuthBrowser.setup(recordId) + + // Update the auth store with new tokens + await Auth.OAuthPool.updateRecord("anthropic", recordId, "default", { + access: tokens.access, + refresh: tokens.refresh, + expires: tokens.expires, + }) + + return c.json({ + success: true, + message: "Browser session configured successfully", + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return c.json({ success: false, message }, 500) + } + }, + ) + .post( + "/auth/browser/sessions/:recordId/refresh", + describeRoute({ + summary: "Refresh tokens via browser session", + description: "Attempt to refresh OAuth tokens using the existing browser session (headless).", + operationId: "provider.browser.session.refresh", + responses: { + 200: { + description: "Token refresh result", + content: { + "application/json": { + schema: resolver( + z.object({ + success: z.boolean(), + message: z.string(), + }), + ), + }, + }, + }, + ...errors(400, 500), + }, + }), + validator( + "param", + z.object({ + recordId: z.string().meta({ description: "OAuth record ID" }), + }), + ), + async (c) => { + const { recordId } = c.req.valid("param") + + const session = await AuthBrowser.status(recordId) + if (!session.isConfigured) { + return c.json({ success: false, message: "Browser session not configured" }, 400) + } + + try { + const tokens = await AuthBrowser.refresh(recordId) + + // Update the auth store with new tokens + await Auth.OAuthPool.updateRecord("anthropic", recordId, "default", { + access: tokens.access, + refresh: tokens.refresh, + expires: tokens.expires, + }) + + return c.json({ + success: true, + message: "Tokens refreshed successfully", + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return c.json({ success: false, message }, 500) + } + }, + ) + .delete( + "/auth/browser/sessions/:recordId", + describeRoute({ + summary: "Remove browser session", + description: "Remove a browser session and its stored profile data.", + operationId: "provider.browser.session.remove", + responses: { + 200: { + description: "Browser session removed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 500), + }, + }), + validator( + "param", + z.object({ + recordId: z.string().meta({ description: "OAuth record ID" }), + }), + ), + async (c) => { + const { recordId } = c.req.valid("param") + + try { + await AuthBrowser.remove(recordId) + return c.json(true) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return c.json({ error: message }, 500) + } + }, ), ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f6dd0d122f8..47319e7a174 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -122,6 +122,45 @@ export namespace Server { }), ) .route("/global", GlobalRoutes()) + .delete( + "/auth/account", + describeRoute({ + summary: "Remove OAuth account", + description: + "Remove an OAuth account from a provider. If this is the last account, the provider will be disconnected.", + operationId: "auth.removeAccount", + responses: { + 200: { + description: "Account removed", + content: { + "application/json": { + schema: resolver( + z.object({ + removed: z.boolean(), + remaining: z.number(), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + providerID: z.string(), + recordID: z.string(), + namespace: z.string().optional(), + }), + ), + async (c) => { + const body = c.req.valid("json") + const namespace = body.namespace ?? "default" + const result = await Auth.OAuthPool.removeRecord(body.providerID, body.recordID, namespace) + return c.json(result) + }, + ) .put( "/auth/:providerID", describeRoute({ @@ -227,6 +266,87 @@ export namespace Server { .route("/", FileRoutes()) .route("/mcp", McpRoutes()) .route("/tui", TuiRoutes()) + .get( + "/auth/usage", + describeRoute({ + summary: "Get auth usage", + description: "Get rate limit and usage information for authenticated providers.", + operationId: "auth.usage", + responses: { + 200: { + description: "Usage information per provider and account", + content: { + "application/json": { + schema: resolver(z.any().meta({ ref: "AuthUsage" })), + }, + }, + }, + ...errors(400), + }, + }), + async (c) => { + const all = await Auth.all() + const result: Record< + string, + { + accounts: Awaited> + anthropicUsage?: Awaited> + } + > = {} + + for (const [providerID, info] of Object.entries(all)) { + if (info.type === "oauth") { + const accounts = await Auth.OAuthPool.getUsage(providerID) + const anthropicUsage = await Auth.OAuthPool.fetchAnthropicUsage(providerID) + result[providerID] = { accounts, anthropicUsage: anthropicUsage ?? undefined } + } + } + + return c.json(result) + }, + ) + .post( + "/auth/active", + describeRoute({ + summary: "Set active OAuth account", + description: + "Set the active OAuth account for a provider. This account will be used for requests until rate limited.", + operationId: "auth.setActive", + responses: { + 200: { + description: "Active account updated", + content: { + "application/json": { + schema: resolver( + z.object({ + success: z.boolean(), + anthropicUsage: z.any().optional(), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + providerID: z.string(), + recordID: z.string(), + namespace: z.string().optional(), + }), + ), + async (c) => { + const body = c.req.valid("json") + const namespace = body.namespace ?? "default" + const success = await Auth.OAuthPool.setActive(body.providerID, namespace, body.recordID) + const anthropicUsage = success + ? await Auth.OAuthPool.fetchAnthropicUsage(body.providerID, namespace, body.recordID) + : null + return c.json({ success, anthropicUsage: anthropicUsage ?? undefined }) + }, + ) .post( "/instance/dispose", describeRoute({ diff --git a/packages/opencode/src/yolo/index.ts b/packages/opencode/src/yolo/index.ts new file mode 100644 index 00000000000..b36e809265d --- /dev/null +++ b/packages/opencode/src/yolo/index.ts @@ -0,0 +1,54 @@ +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Config } from "@/config/config" +import { Flag } from "@/flag/flag" +import { Log } from "@/util/log" +import z from "zod" + +export namespace Yolo { + const log = Log.create({ service: "yolo" }) + + let enabled = Flag.OPENCODE_YOLO + + export const Event = { + Changed: BusEvent.define( + "yolo.changed", + z.object({ + enabled: z.boolean(), + }), + ), + } + + export async function init() { + const config = await Config.global() + if (config.yolo === true) { + enabled = true + log.warn("YOLO mode enabled via config") + } + if (Flag.OPENCODE_YOLO) { + enabled = true + log.warn("YOLO mode enabled via OPENCODE_YOLO env var") + } + if (enabled) { + log.warn("YOLO mode is ACTIVE - all permission prompts will be auto-approved") + } + } + + export function isEnabled(): boolean { + return enabled + } + + export function set(value: boolean) { + const previous = enabled + enabled = value + if (previous !== value) { + log.warn(`YOLO mode ${value ? "ENABLED" : "DISABLED"}`) + Bus.publish(Event.Changed, { enabled: value }) + } + } + + export function toggle(): boolean { + set(!enabled) + return enabled + } +} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b757b753507..9959e0ad592 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -9,16 +9,32 @@ import type { AppLogResponses, AppSkillsResponses, Auth as Auth3, + AuthDeleteAccountErrors, + AuthDeleteAccountResponses, + AuthRemoveAccountErrors, + AuthRemoveAccountResponses, AuthRemoveErrors, AuthRemoveResponses, + AuthSetActive2Errors, + AuthSetActive2Responses, + AuthSetActiveErrors, + AuthSetActiveResponses, AuthSetErrors, AuthSetResponses, + AuthUpdateAccountErrors, + AuthUpdateAccountResponses, + AuthUsage2Errors, + AuthUsage2Responses, + AuthUsageErrors, + AuthUsageResponses, CommandListResponses, Config as Config3, ConfigGetResponses, ConfigProvidersResponses, ConfigUpdateErrors, ConfigUpdateResponses, + ConfigYoloGetResponses, + ConfigYoloSetResponses, EventSubscribeResponses, EventTuiCommandExecute, EventTuiPromptAppend, @@ -74,6 +90,15 @@ import type { ProjectUpdateErrors, ProjectUpdateResponses, ProviderAuthResponses, + ProviderBrowserSessionRefreshErrors, + ProviderBrowserSessionRefreshResponses, + ProviderBrowserSessionRemoveErrors, + ProviderBrowserSessionRemoveResponses, + ProviderBrowserSessionSetupErrors, + ProviderBrowserSessionSetupResponses, + ProviderBrowserSessionsResponses, + ProviderBrowserSessionStatusErrors, + ProviderBrowserSessionStatusResponses, ProviderListResponses, ProviderOauthAuthorizeErrors, ProviderOauthAuthorizeResponses, @@ -353,6 +378,239 @@ export class Auth extends HeyApiClient { }, }) } + + /** + * Get auth usage + * + * Get rate limit and usage information for authenticated providers. + */ + public usage( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/provider/auth/usage", + ...options, + ...params, + }) + } + + /** + * Set active OAuth account + * + * Switch the active OAuth account for a provider. Returns updated usage data. + */ + public setActive( + parameters?: { + directory?: string + providerID?: string + recordID?: string + namespace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "body", key: "providerID" }, + { in: "body", key: "recordID" }, + { in: "body", key: "namespace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/provider/auth/active", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Delete OAuth account + * + * Remove an OAuth account from a provider. + */ + public deleteAccount( + parameters?: { + directory?: string + providerID?: string + recordID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "body", key: "providerID" }, + { in: "body", key: "recordID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/provider/auth/account", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Update OAuth account + * + * Update an OAuth account's label/name. + */ + public updateAccount( + parameters?: { + directory?: string + providerID?: string + recordID?: string + namespace?: string + label?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "body", key: "providerID" }, + { in: "body", key: "recordID" }, + { in: "body", key: "namespace" }, + { in: "body", key: "label" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/provider/auth/account", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Get auth usage + * + * Get rate limit and usage information for authenticated providers. + */ + public usage2( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/auth/usage", + ...options, + ...params, + }) + } + + /** + * Set active OAuth account + * + * Set the active OAuth account for a provider. This account will be used for requests until rate limited. + */ + public setActive2( + parameters?: { + directory?: string + providerID?: string + recordID?: string + namespace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "body", key: "providerID" }, + { in: "body", key: "recordID" }, + { in: "body", key: "namespace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/auth/active", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Remove OAuth account + * + * Remove an OAuth account from a provider. If this is the last account, the provider will be disconnected. + */ + public removeAccount( + parameters?: { + directory?: string + providerID?: string + recordID?: string + namespace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "body", key: "providerID" }, + { in: "body", key: "recordID" }, + { in: "body", key: "namespace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/auth/account", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } } export class Project extends HeyApiClient { @@ -643,6 +901,64 @@ export class Pty extends HeyApiClient { } } +export class Yolo extends HeyApiClient { + /** + * Get YOLO mode status + * + * Check if YOLO mode is enabled. When enabled, all permission prompts are auto-approved (except explicit deny rules). + */ + public get( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/config/yolo", + ...options, + ...params, + }) + } + + /** + * Set YOLO mode + * + * Enable or disable YOLO mode. When enabled, all permission prompts are auto-approved (except explicit deny rules). Use with caution. Set persist=true to save to config file. + */ + public set( + parameters?: { + directory?: string + enabled?: boolean + persist?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "body", key: "enabled" }, + { in: "body", key: "persist" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/config/yolo", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Config2 extends HeyApiClient { /** * Get configuration @@ -716,6 +1032,11 @@ export class Config2 extends HeyApiClient { ...params, }) } + + private _yolo?: Yolo + get yolo(): Yolo { + return (this._yolo ??= new Yolo({ client: this.client })) + } } export class Tool extends HeyApiClient { @@ -2116,6 +2437,170 @@ export class Oauth extends HeyApiClient { } } +export class Session2 extends HeyApiClient { + /** + * Remove browser session + * + * Remove a browser session and its stored profile data. + */ + public remove( + parameters: { + recordId: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "recordId" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + ProviderBrowserSessionRemoveResponses, + ProviderBrowserSessionRemoveErrors, + ThrowOnError + >({ + url: "/provider/auth/browser/sessions/{recordId}", + ...options, + ...params, + }) + } + + /** + * Get browser session status + * + * Get status of a specific browser session. + */ + public status( + parameters: { + recordId: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "recordId" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + ProviderBrowserSessionStatusResponses, + ProviderBrowserSessionStatusErrors, + ThrowOnError + >({ + url: "/provider/auth/browser/sessions/{recordId}", + ...options, + ...params, + }) + } + + /** + * Setup browser session + * + * Start browser session setup. Opens a visible browser for user to log in. Returns tokens on success. + */ + public setup( + parameters: { + recordId: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "recordId" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ProviderBrowserSessionSetupResponses, + ProviderBrowserSessionSetupErrors, + ThrowOnError + >({ + url: "/provider/auth/browser/sessions/{recordId}/setup", + ...options, + ...params, + }) + } + + /** + * Refresh tokens via browser session + * + * Attempt to refresh OAuth tokens using the existing browser session (headless). + */ + public refresh( + parameters: { + recordId: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "recordId" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ProviderBrowserSessionRefreshResponses, + ProviderBrowserSessionRefreshErrors, + ThrowOnError + >({ + url: "/provider/auth/browser/sessions/{recordId}/refresh", + ...options, + ...params, + }) + } +} + +export class Browser extends HeyApiClient { + /** + * List browser sessions + * + * Get status of all browser sessions configured for auto-relogin. + */ + public sessions( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/provider/auth/browser/sessions", + ...options, + ...params, + }) + } + + private _session?: Session2 + get session(): Session2 { + return (this._session ??= new Session2({ client: this.client })) + } +} + export class Provider extends HeyApiClient { /** * List providers @@ -2159,6 +2644,11 @@ export class Provider extends HeyApiClient { get oauth(): Oauth { return (this._oauth ??= new Oauth({ client: this.client })) } + + private _browser?: Browser + get browser(): Browser { + return (this._browser ??= new Browser({ client: this.client })) + } } export class Find extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0556e1ad945..f2f1c4cb6e4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -68,6 +68,71 @@ export type EventGlobalDisposed = { } } +export type EventTuiPromptAppend = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + /** + * Duration in milliseconds + */ + duration?: number + } +} + +export type EventTuiSessionSelect = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type EventCredentialFailover = { + type: "credential.failover" + properties: { + providerID: string + fromRecordID: string + toRecordID?: string + statusCode: number + message: string + } +} + export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -493,6 +558,13 @@ export type EventMessagePartRemoved = { } } +export type EventYoloChanged = { + type: "yolo.changed" + properties: { + enabled: boolean + } +} + export type PermissionRequest = { id: string sessionID: string @@ -664,60 +736,6 @@ export type EventTodoUpdated = { } } -export type EventTuiPromptAppend = { - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ - duration?: number - } -} - -export type EventTuiSessionSelect = { - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type EventMcpToolsChanged = { type: "mcp.tools.changed" properties: { @@ -890,6 +908,11 @@ export type Event = | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventCredentialFailover | EventLspClientDiagnostics | EventLspUpdated | EventFileEdited @@ -897,6 +920,7 @@ export type Event = | EventMessageRemoved | EventMessagePartUpdated | EventMessagePartRemoved + | EventYoloChanged | EventPermissionAsked | EventPermissionReplied | EventSessionStatus @@ -907,10 +931,6 @@ export type Event = | EventSessionCompacted | EventFileWatcherUpdated | EventTodoUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -1499,6 +1519,31 @@ export type ProviderConfig = { } whitelist?: Array blacklist?: Array + /** + * OAuth rotation settings + */ + oauth?: { + /** + * Rate limit cooldown in milliseconds + */ + rateLimitCooldownMs?: number + /** + * Auth failure cooldown in milliseconds + */ + authFailureCooldownMs?: number + /** + * Network retry attempts per OAuth credential before failing + */ + networkRetryAttempts?: number + /** + * Maximum OAuth credential attempts per request + */ + maxAttempts?: number + /** + * Failover toast duration in milliseconds + */ + toastDurationMs?: number + } options?: { apiKey?: string baseURL?: string @@ -1782,6 +1827,10 @@ export type Config = { */ prune?: boolean } + /** + * Enable YOLO mode - skip all permission prompts (dangerous!) + */ + yolo?: boolean experimental?: { disable_paste_summary?: boolean /** @@ -2028,6 +2077,8 @@ export type ProviderAuthAuthorization = { instructions: string } +export type AuthUsage = unknown + export type Symbol = { name: string kind: number @@ -2632,6 +2683,51 @@ export type ConfigProvidersResponses = { export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ConfigYoloGetData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/config/yolo" +} + +export type ConfigYoloGetResponses = { + /** + * YOLO mode status + */ + 200: { + enabled: boolean + persisted: boolean + } +} + +export type ConfigYoloGetResponse = ConfigYoloGetResponses[keyof ConfigYoloGetResponses] + +export type ConfigYoloSetData = { + body?: { + enabled: boolean + persist?: boolean + } + path?: never + query?: { + directory?: string + } + url: "/config/yolo" +} + +export type ConfigYoloSetResponses = { + /** + * YOLO mode updated + */ + 200: { + enabled: boolean + persisted: boolean + } +} + +export type ConfigYoloSetResponse = ConfigYoloSetResponses[keyof ConfigYoloSetResponses] + export type ToolIdsData = { body?: never path?: never @@ -4107,6 +4203,309 @@ export type ProviderOauthCallbackResponses = { export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] +export type AuthUsageData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/provider/auth/usage" +} + +export type AuthUsageErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthUsageError = AuthUsageErrors[keyof AuthUsageErrors] + +export type AuthUsageResponses = { + /** + * Usage information per provider and account + */ + 200: AuthUsage +} + +export type AuthUsageResponse = AuthUsageResponses[keyof AuthUsageResponses] + +export type AuthSetActiveData = { + body?: { + providerID: string + recordID: string + namespace?: string + } + path?: never + query?: { + directory?: string + } + url: "/provider/auth/active" +} + +export type AuthSetActiveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthSetActiveError = AuthSetActiveErrors[keyof AuthSetActiveErrors] + +export type AuthSetActiveResponses = { + /** + * Active account switched with updated usage + */ + 200: { + success: boolean + anthropicUsage?: unknown + } +} + +export type AuthSetActiveResponse = AuthSetActiveResponses[keyof AuthSetActiveResponses] + +export type AuthDeleteAccountData = { + body?: { + providerID: string + recordID: string + } + path?: never + query?: { + directory?: string + } + url: "/provider/auth/account" +} + +export type AuthDeleteAccountErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthDeleteAccountError = AuthDeleteAccountErrors[keyof AuthDeleteAccountErrors] + +export type AuthDeleteAccountResponses = { + /** + * Account deleted + */ + 200: { + success: boolean + remaining: number + } +} + +export type AuthDeleteAccountResponse = AuthDeleteAccountResponses[keyof AuthDeleteAccountResponses] + +export type AuthUpdateAccountData = { + body?: { + providerID: string + recordID: string + namespace?: string + label?: string + } + path?: never + query?: { + directory?: string + } + url: "/provider/auth/account" +} + +export type AuthUpdateAccountErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthUpdateAccountError = AuthUpdateAccountErrors[keyof AuthUpdateAccountErrors] + +export type AuthUpdateAccountResponses = { + /** + * Account updated + */ + 200: { + success: boolean + } +} + +export type AuthUpdateAccountResponse = AuthUpdateAccountResponses[keyof AuthUpdateAccountResponses] + +export type ProviderBrowserSessionsData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/provider/auth/browser/sessions" +} + +export type ProviderBrowserSessionsResponses = { + /** + * List of browser sessions + */ + 200: Array<{ + recordId: string + enabled: boolean + profilePath: string + lastRefresh?: number + lastError?: string + isConfigured: boolean + label?: string + }> +} + +export type ProviderBrowserSessionsResponse = ProviderBrowserSessionsResponses[keyof ProviderBrowserSessionsResponses] + +export type ProviderBrowserSessionRemoveData = { + body?: never + path: { + /** + * OAuth record ID + */ + recordId: string + } + query?: { + directory?: string + } + url: "/provider/auth/browser/sessions/{recordId}" +} + +export type ProviderBrowserSessionRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderBrowserSessionRemoveError = + ProviderBrowserSessionRemoveErrors[keyof ProviderBrowserSessionRemoveErrors] + +export type ProviderBrowserSessionRemoveResponses = { + /** + * Browser session removed + */ + 200: boolean +} + +export type ProviderBrowserSessionRemoveResponse = + ProviderBrowserSessionRemoveResponses[keyof ProviderBrowserSessionRemoveResponses] + +export type ProviderBrowserSessionStatusData = { + body?: never + path: { + /** + * OAuth record ID + */ + recordId: string + } + query?: { + directory?: string + } + url: "/provider/auth/browser/sessions/{recordId}" +} + +export type ProviderBrowserSessionStatusErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type ProviderBrowserSessionStatusError = + ProviderBrowserSessionStatusErrors[keyof ProviderBrowserSessionStatusErrors] + +export type ProviderBrowserSessionStatusResponses = { + /** + * Browser session status + */ + 200: { + recordId: string + enabled: boolean + profilePath: string + lastRefresh?: number + lastError?: string + isConfigured: boolean + } +} + +export type ProviderBrowserSessionStatusResponse = + ProviderBrowserSessionStatusResponses[keyof ProviderBrowserSessionStatusResponses] + +export type ProviderBrowserSessionSetupData = { + body?: never + path: { + /** + * OAuth record ID + */ + recordId: string + } + query?: { + directory?: string + } + url: "/provider/auth/browser/sessions/{recordId}/setup" +} + +export type ProviderBrowserSessionSetupErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderBrowserSessionSetupError = + ProviderBrowserSessionSetupErrors[keyof ProviderBrowserSessionSetupErrors] + +export type ProviderBrowserSessionSetupResponses = { + /** + * Browser session setup successful + */ + 200: { + success: boolean + message: string + } +} + +export type ProviderBrowserSessionSetupResponse = + ProviderBrowserSessionSetupResponses[keyof ProviderBrowserSessionSetupResponses] + +export type ProviderBrowserSessionRefreshData = { + body?: never + path: { + /** + * OAuth record ID + */ + recordId: string + } + query?: { + directory?: string + } + url: "/provider/auth/browser/sessions/{recordId}/refresh" +} + +export type ProviderBrowserSessionRefreshErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderBrowserSessionRefreshError = + ProviderBrowserSessionRefreshErrors[keyof ProviderBrowserSessionRefreshErrors] + +export type ProviderBrowserSessionRefreshResponses = { + /** + * Token refresh result + */ + 200: { + success: boolean + message: string + } +} + +export type ProviderBrowserSessionRefreshResponse = + ProviderBrowserSessionRefreshResponses[keyof ProviderBrowserSessionRefreshResponses] + export type FindTextData = { body?: never path?: never @@ -4765,6 +5164,101 @@ export type TuiControlResponseResponses = { export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses] +export type AuthUsage2Data = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/auth/usage" +} + +export type AuthUsage2Errors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthUsage2Error = AuthUsage2Errors[keyof AuthUsage2Errors] + +export type AuthUsage2Responses = { + /** + * Usage information per provider and account + */ + 200: AuthUsage +} + +export type AuthUsage2Response = AuthUsage2Responses[keyof AuthUsage2Responses] + +export type AuthSetActive2Data = { + body?: { + providerID: string + recordID: string + namespace?: string + } + path?: never + query?: { + directory?: string + } + url: "/auth/active" +} + +export type AuthSetActive2Errors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthSetActive2Error = AuthSetActive2Errors[keyof AuthSetActive2Errors] + +export type AuthSetActive2Responses = { + /** + * Active account updated + */ + 200: { + success: boolean + anthropicUsage?: unknown + } +} + +export type AuthSetActive2Response = AuthSetActive2Responses[keyof AuthSetActive2Responses] + +export type AuthRemoveAccountData = { + body?: { + providerID: string + recordID: string + namespace?: string + } + path?: never + query?: { + directory?: string + } + url: "/auth/account" +} + +export type AuthRemoveAccountErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthRemoveAccountError = AuthRemoveAccountErrors[keyof AuthRemoveAccountErrors] + +export type AuthRemoveAccountResponses = { + /** + * Account removed + */ + 200: { + removed: boolean + remaining: number + } +} + +export type AuthRemoveAccountResponse = AuthRemoveAccountResponses[keyof AuthRemoveAccountResponses] + export type InstanceDisposeData = { body?: never path?: never