diff --git a/AGENTS.md b/AGENTS.md index 7b70b54..56820e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,7 @@ These are the invariants that, if broken, silently degrade K2.6 → K2.5 or prod - **Git commits:** small, logical, imperative subject ("Add oauth device flow"). Do not add a `Co-authored-by` trailer. - **Upstream research:** the `research/` directory is a read-only git-ignored pair of shallow clones (opencode + kimi-cli) for grep. Never edit files there; re-clone if you suspect drift. When citing upstream in a comment, use the `research/…` path so the reference is resolvable. - **Version bumps:** when kimi-cli bumps, (1) pull a fresh `research/kimi-cli`, (2) update `KIMI_CLI_VERSION` in `src/constants.ts`, (3) re-diff `_kimi_default_headers()` / `oauth.py` against `src/headers.ts` and `src/oauth.ts`, (4) smoke-test with `opencode auth login kimi-for-coding-oauth` and a one-turn chat, (5) tag release. +- **Tests:** `test/` holds one file per source file plus `test/exports.test.ts` (the rule-9 guard). Tests mock `fetch` via `test/_util/fetchMock.ts`; no real credentials or network. They use the real `~/.kimi/device_id` on purpose — it is shared with kimi-cli by design and `getDeviceId` is idempotent, so tests don't clobber state. When adding a new contract to the list above, add the matching offline check to the corresponding test file rather than creating new ones. ### What not to do diff --git a/package.json b/package.json index 3f65473..402552c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-kimi-full", - "version": "1.0.1", + "version": "1.0.2", "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/test/_util/fetchMock.ts b/test/_util/fetchMock.ts new file mode 100644 index 0000000..6f10acb --- /dev/null +++ b/test/_util/fetchMock.ts @@ -0,0 +1,51 @@ +// Minimal typed fetch mock. Bun's `bun:test` has `mock()` but swapping +// `globalThis.fetch` with a plain function is enough here and keeps tests +// free of framework-specific mocking magic. + +export type FetchCall = { + url: string + method: string + headers: Record + body: string | undefined +} + +export type Responder = ( + call: FetchCall, + callIndex: number, +) => { status?: number; body?: unknown; bodyText?: string } + +export function installFetchMock(responder: Responder) { + const calls: FetchCall[] = [] + const original = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url + const headers: Record = {} + const hs = new Headers(init?.headers) + hs.forEach((v, k) => { + headers[k] = v + }) + const body = typeof init?.body === "string" ? init.body : init?.body == null ? undefined : String(init.body) + const call: FetchCall = { url, method: (init?.method ?? "GET").toUpperCase(), headers, body } + calls.push(call) + const r = responder(call, calls.length - 1) + const status = r.status ?? 200 + const text = r.bodyText ?? (r.body === undefined ? "" : JSON.stringify(r.body)) + return new Response(text, { + status, + headers: { "Content-Type": "application/json" }, + }) + }) as typeof fetch + return { + calls, + restore: () => { + globalThis.fetch = original + }, + } +} + +export function parseForm(body: string | undefined): Record { + if (!body) return {} + const out: Record = {} + for (const [k, v] of new URLSearchParams(body)) out[k] = v + return out +} diff --git a/test/constants.test.ts b/test/constants.test.ts new file mode 100644 index 0000000..a17964e --- /dev/null +++ b/test/constants.test.ts @@ -0,0 +1,42 @@ +import { test, expect } from "bun:test" +import * as C from "../src/constants.ts" + +// These values form the "identity" of the plugin on the wire. Typos silently +// downgrade K2.6 → K2.5 (scope/client_id) or collide with models.dev +// (PROVIDER_ID). See AGENTS.md "Contracts to keep intact". + +test("KIMI_CLI_VERSION is a non-empty semver", () => { + expect(C.KIMI_CLI_VERSION).toMatch(/^\d+\.\d+\.\d+$/) +}) + +test("USER_AGENT embeds KIMI_CLI_VERSION", () => { + expect(C.USER_AGENT).toBe(`KimiCodeCLI/${C.KIMI_CLI_VERSION}`) +}) + +test("OAuth constants match upstream kimi-cli exactly", () => { + // Pinned values from research/kimi-cli/src/kimi_cli/auth/oauth.py. If these + // drift from upstream, tokens are issued against the wrong scope/client and + // the backend routes to K2.5. + expect(C.OAUTH_HOST).toBe("https://auth.kimi.com") + expect(C.OAUTH_DEVICE_AUTH_URL).toBe("https://auth.kimi.com/api/oauth/device_authorization") + expect(C.OAUTH_TOKEN_URL).toBe("https://auth.kimi.com/api/oauth/token") + expect(C.OAUTH_CLIENT_ID).toBe("17e5f671-d194-4dfb-9706-5516cb48c098") + expect(C.OAUTH_SCOPE).toBe("kimi-code") + expect(C.OAUTH_DEVICE_GRANT).toBe("urn:ietf:params:oauth:grant-type:device_code") + expect(C.OAUTH_REFRESH_GRANT).toBe("refresh_token") +}) + +test("PROVIDER_ID does not collide with models.dev (AGENTS.md rule 8)", () => { + expect(C.PROVIDER_ID).toBe("kimi-for-coding-oauth") + expect(C.PROVIDER_ID).not.toBe("kimi-for-coding") +}) + +test("MODEL_ID goes over the wire verbatim (AGENTS.md rule 6)", () => { + expect(C.MODEL_ID).toBe("kimi-for-coding") +}) + +test("REFRESH_SAFETY_WINDOW_MS is positive and well below token TTL", () => { + // Token TTLs are ~15 min; anything bigger would mean we refresh on every call. + expect(C.REFRESH_SAFETY_WINDOW_MS).toBeGreaterThan(0) + expect(C.REFRESH_SAFETY_WINDOW_MS).toBeLessThan(5 * 60_000) +}) diff --git a/test/headers.test.ts b/test/headers.test.ts new file mode 100644 index 0000000..985609b --- /dev/null +++ b/test/headers.test.ts @@ -0,0 +1,60 @@ +import { test, expect } from "bun:test" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { KIMI_CLI_VERSION, USER_AGENT } from "../src/constants.ts" +import { getDeviceId, kimiHeaders } from "../src/headers.ts" + +// Note: getDeviceId() reads/writes ~/.kimi/device_id. That file is shared +// with kimi-cli on purpose (AGENTS.md rule 2) and the function is +// idempotent — if the file exists we reuse it, otherwise we create it. The +// tests therefore use the real HOME: they cannot clobber anything, and +// mocking `os.homedir` for Node's built-in `os` is fragile (Bun resolves +// the import binding eagerly). + +test("kimiHeaders emits exactly the 7 fingerprint keys kimi-cli sends", () => { + const h = kimiHeaders() + expect(Object.keys(h).sort()).toEqual( + [ + "User-Agent", + "X-Msh-Device-Id", + "X-Msh-Device-Model", + "X-Msh-Device-Name", + "X-Msh-Os-Version", + "X-Msh-Platform", + "X-Msh-Version", + ].sort(), + ) +}) + +test("User-Agent and X-Msh-Version track KIMI_CLI_VERSION (AGENTS.md rule 1)", () => { + const h = kimiHeaders() + expect(h["User-Agent"]).toBe(USER_AGENT) + expect(h["User-Agent"]).toContain(KIMI_CLI_VERSION) + expect(h["X-Msh-Version"]).toBe(KIMI_CLI_VERSION) + expect(h["X-Msh-Platform"]).toBe("kimi_cli") +}) + +test("All header values are ASCII-only (undici rejects non-ASCII)", () => { + for (const [k, v] of Object.entries(kimiHeaders())) { + expect(v, `header ${k}`).toMatch(/^[\x20-\x7e]+$/) + } +}) + +test("Device id is a 32-char lowercase hex string (kimi-cli UUIDv4 no-dashes format)", () => { + expect(getDeviceId()).toMatch(/^[0-9a-f]{32}$/) +}) + +test("Device id is stable across calls and matches ~/.kimi/device_id on disk (AGENTS.md rule 2)", () => { + const first = getDeviceId() + const second = getDeviceId() + expect(second).toBe(first) + // Mirrors kimi-cli's path; shared by design. + const onDisk = fs.readFileSync(path.join(os.homedir(), ".kimi", "device_id"), "utf8").trim() + expect(onDisk).toBe(first) +}) + +test("Device id is also present and matches in the headers map", () => { + const h = kimiHeaders() + expect(h["X-Msh-Device-Id"]).toBe(getDeviceId()) +}) diff --git a/test/oauth.test.ts b/test/oauth.test.ts new file mode 100644 index 0000000..bb4565f --- /dev/null +++ b/test/oauth.test.ts @@ -0,0 +1,118 @@ +import { test, expect, afterEach } from "bun:test" +import { + OAUTH_CLIENT_ID, + OAUTH_DEVICE_AUTH_URL, + OAUTH_DEVICE_GRANT, + OAUTH_REFRESH_GRANT, + OAUTH_SCOPE, + OAUTH_TOKEN_URL, +} from "../src/constants.ts" +import { pollDeviceToken, refreshToken, startDeviceAuth } from "../src/oauth.ts" +import { installFetchMock, parseForm } from "./_util/fetchMock.ts" + +// oauth.ts calls kimiHeaders() on every request, which reads/writes +// ~/.kimi/device_id. That file is shared with kimi-cli by design and +// getDeviceId is idempotent — no HOME redirect needed. + +let mock: ReturnType | undefined +afterEach(() => { + mock?.restore() + mock = undefined +}) + +test("startDeviceAuth posts client_id+scope as form-encoded to the device endpoint", async () => { + mock = installFetchMock(() => ({ + body: { + device_code: "dc", + user_code: "USER-1234", + verification_uri: "https://auth.kimi.com/device", + verification_uri_complete: "https://auth.kimi.com/device?u=USER-1234", + expires_in: 600, + interval: 5, + }, + })) + const d = await startDeviceAuth() + expect(d.user_code).toBe("USER-1234") + expect(mock.calls).toHaveLength(1) + const call = mock.calls[0]! + expect(call.url).toBe(OAUTH_DEVICE_AUTH_URL) + expect(call.method).toBe("POST") + expect(call.headers["content-type"]).toBe("application/x-www-form-urlencoded") + // Fingerprint headers must be present on every oauth call, not just chat. + expect(call.headers["x-msh-version"]).toBeDefined() + expect(call.headers["x-msh-device-id"]).toMatch(/^[0-9a-f]{32}$/) + expect(parseForm(call.body)).toEqual({ + client_id: OAUTH_CLIENT_ID, + scope: OAUTH_SCOPE, + }) +}) + +test("refreshToken posts grant_type=refresh_token and returns normalized shape", async () => { + mock = installFetchMock(() => ({ + body: { access_token: "a2", refresh_token: "r2", token_type: "Bearer", expires_in: 900 }, + })) + const t = await refreshToken("r1") + expect(t).toEqual({ access_token: "a2", refresh_token: "r2", token_type: "Bearer", expires_in: 900 }) + const call = mock.calls[0]! + expect(call.url).toBe(OAUTH_TOKEN_URL) + expect(parseForm(call.body)).toEqual({ + client_id: OAUTH_CLIENT_ID, + refresh_token: "r1", + grant_type: OAUTH_REFRESH_GRANT, + }) +}) + +test("postForm wraps non-OK responses with error.code from the JSON body", async () => { + mock = installFetchMock(() => ({ + status: 400, + body: { error: "invalid_grant", error_description: "refresh token is dead" }, + })) + await expect(refreshToken("bad")).rejects.toThrow(/invalid_grant/) +}) + +test("postForm throws a clear error when the server returns non-JSON", async () => { + mock = installFetchMock(() => ({ status: 502, bodyText: "gateway" })) + await expect(refreshToken("x")).rejects.toThrow(/non-JSON response/) +}) + +test("pollDeviceToken honors authorization_pending and returns on approval", async () => { + // pollDeviceToken clamps with `device.interval || 5` then max(1, …)*1000, + // so the effective poll wait is max(1, interval) seconds. Use interval=1 + // and a single pending cycle to keep the test ~2s. + const device = { + device_code: "dc", + user_code: "U", + verification_uri: "x", + expires_in: 60, + interval: 1, + } + mock = installFetchMock((_, i) => { + if (i < 1) return { status: 400, body: { error: "authorization_pending" } } + return { body: { access_token: "A", refresh_token: "R", token_type: "Bearer", expires_in: 900 } } + }) + const t = await pollDeviceToken(device) + expect(t.access_token).toBe("A") + expect(mock.calls).toHaveLength(2) + // Sends device_code + the RFC 8628 grant type. + expect(parseForm(mock.calls[0]!.body)).toEqual({ + client_id: OAUTH_CLIENT_ID, + device_code: "dc", + grant_type: OAUTH_DEVICE_GRANT, + }) +}) + +test("pollDeviceToken surfaces expired_token with an actionable message", async () => { + mock = installFetchMock(() => ({ status: 400, body: { error: "expired_token" } })) + await expect( + pollDeviceToken({ device_code: "dc", user_code: "U", verification_uri: "x", expires_in: 60, interval: 1 }), + ).rejects.toThrow(/device code expired/) +}) + +test("pollDeviceToken rethrows unknown errors without looping", async () => { + mock = installFetchMock(() => ({ status: 400, body: { error: "access_denied", error_description: "nope" } })) + await expect( + pollDeviceToken({ device_code: "dc", user_code: "U", verification_uri: "x", expires_in: 60, interval: 1 }), + ).rejects.toThrow(/access_denied/) + // Exactly one call; not retried. + expect(mock!.calls).toHaveLength(1) +}) diff --git a/test/plugin.test.ts b/test/plugin.test.ts new file mode 100644 index 0000000..d3002a7 --- /dev/null +++ b/test/plugin.test.ts @@ -0,0 +1,246 @@ +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" +import { installFetchMock } from "./_util/fetchMock.ts" + +// kimiHeaders() → getDeviceId() reads/writes ~/.kimi/device_id; that file is +// shared with kimi-cli by design and writes are idempotent — no HOME +// redirect needed. + +let mock: ReturnType | undefined +afterEach(() => { + mock?.restore() + mock = undefined +}) + +// 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. +function makeContext() { + const writes: Array<{ id: string; body: unknown }> = [] + return { + writes, + ctx: { + client: { + auth: { + set: async ({ path, body }: { path: { id: string }; body: unknown }) => { + writes.push({ id: path.id, body }) + }, + }, + }, + } as unknown as Parameters[0], + } +} + +async function getHooks() { + const { ctx, writes } = makeContext() + const hooks = await plugin(ctx) + return { hooks, writes } +} + +// ---------- chat.params ----------------------------------------------------- + +// Minimal shape for input/output we care about in the params hook. +type ParamsInput = { provider: { info: { id: string } }; model: { id: string }; sessionID: string } +type ParamsOutput = { options: Record } +function callParams( + hook: (i: ParamsInput, o: ParamsOutput) => Promise | void, + providerID: string, + modelID: string, + options: Record = {}, + sessionID = "sess-1", +) { + const output: ParamsOutput = { options: { ...options } } + const res = hook({ provider: { info: { id: providerID } }, model: { id: modelID }, sessionID }, output) + return { res, output } +} + +test("chat.params: no-op for other providers (AGENTS.md rule: gated on PROVIDER_ID)", async () => { + const { hooks } = await getHooks() + const hook = hooks["chat.params"]! + const { output } = callParams(hook, "some-other-provider", MODEL_ID, { reasoning_effort: "high" }) + // Untouched — no prompt_cache_key, no thinking added. + expect(output.options).toEqual({ reasoning_effort: "high" }) +}) + +test("chat.params: no-op for other models under our provider (rule 5 gating)", async () => { + const { hooks } = await getHooks() + const hook = hooks["chat.params"]! + const { output } = callParams(hook, PROVIDER_ID, "kimi-something-else") + expect(output.options.prompt_cache_key).toBeUndefined() + expect(output.options.thinking).toBeUndefined() +}) + +test("chat.params: attaches prompt_cache_key = sessionID for kimi-for-coding only", async () => { + const { hooks } = await getHooks() + const hook = hooks["chat.params"]! + const { output } = await callParams(hook, PROVIDER_ID, MODEL_ID, {}, "sess-42") + expect(output.options.prompt_cache_key).toBe("sess-42") +}) + +// The effort matrix is the most load-bearing contract in AGENTS.md → rule 4. +// Off → thinking disabled, reasoning_effort stripped. +// low/medium/high → reasoning_effort kept, thinking enabled. +// unset → thinking enabled, no reasoning_effort (server-picks default). +const EFFORT_MATRIX: Array<{ + in: Record + effort: string | undefined + thinkingType: "enabled" | "disabled" +}> = [ + { in: { reasoning_effort: "off" }, effort: undefined, thinkingType: "disabled" }, + { in: { reasoning_effort: "low" }, effort: "low", thinkingType: "enabled" }, + { in: { reasoning_effort: "medium" }, effort: "medium", thinkingType: "enabled" }, + { in: { reasoning_effort: "high" }, effort: "high", thinkingType: "enabled" }, + { in: {}, effort: undefined, thinkingType: "enabled" }, +] +for (const row of EFFORT_MATRIX) { + test(`chat.params: effort=${JSON.stringify(row.in)} → effort=${row.effort}, thinking=${row.thinkingType}`, async () => { + const { hooks } = await getHooks() + const { output } = await callParams(hooks["chat.params"]!, PROVIDER_ID, MODEL_ID, row.in) + expect(output.options.reasoning_effort).toBe(row.effort) + expect(output.options.thinking).toEqual({ type: row.thinkingType }) + }) +} + +test("chat.params: `reasoningEffort` (camelCase) input also drives the mapping", async () => { + // opencode may use camelCase upstream; the plugin accepts either. + const { hooks } = await getHooks() + const { output } = await callParams(hooks["chat.params"]!, PROVIDER_ID, MODEL_ID, { reasoningEffort: "off" }) + expect(output.options.thinking).toEqual({ type: "disabled" }) + expect(output.options.reasoning_effort).toBeUndefined() + expect(output.options.reasoningEffort).toBeUndefined() +}) + +// ---------- auth.loader ----------------------------------------------------- + +function jwt() { + return "header.payload.sig" +} +function validAuth(overrides: Partial<{ access: string; refresh: string; expires: number }> = {}) { + return { + type: "oauth" as const, + access: overrides.access ?? "access-1", + refresh: overrides.refresh ?? "refresh-1", + // Far enough in the future to skip the refresh-on-expiry path. + expires: overrides.expires ?? Date.now() + 10 * 60_000, + } +} + +async function getLoaderFetch(readAuth: () => Promise) { + const { hooks, writes } = await getHooks() + const res = await hooks.auth!.loader!(readAuth as any, {} as any) + return { fetch: (res as { fetch: typeof fetch }).fetch, apiKey: (res as { apiKey: string }).apiKey, writes } +} + +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/) +}) + +test("auth.loader: apiKey sentinel is returned (opencode requires truthy)", async () => { + const { apiKey } = await getLoaderFetch(async () => validAuth()) + expect(apiKey).toBe("kimi-for-coding-oauth") +}) + +test("auth.loader: owns Authorization and strips any caller-supplied value (rule 3)", async () => { + mock = installFetchMock(() => ({ body: { ok: true } })) + const { fetch: f } = await getLoaderFetch(async () => validAuth({ access: jwt() })) + await f("https://api.kimi.com/coding/v1/chat", { + method: "POST", + headers: { Authorization: "Bearer SHOULD-BE-OVERRIDDEN", authorization: "lower-also" }, + body: JSON.stringify({}), + }) + expect(mock.calls).toHaveLength(1) + const h = mock.calls[0]!.headers + expect(h["authorization"]).toBe(`Bearer ${jwt()}`) + // Seven kimi-cli fingerprint headers are attached on every request. + expect(h["x-msh-platform"]).toBe("kimi_cli") + expect(h["x-msh-version"]).toBeDefined() + expect(h["x-msh-device-id"]).toMatch(/^[0-9a-f]{32}$/) +}) + +test("auth.loader: refreshes when expiry is within safety window", async () => { + let reads = 0 + const initial = validAuth({ expires: Date.now() + REFRESH_SAFETY_WINDOW_MS / 2 }) + let current: ReturnType = initial + const readAuth = async () => { + reads++ + return current + } + // First fetch call → token refresh, second → the actual request. + mock = installFetchMock((call) => { + if (call.url.includes("/oauth/token")) { + return { body: { access_token: "access-2", refresh_token: "refresh-2", token_type: "Bearer", expires_in: 900 } } + } + return { body: { ok: true } } + }) + const { fetch: f, writes } = await getLoaderFetch(readAuth) + await f("https://api.kimi.com/coding/v1/chat") + // One refresh call + one API call. + expect(mock.calls.map((c) => c.url)).toEqual([ + "https://auth.kimi.com/api/oauth/token", + "https://api.kimi.com/coding/v1/chat", + ]) + expect(mock.calls[1]!.headers["authorization"]).toBe("Bearer access-2") + // Persisted the refreshed token back to opencode's auth store. + expect(writes).toHaveLength(1) + expect(writes[0]!.id).toBe(PROVIDER_ID) + expect((writes[0]!.body as { access: string }).access).toBe("access-2") + expect(reads).toBeGreaterThan(0) +}) + +test("auth.loader: 401 triggers exactly one forced refresh + retry (no infinite loop)", async () => { + let current = validAuth({ access: "stale" }) + const readAuth = async () => current + mock = installFetchMock((call) => { + if (call.url.includes("/oauth/token")) { + current = { ...current, access: "fresh" } + return { body: { access_token: "fresh", refresh_token: "refresh-2", token_type: "Bearer", expires_in: 900 } } + } + // First API call: stale → 401. Every subsequent API call: 401 as well. + // The loader must NOT loop; exactly one retry after refresh. + return { status: 401, body: { error: "unauthorized" } } + }) + const { fetch: f } = await getLoaderFetch(readAuth) + const res = await f("https://api.kimi.com/coding/v1/chat") + expect(res.status).toBe(401) + const urls = mock.calls.map((c) => c.url) + // Expected order: stale call → refresh → retry with fresh token → STOP. + expect(urls).toEqual([ + "https://api.kimi.com/coding/v1/chat", + "https://auth.kimi.com/api/oauth/token", + "https://api.kimi.com/coding/v1/chat", + ]) + expect(mock.calls[0]!.headers["authorization"]).toBe("Bearer stale") + expect(mock.calls[2]!.headers["authorization"]).toBe("Bearer fresh") +}) + +// ---------- auth.methods (device flow wiring) ------------------------------- + +test("auth.methods[0].authorize returns URL + instructions + async callback", async () => { + mock = installFetchMock((call) => { + if (call.url.includes("device_authorization")) { + return { + body: { + device_code: "dc", + user_code: "WXYZ-1234", + verification_uri: "https://auth.kimi.com/device", + verification_uri_complete: "https://auth.kimi.com/device?u=WXYZ-1234", + expires_in: 60, + interval: 1, + }, + } + } + return { body: { access_token: "A", refresh_token: "R", token_type: "Bearer", expires_in: 900 } } + }) + const { hooks } = await getHooks() + const method = hooks.auth!.methods![0] as { authorize: () => Promise } + const r = await method.authorize() + expect(r.url).toBe("https://auth.kimi.com/device?u=WXYZ-1234") + expect(r.instructions).toContain("WXYZ-1234") + const cb = await r.callback() + expect(cb.type).toBe("success") + expect(cb.access).toBe("A") + expect(cb.refresh).toBe("R") + expect(typeof cb.expires).toBe("number") +})