Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> on|off`, including its fail-closed OpenCode behavior and Pi refusal.

## 1.22.0

### Minor Changes
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> 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 "<id>".`
Vault failures report `No custody handle for <id>.`, `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
Expand Down
126 changes: 102 additions & 24 deletions packages/core/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {})

Copy link
Copy Markdown

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. saveAccountsWithConfigLock loads 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
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/accounts.ts, line 1547:

<comment>Changing a custody gate can overwrite a concurrent runtime-state update. `saveAccountsWithConfigLock` loads 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.</comment>

<file context>
@@ -1514,6 +1514,44 @@ export function isClaustrumEnabledForAccount(
+          [input.id]: { ...accounts[input.id], enabled: input.enabled },
+        },
+      }
+      await saveAccountsWithConfigLock(storage, path, {})
+      return 'updated'
+    } finally {
</file context>
Suggested change
await saveAccountsWithConfigLock(storage, path, {})
const existing = await loadExistingTopLevelFields(path)
await writeJsonAtomic(path, {
...existing,
...configFromStorage(storage),
})

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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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>()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/accounts.ts, line 3860:

<comment>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.</comment>

<file context>
@@ -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>()
   private refreshTimer: ReturnType<typeof setInterval> | null = null
   private quotaTimer: ReturnType<typeof setInterval> | null = null
</file context>

private refreshTimer: ReturnType<typeof setInterval> | null = null
private quotaTimer: ReturnType<typeof setInterval> | null = null
readonly quotaManager: import('./quota-manager.ts').QuotaManager | null
Expand Down Expand Up @@ -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 } = {},
Expand Down Expand Up @@ -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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 refreshQuotaForDueAccounts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/accounts.ts, line 4149:

<comment>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 `refreshQuotaForDueAccounts`.</comment>

<file context>
@@ -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',
</file context>

logger.debug(
'refresh',
'fallback oauth background skipped custody verification',
{
accountId: account.id,
},
)
continue
}
if (
!tokenNeedsRefresh(account, storage, this.now()) ||
this.isFallbackAccountVaultEnabled(account.id, storage) ||
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/claustrum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a cached credential is near expiry, get starts #refreshIfApproachingExpiry, whose #load call does not receive cacheIf. If custody off invalidates that handle while the refresh is pending, the response repopulates the cache after the generation fence; pass the predicate through the refresh path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/claustrum.ts, line 662:

<comment>When a cached credential is near expiry, `get` starts `#refreshIfApproachingExpiry`, whose `#load` call does not receive `cacheIf`. If custody off invalidates that handle while the refresh is pending, the response repopulates the cache after the generation fence; pass the predicate through the refresh path.</comment>

<file context>
@@ -658,7 +659,7 @@ export class ClaustrumCredentialCache {
     if (pending) return pending
 
-    const load = this.#load(handle, minTtlMs)
+    const load = this.#load(handle, minTtlMs, options.cacheIf)
     this.#inFlight.set(handle, load)
     try {
</file context>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/claustrum.ts, line 662:

<comment>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.</comment>

<file context>
@@ -658,7 +659,7 @@ export class ClaustrumCredentialCache {
     if (pending) return pending
 
-    const load = this.#load(handle, minTtlMs)
+    const load = this.#load(handle, minTtlMs, options.cacheIf)
     this.#inFlight.set(handle, load)
     try {
</file context>

this.#inFlight.set(handle, load)
try {
return await load
Expand All @@ -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)
}
Expand Down Expand Up @@ -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(
Expand All @@ -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)
}
Expand Down
Loading