feat(custody): /claude-account custody on|off for vault-served fallbacks - #196
feat(custody): /claude-account custody on|off for vault-served fallbacks#196iceteaSA wants to merge 1 commit into
Conversation
…llbacks
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 <id> 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.
There was a problem hiding this comment.
13 issues found across 17 files
Confidence score: 2/5
packages/core/src/claustrum.tshas multiple generation and cache-invalidation races: refreshes or joined in-flight loads can repopulate credentials after custody is turned off or a request times out, potentially serving invalidated credentials. PropagatecacheIfand generation fences through every load path, including joined in-flight requests.packages/opencode/src/index.tsandpackages/core/src/accounts.tscan persist stale custody or runtime state when custody-on/off operations overlap or state is loaded before acquiring the lock, causing an off state or unrelated runtime update to be overwritten. Serialize custody transitions and reload state after taking the write lock.packages/opencode/src/index.tscan warm credentials after custody is turned off and can surface a plugin error instead of the promised custody refusal when another refresh holds the lock. Capture the scheduling generation and convert lock contention into the intended refusal response.packages/core/src/accounts.tsandpackages/opencode/src/sidebar-state.tsleave secondary correctness and visibility gaps: cross-process custody markers, quota processing during verification, stale reauth status, and unrendered custody state can produce false refresh failures or misleading account status. Extend the custody guard across all passes, clear reauth on successful retrieval, coordinate across processes, and render the stored state.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/core/src/claustrum.ts">
<violation number="1" location="packages/core/src/claustrum.ts:662">
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.</violation>
<violation number="2" location="packages/core/src/claustrum.ts:662">
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.</violation>
</file>
<file name="packages/opencode/src/sidebar-state.ts">
<violation number="1" location="packages/opencode/src/sidebar-state.ts:345">
P2: When the sidebar state contains an unknown custody value, this branch preserves it because the cast does not validate at runtime. Validate against the four allowed literals and omit invalid values so normalized state remains within `SidebarAccountState` and fails closed for future or corrupt payloads.</violation>
<violation number="2" location="packages/opencode/src/sidebar-state.ts:345">
P2: The sidebar stores `custodyState` but never renders it, so custody off, vault-served, and cold states remain invisible in the sidebar. Pass this state into the sidebar account renderer and display it consistently with the account dialog.</violation>
</file>
<file name="packages/opencode/src/index.ts">
<violation number="1" location="packages/opencode/src/index.ts:1856">
P2: When a queued warm starts after custody is turned off, `warmClaustrumCredential` captures the already-bumped generation and can repopulate the resident credential. Capture the generation when scheduling the warm and pass that snapshot into the callback so off fences queued as well as in-flight gets.</violation>
<violation number="2" location="packages/opencode/src/index.ts:4014">
P1: When custody-off overlaps a successful custody-on verification, the on path can persist `enabled: true` after off has already persisted false. Serialize off with the same per-account refresh lock or make the persistence operation conditional on the current gate generation.</violation>
<violation number="3" location="packages/opencode/src/index.ts:4030">
P2: When custody is already on but previously marked for vault reauth, a successful `credential.get` still leaves the account displayed as reauth-required. Call `markClaustrumCredentialReady` before returning the unchanged result.</violation>
<violation number="4" location="packages/opencode/src/index.ts:4068">
P2: When another refresh already holds the account lock, this command throws instead of returning a custody refusal, so the slash command produces a plugin error rather than the promised first-step failure response. Catch the lock-acquisition failure and return a bounded retry message through `refuse`.</violation>
</file>
<file name="packages/core/src/accounts.ts">
<violation number="1" location="packages/core/src/accounts.ts:1547">
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.</violation>
<violation number="2" location="packages/core/src/accounts.ts:3860">
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.</violation>
<violation number="3" location="packages/core/src/accounts.ts:4149">
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`.</violation>
</file>
<file name="packages/core/src/commands/account.ts">
<violation number="1" location="packages/core/src/commands/account.ts:336">
P2: When Pi receives custody for a missing, main, API-key, or disabled account, this branch is never reached, so users see validation errors instead of `Custody is OpenCode-only in this version.` Check the unsupported capability before target validation so every Pi custody command refuses consistently without mutation.</violation>
</file>
<file name="packages/opencode/src/tests/index.test.ts">
<violation number="1" location="packages/opencode/src/tests/index.test.ts:1016">
P3: The new `custody command verifies, persists, and invalidates its resident credential on off` test (and the modified modal-projection test) re-implement the ~20 lines that this same describe block just extracted into `configureClaustrumConnection` at line 1015: write `configured-claustrum.json`, save the `OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE` env var, and restore it in a finally block. Every refusal/race/timeout test below uses `configureClaustrumConnection` + `restoreConnection`, so this setup is duplicated verbatim in three places. Use the helper here too so the connection-file shape and env restore stay in one place.</violation>
</file>
Architecture diagram
sequenceDiagram
participant User as User
participant OpenCode as OpenCode Command Runtime
participant AccountCmd as Account Command Handler
participant Manager as Fallback Account Manager
participant Config as Account Config and State Files
participant ConfigLock as Config Write Lock
participant RefreshLock as Per-Account Refresh Lock
participant Cache as Claustrum Credential Cache
participant Claustrum as Claustrum Service
participant Tick as Background Refresh Loop
participant UI as Sidebar and Account Dialog
participant Pi as Pi Command Runtime
Note over User,Claustrum: OpenCode fallback-account custody control
User->>OpenCode: /claude-account custody id on or off
OpenCode->>AccountCmd: Parse and validate custody action
alt Main account
AccountCmd-->>OpenCode: Reject - main account custody is not changeable
else Non-OAuth or disabled fallback
AccountCmd-->>OpenCode: Reject - ineligible account or disabled account
else OpenCode OAuth fallback
AccountCmd->>Manager: Execute custody transition
alt custody on
Manager->>Config: Read account custody handle
alt Handle missing
Manager-->>OpenCode: Refuse without contacting Claustrum
else Handle present
Manager->>Claustrum: Detect configured Claustrum connection
alt Claustrum unavailable
Manager-->>OpenCode: Refuse - config remains unchanged
else Claustrum available
Manager->>RefreshLock: Acquire account refresh lock
alt Lock unavailable
Manager-->>OpenCode: Refuse - config remains unchanged
else Lock held
Manager->>Manager: Mark custody verification in progress
Tick->>Manager: Background refresh tick
Manager-->>Tick: Skip account while verification lock is held
Manager->>Cache: credential.get(handle), max 15 seconds
Cache->>Claustrum: credential.get(handle)
Claustrum-->>Cache: Usable or unusable credential
alt Credential usable at command clock
Cache-->>Manager: Credential
Manager->>ConfigLock: Acquire config write lock
ConfigLock->>Config: Load, set account enabled, save atomically
Config-->>ConfigLock: Persisted
ConfigLock-->>Manager: Gate enabled
Manager->>RefreshLock: Release account refresh lock
Manager-->>OpenCode: Custody on - vault-served
else Timeout, reauth, or vault failure
Cache-->>Manager: Verification error
Manager->>RefreshLock: Release account refresh lock
Manager-->>OpenCode: Refuse - gate remains unchanged
end
end
end
end
else custody off
Manager->>Manager: Bump per-account gate generation
Manager->>ConfigLock: Acquire config write lock
ConfigLock->>Config: Load, clear custody gate, save atomically
Config-->>ConfigLock: Persisted
ConfigLock-->>Manager: Gate disabled
Manager->>Cache: Invalidate resident credential
Manager->>Cache: Fence late in-flight gets by generation
Manager-->>OpenCode: Custody off - plugin-served
end
end
Note over Cache,Manager: Generation checks prevent startup warm, timed-out verification, and background gets from repopulating cache after custody off
OpenCode->>Manager: Build account status and RPC projection
Manager->>Config: Read account and custody state
Manager->>Cache: Check resident vault credential
Cache-->>Manager: Served, reauth, or cold
Manager-->>OpenCode: Allowlisted fields only - handle and token excluded
OpenCode->>UI: Sidebar and dialog payload
UI->>UI: Normalize new fields and tolerate older payloads
UI-->>User: custody off, on - vault-served, on - vault reauth, or on - cold
User->>Pi: /claude-account custody id on or off
Pi->>AccountCmd: Execute with unsupported custody capability
AccountCmd-->>Pi: Custody is OpenCode-only in this version
Pi-->>User: Refusal - no config or state persistence
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
| 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.
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>
|
|
||
| if (!enabled) { | ||
| bumpClaustrumGateGeneration(account.id) | ||
| const changed = await setClaustrumAccountGatePersistent({ |
There was a problem hiding this comment.
P1: When custody-off overlaps a successful custody-on verification, the on path can persist enabled: true after off has already persisted false. Serialize off with the same per-account refresh lock or make the persistence operation conditional on the current gate generation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/index.ts, line 4014:
<comment>When custody-off overlaps a successful custody-on verification, the on path can persist `enabled: true` after off has already persisted false. Serialize off with the same per-account refresh lock or make the persistence operation conditional on the current gate generation.</comment>
<file context>
@@ -3916,15 +3980,209 @@ const anthropicAuthPlugin = async (
+
+ if (!enabled) {
+ bumpClaustrumGateGeneration(account.id)
+ const changed = await setClaustrumAccountGatePersistent({
+ id: account.id,
+ enabled: false,
</file context>
| [input.id]: { ...accounts[input.id], enabled: input.enabled }, | ||
| }, | ||
| } | ||
| await saveAccountsWithConfigLock(storage, path, {}) |
There was a problem hiding this comment.
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>
| await saveAccountsWithConfigLock(storage, path, {}) | |
| const existing = await loadExistingTopLevelFields(path) | |
| await writeJsonAtomic(path, { | |
| ...existing, | |
| ...configFromStorage(storage), | |
| }) |
| 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.
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>
| ...(typeof entry.custodyState === 'string' && { | ||
| custodyState: | ||
| entry.custodyState as SidebarAccountState['custodyState'], | ||
| }), |
There was a problem hiding this comment.
P2: When the sidebar state contains an unknown custody value, this branch preserves it because the cast does not validate at runtime. Validate against the four allowed literals and omit invalid values so normalized state remains within SidebarAccountState and fails closed for future or corrupt payloads.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/sidebar-state.ts, line 345:
<comment>When the sidebar state contains an unknown custody value, this branch preserves it because the cast does not validate at runtime. Validate against the four allowed literals and omit invalid values so normalized state remains within `SidebarAccountState` and fails closed for future or corrupt payloads.</comment>
<file context>
@@ -338,6 +341,11 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
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'],
</file context>
| ...(typeof entry.custodyState === 'string' && { | |
| custodyState: | |
| entry.custodyState as SidebarAccountState['custodyState'], | |
| }), | |
| ...((entry.custodyState === 'off' || | |
| entry.custodyState === 'on-vault-served' || | |
| entry.custodyState === 'on-vault-reauth' || | |
| entry.custodyState === 'on-cold') && { | |
| custodyState: entry.custodyState, | |
| }), |
| 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.
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>
| 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.
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>
| text: `Cannot enable custody for disabled account "${action.id}".`, | ||
| } | ||
| } | ||
| if (input.custody?.platform !== 'opencode') { |
There was a problem hiding this comment.
P2: When Pi receives custody for a missing, main, API-key, or disabled account, this branch is never reached, so users see validation errors instead of Custody is OpenCode-only in this version. Check the unsupported capability before target validation so every Pi custody command refuses consistently without mutation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/commands/account.ts, line 336:
<comment>When Pi receives custody for a missing, main, API-key, or disabled account, this branch is never reached, so users see validation errors instead of `Custody is OpenCode-only in this version.` Check the unsupported capability before target validation so every Pi custody command refuses consistently without mutation.</comment>
<file context>
@@ -228,6 +317,43 @@ export function executeAccountCommand(input: {
+ text: `Cannot enable custody for disabled account "${action.id}".`,
+ }
+ }
+ if (input.custody?.platform !== 'opencode') {
+ return { text: 'Custody is OpenCode-only in this version.' }
+ }
</file context>
| typeof entry.needsReauth === 'boolean' ? entry.needsReauth : false, | ||
| ...(entry.vaultReauth === true && { vaultReauth: true }), | ||
| ...(entry.vaultServed === true && { vaultServed: true }), | ||
| ...(typeof entry.custodyState === 'string' && { |
There was a problem hiding this comment.
P2: The sidebar stores custodyState but never renders it, so custody off, vault-served, and cold states remain invisible in the sidebar. Pass this state into the sidebar account renderer and display it consistently with the account dialog.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/sidebar-state.ts, line 345:
<comment>The sidebar stores `custodyState` but never renders it, so custody off, vault-served, and cold states remain invisible in the sidebar. Pass this state into the sidebar account renderer and display it consistently with the account dialog.</comment>
<file context>
@@ -338,6 +341,11 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
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'],
</file context>
| } | ||
|
|
||
| async function configureClaustrumConnection() { | ||
| const connectionFile = join(tempConfigDir!, 'configured-claustrum.json') |
There was a problem hiding this comment.
P3: The new custody command verifies, persists, and invalidates its resident credential on off test (and the modified modal-projection test) re-implement the ~20 lines that this same describe block just extracted into configureClaustrumConnection at line 1015: write configured-claustrum.json, save the OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE env var, and restore it in a finally block. Every refusal/race/timeout test below uses configureClaustrumConnection + restoreConnection, so this setup is duplicated verbatim in three places. Use the helper here too so the connection-file shape and env restore stay in one place.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tests/index.test.ts, line 1016:
<comment>The new `custody command verifies, persists, and invalidates its resident credential on off` test (and the modified modal-projection test) re-implement the ~20 lines that this same describe block just extracted into `configureClaustrumConnection` at line 1015: write `configured-claustrum.json`, save the `OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE` env var, and restore it in a finally block. Every refusal/race/timeout test below uses `configureClaustrumConnection` + `restoreConnection`, so this setup is duplicated verbatim in three places. Use the helper here too so the connection-file shape and env restore stay in one place.</comment>
<file context>
@@ -1011,6 +1012,44 @@ describe('fallback Claustrum credential resolution', () => {
}
+ async function configureClaustrumConnection() {
+ const connectionFile = join(tempConfigDir!, 'configured-claustrum.json')
+ await writeFile(
+ connectionFile,
</file context>
|
Agreed: converge on one switch. Two constraints matter: "All or nothing" cannot include main yet. Main uses OpenCode's No connection string is needed. The subc connection file is discovered at The shape:
This removes the PR's |
Adds the command that turns Claustrum custody on or off for a fallback OAuth account. Until now the gate was a hand edit of
anthropic-auth.json.What it does
/claude-account custody <id> onruns in a fixed order and refuses at the first failing step with the config left byte-identical:ck auth mint-handle, stored in the state file)acquireRefreshFileLockthe background loop takes — so the plugin cannot start a local refresh while the vault is being asked (a near-expiry sidecar and the vault's get-triggered refresh would otherwise spend the same parent refresh token)credential.get, bounded at 15 s, must return a credential that is usable at the command clockclaustrum.accounts[<id>].enabledpersisted, under the config write lock across load/mutate/saveoffclears the gate, drops the resident credential, and bumps a per-account generation so an in-flight tick get, a startup warm, or a timed-out verifying get cannot re-populate the cache afterwards. A background tick that finds the lock held bycustody onskips at debug.Status text, the RPC payload, the sidebar, and the dialog all derive custody from one
custodyStateFor(account)(off,on · vault-served,on · vault reauth,on · cold), through explicit field allowlists. The TUI tolerates payloads from older servers that lack the custody fields.Pi:
custodyanswersCustody is OpenCode-only in this version.and persists nothing.Main-slot custody is unchanged (frozen on claustrum#31).
Proof
Red-first tests with a mutation per guard: each ordering step, the refresh lock (removing it → the tick refreshes during the pending get →
TOKEN_URLcalls), the 15 s bound (removing it → the test hits its own cap), the generation fence (late credential lands), the gate setter's config lock (concurrent writers clobber), the projection allowlist (handle leaks into the payload), the Pi refusal, the old-payload tolerance (TypeError). Root 1587/0, typecheck, format, biome clean.Reviews: per-task cross-family (MiniMax M3), two accumulated-surface drift passes, and a whole-branch adversarial pass (gpt-5.5) that surfaced the old-payload crash and the unbounded lock hold — both fixed and re-approved.
Follow-ups filed for later PRs:
setAccountEnabledPersistenthas the same load-then-save gap this PR closes for the custody gate; the handle manifest reader/writer (auto-onboarding) and dropping the local refresh token after custody is confirmed are the next two slices.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Adds
/claude-account custody <id> on|offfor fallback OAuth accounts, replacing manualanthropic-auth.jsonedits with a fail-closed OpenCode command.onenables custody only after verifying a usable vault credential; failures leave the config unchanged, whileoffdisables custody and invalidates cached credentials.Behavior
Custody is OpenCode-only in this version.and persists nothing.Written for commit 4a9a169. Summary will update on new commits.