mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
fix(server): stop quota fetcher from writing provider credentials (#3442)
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user