diff --git a/docs/providers.md b/docs/providers.md index cb685ad209..3ec5f710a6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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 diff --git a/packages/server/src/services/quota-fetcher/providers/claude.ts b/packages/server/src/services/quota-fetcher/providers/claude.ts index be922d0858..867f52dc52 100644 --- a/packages/server/src/services/quota-fetcher/providers/claude.ts +++ b/packages/server/src/services/quota-fetcher/providers/claude.ts @@ -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; type ClaudeUsageResponse = z.infer; -type ClaudeTokenRefresh = z.infer; type ClaudeLimit = z.infer; const SCOPED_WEEKLY_KIND = "weekly_scoped"; interface ClaudeCredentialRecord { oauth: { accessToken: string } & NonNullable; - 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 { - 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 { - 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. - } - } } diff --git a/packages/server/src/services/quota-fetcher/providers/codex.ts b/packages/server/src/services/quota-fetcher/providers/codex.ts index a0607fcf2f..78a1f7d26b 100644 --- a/packages/server/src/services/quota-fetcher/providers/codex.ts +++ b/packages/server/src/services/quota-fetcher/providers/codex.ts @@ -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; type CodexWindow = z.infer; type CodexUsageResponse = z.infer; -type CodexTokenRefresh = z.infer; - -interface CodexAuthRecord { - auth: CodexAuth; - path: string; -} interface CodexQuotaProviderOptions { logger: Logger; @@ -102,30 +89,18 @@ export class CodexQuotaProvider implements ProviderUsageFetcher { } async fetchUsage(): Promise { - 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 { + private async readCodexAuth(): Promise { 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 { - 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 { - 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. - } - } } diff --git a/packages/server/src/services/quota-fetcher/providers/kimi.ts b/packages/server/src/services/quota-fetcher/providers/kimi.ts index 84157315dc..1e62fd6eeb 100644 --- a/packages/server/src/services/quota-fetcher/providers/kimi.ts +++ b/packages/server/src/services/quota-fetcher/providers/kimi.ts @@ -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; -type KimiTokenRefresh = z.infer; - -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 { - 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 { return fetchProviderApi(this.fetchApi, KIMI_USAGE_URL, { headers: { @@ -346,95 +304,16 @@ export class KimiQuotaProvider implements ProviderUsageFetcher { }); } - private async refreshToken(refreshToken: string): Promise { - 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 { - 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 { + private async readCredentials(): Promise { 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; diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts index 47d095988f..dc324f1621 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -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("Login", { 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 = []; + 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 | 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 | 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) => { - 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 () => {