From cff4e770e7870cc336fa781d2475cdf9c4d6c184 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:02:03 -0700 Subject: [PATCH 1/9] feat(accounts): link standalone GLM Coding Plans Adapt the reviewed GLM accounts contribution to current main, retain Antigravity behavior, guard late credential results, expose storage protection, and redact quota errors. Co-authored-by: Luchong --- .../register-core-handlers.test.ts | 8 + .../register-core-handlers.ts | 2 + src/main/ipc/zcode-plan-credentials.test.ts | 68 ++++ src/main/ipc/zcode-plan-credentials.ts | 49 +++ .../rate-limits/service-zcode-usage.test.ts | 189 ++++++++++ .../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 | 110 +++++- src/main/rate-limits/zcode-usage-fetcher.ts | 65 +++- 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 | 206 +++++++++++ src/main/zcode/zcode-plan-api-key-store.ts | 169 +++++++++ src/preload/api-types.ts | 4 +- src/preload/api/agent-account-api.ts | 10 + .../api/zcode-plan-credentials-bridge.ts | 12 + src/preload/index.ts | 2 + .../src/components/settings/AccountsPane.tsx | 7 +- .../ZcodePlanAccountsSection.test.tsx | 228 ++++++++++++ .../settings/ZcodePlanAccountsSection.tsx | 331 ++++++++++++++++++ .../components/settings/accounts-search.ts | 33 +- .../settings/use-zcode-plan-credentials.ts | 81 +++++ .../settings/zcode-plan-usage-windows.tsx | 72 ++++ .../status-bar-provider-visibility.test.ts | 8 + .../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 | 51 +++ 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.ts | 6 + .../rpc-contract/client-settings-params.ts | 1 + src/shared/zcode-plan-sites.ts | 31 ++ 42 files changed, 1924 insertions(+), 22 deletions(-) create mode 100644 src/main/ipc/zcode-plan-credentials.test.ts 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/use-zcode-plan-credentials.ts 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 4e67441ea343..99d459501aa1 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 @@ -36,6 +36,7 @@ const { registerAgentHookHandlersMock, registerClaudeAccountHandlersMock, registerMiniMaxCredentialsHandlersMock, + registerZcodePlanCredentialsHandlersMock, registerGrokAccountHandlersMock, registerCursorAccountHandlersMock, registerClipboardHandlersMock, @@ -103,6 +104,7 @@ const { registerAgentHookHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), + registerZcodePlanCredentialsHandlersMock: vi.fn(), registerGrokAccountHandlersMock: vi.fn(), registerCursorAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), @@ -336,6 +338,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', () => { registerAgentHookHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerMiniMaxCredentialsHandlersMock.mockReset() + registerZcodePlanCredentialsHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() setTrustedClipboardRendererWebContentsIdMock.mockReset() registerUpdaterHandlersMock.mockReset() @@ -540,6 +547,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 d3a647cafd71..618dab409d9b 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -62,6 +62,7 @@ import { registerCodexConfigSyncHandlers } from '../codex-config-sync' import { getPtyIdForPaneKey } from '../pty' 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' @@ -151,6 +152,7 @@ export function registerCoreHandlers( registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) + registerZcodePlanCredentialsHandlers(rateLimits) registerGrokAccountHandlers() registerCursorAccountHandlers() registerRateLimitHandlers(rateLimits, codexAccounts) diff --git a/src/main/ipc/zcode-plan-credentials.test.ts b/src/main/ipc/zcode-plan-credentials.test.ts new file mode 100644 index 000000000000..ae675aeb6502 --- /dev/null +++ b/src/main/ipc/zcode-plan-credentials.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { registerZcodePlanCredentialsHandlers } from './zcode-plan-credentials' + +const mocks = vi.hoisted(() => ({ + handle: vi.fn(), + hasKey: vi.fn(() => false), + protection: vi.fn<() => 'sealed' | 'plaintext' | null>(() => null), + hasCli: vi.fn(() => false), + save: vi.fn(), + clear: vi.fn() +})) +vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) +vi.mock('../zcode/zcode-plan-api-key-store', () => ({ + hasZcodePlanApiKey: mocks.hasKey, + getZcodePlanApiKeyProtection: mocks.protection, + saveZcodePlanApiKey: mocks.save, + clearZcodePlanApiKey: mocks.clear +})) +vi.mock('../rate-limits/zcode-usage-fetcher', () => ({ hasZcodeCliPlanCredentials: mocks.hasCli })) + +function handler(channel: string) { + const registration = mocks.handle.mock.calls.find(([name]) => name === channel) + if (!registration) { + throw new Error('Handler missing') + } + return registration[1] +} + +describe('GLM credential IPC', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.hasKey.mockReturnValue(false) + mocks.hasCli.mockReturnValue(false) + mocks.protection.mockReturnValue(null) + registerZcodePlanCredentialsHandlers(null) + }) + + it('returns only presence and protection without exposing the key', () => { + mocks.hasKey.mockReturnValue(true) + mocks.hasCli.mockReturnValue(true) + mocks.protection.mockReturnValue('sealed') + expect(handler('zcodePlanCredentials:getStatus')()).toEqual({ + apiKeyConfigured: true, + zcodeCliConfigured: true, + apiKeyProtection: 'sealed' + }) + }) + + it('validates an untyped IPC key before persistence', () => { + expect(() => handler('zcodePlanCredentials:saveApiKey')(null, 42)).toThrow('must be a string') + expect(mocks.save).not.toHaveBeenCalled() + }) + + it('saves and removes keys while returning status only', () => { + mocks.save.mockImplementationOnce(() => mocks.hasKey.mockReturnValue(true)) + mocks.clear.mockImplementationOnce(() => mocks.hasKey.mockReturnValue(false)) + expect(handler('zcodePlanCredentials:saveApiKey')(null, 'synthetic-key')).toEqual({ + apiKeyConfigured: true, + zcodeCliConfigured: false, + apiKeyProtection: null + }) + expect(handler('zcodePlanCredentials:clearApiKey')()).toEqual({ + apiKeyConfigured: false, + zcodeCliConfigured: false, + apiKeyProtection: null + }) + }) +}) diff --git a/src/main/ipc/zcode-plan-credentials.ts b/src/main/ipc/zcode-plan-credentials.ts new file mode 100644 index 000000000000..9923acba3bb4 --- /dev/null +++ b/src/main/ipc/zcode-plan-credentials.ts @@ -0,0 +1,49 @@ +import { ipcMain } from 'electron' +import { + clearZcodePlanApiKey, + getZcodePlanApiKeyProtection, + hasZcodePlanApiKey, + saveZcodePlanApiKey +} from '../zcode/zcode-plan-api-key-store' +import { hasZcodeCliPlanCredentials } from '../rate-limits/zcode-usage-fetcher' +import type { RateLimitService } from '../rate-limits/service' +import type { ZcodePlanCredentialsStatus } from '../../shared/zcode-plan-sites' + +function getZcodePlanCredentialsStatus(): ZcodePlanCredentialsStatus { + return { + apiKeyConfigured: hasZcodePlanApiKey(), + zcodeCliConfigured: hasZcodeCliPlanCredentials(), + apiKeyProtection: getZcodePlanApiKeyProtection() + } +} + +// 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 000000000000..7aa9e911c602 --- /dev/null +++ b/src/main/rate-limits/service-zcode-usage.test.ts @@ -0,0 +1,189 @@ +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('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: 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 vi.waitFor(() => expect(service.getState().claude?.status).toBe('ok')) + + service.invalidateZcodeCredentialState() + const queuedRefresh = service.refresh() + await Promise.resolve() + + firstZcode.resolve(okProvider('zcode', 50, Date.now())) + await vi.waitFor(() => expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(2)) + + 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 ed8578e36940..e6090e28228c 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 2a788b1cf935..09dbde8a8600 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 12542f311db9..cfd4a6e9d58e 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 a24322a54712..93dc9e20553e 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, @@ -213,4 +214,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 b6a9b40373ee..2731f882be5a 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -25,6 +25,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ opencodeGeneration, miniMaxConfigChanged, miniMaxGeneration, + zcodeConfigChanged, + zcodeGeneration, claudeFetchGated, results: [ claudeResult, @@ -201,6 +203,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ const grok = settleSiblingProviderResult('grok', grokSettled) const cursor = settleSiblingProviderResult('cursor', cursorSettled) const zcode = settleSiblingProviderResult('zcode', zcodeSettled) + const shouldApplyZcode = zcodeGeneration === this.zcodeFetchGeneration const antigravity = settleSiblingProviderResult('antigravity', antigravitySettled) // Why: the stale policy keeps a recent snapshot through a failed refresh, but // a snapshot belonging to a different Cursor account must not survive the @@ -221,16 +224,21 @@ 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.trackActiveFailureStreak('antigravity', antigravity) 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), antigravity: this.applyStalePolicy(antigravity, previousState.antigravity) }) } 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 5395ace5fbff..b09b440f2932 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -7,6 +7,7 @@ import { fetchCursorRateLimits } from '../cursor-fetcher' import { readCursorAuthSession } from '../cursor-auth' import { fetchZcodeRateLimits } from '../zcode-usage-fetcher' import { fetchAntigravityRateLimits } from '../antigravity-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' @@ -35,6 +36,8 @@ export type FetchAllCyclePrepared = { opencodeGeneration: number miniMaxConfigChanged: boolean miniMaxGeneration: number + zcodeConfigChanged: boolean + zcodeGeneration: number claudeFetchGated: boolean results: [ PromiseSettledResult, @@ -116,6 +119,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')}` + : (zcodePlanConfigResult.error ?? '') + 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, @@ -135,7 +157,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 @@ -150,7 +174,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 ) @@ -238,6 +266,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 7af82c6c3135..01c4456cf489 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 a3998c13abe4..bc6902e2fa56 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 b587d196be0f..aadca4c17a59 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,112 @@ 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('error') + expect(result.error).toBe('The saved GLM Coding Plan API key is unusable') + expect(fetch).not.toHaveBeenCalled() + }) + + it('reports an unusable plan credential instead of switching to the CLI config', async () => { + writeConfig() + 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('error') + expect(result.error).toBe('The saved GLM Coding Plan API key is unusable') + expect(fetch).not.toHaveBeenCalled() + expect(result.usageMetadata?.credentialSource).toBeUndefined() + }) +}) + +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) + }) +}) + +describe('quota credential privacy', () => { + it.each(['network', 'response'])('redacts a credential echoed by a %s error', async (kind) => { + writeConfig() + if (kind === 'network') { + vi.mocked(fetch).mockRejectedValue(new Error('Rejected test-secret')) + } else { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ success: false, msg: 'Rejected test-secret' })) + ) + } + const result = await fetchZcodeRateLimits({ configPath }) + expect(result.error).toBe('Rejected [redacted]') + expect(JSON.stringify(result)).not.toContain('test-secret') + }) }) diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts index 852def603e32..2eba29a4f98a 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 { @@ -75,6 +83,10 @@ function failed( } } +function redactCredential(error: string, apiKey: string): string { + return error.replaceAll(apiKey, '[redacted]') +} + function readCredentials(configPath: string): ZcodeUsageCredentials | null { let config: Record | null try { @@ -102,8 +114,20 @@ function readCredentials(configPath: string): ZcodeUsageCredentials | null { ) { return null } + return resolveUsageCredentials(apiKey, baseURL, mainProvider) +} + +function resolveUsageCredentials( + key: string, + baseUrl: string, + identity: string +): ZcodeUsageCredentials | null { + const apiKey = key.trim() + if (!apiKey || /[\r\n]/.test(apiKey)) { + return null + } try { - const parsed = new URL(baseURL) + const parsed = new URL(baseUrl) if ( parsed.protocol !== 'https:' || !SUPPORTED_HOSTS.has(parsed.hostname) || @@ -112,10 +136,10 @@ function readCredentials(configPath: string): ZcodeUsageCredentials | null { return null } return { - apiKey: apiKey.trim(), + apiKey, quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`, authProvenance: createHmac('sha256', CREDENTIAL_IDENTITY_KEY) - .update(JSON.stringify([mainProvider, parsed.origin, apiKey.trim()])) + .update(JSON.stringify([identity, parsed.origin, apiKey])) .digest('hex') } } catch { @@ -123,6 +147,12 @@ function readCredentials(configPath: string): ZcodeUsageCredentials | 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 +212,28 @@ 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 + ? resolveUsageCredentials( + options.planCredential.apiKey, + options.planCredential.baseUrl, + ZCODE_PLAN_CREDENTIAL_SOURCE + ) + : 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') } + const credentialSource = planCredentials ? ZCODE_PLAN_CREDENTIAL_SOURCE : configPath let response: Response try { @@ -208,7 +252,10 @@ export async function fetchZcodeRateLimits( }) } catch (error) { return failed( - error instanceof Error ? error.message : 'ZCode quota request failed', + redactCredential( + error instanceof Error ? error.message : 'ZCode quota request failed', + credentials.apiKey + ), 'network', credentials.authProvenance ) @@ -239,7 +286,11 @@ export async function fetchZcodeRateLimits( ) { const msg = payload?.msg const message = typeof msg === 'string' ? msg : 'Invalid ZCode quota response' - return failed(message, 'parse', credentials.authProvenance) + return failed( + redactCredential(message, credentials.apiKey), + 'parse', + credentials.authProvenance + ) } const limits = reported.filter((value): value is QuotaLimit => isRecord(value)) @@ -270,7 +321,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 d29b9afea1ee..a312fc66dbe5 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -48,6 +48,7 @@ export type RuntimeClientSettings = Pick< | 'minimaxGroupId' | 'minimaxUsageModels' | 'minimaxEndpoint' + | 'zcodePlanSite' | 'prBotAuthorOverrides' | 'artifactSharingEnabled' | 'worktreeVisibilityDefaults' @@ -84,6 +85,7 @@ export type RuntimeClientSettingsUpdate = Pick< | 'minimaxGroupId' | 'minimaxUsageModels' | 'minimaxEndpoint' + | 'zcodePlanSite' | 'prBotAuthorOverrides' | 'worktreeVisibilityDefaults' | 'machineName' @@ -132,6 +134,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 43fe59b34e1a..a7f9af2a9069 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 6abf73d66106..dc7071bd9e50 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' @@ -102,6 +103,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) @@ -129,6 +141,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 000000000000..ed53cd28e7b3 --- /dev/null +++ b/src/main/zcode/zcode-plan-api-key-store.test.ts @@ -0,0 +1,206 @@ +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() + writeSecureFileMock.mockReturnValue(true) + 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.each(['encrypted', 'plaintext'] as const)( + 'reports %s protection without decrypting', + async (kind) => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope(kind, 'glm-secret'))) + const store = await loadStore() + expect(store.getZcodePlanApiKeyProtection()).toBe( + kind === 'encrypted' ? 'sealed' : 'plaintext' + ) + 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('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('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) + 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 000000000000..45c4ad315ac6 --- /dev/null +++ b/src/main/zcode/zcode-plan-api-key-store.ts @@ -0,0 +1,169 @@ +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' +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' + +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 getZcodePlanApiKeyProtection(): SecretAtRestProtection | null { + const keyPath = getZcodePlanApiKeyPath() + if (!existsSync(keyPath)) { + return null + } + try { + return decodeApiKeyEnvelope(readFileSync(keyPath)).kind === 'plaintext' ? 'plaintext' : 'sealed' + } catch { + return null + } +} + +export function saveZcodePlanApiKey(key: string): void { + const trimmed = key.trim() + 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(), + encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed)) + ) + cachedZcodePlanApiKey = trimmed + return + } + 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( + keyPath, + encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')) + ) + // Why: an unrestricted plaintext credential must never be reported as saved. + if (!wroteRestricted) { + 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 +} + +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 220b90c18a6c..6c395100f907 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' @@ -142,6 +143,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 9c8087f4b916..052fcdf2edc3 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -5,6 +5,7 @@ import type { } from '../../shared/managed-account-types' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' import type { CursorAccountStatus, GrokAccountStatus } from '../../shared/rate-limit-types' +import type { ZcodePlanCredentialsStatus } from '../../shared/zcode-plan-sites' export type CodexAccountsApi = { list: () => Promise @@ -96,6 +97,15 @@ export type MinimaxCredentialsApi = { }> } +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 + saveApiKey: (key: string) => Promise + clearApiKey: () => Promise +} + 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 000000000000..cee36fa7b870 --- /dev/null +++ b/src/preload/api/zcode-plan-credentials-bridge.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' +import type { ZcodePlanCredentialsStatus } from '../../shared/zcode-plan-sites' + +export const zcodePlanCredentialsApi = { + getStatus: (): Promise => + ipcRenderer.invoke('zcodePlanCredentials:getStatus'), + saveApiKey: (key: string): Promise => + ipcRenderer.invoke('zcodePlanCredentials:saveApiKey', key), + clearApiKey: (): Promise => + ipcRenderer.invoke('zcodePlanCredentials:clearApiKey') +} satisfies PreloadApi['zcodePlanCredentials'] diff --git a/src/preload/index.ts b/src/preload/index.ts index 42cf236c14db..a68a39650702 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -76,6 +76,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' @@ -175,6 +176,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 1e479ab7e25a..6be660e3db7b 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -24,7 +24,8 @@ import { getAccountsLocationSearchEntries, getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, - getAccountsPaneSearchEntries + getAccountsPaneSearchEntries, + getAccountsZcodePlanSearchEntries } from './accounts-search' import { getRemoteAccountsPaneScope } from './provider-account-scope' import { ProviderHostScopeControl } from './ProviderHostScopeControl' @@ -39,6 +40,7 @@ import { } from './provider-account-visibility' import { GrokAccountsSection } from './GrokAccountsSection' import { CursorAccountsSection } from './CursorAccountsSection' +import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection' import type { AccountsPaneProps, AccountsPaneSectionModel, @@ -395,6 +397,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 000000000000..bc52724e40b8 --- /dev/null +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx @@ -0,0 +1,228 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import type { ZcodePlanSite } from '../../../../shared/zcode-plan-sites' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const settings: { zcodePlanSite: ZcodePlanSite } = { zcodePlanSite: 'zai' } + return { + getStatus: vi.fn(), + saveApiKey: vi.fn(), + clearApiKey: vi.fn(), + refreshRateLimits: vi.fn(), + updateSettings: vi.fn(), + recordFeatureInteraction: vi.fn(), + zcodeUsage: vi.fn<() => unknown>(() => null), + settings + } +}) + +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() + }) + + 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() + }) +}) + +describe('GLM credential status races', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('keeps the saved status when an earlier status read finishes late', async () => { + let finishRead: ((value: unknown) => void) | undefined + mocks.getStatus.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRead = resolve + }) + ) + mocks.saveApiKey.mockResolvedValue({ + apiKeyConfigured: true, + zcodeCliConfigured: false, + apiKeyProtection: 'sealed' + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + zcodePlanCredentials: { + getStatus: mocks.getStatus, + saveApiKey: mocks.saveApiKey, + clearApiKey: mocks.clearApiKey + } + } + }) + render() + fireEvent.change(screen.getByPlaceholderText('Paste your GLM Coding Plan API key'), { + target: { value: 'synthetic-key' } + }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await screen.findByRole('button', { name: 'Forget key' }) + finishRead?.({ apiKeyConfigured: false, zcodeCliConfigured: false, apiKeyProtection: null }) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Forget key' })).toBeInTheDocument() + ) + }) +}) diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx new file mode 100644 index 000000000000..0cb66df2832c --- /dev/null +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx @@ -0,0 +1,331 @@ +import { useState } from 'react' +import { ExternalLink, Loader2, Lock, LockOpen, RefreshCw, ShieldCheck } from 'lucide-react' +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' +import { useZcodePlanCredentials } from './use-zcode-plan-credentials' +import { UnsealedCredentialNotice } from './UnsealedCredentialNotice' + +const SEARCH_KEYWORDS = [ + 'glm', + 'zai', + 'z.ai', + 'zhipu', + 'bigmodel', + 'coding plan', + 'usage', + 'rate limit', + 'zcode' +] + +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 refreshRateLimits = useAppStore((s) => s.refreshRateLimits) + const zcodeUsage = useAppStore((s) => s.rateLimits.zcode) + const { status, apiKeyDraft, setApiKeyDraft, credentialBusy, saveApiKey, clearApiKey } = + useZcodePlanCredentials(zcodeUsage?.updatedAt) + const [refreshing, setRefreshing] = useState(false) + const now = useNow(60_000) + + 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 + } + // Why: main invalidates and refreshes on this settings change, so no local refresh is needed. + void updateSettings({ zcodePlanSite: value }) + } + + 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" + /> + + {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 60fa795caa99..405f51196135 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/use-zcode-plan-credentials.ts b/src/renderer/src/components/settings/use-zcode-plan-credentials.ts new file mode 100644 index 000000000000..e14f975798fa --- /dev/null +++ b/src/renderer/src/components/settings/use-zcode-plan-credentials.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import type { ZcodePlanCredentialsStatus } from '../../../../shared/zcode-plan-sites' + +export function useZcodePlanCredentials(updatedAt: number | undefined) { + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const [status, setStatus] = useState(null) + const [apiKeyDraft, setApiKeyDraft] = useState('') + const [credentialBusy, setCredentialBusy] = useState(false) + const generation = useRef(0) + const mutationPending = useRef(false) + + useEffect(() => { + if (mutationPending.current) { + return + } + const request = ++generation.current + void window.api.zcodePlanCredentials.getStatus().then( + (next) => { + if (request === generation.current) { + setStatus(next) + } + }, + () => { + if (request === generation.current) { + setStatus(null) + } + } + ) + return () => { + generation.current += 1 + } + }, [updatedAt]) + + const updateCredential = async (action: 'save' | 'clear'): Promise => { + const request = ++generation.current + mutationPending.current = true + setCredentialBusy(true) + try { + const next = + action === 'save' + ? await window.api.zcodePlanCredentials.saveApiKey(apiKeyDraft.trim()) + : await window.api.zcodePlanCredentials.clearApiKey() + if (request === generation.current) { + setStatus(next) + } + setApiKeyDraft('') + recordFeatureInteraction('usage-tracking') + if (action === 'save') { + 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 { + mutationPending.current = false + setCredentialBusy(false) + } + } + + return { + status, + apiKeyDraft, + setApiKeyDraft, + credentialBusy, + saveApiKey: () => updateCredential('save'), + clearApiKey: () => updateCredential('clear') + } +} 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 000000000000..6701ee44adaa --- /dev/null +++ b/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx @@ -0,0 +1,72 @@ +import { translate } from '@/i18n/i18n' +import { formatResetDuration } 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 ? formatResetDuration(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 987bb91c040e..3020048273e0 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,11 +78,19 @@ function usageSettings(overrides: Partial = {}): UsagePro opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, cursorAuthConfigured: false, + zcodePlanApiKeyConfigured: false, ...overrides } } describe('hasUsageProviderSettings', () => { + it('keeps a linked GLM plan visible without CLI detection or a first quota result', () => { + const settings = usageSettings({ zcodePlanApiKeyConfigured: true }) + expect(hasUsageProviderSettings(settings)).toBe(true) + expect(getVisibleUsageProvider('zcode', null, settings)?.status).toBe('fetching') + expect(getVisibleUsageProvider('zcode', null, usageSettings())).toBeNull() + }) + it('treats persisted managed accounts as configured usage providers', () => { expect( hasUsageProviderSettings( 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 a49a9fe3a59b..6f199db68f04 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 b531bffd161e..6da92332d443 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 8ec0bcfce437..bd7b9aa4258a 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 2f29e560803a..e52ec89d5f75 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9145,6 +9145,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" + } } } }, @@ -12259,6 +12271,45 @@ }, "UnsealedCredentialNotice": { "body": "{{credential}} is stored unencrypted — this system has no OS keyring Orca can use. Anyone who can read your disk or a backup of it can read the credential. Install and unlock gnome-keyring or kwallet, then save it again to seal it." + }, + "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 63fc2b87de52..f0bbca866f3d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8898,6 +8898,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" + } } } }, @@ -12058,6 +12070,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 3c6a9d2aa591..cb085cfd7284 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -215,6 +215,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 c1a9b7d2df12..7ff724d43ce9 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, @@ -400,6 +401,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 494f56e4dff1..b9af7fe1b68c 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.ts b/src/shared/rate-limit-types.ts index 6b06f09d10e9..7f69b33264ba 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 fd0c2f986a93..6574c89a8bb7 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 000000000000..fe4088838426 --- /dev/null +++ b/src/shared/zcode-plan-sites.ts @@ -0,0 +1,31 @@ +import type { SecretAtRestProtection } from './secret-at-rest-protection' + +/** + * 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' +} + +export type ZcodePlanCredentialsStatus = { + apiKeyConfigured: boolean + zcodeCliConfigured: boolean + apiKeyProtection: SecretAtRestProtection | null +} From f541989f6a1ae709f3fca649b4ff7bb94959e727 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:36:49 -0700 Subject: [PATCH 2/9] fix(accounts): retain GLM credential results during quota refresh --- .../ZcodePlanAccountsSection.test.tsx | 9 +- .../use-zcode-plan-credentials.test.tsx | 86 +++++++++++++++++++ .../settings/use-zcode-plan-credentials.ts | 28 ++++-- 3 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx index bc52724e40b8..5358eb262ad4 100644 --- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx @@ -52,8 +52,8 @@ 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.saveApiKey.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false }) + mocks.clearApiKey.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: false }) mocks.refreshRateLimits.mockResolvedValue(undefined) mocks.updateSettings.mockResolvedValue(undefined) mocks.recordFeatureInteraction.mockReset() @@ -193,6 +193,11 @@ describe('GLM credential status races', () => { it('keeps the saved status when an earlier status read finishes late', async () => { let finishRead: ((value: unknown) => void) | undefined + mocks.getStatus.mockResolvedValue({ + apiKeyConfigured: true, + zcodeCliConfigured: false, + apiKeyProtection: 'sealed' + }) mocks.getStatus.mockImplementationOnce( () => new Promise((resolve) => { diff --git a/src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx b/src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx new file mode 100644 index 000000000000..5e2d91e778a2 --- /dev/null +++ b/src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ZcodePlanCredentialsStatus } from '../../../../shared/zcode-plan-sites' +import { useZcodePlanCredentials } from './use-zcode-plan-credentials' + +const mocks = vi.hoisted(() => ({ interaction: vi.fn(), success: vi.fn(), error: vi.fn() })) +vi.mock('../../store', () => ({ + useAppStore: (select: (s: { recordFeatureInteraction: typeof mocks.interaction }) => unknown) => + select({ recordFeatureInteraction: mocks.interaction }) +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: { success: mocks.success, error: mocks.error } })) +const unlinked: ZcodePlanCredentialsStatus = { + apiKeyConfigured: false, + zcodeCliConfigured: false, + apiKeyProtection: null +} +const linked: ZcodePlanCredentialsStatus = { + ...unlinked, + apiKeyConfigured: true, + apiKeyProtection: 'sealed' +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('GLM credential mutation refresh races', () => { + it.each(['save', 'clear', 'failed-save', 'failed-clear'] as const)( + 'applies %s while quota changes and rereads after settling', + async (action) => { + let complete: ((status: ZcodePlanCredentialsStatus) => void) | undefined + let reject: ((error: Error) => void) | undefined + const pending = new Promise((resolve, fail) => { + complete = resolve + reject = fail + }) + const initial = action.endsWith('clear') ? linked : unlinked + const final = action === 'save' ? linked : action === 'clear' ? unlinked : initial + let stored = initial + const api = { + getStatus: vi.fn(async () => stored), + saveApiKey: vi.fn(() => pending), + clearApiKey: vi.fn(() => pending) + } + Object.defineProperty(window, 'api', { + configurable: true, + value: { zcodePlanCredentials: api } + }) + const { result, rerender } = renderHook(({ time }) => useZcodePlanCredentials(time), { + initialProps: { time: 1 } + }) + await waitFor(() => expect(result.current.status).toEqual(initial)) + act(() => result.current.setApiKeyDraft('synthetic-key')) + let mutation: Promise | undefined + act(() => { + mutation = action.endsWith('clear') + ? result.current.clearApiKey() + : result.current.saveApiKey() + }) + rerender({ time: 2 }) + expect(api.getStatus).toHaveBeenCalledTimes(1) + await act(async () => { + stored = final + if (action.startsWith('failed')) { + reject?.(new Error('Synthetic failure')) + } else { + complete?.(final) + } + await mutation + }) + await waitFor(() => expect(result.current.status).toEqual(final)) + expect(api.getStatus).toHaveBeenCalledTimes(2) + expect(result.current.credentialBusy).toBe(false) + if (action.startsWith('failed')) { + expect(result.current.apiKeyDraft).toBe('synthetic-key') + expect(mocks.error).toHaveBeenCalledTimes(1) + expect(mocks.success).not.toHaveBeenCalled() + } else { + expect(result.current.apiKeyDraft).toBe('') + } + } + ) +}) diff --git a/src/renderer/src/components/settings/use-zcode-plan-credentials.ts b/src/renderer/src/components/settings/use-zcode-plan-credentials.ts index e14f975798fa..e01b30cccc18 100644 --- a/src/renderer/src/components/settings/use-zcode-plan-credentials.ts +++ b/src/renderer/src/components/settings/use-zcode-plan-credentials.ts @@ -9,33 +9,36 @@ export function useZcodePlanCredentials(updatedAt: number | undefined) { const [status, setStatus] = useState(null) const [apiKeyDraft, setApiKeyDraft] = useState('') const [credentialBusy, setCredentialBusy] = useState(false) - const generation = useRef(0) + const readGeneration = useRef(0) const mutationPending = useRef(false) useEffect(() => { if (mutationPending.current) { return } - const request = ++generation.current + const request = ++readGeneration.current void window.api.zcodePlanCredentials.getStatus().then( (next) => { - if (request === generation.current) { + if (request === readGeneration.current) { setStatus(next) } }, () => { - if (request === generation.current) { + if (request === readGeneration.current) { setStatus(null) } } ) return () => { - generation.current += 1 + readGeneration.current += 1 } - }, [updatedAt]) + }, [updatedAt, credentialBusy]) const updateCredential = async (action: 'save' | 'clear'): Promise => { - const request = ++generation.current + if (mutationPending.current) { + return + } + readGeneration.current += 1 mutationPending.current = true setCredentialBusy(true) try { @@ -43,9 +46,16 @@ export function useZcodePlanCredentials(updatedAt: number | undefined) { action === 'save' ? await window.api.zcodePlanCredentials.saveApiKey(apiKeyDraft.trim()) : await window.api.zcodePlanCredentials.clearApiKey() - if (request === generation.current) { - setStatus(next) + if ( + !next || + typeof next.apiKeyConfigured !== 'boolean' || + typeof next.zcodeCliConfigured !== 'boolean' + ) { + throw new Error( + 'GLM Coding Plan keys can only be changed in the desktop app on the computer running Orca.' + ) } + setStatus(next) setApiKeyDraft('') recordFeatureInteraction('usage-tracking') if (action === 'save') { From 3b84874eee4f07152f5f0f6fca1ce58c87abebbe Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:38:40 -0700 Subject: [PATCH 3/9] fix(accounts): make GLM credential editing desktop-only --- .../ZcodePlanAccountsSection.test.tsx | 17 +++++++++++++ .../settings/ZcodePlanAccountsSection.tsx | 25 +++++++++++++++---- .../use-zcode-plan-credentials.test.tsx | 14 +++++++++++ src/renderer/src/i18n/locales/en.json | 3 ++- src/renderer/src/i18n/locales/zh.json | 3 ++- .../web/preload-api/web-agent-accounts-api.ts | 15 +++++++++++ src/renderer/src/web/web-preload-api.ts | 2 ++ 7 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx index 5358eb262ad4..ecfc43f25d0b 100644 --- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { const settings: { zcodePlanSite: ZcodePlanSite } = { zcodePlanSite: 'zai' } return { + isWeb: vi.fn(() => false), getStatus: vi.fn(), saveApiKey: vi.fn(), clearApiKey: vi.fn(), @@ -21,6 +22,8 @@ const mocks = vi.hoisted(() => { } }) +vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: mocks.isWeb })) + vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => React.createElement('span', { 'data-testid': 'zcode-icon' }) })) @@ -51,6 +54,7 @@ import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection' describe('ZcodePlanAccountsSection', () => { beforeEach(() => { + mocks.isWeb.mockReturnValue(false) mocks.getStatus.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: false }) mocks.saveApiKey.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false }) mocks.clearApiKey.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: false }) @@ -76,6 +80,19 @@ describe('ZcodePlanAccountsSection', () => { vi.clearAllMocks() }) + it('disables site and secret editing on a paired web client', async () => { + mocks.isWeb.mockReturnValue(true) + render() + expect( + await screen.findByText( + 'Change the plan site and API key in the desktop app on the computer running Orca.' + ) + ).toBeInTheDocument() + expect(screen.getByRole('combobox')).toBeDisabled() + expect(screen.getByPlaceholderText('Paste your GLM Coding Plan API key')).toBeDisabled() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + it('shows the unlinked state when neither an API key nor a CLI config exists', async () => { render() diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx index 0cb66df2832c..0e716a0e29d3 100644 --- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { isWebClientLocation } from '@/lib/web-client-location' import { ExternalLink, Loader2, Lock, LockOpen, RefreshCw, ShieldCheck } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { translate } from '@/i18n/i18n' @@ -51,12 +52,13 @@ export function ZcodePlanAccountsSection(): React.JSX.Element { const [refreshing, setRefreshing] = useState(false) const now = useNow(60_000) + const credentialEditable = !isWebClientLocation() 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) { + if (!credentialEditable || (value !== 'zai' && value !== 'bigmodel') || value === site) { return } // Why: main invalidates and refreshes on this settings change, so no local refresh is needed. @@ -189,6 +191,15 @@ export function ZcodePlanAccountsSection(): React.JSX.Element { + {!credentialEditable ? ( +

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.hostOnly', + 'Change the plan site and API key in the desktop app on the computer running Orca.' + )} +

+ ) : null} + {translate('auto.components.settings.ZcodePlanAccountsSection.siteTitle', 'Plan site')} - @@ -254,7 +269,7 @@ export function ZcodePlanAccountsSection(): React.JSX.Element { setApiKeyDraft(e.target.value)} placeholder={translate( @@ -267,7 +282,7 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {