-
Notifications
You must be signed in to change notification settings - Fork 14
feat(custody): /claude-account custody on|off for vault-served fallbacks #196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string, Promise<OAuthAccount>>() | ||
| private readonly custodyVerificationAccounts = new Set<string>() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The custody marker is process-local, so another OpenCode process does not skip a held custody lock and instead records a false refresh failure after waiting for it. Use an interprocess custody marker or make background refresh treat this lock as a skip rather than joining it as a normal refresh. Prompt for AI agents |
||
| private refreshTimer: ReturnType<typeof setInterval> | null = null | ||
| private quotaTimer: ReturnType<typeof setInterval> | null = null | ||
| readonly quotaManager: import('./quota-manager.ts').QuotaManager | null | ||
|
|
@@ -3921,6 +3968,27 @@ export class FallbackAccountManager { | |
| this.quotaTimer = null | ||
| } | ||
|
|
||
| async withAccountRefreshLock<T>( | ||
| accountId: string, | ||
| fn: () => Promise<T>, | ||
| ): Promise<T> { | ||
| 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)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: While custody verification holds this manager's lock, the new guard skips only the normal refresh pass; the immediately-following quota pass still processes the same account. Apply the custody-verification skip to every background account pass, including Prompt for AI agents |
||
| logger.debug( | ||
| 'refresh', | ||
| 'fallback oauth background skipped custody verification', | ||
| { | ||
| accountId: account.id, | ||
| }, | ||
| ) | ||
| continue | ||
| } | ||
| if ( | ||
| !tokenNeedsRefresh(account, storage, this.now()) || | ||
| this.isFallbackAccountVaultEnabled(account.id, storage) || | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -638,6 +638,7 @@ export class ClaustrumCredentialCache { | |
| async get( | ||
| handle: string, | ||
| minTtlMs = this.#minTtlMs, | ||
| options: { cacheIf?: () => boolean } = {}, | ||
| ): Promise<ClaustrumCredential> { | ||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When a cached credential is near expiry, Prompt for AI agentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When custody verification joins an existing in-flight cache load, the generation predicate is ignored and a timed-out load can repopulate the cache later. Apply the caller's generation fence to in-flight loads, or otherwise prevent abandoned loads from caching their result. Prompt for AI agents |
||
| 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<ClaustrumCredential> { | ||
| async #load( | ||
| handle: string, | ||
| minTtlMs: number, | ||
| cacheIf?: () => boolean, | ||
| ): Promise<ClaustrumCredential> { | ||
| 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) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: Changing a custody gate can overwrite a concurrent runtime-state update.
saveAccountsWithConfigLockloads state before acquiring the state lock, then writes all account runtime fields from that stale snapshot. Write only the config gate here instead of calling the full account-save helper.Prompt for AI agents