From efca3010795fa90faaec1095627fea8d7cedc29b Mon Sep 17 00:00:00 2001 From: lemon07r Date: Sun, 19 Apr 2026 01:39:56 -0400 Subject: [PATCH] Harden OAuth refresh token recovery --- AGENTS.md | 6 +- README.md | 5 +- package.json | 2 +- src/index.ts | 185 +++++++++++++++++++++++++++++++++++++++----- test/plugin.test.ts | 147 ++++++++++++++++++++++++++++++++++- 5 files changed, 318 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 30c09a6..0f9aaf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Data flow on a chat request: 1. opencode asks the `@ai-sdk/openai-compatible` provider for a language model. 2. Before instantiating it, opencode calls our `auth.loader`. We return `{ apiKey, fetch }`. 3. The SDK uses our `fetch` for every HTTP call (models, chat, whatever). -4. Our `fetch` calls `ensureFresh()` → maybe refreshes (sharing one in-flight refresh across concurrent callers so they don't race the same refresh token) → lazily discovers `/coding/v1/models` when needed → sets Authorization + the seven `X-Msh-*` headers → on 401 refreshes once and retries. +4. Our `fetch` calls `ensureFresh()` → prefers the live opencode auth-store entry over stale `OPENCODE_AUTH_CONTENT` snapshots → maybe refreshes (sharing one in-flight promise in-process and a lock across plugin instances so they don't race the same refresh token) → lazily discovers `/coding/v1/models` when needed → sets Authorization + the seven `X-Msh-*` headers → on 401 refreshes once and retries. 5. Separately, opencode runs `chat.headers` and `chat.params`. `chat.headers` computes `thinking`, `reasoning_effort`, and `prompt_cache_key` from `input.model.options` plus the selected `input.message.model.variant`, then passes them to `loader.fetch` via private `x-opencode-kimi-*` headers. `loader.fetch` strips those headers and injects the wire fields into the JSON body. `chat.params` mirrors the same keys into `output.options` only as a forward-compat fallback if opencode later fixes its openai-compatible providerOptions namespace mismatch. ### Contracts to keep intact @@ -67,11 +67,11 @@ These are the invariants that, if broken, silently degrade K2.6 → K2.5 or prod 5. **`prompt_cache_key` only for `kimi-for-coding`.** Never attach it to unrelated models. The check is `input.model.id === MODEL_ID` in the Kimi chat hooks, and the actual wire injection happens in `loader.fetch`. 6. **Wire model id comes from `/coding/v1/models`, not from user config.** The opencode-side model id is a stable alias (`MODEL_ID = "kimi-for-coding"`); the plugin calls `GET /coding/v1/models` at login and on every token refresh (mirroring kimi-cli's `refresh_managed_models` in `research/kimi-cli/src/kimi_cli/auth/platforms.py`), caches the first returned `{id, context_length, display_name}` in loader memory, and rewrites the JSON body `model` field inside `loader.fetch` whenever the discovered id differs from `MODEL_ID` (the K2.5 case — server may return `k2p5` instead). A new loader instance re-discovers on first use if needed. K2.6 accounts see `id: "kimi-for-coding"` and the rewrite is a no-op. Do not strip the `kimi-` prefix; send whatever the server returned. Discovery failures are non-fatal (warm cached id still works; 401 retry flushes broken tokens). -7. **Auth store is opencode's, not kimi-cli's.** We use opencode's auth store for tokens under the `kimi-for-coding-oauth` provider id. Do not read/write `~/.kimi/credentials/kimi-code.json`; that's kimi-cli's file and sharing it across independent apps causes token-race bugs. Also note that opencode's SDK auth schema only persists the standard oauth fields, so model discovery metadata cannot be stored there durably. +7. **Auth store is opencode's, not kimi-cli's.** We use opencode's auth store for tokens under the `kimi-for-coding-oauth` provider id. Do not read/write `~/.kimi/credentials/kimi-code.json`; that's kimi-cli's file and sharing it across independent apps causes token-race bugs. The plugin may live-read opencode's `auth.json` entry for this provider to bypass stale `OPENCODE_AUTH_CONTENT` workspace snapshots, but writes still go through opencode's auth store (`client.auth.set`). Also note that opencode's SDK auth schema only persists the standard oauth fields, so model discovery metadata cannot be stored there durably. 8. **Provider id must not collide with any id in the [models.dev](https://models.dev) catalog.** models.dev publishes `kimi-for-coding` (static `KIMI_API_KEY` → `@ai-sdk/anthropic` → K2.5). If we registered under that same id, `opencode auth login kimi-for-coding` would surface two methods under one entry and users picking the API-key one would silently land on K2.5. We deliberately use `kimi-for-coding-oauth` instead; `MODEL_ID` on the wire stays `kimi-for-coding` (rule 6). 9. **`src/index.ts` must have exactly one export — the default plugin function.** opencode's plugin loader (`research/opencode/packages/opencode/src/plugin/index.ts` → `getLegacyPlugins`) iterates every export of the plugin module and throws `Plugin export is not a function` if any named export is not callable. The failure mode is silent in the CLI (the provider just doesn't appear in `opencode auth login`); the error only surfaces in `~/.local/share/opencode/log/*.log`. Keep constants in `src/constants.ts` and import them in `src/index.ts` rather than re-exporting. `test/exports.test.ts` guards this. 10. **The post-login config hint must not emit a partial `limit` object.** opencode's live config schema at `https://opencode.ai/config.json` requires both `limit.context` and `limit.output` whenever `limit` is present, while Kimi's `GET /coding/v1/models` only gives us `context_length`. Therefore `buildConfigBlock()` omits `limit` entirely and leaves `provider.models` to backfill `limit.context` at runtime. Do not invent `output` or set `input` heuristically; opencode's overflow logic treats `limit.input` as authoritative (`research/opencode/packages/opencode/src/session/overflow.ts`). -11. **Concurrent refreshes must collapse to one in-flight OAuth exchange.** `provider.models` and `auth.loader` can both notice an expiring token at about the same time. `refreshAuth()` in `src/index.ts` therefore shares one promise across overlapping callers so they do not race the same refresh token or double-write auth state. `test/plugin.test.ts` covers both loader-vs-loader and provider.models-vs-loader overlap. +11. **Concurrent refreshes must collapse to one in-flight OAuth exchange, even across plugin instances.** `provider.models` and `auth.loader` can both notice an expiring token at about the same time, and separate opencode workspace/plugin instances can inherit stale auth snapshots. `refreshAuth()` in `src/index.ts` therefore shares one promise across overlapping callers, takes a provider-scoped auth-store lock before refreshing, re-reads opencode's live auth-store entry under that lock, and treats a changed on-disk token chain as authoritative. `test/plugin.test.ts` covers loader-vs-loader, provider.models-vs-loader, cross-instance lock reuse, and the `invalid_grant` self-heal path where another process already rotated the refresh token. ### Working on this repo diff --git a/README.md b/README.md index 5e78c43..a30349c 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Two identifiers are load-bearing: opencode auth login -p kimi-for-coding-oauth ``` -The plugin returns a verification URL and user code. After browser approval it polls the device-auth endpoint, queries `/coding/v1/models` to discover the current model id and context length for your account, prints a ready-to-paste config snippet, and stores the token in opencode's auth store. The authorization message still includes the discovered context length; the snippet intentionally omits `limit` because opencode's schema requires `limit.output` too, and Kimi's models endpoint only exposes `context_length`. Model discovery runs again on every token refresh, and a fresh loader instance will re-query `/coding/v1/models` on first use if it needs the current wire model id. Access tokens refresh automatically, and the loader retries once after a `401`. +The plugin returns a verification URL and user code. After browser approval it polls the device-auth endpoint, queries `/coding/v1/models` to discover the current model id and context length for your account, prints a ready-to-paste config snippet, and stores the token in opencode's auth store. The authorization message still includes the discovered context length; the snippet intentionally omits `limit` because opencode's schema requires `limit.output` too, and Kimi's models endpoint only exposes `context_length`. Model discovery runs again on every token refresh, and a fresh loader instance will re-query `/coding/v1/models` on first use if it needs the current wire model id. Access tokens refresh automatically, the loader retries once after a `401`, and refreshes are coordinated through opencode's live auth store so concurrent workspaces do not burn a stale refresh token from an old `OPENCODE_AUTH_CONTENT` snapshot. ### Use @@ -136,6 +136,7 @@ This plugin exists to bring the OAuth/device-flow `kimi-cli` path into opencode - The seven `X-Msh-*` headers and a kimi-cli-shaped `User-Agent`. - `~/.kimi/device_id` shared with a locally-installed kimi-cli. - Tokens stored in opencode's auth store under a dedicated provider id, so the plugin and kimi-cli keep independent refresh-token chains and do not invalidate each other. +- Live auth-store rereads plus a provider-scoped refresh lock, so concurrent opencode workspaces converge on the latest refresh-token chain instead of tripping `invalid_grant`. - Streaming, `reasoning_content` deltas, and tool-call schemas are handled upstream by `@ai-sdk/openai-compatible` — not reimplemented here. @@ -168,7 +169,7 @@ Effort-to-field mapping used by the plugin: | Path | Purpose | |---|---| | `~/.kimi/device_id` | Stable UUID used in `X-Msh-Device-Id`. Shared with kimi-cli. | -| opencode auth store (`auth.json` in opencode's XDG data dir; on Linux typically `~/.local/share/opencode/auth.json`) | Token storage, managed by opencode through `client.auth.*`. | +| opencode auth store (`auth.json` in opencode's XDG data dir; on Linux typically `~/.local/share/opencode/auth.json`) | Token storage, managed by opencode through `client.auth.*`; the plugin also live-reads this entry to avoid stale workspace auth snapshots during refresh. | No other state is persisted. Credentials are never written to `~/.kimi/credentials/`; that path belongs to kimi-cli, and sharing it would cause refresh-token races between the two clients. diff --git a/package.json b/package.json index c54d0a3..2d64d05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-kimi-full", - "version": "1.2.4", + "version": "1.2.5", "description": "OpenCode plugin that adds first-class support for Kimi K2.6 (kimi-for-coding) via the official Kimi OAuth device flow, matching the upstream kimi-cli 1:1.", "license": "MIT", "repository": { diff --git a/src/index.ts b/src/index.ts index 76872cc..44afc4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" import type { Plugin } from "@opencode-ai/plugin" import { MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "./constants.ts" import { kimiHeaders } from "./headers.ts" @@ -54,6 +57,13 @@ type KimiHookInput = { const INTERNAL_PROMPT_CACHE_KEY_HEADER = "x-opencode-kimi-prompt-cache-key" const INTERNAL_REASONING_EFFORT_HEADER = "x-opencode-kimi-reasoning-effort" const INTERNAL_THINKING_TYPE_HEADER = "x-opencode-kimi-thinking-type" +const REFRESH_LOCK_WAIT_MS = 15_000 +const REFRESH_LOCK_POLL_MS = 100 +const REFRESH_LOCK_STALE_MS = 120_000 + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} function isOAuthAuth(value: unknown): value is OAuthAuth { if (!value || typeof value !== "object" || Array.isArray(value)) return false @@ -71,6 +81,96 @@ function asRecord(value: unknown): Record | undefined { return value as Record } +function authStoreCandidates() { + const home = os.homedir() + if (process.env.XDG_DATA_HOME) { + return [path.join(process.env.XDG_DATA_HOME, "opencode", "auth.json")] + } + const candidates = new Set() + candidates.add(path.join(home, ".local", "share", "opencode", "auth.json")) + if (process.platform === "darwin") { + candidates.add(path.join(home, "Library", "Application Support", "opencode", "auth.json")) + } + if (process.platform === "win32") { + const local = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local") + candidates.add(path.join(local, "opencode", "auth.json")) + if (process.env.APPDATA) { + candidates.add(path.join(process.env.APPDATA, "opencode", "auth.json")) + } + } + return [...candidates] +} + +async function resolveAuthStorePath() { + const candidates = authStoreCandidates() + for (const file of candidates) { + try { + await fs.access(file) + return file + } catch {} + } + return candidates[0]! +} + +async function readAuthStoreEntry() { + for (const file of authStoreCandidates()) { + try { + const parsed = JSON.parse(await fs.readFile(file, "utf8")) as Record + const entry = parsed[PROVIDER_ID] ?? parsed[`${PROVIDER_ID}/`] + if (isOAuthAuth(entry)) return entry + } catch {} + } + return +} + +function sameAuth(left: OAuthAuth, right: OAuthAuth) { + return left.access === right.access && left.refresh === right.refresh && left.expires === right.expires +} + +function withInvalidGrantHint(error: unknown) { + if (!(error instanceof Error) || !/invalid_grant/.test(error.message)) return error + const next = new Error( + `${error.message}. The token may have been rotated or revoked in another opencode session — run \`opencode auth login kimi-for-coding-oauth\` again if it does not self-heal.`, + ) as Error & { code?: string; status?: number } + next.code = (error as Error & { code?: string }).code + next.status = (error as Error & { status?: number }).status + return next +} + +async function withRefreshLock(work: () => Promise) { + const authFile = await resolveAuthStorePath() + const lockDir = `${authFile}.${PROVIDER_ID}.refresh.lock` + await fs.mkdir(path.dirname(lockDir), { recursive: true }) + const deadline = Date.now() + REFRESH_LOCK_WAIT_MS + + while (true) { + try { + await fs.mkdir(lockDir) + break + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code !== "EEXIST") throw error + try { + const stat = await fs.stat(lockDir) + if (Date.now() - stat.mtimeMs > REFRESH_LOCK_STALE_MS) { + await fs.rm(lockDir, { recursive: true, force: true }) + continue + } + } catch {} + if (Date.now() >= deadline) { + throw new Error("kimi oauth: timed out waiting for the auth refresh lock") + } + await sleep(REFRESH_LOCK_POLL_MS) + } + } + + try { + return await work() + } finally { + await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined) + } +} + function asThinking(value: unknown): KimiBodyFields["thinking"] | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return const type = (value as { type?: unknown }).type @@ -220,7 +320,9 @@ function buildConfigBlock(info: { model_id: string; display?: string }) { * Responsibilities, in order of execution: * 1. `auth` — register device-flow OAuth login under the * `kimi-for-coding-oauth` provider id. opencode persists the returned tokens in its - * own auth.json; we never touch disk for credentials. + * own auth.json; the plugin also live-reads that file so + * workspace auth snapshots do not strand stale refresh + * tokens. * 2. `loader` — runs every time opencode instantiates the provider. Returns * a custom `fetch` that (a) refreshes the access token when * it is about to expire, (b) injects the seven X-Msh-* / UA @@ -245,8 +347,19 @@ const plugin: Plugin = async ({ client }) => { let cachedDiscovery: ModelDiscovery = {} let refreshPromise: Promise | undefined + const syncProcessAuthContent = (auth: OAuthAuth) => { + if (!process.env.OPENCODE_AUTH_CONTENT) return + try { + const parsed = JSON.parse(process.env.OPENCODE_AUTH_CONTENT) as Record + delete parsed[`${PROVIDER_ID}/`] + parsed[PROVIDER_ID] = auth + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify(parsed) + } catch {} + } + const persistAuth = async (auth: OAuthAuth) => { await client.auth.set({ path: { id: PROVIDER_ID }, body: auth }) + syncProcessAuthContent(auth) } const isExpiring = (auth: OAuthAuth) => auth.expires - Date.now() < REFRESH_SAFETY_WINDOW_MS @@ -256,22 +369,52 @@ const plugin: Plugin = async ({ client }) => { return cachedDiscovery } - const refreshAuth = async (auth: OAuthAuth) => { + const readLiveAuth = async () => { + const auth = await readAuthStoreEntry() + if (auth) syncProcessAuthContent(auth) + return auth + } + + const readCurrentAuth = async (readAuth?: () => Promise) => { + const live = await readLiveAuth() + if (live) return live + if (!readAuth) return + const current = await readAuth() + if (!isOAuthAuth(current)) return + syncProcessAuthContent(current) + return current + } + + const refreshAuth = async (auth: OAuthAuth, force = false) => { // opencode can ask both `provider.models` and `loader.fetch` to refresh - // around the same time. Serialize those calls so one refresh token does - // not fan out into multiple concurrent refresh exchanges. + // around the same time, including from separate workspace processes that + // only inherited a stale `OPENCODE_AUTH_CONTENT` snapshot. Serialize + // refreshes through a lock and re-read opencode's live auth store before + // spending the refresh token. if (refreshPromise) return refreshPromise refreshPromise = (async () => { try { - const tokens = await refreshToken(auth.refresh) - const next: OAuthAuth = { - type: "oauth", - refresh: tokens.refresh_token, - access: tokens.access_token, - expires: Date.now() + tokens.expires_in * 1000, - } - await persistAuth(next) - return next + return await withRefreshLock(async () => { + const latest = await readLiveAuth() + const current = latest ?? auth + if (latest && !sameAuth(latest, auth) && !force && !isExpiring(latest)) return latest + if (!force && !isExpiring(current)) return current + try { + const tokens = await refreshToken(current.refresh) + const next: OAuthAuth = { + type: "oauth", + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + tokens.expires_in * 1000, + } + await persistAuth(next) + return next + } catch (error) { + const newest = await readLiveAuth() + if (newest && !sameAuth(newest, current)) return newest + throw withInvalidGrantHint(error) + } + }) } finally { refreshPromise = undefined } @@ -290,7 +433,7 @@ const plugin: Plugin = async ({ client }) => { const discover = async (auth: OAuthAuth) => applyDiscoveryToModels(provider.models, rememberDiscovery(pickModelInfo(await listModels(auth.access)))) - const current = ctx.auth + const current = (await readCurrentAuth()) ?? ctx.auth let auth = current try { if (isExpiring(auth)) auth = await refreshAuth(auth) @@ -300,7 +443,7 @@ const plugin: Plugin = async ({ client }) => { } try { - return await discover(await refreshAuth(current)) + return await discover(await refreshAuth(current, true)) } catch { return provider.models } @@ -315,9 +458,11 @@ const plugin: Plugin = async ({ client }) => { * and header concerns so no other hook has to worry about them. * * `readAuth` comes from opencode: it returns the currently persisted - * credentials for this provider id (opencode's `auth.json`). The SDK - * client intentionally does not expose a `get` — reading is scoped to - * this loader callback. Writes still go through `client.auth.set`. + * credentials for this provider id. opencode workspace processes may + * hydrate that from a stale `OPENCODE_AUTH_CONTENT` snapshot, so the + * loader prefers the live auth.json entry on disk and only falls back to + * `readAuth` when the file is absent. Writes still go through + * `client.auth.set`. */ loader: async (readAuth) => { let discovery: ModelDiscovery = cachedDiscovery @@ -349,13 +494,13 @@ const plugin: Plugin = async ({ client }) => { } const ensureFresh = async (force = false): Promise => { - const current = (await readAuth()) as (OAuthAuth & Partial) | undefined + const current = (await readCurrentAuth(readAuth)) as (OAuthAuth & Partial) | undefined if (!current || current.type !== "oauth") throw new Error( "kimi-for-coding-oauth: not logged in — run `opencode auth login kimi-for-coding-oauth`", ) if (!force && !isExpiring(current)) return ensureDiscovered(current) - const next = await refreshAuth(current) + const next = await refreshAuth(current, force) // kimi-cli re-runs `refresh_managed_models` on every successful // refresh — we mirror that so entitlement changes (e.g. an // account gaining/losing K2.6 access) are picked up without a diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 997e930..fb233bd 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" import { test, expect, afterEach } from "bun:test" import plugin from "../src/index.ts" import { MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "../src/constants.ts" @@ -7,12 +10,45 @@ import { installFetchMock } from "./_util/fetchMock.ts" // shared with kimi-cli by design and writes are idempotent — no HOME // redirect needed. +const TEST_XDG_DATA_HOME = path.join(os.tmpdir(), `opencode-kimi-full-test-${process.pid}`) +process.env.XDG_DATA_HOME = TEST_XDG_DATA_HOME +delete process.env.OPENCODE_AUTH_CONTENT + let mock: ReturnType | undefined -afterEach(() => { +afterEach(async () => { mock?.restore() mock = undefined + process.env.XDG_DATA_HOME = TEST_XDG_DATA_HOME + delete process.env.OPENCODE_AUTH_CONTENT + await fs.rm(TEST_XDG_DATA_HOME, { recursive: true, force: true }) }) +async function withTempAuthStore(entry: unknown, run: (root: string) => Promise) { + const prev = process.env.XDG_DATA_HOME + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-kimi-full-")) + process.env.XDG_DATA_HOME = root + await writeAuthStore(root, entry) + try { + return await run(root) + } finally { + if (prev === undefined) { + delete process.env.XDG_DATA_HOME + } else { + process.env.XDG_DATA_HOME = prev + } + await fs.rm(root, { recursive: true, force: true }) + } +} + +function authStorePath(root: string) { + return path.join(root, "opencode", "auth.json") +} + +async function writeAuthStore(root: string, entry: unknown) { + await fs.mkdir(path.dirname(authStorePath(root)), { recursive: true }) + await fs.writeFile(authStorePath(root), JSON.stringify({ [PROVIDER_ID]: entry }), "utf8") +} + // Fake opencode plugin context. Only `client.auth.set` is used by the // plugin's writes; reads go through the `readAuth` callback passed to // `loader`, not through client. @@ -329,6 +365,26 @@ test("provider.models: retries once with a refreshed token after 401", async () expect((writes[0]!.body as { access: string }).access).toBe("fresh") }) +test("provider.models: prefers the live auth store over a stale ctx.auth snapshot", async () => { + await withTempAuthStore(validAuth({ access: "fresh", refresh: "refresh-2" }), async () => { + mock = installFetchMock((call) => { + if (call.url.endsWith("/coding/v1/models") && call.headers["authorization"] === "Bearer fresh") { + return { body: { data: [{ id: MODEL_ID, context_length: 131072 }] } } + } + return { status: 401, body: { error: "unauthorized" } } + }) + const { hooks } = await getHooks() + const provider = makeProviderState() + const next = await hooks.provider!.models!( + provider as any, + { auth: validAuth({ access: "stale", refresh: "refresh-1" }) } as any, + ) + expect(mock.calls).toHaveLength(1) + expect(mock.calls[0]!.headers["authorization"]).toBe("Bearer fresh") + expect(next[MODEL_ID]!.limit?.context).toBe(131072) + }) +}) + test("auth.loader: refuses to run when no credentials are persisted", async () => { const { fetch: f } = await getLoaderFetch(async () => undefined) await expect(f("https://api.kimi.com/coding/v1/models")).rejects.toThrow(/not logged in/) @@ -339,6 +395,25 @@ test("auth.loader: apiKey sentinel is returned (opencode requires truthy)", asyn expect(apiKey).toBe("kimi-for-coding-oauth") }) +test("auth.loader: prefers live auth.json over a stale readAuth snapshot", async () => { + await withTempAuthStore(validAuth({ access: "fresh", refresh: "refresh-2" }), async () => { + mock = installFetchMock((call) => { + if (call.url.endsWith("/coding/v1/models")) { + return { body: { data: [{ id: MODEL_ID, context_length: 262144 }] } } + } + return { body: { ok: true } } + }) + const { fetch: f } = await getLoaderFetch(async () => validAuth({ access: "stale", refresh: "refresh-1" })) + await f("https://api.kimi.com/coding/v1/chat") + expect(mock.calls.map((c) => c.url)).toEqual([ + "https://api.kimi.com/coding/v1/models", + "https://api.kimi.com/coding/v1/chat", + ]) + expect(mock.calls[0]!.headers["authorization"]).toBe("Bearer fresh") + expect(mock.calls[1]!.headers["authorization"]).toBe("Bearer fresh") + }) +}) + test("auth.loader: owns Authorization and strips any caller-supplied value (rule 3)", async () => { mock = installFetchMock((call) => { if (call.url.endsWith("/coding/v1/models")) { @@ -561,6 +636,45 @@ test("provider.models and auth.loader share one in-flight refresh exchange", asy expect(writes).toHaveLength(1) }) +test("auth.loader: separate plugin instances share one refresh via the auth-store lock", async () => { + const stale = validAuth({ access: "stale", expires: Date.now() + REFRESH_SAFETY_WINDOW_MS / 2 }) + await withTempAuthStore(stale, async (root) => { + const gate = deferred() + mock = installFetchMock(async (call) => { + if (call.url.includes("/oauth/token")) { + await gate.promise + const next = validAuth({ access: "access-2", refresh: "refresh-2", expires: Date.now() + 15 * 60_000 }) + await writeAuthStore(root, next) + return { body: { access_token: next.access, refresh_token: next.refresh, token_type: "Bearer", expires_in: 900 } } + } + if (call.url.endsWith("/coding/v1/models")) { + return { body: { data: [{ id: MODEL_ID, context_length: 262144 }] } } + } + return { body: { ok: true } } + }) + const readAuth = async () => stale + const { fetch: f1 } = await getLoaderFetch(readAuth) + const { fetch: f2 } = await getLoaderFetch(readAuth) + const request = { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: MODEL_ID, messages: [] }), + } + + const p1 = f1("https://api.kimi.com/coding/v1/chat/completions", request) + const p2 = f2("https://api.kimi.com/coding/v1/chat/completions", request) + await new Promise((r) => setTimeout(r, 0)) + gate.resolve() + await Promise.all([p1, p2]) + + expect(mock.calls.filter((c) => c.url.includes("/oauth/token"))).toHaveLength(1) + expect(mock.calls.filter((c) => c.url.endsWith("/coding/v1/chat/completions")).map((c) => c.headers["authorization"])).toEqual([ + "Bearer access-2", + "Bearer access-2", + ]) + }) +}) + test("auth.loader: prefers the canonical MODEL_ID slug when /models returns multiple", async () => { // Server returns several entries; the canonical `kimi-for-coding` is not first. // Selection must still prefer it over the first element. @@ -605,6 +719,37 @@ test("auth.loader: model discovery failure does not break refresh (graceful)", a expect((writes[0]!.body as { model_id?: string }).model_id).toBeUndefined() }) +test("auth.loader: invalid_grant self-heals when the live auth store rotated mid-refresh", async () => { + const stale = validAuth({ access: "stale", expires: Date.now() + REFRESH_SAFETY_WINDOW_MS / 2 }) + await withTempAuthStore(stale, async (root) => { + mock = installFetchMock(async (call) => { + if (call.url.includes("/oauth/token")) { + const next = validAuth({ access: "fresh", refresh: "refresh-2", expires: Date.now() + 15 * 60_000 }) + await writeAuthStore(root, next) + return { + status: 400, + body: { error: "invalid_grant", error_description: "The provided authorization grant is invalid" }, + } + } + if (call.url.endsWith("/coding/v1/models")) { + return { body: { data: [{ id: MODEL_ID, context_length: 262144 }] } } + } + return { body: { ok: true } } + }) + const { fetch: f, writes } = await getLoaderFetch(async () => stale) + const res = await f("https://api.kimi.com/coding/v1/chat") + expect(res.ok).toBe(true) + expect(mock.calls.map((c) => c.url)).toEqual([ + "https://auth.kimi.com/api/oauth/token", + "https://api.kimi.com/coding/v1/models", + "https://api.kimi.com/coding/v1/chat", + ]) + expect(mock.calls[1]!.headers["authorization"]).toBe("Bearer fresh") + expect(mock.calls[2]!.headers["authorization"]).toBe("Bearer fresh") + expect(writes).toHaveLength(0) + }) +}) + test("auth.loader: discovers /models on first request when auth storage only has bare oauth fields", async () => { mock = installFetchMock((call) => { if (call.url.endsWith("/coding/v1/models")) {