From dd3277d0e1157302703de7dc70f89b7d9ce5e6cf Mon Sep 17 00:00:00 2001 From: Luchong Date: Tue, 29 Sep 2026 13:55:03 +0800 Subject: [PATCH 1/3] feat(usage): link GLM Coding Plan accounts in AI Provider Accounts Add a GLM Coding Plan section to Settings > AI Provider Accounts with a site selector (Z.AI / Zhipu BigModel) and an encrypted in-app API key store, extending the merged zcode provider so the saved key takes priority over ~/.zcode/cli/config.json and usage refreshes hit the selected site. A saved plan key also exempts the zcode status-bar meter from ZCode CLI PATH detection, so subscribers without the CLI still see their quota. --- .../register-core-handlers.test.ts | 8 + .../register-core-handlers.ts | 2 + src/main/ipc/zcode-plan-credentials.ts | 51 +++ .../rate-limits/service-zcode-usage.test.ts | 186 ++++++++ .../service/service-account-refresh.ts | 9 + .../service/service-configuration.ts | 7 + .../service/service-fetch-policy.ts | 13 + .../service/service-fetch-targets.ts | 13 + .../service/service-full-cycle-application.ts | 16 +- .../service/service-full-cycle-preparation.ts | 34 +- src/main/rate-limits/service/service-state.ts | 4 + src/main/rate-limits/service/service-types.ts | 11 + .../rate-limits/zcode-usage-fetcher.test.ts | 101 ++++- src/main/rate-limits/zcode-usage-fetcher.ts | 49 ++- src/main/runtime/runtime-client-settings.ts | 3 + src/main/runtime/runtime-store-contract.ts | 1 + .../startup/main-process-account-services.ts | 16 + .../zcode/zcode-plan-api-key-store.test.ts | 151 +++++++ src/main/zcode/zcode-plan-api-key-store.ts | 126 ++++++ src/preload/api-types.ts | 4 +- src/preload/api/agent-account-api.ts | 12 + .../api/zcode-plan-credentials-bridge.ts | 13 + src/preload/index.ts | 2 + .../src/components/settings/AccountsPane.tsx | 7 +- .../ZcodePlanAccountsSection.test.tsx | 157 +++++++ .../settings/ZcodePlanAccountsSection.tsx | 410 ++++++++++++++++++ .../components/settings/accounts-search.ts | 33 +- .../settings/zcode-plan-usage-windows.tsx | 72 +++ .../status-bar-provider-visibility.test.ts | 1 + .../status-bar-provider-visibility.ts | 9 +- .../usage-provider-settings-target.ts | 4 +- .../status-bar/use-status-bar-controller.ts | 7 +- src/renderer/src/i18n/locales/en.json | 57 ++- src/renderer/src/i18n/locales/zh.json | 51 +++ src/shared/default-global-settings.ts | 1 + src/shared/global-settings-types.ts | 3 + src/shared/rate-limit-state-factory.ts | 1 + src/shared/rate-limit-types.test.ts | 1 + src/shared/rate-limit-types.ts | 6 + .../rpc-contract/client-settings-params.ts | 1 + src/shared/zcode-plan-sites.ts | 23 + 41 files changed, 1657 insertions(+), 19 deletions(-) create mode 100644 src/main/ipc/zcode-plan-credentials.ts create mode 100644 src/main/rate-limits/service-zcode-usage.test.ts create mode 100644 src/main/zcode/zcode-plan-api-key-store.test.ts create mode 100644 src/main/zcode/zcode-plan-api-key-store.ts create mode 100644 src/preload/api/zcode-plan-credentials-bridge.ts create mode 100644 src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx create mode 100644 src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx create mode 100644 src/renderer/src/components/settings/zcode-plan-usage-windows.tsx create mode 100644 src/shared/zcode-plan-sites.ts diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 22fe84caba6..d440dbeded0 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -37,6 +37,7 @@ const { registerAgentTrustHandlersMock, registerClaudeAccountHandlersMock, registerMiniMaxCredentialsHandlersMock, + registerZcodePlanCredentialsHandlersMock, registerGrokAccountHandlersMock, registerCursorAccountHandlersMock, registerClipboardHandlersMock, @@ -105,6 +106,7 @@ const { registerAgentTrustHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), + registerZcodePlanCredentialsHandlersMock: vi.fn(), registerGrokAccountHandlersMock: vi.fn(), registerCursorAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), @@ -342,6 +344,10 @@ vi.mock('../minimax-credentials', () => ({ registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock })) +vi.mock('../zcode-plan-credentials', () => ({ + registerZcodePlanCredentialsHandlers: registerZcodePlanCredentialsHandlersMock +})) + vi.mock('../grok-accounts', () => ({ registerGrokAccountHandlers: registerGrokAccountHandlersMock })) @@ -441,6 +447,7 @@ describe('registerCoreHandlers', () => { registerAgentTrustHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerMiniMaxCredentialsHandlersMock.mockReset() + registerZcodePlanCredentialsHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() setTrustedClipboardRendererWebContentsIdMock.mockReset() registerUpdaterHandlersMock.mockReset() @@ -533,6 +540,7 @@ describe('registerCoreHandlers', () => { expect(registerPetHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) + expect(registerZcodePlanCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGrokAccountHandlersMock).toHaveBeenCalled() expect(registerCursorAccountHandlersMock).toHaveBeenCalled() expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits, codexAccounts) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index e692cc9c5e8..47e7a006581 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -63,6 +63,7 @@ import { getPtyIdForPaneKey } from '../pty' import { registerAgentTrustHandlers } from '../agent-trust' import { registerClaudeAccountHandlers } from '../claude-accounts' import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials' +import { registerZcodePlanCredentialsHandlers } from '../zcode-plan-credentials' import { registerGrokAccountHandlers } from '../grok-accounts' import { registerCursorAccountHandlers } from '../cursor-accounts' import { registerUpdaterHandlers } from '../../window/attach-main-window-services' @@ -152,6 +153,7 @@ export function registerCoreHandlers( registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) + registerZcodePlanCredentialsHandlers(rateLimits) registerGrokAccountHandlers() registerCursorAccountHandlers() registerRateLimitHandlers(rateLimits, codexAccounts) diff --git a/src/main/ipc/zcode-plan-credentials.ts b/src/main/ipc/zcode-plan-credentials.ts new file mode 100644 index 00000000000..63dd709609c --- /dev/null +++ b/src/main/ipc/zcode-plan-credentials.ts @@ -0,0 +1,51 @@ +import { ipcMain } from 'electron' +import { + clearZcodePlanApiKey, + hasZcodePlanApiKey, + saveZcodePlanApiKey +} from '../zcode/zcode-plan-api-key-store' +import { hasZcodeCliPlanCredentials } from '../rate-limits/zcode-usage-fetcher' +import type { RateLimitService } from '../rate-limits/service' + +export type ZcodePlanCredentialsStatus = { + apiKeyConfigured: boolean + zcodeCliConfigured: boolean +} + +function getZcodePlanCredentialsStatus(): ZcodePlanCredentialsStatus { + return { + apiKeyConfigured: hasZcodePlanApiKey(), + zcodeCliConfigured: hasZcodeCliPlanCredentials() + } +} + +// Why: fire-and-forget — callers get the persisted credential status immediately; +// the rate-limit refresh runs in the background and only logs on failure. +function refreshAfterZcodePlanCredentialChange( + rateLimits: RateLimitService | null, + action: 'save' | 'clear' +): void { + rateLimits?.invalidateZcodeCredentialState() + void rateLimits?.refresh().catch((error: unknown) => { + console.error(`[zcode] failed to trigger rate-limit refresh after ${action}:`, error) + }) +} + +export function registerZcodePlanCredentialsHandlers(rateLimits: RateLimitService | null): void { + ipcMain.handle('zcodePlanCredentials:getStatus', () => getZcodePlanCredentialsStatus()) + ipcMain.handle('zcodePlanCredentials:saveApiKey', (_event, key: string) => { + // Validate the IPC argument in the main process; the renderer-declared type + // is compile-time only and the value arrives as unknown over IPC. + if (typeof key !== 'string') { + throw new Error('GLM Coding Plan API key must be a string') + } + saveZcodePlanApiKey(key) + refreshAfterZcodePlanCredentialChange(rateLimits, 'save') + return getZcodePlanCredentialsStatus() + }) + ipcMain.handle('zcodePlanCredentials:clearApiKey', () => { + clearZcodePlanApiKey() + refreshAfterZcodePlanCredentialChange(rateLimits, 'clear') + return getZcodePlanCredentialsStatus() + }) +} diff --git a/src/main/rate-limits/service-zcode-usage.test.ts b/src/main/rate-limits/service-zcode-usage.test.ts new file mode 100644 index 00000000000..6169b081525 --- /dev/null +++ b/src/main/rate-limits/service-zcode-usage.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { RateLimitService } from './service' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { fetchCodexRateLimits } from './codex-fetcher' +import { fetchZcodeRateLimits } from './zcode-usage-fetcher' +import { hasZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store' +import { + deferred, + okProvider, + resetRateLimitProviderMocks +} from './rate-limit-service-test-harness' + +vi.mock('./claude-fetcher', () => ({ + fetchClaudeRateLimits: vi.fn(), + fetchManagedAccountUsage: vi.fn() +})) + +vi.mock('./codex-fetcher', () => ({ + consumeCodexRateLimitResetCredit: vi.fn(), + fetchCodexRateLimits: vi.fn() +})) + +vi.mock('./gemini-usage-fetcher', () => ({ + fetchGeminiRateLimits: vi.fn() +})) + +vi.mock('./kimi-fetcher', () => ({ + fetchKimiRateLimits: vi.fn() +})) + +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: vi.fn() +})) + +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn(), + hasZcodeCliPlanCredentials: vi.fn(() => false) +})) + +vi.mock('./minimax/minimax-fetcher', () => ({ + fetchMiniMaxRateLimits: vi.fn() +})) + +vi.mock('./grok-fetcher', () => ({ + fetchGrokRateLimits: vi.fn() +})) + +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + +vi.mock('./grok-auth', () => ({ + readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + +vi.mock('../minimax/minimax-cookie-store', () => ({ + hasMiniMaxSessionCookie: vi.fn(() => false) +})) + +vi.mock('../minimax/minimax-api-key-store', () => ({ + hasMiniMaxApiKey: vi.fn(() => false) +})) + +vi.mock('../zcode/zcode-plan-api-key-store', () => ({ + hasZcodePlanApiKey: vi.fn(() => false), + readZcodePlanApiKey: vi.fn(() => null), + saveZcodePlanApiKey: vi.fn(), + clearZcodePlanApiKey: vi.fn() +})) + +describe('RateLimitService zcode plan credentials', () => { + beforeEach(() => { + resetRateLimitProviderMocks() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) + }) + + it('fetches zcode with the site-resolved plan credential when a resolver is set', async () => { + const service = new RateLimitService() + service.setZcodePlanConfigResolver(() => ({ site: 'bigmodel', apiKey: 'glm-key' })) + vi.mocked(hasZcodePlanApiKey).mockReturnValue(true) + vi.mocked(fetchZcodeRateLimits).mockResolvedValueOnce(okProvider('zcode', 33, Date.now())) + + await service.refresh() + + expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(1) + expect(fetchZcodeRateLimits).toHaveBeenCalledWith({ + signal: expect.any(AbortSignal), + planCredential: { apiKey: 'glm-key', baseUrl: 'https://open.bigmodel.cn' } + }) + const state = service.getState() + expect(state.zcode?.status).toBe('ok') + expect(state.zcode?.session?.usedPercent).toBe(33) + expect(state.zcodePlanApiKeyConfigured).toBe(true) + }) + + it('passes no plan credential while no key is saved and still fetches via the CLI config', async () => { + const service = new RateLimitService() + service.setZcodePlanConfigResolver(() => ({ site: 'zai', apiKey: '' })) + vi.mocked(fetchZcodeRateLimits).mockResolvedValueOnce(okProvider('zcode', 12, Date.now())) + + await service.refresh() + + expect(fetchZcodeRateLimits).toHaveBeenCalledWith({ + signal: expect.any(AbortSignal), + planCredential: null + }) + expect(service.getState().zcode?.session?.usedPercent).toBe(12) + }) + + it('surfaces a resolver failure as a zcode-only error without fetching', async () => { + const service = new RateLimitService() + service.setZcodePlanConfigResolver(() => { + throw new Error('GLM Coding Plan API key could not be decrypted') + }) + + await service.refresh() + + expect(fetchZcodeRateLimits).not.toHaveBeenCalled() + const zcode = service.getState().zcode + expect(zcode?.status).toBe('error') + expect(zcode?.error).toContain('could not be decrypted') + expect(zcode?.usageMetadata?.failureKind).toBe('keychain-unavailable') + expect(service.getState().claude?.status).toBe('ok') + }) + + it('discards the previous zcode snapshot when the saved site changes', async () => { + const service = new RateLimitService() + let site: 'zai' | 'bigmodel' = 'zai' + service.setZcodePlanConfigResolver(() => ({ site, apiKey: 'glm-key' })) + vi.mocked(fetchZcodeRateLimits) + .mockResolvedValueOnce(okProvider('zcode', 40, Date.now())) + .mockRejectedValueOnce(new Error('Zcode quota request failed (401)')) + + await service.refresh() + expect(service.getState().zcode?.session?.usedPercent).toBe(40) + + site = 'bigmodel' + await service.refresh() + + const state = service.getState() + expect(fetchZcodeRateLimits).toHaveBeenLastCalledWith({ + signal: expect.any(AbortSignal), + planCredential: { apiKey: 'glm-key', baseUrl: 'https://open.bigmodel.cn' } + }) + expect(state.zcode?.status).toBe('error') + expect(state.zcode?.session).toBeNull() + }) + + it('does not apply an in-flight zcode result after credential invalidation', async () => { + const service = new RateLimitService() + const firstZcode = deferred() + const secondZcode = deferred() + service.setZcodePlanConfigResolver(() => ({ site: 'zai', apiKey: 'glm-key' })) + vi.mocked(fetchZcodeRateLimits) + .mockImplementationOnce(() => firstZcode.promise) + .mockImplementationOnce(() => secondZcode.promise) + + const firstRefresh = service.refresh() + await Promise.resolve() + + service.invalidateZcodeCredentialState() + const queuedRefresh = service.refresh() + await Promise.resolve() + + firstZcode.resolve(okProvider('zcode', 50, Date.now())) + await Promise.resolve() + await Promise.resolve() + + expect(service.getState().zcode?.status).toBe('fetching') + expect(service.getState().zcode?.session).toBeNull() + + secondZcode.resolve(okProvider('zcode', 10, Date.now())) + await firstRefresh + await queuedRefresh + + const state = service.getState() + expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(2) + expect(state.zcode?.session?.usedPercent).toBe(10) + }) +}) diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts index ed8578e3694..e6090e28228 100644 --- a/src/main/rate-limits/service/service-account-refresh.ts +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -38,6 +38,15 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna }) } + invalidateZcodeCredentialState(): void { + this.zcodeFetchGeneration += 1 + // Why: saving/forgetting the plan key can race an in-flight fetch; clear the visible snapshot before any old-key result returns. + this.updateState({ + ...this.state, + zcode: this.withFetchingStatus(null, 'zcode') + }) + } + async refreshForCodexAccountChange( outgoingAccountId?: string | null, target?: CodexAccountSelectionTarget diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index 2a788b1cf93..09dbde8a860 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -1,6 +1,7 @@ import type { BrowserWindow } from 'electron' import { hasMiniMaxSessionCookie } from '../../minimax/minimax-cookie-store' import { hasMiniMaxApiKey } from '../../minimax/minimax-api-key-store' +import { hasZcodePlanApiKey } from '../../zcode/zcode-plan-api-key-store' import { RateLimitServiceAccountRefresh } from './service-account-refresh' import { type CodexAccountSelectionTarget, @@ -10,6 +11,7 @@ import { type ClaudeAuthPreparationResolver, type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, + type ZcodePlanRateLimitConfig, type GeminiCliOAuthEnabledResolver, type InactiveCodexAccountInfo, type InactiveClaudeAccountInfo, @@ -48,6 +50,10 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco this.miniMaxConfigResolver = resolver } + setZcodePlanConfigResolver(resolver: () => ZcodePlanRateLimitConfig): void { + this.zcodePlanConfigResolver = resolver + } + setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { this.geminiCliOAuthEnabledResolver = resolver } @@ -125,6 +131,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco // Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads. minimaxCookieConfigured: hasMiniMaxSessionCookie(), minimaxApiKeyConfigured: hasMiniMaxApiKey(), + zcodePlanApiKeyConfigured: hasZcodePlanApiKey(), opencodeGoApiKeyConfigured: this.openCodeGoApiKeyConfigured, grokAuthConfigured: this.grokAuthConfigured, cursorAuthConfigured: this.cursorAuthConfigured, diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts index 12542f311db..cfd4a6e9d58 100644 --- a/src/main/rate-limits/service/service-fetch-policy.ts +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -24,6 +24,19 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT } } + protected getZcodePlanCredentialError(message: string): ProviderRateLimits { + return { + provider: 'zcode', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: message, + status: 'error', + usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' } + } + } + // Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive. // A live post flips the snapshot back to ok, but the endpoint's Retry-After is still binding. protected isRetryAfterActive(limits: ProviderRateLimits | null): boolean { diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts index 51ce2db3d0b..da15da71a29 100644 --- a/src/main/rate-limits/service/service-fetch-targets.ts +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -6,6 +6,7 @@ import { type ClaudeRuntimeAuthPreparation, type CodexAccountSelectionTarget, type MiniMaxResolvedConfig, + type ZcodePlanResolvedConfig, type NormalizedCodexAccountSelectionTarget, type NormalizedClaudeAccountSelectionTarget, type ProviderRateLimits, @@ -219,4 +220,16 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul } } } + + protected resolveZcodePlanConfig(): ZcodePlanResolvedConfig { + try { + return { + config: this.zcodePlanConfigResolver?.() ?? { site: 'zai', apiKey: '' }, + error: null + } + } catch (error) { + // Why: an undecryptable saved key must not abort every provider's refresh; surface it as ZCode-only state instead. + return { config: { site: 'zai', apiKey: '' }, error: toErrorMessage(error) } + } + } } diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 70b58933c16..72e767ab3b3 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -26,6 +26,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ opencodeGeneration, miniMaxConfigChanged, miniMaxGeneration, + zcodeConfigChanged, + zcodeGeneration, claudeFetchGated, results: [ claudeResult, @@ -151,6 +153,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration + const shouldApplyZcode = zcodeGeneration === this.zcodeFetchGeneration if (shouldApplyClaude) { this.trackActiveFailureStreak('claude', claude) @@ -224,15 +227,20 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ previousZcodeAccount === zcodeAccount this.trackActiveFailureStreak('grok', grok) this.trackActiveFailureStreak('cursor', cursor) - this.trackActiveFailureStreak('zcode', zcode) + if (shouldApplyZcode) { + this.trackActiveFailureStreak('zcode', zcode) + } this.updateState({ ...this.state, grok: this.applyStalePolicy(grok, previousState.grok), cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor), - zcode: - zcode.status === 'error' && !sameZcodeAccount + zcode: !shouldApplyZcode + ? this.state.zcode + : zcodeConfigChanged ? zcode - : this.applyStalePolicy(zcode, previousState.zcode) + : zcode.status === 'error' && !sameZcodeAccount + ? zcode + : this.applyStalePolicy(zcode, previousState.zcode) }) } } diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 55a2cc2a062..416143d3afb 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -6,6 +6,7 @@ import { readGrokAuthSession } from '../grok-auth' import { fetchCursorRateLimits } from '../cursor-fetcher' import { readCursorAuthSession } from '../cursor-auth' import { fetchZcodeRateLimits } from '../zcode-usage-fetcher' +import { ZCODE_PLAN_SITE_BASE_URLS } from '../../../shared/zcode-plan-sites' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' import { createHash } from 'node:crypto' import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection' @@ -34,6 +35,8 @@ export type FetchAllCyclePrepared = { opencodeGeneration: number miniMaxConfigChanged: boolean miniMaxGeneration: number + zcodeConfigChanged: boolean + zcodeGeneration: number claudeFetchGated: boolean results: [ PromiseSettledResult, @@ -114,6 +117,25 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ } const miniMaxGeneration = this.minimaxFetchGeneration + const zcodePlanConfigResult = this.resolveZcodePlanConfig() + const zcodePlanApiKey = zcodePlanConfigResult.config.apiKey + // Why digest, not the key: this string only has to change when the credential does. + const currentZcodeConfigHash = zcodePlanApiKey + ? `${zcodePlanConfigResult.config.site}|${createHash('sha256').update(zcodePlanApiKey).digest('hex')}` + : '' + const zcodeConfigChanged = currentZcodeConfigHash !== this.lastZcodeConfigHash + if (zcodeConfigChanged) { + this.lastZcodeConfigHash = currentZcodeConfigHash + this.zcodeFetchGeneration += 1 + } + const zcodeGeneration = this.zcodeFetchGeneration + const zcodePlanCredential = zcodePlanApiKey + ? { + apiKey: zcodePlanApiKey, + baseUrl: ZCODE_PLAN_SITE_BASE_URLS[zcodePlanConfigResult.config.site] + } + : null + // Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change). this.updateState({ ...previousState, @@ -133,7 +155,9 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ : this.withFetchingStatus(previousState.minimax, 'minimax'), grok: this.withFetchingStatus(previousState.grok, 'grok'), cursor: this.withFetchingStatus(previousState.cursor, 'cursor'), - zcode: this.withFetchingStatus(previousState.zcode, 'zcode') + zcode: zcodeConfigChanged + ? this.withFetchingStatus(null, 'zcode') + : this.withFetchingStatus(previousState.zcode, 'zcode') }) // Why: the Cursor probe reads the macOS Keychain, so it is awaited inside the @@ -148,7 +172,11 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ (reason) => ({ status: 'rejected', reason }) as const ) - const zcodeResultPromise = fetchZcodeRateLimits({ signal }).then( + const zcodeResultPromise = ( + zcodePlanConfigResult.error + ? Promise.resolve(this.getZcodePlanCredentialError(zcodePlanConfigResult.error)) + : fetchZcodeRateLimits({ signal, planCredential: zcodePlanCredential }) + ).then( (value) => ({ status: 'fulfilled', value }) as const, (reason) => ({ status: 'rejected', reason }) as const ) @@ -229,6 +257,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ opencodeGeneration, miniMaxConfigChanged, miniMaxGeneration, + zcodeConfigChanged, + zcodeGeneration, claudeFetchGated, results: [ claudeResult, diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 7af82c6c313..01c4456cf48 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -13,6 +13,7 @@ import { type ClaudeAuthPreparationResolver, type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, + type ZcodePlanRateLimitConfig, type GeminiCliOAuthEnabledResolver, type NormalizedCodexAccountSelectionTarget, type NormalizedClaudeAccountSelectionTarget, @@ -84,8 +85,10 @@ export abstract class RateLimitServiceState { protected lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null protected opencodeFetchGeneration = 0 protected minimaxFetchGeneration = 0 + protected zcodeFetchGeneration = 0 protected lastOpencodeConfigHash = '' protected lastMiniMaxConfigHash = '' + protected lastZcodeConfigHash = '' protected codexHomePathResolver: CodexHomePathResolver | null = null protected codexFetchTarget: NormalizedCodexAccountSelectionTarget = { runtime: 'host', @@ -100,6 +103,7 @@ export abstract class RateLimitServiceState { } protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null + protected zcodePlanConfigResolver: (() => ZcodePlanRateLimitConfig) | null = null protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null protected inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index a3998c13abe..bc6902e2fa5 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -1,4 +1,5 @@ import type { ProviderRateLimits } from '../../../shared/rate-limit-types' +import type { ZcodePlanSite } from '../../../shared/zcode-plan-sites' import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection' import type { KimiHomeResolution } from '../../kimi/kimi-runtime-home' @@ -61,6 +62,16 @@ export type MiniMaxResolvedConfig = { error: string | null } +export type ZcodePlanRateLimitConfig = { + site: ZcodePlanSite + apiKey: string +} + +export type ZcodePlanResolvedConfig = { + config: ZcodePlanRateLimitConfig + error: string | null +} + export type GeminiCliOAuthEnabledResolver = () => boolean export type ActiveRateLimitProvider = ProviderRateLimits['provider'] export type ActiveProviderState = { diff --git a/src/main/rate-limits/zcode-usage-fetcher.test.ts b/src/main/rate-limits/zcode-usage-fetcher.test.ts index b587d196be0..7c293b628a4 100644 --- a/src/main/rate-limits/zcode-usage-fetcher.test.ts +++ b/src/main/rate-limits/zcode-usage-fetcher.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { fetchZcodeRateLimits } from './zcode-usage-fetcher' +import { fetchZcodeRateLimits, hasZcodeCliPlanCredentials } from './zcode-usage-fetcher' let dir: string let configPath: string @@ -289,4 +289,103 @@ describe('fetchZcodeRateLimits', () => { expect(result.status).toBe('error') expect(result.usageMetadata?.failureKind).toBe('parse') }) + + it('prefers the Orca-saved plan credential over the ZCode CLI config', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + level: 'pro', + limits: [ + { type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 30 }, + { type: 'CREDIT_LIMIT', unit: 6, number: 1, percentage: 60 } + ] + } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ + configPath, + planCredential: { apiKey: 'orca-saved-key', baseUrl: 'https://api.z.ai' } + }) + + expect(result.status).toBe('ok') + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect(String(url)).toBe('https://api.z.ai/api/monitor/usage/quota/limit') + expect(new Headers(init?.headers).get('Authorization')).toBe('orca-saved-key') + expect(result.usageMetadata?.credentialSource).toBe('orca-plan') + expect(JSON.stringify(result)).not.toContain('orca-saved-key') + }) + + it('uses the BigModel host for the mainland plan site', async () => { + writeFileSync(configPath, JSON.stringify({ provider: {} })) + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { limits: [{ type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 10 }] } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ + configPath, + planCredential: { apiKey: 'bigmodel-key', baseUrl: 'https://open.bigmodel.cn' } + }) + + expect(result.status).toBe('ok') + expect(String(vi.mocked(fetch).mock.calls[0][0])).toBe( + 'https://open.bigmodel.cn/api/monitor/usage/quota/limit' + ) + }) + + it('rejects a plan credential whose base URL is not a supported site', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('{}')) + + const result = await fetchZcodeRateLimits({ + configPath, + planCredential: { apiKey: 'key', baseUrl: 'https://evil.example.com' } + }) + + expect(result.status).toBe('unavailable') + expect(fetch).not.toHaveBeenCalled() + }) + + it('falls back to the CLI config when the plan credential is malformed', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { limits: [{ type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 10 }] } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ + configPath, + planCredential: { apiKey: 'bad\r\nkey', baseUrl: 'https://api.z.ai' } + }) + + expect(result.status).toBe('ok') + expect(String(vi.mocked(fetch).mock.calls[0][0])).toBe( + 'https://open.bigmodel.cn/api/monitor/usage/quota/limit' + ) + expect(result.usageMetadata?.credentialSource).toBe(configPath) + }) +}) + +describe('hasZcodeCliPlanCredentials', () => { + it('detects a usable CLI config', async () => { + writeConfig() + + expect(hasZcodeCliPlanCredentials(configPath)).toBe(true) + }) + + it('reports false without a config file', async () => { + expect(hasZcodeCliPlanCredentials(configPath)).toBe(false) + }) }) diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts index 852def603e3..878ed9023b3 100644 --- a/src/main/rate-limits/zcode-usage-fetcher.ts +++ b/src/main/rate-limits/zcode-usage-fetcher.ts @@ -26,6 +26,14 @@ type ZcodeUsageCredentials = { authProvenance: string } +/** A GLM Coding Plan key saved through Orca's AI Provider Accounts; takes priority over the ZCode CLI config. */ +export type ZcodePlanCredential = { + apiKey: string + baseUrl: string +} + +export const ZCODE_PLAN_CREDENTIAL_SOURCE = 'orca-plan' + // Why readers and not casts: both JSON sources are outside our control — a user-edited // config file and a remote response — so their shape is a guess until something checks it. function isRecord(value: unknown): value is Record { @@ -123,6 +131,38 @@ function readCredentials(configPath: string): ZcodeUsageCredentials | null { } } +function readPlanCredentials(plan: ZcodePlanCredential): ZcodeUsageCredentials | null { + const apiKey = plan.apiKey.trim() + if (!apiKey || /[\r\n]/.test(apiKey)) { + return null + } + try { + const parsed = new URL(plan.baseUrl) + if ( + parsed.protocol !== 'https:' || + !SUPPORTED_HOSTS.has(parsed.hostname) || + (parsed.port !== '' && parsed.port !== '443') + ) { + return null + } + return { + apiKey, + quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`, + authProvenance: createHmac('sha256', CREDENTIAL_IDENTITY_KEY) + .update(JSON.stringify([ZCODE_PLAN_CREDENTIAL_SOURCE, parsed.origin, apiKey])) + .digest('hex') + } + } catch { + return null + } +} + +export function hasZcodeCliPlanCredentials( + configPath = join(homedir(), '.zcode', 'cli', 'config.json') +): boolean { + return readCredentials(configPath) !== null +} + function asNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } @@ -182,14 +222,19 @@ function asWindow(limit: QuotaLimit | undefined): RateLimitWindow | null { export async function fetchZcodeRateLimits( options: { configPath?: string + planCredential?: ZcodePlanCredential | null signal?: AbortSignal } = {} ): Promise { const configPath = options.configPath ?? join(homedir(), '.zcode', 'cli', 'config.json') - const credentials = readCredentials(configPath) + const planCredentials = options.planCredential + ? readPlanCredentials(options.planCredential) + : null + const credentials = planCredentials ?? readCredentials(configPath) if (!credentials) { return unavailable('ZCode Coding Plan credentials are not configured') } + const credentialSource = planCredentials ? ZCODE_PLAN_CREDENTIAL_SOURCE : configPath let response: Response try { @@ -270,7 +315,7 @@ export async function fetchZcodeRateLimits( status: 'ok', usageMetadata: { source: 'web', - credentialSource: configPath, + credentialSource, authProvenance: credentials.authProvenance } } diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index c932e59a904..051b1079a23 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -47,6 +47,7 @@ export type RuntimeClientSettings = Pick< | 'minimaxGroupId' | 'minimaxUsageModels' | 'minimaxEndpoint' + | 'zcodePlanSite' | 'prBotAuthorOverrides' | 'artifactSharingEnabled' | 'worktreeVisibilityDefaults' @@ -83,6 +84,7 @@ export type RuntimeClientSettingsUpdate = Pick< | 'minimaxGroupId' | 'minimaxUsageModels' | 'minimaxEndpoint' + | 'zcodePlanSite' | 'prBotAuthorOverrides' | 'worktreeVisibilityDefaults' | 'machineName' @@ -131,6 +133,7 @@ export class RuntimeClientSettingsController { minimaxGroupId: settings.minimaxGroupId ?? '', minimaxUsageModels: settings.minimaxUsageModels ?? 'general', minimaxEndpoint: settings.minimaxEndpoint ?? 'overseas', + zcodePlanSite: settings.zcodePlanSite ?? 'zai', prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [], artifactSharingEnabled: isArtifactSharingEnabled(settings), worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' }, diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 43fe59b34e1..a7f9af2a906 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -103,6 +103,7 @@ export type RuntimeStore = { minimaxGroupId?: GlobalSettings['minimaxGroupId'] minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] minimaxEndpoint?: GlobalSettings['minimaxEndpoint'] + zcodePlanSite?: GlobalSettings['zcodePlanSite'] prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides'] artifactSharingEnabled?: GlobalSettings['artifactSharingEnabled'] terminalQuickCommands?: GlobalSettings['terminalQuickCommands'] diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 7a677ed760e..a740a0b2efc 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -15,6 +15,7 @@ import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limi import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home' import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' import { readMiniMaxApiKey } from '../minimax/minimax-api-key-store' +import { readZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store' import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync' import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection' @@ -107,6 +108,17 @@ export function initializeMainProcessAccountServices(): void { ) }) } + // Why: the site picks the GLM Coding Plan quota host, so a stale snapshot from + // the previous site would otherwise sit in the status bar until the next poll. + if ('zcodePlanSite' in updates) { + state.rateLimits?.invalidateZcodeCredentialState() + void state.rateLimits?.refresh().catch((error: unknown) => { + console.warn( + '[rate-limits] Failed to refresh GLM Coding Plan usage after a settings change:', + error + ) + }) + } }) state.rateLimits.setClaudeAuthPreparationResolver((target) => state.claudeRuntimeAuth!.prepareForRateLimitFetch(target) @@ -134,6 +146,10 @@ export function initializeMainProcessAccountServices(): void { apiKey } }) + state.rateLimits.setZcodePlanConfigResolver(() => ({ + site: store.getSettings().zcodePlanSite, + apiKey: readZcodePlanApiKey() ?? '' + })) state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled) state.rateLimits.setNetworkProxySettingsResolver(() => store.getSettings()) state.keybindings = new KeybindingService({ diff --git a/src/main/zcode/zcode-plan-api-key-store.test.ts b/src/main/zcode/zcode-plan-api-key-store.test.ts new file mode 100644 index 00000000000..d583684c5fc --- /dev/null +++ b/src/main/zcode/zcode-plan-api-key-store.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ZcodePlanApiKeyStore from './zcode-plan-api-key-store' + +const safeStorageMock = vi.hoisted(() => ({ + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((value: string) => Buffer.from(value)), + decryptString: vi.fn((value: Buffer) => value.toString('utf8')) +})) + +const electronMock = vi.hoisted(() => ({ + safeStorage: safeStorageMock +})) + +vi.mock('electron', () => electronMock) + +const existsSyncMock = vi.fn() +const readFileSyncMock = vi.fn() +const rmSyncMock = vi.fn() +const hardenExistingSecureFileMock = vi.fn() +const writeSecureFileMock = vi.fn() +const homedirMock = vi.fn(() => '/home/test') + +vi.mock('node:fs', () => ({ + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + rmSync: rmSyncMock +})) + +vi.mock('node:os', () => ({ + homedir: homedirMock +})) + +vi.mock('node:path', () => ({ + join: (...parts: string[]) => parts.join('/') +})) + +vi.mock('../../shared/secure-file', () => ({ + hardenExistingSecureFile: hardenExistingSecureFileMock, + writeSecureFile: writeSecureFileMock +})) + +const storePath = '/home/test/.orca/zcode-plan-api-key.enc' +const envelope = (kind: 'encrypted' | 'plaintext', value: string): string => + `orca-zcode-plan-api-key:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}` + +async function loadStore(): Promise { + return await import('./zcode-plan-api-key-store') +} + +describe('zcode-plan-api-key-store', () => { + beforeEach(() => { + existsSyncMock.mockReset() + readFileSyncMock.mockReset() + rmSyncMock.mockReset() + hardenExistingSecureFileMock.mockReset() + writeSecureFileMock.mockReset() + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value)) + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8')) + homedirMock.mockReturnValue('/home/test') + vi.resetModules() + }) + + it('reports unconfigured while no key file exists', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + + expect(store.hasZcodePlanApiKey()).toBe(false) + expect(store.readZcodePlanApiKey()).toBeNull() + }) + + it('saves an encrypted envelope and reads it back through the cache', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + + store.saveZcodePlanApiKey(' glm-secret ') + + expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('encrypted', 'glm-secret')) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'glm-secret'))) + expect(store.hasZcodePlanApiKey()).toBe(true) + expect(store.readZcodePlanApiKey()).toBe('glm-secret') + }) + + it('warns and writes plaintext when safeStorage is unavailable', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + + store.saveZcodePlanApiKey('glm-secret') + + expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('plaintext', 'glm-secret')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable')) + warn.mockRestore() + }) + + it('reads a plaintext envelope back without requiring safeStorage', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', 'glm-secret'))) + const store = await loadStore() + + expect(store.readZcodePlanApiKey()).toBe('glm-secret') + expect(safeStorageMock.decryptString).not.toHaveBeenCalled() + }) + + it('rejects saving an empty key', async () => { + const store = await loadStore() + + expect(() => store.saveZcodePlanApiKey(' ')).toThrow('GLM Coding Plan API key is required') + expect(writeSecureFileMock).not.toHaveBeenCalled() + }) + + it('refuses to decrypt an encrypted envelope once safeStorage becomes unavailable', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'glm-secret'))) + const store = await loadStore() + + expect(() => store.readZcodePlanApiKey()).toThrow('could not be decrypted') + }) + + it('throws on an unreadable envelope instead of returning a partial key', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('not-an-envelope')) + const store = await loadStore() + + expect(() => store.readZcodePlanApiKey()).toThrow('could not be decrypted') + }) + + it('clearing removes the file and resets the cached value', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + store.saveZcodePlanApiKey('glm-secret') + + existsSyncMock.mockReturnValue(true) + store.clearZcodePlanApiKey() + + expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true }) + existsSyncMock.mockReturnValue(false) + expect(store.readZcodePlanApiKey()).toBeNull() + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) diff --git a/src/main/zcode/zcode-plan-api-key-store.ts b/src/main/zcode/zcode-plan-api-key-store.ts new file mode 100644 index 00000000000..3f37b52ca18 --- /dev/null +++ b/src/main/zcode/zcode-plan-api-key-store.ts @@ -0,0 +1,126 @@ +import { safeStorage } from 'electron' +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file' + +const ZCODE_PLAN_API_KEY_FILE = 'zcode-plan-api-key.enc' +const API_KEY_ENVELOPE_PREFIX = 'orca-zcode-plan-api-key:v1:' +let cachedZcodePlanApiKey: string | null = null +let warnedZcodePlanApiKeyStatusHardenFailure = false + +type ZcodePlanApiKeyEnvelope = { + kind: 'encrypted' | 'plaintext' + payload: Buffer +} + +function getZcodePlanApiKeyPath(): string { + return join(homedir(), '.orca', ZCODE_PLAN_API_KEY_FILE) +} + +function encodeApiKeyEnvelope(kind: ZcodePlanApiKeyEnvelope['kind'], payload: Buffer): string { + return `${API_KEY_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}` +} + +function decodeApiKeyEnvelope(raw: Buffer): ZcodePlanApiKeyEnvelope { + const text = raw.toString('utf8') + if (!text.startsWith(API_KEY_ENVELOPE_PREFIX)) { + throw new Error('GLM Coding Plan API key could not be decrypted') + } + const rest = text.slice(API_KEY_ENVELOPE_PREFIX.length) + const separator = rest.indexOf(':') + if (separator === -1) { + throw new Error('GLM Coding Plan API key could not be decrypted') + } + const kind = rest.slice(0, separator) + if (kind !== 'encrypted' && kind !== 'plaintext') { + throw new Error('GLM Coding Plan API key could not be decrypted') + } + return { + kind, + payload: Buffer.from(rest.slice(separator + 1), 'base64') + } +} + +function readEnvelope(envelope: ZcodePlanApiKeyEnvelope): string { + if (envelope.kind === 'plaintext') { + return envelope.payload.toString('utf8') + } + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('GLM Coding Plan API key could not be decrypted') + } + return safeStorage.decryptString(envelope.payload) +} + +export function hasZcodePlanApiKey(): boolean { + const keyPath = getZcodePlanApiKeyPath() + if (!existsSync(keyPath)) { + return false + } + try { + hardenExistingSecureFile(keyPath) + } catch (error) { + if (!warnedZcodePlanApiKeyStatusHardenFailure) { + warnedZcodePlanApiKeyStatusHardenFailure = true + console.warn( + '[zcode] Failed to harden GLM Coding Plan API key file while checking status', + error + ) + } + } + return true +} + +export function saveZcodePlanApiKey(key: string): void { + const trimmed = key.trim() + if (!trimmed) { + throw new Error('GLM Coding Plan API key is required') + } + if (safeStorage.isEncryptionAvailable()) { + writeSecureFile( + getZcodePlanApiKeyPath(), + encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed)) + ) + cachedZcodePlanApiKey = trimmed + return + } + console.warn( + '[zcode] safeStorage encryption unavailable — storing GLM Coding Plan API key in plaintext' + ) + writeSecureFile( + getZcodePlanApiKeyPath(), + encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')) + ) + cachedZcodePlanApiKey = trimmed +} + +export function readZcodePlanApiKey(): string | null { + if (cachedZcodePlanApiKey !== null) { + return cachedZcodePlanApiKey + } + const keyPath = getZcodePlanApiKeyPath() + if (!existsSync(keyPath)) { + return null + } + // Why: keep hardening out of the decode/decrypt try below so a chmod/ACL + // failure isn't misreported as a decrypt failure (matches hasZcodePlanApiKey). + try { + hardenExistingSecureFile(keyPath) + } catch (error) { + console.warn('[zcode] Failed to harden GLM Coding Plan API key file while reading', error) + } + try { + const raw = readFileSync(keyPath) + const envelope = decodeApiKeyEnvelope(raw) + cachedZcodePlanApiKey = readEnvelope(envelope) + return cachedZcodePlanApiKey + } catch (error) { + console.error('[zcode] failed to decode/decrypt GLM Coding Plan API key', error) + throw new Error('GLM Coding Plan API key could not be decrypted') + } +} + +export function clearZcodePlanApiKey(): void { + cachedZcodePlanApiKey = null + rmSync(getZcodePlanApiKeyPath(), { force: true }) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7e4b2def434..e7b44159db3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -4,7 +4,8 @@ import type { CodexConfigSyncApi, CursorAccountsApi, GrokAccountsApi, - MinimaxCredentialsApi + MinimaxCredentialsApi, + ZcodePlanCredentialsApi } from './api/agent-account-api' import type { HooksApi } from './api/agent-hook-api' import type { SkillsApi } from './api/agent-skill-api' @@ -143,6 +144,7 @@ export type PreloadApi = { runtimeEnvironments: RuntimeApi['runtimeEnvironments'] rateLimits: RateLimitsApi minimaxCredentials: MinimaxCredentialsApi + zcodePlanCredentials: ZcodePlanCredentialsApi grokAccounts: GrokAccountsApi cursorAccounts: CursorAccountsApi ssh: SshApi diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index e8d574c285b..46331570b34 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -81,6 +81,18 @@ export type MinimaxCredentialsApi = { clearApiKey: () => Promise<{ apiKeyConfigured: boolean }> } +export type ZcodePlanCredentialsApi = { + // Why: the GLM Coding Plan key lives in its own safeStorage file and the + // ZCode CLI's config is read-only, so the status reports both sources' + // presence; neither credential value ever crosses the IPC boundary. + getStatus: () => Promise<{ + apiKeyConfigured: boolean + zcodeCliConfigured: boolean + }> + saveApiKey: (key: string) => Promise<{ apiKeyConfigured: boolean }> + clearApiKey: () => Promise<{ apiKeyConfigured: boolean }> +} + export type CodexConfigSyncApi = { status: () => Promise } diff --git a/src/preload/api/zcode-plan-credentials-bridge.ts b/src/preload/api/zcode-plan-credentials-bridge.ts new file mode 100644 index 00000000000..e1ae42377c4 --- /dev/null +++ b/src/preload/api/zcode-plan-credentials-bridge.ts @@ -0,0 +1,13 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const zcodePlanCredentialsApi = { + getStatus: (): Promise<{ + apiKeyConfigured: boolean + zcodeCliConfigured: boolean + }> => ipcRenderer.invoke('zcodePlanCredentials:getStatus'), + saveApiKey: (key: string): Promise<{ apiKeyConfigured: boolean }> => + ipcRenderer.invoke('zcodePlanCredentials:saveApiKey', key), + clearApiKey: (): Promise<{ apiKeyConfigured: boolean }> => + ipcRenderer.invoke('zcodePlanCredentials:clearApiKey') +} satisfies PreloadApi['zcodePlanCredentials'] diff --git a/src/preload/index.ts b/src/preload/index.ts index 742a8168196..7848954a53e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -77,6 +77,7 @@ import { runtimeApi } from './api/runtime-bridge' import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge' import { rateLimitsApi } from './api/rate-limits-bridge' import { minimaxCredentialsApi } from './api/minimax-credentials-bridge' +import { zcodePlanCredentialsApi } from './api/zcode-plan-credentials-bridge' import { grokAccountsApi } from './api/grok-accounts-bridge' import { cursorAccountsApi } from './api/cursor-accounts-bridge' import { sshApi } from './api/ssh-bridge' @@ -177,6 +178,7 @@ const api = { runtimeEnvironments: runtimeEnvironmentsApi, rateLimits: rateLimitsApi, minimaxCredentials: minimaxCredentialsApi, + zcodePlanCredentials: zcodePlanCredentialsApi, grokAccounts: grokAccountsApi, cursorAccounts: cursorAccountsApi, ssh: sshApi, diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index a418aa9916b..d1ac2b7de57 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -23,7 +23,8 @@ import { getAccountsLocationSearchEntries, getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, - getAccountsPaneSearchEntries + getAccountsPaneSearchEntries, + getAccountsZcodePlanSearchEntries } from './accounts-search' import { getRemoteAccountsPaneScope } from './provider-account-scope' import { ProviderHostScopeControl } from './ProviderHostScopeControl' @@ -38,6 +39,7 @@ import { } from './provider-account-visibility' import { GrokAccountsSection } from './GrokAccountsSection' import { CursorAccountsSection } from './CursorAccountsSection' +import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection' import type { AccountsPaneProps, AccountsPaneSectionModel, @@ -384,6 +386,9 @@ export function AccountsPane({ ) : null, matchesSettingsSearch(searchQuery, getAccountsCursorSearchEntries()) ? ( + ) : null, + matchesSettingsSearch(searchQuery, getAccountsZcodePlanSearchEntries()) ? ( + ) : null ] diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx new file mode 100644 index 00000000000..a4ace53a24e --- /dev/null +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + saveApiKey: vi.fn(), + clearApiKey: vi.fn(), + refreshRateLimits: vi.fn(), + updateSettings: vi.fn(), + recordFeatureInteraction: vi.fn(), + zcodeUsage: vi.fn<() => unknown>(() => null), + settings: { + zcodePlanSite: 'zai' as 'zai' | 'bigmodel' + } +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => React.createElement('span', { 'data-testid': 'zcode-icon' }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + refreshRateLimits: mocks.refreshRateLimits, + updateSettings: mocks.updateSettings, + recordFeatureInteraction: mocks.recordFeatureInteraction, + settingsSearchQuery: '', + settings: mocks.settings, + rateLimits: { zcode: mocks.zcodeUsage() } + }) +})) + +import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection' + +describe('ZcodePlanAccountsSection', () => { + beforeEach(() => { + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: false }) + mocks.saveApiKey.mockResolvedValue({ apiKeyConfigured: true }) + mocks.clearApiKey.mockResolvedValue({ apiKeyConfigured: false }) + mocks.refreshRateLimits.mockResolvedValue(undefined) + mocks.updateSettings.mockResolvedValue(undefined) + mocks.recordFeatureInteraction.mockReset() + mocks.zcodeUsage.mockReturnValue(null) + mocks.settings.zcodePlanSite = 'zai' + Object.defineProperty(window, 'api', { + configurable: true, + value: { + zcodePlanCredentials: { + getStatus: mocks.getStatus, + saveApiKey: mocks.saveApiKey, + clearApiKey: mocks.clearApiKey + } + } + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('shows the unlinked state when neither an API key nor a CLI config exists', async () => { + render() + + expect(await screen.findByText('No GLM Coding Plan linked')).toBeInTheDocument() + expect(screen.queryByText('Using the ZCode CLI sign-in')).not.toBeInTheDocument() + }) + + it('explains the CLI fallback when only the ZCode CLI config exists', async () => { + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: true }) + + render() + + expect(await screen.findByText('Using the ZCode CLI sign-in')).toBeInTheDocument() + expect(screen.getByText(/~\/\.zcode\/cli\/config\.json/)).toBeInTheDocument() + }) + + it('saves a trimmed API key through the credential IPC', async () => { + render() + + const input = await screen.findByPlaceholderText('Paste your GLM Coding Plan API key') + fireEvent.change(input, { target: { value: ' glm-secret ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mocks.saveApiKey).toHaveBeenCalledWith('glm-secret') + }) + expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking') + await screen.findByText('Replace') + }) + + it('requires a non-empty key before saving', async () => { + render() + + const save = await screen.findByRole('button', { name: 'Save' }) + expect(save).toBeDisabled() + }) + + it('forgets a saved key through the credential IPC', async () => { + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: true }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Forget key' })) + + await waitFor(() => { + expect(mocks.clearApiKey).toHaveBeenCalledTimes(1) + }) + expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking') + }) + + it('labels the saved state with the selected site', async () => { + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false }) + mocks.settings.zcodePlanSite = 'bigmodel' + + render() + + expect( + await screen.findByText('API key saved · Zhipu · BigModel (open.bigmodel.cn)') + ).toBeInTheDocument() + }) + + it('renders the live quota windows for a linked plan', async () => { + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false }) + mocks.zcodeUsage.mockReturnValue({ + provider: 'zcode', + status: 'ok', + error: null, + planType: 'max', + session: { usedPercent: 42, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 73, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + monthly: null, + updatedAt: Date.now() + }) + + render() + + expect(await screen.findByText('42%')).toBeInTheDocument() + expect(screen.getByText('73%')).toBeInTheDocument() + expect(screen.getByText('Plan: max')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx new file mode 100644 index 00000000000..cf4ef049cc9 --- /dev/null +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx @@ -0,0 +1,410 @@ +import { useCallback, useEffect, useState } from 'react' +import { ExternalLink, Loader2, Lock, LockOpen, RefreshCw, ShieldCheck } from 'lucide-react' +import { toast } from 'sonner' +import { AgentIcon } from '@/lib/agent-catalog' +import { translate } from '@/i18n/i18n' +import { + ZCODE_PLAN_SITE_CONSOLE_URLS, + type ZcodePlanSite +} from '../../../../shared/zcode-plan-sites' +import { cn } from '@/lib/utils' +import { useAppStore } from '../../store' +import { useNow } from '../../hooks/use-now' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SearchableSetting } from './SearchableSetting' +import { collectZcodeUsageWindows, ZcodeUsageWindowView } from './zcode-plan-usage-windows' + +const SEARCH_KEYWORDS = [ + 'glm', + 'zai', + 'z.ai', + 'zhipu', + 'bigmodel', + 'coding plan', + 'usage', + 'rate limit', + 'zcode' +] + +type ZcodePlanStatus = { + apiKeyConfigured: boolean + zcodeCliConfigured: boolean +} + +function siteLabel(site: ZcodePlanSite): string { + if (site === 'bigmodel') { + return translate( + 'auto.components.settings.ZcodePlanAccountsSection.site.bigmodel', + 'Zhipu · BigModel (open.bigmodel.cn)' + ) + } + return translate('auto.components.settings.ZcodePlanAccountsSection.site.zai', 'Z.AI (z.ai)') +} + +export function ZcodePlanAccountsSection(): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const updateSettings = useAppStore((s) => s.updateSettings) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const refreshRateLimits = useAppStore((s) => s.refreshRateLimits) + const zcodeUsage = useAppStore((s) => s.rateLimits.zcode) + const [status, setStatus] = useState(null) + const [apiKeyDraft, setApiKeyDraft] = useState('') + const [credentialBusy, setCredentialBusy] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const now = useNow(60_000) + + const loadStatus = useCallback(async (): Promise => { + try { + setStatus(await window.api.zcodePlanCredentials.getStatus()) + } catch (error) { + console.error('Failed to load GLM Coding Plan credential status:', error) + setStatus({ apiKeyConfigured: false, zcodeCliConfigured: false }) + } + }, []) + + // Why: a usage refresh can change what the credential-status box should say + // (the CLI config appears or disappears), so reload after each snapshot. + useEffect(() => { + void loadStatus() + }, [loadStatus, zcodeUsage?.updatedAt]) + + const site = settings?.zcodePlanSite ?? 'zai' + const consoleUrl = ZCODE_PLAN_SITE_CONSOLE_URLS[site] + const apiKeyConfigured = status?.apiKeyConfigured === true + + const handleSiteChange = (value: string): void => { + if ((value !== 'zai' && value !== 'bigmodel') || value === site) { + return + } + recordFeatureInteraction('usage-tracking') + // Why: main invalidates and refreshes on this settings change, so no local refresh is needed. + void updateSettings({ zcodePlanSite: value }) + } + + const saveApiKey = async (): Promise => { + if (!apiKeyDraft.trim()) { + toast.error( + translate( + 'auto.components.settings.ZcodePlanAccountsSection.keyRequired', + 'GLM Coding Plan API key is required.' + ) + ) + return + } + setCredentialBusy(true) + try { + const next = await window.api.zcodePlanCredentials.saveApiKey(apiKeyDraft.trim()) + setStatus((previous) => ({ + apiKeyConfigured: next.apiKeyConfigured, + zcodeCliConfigured: previous?.zcodeCliConfigured ?? false + })) + setApiKeyDraft('') + recordFeatureInteraction('usage-tracking') + toast.success( + translate( + 'auto.components.settings.ZcodePlanAccountsSection.keySaved', + 'GLM Coding Plan API key saved.' + ) + ) + } catch (error) { + toast.error( + translate( + 'auto.components.settings.ZcodePlanAccountsSection.keySaveFailed', + 'GLM Coding Plan credential update failed.' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + } finally { + setCredentialBusy(false) + } + } + + const clearApiKey = async (): Promise => { + setCredentialBusy(true) + try { + const next = await window.api.zcodePlanCredentials.clearApiKey() + setStatus((previous) => ({ + apiKeyConfigured: next.apiKeyConfigured, + zcodeCliConfigured: previous?.zcodeCliConfigured ?? false + })) + setApiKeyDraft('') + recordFeatureInteraction('usage-tracking') + } catch (error) { + toast.error( + translate( + 'auto.components.settings.ZcodePlanAccountsSection.keySaveFailed', + 'GLM Coding Plan credential update failed.' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + } finally { + setCredentialBusy(false) + } + } + + const handleRefreshUsage = async (): Promise => { + setRefreshing(true) + try { + await refreshRateLimits() + } finally { + setRefreshing(false) + } + } + + const usage = zcodeUsage ?? null + const usageWindows = collectZcodeUsageWindows(usage) + const staleUsageError = usage?.status === 'error' ? (usage.error ?? null) : null + + return ( +
+
+
+

+ + {translate( + 'auto.components.settings.ZcodePlanAccountsSection.title', + 'GLM Coding Plan' + )} +

+

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.subtitle', + 'Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage in the status bar. Save the plan API key here — no ZCode CLI setup needed.' + )} +

+
+ + {translate( + 'auto.components.settings.ZcodePlanAccountsSection.consoleLink', + 'Get API key' + )} + + +
+ +
+ +
+ {apiKeyConfigured ? ( + <> +

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.keyStored', + 'API key saved · {{value0}}', + { value0: siteLabel(site) } + )} +

+

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.keyStoredHelp', + 'Stored locally and sent only to the selected site for usage refreshes. It takes priority over the ZCode CLI sign-in.' + )} +

+ + ) : status?.zcodeCliConfigured ? ( + <> +

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.usingCli', + 'Using the ZCode CLI sign-in' + )} +

+

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.usingCliHelp', + 'Orca reads the Coding Plan key from ~/.zcode/cli/config.json. Save an API key below to link the plan here instead.' + )} +

+ + ) : ( + <> +

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.notConfigured', + 'No GLM Coding Plan linked' + )} +

+

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.notConfiguredHelp', + 'Save the plan API key below, or sign in with the ZCode CLI on this computer.' + )} +

+ + )} + {staleUsageError ?

{staleUsageError}

: null} +
+ +
+ + + + + + + +
+
+ + + {apiKeyConfigured ? : } + {apiKeyConfigured + ? translate('auto.components.settings.ZcodePlanAccountsSection.saved', 'Saved') + : translate( + 'auto.components.settings.ZcodePlanAccountsSection.notSaved', + 'Not saved' + )} + +
+
+
+ setApiKeyDraft(e.target.value)} + placeholder={translate( + 'auto.components.settings.ZcodePlanAccountsSection.keyPlaceholder', + 'Paste your GLM Coding Plan API key' + )} + spellCheck={false} + className="flex-1 text-xs" + /> + + {apiKeyConfigured ? ( + + ) : null} +
+

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.keyHelp', + 'The same key your coding tools use for the plan (for example Claude Code with ANTHROPIC_BASE_URL pointed at the site). Switching the site above changes which host receives it.' + )} +

