Files
opencode-kimi-full/test/headers.test.ts
T
lemon07r ed7145c9c9 Release v1.0.3: fix header fingerprint (UA, Device-Model, Os-Version)
Plugin was silently 403ing every live request with
'access_terminated_error: only available for Coding Agents'. Discovered by
capturing what kimi-cli actually sends on the wire and diffing.

Three fingerprint bugs, each sufficient on its own to trigger the 403:

* User-Agent was 'KimiCodeCLI/<v>'; upstream sends 'KimiCLI/<v>'.
  (research/kimi-cli/src/kimi_cli/constant.py::get_user_agent)
* X-Msh-Device-Model was 'x86_64'; upstream sends
  '{system} {release} {machine}' e.g. 'Linux 7.0.0 x86_64'.
  (research/kimi-cli/src/kimi_cli/auth/oauth.py::_device_model)
* X-Msh-Os-Version was '{type} {release}' e.g. 'Linux 7.0.0'; upstream
  sends platform.version() i.e. the kernel build string. Node equivalent
  is os.version().

Verified live against api.kimi.com/coding/v1 with a freshly-minted JWT:
200 OK and real K2.6 response after the fix; 403 before.

Locked in with regression tests in test/headers.test.ts and
test/constants.test.ts, and documented in AGENTS.md contract rule 1.
2026-04-17 03:51:45 -04:00

87 lines
3.3 KiB
TypeScript

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())
})
// Regression guard: prior to v1.0.3 we were sending `X-Msh-Device-Model =
// <arch>` and `X-Msh-Os-Version = <type release>`. That didn't match kimi-cli
// (which uses `platform.system() + release() + machine()` for the model and
// `platform.version()` — the kernel build string — for the os version) and
// caused Moonshot to 403 every request from this plugin with
// "access_terminated_error". Keep these shape-asserts strict so a future
// "cleanup" of headers.ts can't silently regress the fingerprint.
test("X-Msh-Device-Model matches kimi-cli _device_model() shape (system release machine)", () => {
const h = kimiHeaders()
const sys = os.type()
const rel = os.release()
const mach = os.machine?.() || os.arch()
expect(h["X-Msh-Device-Model"]).toBe(`${sys} ${rel} ${mach}`)
// Must contain whitespace-separated release and machine — i.e. more than a
// bare arch string.
expect(h["X-Msh-Device-Model"]).toContain(" ")
})
test("X-Msh-Os-Version matches os.version() (kernel build string on Linux)", () => {
const h = kimiHeaders()
// `os.version()` exists in Node 13.13+ — be lenient if missing.
if (typeof os.version === "function") {
expect(h["X-Msh-Os-Version"]).toBe(os.version())
}
})