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 7db5d24a4a75..b2acd9882ede 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, @@ -11,6 +12,7 @@ import { type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, type AntigravityUsageEnabledResolver, + type ZcodePlanRateLimitConfig, type GeminiCliOAuthEnabledResolver, type InactiveCodexAccountInfo, type InactiveClaudeAccountInfo, @@ -49,6 +51,10 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco this.miniMaxConfigResolver = resolver } + setZcodePlanConfigResolver(resolver: () => ZcodePlanRateLimitConfig): void { + this.zcodePlanConfigResolver = resolver + } + setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { this.geminiCliOAuthEnabledResolver = resolver } @@ -130,6 +136,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 405650ddf571..842a2d022441 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -8,6 +8,7 @@ import { readCursorAuthSession } from '../cursor-auth' import { fetchZcodeRateLimits } from '../zcode-usage-fetcher' import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher' import { antigravityUsageDisabledSnapshot } from '../antigravity-usage-snapshot' +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' @@ -36,6 +37,8 @@ export type FetchAllCyclePrepared = { opencodeGeneration: number miniMaxConfigChanged: boolean miniMaxGeneration: number + zcodeConfigChanged: boolean + zcodeGeneration: number claudeFetchGated: boolean results: [ PromiseSettledResult, @@ -119,6 +122,25 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ const antigravityUsageEnabled = this.antigravityUsageEnabledResolver?.() ?? true + 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, @@ -140,7 +162,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 its own promise: the keychain read and the desktop state.vscdb read @@ -155,7 +179,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 ) @@ -245,6 +273,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 eb810f1e21bc..78f59b281bf8 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -14,6 +14,7 @@ import { type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, type AntigravityUsageEnabledResolver, + type ZcodePlanRateLimitConfig, type GeminiCliOAuthEnabledResolver, type NormalizedCodexAccountSelectionTarget, type NormalizedClaudeAccountSelectionTarget, @@ -85,8 +86,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', @@ -101,6 +104,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 antigravityUsageEnabledResolver: AntigravityUsageEnabledResolver | null = null protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index c80f6bb68f14..c999277f61fc 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 /** Whether the user is actually showing Antigravity usage, so the `agy` probe is worth spawning. */ 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 b32c54e41546..a974c7d9edca 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 ?? 'zai', + apiKey: readZcodePlanApiKey() ?? '' + })) state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled) // Reuse the meter switch so hidden Antigravity usage does not spawn agy. state.rateLimits.setAntigravityUsageEnabledResolver(() => 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.section-lifetime.test.tsx b/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx index ff46d9637327..89161b687efa 100644 --- a/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx @@ -86,6 +86,13 @@ beforeEach(() => { minimaxCredentials: { getStatus: vi.fn(async () => ({ cookieConfigured: false, apiKeyConfigured: false })) }, + zcodePlanCredentials: { + getStatus: vi.fn(async () => ({ + apiKeyConfigured: false, + zcodeCliConfigured: false, + apiKeyProtection: null + })) + }, codexConfigSync: { status: vi.fn(async () => ({ state: 'synced', diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 634131a3849c..e99453afabbe 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -25,7 +25,8 @@ import { getAccountsLocationSearchEntries, getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, - getAccountsPaneSearchEntries + getAccountsPaneSearchEntries, + getAccountsZcodePlanSearchEntries } from './accounts-search' import { getRemoteAccountsPaneScope } from './provider-account-scope' import { ProviderHostScopeControl } from './ProviderHostScopeControl' @@ -42,6 +43,7 @@ import { GrokAccountsSection } from './GrokAccountsSection' import { AntigravityAccountsSection } from './AntigravityAccountsSection' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { CursorAccountsSection } from './CursorAccountsSection' +import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection' import type { AccountsPaneProps, AccountsPaneSectionModel, @@ -406,6 +408,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..32c277b72ced --- /dev/null +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx @@ -0,0 +1,282 @@ +// @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 { + isWeb: vi.fn(() => false), + 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/web-client-location', () => ({ isWebClientLocation: mocks.isWeb })) + +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.isWeb.mockReturnValue(false) + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: 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() + 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('disables site and secret editing on a paired web client', async () => { + mocks.isWeb.mockReturnValue(true) + mocks.settings.zcodePlanSite = 'bigmodel' + 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.getByRole('combobox')).toHaveTextContent('Zhipu · BigModel') + expect(screen.getByPlaceholderText('Paste your GLM Coding Plan API key')).toBeDisabled() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('keeps paired host credentials unknown beside successful quota', async () => { + mocks.isWeb.mockReturnValue(true) + mocks.zcodeUsage.mockReturnValue({ + provider: 'zcode', + status: 'ok', + error: null, + planType: null, + session: { usedPercent: 42, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + monthly: null, + updatedAt: 1 + }) + render() + expect(await screen.findByText('42%')).toBeInTheDocument() + expect( + screen.getByText('Plan credential details are only readable on the computer running Orca.') + ).toBeInTheDocument() + expect(screen.queryByText('No GLM Coding Plan linked')).not.toBeInTheDocument() + expect(screen.queryByText('Not saved')).not.toBeInTheDocument() + }) + + it('does not invent a site or console link for an older host', async () => { + mocks.isWeb.mockReturnValue(true) + delete mocks.settings.zcodePlanSite + render() + expect(await screen.findByText('Host plan site unavailable')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Get API key' })).not.toBeInTheDocument() + expect(screen.getByRole('combobox')).toBeDisabled() + }) + + 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.mockResolvedValue({ + apiKeyConfigured: true, + zcodeCliConfigured: false, + apiKeyProtection: 'sealed' + }) + 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..be3d5e1010ef --- /dev/null +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx @@ -0,0 +1,363 @@ +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' +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 credentialEditable = !isWebClientLocation() + const site = settings?.zcodePlanSite ?? (credentialEditable ? 'zai' : undefined) + const detailsUnavailable = !credentialEditable || status?.detailsUnavailable === true + const consoleUrl = site ? ZCODE_PLAN_SITE_CONSOLE_URLS[site] : undefined + const apiKeyConfigured = status?.apiKeyConfigured === true + + const handleSiteChange = (value: string): void => { + if (!credentialEditable || (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.' + )} +

+
+ {consoleUrl ? ( + + {translate( + 'auto.components.settings.ZcodePlanAccountsSection.consoleLink', + 'Get API key' + )} + + + ) : null} +
+ +
+ +
+ {detailsUnavailable ? ( +

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.detailsUnavailable', + 'Plan credential details are only readable on the computer running Orca.' + )} +

+ ) : apiKeyConfigured ? ( + <> +

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

+

+ {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} +
+ +
+ + {!credentialEditable ? ( +

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

+ ) : null} + + + + + + + +
+
+ + {!detailsUnavailable ? ( + + {apiKeyConfigured ? : } + {apiKeyConfigured + ? translate('auto.components.settings.ZcodePlanAccountsSection.saved', 'Saved') + : translate( + 'auto.components.settings.ZcodePlanAccountsSection.notSaved', + 'Not saved' + )} + + ) : null} +
+
+ +
+ 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.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts index 6e027fddbede..4931510b1b92 100644 --- a/src/renderer/src/components/settings/accounts-search.test.ts +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -21,6 +21,24 @@ import { getAccountsPaneSearchEntries } from './accounts-search' +describe('getAccountsPaneSearchEntries', () => { + it('keeps Antigravity and GLM plan settings discoverable in pane order', () => { + const entries = getAccountsPaneSearchEntries() + const titles = entries.map((entry) => entry.title) + expect( + titles.filter((title) => + ['Antigravity Accounts', 'Cursor Usage', 'GLM Coding Plan'].includes(title) + ) + ).toEqual(['Antigravity Accounts', 'Cursor Usage', 'GLM Coding Plan']) + expect(entries.find((entry) => entry.title === 'Antigravity Accounts')?.keywords).toEqual( + expect.arrayContaining(['antigravity', 'agy', 'google', 'accounts']) + ) + expect(entries.find((entry) => entry.title === 'GLM Coding Plan')?.keywords).toEqual( + expect.arrayContaining(['glm', 'zai', 'zhipu', 'bigmodel', 'coding plan']) + ) + }) +}) + describe('getAccountsMiniMaxSearchEntries', () => { it('returns a single entry that targets the MiniMax session cookie flow', () => { const entries = getAccountsMiniMaxSearchEntries() diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 49296504e748..948f258323f3 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -267,6 +267,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(), @@ -276,5 +306,6 @@ export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsS ...getAccountsMiniMaxSearchEntries(), ...getAccountsGrokSearchEntries(), ...getAccountsAntigravitySearchEntries(), - ...getAccountsCursorSearchEntries() + ...getAccountsCursorSearchEntries(), + ...getAccountsZcodePlanSearchEntries() ]) 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..b1c86c5da574 --- /dev/null +++ b/src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx @@ -0,0 +1,102 @@ +// @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 { createZcodePlanCredentialsApi } from '../../web/preload-api/web-agent-accounts-api' +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('') + } + } + ) + it('does not claim a web save succeeded or discard the key draft', async () => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { zcodePlanCredentials: createZcodePlanCredentialsApi() } + }) + const { result } = renderHook(() => useZcodePlanCredentials(1)) + await waitFor(() => + expect(result.current.status).toEqual({ ...unlinked, detailsUnavailable: true }) + ) + act(() => result.current.setApiKeyDraft('synthetic-web-key')) + await act(() => result.current.saveApiKey()) + expect(result.current.apiKeyDraft).toBe('synthetic-web-key') + expect(mocks.success).not.toHaveBeenCalled() + expect(mocks.error).toHaveBeenCalled() + }) +}) 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..e01b30cccc18 --- /dev/null +++ b/src/renderer/src/components/settings/use-zcode-plan-credentials.ts @@ -0,0 +1,91 @@ +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 readGeneration = useRef(0) + const mutationPending = useRef(false) + + useEffect(() => { + if (mutationPending.current) { + return + } + const request = ++readGeneration.current + void window.api.zcodePlanCredentials.getStatus().then( + (next) => { + if (request === readGeneration.current) { + setStatus(next) + } + }, + () => { + if (request === readGeneration.current) { + setStatus(null) + } + } + ) + return () => { + readGeneration.current += 1 + } + }, [updatedAt, credentialBusy]) + + const updateCredential = async (action: 'save' | 'clear'): Promise => { + if (mutationPending.current) { + return + } + readGeneration.current += 1 + mutationPending.current = true + setCredentialBusy(true) + try { + const next = + action === 'save' + ? await window.api.zcodePlanCredentials.saveApiKey(apiKeyDraft.trim()) + : await window.api.zcodePlanCredentials.clearApiKey() + 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') { + 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 8506bae6779c..c01e803b1518 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 98c54eb14b0f..4ade7294abf8 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 @@ -16,6 +16,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 @@ -85,7 +88,8 @@ export function hasUsageProviderSettings( settings?.minimaxCookieConfigured === true || settings?.minimaxApiKeyConfigured === true || settings?.grokAuthConfigured === true || - settings?.cursorAuthConfigured === true + settings?.cursorAuthConfigured === true || + settings?.zcodePlanApiKeyConfigured === true ) } @@ -125,6 +129,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.test.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts index 6977319fa152..074d4aeebc95 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts @@ -9,11 +9,12 @@ describe('getUsageProviderAccountsSectionId', () => { expect(getUsageProviderAccountsSectionId('opencode-go')).toBe('accounts-opencode-go') expect(getUsageProviderAccountsSectionId('minimax')).toBe('accounts-minimax') expect(getUsageProviderAccountsSectionId('grok')).toBe('accounts-grok') + expect(getUsageProviderAccountsSectionId('cursor')).toBe('accounts-cursor') + expect(getUsageProviderAccountsSectionId('zcode')).toBe('accounts-zcode') }) it('does not invent an Accounts section for CLI-owned credentials', () => { expect(getUsageProviderAccountsSectionId('antigravity')).toBeNull() expect(getUsageProviderAccountsSectionId('kimi')).toBeNull() - expect(getUsageProviderAccountsSectionId('zcode')).toBeNull() }) }) 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 5a86e98ec350..ec9b79ca82b9 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 @@ -20,10 +20,10 @@ export function getUsageProviderAccountsSectionId( return 'accounts-cursor' case 'antigravity': 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. // Antigravity credentials live in the agy CLI; quota is fetched directly via agy. 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 8c94bc0ccc33..1c98e3e2f47f 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 @@ -107,7 +107,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) @@ -147,10 +148,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/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index dd7c81ac47f1..fb676cac5dbe 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1567,6 +1567,9 @@ "7c3bb36706": "remove", "e2b0ee267f": "stale" }, + "ZcodePlanAccountsSection": { + "keyRequired": "GLM Coding Plan API key is required." + }, "accounts": { "search": { "d1d2ae383c": "Paste your opencode.ai session cookie for rate limit fetching." diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e310234e2920..dbb888ffae76 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9152,6 +9152,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" + } } } }, @@ -12266,6 +12278,48 @@ }, "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}}", + "hostOnly": "Change the plan site and API key in the desktop app on the computer running Orca.", + "detailsUnavailable": "Plan credential details are only readable on the computer running Orca.", + "siteUnavailable": "Host plan site unavailable" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3f3a4883995a..ccd9d4acbea6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8899,6 +8899,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" + } } } }, @@ -12059,6 +12071,46 @@ "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}}", + "hostOnly": "请在运行 Orca 的电脑上的桌面应用中更改套餐站点和 API 密钥。" } }, "right": { diff --git a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts index 60b98274d319..9b92a5d75dec 100644 --- a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts @@ -23,6 +23,26 @@ export function createMiniMaxCredentialsApi(): NonNullable< } } +export function createZcodePlanCredentialsApi(): PreloadApi['zcodePlanCredentials'] { + const status = { + detailsUnavailable: true, + apiKeyConfigured: false, + zcodeCliConfigured: false, + apiKeyProtection: null + } + const unsupported = () => + Promise.reject( + new Error( + 'GLM Coding Plan keys can only be changed in the desktop app on the computer running Orca.' + ) + ) + return { + getStatus: () => Promise.resolve(status), + saveApiKey: unsupported, + clearApiKey: unsupported + } +} + export function createCursorAccountsApi(): NonNullable['cursorAccounts']> { // Why an explanation and not a bare `signedIn: false`: Cursor's session lives on // the machine running Orca, and this bridge cannot read it. The host may well be diff --git a/src/renderer/src/web/preload-api/web-preferences-store.ts b/src/renderer/src/web/preload-api/web-preferences-store.ts index f42c31d628a1..0f96d27144da 100644 --- a/src/renderer/src/web/preload-api/web-preferences-store.ts +++ b/src/renderer/src/web/preload-api/web-preferences-store.ts @@ -23,6 +23,7 @@ import { readStoredWebRuntimeEnvironment } from '../web-runtime-environment' import { mergeSettings, mergeWebUIState } from './web-preference-normalization' import { callRuntimeResult } from './web-runtime-calls' import { requireActiveEnvironmentOrNull, webRuntimeState } from './web-runtime-session' +import { zcodePlanSiteOwner, settingsForZcodePlanSiteOwner } from './web-zcode-plan-site' import { SETTINGS_STORAGE_KEY, UI_STORAGE_KEY, readJson, writeJson } from './web-storage' export type WebSettingsApi = NonNullable @@ -74,7 +75,7 @@ export function getStoredSettings(): GlobalSettings { // Keep readJson's invalid-JSON fallback non-destructive. } } - return mergeSettings( + const settings = mergeSettings( { ...defaults, floatingTerminalEnabled: false, @@ -83,6 +84,8 @@ export function getStoredSettings(): GlobalSettings { }, migratedStored ) + delete settings.zcodePlanSite + return settings } export function writeStoredSettings( @@ -106,6 +109,7 @@ export function writeStoredSettings( export async function getRuntimeBackedStoredSettings(): Promise { const local = getStoredSettings() const requestedEnvironment = requireActiveEnvironmentOrNull() + const requestedSiteOwner = zcodePlanSiteOwner(requestedEnvironment) if (!requestedEnvironment) { return local } @@ -139,6 +143,12 @@ export async function getRuntimeBackedStoredSettings(): Promise if (typeof result.settings.minimaxUsageModels === 'string') { runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels } + if (zcodePlanSiteOwner(currentEnvironment) === requestedSiteOwner) { + webRuntimeState.zcodePlanSiteRuntimeOwner = requestedSiteOwner + const site = result.settings.zcodePlanSite + webRuntimeState.zcodePlanSiteRuntimeValue = + site === 'zai' || site === 'bigmodel' ? site : null + } if ( result.settings.minimaxEndpoint === 'overseas' || result.settings.minimaxEndpoint === 'cn' @@ -169,19 +179,20 @@ export async function getRuntimeBackedStoredSettings(): Promise export function settingsForActiveVisibilityOwner(settings: GlobalSettings): GlobalSettings { const environment = requireActiveEnvironmentOrNull() + const ownedSettings = settingsForZcodePlanSiteOwner(settings, environment) if (!environment) { - return settings + return ownedSettings } if ( environment.id === webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId && webRuntimeState.worktreeVisibilityDefaultsRuntimeValue ) { return { - ...settings, + ...ownedSettings, worktreeVisibilityDefaults: webRuntimeState.worktreeVisibilityDefaultsRuntimeValue } } - const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = settings + const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = ownedSettings return supportedSettings as GlobalSettings } diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts index 15b6cb63ee75..a50f182e4211 100644 --- a/src/renderer/src/web/preload-api/web-runtime-session.ts +++ b/src/renderer/src/web/preload-api/web-runtime-session.ts @@ -2,6 +2,7 @@ import type { RuntimeHostStatusSnapshot, RuntimeHostStatusResponse } from '../../../../shared/runtime-host-status' +import type { ZcodePlanSite } from '../../../../shared/zcode-plan-sites' import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' import { RuntimeRpcCallQueuePool } from '../../../../shared/runtime-rpc-call-queue' import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' @@ -18,6 +19,8 @@ import { translate } from '@/i18n/i18n' export const webRuntimeState: { activeEnvironment: StoredWebRuntimeEnvironment | null + zcodePlanSiteRuntimeOwner: string | null + zcodePlanSiteRuntimeValue: ZcodePlanSite | null worktreeVisibilityDefaultsRuntimeEnvironmentId: string | null worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null activeClient: WebRuntimeClient | null @@ -26,6 +29,8 @@ export const webRuntimeState: { cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null } = { activeEnvironment: readStoredWebRuntimeEnvironment(), + zcodePlanSiteRuntimeOwner: null, + zcodePlanSiteRuntimeValue: null, worktreeVisibilityDefaultsRuntimeEnvironmentId: null, worktreeVisibilityDefaultsRuntimeValue: null, activeClient: null, diff --git a/src/renderer/src/web/preload-api/web-zcode-plan-site.ts b/src/renderer/src/web/preload-api/web-zcode-plan-site.ts new file mode 100644 index 000000000000..37f358a70d4e --- /dev/null +++ b/src/renderer/src/web/preload-api/web-zcode-plan-site.ts @@ -0,0 +1,23 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { StoredWebRuntimeEnvironment } from '../web-runtime-environment' +import { webRuntimeState } from './web-runtime-session' + +export function zcodePlanSiteOwner(environment: StoredWebRuntimeEnvironment | null): string | null { + return environment + ? JSON.stringify([environment.id, environment.pairingRevision ?? environment.createdAt]) + : null +} + +export function settingsForZcodePlanSiteOwner( + settings: GlobalSettings, + environment: StoredWebRuntimeEnvironment | null +): GlobalSettings { + const owner = zcodePlanSiteOwner(environment) + return { + ...settings, + zcodePlanSite: + owner && owner === webRuntimeState.zcodePlanSiteRuntimeOwner + ? (webRuntimeState.zcodePlanSiteRuntimeValue ?? undefined) + : undefined + } +} diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts index fc119f88258b..9ec74fb2d7ee 100644 --- a/src/renderer/src/web/web-preload-api-composition.test.ts +++ b/src/renderer/src/web/web-preload-api-composition.test.ts @@ -54,6 +54,7 @@ describe('web preload API composition', () => { 'notifications', 'rateLimits', 'minimaxCredentials', + 'zcodePlanCredentials', 'grokAccounts', 'cursorAccounts', 'codexAccounts', diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 17f77ded803c..7d221c0e0dcb 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -6,6 +6,7 @@ import { createCodexAccountsApi, createCursorAccountsApi, createGrokAccountsApi, + createZcodePlanCredentialsApi, createMiniMaxCredentialsApi } from './preload-api/web-agent-accounts-api' import { createWebAgentStatusApi } from './preload-api/web-agent-status-api' @@ -107,6 +108,7 @@ function createWebPreloadApi(): Partial { notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), minimaxCredentials: createMiniMaxCredentialsApi(), + zcodePlanCredentials: createZcodePlanCredentialsApi(), grokAccounts: createGrokAccountsApi(), cursorAccounts: createCursorAccountsApi(), codexAccounts: createCodexAccountsApi(), diff --git a/src/renderer/src/web/web-zcode-plan-settings.test.ts b/src/renderer/src/web/web-zcode-plan-settings.test.ts new file mode 100644 index 000000000000..dea9b84c985c --- /dev/null +++ b/src/renderer/src/web/web-zcode-plan-settings.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + installBrowserGlobals, + writeStoredRuntimeEnvironment +} from './web-preload-api-test-harness' + +describe('web GLM host site', () => { + beforeEach(() => vi.resetModules()) + afterEach(() => vi.unstubAllGlobals()) + it('reads the host GLM site without sending site or secret changes back', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: 'site-read', + ok: true, + result: { settings: { zcodePlanSite: 'bigmodel' } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + expect((await globals.window.api.settings.get()).zcodePlanSite).toBe('bigmodel') + expect((await globals.window.api.settings.set({ zcodePlanSite: 'zai' })).zcodePlanSite).toBe( + 'bigmodel' + ) + await expect( + globals.window.api.zcodePlanCredentials.saveApiKey('synthetic-key') + ).rejects.toThrow() + expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }]) + }) +}) + +describe('old host GLM settings', () => { + beforeEach(() => vi.resetModules()) + afterEach(() => vi.unstubAllGlobals()) + + it.each([undefined, 'bigmodel', 'zai'])( + 'does not promote browser site %s to omitted host site', + async (cachedSite) => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(): Promise> { + return Promise.resolve({ + id: 'old-host', + ok: true, + result: { settings: {} }, + _meta: { runtimeId: 'runtime-1' } + }) + } + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + globals.storage.setItem('orca.web.settings.v1', JSON.stringify({ zcodePlanSite: cachedSite })) + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + expect((await globals.window.api.settings.get()).zcodePlanSite).toBeUndefined() + expect(await globals.window.api.zcodePlanCredentials.getStatus()).toMatchObject({ + detailsUnavailable: true + }) + } + ) +}) + +describe('GLM site host attestation lifetime', () => { + beforeEach(() => vi.resetModules()) + afterEach(() => vi.unstubAllGlobals()) + it('drops site attestation when the same host later omits the field or is repaired', async () => { + let site: string | undefined = 'bigmodel' + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(): Promise> { + return Promise.resolve({ + id: 'site', + ok: true, + result: { settings: site ? { zcodePlanSite: site } : {} }, + _meta: { runtimeId: 'runtime-1' } + }) + } + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + expect((await globals.window.api.settings.get()).zcodePlanSite).toBe('bigmodel') + expect((await globals.window.api.settings.set({ uiLanguage: 'en' })).zcodePlanSite).toBe( + 'bigmodel' + ) + const { webRuntimeState } = await import('./preload-api/web-runtime-session') + const environment = webRuntimeState.activeEnvironment + if (!environment) { + throw new Error('Missing synthetic paired host') + } + environment.pairingRevision = (environment.pairingRevision ?? environment.createdAt) + 1 + expect(globals.window.api.settings.getSync?.()?.zcodePlanSite).toBeUndefined() + site = undefined + expect((await globals.window.api.settings.get()).zcodePlanSite).toBeUndefined() + }) +}) 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 2a3fac5d5459..995b97ebbf48 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -1,4 +1,5 @@ import type { ExecutionHostId } from './execution-host' +import type { OrcaWorkspaceLayout } from './orca-workspace-layout' import type { GitHubProjectSettings } from './github/project-types' import type { VoiceSettings } from './speech-types' import type { AiVaultSearchSettings } from './ai-vault-search-settings' @@ -26,6 +27,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 +402,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. */ @@ -537,11 +541,7 @@ export type GlobalSettings = { aiVaultSearch?: AiVaultSearchSettings } -export type OrcaWorkspaceLayout = { - path: string - nestWorkspaces: boolean -} - // Re-exported so existing importers keep one entry point; the shape lives in its // own file because this one is at the max-lines ceiling. export type { GhosttyImportPreview } from './ghostty-import-preview' +export type { OrcaWorkspaceLayout } from './orca-workspace-layout' diff --git a/src/shared/orca-workspace-layout.ts b/src/shared/orca-workspace-layout.ts new file mode 100644 index 000000000000..8019ee0919e9 --- /dev/null +++ b/src/shared/orca-workspace-layout.ts @@ -0,0 +1,4 @@ +export type OrcaWorkspaceLayout = { + path: string + nestWorkspaces: boolean +} 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..ba1770cf9562 --- /dev/null +++ b/src/shared/zcode-plan-sites.ts @@ -0,0 +1,32 @@ +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 = { + detailsUnavailable?: boolean + apiKeyConfigured: boolean + zcodeCliConfigured: boolean + apiKeyProtection: SecretAtRestProtection | null +}