Skip to content

Design: OAuth half of the OpenCode custody plugin (xai as the first OpenCode-native OAuth provider) #57

Closed

Description

@iceteaSA

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:

  • anthropic and openai. 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 in auth.json expired 2026-06-17 and nothing has refreshed it in three months. The live Google credential on this host is antigravity: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-main and finalize-opencode-main exist because OpenCode's credentials already lived on disk ... and the vault ended up writing opencode-handles.json and auth.json tombstones 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, --device also accepted), then ck auth mint-handle --id oauth:xai --out <0600 file> (exists). Nothing new. Login rather than import because the family in auth.json is expired anyway, and login lands the record with the vault as the only holder from the first second, which import cannot promise. import --source opencode for an OAuth entry is parser-tested (OAuthCredential::import_provider, the "opencode" | "pi" arm, through from_auth_json) but has no end-to-end CLI test in tests/cli_admin.rs; not on this path.
  • Consumer: a packages/opencode enrolment tool, bun run enroll -- --provider xai --handle-file <path>, that does what anthropic-auth's /claude-account claustrum and openai-auth's /openai-account claustrum do for their providers:
    1. credential.get on the handle and refuse unless the served credential_id is exactly oauth: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 --main flag; and the tool never imports. Tombstoning a slot whose vault record does not serve is the one irreversible mistake available here, so the credential.get runs first and a failure stops everything.
    2. 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"}]}.
    3. 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 via OPENCODE_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

What this does not touch

Questions for the maintainer [v2: all three answered above; kept for the record]

  1. Is a consumer-side bun run enroll in packages/opencode the 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.
  2. ck auth import --source opencode for an OAuth entry: confirmed to work for oauth:xai specifically, or is that documented-but-unexercised? I will find out when the family is live, but if you know, it saves a round.
  3. 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]

  1. 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.
  2. ck auth login --provider xai --id oauth:xai on a family intended for measurement: expires_in, and predecessor revocation on rotation.
  3. Enrolment tool in packages/opencode: tombstone writer and manifest writer as separable functions, manifest minTtlMs field, the three preflight refusals, convergent reconciliation on the auth.json write, and the exit path.
  4. Live enrolment of xai on this host.

Nothing here is built.

Activity

  1. ckcred-alfonso commented on Sep 18, 2026

    @ckcred-alfonso

    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 on llm-provider) and stop holding per-provider handles entirely, so a slash command per provider is building the surface that goes away. A bun run enroll -- --provider xai --handle-file <path> script is the right interim: hermetically testable, and its credential_id fence 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 opencode for 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-provider auth.json and parses it through from_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 in tests/cli_admin.rs that 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 your auth.json is expired, ck auth login --provider xai --id oauth:xai is the better start anyway; it exists (browser flow with loopback capture, --device also 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 whose minTtlMs is not below the measured token lifetime. On which:

    xai token lifetime, measured. From this vault's oauth:xai record over the last seven days: 24 refresh_commit rows, mean gap between commits 419 minutes, and the current access token reads good for 166m at the moment of writing. The refresh trigger is now + min_ttl >= expires_at with 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 needs minTtlMs >= lifetime, and it is not close. Measure it once more from a fresh login's expires_in before 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_hash values in audit_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:xai has zero invalid_grant events in its history, consistent with single custody, not evidence either way about reuse. That measurement needs a deliberate reuse of a superseded token against auth.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.json and 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 and credential.list_scoped rather 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.

  2. iceteaSA commented on Sep 18, 2026

    @iceteaSA
    ContributorAuthor

    Taking all of it. The amendments, so the record shows what changed:

    • Enrolment is a script, not a command, and it is built so the tombstone writer and the manifest writer are separable. The manifest half is interim scaffolding that dies when the plugin resolves accounts by category grant; the tombstone half outlives it. I will structure the tool as two functions with two test suites rather than one procedure.
    • Start from 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.
    • minTtlMs on the manifest entry, with the enrol script refusing to write an entry whose value is not below the measured lifetime.
    • Your lifetime measurement (mean gap 419m, so lifetime on the order of 7h, lower bound) puts the 270m default well clear of a tick-driven storm. I will still take expires_in from the fresh login before pinning it, as planned.
    • Rotation on every refresh: taken as settled (24/24 distinct hashes). Predecessor revocation stays unmeasured until I can test it against a login I intend to discard. Not from the production record.

    Order stands: tombstone-canonical PR first. Rebasing it now.

  3. iceteaSA commented on Sep 23, 2026

    @iceteaSA
    ContributorAuthor

    This design shipped, so I'm closing the issue.

    Live since 09-19T09:53Z. oauth:xai is at v28 after 25 vault refreshes, with no invalidations. It refreshes every 4 h (6 h token, minTtlMs 120 min).

    Two measurements that were still open when you replied:

    • Lifetime is 6.0 h (21,599,663 ms from a fresh login), not ~7 h. The 419-minute mean gap was an upper-side estimate.
    • Predecessor revocation: confirmed on a discardable login. Replaying the pre-rotation refresh token got invalid_grant.

    The manifest half is still the interim scaffolding you described. It goes away with the category-grant work tracked on #5.

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

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