From 4a9a16953324b1664c87049ed71fecd4db162587 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:09:49 +0200 Subject: [PATCH] feat(custody): add /claude-account custody on|off for vault-served fallbacks Custody for a fallback OAuth account was a hand edit of two files: the gate in anthropic-auth.json and the handle in the state file. This adds the command, keeps every failure path fail-closed, and projects the resulting state on every surface that already showed the account. Grammar and contract (core): `custody on|off` on the shared account command; `AccountCustodyCapability` lets the host own vault I/O and persistence while core owns the deterministic guards (account exists, not main, OAuth, enabled). Pi passes an unsupported capability and refuses with a fixed text; nothing is persisted there. Ordered `on` (OpenCode): handle present -> Claustrum detected -> hold the account's own refresh lock (the same acquireRefreshFileLock the background loop takes) for {connect on demand, one credential.get bounded at 15 s, usable at the command clock} -> only then persist claustrum.accounts[id].enabled under acquireAccountConfigWriteLock across load/mutate/save. Any refusal leaves the config byte-identical. The lock closes the incident-1 race: without it a near-expiry sidecar and the vault's get-triggered refresh could spend the same parent refresh token during the verifying call. A contended background tick skips at debug. `off` clears the gate, drops the resident credential, and bumps a per-account generation so an in-flight tick get, startup warm, or timed-out verifying get cannot re-populate the cache afterwards. Projection: one custodyStateFor(account) feeds sidebar, status text, RPC payload, and dialog; one custodyStatusLabel in core; explicit field allowlists with handle-shaped canaries; the TUI tolerates payloads from older servers that lack the custody fields. Verified with red-first tests and per-guard mutations (every ordering guard, the lock, the timeout, the generation fence, the projection allowlist, the Pi refusal); root 1587/0. --- CHANGELOG.md | 6 + README.md | 7 + packages/core/src/accounts.ts | 126 ++- packages/core/src/claustrum.ts | 16 +- packages/core/src/commands/account.ts | 166 +++- packages/opencode/src/index.ts | 386 +++++++-- packages/opencode/src/rpc/protocol.ts | 5 +- packages/opencode/src/sidebar-state.ts | 8 + .../src/tests/account-command.test.ts | 309 +++++-- packages/opencode/src/tests/accounts.test.ts | 83 ++ packages/opencode/src/tests/claustrum.test.ts | 145 +++- .../src/tests/command-dialogs.test.ts | 87 +- .../tests/credential-handle-blindness.test.ts | 6 + packages/opencode/src/tests/index.test.ts | 769 +++++++++++++++++- packages/opencode/src/tui/command-dialogs.tsx | 195 +++-- packages/pi/src/commands.ts | 6 +- packages/pi/src/tests/commands.test.ts | 40 +- 17 files changed, 2137 insertions(+), 223 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c51de3b..3f4845a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. The OpenCode package is a fork of the original `@ex-machina/opencode-anthropic-auth` plugin, so older entries below the initial CortexKit release are inherited from upstream package history. +## Unreleased + +### Patch Changes + +- Document `/claude-account custody on|off`, including its fail-closed OpenCode behavior and Pi refusal. + ## 1.22.0 ### Minor Changes diff --git a/README.md b/README.md index 87f8b1de..8444ebaf 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,13 @@ OpenCode can obtain an opted-in fallback OAuth account's access credential from The request path reads only a resident in-memory credential. Startup warming and periodic custody ticks perform vault I/O and keep idle credentials refreshed; a cold or unavailable vault falls back to the sidecar credential path. Vault-served 401 reports carry the exact record version and response provenance, including relay-stream 401s, so a sidecar-served failure cannot invalidate a healthy vault credential. `/claude-account` and the OpenCode account modal show the gate, current vault service, and vault reauthentication state without exposing capability handles. +Use `/claude-account custody on|off` to change an eligible fallback OAuth account. +`on` verifies a usable vault credential under the account refresh lock, then persists the gate. A failed check leaves the gate off. +`off` persists first, invalidates the resident credential, and returns the account to sidecar service. +Refusals are explicit: `Cannot change custody for the main account.`, `Custody requires an OAuth fallback account.`, and `Cannot enable custody for disabled account "".` +Vault failures report `No custody handle for .`, `Claustrum is not available (...)`, `Vault reports the handle as unknown or revoked.`, `Vault credential needs re-login (...)`, or `Vault unavailable: ... Retry.` +Pi accepts the command but refuses it with `Custody is OpenCode-only in this version.` + Custody currently applies only to fallback OAuth accounts. Main-account vault service is not implemented. If Claustrum has replaced the main host credential with its provider-bound tombstone, the plugin rejects refresh locally without contacting Anthropic or persisting a permanent `invalid_grant` state. ## Quota-aware routing diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index e1f55fb0..cb3a3d6e 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -1514,6 +1514,44 @@ export function isClaustrumEnabledForAccount( return storage.claustrum?.accounts?.[accountId]?.enabled === true } +export async function setClaustrumAccountGatePersistent(input: { + id: string + enabled: boolean + path?: string +}): Promise<'updated' | 'unchanged' | 'missing' | 'ineligible'> { + const path = input.path ?? getAccountStoragePath() + return enqueueSave(async () => { + const lock = await acquireAccountConfigWriteLock(path) + try { + const storage = await loadAccounts(path) + if (!storage) { + return 'missing' + } + const account = storage.accounts.find( + (candidate) => candidate.id === input.id, + ) + if (!account) return 'missing' + if (!isOAuthAccount(account)) return 'ineligible' + if (isClaustrumEnabledForAccount(storage, input.id) === input.enabled) { + return 'unchanged' + } + + const accounts = storage.claustrum?.accounts ?? {} + storage.claustrum = { + ...storage.claustrum, + accounts: { + ...accounts, + [input.id]: { ...accounts[input.id], enabled: input.enabled }, + }, + } + await saveAccountsWithConfigLock(storage, path, {}) + return 'updated' + } finally { + await lock.release() + } + }) +} + // --------------------------------------------------------------------------- // In-process save mutex — serializes all account-store writes so concurrent // read-modify-write callers (background timers that call saveAccountState with @@ -1688,35 +1726,43 @@ async function saveAccountsLocked( ) { const lock = await acquireAccountConfigWriteLock(path) try { - const current = await loadAccounts(path) - const nextStorage: AccountStorage = { - ...storage, - accounts: mergeAccountsForSave( - current?.accounts ?? [], - storage.accounts, - options, - ), - } - const existing = await loadExistingTopLevelFields(path) - const nextConfig = { ...existing, ...configFromStorage(nextStorage) } - await writeJsonAtomic(path, nextConfig) - // Config precedes state everywhere both locks are needed; reversing this - // order can deadlock profile mutations against full account saves. - const stateLock = await acquireAccountStateWriteLock(path) - try { - await saveAccountStateUnlocked(nextStorage, path, { - mainQuota: true, - mainRefresh: true, - accounts: true, - }) - } finally { - await stateLock.release() - } + await saveAccountsWithConfigLock(storage, path, options) } finally { await lock.release() } } +async function saveAccountsWithConfigLock( + storage: AccountStorage, + path: string, + options: SaveAccountsOptions, +) { + const current = await loadAccounts(path) + const nextStorage: AccountStorage = { + ...storage, + accounts: mergeAccountsForSave( + current?.accounts ?? [], + storage.accounts, + options, + ), + } + const existing = await loadExistingTopLevelFields(path) + const nextConfig = { ...existing, ...configFromStorage(nextStorage) } + await writeJsonAtomic(path, nextConfig) + // Config precedes state everywhere both locks are needed; reversing this + // order can deadlock profile mutations against full account saves. + const stateLock = await acquireAccountStateWriteLock(path) + try { + await saveAccountStateUnlocked(nextStorage, path, { + mainQuota: true, + mainRefresh: true, + accounts: true, + }) + } finally { + await stateLock.release() + } +} + function applyMainProfileStatePatch( state: AccountRuntimeState, storage: AccountStorage, @@ -3811,6 +3857,7 @@ export class FallbackAccountManager { private readonly fetchImpl: typeof fetch private readonly configPath: string private readonly refreshPromises = new Map>() + private readonly custodyVerificationAccounts = new Set() private refreshTimer: ReturnType | null = null private quotaTimer: ReturnType | null = null readonly quotaManager: import('./quota-manager.ts').QuotaManager | null @@ -3921,6 +3968,27 @@ export class FallbackAccountManager { this.quotaTimer = null } + async withAccountRefreshLock( + accountId: string, + fn: () => Promise, + ): Promise { + const lock = await acquireRefreshFileLock({ + name: fallbackRefreshLockName(accountId), + ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS, + path: this.configPath, + now: this.now, + renew: true, + }) + if (!lock) throw new Error('Fallback OAuth refresh is already in progress') + this.custodyVerificationAccounts.add(accountId) + try { + return await fn() + } finally { + this.custodyVerificationAccounts.delete(accountId) + await lock.release() + } + } + async getUsableFallbackAccounts( existingStorage?: AccountStorage | null, options: { modelId?: string } = {}, @@ -4078,6 +4146,16 @@ export class FallbackAccountManager { let changed = false for (const account of storage.accounts) { if (account.enabled === false || !isOAuthAccount(account)) continue + if (this.custodyVerificationAccounts.has(account.id)) { + logger.debug( + 'refresh', + 'fallback oauth background skipped custody verification', + { + accountId: account.id, + }, + ) + continue + } if ( !tokenNeedsRefresh(account, storage, this.now()) || this.isFallbackAccountVaultEnabled(account.id, storage) || diff --git a/packages/core/src/claustrum.ts b/packages/core/src/claustrum.ts index c464b41f..8327a185 100644 --- a/packages/core/src/claustrum.ts +++ b/packages/core/src/claustrum.ts @@ -638,6 +638,7 @@ export class ClaustrumCredentialCache { async get( handle: string, minTtlMs = this.#minTtlMs, + options: { cacheIf?: () => boolean } = {}, ): Promise { if (!Number.isSafeInteger(minTtlMs) || minTtlMs < 0) { throw new RangeError('minTtlMs must be a non-negative safe integer') @@ -658,7 +659,7 @@ export class ClaustrumCredentialCache { const pending = this.#inFlight.get(handle) if (pending) return pending - const load = this.#load(handle, minTtlMs) + const load = this.#load(handle, minTtlMs, options.cacheIf) this.#inFlight.set(handle, load) try { return await load @@ -671,6 +672,10 @@ export class ClaustrumCredentialCache { return this.#cache.get(handle) } + abandonPending(handle: string): void { + this.#inFlight.delete(handle) + } + seedForTest(handle: string, credential: ClaustrumCredential): void { this.#cache.set(handle, credential) } @@ -787,7 +792,11 @@ export class ClaustrumCredentialCache { }) } - async #load(handle: string, minTtlMs: number): Promise { + async #load( + handle: string, + minTtlMs: number, + cacheIf?: () => boolean, + ): Promise { let response: unknown try { response = await this.#client.call( @@ -813,7 +822,8 @@ export class ClaustrumCredentialCache { } if ( credential.expiresAtMs !== null && - credential.expiresAtMs > this.#now() + credential.expiresAtMs > this.#now() && + (cacheIf?.() ?? true) ) { this.#cache.set(handle, credential) } diff --git a/packages/core/src/commands/account.ts b/packages/core/src/commands/account.ts index fd2c5623..69feb402 100644 --- a/packages/core/src/commands/account.ts +++ b/packages/core/src/commands/account.ts @@ -1,5 +1,9 @@ -import type { AccountStorage, FallbackAccount } from '../accounts.ts' -import { isClaustrumEnabledForAccount } from '../accounts.ts' +import type { + AccountStorage, + FallbackAccount, + OAuthAccount, +} from '../accounts.ts' +import { isClaustrumEnabledForAccount, isOAuthAccount } from '../accounts.ts' import type { ClaustrumDetection } from '../claustrum.ts' import { formatOAuthAccountTier } from '../oauth-profile.ts' @@ -7,6 +11,7 @@ export const CLAUDE_ACCOUNT_COMMAND_NAME = 'claude-account' export type AccountCommandAction = | { type: 'status' } + | { type: 'custody'; id: string; enabled: boolean } | { type: 'enable'; id: string } | { type: 'disable'; id: string } | { type: 'remove'; id: string } @@ -24,6 +29,34 @@ export type AccountCommandAction = | { type: 'add-oauth-finish'; code: string; label?: string } | { type: 'usage' } +export type AccountCustodyCapability = + | { + platform: 'opencode' + set(input: { + account: OAuthAccount + storage: AccountStorage + enabled: boolean + }): Promise<{ text: string; changed: boolean }> + } + | { platform: 'unsupported'; reason: string } + +export type AccountCommandResult = { + text: string + updated?: { + id: string + action: + | 'enable' + | 'disable' + | 'remove' + | 'reorder' + | 'reset-backoff' + | 'custody' + enabled?: boolean + previousOrder?: string[] + newOrder?: string[] + } +} + export function parseAccountCommandAction( argumentsText: string, ): AccountCommandAction { @@ -39,6 +72,20 @@ export function parseAccountCommandAction( if (action === 'move-up' && rest) return { type: 'move-up', id: rest } if (action === 'move-down' && rest) return { type: 'move-down', id: rest } if (action === 'reset-backoff' && !rest) return { type: 'reset-backoff' } + const custodyId = parts[1] + const custodyState = parts[2] + if ( + action === 'custody' && + parts.length === 3 && + custodyId !== undefined && + (custodyState === 'on' || custodyState === 'off') + ) { + return { + type: 'custody', + id: custodyId, + enabled: custodyState === 'on', + } + } if (action === 'add-apikey' && rest) { let remaining = rest @@ -123,6 +170,41 @@ export interface AccountListItem { tierLabel?: string } +export type CustodyStatusState = + | 'na' + | 'off' + | 'on-vault-served' + | 'on-vault-reauth' + | 'on-cold' + +export function custodyStatusLabel(state: CustodyStatusState): string { + switch (state) { + case 'na': + return 'n/a (OpenCode managed)' + case 'off': + return 'off' + case 'on-vault-served': + return 'on · vault-served' + case 'on-vault-reauth': + return 'on · vault reauth' + case 'on-cold': + return 'on · cold' + } +} + +export type AccountCommandStatusProjection = { + claustrumDetection: string + accounts: Array< + AccountListItem & { + claustrumGate: 'on' | 'off' | 'na' + vaultServed: boolean + vaultReauth: boolean + custodyState: CustodyStatusState + custodyEligible: boolean + } + > +} + export function buildAccountList(storage: AccountStorage): AccountListItem[] { const list: AccountListItem[] = [] @@ -163,6 +245,7 @@ const USAGE_TEXT = [ ' /claude-account enable Enable a fallback account', ' /claude-account disable Disable a fallback account', ' /claude-account remove Remove a fallback account', + ' /claude-account custody on|off Set fallback custody gate', ' /claude-account move-up Move a fallback account up', ' /claude-account move-down Move a fallback account down', ' /claude-account reset-backoff Clear main OAuth refresh and quota backoff', @@ -171,38 +254,44 @@ const USAGE_TEXT = [ ' /claude-account add-oauth-finish Complete OAuth flow', ].join('\n') -export function executeAccountCommand(input: { +export async function executeAccountCommand(input: { argumentsText: string storage: AccountStorage claustrum?: ClaustrumDetection -}): { - text: string - updated?: { - id: string - action: 'enable' | 'disable' | 'remove' | 'reorder' | 'reset-backoff' - enabled?: boolean - previousOrder?: string[] - newOrder?: string[] - } -} { + custody?: AccountCustodyCapability + statusProjection?: AccountCommandStatusProjection +}): Promise { const action = parseAccountCommandAction(input.argumentsText) const accounts = input.storage.accounts const mainId = 'main' if (action.type === 'status') { - const list = buildAccountList(input.storage) - const detection = input.claustrum?.status ?? 'unknown' + const list = + input.statusProjection?.accounts ?? buildAccountList(input.storage) + const detection = + input.statusProjection?.claustrumDetection ?? + input.claustrum?.status ?? + 'unknown' const lines = ['## Claude Accounts', '', `- Claustrum: ${detection}`, ''] for (const a of list) { const pct = a.quotaPercent != null ? ` ${Math.round(a.quotaPercent)}%` : '' const status = !a.enabled ? ' (disabled)' : '' const tier = a.tierLabel ? ` · ${a.tierLabel}` : '' - const gate = - a.id === mainId - ? ' · gate n/a (OpenCode managed)' - : ` · gate ${isClaustrumEnabledForAccount(input.storage, a.id) ? 'on' : 'off'}` - lines.push(`- **${a.label}** [${a.role}]${tier}${status}${pct}${gate}`) + const projected = input.statusProjection?.accounts.find( + (account) => account.id === a.id, + ) + const custody = custodyStatusLabel( + projected?.custodyState ?? + (a.id === mainId + ? 'na' + : isClaustrumEnabledForAccount(input.storage, a.id) + ? 'on-cold' + : 'off'), + ) + lines.push( + `- **${a.label}** [${a.role}]${tier}${status}${pct} · custody ${custody}`, + ) } lines.push('', USAGE_TEXT) return { text: lines.join('\n') } @@ -228,6 +317,43 @@ export function executeAccountCommand(input: { } } + if (action.type === 'custody') { + const target = accounts.find((account) => account.id === action.id) + if (!target) return { text: `Account "${action.id}" not found.` } + if (target.id === mainId) { + return { + text: 'Cannot change custody for the main account.', + } + } + if (!isOAuthAccount(target)) { + return { text: 'Custody requires an OAuth fallback account.' } + } + if (action.enabled && !target.enabled) { + return { + text: `Cannot enable custody for disabled account "${action.id}".`, + } + } + if (input.custody?.platform !== 'opencode') { + return { text: 'Custody is OpenCode-only in this version.' } + } + + const result = await input.custody.set({ + account: target, + storage: input.storage, + enabled: action.enabled, + }) + return { + text: result.text, + ...(result.changed && { + updated: { + id: action.id, + action: 'custody' as const, + enabled: action.enabled, + }, + }), + } + } + const id = action.id if (id === mainId) { return { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5822c771..0c7a07ca 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { + type AccountCommandStatusProjection, type AccountStorage, type ApiKeyAccount, acquireRefreshFileLock, @@ -33,6 +34,7 @@ import { type ClaustrumCredentialCache, ClaustrumCredentialError, type ClaustrumReporterSource, + type CustodyStatusState, CustodyTombstoneRefreshError, clearClaustrumRefreshErrorPersistent, computeXxhash64Hex, @@ -167,6 +169,7 @@ import { setCacheKeepPersistentEnabled, setCacheKeepPersistentWindow, setCacheKeepSubagentsEnabled, + setClaustrumAccountGatePersistent, setDumpEnabled, setDumpPersistentEnabled, setFastModeEnabled, @@ -866,6 +869,7 @@ type PluginRuntimeOverrides = Partial<{ // Keep boot above the resident IPC fast path, but never let a stale-marked // refresh turn a vault treadmill into a seconds-long plugin-start delay. const CLAUSTRUM_WARMUP_TIMEOUT_MS = 100 +const CLAUSTRUM_CUSTODY_VERIFY_TIMEOUT_MS = 15_000 const CLAUSTRUM_TRANSIENT_WARM_BACKOFF_MS = 5_000 const CLAUSTRUM_REAUTH_WARM_BACKOFF_MS = FALLBACK_BACKGROUND_TICK_MS @@ -1688,6 +1692,7 @@ const anthropicAuthPlugin = async ( const claustrumReauthAccounts = new Set() const claustrumWarmScheduled = new Set() const claustrumWarmBackoffUntil = new Map() + const claustrumGateGenerations = new Map() let claustrumConnectBackoffUntil = 0 const claustrumAccounts = initialStorage ? initialStorage.accounts.filter( @@ -1717,6 +1722,44 @@ const anthropicAuthPlugin = async ( return Boolean(cached && usableClaustrumAccessToken(cached, claustrumNow())) } + const custodyStateFor = ( + account: { id: string; role: 'main' | 'fallback' }, + storage: Awaited>, + vaultServed = isFallbackAccountVaultServed(account.id, storage), + ): CustodyStatusState => { + if (account.role === 'main') return 'na' + if (!storage) return 'off' + if (!isClaustrumEnabledForAccount(storage, account.id)) return 'off' + if (claustrumReauthAccounts.has(account.id)) return 'on-vault-reauth' + if (vaultServed) { + return 'on-vault-served' + } + return 'on-cold' + } + + const fallbackCustodyStateFor = ( + accountId: string, + storage: Awaited>, + vaultServed = isFallbackAccountVaultServed(accountId, storage), + ): Exclude => { + const state = custodyStateFor( + { id: accountId, role: 'fallback' }, + storage, + vaultServed, + ) + return state === 'na' ? 'off' : state + } + + const claustrumGateGeneration = (accountId: string) => + claustrumGateGenerations.get(accountId) ?? 0 + + const bumpClaustrumGateGeneration = (accountId: string) => { + claustrumGateGenerations.set( + accountId, + claustrumGateGeneration(accountId) + 1, + ) + } + function claustrumWarmBackoffActive(handle: string): boolean { const retryAt = claustrumWarmBackoffUntil.get(handle) if (retryAt === undefined) return false @@ -1810,8 +1853,13 @@ const anthropicAuthPlugin = async ( ): Promise { const cache = claustrumCredentialCache if (!cache) return + const generation = claustrumGateGeneration(accountId) try { const credential = await cache.get(handle) + if (generation !== claustrumGateGeneration(accountId)) { + cache.invalidate(handle) + return + } if (usableClaustrumAccessToken(credential, claustrumNow())) { await markClaustrumCredentialReady(accountId, handle) } @@ -2026,7 +2074,12 @@ const anthropicAuthPlugin = async ( continue } try { + const generation = claustrumGateGeneration(account.id) const credential = await cache.get(handle, minTtlMs) + if (generation !== claustrumGateGeneration(account.id)) { + cache.invalidate(handle) + continue + } if (!usableClaustrumAccessToken(credential, claustrumNow())) { log('[refresh] vault fallback credential unusable', { accountId: account.id, @@ -2081,8 +2134,13 @@ const anthropicAuthPlugin = async ( claustrumAccounts.map(async (account) => { const handle = account.claustrumHandle if (!handle) return + const generation = claustrumGateGeneration(account.id) try { const credential = await cache.get(handle) + if (generation !== claustrumGateGeneration(account.id)) { + cache.invalidate(handle) + return + } if (usableClaustrumAccessToken(credential, claustrumNow())) { await markClaustrumCredentialReady(account.id, handle) } @@ -2927,38 +2985,44 @@ const anthropicAuthPlugin = async ( (account): account is OAuthAccount => account.enabled !== false && isOAuthAccount(account), ) - .map((account) => ({ - id: account.id, - label: account.label, - tierLabel: formatOAuthAccountTier(account.profile), - // Token-aware read: if a fallback account was re-logged with the same - // id/label, an old in-memory quota snapshot must not be shown as the - // new account's quota. - quota: options.skipFallbackQuotaSeed - ? null - : account.access - ? (quotaManager.getFallback(account.id, account)?.quota ?? null) - : null, - // A fallback with a permanently-dead refresh token (400 invalid_grant) - // is dropped by getUsableFallbackAccounts and silently degrades to - // main — surface it as "needs re-login". Only flag truly-dead tokens - // whose backoff is still active, not transient (429/5xx) backoff. - needsReauth: - account.lastRefreshError != null && - refreshBackoffActive( - account.lastRefreshError, - account.id, - Date.now(), - tokenFingerprint(account.refresh), - ) && - isPermanentRefreshError(account.lastRefreshError), - vaultReauth: - claustrumReauthAccounts.has(account.id) && - Boolean( - account.access && account.expires && account.expires > Date.now(), - ), - enabled: account.enabled !== false, - })), + .map((account) => { + const vaultServed = isFallbackAccountVaultServed(account.id, storage) + const custodyState = fallbackCustodyStateFor( + account.id, + storage, + vaultServed, + ) + return { + id: account.id, + label: account.label, + tierLabel: formatOAuthAccountTier(account.profile), + // Token-aware read: if a fallback account was re-logged with the same + // id/label, an old in-memory quota snapshot must not be shown as the + // new account's quota. + quota: options.skipFallbackQuotaSeed + ? null + : account.access + ? (quotaManager.getFallback(account.id, account)?.quota ?? null) + : null, + // A fallback with a permanently-dead refresh token (400 invalid_grant) + // is dropped by getUsableFallbackAccounts and silently degrades to + // main — surface it as "needs re-login". Only flag truly-dead tokens + // whose backoff is still active, not transient (429/5xx) backoff. + needsReauth: + account.lastRefreshError != null && + refreshBackoffActive( + account.lastRefreshError, + account.id, + Date.now(), + tokenFingerprint(account.refresh), + ) && + isPermanentRefreshError(account.lastRefreshError), + vaultReauth: custodyState === 'on-vault-reauth', + vaultServed, + custodyState, + enabled: account.enabled !== false, + } + }), activeId: options.activeId, route: options.route, relay: (() => { @@ -3916,15 +3980,209 @@ const anthropicAuthPlugin = async ( AbortSignal.timeout(3_000), ) } - const result = executeAccountCommand({ + const statusProjection = + action.type === 'status' + ? ((await buildAccountDialogProjection( + storage, + )) satisfies AccountCommandStatusProjection) + : undefined + const result = await executeAccountCommand({ argumentsText, storage: storage ?? { version: 1, accounts: [] }, claustrum: - action.type === 'status' + action.type === 'status' && !statusProjection ? await detectClaustrumConnection( getConfiguredClaustrumConnectionFile(), ) : undefined, + statusProjection, + custody: { + platform: 'opencode', + async set({ account, storage, enabled }) { + const refuse = (step: string, errorClass: string, text: string) => { + logger.warn('commands', 'custody gate refused', { + id: account.id, + step, + errorClass, + }) + return { text, changed: false } + } + const handle = account.claustrumHandle + + if (!enabled) { + bumpClaustrumGateGeneration(account.id) + const changed = await setClaustrumAccountGatePersistent({ + id: account.id, + enabled: false, + path: accountStoragePath, + }) + if (changed === 'missing') { + return refuse( + 'persist', + 'missing', + `Account "${account.id}" not found.`, + ) + } + if (handle) claustrumCredentialCache?.invalidate(handle) + claustrumBlockedAccounts.delete(account.id) + claustrumReauthAccounts.delete(account.id) + if (handle) claustrumWarmBackoffUntil.delete(handle) + if (changed === 'unchanged') { + return { + text: `Custody already off for ${account.id}.`, + changed: false, + } + } + logger.info('commands', 'custody gate changed', { + id: account.id, + enabled: false, + }) + return { + text: `Custody off for ${account.id} (plugin-served).`, + changed: true, + } + } + + if (!handle) { + return refuse( + 'handle', + 'missing_handle', + `No custody handle for ${account.id}. Mint one with ck auth mint-handle and store it, then retry.`, + ) + } + const detection = await detectClaustrumConnection( + getConfiguredClaustrumConnectionFile(), + ) + if (detection.status !== 'available') { + const reason = + detection.status === 'absent' + ? 'connection file absent' + : detection.reason + return refuse( + 'connection', + detection.status, + `Claustrum is not available (${reason}).`, + ) + } + + return fallbackManager.withAccountRefreshLock( + account.id, + async () => { + const cache = await ensureClaustrumCredentialCache() + if (!cache) { + return refuse( + 'connect', + 'transient', + 'Vault unavailable: transient. Retry.', + ) + } + const minTtlMs = getRefreshBeforeExpiryMs(storage) + 30 * 60_000 + const verificationGeneration = claustrumGateGeneration(account.id) + let timeout: ReturnType | undefined + const timeoutError = new Error( + 'custody vault verification timed out', + ) + try { + const timeoutPromise = new Promise((_, reject) => { + timeout = runtimeTimers.setTimeout( + () => reject(timeoutError), + CLAUSTRUM_CUSTODY_VERIFY_TIMEOUT_MS, + ) + if ('unref' in timeout) timeout.unref() + }) + const credential = await Promise.race([ + cache.get(handle, minTtlMs, { + cacheIf: () => + verificationGeneration === + claustrumGateGeneration(account.id), + }), + timeoutPromise, + ]) + if (!usableClaustrumAccessToken(credential, claustrumNow())) { + return refuse( + 'credential', + 'unusable', + 'Vault unavailable: unusable. Retry.', + ) + } + } catch (error) { + if (error === timeoutError) { + bumpClaustrumGateGeneration(account.id) + cache.abandonPending(handle) + return refuse( + 'credential', + 'timeout', + 'Vault unavailable: timeout. Retry.', + ) + } + if (error instanceof ClaustrumCredentialError) { + if (error.errorClass === 'permanent') { + return refuse( + 'credential', + error.errorClass, + 'Vault reports the handle as unknown or revoked.', + ) + } + if (error.errorClass === 'auth_required') { + return refuse( + 'credential', + error.errorClass, + `Vault credential needs re-login (ck auth login --id oauth:anthropic:${account.label ?? account.id}).`, + ) + } + return refuse( + 'credential', + error.errorClass, + `Vault unavailable: ${error.errorClass}. Retry.`, + ) + } + return refuse( + 'credential', + 'transient', + 'Vault unavailable: transient. Retry.', + ) + } finally { + if (timeout) globalThis.clearTimeout(timeout) + } + + const changed = await setClaustrumAccountGatePersistent({ + id: account.id, + enabled: true, + path: accountStoragePath, + }) + if (changed === 'missing') { + return refuse( + 'persist', + 'missing', + `Account "${account.id}" not found.`, + ) + } + if (changed === 'ineligible') { + return refuse( + 'persist', + 'ineligible', + 'Custody requires an OAuth fallback account.', + ) + } + if (changed === 'unchanged') { + return { + text: `Custody already on for ${account.id}.`, + changed: false, + } + } + await markClaustrumCredentialReady(account.id, handle) + logger.info('commands', 'custody gate changed', { + id: account.id, + enabled: true, + }) + return { + text: `Custody on for ${account.id} (vault-served).`, + changed: true, + } + }, + ) + }, + }, }) if (result.updated) { @@ -4026,31 +4284,46 @@ const anthropicAuthPlugin = async ( const accounts = buildAccountList( updatedStorage ?? { version: 1, accounts: [] }, ) - return { text: result.text, accounts } + return { text: result.text, accounts, statusProjection } } - async function buildAccountDialogProjection(): Promise<{ + async function buildAccountDialogProjection( + storageOverride?: Awaited>, + ): Promise<{ accounts: AccountDialogAccount[] claustrumDetection: string }> { - const storage = await loadAccounts(accountStoragePath) - const accounts = buildAccountList(storage ?? createEmptyStorage()).map( - (account) => ({ - ...account, - claustrumGate: - account.role === 'main' - ? ('na' as const) - : isClaustrumEnabledForAccount( - storage ?? createEmptyStorage(), - account.id, - ) - ? ('on' as const) - : ('off' as const), - vaultServed: + const storage = storageOverride ?? (await loadAccounts(accountStoragePath)) + const accountStorage = storage ?? createEmptyStorage() + const accounts = buildAccountList(accountStorage).map((account) => { + const stored = accountStorage.accounts.find( + (candidate) => candidate.id === account.id, + ) + const claustrumGate = + account.role === 'main' + ? ('na' as const) + : isClaustrumEnabledForAccount(accountStorage, account.id) + ? ('on' as const) + : ('off' as const) + const custodyState = custodyStateFor(account, storage) + return { + id: account.id, + label: account.label, + role: account.role, + enabled: account.enabled, + quotaPercent: account.quotaPercent, + ...(account.tierLabel && { tierLabel: account.tierLabel }), + claustrumGate, + custodyState, + vaultServed: custodyState === 'on-vault-served', + vaultReauth: custodyState === 'on-vault-reauth', + custodyEligible: account.role === 'fallback' && - isFallbackAccountVaultServed(account.id, storage), - }), - ) + stored !== undefined && + isOAuthAccount(stored) && + stored.enabled !== false, + } + }) const detection = await detectClaustrumConnection( getConfiguredClaustrumConnectionFile(), ) @@ -4083,7 +4356,8 @@ const anthropicAuthPlugin = async ( } if (command === 'claude-account') { const result = await executePersistentAccountCommand(args, sessionId) - const accountProjection = await buildAccountDialogProjection() + const accountProjection = + result.statusProjection ?? (await buildAccountDialogProjection()) const knobs: Record = { accounts: accountProjection.accounts, claustrumDetection: accountProjection.claustrumDetection, @@ -7668,7 +7942,9 @@ const anthropicAuthPlugin = async ( __quotaManager: quotaManager, __persistFallbackQuotaErrorForTest: persistFallbackQuotaError, __fallbackRefreshReady: fallbackRefreshReady, - __claustrumCredentialCache: claustrumCredentialCache, + get __claustrumCredentialCache() { + return claustrumCredentialCache + }, // biome-ignore lint/suspicious/noExplicitAny: Plugin type doesn't include undocumented auth/hooks } as any } diff --git a/packages/opencode/src/rpc/protocol.ts b/packages/opencode/src/rpc/protocol.ts index 1a0e2a78..7688480d 100644 --- a/packages/opencode/src/rpc/protocol.ts +++ b/packages/opencode/src/rpc/protocol.ts @@ -17,12 +17,15 @@ export type CommandModalName = (typeof COMMAND_MODAL_NAMES)[number] export interface AccountDialogAccount { id: string label: string - role: string + role: 'main' | 'fallback' enabled: boolean quotaPercent: number | null tierLabel?: string claustrumGate: 'on' | 'off' | 'na' vaultServed: boolean + vaultReauth: boolean + custodyState: 'na' | 'off' | 'on-vault-served' | 'on-vault-reauth' | 'on-cold' + custodyEligible: boolean } export interface AccountDialogKnobs { diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index e8b0ae1d..23b021d2 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -36,6 +36,9 @@ export interface SidebarAccountState { needsReauth: boolean // True when the vault copy needs re-importing while the sidecar remains usable. vaultReauth?: boolean + // A gate alone does not prove the sidecar can serve a request. + vaultServed?: boolean + custodyState?: 'off' | 'on-vault-served' | 'on-vault-reauth' | 'on-cold' tierLabel?: string } @@ -338,6 +341,11 @@ export function normalizeSidebarState(raw: unknown): SidebarState { needsReauth: typeof entry.needsReauth === 'boolean' ? entry.needsReauth : false, ...(entry.vaultReauth === true && { vaultReauth: true }), + ...(entry.vaultServed === true && { vaultServed: true }), + ...(typeof entry.custodyState === 'string' && { + custodyState: + entry.custodyState as SidebarAccountState['custodyState'], + }), tierLabel: typeof entry.tierLabel === 'string' && entry.tierLabel.trim() ? entry.tierLabel.trim() diff --git a/packages/opencode/src/tests/account-command.test.ts b/packages/opencode/src/tests/account-command.test.ts index cbf34301..2966729c 100644 --- a/packages/opencode/src/tests/account-command.test.ts +++ b/packages/opencode/src/tests/account-command.test.ts @@ -7,13 +7,14 @@ import { mock, test, } from 'bun:test' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { __setLogTestSink, type AccountStorage, buildAccountList, + custodyStatusLabel, executeAccountCommand, type LogTestRecord, loadAccounts, @@ -24,6 +25,7 @@ import { setAccountEnabledPersistent, } from '@cortexkit/anthropic-auth-core' import { AnthropicAuthPlugin } from '../index' +import { drainNotifications } from '../rpc/notifications' import { DEFAULT_FETCH_MOCK, installDefaultFetchMock } from './test-fetch' import { createTimerTracking, @@ -138,61 +140,83 @@ afterAll(async () => { // parseAccountCommandAction // --------------------------------------------------------------------------- describe('parseAccountCommandAction', () => { - test('bare command returns status', () => { + test('bare command returns status', async () => { expect(parseAccountCommandAction('')).toEqual({ type: 'status' }) }) - test('enable with id', () => { + test('enable with id', async () => { expect(parseAccountCommandAction('enable fallback-1')).toEqual({ type: 'enable', id: 'fallback-1', }) }) - test('disable with id', () => { + test('disable with id', async () => { expect(parseAccountCommandAction('disable fallback-1')).toEqual({ type: 'disable', id: 'fallback-1', }) }) - test('remove with id', () => { + test('custody on with id', async () => { + expect(parseAccountCommandAction('custody fallback-1 on')).toEqual({ + type: 'custody', + id: 'fallback-1', + enabled: true, + }) + }) + + test('custody off with id', async () => { + expect(parseAccountCommandAction('custody fallback-1 off')).toEqual({ + type: 'custody', + id: 'fallback-1', + enabled: false, + }) + }) + + test('custody with a malformed state returns usage', async () => { + expect(parseAccountCommandAction('custody fallback-1 maybe')).toEqual({ + type: 'usage', + }) + }) + + test('remove with id', async () => { expect(parseAccountCommandAction('remove fallback-1')).toEqual({ type: 'remove', id: 'fallback-1', }) }) - test('move-up with id', () => { + test('move-up with id', async () => { expect(parseAccountCommandAction('move-up fallback-1')).toEqual({ type: 'move-up', id: 'fallback-1', }) }) - test('move-down with id', () => { + test('move-down with id', async () => { expect(parseAccountCommandAction('move-down fallback-1')).toEqual({ type: 'move-down', id: 'fallback-1', }) }) - test('enable without id returns usage', () => { + test('enable without id returns usage', async () => { expect(parseAccountCommandAction('enable')).toEqual({ type: 'usage' }) }) - test('garbage returns usage', () => { + test('garbage returns usage', async () => { expect(parseAccountCommandAction('garbage')).toEqual({ type: 'usage' }) }) - test('add-oauth-finish with code only (no label)', () => { + test('add-oauth-finish with code only (no label)', async () => { expect(parseAccountCommandAction('add-oauth-finish abc123')).toEqual({ type: 'add-oauth-finish', code: 'abc123', }) }) - test('add-oauth-finish with --label', () => { + test('add-oauth-finish with --label', async () => { expect( parseAccountCommandAction('add-oauth-finish abc123 --label work'), ).toEqual({ @@ -202,7 +226,7 @@ describe('parseAccountCommandAction', () => { }) }) - test('add-oauth-finish --label with multi-word label', () => { + test('add-oauth-finish --label with multi-word label', async () => { expect( parseAccountCommandAction('add-oauth-finish abc123 --label my work acct'), ).toEqual({ @@ -240,14 +264,14 @@ describe('buildAccountList', () => { expect(list[3]!.enabled).toBe(false) }) - test('no main quota returns null percent', () => { + test('no main quota returns null percent', async () => { const storage = baseStorage() storage.quota!.mainQuota = undefined const list = buildAccountList(storage) expect(list[0]!.quotaPercent).toBeNull() }) - test('no label falls back to id', () => { + test('no label falls back to id', async () => { const storage: AccountStorage = { version: 1, accounts: [{ id: 'abc', type: 'oauth', refresh: 'x' }], @@ -256,7 +280,7 @@ describe('buildAccountList', () => { expect(list[1]!.label).toBe('abc') }) - test('buildAccountList adds tierLabel only when profile exists', () => { + test('buildAccountList adds tierLabel only when profile exists', async () => { const storage = baseStorage() storage.main = { ...storage.main!, @@ -281,7 +305,7 @@ describe('buildAccountList', () => { expect(list[2]!.tierLabel).toBeUndefined() }) - test('account modal includes optional tier label', () => { + test('account modal includes optional tier label', async () => { const storage = baseStorage() storage.main = { ...storage.main!, @@ -292,7 +316,7 @@ describe('buildAccountList', () => { }, } - const result = executeAccountCommand({ argumentsText: '', storage }) + const result = await executeAccountCommand({ argumentsText: '', storage }) expect(result.text).toContain('Max 20x') }) @@ -302,9 +326,17 @@ describe('buildAccountList', () => { // executeAccountCommand — status // --------------------------------------------------------------------------- describe('executeAccountCommand status', () => { - test('bare status returns account list in text', () => { + test('labels every custody state from the shared formatter', () => { + expect(custodyStatusLabel('na')).toBe('n/a (OpenCode managed)') + expect(custodyStatusLabel('off')).toBe('off') + expect(custodyStatusLabel('on-vault-served')).toBe('on · vault-served') + expect(custodyStatusLabel('on-vault-reauth')).toBe('on · vault reauth') + expect(custodyStatusLabel('on-cold')).toBe('on · cold') + }) + + test('bare status returns account list in text', async () => { const storage = baseStorage() - const result = executeAccountCommand({ argumentsText: '', storage }) + const result = await executeAccountCommand({ argumentsText: '', storage }) expect(result.text).toContain('## Claude Accounts') expect(result.text).toContain('OpenCode anthropic') expect(result.text).toContain('Work account') @@ -313,14 +345,59 @@ describe('executeAccountCommand status', () => { expect(result.text).toContain('42%') expect(result.text).toContain('(disabled)') expect(result.text).toContain( - '**OpenCode anthropic** [main] 42% · gate n/a (OpenCode managed)', + '**OpenCode anthropic** [main] 42% · custody n/a (OpenCode managed)', + ) + expect(result.text).toContain('**Work account** [fallback] · custody off') + }) + + test('renders the settled custody projection in account status text', async () => { + const storage = baseStorage() + const result = await executeAccountCommand({ + argumentsText: '', + storage, + statusProjection: { + claustrumDetection: 'available', + accounts: [ + { + id: 'main', + label: 'OpenCode anthropic', + role: 'main', + enabled: true, + quotaPercent: 42, + claustrumGate: 'na', + vaultServed: false, + vaultReauth: false, + custodyState: 'na', + custodyEligible: false, + }, + { + id: 'fallback-1', + label: 'Work account', + role: 'fallback', + enabled: true, + quotaPercent: null, + claustrumGate: 'on', + vaultServed: false, + vaultReauth: true, + custodyState: 'on-vault-reauth', + custodyEligible: true, + }, + ], + }, + }) + + expect(result.text).toContain('Claustrum: available') + expect(result.text).toContain( + '**Work account** [fallback] · custody on · vault reauth', ) - expect(result.text).toContain('**Work account** [fallback] · gate off') }) - test('usage returns usage text', () => { + test('usage returns usage text', async () => { const storage = baseStorage() - const result = executeAccountCommand({ argumentsText: 'garbage', storage }) + const result = await executeAccountCommand({ + argumentsText: 'garbage', + storage, + }) expect(result.text).toContain('Usage:') expect(result.text).toContain('/claude-account enable') }) @@ -330,9 +407,9 @@ describe('executeAccountCommand status', () => { // executeAccountCommand — enable / disable // --------------------------------------------------------------------------- describe('executeAccountCommand enable/disable', () => { - test('enable sets enabled flag on result', () => { + test('enable sets enabled flag on result', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'enable fallback-3', storage, }) @@ -344,9 +421,9 @@ describe('executeAccountCommand enable/disable', () => { }) }) - test('disable sets enabled flag on result', () => { + test('disable sets enabled flag on result', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'disable fallback-1', storage, }) @@ -358,9 +435,9 @@ describe('executeAccountCommand enable/disable', () => { }) }) - test('enable main is rejected', () => { + test('enable main is rejected', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'enable main', storage, }) @@ -368,9 +445,9 @@ describe('executeAccountCommand enable/disable', () => { expect(result.updated).toBeUndefined() }) - test('disable main is rejected', () => { + test('disable main is rejected', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'disable main', storage, }) @@ -378,9 +455,9 @@ describe('executeAccountCommand enable/disable', () => { expect(result.updated).toBeUndefined() }) - test('enable non-existent returns not found', () => { + test('enable non-existent returns not found', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'enable nonexistent', storage, }) @@ -408,13 +485,129 @@ describe('executeAccountCommand enable/disable', () => { }) }) +// --------------------------------------------------------------------------- +// executeAccountCommand — custody +// --------------------------------------------------------------------------- +describe('executeAccountCommand custody', () => { + test('checks account existence before rejecting the main account', async () => { + const result = await executeAccountCommand({ + argumentsText: 'custody main on', + storage: baseStorage(), + }) + + expect(result.text).toBe('Account "main" not found.') + expect(result.updated).toBeUndefined() + }) + + test('rejects a stored main account after confirming it exists', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'main', + type: 'oauth', + refresh: 'refresh-main', + enabled: true, + }) + + const result = await executeAccountCommand({ + argumentsText: 'custody main on', + storage, + }) + + expect(result.text).toBe('Cannot change custody for the main account.') + expect(result.updated).toBeUndefined() + }) + + test('rejects an API-key account before invoking the custody capability', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'api-fallback', + type: 'api', + apiKey: 'test-api-key', + baseURL: 'https://api.example.test/claude', + enabled: true, + }) + let calls = 0 + + const result = await executeAccountCommand({ + argumentsText: 'custody api-fallback on', + storage, + custody: { + platform: 'opencode', + async set() { + calls += 1 + return { text: 'unexpected', changed: true } + }, + }, + }) + + expect(result.text).toContain('OAuth') + expect(result.updated).toBeUndefined() + expect(calls).toBe(0) + }) + + test('rejects enabling custody for a disabled OAuth account', async () => { + const result = await executeAccountCommand({ + argumentsText: 'custody fallback-3 on', + storage: baseStorage(), + custody: { + platform: 'opencode', + async set() { + return { text: 'unexpected', changed: true } + }, + }, + }) + + expect(result.text).toContain('disabled') + expect(result.updated).toBeUndefined() + }) + + test('returns the pinned Pi text without a mutation intent when unsupported', async () => { + const result = await executeAccountCommand({ + argumentsText: 'custody fallback-1 on', + storage: baseStorage(), + custody: { + platform: 'unsupported', + reason: 'Pi does not support custody', + }, + }) + + expect(result.text).toBe('Custody is OpenCode-only in this version.') + expect(result.updated).toBeUndefined() + }) + + test('delegates custody changes to the supported host capability', async () => { + const storage = baseStorage() + let receivedEnabled: boolean | undefined + + const result = await executeAccountCommand({ + argumentsText: 'custody fallback-1 off', + storage, + custody: { + platform: 'opencode', + async set(input) { + receivedEnabled = input.enabled + return { text: 'Custody disabled.', changed: true } + }, + }, + }) + + expect(receivedEnabled).toBe(false) + expect(result.text).toBe('Custody disabled.') + expect(result.updated).toEqual({ + id: 'fallback-1', + action: 'custody', + enabled: false, + }) + }) +}) + // --------------------------------------------------------------------------- // executeAccountCommand — remove // --------------------------------------------------------------------------- describe('executeAccountCommand remove', () => { - test('remove returns updated', () => { + test('remove returns updated', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'remove fallback-1', storage, }) @@ -425,9 +618,9 @@ describe('executeAccountCommand remove', () => { }) }) - test('remove main is rejected', () => { + test('remove main is rejected', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'remove main', storage, }) @@ -435,9 +628,9 @@ describe('executeAccountCommand remove', () => { expect(result.updated).toBeUndefined() }) - test('remove non-existent returns not found', () => { + test('remove non-existent returns not found', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'remove nonexistent', storage, }) @@ -469,9 +662,9 @@ describe('executeAccountCommand remove', () => { // executeAccountCommand — reorder (move-up / move-down) // --------------------------------------------------------------------------- describe('executeAccountCommand reorder', () => { - test('move-up returns updated with new order', () => { + test('move-up returns updated with new order', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'move-up fallback-2', storage, }) @@ -484,9 +677,9 @@ describe('executeAccountCommand reorder', () => { }) }) - test('move-up first item is no-op', () => { + test('move-up first item is no-op', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'move-up fallback-1', storage, }) @@ -494,9 +687,9 @@ describe('executeAccountCommand reorder', () => { expect(result.updated).toBeUndefined() }) - test('move-down returns updated with new order', () => { + test('move-down returns updated with new order', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'move-down fallback-1', storage, }) @@ -509,9 +702,9 @@ describe('executeAccountCommand reorder', () => { }) }) - test('move-down last item is no-op', () => { + test('move-down last item is no-op', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'move-down fallback-3', storage, }) @@ -519,9 +712,9 @@ describe('executeAccountCommand reorder', () => { expect(result.updated).toBeUndefined() }) - test('move-up non-existent returns not found', () => { + test('move-up non-existent returns not found', async () => { const storage = baseStorage() - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: 'move-up nonexistent', storage, }) @@ -707,6 +900,24 @@ describe('account command INFO logs (via plugin)', () => { ).toHaveLength(0) }) + test('custody on refuses a missing handle without touching the account file', async () => { + const storage = baseStorage() + await saveAccounts(storage, accountPath) + const before = await readFile(accountPath, 'utf8') + const beforeMtime = (await stat(accountPath)).mtimeMs + const plugin = await getPlugin() + drainNotifications(0, 'ses_test') + + await executeCommand(plugin, 'claude-account', 'custody fallback-1 on') + + const payload = drainNotifications(0, 'ses_test').at(-1)?.payload + expect(payload?.text).toBe( + 'No custody handle for fallback-1. Mint one with ck auth mint-handle and store it, then retry.', + ) + expect(await readFile(accountPath, 'utf8')).toBe(before) + expect((await stat(accountPath)).mtimeMs).toBe(beforeMtime) + }) + test('does not retain a background interval unless the helper opts in', async () => { await saveAccounts(baseStorage(), accountPath) await getPlugin() diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 6e7581c4..3ac2a5a4 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -71,6 +71,7 @@ import { setCacheKeepPersistentEnabled, setCacheKeepPersistentWindow, setCacheKeepSubagentsEnabled, + setClaustrumAccountGatePersistent, setFastModePersistentEnabled, setLogLevel, setLogLevelPersistent, @@ -3756,6 +3757,63 @@ describe('FallbackAccountManager', () => { expect(expectOAuthAccount(saved?.accounts[0]).refresh).toBe('new-refresh') }) + test('background refresh skips a custody verification lock without warning', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'custody-verifying', + type: 'oauth', + access: 'old-access', + refresh: 'old-refresh', + expires: Date.now() + 60_000, + }) + await saveAccounts(storage, accountPath) + const logs: LogTestRecord[] = [] + const previousLogLevel = getLogLevel() + const entered = deferred() + const release = deferred() + const fetchImpl = mock(() => + Promise.resolve(new Response(null, { status: 200 })), + ) as unknown as typeof fetch + const manager = new FallbackAccountManager({ + configPath: accountPath, + fetchImpl, + }) + + try { + setLogLevel('debug') + __setLogTestSink((record) => logs.push(record)) + const held = manager.withAccountRefreshLock( + 'custody-verifying', + async () => { + entered.resolve() + await release.promise + }, + ) + await entered.promise + + await manager.refreshDueAccounts() + + expect(fetchImpl).not.toHaveBeenCalled() + expect(logs).toContainEqual( + expect.objectContaining({ + level: 'debug', + channel: 'refresh', + message: 'fallback oauth background skipped custody verification', + }), + ) + expect( + logs.some( + (record) => record.level === 'warn' || record.level === 'error', + ), + ).toBe(false) + release.resolve() + await held + } finally { + __setLogTestSink(null) + setLogLevel(previousLogLevel) + } + }) + test('background fallback refresh retries after a permanent backoff belongs to an older refresh token', async () => { const now = Date.now() const storage = baseStorage() @@ -6781,6 +6839,31 @@ describe('setAccountEnabledPersistent', () => { }) }) +describe('setClaustrumAccountGatePersistent', () => { + test('refuses an API fallback without writing custody state', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'api-fallback', + type: 'api', + apiKey: 'test-api-key', + baseURL: 'https://example.test', + }) + await saveAccounts(storage, accountPath) + const before = await readFile(accountPath, 'utf8') + const beforeStat = await stat(accountPath) + + expect( + await setClaustrumAccountGatePersistent({ + id: 'api-fallback', + enabled: true, + path: accountPath, + }), + ).toBe('ineligible') + expect(await readFile(accountPath, 'utf8')).toBe(before) + expect((await stat(accountPath)).mtimeMs).toBe(beforeStat.mtimeMs) + }) +}) + describe('addAccountPersistent', () => { test('adds a new account and persists', async () => { const storage = baseStorage() diff --git a/packages/opencode/src/tests/claustrum.test.ts b/packages/opencode/src/tests/claustrum.test.ts index cfc32048..d2cd2d40 100644 --- a/packages/opencode/src/tests/claustrum.test.ts +++ b/packages/opencode/src/tests/claustrum.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -10,6 +10,7 @@ import { isClaustrumEnabledForAccount, loadAccounts, saveAccounts, + setClaustrumAccountGatePersistent, } from '@cortexkit/anthropic-auth-core' let tempDir: string @@ -163,7 +164,7 @@ describe('Claustrum connection detection', () => { }) }) - test('derives the default connection path from the current uid', () => { + test('derives the default connection path from the current uid', async () => { const originalGetuid = process.getuid Object.defineProperty(process, 'getuid', { value: () => 4242 }) try { @@ -215,11 +216,143 @@ describe('per-account Claustrum gate', () => { expect(isClaustrumEnabledForAccount(storage!, 'account-a')).toBe(true) expect(isClaustrumEnabledForAccount(storage!, 'account-b')).toBe(false) }) + + test('loads the current storage before enabling an account gate', async () => { + await saveAccounts(baseStorage(), accountPath) + + const result = await setClaustrumAccountGatePersistent({ + id: 'account-a', + enabled: true, + path: accountPath, + }) + const storage = await loadAccounts(accountPath) + + expect(result).toBe('updated') + expect(isClaustrumEnabledForAccount(storage!, 'account-a')).toBe(true) + }) + + test('disables an enabled account gate', async () => { + await saveAccounts( + { + ...baseStorage(), + claustrum: { accounts: { 'account-a': { enabled: true } } }, + }, + accountPath, + ) + + const result = await setClaustrumAccountGatePersistent({ + id: 'account-a', + enabled: false, + path: accountPath, + }) + const storage = await loadAccounts(accountPath) + + expect(result).toBe('updated') + expect(isClaustrumEnabledForAccount(storage!, 'account-a')).toBe(false) + }) + + test('preserves another account gate while changing the target', async () => { + await saveAccounts( + { + ...baseStorage(), + claustrum: { + accounts: { + 'account-a': { enabled: true }, + 'account-b': { enabled: true }, + }, + }, + }, + accountPath, + ) + + await setClaustrumAccountGatePersistent({ + id: 'account-a', + enabled: false, + path: accountPath, + }) + const storage = await loadAccounts(accountPath) + + expect(isClaustrumEnabledForAccount(storage!, 'account-a')).toBe(false) + expect(isClaustrumEnabledForAccount(storage!, 'account-b')).toBe(true) + }) + + test('preserves concurrent gate mutations for different accounts', async () => { + await saveAccounts(baseStorage(), accountPath) + const coreModule = new URL('../../../core/dist/index.js', import.meta.url) + const runMutation = (id: string) => + Bun.spawn([ + process.execPath, + '--eval', + `import { setClaustrumAccountGatePersistent } from ${JSON.stringify(coreModule.href)}; const result = await setClaustrumAccountGatePersistent({ id: ${JSON.stringify(id)}, enabled: true, path: ${JSON.stringify(accountPath)} }); if (result !== 'updated') process.exit(1);`, + ]) + + const first = runMutation('account-a') + const second = runMutation('account-b') + expect(await first.exited).toBe(0) + expect(await second.exited).toBe(0) + + const storage = await loadAccounts(accountPath) + expect(isClaustrumEnabledForAccount(storage!, 'account-a')).toBe(true) + expect(isClaustrumEnabledForAccount(storage!, 'account-b')).toBe(true) + }) + + test('preserves unrelated top-level configuration', async () => { + await writeFile( + accountPath, + JSON.stringify({ ...baseStorage(), custom: { retain: true } }), + ) + + await setClaustrumAccountGatePersistent({ + id: 'account-a', + enabled: true, + path: accountPath, + }) + const config = JSON.parse(await readFile(accountPath, 'utf8')) + + expect(config.custom).toEqual({ retain: true }) + }) + + test('does not write a missing account gate', async () => { + await saveAccounts(baseStorage(), accountPath) + const before = await readFile(accountPath, 'utf8') + const beforeStat = await stat(accountPath) + + const result = await setClaustrumAccountGatePersistent({ + id: 'missing', + enabled: true, + path: accountPath, + }) + const after = await readFile(accountPath, 'utf8') + const afterStat = await stat(accountPath) + + expect(result).toBe('missing') + expect(after).toBe(before) + expect(afterStat.mtimeMs).toBe(beforeStat.mtimeMs) + }) + + test('does not write or create an entry when disabling an already-off gate', async () => { + await saveAccounts(baseStorage(), accountPath) + const before = await readFile(accountPath, 'utf8') + const beforeStat = await stat(accountPath) + + const result = await setClaustrumAccountGatePersistent({ + id: 'account-a', + enabled: false, + path: accountPath, + }) + const after = await readFile(accountPath, 'utf8') + const afterStat = await stat(accountPath) + + expect(result).toBe('unchanged') + expect(after).toBe(before) + expect(afterStat.mtimeMs).toBe(beforeStat.mtimeMs) + expect(JSON.parse(after).claustrum).toBeUndefined() + }) }) describe('account status Claustrum surface', () => { - test('reports detection and each account gate without changing account behavior', () => { - const result = executeAccountCommand({ + test('reports detection and each account gate without changing account behavior', async () => { + const result = await executeAccountCommand({ argumentsText: '', storage: { ...baseStorage(), @@ -237,9 +370,9 @@ describe('account status Claustrum surface', () => { expect(result.text).toContain('Claustrum: available') expect(result.text).toContain('account-a') - expect(result.text).toContain('gate on') + expect(result.text).toContain('custody on · cold') expect(result.text).toContain('account-b') - expect(result.text).toContain('gate off') + expect(result.text).toContain('custody off') }) }) diff --git a/packages/opencode/src/tests/command-dialogs.test.ts b/packages/opencode/src/tests/command-dialogs.test.ts index 08e17a30..00536895 100644 --- a/packages/opencode/src/tests/command-dialogs.test.ts +++ b/packages/opencode/src/tests/command-dialogs.test.ts @@ -3,8 +3,10 @@ import type { PrimeAccountStatus } from '@cortexkit/anthropic-auth-core' import { buildAccountDialogOption, buildKillswitchThresholdSeed, + buildManageAccountOptions, buildPrimeStatusRows, handlePrimeStatusOption, + normalizeAccountDialogAccounts, PRIME_DIALOG_OPTIONS, } from '../tui/command-dialogs' @@ -44,29 +46,51 @@ describe('buildAccountDialogOption', () => { tierLabel: 'Team · Max 5x', claustrumGate: 'on', vaultServed: true, + vaultReauth: false, + custodyState: 'on-vault-served', + custodyEligible: true, }), ).toEqual({ - title: 'Work [fallback] 22% · gate on · vault served', + title: 'Work [fallback] 22% · custody on · vault-served', value: 'work', description: 'Team · Max 5x', }) }) - test('renders gate and vault markers without exposing credentials', () => { + test('renders the settled custody state without exposing credentials', () => { const option = buildAccountDialogOption({ id: 'work', label: 'Work', role: 'fallback', enabled: true, quotaPercent: null, - claustrumGate: 'off', + claustrumGate: 'on', vaultServed: false, + vaultReauth: true, + custodyState: 'on-vault-reauth', + custodyEligible: true, }) - expect(option.title).toContain('gate off') - expect(option.title).toContain('vault cold') + expect(option.title).toContain('custody on · vault reauth') expect(option.title).not.toContain('handle') }) + test('renders the cold custody state', () => { + const option = buildAccountDialogOption({ + id: 'work', + label: 'Work', + role: 'fallback', + enabled: true, + quotaPercent: null, + claustrumGate: 'on', + vaultServed: false, + vaultReauth: false, + custodyState: 'on-cold', + custodyEligible: true, + }) + + expect(option.title).toContain('custody on · cold') + }) + test('renders the main account gate placeholder as n/a', () => { const option = buildAccountDialogOption({ id: 'main', @@ -76,10 +100,59 @@ describe('buildAccountDialogOption', () => { quotaPercent: null, claustrumGate: 'na', vaultServed: false, + vaultReauth: false, + custodyState: 'na', + custodyEligible: false, + }) + + expect(option.title).toContain('custody n/a') + }) + + test('omits custody for an older account-modal payload without custody fields', () => { + const [oldPayloadAccount] = normalizeAccountDialogAccounts([ + { + id: 'work', + label: 'Work', + role: 'fallback', + enabled: true, + quotaPercent: null, + claustrumGate: 'off', + vaultServed: false, + }, + ]) + + expect(() => buildAccountDialogOption(oldPayloadAccount!)).not.toThrow() + expect(buildAccountDialogOption(oldPayloadAccount!)).toEqual({ + title: 'Work [fallback] –%', + value: 'work', }) + }) +}) + +describe('buildManageAccountOptions', () => { + test('offers custody only for eligible OAuth fallback accounts', () => { + const base = { + id: 'work', + label: 'Work', + role: 'fallback' as const, + enabled: true, + quotaPercent: null, + claustrumGate: 'on' as const, + vaultServed: false, + vaultReauth: false, + custodyState: 'on-cold' as const, + } - expect(option.title).toContain('gate n/a') - expect(option.title).toContain('vault n/a') + expect( + buildManageAccountOptions({ ...base, custodyEligible: true }).map( + (option) => option.title, + ), + ).toContain('Custody off') + expect( + buildManageAccountOptions({ ...base, custodyEligible: false }).map( + (option) => option.title, + ), + ).not.toContain('Custody off') }) }) diff --git a/packages/opencode/src/tests/credential-handle-blindness.test.ts b/packages/opencode/src/tests/credential-handle-blindness.test.ts index 1d7852d0..d3408cd2 100644 --- a/packages/opencode/src/tests/credential-handle-blindness.test.ts +++ b/packages/opencode/src/tests/credential-handle-blindness.test.ts @@ -957,6 +957,12 @@ describe('credential-handle blindness', () => { claustrumGate: account.role === 'main' ? ('na' as const) : ('on' as const), vaultServed: account.role === 'fallback', + vaultReauth: false, + custodyState: + account.role === 'main' + ? ('na' as const) + : ('on-vault-served' as const), + custodyEligible: account.role === 'fallback', })) const server = await startRpcServer({ dir: rpcDir, diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 6a2f0e6e..3b1e70c2 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -15,6 +15,7 @@ import { readdir, readFile, rm, + stat, writeFile, } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -1011,6 +1012,44 @@ describe('fallback Claustrum credential resolution', () => { }) as never } + async function configureClaustrumConnection() { + const connectionFile = join(tempConfigDir!, 'configured-claustrum.json') + await writeFile( + connectionFile, + JSON.stringify({ + schema: 1, + wire_version: 1, + endpoints: [{ host: '127.0.0.1', port: 1234 }], + }), + ) + const previous = + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = + connectionFile + return () => { + if (previous === undefined) + delete process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + else + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = previous + } + } + + async function runCustodyCommand( + plugin: any, + sessionID: string, + argumentsText: string, + ) { + drainNotifications(0, sessionID) + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: argumentsText, + sessionID, + }), + ) + return drainNotifications(0, sessionID).at(-1)?.payload + } + async function clearAfterConcurrentSnapshot( accountId: string, handle: string, @@ -2126,7 +2165,7 @@ describe('fallback Claustrum credential resolution', () => { test('account modal payload projects Claustrum status through the real builder', async () => { const sessionID = 'account-modal-projection' const storage = fallbackWithClaustrum({ - claustrumHandle: 'projection-handle', + claustrumHandle: 'ckh_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', claustrum: { accounts: { 'fallback-1': { enabled: true } } }, }) await useTempAccountFile(storage) @@ -2143,10 +2182,18 @@ describe('fallback Claustrum credential resolution', () => { process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = connectionFile + const calls: CredentialCall[] = [] try { resetNotificationsForTest() - const plugin = await getPlugin(createMockClient(), tempConfigDir!) + const plugin = await getPlugin(createMockClient(), tempConfigDir!, { + claustrumConnector: connectorFor(calls, (method) => { + if (method !== 'credential.get') { + throw new Error(`unexpected method: ${method}`) + } + return credentialResponse('vault-projected-access', 1) + }), + }) await plugin.auth.loader( () => Promise.resolve({ @@ -2158,6 +2205,15 @@ describe('fallback Claustrum credential resolution', () => { { models: {} }, ) drainNotifications(0, sessionID) + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: 'custody fallback-1 on', + sessionID, + }), + ) + await drainSidebarWrites() + const sidebarState = await getSidebarState() await expectHandledCommandResponse( plugin['command.execute.before']({ command: 'claude-account', @@ -2171,6 +2227,14 @@ describe('fallback Claustrum credential resolution', () => { id: string claustrumGate: string vaultServed: boolean + vaultReauth: boolean + custodyState: + | 'na' + | 'off' + | 'on-vault-served' + | 'on-vault-reauth' + | 'on-cold' + custodyEligible: boolean }> expect(payload?.command).toBe('claude-account') expect(payload?.knobs.claustrumDetection).toBe('available') @@ -2182,7 +2246,28 @@ describe('fallback Claustrum credential resolution', () => { ).toBe('on') expect( accounts.find((account) => account.id === 'fallback-1')?.vaultServed, + ).toBe(true) + expect( + accounts.find((account) => account.id === 'fallback-1')?.vaultReauth, ).toBe(false) + expect( + accounts.find((account) => account.id === 'fallback-1')?.custodyState, + ).toBe('on-vault-served') + const dialogCustodyState = accounts.find( + (account) => account.id === 'fallback-1', + )?.custodyState + expect( + sidebarState.fallbacks.find((account) => account.id === 'fallback-1') + ?.custodyState, + ).toBe(dialogCustodyState === 'na' ? undefined : dialogCustodyState) + expect( + accounts.find((account) => account.id === 'fallback-1') + ?.custodyEligible, + ).toBe(true) + const payloadBytes = JSON.stringify(payload) + expect(payloadBytes).not.toContain( + 'ckh_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ) storage.claustrum = { accounts: { 'fallback-1': { enabled: false } } } await saveAccounts(storage) @@ -2214,6 +2299,686 @@ describe('fallback Claustrum credential resolution', () => { } }) + test('custody command verifies, persists, and invalidates its resident credential on off', async () => { + const sessionID = 'custody-command' + const handle = 'ckh_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + const storage = fallbackWithClaustrum({ + claustrumHandle: handle, + enabled: true, + }) + await useTempAccountFile(storage) + const connectionFile = join(tempConfigDir!, 'configured-claustrum.json') + await writeFile( + connectionFile, + JSON.stringify({ + schema: 1, + wire_version: 1, + endpoints: [{ host: '127.0.0.1', port: 1234 }], + }), + ) + const previousConnectionFile = + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = + connectionFile + const calls: CredentialCall[] = [] + + try { + resetNotificationsForTest() + const plugin = await getPlugin(createMockClient(), tempConfigDir!, { + claustrumConnector: connectorFor(calls, (method) => { + if (method === 'credential.get') { + return credentialResponse( + 'vault-access-token', + 1, + Date.now() + 24 * 60 * 60_000, + ) + } + throw new Error(`unexpected method: ${method}`) + }), + }) + drainNotifications(0, sessionID) + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: 'custody fallback-1 on', + sessionID, + }), + ) + const onPayload = drainNotifications(0, sessionID).at(-1)?.payload + expect(onPayload?.text).toBe('Custody on for fallback-1 (vault-served).') + expect( + calls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(1) + expect( + (await loadAccounts())?.claustrum?.accounts?.['fallback-1']?.enabled, + ).toBe(true) + expect(JSON.stringify(onPayload)).not.toContain(handle) + expect(JSON.stringify(onPayload)).not.toContain('vault-access-token') + + drainNotifications(0, sessionID) + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: 'custody fallback-1 off', + sessionID, + }), + ) + expect(drainNotifications(0, sessionID).at(-1)?.payload.text).toBe( + 'Custody off for fallback-1 (plugin-served).', + ) + expect( + (await loadAccounts())?.claustrum?.accounts?.['fallback-1']?.enabled, + ).toBe(false) + + drainNotifications(0, sessionID) + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: 'custody fallback-1 on', + sessionID, + }), + ) + expect( + calls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(2) + await plugin.dispose?.() + } finally { + if (previousConnectionFile === undefined) + delete process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + else + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = + previousConnectionFile + } + }) + + test('custody on refuses without a handle before contacting the vault', async () => { + await useTempAccountFile(fallbackWithClaustrum({ enabled: true })) + let connectorCalls = 0 + const calls: CredentialCall[] = [] + const plugin = await getPlugin(createMockClient(), tempConfigDir!, { + claustrumConnector: async () => { + connectorCalls += 1 + return connectorFor(calls, () => ({ result: {} }))() + }, + }) + const path = process.env.OPENCODE_ANTHROPIC_AUTH_FILE! + const before = await readFile(path, 'utf8') + const beforeStat = await stat(path) + + const payload = await runCustodyCommand( + plugin, + 'custody-no-handle', + 'custody fallback-1 on', + ) + + expect(payload?.text).toBe( + 'No custody handle for fallback-1. Mint one with ck auth mint-handle and store it, then retry.', + ) + expect(connectorCalls).toBe(0) + expect( + calls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(0) + expect(await readFile(path, 'utf8')).toBe(before) + expect((await stat(path)).mtimeMs).toBe(beforeStat.mtimeMs) + await plugin.dispose?.() + }) + + test('custody on refuses when the Claustrum connection is unavailable', async () => { + const handle = 'ckh_FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' + await useTempAccountFile( + fallbackWithClaustrum({ claustrumHandle: handle, enabled: true }), + ) + const previous = + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = join( + tempConfigDir!, + 'missing-claustrum.json', + ) + let connectorCalls = 0 + const plugin = await getPlugin(createMockClient(), tempConfigDir!, { + claustrumConnector: async () => { + connectorCalls += 1 + return connectorFor([], () => ({ result: {} }))() + }, + }) + const path = process.env.OPENCODE_ANTHROPIC_AUTH_FILE! + const before = await readFile(path, 'utf8') + const beforeStat = await stat(path) + + try { + const payload = await runCustodyCommand( + plugin, + 'custody-no-connection', + 'custody fallback-1 on', + ) + + expect(payload?.text).toBe( + 'Claustrum is not available (connection file absent).', + ) + expect(connectorCalls).toBe(0) + expect(await readFile(path, 'utf8')).toBe(before) + expect((await stat(path)).mtimeMs).toBe(beforeStat.mtimeMs) + } finally { + if (previous === undefined) + delete process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + else + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = previous + await plugin.dispose?.() + } + }) + + test('custody on refuses an auth-required vault credential without changing config', async () => { + const handle = 'ckh_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + await useTempAccountFile( + fallbackWithClaustrum({ claustrumHandle: handle, enabled: true }), + ) + const restoreConnection = await configureClaustrumConnection() + const calls: CredentialCall[] = [] + try { + const plugin = await getPlugin(createMockClient(), tempConfigDir!, { + claustrumConnector: connectorFor(calls, (method) => { + if (method === 'credential.get') { + return { + result: { + error: { class: 'auth_required', code: 'reauth_required' }, + }, + } + } + throw new Error(`unexpected method: ${method}`) + }), + }) + const path = process.env.OPENCODE_ANTHROPIC_AUTH_FILE! + const before = await readFile(path, 'utf8') + const beforeStat = await stat(path) + + const payload = await runCustodyCommand( + plugin, + 'custody-auth-required', + 'custody fallback-1 on', + ) + + expect(payload?.text).toBe( + 'Vault credential needs re-login (ck auth login --id oauth:anthropic:fallback-1).', + ) + expect( + calls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(1) + expect(await readFile(path, 'utf8')).toBe(before) + expect((await stat(path)).mtimeMs).toBe(beforeStat.mtimeMs) + await plugin.dispose?.() + } finally { + restoreConnection() + } + }) + + test('custody on refuses a transient vault error without changing config', async () => { + const handle = 'ckh_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC' + await useTempAccountFile( + fallbackWithClaustrum({ claustrumHandle: handle, enabled: true }), + ) + const restoreConnection = await configureClaustrumConnection() + const calls: CredentialCall[] = [] + try { + const plugin = await getPlugin(createMockClient(), tempConfigDir!, { + claustrumConnector: connectorFor(calls, (method) => { + if (method === 'credential.get') throw new Error('transport failed') + throw new Error(`unexpected method: ${method}`) + }), + }) + const path = process.env.OPENCODE_ANTHROPIC_AUTH_FILE! + const before = await readFile(path, 'utf8') + const beforeStat = await stat(path) + + const payload = await runCustodyCommand( + plugin, + 'custody-transient', + 'custody fallback-1 on', + ) + + expect(payload?.text).toBe('Vault unavailable: transient. Retry.') + expect( + calls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(1) + expect(await readFile(path, 'utf8')).toBe(before) + expect((await stat(path)).mtimeMs).toBe(beforeStat.mtimeMs) + await plugin.dispose?.() + } finally { + restoreConnection() + } + }) + + test('custody on refuses an expired vault credential at the command clock', async () => { + const handle = 'ckh_DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD' + const commandNow = 1_000_000 + await useTempAccountFile( + fallbackWithClaustrum({ claustrumHandle: handle, enabled: true }), + ) + const restoreConnection = await configureClaustrumConnection() + const calls: CredentialCall[] = [] + try { + const plugin = await getPlugin(createMockClient(), tempConfigDir!, { + claustrumNow: () => commandNow, + claustrumConnector: connectorFor(calls, (method) => { + if (method === 'credential.get') + return credentialResponse('vault-expired', 1, commandNow) + throw new Error(`unexpected method: ${method}`) + }), + }) + const path = process.env.OPENCODE_ANTHROPIC_AUTH_FILE! + const before = await readFile(path, 'utf8') + const beforeStat = await stat(path) + + const payload = await runCustodyCommand( + plugin, + 'custody-expired', + 'custody fallback-1 on', + ) + + expect(payload?.text).toBe('Vault unavailable: unusable. Retry.') + expect(await readFile(path, 'utf8')).toBe(before) + expect((await stat(path)).mtimeMs).toBe(beforeStat.mtimeMs) + await plugin.dispose?.() + } finally { + restoreConnection() + } + }) + + test('custody on holds the fallback refresh lock while vault verification is pending', async () => { + const handle = 'ckh_EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE' + const storage = fallbackWithClaustrum({ + claustrumHandle: handle, + enabled: true, + }) + storage.refresh = { enabled: true, refreshBeforeExpiryMinutes: 30 } + await useTempAccountFile(storage) + const restoreConnection = await configureClaustrumConnection() + const intervalHandlers: Array<() => void> = [] + const setIntervalMock = mock((handler: () => void) => { + intervalHandlers.push(handler) + return { unref() {} } + }) as unknown as typeof setInterval + let resolveCredential!: (value: unknown) => void + let signalCredentialGet!: () => void + const credentialPending = new Promise((resolve) => { + resolveCredential = resolve + }) + const credentialGetStarted = new Promise((resolve) => { + signalCredentialGet = resolve + }) + let tokenEndpointCalls = 0 + globalThis.fetch = mock((input: unknown) => { + if (extractUrl(input as string | URL | Request) === TOKEN_URL) { + tokenEndpointCalls += 1 + return Promise.resolve( + Response.json({ + access_token: 'local-refresh-access', + refresh_token: 'local-refresh-token', + expires_in: 3_600, + }), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const calls: CredentialCall[] = [] + + try { + const plugin = await getPlugin(createMockClient(), tempConfigDir!, { + setInterval: setIntervalMock, + clearInterval: mock(() => {}) as unknown as typeof clearInterval, + claustrumConnector: connectorFor(calls, (method) => { + if (method !== 'credential.get') + throw new Error(`unexpected method: ${method}`) + signalCredentialGet() + return credentialPending + }), + }) + await plugin.__fallbackRefreshReady + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 5 * 60 * 60_000, + }), + { models: {} }, + ) + const path = process.env.OPENCODE_ANTHROPIC_AUTH_FILE! + const current = (await loadAccounts(path))! + const account = current.accounts.find( + (candidate) => candidate.id === 'fallback-1', + ) as OAuthAccount + account.expires = Date.now() + 60_000 + await saveAccounts(current, path) + + const command = runCustodyCommand( + plugin, + 'custody-refresh-race', + 'custody fallback-1 on', + ) + await credentialGetStarted + expect(intervalHandlers.length).toBeGreaterThanOrEqual(1) + for (const handler of intervalHandlers) handler() + for (let turn = 0; turn < 20; turn++) await Promise.resolve() + expect(tokenEndpointCalls).toBe(0) + + resolveCredential(credentialResponse('vault-race-access', 1)) + const payload = await command + await drainSidebarWrites() + expect(payload?.text).toBe('Custody on for fallback-1 (vault-served).') + expect( + (await loadAccounts(path))?.claustrum?.accounts?.['fallback-1'] + ?.enabled, + ).toBe(true) + expect( + (await getSidebarState()).fallbacks.find( + (fallback) => fallback.id === 'fallback-1', + )?.vaultServed, + ).toBe(true) + expect(tokenEndpointCalls).toBe(0) + await plugin.dispose?.() + } finally { + restoreConnection() + } + }) + + test('custody on times out vault verification, releases the refresh lock, and leaves config untouched', async () => { + const handle = 'ckh_FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' + const storage = fallbackWithClaustrum({ + claustrumHandle: handle, + enabled: true, + }) + storage.refresh = { enabled: true, refreshBeforeExpiryMinutes: 30 } + await useTempAccountFile(storage) + const restoreConnection = await configureClaustrumConnection() + const intervalHandlers: Array<() => void> = [] + const timeoutHandlers: Array<{ handler: () => void; delay: number }> = [] + const setIntervalMock = mock((handler: () => void) => { + intervalHandlers.push(handler) + return { unref() {} } + }) as unknown as typeof setInterval + const setTimeoutMock = mock((handler: () => void, delay?: number) => { + timeoutHandlers.push({ handler, delay: delay ?? 0 }) + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof setTimeout + let claustrumNow = 1_000_000 + let signalCredentialGet!: () => void + const credentialGetStarted = new Promise((resolve) => { + signalCredentialGet = resolve + }) + let resolveCredential!: (value: unknown) => void + const credentialPending = new Promise((resolve) => { + resolveCredential = resolve + }) + let signalTokenRefresh!: () => void + const tokenRefreshStarted = new Promise((resolve) => { + signalTokenRefresh = resolve + }) + let tokenEndpointCalls = 0 + globalThis.fetch = mock((input: unknown) => { + if (extractUrl(input as string | URL | Request) === TOKEN_URL) { + tokenEndpointCalls += 1 + signalTokenRefresh() + return Promise.resolve( + Response.json({ + access_token: 'local-refresh-access', + refresh_token: 'local-refresh-token', + expires_in: 3_600, + }), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const calls: CredentialCall[] = [] + + try { + const plugin = await getPlugin(createMockClient(), tempConfigDir!, { + setInterval: setIntervalMock, + clearInterval: mock(() => {}) as unknown as typeof clearInterval, + setTimeout: setTimeoutMock, + claustrumNow: () => claustrumNow, + claustrumConnector: connectorFor(calls, (method) => { + if (method !== 'credential.get') { + throw new Error(`unexpected method: ${method}`) + } + signalCredentialGet() + return credentialPending + }), + }) + await plugin.__fallbackRefreshReady + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 5 * 60 * 60_000, + }), + { models: {} }, + ) + const path = process.env.OPENCODE_ANTHROPIC_AUTH_FILE! + const current = (await loadAccounts(path))! + const account = current.accounts.find( + (candidate) => candidate.id === 'fallback-1', + ) as OAuthAccount + account.expires = Date.now() + 60_000 + await saveAccounts(current, path) + const before = await readFile(path, 'utf8') + const beforeStat = await stat(path) + + const command = runCustodyCommand( + plugin, + 'custody-timeout', + 'custody fallback-1 on', + ) + await credentialGetStarted + const verificationTimeout = timeoutHandlers.find( + (timer) => timer.delay === 15_000, + ) + if (!verificationTimeout) { + await Promise.race([ + command, + Bun.sleep(250).then(() => { + throw new Error( + 'red cap: custody verification did not settle without its 15s timeout', + ) + }), + ]) + throw new Error('expected custody verification timeout') + } + + claustrumNow += 15_000 + verificationTimeout.handler() + const payload = await Promise.race([ + command, + Bun.sleep(250).then(() => { + throw new Error( + 'custody timeout did not settle after its timer fired', + ) + }), + ]) + expect(payload?.text).toBe('Vault unavailable: timeout. Retry.') + expect( + calls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(1) + expect(await readFile(path, 'utf8')).toBe(before) + expect((await stat(path)).mtimeMs).toBe(beforeStat.mtimeMs) + + resolveCredential(credentialResponse('vault-late-timeout', 1)) + for (let turn = 0; turn < 20; turn++) await Promise.resolve() + const cache = plugin.__claustrumCredentialCache + expect(cache).not.toBeNull() + expect(cache?.peek(handle)).toBeUndefined() + + expect(intervalHandlers.length).toBeGreaterThanOrEqual(1) + intervalHandlers[0]!() + await Promise.race([ + tokenRefreshStarted, + Bun.sleep(250).then(() => { + throw new Error( + 'background refresh did not run after custody timeout', + ) + }), + ]) + expect(tokenEndpointCalls).toBe(1) + await plugin.dispose?.() + } finally { + restoreConnection() + } + }) + + test('a timed-out custody verification cannot overwrite a later vault credential', async () => { + const handle = 'ckh_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ' + await useTempAccountFile( + fallbackWithClaustrum({ claustrumHandle: handle, enabled: true }), + ) + const restoreConnection = await configureClaustrumConnection() + const timeoutHandlers: Array<{ handler: () => void; delay: number }> = [] + const setTimeoutMock = mock((handler: () => void, delay?: number) => { + timeoutHandlers.push({ handler, delay: delay ?? 0 }) + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof setTimeout + let resolveFirstCredential!: (value: unknown) => void + const firstCredential = new Promise((resolve) => { + resolveFirstCredential = resolve + }) + let signalFirstCredential!: () => void + const firstCredentialStarted = new Promise((resolve) => { + signalFirstCredential = resolve + }) + let credentialGets = 0 + const calls: CredentialCall[] = [] + + try { + const plugin = await getPlugin(createMockClient(), tempConfigDir!, { + setTimeout: setTimeoutMock, + claustrumConnector: connectorFor(calls, (method) => { + if (method !== 'credential.get') { + throw new Error(`unexpected method: ${method}`) + } + credentialGets += 1 + if (credentialGets === 1) { + signalFirstCredential() + return firstCredential + } + if (credentialGets === 2) { + return credentialResponse('vault-newer', 2) + } + throw new Error(`unexpected credential.get #${credentialGets}`) + }), + }) + await plugin.__fallbackRefreshReady + + const timedOut = runCustodyCommand( + plugin, + 'custody-timeout-old', + 'custody fallback-1 on', + ) + await firstCredentialStarted + const verificationTimeout = timeoutHandlers.find( + (timer) => timer.delay === 15_000, + ) + expect(verificationTimeout).toBeDefined() + verificationTimeout!.handler() + expect((await timedOut)?.text).toBe('Vault unavailable: timeout. Retry.') + + const laterOn = runCustodyCommand( + plugin, + 'custody-timeout-new', + 'custody fallback-1 on', + ) + const payload = await Promise.race([ + laterOn, + Bun.sleep(250).then(() => { + throw new Error( + 'red cap: later custody verification did not supersede the abandoned call', + ) + }), + ]) + expect(payload?.text).toBe('Custody on for fallback-1 (vault-served).') + const cache = plugin.__claustrumCredentialCache + expect(cache?.peek(handle)?.recordVersion).toBe(2) + + resolveFirstCredential(credentialResponse('vault-older', 1)) + for (let turn = 0; turn < 20; turn++) await Promise.resolve() + expect(cache?.peek(handle)?.recordVersion).toBe(2) + await plugin.dispose?.() + } finally { + restoreConnection() + } + }) + + test('custody off fences a pending vault tick from repopulating the resident cache', async () => { + const handle = 'ckh_GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG' + await useTempAccountFile( + fallbackWithClaustrum({ + claustrumHandle: handle, + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + }), + ) + const restoreConnection = await configureClaustrumConnection() + const intervalHandlers: Array<() => void> = [] + const setIntervalMock = mock((handler: () => void) => { + intervalHandlers.push(handler) + return { unref() {} } + }) as unknown as typeof setInterval + let resolveCredential!: (value: unknown) => void + let signalCredentialGet!: () => void + const credentialPending = new Promise((resolve) => { + resolveCredential = resolve + }) + const credentialGetStarted = new Promise((resolve) => { + signalCredentialGet = resolve + }) + const calls: CredentialCall[] = [] + + try { + const plugin = await getPlugin(createMockClient(), tempConfigDir!, { + setInterval: setIntervalMock, + clearInterval: mock(() => {}) as unknown as typeof clearInterval, + claustrumConnector: connectorFor(calls, (method) => { + if (method !== 'credential.get') { + throw new Error(`unexpected method: ${method}`) + } + signalCredentialGet() + return credentialPending + }), + }) + await plugin.__fallbackRefreshReady + expect(intervalHandlers.length).toBeGreaterThanOrEqual(1) + for (const handler of intervalHandlers) handler() + await credentialGetStarted + expect( + calls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(1) + + const offPayload = await runCustodyCommand( + plugin, + 'custody-off-pending-tick', + 'custody fallback-1 off', + ) + expect(offPayload?.text).toBe( + 'Custody off for fallback-1 (plugin-served).', + ) + expect( + (await loadAccounts())?.claustrum?.accounts?.['fallback-1']?.enabled, + ).toBe(false) + expect(plugin.__claustrumCredentialCache.peek(handle)).toBeUndefined() + + resolveCredential( + credentialResponse('vault-pending-tick-access', 1, Date.now() + 60_000), + ) + for (let turn = 0; turn < 20; turn++) await Promise.resolve() + expect(plugin.__claustrumCredentialCache.peek(handle)).toBeUndefined() + expect( + (await loadAccounts())?.claustrum?.accounts?.['fallback-1']?.enabled, + ).toBe(false) + await plugin.dispose?.() + } finally { + restoreConnection() + } + }) + test('treats an empty Claustrum connection setting as unset', async () => { const previousConnectionFile = process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE diff --git a/packages/opencode/src/tui/command-dialogs.tsx b/packages/opencode/src/tui/command-dialogs.tsx index 791702d8..eb17220c 100644 --- a/packages/opencode/src/tui/command-dialogs.tsx +++ b/packages/opencode/src/tui/command-dialogs.tsx @@ -1,5 +1,8 @@ /** @jsxImportSource @opentui/solid */ -import type { PrimeAccountStatus } from '@cortexkit/anthropic-auth-core' +import { + custodyStatusLabel, + type PrimeAccountStatus, +} from '@cortexkit/anthropic-auth-core' import type { TuiPluginApi } from '@opencode-ai/plugin/tui' import type { AccountDialogAccount } from '../rpc/protocol' import type { OpenDialogPayload } from '../rpc/protocol.js' @@ -16,6 +19,17 @@ type KillswitchDialogConfig = { accounts?: Record> } +type TuiAccountDialogAccount = Omit< + AccountDialogAccount, + 'vaultReauth' | 'custodyState' | 'custodyEligible' +> & + Partial< + Pick< + AccountDialogAccount, + 'vaultReauth' | 'custodyState' | 'custodyEligible' + > + > + export const PRIME_DIALOG_OPTIONS = [ { title: 'Enable', value: 'on' }, { title: 'Disable', value: 'off' }, @@ -49,24 +63,122 @@ export function buildKillswitchThresholdSeed( return seedParts.join(' ') } -export function buildAccountDialogOption(account: AccountDialogAccount) { +export function normalizeAccountDialogAccounts( + value: unknown, +): TuiAccountDialogAccount[] { + if (!Array.isArray(value)) return [] + return value.flatMap((value) => { + if (!value || typeof value !== 'object') return [] + const account = value as Record + const quotaPercent = account.quotaPercent + if ( + typeof account.id !== 'string' || + typeof account.label !== 'string' || + (account.role !== 'main' && account.role !== 'fallback') || + typeof account.enabled !== 'boolean' || + (quotaPercent !== null && + (typeof quotaPercent !== 'number' || !Number.isFinite(quotaPercent))) || + (account.claustrumGate !== 'on' && + account.claustrumGate !== 'off' && + account.claustrumGate !== 'na') || + typeof account.vaultServed !== 'boolean' + ) { + return [] + } + const normalized = { + id: account.id, + label: account.label, + role: account.role, + enabled: account.enabled, + quotaPercent, + ...(typeof account.tierLabel === 'string' && { + tierLabel: account.tierLabel, + }), + claustrumGate: account.claustrumGate, + vaultServed: account.vaultServed, + } satisfies TuiAccountDialogAccount + const custodyState = account.custodyState + if ( + (custodyState === 'na' || + custodyState === 'off' || + custodyState === 'on-vault-served' || + custodyState === 'on-vault-reauth' || + custodyState === 'on-cold') && + typeof account.vaultReauth === 'boolean' && + typeof account.custodyEligible === 'boolean' + ) { + return [ + { + ...normalized, + custodyState, + vaultReauth: account.vaultReauth, + custodyEligible: account.custodyEligible, + }, + ] + } + return [normalized] + }) +} + +export function buildAccountDialogOption(account: TuiAccountDialogAccount) { const pct = account.quotaPercent != null ? ` ${Math.round(account.quotaPercent)}%` : ' \u2013%' const status = !account.enabled ? ' (disabled)' : '' - const gate = ` · gate ${account.claustrumGate === 'na' ? 'n/a' : account.claustrumGate}` - const vault = - account.role === 'main' - ? ' · vault n/a' - : ` · vault ${account.vaultServed ? 'served' : 'cold'}` + const custody = account.custodyState + ? custodyStatusLabel(account.custodyState).replace( + ' (OpenCode managed)', + '', + ) + : undefined return { - title: `${account.label} [${account.role}]${status}${pct}${gate}${vault}`, + title: `${account.label} [${account.role}]${status}${pct}${custody ? ` · custody ${custody}` : ''}`, value: account.id, ...(account.tierLabel && { description: account.tierLabel }), } } +export function buildManageAccountOptions(account: TuiAccountDialogAccount) { + const options: Array<{ + title: string + value: string + description?: string + }> = [] + const toggleLabel = account.enabled ? 'Disable' : 'Enable' + options.push({ + title: toggleLabel, + value: account.enabled ? 'disable' : 'enable', + description: account.enabled + ? 'Stop using this fallback account' + : 'Allow this fallback account to be used', + }) + options.push({ + title: 'Move up', + value: 'move-up', + description: 'Higher priority in fallback order', + }) + options.push({ + title: 'Move down', + value: 'move-down', + description: 'Lower priority in fallback order', + }) + if (account.custodyEligible) { + options.push({ + title: account.claustrumGate === 'on' ? 'Custody off' : 'Custody on', + value: 'custody', + description: 'Choose whether the vault may serve this account', + }) + } + options.push({ + title: 'Remove\u2026', + value: 'remove', + description: 'Delete this account permanently', + }) + options.push({ title: 'Back', value: 'back' }) + return options +} + function showText(api: TuiPluginApi, text: string) { api.ui.dialog.setSize('xlarge') api.ui.dialog.replace(() => ( @@ -377,8 +489,7 @@ export function openCommandDialog( } if (payload.command === 'claude-account') { - const accounts = - (payload.knobs.accounts as AccountDialogAccount[] | undefined) ?? [] + const accounts = normalizeAccountDialogAccounts(payload.knobs.accounts) const claustrumDetection = (payload.knobs.claustrumDetection as string | undefined) ?? 'unknown' @@ -386,7 +497,7 @@ export function openCommandDialog( text: string knobs: Record }) => { - const updated = r.knobs.accounts as typeof accounts + const updated = normalizeAccountDialogAccounts(r.knobs.accounts) if (updated && updated.length > 0) { accounts.length = 0 accounts.push(...updated) @@ -737,37 +848,9 @@ export function openCommandDialog( const DialogConfirm = api.ui.DialogConfirm api.ui.dialog.setSize('xlarge') - const options: Array<{ - title: string - value: string - description?: string - }> = [] - if (!isMain) { - const toggleLabel = account.enabled ? 'Disable' : 'Enable' - options.push({ - title: toggleLabel, - value: account.enabled ? 'disable' : 'enable', - description: account.enabled - ? 'Stop using this fallback account' - : 'Allow this fallback account to be used', - }) - options.push({ - title: 'Move up', - value: 'move-up', - description: 'Higher priority in fallback order', - }) - options.push({ - title: 'Move down', - value: 'move-down', - description: 'Lower priority in fallback order', - }) - options.push({ - title: 'Remove\u2026', - value: 'remove', - description: 'Delete this account permanently', - }) - } - options.push({ title: 'Back', value: 'back' }) + const options = isMain + ? [{ title: 'Back', value: 'back' }] + : buildManageAccountOptions(account) api.ui.dialog.replace(() => ( { - api.ui.toast({ message: r.text }) - updateAccounts(r) - const updatedList = r.knobs.accounts as typeof accounts - const refreshed = - (updatedList && updatedList.length > 0 - ? updatedList.find((a) => a.id === account.id) - : undefined) ?? account - openManage(refreshed, isMain) - }, - ) + const args = + option.value === 'custody' + ? `custody ${account.id} ${account.claustrumGate === 'on' ? 'off' : 'on'}` + : `${option.value} ${account.id}` + void apply('claude-account', args).then((r) => { + api.ui.toast({ message: r.text }) + updateAccounts(r) + const updatedList = normalizeAccountDialogAccounts( + r.knobs.accounts, + ) + const refreshed = + (updatedList && updatedList.length > 0 + ? updatedList.find((a) => a.id === account.id) + : undefined) ?? account + openManage(refreshed, isMain) + }) }} /> )) diff --git a/packages/pi/src/commands.ts b/packages/pi/src/commands.ts index 890bfd1a..e903fba5 100644 --- a/packages/pi/src/commands.ts +++ b/packages/pi/src/commands.ts @@ -270,9 +270,13 @@ export function registerCommands(pi: ExtensionAPI) { const path = getPiAccountStoragePath() const storage = await loadAccounts(path) const action = parseAccountCommandAction(args ?? '') - const result = executeAccountCommand({ + const result = await executeAccountCommand({ argumentsText: args ?? '', storage: storage ?? createEmptyStorage(), + custody: { + platform: 'unsupported', + reason: 'Custody is OpenCode-only in this version.', + }, claustrum: action.type === 'status' ? await detectClaustrumConnection() diff --git a/packages/pi/src/tests/commands.test.ts b/packages/pi/src/tests/commands.test.ts index 63e3b92c..3cb0d081 100644 --- a/packages/pi/src/tests/commands.test.ts +++ b/packages/pi/src/tests/commands.test.ts @@ -7,7 +7,7 @@ import { test, } from 'bun:test' import { createHash } from 'node:crypto' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { CacheKeepSessionRegistry } from '@cortexkit/anthropic-auth-core' @@ -84,6 +84,44 @@ afterAll(() => { }) describe('claude-account persistence', () => { + test('refuses custody without changing Pi config or state', async () => { + const initial = { + version: 1, + claustrum: { accounts: { unrelated: { enabled: true } } }, + accounts: [ + { + id: 'oauth-work', + type: 'oauth', + access: 'access', + refresh: 'refresh', + enabled: true, + }, + ], + } + await writeFile(accountPath, JSON.stringify(initial), 'utf8') + await writeFile(statePath, JSON.stringify({ version: 1 }), 'utf8') + const beforeConfig = await readFile(accountPath, 'utf8') + const beforeState = await readFile(statePath, 'utf8') + const beforeConfigMtime = (await stat(accountPath)).mtimeMs + const beforeStateMtime = (await stat(statePath)).mtimeMs + const { pi, commands } = mockPi() + registerCommands(pi) + const handler = commands.get('claude-account')?.handler + const { ctx, notified } = mockNotify() + + await handler!('custody oauth-work on', ctx) + await handler!('custody oauth-work off', ctx) + + expect(notified).toEqual([ + 'Custody is OpenCode-only in this version.', + 'Custody is OpenCode-only in this version.', + ]) + expect(await readFile(accountPath, 'utf8')).toBe(beforeConfig) + expect(await readFile(statePath, 'utf8')).toBe(beforeState) + expect((await stat(accountPath)).mtimeMs).toBe(beforeConfigMtime) + expect((await stat(statePath)).mtimeMs).toBe(beforeStateMtime) + }) + test('disable persists to storage', async () => { await writeFile( accountPath,