Enhancement / design discussion. Not a work item yet — the fallback-only slice (anthropic-auth, in flight) has to land and prove itself first. Filing now while the shape is fresh.
The idea
Once Claustrum-mode works for fallback accounts, write an OpenCode plugin that serves MAIN slots from the vault for every provider that does not have a dedicated auth plugin — and in doing so, replace auth.json as the credential store rather than mirroring it.
Possibly the same treatment for Claude Code and Codex, which have structurally identical stores (details below).
Why auth.json is the thing to replace
Measured on one box, ~/.local/share/opencode/auth.json, mode 600, 4270 bytes:
anthropic type=oauth [access, expires, refresh, type]
openai type=oauth [access, expires, refresh, type]
google type=oauth [access, expires, refresh, type]
xai type=oauth [access, expires, refresh, type]
minimax-coding-plan type=api [key, type]
deepseek type=api [key, type]
synthetic type=api [key, type]
alibaba-token-plan type=api [key, type]
Eight providers, four holding live refresh tokens, one file, plaintext. What that costs, in the order it bites:
- Plaintext at rest. No envelope, no AEAD, no binding between a record and its metadata. Claustrum gives per-record XChaCha20-Poly1305 with the record's identity in the AAD.
- No audit trail. Nothing records who read a credential, who wrote one, or when. Claustrum appends an HMAC-chained entry per mutation.
- Single-file multi-tenant, so there is no per-provider change signal. Any provider refreshing rewrites the whole file. File mtime cannot tell you which credential moved — I had to hash the
.anthropic subtree specifically to detect rotations, after mtime produced false positives from other providers' refreshes.
- No versioning, so no safe invalidation. Claustrum's
report_auth_failure is version-gated: a consumer can only invalidate the exact version it was served, so a stale 401 about a since-refreshed credential is a silent no-op. With auth.json there is no version to gate on.
- Last-writer-wins under real concurrency. Ten
opencode processes were live on this box while writing this. Claustrum holds an exclusive single-writer lease with epoch fencing.
- No refresh ownership — this is the expensive one. Any process holding the file may refresh, and a refresh revokes the prior token family. That is the "treadmill": measured here as an external rotation every 4.00h (±20s across 25+ consecutive episodes), each one killing whatever else held the old token. A vault that mirrors
auth.json inherits this and cannot recover — the imported refresh token dies with its family, and re-import is the only repair.
Point 6 is the argument for replacement over mirroring. Two writers cannot share one token family; custody has to move, not be copied.
The load-bearing claim: why MAIN slots are reachable here
anthropic-auth freezes main-account migration, and that freeze is well-founded — but it is plugin-specific, not structural. Its refreshMainAccessToken reads the stored refresh and posts it to the token endpoint; a vault-managed main would send a placeholder, take 400 invalid_grant, and its classifier marks that permanent: true — disabling the account with no rollback.
For a provider with no dedicated auth plugin there is no such reader. The host core never reads refresh (verified in host source). So the mechanism that forces fallback-only does not apply to the unplugged providers, which is exactly the set this proposal targets.
This needs per-provider verification, not generalisation. I have already made the adjacent mistake once: I verified "the host never reads refresh" in host source and generalised it to "nothing reads refresh" — while the plugin writing the store was itself a reader of it, at six live entrypoints. The check is "does anything in this provider's path read the refresh token", per provider, at source. Absence of a plugin is a strong prior, not a proof.
Design questions worth settling before any code
Custody and rollback
- Migration is one-way.
credential.get returns the payload, not the refresh token, so a claustrum-mode consumer is refresh-blind by design and cannot reconstruct what it dropped. Rollback-by-flag-off does not exist; recovery is interactive re-login. Is that acceptable for main slots, or does this need a retained-refresh escrow with its own (worse) threat model?
- Identity has to be derived before the token is dropped — anything deriving an account identity from a live refresh token stops working after migration.
Concurrency
- A concurrent in-flight refresh can persist a rotated token back into local storage after the drop, silently resurrecting split custody. Needs a per-account refresh lock and a compare-and-set, or the migration is racy.
- Ten live processes is the normal case here, not a stress scenario.
The store's future
- Does
auth.json remain as a read-through cache with the vault authoritative, or does the plugin remove entries it has taken custody of? A residual stale entry is a loaded gun: serving it guarantees a 401, and worse, feeds a 401 handler with a credential the vault never issued — which is how a spurious report against a healthy record gets born.
- What does a non-plugin-aware reader of
auth.json see after migration? Anything still reading the file directly needs to fail loudly rather than serve a stale token.
Serve contract
- Latency is settled:
credential.get on a resident route measures p50 0.035 ms / p95 0.078 ms / p99 0.093 ms over 60 calls. But it is bimodal — a get landing on a stale-marked record forces a synchronous upstream exchange, seconds not microseconds. Consumers must warm caches ahead of exposure and never await a get on a request path.
credential.status now publishes stale_pending, so a consumer can see which accounts would take the slow path without making the call whose cost is in question.
- Per-connection limiter: 64 fetches/60s, shared between
get and report_auth_failure. It alarms rather than refuses, but a per-slot main-account consumer changes the traffic shape and the ceilings should be revisited against it.
Bootstrapping
- Importing eight providers by hand does not scale. A
ck-auth import --all-from-opencode needs a per-provider adapter decision: OAuth entries need a refresh adapter that actually exists, API-key entries are static and trivially safe (no refresh path, no treadmill, no family to revoke).
- The four
type=api providers above are the obvious first slice: they cannot be revoked-on-rotation because nothing rotates them.
Claude Code and Codex
Both keep structurally similar stores on the same box:
~/.claude/.credentials.json mode 600, 368 B top-level: claudeAiOauth
~/.codex/auth.json mode 600, 3963 B top-level: OPENAI_API_KEY, auth_mode, last_refresh, tokens
Same properties, same problems: plaintext, unversioned, unaudited, last-writer-wins. Codex's last_refresh suggests it owns a refresh loop, so it has the same custody-conflict question as OpenCode's main slots — and the same treadmill exposure if a vault mirrors instead of owning.
Worth treating as a separate slice each, after OpenCode main slots prove out. Neither is a plugin host in the way OpenCode is, so integration is a different shape — likely a wrapper or a credential-helper rather than an in-process plugin, and that difference deserves its own discussion rather than being assumed away here.
Sequencing
1. fallback-only (anthropic-auth) in flight -- must prove first
2. OC main slots, API-key providers no refresh path, no revocation risk
3. OC main slots, OAuth providers per-provider "who reads refresh" audit
4. Claude Code / Codex separate integration shape, separate slice
Nothing here is committed. Opening it for discussion so the custody and rollback questions get argued before anyone writes the plugin — those are the ones with no cheap fix after the fact.
Enhancement / design discussion. Not a work item yet — the fallback-only slice (anthropic-auth, in flight) has to land and prove itself first. Filing now while the shape is fresh.
The idea
Once Claustrum-mode works for fallback accounts, write an OpenCode plugin that serves MAIN slots from the vault for every provider that does not have a dedicated auth plugin — and in doing so, replace
auth.jsonas the credential store rather than mirroring it.Possibly the same treatment for Claude Code and Codex, which have structurally identical stores (details below).
Why
auth.jsonis the thing to replaceMeasured on one box,
~/.local/share/opencode/auth.json, mode 600, 4270 bytes:Eight providers, four holding live refresh tokens, one file, plaintext. What that costs, in the order it bites:
.anthropicsubtree specifically to detect rotations, after mtime produced false positives from other providers' refreshes.report_auth_failureis version-gated: a consumer can only invalidate the exact version it was served, so a stale 401 about a since-refreshed credential is a silent no-op. Withauth.jsonthere is no version to gate on.opencodeprocesses were live on this box while writing this. Claustrum holds an exclusive single-writer lease with epoch fencing.auth.jsoninherits this and cannot recover — the imported refresh token dies with its family, and re-import is the only repair.Point 6 is the argument for replacement over mirroring. Two writers cannot share one token family; custody has to move, not be copied.
The load-bearing claim: why MAIN slots are reachable here
anthropic-authfreezes main-account migration, and that freeze is well-founded — but it is plugin-specific, not structural. ItsrefreshMainAccessTokenreads the storedrefreshand posts it to the token endpoint; a vault-managed main would send a placeholder, take400 invalid_grant, and its classifier marks thatpermanent: true— disabling the account with no rollback.For a provider with no dedicated auth plugin there is no such reader. The host core never reads
refresh(verified in host source). So the mechanism that forces fallback-only does not apply to the unplugged providers, which is exactly the set this proposal targets.This needs per-provider verification, not generalisation. I have already made the adjacent mistake once: I verified "the host never reads
refresh" in host source and generalised it to "nothing readsrefresh" — while the plugin writing the store was itself a reader of it, at six live entrypoints. The check is "does anything in this provider's path read the refresh token", per provider, at source. Absence of a plugin is a strong prior, not a proof.Design questions worth settling before any code
Custody and rollback
credential.getreturns the payload, not the refresh token, so a claustrum-mode consumer is refresh-blind by design and cannot reconstruct what it dropped. Rollback-by-flag-off does not exist; recovery is interactive re-login. Is that acceptable for main slots, or does this need a retained-refresh escrow with its own (worse) threat model?Concurrency
The store's future
auth.jsonremain as a read-through cache with the vault authoritative, or does the plugin remove entries it has taken custody of? A residual stale entry is a loaded gun: serving it guarantees a 401, and worse, feeds a 401 handler with a credential the vault never issued — which is how a spurious report against a healthy record gets born.auth.jsonsee after migration? Anything still reading the file directly needs to fail loudly rather than serve a stale token.Serve contract
credential.geton a resident route measures p50 0.035 ms / p95 0.078 ms / p99 0.093 ms over 60 calls. But it is bimodal — a get landing on a stale-marked record forces a synchronous upstream exchange, seconds not microseconds. Consumers must warm caches ahead of exposure and never await a get on a request path.credential.statusnow publishesstale_pending, so a consumer can see which accounts would take the slow path without making the call whose cost is in question.getandreport_auth_failure. It alarms rather than refuses, but a per-slot main-account consumer changes the traffic shape and the ceilings should be revisited against it.Bootstrapping
ck-auth import --all-from-opencodeneeds a per-provider adapter decision: OAuth entries need a refresh adapter that actually exists, API-key entries are static and trivially safe (no refresh path, no treadmill, no family to revoke).type=apiproviders above are the obvious first slice: they cannot be revoked-on-rotation because nothing rotates them.Claude Code and Codex
Both keep structurally similar stores on the same box:
Same properties, same problems: plaintext, unversioned, unaudited, last-writer-wins. Codex's
last_refreshsuggests it owns a refresh loop, so it has the same custody-conflict question as OpenCode's main slots — and the same treadmill exposure if a vault mirrors instead of owning.Worth treating as a separate slice each, after OpenCode main slots prove out. Neither is a plugin host in the way OpenCode is, so integration is a different shape — likely a wrapper or a credential-helper rather than an in-process plugin, and that difference deserves its own discussion rather than being assumed away here.
Sequencing
Nothing here is committed. Opening it for discussion so the custody and rollback questions get argued before anyone writes the plugin — those are the ones with no cheap fix after the fact.