Skip to content

feat(custody): /claude-account custody on|off for vault-served fallbacks - #196

Open
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:feat/custody-toggle
Open

feat(custody): /claude-account custody on|off for vault-served fallbacks#196
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:feat/custody-toggle

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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> on runs in a fixed order and refuses at the first failing step with the config left byte-identical:

  1. the account has a custody handle (ck auth mint-handle, stored in the state file)
  2. Claustrum is detected
  3. the account's own refresh lock is held for the rest — the same acquireRefreshFileLock the 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)
  4. one credential.get, bounded at 15 s, must return a credential that is usable at the command clock
  5. only then is claustrum.accounts[<id>].enabled persisted, under the config write lock across load/mutate/save

off clears 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 by custody on skips 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: custody answers Custody 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_URL calls), 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: setAccountEnabledPersistent has 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.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Adds /claude-account custody <id> on|off for fallback OAuth accounts, replacing manual anthropic-auth.json edits with a fail-closed OpenCode command. on enables custody only after verifying a usable vault credential; failures leave the config unchanged, while off disables custody and invalidates cached credentials.

Behavior

  • Checks the custody handle, Claustrum connection, refresh lock, and credential availability in order, with verification capped at 15 seconds.
  • Shows consistent custody states in account status, the sidebar, RPC responses, and the account dialog without exposing handles.
  • Skips background refresh while custody verification holds the account lock.
  • Pi rejects the command with Custody is OpenCode-only in this version. and persists nothing.

Written for commit 4a9a169. Summary will update on new commits.

Review in cubic

…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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

13 issues found across 17 files

Confidence score: 2/5

  • packages/core/src/claustrum.ts has 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. Propagate cacheIf and generation fences through every load path, including joined in-flight requests.
  • packages/opencode/src/index.ts and packages/core/src/accounts.ts can 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.ts can 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.ts and packages/opencode/src/sidebar-state.ts leave 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
Loading

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)

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>


if (!enabled) {
bumpClaustrumGateGeneration(account.id)
const changed = await setClaustrumAccountGatePersistent({

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-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, {})

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),
})

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 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>

Comment on lines +345 to +348
...(typeof entry.custodyState === 'string' && {
custodyState:
entry.custodyState as SidebarAccountState['custodyState'],
}),

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: 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>
Suggested change
...(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>()

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>

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>

text: `Cannot enable custody for disabled account "${action.id}".`,
}
}
if (input.custody?.platform !== 'opencode') {

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: 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' && {

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 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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>

@iceteaSA

iceteaSA commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: converge on one switch. Two constraints matter:

"All or nothing" cannot include main yet. Main uses OpenCode's auth.json slot, and vault custody there is frozen upstream (claustrum#31, mirror-only). Until the main-takeover plugin lands, a global switch means fallbacks use the vault while main stays local.

No connection string is needed. The subc connection file is discovered at /run/user/<uid>/subc-connection.json. Per-account membership belongs in Claustrum's handle manifest, ~/.config/cortexkit/opencode-handles.json, in our provider: anthropic / serve: anthropic-auth block. An account listed there is vault-served; otherwise it is local. The next PR carries that reader/resolver/writer, plus the cross-tenant lock convention agreed with Claustrum, unchanged by this decision.

The shape:

  • claustrum.enabled: true|false is the only config gate. Membership comes from the manifest. The plugin verifies entries at startup and on each custody tick, then reports status.
  • /claude-account custody <id> on becomes an enroll verb, not a toggle: verify the handle against the vault, write the manifest entry, and drop the local refresh token (C2). A bare config flag cannot perform the fail-closed verification that closed incidents 1–3. off removes the entry; recovery is ck auth login locally.
  • If the vault is cold, exclude the account from routing candidates. No sidecar ladder, consistent with dropping the refresh token.

This removes the PR's claustrum.accounts[<id>].enabled surface. Better to change it before merge than after. If this works, I'll rework #196 around the gate and enroll verb, then rebase the manifest PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant