From 2b1fb9121e25bfeb02848ef1955def2721310ded Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:36:29 -0700 Subject: [PATCH] fix(rate-limits): read Kimi usage credentials from the configured WSL runtime Kimi's usage fetch always read the Windows host's ~/.kimi-code, so a Kimi CLI running inside WSL rotated only the WSL-side token and the status bar was stuck on 'Run Kimi to refresh'. Resolve the Kimi home from the local-account runtime target (mirroring Codex's getDefaultWslDistro()/getWslHome() UNC pattern), pinned to host off Windows, and keep KIMI_CODE_HOME host-only. Fixes #12370 Co-authored-by: Orca --- src/main/index.ts | 4 + src/main/kimi/kimi-runtime-home.test.ts | 125 ++++++++++++++++++ src/main/kimi/kimi-runtime-home.ts | 56 ++++++++ .../rate-limits/kimi-fetcher-wsl-home.test.ts | 111 ++++++++++++++++ src/main/rate-limits/kimi-fetcher.ts | 59 ++++++--- src/main/rate-limits/service.test.ts | 30 +++++ src/main/rate-limits/service.ts | 9 +- 7 files changed, 373 insertions(+), 21 deletions(-) create mode 100644 src/main/kimi/kimi-runtime-home.test.ts create mode 100644 src/main/kimi/kimi-runtime-home.ts create mode 100644 src/main/rate-limits/kimi-fetcher-wsl-home.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index b85a315593b..66a22783d18 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -183,6 +183,7 @@ import { RateLimitService } from './rate-limits/service' import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' +import { getKimiRuntimeTarget, resolveKimiHome } from './kimi/kimi-runtime-home' import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync' import { attachMainWindowServices, @@ -2306,6 +2307,9 @@ void app.whenReady().then(async () => { codexRuntimeHome!.prepareForRateLimitFetch(target) ) rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) + // Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the + // usage fetch must read the WSL-side credentials when that's the configured runtime (#12370). + rateLimits.setKimiHomeResolver(() => resolveKimiHome(getKimiRuntimeTarget(store!.getSettings()))) rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( rateLimits, diff --git a/src/main/kimi/kimi-runtime-home.test.ts b/src/main/kimi/kimi-runtime-home.test.ts new file mode 100644 index 00000000000..fd05c22a303 --- /dev/null +++ b/src/main/kimi/kimi-runtime-home.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const wslMocks = vi.hoisted(() => ({ + getDefaultWslDistro: vi.fn<() => string | null>(), + getWslHome: vi.fn<(distro: string) => string | null>() +})) + +vi.mock('../wsl', () => wslMocks) +vi.mock('node:os', () => ({ homedir: () => 'C:\\Users\\neil' })) + +import { getHostKimiHome, getKimiRuntimeTarget, resolveKimiHome } from './kimi-runtime-home' +import type { GlobalSettings } from '../../shared/types' + +function settings(overrides: Partial): GlobalSettings { + return overrides as GlobalSettings +} + +describe('getKimiRuntimeTarget', () => { + it('follows the configured WSL runtime on Windows', () => { + expect( + getKimiRuntimeTarget( + settings({ localAccountRuntime: 'wsl', localAccountWslDistro: ' Ubuntu ' }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('pins to host off Windows even when the setting says wsl', () => { + expect( + getKimiRuntimeTarget( + settings({ localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' }), + 'darwin' + ) + ).toEqual({ runtime: 'host', wslDistro: null }) + }) + + it('follows the Windows runtime default when the policy is auto', () => { + expect( + getKimiRuntimeTarget( + settings({ + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Debian' }) + }) +}) + +describe('resolveKimiHome', () => { + const originalKimiCodeHome = process.env.KIMI_CODE_HOME + + beforeEach(() => { + delete process.env.KIMI_CODE_HOME + wslMocks.getDefaultWslDistro.mockReset().mockReturnValue('Ubuntu') + wslMocks.getWslHome.mockReset().mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\neil') + }) + + afterEach(() => { + if (originalKimiCodeHome === undefined) { + delete process.env.KIMI_CODE_HOME + } else { + process.env.KIMI_CODE_HOME = originalKimiCodeHome + } + }) + + it('resolves the host home for a host target', () => { + expect(resolveKimiHome({ runtime: 'host', wslDistro: null }, 'win32')).toEqual({ + runtime: 'host', + wslDistro: null, + path: getHostKimiHome() + }) + expect(wslMocks.getWslHome).not.toHaveBeenCalled() + }) + + it('resolves the WSL distro home for a WSL target', () => { + expect(resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32')).toEqual({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + path: '\\\\wsl.localhost\\Ubuntu\\home\\neil\\.kimi-code' + }) + }) + + it('ignores the host KIMI_CODE_HOME when reading a WSL home', () => { + process.env.KIMI_CODE_HOME = 'D:\\kimi-home' + expect(resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32').path).toBe( + '\\\\wsl.localhost\\Ubuntu\\home\\neil\\.kimi-code' + ) + }) + + it('falls back to the default distro when none is configured', () => { + expect(resolveKimiHome({ runtime: 'wsl', wslDistro: null }, 'win32')).toMatchObject({ + wslDistro: 'Ubuntu' + }) + expect(wslMocks.getWslHome).toHaveBeenCalledWith('Ubuntu') + }) + + it('reports no path when the distro home cannot be probed', () => { + wslMocks.getWslHome.mockReturnValue(null) + expect(resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32')).toEqual({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + path: null + }) + }) + + it('reports no path when no distro exists at all', () => { + wslMocks.getDefaultWslDistro.mockReturnValue(null) + expect(resolveKimiHome({ runtime: 'wsl', wslDistro: null }, 'win32')).toEqual({ + runtime: 'wsl', + wslDistro: null, + path: null + }) + }) + + it('never probes WSL off Windows', () => { + expect(resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'darwin')).toEqual({ + runtime: 'host', + wslDistro: null, + path: getHostKimiHome() + }) + expect(wslMocks.getDefaultWslDistro).not.toHaveBeenCalled() + expect(wslMocks.getWslHome).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/kimi/kimi-runtime-home.ts b/src/main/kimi/kimi-runtime-home.ts new file mode 100644 index 00000000000..183cd0ed921 --- /dev/null +++ b/src/main/kimi/kimi-runtime-home.ts @@ -0,0 +1,56 @@ +import { homedir } from 'node:os' +import { join, win32 as pathWin32 } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { + resolveLocalAccountRuntimeTarget, + type LocalAccountRuntimeTarget +} from '../../shared/local-account-runtime' +import type { GlobalSettings } from '../../shared/types' +import { getDefaultWslDistro, getWslHome } from '../wsl' + +export type KimiHomeResolution = LocalAccountRuntimeTarget & { + /** null when the WSL distro's home could not be probed (distro missing/stopped). */ + path: string | null +} + +// Why: match the CLI's `KIMI_CODE_HOME ?? ~/.kimi-code` resolution so we read the +// same files the running Kimi CLI writes. +export function getHostKimiHome(): string { + return process.env.KIMI_CODE_HOME?.trim() || join(homedir(), '.kimi-code') +} + +/** + * Kimi has no per-account runtime switcher, so it follows the local-account + * runtime policy. WSL is Windows-only — pin everywhere else so no `wsl.exe` + * probe is attempted on a stale `wsl` setting synced from a Windows machine. + */ +export function getKimiRuntimeTarget( + settings: GlobalSettings, + platform: NodeJS.Platform = process.platform +): LocalAccountRuntimeTarget { + if (platform !== 'win32') { + return { runtime: 'host', wslDistro: null } + } + return resolveLocalAccountRuntimeTarget(settings, platform) +} + +/** Resolve a runtime target to the Kimi home Orca should read (UNC path for WSL). */ +export function resolveKimiHome( + target: LocalAccountRuntimeTarget, + platform: NodeJS.Platform = process.platform +): KimiHomeResolution { + if (target.runtime !== 'wsl' || platform !== 'win32') { + return { runtime: 'host', wslDistro: null, path: getHostKimiHome() } + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return { runtime: 'wsl', wslDistro: null, path: null } + } + const home = getWslHome(distro) + // KIMI_CODE_HOME describes the Windows host process, never the distro's home. + return { runtime: 'wsl', wslDistro: distro, path: home ? joinKimiHome(home) : null } +} + +function joinKimiHome(home: string): string { + return parseWslUncPath(home) ? pathWin32.join(home, '.kimi-code') : join(home, '.kimi-code') +} diff --git a/src/main/rate-limits/kimi-fetcher-wsl-home.test.ts b/src/main/rate-limits/kimi-fetcher-wsl-home.test.ts new file mode 100644 index 00000000000..ab9ba795688 --- /dev/null +++ b/src/main/rate-limits/kimi-fetcher-wsl-home.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const netFetchMock = vi.hoisted(() => vi.fn()) +const files = vi.hoisted(() => new Map()) + +vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) +vi.mock('node:fs', () => ({ + existsSync: (path: string) => files.has(path), + readFileSync: (path: string) => { + const contents = files.get(path) + if (contents === undefined) { + throw new Error(`ENOENT: ${path}`) + } + return contents + } +})) +// Posix-shaped host home so `node:path.join` behaves the same on every test runner. +vi.mock('node:os', () => ({ homedir: () => '/home/neil' })) + +import { fetchKimiRateLimits } from './kimi-fetcher' + +const HOST_CREDENTIALS = '/home/neil/.kimi-code/credentials/kimi-code.json' +const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\neil\\.kimi-code' +const WSL_CREDENTIALS = `${WSL_HOME}\\credentials\\kimi-code.json` + +function credentials(token: string, expiresInSeconds: number): string { + return JSON.stringify({ + access_token: token, + expires_at: Math.floor(Date.now() / 1000) + expiresInSeconds + }) +} + +function usageResponse(): Response { + return { + ok: true, + status: 200, + json: async () => ({ + usage: { limit: '1000', remaining: '900' }, + limits: [ + { + window: { duration: 5, timeUnit: 'TIME_UNIT_HOUR' }, + detail: { limit: '100', remaining: '40' } + } + ] + }) + } as Response +} + +describe('fetchKimiRateLimits with a WSL credentials home', () => { + beforeEach(() => { + files.clear() + netFetchMock.mockReset() + netFetchMock.mockResolvedValue(usageResponse()) + // The Windows-side copy stopped rotating when the CLI moved into WSL. + files.set(HOST_CREDENTIALS, credentials('host-stale', -3 * 24 * 3600)) + files.set(WSL_CREDENTIALS, credentials('wsl-fresh', 13 * 60)) + }) + + it('reads the WSL token instead of the stale host one', async () => { + const result = await fetchKimiRateLimits({ + home: { runtime: 'wsl', wslDistro: 'Ubuntu', path: WSL_HOME } + }) + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(60) + expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(netFetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer wsl-fresh') + }) + + it('still reads the host home by default', async () => { + const result = await fetchKimiRateLimits() + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('delegated-refresh-required') + expect(result.error).toContain('on the computer running Orca') + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('points an expired WSL session at the distro to rerun kimi in', async () => { + files.set(WSL_CREDENTIALS, credentials('wsl-stale', -60)) + + const result = await fetchKimiRateLimits({ + home: { runtime: 'wsl', wslDistro: 'Ubuntu', path: WSL_HOME } + }) + + expect(result.error).toBe( + 'Kimi session expired — run kimi inside WSL (Ubuntu), then retry usage.' + ) + expect(result.usageMetadata?.failureKind).toBe('delegated-refresh-required') + }) + + it('reports an unresolvable WSL home instead of falling back to host credentials', async () => { + const result = await fetchKimiRateLimits({ + home: { runtime: 'wsl', wslDistro: 'Ubuntu', path: null } + }) + + expect(result.status).toBe('error') + expect(result.error).toBe('WSL Kimi home unavailable for Ubuntu') + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('reports not signed in when the WSL home has no credentials file', async () => { + files.delete(WSL_CREDENTIALS) + + const result = await fetchKimiRateLimits({ + home: { runtime: 'wsl', wslDistro: 'Ubuntu', path: WSL_HOME } + }) + + expect(result.status).toBe('unavailable') + }) +}) diff --git a/src/main/rate-limits/kimi-fetcher.ts b/src/main/rate-limits/kimi-fetcher.ts index 778b90b796c..2172568b110 100644 --- a/src/main/rate-limits/kimi-fetcher.ts +++ b/src/main/rate-limits/kimi-fetcher.ts @@ -1,12 +1,13 @@ import { existsSync, readFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' +import { join, win32 as pathWin32 } from 'node:path' import { net } from 'electron' import type { ProviderRateLimits, RateLimitWindow, UsageRateLimitMetadata } from '../../shared/rate-limit-types' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { getHostKimiHome, type KimiHomeResolution } from '../kimi/kimi-runtime-home' // Why: Kimi Code's managed coding plan exposes subscription usage at // `${base}/usages` (see packages/oauth/src/managed-usage.ts in the CLI bundle). @@ -18,14 +19,11 @@ const API_TIMEOUT_MS = 10_000 const SESSION_WINDOW_MINUTES = 300 // 5h const WEEKLY_WINDOW_MINUTES = 10080 // 7d -function getKimiHome(): string { - // Why: match the CLI's `KIMI_CODE_HOME ?? ~/.kimi-code` resolution so we read - // the same OAuth credentials the running Kimi CLI writes. - return process.env.KIMI_CODE_HOME ?? join(homedir(), '.kimi-code') -} - -function getCredentialsPath(): string { - return join(getKimiHome(), 'credentials', 'kimi-code.json') +function getCredentialsPath(kimiHome: string): string { + // WSL homes arrive as `\\wsl.localhost\\...`, which only win32 join keeps intact. + return parseWslUncPath(kimiHome) + ? pathWin32.join(kimiHome, 'credentials', 'kimi-code.json') + : join(kimiHome, 'credentials', 'kimi-code.json') } type KimiCredentials = { @@ -52,8 +50,8 @@ function parseCredentials(value: unknown): KimiCredentials | null { return credentials } -function readCredentials(): CredentialsReadResult { - const path = getCredentialsPath() +function readCredentials(kimiHome: string): CredentialsReadResult { + const path = getCredentialsPath(kimiHome) if (!existsSync(path)) { return { status: 'missing' } } @@ -212,6 +210,14 @@ function mapUsageResponse(data: KimiUsageResponse): ProviderRateLimits { } } +function expiredSessionMessage(home: KimiHomeResolution): string { + const where = + home.runtime === 'wsl' + ? `inside WSL (${home.wslDistro ?? 'default distro'})` + : 'on the computer running Orca' + return `Kimi session expired — run kimi ${where}, then retry usage.` +} + function result( status: ProviderRateLimits['status'], error: string | null, @@ -231,7 +237,11 @@ function result( /** * Read-only subscription usage for Kimi Code. * - * Why read-only: the access token lives in `~/.kimi-code/credentials/kimi-code.json` + * `home` selects which machine's credentials to read: on Windows the Kimi CLI + * often runs inside WSL, and only that copy is refreshed (#12370). Defaults to + * the host home. + * + * Why read-only: the access token lives in `/credentials/kimi-code.json` * and is refreshed by the Kimi CLI itself (15-min TTL, refresh-token rotation). * Orca must NEVER refresh or rewrite that file — a rotated refresh token would * log out a live `kimi` session. We only read the current token and call the @@ -239,8 +249,18 @@ function result( * `/usage` command uses. The completion endpoint (the one Moonshot gates to * approved coding agents) is never touched here. */ -export async function fetchKimiRateLimits(): Promise { - const readResult = readCredentials() +export async function fetchKimiRateLimits(options?: { + home?: KimiHomeResolution +}): Promise { + const home: KimiHomeResolution = options?.home ?? { + runtime: 'host', + wslDistro: null, + path: getHostKimiHome() + } + if (home.path === null) { + return result('error', `WSL Kimi home unavailable for ${home.wslDistro ?? 'default distro'}`) + } + const readResult = readCredentials(home.path) if (readResult.status === 'missing') { return result('unavailable', 'Not signed in to Kimi Code') } @@ -255,11 +275,10 @@ export async function fetchKimiRateLimits(): Promise { // Why: don't refresh — the CLI owns the token lifecycle. Report a transient // error so the rate-limit service keeps the last good snapshot (stale // policy) until the user next runs Kimi and the CLI refreshes the file. - return result( - 'error', - 'Kimi session expired — run kimi on the computer running Orca, then retry usage.', - { failureKind: 'delegated-refresh-required', source: 'oauth' } - ) + return result('error', expiredSessionMessage(home), { + failureKind: 'delegated-refresh-required', + source: 'oauth' + }) } try { diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index dafa9a30ed0..ab66aa0404e 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -1432,6 +1432,36 @@ describe('RateLimitService', () => { expect(state.opencodeGo?.session?.usedPercent).toBe(40) }) + it('passes the resolved Kimi home into each fetch cycle', async () => { + const service = new RateLimitService() + const home = { + runtime: 'wsl' as const, + wslDistro: 'Ubuntu', + path: '\\\\wsl.localhost\\Ubuntu\\home\\neil\\.kimi-code' + } + const resolver = vi.fn(() => home) + service.setKimiHomeResolver(resolver) + mockFreshBackgroundProviderFetches() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10)) + + await service.refresh() + await service.refresh() + + // Resolved per cycle so a runtime-policy change takes effect without a restart. + expect(resolver).toHaveBeenCalledTimes(2) + expect(fetchKimiRateLimits).toHaveBeenCalledWith({ home }) + }) + + it('reads the host Kimi home when no resolver is wired', async () => { + const service = new RateLimitService() + mockFreshBackgroundProviderFetches() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10)) + + await service.refresh() + + expect(fetchKimiRateLimits).toHaveBeenCalledWith({ home: undefined }) + }) + it('passes the selected WSL Codex home into active account rate-limit fetches', async () => { const service = new RateLimitService() const wslCodexHome = diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 7cdb1e7bef9..d149ef9f082 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -21,6 +21,7 @@ import { } from '../claude-accounts/runtime-selection' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' +import type { KimiHomeResolution } from '../kimi/kimi-runtime-home' import { fetchGrokRateLimits } from './grok-fetcher' import { readGrokAuthSession } from './grok-auth' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' @@ -222,6 +223,8 @@ export class RateLimitService { runtime: 'host', wslDistro: null } + // Why: resolved per cycle — the local-account runtime policy can flip between fetches. + private kimiHomeResolver: (() => KimiHomeResolution) | null = null private claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null private claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { runtime: 'host', @@ -260,6 +263,10 @@ export class RateLimitService { this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) } + setKimiHomeResolver(resolver: () => KimiHomeResolution): void { + this.kimiHomeResolver = resolver + } + setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { this.claudeAuthPreparationResolver = resolver } @@ -1661,7 +1668,7 @@ export class RateLimitService { workspaceIdOverride || undefined, this.networkProxySettingsResolver?.() ), - fetchKimiRateLimits(), + fetchKimiRateLimits({ home: this.kimiHomeResolver?.() }), miniMaxConfigResult.error ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) : fetchMiniMaxRateLimits({