mirror of
https://github.com/lemon07r/opencode-kimi-full.git
synced 2026-07-18 08:05:52 +02:00
Harden OAuth refresh token recovery
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
</details>
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+165
-20
@@ -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<string, unknown> | undefined {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
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<string>()
|
||||
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<string, unknown>
|
||||
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<T>(work: () => Promise<T>) {
|
||||
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<OAuthAuth> | undefined
|
||||
|
||||
const syncProcessAuthContent = (auth: OAuthAuth) => {
|
||||
if (!process.env.OPENCODE_AUTH_CONTENT) return
|
||||
try {
|
||||
const parsed = JSON.parse(process.env.OPENCODE_AUTH_CONTENT) as Record<string, unknown>
|
||||
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<unknown>) => {
|
||||
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<OAuthAuth & ModelDiscovery> => {
|
||||
const current = (await readAuth()) as (OAuthAuth & Partial<ModelDiscovery>) | undefined
|
||||
const current = (await readCurrentAuth(readAuth)) as (OAuthAuth & Partial<ModelDiscovery>) | 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
|
||||
|
||||
+146
-1
@@ -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<typeof installFetchMock> | 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<T>(entry: unknown, run: (root: string) => Promise<T>) {
|
||||
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<void>()
|
||||
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")) {
|
||||
|
||||
Reference in New Issue
Block a user