Repository navigation
Design: OAuth half of the OpenCode custody plugin (xai as the first OpenCode-native OAuth provider) #57
Description
Status: design final (v2, 2026-09-18T21:38Z). Awaiting an implementation plan; nothing built. v2 folds in the maintainer's amendments from the comment thread so this body is the single artifact. Changes from v1 are marked [v2].
Scope
The custody plugin serves three static API keys from the vault today (minimax-coding-plan, deepseek, synthetic). This issue is the OAuth half: OAuth providers that OpenCode authenticates natively, through its own shipped loader, with no separate auth plugin. On this deployment that is exactly one provider, xai.
Not in scope, and worth saying plainly so nobody reads this as redoing it:
anthropicandopenai. Both are already vault-custodied through their own tenant plugins (anthropic-auth as of 2026-09-17, openai-auth as of 2026-09-18). They own their manifest blocks and their tombstones.google. The OpenCode-native slot inauth.jsonexpired 2026-06-17 and nothing has refreshed it in three months. The live Google credential on this host isantigravity:google, owned by antigravity-auth. There is no consumer for an OpenCode-native Google OAuth path, so I am not designing one.
What already exists (verified at 30c077b)
More than I expected. The vault side is done and the serve side is done; the gap is enrolment.
Vault. oauth:xai is a first-class record type. refresh_adapters/xai.rs hits auth.x.ai/oauth2/token and repacks a rotated refresh token when the provider returns one (xai.rs:149-151), falling back to the stored one otherwise. ck auth import --source opencode --id oauth:xai --provider xai is documented to import an OAuth entry from auth.json today ("an oauth id imports tokens"). The parser at oauth.rs:154-180 accepts {type:"oauth", access, refresh, expires} structurally and refuses tombstone material.
Plugin serve path. shape: "oauth" is accepted by the manifest parser and materialised identically to api: plugin.ts:418-440 builds a FreshnessController and injects { apiKey: sentinel(provider), fetch } into cfg.provider[id].options. The fetch closure substitutes the sentinel with vault material wherever the SDK put it. For xai that lands in Authorization: Bearer, which is why the T0 spike returned custom_fetch=1, shipped_refresh=0 for xai before any of this was built.
Freshness. freshness.ts:45-48 runs a 60s idle tick for OAuth accounts calling credential.get with minTtlMs = 270 * 60_000 by default, overridable once, globally, via oauthMinTtlMs. The plugin never holds a refresh token; the vault refreshes.
Tombstone. tombstone.ts:16-30 has a writer and a recogniser for the oauth shape.
So ck auth import + ck auth mint-handle + a hand-edited manifest entry + a hand-written tombstone would serve xai from the vault tonight, with no code change. That is the baseline this design has to beat, not a blank page.
What is missing
Four things, in order of how much they decide.
1. Enrolment is consumer-owned, and there is no consumer-side enrolment
The direction note on #5 is explicit and I am designing to it rather than around it:
The vault mints and serves; the consumer writes its own files.
and
migrate-opencode,opencode-account,bind,onboard-opencode-mainandfinalize-opencode-mainexist because OpenCode's credentials already lived on disk ... and the vault ended up writingopencode-handles.jsonandauth.jsontombstones at 18 sites. That contradicts ... that the vault does not own consumer paths, and it is going.
So extending migrate-opencode to accept type: "oauth" (today it filters to type == "api" at opencode_migration.rs:497-515) would be building on a verb that is being removed. The right shape is:
[v2] Maintainer ruling: a script, not an OpenCode slash command. Under the direction note the plugin will enrol itself once (enrolled:<name>, a category grant on llm-provider) and stop holding per-provider handles, so a per-provider command builds a surface that goes away. The tool is built so the tombstone writer and the manifest writer are separable, with separate tests: the manifest half is interim scaffolding that dies when the plugin resolves accounts by grant and credential.list_scoped; the tombstone half (keep OpenCode's native loader off a stale local token) outlives it.
- Vault: [v2]
ck auth login --provider xai --id oauth:xai(exists: browser flow with loopback capture,--devicealso accepted), thenck auth mint-handle --id oauth:xai --out <0600 file>(exists). Nothing new. Login rather than import because the family inauth.jsonis expired anyway, and login lands the record with the vault as the only holder from the first second, which import cannot promise.import --source opencodefor an OAuth entry is parser-tested (OAuthCredential::import_provider, the"opencode" | "pi"arm, throughfrom_auth_json) but has no end-to-end CLI test intests/cli_admin.rs; not on this path. - Consumer: a
packages/opencodeenrolment tool,bun run enroll -- --provider xai --handle-file <path>, that does what anthropic-auth's/claude-account claustrumand openai-auth's/openai-account claustrumdo for their providers:credential.geton the handle and refuse unless the servedcredential_idis exactlyoauth:xai(the identity fence that landed in opencode plugin: the served credential_id is now available and still unused — and the fix must not fail closed on absence #55, now applied at enrolment rather than only at serve).
[v2] Three refusals that belong here, not later: the id is operator-supplied (--id), never derived, and must satisfy the provider-segment rule (segment 2 == provider); a two-segment id (oauth:xai, the operator's main slot) is the highest-consequence action the tool takes and is refused without an explicit--mainflag; and the tool never imports. Tombstoning a slot whose vault record does not serve is the one irreversible mistake available here, so thecredential.getruns first and a failure stops everything.- Write the manifest entry under the cross-tenant lock (
writeHandleFileLocked, opencode: a cross-tenant manifest writer lock, and the eviction race it hides #33):{provider:"xai", shape:"oauth", serve:"opencode-claustrum", accounts:[{label:"main", handle, credential_id:"oauth:xai"}]}. - Write the tombstone into
auth.json, then re-read and compare. Byte-equal to what was written: done. Byte-equal to the pre-write bytes: a workspace child clobbered it viaOPENCODE_AUTH_CONTENT(workspace.ts:532/auth/index.ts:59), rewrite once, bounded. Anything else: newer material landed from somewhere, refuse loudly and leave it.
Step 3 is the same convergent reconciliation anthropic-auth adopted in #196 after I traced the clobber vector. It is not new design; it is the same protocol in a third tenant.
This also gives xai an exit path, which the static-key half never needed: remove the manifest entry, and the plugin stops injecting; the operator runs /login for xai in OpenCode and local serving resumes. Order matters and the tool should do it in that order, because the reverse (login first, manifest entry still present) is the SplitCustody cell, which correctly refuses.
2. The OAuth tombstone on master is the wrong shape
tombstone.ts:30 requires access === sentinel. Nothing in production writes that. anthropic-auth writes access: "" deliberately (custodyTombstoneOAuth in packages/core/src/claustrum.ts, access: '' at line 258 of e25807c) so that Claustrum's unattended sealer refuses the marker at its shape gate. openai-auth writes the same. The golden fixture packages/opencode/golden/tombstone.json encodes the sentinel-access form, so both peers pin against a fixture that describes a shape nobody emits.
For xai the claustrum plugin is the writer, so this matters: it should emit the canonical empty-access form and recognise only that. fix/oauth-tombstone-canonical (cbe5f03) does exactly this and is 43 commits behind; it becomes the first PR of this work rather than a standalone fix.
3. minTtlMs is global and was derived from Anthropic's token lifetime
270 minutes came from 8-hour Anthropic tokens: refresh-before-expiry 240m plus 30m margin. The engine refreshes whenever now + min_ttl >= expires_at (engine.rs:325). If xai issues tokens shorter than 270 minutes, every 60s tick forces a refresh, and xai rotates refresh tokens, so that is a rotation storm against auth.x.ai on a 60-second cadence.
[v2] minTtlMs becomes a per-account manifest field, and the enrol script refuses to write an entry whose value is not below the measured token lifetime. The invariant: minTtlMs < token_lifetime for every enrolled OAuth provider, or the tick becomes the refresher.
[v2] Lifetime, measured by the maintainer on a vault that already holds oauth:xai: 24 refresh_commit rows over seven days, mean gap 419 minutes, current token good for 166m at time of writing. The engine refreshes when remaining lifetime drops under its floor, not at expiry, so the gap is a lower bound; the lifetime is on the order of seven hours. The 270-minute default sits well under that. I will still take expires_in from the fresh login before pinning the number, but the order of magnitude is settled.
4. Two provider facts have to be measured, not assumed
I got both of these wrong for other providers this week by reasoning from a rule that was true somewhere else.
[v2] Rotation: settled. xai rotates on every refresh: 24 commits, 24 distinct payload_hash values (the hash covers the committed material, so an unrotated refresh token would repeat). Revocation of the predecessor: still unmeasured, and the maintainer will not test it against the production record. It gets measured against a fresh login I intend to discard.
Does xai revoke the predecessor refresh token on rotation? Anthropic does (single-use; one vault refresh strands every leaked copy). OpenAI does not (four vault refreshes over 33 days while the plugin served from a sibling lineage, zero 401s from a reporting path proven live on 08-14). The adapter repacks a rotated token if one is returned, which tells us xai may rotate, not whether the old one dies. This decides whether custodying xai leaves the old auth.json lineage valid in backups, and whether "burn the outgoing lineage" is a refresh or a provider-side revocation.
Does OpenCode's shipped xai loader attempt its own refresh when the local entry is a tombstone? T0 showed shipped_refresh=0 with our fetch injected, on the OpenCode version of 2026-09-02. That has to be re-run on the current version with an actual tombstone in place, because if the loader tries to refresh a sentinel it will 400 at auth.x.ai and the question is whether that failure is silent or wedges the provider.
Preconditions
- The xai family in
auth.jsonis expired (2026-09-17T11:14Z). There is nothing to import. Enrolment starts with/loginfor xai in OpenCode, orck auth login --provider xai --id oauth:xaiif that path exists for xai (I have not checked; the CLI has--deviceflows for some providers). This is also the moment to take the lifetime measurement in item 3. - Single host. Multi-account custody: N accounts per provider now (non-rotating kinds), custody inversion later (rotating OAuth) — the one-refresher invariant made structural #5 pins rotating OAuth to one home host with no refresh-token replication. icebox is the only host and the daemon binds loopback only. Stating it so the design does not have to answer a question it does not have.
What this does not touch
- The handle-to-category-grant migration from Multi-account custody: N accounts per provider now (non-rotating kinds), custody inversion later (rotating OAuth) — the one-refresher invariant made structural #5. Enrolment uses a capability handle today because that is what exists; the enrolment tool is the seam that swaps to a grant later, and the
credential_ididentity fence is the invariant that survives the swap unchanged. #45(per-row manifest refusal). Still a safety precondition for adding tenants, still parked per the maintainer.- Any change to
packages/client.
Questions for the maintainer [v2: all three answered above; kept for the record]
- Is a consumer-side
bun run enrollinpackages/opencodethe shape you want, or would you rather the plugin register an OpenCode slash command the way anthropic-auth and openai-auth do? The script is simpler and testable hermetically; the command is what the other two tenants converged on. ck auth import --source opencodefor an OAuth entry: confirmed to work foroauth:xaispecifically, or is that documented-but-unexercised? I will find out when the family is live, but if you know, it saves a round.- Per-provider
minTtlMs: manifest field, or plugin config? I lean manifest, since the value is a property of the provider's token lifetime and the manifest is where provider facts live.
Order [v2]
- Tombstone-canonical PR (
fix/oauth-tombstone-canonical, rebased to master, mutation arms re-proven). Recognise and write only{"type":"oauth","access":"","refresh":"<sentinel>","expires":0}.access: ""is load-bearing on both sides: the tenant writes it because the unattended sealer refuses empty access at its shape gate, and the sealer must never widen. Two halves of one interlock. ck auth login --provider xai --id oauth:xaion a family intended for measurement:expires_in, and predecessor revocation on rotation.- Enrolment tool in
packages/opencode: tombstone writer and manifest writer as separable functions, manifestminTtlMsfield, the three preflight refusals, convergent reconciliation on theauth.jsonwrite, and the exit path. - Live enrolment of xai on this host.
Nothing here is built.
Activity
ckcred-alfonso commentedon Sep 18, 2026
Good design, and it starts from the right place: the baseline it has to beat is "import + mint + two hand-edited files works tonight", which is true. Answers to the three questions first, then two provider facts you listed as unmeasured that I can measure from this vault, then one thing the direction note changes about item 1.
Q1, shape of enrolment. Neither, in the end state; a script for now. Under the direction note the plugin will enroll itself once (
enrolled:<name>, a category grant onllm-provider) and stop holding per-provider handles entirely, so a slash command per provider is building the surface that goes away. Abun run enroll -- --provider xai --handle-file <path>script is the right interim: hermetically testable, and itscredential_idfence at enrolment is the invariant that survives the swap to a grant unchanged, exactly as you say. I would not have it register an OpenCode command.Q2,
import --source opencodefor an OAuth entry. Exercised at the parser, not at the CLI.OAuthCredential::import_provider(crates/credentials-core/src/oauth.rs,"opencode" | "pi"arm) selects the provider sub-object of a multi-providerauth.jsonand parses it throughfrom_auth_json, the same path a single-entry file takes; that path is what the tombstone refusal and the structural{type:"oauth", access, refresh, expires}acceptance are pinned on. What does not exist is an integration test intests/cli_admin.rsthat runs the CLI verb end to end against an opencode-shaped file with an OAuth entry (grep finds none). So: documented, parser-tested, CLI-unexercised for OAuth. Given the xai family in yourauth.jsonis expired,ck auth login --provider xai --id oauth:xaiis the better start anyway; it exists (browser flow with loopback capture,--devicealso accepted), and it lands the record with the vault as the only holder from the first second, which import cannot promise.Q3, per-provider
minTtlMs. Manifest, for the reason you give, and the invariant belongs beside it as a check the enrol script runs: refuse to write a manifest entry whoseminTtlMsis not below the measured token lifetime. On which:xai token lifetime, measured. From this vault's
oauth:xairecord over the last seven days: 24refresh_commitrows, mean gap between commits 419 minutes, and the current access token readsgood for 166mat the moment of writing. The refresh trigger isnow + min_ttl >= expires_atwith the vault's own default, so the cadence says the lifetime is on the order of seven hours (the engine refreshes when the remaining lifetime drops under its floor, not at expiry, so the gap is a lower bound). Your 270-minute default sits under that with room; a tick-driven rotation storm needsminTtlMs >= lifetime, and it is not close. Measure it once more from a fresh login'sexpires_inbefore you pin the number, as you planned, but the order of magnitude is settled.Does xai rotate the refresh token? Yes, on every refresh, measured: 24 refresh commits in seven days, 24 distinct
payload_hashvalues inaudit_log(the hash is over the committed material, so an unrotated refresh token would repeat). Whether the predecessor is revoked on rotation is the half I cannot see from here: no reuse has ever been attempted from this vault because it is the single holder. The one relevant datum is negative:oauth:xaihas zeroinvalid_grantevents in its history, consistent with single custody, not evidence either way about reuse. That measurement needs a deliberate reuse of a superseded token againstauth.x.ai, which I will not run from the production record; do it from a fresh login you intend to discard.The one thing the direction note changes in item 1. Your step 3 has the plugin write the tombstone into
auth.jsonand reconcile. That is correct under "the consumer writes its own files", and it is the same convergent protocol the other two tenants use, so nothing to object to. But note what it does not need to survive: once enrollment lands, the plugin resolves accounts by category grant andcredential.list_scopedrather than by manifest rows, and the tombstone's job (keep OpenCode's native loader from serving a stale local token) is unchanged while the manifest half of the tool becomes dead weight. Build the tool so the tombstone writer and the manifest writer are separable; the first outlives the second.The tombstone-canonical PR first is the right order.
access: ""as the canonical form is what my sealer refuses at the shape gate, so recognising only that form is correct on both sides.iceteaSA commentedon Sep 18, 2026
Taking all of it. The amendments, so the record shows what changed:
ck auth login --provider xai --id oauth:xai, not import. The vault is the sole holder from the first second, and it sidesteps the CLI-unexercised import path for OAuth entirely.minTtlMson the manifest entry, with the enrol script refusing to write an entry whose value is not below the measured lifetime.expires_infrom the fresh login before pinning it, as planned.Order stands: tombstone-canonical PR first. Rebasing it now.
iceteaSA commentedon Sep 23, 2026
This design shipped, so I'm closing the issue.
access: ""form.minTtlMson the manifest entry.Live since 09-19T09:53Z.
oauth:xaiis at v28 after 25 vault refreshes, with no invalidations. It refreshes every 4 h (6 h token,minTtlMs120 min).Two measurements that were still open when you replied:
invalid_grant.The manifest half is still the interim scaffolding you described. It goes away with the category-grant work tracked on #5.