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 <help@stably.ai>
This commit is contained in:
Neil
2026-08-07 18:35:39 -07:00
committed by OrcaWin
co-authored by Orca
parent 2c865cda66
commit 2b1fb9121e
7 changed files with 373 additions and 21 deletions
+4
View File
@@ -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,
+125
View File
@@ -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>): 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()
})
})
+56
View File
@@ -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')
}
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const netFetchMock = vi.hoisted(() => vi.fn())
const files = vi.hoisted(() => new Map<string, string>())
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')
})
})
+39 -20
View File
@@ -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\<distro>\...`, 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 `<kimi home>/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<ProviderRateLimits> {
const readResult = readCredentials()
export async function fetchKimiRateLimits(options?: {
home?: KimiHomeResolution
}): Promise<ProviderRateLimits> {
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<ProviderRateLimits> {
// 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 {
+30
View File
@@ -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 =
+8 -1
View File
@@ -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({