diff --git a/packages/core/src/agents/local-providers/kimi.ts b/packages/core/src/agents/local-providers/kimi.ts index 50803990..6058803a 100644 --- a/packages/core/src/agents/local-providers/kimi.ts +++ b/packages/core/src/agents/local-providers/kimi.ts @@ -54,7 +54,7 @@ type KimiConfiguredProvider = { sourceFile: string; }; -export class KimiRefreshAuthError extends Error { +class KimiRefreshAuthError extends Error { readonly status: number; constructor(status: number, message: string) { @@ -224,7 +224,7 @@ function adoptPeerRotatedKimiAuth(auth: KimiTokenSet, error: unknown): KimiToken // is stale and the server rejects it, but the file on disk already holds // the newer credential. Adopt it instead of failing the request. const latest = readKimiAuthFromFile(auth.sourceFile, auth.oauthHost); - if (latest?.refreshToken && latest.refreshToken !== auth.refreshToken) { + if (latest?.refreshToken && latest.accessToken && latest.refreshToken !== auth.refreshToken) { return latest; } throw error; diff --git a/packages/core/test/unit/agents/local-agent-provider-kimi.test.mjs b/packages/core/test/unit/agents/local-agent-provider-kimi.test.mjs index e4a0ff1c..620b99da 100644 --- a/packages/core/test/unit/agents/local-agent-provider-kimi.test.mjs +++ b/packages/core/test/unit/agents/local-agent-provider-kimi.test.mjs @@ -221,6 +221,48 @@ oauth = { storage = "file", key = "oauth/kimi-code", oauth_host = "http://127.0. }); }); +test("Kimi CLI OAuth does not adopt a rotated credential without an access token", async (t) => { + await withKimiHome(async (kimiHome) => { + writeFileSync(path.join(kimiHome, "config.toml"), ` +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +oauth = { storage = "file", key = "oauth/kimi-code", oauth_host = "http://127.0.0.1" } +`); + const credentialsDir = path.join(kimiHome, "credentials"); + mkdirSync(credentialsDir, { recursive: true }); + const credentialFile = path.join(credentialsDir, "kimi-code.json"); + writeFileSync(credentialFile, JSON.stringify({ + access_token: "expired-token", + expires_at: 1, + expires_in: 3600, + refresh_token: "stale-refresh-token" + })); + + const previousFetch = globalThis.fetch; + globalThis.fetch = async () => { + // Partial write: a new refresh token landed without an access token. + writeFileSync(credentialFile, JSON.stringify({ + expires_at: 1, + expires_in: 7200, + refresh_token: "peer-refresh-token" + })); + return new Response(JSON.stringify({ error: "invalid_grant" }), { + headers: { "content-type": "application/json" }, + status: 401 + }); + }; + t.after(() => { + globalThis.fetch = previousFetch; + }); + + await assert.rejects( + () => resolveKimiAuth({ key: "oauth/kimi-code", oauthHost: "http://127.0.0.1" }), + /HTTP 401/ + ); + }); +}); + async function withKimiHome(run) { const kimiHome = mkdtempSync(path.join(os.tmpdir(), "ccr-kimi-provider-")); const previous = {