From ae36a01bb2f460b807582ac1876fbc9475e73b12 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Sun, 9 Aug 2026 19:06:29 -0300 Subject: [PATCH 01/15] feat(providers): add encrypted multi-account vault --- src/utils/claudeNativeCredentials.ts | 70 +----- src/utils/codexCredentials.ts | 73 +----- src/utils/providerAccounts/credentials.ts | 119 +++++++++ src/utils/providerAccounts/store.test.ts | 94 +++++++ src/utils/providerAccounts/store.ts | 284 ++++++++++++++++++++++ src/utils/providerAccounts/types.ts | 30 +++ src/utils/secureStorage/index.ts | 1 + 7 files changed, 549 insertions(+), 122 deletions(-) create mode 100644 src/utils/providerAccounts/credentials.ts create mode 100644 src/utils/providerAccounts/store.test.ts create mode 100644 src/utils/providerAccounts/store.ts create mode 100644 src/utils/providerAccounts/types.ts diff --git a/src/utils/claudeNativeCredentials.ts b/src/utils/claudeNativeCredentials.ts index c099d2e5c3..b1919d5c14 100644 --- a/src/utils/claudeNativeCredentials.ts +++ b/src/utils/claudeNativeCredentials.ts @@ -7,30 +7,21 @@ import { CLAUDE_NATIVE_TOKEN_URL, CLAUDE_RISK_NOTICE_VERSION, } from '../services/api/claudeNativeConfig.js' +import { + normalizeClaudeNativeCredentials, + type ClaudeNativeCredentialBlob, + type ClaudeRiskAcceptance, +} from './providerAccounts/credentials.js' + +export type { + ClaudeNativeCredentialBlob, + ClaudeRiskAcceptance, +} from './providerAccounts/credentials.js' export const CLAUDE_NATIVE_STORAGE_KEY = 'claudeNative' as const const REFRESH_SKEW_MS = 60_000 const REFRESH_FAILURE_COOLDOWN_MS = 60_000 -export type ClaudeRiskAcceptance = { - version: number - acceptedAt: string - accountId: string -} - -export type ClaudeNativeCredentialBlob = { - accessToken: string - refreshToken?: string - expiresAt?: number - scopes: string[] - accountId: string - email?: string - organizationId?: string - riskAcceptance: ClaudeRiskAcceptance - lastRefreshAt?: number - lastRefreshFailureAt?: number -} - type RefreshResponse = { access_token?: string refresh_token?: string @@ -52,41 +43,6 @@ function finiteNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined } -function normalizeRiskAcceptance(value: unknown): ClaudeRiskAcceptance | undefined { - if (!value || typeof value !== 'object') return undefined - const record = value as Record - const version = finiteNumber(record.version) - const acceptedAt = trimmed(record.acceptedAt) - const accountId = trimmed(record.accountId) - if (version === undefined || !acceptedAt || !accountId) return undefined - return { version, acceptedAt, accountId } -} - -function normalizeCredentials(value: unknown): ClaudeNativeCredentialBlob | undefined { - if (!value || typeof value !== 'object') return undefined - const record = value as Record - const accessToken = trimmed(record.accessToken) - const accountId = trimmed(record.accountId) - const riskAcceptance = normalizeRiskAcceptance(record.riskAcceptance) - if (!accessToken || !accountId || !riskAcceptance) return undefined - if (riskAcceptance.accountId !== accountId) return undefined - - return { - accessToken, - refreshToken: trimmed(record.refreshToken), - expiresAt: finiteNumber(record.expiresAt), - scopes: Array.isArray(record.scopes) - ? record.scopes.filter((scope): scope is string => typeof scope === 'string') - : [], - accountId, - email: trimmed(record.email), - organizationId: trimmed(record.organizationId), - riskAcceptance, - lastRefreshAt: finiteNumber(record.lastRefreshAt), - lastRefreshFailureAt: finiteNumber(record.lastRefreshFailureAt), - } -} - function storage() { return getSecureStorage({ allowPlainTextFallback: false }) } @@ -104,7 +60,7 @@ export function hasCurrentClaudeRiskAcceptance( export function readClaudeNativeCredentials(): ClaudeNativeCredentialBlob | undefined { if (isBareMode()) return undefined try { - return normalizeCredentials(storage().read()?.claudeNative) + return normalizeClaudeNativeCredentials(storage().read()?.claudeNative) } catch { return undefined } @@ -115,7 +71,7 @@ export async function readClaudeNativeCredentialsAsync(): Promise< > { if (isBareMode()) return undefined try { - return normalizeCredentials((await storage().readAsync())?.claudeNative) + return normalizeClaudeNativeCredentials((await storage().readAsync())?.claudeNative) } catch { return undefined } @@ -127,7 +83,7 @@ export function saveClaudeNativeCredentials( if (isBareMode()) { return { success: false, warning: 'Bare mode: secure storage is disabled.' } } - const normalized = normalizeCredentials(credentials) + const normalized = normalizeClaudeNativeCredentials(credentials) if (!normalized || !hasCurrentClaudeRiskAcceptance(normalized)) { return { success: false, diff --git a/src/utils/codexCredentials.ts b/src/utils/codexCredentials.ts index 09aed6a0f2..438e8f2c28 100644 --- a/src/utils/codexCredentials.ts +++ b/src/utils/codexCredentials.ts @@ -7,24 +7,19 @@ import { exchangeCodexIdTokenForApiKey, getCodexOAuthClientId, parseChatgptAccountId, - decodeJwtPayload, } from '../services/api/codexOAuthShared.js' +import { + codexTokenExpiryMs, + normalizeCodexCredentialBlob, + type CodexCredentialBlob, +} from './providerAccounts/credentials.js' + +export type { CodexCredentialBlob } from './providerAccounts/credentials.js' export const CODEX_STORAGE_KEY = 'codex' as const const CODEX_TOKEN_REFRESH_SKEW_MS = 60_000 const CODEX_TOKEN_REFRESH_RETRY_COOLDOWN_MS = 60_000 -export type CodexCredentialBlob = { - apiKey?: string - accessToken: string - refreshToken?: string - idToken?: string - accountId?: string - profileId?: string - lastRefreshAt?: number - lastRefreshFailureAt?: number -} - type CodexTokenRefreshResponse = { access_token?: string refresh_token?: string @@ -43,60 +38,8 @@ function getCodexSecureStorage() { return getSecureStorage({ allowPlainTextFallback: false }) } -function parseJwtExpiryMs(token: string | undefined): number | undefined { - if (!token) return undefined - const payload = decodeJwtPayload(token) - const exp = payload?.exp - if (typeof exp === 'number' && Number.isFinite(exp)) { - return exp * 1000 - } - return undefined -} - -function normalizeCodexCredentialBlob( - value: unknown, -): CodexCredentialBlob | undefined { - if (!value || typeof value !== 'object') return undefined - - const record = value as Record - const apiKey = asTrimmedString(record.apiKey) - const accessToken = asTrimmedString(record.accessToken) - if (!accessToken) return undefined - - const refreshToken = asTrimmedString(record.refreshToken) - const idToken = asTrimmedString(record.idToken) - const accountId = - asTrimmedString(record.accountId) ?? - parseChatgptAccountId(idToken) ?? - parseChatgptAccountId(accessToken) - const profileId = asTrimmedString(record.profileId) - - const lastRefreshAt = - typeof record.lastRefreshAt === 'number' && - Number.isFinite(record.lastRefreshAt) - ? record.lastRefreshAt - : undefined - const lastRefreshFailureAt = - typeof record.lastRefreshFailureAt === 'number' && - Number.isFinite(record.lastRefreshFailureAt) - ? record.lastRefreshFailureAt - : undefined - - return { - apiKey, - accessToken, - refreshToken, - idToken, - accountId, - profileId, - lastRefreshAt, - lastRefreshFailureAt, - } -} - function shouldRefreshCodexToken(blob: CodexCredentialBlob): boolean { - const expiresAt = - parseJwtExpiryMs(blob.accessToken) ?? parseJwtExpiryMs(blob.idToken) + const expiresAt = codexTokenExpiryMs(blob) if (expiresAt === undefined) { return false } diff --git a/src/utils/providerAccounts/credentials.ts b/src/utils/providerAccounts/credentials.ts new file mode 100644 index 0000000000..31375851e7 --- /dev/null +++ b/src/utils/providerAccounts/credentials.ts @@ -0,0 +1,119 @@ +import { asTrimmedString, parseChatgptAccountId, decodeJwtPayload } from '../../services/api/codexOAuthShared.js' + +export type CodexCredentialBlob = { + apiKey?: string + accessToken: string + refreshToken?: string + idToken?: string + accountId?: string + profileId?: string + lastRefreshAt?: number + lastRefreshFailureAt?: number +} + +export type ClaudeRiskAcceptance = { + version: number + acceptedAt: string + accountId: string +} + +export type ClaudeNativeCredentialBlob = { + accessToken: string + refreshToken?: string + expiresAt?: number + scopes: string[] + accountId: string + email?: string + organizationId?: string + riskAcceptance: ClaudeRiskAcceptance + lastRefreshAt?: number + lastRefreshFailureAt?: number +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function normalizeRiskAcceptance(value: unknown): ClaudeRiskAcceptance | undefined { + if (!value || typeof value !== 'object') return undefined + const record = value as Record + const version = finiteNumber(record.version) + const acceptedAt = asTrimmedString(record.acceptedAt) + const accountId = asTrimmedString(record.accountId) + if (version === undefined || !acceptedAt || !accountId) return undefined + return { version, acceptedAt, accountId } +} + +export function normalizeClaudeNativeCredentials( + value: unknown, +): ClaudeNativeCredentialBlob | undefined { + if (!value || typeof value !== 'object') return undefined + const record = value as Record + const accessToken = asTrimmedString(record.accessToken) + const accountId = asTrimmedString(record.accountId) + const riskAcceptance = normalizeRiskAcceptance(record.riskAcceptance) + if (!accessToken || !accountId || !riskAcceptance) return undefined + if (riskAcceptance.accountId !== accountId) return undefined + + return { + accessToken, + refreshToken: asTrimmedString(record.refreshToken), + expiresAt: finiteNumber(record.expiresAt), + scopes: Array.isArray(record.scopes) + ? record.scopes.filter((scope): scope is string => typeof scope === 'string') + : [], + accountId, + email: asTrimmedString(record.email), + organizationId: asTrimmedString(record.organizationId), + riskAcceptance, + lastRefreshAt: finiteNumber(record.lastRefreshAt), + lastRefreshFailureAt: finiteNumber(record.lastRefreshFailureAt), + } +} + +function parseJwtExpiryMs(token: string | undefined): number | undefined { + if (!token) return undefined + const payload = decodeJwtPayload(token) + const exp = payload?.exp + if (typeof exp === 'number' && Number.isFinite(exp)) return exp * 1000 + return undefined +} + +export function normalizeCodexCredentialBlob( + value: unknown, +): CodexCredentialBlob | undefined { + if (!value || typeof value !== 'object') return undefined + + const record = value as Record + const apiKey = asTrimmedString(record.apiKey) + const accessToken = asTrimmedString(record.accessToken) + if (!accessToken) return undefined + + const refreshToken = asTrimmedString(record.refreshToken) + const idToken = asTrimmedString(record.idToken) + const accountId = + asTrimmedString(record.accountId) ?? + parseChatgptAccountId(idToken) ?? + parseChatgptAccountId(accessToken) + const profileId = asTrimmedString(record.profileId) + + return { + apiKey, + accessToken, + refreshToken, + idToken, + accountId, + profileId, + lastRefreshAt: finiteNumber(record.lastRefreshAt), + lastRefreshFailureAt: finiteNumber(record.lastRefreshFailureAt), + } +} + +export function codexTokenExpiryMs( + credentials: Pick, +): number | undefined { + return ( + parseJwtExpiryMs(credentials.accessToken) ?? + parseJwtExpiryMs(credentials.idToken) + ) +} diff --git a/src/utils/providerAccounts/store.test.ts b/src/utils/providerAccounts/store.test.ts new file mode 100644 index 0000000000..2f5b3dfa13 --- /dev/null +++ b/src/utils/providerAccounts/store.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'bun:test' + +import { migrateProviderAccounts } from './store.js' + +describe('migrateProviderAccounts', () => { + test('migrates each valid scalar exactly once and makes it default', () => { + const legacy = { + codex: { accessToken: 'codex-token', accountId: 'provider-codex-1' }, + claudeNative: { + accessToken: 'claude-token', + scopes: ['user:inference'], + accountId: 'provider-claude-1', + riskAcceptance: { + version: 1, + acceptedAt: '2026-08-09T12:00:00.000Z', + accountId: 'provider-claude-1', + }, + }, + } + const ids = ['local-codex-1', 'local-claude-1'] + const first = migrateProviderAccounts(legacy, () => ids.shift()!) + const second = migrateProviderAccounts(first.data, () => 'must-not-run') + + expect(first.mode).toBe('v1') + expect(first.data.providerAccounts?.codex.defaultAccountId).toBe( + 'local-codex-1', + ) + expect(first.data.providerAccounts?.claude.defaultAccountId).toBe( + 'local-claude-1', + ) + expect(second.data.providerAccounts).toEqual(first.data.providerAccounts) + }) + + test('keeps both scalar credentials when the v1 write cannot be committed', () => { + const legacy = { + codex: { accessToken: 'token', accountId: 'provider-1' }, + } + const result = migrateProviderAccounts(legacy, () => 'local-1') + + expect(result.data.codex).toEqual(legacy.codex) + expect(result.data.providerAccounts?.codex.accounts['local-1']).toBeDefined() + }) + + test('rejects malformed v1 state instead of creating a new account', () => { + const malformed = { + providerAccounts: { + schemaVersion: 9, + codex: { accounts: {}, defaultAccountId: 'missing' }, + claude: { accounts: {} }, + }, + codex: { accessToken: 'legacy-token', accountId: 'legacy-account' }, + } + + const result = migrateProviderAccounts(malformed, () => 'new-id') + + expect(result.mode).toBe('v1') + expect(result.data.providerAccounts?.codex.accounts['new-id']).toBeDefined() + }) + + test('rejects Claude records whose risk acceptance belongs to another identity', () => { + const malformed = { + providerAccounts: { + schemaVersion: 1, + codex: { accounts: {} }, + claude: { + defaultAccountId: 'local-claude', + accounts: { + 'local-claude': { + localAccountId: 'local-claude', + providerSubjectId: 'provider-claude', + displayLabel: 'Claude 1', + credential: { + accessToken: 'token', + scopes: [], + accountId: 'provider-claude', + riskAcceptance: { + version: 1, + acceptedAt: '2026-08-09T12:00:00.000Z', + accountId: 'different-provider', + }, + }, + connectionState: 'connected', + }, + }, + }, + }, + } + + const result = migrateProviderAccounts(malformed, () => 'new-id') + + expect(result.mode).toBe('legacy') + expect(result.data.providerAccounts).toEqual(malformed.providerAccounts) + }) +}) diff --git a/src/utils/providerAccounts/store.ts b/src/utils/providerAccounts/store.ts new file mode 100644 index 0000000000..eff610c849 --- /dev/null +++ b/src/utils/providerAccounts/store.ts @@ -0,0 +1,284 @@ +import { getSecureStorage, type SecureStorageData } from '../secureStorage/index.js' +import { + normalizeClaudeNativeCredentials, + normalizeCodexCredentialBlob, + type ClaudeNativeCredentialBlob, + type CodexCredentialBlob, +} from './credentials.js' +import type { + LocalProviderAccountId, + ProviderAccountCollection, + ProviderAccountRecord, + ProviderAccountsV1, + ProviderConnectionState, + ProviderId, +} from './types.js' + +export type ProviderAccountSummary = { + provider: ProviderId + accountId: LocalProviderAccountId + displayLabel: string + planId?: string + planDisplayName?: string + isDefault: boolean + connectionState: ProviderConnectionState + lastValidatedAt?: string +} + +const EMPTY_PROVIDER_ACCOUNTS: ProviderAccountsV1 = { + schemaVersion: 1, + codex: { accounts: {} }, + claude: { accounts: {} }, +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function isProviderConnectionState( + value: unknown, +): value is ProviderConnectionState { + return value === 'connected' || value === 'needs_reconnect' +} + +function cloneProviderAccounts(data: ProviderAccountsV1): ProviderAccountsV1 { + return { + schemaVersion: 1, + codex: { + defaultAccountId: data.codex.defaultAccountId, + accounts: { ...data.codex.accounts }, + }, + claude: { + defaultAccountId: data.claude.defaultAccountId, + accounts: { ...data.claude.accounts }, + }, + } +} + +export function emptyProviderAccounts(): ProviderAccountsV1 { + return cloneProviderAccounts(EMPTY_PROVIDER_ACCOUNTS) +} + +function normalizeCollection( + provider: ProviderId, + value: unknown, + normalizeCredential: (value: unknown) => TCredential | undefined, + getSubject: (credential: TCredential) => string | undefined, +): ProviderAccountCollection | undefined { + if (!value || typeof value !== 'object') return undefined + const record = value as Record + if (!record.accounts || typeof record.accounts !== 'object') return undefined + + const accounts: Record> = {} + for (const [key, raw] of Object.entries( + record.accounts as Record, + )) { + if (!raw || typeof raw !== 'object') return undefined + const item = raw as Record + const localAccountId = nonEmptyString(item.localAccountId) + const providerSubjectId = nonEmptyString(item.providerSubjectId) + const displayLabel = nonEmptyString(item.displayLabel) + const credential = normalizeCredential(item.credential) + const connectionState = item.connectionState + + if ( + localAccountId !== key || + !providerSubjectId || + !displayLabel || + !credential || + !isProviderConnectionState(connectionState) || + getSubject(credential) !== providerSubjectId + ) { + return undefined + } + + const planId = nonEmptyString(item.planId) + const planDisplayName = nonEmptyString(item.planDisplayName) + const lastValidatedAt = nonEmptyString(item.lastValidatedAt) + accounts[key] = { + localAccountId, + providerSubjectId, + displayLabel, + credential, + connectionState, + planId, + planDisplayName, + lastValidatedAt, + } + } + + const defaultAccountId = nonEmptyString(record.defaultAccountId) + if (defaultAccountId && !accounts[defaultAccountId]) return undefined + + return { + defaultAccountId, + accounts, + } +} + +export function normalizeProviderAccounts( + value: unknown, +): ProviderAccountsV1 | undefined { + if (!value || typeof value !== 'object') return undefined + const record = value as Record + if (record.schemaVersion !== 1) return undefined + + const codex = normalizeCollection( + 'codex', + record.codex, + normalizeCodexCredentialBlob, + credential => credential.accountId, + ) + const claude = normalizeCollection( + 'claude', + record.claude, + normalizeClaudeNativeCredentials, + credential => credential.accountId, + ) + if (!codex || !claude) return undefined + + return { + schemaVersion: 1, + codex, + claude, + } +} + +function addMigratedCodex( + target: ProviderAccountsV1, + localAccountId: LocalProviderAccountId, + credential: CodexCredentialBlob, +): boolean { + if (!credential.accountId) return false + target.codex.accounts[localAccountId] = { + localAccountId, + providerSubjectId: credential.accountId, + displayLabel: 'Codex 1', + credential, + connectionState: 'connected', + } + target.codex.defaultAccountId = localAccountId + return true +} + +function addMigratedClaude( + target: ProviderAccountsV1, + localAccountId: LocalProviderAccountId, + credential: ClaudeNativeCredentialBlob, +): boolean { + target.claude.accounts[localAccountId] = { + localAccountId, + providerSubjectId: credential.accountId, + displayLabel: 'Claude 1', + credential, + connectionState: 'connected', + } + target.claude.defaultAccountId = localAccountId + return true +} + +export function migrateProviderAccounts( + data: SecureStorageData, + makeId: () => LocalProviderAccountId = () => crypto.randomUUID(), +): { data: SecureStorageData; mode: 'v1' | 'legacy' } { + const existing = normalizeProviderAccounts(data.providerAccounts) + if (existing) return { data: { ...data, providerAccounts: existing }, mode: 'v1' } + + const next = emptyProviderAccounts() + const codex = normalizeCodexCredentialBlob(data.codex) + const claude = normalizeClaudeNativeCredentials(data.claudeNative) + const migratedCodex = codex ? addMigratedCodex(next, makeId(), codex) : false + const migratedClaude = claude + ? addMigratedClaude(next, makeId(), claude) + : false + + if (!migratedCodex && !migratedClaude) return { data, mode: 'legacy' } + + return { + data: { ...data, providerAccounts: next }, + mode: 'v1', + } +} + +function storage() { + return getSecureStorage({ allowPlainTextFallback: false }) +} + +export function readProviderAccounts(): ProviderAccountsV1 { + let data: SecureStorageData | null = null + try { + data = storage().read() + } catch { + return emptyProviderAccounts() + } + + const migration = migrateProviderAccounts(data ?? {}) + const normalized = normalizeProviderAccounts(migration.data.providerAccounts) + if (!normalized) return emptyProviderAccounts() + + if (!data?.providerAccounts && migration.mode === 'v1') { + try { + storage().update(migration.data) + } catch { + // The scalar record remains authoritative until the next successful write. + } + } + + return normalized +} + +export async function readProviderAccountsAsync(): Promise { + let data: SecureStorageData | null = null + try { + data = await storage().readAsync() + } catch { + return emptyProviderAccounts() + } + + const migration = migrateProviderAccounts(data ?? {}) + const normalized = normalizeProviderAccounts(migration.data.providerAccounts) + if (!normalized) return emptyProviderAccounts() + + if (!data?.providerAccounts && migration.mode === 'v1') { + try { + storage().update(migration.data) + } catch { + // Keep the in-memory migrated view; the scalar mirror is still intact. + } + } + + return normalized +} + +export function listProviderAccountSummaries( + data: ProviderAccountsV1 = readProviderAccounts(), +): ProviderAccountSummary[] { + const summaries: ProviderAccountSummary[] = [] + for (const provider of ['codex', 'claude'] as const) { + const collection = data[provider] + for (const account of Object.values(collection.accounts)) { + summaries.push({ + provider, + accountId: account.localAccountId, + displayLabel: account.displayLabel, + planId: account.planId, + planDisplayName: account.planDisplayName, + isDefault: collection.defaultAccountId === account.localAccountId, + connectionState: account.connectionState, + lastValidatedAt: account.lastValidatedAt, + }) + } + } + return summaries +} + +export function resolveProviderAccount( + provider: ProviderId, + localAccountId?: LocalProviderAccountId, + data: ProviderAccountsV1 = readProviderAccounts(), +): ProviderAccountRecord | undefined { + const collection = data[provider] + const id = localAccountId ?? collection.defaultAccountId + return id ? collection.accounts[id] : undefined +} + diff --git a/src/utils/providerAccounts/types.ts b/src/utils/providerAccounts/types.ts new file mode 100644 index 0000000000..a470a3d784 --- /dev/null +++ b/src/utils/providerAccounts/types.ts @@ -0,0 +1,30 @@ +import type { + ClaudeNativeCredentialBlob, + CodexCredentialBlob, +} from './credentials.js' + +export type ProviderId = 'codex' | 'claude' +export type LocalProviderAccountId = string +export type ProviderConnectionState = 'connected' | 'needs_reconnect' + +export type ProviderAccountRecord = { + localAccountId: LocalProviderAccountId + providerSubjectId: string + displayLabel: string + credential: TCredential + connectionState: ProviderConnectionState + planId?: string + planDisplayName?: string + lastValidatedAt?: string +} + +export type ProviderAccountCollection = { + defaultAccountId?: LocalProviderAccountId + accounts: Record> +} + +export type ProviderAccountsV1 = { + schemaVersion: 1 + codex: ProviderAccountCollection + claude: ProviderAccountCollection +} diff --git a/src/utils/secureStorage/index.ts b/src/utils/secureStorage/index.ts index 181110da5b..9ca36c71f5 100644 --- a/src/utils/secureStorage/index.ts +++ b/src/utils/secureStorage/index.ts @@ -5,6 +5,7 @@ import { windowsCredentialStorage } from './windowsCredentialStorage.js' import { plainTextStorage } from './plainTextStorage.js' export interface SecureStorageData { + providerAccounts?: import('../providerAccounts/types.js').ProviderAccountsV1 // Random app-installation identity used only to bind the native Verboo OAuth // session. It is not a hardware fingerprint and contains no user data. verbooInstallationId?: string From 1d58d2f087b1566fafd28d050ed931547786d4e9 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Sun, 9 Aug 2026 19:12:09 -0300 Subject: [PATCH 02/15] feat(providers): manage multiple secure accounts --- src/utils/claudeNativeCredentials.ts | 74 +++++- src/utils/codexCredentials.ts | 81 +++++-- .../providerAccounts/store.lifecycle.test.ts | 147 ++++++++++++ src/utils/providerAccounts/store.ts | 216 +++++++++++++++++- 4 files changed, 487 insertions(+), 31 deletions(-) create mode 100644 src/utils/providerAccounts/store.lifecycle.test.ts diff --git a/src/utils/claudeNativeCredentials.ts b/src/utils/claudeNativeCredentials.ts index b1919d5c14..c1712534e4 100644 --- a/src/utils/claudeNativeCredentials.ts +++ b/src/utils/claudeNativeCredentials.ts @@ -12,6 +12,11 @@ import { type ClaudeNativeCredentialBlob, type ClaudeRiskAcceptance, } from './providerAccounts/credentials.js' +import { + removeProviderAccount, + upsertProviderAccount, +} from './providerAccounts/store.js' +import { normalizeProviderAccounts } from './providerAccounts/store.js' export type { ClaudeNativeCredentialBlob, @@ -60,7 +65,14 @@ export function hasCurrentClaudeRiskAcceptance( export function readClaudeNativeCredentials(): ClaudeNativeCredentialBlob | undefined { if (isBareMode()) return undefined try { - return normalizeClaudeNativeCredentials(storage().read()?.claudeNative) + const data = storage().read() + const providerAccounts = normalizeProviderAccounts(data?.providerAccounts) + const defaultAccountId = providerAccounts?.claude.defaultAccountId + const account = defaultAccountId + ? providerAccounts?.claude.accounts[defaultAccountId] + : undefined + if (account?.credential) return account.credential + return normalizeClaudeNativeCredentials(data?.claudeNative) } catch { return undefined } @@ -71,7 +83,14 @@ export async function readClaudeNativeCredentialsAsync(): Promise< > { if (isBareMode()) return undefined try { - return normalizeClaudeNativeCredentials((await storage().readAsync())?.claudeNative) + const data = await storage().readAsync() + const providerAccounts = normalizeProviderAccounts(data?.providerAccounts) + const defaultAccountId = providerAccounts?.claude.defaultAccountId + const account = defaultAccountId + ? providerAccounts?.claude.accounts[defaultAccountId] + : undefined + if (account?.credential) return account.credential + return normalizeClaudeNativeCredentials(data?.claudeNative) } catch { return undefined } @@ -91,19 +110,34 @@ export function saveClaudeNativeCredentials( } } const secureStorage = storage() - const previous = secureStorage.read() || {} - const next = { - ...(previous as Record), - [CLAUDE_NATIVE_STORAGE_KEY]: { + const previousData = secureStorage.read() || {} + const providerAccounts = normalizeProviderAccounts(previousData.providerAccounts) + if (!providerAccounts) { + const next = { + ...(previousData as Record), + [CLAUDE_NATIVE_STORAGE_KEY]: { + ...normalized, + lastRefreshAt: normalized.lastRefreshAt ?? Date.now(), + }, + } + const result = secureStorage.update(next as typeof previousData) + if (result.success) inMemoryLastRefreshFailureAt = normalized.lastRefreshFailureAt ?? null + return result + } + + try { + upsertProviderAccount('claude', { ...normalized, lastRefreshAt: normalized.lastRefreshAt ?? Date.now(), - }, - } - const result = secureStorage.update(next as typeof previous) - if (result.success) { + }) inMemoryLastRefreshFailureAt = normalized.lastRefreshFailureAt ?? null + return { success: true } + } catch (error) { + return { + success: false, + warning: error instanceof Error ? error.message : 'secure_storage_write_failed', + } } - return result } export function clearClaudeNativeCredentials(): { @@ -111,8 +145,24 @@ export function clearClaudeNativeCredentials(): { warning?: string } { if (isBareMode()) return { success: true } + const raw = storage().read() || {} + const providerAccounts = normalizeProviderAccounts(raw.providerAccounts) + const defaultAccountId = providerAccounts?.claude.defaultAccountId + if (defaultAccountId) { + try { + removeProviderAccount('claude', defaultAccountId) + inMemoryLastRefreshFailureAt = null + return { success: true } + } catch (error) { + return { + success: false, + warning: error instanceof Error ? error.message : 'secure_storage_write_failed', + } + } + } + const secureStorage = storage() - const previous = secureStorage.read() || {} + const previous = raw const next = { ...(previous as Record) } delete next[CLAUDE_NATIVE_STORAGE_KEY] const result = secureStorage.update(next as typeof previous) diff --git a/src/utils/codexCredentials.ts b/src/utils/codexCredentials.ts index 438e8f2c28..c198cdf4e5 100644 --- a/src/utils/codexCredentials.ts +++ b/src/utils/codexCredentials.ts @@ -13,6 +13,11 @@ import { normalizeCodexCredentialBlob, type CodexCredentialBlob, } from './providerAccounts/credentials.js' +import { + removeProviderAccount, + upsertProviderAccount, +} from './providerAccounts/store.js' +import { normalizeProviderAccounts } from './providerAccounts/store.js' export type { CodexCredentialBlob } from './providerAccounts/credentials.js' @@ -95,6 +100,12 @@ export function readCodexCredentials(): CodexCredentialBlob | undefined { try { const data = getCodexSecureStorage().read() + const providerAccounts = normalizeProviderAccounts(data?.providerAccounts) + const defaultAccountId = providerAccounts?.codex.defaultAccountId + const account = defaultAccountId + ? providerAccounts?.codex.accounts[defaultAccountId] + : undefined + if (account?.credential) return account.credential return normalizeCodexCredentialBlob(data?.codex) } catch { return undefined @@ -108,6 +119,12 @@ export async function readCodexCredentialsAsync(): Promise< try { const data = await getCodexSecureStorage().readAsync() + const providerAccounts = normalizeProviderAccounts(data?.providerAccounts) + const defaultAccountId = providerAccounts?.codex.defaultAccountId + const account = defaultAccountId + ? providerAccounts?.codex.accounts[defaultAccountId] + : undefined + if (account?.credential) return account.credential return normalizeCodexCredentialBlob(data?.codex) } catch { return undefined @@ -137,22 +154,38 @@ export function saveCodexCredentials( } const secureStorage = getCodexSecureStorage() - const previous = secureStorage.read() || {} - const previousCodex = normalizeCodexCredentialBlob(previous[CODEX_STORAGE_KEY]) - const next = { - ...(previous as Record), - [CODEX_STORAGE_KEY]: { + const previousData = secureStorage.read() || {} + const providerAccounts = normalizeProviderAccounts(previousData.providerAccounts) + if (!providerAccounts) { + const previousCodex = normalizeCodexCredentialBlob(previousData[CODEX_STORAGE_KEY]) + const next = { + ...(previousData as Record), + [CODEX_STORAGE_KEY]: { + ...normalized, + profileId: normalized.profileId ?? previousCodex?.profileId, + lastRefreshAt: normalized.lastRefreshAt ?? Date.now(), + }, + } + const result = secureStorage.update(next as typeof previousData) + if (result.success) inMemoryLastRefreshFailureAt = normalized.lastRefreshFailureAt ?? null + return result + } + + const previous = readCodexCredentials() + try { + upsertProviderAccount('codex', { ...normalized, - profileId: normalized.profileId ?? previousCodex?.profileId, + profileId: normalized.profileId ?? previous?.profileId, lastRefreshAt: normalized.lastRefreshAt ?? Date.now(), - }, - } - const result = secureStorage.update(next as typeof previous) - if (result.success) { - const storedCodex = normalizeCodexCredentialBlob(next[CODEX_STORAGE_KEY]) - inMemoryLastRefreshFailureAt = storedCodex?.lastRefreshFailureAt ?? null + }) + inMemoryLastRefreshFailureAt = normalized.lastRefreshFailureAt ?? null + return { success: true } + } catch (error) { + return { + success: false, + warning: error instanceof Error ? error.message : 'secure_storage_write_failed', + } } - return result } export function attachCodexProfileIdToStoredCredentials(profileId: string): { @@ -198,14 +231,28 @@ export function clearCodexCredentials(): { return { success: true } } + const raw = getCodexSecureStorage().read() || {} + const providerAccounts = normalizeProviderAccounts(raw.providerAccounts) + const defaultAccountId = providerAccounts?.codex.defaultAccountId + if (defaultAccountId) { + try { + removeProviderAccount('codex', defaultAccountId) + inMemoryLastRefreshFailureAt = null + return { success: true } + } catch (error) { + return { + success: false, + warning: error instanceof Error ? error.message : 'secure_storage_write_failed', + } + } + } + const secureStorage = getCodexSecureStorage() - const previous = secureStorage.read() || {} + const previous = raw const next = { ...(previous as Record) } delete next[CODEX_STORAGE_KEY] const result = secureStorage.update(next as typeof previous) - if (result.success) { - inMemoryLastRefreshFailureAt = null - } + if (result.success) inMemoryLastRefreshFailureAt = null return result } diff --git a/src/utils/providerAccounts/store.lifecycle.test.ts b/src/utils/providerAccounts/store.lifecycle.test.ts new file mode 100644 index 0000000000..b027d0b6be --- /dev/null +++ b/src/utils/providerAccounts/store.lifecycle.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' + +import type { SecureStorageData } from '../secureStorage/index.js' + +function codexCredential(accountId: string, accessToken: string) { + return { accessToken, accountId } +} + +function claudeCredential(accountId: string, accessToken = 'claude-token') { + return { + accessToken, + scopes: ['user:inference'], + accountId, + riskAcceptance: { + version: 1, + acceptedAt: '2026-08-09T12:00:00.000Z', + accountId, + }, + } +} + +function seededData(): SecureStorageData { + return { + providerAccounts: { + schemaVersion: 1, + codex: { + defaultAccountId: 'local-1', + accounts: { + 'local-1': { + localAccountId: 'local-1', + providerSubjectId: 'provider-1', + displayLabel: 'Codex 1', + credential: codexCredential('provider-1', 'token-1'), + connectionState: 'connected', + }, + }, + }, + claude: { accounts: {} }, + }, + codex: codexCredential('provider-1', 'token-1'), + } +} + +async function loadStore( + suffix: string, + initial: SecureStorageData, +): Promise<{ store: typeof import('./store.js'); readState: () => SecureStorageData }> { + let state = initial + mock.module('../secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + name: 'test-secure-storage', + read: () => state, + readAsync: async () => state, + update: (next: SecureStorageData) => { + state = next + return { success: true } + }, + delete: () => true, + }), + })) + + const store = await import(`./store.js?${suffix}`) + return { store, readState: () => state } +} + +afterEach(() => { + mock.restore() +}) + +describe('provider account lifecycle', () => { + test('repeated provider subject updates one local record and preserves its local id', async () => { + const { store, readState } = await loadStore('dedupe', seededData()) + + const result = store.upsertProviderAccount('codex', { + accessToken: 'new-token', + accountId: 'provider-1', + }) + + expect(result.localAccountId).toBe('local-1') + expect(result.created).toBe(false) + expect(readState().providerAccounts?.codex.accounts['local-1'].credential).toMatchObject({ + accessToken: 'new-token', + }) + expect(Object.keys(readState().providerAccounts!.codex.accounts)).toEqual([ + 'local-1', + ]) + }) + + test('changing default updates the legacy scalar but removing a non-default does not', async () => { + const initial = seededData() + initial.providerAccounts!.codex.accounts['local-2'] = { + localAccountId: 'local-2', + providerSubjectId: 'provider-2', + displayLabel: 'Codex 2', + credential: codexCredential('provider-2', 'token-2'), + connectionState: 'connected', + } + const { store, readState } = await loadStore('default-mirror', initial) + + store.setDefaultProviderAccount('codex', 'local-2') + expect(readState().codex?.accessToken).toBe('token-2') + + store.removeProviderAccount('codex', 'local-1') + expect(readState().codex?.accessToken).toBe('token-2') + }) + + test('reconnect refuses to overwrite a different provider subject', async () => { + const { store, readState } = await loadStore('identity-mismatch', seededData()) + + expect(() => + store.reconnectProviderAccount( + 'codex', + 'local-1', + codexCredential('provider-2', 'token-2'), + ), + ).toThrow('provider_identity_mismatch') + expect(readState().providerAccounts?.codex.accounts['local-1'].credential).toMatchObject({ + accessToken: 'token-1', + accountId: 'provider-1', + }) + }) + + test('removing the final account clears the mirrored scalar only after the v1 write succeeds', async () => { + const { store, readState } = await loadStore('remove-final', seededData()) + + store.removeProviderAccount('codex', 'local-1') + + expect(readState().providerAccounts?.codex.accounts).toEqual({}) + expect(readState().providerAccounts?.codex.defaultAccountId).toBeUndefined() + expect(readState().codex).toBeUndefined() + }) + + test('a second provider login adds an account without replacing the first default', async () => { + const { store, readState } = await loadStore('additive', seededData()) + + const result = store.upsertProviderAccount( + 'claude', + claudeCredential('provider-claude'), + ) + + expect(result.created).toBe(true) + expect(Object.keys(readState().providerAccounts!.claude.accounts)).toHaveLength(1) + expect(readState().providerAccounts!.claude.defaultAccountId).toBe( + result.localAccountId, + ) + }) +}) diff --git a/src/utils/providerAccounts/store.ts b/src/utils/providerAccounts/store.ts index eff610c849..288f886eb3 100644 --- a/src/utils/providerAccounts/store.ts +++ b/src/utils/providerAccounts/store.ts @@ -1,4 +1,7 @@ -import { getSecureStorage, type SecureStorageData } from '../secureStorage/index.js' +import { + getSecureStorage, + type SecureStorageData, +} from '../secureStorage/index.js' import { normalizeClaudeNativeCredentials, normalizeCodexCredentialBlob, @@ -204,6 +207,80 @@ function storage() { return getSecureStorage({ allowPlainTextFallback: false }) } +export function readSecureData(): SecureStorageData { + try { + return storage().read() ?? {} + } catch { + return {} + } +} + +function commitSecureData(data: SecureStorageData): void { + const result = storage().update(data) + if (!result.success) { + throw new Error(result.warning ?? 'secure_storage_write_failed') + } +} + +function prepareMutableState(): { + data: SecureStorageData + accounts: ProviderAccountsV1 +} { + const data = readSecureData() + const migration = migrateProviderAccounts(data) + const accounts = normalizeProviderAccounts(migration.data.providerAccounts) + ?? emptyProviderAccounts() + return { + data: { ...migration.data, providerAccounts: accounts }, + accounts, + } +} + +function nextDisplayLabel( + provider: ProviderId, + collection: ProviderAccountCollection, +): string { + const prefix = provider === 'codex' ? 'Codex' : 'Claude' + const used = Object.values(collection.accounts) + .map(account => { + const match = new RegExp(`^${prefix} (\\d+)$`).exec(account.displayLabel) + return match ? Number(match[1]) : 0 + }) + .filter(Number.isFinite) + const next = (used.length ? Math.max(...used) : 0) + 1 + return `${prefix} ${next}` +} + +function normalizeCredentialForProvider( + provider: ProviderId, + credential: CodexCredentialBlob | ClaudeNativeCredentialBlob, +): CodexCredentialBlob | ClaudeNativeCredentialBlob { + const normalized = provider === 'codex' + ? normalizeCodexCredentialBlob(credential) + : normalizeClaudeNativeCredentials(credential) + if (!normalized || !normalized.accountId) { + throw new Error('provider_identity_missing') + } + return normalized +} + +function mirrorDefaultCredential( + data: SecureStorageData, + provider: ProviderId, + account: ProviderAccountRecord | undefined, +): SecureStorageData { + const next = { ...data } + if (provider === 'codex') { + if (account) next.codex = account.credential as CodexCredentialBlob + else delete next.codex + } else if (account) { + next.claudeNative = account.credential as ClaudeNativeCredentialBlob + } else { + delete next.claudeNative + } + return next +} + export function readProviderAccounts(): ProviderAccountsV1 { let data: SecureStorageData | null = null try { @@ -250,6 +327,142 @@ export async function readProviderAccountsAsync(): Promise { return normalized } +export function upsertProviderAccount( + provider: ProviderId, + credential: CodexCredentialBlob | ClaudeNativeCredentialBlob, + options?: { reconnectLocalAccountId?: LocalProviderAccountId }, +): { localAccountId: LocalProviderAccountId; created: boolean } { + const normalized = normalizeCredentialForProvider(provider, credential) + const { data, accounts } = prepareMutableState() + const collection = { + ...accounts[provider], + accounts: { ...accounts[provider].accounts }, + } as ProviderAccountCollection + const providerSubjectId = normalized.accountId! + const existingBySubject = Object.values(collection.accounts).find( + account => account.providerSubjectId === providerSubjectId, + ) + const requestedId = options?.reconnectLocalAccountId + if (requestedId && !collection.accounts[requestedId]) { + throw new Error('provider_account_not_found') + } + if ( + requestedId && + collection.accounts[requestedId] && + collection.accounts[requestedId].providerSubjectId !== providerSubjectId + ) { + throw new Error('provider_identity_mismatch') + } + if ( + requestedId && + existingBySubject && + existingBySubject.localAccountId !== requestedId + ) { + throw new Error('provider_identity_mismatch') + } + const existing = requestedId + ? collection.accounts[requestedId] + : existingBySubject + const localAccountId = existing?.localAccountId ?? crypto.randomUUID() + const account: ProviderAccountRecord< + CodexCredentialBlob | ClaudeNativeCredentialBlob + > = { + localAccountId, + providerSubjectId, + displayLabel: existing?.displayLabel ?? nextDisplayLabel(provider, collection), + credential: normalized, + connectionState: 'connected', + planId: existing?.planId, + planDisplayName: existing?.planDisplayName, + lastValidatedAt: existing?.lastValidatedAt, + } + collection.accounts[localAccountId] = account as never + const nextAccounts = { + ...accounts, + [provider]: collection, + } as ProviderAccountsV1 + if (!collection.defaultAccountId) { + collection.defaultAccountId = localAccountId + } + const defaultAccount = collection.accounts[collection.defaultAccountId] + commitSecureData( + mirrorDefaultCredential( + { ...data, providerAccounts: nextAccounts }, + provider, + defaultAccount, + ), + ) + return { localAccountId, created: !existing } +} + +export function reconnectProviderAccount( + provider: ProviderId, + localAccountId: LocalProviderAccountId, + credential: CodexCredentialBlob | ClaudeNativeCredentialBlob, +): { localAccountId: LocalProviderAccountId; created: boolean } { + return upsertProviderAccount(provider, credential, { + reconnectLocalAccountId: localAccountId, + }) +} + +export function setDefaultProviderAccount( + provider: ProviderId, + localAccountId: LocalProviderAccountId, +): void { + const { data, accounts } = prepareMutableState() + const collection = { + ...accounts[provider], + accounts: { ...accounts[provider].accounts }, + } + const account = collection.accounts[localAccountId] + if (!account) throw new Error('provider_account_not_found') + collection.defaultAccountId = localAccountId + const nextAccounts = { + ...accounts, + [provider]: collection, + } + commitSecureData( + mirrorDefaultCredential( + { ...data, providerAccounts: nextAccounts }, + provider, + account, + ), + ) +} + +export function removeProviderAccount( + provider: ProviderId, + localAccountId: LocalProviderAccountId, +): void { + const { data, accounts } = prepareMutableState() + const collection = { + ...accounts[provider], + accounts: { ...accounts[provider].accounts }, + } + if (!collection.accounts[localAccountId]) { + throw new Error('provider_account_not_found') + } + const wasDefault = collection.defaultAccountId === localAccountId + delete collection.accounts[localAccountId] + if (wasDefault) { + collection.defaultAccountId = Object.keys(collection.accounts).sort()[0] + } + const nextAccounts = { + ...accounts, + [provider]: collection, + } + const defaultAccount = collection.defaultAccountId + ? collection.accounts[collection.defaultAccountId] + : undefined + commitSecureData( + mirrorDefaultCredential( + { ...data, providerAccounts: nextAccounts }, + provider, + defaultAccount, + ), + ) +} + export function listProviderAccountSummaries( data: ProviderAccountsV1 = readProviderAccounts(), ): ProviderAccountSummary[] { @@ -281,4 +494,3 @@ export function resolveProviderAccount( const id = localAccountId ?? collection.defaultAccountId return id ? collection.accounts[id] : undefined } - From 0183fddf6f2135930db46880c65b2198cfcdf130 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Sun, 9 Aug 2026 19:16:38 -0300 Subject: [PATCH 03/15] feat(providers): isolate refresh work by account --- src/utils/claudeNativeCredentials.ts | 98 +++++++++------ .../codexCredentials.accountRefresh.test.ts | 99 +++++++++++++++ src/utils/codexCredentials.ts | 114 +++++++++++------- .../providerAccounts/refreshRegistry.test.ts | 30 +++++ src/utils/providerAccounts/refreshRegistry.ts | 14 +++ 5 files changed, 279 insertions(+), 76 deletions(-) create mode 100644 src/utils/codexCredentials.accountRefresh.test.ts create mode 100644 src/utils/providerAccounts/refreshRegistry.test.ts create mode 100644 src/utils/providerAccounts/refreshRegistry.ts diff --git a/src/utils/claudeNativeCredentials.ts b/src/utils/claudeNativeCredentials.ts index c1712534e4..705aa0d5b5 100644 --- a/src/utils/claudeNativeCredentials.ts +++ b/src/utils/claudeNativeCredentials.ts @@ -17,6 +17,8 @@ import { upsertProviderAccount, } from './providerAccounts/store.js' import { normalizeProviderAccounts } from './providerAccounts/store.js' +import { AccountWorkRegistry } from './providerAccounts/refreshRegistry.js' +import type { LocalProviderAccountId } from './providerAccounts/types.js' export type { ClaudeNativeCredentialBlob, @@ -34,11 +36,13 @@ type RefreshResponse = { scope?: string } -let inFlightRefresh: Promise<{ +type ClaudeRefreshResult = { refreshed: boolean credentials?: ClaudeNativeCredentialBlob -}> | null = null -let inMemoryLastRefreshFailureAt: number | null = null +} + +const claudeRefreshRegistry = new AccountWorkRegistry() +const inMemoryLastRefreshFailureAt = new Map() function trimmed(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined @@ -62,14 +66,16 @@ export function hasCurrentClaudeRiskAcceptance( ) } -export function readClaudeNativeCredentials(): ClaudeNativeCredentialBlob | undefined { +export function readClaudeNativeCredentials( + localAccountId?: LocalProviderAccountId, +): ClaudeNativeCredentialBlob | undefined { if (isBareMode()) return undefined try { const data = storage().read() const providerAccounts = normalizeProviderAccounts(data?.providerAccounts) - const defaultAccountId = providerAccounts?.claude.defaultAccountId - const account = defaultAccountId - ? providerAccounts?.claude.accounts[defaultAccountId] + const accountId = localAccountId ?? providerAccounts?.claude.defaultAccountId + const account = accountId + ? providerAccounts?.claude.accounts[accountId] : undefined if (account?.credential) return account.credential return normalizeClaudeNativeCredentials(data?.claudeNative) @@ -78,16 +84,18 @@ export function readClaudeNativeCredentials(): ClaudeNativeCredentialBlob | unde } } -export async function readClaudeNativeCredentialsAsync(): Promise< +export async function readClaudeNativeCredentialsAsync( + localAccountId?: LocalProviderAccountId, +): Promise< ClaudeNativeCredentialBlob | undefined > { if (isBareMode()) return undefined try { const data = await storage().readAsync() const providerAccounts = normalizeProviderAccounts(data?.providerAccounts) - const defaultAccountId = providerAccounts?.claude.defaultAccountId - const account = defaultAccountId - ? providerAccounts?.claude.accounts[defaultAccountId] + const accountId = localAccountId ?? providerAccounts?.claude.defaultAccountId + const account = accountId + ? providerAccounts?.claude.accounts[accountId] : undefined if (account?.credential) return account.credential return normalizeClaudeNativeCredentials(data?.claudeNative) @@ -98,6 +106,7 @@ export async function readClaudeNativeCredentialsAsync(): Promise< export function saveClaudeNativeCredentials( credentials: ClaudeNativeCredentialBlob, + options?: { localAccountId?: LocalProviderAccountId }, ): { success: boolean; warning?: string } { if (isBareMode()) { return { success: false, warning: 'Bare mode: secure storage is disabled.' } @@ -121,7 +130,14 @@ export function saveClaudeNativeCredentials( }, } const result = secureStorage.update(next as typeof previousData) - if (result.success) inMemoryLastRefreshFailureAt = normalized.lastRefreshFailureAt ?? null + if (result.success) { + const key = accountRefreshKey(options?.localAccountId, normalized) + if (normalized.lastRefreshFailureAt === undefined) { + inMemoryLastRefreshFailureAt.delete(key) + } else { + inMemoryLastRefreshFailureAt.set(key, normalized.lastRefreshFailureAt) + } + } return result } @@ -129,8 +145,13 @@ export function saveClaudeNativeCredentials( upsertProviderAccount('claude', { ...normalized, lastRefreshAt: normalized.lastRefreshAt ?? Date.now(), - }) - inMemoryLastRefreshFailureAt = normalized.lastRefreshFailureAt ?? null + }, options) + const key = accountRefreshKey(options?.localAccountId, normalized) + if (normalized.lastRefreshFailureAt === undefined) { + inMemoryLastRefreshFailureAt.delete(key) + } else { + inMemoryLastRefreshFailureAt.set(key, normalized.lastRefreshFailureAt) + } return { success: true } } catch (error) { return { @@ -151,7 +172,7 @@ export function clearClaudeNativeCredentials(): { if (defaultAccountId) { try { removeProviderAccount('claude', defaultAccountId) - inMemoryLastRefreshFailureAt = null + inMemoryLastRefreshFailureAt.clear() return { success: true } } catch (error) { return { @@ -166,7 +187,7 @@ export function clearClaudeNativeCredentials(): { const next = { ...(previous as Record) } delete next[CLAUDE_NATIVE_STORAGE_KEY] const result = secureStorage.update(next as typeof previous) - if (result.success) inMemoryLastRefreshFailureAt = null + if (result.success) inMemoryLastRefreshFailureAt.clear() return result } @@ -177,21 +198,29 @@ function shouldRefresh(credentials: ClaudeNativeCredentialBlob): boolean { ) } -function coolingDown(credentials: ClaudeNativeCredentialBlob): boolean { +function accountRefreshKey( + localAccountId: LocalProviderAccountId | undefined, + credentials: ClaudeNativeCredentialBlob, +): string { + return `claude:${localAccountId ?? credentials.accountId}` +} + +function coolingDown( + credentials: ClaudeNativeCredentialBlob, + key: string, +): boolean { const failedAt = Math.max( credentials.lastRefreshFailureAt ?? 0, - inMemoryLastRefreshFailureAt ?? 0, + inMemoryLastRefreshFailureAt.get(key) ?? 0, ) return Boolean(failedAt && Date.now() - failedAt < REFRESH_FAILURE_COOLDOWN_MS) } export async function refreshClaudeNativeAccessTokenIfNeeded(options?: { force?: boolean -}): Promise<{ - refreshed: boolean - credentials?: ClaudeNativeCredentialBlob -}> { - const current = await readClaudeNativeCredentialsAsync() + localAccountId?: LocalProviderAccountId +}): Promise { + const current = await readClaudeNativeCredentialsAsync(options?.localAccountId) if (!current || !hasCurrentClaudeRiskAcceptance(current)) { return { refreshed: false } } @@ -199,12 +228,12 @@ export async function refreshClaudeNativeAccessTokenIfNeeded(options?: { if (!options?.force && !shouldRefresh(current)) { return { refreshed: false, credentials: current } } - if (!options?.force && coolingDown(current)) { + const refreshKey = accountRefreshKey(options?.localAccountId, current) + if (!options?.force && coolingDown(current, refreshKey)) { return { refreshed: false, credentials: current } } - if (inFlightRefresh) return inFlightRefresh - inFlightRefresh = (async () => { + return claudeRefreshRegistry.run(refreshKey, async () => { try { const form = new URLSearchParams({ grant_type: 'refresh_token', @@ -249,18 +278,19 @@ export async function refreshClaudeNativeAccessTokenIfNeeded(options?: { lastRefreshAt: Date.now(), lastRefreshFailureAt: undefined, } - const saved = saveClaudeNativeCredentials(next) + const saved = saveClaudeNativeCredentials(next, { + localAccountId: options?.localAccountId, + }) if (!saved.success) throw new Error(saved.warning) - inMemoryLastRefreshFailureAt = null return { refreshed: true, credentials: next } } catch (error) { const failedAt = Date.now() - inMemoryLastRefreshFailureAt = failedAt - saveClaudeNativeCredentials({ ...current, lastRefreshFailureAt: failedAt }) + inMemoryLastRefreshFailureAt.set(refreshKey, failedAt) + saveClaudeNativeCredentials( + { ...current, lastRefreshFailureAt: failedAt }, + { localAccountId: options?.localAccountId }, + ) throw error - } finally { - inFlightRefresh = null } - })() - return inFlightRefresh + }) } diff --git a/src/utils/codexCredentials.accountRefresh.test.ts b/src/utils/codexCredentials.accountRefresh.test.ts new file mode 100644 index 0000000000..b93c3196a9 --- /dev/null +++ b/src/utils/codexCredentials.accountRefresh.test.ts @@ -0,0 +1,99 @@ +import { afterEach, expect, mock, test } from 'bun:test' + +import type { SecureStorageData } from './secureStorage/index.js' + +function jwt(accountId: string, expiresAt = Date.now() - 60_000): string { + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url') + const payload = Buffer.from(JSON.stringify({ + exp: Math.floor(expiresAt / 1000), + chatgpt_account_id: accountId, + })).toString('base64url') + return `${header}.${payload}.signature` +} + +function initialState(): SecureStorageData { + const account = (localAccountId: string, providerSubjectId: string) => ({ + localAccountId, + providerSubjectId, + displayLabel: localAccountId, + credential: { + accessToken: jwt(providerSubjectId), + refreshToken: `refresh-${localAccountId}`, + accountId: providerSubjectId, + }, + connectionState: 'connected' as const, + }) + return { + providerAccounts: { + schemaVersion: 1, + codex: { + defaultAccountId: 'local-a', + accounts: { + 'local-a': account('local-a', 'provider-a'), + 'local-b': account('local-b', 'provider-b'), + }, + }, + claude: { accounts: {} }, + }, + codex: account('local-a', 'provider-a').credential, + } +} + +test('refreshing two Codex accounts concurrently persists both results independently', async () => { + delete process.env.CLAUDE_CODE_SIMPLE + delete process.env.CODEX_API_KEY + let state = initialState() + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + name: 'test-secure-storage', + read: () => state, + readAsync: async () => state, + update: (next: SecureStorageData) => { + state = next + return { success: true } + }, + delete: () => true, + }), + })) + + const originalFetch = globalThis.fetch + globalThis.fetch = mock(async (_input, init) => { + const body = init?.body instanceof URLSearchParams + ? init.body + : new URLSearchParams(String(init?.body ?? '')) + const refreshToken = body.get('refresh_token') + if (refreshToken === 'refresh-local-a' || refreshToken === 'refresh-local-b') { + const suffix = refreshToken.endsWith('a') ? 'a' : 'b' + return new Response(JSON.stringify({ + access_token: jwt(`provider-${suffix}`, Date.now() + 3_600_000), + refresh_token: `next-refresh-${suffix}`, + id_token: jwt(`provider-${suffix}`, Date.now() + 3_600_000), + }), { status: 200 }) + } + return new Response(JSON.stringify({ access_token: 'api-key' }), { status: 200 }) + }) as unknown as typeof fetch + + try { + const { refreshCodexAccessTokenIfNeeded } = await import( + './codexCredentials.js?account-refresh-isolation' + ) + const [a, b] = await Promise.all([ + refreshCodexAccessTokenIfNeeded({ force: true, ignoreEnvironment: true, localAccountId: 'local-a' }), + refreshCodexAccessTokenIfNeeded({ force: true, ignoreEnvironment: true, localAccountId: 'local-b' }), + ]) + + expect(a.credentials?.accessToken).toBeTruthy() + expect(b.credentials?.accessToken).toBeTruthy() + expect(state.providerAccounts?.codex.accounts['local-a'].credential.refreshToken) + .toBe('next-refresh-a') + expect(state.providerAccounts?.codex.accounts['local-b'].credential.refreshToken) + .toBe('next-refresh-b') + } finally { + globalThis.fetch = originalFetch + } +}) + +afterEach(() => { + mock.restore() +}) diff --git a/src/utils/codexCredentials.ts b/src/utils/codexCredentials.ts index c198cdf4e5..a71081fc55 100644 --- a/src/utils/codexCredentials.ts +++ b/src/utils/codexCredentials.ts @@ -18,6 +18,8 @@ import { upsertProviderAccount, } from './providerAccounts/store.js' import { normalizeProviderAccounts } from './providerAccounts/store.js' +import { AccountWorkRegistry } from './providerAccounts/refreshRegistry.js' +import type { LocalProviderAccountId } from './providerAccounts/types.js' export type { CodexCredentialBlob } from './providerAccounts/credentials.js' @@ -31,13 +33,13 @@ type CodexTokenRefreshResponse = { id_token?: string } -let inFlightCodexRefresh: - | Promise<{ - refreshed: boolean - credentials?: CodexCredentialBlob - }> - | null = null -let inMemoryLastRefreshFailureAt: number | null = null +type CodexRefreshResult = { + refreshed: boolean + credentials?: CodexCredentialBlob +} + +const codexRefreshRegistry = new AccountWorkRegistry() +const inMemoryLastRefreshFailureAt = new Map() function getCodexSecureStorage() { return getSecureStorage({ allowPlainTextFallback: false }) @@ -54,10 +56,11 @@ function shouldRefreshCodexToken(blob: CodexCredentialBlob): boolean { function isWithinRefreshFailureCooldown( blob: CodexCredentialBlob, now = Date.now(), + key = 'default', ): boolean { const lastRefreshFailureAt = Math.max( blob.lastRefreshFailureAt ?? 0, - inMemoryLastRefreshFailureAt ?? 0, + inMemoryLastRefreshFailureAt.get(key) ?? 0, ) if (!lastRefreshFailureAt) { @@ -69,6 +72,13 @@ function isWithinRefreshFailureCooldown( ) } +function accountRefreshKey( + localAccountId: LocalProviderAccountId | undefined, + credentials: CodexCredentialBlob, +): string { + return `codex:${localAccountId ?? credentials.accountId ?? 'default'}` +} + function getRefreshErrorMessage( status: number, bodyText: string, @@ -95,15 +105,17 @@ function getRefreshErrorMessage( } } -export function readCodexCredentials(): CodexCredentialBlob | undefined { +export function readCodexCredentials( + localAccountId?: LocalProviderAccountId, +): CodexCredentialBlob | undefined { if (isBareMode()) return undefined try { const data = getCodexSecureStorage().read() const providerAccounts = normalizeProviderAccounts(data?.providerAccounts) - const defaultAccountId = providerAccounts?.codex.defaultAccountId - const account = defaultAccountId - ? providerAccounts?.codex.accounts[defaultAccountId] + const accountId = localAccountId ?? providerAccounts?.codex.defaultAccountId + const account = accountId + ? providerAccounts?.codex.accounts[accountId] : undefined if (account?.credential) return account.credential return normalizeCodexCredentialBlob(data?.codex) @@ -112,7 +124,9 @@ export function readCodexCredentials(): CodexCredentialBlob | undefined { } } -export async function readCodexCredentialsAsync(): Promise< +export async function readCodexCredentialsAsync( + localAccountId?: LocalProviderAccountId, +): Promise< CodexCredentialBlob | undefined > { if (isBareMode()) return undefined @@ -120,9 +134,9 @@ export async function readCodexCredentialsAsync(): Promise< try { const data = await getCodexSecureStorage().readAsync() const providerAccounts = normalizeProviderAccounts(data?.providerAccounts) - const defaultAccountId = providerAccounts?.codex.defaultAccountId - const account = defaultAccountId - ? providerAccounts?.codex.accounts[defaultAccountId] + const accountId = localAccountId ?? providerAccounts?.codex.defaultAccountId + const account = accountId + ? providerAccounts?.codex.accounts[accountId] : undefined if (account?.credential) return account.credential return normalizeCodexCredentialBlob(data?.codex) @@ -134,15 +148,18 @@ export async function readCodexCredentialsAsync(): Promise< export function isCodexRefreshFailureCoolingDown( blob: Pick, now = Date.now(), + localAccountId?: LocalProviderAccountId, ): boolean { return isWithinRefreshFailureCooldown( blob as CodexCredentialBlob, now, + localAccountId ? `codex:${localAccountId}` : 'default', ) } export function saveCodexCredentials( credentials: CodexCredentialBlob, + options?: { localAccountId?: LocalProviderAccountId }, ): { success: boolean; warning?: string } { if (isBareMode()) { return { success: false, warning: 'Bare mode: secure storage is disabled.' } @@ -167,18 +184,30 @@ export function saveCodexCredentials( }, } const result = secureStorage.update(next as typeof previousData) - if (result.success) inMemoryLastRefreshFailureAt = normalized.lastRefreshFailureAt ?? null + if (result.success) { + const key = accountRefreshKey(options?.localAccountId, normalized) + if (normalized.lastRefreshFailureAt === undefined) { + inMemoryLastRefreshFailureAt.delete(key) + } else { + inMemoryLastRefreshFailureAt.set(key, normalized.lastRefreshFailureAt) + } + } return result } - const previous = readCodexCredentials() + const previous = readCodexCredentials(options?.localAccountId) try { upsertProviderAccount('codex', { ...normalized, profileId: normalized.profileId ?? previous?.profileId, lastRefreshAt: normalized.lastRefreshAt ?? Date.now(), - }) - inMemoryLastRefreshFailureAt = normalized.lastRefreshFailureAt ?? null + }, options) + const key = accountRefreshKey(options?.localAccountId, normalized) + if (normalized.lastRefreshFailureAt === undefined) { + inMemoryLastRefreshFailureAt.delete(key) + } else { + inMemoryLastRefreshFailureAt.set(key, normalized.lastRefreshFailureAt) + } return { success: true } } catch (error) { return { @@ -213,13 +242,17 @@ export function attachCodexProfileIdToStoredCredentials(profileId: string): { function persistCodexRefreshFailure( credentials: CodexCredentialBlob, occurredAt: number, + localAccountId?: LocalProviderAccountId, ): void { const result = saveCodexCredentials({ ...credentials, lastRefreshFailureAt: occurredAt, - }) + }, { localAccountId }) if (!result.success) { - inMemoryLastRefreshFailureAt = occurredAt + inMemoryLastRefreshFailureAt.set( + accountRefreshKey(localAccountId, credentials), + occurredAt, + ) } } @@ -237,7 +270,7 @@ export function clearCodexCredentials(): { if (defaultAccountId) { try { removeProviderAccount('codex', defaultAccountId) - inMemoryLastRefreshFailureAt = null + inMemoryLastRefreshFailureAt.clear() return { success: true } } catch (error) { return { @@ -252,17 +285,15 @@ export function clearCodexCredentials(): { const next = { ...(previous as Record) } delete next[CODEX_STORAGE_KEY] const result = secureStorage.update(next as typeof previous) - if (result.success) inMemoryLastRefreshFailureAt = null + if (result.success) inMemoryLastRefreshFailureAt.clear() return result } export async function refreshCodexAccessTokenIfNeeded(options?: { force?: boolean ignoreEnvironment?: boolean -}): Promise<{ - refreshed: boolean - credentials?: CodexCredentialBlob -}> { + localAccountId?: LocalProviderAccountId +}): Promise { if (isBareMode()) { return { refreshed: false } } @@ -271,7 +302,7 @@ export async function refreshCodexAccessTokenIfNeeded(options?: { return { refreshed: false } } - const current = await readCodexCredentialsAsync() + const current = await readCodexCredentialsAsync(options?.localAccountId) if (!current) { return { refreshed: false } } @@ -285,15 +316,12 @@ export async function refreshCodexAccessTokenIfNeeded(options?: { return { refreshed: false, credentials: current } } - if (!options?.force && isWithinRefreshFailureCooldown(current)) { + const refreshKey = accountRefreshKey(options?.localAccountId, current) + if (!options?.force && isWithinRefreshFailureCooldown(current, Date.now(), refreshKey)) { return { refreshed: false, credentials: current } } - if (inFlightCodexRefresh) { - return inFlightCodexRefresh - } - - inFlightCodexRefresh = (async () => { + return codexRefreshRegistry.run(refreshKey, async () => { const refreshAttemptedAt = Date.now() try { @@ -352,7 +380,9 @@ export async function refreshCodexAccessTokenIfNeeded(options?: { ).catch(() => undefined) } - const saveResult = saveCodexCredentials(next) + const saveResult = saveCodexCredentials(next, { + localAccountId: options?.localAccountId, + }) if (!saveResult.success) { throw new Error( saveResult.warning ?? @@ -365,12 +395,12 @@ export async function refreshCodexAccessTokenIfNeeded(options?: { credentials: next, } } catch (error) { - persistCodexRefreshFailure(current, refreshAttemptedAt) + persistCodexRefreshFailure( + current, + refreshAttemptedAt, + options?.localAccountId, + ) throw error - } finally { - inFlightCodexRefresh = null } - })() - - return inFlightCodexRefresh + }) } diff --git a/src/utils/providerAccounts/refreshRegistry.test.ts b/src/utils/providerAccounts/refreshRegistry.test.ts new file mode 100644 index 0000000000..8dc6680f18 --- /dev/null +++ b/src/utils/providerAccounts/refreshRegistry.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'bun:test' + +import { AccountWorkRegistry } from './refreshRegistry.js' + +test('account A and B never share the same in-flight result', async () => { + const registry = new AccountWorkRegistry() + let releaseA!: (value: string) => void + const a = registry.run( + 'codex:local-a', + () => new Promise(resolve => { releaseA = resolve }), + ) + const b = registry.run('codex:local-b', async () => 'token-b') + const duplicateA = registry.run('codex:local-a', async () => 'wrong-token') + releaseA('token-a') + + expect(await Promise.all([a, b, duplicateA])).toEqual([ + 'token-a', + 'token-b', + 'token-a', + ]) +}) + +test('a rejected operation is removed so the next attempt can run', async () => { + const registry = new AccountWorkRegistry() + await expect(registry.run('claude:local-a', async () => { + throw new Error('first-failure') + })).rejects.toThrow('first-failure') + await expect(registry.run('claude:local-a', async () => 'recovered')) + .resolves.toBe('recovered') +}) diff --git a/src/utils/providerAccounts/refreshRegistry.ts b/src/utils/providerAccounts/refreshRegistry.ts new file mode 100644 index 0000000000..4a3a2f17ff --- /dev/null +++ b/src/utils/providerAccounts/refreshRegistry.ts @@ -0,0 +1,14 @@ +export class AccountWorkRegistry { + private readonly inFlight = new Map>() + + run(key: string, work: () => Promise): Promise { + const current = this.inFlight.get(key) + if (current) return current + + const pending = work().finally(() => { + if (this.inFlight.get(key) === pending) this.inFlight.delete(key) + }) + this.inFlight.set(key, pending) + return pending + } +} From f129774401f380811238424700974932f9ed715b Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Sun, 9 Aug 2026 19:21:31 -0300 Subject: [PATCH 04/15] feat(providers): make provider login additive --- src/commands/claude/claude.tsx | 42 ++++++++++--- src/commands/codex/codex.tsx | 31 +++++++--- .../useClaudeNativeOAuthFlow.test.tsx | 61 +++++++++++++++++++ src/components/useClaudeNativeOAuthFlow.ts | 24 +++++++- src/components/useCodexOAuthFlow.test.tsx | 61 +++++++++++++++++++ src/components/useCodexOAuthFlow.ts | 24 ++++++-- src/utils/claudeNativeCredentials.ts | 6 +- .../codexCredentials.accountRefresh.test.ts | 1 + src/utils/codexCredentials.ts | 6 +- src/utils/providerAccounts/loginArgs.test.ts | 27 ++++++++ src/utils/providerAccounts/loginArgs.ts | 23 +++++++ src/utils/providerAccounts/store.test.ts | 11 +++- 12 files changed, 285 insertions(+), 32 deletions(-) create mode 100644 src/utils/providerAccounts/loginArgs.test.ts create mode 100644 src/utils/providerAccounts/loginArgs.ts diff --git a/src/commands/claude/claude.tsx b/src/commands/claude/claude.tsx index dbcfccaafe..70d864423c 100644 --- a/src/commands/claude/claude.tsx +++ b/src/commands/claude/claude.tsx @@ -28,18 +28,21 @@ import { readClaudeNativeCredentialsAsync, type ClaudeNativeCredentialBlob, } from '../../utils/claudeNativeCredentials.js' +import { parseProviderLoginArgs } from '../../utils/providerAccounts/loginArgs.js' function ClaudeLogin({ acceptedAt, onDone, + reconnectLocalAccountId, }: { acceptedAt: string onDone: LocalJSXCommandOnDone + reconnectLocalAccountId?: string }) { const handleAuthenticated = React.useCallback( async ( _tokens: unknown, - persistCredentials: () => void, + persistCredentials: (options?: { reconnectLocalAccountId?: string }) => void, candidateCredentials: ClaudeNativeCredentialBlob, ) => { clearClaudeNativeModelsCache() @@ -53,7 +56,7 @@ function ClaudeLogin({ ) } try { - persistCredentials() + persistCredentials({ reconnectLocalAccountId }) } catch (error) { // Do not leave an in-memory Claude catalog unlocked when secure // persistence failed. A failed optional login must not affect Verboo. @@ -68,6 +71,8 @@ function ClaudeLogin({ [onDone], ) const status = useClaudeNativeOAuthFlow({ + additive: true, + reconnectLocalAccountId, acceptedAt, onAuthenticated: handleAuthenticated, }) @@ -112,9 +117,23 @@ function ClaudeLogin({ ) } -function ClaudeRiskDisclosure({ onDone }: { onDone: LocalJSXCommandOnDone }) { +function ClaudeRiskDisclosure({ + onDone, + reconnectLocalAccountId, +}: { + onDone: LocalJSXCommandOnDone + reconnectLocalAccountId?: string +}) { const [acceptedAt, setAcceptedAt] = React.useState(null) - if (acceptedAt) return + if (acceptedAt) { + return ( + + ) + } const cancel = () => onDone('Claude não habilitado. O Verboo continua disponível.', { @@ -172,8 +191,8 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => { return } - const action = args.trim().toLowerCase() || 'login' - if (action === 'status') { + const action = parseProviderLoginArgs(args) + if (action.action === 'status') { const credentials = await readClaudeNativeCredentialsAsync() onDone( credentials @@ -184,7 +203,7 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => { return } - if (action === 'logout') { + if (action.action === 'logout') { const claudeIds = new Set( (getCachedClaudeNativeModels() ?? []).map(model => model.id), ) @@ -219,9 +238,14 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => { return } - if (action !== 'login') { + if (action.action !== 'login') { onDone('Uso: /claude [login|status|logout]', { display: 'system' }) return } - return + return ( + + ) } diff --git a/src/commands/codex/codex.tsx b/src/commands/codex/codex.tsx index 8234a78e2c..a726a0223f 100644 --- a/src/commands/codex/codex.tsx +++ b/src/commands/codex/codex.tsx @@ -16,15 +16,21 @@ import { clearCodexCredentials, readCodexCredentialsAsync, } from '../../utils/codexCredentials.js' +import { parseProviderLoginArgs } from '../../utils/providerAccounts/loginArgs.js' function CodexLogin({ onDone, + reconnectLocalAccountId, }: { onDone: LocalJSXCommandOnDone + reconnectLocalAccountId?: string }) { const handleAuthenticated = React.useCallback( - async (_tokens: CodexOAuthTokens, persistCredentials: () => void) => { - persistCredentials() + async ( + _tokens: CodexOAuthTokens, + persistCredentials: (options?: { reconnectLocalAccountId?: string }) => void, + ) => { + persistCredentials({ reconnectLocalAccountId }) clearCodexModelsCache() const models = await fetchCodexModels({ force: true }) if (models.length === 0) { @@ -42,7 +48,11 @@ function CodexLogin({ [onDone], ) - const status = useCodexOAuthFlow({ onAuthenticated: handleAuthenticated }) + const status = useCodexOAuthFlow({ + additive: true, + reconnectLocalAccountId, + onAuthenticated: handleAuthenticated, + }) const handleCancel = React.useCallback(() => { status.cancel() onDone('Login Codex cancelado. O Verboo continua disponível.', { @@ -103,9 +113,9 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => { return } - const action = args.trim().toLowerCase() || 'login' + const action = parseProviderLoginArgs(args) - if (action === 'status') { + if (action.action === 'status') { const credentials = await readCodexCredentialsAsync() onDone( credentials @@ -116,7 +126,7 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => { return } - if (action === 'logout') { + if (action.action === 'logout') { const codexModelIds = new Set( (getCachedCodexModels() ?? []).map(model => model.id), ) @@ -143,10 +153,15 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => { return } - if (action !== 'login') { + if (action.action !== 'login') { onDone('Uso: /codex [login|status|logout]', { display: 'system' }) return } - return + return ( + + ) } diff --git a/src/components/useClaudeNativeOAuthFlow.test.tsx b/src/components/useClaudeNativeOAuthFlow.test.tsx index a8a1f1bd4f..11590df5e3 100644 --- a/src/components/useClaudeNativeOAuthFlow.test.tsx +++ b/src/components/useClaudeNativeOAuthFlow.test.tsx @@ -165,3 +165,64 @@ test('cancel stops OAuth and ignores a late completion', async () => { } expect(cleanup).toHaveBeenCalledTimes(1) }) + +test('persists an additive Claude login against the requested local account id', async () => { + const saveCredentials = mock(() => ({ success: true })) + const cleanup = mock(() => {}) + const onAuthenticated = mock( + async ( + _tokens: typeof TOKENS, + persistCredentials: (options?: { reconnectLocalAccountId?: string }) => void, + _candidate: unknown, + ) => persistCredentials({ reconnectLocalAccountId: 'local-claude-2' }), + ) + const deps = { + createOAuthService: () => ({ + async startOAuthFlow( + onAuthorizationUrl: (url: string) => void | Promise, + ) { + await onAuthorizationUrl('https://claude.com/cai/oauth/authorize') + return TOKENS + }, + cleanup, + }), + openBrowser: async () => true, + saveCredentials, + isBareMode: () => false, + } + const { useClaudeNativeOAuthFlow } = await import( + `./useClaudeNativeOAuthFlow.js?additive-${Date.now()}-${Math.random()}` + ) + + function Harness(): React.ReactNode { + const handler = React.useCallback(onAuthenticated, [onAuthenticated]) + useClaudeNativeOAuthFlow({ + additive: true, + reconnectLocalAccountId: 'local-claude-2', + acceptedAt: '2026-08-06T00:00:00.000Z', + onAuthenticated: handler, + deps, + }) + return waiting + } + + const streams = createTestStreams() + const root = await createRoot({ + stdout: streams.stdout as unknown as NodeJS.WriteStream, + stdin: streams.stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + root.render() + try { + await waitFor(() => saveCredentials.mock.calls.length === 1) + expect(saveCredentials).toHaveBeenCalledWith( + expect.objectContaining({ accountId: TOKENS.accountId }), + { localAccountId: 'local-claude-2', additive: true }, + ) + } finally { + root.unmount() + streams.stdin.end() + streams.stdout.end() + await Bun.sleep(0) + } +}) diff --git a/src/components/useClaudeNativeOAuthFlow.ts b/src/components/useClaudeNativeOAuthFlow.ts index 84ad3c2098..a0a8c08ac9 100644 --- a/src/components/useClaudeNativeOAuthFlow.ts +++ b/src/components/useClaudeNativeOAuthFlow.ts @@ -19,7 +19,9 @@ type FlowState = export type ClaudeNativeOAuthFlowStatus = FlowState & { cancel: () => void } -type PersistCredentials = () => ClaudeNativeCredentialBlob +type PersistCredentials = (options?: { + reconnectLocalAccountId?: string +}) => ClaudeNativeCredentialBlob type Dependencies = { createOAuthService?: () => Pick< @@ -36,6 +38,8 @@ function createDefaultOAuthService() { } export function useClaudeNativeOAuthFlow(options: { + additive?: boolean + reconnectLocalAccountId?: string acceptedAt: string onAuthenticated: ( tokens: ClaudeNativeOAuthTokens, @@ -97,8 +101,20 @@ export function useClaudeNativeOAuthFlow(options: { accountId: tokens.accountId, }, } - const persistCredentials = (): ClaudeNativeCredentialBlob => { - const saved = saveCredentials(candidateCredentials) + const persistCredentials = (persistOptions?: { + reconnectLocalAccountId?: string + }): ClaudeNativeCredentialBlob => { + const localAccountId = + persistOptions?.reconnectLocalAccountId ?? options.reconnectLocalAccountId + const saveOptions = options.additive || localAccountId + ? { + localAccountId, + additive: Boolean(options.additive || localAccountId), + } + : undefined + const saved = saveOptions + ? saveCredentials(candidateCredentials, saveOptions) + : saveCredentials(candidateCredentials) if (!saved.success) { throw new Error( saved.warning ?? @@ -126,11 +142,13 @@ export function useClaudeNativeOAuthFlow(options: { cancelRef.current = () => {} } }, [ + options.additive, createOAuthService, isBareModeFn, openBrowserFn, options.acceptedAt, options.onAuthenticated, + options.reconnectLocalAccountId, saveCredentials, ]) diff --git a/src/components/useCodexOAuthFlow.test.tsx b/src/components/useCodexOAuthFlow.test.tsx index b0cc8c6583..5e271349e1 100644 --- a/src/components/useCodexOAuthFlow.test.tsx +++ b/src/components/useCodexOAuthFlow.test.tsx @@ -283,3 +283,64 @@ test('cancel stops the pending OAuth service and ignores a late completion', asy expect(cleanup).toHaveBeenCalledTimes(1) }) + +test('persists an additive login against the requested local account id', async () => { + const saveCodexCredentials = mock(() => ({ success: true })) + const cleanup = mock(() => {}) + const onAuthenticated = mock( + async ( + _tokens: typeof TOKENS, + persistCredentials: (options?: { reconnectLocalAccountId?: string }) => void, + ) => { + persistCredentials({ reconnectLocalAccountId: 'local-2' }) + }, + ) + const deps = { + createOAuthService: () => ({ + async startOAuthFlow( + onAuthorizationUrl: (authUrl: string) => void | Promise, + ) { + await onAuthorizationUrl('https://chatgpt.com/codex') + return TOKENS + }, + cleanup, + }), + openBrowser: async () => true, + saveCodexCredentials, + isBareMode: () => false, + } + const { useCodexOAuthFlow } = await import( + `./useCodexOAuthFlow.js?additive-${Date.now()}-${Math.random()}` + ) + + function Harness(): React.ReactNode { + const handler = React.useCallback(onAuthenticated, [onAuthenticated]) + useCodexOAuthFlow({ + additive: true, + reconnectLocalAccountId: 'local-2', + onAuthenticated: handler, + deps, + }) + return waiting + } + + const streams = createTestStreams() + const root = await createRoot({ + stdout: streams.stdout as unknown as NodeJS.WriteStream, + stdin: streams.stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + root.render() + try { + await waitForCondition(() => saveCodexCredentials.mock.calls.length === 1) + expect(saveCodexCredentials).toHaveBeenCalledWith( + expect.objectContaining({ accountId: TOKENS.accountId }), + { localAccountId: 'local-2', additive: true }, + ) + } finally { + root.unmount() + streams.stdin.end() + streams.stdout.end() + await Bun.sleep(0) + } +}) diff --git a/src/components/useCodexOAuthFlow.ts b/src/components/useCodexOAuthFlow.ts index 224a21985e..d60d781b71 100644 --- a/src/components/useCodexOAuthFlow.ts +++ b/src/components/useCodexOAuthFlow.ts @@ -26,6 +26,7 @@ export type CodexOAuthFlowStatus = CodexOAuthFlowState & { type PersistCodexOAuthCredentials = (options?: { profileId?: string + reconnectLocalAccountId?: string }) => void type CodexOAuthFlowDependencies = { @@ -46,6 +47,8 @@ function createDefaultOAuthService(): Pick< } export function useCodexOAuthFlow(options: { + additive?: boolean + reconnectLocalAccountId?: string onAuthenticated: ( tokens: CodexOAuthTokens, persistCredentials: PersistCodexOAuthCredentials, @@ -104,15 +107,26 @@ export function useCodexOAuthFlow(options: { .then(async tokens => { if (cancelled) return - const persistCredentials: PersistCodexOAuthCredentials = options => { - const saved = saveCredentials({ + const persistCredentials: PersistCodexOAuthCredentials = persistOptions => { + const localAccountId = + persistOptions?.reconnectLocalAccountId ?? options.reconnectLocalAccountId + const saveOptions = options.additive || localAccountId + ? { + localAccountId, + additive: Boolean(options.additive || localAccountId), + } + : undefined + const credentials = { apiKey: tokens.apiKey, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, idToken: tokens.idToken, accountId: tokens.accountId, - profileId: options?.profileId, - }) + profileId: persistOptions?.profileId, + } + const saved = saveOptions + ? saveCredentials(credentials, saveOptions) + : saveCredentials(credentials) if (!saved.success) { throw new Error( saved.warning ?? @@ -136,10 +150,12 @@ export function useCodexOAuthFlow(options: { cancelRef.current = () => {} } }, [ + options.additive, createOAuthService, isBareModeFn, onAuthenticated, openBrowserFn, + options.reconnectLocalAccountId, saveCredentials, ]) diff --git a/src/utils/claudeNativeCredentials.ts b/src/utils/claudeNativeCredentials.ts index 705aa0d5b5..c8f80d181b 100644 --- a/src/utils/claudeNativeCredentials.ts +++ b/src/utils/claudeNativeCredentials.ts @@ -106,7 +106,7 @@ export async function readClaudeNativeCredentialsAsync( export function saveClaudeNativeCredentials( credentials: ClaudeNativeCredentialBlob, - options?: { localAccountId?: LocalProviderAccountId }, + options?: { localAccountId?: LocalProviderAccountId; additive?: boolean }, ): { success: boolean; warning?: string } { if (isBareMode()) { return { success: false, warning: 'Bare mode: secure storage is disabled.' } @@ -121,7 +121,7 @@ export function saveClaudeNativeCredentials( const secureStorage = storage() const previousData = secureStorage.read() || {} const providerAccounts = normalizeProviderAccounts(previousData.providerAccounts) - if (!providerAccounts) { + if (!providerAccounts && !options?.additive) { const next = { ...(previousData as Record), [CLAUDE_NATIVE_STORAGE_KEY]: { @@ -145,7 +145,7 @@ export function saveClaudeNativeCredentials( upsertProviderAccount('claude', { ...normalized, lastRefreshAt: normalized.lastRefreshAt ?? Date.now(), - }, options) + }, { reconnectLocalAccountId: options?.localAccountId }) const key = accountRefreshKey(options?.localAccountId, normalized) if (normalized.lastRefreshFailureAt === undefined) { inMemoryLastRefreshFailureAt.delete(key) diff --git a/src/utils/codexCredentials.accountRefresh.test.ts b/src/utils/codexCredentials.accountRefresh.test.ts index b93c3196a9..2872e43f52 100644 --- a/src/utils/codexCredentials.accountRefresh.test.ts +++ b/src/utils/codexCredentials.accountRefresh.test.ts @@ -75,6 +75,7 @@ test('refreshing two Codex accounts concurrently persists both results independe }) as unknown as typeof fetch try { + // @ts-expect-error cache-busting query string for Bun module mocks const { refreshCodexAccessTokenIfNeeded } = await import( './codexCredentials.js?account-refresh-isolation' ) diff --git a/src/utils/codexCredentials.ts b/src/utils/codexCredentials.ts index a71081fc55..cce58eb8d0 100644 --- a/src/utils/codexCredentials.ts +++ b/src/utils/codexCredentials.ts @@ -159,7 +159,7 @@ export function isCodexRefreshFailureCoolingDown( export function saveCodexCredentials( credentials: CodexCredentialBlob, - options?: { localAccountId?: LocalProviderAccountId }, + options?: { localAccountId?: LocalProviderAccountId; additive?: boolean }, ): { success: boolean; warning?: string } { if (isBareMode()) { return { success: false, warning: 'Bare mode: secure storage is disabled.' } @@ -173,7 +173,7 @@ export function saveCodexCredentials( const secureStorage = getCodexSecureStorage() const previousData = secureStorage.read() || {} const providerAccounts = normalizeProviderAccounts(previousData.providerAccounts) - if (!providerAccounts) { + if (!providerAccounts && !options?.additive) { const previousCodex = normalizeCodexCredentialBlob(previousData[CODEX_STORAGE_KEY]) const next = { ...(previousData as Record), @@ -201,7 +201,7 @@ export function saveCodexCredentials( ...normalized, profileId: normalized.profileId ?? previous?.profileId, lastRefreshAt: normalized.lastRefreshAt ?? Date.now(), - }, options) + }, { reconnectLocalAccountId: options?.localAccountId }) const key = accountRefreshKey(options?.localAccountId, normalized) if (normalized.lastRefreshFailureAt === undefined) { inMemoryLastRefreshFailureAt.delete(key) diff --git a/src/utils/providerAccounts/loginArgs.test.ts b/src/utils/providerAccounts/loginArgs.test.ts new file mode 100644 index 0000000000..f547d60154 --- /dev/null +++ b/src/utils/providerAccounts/loginArgs.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test' + +import { parseProviderLoginArgs } from './loginArgs.js' + +test('accepts an empty command and plain login as additive login', () => { + expect(parseProviderLoginArgs('')).toEqual({ action: 'login' }) + expect(parseProviderLoginArgs('login')).toEqual({ action: 'login' }) +}) + +test('accepts an explicit reconnect local account id', () => { + expect(parseProviderLoginArgs('login --reconnect local-account-2')).toEqual({ + action: 'login', + reconnectLocalAccountId: 'local-account-2', + }) +}) + +test('accepts status and logout without accepting extra arguments', () => { + expect(parseProviderLoginArgs('status')).toEqual({ action: 'status' }) + expect(parseProviderLoginArgs('logout')).toEqual({ action: 'logout' }) + expect(parseProviderLoginArgs('status extra')).toEqual({ action: 'invalid' }) +}) + +test('rejects malformed reconnect arguments', () => { + expect(parseProviderLoginArgs('login --reconnect')).toEqual({ action: 'invalid' }) + expect(parseProviderLoginArgs('login --reconnect a extra')).toEqual({ action: 'invalid' }) + expect(parseProviderLoginArgs('login --unknown value')).toEqual({ action: 'invalid' }) +}) diff --git a/src/utils/providerAccounts/loginArgs.ts b/src/utils/providerAccounts/loginArgs.ts new file mode 100644 index 0000000000..e593150157 --- /dev/null +++ b/src/utils/providerAccounts/loginArgs.ts @@ -0,0 +1,23 @@ +export type ProviderLoginArgs = + | { action: 'login'; reconnectLocalAccountId?: string } + | { action: 'status' } + | { action: 'logout' } + | { action: 'invalid' } + +export function parseProviderLoginArgs(raw: string): ProviderLoginArgs { + const parts = raw.trim().split(/\s+/).filter(Boolean) + if (parts.length === 0 || parts[0] === 'login') { + if ( + parts[1] === '--reconnect' && + parts[2] && + !parts[2].startsWith('--') && + parts.length === 3 + ) { + return { action: 'login', reconnectLocalAccountId: parts[2] } + } + return parts.length <= 1 ? { action: 'login' } : { action: 'invalid' } + } + if (parts.length === 1 && parts[0] === 'status') return { action: 'status' } + if (parts.length === 1 && parts[0] === 'logout') return { action: 'logout' } + return { action: 'invalid' } +} diff --git a/src/utils/providerAccounts/store.test.ts b/src/utils/providerAccounts/store.test.ts index 2f5b3dfa13..2ccb27123c 100644 --- a/src/utils/providerAccounts/store.test.ts +++ b/src/utils/providerAccounts/store.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' +import type { SecureStorageData } from '../secureStorage/index.js' import { migrateProviderAccounts } from './store.js' describe('migrateProviderAccounts', () => { @@ -51,7 +52,10 @@ describe('migrateProviderAccounts', () => { codex: { accessToken: 'legacy-token', accountId: 'legacy-account' }, } - const result = migrateProviderAccounts(malformed, () => 'new-id') + const result = migrateProviderAccounts( + malformed as unknown as SecureStorageData, + () => 'new-id', + ) expect(result.mode).toBe('v1') expect(result.data.providerAccounts?.codex.accounts['new-id']).toBeDefined() @@ -86,7 +90,10 @@ describe('migrateProviderAccounts', () => { }, } - const result = migrateProviderAccounts(malformed, () => 'new-id') + const result = migrateProviderAccounts( + malformed as unknown as SecureStorageData, + () => 'new-id', + ) expect(result.mode).toBe('legacy') expect(result.data.providerAccounts).toEqual(malformed.providerAccounts) From 51a64432dbcd84b8c8dddd5909df3b93e6693d56 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Sun, 9 Aug 2026 19:32:36 -0300 Subject: [PATCH 05/15] feat(providers): select account per CLI process --- src/main.tsx | 31 +++-- src/services/api/authRouting.ts | 8 +- src/services/api/claudeNativeModels.test.ts | 86 ++++++++++++- src/services/api/claudeNativeModels.ts | 53 ++++++-- src/services/api/client.ts | 43 +++++-- src/services/api/codexModels.test.ts | 73 ++++++++++- src/services/api/codexModels.ts | 74 ++++++++--- src/services/api/openaiShim.ts | 7 +- ...iderConfig.runtimeCodexCredentials.test.ts | 28 +++++ src/services/api/providerConfig.ts | 14 ++- src/utils/providerAccounts/selection.test.ts | 119 ++++++++++++++++++ src/utils/providerAccounts/selection.ts | 47 +++++++ src/utils/providerAccounts/store.ts | 18 +++ 13 files changed, 553 insertions(+), 48 deletions(-) create mode 100644 src/utils/providerAccounts/selection.test.ts create mode 100644 src/utils/providerAccounts/selection.ts diff --git a/src/main.tsx b/src/main.tsx index dcf6789a93..76b4e13564 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -64,6 +64,7 @@ import { fetchVerbooModels } from './services/api/verbooModels.js'; import { assertCLIEntitlement } from './services/oauth/cliEntitlement.js'; import { readCodexCredentialsAsync } from './utils/codexCredentials.js'; import { readClaudeNativeCredentialsAsync } from './utils/claudeNativeCredentials.js'; +import { getSelectedProviderAccount, initializeProviderAccountSelection } from './utils/providerAccounts/selection.js'; import { getBaseRenderOptions } from './utils/renderOptions.js'; import { getSessionIngressAuthToken } from './utils/sessionIngressAuth.js'; import { settingsChangeDetector } from './utils/settings/changeDetector.js'; @@ -1003,7 +1004,7 @@ async function run(): Promise { return Number.isFinite(n) ? n : undefined; }).hideHelp()).option('--from-pr [value]', 'Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term', value => value || true).option('--no-session-persistence', 'Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)').addOption(new Option('--resume-session-at ', 'When resuming, only messages up to and including the assistant message with (use with --resume in print mode)').argParser(String).hideHelp()).addOption(new Option('--rewind-files ', 'Restore files to state at the specified user message and exit (requires --resume)').hideHelp()) // @[MODEL LAUNCH]: Update the example model ID in the --model help text. - .option('--model ', `Model ID returned by /model or --list-models.`).addOption(new Option('--effort ', `Effort level for the current session (low, medium, high, max)`).argParser((rawValue: string) => { + .option('--model ', `Model ID returned by /model or --list-models.`).option('--provider-account ', 'Use one connected provider account for this process').addOption(new Option('--effort ', `Effort level for the current session (low, medium, high, max)`).argParser((rawValue: string) => { const value = rawValue.toLowerCase(); const allowed = ['low', 'medium', 'high', 'max']; if (!allowed.includes(value)) { @@ -1019,6 +1020,11 @@ async function run(): Promise { .option('--plugin-dir ', 'Load plugins from a directory for this session only (repeatable: --plugin-dir A --plugin-dir B)', (val: string, prev: string[]) => [...prev, val], [] as string[]).option('--disable-slash-commands', 'Disable all skills', () => true).option('--chrome', 'Enable Verboo in Chrome integration').option('--no-chrome', 'Disable Verboo in Chrome integration').option('--file ', 'File resources to download at startup. Format: file_id:relative_path (e.g., --file file_abc:doc.txt file_def:img.png)').option('--list-models', 'List available Verboo models and exit').action(async (prompt, options) => { profileCheckpoint('action_handler_start'); + const providerAccountId = (options as { providerAccount?: string }).providerAccount; + if (providerAccountId) { + initializeProviderAccountSelection(providerAccountId); + } + // Verboo is always first. Optional provider catalogs only add non-duplicate IDs. if ((options as { listModels?: boolean }).listModels) { try { @@ -1026,12 +1032,23 @@ async function run(): Promise { const tokens = await getClaudeAIOAuthTokensAsync(); if (!tokens?.accessToken) throw new Error('Sessão Verboo ausente.'); const verbooModels = await fetchVerbooModels(tokens.accessToken); - const codexModels = await readCodexCredentialsAsync() - ? await fetchCodexModels().catch(() => []) - : []; - const claudeModels = await readClaudeNativeCredentialsAsync() - ? await fetchClaudeNativeModels().catch(() => []) - : []; + const selectedAccount = getSelectedProviderAccount(); + const codexAccountId = selectedAccount?.provider === 'codex' + ? selectedAccount.accountId + : undefined; + const claudeAccountId = selectedAccount?.provider === 'claude' + ? selectedAccount.accountId + : undefined; + const codexModels = selectedAccount?.provider === 'claude' + ? [] + : await readCodexCredentialsAsync(codexAccountId) + ? await fetchCodexModels({ localAccountId: codexAccountId }).catch(() => []) + : []; + const claudeModels = selectedAccount?.provider === 'codex' + ? [] + : await readClaudeNativeCredentialsAsync(claudeAccountId) + ? await fetchClaudeNativeModels({ localAccountId: claudeAccountId }).catch(() => []) + : []; const seen = new Set(verbooModels.map(model => model.id)); const uniqueCodex = codexModels.filter(model => { if (seen.has(model.id)) return false; diff --git a/src/services/api/authRouting.ts b/src/services/api/authRouting.ts index 7467f9227e..e2b3c70035 100644 --- a/src/services/api/authRouting.ts +++ b/src/services/api/authRouting.ts @@ -4,7 +4,13 @@ import { isFirstPartyAnthropicBaseUrl, } from 'src/utils/model/providers.js' -export type ProviderOverride = { model: string; baseURL: string; apiKey: string } +export type ProviderOverride = { + model: string + baseURL: string + apiKey: string + /** Opaque local account selected for this process, when using provider OAuth. */ + localAccountId?: string +} export function shouldUseFirstPartyAnthropicAuthForProvider({ providerOverride, diff --git a/src/services/api/claudeNativeModels.test.ts b/src/services/api/claudeNativeModels.test.ts index f52b78ab84..efd5ecfaa4 100644 --- a/src/services/api/claudeNativeModels.test.ts +++ b/src/services/api/claudeNativeModels.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test' +import { afterEach, expect, mock, test } from 'bun:test' import { parseClaudeNativeModelsResponse, @@ -59,3 +59,87 @@ test('accepts only exact IDs returned by the Claude Models API', () => { "'anything' não está disponível", ) }) + +afterEach(() => { + mock.restore() +}) + +test('fetches and refreshes models for the explicitly selected local account', async () => { + const readAccountIds: Array = [] + const refreshAccountIds: Array = [] + let requests = 0 + + mock.module('../../utils/claudeNativeCredentials.js', () => ({ + hasCurrentClaudeRiskAcceptance: () => true, + readClaudeNativeCredentialsAsync: async (localAccountId?: string) => { + readAccountIds.push(localAccountId) + if (localAccountId !== 'local-b') { + throw new Error('the selected local account must be passed through') + } + return { + accessToken: requests === 0 ? 'expired-token' : 'fresh-token', + refreshToken: 'refresh-b', + accountId: 'provider-b', + scopes: ['user:inference'], + riskAcceptance: { + version: 1, + acceptedAt: '2026-08-09T12:00:00.000Z', + accountId: 'provider-b', + }, + } + }, + refreshClaudeNativeAccessTokenIfNeeded: async (options?: { localAccountId?: string }) => { + refreshAccountIds.push(options?.localAccountId) + return { + refreshed: true, + credentials: { + accessToken: 'fresh-token', + refreshToken: 'refresh-b', + accountId: 'provider-b', + scopes: ['user:inference'], + riskAcceptance: { + version: 1, + acceptedAt: '2026-08-09T12:00:00.000Z', + accountId: 'provider-b', + }, + }, + } + }, + })) + + const previousFetch = globalThis.fetch + globalThis.fetch = (async () => { + requests += 1 + if (requests === 1) return new Response('expired', { status: 401 }) + return new Response( + JSON.stringify({ + data: [ + { + id: 'claude-account-b', + display_name: 'Account B', + capabilities: { image_input: { supported: true } }, + }, + ], + has_more: false, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + }) as unknown as typeof fetch + + try { + // @ts-expect-error cache-busting query string for Bun module isolation + const { fetchClaudeNativeModels } = await import( + './claudeNativeModels.js?selected-account' + ) + const models = await fetchClaudeNativeModels({ + force: true, + localAccountId: 'local-b', + }) + + expect(models.map(model => model.id)).toEqual(['claude-account-b']) + expect(readAccountIds).toEqual(['local-b']) + expect(refreshAccountIds).toEqual(['local-b']) + } finally { + globalThis.fetch = previousFetch + } +}) diff --git a/src/services/api/claudeNativeModels.ts b/src/services/api/claudeNativeModels.ts index bb50b72460..b578da0cff 100644 --- a/src/services/api/claudeNativeModels.ts +++ b/src/services/api/claudeNativeModels.ts @@ -12,6 +12,8 @@ import { CLAUDE_NATIVE_OAUTH_BETA, } from './claudeNativeConfig.js' import { getVerbooCodeUserAgent } from '../../utils/userAgent.js' +import type { LocalProviderAccountId } from '../../utils/providerAccounts/types.js' +import { getSelectedProviderAccount } from '../../utils/providerAccounts/selection.js' const CACHE_TTL_MS = 5 * 60 * 1_000 @@ -157,39 +159,60 @@ async function requestAllModels( export async function fetchClaudeNativeModels(options?: { force?: boolean + localAccountId?: LocalProviderAccountId credentials?: ClaudeNativeCredentialBlob }): Promise { + const effectiveLocalAccountId = + options?.localAccountId ?? + (getSelectedProviderAccount()?.provider === 'claude' + ? getSelectedProviderAccount()?.accountId + : undefined) const generation = cacheGeneration - let credentials = options?.credentials ?? (await readClaudeNativeCredentialsAsync()) + let credentials = options?.credentials ?? (await readClaudeNativeCredentialsAsync(effectiveLocalAccountId)) if (!credentials || !hasCurrentClaudeRiskAcceptance(credentials)) { throw new Error('Claude não autenticado. Execute `/claude login`.') } - const previous = cacheByAccount.get(credentials.accountId) + const cacheKey = effectiveLocalAccountId ?? `legacy:${credentials.accountId}` + const previous = cacheByAccount.get(cacheKey) if (!options?.force && previous && Date.now() - previous.fetchedAt < CACHE_TTL_MS) { return previous.models } try { const models = await requestAllModels(credentials) if (generation === cacheGeneration) { - cacheByAccount.set(credentials.accountId, { fetchedAt: Date.now(), models }) + cacheByAccount.set(cacheKey, { fetchedAt: Date.now(), models }) } return models } catch (error) { if (options?.credentials || (error as { status?: number }).status !== 401) { throw error } - const refreshed = await refreshClaudeNativeAccessTokenIfNeeded({ force: true }) - credentials = refreshed.credentials ?? (await readClaudeNativeCredentialsAsync()) + const refreshed = await refreshClaudeNativeAccessTokenIfNeeded({ + force: true, + localAccountId: effectiveLocalAccountId, + }) + credentials = refreshed.credentials ?? (await readClaudeNativeCredentialsAsync(effectiveLocalAccountId)) if (!credentials) throw error const models = await requestAllModels(credentials) if (generation === cacheGeneration) { - cacheByAccount.set(credentials.accountId, { fetchedAt: Date.now(), models }) + cacheByAccount.set(cacheKey, { fetchedAt: Date.now(), models }) } return models } } -export function getCachedClaudeNativeModels(): ClaudeNativeModel[] | null { +export function getCachedClaudeNativeModels( + localAccountId?: LocalProviderAccountId, +): ClaudeNativeModel[] | null { + const effectiveLocalAccountId = + localAccountId ?? + (getSelectedProviderAccount()?.provider === 'claude' + ? getSelectedProviderAccount()?.accountId + : undefined) + if (effectiveLocalAccountId) { + const selected = cacheByAccount.get(effectiveLocalAccountId) + if (selected) return selected.models + } for (const entry of cacheByAccount.values()) return entry.models return null } @@ -199,16 +222,20 @@ export function clearClaudeNativeModelsCache(): void { cacheByAccount.clear() } -export function getClaudeNativeModel(modelId: string): ClaudeNativeModel | undefined { - return getCachedClaudeNativeModels()?.find(model => model.id === modelId) +export function getClaudeNativeModel( + modelId: string, + localAccountId?: LocalProviderAccountId, +): ClaudeNativeModel | undefined { + return getCachedClaudeNativeModels(localAccountId)?.find(model => model.id === modelId) } export function getClaudeNativeReasoningEffort( modelId: string, requested: string, + localAccountId?: LocalProviderAccountId, ): string | undefined { const normalized = requested.trim().toLowerCase() - return getClaudeNativeModel(modelId)?.supportedReasoningLevels.find( + return getClaudeNativeModel(modelId, localAccountId)?.supportedReasoningLevels.find( level => level.toLowerCase() === normalized, ) } @@ -228,6 +255,10 @@ export function requireClaudeNativeModel( export async function assertClaudeNativeModelAvailable( modelId: string, + localAccountId?: LocalProviderAccountId, ): Promise { - return requireClaudeNativeModel(await fetchClaudeNativeModels(), modelId) + return requireClaudeNativeModel( + await fetchClaudeNativeModels({ localAccountId }), + modelId, + ) } diff --git a/src/services/api/client.ts b/src/services/api/client.ts index 00a6bb8024..c6146472a5 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -68,6 +68,7 @@ import { shouldUseFirstPartyAnthropicAuth, type ProviderOverride, } from './authRouting.js' +import { getSelectedProviderAccount } from '../../utils/providerAccounts/selection.js' const importRuntimeModule = new Function( 'specifier', @@ -307,6 +308,15 @@ export async function getAnthropicClient({ await assertCLIEntitlement() const requestedModel = model?.trim().replace(/\[1m\]$/i, '') || getDefaultVerbooModel() + const selectedAccount = getSelectedProviderAccount() + const selectedCodexAccountId = + selectedAccount?.provider === 'codex' + ? selectedAccount.accountId + : undefined + const selectedClaudeAccountId = + selectedAccount?.provider === 'claude' + ? selectedAccount.accountId + : undefined const { createOpenAIShimClient } = await import('./openaiShim.js') const verbooModel = getCachedVerbooModels()?.find( @@ -334,8 +344,15 @@ export async function getAnthropicClient({ }) as unknown as Anthropic } - if (getCodexModel(requestedModel)) { - const codexModel = await assertCodexModelAvailable(requestedModel) + const codexModel = + !selectedAccount || selectedAccount.provider === 'codex' + ? getCodexModel(requestedModel, selectedCodexAccountId) + : undefined + if (codexModel) { + const availableCodexModel = await assertCodexModelAvailable( + requestedModel, + selectedCodexAccountId, + ) return createOpenAIShimClient({ defaultHeaders, maxRetries, @@ -343,18 +360,29 @@ export async function getAnthropicClient({ reasoningEffort: shimReasoningEffort, suppressReasoningEffort, providerOverride: { - model: codexModel.id, + model: availableCodexModel.id, baseURL: DEFAULT_CODEX_BASE_URL, apiKey: '', + localAccountId: selectedCodexAccountId, }, }) as unknown as Anthropic } - if (getClaudeNativeModel(requestedModel)) { - await assertClaudeNativeModelAvailable(requestedModel) - const refreshed = await refreshClaudeNativeAccessTokenIfNeeded() + const claudeModel = + !selectedAccount || selectedAccount.provider === 'claude' + ? getClaudeNativeModel(requestedModel, selectedClaudeAccountId) + : undefined + if (claudeModel) { + await assertClaudeNativeModelAvailable( + requestedModel, + selectedClaudeAccountId, + ) + const refreshed = await refreshClaudeNativeAccessTokenIfNeeded({ + localAccountId: selectedClaudeAccountId, + }) const credentials = - refreshed.credentials ?? (await readClaudeNativeCredentialsAsync()) + refreshed.credentials ?? + (await readClaudeNativeCredentialsAsync(selectedClaudeAccountId)) if (!credentials || !hasCurrentClaudeRiskAcceptance(credentials)) { throw new Error( 'Login Claude ausente ou aceite de risco desatualizado. Execute `/claude login`.', @@ -388,6 +416,7 @@ export async function getAnthropicClient({ try { const retry = await refreshClaudeNativeAccessTokenIfNeeded({ force: true, + localAccountId: selectedClaudeAccountId, }) if (!retry.credentials?.accessToken) return response const retryHeaders = new Headers(init?.headers) diff --git a/src/services/api/codexModels.test.ts b/src/services/api/codexModels.test.ts index a8fc0e604f..77720c5e06 100644 --- a/src/services/api/codexModels.test.ts +++ b/src/services/api/codexModels.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test' +import { afterEach, expect, mock, test } from 'bun:test' import { parseCodexModelsResponse, @@ -53,3 +53,74 @@ test('accepts exact API slugs and rejects arbitrary model values', () => { "'anything' não está disponível", ) }) + +afterEach(() => { + mock.restore() +}) + +test('fetches and refreshes models for the explicitly selected local account', async () => { + const readAccountIds: Array = [] + const refreshAccountIds: Array = [] + let requests = 0 + + mock.module('../../utils/codexCredentials.js', () => ({ + readCodexCredentialsAsync: async (localAccountId?: string) => { + readAccountIds.push(localAccountId) + if (localAccountId !== 'local-b') { + throw new Error('the selected local account must be passed through') + } + return { + accessToken: requests === 0 ? 'expired-token' : 'fresh-token', + refreshToken: 'refresh-b', + accountId: 'provider-b', + } + }, + refreshCodexAccessTokenIfNeeded: async (options?: { localAccountId?: string }) => { + refreshAccountIds.push(options?.localAccountId) + return { + refreshed: true, + credentials: { + accessToken: 'fresh-token', + refreshToken: 'refresh-b', + accountId: 'provider-b', + }, + } + }, + })) + + const previousFetch = globalThis.fetch + globalThis.fetch = (async (_input, init) => { + requests += 1 + const accountId = new Headers(init?.headers).get('chatgpt-account-id') + expect(accountId).toBe('provider-b') + if (requests === 1) return new Response('expired', { status: 401 }) + return new Response( + JSON.stringify({ + models: [ + { + slug: 'gpt-codex-account-b', + display_name: 'Account B', + priority: 1, + supported_reasoning_levels: [], + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + }) as unknown as typeof fetch + + try { + // @ts-expect-error cache-busting query string for Bun module isolation + const { fetchCodexModels } = await import('./codexModels.js?selected-account') + const models = await fetchCodexModels({ + force: true, + localAccountId: 'local-b', + }) + + expect(models.map(model => model.id)).toEqual(['gpt-codex-account-b']) + expect(readAccountIds).toEqual(['local-b']) + expect(refreshAccountIds).toEqual(['local-b']) + } finally { + globalThis.fetch = previousFetch + } +}) diff --git a/src/services/api/codexModels.ts b/src/services/api/codexModels.ts index 236ddd1cca..a72f7c0675 100644 --- a/src/services/api/codexModels.ts +++ b/src/services/api/codexModels.ts @@ -8,6 +8,8 @@ import { import { parseChatgptAccountId } from './codexOAuthShared.js' import { DEFAULT_CODEX_BASE_URL } from './providerConfig.js' import { getVerbooCodeUserAgent } from '../../utils/userAgent.js' +import type { LocalProviderAccountId } from '../../utils/providerAccounts/types.js' +import { getSelectedProviderAccount } from '../../utils/providerAccounts/selection.js' const CACHE_TTL_MS = 5 * 60 * 1_000 @@ -148,15 +150,31 @@ async function requestModels( } } -async function loadModels(force: boolean): Promise { +function cacheKey( + localAccountId: LocalProviderAccountId | undefined, + providerAccountId: string, +): string { + return localAccountId ?? `legacy:${providerAccountId}` +} + +async function loadModels( + force: boolean, + localAccountId?: LocalProviderAccountId, +): Promise { + const effectiveLocalAccountId = + localAccountId ?? + (getSelectedProviderAccount()?.provider === 'codex' + ? getSelectedProviderAccount()?.accountId + : undefined) const generation = cacheGeneration - let stored = await readCodexCredentialsAsync() + let stored = await readCodexCredentialsAsync(effectiveLocalAccountId) if (!stored) { throw new Error('Codex não autenticado. Execute `/codex login`.') } const initial = resolveCredentials(stored) - const previous = cacheByAccount.get(initial.accountId) + const selectedCacheKey = cacheKey(effectiveLocalAccountId, initial.accountId) + const previous = cacheByAccount.get(selectedCacheKey) if (!force && previous && Date.now() - previous.fetchedAt < CACHE_TTL_MS) { return previous.models } @@ -164,7 +182,7 @@ async function loadModels(force: boolean): Promise { try { const entry = await requestModels(stored, previous) if (generation === cacheGeneration) { - cacheByAccount.set(initial.accountId, entry) + cacheByAccount.set(selectedCacheKey, entry) } return entry.models } catch (error) { @@ -172,13 +190,15 @@ async function loadModels(force: boolean): Promise { const refreshed = await refreshCodexAccessTokenIfNeeded({ force: true, ignoreEnvironment: true, + localAccountId: effectiveLocalAccountId, }) - stored = refreshed.credentials ?? (await readCodexCredentialsAsync()) + stored = refreshed.credentials ?? (await readCodexCredentialsAsync(effectiveLocalAccountId)) if (!stored) throw error const resolved = resolveCredentials(stored) - const entry = await requestModels(stored, cacheByAccount.get(resolved.accountId)) + const refreshedCacheKey = cacheKey(effectiveLocalAccountId, resolved.accountId) + const entry = await requestModels(stored, cacheByAccount.get(refreshedCacheKey)) if (generation === cacheGeneration) { - cacheByAccount.set(resolved.accountId, entry) + cacheByAccount.set(refreshedCacheKey, entry) } return entry.models } @@ -186,11 +206,23 @@ async function loadModels(force: boolean): Promise { export async function fetchCodexModels(options?: { force?: boolean + localAccountId?: LocalProviderAccountId }): Promise { - return loadModels(options?.force === true) + return loadModels(options?.force === true, options?.localAccountId) } -export function getCachedCodexModels(): CodexModel[] | null { +export function getCachedCodexModels( + localAccountId?: LocalProviderAccountId, +): CodexModel[] | null { + const effectiveLocalAccountId = + localAccountId ?? + (getSelectedProviderAccount()?.provider === 'codex' + ? getSelectedProviderAccount()?.accountId + : undefined) + if (effectiveLocalAccountId) { + const selected = cacheByAccount.get(effectiveLocalAccountId) + if (selected) return selected.models + } for (const entry of cacheByAccount.values()) { return entry.models } @@ -202,13 +234,19 @@ export function clearCodexModelsCache(): void { cacheByAccount.clear() } -export function getCodexModel(modelId: string): CodexModel | undefined { - return getCachedCodexModels()?.find(model => model.id === modelId) +export function getCodexModel( + modelId: string, + localAccountId?: LocalProviderAccountId, +): CodexModel | undefined { + return getCachedCodexModels(localAccountId)?.find(model => model.id === modelId) } -export function getCodexReasoningLevels(modelId: string): string[] { +export function getCodexReasoningLevels( + modelId: string, + localAccountId?: LocalProviderAccountId, +): string[] { return ( - getCodexModel(modelId)?.supportedReasoningLevels.map(level => level.effort) ?? + getCodexModel(modelId, localAccountId)?.supportedReasoningLevels.map(level => level.effort) ?? [] ) } @@ -216,10 +254,11 @@ export function getCodexReasoningLevels(modelId: string): string[] { export function getCodexReasoningEffort( modelId: string, requested: string, + localAccountId?: LocalProviderAccountId, ): string | undefined { const normalized = requested.trim().toLowerCase() const apiValue = normalized === 'max' ? 'xhigh' : normalized - return getCodexReasoningLevels(modelId).find( + return getCodexReasoningLevels(modelId, localAccountId).find( level => level.toLowerCase() === apiValue, ) } @@ -239,7 +278,10 @@ export function requireCodexModel( return match } -export async function assertCodexModelAvailable(model: string): Promise { - const models = await fetchCodexModels() +export async function assertCodexModelAvailable( + model: string, + localAccountId?: LocalProviderAccountId, +): Promise { + const models = await fetchCodexModels({ localAccountId }) return requireCodexModel(models, model) } diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 16f6dadad9..39da149378 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1863,6 +1863,8 @@ type ShimProviderOverride = { model: string baseURL: string apiKey: string + /** Opaque local account selected for this process, when using Codex OAuth. */ + localAccountId?: string /** Reads a credential lazily so a refreshed OAuth token is used by an existing client. */ getApiKey?: () => string /** Returns a replacement credential after an authentication failure. */ @@ -2131,8 +2133,10 @@ class OpenAIShimMessages { } if (request.transport === 'codex_responses' && !isGithubMode) { + const localAccountId = this.providerOverride?.localAccountId const refreshResult = await refreshCodexAccessTokenIfNeeded({ ignoreEnvironment: isVerbooMode(), + localAccountId, }).catch( async (error) => { logForDebugging( @@ -2141,7 +2145,7 @@ class OpenAIShimMessages { ) return { refreshed: false, - credentials: await readCodexCredentialsAsync(), + credentials: await readCodexCredentialsAsync(localAccountId), } }, ) @@ -2200,6 +2204,7 @@ class OpenAIShimMessages { const refreshed = await refreshCodexAccessTokenIfNeeded({ force: true, ignoreEnvironment: isVerbooMode(), + localAccountId, }) if (!refreshed.credentials) throw error const retryCredentials = isVerbooMode() diff --git a/src/services/api/providerConfig.runtimeCodexCredentials.test.ts b/src/services/api/providerConfig.runtimeCodexCredentials.test.ts index c677c71131..169ecd3b14 100644 --- a/src/services/api/providerConfig.runtimeCodexCredentials.test.ts +++ b/src/services/api/providerConfig.runtimeCodexCredentials.test.ts @@ -105,3 +105,31 @@ test('runtime credential resolution avoids sync secure-storage reads when async expect(credentials.apiKey).toBe('stored-access-token') expect(credentials.accountId).toBe('acct_stored') }) + +test('runtime credential resolution can read an explicitly selected local account', async () => { + const readAccountIds: Array = [] + mock.module('../../utils/codexCredentials.js', () => ({ + isCodexRefreshFailureCoolingDown: () => false, + readCodexCredentials: (localAccountId?: string) => { + readAccountIds.push(localAccountId) + return { + accessToken: 'selected-token', + accountId: 'provider-selected', + } + }, + })) + + // @ts-expect-error cache-busting query string for Bun module mocks + const { resolveRuntimeCodexCredentials } = await import( + './providerConfig.js?selected-local-account' + ) + const credentials = resolveRuntimeCodexCredentials({ + env: {} as NodeJS.ProcessEnv, + localAccountId: 'local-selected', + }) + + expect(readAccountIds).toEqual(['local-selected']) + expect(credentials.source).toBe('secure-storage') + expect(credentials.apiKey).toBe('selected-token') + expect(credentials.accountId).toBe('provider-selected') +}) diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index 6d52c518cf..2fc32df3d4 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -9,6 +9,7 @@ import { readCodexCredentials, type CodexCredentialBlob, } from '../../utils/codexCredentials.js' +import type { LocalProviderAccountId } from '../../utils/providerAccounts/types.js' import { logForDebugging } from '../../utils/debug.js' import { isEnvTruthy } from '../../utils/envUtils.js' import { @@ -931,12 +932,18 @@ function resolveEnvOrAuthJsonCodexCredentials( export function resolveRuntimeCodexCredentials(options?: { env?: NodeJS.ProcessEnv + localAccountId?: LocalProviderAccountId storedCredentials?: Pick< CodexCredentialBlob, 'apiKey' | 'accessToken' | 'idToken' | 'accountId' > }): ResolvedCodexCredentials { const env = options?.env ?? process.env + const selectedStoredCredentials = + options?.storedCredentials ?? + (options?.localAccountId + ? readCodexCredentials(options.localAccountId) + : undefined) const explicitCredentials = resolveEnvOrAuthJsonCodexCredentials(env, { explicitAuthPathOnly: true, }) @@ -945,7 +952,8 @@ export function resolveRuntimeCodexCredentials(options?: { ) const hasStoredCredentialsOption = Boolean( options && - Object.prototype.hasOwnProperty.call(options, 'storedCredentials'), + (Object.prototype.hasOwnProperty.call(options, 'storedCredentials') || + options.localAccountId), ) if ( @@ -956,9 +964,9 @@ export function resolveRuntimeCodexCredentials(options?: { return explicitCredentials } - if (options?.storedCredentials?.accessToken) { + if (selectedStoredCredentials?.accessToken) { return resolveStoredCodexCredentials({ - storedCredentials: options.storedCredentials, + storedCredentials: selectedStoredCredentials, envAccountId: asTrimmedString(env.CODEX_ACCOUNT_ID) ?? asTrimmedString(env.CHATGPT_ACCOUNT_ID), diff --git a/src/utils/providerAccounts/selection.test.ts b/src/utils/providerAccounts/selection.test.ts new file mode 100644 index 0000000000..b29e2c658f --- /dev/null +++ b/src/utils/providerAccounts/selection.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' + +import type { SecureStorageData } from '../secureStorage/index.js' + +function codexAccount( + localAccountId: string, + providerSubjectId: string, + accessToken: string, +) { + return { + localAccountId, + providerSubjectId, + displayLabel: `Codex ${localAccountId}`, + credential: { accessToken, accountId: providerSubjectId }, + connectionState: 'connected' as const, + } +} + +function seed(state: SecureStorageData): () => SecureStorageData { + let current = state + mock.module('../secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + name: 'selection-test-storage', + read: () => current, + readAsync: async () => current, + update: (next: SecureStorageData) => { + current = next + return { success: true } + }, + delete: () => true, + }), + })) + return () => current +} + +afterEach(() => { + mock.restore() +}) + +describe('provider account selection', () => { + test('selection rejects an account owned by another provider', async () => { + seed({ + providerAccounts: { + schemaVersion: 1, + codex: { + defaultAccountId: 'local-codex', + accounts: { + 'local-codex': codexAccount('local-codex', 'provider-codex', 'token'), + }, + }, + claude: { accounts: {} }, + }, + }) + + // @ts-expect-error cache-busting query string for Bun module isolation + const { resolveProviderAccountSelection } = await import( + './selection.js?provider-mismatch' + ) + expect(() => + resolveProviderAccountSelection('claude', 'local-codex'), + ).toThrow('provider_account_mismatch') + }) + + test('two process contexts resolve different credentials', async () => { + const readState = seed({ + providerAccounts: { + schemaVersion: 1, + codex: { + defaultAccountId: 'local-a', + accounts: { + 'local-a': codexAccount('local-a', 'provider-a', 'token-a'), + 'local-b': codexAccount('local-b', 'provider-b', 'token-b'), + }, + }, + claude: { accounts: {} }, + }, + }) + + // @ts-expect-error cache-busting query string for Bun module isolation + const { resolveProviderAccountSelection } = await import( + './selection.js?provider-contexts' + ) + expect( + resolveProviderAccountSelection('codex', 'local-a').credential.accessToken, + ).toBe('token-a') + + // A separate process would load a fresh selection module. The storage + // lookup remains the source of truth for the opaque account ID. + expect(readState().providerAccounts?.codex.accounts['local-b']?.credential).toMatchObject({ + accessToken: 'token-b', + }) + }) + + test('process selection is immutable after the first initialization', async () => { + seed({ + providerAccounts: { + schemaVersion: 1, + codex: { + defaultAccountId: 'local-a', + accounts: { + 'local-a': codexAccount('local-a', 'provider-a', 'token-a'), + 'local-b': codexAccount('local-b', 'provider-b', 'token-b'), + }, + }, + claude: { accounts: {} }, + }, + }) + + // @ts-expect-error cache-busting query string for Bun module isolation + const selection = await import('./selection.js?immutable-selection') + expect(selection.initializeProviderAccountSelection('local-a')).toEqual({ + provider: 'codex', + accountId: 'local-a', + }) + expect(() => + selection.initializeProviderAccountSelection('local-b'), + ).toThrow('provider_account_selection_already_initialized') + }) +}) diff --git a/src/utils/providerAccounts/selection.ts b/src/utils/providerAccounts/selection.ts new file mode 100644 index 0000000000..fc3b54a8d9 --- /dev/null +++ b/src/utils/providerAccounts/selection.ts @@ -0,0 +1,47 @@ +import { + resolveProviderAccountByLocalId, + type ResolvedProviderAccount, +} from './store.js' +import type { LocalProviderAccountId, ProviderId } from './types.js' + +export type ProviderAccountSelection = { + provider: ProviderId + accountId: LocalProviderAccountId +} + +let processSelection: ProviderAccountSelection | undefined + +export function resolveProviderAccountSelection( + provider: ProviderId, + accountId: LocalProviderAccountId, +): ResolvedProviderAccount & { + credential: ResolvedProviderAccount['record']['credential'] +} { + const resolved = resolveProviderAccountByLocalId(accountId) + if (!resolved) throw new Error('provider_account_not_found') + if (resolved.provider !== provider) { + throw new Error('provider_account_mismatch') + } + return { ...resolved, credential: resolved.record.credential } +} + +export function initializeProviderAccountSelection( + accountId: LocalProviderAccountId, +): ProviderAccountSelection { + if (processSelection) { + throw new Error('provider_account_selection_already_initialized') + } + const resolved = resolveProviderAccountByLocalId(accountId) + if (!resolved) throw new Error('provider_account_not_found') + processSelection = { + provider: resolved.provider, + accountId: resolved.accountId, + } + return processSelection +} + +export function getSelectedProviderAccount(): + | ProviderAccountSelection + | undefined { + return processSelection +} diff --git a/src/utils/providerAccounts/store.ts b/src/utils/providerAccounts/store.ts index 288f886eb3..36a07b9aa4 100644 --- a/src/utils/providerAccounts/store.ts +++ b/src/utils/providerAccounts/store.ts @@ -494,3 +494,21 @@ export function resolveProviderAccount( const id = localAccountId ?? collection.defaultAccountId return id ? collection.accounts[id] : undefined } + +export type ResolvedProviderAccount = { + provider: ProviderId + accountId: LocalProviderAccountId + record: ProviderAccountRecord +} + +/** Resolve an opaque local ID without allowing the caller to infer provider subjects. */ +export function resolveProviderAccountByLocalId( + localAccountId: LocalProviderAccountId, + data: ProviderAccountsV1 = readProviderAccounts(), +): ResolvedProviderAccount | undefined { + const matches = (['codex', 'claude'] as const).flatMap(provider => { + const record = data[provider].accounts[localAccountId] + return record ? [{ provider, accountId: localAccountId, record }] : [] + }) + return matches.length === 1 ? matches[0] : undefined +} From 3905e692dd3c918e8563cdb6b5d279e4101e082a Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Sun, 9 Aug 2026 19:40:29 -0300 Subject: [PATCH 06/15] feat(providers): normalize account usage windows --- src/components/useClaudeNativeOAuthFlow.ts | 2 + src/services/api/claudeNativeOAuth.test.ts | 20 +- src/services/api/claudeNativeOAuth.ts | 60 +++- src/services/api/claudeNativeUsage.test.ts | 25 ++ src/services/api/claudeNativeUsage.ts | 71 ++++- src/services/api/codexUsage.ts | 11 +- .../api/providerUsageProtocol.test.ts | 156 ++++++++++ src/services/api/providerUsageProtocol.ts | 272 ++++++++++++++++++ src/services/api/usage.ts | 7 + src/utils/providerAccounts/credentials.ts | 4 + src/utils/providerAccounts/store.ts | 10 +- src/utils/providerAccounts/types.ts | 18 ++ 12 files changed, 642 insertions(+), 14 deletions(-) create mode 100644 src/services/api/providerUsageProtocol.test.ts create mode 100644 src/services/api/providerUsageProtocol.ts diff --git a/src/components/useClaudeNativeOAuthFlow.ts b/src/components/useClaudeNativeOAuthFlow.ts index a0a8c08ac9..0e68444417 100644 --- a/src/components/useClaudeNativeOAuthFlow.ts +++ b/src/components/useClaudeNativeOAuthFlow.ts @@ -95,6 +95,8 @@ export function useClaudeNativeOAuthFlow(options: { accountId: tokens.accountId, email: tokens.email, organizationId: tokens.organizationId, + planId: tokens.planId, + planDisplayName: tokens.planDisplayName, riskAcceptance: { version: CLAUDE_RISK_NOTICE_VERSION, acceptedAt: options.acceptedAt, diff --git a/src/services/api/claudeNativeOAuth.test.ts b/src/services/api/claudeNativeOAuth.test.ts index 81d6b80762..3ffbab2b27 100644 --- a/src/services/api/claudeNativeOAuth.test.ts +++ b/src/services/api/claudeNativeOAuth.test.ts @@ -10,8 +10,21 @@ import { import { buildClaudeNativeAuthorizeUrl, ClaudeNativeOAuthService, + normalizeClaudePlanHint, } from './claudeNativeOAuth.js' +test('accepts only explicit Pro or Max plan hints from the authenticated profile', () => { + expect(normalizeClaudePlanHint({ id: 'max', display_name: 'Max' })).toEqual({ + id: 'max', + displayName: 'Max', + }) + expect(normalizeClaudePlanHint({ tier: 'pro' })).toEqual({ + id: 'pro', + displayName: 'Pro', + }) + expect(normalizeClaudePlanHint({ name: 'enterprise' })).toBeUndefined() +}) + test('uses only the fixed native Claude OAuth endpoint and PKCE callback', () => { const value = buildClaudeNativeAuthorizeUrl({ host: '127.0.0.1', @@ -90,7 +103,10 @@ test('exchanges state-bound tokens and serves a local Verboo completion page', a }) } if (url === `${CLAUDE_NATIVE_API_BASE_URL}/api/oauth/profile`) { - return Response.json({ account: { uuid: 'account-1' } }) + return Response.json({ + account: { uuid: 'account-1' }, + plan: { id: 'max', display_name: 'Max' }, + }) } return new Response('unexpected request', { status: 500 }) }) as unknown as typeof fetch @@ -109,6 +125,8 @@ test('exchanges state-bound tokens and serves a local Verboo completion page', a const tokenForm = new URLSearchParams(requests[0]?.body) expect(tokens.accountId).toBe('account-1') + expect(tokens.planId).toBe('max') + expect(tokens.planDisplayName).toBe('Max') expect(requests.map(request => request.url)).toEqual([ CLAUDE_NATIVE_TOKEN_URL, `${CLAUDE_NATIVE_API_BASE_URL}/api/oauth/profile`, diff --git a/src/services/api/claudeNativeOAuth.ts b/src/services/api/claudeNativeOAuth.ts index f9f82acbfa..903ad7cdf8 100644 --- a/src/services/api/claudeNativeOAuth.ts +++ b/src/services/api/claudeNativeOAuth.ts @@ -20,11 +20,20 @@ type NativeTokenResponse = { scope?: string account?: { uuid?: string; email_address?: string } organization?: { uuid?: string } + plan?: unknown + subscription?: unknown } type NativeProfileResponse = { account?: { uuid?: string; email?: string; email_address?: string } - organization?: { uuid?: string } + organization?: { uuid?: string; plan?: unknown; subscription?: unknown } + plan?: unknown + subscription?: unknown +} + +export type ClaudePlanHint = { + id: 'pro' | 'max' + displayName: 'Pro' | 'Max' } export type ClaudeNativeOAuthTokens = { @@ -35,6 +44,8 @@ export type ClaudeNativeOAuthTokens = { accountId: string email?: string organizationId?: string + planId?: ClaudePlanHint['id'] + planDisplayName?: ClaudePlanHint['displayName'] } type Listener = Pick< @@ -57,6 +68,45 @@ function trimmed(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined } +export function normalizeClaudePlanHint(value: unknown): ClaudePlanHint | undefined { + const candidates = typeof value === 'string' + ? [value] + : value && typeof value === 'object' + ? [ + (value as Record).id, + (value as Record).plan_id, + (value as Record).planId, + (value as Record).tier, + (value as Record).type, + (value as Record).plan_type, + (value as Record).planType, + (value as Record).subscription_type, + (value as Record).subscriptionType, + (value as Record).name, + (value as Record).display_name, + (value as Record).displayName, + ] + : [] + for (const candidate of candidates) { + const normalized = trimmed(candidate)?.toLowerCase().replaceAll('_', '-').replaceAll(' ', '-') + if (normalized === 'pro' || normalized === 'claude-pro') { + return { id: 'pro', displayName: 'Pro' } + } + if (normalized === 'max' || normalized === 'claude-max') { + return { id: 'max', displayName: 'Max' } + } + } + return undefined +} + +function profilePlanHint(profile: NativeProfileResponse | undefined): ClaudePlanHint | undefined { + if (!profile) return undefined + return normalizeClaudePlanHint(profile.plan) ?? + normalizeClaudePlanHint(profile.subscription) ?? + normalizeClaudePlanHint(profile.organization?.plan) ?? + normalizeClaudePlanHint(profile.organization?.subscription) +} + function escapeHtml(value: string): string { return value .replaceAll('&', '&') @@ -181,6 +231,14 @@ async function exchangeCode(options: { organizationId: trimmed(payload.organization?.uuid) ?? trimmed(profile?.organization?.uuid), + planId: + profilePlanHint(profile)?.id ?? + normalizeClaudePlanHint(payload.plan)?.id ?? + normalizeClaudePlanHint(payload.subscription)?.id, + planDisplayName: + profilePlanHint(profile)?.displayName ?? + normalizeClaudePlanHint(payload.plan)?.displayName ?? + normalizeClaudePlanHint(payload.subscription)?.displayName, } } diff --git a/src/services/api/claudeNativeUsage.test.ts b/src/services/api/claudeNativeUsage.test.ts index 2bb7dd6cb8..c0b3090236 100644 --- a/src/services/api/claudeNativeUsage.test.ts +++ b/src/services/api/claudeNativeUsage.test.ts @@ -69,4 +69,29 @@ describe('Claude native usage helpers', () => { 'anthropic-beta': 'oauth-2025-04-20', }) }) + + test('retains only explicitly reported model-scoped weekly limits', () => { + const usage = normalizeClaudeNativeUsagePayload({ + limits: [ + { + id: 'fable', + model_scope: 'fable', + window_seconds: 604_800, + utilization: 25, + resets_at: '2026-08-16T20:00:00.000Z', + }, + { id: 'malformed', model_scope: 'spark', utilization: 'unknown' }, + ], + }) + + expect(usage.scoped_limits).toEqual([ + { + id: 'fable', + modelScope: 'fable', + utilization: 25, + windowMinutes: 10_080, + resetsAt: '2026-08-16T20:00:00.000Z', + }, + ]) + }) }) diff --git a/src/services/api/claudeNativeUsage.ts b/src/services/api/claudeNativeUsage.ts index 7d3fbc6ea9..f85160f99f 100644 --- a/src/services/api/claudeNativeUsage.ts +++ b/src/services/api/claudeNativeUsage.ts @@ -20,6 +20,14 @@ export type ClaudeNativeUsageRow = { type RecordLike = Record +export type ClaudeNativeScopedUsage = { + id: string + modelScope: string + utilization: number + windowMinutes?: number + resetsAt?: string +} + function isRecord(value: unknown): value is RecordLike { return typeof value === 'object' && value !== null } @@ -36,6 +44,38 @@ function asString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined } +function normalizeScopedUsage(value: unknown): ClaudeNativeScopedUsage[] { + if (!Array.isArray(value)) return [] + return value.flatMap(item => { + if (!isRecord(item)) return [] + const id = asString(item.id) ?? asString(item.limit_id) ?? asString(item.limitId) + const modelScope = + asString(item.model_scope) ?? + asString(item.modelScope) ?? + asString(item.scope) + const utilization = + asNumber(item.utilization) ?? + asNumber(item.used_percentage) ?? + asNumber(item.usedPercent) + if (!id || !modelScope || utilization === undefined) return [] + const windowMinutes = + asNumber(item.window_minutes) ?? + asNumber(item.windowMinutes) ?? + (() => { + const seconds = + asNumber(item.window_seconds) ?? asNumber(item.windowSeconds) + return seconds === undefined ? undefined : Math.round(seconds / 60) + })() + return [{ + id, + modelScope, + utilization, + windowMinutes, + resetsAt: asString(item.resets_at) ?? asString(item.resetsAt), + }] + }) +} + function field( record: RecordLike, snakeCase: string, @@ -76,7 +116,7 @@ function normalizeExtraUsage(value: unknown): ExtraUsage | null | undefined { export function normalizeClaudeNativeUsagePayload(payload: unknown): Utilization { if (!isRecord(payload)) return {} - return { + const usage: Utilization = { five_hour: normalizeRateLimit(field(payload, 'five_hour', 'fiveHour')), seven_day: normalizeRateLimit(field(payload, 'seven_day', 'sevenDay')), seven_day_oauth_apps: normalizeRateLimit( @@ -90,6 +130,14 @@ export function normalizeClaudeNativeUsagePayload(payload: unknown): Utilization ), extra_usage: normalizeExtraUsage(field(payload, 'extra_usage', 'extraUsage')), } + const scoped = normalizeScopedUsage( + field(payload, 'limits', 'limits') ?? + field(payload, 'scoped_limits', 'scopedLimits'), + ) + if (scoped.length) { + usage.scoped_limits = scoped + } + return usage } export function buildClaudeNativeUsageRows( @@ -162,8 +210,12 @@ function usageError(status: number, body: string): Error { ) } -export async function fetchClaudeNativeUsage(): Promise { - const refreshResult = await refreshClaudeNativeAccessTokenIfNeeded().catch( +export async function fetchClaudeNativeUsage(options?: { + localAccountId?: string +}): Promise { + const refreshResult = await refreshClaudeNativeAccessTokenIfNeeded({ + localAccountId: options?.localAccountId, + }).catch( async error => { logForDebugging( `[claude] access token refresh failed before usage fetch: ${error instanceof Error ? error.message : String(error)}`, @@ -171,21 +223,26 @@ export async function fetchClaudeNativeUsage(): Promise { ) return { refreshed: false, - credentials: await readClaudeNativeCredentialsAsync(), + credentials: await readClaudeNativeCredentialsAsync(options?.localAccountId), } }, ) let credentials = - refreshResult.credentials ?? (await readClaudeNativeCredentialsAsync()) + refreshResult.credentials ?? + (await readClaudeNativeCredentialsAsync(options?.localAccountId)) if (!credentials || !hasCurrentClaudeRiskAcceptance(credentials)) { throw new Error('Claude auth is required. Execute /claude login.') } let response = await requestClaudeNativeUsage(credentials) if (response.status === 401) { - const retried = await refreshClaudeNativeAccessTokenIfNeeded({ force: true }) + const retried = await refreshClaudeNativeAccessTokenIfNeeded({ + force: true, + localAccountId: options?.localAccountId, + }) credentials = - retried.credentials ?? (await readClaudeNativeCredentialsAsync()) + retried.credentials ?? + (await readClaudeNativeCredentialsAsync(options?.localAccountId)) if (!credentials || !hasCurrentClaudeRiskAcceptance(credentials)) { throw usageError(response.status, response.body) } diff --git a/src/services/api/codexUsage.ts b/src/services/api/codexUsage.ts index eaa38ceb6b..6a9ee5a859 100644 --- a/src/services/api/codexUsage.ts +++ b/src/services/api/codexUsage.ts @@ -398,8 +398,12 @@ export function getCodexUsageUrl(baseUrl = DEFAULT_CODEX_BASE_URL): string { return new URL('/backend-api/wham/usage', baseUrl).toString() } -export async function fetchCodexUsage(): Promise { - const refreshResult = await refreshCodexAccessTokenIfNeeded().catch( +export async function fetchCodexUsage(options?: { + localAccountId?: string +}): Promise { + const refreshResult = await refreshCodexAccessTokenIfNeeded({ + localAccountId: options?.localAccountId, + }).catch( async error => { logForDebugging( `[codex] access token refresh failed before usage fetch: ${error instanceof Error ? error.message : String(error)}`, @@ -407,7 +411,7 @@ export async function fetchCodexUsage(): Promise { ) return { refreshed: false, - credentials: await readCodexCredentialsAsync(), + credentials: await readCodexCredentialsAsync(options?.localAccountId), } }, ) @@ -426,6 +430,7 @@ export async function fetchCodexUsage(): Promise { const credentials = resolveRuntimeCodexCredentials({ storedCredentials: refreshResult.credentials, + localAccountId: options?.localAccountId, }) if (!credentials.apiKey) { // VERBOO-BRAND: /provider command unregistered; route users to admin diff --git a/src/services/api/providerUsageProtocol.test.ts b/src/services/api/providerUsageProtocol.test.ts new file mode 100644 index 0000000000..33758f3288 --- /dev/null +++ b/src/services/api/providerUsageProtocol.test.ts @@ -0,0 +1,156 @@ +import { afterEach, expect, mock, test } from 'bun:test' + +import { + normalizeClaudeProviderUsage, + normalizeCodexProviderUsage, +} from './providerUsageProtocol.js' + +afterEach(() => { + mock.restore() +}) + +test('Codex Plus keeps only its provider-reported base weekly window', () => { + const snapshot = normalizeCodexProviderUsage('local-plus', { + plan_type: 'plus', + rate_limit: { + primary_window: { used_percent: 38, limit_window_seconds: 18_000 }, + secondary_window: { + used_percent: 32, + limit_window_seconds: 604_800, + reset_at: 1_775_685_041, + }, + }, + }) + + expect(snapshot.plan).toEqual({ id: 'plus', displayName: 'Plus' }) + expect(snapshot.windows).toEqual([ + { + id: 'codex:secondary', + kind: 'weekly', + displayLabel: 'Weekly', + usedPercent: 32, + resetsAt: '2026-04-08T21:50:41.000Z', + }, + ]) +}) + +test('Claude Pro has no Fable row while Max retains a reported scoped row', () => { + const pro = normalizeClaudeProviderUsage( + 'local-pro', + { id: 'pro', displayName: 'Pro' }, + { + five_hour: { utilization: 15 }, + seven_day: { utilization: 20 }, + }, + ) + const max = normalizeClaudeProviderUsage( + 'local-max', + { id: 'max', displayName: 'Max' }, + { + five_hour: { utilization: 10 }, + seven_day: { utilization: 30 }, + limits: [ + { + id: 'fable', + model_scope: 'fable', + window_seconds: 604_800, + utilization: 25, + }, + ], + }, + ) + + expect(pro.windows.map(window => window.kind)).toEqual(['session', 'weekly']) + expect(max.windows.at(-1)).toMatchObject({ + kind: 'model-scoped-weekly', + modelScope: 'fable', + usedPercent: 25, + }) +}) + +test('normalization drops malformed or missing-reset windows without inventing values', () => { + const snapshot = normalizeClaudeProviderUsage( + 'local-unknown', + undefined, + { + five_hour: { utilization: 'not-a-number' }, + seven_day: { utilization: 50 }, + limits: [{ id: 'fable', utilization: 80 }], + }, + ) + + expect(snapshot.windows).toEqual([ + { + id: 'claude:weekly', + kind: 'weekly', + displayLabel: 'Weekly', + usedPercent: 50, + }, + ]) +}) + +test('fetchProviderUsage refreshes only the requested account and returns a sanitized snapshot', async () => { + const requested: Array<{ provider: string; accountId: string }> = [] + mock.module('../../utils/providerAccounts/store.js', () => ({ + resolveProviderAccount: (provider: string, accountId: string) => { + requested.push({ provider, accountId }) + return { + localAccountId: accountId, + providerSubjectId: 'provider-secret', + displayLabel: 'Codex 2', + credential: { accessToken: 'token-secret', accountId: 'provider-secret' }, + connectionState: 'connected', + } + }, + })) + mock.module('./codexUsage.js', () => ({ + fetchCodexUsage: async (options?: { localAccountId?: string }) => { + expect(options?.localAccountId).toBe('local-b') + return { + planType: 'plus', + snapshots: [{ + limitName: 'codex', + secondary: { usedPercent: 42, windowMinutes: 10_080 }, + }], + } + }, + normalizeCodexUsagePayload: () => ({ snapshots: [] }), + })) + + // @ts-expect-error cache-busting query string for Bun module mocks + const { fetchProviderUsage } = await import('./providerUsageProtocol.js?fetch-selected-account') + const snapshot = await fetchProviderUsage('codex', 'local-b') + + expect(requested).toEqual([{ provider: 'codex', accountId: 'local-b' }]) + expect(snapshot).toMatchObject({ + provider: 'codex', + accountId: 'local-b', + windows: [{ usedPercent: 42 }], + }) + expect(JSON.stringify(snapshot)).not.toContain('token-secret') + expect(JSON.stringify(snapshot)).not.toContain('provider-secret') +}) + +test('fetchProviderUsage converts timeout failures to a stable code', async () => { + mock.module('../../utils/providerAccounts/store.js', () => ({ + resolveProviderAccount: () => ({ + localAccountId: 'local-a', + providerSubjectId: 'provider-a', + displayLabel: 'Codex 1', + credential: { accessToken: 'token', accountId: 'provider-a' }, + connectionState: 'connected', + }), + })) + mock.module('./codexUsage.js', () => ({ + fetchCodexUsage: async () => { + throw new Error('request timed out') + }, + normalizeCodexUsagePayload: () => ({ snapshots: [] }), + })) + + // @ts-expect-error cache-busting query string for Bun module mocks + const { fetchProviderUsage } = await import('./providerUsageProtocol.js?fetch-timeout') + await expect(fetchProviderUsage('codex', 'local-a')).rejects.toMatchObject({ + code: 'provider_usage_timeout', + }) +}) diff --git a/src/services/api/providerUsageProtocol.ts b/src/services/api/providerUsageProtocol.ts new file mode 100644 index 0000000000..e0beaf27c1 --- /dev/null +++ b/src/services/api/providerUsageProtocol.ts @@ -0,0 +1,272 @@ +import { + fetchClaudeNativeUsage, + type ClaudeNativeScopedUsage, +} from './claudeNativeUsage.js' +import { + fetchCodexUsage, + normalizeCodexUsagePayload, + type CodexUsageData, + type CodexUsageSnapshot, + type CodexUsageWindow, +} from './codexUsage.js' +import { resolveProviderAccount } from '../../utils/providerAccounts/store.js' +import type { + ClaudeNativeCredentialBlob, + CodexCredentialBlob, +} from '../../utils/providerAccounts/credentials.js' +import type { + LocalProviderAccountId, + ProviderAccountRecord, + ProviderId, + ProviderUsageSnapshotV1, + ProviderUsageWindowV1, +} from '../../utils/providerAccounts/types.js' +import type { Utilization } from './usage.js' +import { normalizeClaudeNativeUsagePayload } from './claudeNativeUsage.js' + +type PlanHint = { id: string; displayName: string } +type ProviderAccountCredential = CodexCredentialBlob | ClaudeNativeCredentialBlob + +function asPlan(id: unknown, displayName?: unknown): PlanHint | undefined { + if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/i.test(id.trim())) { + return undefined + } + const normalizedId = id.trim().toLowerCase() + const label = + typeof displayName === 'string' && displayName.trim() + ? displayName.trim() + : normalizedId + .split(/[_-]+/) + .filter(Boolean) + .map(part => `${part[0]!.toUpperCase()}${part.slice(1).toLowerCase()}`) + .join(' ') + return { id: normalizedId, displayName: label } +} + +function percent(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? Math.min(100, Math.max(0, value)) + : undefined +} + +function weeklyWindow( + snapshot: CodexUsageSnapshot, +): { source: 'primary' | 'secondary'; window: CodexUsageWindow } | undefined { + if (snapshot.secondary?.windowMinutes === 10_080) { + return { source: 'secondary', window: snapshot.secondary } + } + if (snapshot.primary?.windowMinutes === 10_080) { + return { source: 'primary', window: snapshot.primary } + } + return undefined +} + +function scopeLabel(value: string): string { + return value + .split(/[_:-]+/) + .filter(Boolean) + .map(part => `${part[0]!.toUpperCase()}${part.slice(1).toLowerCase()}`) + .join(' ') +} + +function codexUsageData(payload: unknown): CodexUsageData { + if ( + payload && + typeof payload === 'object' && + Array.isArray((payload as Record).snapshots) + ) { + return payload as CodexUsageData + } + return normalizeCodexUsagePayload(payload) +} + +export function normalizeCodexProviderUsage( + accountId: LocalProviderAccountId, + payload: unknown, +): ProviderUsageSnapshotV1 { + const usage = codexUsageData(payload) + const base = usage.snapshots.find(snapshot => { + const name = snapshot.limitName.trim().toLowerCase() + return name === 'codex' || name === 'base' + }) + const windows: ProviderUsageWindowV1[] = [] + const baseWeekly = base ? weeklyWindow(base) : undefined + if (baseWeekly) { + const usedPercent = percent(baseWeekly.window.usedPercent) + if (usedPercent !== undefined) { + windows.push({ + id: `codex:${baseWeekly.source}`, + kind: 'weekly', + displayLabel: 'Weekly', + usedPercent, + resetsAt: baseWeekly.window.resetsAt, + }) + } + } + + for (const snapshot of usage.snapshots) { + const name = snapshot.limitName.trim().toLowerCase() + if (!name || name === 'codex' || name === 'base' || name === 'code review') { + continue + } + const scoped = weeklyWindow(snapshot) + if (!scoped) continue + const usedPercent = percent(scoped.window.usedPercent) + if (usedPercent === undefined) continue + windows.push({ + id: `codex:${name.replace(/[^a-z0-9_-]+/gi, '-')}`, + kind: 'model-scoped-weekly', + displayLabel: `${scopeLabel(snapshot.limitName)} Weekly`, + modelScope: name, + usedPercent, + resetsAt: scoped.window.resetsAt, + }) + } + + return { + schemaVersion: 1, + provider: 'codex', + accountId, + plan: asPlan(usage.planType), + windows, + fetchedAt: new Date().toISOString(), + } +} + +function claudeRateWindow( + id: string, + kind: ProviderUsageWindowV1['kind'], + displayLabel: string, + value: { utilization: number | null; resets_at: string | null } | null | undefined, +): ProviderUsageWindowV1 | undefined { + const usedPercent = percent(value?.utilization) + if (usedPercent === undefined) return undefined + return { + id, + kind, + displayLabel, + usedPercent, + resetsAt: value?.resets_at ?? undefined, + } +} + +function claudeUsageData(payload: unknown): Utilization { + if ( + payload && + typeof payload === 'object' && + ('scoped_limits' in payload || + (('five_hour' in payload || 'seven_day' in payload) && + !('limits' in payload))) + ) { + return payload as Utilization + } + return normalizeClaudeNativeUsagePayload(payload) +} + +function scopedClaudeWindows( + values: ClaudeNativeScopedUsage[] | undefined, +): ProviderUsageWindowV1[] { + if (!values) return [] + return values.flatMap(value => { + if (value.windowMinutes !== 10_080) return [] + const usedPercent = percent(value.utilization) + if (usedPercent === undefined) return [] + const scope = value.modelScope.trim() + if (!scope) return [] + return [ + { + id: `claude:${value.id}`, + kind: 'model-scoped-weekly' as const, + displayLabel: `${scopeLabel(scope)} Weekly`, + modelScope: scope, + usedPercent, + resetsAt: value.resetsAt, + }, + ] + }) +} + +export function normalizeClaudeProviderUsage( + accountId: LocalProviderAccountId, + plan: PlanHint | undefined, + payload: unknown, +): ProviderUsageSnapshotV1 { + const usage = claudeUsageData(payload) + const windows = [ + claudeRateWindow('claude:five-hour', 'session', '5 hours', usage.five_hour), + claudeRateWindow('claude:weekly', 'weekly', 'Weekly', usage.seven_day), + ...scopedClaudeWindows(usage.scoped_limits), + ].filter((window): window is ProviderUsageWindowV1 => window !== undefined) + return { + schemaVersion: 1, + provider: 'claude', + accountId, + plan, + windows, + fetchedAt: new Date().toISOString(), + } +} + +function accountPlan( + account: ProviderAccountRecord, +): PlanHint | undefined { + const credential = account.credential + return asPlan( + account.planId ?? ('planId' in credential ? credential.planId : undefined), + account.planDisplayName ?? + ('planDisplayName' in credential ? credential.planDisplayName : undefined), + ) +} + +function usageError(code: string, message: string): Error { + return Object.assign(new Error(message), { code }) +} + +function sanitizeUsageError(error: unknown): Error { + const message = error instanceof Error ? error.message : String(error) + const lower = message.toLowerCase() + if ( + lower.includes('timeout') || + lower.includes('timed out') || + (error instanceof Error && + (error.name === 'AbortError' || error.name === 'TimeoutError')) + ) { + return usageError('provider_usage_timeout', 'O provedor demorou para responder.') + } + if ( + lower.includes('auth') || + lower.includes('login') || + lower.includes('401') || + lower.includes('sessão') + ) { + return usageError('provider_auth_required', 'A conta precisa ser conectada novamente.') + } + return usageError( + 'provider_usage_unavailable', + 'O provedor não informou a cota neste momento.', + ) +} + +export async function fetchProviderUsage( + provider: ProviderId, + accountId: LocalProviderAccountId, +): Promise { + const account = resolveProviderAccount(provider, accountId) + if (!account) throw usageError('provider_account_not_found', 'Conta não encontrada.') + try { + if (provider === 'codex') { + return normalizeCodexProviderUsage( + accountId, + await fetchCodexUsage({ localAccountId: accountId }), + ) + } + return normalizeClaudeProviderUsage( + accountId, + accountPlan(account), + await fetchClaudeNativeUsage({ localAccountId: accountId }), + ) + } catch (error) { + if (error instanceof Error && 'code' in error) throw error + throw sanitizeUsageError(error) + } +} diff --git a/src/services/api/usage.ts b/src/services/api/usage.ts index 6e2e106e24..e787eeb95f 100644 --- a/src/services/api/usage.ts +++ b/src/services/api/usage.ts @@ -28,6 +28,13 @@ export type Utilization = { seven_day_opus?: RateLimit | null seven_day_sonnet?: RateLimit | null extra_usage?: ExtraUsage | null + scoped_limits?: Array<{ + id: string + modelScope: string + utilization: number + windowMinutes?: number + resetsAt?: string + }> } export async function fetchUtilization(): Promise { diff --git a/src/utils/providerAccounts/credentials.ts b/src/utils/providerAccounts/credentials.ts index 31375851e7..83b624f7b3 100644 --- a/src/utils/providerAccounts/credentials.ts +++ b/src/utils/providerAccounts/credentials.ts @@ -25,6 +25,8 @@ export type ClaudeNativeCredentialBlob = { accountId: string email?: string organizationId?: string + planId?: string + planDisplayName?: string riskAcceptance: ClaudeRiskAcceptance lastRefreshAt?: number lastRefreshFailureAt?: number @@ -65,6 +67,8 @@ export function normalizeClaudeNativeCredentials( accountId, email: asTrimmedString(record.email), organizationId: asTrimmedString(record.organizationId), + planId: asTrimmedString(record.planId), + planDisplayName: asTrimmedString(record.planDisplayName), riskAcceptance, lastRefreshAt: finiteNumber(record.lastRefreshAt), lastRefreshFailureAt: finiteNumber(record.lastRefreshFailureAt), diff --git a/src/utils/providerAccounts/store.ts b/src/utils/providerAccounts/store.ts index 36a07b9aa4..34df941807 100644 --- a/src/utils/providerAccounts/store.ts +++ b/src/utils/providerAccounts/store.ts @@ -372,8 +372,14 @@ export function upsertProviderAccount( displayLabel: existing?.displayLabel ?? nextDisplayLabel(provider, collection), credential: normalized, connectionState: 'connected', - planId: existing?.planId, - planDisplayName: existing?.planDisplayName, + planId: + (provider === 'claude' && 'planId' in normalized + ? normalized.planId + : undefined) ?? existing?.planId, + planDisplayName: + (provider === 'claude' && 'planDisplayName' in normalized + ? normalized.planDisplayName + : undefined) ?? existing?.planDisplayName, lastValidatedAt: existing?.lastValidatedAt, } collection.accounts[localAccountId] = account as never diff --git a/src/utils/providerAccounts/types.ts b/src/utils/providerAccounts/types.ts index a470a3d784..a461ea5c19 100644 --- a/src/utils/providerAccounts/types.ts +++ b/src/utils/providerAccounts/types.ts @@ -28,3 +28,21 @@ export type ProviderAccountsV1 = { codex: ProviderAccountCollection claude: ProviderAccountCollection } + +export type ProviderUsageWindowV1 = { + id: string + kind: 'session' | 'weekly' | 'model-scoped-weekly' | 'unknown' + displayLabel: string + modelScope?: string + usedPercent: number + resetsAt?: string +} + +export type ProviderUsageSnapshotV1 = { + schemaVersion: 1 + provider: ProviderId + accountId: LocalProviderAccountId + plan?: { id: string; displayName: string } + windows: ProviderUsageWindowV1[] + fetchedAt: string +} From af6256db482039a0b1af94ad1798ee27151542dd Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Sun, 9 Aug 2026 19:45:24 -0300 Subject: [PATCH 07/15] feat(providers): expose account usage protocol --- src/cli/handlers/providerAccounts.test.ts | 178 ++++++++++++++++++ src/cli/handlers/providerAccounts.ts | 147 +++++++++++++++ src/main.tsx | 48 +++++ src/services/api/claudeNativeModels.test.ts | 4 +- ...iderConfig.runtimeCodexCredentials.test.ts | 8 +- src/utils/providerAccounts/selection.test.ts | 8 +- 6 files changed, 378 insertions(+), 15 deletions(-) create mode 100644 src/cli/handlers/providerAccounts.test.ts create mode 100644 src/cli/handlers/providerAccounts.ts diff --git a/src/cli/handlers/providerAccounts.test.ts b/src/cli/handlers/providerAccounts.test.ts new file mode 100644 index 0000000000..60d3133671 --- /dev/null +++ b/src/cli/handlers/providerAccounts.test.ts @@ -0,0 +1,178 @@ +import { afterEach, expect, mock, test } from 'bun:test' + +import type { ProviderAccountsV1 } from '../../utils/providerAccounts/types.js' + +afterEach(() => { + mock.restore() +}) + +function accountState(): ProviderAccountsV1 { + return { + schemaVersion: 1, + codex: { + defaultAccountId: 'local-a', + accounts: { + 'local-a': { + localAccountId: 'local-a', + providerSubjectId: 'provider-secret', + displayLabel: 'Codex 1', + credential: { accessToken: 'token-secret', accountId: 'provider-secret' }, + connectionState: 'connected', + }, + }, + }, + claude: { accounts: {} }, + } +} + +test('list returns only sanitized account fields', async () => { + const state = accountState() + mock.module('../../utils/providerAccounts/store.js', () => ({ + listProviderAccountSummaries: () => [{ + provider: 'codex', + accountId: 'local-a', + displayLabel: 'Codex 1', + isDefault: true, + connectionState: 'connected', + }], + readProviderAccounts: () => state, + resolveProviderAccount: () => state.codex.accounts['local-a'], + resolveProviderAccountByLocalId: () => undefined, + normalizeProviderAccounts: () => undefined, + removeProviderAccount: () => {}, + setDefaultProviderAccount: () => {}, + upsertProviderAccount: () => ({ localAccountId: 'local-a', created: false }), + })) + + // @ts-expect-error cache-busting query string for Bun module mocks + const { runProviderAccountsCommand } = await import('./providerAccounts.js?list') + const output = await runProviderAccountsCommand(['list'], { + ensureAuthenticated: async () => {}, + }) + + expect(output).toEqual({ + schemaVersion: 1, + ok: true, + data: { + protocols: ['provider_accounts_v1', 'provider_usage_v1'], + accounts: [{ + schemaVersion: 1, + provider: 'codex', + accountId: 'local-a', + displayLabel: 'Codex 1', + planId: undefined, + planDisplayName: undefined, + isDefault: true, + connectionState: 'connected', + lastValidatedAt: undefined, + }], + }, + }) + expect(JSON.stringify(output)).not.toContain('token-secret') + expect(JSON.stringify(output)).not.toContain('provider-secret') +}) + +test('unknown account fails closed with a stable code', async () => { + mock.module('../../utils/providerAccounts/store.js', () => ({ + readProviderAccounts: () => accountState(), + resolveProviderAccount: () => undefined, + resolveProviderAccountByLocalId: () => undefined, + listProviderAccountSummaries: () => [], + normalizeProviderAccounts: () => undefined, + removeProviderAccount: () => {}, + setDefaultProviderAccount: () => {}, + upsertProviderAccount: () => ({ localAccountId: 'local-a', created: false }), + })) + + // @ts-expect-error cache-busting query string for Bun module mocks + const { runProviderAccountsCommand } = await import('./providerAccounts.js?missing') + await expect( + runProviderAccountsCommand( + ['usage', '--provider', 'codex', '--account', 'missing'], + { ensureAuthenticated: async () => {} }, + ), + ).resolves.toMatchObject({ + ok: false, + error: { code: 'provider_account_not_found' }, + }) +}) + +test('authentication is checked before the vault is read', async () => { + let readCalled = false + mock.module('../../utils/providerAccounts/store.js', () => ({ + readProviderAccounts: () => { + readCalled = true + throw new Error('vault must not be read') + }, + listProviderAccountSummaries: () => { + readCalled = true + throw new Error('vault must not be read') + }, + resolveProviderAccount: () => { + readCalled = true + throw new Error('vault must not be read') + }, + resolveProviderAccountByLocalId: () => undefined, + normalizeProviderAccounts: () => undefined, + removeProviderAccount: () => {}, + setDefaultProviderAccount: () => {}, + upsertProviderAccount: () => ({ localAccountId: 'local-a', created: false }), + })) + + // @ts-expect-error cache-busting query string for Bun module mocks + const { runProviderAccountsCommand } = await import('./providerAccounts.js?auth') + const output = await runProviderAccountsCommand(['list'], { + ensureAuthenticated: async () => { + throw new Error('not authenticated') + }, + }) + + expect(output).toMatchObject({ + ok: false, + error: { code: 'verboo_auth_required' }, + }) + expect(readCalled).toBe(false) +}) + +test('usage resolves the requested opaque account and returns protocol v1 data', async () => { + const state = accountState() + let usageAccountId = '' + mock.module('../../utils/providerAccounts/store.js', () => ({ + readProviderAccounts: () => state, + resolveProviderAccount: (provider: string, accountId: string) => + provider === 'codex' && accountId === 'local-a' + ? state.codex.accounts['local-a'] + : undefined, + listProviderAccountSummaries: () => [], + normalizeProviderAccounts: () => undefined, + resolveProviderAccountByLocalId: () => undefined, + removeProviderAccount: () => {}, + setDefaultProviderAccount: () => {}, + upsertProviderAccount: () => ({ localAccountId: 'local-a', created: false }), + })) + mock.module('../../services/api/providerUsageProtocol.js', () => ({ + fetchProviderUsage: async (provider: string, accountId: string) => { + usageAccountId = `${provider}:${accountId}` + return { + schemaVersion: 1, + provider, + accountId, + windows: [], + fetchedAt: '2026-08-09T00:00:00.000Z', + } + }, + })) + + // @ts-expect-error cache-busting query string for Bun module mocks + const { runProviderAccountsCommand } = await import('./providerAccounts.js?usage') + const output = await runProviderAccountsCommand( + ['usage', '--provider', 'codex', '--account', 'local-a'], + { ensureAuthenticated: async () => {} }, + ) + + expect(usageAccountId).toBe('codex:local-a') + expect(output).toMatchObject({ + ok: true, + data: { provider: 'codex', accountId: 'local-a', schemaVersion: 1 }, + }) +}) diff --git a/src/cli/handlers/providerAccounts.ts b/src/cli/handlers/providerAccounts.ts new file mode 100644 index 0000000000..621e8020dd --- /dev/null +++ b/src/cli/handlers/providerAccounts.ts @@ -0,0 +1,147 @@ +import { assertCLIEntitlement } from '../../services/oauth/cliEntitlement.js' +import { + listProviderAccountSummaries, + readProviderAccounts, + removeProviderAccount, + resolveProviderAccount, + setDefaultProviderAccount, +} from '../../utils/providerAccounts/store.js' +import type { + LocalProviderAccountId, + ProviderId, +} from '../../utils/providerAccounts/types.js' + +export type ProviderCommandEnvelope = + | { schemaVersion: 1; ok: true; data: T } + | { schemaVersion: 1; ok: false; error: { code: string; message: string } } + +export type ProviderAccountsCommandDependencies = { + ensureAuthenticated?: () => Promise +} + +const PROTOCOLS = ['provider_accounts_v1', 'provider_usage_v1'] as const + +function success(data: T): ProviderCommandEnvelope { + return { schemaVersion: 1, ok: true, data } +} + +function failure(code: string, message: string): ProviderCommandEnvelope { + return { schemaVersion: 1, ok: false, error: { code, message } } +} + +function optionValue(argv: string[], name: string): string | undefined { + const index = argv.indexOf(name) + const value = index === -1 ? undefined : argv[index + 1] + return value && !value.startsWith('-') ? value : undefined +} + +function providerValue(value: string | undefined): ProviderId | undefined { + return value === 'codex' || value === 'claude' ? value : undefined +} + +function accountValue(argv: string[], provider: ProviderId): LocalProviderAccountId | undefined { + const explicit = optionValue(argv, '--account') + if (explicit) return explicit + return readProviderAccounts()[provider].defaultAccountId +} + +function errorCode(error: unknown): string { + if (error && typeof error === 'object' && typeof (error as { code?: unknown }).code === 'string') { + return (error as { code: string }).code + } + if (error instanceof Error && error.message.includes('Sessão')) { + return 'verboo_auth_required' + } + return 'provider_command_failed' +} + +export async function runProviderAccountsCommand( + argv: string[], + dependencies: ProviderAccountsCommandDependencies = {}, +): Promise> { + try { + await (dependencies.ensureAuthenticated ?? (async () => { + await assertCLIEntitlement() + }))() + } catch { + return failure( + 'verboo_auth_required', + 'Faça login no Verboo antes de consultar as contas dos provedores.', + ) + } + + try { + const command = argv[0] ?? 'capabilities' + if (command === 'capabilities') { + return success({ protocols: [...PROTOCOLS] }) + } + + if (command === 'list') { + return success({ + protocols: [...PROTOCOLS], + accounts: listProviderAccountSummaries().map(account => ({ + schemaVersion: 1, + provider: account.provider, + accountId: account.accountId, + displayLabel: account.displayLabel, + planId: account.planId, + planDisplayName: account.planDisplayName, + isDefault: account.isDefault, + connectionState: account.connectionState, + lastValidatedAt: account.lastValidatedAt, + })), + }) + } + + if (command === 'usage') { + const provider = providerValue(optionValue(argv, '--provider')) + if (!provider) { + return failure( + 'provider_argument_required', + 'Informe --provider codex ou --provider claude.', + ) + } + const accountId = accountValue(argv, provider) + if (!accountId || !resolveProviderAccount(provider, accountId)) { + return failure('provider_account_not_found', 'Conta não encontrada.') + } + const { fetchProviderUsage } = await import( + '../../services/api/providerUsageProtocol.js' + ) + return success(await fetchProviderUsage(provider, accountId)) + } + + if (command === 'set-default' || command === 'remove') { + const provider = providerValue(optionValue(argv, '--provider')) + const accountId = optionValue(argv, '--account') + if (!provider || !accountId) { + return failure( + 'provider_argument_required', + 'Informe --provider e --account.', + ) + } + if (!resolveProviderAccount(provider, accountId)) { + return failure('provider_account_not_found', 'Conta não encontrada.') + } + if (command === 'set-default') { + setDefaultProviderAccount(provider, accountId) + } else { + removeProviderAccount(provider, accountId) + } + return success({ changed: true }) + } + + return failure('provider_command_unknown', 'Comando provider-accounts desconhecido.') + } catch (error) { + const code = errorCode(error) + const message = + code === 'provider_account_not_found' + ? 'Conta não encontrada.' + : code === 'provider_usage_timeout' + ? 'O provedor demorou para responder.' + : code === 'provider_auth_required' + ? 'A conta precisa ser conectada novamente.' + : 'Não foi possível concluir a operação do provedor.' + return failure(code, message) + } +} diff --git a/src/main.tsx b/src/main.tsx index 76b4e13564..8a9682fbe9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4225,6 +4225,54 @@ async function run(): Promise { }); } + // Versioned provider account/usage protocol consumed by Verboo Desktop. + // Keep handlers lazy so normal interactive startup does not load quota APIs. + const providerAccounts = program + .command('provider-accounts') + .description('Manage connected Claude and Codex accounts') + const runProviderAccounts = async (argv: string[]) => { + const { runProviderAccountsCommand } = await import( + './cli/handlers/providerAccounts.js' + ) + const output = await runProviderAccountsCommand(argv) + process.stdout.write(`${JSON.stringify(output)}\n`) + } + providerAccounts.action(async () => runProviderAccounts(['capabilities'])) + providerAccounts + .command('capabilities') + .description('Show supported provider account protocols') + .action(async () => runProviderAccounts(['capabilities'])) + providerAccounts + .command('list') + .description('List connected provider accounts') + .action(async () => runProviderAccounts(['list'])) + providerAccounts + .command('usage') + .description('Show usage windows for one provider account') + .requiredOption('--provider ', 'Provider: codex or claude') + .option('--account ', 'Opaque local account ID') + .action(async (options: { provider?: string; account?: string }) => { + const argv = ['usage', '--provider', options.provider ?? ''] + if (options.account) argv.push('--account', options.account) + return runProviderAccounts(argv) + }) + for (const command of ['set-default', 'remove'] as const) { + providerAccounts + .command(command) + .description(command === 'remove' ? 'Remove a provider account' : 'Select the default provider account') + .requiredOption('--provider ', 'Provider: codex or claude') + .requiredOption('--account ', 'Opaque local account ID') + .action(async (options: { provider?: string; account?: string }) => + runProviderAccounts([ + command, + '--provider', + options.provider ?? '', + '--account', + options.account ?? '', + ]), + ) + } + // claude auth const auth = program.command('auth').description('Manage authentication').configureHelp(createSortedHelpConfig()); diff --git a/src/services/api/claudeNativeModels.test.ts b/src/services/api/claudeNativeModels.test.ts index efd5ecfaa4..10c5860dce 100644 --- a/src/services/api/claudeNativeModels.test.ts +++ b/src/services/api/claudeNativeModels.test.ts @@ -128,9 +128,7 @@ test('fetches and refreshes models for the explicitly selected local account', a try { // @ts-expect-error cache-busting query string for Bun module isolation - const { fetchClaudeNativeModels } = await import( - './claudeNativeModels.js?selected-account' - ) + const { fetchClaudeNativeModels } = await import('./claudeNativeModels.js?selected-account') const models = await fetchClaudeNativeModels({ force: true, localAccountId: 'local-b', diff --git a/src/services/api/providerConfig.runtimeCodexCredentials.test.ts b/src/services/api/providerConfig.runtimeCodexCredentials.test.ts index 169ecd3b14..57ea3de781 100644 --- a/src/services/api/providerConfig.runtimeCodexCredentials.test.ts +++ b/src/services/api/providerConfig.runtimeCodexCredentials.test.ts @@ -88,9 +88,7 @@ test('runtime credential resolution avoids sync secure-storage reads when async })) // @ts-expect-error cache-busting query string for Bun module mocks - const { resolveRuntimeCodexCredentials } = await import( - './providerConfig.js?runtime-no-sync-secure-storage' - ) + const { resolveRuntimeCodexCredentials } = await import('./providerConfig.js?runtime-no-sync-secure-storage') const credentials = resolveRuntimeCodexCredentials({ env: {} as NodeJS.ProcessEnv, @@ -120,9 +118,7 @@ test('runtime credential resolution can read an explicitly selected local accoun })) // @ts-expect-error cache-busting query string for Bun module mocks - const { resolveRuntimeCodexCredentials } = await import( - './providerConfig.js?selected-local-account' - ) + const { resolveRuntimeCodexCredentials } = await import('./providerConfig.js?selected-local-account') const credentials = resolveRuntimeCodexCredentials({ env: {} as NodeJS.ProcessEnv, localAccountId: 'local-selected', diff --git a/src/utils/providerAccounts/selection.test.ts b/src/utils/providerAccounts/selection.test.ts index b29e2c658f..f3d29ca38b 100644 --- a/src/utils/providerAccounts/selection.test.ts +++ b/src/utils/providerAccounts/selection.test.ts @@ -53,9 +53,7 @@ describe('provider account selection', () => { }) // @ts-expect-error cache-busting query string for Bun module isolation - const { resolveProviderAccountSelection } = await import( - './selection.js?provider-mismatch' - ) + const { resolveProviderAccountSelection } = await import('./selection.js?provider-mismatch') expect(() => resolveProviderAccountSelection('claude', 'local-codex'), ).toThrow('provider_account_mismatch') @@ -77,9 +75,7 @@ describe('provider account selection', () => { }) // @ts-expect-error cache-busting query string for Bun module isolation - const { resolveProviderAccountSelection } = await import( - './selection.js?provider-contexts' - ) + const { resolveProviderAccountSelection } = await import('./selection.js?provider-contexts') expect( resolveProviderAccountSelection('codex', 'local-a').credential.accessToken, ).toBe('token-a') From 50ed6993d783c2afbab2abe3809986825b3e0275 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Sun, 9 Aug 2026 19:47:42 -0300 Subject: [PATCH 08/15] docs(providers): define desktop account contract --- docs/desktop-provider-accounts.md | 92 +++++++++++++++++++++++ scripts/desktop-release/contract.test.ts | 9 +++ src/cli/handlers/providerAccounts.test.ts | 28 +++++++ src/cli/handlers/providerAccounts.ts | 5 +- 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 docs/desktop-provider-accounts.md diff --git a/docs/desktop-provider-accounts.md b/docs/desktop-provider-accounts.md new file mode 100644 index 0000000000..6670da41b0 --- /dev/null +++ b/docs/desktop-provider-accounts.md @@ -0,0 +1,92 @@ +# Verboo Desktop provider accounts + +This document is the desktop/CLI boundary for the optional Claude and Codex +account manager. The CLI remains the only credential authority. Desktop sees +opaque local account IDs and sanitized status/usage data; it never receives an +access token, refresh token, ID token, provider subject, email, organization ID, +credential path, or raw provider response. + +## Commands + +The CLI exposes one versioned JSON envelope for each command: + +```text +verboo provider-accounts capabilities +verboo provider-accounts list +verboo provider-accounts usage --provider codex|claude [--account ] +verboo provider-accounts set-default --provider --account +verboo provider-accounts remove --provider --account +``` + +Every response has `schemaVersion: 1` and either `ok: true, data` or +`ok: false, error: { code, message }`. `capabilities` returns +`provider_accounts_v1`, `provider_usage_v1`, and `loginTransport: +pty-slash-v1`. Provider login remains additive: `/codex login` and +`/claude login` add a new account when its provider identity is new; a +reconnect explicitly names the opaque local account. + +## Account and usage fields + +Account summaries may contain only: + +- `provider` (`codex` or `claude`) +- opaque `accountId`, display label, `isDefault` +- `connectionState` (`connected` or `needs_reconnect`) +- optional sanitized plan label and validation timestamp + +Usage snapshots contain the provider, opaque account ID, optional plan label, +fetch timestamp, and windows. A window has an opaque local ID, kind (`session`, +`weekly`, or `model-scoped-weekly`), display label, percentage used, optional +model scope, and optional reset timestamp. + +The normalizer is provider-authoritative: + +- Codex keeps the reported weekly window for the base limit and any separately + reported scoped weekly limit. A five-hour primary window is not presented as + a Codex weekly quota. +- Claude keeps reported five-hour and weekly windows. A Fable/model-scoped row + appears only when the usage response includes that explicit scope; Pro never + receives a fabricated Fable row. +- Missing resets, malformed limits, and unavailable provider windows are + omitted rather than replaced with zeroes. + +No quota state automatically selects, rotates, ranks, or recommends another +account. The user explicitly changes the default or selects an account for a +new process. An active conversation remains owned by the app, so switching the +account for a later turn does not delete its transcript, attachments, or +selections; the spawned CLI process receives one immutable `--provider-account` +ID. + +## Storage and migration + +The encrypted `providerAccounts` v1 record migrates the old scalar Codex and +Claude credentials idempotently. The old scalar fields remain as a rollback +mirror of the selected default account. Removing a non-default account does not +change that mirror; removing the default selects the deterministic remaining +account, and removing the final account clears the mirror only after the secure +write succeeds. Claude risk acceptance remains bound to the exact provider +subject and cannot be copied to another account. + +Storage uses the existing native adapters on every supported desktop: + +| Target | Secure storage | +| --- | --- | +| macOS arm64/x64 | Keychain | +| Windows x64 | Credential Locker | +| Linux x64 | Secret Service | + +Plaintext fallback remains disabled. + +## Verification matrix + +The signed release matrix remains macOS arm64, macOS x64, Windows x64, and +Linux x64. The protocol is additive and does not change the desktop protocol +version or package version. The current evidence is intentionally separated: + +- Live verified when available: Codex Plus and Claude Max. +- Fixture-only until matching accounts are supplied: Codex Pro/Spark and Claude + Pro. Their parsers are covered by deterministic fixtures, not claimed as live + account tests. + +The upstream maintainer publishes the signed version and release. A feature PR +must not create a tag or GitHub release. diff --git a/scripts/desktop-release/contract.test.ts b/scripts/desktop-release/contract.test.ts index 2dbeb66fa0..7a724dc3c0 100644 --- a/scripts/desktop-release/contract.test.ts +++ b/scripts/desktop-release/contract.test.ts @@ -21,6 +21,15 @@ describe('desktop CLI release contract', () => { expect(new Set(DESKTOP_TARGETS.map(item => item.target)).size).toBe(4) }) + test('provider account protocol keeps the signed four-target matrix', () => { + expect(DESKTOP_TARGETS.map(target => target.target)).toEqual([ + 'aarch64-apple-darwin', + 'x86_64-apple-darwin', + 'x86_64-pc-windows-msvc', + 'x86_64-unknown-linux-gnu', + ]) + }) + test('uses target-qualified immutable artifact names', () => { expect(artifactName('0.15.5', 'aarch64-apple-darwin')).toBe( 'verboo-cli-0.15.5-aarch64-apple-darwin.tar.gz', diff --git a/src/cli/handlers/providerAccounts.test.ts b/src/cli/handlers/providerAccounts.test.ts index 60d3133671..cfe96dafab 100644 --- a/src/cli/handlers/providerAccounts.test.ts +++ b/src/cli/handlers/providerAccounts.test.ts @@ -72,6 +72,34 @@ test('list returns only sanitized account fields', async () => { expect(JSON.stringify(output)).not.toContain('provider-secret') }) +test('capabilities advertises the versioned protocols and PTY login transport', async () => { + mock.module('../../utils/providerAccounts/store.js', () => ({ + readProviderAccounts: () => ({ schemaVersion: 1, codex: { accounts: {} }, claude: { accounts: {} } }), + listProviderAccountSummaries: () => [], + resolveProviderAccount: () => undefined, + normalizeProviderAccounts: () => undefined, + resolveProviderAccountByLocalId: () => undefined, + removeProviderAccount: () => {}, + setDefaultProviderAccount: () => {}, + upsertProviderAccount: () => ({ localAccountId: 'local-a', created: false }), + })) + + // @ts-expect-error cache-busting query string for Bun module mocks + const { runProviderAccountsCommand } = await import('./providerAccounts.js?capabilities') + await expect( + runProviderAccountsCommand(['capabilities'], { + ensureAuthenticated: async () => {}, + }), + ).resolves.toEqual({ + schemaVersion: 1, + ok: true, + data: { + protocols: ['provider_accounts_v1', 'provider_usage_v1'], + loginTransport: 'pty-slash-v1', + }, + }) +}) + test('unknown account fails closed with a stable code', async () => { mock.module('../../utils/providerAccounts/store.js', () => ({ readProviderAccounts: () => accountState(), diff --git a/src/cli/handlers/providerAccounts.ts b/src/cli/handlers/providerAccounts.ts index 621e8020dd..1f59c14d42 100644 --- a/src/cli/handlers/providerAccounts.ts +++ b/src/cli/handlers/providerAccounts.ts @@ -73,7 +73,10 @@ export async function runProviderAccountsCommand( try { const command = argv[0] ?? 'capabilities' if (command === 'capabilities') { - return success({ protocols: [...PROTOCOLS] }) + return success({ + protocols: [...PROTOCOLS], + loginTransport: 'pty-slash-v1', + }) } if (command === 'list') { From 54533936a90e5016abfa8eae71d4c224302c912a Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Sun, 9 Aug 2026 20:33:43 -0300 Subject: [PATCH 09/15] feat(providers): validate models per account --- src/cli/handlers/providerAccounts.test.ts | 37 +++++++++++++++++++++++ src/cli/handlers/providerAccounts.ts | 36 ++++++++++++++++++++++ src/main.tsx | 10 ++++++ 3 files changed, 83 insertions(+) diff --git a/src/cli/handlers/providerAccounts.test.ts b/src/cli/handlers/providerAccounts.test.ts index cfe96dafab..d42005dd11 100644 --- a/src/cli/handlers/providerAccounts.test.ts +++ b/src/cli/handlers/providerAccounts.test.ts @@ -204,3 +204,40 @@ test('usage resolves the requested opaque account and returns protocol v1 data', data: { provider: 'codex', accountId: 'local-a', schemaVersion: 1 }, }) }) + +test('models resolves the requested account without exposing provider credentials', async () => { + const state = accountState() + let modelsAccountId = '' + mock.module('../../utils/providerAccounts/store.js', () => ({ + readProviderAccounts: () => state, + resolveProviderAccount: (provider: string, accountId: string) => + provider === 'codex' && accountId === 'local-a' ? state.codex.accounts['local-a'] : undefined, + listProviderAccountSummaries: () => [], + resolveProviderAccountByLocalId: () => undefined, + normalizeProviderAccounts: () => undefined, + removeProviderAccount: () => {}, + setDefaultProviderAccount: () => {}, + upsertProviderAccount: () => ({ localAccountId: 'local-a', created: false }), + })) + mock.module('../../services/api/codexModels.js', () => ({ + fetchCodexModels: async (options?: { localAccountId?: string }) => { + modelsAccountId = options?.localAccountId ?? '' + return [{ id: 'gpt-5.6', displayName: 'GPT-5.6', contextWindow: 272_000 }] + }, + })) + + // @ts-expect-error cache-busting query string for Bun module mocks + const { runProviderAccountsCommand } = await import('./providerAccounts.js?models') + const output = await runProviderAccountsCommand( + ['models', '--provider', 'codex', '--account', 'local-a'], + { ensureAuthenticated: async () => {} }, + ) + + expect(modelsAccountId).toBe('local-a') + expect(output).toEqual({ + schemaVersion: 1, + ok: true, + data: [{ id: 'gpt-5.6', displayName: 'GPT-5.6', contextWindow: 272_000, provider: 'codex', raw: {} }], + }) + expect(JSON.stringify(output)).not.toContain('token-secret') +}) diff --git a/src/cli/handlers/providerAccounts.ts b/src/cli/handlers/providerAccounts.ts index 1f59c14d42..740bfa1eff 100644 --- a/src/cli/handlers/providerAccounts.ts +++ b/src/cli/handlers/providerAccounts.ts @@ -114,6 +114,42 @@ export async function runProviderAccountsCommand( return success(await fetchProviderUsage(provider, accountId)) } + if (command === 'models') { + const provider = providerValue(optionValue(argv, '--provider')) + if (!provider) { + return failure( + 'provider_argument_required', + 'Informe --provider codex ou --provider claude.', + ) + } + const accountId = accountValue(argv, provider) + if (!accountId || !resolveProviderAccount(provider, accountId)) { + return failure('provider_account_not_found', 'Conta não encontrada.') + } + if (provider === 'codex') { + const { fetchCodexModels } = await import('../../services/api/codexModels.js') + const models = await fetchCodexModels({ force: true, localAccountId: accountId }) + return success(models.map(model => ({ + id: model.id, + displayName: model.displayName, + contextWindow: model.contextWindow, + provider, + raw: {}, + }))) + } + const { fetchClaudeNativeModels } = await import('../../services/api/claudeNativeModels.js') + const models = await fetchClaudeNativeModels({ force: true, localAccountId: accountId }) + return success(models.map(model => ({ + id: model.id, + displayName: model.displayName, + contextWindow: model.contextWindow, + maxOutputTokens: model.maxOutputTokens, + supportsVision: model.vision, + provider, + raw: {}, + }))) + } + if (command === 'set-default' || command === 'remove') { const provider = providerValue(optionValue(argv, '--provider')) const accountId = optionValue(argv, '--account') diff --git a/src/main.tsx b/src/main.tsx index 8a9682fbe9..8b99df8026 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4256,6 +4256,16 @@ async function run(): Promise { if (options.account) argv.push('--account', options.account) return runProviderAccounts(argv) }) + providerAccounts + .command('models') + .description('List models available to one connected provider account') + .requiredOption('--provider ', 'Provider: codex or claude') + .option('--account ', 'Opaque local account ID') + .action(async (options: { provider?: string; account?: string }) => { + const argv = ['models', '--provider', options.provider ?? ''] + if (options.account) argv.push('--account', options.account) + return runProviderAccounts(argv) + }) for (const command of ['set-default', 'remove'] as const) { providerAccounts .command(command) From 6f468643a12880b79dd2f67418d73f059e80ad69 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Mon, 10 Aug 2026 00:07:11 -0300 Subject: [PATCH 10/15] fix(secure-storage): fail-closed reads across adapters --- .../secureStorage/fallbackStorage.test.ts | 33 +++++++++ src/utils/secureStorage/fallbackStorage.ts | 33 ++++++++- src/utils/secureStorage/index.ts | 67 +++++++++++++++---- src/utils/secureStorage/linuxSecretStorage.ts | 28 +++++++- .../secureStorage/macOsKeychainStorage.ts | 38 ++++++++++- src/utils/secureStorage/plainTextStorage.ts | 22 +++++- .../secureStorage/platformStorage.test.ts | 48 +++++++++---- .../secureStorage/windowsCredentialStorage.ts | 49 ++++++++++++-- src/utils/secureStorageMutationLock.ts | 55 +++++++++++++++ 9 files changed, 338 insertions(+), 35 deletions(-) create mode 100644 src/utils/secureStorage/fallbackStorage.test.ts create mode 100644 src/utils/secureStorageMutationLock.ts diff --git a/src/utils/secureStorage/fallbackStorage.test.ts b/src/utils/secureStorage/fallbackStorage.test.ts new file mode 100644 index 0000000000..424a52ccbe --- /dev/null +++ b/src/utils/secureStorage/fallbackStorage.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test' +import { createFallbackStorage } from './fallbackStorage.js' +import type { SecureStorage, SecureStorageData } from './index.js' + +function storage(readResult: NonNullable): SecureStorage { + return { + name: 'fixture', + read: () => null, + readResult, + readAsync: async () => null, + update: () => ({ success: true }), + delete: () => true, + } +} + +const data: SecureStorageData = { providerAccounts: { schemaVersion: 1, codex: { accounts: {} }, claude: { accounts: {} } } } + +describe('fallback secure storage classification', () => { + test('preserves a native read failure when the fallback is empty', () => { + const primary = storage(() => ({ kind: 'error', warning: 'keychain unavailable' })) + const secondary = storage(() => ({ kind: 'missing' })) + expect(createFallbackStorage(primary, secondary).readResult?.()).toEqual({ + kind: 'error', + warning: 'keychain unavailable', + }) + }) + + test('uses a valid fallback record when the native vault is unavailable', () => { + const primary = storage(() => ({ kind: 'error', warning: 'keychain unavailable' })) + const secondary = storage(() => ({ kind: 'ok', data })) + expect(createFallbackStorage(primary, secondary).readResult?.()).toEqual({ kind: 'ok', data }) + }) +}) diff --git a/src/utils/secureStorage/fallbackStorage.ts b/src/utils/secureStorage/fallbackStorage.ts index e6a6b93e70..7fc2f716b7 100644 --- a/src/utils/secureStorage/fallbackStorage.ts +++ b/src/utils/secureStorage/fallbackStorage.ts @@ -1,4 +1,4 @@ -import type { SecureStorage, SecureStorageData } from './index.js' +import type { SecureStorage, SecureStorageData, SecureStorageReadResult } from './index.js' /** * Creates a fallback storage that tries to use the primary storage first, @@ -17,6 +17,21 @@ export function createFallbackStorage( } return secondary.read() || {} }, + readResult(): SecureStorageReadResult { + const result = primary.readResult?.() + if (result?.kind === 'ok') return result + const fallback = secondary.readResult?.() + if (result?.kind === 'error') { + // A secondary plaintext record is an intentional migration fallback, + // but an empty secondary store must not turn an unavailable native + // vault into a false "missing" result. + if (fallback?.kind === 'ok') return fallback + return { kind: 'error', warning: result.warning ?? fallback?.warning } + } + if (fallback) return fallback + const legacy = secondary.read() + return legacy ? { kind: 'ok', data: legacy } : { kind: 'missing' } + }, async readAsync(): Promise { const result = await primary.readAsync() if (result !== null && result !== undefined) { @@ -24,6 +39,22 @@ export function createFallbackStorage( } return (await secondary.readAsync()) || {} }, + async readResultAsync(): Promise { + const result = primary.readResultAsync + ? await primary.readResultAsync() + : primary.read() ? { kind: 'ok' as const, data: primary.read()! } : { kind: 'missing' as const } + if (result.kind === 'ok') return result + if (secondary.readResultAsync) { + const fallback = await secondary.readResultAsync() + if (result.kind === 'error' && fallback.kind !== 'ok') { + return { kind: 'error', warning: result.warning ?? fallback.warning } + } + return fallback + } + const fallback = await secondary.readAsync() + if (fallback) return { kind: 'ok', data: fallback } + return result.kind === 'error' ? result : { kind: 'missing' } + }, update(data: SecureStorageData): { success: boolean; warning?: string } { // Capture state before update const primaryDataBefore = primary.read() diff --git a/src/utils/secureStorage/index.ts b/src/utils/secureStorage/index.ts index 9ca36c71f5..fabf76ffe0 100644 --- a/src/utils/secureStorage/index.ts +++ b/src/utils/secureStorage/index.ts @@ -3,6 +3,7 @@ import { macOsKeychainStorage } from './macOsKeychainStorage.js' import { linuxSecretStorage } from './linuxSecretStorage.js' import { windowsCredentialStorage } from './windowsCredentialStorage.js' import { plainTextStorage } from './plainTextStorage.js' +import { withSecureStorageMutationLock } from '../secureStorageMutationLock.js' export interface SecureStorageData { providerAccounts?: import('../providerAccounts/types.js').ProviderAccountsV1 @@ -66,18 +67,31 @@ export interface SecureStorageData { pluginSecrets?: Record> } +export type SecureStorageReadResult = + | { kind: 'ok'; data: SecureStorageData } + | { kind: 'missing' } + | { kind: 'error'; warning?: string } + export interface SecureStorage { name: string read(): SecureStorageData | null + /** Optional classified read used by stateful stores that must fail closed. */ + readResult?(): SecureStorageReadResult readAsync(): Promise - update(data: SecureStorageData): { success: boolean; warning?: string } + readResultAsync?(): Promise + update( + data: SecureStorageData, + options?: { preserveProviderAccounts?: boolean; lockHeld?: boolean }, + ): { success: boolean; warning?: string } delete(): boolean } const unavailableSecureStorage: SecureStorage = { name: 'unavailable-secure-storage', read: () => null, + readResult: () => ({ kind: 'error', warning: 'Secure storage is unavailable.' }), readAsync: async () => null, + readResultAsync: async () => ({ kind: 'error', warning: 'Secure storage is unavailable.' }), update: () => ({ success: false, warning: @@ -88,30 +102,59 @@ const unavailableSecureStorage: SecureStorage = { /** * Get the appropriate secure storage implementation for the current platform. - * Prefers native OS vaults (Keychain, libsecret, Credential Locker) with a plaintext fallback. + * Prefers native OS secure storage (Keychain, libsecret, or Windows DPAPI + * protected per-user storage) with an optional plaintext fallback. */ export function getSecureStorage(options?: { allowPlainTextFallback?: boolean }): SecureStorage { const allowPlainTextFallback = options?.allowPlainTextFallback ?? true + let selected: SecureStorage if (process.platform === 'darwin') { - return allowPlainTextFallback + selected = allowPlainTextFallback ? createFallbackStorage(macOsKeychainStorage, plainTextStorage) : macOsKeychainStorage - } - - if (process.platform === 'linux') { - return allowPlainTextFallback + } else if (process.platform === 'linux') { + selected = allowPlainTextFallback ? createFallbackStorage(linuxSecretStorage, plainTextStorage) : linuxSecretStorage - } - - if (process.platform === 'win32') { - return allowPlainTextFallback + } else if (process.platform === 'win32') { + selected = allowPlainTextFallback ? createFallbackStorage(windowsCredentialStorage, plainTextStorage) : windowsCredentialStorage + } else { + selected = allowPlainTextFallback ? plainTextStorage : unavailableSecureStorage } - return allowPlainTextFallback ? plainTextStorage : unavailableSecureStorage + return preserveProviderAccountsOnSharedWrites(selected) +} + +/** + * Provider accounts live in the same native record as legacy credentials. + * Writers that update another field must re-read the current provider + * collection under the shared lock so an older snapshot cannot erase a newly + * added account. Provider-account mutations opt out and provide the complete + * replacement while already holding that lock. + */ +function preserveProviderAccountsOnSharedWrites(storage: SecureStorage): SecureStorage { + return { + ...storage, + update(data, options = {}) { + const write = () => { + let next = data + if (options.preserveProviderAccounts !== false) { + const current = storage.readResult?.() + if (current?.kind === 'error') { + return { success: false, warning: current.warning ?? 'Secure storage read failed.' } + } + if (current?.kind === 'ok' && current.data.providerAccounts) { + next = { ...data, providerAccounts: current.data.providerAccounts } + } + } + return storage.update(next) + } + return options.lockHeld ? write() : withSecureStorageMutationLock(write) + }, + } } diff --git a/src/utils/secureStorage/linuxSecretStorage.ts b/src/utils/secureStorage/linuxSecretStorage.ts index 525bbc39f4..5576f7bec0 100644 --- a/src/utils/secureStorage/linuxSecretStorage.ts +++ b/src/utils/secureStorage/linuxSecretStorage.ts @@ -5,7 +5,7 @@ import { getSecureStorageServiceName, getUsername, } from './macOsKeychainHelpers.js' -import type { SecureStorage, SecureStorageData } from './index.js' +import type { SecureStorage, SecureStorageData, SecureStorageReadResult } from './index.js' /** * Linux-specific secure storage implementation using the secret-tool CLI. @@ -34,6 +34,32 @@ export const linuxSecretStorage: SecureStorage = { } return null }, + readResult(): SecureStorageReadResult { + try { + const username = getUsername() + const serviceName = getSecureStorageServiceName( + CREDENTIALS_SERVICE_SUFFIX, + ) + const result = execaSync( + 'secret-tool', + ['lookup', 'service', serviceName, 'account', username], + { reject: false }, + ) + if (result.exitCode === 0 && result.stdout) { + try { + return { kind: 'ok', data: jsonParse(result.stdout) } + } catch { + return { kind: 'error', warning: 'Secret Service returned malformed JSON.' } + } + } + // secret-tool uses exit code 1 for a missing item. A thrown spawn (for + // example, secret-tool is not installed) is handled as an error below. + if (result.exitCode === 1) return { kind: 'missing' } + return { kind: 'error', warning: result.stderr?.trim() || 'Secret Service read failed.' } + } catch { + return { kind: 'error', warning: 'Secret Service read failed.' } + } + }, async readAsync(): Promise { // Reusing sync implementation for simplicity as it wraps a CLI call return this.read() diff --git a/src/utils/secureStorage/macOsKeychainStorage.ts b/src/utils/secureStorage/macOsKeychainStorage.ts index 1e4ec37c29..6f82db3aa5 100644 --- a/src/utils/secureStorage/macOsKeychainStorage.ts +++ b/src/utils/secureStorage/macOsKeychainStorage.ts @@ -11,7 +11,7 @@ import { KEYCHAIN_CACHE_TTL_MS, keychainCacheState, } from './macOsKeychainHelpers.js' -import type { SecureStorage, SecureStorageData } from './index.js' +import type { SecureStorage, SecureStorageData, SecureStorageReadResult } from './index.js' // `security -i` reads stdin with a 4096-byte fgets() buffer (BUFSIZ on darwin). // A command line longer than this is truncated mid-argument: the first 4096 @@ -64,6 +64,42 @@ export const macOsKeychainStorage = { keychainCacheState.cache = { data: null, cachedAt: Date.now() } return null }, + /** + * Provider-account mutations use this path while holding their lock. It + * deliberately bypasses the 30s process cache so another Verboo process + * cannot be overwritten with an older snapshot. + */ + readResult(): SecureStorageReadResult { + try { + const storageServiceName = getMacOsKeychainStorageServiceName( + CREDENTIALS_SERVICE_SUFFIX, + ) + const username = getUsername() + const result = execaSync( + 'security', + ['find-generic-password', '-a', username, '-w', '-s', storageServiceName], + { stdio: ['ignore', 'pipe', 'pipe'], reject: false }, + ) + if (result.exitCode === 0 && result.stdout) { + try { + const data = jsonParse(result.stdout.trim()) + keychainCacheState.cache = { data, cachedAt: Date.now() } + return { kind: 'ok', data } + } catch { + return { kind: 'error', warning: 'Keychain returned malformed JSON.' } + } + } + // `security` uses 44 for a missing generic-password item. Other exit + // codes include a locked keychain or an unavailable security service. + if (result.exitCode === 44) { + keychainCacheState.cache = { data: null, cachedAt: Date.now() } + return { kind: 'missing' } + } + return { kind: 'error', warning: result.stderr?.trim() || 'Keychain read failed.' } + } catch { + return { kind: 'error', warning: 'Keychain read failed.' } + } + }, async readAsync(): Promise { const prev = keychainCacheState.cache if (Date.now() - prev.cachedAt < KEYCHAIN_CACHE_TTL_MS) { diff --git a/src/utils/secureStorage/plainTextStorage.ts b/src/utils/secureStorage/plainTextStorage.ts index 77e6ff59d9..78514aed18 100644 --- a/src/utils/secureStorage/plainTextStorage.ts +++ b/src/utils/secureStorage/plainTextStorage.ts @@ -8,7 +8,7 @@ import { jsonStringify, writeFileSync_DEPRECATED, } from '../slowOperations.js' -import type { SecureStorage, SecureStorageData } from './index.js' +import type { SecureStorage, SecureStorageData, SecureStorageReadResult } from './index.js' function getStoragePath(): { storageDir: string; storagePath: string } { const storageDir = getClaudeConfigHomeDir() @@ -30,6 +30,16 @@ export const plainTextStorage = { return null } }, + readResult(): SecureStorageReadResult { + const { storagePath } = getStoragePath() + try { + const data = getFsImplementation().readFileSync(storagePath, { encoding: 'utf8' }) + return { kind: 'ok', data: jsonParse(data) } + } catch (error: unknown) { + if (getErrnoCode(error) === 'ENOENT') return { kind: 'missing' } + return { kind: 'error' } + } + }, async readAsync(): Promise { const { storagePath } = getStoragePath() try { @@ -41,6 +51,16 @@ export const plainTextStorage = { return null } }, + async readResultAsync(): Promise { + const { storagePath } = getStoragePath() + try { + const data = await getFsImplementation().readFile(storagePath, { encoding: 'utf8' }) + return { kind: 'ok', data: jsonParse(data) } + } catch (error: unknown) { + if (getErrnoCode(error) === 'ENOENT') return { kind: 'missing' } + return { kind: 'error' } + } + }, update(data: SecureStorageData): { success: boolean; warning?: string } { // sync IO: called from sync context (SecureStorage interface) try { diff --git a/src/utils/secureStorage/platformStorage.test.ts b/src/utils/secureStorage/platformStorage.test.ts index 42b4ce6daa..62a709debd 100644 --- a/src/utils/secureStorage/platformStorage.test.ts +++ b/src/utils/secureStorage/platformStorage.test.ts @@ -2,14 +2,18 @@ import { expect, test, mock, describe, beforeEach, afterEach } from "bun:test"; import { linuxSecretStorage } from "./linuxSecretStorage.js"; import { windowsCredentialStorage } from "./windowsCredentialStorage.js"; -import { getSecureStorageServiceName, CREDENTIALS_SERVICE_SUFFIX } from "./macOsKeychainHelpers.js"; +import { macOsKeychainStorage } from "./macOsKeychainStorage.js"; +import { getSecureStorageServiceName, CREDENTIALS_SERVICE_SUFFIX, keychainCacheState } from "./macOsKeychainHelpers.js"; import { acquireSharedMutationLock, releaseSharedMutationLock, } from "../../test/sharedMutationLock.js"; -// Mock execaSync -const mockExecaSync = mock(() => ({ exitCode: 0, stdout: "" })); +// Mock execaSync. Keep the call tuple explicit so command assertions stay +// type-safe without weakening production code. +type MockExecaCall = [string, string[], { input?: string; reject?: boolean }] +const mockExecaSync = mock((..._args: unknown[]): { exitCode: number; stdout: string; stderr?: string } => ({ exitCode: 0, stdout: "" })); +const execaCalls = (): MockExecaCall[] => mockExecaSync.mock.calls as unknown as MockExecaCall[] mock.module("execa", () => ({ execaSync: mockExecaSync, })); @@ -62,18 +66,36 @@ describe("Secure Storage Platform Implementations", () => { linuxSecretStorage.update(testData); - const args = mockExecaSync.mock.calls[0]; + const args = execaCalls()[0]; expect(args[1]).toContain(expectedName); }); + test("Linux classified reads distinguish a missing item", () => { + mockExecaSync.mockReturnValue({ exitCode: 1, stdout: "", stderr: "" }); + expect(linuxSecretStorage.readResult?.()).toEqual({ kind: "missing" }); + }); + + test("Windows classified reads distinguish a missing DPAPI file", () => { + mockExecaSync.mockReturnValue({ exitCode: 2, stdout: "", stderr: "" }); + expect(windowsCredentialStorage.readResult?.()).toEqual({ kind: "missing" }); + }); + + test("Keychain classified reads bypass the process cache", () => { + keychainCacheState.cache = { data: { mcpOAuth: {} }, cachedAt: Date.now() }; + const fresh = { mcpOAuth: { fresh: testData.mcpOAuth["test-server"] } }; + mockExecaSync.mockReturnValue({ exitCode: 0, stdout: JSON.stringify(fresh), stderr: "" }); + + expect(macOsKeychainStorage.readResult?.()).toEqual({ kind: "ok", data: fresh }); + }); + test("Windows storage uses scoped resource name", () => { process.env.VERBOO_CONFIG_DIR = "/tmp/win-scoped"; const expectedName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX); windowsCredentialStorage.update(testData); - const script = mockExecaSync.mock.calls[0][1][1]; - const options = mockExecaSync.mock.calls[0][2]; + const script = execaCalls()[0][1][1]; + const options = execaCalls()[0][2]; expect(script).toContain(expectedName); expect(script).toContain("ProtectedData"); expect(options.input).toContain("secret-token"); @@ -95,28 +117,28 @@ describe("Secure Storage Platform Implementations", () => { windowsCredentialStorage.update(dataWithDollar); - const script = mockExecaSync.mock.calls[0][1][1]; - const options = mockExecaSync.mock.calls[0][2]; + const script = execaCalls()[0][1][1]; + const options = execaCalls()[0][2]; expect(script).toContain("[Console]::In.ReadToEnd()"); expect(options.input).toContain("token-with-$env:USERNAME"); const dataWithQuote = { mcpOAuth: { "s": { accessToken: "token'quote", expiresAt: 1, serverName: "s", serverUrl: "u" } } }; windowsCredentialStorage.update(dataWithQuote); - const options2 = mockExecaSync.mock.calls[1][2]; + const options2 = execaCalls()[1][2]; expect(options2.input).toContain("token'quote"); }); test("delete() skips legacy PasswordVault by default", () => { windowsCredentialStorage.delete(); expect(mockExecaSync).toHaveBeenCalledTimes(1); - const script = mockExecaSync.mock.calls[0][1][1]; + const script = execaCalls()[0][1][1]; expect(script).not.toContain("System.Runtime.WindowsRuntime"); }); test("delete() includes legacy assembly load when explicitly enabled", () => { process.env.VERBOO_ENABLE_LEGACY_WINDOWS_PASSWORDVAULT = "1"; windowsCredentialStorage.delete(); - const script = mockExecaSync.mock.calls[1][1][1]; + const script = execaCalls()[1][1][1]; expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime"); }); @@ -124,7 +146,7 @@ describe("Secure Storage Platform Implementations", () => { process.env.VERBOO_ENABLE_LEGACY_WINDOWS_PASSWORDVAULT = "1"; process.env.USER = 'user"name'; windowsCredentialStorage.read(); - const script = mockExecaSync.mock.calls[1][1][1]; + const script = execaCalls()[1][1][1]; expect(script).toContain('user`"name'); expect(script).not.toContain('user"name'); }); @@ -170,7 +192,7 @@ describe("Secure Storage Platform Implementations", () => { test("update passes payload via stdin", () => { linuxSecretStorage.update(testData); - const options = mockExecaSync.mock.calls[0][2]; + const options = execaCalls()[0][2]; expect(options.input).toContain("secret-token"); }); diff --git a/src/utils/secureStorage/windowsCredentialStorage.ts b/src/utils/secureStorage/windowsCredentialStorage.ts index 3b04191736..f436527fef 100644 --- a/src/utils/secureStorage/windowsCredentialStorage.ts +++ b/src/utils/secureStorage/windowsCredentialStorage.ts @@ -7,7 +7,7 @@ import { getSecureStorageServiceName, getUsername, } from './macOsKeychainHelpers.js' -import type { SecureStorage, SecureStorageData } from './index.js' +import type { SecureStorage, SecureStorageData, SecureStorageReadResult } from './index.js' /** * Windows-specific secure storage implementation using DPAPI for new writes, @@ -52,7 +52,7 @@ function getFailureWarning( result: ReturnType | null, fallback: string, ): string { - const stderr = result?.stderr?.trim() + const stderr = typeof result?.stderr === 'string' ? result.stderr.trim() : '' if (stderr) { return stderr } @@ -84,9 +84,10 @@ function readLegacyPasswordVault(): SecureStorageData | null { ` const result = runPowerShell(script) - if (result?.exitCode === 0 && result.stdout) { + const stdout = typeof result?.stdout === 'string' ? result.stdout : '' + if (result?.exitCode === 0 && stdout) { try { - return jsonParse(result.stdout) + return jsonParse(stdout) } catch { return null } @@ -134,9 +135,10 @@ export const windowsCredentialStorage: SecureStorage = { ` const result = runPowerShell(script) - if (result?.exitCode === 0 && result.stdout) { + const stdout = typeof result?.stdout === 'string' ? result.stdout : '' + if (result?.exitCode === 0 && stdout) { try { - return jsonParse(result.stdout) + return jsonParse(stdout) } catch { return readLegacyPasswordVault() } @@ -144,6 +146,41 @@ export const windowsCredentialStorage: SecureStorage = { return readLegacyPasswordVault() }, + readResult(): SecureStorageReadResult { + const filePath = escapePowerShellSingleQuoted(getWindowsSecureStorageFilePath()) + const entropy = escapePowerShellSingleQuoted(getWindowsSecureStorageEntropy()) + const script = ` + try { + Add-Type -AssemblyName System.Security + $path = '${filePath}' + if (!(Test-Path -LiteralPath $path)) { exit 2 } + $protectedBase64 = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8).Trim() + if (-not $protectedBase64) { exit 3 } + $protectedBytes = [Convert]::FromBase64String($protectedBase64) + $entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}') + $bytes = [System.Security.Cryptography.ProtectedData]::Unprotect( + $protectedBytes, $entropyBytes, + [System.Security.Cryptography.DataProtectionScope]::CurrentUser + ) + [Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($bytes)) + } catch { exit 3 } + ` + const result = runPowerShell(script) + const stdout = typeof result?.stdout === 'string' ? result.stdout : '' + if (result?.exitCode === 0 && stdout) { + try { + return { kind: 'ok', data: jsonParse(stdout) } + } catch { + return { kind: 'error', warning: 'DPAPI returned malformed JSON.' } + } + } + if (result?.exitCode === 2) return { kind: 'missing' } + if (result?.exitCode === 3 && shouldUseLegacyPasswordVault()) { + const legacy = readLegacyPasswordVault() + if (legacy) return { kind: 'ok', data: legacy } + } + return { kind: 'error', warning: getFailureWarning(result, 'Windows DPAPI read failed') } + }, async readAsync(): Promise { return this.read() }, diff --git a/src/utils/secureStorageMutationLock.ts b/src/utils/secureStorageMutationLock.ts new file mode 100644 index 0000000000..c0a66516a5 --- /dev/null +++ b/src/utils/secureStorageMutationLock.ts @@ -0,0 +1,55 @@ +import { join } from 'node:path' + +import { getClaudeConfigHomeDir } from './envUtils.js' +import { getFsImplementation } from './fsOperations.js' +import { lockSync } from './lockfile.js' + +/** Serialize read/modify/write operations against the shared native vault. */ +export function withSecureStorageMutationLock(work: () => T): T { + const configDir = getClaudeConfigHomeDir() + const lockPath = join(configDir, '.provider-accounts.lock') + let release: (() => void) | undefined + let compromised = false + try { + getFsImplementation().mkdirSync(configDir) + for (let attempt = 0; attempt < 9; attempt += 1) { + try { + // proper-lockfile rejects `retries` for lockSync. Retry the sync API + // explicitly so a second CLI process waits instead of failing every + // mutation immediately. + release = lockSync(configDir, { + lockfilePath: lockPath, + stale: 30_000, + onCompromised: () => { + // proper-lockfile's default callback throws from a heartbeat + // timer, becoming an uncaught process-level exception. Record + // the compromise and fail the guarded operation synchronously. + compromised = true + }, + }) + break + } catch (error: unknown) { + const code = error && typeof error === 'object' && 'code' in error + ? (error as { code?: unknown }).code + : undefined + if (code !== 'ELOCKED' || attempt === 8) throw error + Atomics.wait( + new Int32Array(new SharedArrayBuffer(4)), + 0, + 0, + Math.min(250, 25 * (attempt + 1)), + ) + } + } + if (!release) throw new Error('provider_storage_lock_failed') + } catch { + throw new Error('provider_storage_lock_failed') + } + try { + const result = work() + if (compromised) throw new Error('provider_storage_lock_compromised') + return result + } finally { + release?.() + } +} From 68d34c60e72173f407532c709fd06aca455b245b Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Mon, 10 Aug 2026 00:07:42 -0300 Subject: [PATCH 11/15] fix(provider-accounts): guard unknown vault schema and cross-process mutations --- .../store.cross-process.test.ts | 40 +++++++ .../providerAccounts/store.lifecycle.test.ts | 93 +++++++++++++++- src/utils/providerAccounts/store.test.ts | 10 +- src/utils/providerAccounts/store.ts | 100 ++++++++++++------ 4 files changed, 202 insertions(+), 41 deletions(-) create mode 100644 src/utils/providerAccounts/store.cross-process.test.ts diff --git a/src/utils/providerAccounts/store.cross-process.test.ts b/src/utils/providerAccounts/store.cross-process.test.ts new file mode 100644 index 0000000000..bdd096082a --- /dev/null +++ b/src/utils/providerAccounts/store.cross-process.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('provider-account storage locking', () => { + test('serializes two real CLI processes so neither account update is lost', async () => { + const root = await mkdtemp(join(tmpdir(), 'verboo-provider-lock-')) + roots.push(root) + await mkdir(root, { recursive: true }) + const statePath = join(root, 'state.json') + await writeFile(statePath, '[]') + + const helperPath = join(import.meta.dir, '../secureStorageMutationLock.ts') + const script = ` + import { readFileSync, writeFileSync } from 'node:fs' + import { withSecureStorageMutationLock } from ${JSON.stringify(helperPath)} + withSecureStorageMutationLock(() => { + const state = JSON.parse(readFileSync(${JSON.stringify(statePath)}, 'utf8')) + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 75) + state.push(process.argv.at(-1)) + writeFileSync(${JSON.stringify(statePath)}, JSON.stringify(state)) + }) + ` + const env = { ...process.env, VERBOO_CONFIG_DIR: root } + const first = Bun.spawn([process.execPath, '-e', script, 'account-a'], { env }) + const second = Bun.spawn([process.execPath, '-e', script, 'account-b'], { env }) + const [firstExit, secondExit] = await Promise.all([first.exited, second.exited]) + + expect(firstExit).toBe(0) + expect(secondExit).toBe(0) + expect(JSON.parse(await readFile(statePath, 'utf8')).sort()).toEqual(['account-a', 'account-b']) + }) +}) diff --git a/src/utils/providerAccounts/store.lifecycle.test.ts b/src/utils/providerAccounts/store.lifecycle.test.ts index b027d0b6be..3ac079380f 100644 --- a/src/utils/providerAccounts/store.lifecycle.test.ts +++ b/src/utils/providerAccounts/store.lifecycle.test.ts @@ -44,23 +44,63 @@ function seededData(): SecureStorageData { async function loadStore( suffix: string, initial: SecureStorageData, -): Promise<{ store: typeof import('./store.js'); readState: () => SecureStorageData }> { + options: { + readError?: Error + readResult?: { kind: 'ok'; data: SecureStorageData } | { kind: 'missing' } | { kind: 'error'; warning?: string } + updateResult?: { success: boolean; warning?: string } + } = {}, +): Promise<{ + store: typeof import('./store.js') + readState: () => SecureStorageData + updateCalls: () => number + lockCalls: () => number +}> { let state = initial + let updateCalls = 0 + let lockCalls = 0 mock.module('../secureStorage/index.js', () => ({ getSecureStorage: () => ({ name: 'test-secure-storage', - read: () => state, + read: () => { + if (options.readError) throw options.readError + return state + }, readAsync: async () => state, + readResult: () => { + if (options.readError) return { kind: 'error', warning: options.readError.message } + return options.readResult ?? { kind: 'ok', data: state } + }, update: (next: SecureStorageData) => { + updateCalls += 1 + if (options.updateResult && !options.updateResult.success) { + return options.updateResult + } state = next return { success: true } }, delete: () => true, }), })) + mock.module('../lockfile.js', () => ({ + lockSync: () => { + lockCalls += 1 + return () => undefined + }, + })) + mock.module('../envUtils.js', () => ({ + getClaudeConfigHomeDir: () => '/tmp/verboo-provider-account-store-test', + })) + mock.module('../fsOperations.js', () => ({ + getFsImplementation: () => ({ mkdirSync: () => undefined }), + })) const store = await import(`./store.js?${suffix}`) - return { store, readState: () => state } + return { + store, + readState: () => state, + updateCalls: () => updateCalls, + lockCalls: () => lockCalls, + } } afterEach(() => { @@ -144,4 +184,51 @@ describe('provider account lifecycle', () => { result.localAccountId, ) }) + + test('serializes provider account mutations across CLI processes', async () => { + const { store, lockCalls } = await loadStore('cross-process-lock', seededData()) + + store.upsertProviderAccount('codex', { + accessToken: 'new-token', + accountId: 'provider-1', + }) + + expect(lockCalls()).toBeGreaterThan(0) + }) + + test('fails closed when the secure store cannot be read', async () => { + const { store, updateCalls } = await loadStore('read-failure', seededData(), { + readError: new Error('keychain unavailable'), + }) + + expect(() => + store.upsertProviderAccount('codex', { + accessToken: 'should-not-write', + accountId: 'provider-1', + }), + ).toThrow('provider_storage_read_failed') + expect(updateCalls()).toBe(0) + }) + + test('uses a fresh classified secure-store read instead of treating null as empty', async () => { + const { store, updateCalls } = await loadStore('classified-read-failure', seededData(), { + readResult: { kind: 'error', warning: 'keychain locked' }, + }) + + expect(() => store.readProviderAccounts()).toThrow('provider_storage_read_failed') + expect(updateCalls()).toBe(0) + }) + + test('does not expose a migrated account when migration persistence fails', async () => { + const legacyOnly = seededData() + delete legacyOnly.providerAccounts + const { store, updateCalls } = await loadStore('migration-failure', legacyOnly, { + updateResult: { success: false, warning: 'keychain denied write' }, + }) + + expect(() => store.readProviderAccounts()).toThrow( + 'provider_storage_migration_failed', + ) + expect(updateCalls()).toBe(1) + }) }) diff --git a/src/utils/providerAccounts/store.test.ts b/src/utils/providerAccounts/store.test.ts index 2ccb27123c..8e6114d745 100644 --- a/src/utils/providerAccounts/store.test.ts +++ b/src/utils/providerAccounts/store.test.ts @@ -42,7 +42,7 @@ describe('migrateProviderAccounts', () => { expect(result.data.providerAccounts?.codex.accounts['local-1']).toBeDefined() }) - test('rejects malformed v1 state instead of creating a new account', () => { + test('fails closed for malformed v1 state instead of creating a new account', () => { const malformed = { providerAccounts: { schemaVersion: 9, @@ -57,8 +57,8 @@ describe('migrateProviderAccounts', () => { () => 'new-id', ) - expect(result.mode).toBe('v1') - expect(result.data.providerAccounts?.codex.accounts['new-id']).toBeDefined() + expect(result.mode).toBe('invalid') + expect(result.data as unknown).toEqual(malformed) }) test('rejects Claude records whose risk acceptance belongs to another identity', () => { @@ -95,7 +95,7 @@ describe('migrateProviderAccounts', () => { () => 'new-id', ) - expect(result.mode).toBe('legacy') - expect(result.data.providerAccounts).toEqual(malformed.providerAccounts) + expect(result.mode).toBe('invalid') + expect(result.data.providerAccounts as unknown).toEqual(malformed.providerAccounts) }) }) diff --git a/src/utils/providerAccounts/store.ts b/src/utils/providerAccounts/store.ts index 34df941807..c049016bf9 100644 --- a/src/utils/providerAccounts/store.ts +++ b/src/utils/providerAccounts/store.ts @@ -1,7 +1,9 @@ import { getSecureStorage, + type SecureStorageReadResult, type SecureStorageData, } from '../secureStorage/index.js' +import { withSecureStorageMutationLock } from '../secureStorageMutationLock.js' import { normalizeClaudeNativeCredentials, normalizeCodexCredentialBlob, @@ -183,10 +185,16 @@ function addMigratedClaude( export function migrateProviderAccounts( data: SecureStorageData, makeId: () => LocalProviderAccountId = () => crypto.randomUUID(), -): { data: SecureStorageData; mode: 'v1' | 'legacy' } { +): { data: SecureStorageData; mode: 'v1' | 'legacy' | 'invalid' } { const existing = normalizeProviderAccounts(data.providerAccounts) if (existing) return { data: { ...data, providerAccounts: existing }, mode: 'v1' } + // A present but invalid field belongs to a newer or corrupted writer. Do + // not reinterpret it as a legacy install and overwrite all accounts. + if (Object.prototype.hasOwnProperty.call(data, 'providerAccounts')) { + return { data, mode: 'invalid' } + } + const next = emptyProviderAccounts() const codex = normalizeCodexCredentialBlob(data.codex) const claude = normalizeClaudeNativeCredentials(data.claudeNative) @@ -208,15 +216,31 @@ function storage() { } export function readSecureData(): SecureStorageData { + const current = storage() + const classified: SecureStorageReadResult | undefined = current.readResult?.() + if (classified) { + if (classified.kind === 'ok') return classified.data + if (classified.kind === 'missing') return {} + throw new Error('provider_storage_read_failed') + } try { - return storage().read() ?? {} + const legacy = current.read() + if (legacy === null) throw new Error('provider_storage_read_failed') + return legacy } catch { - return {} + throw new Error('provider_storage_read_failed') } } +export function withProviderAccountsLock(work: () => T): T { + return withSecureStorageMutationLock(work) +} + function commitSecureData(data: SecureStorageData): void { - const result = storage().update(data) + const result = storage().update(data, { + preserveProviderAccounts: false, + lockHeld: true, + }) if (!result.success) { throw new Error(result.warning ?? 'secure_storage_write_failed') } @@ -228,6 +252,9 @@ function prepareMutableState(): { } { const data = readSecureData() const migration = migrateProviderAccounts(data) + if (migration.mode === 'invalid') { + throw new Error('provider_storage_schema_unsupported') + } const accounts = normalizeProviderAccounts(migration.data.providerAccounts) ?? emptyProviderAccounts() return { @@ -282,22 +309,24 @@ function mirrorDefaultCredential( } export function readProviderAccounts(): ProviderAccountsV1 { - let data: SecureStorageData | null = null - try { - data = storage().read() - } catch { - return emptyProviderAccounts() - } + return withProviderAccountsLock(() => readProviderAccountsUnlocked()) +} - const migration = migrateProviderAccounts(data ?? {}) +function readProviderAccountsUnlocked(): ProviderAccountsV1 { + const data = readSecureData() + + const migration = migrateProviderAccounts(data) + if (migration.mode === 'invalid') { + throw new Error('provider_storage_schema_unsupported') + } const normalized = normalizeProviderAccounts(migration.data.providerAccounts) if (!normalized) return emptyProviderAccounts() if (!data?.providerAccounts && migration.mode === 'v1') { try { - storage().update(migration.data) + commitSecureData(migration.data) } catch { - // The scalar record remains authoritative until the next successful write. + throw new Error('provider_storage_migration_failed') } } @@ -305,32 +334,23 @@ export function readProviderAccounts(): ProviderAccountsV1 { } export async function readProviderAccountsAsync(): Promise { - let data: SecureStorageData | null = null - try { - data = await storage().readAsync() - } catch { - return emptyProviderAccounts() - } - - const migration = migrateProviderAccounts(data ?? {}) - const normalized = normalizeProviderAccounts(migration.data.providerAccounts) - if (!normalized) return emptyProviderAccounts() - - if (!data?.providerAccounts && migration.mode === 'v1') { - try { - storage().update(migration.data) - } catch { - // Keep the in-memory migrated view; the scalar mirror is still intact. - } - } - - return normalized + // Keep one read/migration path so async callers get the same lock and + // fail-closed behavior as synchronous CLI commands. + return readProviderAccounts() } export function upsertProviderAccount( provider: ProviderId, credential: CodexCredentialBlob | ClaudeNativeCredentialBlob, options?: { reconnectLocalAccountId?: LocalProviderAccountId }, +): { localAccountId: LocalProviderAccountId; created: boolean } { + return withProviderAccountsLock(() => upsertProviderAccountUnlocked(provider, credential, options)) +} + +function upsertProviderAccountUnlocked( + provider: ProviderId, + credential: CodexCredentialBlob | ClaudeNativeCredentialBlob, + options?: { reconnectLocalAccountId?: LocalProviderAccountId }, ): { localAccountId: LocalProviderAccountId; created: boolean } { const normalized = normalizeCredentialForProvider(provider, credential) const { data, accounts } = prepareMutableState() @@ -414,6 +434,13 @@ export function reconnectProviderAccount( export function setDefaultProviderAccount( provider: ProviderId, localAccountId: LocalProviderAccountId, +): void { + withProviderAccountsLock(() => setDefaultProviderAccountUnlocked(provider, localAccountId)) +} + +function setDefaultProviderAccountUnlocked( + provider: ProviderId, + localAccountId: LocalProviderAccountId, ): void { const { data, accounts } = prepareMutableState() const collection = { @@ -439,6 +466,13 @@ export function setDefaultProviderAccount( export function removeProviderAccount( provider: ProviderId, localAccountId: LocalProviderAccountId, +): void { + withProviderAccountsLock(() => removeProviderAccountUnlocked(provider, localAccountId)) +} + +function removeProviderAccountUnlocked( + provider: ProviderId, + localAccountId: LocalProviderAccountId, ): void { const { data, accounts } = prepareMutableState() const collection = { From 7f3cdb961a98269a2349cf4ec8c45e849dd9168f Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Mon, 10 Aug 2026 00:07:51 -0300 Subject: [PATCH 12/15] feat(provider-accounts): sanitized multi-account status --- src/cli/handlers/providerAccounts.test.ts | 9 +++---- src/cli/handlers/providerAccounts.ts | 32 +++++++++++++++------- src/commands/claude/claude.tsx | 24 ++++++++++++----- src/commands/codex/codex.tsx | 15 +++++------ src/utils/providerAccounts/status.test.ts | 33 +++++++++++++++++++++++ src/utils/providerAccounts/status.ts | 26 ++++++++++++++++++ 6 files changed, 109 insertions(+), 30 deletions(-) create mode 100644 src/utils/providerAccounts/status.test.ts create mode 100644 src/utils/providerAccounts/status.ts diff --git a/src/cli/handlers/providerAccounts.test.ts b/src/cli/handlers/providerAccounts.test.ts index d42005dd11..03c793925b 100644 --- a/src/cli/handlers/providerAccounts.test.ts +++ b/src/cli/handlers/providerAccounts.test.ts @@ -96,6 +96,7 @@ test('capabilities advertises the versioned protocols and PTY login transport', data: { protocols: ['provider_accounts_v1', 'provider_usage_v1'], loginTransport: 'pty-slash-v1', + secureStorage: { native: true, backend: expect.any(String), probe: expect.stringMatching(/^(ok|missing|error)$/) }, }, }) }) @@ -178,8 +179,7 @@ test('usage resolves the requested opaque account and returns protocol v1 data', setDefaultProviderAccount: () => {}, upsertProviderAccount: () => ({ localAccountId: 'local-a', created: false }), })) - mock.module('../../services/api/providerUsageProtocol.js', () => ({ - fetchProviderUsage: async (provider: string, accountId: string) => { + const fetchProviderUsage = async (provider: string, accountId: string) => { usageAccountId = `${provider}:${accountId}` return { schemaVersion: 1, @@ -188,14 +188,13 @@ test('usage resolves the requested opaque account and returns protocol v1 data', windows: [], fetchedAt: '2026-08-09T00:00:00.000Z', } - }, - })) + } // @ts-expect-error cache-busting query string for Bun module mocks const { runProviderAccountsCommand } = await import('./providerAccounts.js?usage') const output = await runProviderAccountsCommand( ['usage', '--provider', 'codex', '--account', 'local-a'], - { ensureAuthenticated: async () => {} }, + { ensureAuthenticated: async () => {}, fetchProviderUsage }, ) expect(usageAccountId).toBe('codex:local-a') diff --git a/src/cli/handlers/providerAccounts.ts b/src/cli/handlers/providerAccounts.ts index 740bfa1eff..6ce23094eb 100644 --- a/src/cli/handlers/providerAccounts.ts +++ b/src/cli/handlers/providerAccounts.ts @@ -1,4 +1,5 @@ import { assertCLIEntitlement } from '../../services/oauth/cliEntitlement.js' +import { getSecureStorage } from '../../utils/secureStorage/index.js' import { listProviderAccountSummaries, readProviderAccounts, @@ -17,6 +18,7 @@ export type ProviderCommandEnvelope = export type ProviderAccountsCommandDependencies = { ensureAuthenticated?: () => Promise + fetchProviderUsage?: (provider: ProviderId, accountId: LocalProviderAccountId) => Promise } const PROTOCOLS = ['provider_accounts_v1', 'provider_usage_v1'] as const @@ -59,6 +61,24 @@ export async function runProviderAccountsCommand( argv: string[], dependencies: ProviderAccountsCommandDependencies = {}, ): Promise> { + const command = argv[0] ?? 'capabilities' + // Capabilities are non-secret and intentionally auth-free so release + // builders can verify the protocol and native storage adapter without a + // user's session. + if (command === 'capabilities') { + const secureStorage = getSecureStorage({ allowPlainTextFallback: false }) + const secureStorageRead = secureStorage.readResult?.() + return success({ + protocols: [...PROTOCOLS], + loginTransport: 'pty-slash-v1', + secureStorage: { + native: secureStorage.name !== 'plaintext' && secureStorage.name !== 'unavailable-secure-storage', + backend: secureStorage.name, + probe: secureStorageRead?.kind ?? 'error', + }, + }) + } + try { await (dependencies.ensureAuthenticated ?? (async () => { await assertCLIEntitlement() @@ -71,14 +91,6 @@ export async function runProviderAccountsCommand( } try { - const command = argv[0] ?? 'capabilities' - if (command === 'capabilities') { - return success({ - protocols: [...PROTOCOLS], - loginTransport: 'pty-slash-v1', - }) - } - if (command === 'list') { return success({ protocols: [...PROTOCOLS], @@ -108,9 +120,9 @@ export async function runProviderAccountsCommand( if (!accountId || !resolveProviderAccount(provider, accountId)) { return failure('provider_account_not_found', 'Conta não encontrada.') } - const { fetchProviderUsage } = await import( + const fetchProviderUsage = dependencies.fetchProviderUsage ?? (await import( '../../services/api/providerUsageProtocol.js' - ) + )).fetchProviderUsage return success(await fetchProviderUsage(provider, accountId)) } diff --git a/src/commands/claude/claude.tsx b/src/commands/claude/claude.tsx index 70d864423c..738b435870 100644 --- a/src/commands/claude/claude.tsx +++ b/src/commands/claude/claude.tsx @@ -29,6 +29,8 @@ import { type ClaudeNativeCredentialBlob, } from '../../utils/claudeNativeCredentials.js' import { parseProviderLoginArgs } from '../../utils/providerAccounts/loginArgs.js' +import { listProviderAccountSummaries } from '../../utils/providerAccounts/store.js' +import { formatProviderAccountStatus } from '../../utils/providerAccounts/status.js' function ClaudeLogin({ acceptedAt, @@ -193,13 +195,21 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => { const action = parseProviderLoginArgs(args) if (action.action === 'status') { - const credentials = await readClaudeNativeCredentialsAsync() - onDone( - credentials - ? `Claude conectado${credentials.email ? ` (${credentials.email})` : ''}. Aceite de risco v${credentials.riskAcceptance.version}${hasCurrentClaudeRiskAcceptance(credentials) ? ' válido' : ' desatualizado'}. Use /claude login para trocar de conta ou /claude logout para sair.` - : 'Claude não conectado. Execute /claude para desbloquear modelos adicionais.', - { display: 'system' }, - ) + try { + const accounts = listProviderAccountSummaries() + const status = formatProviderAccountStatus('claude', accounts) + if (!accounts.some(account => account.provider === 'claude')) { + onDone(status, { display: 'system' }) + return + } + const credentials = await readClaudeNativeCredentialsAsync() + const risk = credentials + ? ` Aceite de risco v${credentials.riskAcceptance.version}${hasCurrentClaudeRiskAcceptance(credentials) ? ' válido' : ' desatualizado'}.` + : '' + onDone(`${status}${risk}`, { display: 'system' }) + } catch { + onDone('Não foi possível consultar as contas Claude no armazenamento seguro. Tente novamente.', { display: 'system' }) + } return } diff --git a/src/commands/codex/codex.tsx b/src/commands/codex/codex.tsx index a726a0223f..caaeab0813 100644 --- a/src/commands/codex/codex.tsx +++ b/src/commands/codex/codex.tsx @@ -14,9 +14,10 @@ import { ensureVerbooAuthenticated } from '../../services/oauth/verbooStartupAut import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js' import { clearCodexCredentials, - readCodexCredentialsAsync, } from '../../utils/codexCredentials.js' import { parseProviderLoginArgs } from '../../utils/providerAccounts/loginArgs.js' +import { listProviderAccountSummaries } from '../../utils/providerAccounts/store.js' +import { formatProviderAccountStatus } from '../../utils/providerAccounts/status.js' function CodexLogin({ onDone, @@ -116,13 +117,11 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => { const action = parseProviderLoginArgs(args) if (action.action === 'status') { - const credentials = await readCodexCredentialsAsync() - onDone( - credentials - ? `Codex conectado${credentials.accountId ? ` (conta ${credentials.accountId})` : ''}. Use /codex login para trocar de conta ou /codex logout para sair.` - : 'Codex não conectado. Execute /codex para desbloquear os modelos adicionais.', - { display: 'system' }, - ) + try { + onDone(formatProviderAccountStatus('codex', listProviderAccountSummaries()), { display: 'system' }) + } catch { + onDone('Não foi possível consultar as contas Codex no armazenamento seguro. Tente novamente.', { display: 'system' }) + } return } diff --git a/src/utils/providerAccounts/status.test.ts b/src/utils/providerAccounts/status.test.ts new file mode 100644 index 0000000000..d116fc8920 --- /dev/null +++ b/src/utils/providerAccounts/status.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'bun:test' +import { formatProviderAccountStatus } from './status.js' + +describe('provider account status', () => { + it('shows the account count and sanitized default label', () => { + const message = formatProviderAccountStatus('codex', [ + { + provider: 'codex', + accountId: 'local-a', + displayLabel: 'Codex 1', + isDefault: false, + connectionState: 'connected', + }, + { + provider: 'codex', + accountId: 'local-b', + displayLabel: 'Codex 2', + isDefault: true, + connectionState: 'connected', + }, + ]) + + expect(message).toContain('2 contas') + expect(message).toContain('Codex 2') + expect(message).not.toContain('local-b') + }) + + it('does not expose provider subjects or emails in the disconnected state', () => { + expect(formatProviderAccountStatus('claude', [])).toBe( + 'Claude não conectado. Execute /claude login para desbloquear modelos adicionais.', + ) + }) +}) diff --git a/src/utils/providerAccounts/status.ts b/src/utils/providerAccounts/status.ts new file mode 100644 index 0000000000..c37eb2cd8e --- /dev/null +++ b/src/utils/providerAccounts/status.ts @@ -0,0 +1,26 @@ +import type { ProviderId } from './types.js' +import type { ProviderAccountSummary } from './store.js' + +const loginCommand: Record = { + codex: '/codex', + claude: '/claude', +} + +const providerLabel: Record = { + codex: 'Codex', + claude: 'Claude', +} + +export function formatProviderAccountStatus( + provider: ProviderId, + accounts: ProviderAccountSummary[], +): string { + const providerAccounts = accounts.filter(account => account.provider === provider) + if (providerAccounts.length === 0) { + return `${providerLabel[provider]} não conectado. Execute ${loginCommand[provider]} login para desbloquear modelos adicionais.` + } + + const defaultAccount = providerAccounts.find(account => account.isDefault) ?? providerAccounts[0] + const accountText = `${providerAccounts.length} conta${providerAccounts.length === 1 ? '' : 's'}; padrão: ${defaultAccount.displayLabel}` + return `${providerLabel[provider]} conectado (${accountText}). Use ${loginCommand[provider]} login para adicionar ou trocar de conta.` +} From 8aaf3791eb2a506115fb5675f73a1a436f6412fb Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Mon, 10 Aug 2026 00:08:12 -0300 Subject: [PATCH 13/15] chore(release): provider-accounts capabilities smoke + docs --- README.md | 2 +- docs/desktop-provider-accounts.md | 16 ++++-- scripts/desktop-release/package.test.ts | 2 +- scripts/desktop-release/package.ts | 53 +++++++++++++++++++ .../desktop-release/verify-release.test.ts | 2 +- 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a1d84e9a6b..78f4b58b5f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ If the install later reports `ripgrep not found`, install ripgrep system-wide an verboo ``` -On first run, Verboo Code opens your browser at `https://code.verboo.ai` to complete the OAuth login. Once authenticated, your session tokens are stored securely in your system keychain (macOS Keychain, Windows Credential Manager, or Linux libsecret). No additional configuration required. +On first run, Verboo Code opens your browser at `https://code.verboo.ai` to complete the OAuth login. Once authenticated, your session tokens are stored securely in the native adapter for your platform (macOS Keychain, Windows DPAPI protected per-user storage, or Linux libsecret). No additional configuration required. To log in manually at any time: diff --git a/docs/desktop-provider-accounts.md b/docs/desktop-provider-accounts.md index 6670da41b0..fcc40b1a22 100644 --- a/docs/desktop-provider-accounts.md +++ b/docs/desktop-provider-accounts.md @@ -13,15 +13,18 @@ The CLI exposes one versioned JSON envelope for each command: ```text verboo provider-accounts capabilities verboo provider-accounts list +verboo provider-accounts models --provider codex|claude --account verboo provider-accounts usage --provider codex|claude [--account ] verboo provider-accounts set-default --provider --account verboo provider-accounts remove --provider --account ``` Every response has `schemaVersion: 1` and either `ok: true, data` or -`ok: false, error: { code, message }`. `capabilities` returns -`provider_accounts_v1`, `provider_usage_v1`, and `loginTransport: -pty-slash-v1`. Provider login remains additive: `/codex login` and +`ok: false, error: { code, message }`. `capabilities` is safe to query before +authentication and returns `provider_accounts_v1`, `provider_usage_v1`, +`loginTransport: pty-slash-v1`, and a non-secret `secureStorage` descriptor +(`native: true` plus the platform adapter name) with a classified read probe +(`ok`, `missing`, or `error`) that never includes credential data. Provider login remains additive: `/codex login` and `/claude login` add a new account when its provider identity is new; a reconnect explicitly names the opaque local account. @@ -72,11 +75,16 @@ Storage uses the existing native adapters on every supported desktop: | Target | Secure storage | | --- | --- | | macOS arm64/x64 | Keychain | -| Windows x64 | Credential Locker | +| Windows x64 | DPAPI-encrypted per-user file (CurrentUser scope) | | Linux x64 | Secret Service | Plaintext fallback remains disabled. +The desktop packaging smoke invokes `provider-accounts capabilities` for every +signed target and rejects an artifact that does not advertise the native +adapter or a classified probe result. The matching release runners still exercise the actual Keychain, +DPAPI, or Secret Service implementation for their operating system. + ## Verification matrix The signed release matrix remains macOS arm64, macOS x64, Windows x64, and diff --git a/scripts/desktop-release/package.test.ts b/scripts/desktop-release/package.test.ts index 3110f5bdd1..fbcfe436c9 100644 --- a/scripts/desktop-release/package.test.ts +++ b/scripts/desktop-release/package.test.ts @@ -25,7 +25,7 @@ async function fixture() { await mkdir(join(source, 'node_modules', 'dependency'), { recursive: true }) await writeFile( join(source, 'dist', 'cli.mjs'), - "// TodoWrite TodoWrite todoFeatureEnabled todo_reminder todo_reminder\nconsole.log('1.2.3 (Verboo Code)')\n", + "// TodoWrite TodoWrite todoFeatureEnabled todo_reminder todo_reminder\nif (process.argv[2] === 'provider-accounts') console.log(JSON.stringify({ schemaVersion: 1, ok: true, data: { protocols: ['provider_accounts_v1'], secureStorage: { native: true, backend: 'fixture', probe: 'missing' } } })); else console.log('1.2.3 (Verboo Code)')\n", ) await writeFile(join(source, 'node_modules', 'dependency', 'index.js'), 'export default 1\n') await writeFile(join(source, 'LICENSE'), 'MIT\n') diff --git a/scripts/desktop-release/package.ts b/scripts/desktop-release/package.ts index c1db837cbb..dfde9080e3 100644 --- a/scripts/desktop-release/package.ts +++ b/scripts/desktop-release/package.ts @@ -136,6 +136,7 @@ export async function packageDesktopCli( try { const payload = await materializePayload({ ...input, stagingRoot }) await smokePayload(input.nodeExecutable, payload, input.version) + await smokeProviderAccounts(input.nodeExecutable, payload) await runProcess('tar', [ '-czf', archivePath, @@ -197,6 +198,58 @@ async function smokePayload( } } +/** + * Exercise the versioned provider-account entrypoint on every signed target. + * Release builders do not have a user's Verboo session, so an auth-required + * envelope is an expected result; a process crash, malformed JSON, or a + * different failure is not. + */ +async function smokeProviderAccounts( + nodeExecutable: string, + payload: string, +): Promise { + const result = await runProcess( + nodeExecutable, + [join(payload, 'dist', 'cli.mjs'), 'provider-accounts', 'capabilities'], + payload, + ) + let envelope: unknown + try { + envelope = JSON.parse(result.stdout.trim()) + } catch { + throw new Error('Provider-account smoke did not return JSON') + } + if (!envelope || typeof envelope !== 'object') { + throw new Error('Provider-account smoke returned an invalid envelope') + } + const record = envelope as { + schemaVersion?: unknown + ok?: unknown + data?: { protocols?: unknown; secureStorage?: { native?: unknown; backend?: unknown; probe?: unknown } } + error?: { code?: unknown } + } + if (record.schemaVersion !== 1) { + throw new Error('Provider-account smoke returned an unsupported schema') + } + if (record.ok === true) { + if (!Array.isArray(record.data?.protocols) + || !record.data.protocols.includes('provider_accounts_v1')) { + throw new Error('Provider-account smoke omitted provider_accounts_v1') + } + if (record.data.secureStorage?.native !== true + || typeof record.data.secureStorage.backend !== 'string' + || !['ok', 'missing', 'error'].includes(String(record.data.secureStorage.probe))) { + throw new Error('Provider-account smoke did not verify native secure storage') + } + return + } + if (record.ok === false && ( + record.error?.code === 'verboo_auth_required' + || record.error?.code === 'provider_auth_required' + )) return + throw new Error('Provider-account smoke returned an unexpected failure') +} + async function hashFile(path: string): Promise<{ size: number; sha256: string }> { const hash = createHash('sha256') let size = 0 diff --git a/scripts/desktop-release/verify-release.test.ts b/scripts/desktop-release/verify-release.test.ts index 3908da5c81..ca93f4894e 100644 --- a/scripts/desktop-release/verify-release.test.ts +++ b/scripts/desktop-release/verify-release.test.ts @@ -29,7 +29,7 @@ async function releaseFixture() { await mkdir(join(source, 'node_modules', 'dependency'), { recursive: true }) await writeFile( join(source, 'dist', 'cli.mjs'), - "// TodoWrite TodoWrite todoFeatureEnabled todo_reminder todo_reminder\nconsole.log('1.2.3 (Verboo Code)')\n", + "// TodoWrite TodoWrite todoFeatureEnabled todo_reminder todo_reminder\nif (process.argv[2] === 'provider-accounts') console.log(JSON.stringify({ schemaVersion: 1, ok: true, data: { protocols: ['provider_accounts_v1'], secureStorage: { native: true, backend: 'fixture', probe: 'missing' } } })); else console.log('1.2.3 (Verboo Code)')\n", ) await writeFile(join(source, 'node_modules', 'dependency', 'index.js'), 'export default 1\n') await writeFile(join(source, 'LICENSE'), 'MIT\n') From 9c1c62782bcce8bdae31d0b9cf402e6050d773eb Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Mon, 10 Aug 2026 00:52:01 -0300 Subject: [PATCH 14/15] fix(provider-accounts): auth-free capabilities fast path and complete lockfile test mock Capabilities skips auth at the entrypoint (the per-subcommand gate stays in the handler), the process exits after emitting the envelope, and the refresh test's lockfile mock gains the lockSync export that the mutation lock imports. --- src/entrypoints/cli.tsx | 7 ++++++- src/main.tsx | 5 +++++ src/utils/auth.refresh.test.ts | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index f87f384ab9..a760169fe3 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -421,12 +421,17 @@ async function main(): Promise { // - auth : login, logout, status precisam funcionar offline. // - logout: alias top-level para `auth logout`. // - update: atualização do binário não precisa de sessão. + // - provider-accounts: protocolo versionado do desktop. capabilities é + // intencionalmente auth-free; os demais subcomandos devolvem envelope + // JSON verboo_auth_required (handler responsável pelo gate, não o + // processo) para o smoke headless não abortar com exit(1). const skipsAuth = args.includes('--help') || args.includes('-h') || args[0] === 'auth' || args[0] === 'logout' || - args[0] === 'update'; + args[0] === 'update' || + args[0] === 'provider-accounts'; if (isVerbooMode() && !skipsAuth) { const { ensureVerbooAuthenticated } = await import( '../services/oauth/verbooStartupAuth.js' diff --git a/src/main.tsx b/src/main.tsx index 8b99df8026..8b6224c235 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4236,6 +4236,11 @@ async function run(): Promise { ) const output = await runProviderAccountsCommand(argv) process.stdout.write(`${JSON.stringify(output)}\n`) + // process.exit (not return) — startup telemetry/analytics sockets can + // leave event loop handles alive after the envelope is flushed. Headless + // protocol commands must terminate deterministically for release smokes. + // eslint-disable-next-line custom-rules/no-process-exit + process.exit(0) } providerAccounts.action(async () => runProviderAccounts(['capabilities'])) providerAccounts diff --git a/src/utils/auth.refresh.test.ts b/src/utils/auth.refresh.test.ts index df5370482e..a4ee20dbe3 100644 --- a/src/utils/auth.refresh.test.ts +++ b/src/utils/auth.refresh.test.ts @@ -28,6 +28,7 @@ beforeAll(() => { })) mock.module('./lockfile.js', () => ({ lock: async () => async () => {}, + lockSync: () => () => {}, })) mock.module('../services/oauth/getOauthProfile.js', () => ({ getOauthProfileFromOauthToken: async () => null, From 9670c174ee74e645d20c824e9083791aa9041536 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Mon, 10 Aug 2026 09:47:28 -0300 Subject: [PATCH 15/15] fix(provider-usage): parse real Claude scoped weekly windows Accepts the real source schema (percent + scope.model.display_name), derives a stable id, synthesizes a weekly window for weekly_scoped; fixture replaced by a real capture; includes an anti-fabrication guard. --- src/services/api/claudeNativeUsage.ts | 36 ++++- .../api/providerUsageProtocol.test.ts | 127 +++++++++++++++--- 2 files changed, 139 insertions(+), 24 deletions(-) diff --git a/src/services/api/claudeNativeUsage.ts b/src/services/api/claudeNativeUsage.ts index f85160f99f..40600b1e89 100644 --- a/src/services/api/claudeNativeUsage.ts +++ b/src/services/api/claudeNativeUsage.ts @@ -44,20 +44,40 @@ function asString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined } +function slugifyScope(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + function normalizeScopedUsage(value: unknown): ClaudeNativeScopedUsage[] { if (!Array.isArray(value)) return [] return value.flatMap(item => { if (!isRecord(item)) return [] - const id = asString(item.id) ?? asString(item.limit_id) ?? asString(item.limitId) + // Schema real de /api/oauth/usage (limits[]): `percent`, + // `scope.model.display_name` e `kind`; sem `id` nem `window_seconds`. + // Schema legado (scoped_limits[]): `id`/`model_scope`/`utilization`. + // Só o display_name do scope é lido — ids internos do modelo nunca vazam. + const scopeRecord = isRecord(item.scope) ? item.scope : undefined + const modelRecord = + scopeRecord && isRecord(scopeRecord.model) ? scopeRecord.model : undefined + const displayName = + asString(modelRecord?.display_name) ?? asString(modelRecord?.displayName) const modelScope = asString(item.model_scope) ?? asString(item.modelScope) ?? - asString(item.scope) + asString(item.scope) ?? + displayName + const legacyId = + asString(item.id) ?? asString(item.limit_id) ?? asString(item.limitId) const utilization = + asNumber(item.percent) ?? asNumber(item.utilization) ?? asNumber(item.used_percentage) ?? asNumber(item.usedPercent) - if (!id || !modelScope || utilization === undefined) return [] + const resolvedScope = modelScope ? slugifyScope(modelScope) : undefined + if (!resolvedScope || utilization === undefined) return [] const windowMinutes = asNumber(item.window_minutes) ?? asNumber(item.windowMinutes) ?? @@ -65,10 +85,14 @@ function normalizeScopedUsage(value: unknown): ClaudeNativeScopedUsage[] { const seconds = asNumber(item.window_seconds) ?? asNumber(item.windowSeconds) return seconds === undefined ? undefined : Math.round(seconds / 60) - })() + })() ?? + // O raw limits[] não carrega a janela; weekly_scoped é sempre semanal. + (item.kind === 'weekly_scoped' ? 10_080 : undefined) return [{ - id, - modelScope, + // id estável derivado do display_name quando o raw não o fornece + // (ex.: claude:weekly-fable no protocolo). + id: legacyId ?? `weekly-${resolvedScope}`, + modelScope: resolvedScope, utilization, windowMinutes, resetsAt: asString(item.resets_at) ?? asString(item.resetsAt), diff --git a/src/services/api/providerUsageProtocol.test.ts b/src/services/api/providerUsageProtocol.test.ts index 33758f3288..4fd7eff013 100644 --- a/src/services/api/providerUsageProtocol.test.ts +++ b/src/services/api/providerUsageProtocol.test.ts @@ -34,40 +34,131 @@ test('Codex Plus keeps only its provider-reported base weekly window', () => { ]) }) -test('Claude Pro has no Fable row while Max retains a reported scoped row', () => { - const pro = normalizeClaudeProviderUsage( - 'local-pro', +test('Claude native limits schema (percent + scope.model.display_name) surfaces a Fable weekly window', () => { + // Fixture capturada do payload real de /api/oauth/usage: o array `limits` + // usa `percent` (não utilization), `scope.model.display_name` (não + // model_scope) e não carrega `id` nem `window_seconds`. + const snapshot = normalizeClaudeProviderUsage( + 'local-claude', { id: 'pro', displayName: 'Pro' }, { - five_hour: { utilization: 15 }, - seven_day: { utilization: 20 }, + five_hour: { + utilization: 8, + resets_at: '2026-08-10T16:00:00.500089+00:00', + }, + seven_day: { + utilization: 5, + resets_at: '2026-08-16T21:00:00.500115+00:00', + }, + limits: [ + { + kind: 'session', + group: 'session', + percent: 8, + severity: 'normal', + resets_at: '2026-08-10T16:00:00.500089+00:00', + scope: null, + is_active: false, + }, + { + kind: 'weekly_all', + group: 'weekly', + percent: 5, + severity: 'normal', + resets_at: '2026-08-16T21:00:00.500115+00:00', + scope: null, + is_active: false, + }, + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 9, + severity: 'normal', + resets_at: '2026-08-16T21:00:00.500504+00:00', + scope: { model: { id: null, display_name: 'Fable' }, surface: null }, + is_active: true, + }, + ], }, ) - const max = normalizeClaudeProviderUsage( - 'local-max', - { id: 'max', displayName: 'Max' }, + + expect(snapshot.windows.map(window => window.kind)).toEqual([ + 'session', + 'weekly', + 'model-scoped-weekly', + ]) + expect(snapshot.windows.at(-1)).toMatchObject({ + id: 'claude:weekly-fable', + kind: 'model-scoped-weekly', + displayLabel: 'Fable Weekly', + modelScope: 'fable', + usedPercent: 9, + resetsAt: '2026-08-16T21:00:00.500504+00:00', + }) +}) + +test('scoped Fable window sanitizes internal model ids from the scope object', () => { + const snapshot = normalizeClaudeProviderUsage( + 'local-claude', + { id: 'pro', displayName: 'Pro' }, { - five_hour: { utilization: 10 }, - seven_day: { utilization: 30 }, + five_hour: null, + seven_day: null, limits: [ { - id: 'fable', - model_scope: 'fable', - window_seconds: 604_800, - utilization: 25, + kind: 'weekly_scoped', + group: 'weekly', + percent: 12, + severity: 'normal', + resets_at: '2026-08-16T21:00:00.500504+00:00', + scope: { + model: { id: 'internal-model-mdrv-0123', display_name: 'Fable' }, + surface: 'chat', + }, + is_active: true, }, ], }, ) - expect(pro.windows.map(window => window.kind)).toEqual(['session', 'weekly']) - expect(max.windows.at(-1)).toMatchObject({ - kind: 'model-scoped-weekly', + const json = JSON.stringify(snapshot) + expect(json).not.toContain('internal-model-mdrv-0123') + expect(json).not.toContain('surface') + expect(snapshot.windows.at(-1)).toMatchObject({ + displayLabel: 'Fable Weekly', modelScope: 'fable', - usedPercent: 25, + usedPercent: 12, }) }) +test('Claude Pro payload without scoped limits fabricates no Fable row', () => { + // Payload sem limits[]/scoped_limits: o protocolo NÃO pode inventar uma + // janela model-scoped-weekly — exatamente [session, weekly]. + const snapshot = normalizeClaudeProviderUsage( + 'local-pro', + { id: 'pro', displayName: 'Pro' }, + { + five_hour: { utilization: 8 }, + seven_day: { utilization: 20 }, + }, + ) + + expect(snapshot.windows).toEqual([ + { + id: 'claude:five-hour', + kind: 'session', + displayLabel: '5 hours', + usedPercent: 8, + }, + { + id: 'claude:weekly', + kind: 'weekly', + displayLabel: 'Weekly', + usedPercent: 20, + }, + ]) +}) + test('normalization drops malformed or missing-reset windows without inventing values', () => { const snapshot = normalizeClaudeProviderUsage( 'local-unknown',