+
+ + {usageWindows.length > 0 ? ( + +
+ {usageWindows.map((row) => ( + + ))} + {usage?.planType ? ( +

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.planLevel', + 'Plan: {{value0}}', + { value0: usage.planType } + )} +

+ ) : null} +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 60fa795caa9..405f5119613 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -249,6 +249,36 @@ export const getAccountsCursorSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getAccountsZcodePlanSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.accounts.search.zcodePlan.title', 'GLM Coding Plan'), + description: translate( + 'auto.components.settings.accounts.search.zcodePlan.description', + 'Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage. Pick the site and save the plan API key.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.zcodePlan.kw.glm', 'glm'), + ...translateSearchKeyword('auto.components.settings.accounts.search.zcodePlan.kw.zai', 'zai'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.zcodePlan.kw.zhipu', + 'zhipu' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.zcodePlan.kw.bigmodel', + 'bigmodel' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.zcodePlan.kw.codingPlan', + 'coding plan' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.zcodePlan.kw.rateLimit', + 'rate limit' + ) + ] + } +]) + export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ ...getAccountsLocationSearchEntries(), ...getAccountsClaudeSearchEntries(), @@ -257,5 +287,6 @@ export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsS ...getAccountsOpencodeSearchEntries(), ...getAccountsMiniMaxSearchEntries(), ...getAccountsGrokSearchEntries(), - ...getAccountsCursorSearchEntries() + ...getAccountsCursorSearchEntries(), + ...getAccountsZcodePlanSearchEntries() ]) diff --git a/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx b/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx new file mode 100644 index 00000000000..e56c8a875bd --- /dev/null +++ b/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx @@ -0,0 +1,72 @@ +import { translate } from '@/i18n/i18n' +import { formatResetCountdown } from '../../../../shared/rate-limit-reset-format' +import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' +import { Badge } from '../ui/badge' + +export type ZcodeUsageWindowKind = 'session' | 'weekly' | 'monthly' + +export type ZcodeUsageWindowRow = { + kind: ZcodeUsageWindowKind + window: RateLimitWindow +} + +function windowLabel(kind: ZcodeUsageWindowKind): string { + if (kind === 'session') { + return translate('auto.components.settings.ZcodePlanAccountsSection.window.session', '5 hours') + } + if (kind === 'weekly') { + return translate('auto.components.settings.ZcodePlanAccountsSection.window.weekly', 'Weekly') + } + return translate('auto.components.settings.ZcodePlanAccountsSection.window.mcp', 'MCP monthly') +} + +function formatWindowReset(window: RateLimitWindow, now: number): string | null { + if (!window.resetsAt) { + return null + } + const remaining = window.resetsAt - now + return remaining > 0 ? formatResetCountdown(remaining) : null +} + +// Why: a window only renders when its data survived the fetcher's mapping, so +// error snapshots and MCP-less plans show exactly the windows they reported. +export function collectZcodeUsageWindows(usage: ProviderRateLimits | null): ZcodeUsageWindowRow[] { + const rows: ZcodeUsageWindowRow[] = [] + if (usage?.session) { + rows.push({ kind: 'session', window: usage.session }) + } + if (usage?.weekly) { + rows.push({ kind: 'weekly', window: usage.weekly }) + } + if (usage?.monthly) { + rows.push({ kind: 'monthly', window: usage.monthly }) + } + return rows +} + +export function ZcodeUsageWindowView({ + row, + now +}: { + row: ZcodeUsageWindowRow + now: number +}): React.JSX.Element { + const resetLabel = formatWindowReset(row.window, now) + return ( +
+ + {Math.round(row.window.usedPercent)}% + + + {windowLabel(row.kind)} + {resetLabel + ? translate( + 'auto.components.settings.ZcodePlanAccountsSection.resetIn', + ' — resets in {{value0}}', + { value0: resetLabel } + ) + : ''} + +
+ ) +} diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index 987bb91c040..7d56063d900 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -78,6 +78,7 @@ function usageSettings(overrides: Partial = {}): UsagePro opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, cursorAuthConfigured: false, + zcodePlanApiKeyConfigured: false, ...overrides } } diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 24392aa48ab..72e2b500096 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -17,6 +17,9 @@ export type UsageProviderSettings = Pick< // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean minimaxApiKeyConfigured: boolean + // Why: the GLM Coding Plan key lives in its own safeStorage file; main + // reports presence so the ZCode bar survives reloads before the first poll. + zcodePlanApiKeyConfigured: boolean // Why: the OpenCode Go key can live in OPENCODE_API_KEY or in OpenCode's own // store, neither of which the renderer can see; main reports presence. opencodeGoApiKeyConfigured: boolean @@ -87,7 +90,8 @@ export function hasUsageProviderSettings( settings?.minimaxCookieConfigured === true || settings?.minimaxApiKeyConfigured === true || settings?.grokAuthConfigured === true || - settings?.cursorAuthConfigured === true + settings?.cursorAuthConfigured === true || + settings?.zcodePlanApiKeyConfigured === true ) } @@ -128,6 +132,9 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'cursor') { return settings.cursorAuthConfigured === true } + if (providerId === 'zcode') { + return settings.zcodePlanApiKeyConfigured === true + } return false } diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index b531bffd161..6da92332d44 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -21,9 +21,9 @@ export function getUsageProviderAccountsSectionId( case 'cursor': return 'accounts-cursor' case 'kimi': - case 'zcode': // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. - // ZCode likewise owns its Coding Plan credential in ~/.zcode/cli/config.json. return null + case 'zcode': + return 'accounts-zcode' } } diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index 8ec0bcfce43..bd7b9aa4258 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -108,7 +108,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { minimaxApiKeyConfigured: rateLimits.minimaxApiKeyConfigured, opencodeGoApiKeyConfigured: rateLimits.opencodeGoApiKeyConfigured, grokAuthConfigured: rateLimits.grokAuthConfigured, - cursorAuthConfigured: rateLimits.cursorAuthConfigured + cursorAuthConfigured: rateLimits.cursorAuthConfigured, + zcodePlanApiKeyConfigured: rateLimits.zcodePlanApiKeyConfigured } const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) @@ -148,10 +149,12 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { // Why: a Cursor session can come from the IDE alone, so PATH detection of // cursor-agent would hide a real meter from IDE-only users. const showCursor = visibleCursor !== null && statusBarItems.includes('cursor') + // Why: a saved Coding Plan key is site-auth, not a CLI on PATH — a subscriber + // without the ZCode CLI must still earn the meter (same exemption as MiniMax/Cursor). const showZcode = visibleZcode !== null && statusBarItems.includes('zcode') && - isStatusBarItemAvailable('zcode', detectedAgentIds) + (rateLimits.zcodePlanApiKeyConfigured || isStatusBarItemAvailable('zcode', detectedAgentIds)) // Why: OpenCode Go is web/cookie-auth, not a CLI on PATH, so detection-gating doesn't apply. const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings) const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cbbba795d59..bc4af5edc5d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3278,7 +3278,7 @@ "missing": { "tui": { "title": "This ZCode build has no terminal UI", - "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + "description": "Orca's hooks installed correctly — the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." } } }, @@ -3977,7 +3977,9 @@ } }, "tooltip": { - "zcode": { "mcp": "MCP" }, + "zcode": { + "mcp": "MCP" + }, "cedb7b99e3": "% used", "6d6df77f41": "No data available", "7f7f208060": "Monthly", @@ -9119,6 +9121,18 @@ "rateLimit": "rate limit", "statusBar": "status bar" } + }, + "zcodePlan": { + "title": "GLM Coding Plan", + "description": "Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage. Pick the site and save the plan API key.", + "kw": { + "glm": "glm", + "zai": "zai", + "zhipu": "zhipu", + "bigmodel": "bigmodel", + "codingPlan": "coding plan", + "rateLimit": "rate limit" + } } } }, @@ -12223,6 +12237,45 @@ "usageLabel": "Usage", "noAllowance": "Cursor reported no usage allowance for this account.", "staleUsage": "Last known usage — the latest refresh failed: {{reason}}" + }, + "ZcodePlanAccountsSection": { + "site": { + "bigmodel": "Zhipu · BigModel (open.bigmodel.cn)", + "zai": "Z.AI (z.ai)" + }, + "window": { + "session": "5 hours", + "weekly": "Weekly", + "mcp": "MCP monthly" + }, + "resetIn": " — resets in {{value0}}", + "keyRequired": "GLM Coding Plan API key is required.", + "keySaved": "GLM Coding Plan API key saved.", + "keySaveFailed": "GLM Coding Plan credential update failed.", + "title": "GLM Coding Plan", + "subtitle": "Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage in the status bar. Save the plan API key here — no ZCode CLI setup needed.", + "consoleLink": "Get API key", + "keyStored": "API key saved · {{value0}}", + "keyStoredHelp": "Stored locally and sent only to the selected site for usage refreshes. It takes priority over the ZCode CLI sign-in.", + "usingCli": "Using the ZCode CLI sign-in", + "usingCliHelp": "Orca reads the Coding Plan key from ~/.zcode/cli/config.json. Save an API key below to link the plan here instead.", + "notConfigured": "No GLM Coding Plan linked", + "notConfiguredHelp": "Save the plan API key below, or sign in with the ZCode CLI on this computer.", + "refreshUsage": "Refresh usage", + "siteTitle": "Plan site", + "siteDescription": "Pick the console your Coding Plan belongs to: Z.AI for the international site, Zhipu BigModel for the mainland site.", + "keyTitle": "API key", + "keyDescription": "Paste the API key from the selected console’s API Keys page. Stored locally, encrypted when the OS supports it, and sent only to that site for usage refreshes.", + "saved": "Saved", + "notSaved": "Not saved", + "keyPlaceholder": "Paste your GLM Coding Plan API key", + "replace": "Replace", + "save": "Save", + "forgetKey": "Forget key", + "keyHelp": "The same key your coding tools use for the plan (for example Claude Code with ANTHROPIC_BASE_URL pointed at the site). Switching the site above changes which host receives it.", + "usageTitle": "Plan usage", + "usageDescription": "Live quota windows for the linked Coding Plan, refreshed with the status bar usage cycle.", + "planLevel": "Plan: {{value0}}" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 47fefd2f0e3..2e51c484393 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8895,6 +8895,18 @@ "usage": "用量" }, "title": "Cursor 用量" + }, + "zcodePlan": { + "title": "GLM Coding Plan 用量", + "description": "追踪 Z.AI 或智谱(BigModel)GLM Coding Plan 的用量。选择站点并保存套餐 API key。", + "kw": { + "glm": "glm", + "zai": "zai", + "zhipu": "zhipu", + "bigmodel": "bigmodel", + "codingPlan": "coding plan", + "rateLimit": "rate limit" + } } } }, @@ -12056,6 +12068,45 @@ "usageDescription": "Cursor 按两个随账单周期重置的额度池计费,用完后按需计费。", "usageLabel": "用量", "usageTitle": "每月套餐用量" + }, + "ZcodePlanAccountsSection": { + "site": { + "bigmodel": "智谱 · BigModel(open.bigmodel.cn)", + "zai": "Z.AI(z.ai)" + }, + "window": { + "session": "5 小时", + "weekly": "每周", + "mcp": "MCP 月度" + }, + "resetIn": " — {{value0}}后重置", + "keyRequired": "请填写 GLM Coding Plan API key。", + "keySaved": "GLM Coding Plan API key 已保存。", + "keySaveFailed": "GLM Coding Plan 凭据更新失败。", + "title": "GLM Coding Plan", + "subtitle": "在状态栏追踪 Z.AI 或智谱(BigModel)GLM Coding Plan 用量。在此保存套餐 API key,无需安装 ZCode CLI。", + "consoleLink": "获取 API key", + "keyStored": "API key 已保存 · {{value0}}", + "keyStoredHelp": "仅保存在本地,并只发送到所选站点用于用量刷新。优先级高于 ZCode CLI 登录。", + "usingCli": "正在使用 ZCode CLI 登录", + "usingCliHelp": "Orca 从 ~/.zcode/cli/config.json 读取 Coding Plan key。在下方保存 API key 即可改为在此关联套餐。", + "notConfigured": "尚未关联 GLM Coding Plan", + "notConfiguredHelp": "在下方保存套餐 API key,或在这台电脑上通过 ZCode CLI 登录。", + "refreshUsage": "刷新用量", + "siteTitle": "套餐站点", + "siteDescription": "选择你的 Coding Plan 所属控制台:国际站选 Z.AI,国内站选智谱 BigModel。", + "keyTitle": "API key", + "keyDescription": "粘贴所选控制台 API Keys 页面中的 API key。保存在本地,系统支持时加密存储,并只发送到该站点用于用量刷新。", + "saved": "已保存", + "notSaved": "未保存", + "keyPlaceholder": "粘贴你的 GLM Coding Plan API key", + "replace": "替换", + "save": "保存", + "forgetKey": "清除 key", + "keyHelp": "与编码工具使用同一把套餐 key(例如 Claude Code 将 ANTHROPIC_BASE_URL 指向对应站点)。切换上方站点会变更接收该 key 的主机。", + "usageTitle": "套餐用量", + "usageDescription": "已关联 Coding Plan 的实时配额窗口,随状态栏用量周期刷新。", + "planLevel": "套餐:{{value0}}" } }, "right": { diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 2fd16df1c4f..e5ec7bed28e 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -214,6 +214,7 @@ export function buildDefaultSettings(args: { minimaxGroupId: '', minimaxUsageModels: 'general', minimaxEndpoint: 'overseas', + zcodePlanSite: 'zai', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 1e0410f4c22..b1f2af1d137 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -26,6 +26,7 @@ import type { CtrlTabOrderMode } from './tab-types' import type { TerminalColorOverrides } from './terminal-color-overrides' import type { TerminalQuickCommand } from './terminal-quick-command-types' import type { TuiAgent } from './tui-agent' +import type { ZcodePlanSite } from './zcode-plan-sites' import type { AgentDashboardMode, BranchPrefixStrategy, @@ -397,6 +398,8 @@ export type GlobalSettings = { minimaxUsageModels: string /** MiniMax account region; defaults to overseas for existing users. */ minimaxEndpoint: MiniMaxEndpoint + /** GLM Coding Plan site whose API key is saved in AI Provider Accounts; defaults to the international Z.AI console. */ + zcodePlanSite: ZcodePlanSite /** Extract OAuth credentials from the local Gemini CLI for rate-limit fetching. Off by default (explicit opt-in). */ geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts index 494f56e4dff..b9af7fe1b68 100644 --- a/src/shared/rate-limit-state-factory.ts +++ b/src/shared/rate-limit-state-factory.ts @@ -18,6 +18,7 @@ export function createEmptyRateLimitState(overrides: Partial = { opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, cursorAuthConfigured: false, + zcodePlanApiKeyConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index 556af578565..3e10a7c9e79 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -23,6 +23,7 @@ describe('RateLimitState', () => { opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, cursorAuthConfigured: false, + zcodePlanApiKeyConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 6b06f09d10e..e18ca57288f 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -170,6 +170,12 @@ export type RateLimitState = { * stored login. The token itself never leaves main. */ cursorAuthConfigured: boolean + /** + * True when a GLM Coding Plan API key is saved in Orca's AI Provider + * Accounts. The key itself never leaves main; the status bar uses this to + * keep the ZCode bar visible across reloads between snapshot refreshes. + */ + zcodePlanApiKeyConfigured: boolean claudeTarget: RateLimitRuntimeTarget codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[] diff --git a/src/shared/rpc-contract/client-settings-params.ts b/src/shared/rpc-contract/client-settings-params.ts index fd0c2f986a9..6574c89a8bb 100644 --- a/src/shared/rpc-contract/client-settings-params.ts +++ b/src/shared/rpc-contract/client-settings-params.ts @@ -115,6 +115,7 @@ export const SettingsUpdate = z minimaxGroupId: z.string().optional(), minimaxUsageModels: z.string().optional(), minimaxEndpoint: z.enum(['overseas', 'cn']).optional(), + zcodePlanSite: z.enum(['zai', 'bigmodel']).optional(), githubProjects: GitHubProjectSettings.optional(), prBotAuthorOverrides: z .unknown() diff --git a/src/shared/zcode-plan-sites.ts b/src/shared/zcode-plan-sites.ts new file mode 100644 index 00000000000..0860d1de4cf --- /dev/null +++ b/src/shared/zcode-plan-sites.ts @@ -0,0 +1,23 @@ +/** + * GLM Coding Plan site table shared by the zcode credential store, the usage + * fetcher, and the Accounts settings section. The provider id stays `zcode`; + * the two sites are the international Z.AI console and Zhipu's mainland + * BigModel platform. + */ +export type ZcodePlanSite = 'zai' | 'bigmodel' + +export const ZCODE_PLAN_SITES: readonly ZcodePlanSite[] = ['zai', 'bigmodel'] + +export function isZcodePlanSite(value: unknown): value is ZcodePlanSite { + return value === 'zai' || value === 'bigmodel' +} + +export const ZCODE_PLAN_SITE_BASE_URLS: Record = { + zai: 'https://api.z.ai', + bigmodel: 'https://open.bigmodel.cn' +} + +export const ZCODE_PLAN_SITE_CONSOLE_URLS: Record = { + zai: 'https://z.ai/manage-apikey', + bigmodel: 'https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys' +} From e3b970bcc7886bbade35f1700d94c6ed3f61cbc0 Mon Sep 17 00:00:00 2001 From: Luchong Date: Tue, 29 Sep 2026 14:27:46 +0800 Subject: [PATCH 2/3] fix(usage): address review on GLM Coding Plan linking - Reject keys with interior newlines at save time and surface an unusable saved key as its own error instead of silently switching to the ZCode CLI config's account (greptile P1). - Refuse to keep an unrestricted plaintext key when file hardening fails (greptile P1, security). - Include the resolver error in the zcode config hash, mirroring MiniMax (CodeRabbit). - Render the reset countdown from the bare duration so the copy reads 'resets in 47m' once, not 'resets in Resets in 47m' (Pullfrog); covered by a new component test. --- .../service/service-full-cycle-preparation.ts | 2 +- .../rate-limits/zcode-usage-fetcher.test.ts | 23 ++++++---------- src/main/rate-limits/zcode-usage-fetcher.ts | 5 ++++ .../zcode/zcode-plan-api-key-store.test.ts | 22 +++++++++++++++ src/main/zcode/zcode-plan-api-key-store.ts | 11 +++++++- .../ZcodePlanAccountsSection.test.tsx | 27 +++++++++++++++++++ .../settings/zcode-plan-usage-windows.tsx | 4 +-- 7 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 416143d3afb..4ebe8fed54c 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -122,7 +122,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ // Why digest, not the key: this string only has to change when the credential does. const currentZcodeConfigHash = zcodePlanApiKey ? `${zcodePlanConfigResult.config.site}|${createHash('sha256').update(zcodePlanApiKey).digest('hex')}` - : '' + : (zcodePlanConfigResult.error ?? '') const zcodeConfigChanged = currentZcodeConfigHash !== this.lastZcodeConfigHash if (zcodeConfigChanged) { this.lastZcodeConfigHash = currentZcodeConfigHash diff --git a/src/main/rate-limits/zcode-usage-fetcher.test.ts b/src/main/rate-limits/zcode-usage-fetcher.test.ts index 7c293b628a4..bb1a8022162 100644 --- a/src/main/rate-limits/zcode-usage-fetcher.test.ts +++ b/src/main/rate-limits/zcode-usage-fetcher.test.ts @@ -350,31 +350,24 @@ describe('fetchZcodeRateLimits', () => { planCredential: { apiKey: 'key', baseUrl: 'https://evil.example.com' } }) - expect(result.status).toBe('unavailable') + expect(result.status).toBe('error') + expect(result.error).toBe('The saved GLM Coding Plan API key is unusable') expect(fetch).not.toHaveBeenCalled() }) - it('falls back to the CLI config when the plan credential is malformed', async () => { + it('reports an unusable plan credential instead of switching to the CLI config', async () => { writeConfig() - vi.mocked(fetch).mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - data: { limits: [{ type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 10 }] } - }) - ) - ) + vi.mocked(fetch).mockResolvedValue(new Response('{}')) const result = await fetchZcodeRateLimits({ configPath, planCredential: { apiKey: 'bad\r\nkey', baseUrl: 'https://api.z.ai' } }) - expect(result.status).toBe('ok') - expect(String(vi.mocked(fetch).mock.calls[0][0])).toBe( - 'https://open.bigmodel.cn/api/monitor/usage/quota/limit' - ) - expect(result.usageMetadata?.credentialSource).toBe(configPath) + expect(result.status).toBe('error') + expect(result.error).toBe('The saved GLM Coding Plan API key is unusable') + expect(fetch).not.toHaveBeenCalled() + expect(result.usageMetadata?.credentialSource).toBeUndefined() }) }) diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts index 878ed9023b3..dcc7671ba5d 100644 --- a/src/main/rate-limits/zcode-usage-fetcher.ts +++ b/src/main/rate-limits/zcode-usage-fetcher.ts @@ -230,6 +230,11 @@ export async function fetchZcodeRateLimits( const planCredentials = options.planCredential ? readPlanCredentials(options.planCredential) : null + if (!planCredentials && options.planCredential) { + // Why: a saved-but-unusable key must surface as its own error; silently + // falling back to the CLI config would show a different account's quota. + return failed('The saved GLM Coding Plan API key is unusable', 'parse', '') + } const credentials = planCredentials ?? readCredentials(configPath) if (!credentials) { return unavailable('ZCode Coding Plan credentials are not configured') diff --git a/src/main/zcode/zcode-plan-api-key-store.test.ts b/src/main/zcode/zcode-plan-api-key-store.test.ts index d583684c5fc..f9bd8184e01 100644 --- a/src/main/zcode/zcode-plan-api-key-store.test.ts +++ b/src/main/zcode/zcode-plan-api-key-store.test.ts @@ -54,6 +54,7 @@ describe('zcode-plan-api-key-store', () => { rmSyncMock.mockReset() hardenExistingSecureFileMock.mockReset() writeSecureFileMock.mockReset() + writeSecureFileMock.mockReturnValue(true) safeStorageMock.isEncryptionAvailable.mockReset() safeStorageMock.encryptString.mockReset() safeStorageMock.decryptString.mockReset() @@ -115,6 +116,27 @@ describe('zcode-plan-api-key-store', () => { expect(writeSecureFileMock).not.toHaveBeenCalled() }) + it('rejects a key with an interior newline instead of saving it', async () => { + const store = await loadStore() + + expect(() => store.saveZcodePlanApiKey('glm\r\nsecret')).toThrow('must be a single line') + expect(writeSecureFileMock).not.toHaveBeenCalled() + }) + + it('refuses to keep an unrestricted plaintext key when hardening fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + writeSecureFileMock.mockReturnValue(false) + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + + expect(() => store.saveZcodePlanApiKey('glm-secret')).toThrow( + 'could not be stored securely on this device' + ) + expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true }) + warn.mockRestore() + }) + it('refuses to decrypt an encrypted envelope once safeStorage becomes unavailable', async () => { safeStorageMock.isEncryptionAvailable.mockReturnValue(false) existsSyncMock.mockReturnValue(true) diff --git a/src/main/zcode/zcode-plan-api-key-store.ts b/src/main/zcode/zcode-plan-api-key-store.ts index 3f37b52ca18..3724b9a0a31 100644 --- a/src/main/zcode/zcode-plan-api-key-store.ts +++ b/src/main/zcode/zcode-plan-api-key-store.ts @@ -76,6 +76,9 @@ export function saveZcodePlanApiKey(key: string): void { if (!trimmed) { throw new Error('GLM Coding Plan API key is required') } + if (/[\r\n]/.test(trimmed)) { + throw new Error('GLM Coding Plan API key must be a single line') + } if (safeStorage.isEncryptionAvailable()) { writeSecureFile( getZcodePlanApiKeyPath(), @@ -87,10 +90,16 @@ export function saveZcodePlanApiKey(key: string): void { console.warn( '[zcode] safeStorage encryption unavailable — storing GLM Coding Plan API key in plaintext' ) - writeSecureFile( + const wroteRestricted = writeSecureFile( getZcodePlanApiKeyPath(), encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')) ) + // Why: an unrestricted plaintext credential must never be reported as saved; + // writeSecureFile has already published the file by the time it returns false. + if (!wroteRestricted) { + rmSync(getZcodePlanApiKeyPath(), { force: true }) + throw new Error('GLM Coding Plan API key could not be stored securely on this device') + } cachedZcodePlanApiKey = trimmed } diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx index a4ace53a24e..1f856d297ff 100644 --- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx @@ -154,4 +154,31 @@ describe('ZcodePlanAccountsSection', () => { expect(screen.getByText('73%')).toBeInTheDocument() expect(screen.getByText('Plan: max')).toBeInTheDocument() }) + + it('renders the reset countdown once, not doubled', async () => { + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false }) + mocks.zcodeUsage.mockReturnValue({ + provider: 'zcode', + status: 'ok', + error: null, + planType: null, + // Why 47.5 minutes: the floor survives a minute-boundary crossing between + // mock setup and render, so the assertion stays deterministic. + session: { + usedPercent: 42, + windowMinutes: 300, + resetsAt: Date.now() + 47 * 60_000 + 30_000, + resetDescription: null + }, + weekly: null, + monthly: null, + updatedAt: Date.now() + }) + + render() + + expect(await screen.findByText('42%')).toBeInTheDocument() + expect(screen.getByText(/5 hours — resets in 47m/)).toBeInTheDocument() + expect(screen.queryByText(/Resets in Resets/)).not.toBeInTheDocument() + }) }) diff --git a/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx b/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx index e56c8a875bd..6701ee44ada 100644 --- a/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx +++ b/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx @@ -1,5 +1,5 @@ import { translate } from '@/i18n/i18n' -import { formatResetCountdown } from '../../../../shared/rate-limit-reset-format' +import { formatResetDuration } from '../../../../shared/rate-limit-reset-format' import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' import { Badge } from '../ui/badge' @@ -25,7 +25,7 @@ function formatWindowReset(window: RateLimitWindow, now: number): string | null return null } const remaining = window.resetsAt - now - return remaining > 0 ? formatResetCountdown(remaining) : null + return remaining > 0 ? formatResetDuration(remaining) : null } // Why: a window only renders when its data survived the fetcher's mapping, so From d2f13d2596b0a62eb6cb24c682da1b5f5c67e6d1 Mon Sep 17 00:00:00 2001 From: Luchong Date: Tue, 29 Sep 2026 14:39:31 +0800 Subject: [PATCH 3/3] fix(usage): restore the previous key when a plaintext replacement fails writeSecureFile has already replaced the credential file when it reports that restriction failed, so deleting the result also destroyed the user's previous working key (greptile P1, CodeRabbit major). Capture the previous envelope before the write and restore it on failure; only a first-time key with nothing to preserve is removed. --- .../zcode/zcode-plan-api-key-store.test.ts | 20 +++++++++++++ src/main/zcode/zcode-plan-api-key-store.ts | 29 ++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/main/zcode/zcode-plan-api-key-store.test.ts b/src/main/zcode/zcode-plan-api-key-store.test.ts index f9bd8184e01..94b23c4cb8c 100644 --- a/src/main/zcode/zcode-plan-api-key-store.test.ts +++ b/src/main/zcode/zcode-plan-api-key-store.test.ts @@ -137,6 +137,26 @@ describe('zcode-plan-api-key-store', () => { warn.mockRestore() }) + it('restores the previous envelope when a plaintext replacement cannot be restricted', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + const previous = Buffer.from(envelope('encrypted', 'old-key')) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(previous) + writeSecureFileMock.mockReturnValueOnce(false).mockReturnValueOnce(true) + const store = await loadStore() + + expect(() => store.saveZcodePlanApiKey('new-key')).toThrow( + 'could not be stored securely on this device' + ) + // First write publishes the unrestricted replacement; the second restores + // the previous envelope so the user's old key survives the failed replace. + expect(writeSecureFileMock).toHaveBeenCalledTimes(2) + expect(writeSecureFileMock).toHaveBeenLastCalledWith(storePath, previous.toString('utf8')) + expect(rmSyncMock).not.toHaveBeenCalledWith(storePath, expect.anything()) + warn.mockRestore() + }) + it('refuses to decrypt an encrypted envelope once safeStorage becomes unavailable', async () => { safeStorageMock.isEncryptionAvailable.mockReturnValue(false) existsSyncMock.mockReturnValue(true) diff --git a/src/main/zcode/zcode-plan-api-key-store.ts b/src/main/zcode/zcode-plan-api-key-store.ts index 3724b9a0a31..5652d8907e5 100644 --- a/src/main/zcode/zcode-plan-api-key-store.ts +++ b/src/main/zcode/zcode-plan-api-key-store.ts @@ -90,14 +90,35 @@ export function saveZcodePlanApiKey(key: string): void { console.warn( '[zcode] safeStorage encryption unavailable — storing GLM Coding Plan API key in plaintext' ) + const keyPath = getZcodePlanApiKeyPath() + // Why: capture the previous envelope — writeSecureFile has already replaced + // the file by the time it reports that restriction failed, and deleting the + // result must not take the user's previous working key with it. + let previousEnvelope: Buffer | null = null + if (existsSync(keyPath)) { + try { + previousEnvelope = readFileSync(keyPath) + } catch { + previousEnvelope = null + } + } const wroteRestricted = writeSecureFile( - getZcodePlanApiKeyPath(), + keyPath, encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')) ) - // Why: an unrestricted plaintext credential must never be reported as saved; - // writeSecureFile has already published the file by the time it returns false. + // Why: an unrestricted plaintext credential must never be reported as saved. if (!wroteRestricted) { - rmSync(getZcodePlanApiKeyPath(), { force: true }) + if (!previousEnvelope) { + rmSync(keyPath, { force: true }) + } else { + try { + writeSecureFile(keyPath, previousEnvelope.toString('utf8')) + } catch { + // Why: restriction is failing device-wide; the restored bytes keep the + // previous credential available instead of deleting it, and the thrown + // save error still tells the user the store is not secure. + } + } throw new Error('GLM Coding Plan API key could not be stored securely on this device') } cachedZcodePlanApiKey = trimmed