diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 93abe731d2..ed9fcd5059 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -446,6 +446,23 @@ rotate the request to another eligible Pool account. These failure transitions r Clear the manual Codex account selection without resolving an account id, so it works even when an account is literally named `auto`. Codex pools only; other provider types have no automatic selection to restore. +### `ocx account pause|resume [--json]` + +Pause or resume one account in the Codex pool or a generic OAuth provider pool, including +`google-antigravity`. For the Codex pool, `main` identifies only the built-in Codex account; +generic OAuth accounts must be identified by id or a unique alias. A paused generic OAuth account +is excluded from request selection, 429 failover, and proactive token refresh, and cannot be +selected manually. Pausing the active account switches to the next usable account when one exists. +If every account is paused, requests that need that pool return 403 until an account is resumed. + +For a generic OAuth provider, identify the account by id or by a unique exact or case-insensitive +alias. The JSON response reports the account id, pause state, and active account id. + +```bash +ocx account pause google-antigravity +ocx account resume google-antigravity +``` + ### `ocx account refresh [--json]` For the Codex pool, use `ocx account refresh openai [--json]`. It force-refreshes account quotas and diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index f10c36ba19..bbda527682 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -516,8 +516,10 @@ outcome fields from an older server do not establish successful recovery. | `POST /api/oauth/login/cancel` | Cancel a public in-progress OAuth flow | 400 unknown provider | | `GET /api/oauth/status` | Poll one provider's OAuth flow | 400 unknown provider | | `POST /api/oauth/logout` | Remove the selected provider credential | 400 unknown provider; `oauth_mutation_busy` | -| `GET, DELETE /api/oauth/accounts` | List masked accounts or remove one account. Kiro rows include `autoSelectable` and a closed `skipReason` when excluded from automatic selection; an active singleton may still send. Quota remains opt-in. | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | -| `PUT /api/oauth/accounts/active` | Select the active OAuth account | 400 invalid provider/account; `oauth_mutation_busy` | +| `GET /api/oauth/accounts` | List masked accounts; generic OAuth account rows include their `paused` state. Kiro rows include `autoSelectable` and a closed `skipReason` when excluded from automatic selection; an active singleton may still send. Quota remains opt-in. | 400 invalid provider | +| `DELETE /api/oauth/accounts` | Remove one account | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | +| `PUT /api/oauth/accounts/active` | Select the active OAuth account | 400 invalid provider/account; 404 account missing; 409 account paused; `oauth_mutation_busy` | +| `PUT /api/oauth/accounts/pause` | Pause or resume one generic OAuth account. Body `{ provider, accountId, paused }`; pausing the active account selects the next usable account when available | 400 unsupported provider or invalid body; 404 account missing; `oauth_mutation_busy` | | `GET, PUT, PATCH /api/pool/settings` | Read or update pool policy for any kind (codex, anthropic, generic); answers with the same keys for all three and declares in `supported` which the kind honours | 400 unknown provider, a field the kind does not support, or an invalid value | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Legacy per-pool policy for Anthropic and generic OAuth providers; superseded by `/api/pool/settings` and kept for existing clients | 400 codex or api-key provider, or invalid policy | | `POST /api/oauth/accounts/clear-cooldown` | Clear one OAuth account's runtime cooldown | 400 invalid provider/account | diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index 432f525737..141467062a 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -142,6 +142,22 @@ Codex 池選擇套用於清除既有親和性後的下一個請求;進行中 不解析帳號 id 即清除 Codex 帳號的手動選擇,即使存在名為 `auto` 的帳號仍有效。僅適用於 Codex 池;其他提供者類型沒有可還原的自動選擇。 +### `ocx account pause|resume [--json]` + +暫停或恢復 Codex 帳號池或通用 OAuth 供應商池中的單一帳號,包括 +`google-antigravity`。在 Codex 池中,`main` 僅代表 Codex 內建帳號;通用 OAuth 帳號必須用 id 或唯一別名識別。 +已暫停的通用 OAuth 帳號不會參與請求選帳、429 輪替或主動 Token 刷新,也不能手動選取。 +若暫停目前使用中的帳號,系統會在有其他可用帳號時切換過去。若全部帳號都已暫停, +需要該池的請求會回覆 403,直到恢復其中一個帳號。 + +通用 OAuth 供應商可用帳號 id,或唯一且完全相符/不區分大小寫的別名識別帳號。 +JSON 回應會提供帳號 id、暫停狀態與目前 active 帳號 id。 + +```bash +ocx account pause google-antigravity +ocx account resume google-antigravity +``` + ### `ocx account refresh [--json]` 對於 Codex 池,請使用 `ocx account refresh openai [--json]`。它強制重新整理帳號配額並印出可用的週/月百分比與重置時間;缺失的配額資料被回報為未知,而非 0%。其 JSON 封裝為 `{ accounts: AccountRow[] }`,每個 Codex 列上有 `quota`。 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index f140be115a..9e3549dbb4 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -233,8 +233,10 @@ Aside 設定檔的變更在這種情況下仍會儲存一件事:確認之後 | `POST /api/oauth/login/cancel` | 取消公開進行中的 OAuth 流程 | 400 未知供應商 | | `GET /api/oauth/status` | 輪詢一個供應商的 OAuth 流程 | 400 未知供應商 | | `POST /api/oauth/logout` | 移除所選的供應商憑證 | 400 未知供應商;`oauth_mutation_busy` | -| `GET, DELETE /api/oauth/accounts` | 列出遮罩帳號或移除一個帳號 Kiro 列包含自動選取狀態 `autoSelectable`,排除時還包含封閉集合的 `skipReason`。唯一的有效帳號仍可傳送請求,配額查詢仍為選用。 | 400 無效供應商/id;404 帳號缺失;`oauth_mutation_busy` | -| `PUT /api/oauth/accounts/active` | 選擇現用 OAuth 帳號 | 400 無效供應商/帳號;`oauth_mutation_busy` | +| `GET /api/oauth/accounts` | 列出遮罩帳號;通用 OAuth 帳號列也會提供 `paused` 狀態。Kiro 列包含自動選取狀態 `autoSelectable`,排除時還包含封閉集合的 `skipReason`。唯一的有效帳號仍可傳送請求,配額查詢仍為選用。 | 400 無效供應商 | +| `DELETE /api/oauth/accounts` | 移除一個帳號 | 400 無效供應商/id;404 帳號缺失;`oauth_mutation_busy` | +| `PUT /api/oauth/accounts/active` | 選擇現用 OAuth 帳號 | 400 無效供應商/帳號;404 帳號缺失;409 帳號已暫停;`oauth_mutation_busy` | +| `PUT /api/oauth/accounts/pause` | 暫停或恢復一個通用 OAuth 帳號。Body `{ provider, accountId, paused }`;若暫停現用帳號,且有可用帳號,會切換至下一個 | 400 不支援的供應商或無效 body;404 帳號缺失;`oauth_mutation_busy` | | `GET, PUT, PATCH /api/oauth/accounts/pool` | 讀取或更新 Anthropic OAuth 池政策 | 400 非 Anthropic 供應商或無效政策 | | `POST /api/oauth/accounts/clear-cooldown` | 清除一個 OAuth 帳號的 runtime 冷卻 | 400 無效供應商/帳號 | | `PUT /api/oauth/accounts/alias` | 設定或清除 OAuth 帳號別名 | 400 無效供應商/帳號/別名 | diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 61debe1abb..58b7eb214e 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -179,7 +179,7 @@ function safeCockpitImportResult(value: unknown): CockpitImportResult | null { export default function ProviderAuthPanel({ item, apiBase, oauth, accounts = EMPTY_OAUTH_ACCOUNTS, keys = EMPTY_API_KEYS, accountLoadState = "ready", - switchingAccountId = null, busy = false, loginHint, authHandlers, onCodexActiveNeedsReauthChange, + switchingAccountId = null, pausingAccountId = null, busy = false, loginHint, authHandlers, onCodexActiveNeedsReauthChange, codexController, onUpdateProvider, }: { item: WorkspaceItem; @@ -189,6 +189,7 @@ export default function ProviderAuthPanel({ keys?: ApiKeyRow[]; accountLoadState?: AccountLoadState; switchingAccountId?: string | null; + pausingAccountId?: string | null; busy?: boolean; loginHint?: LoginHint | null; authHandlers?: ProviderAuthHandlers; @@ -536,6 +537,7 @@ export default function ProviderAuthPanel({ {accounts.map(account => { const label = oauthAccountDisplayLabel(accounts, account, t); const switching = switchingAccountId === account.id; + const pausing = pausingAccountId === account.id; const healthStatus = account.health?.status; const showReauth = accountShowsReauth(account); const inCooldown = oauthHealthIsCooldown(healthStatus); @@ -546,11 +548,11 @@ export default function ProviderAuthPanel({
  • + {typeof account.paused === "boolean" && authHandlers.onPauseAccount && ( + + )} {showReauth && ( diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index e601635f1d..495158b567 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -54,6 +54,7 @@ export default function ProviderDetails({ settingsFocusToken = 0, settingsFocusProvider = null, switchingAccountId, + pausingAccountId, keys, busyProvider, loginHint, @@ -98,6 +99,7 @@ export default function ProviderDetails({ settingsFocusToken?: number; settingsFocusProvider?: string | null; switchingAccountId?: string | null; + pausingAccountId?: string | null; keys?: ApiKeyRow[]; busyProvider?: string | null; loginHint?: LoginHint | null; @@ -357,6 +359,7 @@ export default function ProviderDetails({ keys={keys} accountLoadState={accountLoadState} switchingAccountId={switchingAccountId} + pausingAccountId={pausingAccountId} busy={busyProvider === item.name} loginHint={loginHint} authHandlers={authHandlers} diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index 8f95133b77..0fbca409b7 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -59,7 +59,8 @@ export type OAuthAccountRow = AccountQuotaReading & { active: boolean; needsReauth?: boolean; autoSelectable?: boolean; - skipReason?: "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted"; + skipReason?: "needs_reauth" | "paused" | "suspended" | "cooldown" | "quota_exhausted"; + paused?: boolean; health?: { status: OAuthAccountHealthStatus; reason?: string; until?: string }; healthLabel?: string; healthSummary?: string; @@ -89,6 +90,7 @@ export interface ProviderAuthHandlers { onLogout: (provider: string) => void | Promise; onReauth: (provider: string, accountId?: string) => void | Promise; onSwitchAccount: (provider: string, account: OAuthAccountRow) => void | Promise; + onPauseAccount: (provider: string, account: OAuthAccountRow, paused: boolean) => void | Promise; onRemoveAccount: (provider: string, account: OAuthAccountRow) => void | Promise; onRetryAccounts?: (provider: string) => void | Promise; onAddApiKey: (provider: string, key: string) => Promise; diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index 8cfec14fd5..ff203f3814 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -21,7 +21,8 @@ export interface OAuthAccount extends AccountQuotaReading { active: boolean; needsReauth?: boolean; autoSelectable?: boolean; - skipReason?: "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted"; + skipReason?: "needs_reauth" | "paused" | "suspended" | "cooldown" | "quota_exhausted"; + paused?: boolean; expiresAt?: number; health?: { status: "healthy" | "cooldown" | "reauth_required" | "warning"; reason?: string; until?: string }; healthLabel?: string; @@ -122,6 +123,7 @@ export function useProviderAccountPools(deps: { const [accountSets, setAccountSets] = useState>({}); const [accountLoadStates, setAccountLoadStates] = useState>({}); const [switchingAccount, setSwitchingAccount] = useState<{ provider: string; accountId: string } | null>(null); + const [pausingAccount, setPausingAccount] = useState<{ provider: string; accountId: string; paused: boolean } | null>(null); const [openAccounts, setOpenAccounts] = useState>({}); const [keyPools, setKeyPools] = useState>({}); const [addingKeyFor, setAddingKeyFor] = useState(null); @@ -131,6 +133,7 @@ export function useProviderAccountPools(deps: { const quotaGenerationRef = useRef>({}); const selectionMutationsRef = useRef(new Map()); const requestsRef = useRef(new Set()); + const pausingAccountRef = useRef<{ provider: string; accountId: string } | null>(null); const mountedRef = useRef(true); const serverRef = useRef(apiBase); useEffect(() => { @@ -145,6 +148,7 @@ export function useProviderAccountPools(deps: { if (serverChanged) void Promise.resolve().then(() => { if (!mountedRef.current || serverRef.current !== apiBase) return; setAccountSets({}); + setPausingAccount(null); setKeyPools({}); setAccountLoadStates({}); }); @@ -154,6 +158,7 @@ export function useProviderAccountPools(deps: { for (const key of Object.keys(rosterGenerations)) rosterGenerations[key] += 1; for (const key of Object.keys(quotaGenerations)) quotaGenerations[key] += 1; mutations.clear(); + pausingAccountRef.current = null; for (const controller of requests) controller.abort(); requests.clear(); }; @@ -358,7 +363,7 @@ export function useProviderAccountPools(deps: { }; const switchAccount = async (provider: string, account: OAuthAccount) => { - if (account.active || account.needsReauth || switchingAccountRef.current) return; + if (account.active || account.needsReauth || account.paused || switchingAccountRef.current || pausingAccountRef.current) return; const target = { provider, accountId: account.id }; switchingAccountRef.current = target; setSwitchingAccount(target); @@ -398,6 +403,60 @@ export function useProviderAccountPools(deps: { } }; + const pauseAccount = async (provider: string, account: OAuthAccount, paused: boolean) => { + if (switchingAccountRef.current || pausingAccountRef.current) return; + const target = { provider, accountId: account.id }; + pausingAccountRef.current = target; + setPausingAccount({ ...target, paused }); + const key = invalidateSelectionReads(provider, "oauth"); + const mutation = Symbol(); + selectionMutationsRef.current.set(key, mutation); + const currentMutation = () => aliveRef.current && mountedRef.current && serverRef.current === apiBase + && selectionMutationsRef.current.get(key) === mutation; + const label = oauthAccountDisplayLabel(accountSets[provider]?.accounts ?? [account], account, t); + try { + const res = await fetch(`${apiBase}/api/oauth/accounts/pause`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, accountId: account.id, paused }), + }); + if (!currentMutation()) return; + if (!res.ok) { + notify(t(paused ? "codexAuth.pauseFailed" : "codexAuth.resumeFailed", { email: label }), false); + return; + } + const result = await res.json().catch(() => ({})) as { activeAccountId?: string | null; activeAccountChanged?: boolean }; + if (!currentMutation()) return; + invalidateSelectionReads(provider, "oauth"); + const selected = result.activeAccountId === undefined + ? accountSets[provider]?.activeAccountId ?? null + : result.activeAccountId; + setAccountSets(current => { + const existing = current[provider]; + if (!existing) return current; + const accounts = existing.accounts.map(row => row.id === account.id ? { ...row, paused } : row); + return { ...current, [provider]: { activeAccountId: selected, accounts: selectionRows(accounts, selected) } }; + }); + selectionMutationsRef.current.delete(key); + const refreshed = await refreshAccountRosters({ provider, kind: "oauth" }); + if (result.activeAccountChanged) await Promise.all([fetchOauth(), fetchProviderQuotas(true)]); + if (!refreshed) { notify(t("pws.accountsLoadFailed"), false); return; } + notify(t(paused ? "codexAuth.pauseSucceeded" : "codexAuth.resumeSucceeded", { email: label }), true); + } catch { + if (currentMutation()) notify(t(paused ? "codexAuth.pauseFailed" : "codexAuth.resumeFailed", { email: label }), false); + } finally { + if (currentMutation()) { + invalidateSelectionReads(provider, "oauth"); + selectionMutationsRef.current.delete(key); + void refreshAccountRosters({ provider, kind: "oauth" }); + } + if (pausingAccountRef.current?.provider === target.provider && pausingAccountRef.current.accountId === target.accountId) { + pausingAccountRef.current = null; + if (aliveRef.current) setPausingAccount(null); + } + } + }; + const switchApiKey = async (provider: string, entry: ApiKeyEntry) => { if (entry.active || selectionMutationsRef.current.has(`key:${provider}`)) return; const key = invalidateSelectionReads(provider, "api-key"); @@ -552,9 +611,9 @@ export function useProviderAccountPools(deps: { ); return { - accountSets, accountLoadStates, switchingAccount, openAccounts, keyPools, addingKeyFor, newKeyValue, + accountSets, accountLoadStates, switchingAccount, pausingAccount, openAccounts, keyPools, addingKeyFor, newKeyValue, setAccountSets, setAccountLoadStates, setSwitchingAccount, setOpenAccounts, setKeyPools, setAddingKeyFor, setNewKeyValue, - fetchAccountSets, fetchKeyPools, refreshAccountRosters, switchAccount, switchApiKey, removeApiKey, addApiKeyValue, addApiKey, editCredentialAlias, removeAccount, + fetchAccountSets, fetchKeyPools, refreshAccountRosters, switchAccount, pauseAccount, switchApiKey, removeApiKey, addApiKeyValue, addApiKey, editCredentialAlias, removeAccount, oauthCardProviders, keyCardProviders, activeAccountNeedsReauth, }; } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index c9dfd974a3..d1c42ae514 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2590,6 +2590,7 @@ export const de: Record = { "pws.cockpitImportComplete": "Import abgeschlossen: {imported} importiert, {updated} aktualisiert, {failed} fehlgeschlagen, {unsupported} nicht unterstützt.", "pws.accountSwitching": "Wechsel läuft…", "pws.accountCurrent": "Aktuelles Konto", + "pws.accountPausedHint": "Bis zur Wiederaufnahme von automatischer Auswahl, Wiederholungen, Cooldown-Wiederherstellung, manueller Auswahl und proaktiver Token-Erneuerung ausgeschlossen.", "pws.defaultModelNone": "Keins (Standard des Anbieters verwenden)", "pws.discardSettings": "Verwerfen", "pws.jsonEditorDesc": "Bearbeiten Sie die JSON-Konfiguration des Anbieters. Änderungen werden sofort gespeichert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d5eeb2e927..7a34403c48 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1632,6 +1632,7 @@ export const en = { "pws.cockpitImportComplete": "Import complete: {imported} imported, {updated} updated, {failed} failed, {unsupported} unsupported.", "pws.accountSwitching": "Switching…", "pws.accountCurrent": "Current account", + "pws.accountPausedHint": "Excluded from automatic selection, retries, cooldown recovery, manual selection, and proactive token refresh until resumed.", "pws.defaultModelNone": "None (use provider default)", "pws.discardSettings": "Discard", "pws.jsonEditorDesc": "Edit the raw provider JSON config. Changes are saved immediately.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5ca92bf372..9daf15f223 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1605,6 +1605,7 @@ export const fr: Record = { "pws.cockpitImportComplete": "Importation terminée : {imported} importés, {updated} mis à jour, {failed} en échec, {unsupported} non pris en charge.", "pws.accountSwitching": "Changement…", "pws.accountCurrent": "Compte actuel", + "pws.accountPausedHint": "Exclu du choix automatique, des nouvelles tentatives, de la récupération après délai, de la sélection manuelle et du renouvellement proactif du jeton jusqu’à sa réactivation.", "pws.defaultModelNone": "Aucun (utiliser la valeur par défaut du fournisseur)", "pws.discardSettings": "Abandonner les modifications", "pws.jsonEditorDesc": "Modifiez la configuration JSON brute du fournisseur. Les modifications sont enregistrées immédiatement.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 67b3a4973f..e9c58db75c 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1527,6 +1527,7 @@ export const ja: Record = { "pws.cockpitImportComplete": "インポート完了: インポート {imported}、更新 {updated}、失敗 {failed}、未対応 {unsupported}。", "pws.accountSwitching": "切り替え中…", "pws.accountCurrent": "現在のアカウント", + "pws.accountPausedHint": "再開するまで、自動選択、再試行、クールダウン復旧、手動選択、トークンの事前更新の対象外です。", "pws.defaultModelNone": "なし(プロバイダーのデフォルトを使用)", "pws.discardSettings": "破棄", "pws.jsonEditorDesc": "生のプロバイダー JSON 設定を編集します。変更はすぐに保存されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ecb3c2b100..df8d28ea6e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2629,6 +2629,7 @@ export const ko: Record = { "pws.cockpitImportComplete": "가져오기 완료: 가져옴 {imported}, 업데이트 {updated}, 실패 {failed}, 지원되지 않음 {unsupported}.", "pws.accountSwitching": "전환 중…", "pws.accountCurrent": "현재 계정", + "pws.accountPausedHint": "재개할 때까지 자동 선택, 재시도, 쿨다운 복구, 수동 선택 및 사전 토큰 갱신에서 제외됩니다.", "pws.defaultModelNone": "없음 (프로바이더 기본값 사용)", "pws.discardSettings": "되돌리기", "pws.jsonEditorDesc": "프로바이더 JSON 설정을 직접 편집합니다. 저장 즉시 반영됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 16a41bf697..c9d28b9017 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1599,6 +1599,7 @@ export const ru: Record = { "pws.cockpitImportComplete": "Импорт завершён: импортировано — {imported}, обновлено — {updated}, ошибок — {failed}, неподдерживаемых — {unsupported}.", "pws.accountSwitching": "Переключение…", "pws.accountCurrent": "Текущий аккаунт", + "pws.accountPausedHint": "До возобновления исключён из автоматического выбора, повторов, восстановления после задержки, ручного выбора и проактивного обновления токена.", "pws.defaultModelNone": "Нет (использовать значение провайдера)", "pws.discardSettings": "Не сохранять", "pws.jsonEditorDesc": "Редактируйте исходную JSON-конфигурацию провайдера. Изменения сохраняются сразу.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b592e84752..5731690165 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1618,6 +1618,7 @@ export const tr: Record = { "pws.cockpitImportComplete": "İçe aktarma tamamlandı: {imported} içe aktarıldı, {updated} güncellendi, {failed} başarısız, {unsupported} desteklenmiyor.", "pws.accountSwitching": "Değiştiriliyor…", "pws.accountCurrent": "Mevcut hesap", + "pws.accountPausedHint": "Devam ettirilene kadar otomatik seçim, yeniden deneme, bekleme süresinden kurtarma, manuel seçim ve proaktif belirteç yenilemesinden hariç tutulur.", "pws.defaultModelNone": "Yok (sağlayıcı varsayılanını kullan)", "pws.discardSettings": "Vazgeç", "pws.jsonEditorDesc": "Ham sağlayıcı JSON konfigürasyonunu düzenleyin.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 4be42b9ebc..b3a6a3815a 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1602,6 +1602,7 @@ export const vi: Record = { "pws.cockpitImportComplete": "Đã nhập xong: {imported} được nhập, {updated} được cập nhật, {failed} thất bại, {unsupported} không được hỗ trợ.", "pws.accountSwitching": "Đang chuyển…", "pws.accountCurrent": "Tài khoản hiện tại", + "pws.accountPausedHint": "Được loại trừ khỏi chọn tài khoản tự động, thử lại, hồi phục sau thời gian chờ, lựa chọn thủ công và làm mới token chủ động cho đến khi tiếp tục.", "pws.defaultModelNone": "Không có (sử dụng mặc định của provider)", "pws.discardSettings": "Huỷ bỏ", "pws.jsonEditorDesc": "Chỉnh sửa config dạng JSON thô của provider. Các thay đổi sẽ được lưu ngay lập tức.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 9285fe2a45..86750ef2a5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1306,6 +1306,7 @@ export const zhTW: Record = { "pws.noAccounts": "尚未連線任何帳號。", "pws.accountSwitching": "切換中…", "pws.accountCurrent": "當前帳號", + "pws.accountPausedHint": "恢復前不會參與自動切換、重試、冷卻恢復、手動選擇或主動 Token 刷新。", "pws.defaultModelNone": "無(使用供應商預設值)", "pws.discardSettings": "放棄", "pws.jsonEditorDesc": "直接編輯供應商 JSON 配置。更改將立即儲存。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index d30a30fe4e..9f9bf1e1ff 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2610,6 +2610,7 @@ export const zh: Record = { "pws.cockpitImportComplete": "导入完成:已导入 {imported},已更新 {updated},失败 {failed},不支持 {unsupported}。", "pws.accountSwitching": "切换中…", "pws.accountCurrent": "当前账户", + "pws.accountPausedHint": "恢复前不会参与自动切换、重试、冷却恢复、手动选择或主动令牌刷新。", "pws.defaultModelNone": "无(使用提供商默认值)", "pws.discardSettings": "放弃", "pws.jsonEditorDesc": "直接编辑提供商 JSON 配置。更改将立即保存。", diff --git a/gui/src/kiro-device-login-helpers.ts b/gui/src/kiro-device-login-helpers.ts index b2785756ed..47b4cab06a 100644 --- a/gui/src/kiro-device-login-helpers.ts +++ b/gui/src/kiro-device-login-helpers.ts @@ -55,6 +55,7 @@ export function kiroSkipReasonKey(account: { }, provider: string): TKey | null { if (provider !== "kiro" || account.autoSelectable !== false) return null; if (account.skipReason === "needs_reauth") return null; + if (account.skipReason === "paused") return null; if (account.skipReason === "cooldown" && account.health?.status === "cooldown") return null; if (account.skipReason === "suspended") return "kiroSelection.suspended"; if (account.skipReason === "quota_exhausted") return "kiroSelection.quotaExhausted"; diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index cddbcf7df7..da2c7787f9 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -384,9 +384,9 @@ export default function Providers({ apiBase }: { apiBase: string }) { fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth, }); const { - accountSets, setAccountSets, accountLoadStates, switchingAccount, keyPools, fetchAccountSets, fetchKeyPools, + accountSets, setAccountSets, accountLoadStates, switchingAccount, pausingAccount, keyPools, fetchAccountSets, fetchKeyPools, refreshAccountRosters, oauthCardProviders, keyCardProviders, - switchAccount, switchApiKey, removeApiKey, addApiKeyValue, editCredentialAlias, + switchAccount, pauseAccount, switchApiKey, removeApiKey, addApiKeyValue, editCredentialAlias, removeAccount, activeAccountNeedsReauth, } = pools; const refreshSelection = useCallback((target?: AccountSelectionTarget) => { @@ -648,6 +648,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { settingsFocusToken={settingsFocus.token} settingsFocusProvider={settingsFocus.provider} switchingAccountId={switchingAccount?.provider === item.name ? switchingAccount.accountId : null} + pausingAccountId={pausingAccount?.provider === item.name ? pausingAccount.accountId : null} busyProvider={busy} loginHint={loginInfo} authHandlers={{ @@ -657,6 +658,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onLogout: logoutOAuth, onReauth: (provider, accountId) => requestLoginOAuth(provider, true, accountId), onSwitchAccount: switchAccount, + onPauseAccount: pauseAccount, onRemoveAccount: removeAccount, onRetryAccounts: async provider => { await fetchAccountSets([provider]); }, onAddApiKey: addApiKeyValue, diff --git a/gui/tests/kiro-account-skip-reason.test.tsx b/gui/tests/kiro-account-skip-reason.test.tsx index 4addd00a84..dc7f96c900 100644 --- a/gui/tests/kiro-account-skip-reason.test.tsx +++ b/gui/tests/kiro-account-skip-reason.test.tsx @@ -5,6 +5,7 @@ test("Kiro exclusion labels appear only for eligible rows and avoid duplicate he expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "suspended" }, "kiro")).toBe("kiroSelection.suspended"); expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "quota_exhausted" }, "kiro")).toBe("kiroSelection.quotaExhausted"); expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "needs_reauth" }, "kiro")).toBeNull(); + expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "paused" }, "kiro")).toBeNull(); expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "cooldown", health: { status: "cooldown" } }, "kiro")).toBeNull(); expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "cooldown" }, "kiro")).toBe("kiroSelection.cooldown"); expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "future_reason" }, "kiro")).toBe("kiroSelection.generic"); diff --git a/gui/tests/provider-quota-refresh-controls.test.tsx b/gui/tests/provider-quota-refresh-controls.test.tsx index 31c65cfee5..5766221a13 100644 --- a/gui/tests/provider-quota-refresh-controls.test.tsx +++ b/gui/tests/provider-quota-refresh-controls.test.tsx @@ -14,6 +14,7 @@ import { createRoot, type Root } from "react-dom/client"; import ProviderUsage from "../src/components/provider-workspace/ProviderUsage"; import ProviderAuthPanel from "../src/components/provider-workspace/ProviderAuthPanel"; import { LanguageProvider } from "../src/i18n/provider"; +import { en } from "../src/i18n/en"; import type { WorkspaceItem } from "../src/provider-workspace/catalog"; import type { ProviderAuthHandlers } from "../src/components/provider-workspace/types"; @@ -171,6 +172,34 @@ test("the accounts surface omits the control when the page cannot force a read", expect(findButton("Refresh quotas")).toBeNull(); }); +test("generic OAuth accounts expose pause and resume controls", async () => { + const calls: Array<{ provider: string; accountId: string; paused: boolean }> = []; + const handlers = authHandlers({ + onPauseAccount: async (provider, row, paused) => { calls.push({ provider, accountId: row.id, paused }); }, + }); + const item = { ...oauthItem, name: "google-antigravity" }; + + await render(); + const pause = findButton("Pause"); + expect(pause).not.toBeNull(); + await act(async () => { pause!.click(); }); + expect(calls).toEqual([{ provider: "google-antigravity", accountId: "ga-active", paused: true }]); + + await render(); + const resume = findButton("Resume"); + expect(resume).not.toBeNull(); + expect(resume!.getAttribute("title")).toBe(en["pws.accountPausedHint"]); + expect(host.textContent).toContain(en["pws.accountPausedHint"]); + expect(host.textContent).not.toContain(en["codexAuth.pausedHint"]); + expect(en["pws.accountPausedHint"]).not.toBe(en["codexAuth.pausedHint"]); + await act(async () => { resume!.click(); }); + expect(calls[1]).toEqual({ provider: "google-antigravity", accountId: "ga-active", paused: false }); +}); + test("API-key rows use independent shared credit readings and the same awaited refresh control", async () => { const { handler, settle } = deferredHandler(); const credits = (remaining: number) => ({ updatedAt: Date.now() - 60_000, diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 550a444b69..8b4e14b647 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -772,14 +772,21 @@ export async function cmdPriority(args: string[], deps: AccountDeps): Promise ` (#2702). - * - * The server routes have always existed; only the CLI caller was missing, so pausing an - * account was dashboard-only. The issue reports these as POST; the code is PUT - * (`auth-api.ts:1494`), and the route is shared by both directions with a `paused` boolean - * rather than being two endpoints. - */ +function resolveGenericOAuthPauseTarget(accounts: unknown[], requested: string): { id: string } | { error: string } { + const rows = accounts.filter((value): value is { id: string; alias?: unknown } => + typeof value === "object" && value !== null && typeof (value as { id?: unknown }).id === "string", + ); + if (rows.some(account => account.id === requested)) return { id: requested }; + const exact = rows.filter(account => account.alias === requested); + const matches = exact.length > 0 + ? exact + : rows.filter(account => typeof account.alias === "string" && account.alias.toLowerCase() === requested.toLowerCase()); + if (matches.length === 1) return { id: matches[0]!.id }; + if (matches.length > 1) return { error: `alias "${requested}" names ${matches.length} accounts; use the account id` }; + return { error: `Account not found: no OAuth account has the id or alias "${requested}"` }; +} + +/** Pause or resume a Codex account or a generic OAuth provider account. */ export async function cmdPause(args: string[], deps: AccountDeps, paused: boolean): Promise { const wantsJson = flag(args, "--json"); const name = args.shift(); @@ -788,11 +795,40 @@ export async function cmdPause(args: string[], deps: AccountDeps, paused: boolea if (!name || !requestedId || args.length) return usage(); const classified = configAndType(deps, name); if ("error" in classified) return usage(`Error: ${classified.error}`); - if (classified.type !== "codex") { - return usage(`Error: ${verb} applies to the openai Codex account pool`); - } const baseUrl = await resolveBaseUrl(deps); if (!baseUrl) return proxyUnreachable(); + + if (classified.type === "oauth") { + if (name === "anthropic") return usage(`Error: ${verb} is not supported for the Anthropic OAuth pool`); + const list = await apiJson(deps, baseUrl, "GET", `/api/oauth/accounts?provider=${encodeURIComponent(name)}`); + if (list.status === 0) return proxyUnreachable(list.transportError); + if (list.status !== 200) return apiError(list.json, `failed to list ${name} OAuth accounts`, list.status); + const target = resolveGenericOAuthPauseTarget(Array.isArray(list.json.accounts) ? list.json.accounts : [], requestedId); + if ("error" in target) return usage(`Error: ${target.error}`); + + const response = await apiJson(deps, baseUrl, "PUT", "/api/oauth/accounts/pause", { + provider: name, + accountId: target.id, + paused, + }); + if (response.status === 0) return proxyUnreachable(response.transportError); + if (response.status !== 200) return apiError(response.json, `failed to ${verb} ${requestedId}`, response.status); + + if (wantsJson) { + console.log(JSON.stringify({ ok: true, provider: name, id: target.id, paused, + activeAccountId: response.json.activeAccountId }, null, 2)); + } else { + console.log(`${name}: ${requestedId} ${paused ? "paused" : "resumed"}`); + if (response.json.activeAccountChanged === true) { + console.error(`Active account changed to ${String(response.json.activeAccountId)}.`); + } + } + return 0; + } + + if (classified.type !== "codex") { + return usage(`Error: ${verb} applies to the openai Codex account pool or a generic OAuth provider`); + } const target = await resolveCodexAccountTarget(deps, baseUrl, requestedId); if ("networkDown" in target) return proxyUnreachable(target.transportError); if ("error" in target) return reportCodexAccountTargetError(target); diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index fd269ae855..ccebd0fe6e 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -522,21 +522,28 @@ export const CAPABILITIES: readonly Capability[] = [ }, { command: ["account", "pause"], - summary: "Stop routing new requests to one account in the Codex pool.", - // One route, both directions: `resume` is the same PUT with `paused: false`. - routes: [{ method: "PUT", path: "/api/codex-auth/accounts/pause" }], + summary: "Stop routing new requests to one account in a Codex or supported generic OAuth pool.", + // Resume uses the same endpoints with `paused: false`. + routes: [ + { method: "PUT", path: "/api/codex-auth/accounts/pause" }, + { method: "GET", path: "/api/oauth/accounts" }, + { method: "PUT", path: "/api/oauth/accounts/pause" }, + ], flags: [{ name: "--json", value: "boolean", summary: "Emit the pause result as JSON." }], mutates: true, json: "envelope", details: [ - "Pausing also unbinds threads pinned to the account and selects a fallback if it was active -- side effects of the route, not of the word `pause`.", - "The issue that requested this reported the route as POST; it is PUT.", + "Codex pause unbinds pinned threads and selects a fallback when possible. Generic OAuth pause excludes that account from new requests and failover; Anthropic is unsupported.", ], }, { command: ["account", "resume"], - summary: "Return a paused account to the Codex pool.", - routes: [{ method: "PUT", path: "/api/codex-auth/accounts/pause" }], + summary: "Return a paused account to a Codex or supported generic OAuth pool.", + routes: [ + { method: "PUT", path: "/api/codex-auth/accounts/pause" }, + { method: "GET", path: "/api/oauth/accounts" }, + { method: "PUT", path: "/api/oauth/accounts/pause" }, + ], flags: [{ name: "--json", value: "boolean", summary: "Emit the resume result as JSON." }], mutates: true, json: "envelope", diff --git a/src/lib/account-selection-events.ts b/src/lib/account-selection-events.ts index 655dd9aa99..c3381b9106 100644 --- a/src/lib/account-selection-events.ts +++ b/src/lib/account-selection-events.ts @@ -6,6 +6,7 @@ export type AccountSelectionEvent = { }; const listeners = new Set<(event: AccountSelectionEvent) => void>(); +const oauthPauseListeners = new Set<(provider: string) => void>(); let revision = 0; /** Call only after the authoritative selection has been persisted. */ @@ -27,6 +28,19 @@ export function subscribeAccountSelections(listener: (event: AccountSelectionEve return () => { listeners.delete(subscription); }; } +/** Internal eligibility invalidation; separate from the public selection stream contract. */ +export function publishOAuthAccountPauseChange(provider: string): void { + for (const listener of [...oauthPauseListeners]) { + try { listener(provider); } catch { /* A disconnected cache consumer cannot undo persistence. */ } + } +} + +export function subscribeOAuthAccountPauseChanges(listener: (provider: string) => void): () => void { + const subscription = (provider: string) => listener(provider); + oauthPauseListeners.add(subscription); + return () => { oauthPauseListeners.delete(subscription); }; +} + export function currentAccountSelectionRevision(): number { return revision; } diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts index 809fdba6b2..0c69a2faa1 100644 --- a/src/oauth/generic-account-failover.ts +++ b/src/oauth/generic-account-failover.ts @@ -42,6 +42,7 @@ import { kiroEvidenceIdentity } from "../providers/kiro-account-state-disk"; import { kiroAccountSupportsModel } from "../providers/kiro-model-catalog"; import { ACCOUNT_QUOTA_TTL_MS } from "../providers/quota-wire"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; +import { subscribeOAuthAccountPauseChanges } from "../lib/account-selection-events"; import type { OcxConfig, OcxProviderConfig } from "../types"; /** Cap same-request rotations so a short Retry-After cannot spin. Mirrors the Anthropic bound. */ @@ -98,6 +99,8 @@ const health = new Map(); /** Provider -> recent eligible-account count. TTL-bounded; never holds credential material. */ const presence = new Map(); +subscribeOAuthAccountPauseChanges(provider => presence.delete(provider)); + const healthKey = (provider: string, accountId: string, family?: QuotaModelFamily) => family ? `${provider}\u0000${accountId}\u0000${family}` : `${provider}\u0000${accountId}`; @@ -128,12 +131,13 @@ function isCooled(provider: string, accountId: string, now: number, family?: Quo return true; } -export type KiroSkipReason = "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted"; +export type KiroSkipReason = "paused" | "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted"; /** Eligibility for automatic alternatives; an active singleton can still send. */ export function kiroAutoSelection( account: ProviderAccount, now = Date.now(), ): { autoSelectable: boolean; skipReason?: KiroSkipReason } { + if (account.paused === true) return { autoSelectable: false, skipReason: "paused" }; if (account.needsReauth === true) return { autoSelectable: false, skipReason: "needs_reauth" }; const cooled = isCooled("kiro", account.id, now); if (cooled && health.get(healthKey("kiro", account.id))?.cooldownSource === "kiro-suspension") @@ -163,8 +167,10 @@ function eligibleAccountCount(providerName: string, now: number): number { const set = getAccountSet(providerName); // Kiro terminal refresh marks the just-refused account needsReauth before the alternate // selector runs. Both stored logins still express consent to recover through the survivor. - const eligible = set ? (providerName === "kiro" ? set.accounts.length - : set.accounts.filter(account => account.needsReauth !== true).length) : 0; + const eligible = set + ? set.accounts.filter(account => account.paused !== true + && (providerName === "kiro" || account.needsReauth !== true)).length + : 0; presence.set(providerName, { eligible, readAt: now }); return eligible; } @@ -260,8 +266,9 @@ function eligibleIdsIn( ): string[] { if (!set) return []; return set.accounts - .filter(account => providerName === "kiro" ? kiroAutoSelection(account, now).autoSelectable - : account.needsReauth !== true && !isCooled(providerName, account.id, now, family)) + .filter(account => account.paused !== true + && (providerName === "kiro" ? kiroAutoSelection(account, now).autoSelectable + : account.needsReauth !== true && !isCooled(providerName, account.id, now, family))) .map(account => account.id); } @@ -579,7 +586,9 @@ export function preferredInitialAccount( if (!selected) return null; const active = selected.activeAccountId; const accountRows = new Map(selected.accounts.map(account => [account.id, account])); - const order = selected.accounts.filter(account => account.needsReauth !== true).map(account => account.id); + const order = selected.accounts + .filter(account => account.paused !== true && account.needsReauth !== true) + .map(account => account.id); if (order.length < 2) return null; const modelEligible = preferKiroModelSupport(providerName, @@ -629,7 +638,7 @@ export function preferredInitialAccount( } const activeRow = selected.accounts.find(account => account.id === active); - if (activeRow && activeRow.needsReauth !== true + if (activeRow && activeRow.paused !== true && activeRow.needsReauth !== true && !isCooled(providerName, activeRow.id, now, classifyModelFamilyForQuota(providerName, requestedModelId)) && !isAccountQuotaExhausted(providerName, activeRow.id, requestedModelId, activeRow)) return null; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index f705bf43df..cf7adde6e9 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -108,6 +108,7 @@ export interface ObservedOAuthAccessSnapshot extends OAuthAccessSnapshot { export type OAuthActiveTokenObservation = | { readonly kind: "available"; readonly snapshot: ObservedOAuthAccessSnapshot } + | { readonly kind: "paused" } | { readonly kind: "missing" } | { readonly kind: "malformed" } | { readonly kind: "needs-reauth" } @@ -418,6 +419,14 @@ export class OAuthLoginRequiredError extends Error { } } +/** An operator-paused account is temporarily unavailable, not an invalid login. */ +export class OAuthAccountPausedError extends Error { + constructor() { + super("OAuth account is paused. Resume it in account settings and retry."); + this.name = "OAuthAccountPausedError"; + } +} + export class OAuthProviderPublicationError extends Error { constructor() { super("OAuth credential was saved, but the provider entry was not written. Resolve the account namespace collision, then retry login."); @@ -455,6 +464,7 @@ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { } if ( (error instanceof OAuthLoginRequiredError && isOAuthProvider(error.provider)) + || error instanceof OAuthAccountPausedError || error instanceof OAuthProviderPublicationError // Reauth identity outcomes carry fixed, account-free remediation text. Dropping them to the // generic message hides WHICH failure the user must fix (sign in with the selected account). @@ -531,6 +541,7 @@ export function observeActiveOAuthAccessToken( const accountSet = authStore.store[provider]; const account = accountSet?.accounts.find(candidate => candidate.id === accountSet.activeAccountId); if (!account) return { kind: "missing" }; + if (account.paused === true) return { kind: "paused" }; if (account.needsReauth) return { kind: "needs-reauth" }; if (account.credential.expires <= now) return { kind: "expired" }; if (account.credential.expires <= now + REFRESH_SKEW_MS) return { kind: "near-expiry" }; @@ -562,6 +573,7 @@ async function resolveAccessSnapshotForAccount( // request would dispatch on an account already known to need a fresh login. const row = getAccountCredentialWithStatus(provider, accountId); if (!row) throw new OAuthLoginRequiredError(provider); + if (row.paused === true) throw new OAuthAccountPausedError(); if (requireUsableAccount && row.needsReauth) throw new OAuthLoginRequiredError(provider); const cred = row.credential; const current = accessSnapshot(provider, accountId, cred, oauthProvider); @@ -593,8 +605,10 @@ async function resolveAccessSnapshotForAccount( }; const refresh = (async (): Promise => { const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred, abort.signal, flight, replacedStaleFlight); - const persisted = getAccountCredential(provider, accountId); - if (!persisted) throw new OAuthLoginRequiredError(provider); + const persistedRow = getAccountCredentialWithStatus(provider, accountId); + if (!persistedRow) throw new OAuthLoginRequiredError(provider); + if (persistedRow.paused === true) throw new OAuthAccountPausedError(); + const persisted = persistedRow.credential; if (persisted.access !== accessToken) { throw new Error(`OAuth refresh persisted an unexpected access token for ${provider}`); } diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 1720867c33..cea1d9ce53 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -27,7 +27,7 @@ import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget"; -import { publishAccountSelection } from "../lib/account-selection-events"; +import { publishAccountSelection, publishOAuthAccountPauseChange } from "../lib/account-selection-events"; import { captureConfigGeneration, type GenerationContext, @@ -625,6 +625,7 @@ function normalizeAccount(value: unknown): ProviderAccount | null { const account: ProviderAccount = { id: candidate.id, credential }; if (typeof candidate.alias === "string" && candidate.alias.trim()) account.alias = candidate.alias.trim(); if (candidate.needsReauth === true) account.needsReauth = true; + if (candidate.paused === true) account.paused = true; if (typeof candidate.addedAt === "number") account.addedAt = candidate.addedAt; if (typeof candidate.loginId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate.loginId)) { @@ -874,7 +875,7 @@ export async function saveCredentialWithReceipt( if (existing) { existing.credential = safe; delete existing.needsReauth; - set.activeAccountId = existing.id; + if (existing.paused !== true) set.activeAccountId = existing.id; accountId = existing.id; } else { // Legacy migration: a pre-identity row (no accountId/email) for this provider is the @@ -912,6 +913,15 @@ export async function saveCredentialWithReceipt( accountId = id; } } + const updatedSet = store[provider]; + const savedAccount = updatedSet?.accounts.find(account => account.id === accountId); + const activeAccount = updatedSet?.accounts.find(account => account.id === updatedSet.activeAccountId); + if (savedAccount?.paused === true + && (!activeAccount || activeAccount.paused === true || activeAccount.needsReauth === true)) { + const fallback = updatedSet.accounts.find(account => account.id !== accountId + && account.paused !== true && account.needsReauth !== true); + if (fallback) updatedSet.activeAccountId = fallback.id; + } // Every explicit login, including an in-place legacy slot upgrade, starts new evidence. store[provider]!.accounts.find(account => account.id === accountId)!.loginId = randomUUID(); return { @@ -1047,7 +1057,7 @@ export async function upsertCredentialByIdentity( } /** - * Remove the ACTIVE account; remaining accounts promote the first one. + * Remove the ACTIVE account; promote the first usable survivor when available. * * Returns what actually happened, which a caller cannot otherwise know. A read-then-remove * preflight is not equivalent: `mutateStore` serializes mutations, so between a caller's @@ -1064,7 +1074,8 @@ export async function removeCredential(provider: string): Promise<"removed" | "n delete store[provider]; return "removed" as const; } - set.activeAccountId = set.accounts[0]!.id; + set.activeAccountId = set.accounts.find(account => account.paused !== true && account.needsReauth !== true)?.id + ?? set.accounts[0]!.id; return "removed" as const; }, [provider], { scrubLegacyBackup: result => result === "removed" ? [provider] : [] }); } @@ -1106,10 +1117,14 @@ export function getAccountCredential(provider: string, accountId: string): OAuth export function getAccountCredentialWithStatus( provider: string, accountId: string, -): { credential: OAuthCredentials; needsReauth: boolean } | null { +): { credential: OAuthCredentials; needsReauth: boolean; paused: boolean } | null { const account = loadAuthStore()[provider]?.accounts.find(a => a.id === accountId); if (!account?.credential) return null; - return { credential: account.credential, needsReauth: account.needsReauth === true }; + return { + credential: account.credential, + needsReauth: account.needsReauth === true, + paused: account.paused === true, + }; } /** Persist a refreshed credential for a SPECIFIC account without touching activeAccountId. */ @@ -1162,7 +1177,7 @@ export async function commitOAuthAccountSelection( const valid = (set: ProviderAccountSet): boolean => { if (expected && (set.activeAccountId !== expected.accountId || set.selectionRevision !== expected.revision)) return false; const account = set.accounts.find(account => account.id === accountId); - if (!account || (requireUsableAccount && account.needsReauth === true)) return false; + if (!account || account.paused === true || (requireUsableAccount && account.needsReauth === true)) return false; return expectedCredentialGeneration === undefined || credentialGeneration(account.credential) === expectedCredentialGeneration; }; if (expected?.accountId === accountId) { @@ -1196,7 +1211,64 @@ export async function setAccountAlias(provider: string, accountId: string, alias }, [provider, accountId, alias]); } -/** Remove one account by id; active removal promotes the first remaining account. */ +export type SetAccountPausedResult = + | { status: "updated"; activeAccountId: string; activeAccountChanged: boolean } + | { status: "unchanged"; activeAccountId: string; activeAccountChanged: boolean } + | { status: "not-found" }; + +/** Persist an operator pause and move an active account to the next usable unpaused slot when available. */ +export async function setAccountPaused( + provider: string, + accountId: string, + paused: boolean, +): Promise { + const result = await mutateStore(store => { + const set = store[provider]; + const account = set?.accounts.find(candidate => candidate.id === accountId); + if (!set || !account) return { status: "not-found" } as const; + if ((account.paused === true) === paused) { + return { status: "unchanged", activeAccountId: set.activeAccountId, activeAccountChanged: false } as const; + } + + if (paused) account.paused = true; + else delete account.paused; + // Pause is part of selection eligibility even when the operator changes a non-active + // account. Bump the provider selection revision so observers receive the existing + // post-persistence roster invalidation and stale automatic proposals cannot commit. + set.selectionRevision = randomUUID(); + + let activeAccountChanged = false; + if (paused && set.activeAccountId === accountId) { + const start = set.accounts.findIndex(candidate => candidate.id === accountId); + const ring = [...set.accounts.slice(start + 1), ...set.accounts.slice(0, start)]; + const fallback = ring.find(candidate => candidate.paused !== true && candidate.needsReauth !== true); + if (fallback) { + set.activeAccountId = fallback.id; + set.selectionRevision = randomUUID(); + activeAccountChanged = true; + } + } else if (!paused) { + // If all accounts had been paused, the active id still points at a paused + // slot. Resuming the first usable account must restore a usable selection. + const active = set.accounts.find(candidate => candidate.id === set.activeAccountId); + if ((!active || active.paused === true || active.needsReauth === true) && account.needsReauth !== true) { + set.activeAccountId = accountId; + set.selectionRevision = randomUUID(); + activeAccountChanged = true; + } + } + + return { + status: "updated", + activeAccountId: set.activeAccountId, + activeAccountChanged, + } as const; + }, [provider, accountId, paused]); + if (result.status === "updated") publishOAuthAccountPauseChange(provider); + return result; +} + +/** Remove one account by id; active removal promotes the first usable survivor when available. */ export async function removeAccount(provider: string, accountId: string): Promise { const removed = await mutateStore(store => { const set = store[provider]; @@ -1208,7 +1280,11 @@ export async function removeAccount(provider: string, accountId: string): Promis delete store[provider]; return true; } - if (set.activeAccountId === accountId) set.activeAccountId = set.accounts[0]!.id; + if (set.activeAccountId === accountId) { + const next = set.accounts.find(account => account.paused !== true && account.needsReauth !== true) + ?? set.accounts[0]!; + set.activeAccountId = next.id; + } return true; }, [provider, accountId], { scrubLegacyBackup: removed => removed ? [provider] : [] }); return removed; @@ -1238,6 +1314,7 @@ export async function replaceProviderAccountSet( credential: { ...account.credential, ...(account.credential.kiro ? { kiro: { ...account.credential.kiro } } : {}) }, ...(account.alias ? { alias: account.alias } : {}), ...(account.needsReauth ? { needsReauth: true } : {}), + ...(account.paused ? { paused: true } : {}), ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}), ...(account.loginId ? { loginId: account.loginId } : {}), })), diff --git a/src/oauth/token-guardian.ts b/src/oauth/token-guardian.ts index 6815e83a3b..1ff2090a1e 100644 --- a/src/oauth/token-guardian.ts +++ b/src/oauth/token-guardian.ts @@ -14,7 +14,7 @@ import { loadConfig } from "../config"; import type { OcxConfig, OcxTokenGuardianConfig } from "../types"; import { listAccounts } from "./store"; -import { getValidAccessTokenForAccount, listOAuthProviders, OAuthLoginRequiredError, resolveRefreshPolicy } from "./index"; +import { getValidAccessTokenForAccount, listOAuthProviders, OAuthAccountPausedError, OAuthLoginRequiredError, resolveRefreshPolicy } from "./index"; import { getValidCodexToken, listCodexAccountIds, @@ -141,11 +141,12 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise Promise> = []; // A) OAuth providers — every account in each provider's set (multiauth keep-alive), - // skipping accounts already marked needsReauth (terminal; only a re-login fixes them). + // skipping accounts already marked needsReauth (terminal; only a re-login fixes them) or + // paused by the operator (manual exclusion from all automatic account use). for (const provider of listOAuthProviders()) { if (resolveRefreshPolicy(provider, config) !== "proactive") continue; for (const account of listAccounts(provider)) { - if (account.needsReauth) continue; + if (account.needsReauth || account.paused) continue; if (account.credential.expires > nowMs + horizonMs) continue; const key = `oauth:${provider}:${account.id}`; if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; } @@ -155,6 +156,9 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise { const { req, url, config, deps, principal, syncClaudeAgentDefsBestEffort } = ctx; @@ -384,7 +392,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const quotaMode = providerOAuthAccountQuotaMode(provider); const quotaProvider = config.providers[provider]; const { getAccountSet } = await import("../../oauth/store"); - const { kiroAutoSelection } = await import("../../oauth/generic-account-failover"); + const { isGenericFailoverProvider, kiroAutoSelection } = await import("../../oauth/generic-account-failover"); + const effectiveProvider = genericOAuthProviderConfig(provider, config); + const supportsPause = effectiveProvider !== undefined + && isGenericFailoverProvider(provider, effectiveProvider); const { oauthAccountHealthFields, projectOAuthAccountHealth, @@ -404,6 +415,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined, }); return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode, + ...(supportsPause ? { paused: full?.paused === true } : {}), ...(provider === "kiro" && full ? kiroAutoSelection(full) : {}) }; }), }; @@ -449,8 +461,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400); - const { setActiveAccount } = await import("../../oauth/store"); - if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404); + const { getAccountCredentialWithStatus, setActiveAccount } = await import("../../oauth/store"); + const current = getAccountCredentialWithStatus(provider, body.accountId); + if (!current) return jsonResponse({ error: "account not found" }, 404); + if (current.paused) return jsonResponse({ error: "account is paused" }, 409); + if (!(await setActiveAccount(provider, body.accountId))) { + const latest = getAccountCredentialWithStatus(provider, body.accountId); + if (!latest) return jsonResponse({ error: "account not found" }, 404); + return jsonResponse({ error: latest.paused ? "account is paused" : "account selection changed" }, 409); + } const { forgetGenericFailoverRoster } = await import("../../oauth/generic-account-failover"); forgetGenericFailoverRoster(provider); // Seed the rotation cursor on the operator's pick, or a sticky round-robin ring hands the @@ -472,6 +491,47 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ ok: true, provider, activeAccountId: body.accountId }); } + if (url.pathname === "/api/oauth/accounts/pause" && req.method === "PUT") { + const body = await readManagementJsonBodyOr(req, {}); + if (!isPlainRecord(body)) return jsonResponse({ error: "body must be an object" }, 400); + const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; + if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); + if (typeof body.accountId !== "string" || body.accountId.length === 0) { + return jsonResponse({ error: "missing accountId" }, 400); + } + if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400); + + const { isGenericFailoverProvider } = await import("../../oauth/generic-account-failover"); + const effectiveProvider = genericOAuthProviderConfig(provider, config); + if (!effectiveProvider || !isGenericFailoverProvider(provider, effectiveProvider)) { + return jsonResponse({ error: "account pause is not supported for this OAuth provider" }, 400); + } + + const { setAccountPaused } = await import("../../oauth/store"); + const result = await setAccountPaused(provider, body.accountId, body.paused); + if (result.status === "not-found") return jsonResponse({ error: "account not found" }, 404); + + if (result.activeAccountChanged) { + const { genericPoolKey, seedPoolRotationAccount } = await import("../../oauth/pool-kernel"); + seedPoolRotationAccount(genericPoolKey(provider), result.activeAccountId); + const { clearModelCache } = await import("../../codex/model-cache"); + const { clearGatherRoutedModelsInflight } = await import("../../codex/catalog"); + clearModelCache(provider); + clearGatherRoutedModelsInflight(); + const { clearProviderQuotaCache } = await import("../../providers/quota"); + clearProviderQuotaCache(); + } + + return jsonResponse({ + ok: true, + provider, + accountId: body.accountId, + paused: body.paused, + activeAccountId: result.activeAccountId, + activeAccountChanged: result.activeAccountChanged, + }); + } + // The unified pool-settings contract (#695 wp5c). The three legacy paths keep working and // keep their own shapes -- goldens pin them -- but this is the one an operator or a dashboard // should read, because it answers with the same keys for every kind and DECLARES which of diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index b9b9c4220a..683d7a1612 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -319,6 +319,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/providers/keys", module: "server/management/oauth-account-routes", mutates: true }, { method: "PUT", path: "/api/oauth/accounts/active", module: "server/management/oauth-account-routes", mutates: true }, { method: "PUT", path: "/api/oauth/accounts/alias", module: "server/management/oauth-account-routes", mutates: true }, + { method: "PUT", path: "/api/oauth/accounts/pause", module: "server/management/oauth-account-routes", mutates: true }, { method: "PUT", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: true }, { method: "PUT", path: "/api/providers/keys/active", module: "server/management/oauth-account-routes", mutates: true }, { method: "PUT", path: "/api/providers/keys/alias", module: "server/management/oauth-account-routes", mutates: true }, diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 1e7bec8baf..50ccc3b296 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -42,7 +42,7 @@ import { describeUpstreamConnectFailure } from "./upstream-error"; import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery"; import type { AttemptRecoveryKind } from "../../usage/log"; import type { OAuthAccessSnapshot } from "../../oauth"; -import { OAuthLoginRequiredError, publicOAuthAuthenticationErrorMessage } from "../../oauth"; +import { OAuthAccountPausedError, OAuthLoginRequiredError, publicOAuthAuthenticationErrorMessage } from "../../oauth"; import { tryKiroAlternateAfterTerminalRefresh } from "../../oauth/kiro-terminal-failover"; import { classifyKiroRefusal } from "../../adapters/kiro-refusal"; import { normalizeFinalKiroHttpError } from "../../adapters/kiro-retry"; @@ -650,6 +650,9 @@ export async function prepareAdapterExchange( } } cleanupUpstreamAbort(); + if (err instanceof OAuthAccountPausedError) { + return formatErrorResponse(403, "permission_error", publicOAuthAuthenticationErrorMessage(err)); + } return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); } if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) { diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index b9a3de849d..cbba112ba4 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -24,6 +24,7 @@ import { hasAnthropicFailoverQuorum, } from "../../oauth/anthropic-routing"; import { + OAuthAccountPausedError, getValidAccessSnapshotForAccount, forceRefreshOAuthAccessSnapshot, getValidAccessTokenSnapshot, @@ -697,6 +698,9 @@ export async function prepareResponsesTransport( `${redactSecretString(err.message)}. Remove or reconfigure provider '${safeProviderName}' in the OpenCodex configuration.`, ); } + if (err instanceof OAuthAccountPausedError) { + return formatErrorResponse(403, "permission_error", publicOAuthAuthenticationErrorMessage(err)); + } return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); } } diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 97c8bced79..b3df354deb 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -33,7 +33,8 @@ one upstream attempt; client cancellation aborts the upstream and pool-only fail existing account-health state. Unknown Images subpaths still reach the JSON `/v1/*` 404 guard. When the OpenAI credential path is unavailable or its authentication fails, `generations` (not -`edits`) may fall back to Google Antigravity if that provider is logged in. The fallback is +`edits`) may fall back to Google Antigravity if that provider has an unpaused account. A paused +active account returns an operator-actionable 503 and is not treated as a login failure. The fallback is credential-driven: it exists so an image request reaches a real upstream answer rather than dying on a local credential error, and it does not apply when the caller selected an explicit keyed custom provider, because a configured pool owns its own authentication failure rather than hiding it behind diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 56ea1763b0..e965600d37 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -222,7 +222,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the five operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged; it never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. | -| OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | +| OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply` and `POST /api/link/join`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; join also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` to GUI-session callers so the dashboard enables the Child role only when a join can succeed; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index ad719ac37e..208782a09b 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -76,10 +76,14 @@ probe keeps the same-login last-good display bar. The protected OAuth store rota `ProviderAccount.loginId` on every explicit login, preserves it across credential refresh, and uses `addedAt` for legacy rows without one. -For Kiro, `src/oauth/generic-account-failover.ts` filters confirmed monthly exhaustion -and process-local suspension by the live account identity before picking a replacement. -Its `kiroAutoSelection` projection also supplies the account-list exclusion reason; -cached plan credit amounts share the same identity and expiry fence. +For Kiro, `src/oauth/generic-account-failover.ts` filters operator-paused accounts, +confirmed monthly exhaustion and process-local suspension by the live account identity +before picking a replacement. Its `kiroAutoSelection` projection also supplies the +account-list exclusion reason; cached plan credit amounts share the same identity and +expiry fence. +Across generic OAuth providers, pause also excludes that account from Token Guardian's +proactive refresh. The stored credential remains available for resume, while requests with no +unpaused account fail as temporarily unavailable rather than as a login failure. The account actually sent supplies the generation fence; a rotated bearer always travels with its own profile ARN and region. Reactive rotation follows the stored two-account quorum, while refusal-aware first admission follows the proactive preference setting. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 83261f94b2..cb6873685f 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -41,12 +41,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Image/video generation loop | `src/images/loop.ts`, `src/images/plan.ts`, `src/images/fulfill.ts`, `src/images/xai-client.ts`, `src/images/xai-video-client.ts`, `src/images/artifacts.ts` | A provider-returned image URL is downloaded into a local artifact once, then served locally; warnings stay URL-free because provider CDN URLs may embed credentials. Artifact downloads go through the pinned-IP transport with a 10 s connect deadline (`DOWNLOAD_CONNECT_TIMEOUT_MS`) that bounds TCP/TLS setup on its own, in addition to the 60 s idle timer, and `pinnedHttpsGet` accepts a per-call `connectTimeoutMs`. | | GitHub Copilot | `src/providers/xai-transport.ts` (`resolveProviderTransport`), `src/providers/github-copilot-transport.ts` | `resolveProviderTransport` selects the Copilot transport when the routed provider name is `github-copilot`; the Copilot module then resolves its headers and base URL, and the registry seeds the provider row and model fallback. | | API-key pools | `src/providers/api-key-selection.ts`, `src/providers/key-failover.ts` | A configured `apiKeyPoolStrategy` plus a cooling committed key rotates before the first send (`selectProactiveApiKeyTransport`); a 429 still rotates after the send and records a cooldown. `provider.apiKey` keeps mirroring the active entry so routing stays single-key. The pick is inert without a strategy or while the committed key is healthy. | -| OAuth account failover | `src/oauth/generic-account-failover.ts`, `src/oauth/anthropic-routing.ts` | Reactive pre-output 429 recovery is presence-driven with 2+ eligible accounts. Pool and `oauthAccountFailover` flags govern proactive routing, not the reactive retry: a disabled Anthropic pool recovers through quota ordering rather than its dormant strategy, a per-provider `enabled` beats the global default in either direction, and a non-positive fill-first threshold disables proactive usage-based rotation. Kiro's optional process-local account lease is released at response completion or cancellation; other providers retain their admission path. | - -Kiro's `kiroAutoSelection` projects the same candidate eligibility for routing and account-list -status. Unknown evidence remains eligible; reauth, suspension, cooldown, and confirmed exhaustion -have closed reasons. An active singleton or all-excluded pool can still send unless a separately -configured capacity cap times out. +| OAuth account failover | `src/oauth/generic-account-failover.ts`, `src/oauth/anthropic-routing.ts` | Reactive pre-output 429 recovery is presence-driven with 2+ eligible accounts. Generic OAuth accounts can be operator-paused; paused accounts are excluded from selection, 429 failover, and proactive Token Guardian refresh. If every account is paused, the request fails with 403 rather than as a login failure. Pool and `oauthAccountFailover` flags govern proactive routing, not the reactive retry: a disabled Anthropic pool recovers through quota ordering rather than its dormant strategy, a per-provider `enabled` beats the global default in either direction, and a non-positive fill-first threshold disables proactive usage-based rotation. Kiro's `kiroAutoSelection` projects the same candidate eligibility into routing and account-list status; unknown evidence stays eligible, while pause, reauth, suspension, cooldown, and confirmed exhaustion have closed reasons. An active singleton or a pool excluded only from automatic Kiro selection may still attempt its active account; an explicitly paused active account is never dispatched, and an all-paused pool fails with 403. A configured Kiro capacity cap can also time out. Kiro's optional process-local account lease is released at response completion or cancellation; other providers retain their admission path. | | OAuth login callback (inbound) | `src/oauth/callback-server.ts` | Every response, including non-callback 404s, closes its connection so a pooled socket cannot deliver a later login to a retired flow on the same callback port. | | Alibaba regions | `src/providers/alibaba-region-backup.ts`, `src/providers/alibaba-region-migration.ts`, `src/providers/alibaba-region-startup.ts` | Region migration backs up before rewriting and is idempotent across restarts. | | Discovery and quota | `src/providers/model-discovery.ts`, `src/providers/quota.ts`, `src/providers/registry.ts` | Discovery rejects a response over 4 MiB or past 2,000 raw rows before caching it. Provider-scoped hints fill capabilities omitted by live rosters; OpenCode Go's `deepseek-v4.1-flash` keeps its 1,048,576-token context window. The fixed-key Opper preset uses the shared OpenAI Chat adapter at `https://api.opper.ai/v3/compat`, discovers models through its conventional authenticated `/models` path, preserves an older same-named custom destination, and falls back to bare pool ids while passing vendor-prefixed ids through unchanged. Codex quota DTOs suppress retired Spark evidence under the [OpenAI scope contract](../providers/openai-tiers.md#public-provider-contract), retaining ordinary custom windows. | diff --git a/tests/cli/cli-account-pool-verbs.test.ts b/tests/cli/cli-account-pool-verbs.test.ts index 6c48ac4304..f969c9775e 100644 --- a/tests/cli/cli-account-pool-verbs.test.ts +++ b/tests/cli/cli-account-pool-verbs.test.ts @@ -34,6 +34,9 @@ function deps( // Pool verbs resolve their account argument against the list before writing. return new Response(JSON.stringify({ accounts: KNOWN_ACCOUNTS.map(id => ({ id })) }), { status: 200 }); } + if (captured.method === "GET" && captured.path === "/api/oauth/accounts") { + return new Response(JSON.stringify({ accounts: [{ id: "acct_1", alias: "gem-pro" }] }), { status: 200 }); + } const { status = 200, json } = respond(captured); return new Response(JSON.stringify(json), { status }); }) as unknown as typeof fetch, @@ -54,6 +57,38 @@ function capture(): { lines: string[]; errors: string[]; restore: () => void } { } describe("ocx account pause / resume", () => { + test("generic OAuth pause resolves aliases and uses the OAuth account route", async () => { + const calls: Captured[] = []; + const out = capture(); + const base = deps(() => ({ json: { ok: true } }), calls); + const genericDeps: AccountDeps = { + ...base, + loadConfigImpl: () => ({ providers: { "google-antigravity": { adapter: "google", baseUrl: "https://cloudcode-pa.googleapis.com", authMode: "oauth" } } }) as never, + }; + let code: number; + try { + code = await cmdPause(["google-antigravity", "gem-pro"], genericDeps, true); + } finally { out.restore(); } + expect(code).toBe(0); + const write = calls.find(call => call.path === "/api/oauth/accounts/pause"); + expect(write?.method).toBe("PUT"); + expect(write?.body).toEqual({ provider: "google-antigravity", accountId: "acct_1", paused: true }); + }); + + test("generic OAuth resume reports when it changes the active account", async () => { + const calls: Captured[] = []; + const out = capture(); + const base = deps(() => ({ json: { ok: true, activeAccountChanged: true, activeAccountId: "acct_2" } }), calls); + const genericDeps: AccountDeps = { + ...base, + loadConfigImpl: () => ({ providers: { "google-antigravity": { adapter: "google", baseUrl: "https://cloudcode-pa.googleapis.com", authMode: "oauth" } } }) as never, + }; + try { + await cmdPause(["google-antigravity", "acct_1"], genericDeps, false); + } finally { out.restore(); } + expect(out.errors.join("\n")).toContain("Active account changed to acct_2."); + }); + test("pause PUTs the shared route with paused true", async () => { const calls: Captured[] = []; const out = capture(); diff --git a/tests/cli/cli-capabilities.test.ts b/tests/cli/cli-capabilities.test.ts index bda8041f79..dc5b1685b4 100644 --- a/tests/cli/cli-capabilities.test.ts +++ b/tests/cli/cli-capabilities.test.ts @@ -247,7 +247,6 @@ const UNDECLARED_ROUTES_2026_08_28: readonly string[] = [ "GET /api/models", "GET /api/native-main-profiles", "GET /api/native-main-profiles/doctor", - "GET /api/oauth/accounts", "GET /api/oauth/providers", "GET /api/provider-context-caps", "GET /api/provider-presets", diff --git a/tests/codex-integration/catalog-oauth-observation.test.ts b/tests/codex-integration/catalog-oauth-observation.test.ts index f36ae1b216..301760c541 100644 --- a/tests/codex-integration/catalog-oauth-observation.test.ts +++ b/tests/codex-integration/catalog-oauth-observation.test.ts @@ -45,12 +45,13 @@ const originalKimiRefresh = OAUTH_PROVIDERS.kimi!.refresh; let root: string; let opencodexHome: string; -function authStoreBytes(expires: number): Buffer { +function authStoreBytes(expires: number, paused = false): Buffer { return Buffer.from(JSON.stringify({ kimi: { activeAccountId: "active", accounts: [{ id: "active", + ...(paused ? { paused: true } : {}), credential: { access: "fixture-a", refresh: "fixture-r", @@ -155,6 +156,19 @@ afterEach(() => { }); describe("catalog gather OAuth observation", () => { + test("a paused active OAuth account is not exposed to catalog discovery", async () => { + const now = Date.now(); + const observedBuffer = authStoreBytes(now + 3_600_000, true); + let outboundCalls = 0; + + expect(observeActiveOAuthAccessToken("kimi", observedBuffer, now).kind).toBe("paused"); + const { rows, outcomes } = await runCatalogGather(observedBuffer, () => { outboundCalls += 1; }); + + expect(outboundCalls).toBe(0); + expect(rows.map(row => row.id)).toEqual(["k3"]); + expect(outcomes).toEqual([{ provider: "kimi", state: "paused" }]); + }); + test("refreshing Copilot gather binds the new bearer to the refreshed origin", async () => { await saveCredential("github-copilot", { access: "fixture-old-token", refresh: "fixture-refresh", expires: Date.now() - 1, diff --git a/tests/codex-integration/token-guardian.test.ts b/tests/codex-integration/token-guardian.test.ts index 3bb215d7dc..8edae37188 100644 --- a/tests/codex-integration/token-guardian.test.ts +++ b/tests/codex-integration/token-guardian.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { saveCredential } from "../../src/oauth/store"; +import { getAccountSet, saveCredential, setAccountPaused } from "../../src/oauth/store"; import { getConfigPath } from "../../src/config"; import { flushConfigDirHardening } from "../../src/config/paths"; import { markCodexAccountValidated, readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; @@ -111,6 +111,24 @@ describe("token guardian", () => { expect(mock.count()).toBeGreaterThan(0); }); + test("paused OAuth account is excluded from proactive refresh", async () => { + const mock = mockFetchOk(OK_TOKEN); + writeConfig({ + tokenGuardian: { enabled: true, tickSeconds: 60, leadSeconds: 60 }, + providers: { kimi: kimiProvider("proactive") }, + }); + await saveCredential("kimi", { access: "a", refresh: "r", expires: Date.now() + 5_000 }); + const accountId = getAccountSet("kimi")!.accounts[0]!.id; + await setAccountPaused("kimi", accountId, true); + + const res = await guardianSweep(Date.now()); + + expect(res.refreshed).toEqual([]); + expect(res.failed).toEqual([]); + expect(res.skippedBackoff).toEqual([]); + expect(mock.count()).toBe(0); + }); + test("lazy-only policy is left untouched even when enabled", async () => { const mock = mockFetchOk(OK_TOKEN); writeConfig({ diff --git a/tests/oauth/generic-oauth-failover.test.ts b/tests/oauth/generic-oauth-failover.test.ts index 072c9ca37a..9ee1d505e2 100644 --- a/tests/oauth/generic-oauth-failover.test.ts +++ b/tests/oauth/generic-oauth-failover.test.ts @@ -15,8 +15,10 @@ import { preferredInitialAccount, rotateGenericOAuthAccountOn429, } from "../../src/oauth/generic-account-failover"; -import { getAccountSet, markAccountNeedsReauth, saveCredential, setActiveAccount } from "../../src/oauth/store"; +import { getValidAccessSnapshotForAccount, OAuthAccountPausedError } from "../../src/oauth"; +import { getAccountSet, markAccountNeedsReauth, replaceProviderAccountSet, saveCredential, setAccountPaused, setActiveAccount } from "../../src/oauth/store"; import { clearAccountQuotaCache, setCachedProviderAccountQuotaForTests } from "../../src/providers/quota"; +import { subscribeAccountSelections } from "../../src/lib/account-selection-events"; import { resolveCopilotApiBaseUrl } from "../../src/oauth/github-copilot"; import { resolveProviderTransport } from "../../src/providers/xai-transport"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; @@ -35,6 +37,7 @@ beforeEach(() => { afterEach(() => { clearGenericFailoverHealth(); clearAccountQuotaCache("xai"); + clearAccountQuotaCache("google-antigravity"); if (originalHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = originalHome; removeTreeWithRetry(home); @@ -61,16 +64,20 @@ function config(enabled?: boolean, perProvider?: boolean): OcxConfig { } as unknown as OcxConfig; } -async function seed(count: number, offset = 0): Promise { +async function seedProvider(provider: string, count: number, offset = 0): Promise { for (let i = offset; i < offset + count; i++) { - await saveCredential("xai", { + await saveCredential(provider, { access: `access-${i}`, refresh: `refresh-${i}`, expires: Date.now() + 3_600_000, accountId: `uuid-${i}`, } as never, { addAccount: true }); } - return getAccountSet("xai")?.accounts.map(a => a.id) ?? []; + return getAccountSet(provider)?.accounts.map(a => a.id) ?? []; +} + +async function seed(count: number, offset = 0): Promise { + return seedProvider("xai", count, offset); } describe("#2568 generic OAuth account failover", () => { @@ -204,6 +211,16 @@ describe("#2568 generic OAuth account failover", () => { expect(isGenericOAuthFailoverEnabled(config(), "xai")).toBe(false); }); + test("pausing an account invalidates the cached failover quorum immediately", async () => { + const ids = await seed(2); + expect(hasFailoverAccountQuorum("xai")).toBe(true); + + await setAccountPaused("xai", ids[1]!, true); + + expect(hasFailoverAccountQuorum("xai")).toBe(false); + expect(isGenericOAuthFailoverEnabled(config(), "xai")).toBe(false); + }); + test("the presence answer is cached, but a fresh login is visible within the TTL window", async () => { // The predicate now runs on requests that never see a 429, and loadAuthStore has no cache of // its own — it chmods and re-reads the whole store every call. A count is memoized; a @@ -637,6 +654,96 @@ describe("#695 the generic pool consumes its persisted strategy behind pool.kern expect(new Set(served).size).toBeGreaterThan(1); }); + test("paused generic OAuth accounts are excluded from failover and cannot resolve directly", async () => { + const provider = "google-antigravity"; + const ids = await seedProvider(provider, 3); + await setAccountPaused(provider, ids[1]!, true); + + expect(eligibleFailoverAccounts(provider)).toEqual([ids[0]!, ids[2]!]); + await expect(getValidAccessSnapshotForAccount(provider, ids[1]!)).rejects.toBeInstanceOf(OAuthAccountPausedError); + }); + + test("quota-based proactive preference never selects a paused account", async () => { + const provider = "google-antigravity"; + const model = "gemini-3.8-flash"; + const ids = await seedProvider(provider, 3); + await setActiveAccount(provider, ids[0]!); + await setAccountPaused(provider, ids[1]!, true); + const cfg = { + pool: { kernel: true }, + providers: { + [provider]: { + ...OAUTH_PROVIDER, + oauthAccountFailover: { enabled: true }, + }, + }, + } as unknown as OcxConfig; + const now = Date.now(); + setCachedProviderAccountQuotaForTests(provider, ids[0]!, { + customWindows: [{ label: "Gem", percent: 100 }], updatedAt: now, + }); + setCachedProviderAccountQuotaForTests(provider, ids[1]!, { + customWindows: [{ label: "Gem", percent: 10 }], updatedAt: now, + }); + setCachedProviderAccountQuotaForTests(provider, ids[2]!, { + customWindows: [{ label: "Gem", percent: 20 }], updatedAt: now, + }); + + expect(preferredInitialAccount(cfg, provider, now, model)).toBe(ids[2]!); + }); + + test("an active paused OAuth account is replaced even when quota preference is disabled", async () => { + const provider = "google-antigravity"; + const ids = await seedProvider(provider, 2); + await setActiveAccount(provider, ids[0]!); + await setAccountPaused(provider, ids[0]!, true); + + // Pausing the active credential atomically commits the next usable account. + expect(getAccountSet(provider)?.activeAccountId).toBe(ids[1]!); + }); + + test("resuming an account restores it as active when the current account remains paused", async () => { + const provider = "google-antigravity"; + const ids = await seedProvider(provider, 2); + await setActiveAccount(provider, ids[0]!); + await setAccountPaused(provider, ids[0]!, true); + await setAccountPaused(provider, ids[1]!, true); + + await setAccountPaused(provider, ids[0]!, false); + + expect(getAccountSet(provider)?.activeAccountId).toBe(ids[0]!); + expect(eligibleFailoverAccounts(provider)).toEqual([ids[0]!]); + }); + + test("provider account-set replacement preserves operator pause state", async () => { + const provider = "google-antigravity"; + const ids = await seedProvider(provider, 2); + await setAccountPaused(provider, ids[1]!, true); + const accountSet = getAccountSet(provider)!; + + await replaceProviderAccountSet(provider, accountSet); + + expect(getAccountSet(provider)?.accounts.find(account => account.id === ids[1]!)?.paused).toBe(true); + expect(eligibleFailoverAccounts(provider)).not.toContain(ids[1]!); + }); + + test("pausing a non-active OAuth account publishes a roster invalidation after persistence", async () => { + const provider = "google-antigravity"; + const ids = await seedProvider(provider, 2); + await setActiveAccount(provider, ids[0]!); + const events: Array<{ provider: string; kind: string }> = []; + const unsubscribe = subscribeAccountSelections(event => events.push(event)); + try { + await setAccountPaused(provider, ids[1]!, true); + } finally { + unsubscribe(); + } + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ provider, kind: "oauth" }); + expect(getAccountSet(provider)?.accounts.find(account => account.id === ids[1]!)?.paused).toBe(true); + }); + test("round-robin is a no-op while pool.kernel is off", async () => { const ids = await seed(3); await setActiveAccount("xai", ids[0]!); diff --git a/tests/oauth/oauth-accounts-api.test.ts b/tests/oauth/oauth-accounts-api.test.ts index f152d9fc1f..d1c46e0ede 100644 --- a/tests/oauth/oauth-accounts-api.test.ts +++ b/tests/oauth/oauth-accounts-api.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { managementFetch as fetch } from "../helpers/management-auth"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; @@ -12,6 +12,7 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/iso import { removeTreeWithRetry } from "../helpers/remove-tree"; import { withStubbedProviderFetch } from "../helpers/catalog-provider-fetch"; import { getAccountSet } from "../../src/oauth/store"; +import { getValidAccessSnapshotForAccount } from "../../src/oauth"; import { ACCOUNT_IMPORT_DEADLINE_MS, ACCOUNT_IMPORT_MAX_BYTES, ACCOUNT_IMPORT_MAX_REQUEST_BYTES } from "../../src/oauth/account-import/types"; import { handleOauthAccountRoutes } from "../../src/server/management/oauth-account-routes"; import { createManagementSessionControl, requireManagementAuth, type ManagementAuthState } from "../../src/server/management-auth"; @@ -75,6 +76,29 @@ function writeAccounts(): void { }), { mode: 0o600 }); } +function enableGoogleAntigravityAccounts(configureProvider = true): void { + const config = baseConfig(); + if (configureProvider) { + config.providers["google-antigravity"] = { + adapter: "openai-chat", + baseUrl: "https://cloudcode-pa.googleapis.com", + authMode: "oauth", + } as OcxConfig["providers"][string]; + } + saveConfig(config); + + const authPath = join(testDir, "auth.json"); + const auth = JSON.parse(readFileSync(authPath, "utf8")) as Record; + auth["google-antigravity"] = { + activeAccountId: "ga111111", + accounts: [ + { id: "ga111111", credential: { access: "antigravity-1", refresh: "refresh-1", expires: 9999999999999, email: "first@example.test", accountId: "ga-account-1", projectId: "project-1" } }, + { id: "ga222222", credential: { access: "antigravity-2", refresh: "refresh-2", expires: 9999999999999, email: "second@example.test", accountId: "ga-account-2", projectId: "project-2" } }, + ], + }; + writeFileSync(authPath, JSON.stringify(auth), { mode: 0o600 }); +} + beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; isolatedCodexHome = installIsolatedCodexHome("ocx-oauth-accounts-codex-"); @@ -417,6 +441,103 @@ describe("multiauth accounts API", () => { } }); + test("generic OAuth pause persists, moves active selection when possible, and permits pausing every account", async () => { + enableGoogleAntigravityAccounts(); + const server = startServer(0); + try { + const pause = await fetch(new URL("/api/oauth/accounts/pause", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "google-antigravity", accountId: "ga111111", paused: true }), + }); + expect(pause.status).toBe(200); + expect(await pause.json()).toMatchObject({ ok: true, paused: true, activeAccountId: "ga222222" }); + expect(getAccountSet("google-antigravity")?.accounts.find(account => account.id === "ga111111")?.paused).toBe(true); + + const listed = await fetch(new URL("/api/oauth/accounts?provider=google-antigravity", server.url)); + const rows = await listed.json() as { accounts: Array<{ id: string; paused?: boolean }> }; + expect(rows.accounts.find(account => account.id === "ga111111")?.paused).toBe(true); + expect(rows.accounts.find(account => account.id === "ga222222")?.paused).toBe(false); + + const selectingPaused = await fetch(new URL("/api/oauth/accounts/active", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "google-antigravity", accountId: "ga111111" }), + }); + expect(selectingPaused.status).toBe(409); + + const pauseLast = await fetch(new URL("/api/oauth/accounts/pause", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "google-antigravity", accountId: "ga222222", paused: true }), + }); + expect(pauseLast.status).toBe(200); + expect(getAccountSet("google-antigravity")?.activeAccountId).toBe("ga222222"); + expect(getAccountSet("google-antigravity")?.accounts.find(account => account.id === "ga222222")?.paused).toBe(true); + await expect(getValidAccessSnapshotForAccount("google-antigravity", "ga222222")).rejects.toThrow(); + + const resume = await fetch(new URL("/api/oauth/accounts/pause", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "google-antigravity", accountId: "ga111111", paused: false }), + }); + expect(resume.status).toBe(200); + expect(getAccountSet("google-antigravity")?.accounts.find(account => account.id === "ga111111")?.paused).toBeUndefined(); + + const resumeLast = await fetch(new URL("/api/oauth/accounts/pause", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "google-antigravity", accountId: "ga222222", paused: false }), + }); + expect(resumeLast.status).toBe(200); + expect(getAccountSet("google-antigravity")?.accounts.find(account => account.id === "ga222222")?.paused).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + + test("built-in generic OAuth pause works when the provider config row is absent", async () => { + enableGoogleAntigravityAccounts(false); + const server = startServer(0); + try { + const listed = await fetch(new URL("/api/oauth/accounts?provider=google-antigravity", server.url)); + expect(listed.status).toBe(200); + const rows = await listed.json() as { accounts: Array<{ id: string; paused?: boolean }> }; + expect(rows.accounts.find(account => account.id === "ga111111")?.paused).toBe(false); + + const paused = await fetch(new URL("/api/oauth/accounts/pause", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "google-antigravity", accountId: "ga111111", paused: true }), + }); + expect(paused.status).toBe(200); + expect(await paused.json()).toMatchObject({ ok: true, paused: true }); + expect(getAccountSet("google-antigravity")?.accounts.find(account => account.id === "ga111111")?.paused).toBe(true); + } finally { + await server.stop(true); + } + }); + + test("pause API rejects Anthropic and a generic OAuth account behind an API-key route", async () => { + enableGoogleAntigravityAccounts(); + const keyRouteConfig = baseConfig(); + keyRouteConfig.providers["google-antigravity"] = { + adapter: "openai-chat", baseUrl: "https://cloudcode-pa.googleapis.com", authMode: "key", + } as OcxConfig["providers"][string]; + saveConfig(keyRouteConfig); + const server = startServer(0); + try { + const anthropicPause = await fetch(new URL("/api/oauth/accounts/pause", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "anthropic", accountId: "aaaa1111", paused: true }), + }); + expect(anthropicPause.status).toBe(400); + + const keyRoutePause = await fetch(new URL("/api/oauth/accounts/pause", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "google-antigravity", accountId: "ga111111", paused: true }), + }); + expect(keyRoutePause.status).toBe(400); + expect(getAccountSet("google-antigravity")?.accounts.find(account => account.id === "ga111111")?.paused).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + test("POST Cockpit import admits only the exact provider, format, and array document", async () => { const server = startServer(0); try { diff --git a/tests/oauth/oauth-status-privacy.test.ts b/tests/oauth/oauth-status-privacy.test.ts index 2c782645ee..fa50763c47 100644 --- a/tests/oauth/oauth-status-privacy.test.ts +++ b/tests/oauth/oauth-status-privacy.test.ts @@ -6,6 +6,7 @@ import { clearLoginState, getLoginStatus, getValidAccessToken, + OAuthAccountPausedError, OAuthLoginRequiredError, OAuthProviderPublicationError, OAuthReauthIdentityMismatchError, @@ -16,7 +17,7 @@ import { publicOAuthAuthenticationErrorMessage, UnsupportedOAuthProviderError, } from "../../src/oauth"; -import { OAuthMutationBusyError, saveCredential } from "../../src/oauth/store"; +import { getAccountSet, OAuthMutationBusyError, saveCredential, setAccountPaused } from "../../src/oauth/store"; import { handleManagementAPI } from "../../src/server/management-api"; import { handleResponses } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; @@ -248,6 +249,35 @@ describe("OAuth status privacy", () => { expect(body).not.toContain("config.json"); }); + test("pausing the active OAuth account returns an account-paused response, not login required", async () => { + await saveCredential("xai", { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + accountId: "acct-xai", + }); + const accountId = getAccountSet("xai")!.accounts[0]!.id; + await setAccountPaused("xai", accountId, true); + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "test-model", input: "hello", stream: false }), + }), { + defaultProvider: "xai", + providers: { xai: { adapter: "openai-chat", authMode: "oauth", baseUrl: "https://api.x.ai/v1" } }, + } as OcxConfig, { model: "", provider: "" }); + const body = await response.text(); + + expect(response.status).toBe(403); + expect(JSON.parse(body)).toMatchObject({ error: { + type: "permission_error", + message: "OAuth account is paused. Resume it in account settings and retry.", + } }); + expect(body).toContain("OAuth account is paused"); + expect(body).not.toContain("login xai"); + }); + test("OAuth responses redact token-shaped custom provider names", async () => { const providerName = "sk-secret-provider-key"; const config = { @@ -291,6 +321,9 @@ describe("OAuth status privacy", () => { expect(publicOAuthAuthenticationErrorMessage(new OAuthLoginRequiredError("xai"))).toBe( "Not logged in to xai. Run: ocx login xai", ); + expect(publicOAuthAuthenticationErrorMessage(new OAuthAccountPausedError())).toBe( + "OAuth account is paused. Resume it in account settings and retry.", + ); expect(publicOAuthAuthenticationErrorMessage(new OAuthLoginRequiredError(PUBLIC_ERROR_CANARY))) .toBe(PUBLIC_OAUTH_ERROR); expect(publicOAuthAuthenticationErrorMessage(new OAuthProviderPublicationError())).toBe( diff --git a/tests/oauth/oauth-store-multi.test.ts b/tests/oauth/oauth-store-multi.test.ts index a37726d61e..ef915dace7 100644 --- a/tests/oauth/oauth-store-multi.test.ts +++ b/tests/oauth/oauth-store-multi.test.ts @@ -33,6 +33,7 @@ import { replaceProviderAccountSet, saveAccountCredential, saveCredential, + setAccountPaused, setAccountAlias, setActiveAccount, upsertCredentialByIdentity, @@ -625,6 +626,36 @@ describe("multi-account auth store", () => { expect(getAccountSet("xai")).toBeNull(); }); + test("removing the active account skips paused survivors when promoting", async () => { + await saveCredential("xai", cred({ accountId: "acct-a", access: "access-a" })); + await saveCredential("xai", cred({ accountId: "acct-b", access: "access-b" })); + await saveCredential("xai", cred({ accountId: "acct-c", access: "access-c" })); + const before = getAccountSet("xai")!; + const activeId = before.activeAccountId; + const survivors = before.accounts.filter(account => account.id !== activeId); + expect(survivors).toHaveLength(2); + await setAccountPaused("xai", survivors[0]!.id, true); + + expect(await removeAccount("xai", activeId)).toBe(true); + + const after = getAccountSet("xai")!; + expect(after.activeAccountId).toBe(survivors[1]!.id); + expect(after.accounts.find(account => account.id === after.activeAccountId)?.paused).not.toBe(true); + }); + + test("removing the active account retains a first survivor if every survivor is unusable", async () => { + await saveCredential("xai", cred({ accountId: "acct-a", access: "access-a" })); + await saveCredential("xai", cred({ accountId: "acct-b", access: "access-b" })); + const before = getAccountSet("xai")!; + const activeId = before.activeAccountId; + const survivorId = before.accounts.find(account => account.id !== activeId)!.id; + await setAccountPaused("xai", survivorId, true); + + expect(await removeAccount("xai", activeId)).toBe(true); + + expect(getAccountSet("xai")?.activeAccountId).toBe(survivorId); + }); + test("removeCredential removes only the active account", async () => { await saveCredential("anthropic", cred({ email: "a@example.com", accountId: "acct-a", access: "access-a" })); await saveCredential("anthropic", cred({ email: "b@example.com", accountId: "acct-b", access: "access-b" })); @@ -633,6 +664,25 @@ describe("multi-account auth store", () => { expect(getCredential("anthropic")?.access).toBe("access-a"); }); + test("removeCredential promotes the first usable survivor", async () => { + await saveCredential("xai", cred({ accountId: "logout-a", access: "access-a" })); + await saveCredential("xai", cred({ accountId: "logout-b", access: "access-b" })); + await saveCredential("xai", cred({ accountId: "logout-c", access: "access-c" })); + await saveCredential("xai", cred({ accountId: "logout-active", access: "access-active" })); + const before = getAccountSet("xai")!; + const survivors = before.accounts.filter(account => account.id !== before.activeAccountId); + await setAccountPaused("xai", survivors[0]!.id, true); + await markAccountNeedsReauth("xai", survivors[1]!.id, true); + + expect(await removeCredential("xai")).toBe("removed"); + + const after = getAccountSet("xai")!; + expect(after.activeAccountId).toBe(survivors[2]!.id); + const survivor = after.accounts.find(account => account.id === after.activeAccountId); + expect(survivor?.paused).not.toBe(true); + expect(survivor?.needsReauth).not.toBe(true); + }); + test("needsReauth flag persists and clears on fresh save", async () => { await saveCredential("xai", cred({ email: "a@example.com", accountId: "acct-a" })); const id = getAccountSet("xai")!.activeAccountId; @@ -657,6 +707,33 @@ describe("multi-account auth store", () => { expect(set.activeAccountId).toBe("ok"); // dangling active healed }); + test("resuming an account repairs a dangling active pointer to a usable account", async () => { + const { idA, idB } = await selectionAccounts(); + await setAccountPaused("xai", idA, true); + await setAccountPaused("xai", idB, true); + await mutateStore(store => { store.xai!.activeAccountId = "missing-account"; }); + + await setAccountPaused("xai", idB, false); + + expect(getAccountSet("xai")?.activeAccountId).toBe(idB); + expect(getAccountSet("xai")?.accounts.find(account => account.id === idB)?.paused).toBeUndefined(); + }); + + test("re-authenticating a paused account does not select it", async () => { + const { idA, idB } = await selectionAccounts(); + await markAccountNeedsReauth("xai", idB, true); + await setAccountPaused("xai", idB, true); + + await saveCredential("xai", cred({ accountId: "selection-b", access: "fresh-b" })); + + const set = getAccountSet("xai")!; + const account = set.accounts.find(candidate => candidate.id === idB)!; + expect(set.activeAccountId).toBe(idA); + expect(account.credential.access).toBe("fresh-b"); + expect(account.needsReauth).toBeUndefined(); + expect(account.paused).toBe(true); + }); + test("selection revision rejects an automatic promotion after manual A-B-A", async () => { const { idA, idB } = await selectionAccounts(); const before = getAccountSet("xai")!.selectionRevision; diff --git a/tests/providers/kiro/kiro-auto-selection.test.ts b/tests/providers/kiro/kiro-auto-selection.test.ts index 1dcd253a31..cc4afc762c 100644 --- a/tests/providers/kiro/kiro-auto-selection.test.ts +++ b/tests/providers/kiro/kiro-auto-selection.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearGenericFailoverHealth, eligibleFailoverAccounts, kiroAutoSelection, quarantineKiroSuspendedAccount, rotateGenericOAuthAccountOnRefusal } from "../../../src/oauth/generic-account-failover"; -import { getAccountSet, markAccountNeedsReauth, saveCredential } from "../../../src/oauth/store"; +import { getAccountSet, markAccountNeedsReauth, saveCredential, setAccountPaused } from "../../../src/oauth/store"; import { setCachedProviderAccountQuotaForTests, clearAccountQuotaCache } from "../../../src/providers/quota"; import { commitKiroAccountUsageState } from "../../../src/providers/kiro-usage"; import { kiroEvidenceIdentity } from "../../../src/providers/kiro-account-state-disk"; @@ -68,3 +68,17 @@ test("Kiro candidate and list projection agree on family-less exclusion states", expect(kiroAutoSelection(byName("suspended"), evalNow + 24 * 60 * 60_000 + 1)) .toEqual({ autoSelectable: true }); }); + +test("a paused Kiro account is excluded from automatic selection and its list projection", async () => { + await saveCredential("kiro", { access: "paused-access", refresh: "paused-refresh", + expires: Date.now() + 3600_000, accountId: "paused" }, { addAccount: true }); + const pausedId = getAccountSet("kiro")!.accounts[0]!.id; + await saveCredential("kiro", { access: "live-access", refresh: "live-refresh", + expires: Date.now() + 3600_000, accountId: "live" }, { addAccount: true }); + const survivorId = getAccountSet("kiro")!.accounts.find(account => account.id !== pausedId)!.id; + await setAccountPaused("kiro", pausedId!, true); + + const account = getAccountSet("kiro")!.accounts.find(row => row.id === pausedId)!; + expect(kiroAutoSelection(account)).toEqual({ autoSelectable: false, skipReason: "paused" }); + expect(eligibleFailoverAccounts("kiro")).toEqual([survivorId]); +}); diff --git a/tests/server/server-google-antigravity-oauth-401-replay.test.ts b/tests/server/server-google-antigravity-oauth-401-replay.test.ts index b1f55c238c..329f510515 100644 --- a/tests/server/server-google-antigravity-oauth-401-replay.test.ts +++ b/tests/server/server-google-antigravity-oauth-401-replay.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { forceRefreshOAuthAccessSnapshot, getValidAccessTokenSnapshot } from "../../src/oauth"; -import { getAccountSet, saveCredential } from "../../src/oauth/store"; +import { getAccountSet, saveCredential, setAccountPaused } from "../../src/oauth/store"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -274,6 +274,87 @@ function installOAuthFetch( } describe("Google Antigravity OAuth upstream 401 replay", () => { + test("paused OAuth account returns a non-retryable permission error for CCA image generation", async () => { + await seedOAuth(); + const accountId = getAccountSet("google-antigravity")!.accounts[0]!.id; + await setAccountPaused("google-antigravity", accountId, true); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([]); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat", model: "gpt-image-2" }), + }); + const body = await response.text(); + + expect(response.status).toBe(403); + expect(JSON.parse(body)).toMatchObject({ error: { + type: "permission_error", + message: "OAuth account is paused. Resume it in account settings and retry.", + } }); + expect(body).toContain("OAuth account is paused"); + expect(body).not.toContain("login required"); + expect(observed.counts.refresh).toBe(0); + expect(observed.requestPaths).toEqual([]); + } finally { + await server.stop(true); + } + }); + + test("paused OAuth account returns a non-retryable permission error without refresh or upstream dispatch", async () => { + await seedOAuth(); + const accountId = getAccountSet("google-antigravity")!.accounts[0]!.id; + await setAccountPaused("google-antigravity", accountId, true); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([]); + const server = startServer(0); + try { + const response = await postResponses(server); + const body = await response.text(); + + expect(response.status).toBe(403); + expect(JSON.parse(body)).toMatchObject({ error: { + type: "permission_error", + message: "OAuth account is paused. Resume it in account settings and retry.", + } }); + expect(body).toContain("OAuth account is paused"); + expect(body).not.toContain("login google-antigravity"); + expect(observed.counts.refresh).toBe(0); + expect(observed.requestPaths).toEqual([]); + } finally { + await server.stop(true); + } + }); + + test("an account paused before OAuth 401 replay returns a non-retryable permission error and does not refresh", async () => { + await seedOAuth(); + const accountId = getAccountSet("google-antigravity")!.accounts[0]!.id; + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401], { + beforeFirstUnauthorized: async () => { + await setAccountPaused("google-antigravity", accountId, true); + }, + }); + const server = startServer(0); + try { + const response = await postResponses(server); + const body = await response.text(); + + expect(response.status).toBe(403); + expect(JSON.parse(body)).toMatchObject({ error: { + type: "permission_error", + message: "OAuth account is paused. Resume it in account settings and retry.", + } }); + expect(body).toContain("OAuth account is paused"); + expect(observed.counts.refresh).toBe(0); + expect(observed.requestPaths).toEqual(["/v1internal:generateContent"]); + } finally { + await server.stop(true); + } + }); + test.each([200, 401])("native passthrough replays once and returns the second HTTP %i", async secondStatus => { await seedOAuth(); saveConfig(antigravityPassthroughConfig());