Skip to content

Design discussion: an OpenCode plugin putting Claustrum on MAIN slots for providers with no dedicated auth plugin #17

Description

@iceteaSA

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:

  1. 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.
  2. No audit trail. Nothing records who read a credential, who wrote one, or when. Claustrum appends an HMAC-chained entry per mutation.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions