fix(server): stop quota fetcher from writing provider credentials (#3442)

This commit is contained in:
Dan Berindei
2026-08-23 14:24:18 +02:00
committed by GitHub
parent 35cde075ae
commit 71e375c2bc
5 changed files with 78 additions and 486 deletions
+4
View File
@@ -147,6 +147,10 @@ Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
### Usage fetchers are read-only on credentials
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
---
## ACP Provider Checklist
@@ -22,7 +22,6 @@ import {
const execFileAsync = promisify(execFile);
const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000;
const CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
const ClaudeCredentialsSchema = z.object({
@@ -71,21 +70,14 @@ const ClaudeUsageResponseSchema = z.object({
.nullish(),
});
const ClaudeTokenRefreshSchema = z.object({
access_token: z.string().optional(),
refresh_token: z.string().optional(),
});
type ClaudeCredentials = z.infer<typeof ClaudeCredentialsSchema>;
type ClaudeUsageResponse = z.infer<typeof ClaudeUsageResponseSchema>;
type ClaudeTokenRefresh = z.infer<typeof ClaudeTokenRefreshSchema>;
type ClaudeLimit = z.infer<typeof ClaudeLimitSchema>;
const SCOPED_WEEKLY_KIND = "weekly_scoped";
interface ClaudeCredentialRecord {
oauth: { accessToken: string } & NonNullable<ClaudeCredentials["claudeAiOauth"]>;
filePath: string | null;
}
interface ClaudeQuotaProviderOptions {
@@ -370,30 +362,13 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
return unavailableUsage(this);
}
const { oauth, filePath } = credentials;
const { oauth } = credentials;
const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier);
let resp = await this.callClaudeApi(oauth.accessToken);
const resp = await this.callClaudeApi(oauth.accessToken);
if (resp === "NEEDS_AUTH") {
if (!filePath || !oauth.refreshToken) {
return unavailableUsage(this);
}
const refreshed = await this.refreshClaudeToken(oauth.refreshToken);
if (!refreshed?.access_token) {
return unavailableUsage(this);
}
await this.saveClaudeCredentials(filePath, {
...oauth,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token ?? oauth.refreshToken,
});
resp = await this.callClaudeApi(refreshed.access_token);
if (resp === "NEEDS_AUTH") {
return unavailableUsage(this);
}
// Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md.
return unavailableUsage(this);
}
const scoped = reconcileScopedLimits(
@@ -469,7 +444,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
);
const oauth = creds.claudeAiOauth;
if (oauth?.accessToken) {
return { oauth: { ...oauth, accessToken: oauth.accessToken }, filePath: credPath };
return { oauth: { ...oauth, accessToken: oauth.accessToken } };
}
} catch {
// Fall through to the macOS Keychain below.
@@ -480,7 +455,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
const creds = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials());
const oauth = creds.success ? creds.data.claudeAiOauth : undefined;
if (oauth?.accessToken) {
return { oauth: { ...oauth, accessToken: oauth.accessToken }, filePath: null };
return { oauth: { ...oauth, accessToken: oauth.accessToken } };
}
}
@@ -499,38 +474,4 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`);
return ClaudeUsageResponseSchema.parse(await res.json());
}
private async refreshClaudeToken(refreshToken: string): Promise<ClaudeTokenRefresh | null> {
const res = await fetchProviderApi(
this.fetchApi,
"https://platform.claude.com/v1/oauth/token",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLAUDE_CLIENT_ID,
scope: "user:profile user:inference user:sessions:claude_code user:mcp_servers",
}),
},
);
if (!res.ok) return null;
return ClaudeTokenRefreshSchema.parse(await res.json());
}
private async saveClaudeCredentials(
credPath: string,
oauth: ClaudeCredentials["claudeAiOauth"],
): Promise<void> {
try {
const existing = ClaudeCredentialsSchema.parse(
JSON.parse(await fs.readFile(credPath, "utf8")),
);
existing.claudeAiOauth = oauth;
await fs.writeFile(credPath, JSON.stringify(existing, null, 2), { mode: 0o600 });
} catch {
// Non-fatal; Claude Code can refresh again on its own next time.
}
}
}
@@ -18,8 +18,6 @@ import {
windowFromUsedPct,
} from "../usage.js";
const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const CodexAuthSchema = z.object({
tokens: z
.object({
@@ -58,20 +56,9 @@ const CodexUsageResponseSchema = z.object({
.nullish(),
});
const CodexTokenRefreshSchema = z.object({
access_token: z.string().optional(),
refresh_token: z.string().optional(),
});
type CodexAuth = z.infer<typeof CodexAuthSchema>;
type CodexWindow = z.infer<typeof CodexWindowSchema>;
type CodexUsageResponse = z.infer<typeof CodexUsageResponseSchema>;
type CodexTokenRefresh = z.infer<typeof CodexTokenRefreshSchema>;
interface CodexAuthRecord {
auth: CodexAuth;
path: string;
}
interface CodexQuotaProviderOptions {
logger: Logger;
@@ -102,30 +89,18 @@ export class CodexQuotaProvider implements ProviderUsageFetcher {
}
async fetchUsage(): Promise<ProviderUsage> {
const authRecord = await this.readCodexAuth();
const auth = authRecord?.auth;
const auth = await this.readCodexAuth();
const accessToken = auth?.tokens?.access_token;
if (!authRecord || !auth || !accessToken) {
if (!auth || !accessToken) {
return unavailableUsage(this);
}
const { refresh_token, account_id } = auth.tokens ?? {};
let resp = await this.callCodexApi(accessToken, account_id);
const { account_id } = auth.tokens ?? {};
const resp = await this.callCodexApi(accessToken, account_id);
if (resp === "NEEDS_AUTH") {
if (!refresh_token) {
return unavailableUsage(this);
}
const refreshed = await this.refreshCodexToken(refresh_token);
if (!refreshed?.access_token) {
return unavailableUsage(this);
}
await this.saveCodexAuth(authRecord.path, auth, refreshed);
resp = await this.callCodexApi(refreshed.access_token, account_id);
if (resp === "NEEDS_AUTH") {
return unavailableUsage(this);
}
// Read-only on credentials; the Codex CLI owns refresh. See docs/providers.md.
return unavailableUsage(this);
}
return this.toUsage(resp);
@@ -194,7 +169,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher {
};
}
private async readCodexAuth(): Promise<CodexAuthRecord | null> {
private async readCodexAuth(): Promise<CodexAuth | null> {
const candidates = [
...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []),
join(homedir(), ".config", "codex", "auth.json"),
@@ -204,7 +179,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher {
if (!existsSync(path)) continue;
try {
const auth = CodexAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
if (auth.tokens?.access_token) return { auth, path };
if (auth.tokens?.access_token) return auth;
} catch {
continue;
}
@@ -236,39 +211,4 @@ export class CodexQuotaProvider implements ProviderUsageFetcher {
if (text.trim().startsWith("<")) return "NEEDS_AUTH";
return CodexUsageResponseSchema.parse(JSON.parse(text));
}
private async refreshCodexToken(refreshToken: string): Promise<CodexTokenRefresh | null> {
const params = new URLSearchParams({
grant_type: "refresh_token",
client_id: CODEX_CLIENT_ID,
refresh_token: refreshToken,
});
const res = await fetchProviderApi(this.fetchApi, "https://auth.openai.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
if (!res.ok) return null;
return CodexTokenRefreshSchema.parse(await res.json());
}
private async saveCodexAuth(
authPath: string,
original: CodexAuth,
refreshed: CodexTokenRefresh,
): Promise<void> {
try {
const updated: CodexAuth = {
...original,
tokens: {
...original.tokens,
access_token: refreshed.access_token ?? original.tokens?.access_token,
refresh_token: refreshed.refresh_token ?? original.tokens?.refresh_token,
},
};
await fs.writeFile(authPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
} catch {
// Non-fatal; the next call can refresh again.
}
}
}
@@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto";
import { existsSync, promises as fs } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { join } from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
import type { ProviderUsage, ProviderUsageWindow } from "../../../server/messages.js";
@@ -15,9 +14,7 @@ import {
windowFromUsedPct,
} from "../usage.js";
const KIMI_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
const KIMI_TOKEN_URL = "https://auth.kimi.com/api/oauth/token";
const KimiUsageFieldsSchema = z.object({
limit: ApiOptionalStringSchema,
@@ -249,27 +246,8 @@ const KimiAuthSchema = z
})
.passthrough();
const KimiTokenRefreshSchema = z.object({
access_token: z.string(),
refresh_token: z.string().nullish(),
expires_in: ApiNumberSchema.nullish(),
scope: z.string().nullish(),
token_type: z.string().nullish(),
});
type KimiAuth = z.infer<typeof KimiAuthSchema>;
type KimiTokenRefresh = z.infer<typeof KimiTokenRefreshSchema>;
interface KimiCredentialRecord {
credentials: KimiAuth & { access_token: string };
filePath: string | null;
}
interface SaveRefreshedCredentialsOptions {
filePath: string;
refreshTokenUsed: string;
refreshed: KimiTokenRefresh;
}
type KimiCredentials = KimiAuth & { access_token: string };
interface KimiQuotaProviderOptions {
logger: Logger;
@@ -295,9 +273,10 @@ export class KimiQuotaProvider implements ProviderUsageFetcher {
const credentials = await this.readCredentials();
if (!credentials) return unavailableUsage(this);
const res = await this.fetchUsageResponse(credentials);
const res = await this.callUsageApi(credentials.access_token);
if (!res.ok) {
// Read-only on credentials; the Kimi CLI owns refresh. See docs/providers.md.
this.logger.debug({ status: res.status }, "Kimi usage fetch failed");
return unavailableUsage(this);
}
@@ -316,27 +295,6 @@ export class KimiQuotaProvider implements ProviderUsageFetcher {
};
}
private async fetchUsageResponse(record: KimiCredentialRecord): Promise<Response> {
const { credentials, filePath } = record;
const res = await this.callUsageApi(credentials.access_token);
if (res.status !== 401 || !filePath || !credentials.refresh_token) return res;
const latest = await this.readCredentialFile(filePath);
const retried =
latest?.access_token && latest.access_token !== credentials.access_token
? await this.callUsageApi(latest.access_token)
: res;
if (retried.status !== 401) return retried;
const refreshTokenUsed = latest?.refresh_token ?? credentials.refresh_token;
const refreshed = await this.refreshToken(refreshTokenUsed);
if (!refreshed) return retried;
await this.saveRefreshedCredentials({ filePath, refreshTokenUsed, refreshed });
return this.callUsageApi(refreshed.access_token);
}
private async callUsageApi(token: string): Promise<Response> {
return fetchProviderApi(this.fetchApi, KIMI_USAGE_URL, {
headers: {
@@ -346,95 +304,16 @@ export class KimiQuotaProvider implements ProviderUsageFetcher {
});
}
private async refreshToken(refreshToken: string): Promise<KimiTokenRefresh | null> {
const body = new URLSearchParams({
client_id: KIMI_CLIENT_ID,
grant_type: "refresh_token",
refresh_token: refreshToken,
});
const res = await fetchProviderApi(this.fetchApi, KIMI_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) {
this.logger.debug({ status: res.status }, "Kimi token refresh failed");
return null;
}
const parsed = KimiTokenRefreshSchema.safeParse(await res.json());
return parsed.success ? parsed.data : null;
}
private async saveRefreshedCredentials(options: SaveRefreshedCredentialsOptions): Promise<void> {
const { filePath, refreshTokenUsed, refreshed } = options;
const existing = await this.readCredentialFile(filePath);
if (!existing) return;
// The Kimi CLI owns this file too. If its refresh token no longer matches the one we
// just spent, the CLI rotated the credentials while our refresh was in flight and its
// copy is newer than ours — writing our merge would strand the CLI on dead tokens.
if (existing.refresh_token !== refreshTokenUsed) {
this.logger.debug("Kimi credentials rotated during refresh; keeping the file on disk");
return;
}
const expiresIn = refreshed.expires_in ?? existing.expires_in;
const merged: KimiAuth = {
...existing,
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token ?? existing.refresh_token,
expires_in: expiresIn,
expires_at:
expiresIn == null ? existing.expires_at : Math.floor(Date.now() / 1000) + expiresIn,
scope: refreshed.scope ?? existing.scope,
token_type: refreshed.token_type ?? existing.token_type,
};
const tempPath = join(
dirname(filePath),
`.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
);
try {
await fs.writeFile(tempPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
// writeFile above is async I/O that gives the CLI a window to rotate the file on
// its own. Re-read right before the rename and confirm nothing changed since the
// guard above — the check-then-write gap this closes is what the file-level guard
// couldn't cover, since it ran before the write, not right before the replace.
const beforeRename = await this.readCredentialFile(filePath);
if (!this.credentialsUnchangedSince(existing, beforeRename)) {
this.logger.debug("Kimi credentials rotated during refresh; discarding stale merge");
await fs.rm(tempPath, { force: true }).catch(() => undefined);
return;
}
await fs.rename(tempPath, filePath);
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist refreshed Kimi credentials");
await fs.rm(tempPath, { force: true }).catch(() => undefined);
}
}
private credentialsUnchangedSince(baseline: KimiAuth, candidate: KimiAuth | null): boolean {
return (
candidate?.access_token === baseline.access_token &&
candidate?.refresh_token === baseline.refresh_token
);
}
private async readCredentials(): Promise<KimiCredentialRecord | null> {
private async readCredentials(): Promise<KimiCredentials | null> {
const environmentToken = process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"];
if (environmentToken) {
return { credentials: { access_token: environmentToken }, filePath: null };
return { access_token: environmentToken };
}
for (const path of this.credentialPaths()) {
const credentials = await this.readCredentialFile(path);
if (credentials?.access_token) {
return {
credentials: { ...credentials, access_token: credentials.access_token },
filePath: path,
};
return { ...credentials, access_token: credentials.access_token };
}
}
return null;
@@ -1,6 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { promises as fsPromises } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -528,43 +527,27 @@ describe("real provider usage fetchers", () => {
expect(fetchApi).not.toHaveBeenCalled();
});
it("refreshes Claude access tokens on 401 and retries", async () => {
it("returns unavailable on 401 without refreshing or rewriting credentials", async () => {
writeClaudeCredentials(claudeHome, "at_expired", "rt_valid");
const credPath = join(claudeHome, ".credentials.json");
const before = readFileSync(credPath, "utf8");
let usageCalls = 0;
fetchApi = vi.fn(async (url: RequestInfo | URL) => {
const endpoint = url.toString();
if (endpoint === "https://api.anthropic.com/api/oauth/usage") {
usageCalls += 1;
if (usageCalls === 1) return new Response(null, { status: 401 });
return jsonResponse(makeClaudeResponse());
}
if (endpoint === "https://platform.claude.com/v1/oauth/token") {
return jsonResponse({ access_token: "at_refreshed", refresh_token: "rt_new" });
return new Response(null, { status: 401 });
}
// The read-only fetcher must never hit the OAuth token endpoint.
throw new Error(`Unmocked: ${endpoint}`);
}) as never;
const result = await service().listUsage();
expect(findProvider(result, "claude").status).toBe("available");
expect(usageCalls).toBe(2);
});
it("returns unavailable Claude usage when 401 persists after refresh", async () => {
writeClaudeCredentials(claudeHome, "at_bad", "rt_bad");
fetchApi = mockFetch(
new Map([
["https://api.anthropic.com/api/oauth/usage", () => new Response(null, { status: 401 })],
[
"https://platform.claude.com/v1/oauth/token",
() => jsonResponse({ access_token: "at_still_bad", refresh_token: "rt_still_bad" }),
],
]),
);
const result = await service().listUsage();
expect(findProvider(result, "claude").status).toBe("unavailable");
expect(usageCalls).toBe(1);
// The credentials file must be left untouched for the Claude CLI to own.
expect(readFileSync(credPath, "utf8")).toBe(before);
});
it("does not refresh Claude tokens read from the macOS Keychain", async () => {
@@ -630,7 +613,6 @@ describe("real provider usage fetchers", () => {
"https://chatgpt.com/backend-api/wham/usage",
() => new Response("<html>Login</html>", { status: 200 }),
],
["https://auth.openai.com/oauth/token", () => new Response(null, { status: 401 })],
]),
);
@@ -639,42 +621,42 @@ describe("real provider usage fetchers", () => {
expect(findProvider(result, "codex").status).toBe("unavailable");
});
it("persists refreshed Codex tokens to the auth file that was read", async () => {
const alternateCodexHome = mkdtempSync(join(tmpdir(), "usage-test-codex-alt-"));
process.env["CODEX_HOME"] = alternateCodexHome;
writeFileSync(join(alternateCodexHome, "auth.json"), JSON.stringify({ tokens: {} }));
writeCodexAuth(codexHome, "at_codex_stale", "rt_codex_valid");
let usageCalls = 0;
fetchApi = mockFetch(
new Map([
[
"https://chatgpt.com/backend-api/wham/usage",
() => {
usageCalls += 1;
if (usageCalls === 1) return new Response(null, { status: 401 });
return jsonResponse(makeCodexResponse());
},
],
[
"https://auth.openai.com/oauth/token",
() => jsonResponse({ access_token: "at_codex_fresh", refresh_token: "rt_codex_fresh" }),
],
]),
it("returns unavailable on 401 without refreshing or rewriting auth.json", async () => {
// Regression: the fetcher used to refresh the token and rewrite auth.json
// through a schema that dropped id_token (and OPENAI_API_KEY/last_refresh),
// leaving the file unparseable by the Codex CLI and forcing a re-login.
const authPath = join(codexHome, "auth.json");
writeFileSync(
authPath,
JSON.stringify({
OPENAI_API_KEY: null,
tokens: {
id_token: "id_codex",
access_token: "at_codex_stale",
refresh_token: "rt_codex_valid",
account_id: "acct_codex",
},
last_refresh: "2026-07-04T20:35:00Z",
}),
);
const before = readFileSync(authPath, "utf8");
let usageCalls = 0;
fetchApi = vi.fn(async (url: RequestInfo | URL) => {
const endpoint = url.toString();
if (endpoint === "https://chatgpt.com/backend-api/wham/usage") {
usageCalls += 1;
return new Response(null, { status: 401 });
}
// The read-only fetcher must never hit the OAuth token endpoint.
throw new Error(`Unmocked: ${endpoint}`);
}) as never;
try {
const result = await service().listUsage();
const result = await service().listUsage();
const refreshedAuth = JSON.parse(readFileSync(join(codexHome, "auth.json"), "utf8"));
const untouchedAuth = JSON.parse(readFileSync(join(alternateCodexHome, "auth.json"), "utf8"));
expect(findProvider(result, "codex").status).toBe("available");
expect(refreshedAuth.tokens.access_token).toBe("at_codex_fresh");
expect(refreshedAuth.tokens.refresh_token).toBe("rt_codex_fresh");
expect(untouchedAuth.tokens.access_token).toBeUndefined();
} finally {
rmSync(alternateCodexHome, { recursive: true, force: true });
}
expect(findProvider(result, "codex").status).toBe("unavailable");
expect(usageCalls).toBe(1);
// The auth file must be left byte-for-byte untouched for the Codex CLI to own.
expect(readFileSync(authPath, "utf8")).toBe(before);
});
it("fetches Copilot usage from COPILOT_TOKEN", async () => {
@@ -1103,181 +1085,27 @@ describe("real provider usage fetchers", () => {
expect(kimi.status).toBe("available");
});
it("persists refreshed Kimi tokens to the credential file that was read", async () => {
writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired", {
preserved_field: "keep-me",
});
const authorization: Array<string | null> = [];
it("returns unavailable on 401 without refreshing or rewriting the credential file", async () => {
writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired");
const credPath = kimiCredentialPath(join(homeDir, ".kimi-code"));
const before = readFileSync(credPath, "utf8");
let usageCalls = 0;
fetchApi = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
fetchApi = vi.fn(async (url: RequestInfo | URL) => {
const endpoint = url.toString();
if (endpoint === "https://api.kimi.com/coding/v1/usages") {
usageCalls += 1;
authorization.push(
(init?.headers as Record<string, string> | undefined)?.Authorization ?? null,
);
if (usageCalls === 1) return new Response(null, { status: 401 });
return jsonResponse({ usage: { limit: "100", remaining: "74" } });
return new Response(null, { status: 401 });
}
if (endpoint === "https://auth.kimi.com/api/oauth/token") {
expect(init?.method).toBe("POST");
expect(init?.body?.toString()).toContain("grant_type=refresh_token");
expect(init?.body?.toString()).toContain("refresh_token=rt_kimi");
return jsonResponse({
access_token: "at_kimi_fresh",
refresh_token: "rt_kimi_rotated",
expires_in: 900,
});
}
throw new Error(`Unmocked fetch: ${endpoint}`);
}) as never;
const result = await service({ kimiHomeDir: homeDir }).listUsage();
const persisted = JSON.parse(
readFileSync(kimiCredentialPath(join(homeDir, ".kimi-code")), "utf8"),
);
expect(findProvider(result, "kimi").status).toBe("available");
expect(authorization).toEqual(["Bearer at_kimi_expired", "Bearer at_kimi_fresh"]);
expect(persisted).toMatchObject({
access_token: "at_kimi_fresh",
refresh_token: "rt_kimi_rotated",
expires_in: 900,
scope: "kimi-code",
preserved_field: "keep-me",
});
expect(persisted.expires_at).toBeGreaterThan(Date.now() / 1000);
});
it("prefers a Kimi token rewritten on disk over refreshing again", async () => {
const kimiHome = join(homeDir, ".kimi-code");
writeKimiCredentials(kimiHome, "at_kimi_old");
let usageCalls = 0;
fetchApi = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const endpoint = url.toString();
if (endpoint === "https://api.kimi.com/coding/v1/usages") {
usageCalls += 1;
if (usageCalls === 1) {
writeKimiCredentials(kimiHome, "at_kimi_rewritten");
return new Response(null, { status: 401 });
}
expect((init?.headers as Record<string, string> | undefined)?.Authorization).toBe(
"Bearer at_kimi_rewritten",
);
return jsonResponse({ usage: { limit: "100", remaining: "50" } });
}
throw new Error(`Unmocked fetch: ${endpoint}`);
// The read-only fetcher must never hit the OAuth token endpoint.
throw new Error(`Unmocked: ${endpoint}`);
}) as never;
const result = await service({ kimiHomeDir: homeDir }).listUsage();
expect(findProvider(result, "kimi").status).toBe("available");
expect(usageCalls).toBe(2);
});
it("keeps Kimi credentials rotated by the CLI during the refresh", async () => {
const kimiHome = join(homeDir, ".kimi-code");
writeKimiCredentials(kimiHome, "at_kimi_expired");
fetchApi = mockFetch(
new Map([
["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })],
[
"https://auth.kimi.com/api/oauth/token",
() => {
writeKimiCredentials(kimiHome, "at_kimi_cli", { refresh_token: "rt_kimi_cli" });
return jsonResponse({
access_token: "at_kimi_fresh",
refresh_token: "rt_kimi_rotated",
expires_in: 900,
});
},
],
]),
);
await service({ kimiHomeDir: homeDir }).listUsage();
const persisted = JSON.parse(readFileSync(kimiCredentialPath(kimiHome), "utf8"));
expect(persisted).toMatchObject({
access_token: "at_kimi_cli",
refresh_token: "rt_kimi_cli",
});
});
it("discards a refreshed Kimi merge if the CLI rotates credentials after the guard but before the rename", async () => {
const kimiHome = join(homeDir, ".kimi-code");
writeKimiCredentials(kimiHome, "at_kimi_expired");
fetchApi = mockFetch(
new Map([
["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })],
[
"https://auth.kimi.com/api/oauth/token",
() =>
jsonResponse({
access_token: "at_kimi_fresh",
refresh_token: "rt_kimi_fresh",
expires_in: 900,
}),
],
]),
);
// The credential-file guard runs, passes, and only then does the CLI rotate the file
// — simulated by mutating the file as a side effect of the temp-file write, which is
// the async I/O step that sits between the guard read and the final rename.
const realWriteFile = fsPromises.writeFile;
const writeFileSpy = vi
.spyOn(fsPromises, "writeFile")
.mockImplementationOnce(async (...args: Parameters<typeof fsPromises.writeFile>) => {
writeKimiCredentials(kimiHome, "at_kimi_cli", { refresh_token: "rt_kimi_cli" });
return realWriteFile(...args);
});
await service({ kimiHomeDir: homeDir }).listUsage();
const persisted = JSON.parse(readFileSync(kimiCredentialPath(kimiHome), "utf8"));
expect(persisted).toMatchObject({
access_token: "at_kimi_cli",
refresh_token: "rt_kimi_cli",
});
writeFileSpy.mockRestore();
});
it("does not recreate a Kimi credential file deleted during the refresh", async () => {
const credentialPath = kimiCredentialPath(join(homeDir, ".kimi-code"));
writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired");
fetchApi = mockFetch(
new Map([
["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })],
[
"https://auth.kimi.com/api/oauth/token",
() => {
rmSync(credentialPath, { force: true });
return jsonResponse({ access_token: "at_kimi_fresh", expires_in: 900 });
},
],
]),
);
const result = await service({ kimiHomeDir: homeDir }).listUsage();
expect(findProvider(result, "kimi").status).toBe("unavailable");
expect(existsSync(credentialPath)).toBe(false);
});
it("returns unavailable Kimi usage when the token refresh is rejected", async () => {
writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired");
fetchApi = mockFetch(
new Map([
["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })],
["https://auth.kimi.com/api/oauth/token", () => new Response(null, { status: 400 })],
]),
);
const result = await service({ kimiHomeDir: homeDir }).listUsage();
expect(findProvider(result, "kimi").status).toBe("unavailable");
expect(usageCalls).toBe(1);
// The credentials file must be left untouched for the Kimi CLI to own.
expect(readFileSync(credPath, "utf8")).toBe(before);
});
it("does not refresh Kimi tokens read from the environment", async () => {