From cff4e770e7870cc336fa781d2475cdf9c4d6c184 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 01:02:03 -0700
Subject: [PATCH 1/9] feat(accounts): link standalone GLM Coding Plans
Adapt the reviewed GLM accounts contribution to current main, retain Antigravity behavior, guard late credential results, expose storage protection, and redact quota errors.
Co-authored-by: Luchong
---
.../register-core-handlers.test.ts | 8 +
.../register-core-handlers.ts | 2 +
src/main/ipc/zcode-plan-credentials.test.ts | 68 ++++
src/main/ipc/zcode-plan-credentials.ts | 49 +++
.../rate-limits/service-zcode-usage.test.ts | 189 ++++++++++
.../service/service-account-refresh.ts | 9 +
.../service/service-configuration.ts | 7 +
.../service/service-fetch-policy.ts | 13 +
.../service/service-fetch-targets.ts | 13 +
.../service/service-full-cycle-application.ts | 16 +-
.../service/service-full-cycle-preparation.ts | 34 +-
src/main/rate-limits/service/service-state.ts | 4 +
src/main/rate-limits/service/service-types.ts | 11 +
.../rate-limits/zcode-usage-fetcher.test.ts | 110 +++++-
src/main/rate-limits/zcode-usage-fetcher.ts | 65 +++-
src/main/runtime/runtime-client-settings.ts | 3 +
src/main/runtime/runtime-store-contract.ts | 1 +
.../startup/main-process-account-services.ts | 16 +
.../zcode/zcode-plan-api-key-store.test.ts | 206 +++++++++++
src/main/zcode/zcode-plan-api-key-store.ts | 169 +++++++++
src/preload/api-types.ts | 4 +-
src/preload/api/agent-account-api.ts | 10 +
.../api/zcode-plan-credentials-bridge.ts | 12 +
src/preload/index.ts | 2 +
.../src/components/settings/AccountsPane.tsx | 7 +-
.../ZcodePlanAccountsSection.test.tsx | 228 ++++++++++++
.../settings/ZcodePlanAccountsSection.tsx | 331 ++++++++++++++++++
.../components/settings/accounts-search.ts | 33 +-
.../settings/use-zcode-plan-credentials.ts | 81 +++++
.../settings/zcode-plan-usage-windows.tsx | 72 ++++
.../status-bar-provider-visibility.test.ts | 8 +
.../status-bar-provider-visibility.ts | 9 +-
.../usage-provider-settings-target.ts | 4 +-
.../status-bar/use-status-bar-controller.ts | 7 +-
src/renderer/src/i18n/locales/en.json | 51 +++
src/renderer/src/i18n/locales/zh.json | 51 +++
src/shared/default-global-settings.ts | 1 +
src/shared/global-settings-types.ts | 3 +
src/shared/rate-limit-state-factory.ts | 1 +
src/shared/rate-limit-types.ts | 6 +
.../rpc-contract/client-settings-params.ts | 1 +
src/shared/zcode-plan-sites.ts | 31 ++
42 files changed, 1924 insertions(+), 22 deletions(-)
create mode 100644 src/main/ipc/zcode-plan-credentials.test.ts
create mode 100644 src/main/ipc/zcode-plan-credentials.ts
create mode 100644 src/main/rate-limits/service-zcode-usage.test.ts
create mode 100644 src/main/zcode/zcode-plan-api-key-store.test.ts
create mode 100644 src/main/zcode/zcode-plan-api-key-store.ts
create mode 100644 src/preload/api/zcode-plan-credentials-bridge.ts
create mode 100644 src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
create mode 100644 src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx
create mode 100644 src/renderer/src/components/settings/use-zcode-plan-credentials.ts
create mode 100644 src/renderer/src/components/settings/zcode-plan-usage-windows.tsx
create mode 100644 src/shared/zcode-plan-sites.ts
diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts
index 4e67441ea343..99d459501aa1 100644
--- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts
+++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts
@@ -36,6 +36,7 @@ const {
registerAgentHookHandlersMock,
registerClaudeAccountHandlersMock,
registerMiniMaxCredentialsHandlersMock,
+ registerZcodePlanCredentialsHandlersMock,
registerGrokAccountHandlersMock,
registerCursorAccountHandlersMock,
registerClipboardHandlersMock,
@@ -103,6 +104,7 @@ const {
registerAgentHookHandlersMock: vi.fn(),
registerClaudeAccountHandlersMock: vi.fn(),
registerMiniMaxCredentialsHandlersMock: vi.fn(),
+ registerZcodePlanCredentialsHandlersMock: vi.fn(),
registerGrokAccountHandlersMock: vi.fn(),
registerCursorAccountHandlersMock: vi.fn(),
registerClipboardHandlersMock: vi.fn(),
@@ -336,6 +338,10 @@ vi.mock('../minimax-credentials', () => ({
registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
}))
+vi.mock('../zcode-plan-credentials', () => ({
+ registerZcodePlanCredentialsHandlers: registerZcodePlanCredentialsHandlersMock
+}))
+
vi.mock('../grok-accounts', () => ({
registerGrokAccountHandlers: registerGrokAccountHandlersMock
}))
@@ -441,6 +447,7 @@ describe('registerCoreHandlers', () => {
registerAgentHookHandlersMock.mockReset()
registerClaudeAccountHandlersMock.mockReset()
registerMiniMaxCredentialsHandlersMock.mockReset()
+ registerZcodePlanCredentialsHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
setTrustedClipboardRendererWebContentsIdMock.mockReset()
registerUpdaterHandlersMock.mockReset()
@@ -540,6 +547,7 @@ describe('registerCoreHandlers', () => {
expect(registerPetHandlersMock).toHaveBeenCalled()
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
+ expect(registerZcodePlanCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGrokAccountHandlersMock).toHaveBeenCalled()
expect(registerCursorAccountHandlersMock).toHaveBeenCalled()
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits, codexAccounts)
diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts
index d3a647cafd71..618dab409d9b 100644
--- a/src/main/ipc/register-core-handlers/register-core-handlers.ts
+++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts
@@ -62,6 +62,7 @@ import { registerCodexConfigSyncHandlers } from '../codex-config-sync'
import { getPtyIdForPaneKey } from '../pty'
import { registerClaudeAccountHandlers } from '../claude-accounts'
import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials'
+import { registerZcodePlanCredentialsHandlers } from '../zcode-plan-credentials'
import { registerGrokAccountHandlers } from '../grok-accounts'
import { registerCursorAccountHandlers } from '../cursor-accounts'
import { registerUpdaterHandlers } from '../../window/attach-main-window-services'
@@ -151,6 +152,7 @@ export function registerCoreHandlers(
registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService)
registerClaudeAccountHandlers(claudeAccounts)
registerMiniMaxCredentialsHandlers(rateLimits)
+ registerZcodePlanCredentialsHandlers(rateLimits)
registerGrokAccountHandlers()
registerCursorAccountHandlers()
registerRateLimitHandlers(rateLimits, codexAccounts)
diff --git a/src/main/ipc/zcode-plan-credentials.test.ts b/src/main/ipc/zcode-plan-credentials.test.ts
new file mode 100644
index 000000000000..ae675aeb6502
--- /dev/null
+++ b/src/main/ipc/zcode-plan-credentials.test.ts
@@ -0,0 +1,68 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { registerZcodePlanCredentialsHandlers } from './zcode-plan-credentials'
+
+const mocks = vi.hoisted(() => ({
+ handle: vi.fn(),
+ hasKey: vi.fn(() => false),
+ protection: vi.fn<() => 'sealed' | 'plaintext' | null>(() => null),
+ hasCli: vi.fn(() => false),
+ save: vi.fn(),
+ clear: vi.fn()
+}))
+vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } }))
+vi.mock('../zcode/zcode-plan-api-key-store', () => ({
+ hasZcodePlanApiKey: mocks.hasKey,
+ getZcodePlanApiKeyProtection: mocks.protection,
+ saveZcodePlanApiKey: mocks.save,
+ clearZcodePlanApiKey: mocks.clear
+}))
+vi.mock('../rate-limits/zcode-usage-fetcher', () => ({ hasZcodeCliPlanCredentials: mocks.hasCli }))
+
+function handler(channel: string) {
+ const registration = mocks.handle.mock.calls.find(([name]) => name === channel)
+ if (!registration) {
+ throw new Error('Handler missing')
+ }
+ return registration[1]
+}
+
+describe('GLM credential IPC', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.hasKey.mockReturnValue(false)
+ mocks.hasCli.mockReturnValue(false)
+ mocks.protection.mockReturnValue(null)
+ registerZcodePlanCredentialsHandlers(null)
+ })
+
+ it('returns only presence and protection without exposing the key', () => {
+ mocks.hasKey.mockReturnValue(true)
+ mocks.hasCli.mockReturnValue(true)
+ mocks.protection.mockReturnValue('sealed')
+ expect(handler('zcodePlanCredentials:getStatus')()).toEqual({
+ apiKeyConfigured: true,
+ zcodeCliConfigured: true,
+ apiKeyProtection: 'sealed'
+ })
+ })
+
+ it('validates an untyped IPC key before persistence', () => {
+ expect(() => handler('zcodePlanCredentials:saveApiKey')(null, 42)).toThrow('must be a string')
+ expect(mocks.save).not.toHaveBeenCalled()
+ })
+
+ it('saves and removes keys while returning status only', () => {
+ mocks.save.mockImplementationOnce(() => mocks.hasKey.mockReturnValue(true))
+ mocks.clear.mockImplementationOnce(() => mocks.hasKey.mockReturnValue(false))
+ expect(handler('zcodePlanCredentials:saveApiKey')(null, 'synthetic-key')).toEqual({
+ apiKeyConfigured: true,
+ zcodeCliConfigured: false,
+ apiKeyProtection: null
+ })
+ expect(handler('zcodePlanCredentials:clearApiKey')()).toEqual({
+ apiKeyConfigured: false,
+ zcodeCliConfigured: false,
+ apiKeyProtection: null
+ })
+ })
+})
diff --git a/src/main/ipc/zcode-plan-credentials.ts b/src/main/ipc/zcode-plan-credentials.ts
new file mode 100644
index 000000000000..9923acba3bb4
--- /dev/null
+++ b/src/main/ipc/zcode-plan-credentials.ts
@@ -0,0 +1,49 @@
+import { ipcMain } from 'electron'
+import {
+ clearZcodePlanApiKey,
+ getZcodePlanApiKeyProtection,
+ hasZcodePlanApiKey,
+ saveZcodePlanApiKey
+} from '../zcode/zcode-plan-api-key-store'
+import { hasZcodeCliPlanCredentials } from '../rate-limits/zcode-usage-fetcher'
+import type { RateLimitService } from '../rate-limits/service'
+import type { ZcodePlanCredentialsStatus } from '../../shared/zcode-plan-sites'
+
+function getZcodePlanCredentialsStatus(): ZcodePlanCredentialsStatus {
+ return {
+ apiKeyConfigured: hasZcodePlanApiKey(),
+ zcodeCliConfigured: hasZcodeCliPlanCredentials(),
+ apiKeyProtection: getZcodePlanApiKeyProtection()
+ }
+}
+
+// Why: fire-and-forget — callers get the persisted credential status immediately;
+// the rate-limit refresh runs in the background and only logs on failure.
+function refreshAfterZcodePlanCredentialChange(
+ rateLimits: RateLimitService | null,
+ action: 'save' | 'clear'
+): void {
+ rateLimits?.invalidateZcodeCredentialState()
+ void rateLimits?.refresh().catch((error: unknown) => {
+ console.error(`[zcode] failed to trigger rate-limit refresh after ${action}:`, error)
+ })
+}
+
+export function registerZcodePlanCredentialsHandlers(rateLimits: RateLimitService | null): void {
+ ipcMain.handle('zcodePlanCredentials:getStatus', () => getZcodePlanCredentialsStatus())
+ ipcMain.handle('zcodePlanCredentials:saveApiKey', (_event, key: string) => {
+ // Validate the IPC argument in the main process; the renderer-declared type
+ // is compile-time only and the value arrives as unknown over IPC.
+ if (typeof key !== 'string') {
+ throw new Error('GLM Coding Plan API key must be a string')
+ }
+ saveZcodePlanApiKey(key)
+ refreshAfterZcodePlanCredentialChange(rateLimits, 'save')
+ return getZcodePlanCredentialsStatus()
+ })
+ ipcMain.handle('zcodePlanCredentials:clearApiKey', () => {
+ clearZcodePlanApiKey()
+ refreshAfterZcodePlanCredentialChange(rateLimits, 'clear')
+ return getZcodePlanCredentialsStatus()
+ })
+}
diff --git a/src/main/rate-limits/service-zcode-usage.test.ts b/src/main/rate-limits/service-zcode-usage.test.ts
new file mode 100644
index 000000000000..7aa9e911c602
--- /dev/null
+++ b/src/main/rate-limits/service-zcode-usage.test.ts
@@ -0,0 +1,189 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ProviderRateLimits } from '../../shared/rate-limit-types'
+import { RateLimitService } from './service'
+import { fetchClaudeRateLimits } from './claude-fetcher'
+import { fetchCodexRateLimits } from './codex-fetcher'
+import { fetchZcodeRateLimits } from './zcode-usage-fetcher'
+import { hasZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store'
+import {
+ deferred,
+ okProvider,
+ resetRateLimitProviderMocks
+} from './rate-limit-service-test-harness'
+
+vi.mock('./claude-fetcher', () => ({
+ fetchClaudeRateLimits: vi.fn(),
+ fetchManagedAccountUsage: vi.fn()
+}))
+
+vi.mock('./codex-fetcher', () => ({
+ consumeCodexRateLimitResetCredit: vi.fn(),
+ fetchCodexRateLimits: vi.fn()
+}))
+
+vi.mock('./gemini-usage-fetcher', () => ({
+ fetchGeminiRateLimits: vi.fn()
+}))
+
+vi.mock('./antigravity-usage-fetcher', () => ({
+ fetchAntigravityRateLimits: vi.fn()
+}))
+
+vi.mock('./kimi-fetcher', () => ({
+ fetchKimiRateLimits: vi.fn()
+}))
+
+vi.mock('./opencode-go-usage-source-selection', () => ({
+ fetchOpenCodeGoUsage: vi.fn()
+}))
+
+vi.mock('./zcode-usage-fetcher', () => ({
+ fetchZcodeRateLimits: vi.fn(),
+ hasZcodeCliPlanCredentials: vi.fn(() => false)
+}))
+
+vi.mock('./minimax/minimax-fetcher', () => ({
+ fetchMiniMaxRateLimits: vi.fn()
+}))
+
+vi.mock('./grok-fetcher', () => ({
+ fetchGrokRateLimits: vi.fn()
+}))
+
+vi.mock('./cursor-fetcher', () => ({
+ fetchCursorRateLimits: vi.fn()
+}))
+
+vi.mock('./cursor-auth', () => ({
+ readCursorAuthSession: vi.fn()
+}))
+
+vi.mock('./grok-auth', () => ({
+ readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
+}))
+
+vi.mock('../minimax/minimax-cookie-store', () => ({
+ hasMiniMaxSessionCookie: vi.fn(() => false)
+}))
+
+vi.mock('../minimax/minimax-api-key-store', () => ({
+ hasMiniMaxApiKey: vi.fn(() => false)
+}))
+
+vi.mock('../zcode/zcode-plan-api-key-store', () => ({
+ hasZcodePlanApiKey: vi.fn(() => false),
+ readZcodePlanApiKey: vi.fn(() => null),
+ saveZcodePlanApiKey: vi.fn(),
+ clearZcodePlanApiKey: vi.fn()
+}))
+
+describe('RateLimitService zcode plan credentials', () => {
+ beforeEach(() => {
+ resetRateLimitProviderMocks()
+ vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7))
+ vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20))
+ })
+
+ it('fetches zcode with the site-resolved plan credential when a resolver is set', async () => {
+ const service = new RateLimitService()
+ service.setZcodePlanConfigResolver(() => ({ site: 'bigmodel', apiKey: 'glm-key' }))
+ vi.mocked(hasZcodePlanApiKey).mockReturnValue(true)
+ vi.mocked(fetchZcodeRateLimits).mockResolvedValueOnce(okProvider('zcode', 33, Date.now()))
+
+ await service.refresh()
+
+ expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(1)
+ expect(fetchZcodeRateLimits).toHaveBeenCalledWith({
+ signal: expect.any(AbortSignal),
+ planCredential: { apiKey: 'glm-key', baseUrl: 'https://open.bigmodel.cn' }
+ })
+ const state = service.getState()
+ expect(state.zcode?.status).toBe('ok')
+ expect(state.zcode?.session?.usedPercent).toBe(33)
+ expect(state.zcodePlanApiKeyConfigured).toBe(true)
+ })
+
+ it('passes no plan credential while no key is saved and still fetches via the CLI config', async () => {
+ const service = new RateLimitService()
+ service.setZcodePlanConfigResolver(() => ({ site: 'zai', apiKey: '' }))
+ vi.mocked(fetchZcodeRateLimits).mockResolvedValueOnce(okProvider('zcode', 12, Date.now()))
+
+ await service.refresh()
+
+ expect(fetchZcodeRateLimits).toHaveBeenCalledWith({
+ signal: expect.any(AbortSignal),
+ planCredential: null
+ })
+ expect(service.getState().zcode?.session?.usedPercent).toBe(12)
+ })
+
+ it('surfaces a resolver failure as a zcode-only error without fetching', async () => {
+ const service = new RateLimitService()
+ service.setZcodePlanConfigResolver(() => {
+ throw new Error('GLM Coding Plan API key could not be decrypted')
+ })
+
+ await service.refresh()
+
+ expect(fetchZcodeRateLimits).not.toHaveBeenCalled()
+ const zcode = service.getState().zcode
+ expect(zcode?.status).toBe('error')
+ expect(zcode?.error).toContain('could not be decrypted')
+ expect(zcode?.usageMetadata?.failureKind).toBe('keychain-unavailable')
+ expect(service.getState().claude?.status).toBe('ok')
+ })
+
+ it('discards the previous zcode snapshot when the saved site changes', async () => {
+ const service = new RateLimitService()
+ let site: 'zai' | 'bigmodel' = 'zai'
+ service.setZcodePlanConfigResolver(() => ({ site, apiKey: 'glm-key' }))
+ vi.mocked(fetchZcodeRateLimits)
+ .mockResolvedValueOnce(okProvider('zcode', 40, Date.now()))
+ .mockRejectedValueOnce(new Error('Zcode quota request failed (401)'))
+
+ await service.refresh()
+ expect(service.getState().zcode?.session?.usedPercent).toBe(40)
+
+ site = 'bigmodel'
+ await service.refresh()
+
+ const state = service.getState()
+ expect(fetchZcodeRateLimits).toHaveBeenLastCalledWith({
+ signal: expect.any(AbortSignal),
+ planCredential: { apiKey: 'glm-key', baseUrl: 'https://open.bigmodel.cn' }
+ })
+ expect(state.zcode?.status).toBe('error')
+ expect(state.zcode?.session).toBeNull()
+ })
+
+ it('does not apply an in-flight zcode result after credential invalidation', async () => {
+ const service = new RateLimitService()
+ const firstZcode = deferred()
+ const secondZcode = deferred()
+ service.setZcodePlanConfigResolver(() => ({ site: 'zai', apiKey: 'glm-key' }))
+ vi.mocked(fetchZcodeRateLimits)
+ .mockImplementationOnce(() => firstZcode.promise)
+ .mockImplementationOnce(() => secondZcode.promise)
+
+ const firstRefresh = service.refresh()
+ await vi.waitFor(() => expect(service.getState().claude?.status).toBe('ok'))
+
+ service.invalidateZcodeCredentialState()
+ const queuedRefresh = service.refresh()
+ await Promise.resolve()
+
+ firstZcode.resolve(okProvider('zcode', 50, Date.now()))
+ await vi.waitFor(() => expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(2))
+
+ expect(service.getState().zcode?.status).toBe('fetching')
+ expect(service.getState().zcode?.session).toBeNull()
+
+ secondZcode.resolve(okProvider('zcode', 10, Date.now()))
+ await firstRefresh
+ await queuedRefresh
+
+ const state = service.getState()
+ expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(2)
+ expect(state.zcode?.session?.usedPercent).toBe(10)
+ })
+})
diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts
index ed8578e36940..e6090e28228c 100644
--- a/src/main/rate-limits/service/service-account-refresh.ts
+++ b/src/main/rate-limits/service/service-account-refresh.ts
@@ -38,6 +38,15 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna
})
}
+ invalidateZcodeCredentialState(): void {
+ this.zcodeFetchGeneration += 1
+ // Why: saving/forgetting the plan key can race an in-flight fetch; clear the visible snapshot before any old-key result returns.
+ this.updateState({
+ ...this.state,
+ zcode: this.withFetchingStatus(null, 'zcode')
+ })
+ }
+
async refreshForCodexAccountChange(
outgoingAccountId?: string | null,
target?: CodexAccountSelectionTarget
diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts
index 2a788b1cf935..09dbde8a8600 100644
--- a/src/main/rate-limits/service/service-configuration.ts
+++ b/src/main/rate-limits/service/service-configuration.ts
@@ -1,6 +1,7 @@
import type { BrowserWindow } from 'electron'
import { hasMiniMaxSessionCookie } from '../../minimax/minimax-cookie-store'
import { hasMiniMaxApiKey } from '../../minimax/minimax-api-key-store'
+import { hasZcodePlanApiKey } from '../../zcode/zcode-plan-api-key-store'
import { RateLimitServiceAccountRefresh } from './service-account-refresh'
import {
type CodexAccountSelectionTarget,
@@ -10,6 +11,7 @@ import {
type ClaudeAuthPreparationResolver,
type OpenCodeGoRateLimitConfig,
type MiniMaxRateLimitConfig,
+ type ZcodePlanRateLimitConfig,
type GeminiCliOAuthEnabledResolver,
type InactiveCodexAccountInfo,
type InactiveClaudeAccountInfo,
@@ -48,6 +50,10 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco
this.miniMaxConfigResolver = resolver
}
+ setZcodePlanConfigResolver(resolver: () => ZcodePlanRateLimitConfig): void {
+ this.zcodePlanConfigResolver = resolver
+ }
+
setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void {
this.geminiCliOAuthEnabledResolver = resolver
}
@@ -125,6 +131,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco
// Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads.
minimaxCookieConfigured: hasMiniMaxSessionCookie(),
minimaxApiKeyConfigured: hasMiniMaxApiKey(),
+ zcodePlanApiKeyConfigured: hasZcodePlanApiKey(),
opencodeGoApiKeyConfigured: this.openCodeGoApiKeyConfigured,
grokAuthConfigured: this.grokAuthConfigured,
cursorAuthConfigured: this.cursorAuthConfigured,
diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts
index 12542f311db9..cfd4a6e9d58e 100644
--- a/src/main/rate-limits/service/service-fetch-policy.ts
+++ b/src/main/rate-limits/service/service-fetch-policy.ts
@@ -24,6 +24,19 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT
}
}
+ protected getZcodePlanCredentialError(message: string): ProviderRateLimits {
+ return {
+ provider: 'zcode',
+ session: null,
+ weekly: null,
+ monthly: null,
+ updatedAt: Date.now(),
+ error: message,
+ status: 'error',
+ usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' }
+ }
+ }
+
// Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive.
// A live post flips the snapshot back to ok, but the endpoint's Retry-After is still binding.
protected isRetryAfterActive(limits: ProviderRateLimits | null): boolean {
diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts
index a24322a54712..93dc9e20553e 100644
--- a/src/main/rate-limits/service/service-fetch-targets.ts
+++ b/src/main/rate-limits/service/service-fetch-targets.ts
@@ -6,6 +6,7 @@ import {
type ClaudeRuntimeAuthPreparation,
type CodexAccountSelectionTarget,
type MiniMaxResolvedConfig,
+ type ZcodePlanResolvedConfig,
type NormalizedCodexAccountSelectionTarget,
type NormalizedClaudeAccountSelectionTarget,
type ProviderRateLimits,
@@ -213,4 +214,16 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul
}
}
}
+
+ protected resolveZcodePlanConfig(): ZcodePlanResolvedConfig {
+ try {
+ return {
+ config: this.zcodePlanConfigResolver?.() ?? { site: 'zai', apiKey: '' },
+ error: null
+ }
+ } catch (error) {
+ // Why: an undecryptable saved key must not abort every provider's refresh; surface it as ZCode-only state instead.
+ return { config: { site: 'zai', apiKey: '' }, error: toErrorMessage(error) }
+ }
+ }
}
diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts
index b6a9b40373ee..2731f882be5a 100644
--- a/src/main/rate-limits/service/service-full-cycle-application.ts
+++ b/src/main/rate-limits/service/service-full-cycle-application.ts
@@ -25,6 +25,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
opencodeGeneration,
miniMaxConfigChanged,
miniMaxGeneration,
+ zcodeConfigChanged,
+ zcodeGeneration,
claudeFetchGated,
results: [
claudeResult,
@@ -201,6 +203,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
const grok = settleSiblingProviderResult('grok', grokSettled)
const cursor = settleSiblingProviderResult('cursor', cursorSettled)
const zcode = settleSiblingProviderResult('zcode', zcodeSettled)
+ const shouldApplyZcode = zcodeGeneration === this.zcodeFetchGeneration
const antigravity = settleSiblingProviderResult('antigravity', antigravitySettled)
// Why: the stale policy keeps a recent snapshot through a failed refresh, but
// a snapshot belonging to a different Cursor account must not survive the
@@ -221,16 +224,21 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
previousZcodeAccount === zcodeAccount
this.trackActiveFailureStreak('grok', grok)
this.trackActiveFailureStreak('cursor', cursor)
- this.trackActiveFailureStreak('zcode', zcode)
+ if (shouldApplyZcode) {
+ this.trackActiveFailureStreak('zcode', zcode)
+ }
this.trackActiveFailureStreak('antigravity', antigravity)
this.updateState({
...this.state,
grok: this.applyStalePolicy(grok, previousState.grok),
cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor),
- zcode:
- zcode.status === 'error' && !sameZcodeAccount
+ zcode: !shouldApplyZcode
+ ? this.state.zcode
+ : zcodeConfigChanged
? zcode
- : this.applyStalePolicy(zcode, previousState.zcode),
+ : zcode.status === 'error' && !sameZcodeAccount
+ ? zcode
+ : this.applyStalePolicy(zcode, previousState.zcode),
antigravity: this.applyStalePolicy(antigravity, previousState.antigravity)
})
}
diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts
index 5395ace5fbff..b09b440f2932 100644
--- a/src/main/rate-limits/service/service-full-cycle-preparation.ts
+++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts
@@ -7,6 +7,7 @@ import { fetchCursorRateLimits } from '../cursor-fetcher'
import { readCursorAuthSession } from '../cursor-auth'
import { fetchZcodeRateLimits } from '../zcode-usage-fetcher'
import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher'
+import { ZCODE_PLAN_SITE_BASE_URLS } from '../../../shared/zcode-plan-sites'
import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher'
import { createHash } from 'node:crypto'
import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection'
@@ -35,6 +36,8 @@ export type FetchAllCyclePrepared = {
opencodeGeneration: number
miniMaxConfigChanged: boolean
miniMaxGeneration: number
+ zcodeConfigChanged: boolean
+ zcodeGeneration: number
claudeFetchGated: boolean
results: [
PromiseSettledResult,
@@ -116,6 +119,25 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
}
const miniMaxGeneration = this.minimaxFetchGeneration
+ const zcodePlanConfigResult = this.resolveZcodePlanConfig()
+ const zcodePlanApiKey = zcodePlanConfigResult.config.apiKey
+ // Why digest, not the key: this string only has to change when the credential does.
+ const currentZcodeConfigHash = zcodePlanApiKey
+ ? `${zcodePlanConfigResult.config.site}|${createHash('sha256').update(zcodePlanApiKey).digest('hex')}`
+ : (zcodePlanConfigResult.error ?? '')
+ const zcodeConfigChanged = currentZcodeConfigHash !== this.lastZcodeConfigHash
+ if (zcodeConfigChanged) {
+ this.lastZcodeConfigHash = currentZcodeConfigHash
+ this.zcodeFetchGeneration += 1
+ }
+ const zcodeGeneration = this.zcodeFetchGeneration
+ const zcodePlanCredential = zcodePlanApiKey
+ ? {
+ apiKey: zcodePlanApiKey,
+ baseUrl: ZCODE_PLAN_SITE_BASE_URLS[zcodePlanConfigResult.config.site]
+ }
+ : null
+
// Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change).
this.updateState({
...previousState,
@@ -135,7 +157,9 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
: this.withFetchingStatus(previousState.minimax, 'minimax'),
grok: this.withFetchingStatus(previousState.grok, 'grok'),
cursor: this.withFetchingStatus(previousState.cursor, 'cursor'),
- zcode: this.withFetchingStatus(previousState.zcode, 'zcode')
+ zcode: zcodeConfigChanged
+ ? this.withFetchingStatus(null, 'zcode')
+ : this.withFetchingStatus(previousState.zcode, 'zcode')
})
// Why: the Cursor probe reads the macOS Keychain, so it is awaited inside the
@@ -150,7 +174,11 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
(reason) => ({ status: 'rejected', reason }) as const
)
- const zcodeResultPromise = fetchZcodeRateLimits({ signal }).then(
+ const zcodeResultPromise = (
+ zcodePlanConfigResult.error
+ ? Promise.resolve(this.getZcodePlanCredentialError(zcodePlanConfigResult.error))
+ : fetchZcodeRateLimits({ signal, planCredential: zcodePlanCredential })
+ ).then(
(value) => ({ status: 'fulfilled', value }) as const,
(reason) => ({ status: 'rejected', reason }) as const
)
@@ -238,6 +266,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
opencodeGeneration,
miniMaxConfigChanged,
miniMaxGeneration,
+ zcodeConfigChanged,
+ zcodeGeneration,
claudeFetchGated,
results: [
claudeResult,
diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts
index 7af82c6c3135..01c4456cf489 100644
--- a/src/main/rate-limits/service/service-state.ts
+++ b/src/main/rate-limits/service/service-state.ts
@@ -13,6 +13,7 @@ import {
type ClaudeAuthPreparationResolver,
type OpenCodeGoRateLimitConfig,
type MiniMaxRateLimitConfig,
+ type ZcodePlanRateLimitConfig,
type GeminiCliOAuthEnabledResolver,
type NormalizedCodexAccountSelectionTarget,
type NormalizedClaudeAccountSelectionTarget,
@@ -84,8 +85,10 @@ export abstract class RateLimitServiceState {
protected lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null
protected opencodeFetchGeneration = 0
protected minimaxFetchGeneration = 0
+ protected zcodeFetchGeneration = 0
protected lastOpencodeConfigHash = ''
protected lastMiniMaxConfigHash = ''
+ protected lastZcodeConfigHash = ''
protected codexHomePathResolver: CodexHomePathResolver | null = null
protected codexFetchTarget: NormalizedCodexAccountSelectionTarget = {
runtime: 'host',
@@ -100,6 +103,7 @@ export abstract class RateLimitServiceState {
}
protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null
protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null
+ protected zcodePlanConfigResolver: (() => ZcodePlanRateLimitConfig) | null = null
protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null
protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null
protected inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null
diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts
index a3998c13abe4..bc6902e2fa56 100644
--- a/src/main/rate-limits/service/service-types.ts
+++ b/src/main/rate-limits/service/service-types.ts
@@ -1,4 +1,5 @@
import type { ProviderRateLimits } from '../../../shared/rate-limit-types'
+import type { ZcodePlanSite } from '../../../shared/zcode-plan-sites'
import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service'
import type { ClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection'
import type { KimiHomeResolution } from '../../kimi/kimi-runtime-home'
@@ -61,6 +62,16 @@ export type MiniMaxResolvedConfig = {
error: string | null
}
+export type ZcodePlanRateLimitConfig = {
+ site: ZcodePlanSite
+ apiKey: string
+}
+
+export type ZcodePlanResolvedConfig = {
+ config: ZcodePlanRateLimitConfig
+ error: string | null
+}
+
export type GeminiCliOAuthEnabledResolver = () => boolean
export type ActiveRateLimitProvider = ProviderRateLimits['provider']
export type ActiveProviderState = {
diff --git a/src/main/rate-limits/zcode-usage-fetcher.test.ts b/src/main/rate-limits/zcode-usage-fetcher.test.ts
index b587d196be0f..aadca4c17a59 100644
--- a/src/main/rate-limits/zcode-usage-fetcher.test.ts
+++ b/src/main/rate-limits/zcode-usage-fetcher.test.ts
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { fetchZcodeRateLimits } from './zcode-usage-fetcher'
+import { fetchZcodeRateLimits, hasZcodeCliPlanCredentials } from './zcode-usage-fetcher'
let dir: string
let configPath: string
@@ -289,4 +289,112 @@ describe('fetchZcodeRateLimits', () => {
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('parse')
})
+
+ it('prefers the Orca-saved plan credential over the ZCode CLI config', async () => {
+ writeConfig()
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ success: true,
+ data: {
+ level: 'pro',
+ limits: [
+ { type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 30 },
+ { type: 'CREDIT_LIMIT', unit: 6, number: 1, percentage: 60 }
+ ]
+ }
+ })
+ )
+ )
+
+ const result = await fetchZcodeRateLimits({
+ configPath,
+ planCredential: { apiKey: 'orca-saved-key', baseUrl: 'https://api.z.ai' }
+ })
+
+ expect(result.status).toBe('ok')
+ const [url, init] = vi.mocked(fetch).mock.calls[0]
+ expect(String(url)).toBe('https://api.z.ai/api/monitor/usage/quota/limit')
+ expect(new Headers(init?.headers).get('Authorization')).toBe('orca-saved-key')
+ expect(result.usageMetadata?.credentialSource).toBe('orca-plan')
+ expect(JSON.stringify(result)).not.toContain('orca-saved-key')
+ })
+
+ it('uses the BigModel host for the mainland plan site', async () => {
+ writeFileSync(configPath, JSON.stringify({ provider: {} }))
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ success: true,
+ data: { limits: [{ type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 10 }] }
+ })
+ )
+ )
+
+ const result = await fetchZcodeRateLimits({
+ configPath,
+ planCredential: { apiKey: 'bigmodel-key', baseUrl: 'https://open.bigmodel.cn' }
+ })
+
+ expect(result.status).toBe('ok')
+ expect(String(vi.mocked(fetch).mock.calls[0][0])).toBe(
+ 'https://open.bigmodel.cn/api/monitor/usage/quota/limit'
+ )
+ })
+
+ it('rejects a plan credential whose base URL is not a supported site', async () => {
+ vi.mocked(fetch).mockResolvedValue(new Response('{}'))
+
+ const result = await fetchZcodeRateLimits({
+ configPath,
+ planCredential: { apiKey: 'key', baseUrl: 'https://evil.example.com' }
+ })
+
+ expect(result.status).toBe('error')
+ expect(result.error).toBe('The saved GLM Coding Plan API key is unusable')
+ expect(fetch).not.toHaveBeenCalled()
+ })
+
+ it('reports an unusable plan credential instead of switching to the CLI config', async () => {
+ writeConfig()
+ vi.mocked(fetch).mockResolvedValue(new Response('{}'))
+
+ const result = await fetchZcodeRateLimits({
+ configPath,
+ planCredential: { apiKey: 'bad\r\nkey', baseUrl: 'https://api.z.ai' }
+ })
+
+ expect(result.status).toBe('error')
+ expect(result.error).toBe('The saved GLM Coding Plan API key is unusable')
+ expect(fetch).not.toHaveBeenCalled()
+ expect(result.usageMetadata?.credentialSource).toBeUndefined()
+ })
+})
+
+describe('hasZcodeCliPlanCredentials', () => {
+ it('detects a usable CLI config', async () => {
+ writeConfig()
+
+ expect(hasZcodeCliPlanCredentials(configPath)).toBe(true)
+ })
+
+ it('reports false without a config file', async () => {
+ expect(hasZcodeCliPlanCredentials(configPath)).toBe(false)
+ })
+})
+
+describe('quota credential privacy', () => {
+ it.each(['network', 'response'])('redacts a credential echoed by a %s error', async (kind) => {
+ writeConfig()
+ if (kind === 'network') {
+ vi.mocked(fetch).mockRejectedValue(new Error('Rejected test-secret'))
+ } else {
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(JSON.stringify({ success: false, msg: 'Rejected test-secret' }))
+ )
+ }
+ const result = await fetchZcodeRateLimits({ configPath })
+ expect(result.error).toBe('Rejected [redacted]')
+ expect(JSON.stringify(result)).not.toContain('test-secret')
+ })
})
diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts
index 852def603e32..2eba29a4f98a 100644
--- a/src/main/rate-limits/zcode-usage-fetcher.ts
+++ b/src/main/rate-limits/zcode-usage-fetcher.ts
@@ -26,6 +26,14 @@ type ZcodeUsageCredentials = {
authProvenance: string
}
+/** A GLM Coding Plan key saved through Orca's AI Provider Accounts; takes priority over the ZCode CLI config. */
+export type ZcodePlanCredential = {
+ apiKey: string
+ baseUrl: string
+}
+
+export const ZCODE_PLAN_CREDENTIAL_SOURCE = 'orca-plan'
+
// Why readers and not casts: both JSON sources are outside our control — a user-edited
// config file and a remote response — so their shape is a guess until something checks it.
function isRecord(value: unknown): value is Record {
@@ -75,6 +83,10 @@ function failed(
}
}
+function redactCredential(error: string, apiKey: string): string {
+ return error.replaceAll(apiKey, '[redacted]')
+}
+
function readCredentials(configPath: string): ZcodeUsageCredentials | null {
let config: Record | null
try {
@@ -102,8 +114,20 @@ function readCredentials(configPath: string): ZcodeUsageCredentials | null {
) {
return null
}
+ return resolveUsageCredentials(apiKey, baseURL, mainProvider)
+}
+
+function resolveUsageCredentials(
+ key: string,
+ baseUrl: string,
+ identity: string
+): ZcodeUsageCredentials | null {
+ const apiKey = key.trim()
+ if (!apiKey || /[\r\n]/.test(apiKey)) {
+ return null
+ }
try {
- const parsed = new URL(baseURL)
+ const parsed = new URL(baseUrl)
if (
parsed.protocol !== 'https:' ||
!SUPPORTED_HOSTS.has(parsed.hostname) ||
@@ -112,10 +136,10 @@ function readCredentials(configPath: string): ZcodeUsageCredentials | null {
return null
}
return {
- apiKey: apiKey.trim(),
+ apiKey,
quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`,
authProvenance: createHmac('sha256', CREDENTIAL_IDENTITY_KEY)
- .update(JSON.stringify([mainProvider, parsed.origin, apiKey.trim()]))
+ .update(JSON.stringify([identity, parsed.origin, apiKey]))
.digest('hex')
}
} catch {
@@ -123,6 +147,12 @@ function readCredentials(configPath: string): ZcodeUsageCredentials | null {
}
}
+export function hasZcodeCliPlanCredentials(
+ configPath = join(homedir(), '.zcode', 'cli', 'config.json')
+): boolean {
+ return readCredentials(configPath) !== null
+}
+
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
@@ -182,14 +212,28 @@ function asWindow(limit: QuotaLimit | undefined): RateLimitWindow | null {
export async function fetchZcodeRateLimits(
options: {
configPath?: string
+ planCredential?: ZcodePlanCredential | null
signal?: AbortSignal
} = {}
): Promise {
const configPath = options.configPath ?? join(homedir(), '.zcode', 'cli', 'config.json')
- const credentials = readCredentials(configPath)
+ const planCredentials = options.planCredential
+ ? resolveUsageCredentials(
+ options.planCredential.apiKey,
+ options.planCredential.baseUrl,
+ ZCODE_PLAN_CREDENTIAL_SOURCE
+ )
+ : null
+ if (!planCredentials && options.planCredential) {
+ // Why: a saved-but-unusable key must surface as its own error; silently
+ // falling back to the CLI config would show a different account's quota.
+ return failed('The saved GLM Coding Plan API key is unusable', 'parse', '')
+ }
+ const credentials = planCredentials ?? readCredentials(configPath)
if (!credentials) {
return unavailable('ZCode Coding Plan credentials are not configured')
}
+ const credentialSource = planCredentials ? ZCODE_PLAN_CREDENTIAL_SOURCE : configPath
let response: Response
try {
@@ -208,7 +252,10 @@ export async function fetchZcodeRateLimits(
})
} catch (error) {
return failed(
- error instanceof Error ? error.message : 'ZCode quota request failed',
+ redactCredential(
+ error instanceof Error ? error.message : 'ZCode quota request failed',
+ credentials.apiKey
+ ),
'network',
credentials.authProvenance
)
@@ -239,7 +286,11 @@ export async function fetchZcodeRateLimits(
) {
const msg = payload?.msg
const message = typeof msg === 'string' ? msg : 'Invalid ZCode quota response'
- return failed(message, 'parse', credentials.authProvenance)
+ return failed(
+ redactCredential(message, credentials.apiKey),
+ 'parse',
+ credentials.authProvenance
+ )
}
const limits = reported.filter((value): value is QuotaLimit => isRecord(value))
@@ -270,7 +321,7 @@ export async function fetchZcodeRateLimits(
status: 'ok',
usageMetadata: {
source: 'web',
- credentialSource: configPath,
+ credentialSource,
authProvenance: credentials.authProvenance
}
}
diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts
index d29b9afea1ee..a312fc66dbe5 100644
--- a/src/main/runtime/runtime-client-settings.ts
+++ b/src/main/runtime/runtime-client-settings.ts
@@ -48,6 +48,7 @@ export type RuntimeClientSettings = Pick<
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'minimaxEndpoint'
+ | 'zcodePlanSite'
| 'prBotAuthorOverrides'
| 'artifactSharingEnabled'
| 'worktreeVisibilityDefaults'
@@ -84,6 +85,7 @@ export type RuntimeClientSettingsUpdate = Pick<
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'minimaxEndpoint'
+ | 'zcodePlanSite'
| 'prBotAuthorOverrides'
| 'worktreeVisibilityDefaults'
| 'machineName'
@@ -132,6 +134,7 @@ export class RuntimeClientSettingsController {
minimaxGroupId: settings.minimaxGroupId ?? '',
minimaxUsageModels: settings.minimaxUsageModels ?? 'general',
minimaxEndpoint: settings.minimaxEndpoint ?? 'overseas',
+ zcodePlanSite: settings.zcodePlanSite ?? 'zai',
prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [],
artifactSharingEnabled: isArtifactSharingEnabled(settings),
worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' },
diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts
index 43fe59b34e1a..a7f9af2a9069 100644
--- a/src/main/runtime/runtime-store-contract.ts
+++ b/src/main/runtime/runtime-store-contract.ts
@@ -103,6 +103,7 @@ export type RuntimeStore = {
minimaxGroupId?: GlobalSettings['minimaxGroupId']
minimaxUsageModels?: GlobalSettings['minimaxUsageModels']
minimaxEndpoint?: GlobalSettings['minimaxEndpoint']
+ zcodePlanSite?: GlobalSettings['zcodePlanSite']
prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides']
artifactSharingEnabled?: GlobalSettings['artifactSharingEnabled']
terminalQuickCommands?: GlobalSettings['terminalQuickCommands']
diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts
index 6abf73d66106..dc7071bd9e50 100644
--- a/src/main/startup/main-process-account-services.ts
+++ b/src/main/startup/main-process-account-services.ts
@@ -15,6 +15,7 @@ import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limi
import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home'
import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
import { readMiniMaxApiKey } from '../minimax/minimax-api-key-store'
+import { readZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store'
import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync'
import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection'
import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection'
@@ -102,6 +103,17 @@ export function initializeMainProcessAccountServices(): void {
)
})
}
+ // Why: the site picks the GLM Coding Plan quota host, so a stale snapshot from
+ // the previous site would otherwise sit in the status bar until the next poll.
+ if ('zcodePlanSite' in updates) {
+ state.rateLimits?.invalidateZcodeCredentialState()
+ void state.rateLimits?.refresh().catch((error: unknown) => {
+ console.warn(
+ '[rate-limits] Failed to refresh GLM Coding Plan usage after a settings change:',
+ error
+ )
+ })
+ }
})
state.rateLimits.setClaudeAuthPreparationResolver((target) =>
state.claudeRuntimeAuth!.prepareForRateLimitFetch(target)
@@ -129,6 +141,10 @@ export function initializeMainProcessAccountServices(): void {
apiKey
}
})
+ state.rateLimits.setZcodePlanConfigResolver(() => ({
+ site: store.getSettings().zcodePlanSite,
+ apiKey: readZcodePlanApiKey() ?? ''
+ }))
state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled)
state.rateLimits.setNetworkProxySettingsResolver(() => store.getSettings())
state.keybindings = new KeybindingService({
diff --git a/src/main/zcode/zcode-plan-api-key-store.test.ts b/src/main/zcode/zcode-plan-api-key-store.test.ts
new file mode 100644
index 000000000000..ed53cd28e7b3
--- /dev/null
+++ b/src/main/zcode/zcode-plan-api-key-store.test.ts
@@ -0,0 +1,206 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as ZcodePlanApiKeyStore from './zcode-plan-api-key-store'
+
+const safeStorageMock = vi.hoisted(() => ({
+ isEncryptionAvailable: vi.fn(() => true),
+ encryptString: vi.fn((value: string) => Buffer.from(value)),
+ decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
+}))
+
+const electronMock = vi.hoisted(() => ({
+ safeStorage: safeStorageMock
+}))
+
+vi.mock('electron', () => electronMock)
+
+const existsSyncMock = vi.fn()
+const readFileSyncMock = vi.fn()
+const rmSyncMock = vi.fn()
+const hardenExistingSecureFileMock = vi.fn()
+const writeSecureFileMock = vi.fn()
+const homedirMock = vi.fn(() => '/home/test')
+
+vi.mock('node:fs', () => ({
+ existsSync: existsSyncMock,
+ readFileSync: readFileSyncMock,
+ rmSync: rmSyncMock
+}))
+
+vi.mock('node:os', () => ({
+ homedir: homedirMock
+}))
+
+vi.mock('node:path', () => ({
+ join: (...parts: string[]) => parts.join('/')
+}))
+
+vi.mock('../../shared/secure-file', () => ({
+ hardenExistingSecureFile: hardenExistingSecureFileMock,
+ writeSecureFile: writeSecureFileMock
+}))
+
+const storePath = '/home/test/.orca/zcode-plan-api-key.enc'
+const envelope = (kind: 'encrypted' | 'plaintext', value: string): string =>
+ `orca-zcode-plan-api-key:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}`
+
+async function loadStore(): Promise {
+ return await import('./zcode-plan-api-key-store')
+}
+
+describe('zcode-plan-api-key-store', () => {
+ beforeEach(() => {
+ existsSyncMock.mockReset()
+ readFileSyncMock.mockReset()
+ rmSyncMock.mockReset()
+ hardenExistingSecureFileMock.mockReset()
+ writeSecureFileMock.mockReset()
+ writeSecureFileMock.mockReturnValue(true)
+ safeStorageMock.isEncryptionAvailable.mockReset()
+ safeStorageMock.encryptString.mockReset()
+ safeStorageMock.decryptString.mockReset()
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
+ safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value))
+ safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8'))
+ homedirMock.mockReturnValue('/home/test')
+ vi.resetModules()
+ })
+
+ it('reports unconfigured while no key file exists', async () => {
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+
+ expect(store.hasZcodePlanApiKey()).toBe(false)
+ expect(store.readZcodePlanApiKey()).toBeNull()
+ })
+
+ it('saves an encrypted envelope and reads it back through the cache', async () => {
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+
+ store.saveZcodePlanApiKey(' glm-secret ')
+
+ expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('encrypted', 'glm-secret'))
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'glm-secret')))
+ expect(store.hasZcodePlanApiKey()).toBe(true)
+ expect(store.readZcodePlanApiKey()).toBe('glm-secret')
+ })
+
+ it('warns and writes plaintext when safeStorage is unavailable', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+
+ store.saveZcodePlanApiKey('glm-secret')
+
+ expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('plaintext', 'glm-secret'))
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable'))
+ warn.mockRestore()
+ })
+
+ it('reads a plaintext envelope back without requiring safeStorage', async () => {
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', 'glm-secret')))
+ const store = await loadStore()
+
+ expect(store.readZcodePlanApiKey()).toBe('glm-secret')
+ expect(safeStorageMock.decryptString).not.toHaveBeenCalled()
+ })
+
+ it.each(['encrypted', 'plaintext'] as const)(
+ 'reports %s protection without decrypting',
+ async (kind) => {
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope(kind, 'glm-secret')))
+ const store = await loadStore()
+ expect(store.getZcodePlanApiKeyProtection()).toBe(
+ kind === 'encrypted' ? 'sealed' : 'plaintext'
+ )
+ expect(safeStorageMock.decryptString).not.toHaveBeenCalled()
+ }
+ )
+
+ it('rejects saving an empty key', async () => {
+ const store = await loadStore()
+
+ expect(() => store.saveZcodePlanApiKey(' ')).toThrow('GLM Coding Plan API key is required')
+ expect(writeSecureFileMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects a key with an interior newline instead of saving it', async () => {
+ const store = await loadStore()
+
+ expect(() => store.saveZcodePlanApiKey('glm\r\nsecret')).toThrow('must be a single line')
+ expect(writeSecureFileMock).not.toHaveBeenCalled()
+ })
+
+ it('refuses to keep an unrestricted plaintext key when hardening fails', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ writeSecureFileMock.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+
+ expect(() => store.saveZcodePlanApiKey('glm-secret')).toThrow(
+ 'could not be stored securely on this device'
+ )
+ expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true })
+ warn.mockRestore()
+ })
+
+ it('restores the previous envelope when a plaintext replacement cannot be restricted', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ const previous = Buffer.from(envelope('encrypted', 'old-key'))
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(previous)
+ writeSecureFileMock.mockReturnValueOnce(false).mockReturnValueOnce(true)
+ const store = await loadStore()
+
+ expect(() => store.saveZcodePlanApiKey('new-key')).toThrow(
+ 'could not be stored securely on this device'
+ )
+ // First write publishes the unrestricted replacement; the second restores
+ // the previous envelope so the user's old key survives the failed replace.
+ expect(writeSecureFileMock).toHaveBeenCalledTimes(2)
+ expect(writeSecureFileMock).toHaveBeenLastCalledWith(storePath, previous.toString('utf8'))
+ expect(rmSyncMock).not.toHaveBeenCalledWith(storePath, expect.anything())
+ warn.mockRestore()
+ })
+
+ it('refuses to decrypt an encrypted envelope once safeStorage becomes unavailable', async () => {
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'glm-secret')))
+ const store = await loadStore()
+
+ expect(() => store.readZcodePlanApiKey()).toThrow('could not be decrypted')
+ })
+
+ it('throws on an unreadable envelope instead of returning a partial key', async () => {
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from('not-an-envelope'))
+ const store = await loadStore()
+
+ expect(() => store.readZcodePlanApiKey()).toThrow('could not be decrypted')
+ })
+
+ it('clearing removes the file and resets the cached value', async () => {
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+ store.saveZcodePlanApiKey('glm-secret')
+
+ existsSyncMock.mockReturnValue(true)
+ store.clearZcodePlanApiKey()
+
+ expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true })
+ existsSyncMock.mockReturnValue(false)
+ expect(store.readZcodePlanApiKey()).toBeNull()
+ })
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
diff --git a/src/main/zcode/zcode-plan-api-key-store.ts b/src/main/zcode/zcode-plan-api-key-store.ts
new file mode 100644
index 000000000000..45c4ad315ac6
--- /dev/null
+++ b/src/main/zcode/zcode-plan-api-key-store.ts
@@ -0,0 +1,169 @@
+import { safeStorage } from 'electron'
+import { existsSync, readFileSync, rmSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file'
+import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection'
+
+const ZCODE_PLAN_API_KEY_FILE = 'zcode-plan-api-key.enc'
+const API_KEY_ENVELOPE_PREFIX = 'orca-zcode-plan-api-key:v1:'
+let cachedZcodePlanApiKey: string | null = null
+let warnedZcodePlanApiKeyStatusHardenFailure = false
+
+type ZcodePlanApiKeyEnvelope = {
+ kind: 'encrypted' | 'plaintext'
+ payload: Buffer
+}
+
+function getZcodePlanApiKeyPath(): string {
+ return join(homedir(), '.orca', ZCODE_PLAN_API_KEY_FILE)
+}
+
+function encodeApiKeyEnvelope(kind: ZcodePlanApiKeyEnvelope['kind'], payload: Buffer): string {
+ return `${API_KEY_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}`
+}
+
+function decodeApiKeyEnvelope(raw: Buffer): ZcodePlanApiKeyEnvelope {
+ const text = raw.toString('utf8')
+ if (!text.startsWith(API_KEY_ENVELOPE_PREFIX)) {
+ throw new Error('GLM Coding Plan API key could not be decrypted')
+ }
+ const rest = text.slice(API_KEY_ENVELOPE_PREFIX.length)
+ const separator = rest.indexOf(':')
+ if (separator === -1) {
+ throw new Error('GLM Coding Plan API key could not be decrypted')
+ }
+ const kind = rest.slice(0, separator)
+ if (kind !== 'encrypted' && kind !== 'plaintext') {
+ throw new Error('GLM Coding Plan API key could not be decrypted')
+ }
+ return {
+ kind,
+ payload: Buffer.from(rest.slice(separator + 1), 'base64')
+ }
+}
+
+function readEnvelope(envelope: ZcodePlanApiKeyEnvelope): string {
+ if (envelope.kind === 'plaintext') {
+ return envelope.payload.toString('utf8')
+ }
+ if (!safeStorage.isEncryptionAvailable()) {
+ throw new Error('GLM Coding Plan API key could not be decrypted')
+ }
+ return safeStorage.decryptString(envelope.payload)
+}
+
+export function hasZcodePlanApiKey(): boolean {
+ const keyPath = getZcodePlanApiKeyPath()
+ if (!existsSync(keyPath)) {
+ return false
+ }
+ try {
+ hardenExistingSecureFile(keyPath)
+ } catch (error) {
+ if (!warnedZcodePlanApiKeyStatusHardenFailure) {
+ warnedZcodePlanApiKeyStatusHardenFailure = true
+ console.warn(
+ '[zcode] Failed to harden GLM Coding Plan API key file while checking status',
+ error
+ )
+ }
+ }
+ return true
+}
+
+export function getZcodePlanApiKeyProtection(): SecretAtRestProtection | null {
+ const keyPath = getZcodePlanApiKeyPath()
+ if (!existsSync(keyPath)) {
+ return null
+ }
+ try {
+ return decodeApiKeyEnvelope(readFileSync(keyPath)).kind === 'plaintext' ? 'plaintext' : 'sealed'
+ } catch {
+ return null
+ }
+}
+
+export function saveZcodePlanApiKey(key: string): void {
+ const trimmed = key.trim()
+ if (!trimmed) {
+ throw new Error('GLM Coding Plan API key is required')
+ }
+ if (/[\r\n]/.test(trimmed)) {
+ throw new Error('GLM Coding Plan API key must be a single line')
+ }
+ if (safeStorage.isEncryptionAvailable()) {
+ writeSecureFile(
+ getZcodePlanApiKeyPath(),
+ encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed))
+ )
+ cachedZcodePlanApiKey = trimmed
+ return
+ }
+ console.warn(
+ '[zcode] safeStorage encryption unavailable — storing GLM Coding Plan API key in plaintext'
+ )
+ const keyPath = getZcodePlanApiKeyPath()
+ // Why: capture the previous envelope — writeSecureFile has already replaced
+ // the file by the time it reports that restriction failed, and deleting the
+ // result must not take the user's previous working key with it.
+ let previousEnvelope: Buffer | null = null
+ if (existsSync(keyPath)) {
+ try {
+ previousEnvelope = readFileSync(keyPath)
+ } catch {
+ previousEnvelope = null
+ }
+ }
+ const wroteRestricted = writeSecureFile(
+ keyPath,
+ encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8'))
+ )
+ // Why: an unrestricted plaintext credential must never be reported as saved.
+ if (!wroteRestricted) {
+ if (!previousEnvelope) {
+ rmSync(keyPath, { force: true })
+ } else {
+ try {
+ writeSecureFile(keyPath, previousEnvelope.toString('utf8'))
+ } catch {
+ // Why: restriction is failing device-wide; the restored bytes keep the
+ // previous credential available instead of deleting it, and the thrown
+ // save error still tells the user the store is not secure.
+ }
+ }
+ throw new Error('GLM Coding Plan API key could not be stored securely on this device')
+ }
+ cachedZcodePlanApiKey = trimmed
+}
+
+export function readZcodePlanApiKey(): string | null {
+ if (cachedZcodePlanApiKey !== null) {
+ return cachedZcodePlanApiKey
+ }
+ const keyPath = getZcodePlanApiKeyPath()
+ if (!existsSync(keyPath)) {
+ return null
+ }
+ // Why: keep hardening out of the decode/decrypt try below so a chmod/ACL
+ // failure isn't misreported as a decrypt failure (matches hasZcodePlanApiKey).
+ try {
+ hardenExistingSecureFile(keyPath)
+ } catch (error) {
+ console.warn('[zcode] Failed to harden GLM Coding Plan API key file while reading', error)
+ }
+ try {
+ const raw = readFileSync(keyPath)
+ const envelope = decodeApiKeyEnvelope(raw)
+ cachedZcodePlanApiKey = readEnvelope(envelope)
+ return cachedZcodePlanApiKey
+ } catch (error) {
+ console.error('[zcode] failed to decode/decrypt GLM Coding Plan API key', error)
+ throw new Error('GLM Coding Plan API key could not be decrypted')
+ }
+}
+
+export function clearZcodePlanApiKey(): void {
+ cachedZcodePlanApiKey = null
+ rmSync(getZcodePlanApiKeyPath(), { force: true })
+}
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 220b90c18a6c..6c395100f907 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -4,7 +4,8 @@ import type {
CodexConfigSyncApi,
CursorAccountsApi,
GrokAccountsApi,
- MinimaxCredentialsApi
+ MinimaxCredentialsApi,
+ ZcodePlanCredentialsApi
} from './api/agent-account-api'
import type { HooksApi } from './api/agent-hook-api'
import type { SkillsApi } from './api/agent-skill-api'
@@ -142,6 +143,7 @@ export type PreloadApi = {
runtimeEnvironments: RuntimeApi['runtimeEnvironments']
rateLimits: RateLimitsApi
minimaxCredentials: MinimaxCredentialsApi
+ zcodePlanCredentials: ZcodePlanCredentialsApi
grokAccounts: GrokAccountsApi
cursorAccounts: CursorAccountsApi
ssh: SshApi
diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts
index 9c8087f4b916..052fcdf2edc3 100644
--- a/src/preload/api/agent-account-api.ts
+++ b/src/preload/api/agent-account-api.ts
@@ -5,6 +5,7 @@ import type {
} from '../../shared/managed-account-types'
import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types'
import type { CursorAccountStatus, GrokAccountStatus } from '../../shared/rate-limit-types'
+import type { ZcodePlanCredentialsStatus } from '../../shared/zcode-plan-sites'
export type CodexAccountsApi = {
list: () => Promise
@@ -96,6 +97,15 @@ export type MinimaxCredentialsApi = {
}>
}
+export type ZcodePlanCredentialsApi = {
+ // Why: the GLM Coding Plan key lives in its own safeStorage file and the
+ // ZCode CLI's config is read-only, so the status reports both sources'
+ // presence; neither credential value ever crosses the IPC boundary.
+ getStatus: () => Promise
+ saveApiKey: (key: string) => Promise
+ clearApiKey: () => Promise
+}
+
export type CodexConfigSyncApi = {
status: () => Promise
}
diff --git a/src/preload/api/zcode-plan-credentials-bridge.ts b/src/preload/api/zcode-plan-credentials-bridge.ts
new file mode 100644
index 000000000000..cee36fa7b870
--- /dev/null
+++ b/src/preload/api/zcode-plan-credentials-bridge.ts
@@ -0,0 +1,12 @@
+import { ipcRenderer } from 'electron'
+import type { PreloadApi } from '../api-types'
+import type { ZcodePlanCredentialsStatus } from '../../shared/zcode-plan-sites'
+
+export const zcodePlanCredentialsApi = {
+ getStatus: (): Promise =>
+ ipcRenderer.invoke('zcodePlanCredentials:getStatus'),
+ saveApiKey: (key: string): Promise =>
+ ipcRenderer.invoke('zcodePlanCredentials:saveApiKey', key),
+ clearApiKey: (): Promise =>
+ ipcRenderer.invoke('zcodePlanCredentials:clearApiKey')
+} satisfies PreloadApi['zcodePlanCredentials']
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 42cf236c14db..a68a39650702 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -76,6 +76,7 @@ import { runtimeApi } from './api/runtime-bridge'
import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge'
import { rateLimitsApi } from './api/rate-limits-bridge'
import { minimaxCredentialsApi } from './api/minimax-credentials-bridge'
+import { zcodePlanCredentialsApi } from './api/zcode-plan-credentials-bridge'
import { grokAccountsApi } from './api/grok-accounts-bridge'
import { cursorAccountsApi } from './api/cursor-accounts-bridge'
import { sshApi } from './api/ssh-bridge'
@@ -175,6 +176,7 @@ const api = {
runtimeEnvironments: runtimeEnvironmentsApi,
rateLimits: rateLimitsApi,
minimaxCredentials: minimaxCredentialsApi,
+ zcodePlanCredentials: zcodePlanCredentialsApi,
grokAccounts: grokAccountsApi,
cursorAccounts: cursorAccountsApi,
ssh: sshApi,
diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx
index 1e479ab7e25a..6be660e3db7b 100644
--- a/src/renderer/src/components/settings/AccountsPane.tsx
+++ b/src/renderer/src/components/settings/AccountsPane.tsx
@@ -24,7 +24,8 @@ import {
getAccountsLocationSearchEntries,
getAccountsMiniMaxSearchEntries,
getAccountsOpencodeSearchEntries,
- getAccountsPaneSearchEntries
+ getAccountsPaneSearchEntries,
+ getAccountsZcodePlanSearchEntries
} from './accounts-search'
import { getRemoteAccountsPaneScope } from './provider-account-scope'
import { ProviderHostScopeControl } from './ProviderHostScopeControl'
@@ -39,6 +40,7 @@ import {
} from './provider-account-visibility'
import { GrokAccountsSection } from './GrokAccountsSection'
import { CursorAccountsSection } from './CursorAccountsSection'
+import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection'
import type {
AccountsPaneProps,
AccountsPaneSectionModel,
@@ -395,6 +397,9 @@ export function AccountsPane({
) : null,
matchesSettingsSearch(searchQuery, getAccountsCursorSearchEntries()) ? (
+ ) : null,
+ matchesSettingsSearch(searchQuery, getAccountsZcodePlanSearchEntries()) ? (
+
) : null
]
diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
new file mode 100644
index 000000000000..bc52724e40b8
--- /dev/null
+++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
@@ -0,0 +1,228 @@
+// @vitest-environment happy-dom
+
+import '@testing-library/jest-dom/vitest'
+
+import React from 'react'
+import type { ZcodePlanSite } from '../../../../shared/zcode-plan-sites'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => {
+ const settings: { zcodePlanSite: ZcodePlanSite } = { zcodePlanSite: 'zai' }
+ return {
+ getStatus: vi.fn(),
+ saveApiKey: vi.fn(),
+ clearApiKey: vi.fn(),
+ refreshRateLimits: vi.fn(),
+ updateSettings: vi.fn(),
+ recordFeatureInteraction: vi.fn(),
+ zcodeUsage: vi.fn<() => unknown>(() => null),
+ settings
+ }
+})
+
+vi.mock('@/lib/agent-catalog', () => ({
+ AgentIcon: () => React.createElement('span', { 'data-testid': 'zcode-icon' })
+}))
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string, values?: Record) => {
+ let result = fallback
+ for (const [key, value] of Object.entries(values ?? {})) {
+ result = result.replace(`{{${key}}}`, value)
+ }
+ return result
+ }
+}))
+
+vi.mock('../../store', () => ({
+ useAppStore: (selector: (state: Record) => unknown) =>
+ selector({
+ refreshRateLimits: mocks.refreshRateLimits,
+ updateSettings: mocks.updateSettings,
+ recordFeatureInteraction: mocks.recordFeatureInteraction,
+ settingsSearchQuery: '',
+ settings: mocks.settings,
+ rateLimits: { zcode: mocks.zcodeUsage() }
+ })
+}))
+
+import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection'
+
+describe('ZcodePlanAccountsSection', () => {
+ beforeEach(() => {
+ mocks.getStatus.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: false })
+ mocks.saveApiKey.mockResolvedValue({ apiKeyConfigured: true })
+ mocks.clearApiKey.mockResolvedValue({ apiKeyConfigured: false })
+ mocks.refreshRateLimits.mockResolvedValue(undefined)
+ mocks.updateSettings.mockResolvedValue(undefined)
+ mocks.recordFeatureInteraction.mockReset()
+ mocks.zcodeUsage.mockReturnValue(null)
+ mocks.settings.zcodePlanSite = 'zai'
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: {
+ zcodePlanCredentials: {
+ getStatus: mocks.getStatus,
+ saveApiKey: mocks.saveApiKey,
+ clearApiKey: mocks.clearApiKey
+ }
+ }
+ })
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+ })
+
+ it('shows the unlinked state when neither an API key nor a CLI config exists', async () => {
+ render( )
+
+ expect(await screen.findByText('No GLM Coding Plan linked')).toBeInTheDocument()
+ expect(screen.queryByText('Using the ZCode CLI sign-in')).not.toBeInTheDocument()
+ })
+
+ it('explains the CLI fallback when only the ZCode CLI config exists', async () => {
+ mocks.getStatus.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: true })
+
+ render( )
+
+ expect(await screen.findByText('Using the ZCode CLI sign-in')).toBeInTheDocument()
+ expect(screen.getByText(/~\/\.zcode\/cli\/config\.json/)).toBeInTheDocument()
+ })
+
+ it('saves a trimmed API key through the credential IPC', async () => {
+ render( )
+
+ const input = await screen.findByPlaceholderText('Paste your GLM Coding Plan API key')
+ fireEvent.change(input, { target: { value: ' glm-secret ' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+
+ await waitFor(() => {
+ expect(mocks.saveApiKey).toHaveBeenCalledWith('glm-secret')
+ })
+ expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking')
+ await screen.findByText('Replace')
+ })
+
+ it('requires a non-empty key before saving', async () => {
+ render( )
+
+ const save = await screen.findByRole('button', { name: 'Save' })
+ expect(save).toBeDisabled()
+ })
+
+ it('forgets a saved key through the credential IPC', async () => {
+ mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: true })
+
+ render( )
+
+ fireEvent.click(await screen.findByRole('button', { name: 'Forget key' }))
+
+ await waitFor(() => {
+ expect(mocks.clearApiKey).toHaveBeenCalledTimes(1)
+ })
+ expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking')
+ })
+
+ it('labels the saved state with the selected site', async () => {
+ mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false })
+ mocks.settings.zcodePlanSite = 'bigmodel'
+
+ render( )
+
+ expect(
+ await screen.findByText('API key saved · Zhipu · BigModel (open.bigmodel.cn)')
+ ).toBeInTheDocument()
+ })
+
+ it('renders the live quota windows for a linked plan', async () => {
+ mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false })
+ mocks.zcodeUsage.mockReturnValue({
+ provider: 'zcode',
+ status: 'ok',
+ error: null,
+ planType: 'max',
+ session: { usedPercent: 42, windowMinutes: 300, resetsAt: null, resetDescription: null },
+ weekly: { usedPercent: 73, windowMinutes: 10080, resetsAt: null, resetDescription: null },
+ monthly: null,
+ updatedAt: Date.now()
+ })
+
+ render( )
+
+ expect(await screen.findByText('42%')).toBeInTheDocument()
+ expect(screen.getByText('73%')).toBeInTheDocument()
+ expect(screen.getByText('Plan: max')).toBeInTheDocument()
+ })
+
+ it('renders the reset countdown once, not doubled', async () => {
+ mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false })
+ mocks.zcodeUsage.mockReturnValue({
+ provider: 'zcode',
+ status: 'ok',
+ error: null,
+ planType: null,
+ // Why 47.5 minutes: the floor survives a minute-boundary crossing between
+ // mock setup and render, so the assertion stays deterministic.
+ session: {
+ usedPercent: 42,
+ windowMinutes: 300,
+ resetsAt: Date.now() + 47 * 60_000 + 30_000,
+ resetDescription: null
+ },
+ weekly: null,
+ monthly: null,
+ updatedAt: Date.now()
+ })
+
+ render( )
+
+ expect(await screen.findByText('42%')).toBeInTheDocument()
+ expect(screen.getByText(/5 hours — resets in 47m/)).toBeInTheDocument()
+ expect(screen.queryByText(/Resets in Resets/)).not.toBeInTheDocument()
+ })
+})
+
+describe('GLM credential status races', () => {
+ afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+ })
+
+ it('keeps the saved status when an earlier status read finishes late', async () => {
+ let finishRead: ((value: unknown) => void) | undefined
+ mocks.getStatus.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishRead = resolve
+ })
+ )
+ mocks.saveApiKey.mockResolvedValue({
+ apiKeyConfigured: true,
+ zcodeCliConfigured: false,
+ apiKeyProtection: 'sealed'
+ })
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: {
+ zcodePlanCredentials: {
+ getStatus: mocks.getStatus,
+ saveApiKey: mocks.saveApiKey,
+ clearApiKey: mocks.clearApiKey
+ }
+ }
+ })
+ render( )
+ fireEvent.change(screen.getByPlaceholderText('Paste your GLM Coding Plan API key'), {
+ target: { value: 'synthetic-key' }
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+ await screen.findByRole('button', { name: 'Forget key' })
+ finishRead?.({ apiKeyConfigured: false, zcodeCliConfigured: false, apiKeyProtection: null })
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Forget key' })).toBeInTheDocument()
+ )
+ })
+})
diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx
new file mode 100644
index 000000000000..0cb66df2832c
--- /dev/null
+++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx
@@ -0,0 +1,331 @@
+import { useState } from 'react'
+import { ExternalLink, Loader2, Lock, LockOpen, RefreshCw, ShieldCheck } from 'lucide-react'
+import { AgentIcon } from '@/lib/agent-catalog'
+import { translate } from '@/i18n/i18n'
+import {
+ ZCODE_PLAN_SITE_CONSOLE_URLS,
+ type ZcodePlanSite
+} from '../../../../shared/zcode-plan-sites'
+import { cn } from '@/lib/utils'
+import { useAppStore } from '../../store'
+import { useNow } from '../../hooks/use-now'
+import { Badge } from '../ui/badge'
+import { Button } from '../ui/button'
+import { Input } from '../ui/input'
+import { Label } from '../ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
+import { SearchableSetting } from './SearchableSetting'
+import { collectZcodeUsageWindows, ZcodeUsageWindowView } from './zcode-plan-usage-windows'
+import { useZcodePlanCredentials } from './use-zcode-plan-credentials'
+import { UnsealedCredentialNotice } from './UnsealedCredentialNotice'
+
+const SEARCH_KEYWORDS = [
+ 'glm',
+ 'zai',
+ 'z.ai',
+ 'zhipu',
+ 'bigmodel',
+ 'coding plan',
+ 'usage',
+ 'rate limit',
+ 'zcode'
+]
+
+function siteLabel(site: ZcodePlanSite): string {
+ if (site === 'bigmodel') {
+ return translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.site.bigmodel',
+ 'Zhipu · BigModel (open.bigmodel.cn)'
+ )
+ }
+ return translate('auto.components.settings.ZcodePlanAccountsSection.site.zai', 'Z.AI (z.ai)')
+}
+
+export function ZcodePlanAccountsSection(): React.JSX.Element {
+ const settings = useAppStore((s) => s.settings)
+ const updateSettings = useAppStore((s) => s.updateSettings)
+ const refreshRateLimits = useAppStore((s) => s.refreshRateLimits)
+ const zcodeUsage = useAppStore((s) => s.rateLimits.zcode)
+ const { status, apiKeyDraft, setApiKeyDraft, credentialBusy, saveApiKey, clearApiKey } =
+ useZcodePlanCredentials(zcodeUsage?.updatedAt)
+ const [refreshing, setRefreshing] = useState(false)
+ const now = useNow(60_000)
+
+ const site = settings?.zcodePlanSite ?? 'zai'
+ const consoleUrl = ZCODE_PLAN_SITE_CONSOLE_URLS[site]
+ const apiKeyConfigured = status?.apiKeyConfigured === true
+
+ const handleSiteChange = (value: string): void => {
+ if ((value !== 'zai' && value !== 'bigmodel') || value === site) {
+ return
+ }
+ // Why: main invalidates and refreshes on this settings change, so no local refresh is needed.
+ void updateSettings({ zcodePlanSite: value })
+ }
+
+ const handleRefreshUsage = async (): Promise => {
+ setRefreshing(true)
+ try {
+ await refreshRateLimits()
+ } finally {
+ setRefreshing(false)
+ }
+ }
+
+ const usage = zcodeUsage ?? null
+ const usageWindows = collectZcodeUsageWindows(usage)
+ const staleUsageError = usage?.status === 'error' ? (usage.error ?? null) : null
+
+ return (
+
+
+
+
+
+
+ {apiKeyConfigured ? (
+ <>
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.keyStored',
+ 'API key saved · {{value0}}',
+ { value0: siteLabel(site) }
+ )}
+
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.keyStoredHelp',
+ 'Stored locally and sent only to the selected site for usage refreshes. It takes priority over the ZCode CLI sign-in.'
+ )}
+
+ >
+ ) : status?.zcodeCliConfigured ? (
+ <>
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.usingCli',
+ 'Using the ZCode CLI sign-in'
+ )}
+
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.usingCliHelp',
+ 'Orca reads the Coding Plan key from ~/.zcode/cli/config.json. Save an API key below to link the plan here instead.'
+ )}
+
+ >
+ ) : (
+ <>
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.notConfigured',
+ 'No GLM Coding Plan linked'
+ )}
+
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.notConfiguredHelp',
+ 'Save the plan API key below, or sign in with the ZCode CLI on this computer.'
+ )}
+
+ >
+ )}
+ {staleUsageError ?
{staleUsageError}
: null}
+
+
void handleRefreshUsage()}
+ className="shrink-0"
+ >
+ {refreshing ? (
+
+ ) : (
+
+ )}
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.refreshUsage',
+ 'Refresh usage'
+ )}
+
+
+
+
+
+ {translate('auto.components.settings.ZcodePlanAccountsSection.siteTitle', 'Plan site')}
+
+
+
+
+
+
+ {(['zai', 'bigmodel'] as const).map((option) => (
+
+ {siteLabel(option)}
+
+ ))}
+
+
+
+
+
+
+
+
+ {translate('auto.components.settings.ZcodePlanAccountsSection.keyTitle', 'API key')}
+
+
+ {apiKeyConfigured ? : }
+ {apiKeyConfigured
+ ? translate('auto.components.settings.ZcodePlanAccountsSection.saved', 'Saved')
+ : translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.notSaved',
+ 'Not saved'
+ )}
+
+
+
+
+
+ setApiKeyDraft(e.target.value)}
+ placeholder={translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.keyPlaceholder',
+ 'Paste your GLM Coding Plan API key'
+ )}
+ spellCheck={false}
+ className="flex-1"
+ />
+ void saveApiKey()}
+ disabled={credentialBusy || !apiKeyDraft.trim()}
+ className="shrink-0"
+ >
+ {credentialBusy ? : null}
+ {apiKeyConfigured
+ ? translate('auto.components.settings.ZcodePlanAccountsSection.replace', 'Replace')
+ : translate('auto.components.settings.ZcodePlanAccountsSection.save', 'Save')}
+
+ {apiKeyConfigured ? (
+ void clearApiKey()}
+ disabled={credentialBusy}
+ className="shrink-0"
+ >
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.forgetKey',
+ 'Forget key'
+ )}
+
+ ) : null}
+
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.keyHelp',
+ 'The same key your coding tools use for the plan (for example Claude Code with ANTHROPIC_BASE_URL pointed at the site). Switching the site above changes which host receives it.'
+ )}
+
+
+
+ {usageWindows.length > 0 ? (
+
+
+ {usageWindows.map((row) => (
+
+ ))}
+ {usage?.planType ? (
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.planLevel',
+ 'Plan: {{value0}}',
+ { value0: usage.planType }
+ )}
+
+ ) : null}
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts
index 60fa795caa99..405f51196135 100644
--- a/src/renderer/src/components/settings/accounts-search.ts
+++ b/src/renderer/src/components/settings/accounts-search.ts
@@ -249,6 +249,36 @@ export const getAccountsCursorSearchEntries = createLocalizedCatalog(() => [
}
])
+export const getAccountsZcodePlanSearchEntries = createLocalizedCatalog(() => [
+ {
+ title: translate('auto.components.settings.accounts.search.zcodePlan.title', 'GLM Coding Plan'),
+ description: translate(
+ 'auto.components.settings.accounts.search.zcodePlan.description',
+ 'Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage. Pick the site and save the plan API key.'
+ ),
+ keywords: [
+ ...translateSearchKeyword('auto.components.settings.accounts.search.zcodePlan.kw.glm', 'glm'),
+ ...translateSearchKeyword('auto.components.settings.accounts.search.zcodePlan.kw.zai', 'zai'),
+ ...translateSearchKeyword(
+ 'auto.components.settings.accounts.search.zcodePlan.kw.zhipu',
+ 'zhipu'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.accounts.search.zcodePlan.kw.bigmodel',
+ 'bigmodel'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.accounts.search.zcodePlan.kw.codingPlan',
+ 'coding plan'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.accounts.search.zcodePlan.kw.rateLimit',
+ 'rate limit'
+ )
+ ]
+ }
+])
+
export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
...getAccountsLocationSearchEntries(),
...getAccountsClaudeSearchEntries(),
@@ -257,5 +287,6 @@ export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsS
...getAccountsOpencodeSearchEntries(),
...getAccountsMiniMaxSearchEntries(),
...getAccountsGrokSearchEntries(),
- ...getAccountsCursorSearchEntries()
+ ...getAccountsCursorSearchEntries(),
+ ...getAccountsZcodePlanSearchEntries()
])
diff --git a/src/renderer/src/components/settings/use-zcode-plan-credentials.ts b/src/renderer/src/components/settings/use-zcode-plan-credentials.ts
new file mode 100644
index 000000000000..e14f975798fa
--- /dev/null
+++ b/src/renderer/src/components/settings/use-zcode-plan-credentials.ts
@@ -0,0 +1,81 @@
+import { useEffect, useRef, useState } from 'react'
+import { toast } from 'sonner'
+import { translate } from '@/i18n/i18n'
+import { useAppStore } from '../../store'
+import type { ZcodePlanCredentialsStatus } from '../../../../shared/zcode-plan-sites'
+
+export function useZcodePlanCredentials(updatedAt: number | undefined) {
+ const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
+ const [status, setStatus] = useState(null)
+ const [apiKeyDraft, setApiKeyDraft] = useState('')
+ const [credentialBusy, setCredentialBusy] = useState(false)
+ const generation = useRef(0)
+ const mutationPending = useRef(false)
+
+ useEffect(() => {
+ if (mutationPending.current) {
+ return
+ }
+ const request = ++generation.current
+ void window.api.zcodePlanCredentials.getStatus().then(
+ (next) => {
+ if (request === generation.current) {
+ setStatus(next)
+ }
+ },
+ () => {
+ if (request === generation.current) {
+ setStatus(null)
+ }
+ }
+ )
+ return () => {
+ generation.current += 1
+ }
+ }, [updatedAt])
+
+ const updateCredential = async (action: 'save' | 'clear'): Promise => {
+ const request = ++generation.current
+ mutationPending.current = true
+ setCredentialBusy(true)
+ try {
+ const next =
+ action === 'save'
+ ? await window.api.zcodePlanCredentials.saveApiKey(apiKeyDraft.trim())
+ : await window.api.zcodePlanCredentials.clearApiKey()
+ if (request === generation.current) {
+ setStatus(next)
+ }
+ setApiKeyDraft('')
+ recordFeatureInteraction('usage-tracking')
+ if (action === 'save') {
+ toast.success(
+ translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.keySaved',
+ 'GLM Coding Plan API key saved.'
+ )
+ )
+ }
+ } catch (error) {
+ toast.error(
+ translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.keySaveFailed',
+ 'GLM Coding Plan credential update failed.'
+ ),
+ { description: error instanceof Error ? error.message : String(error) }
+ )
+ } finally {
+ mutationPending.current = false
+ setCredentialBusy(false)
+ }
+ }
+
+ return {
+ status,
+ apiKeyDraft,
+ setApiKeyDraft,
+ credentialBusy,
+ saveApiKey: () => updateCredential('save'),
+ clearApiKey: () => updateCredential('clear')
+ }
+}
diff --git a/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx b/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx
new file mode 100644
index 000000000000..6701ee44adaa
--- /dev/null
+++ b/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx
@@ -0,0 +1,72 @@
+import { translate } from '@/i18n/i18n'
+import { formatResetDuration } from '../../../../shared/rate-limit-reset-format'
+import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
+import { Badge } from '../ui/badge'
+
+export type ZcodeUsageWindowKind = 'session' | 'weekly' | 'monthly'
+
+export type ZcodeUsageWindowRow = {
+ kind: ZcodeUsageWindowKind
+ window: RateLimitWindow
+}
+
+function windowLabel(kind: ZcodeUsageWindowKind): string {
+ if (kind === 'session') {
+ return translate('auto.components.settings.ZcodePlanAccountsSection.window.session', '5 hours')
+ }
+ if (kind === 'weekly') {
+ return translate('auto.components.settings.ZcodePlanAccountsSection.window.weekly', 'Weekly')
+ }
+ return translate('auto.components.settings.ZcodePlanAccountsSection.window.mcp', 'MCP monthly')
+}
+
+function formatWindowReset(window: RateLimitWindow, now: number): string | null {
+ if (!window.resetsAt) {
+ return null
+ }
+ const remaining = window.resetsAt - now
+ return remaining > 0 ? formatResetDuration(remaining) : null
+}
+
+// Why: a window only renders when its data survived the fetcher's mapping, so
+// error snapshots and MCP-less plans show exactly the windows they reported.
+export function collectZcodeUsageWindows(usage: ProviderRateLimits | null): ZcodeUsageWindowRow[] {
+ const rows: ZcodeUsageWindowRow[] = []
+ if (usage?.session) {
+ rows.push({ kind: 'session', window: usage.session })
+ }
+ if (usage?.weekly) {
+ rows.push({ kind: 'weekly', window: usage.weekly })
+ }
+ if (usage?.monthly) {
+ rows.push({ kind: 'monthly', window: usage.monthly })
+ }
+ return rows
+}
+
+export function ZcodeUsageWindowView({
+ row,
+ now
+}: {
+ row: ZcodeUsageWindowRow
+ now: number
+}): React.JSX.Element {
+ const resetLabel = formatWindowReset(row.window, now)
+ return (
+
+
+ {Math.round(row.window.usedPercent)}%
+
+
+ {windowLabel(row.kind)}
+ {resetLabel
+ ? translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.resetIn',
+ ' — resets in {{value0}}',
+ { value0: resetLabel }
+ )
+ : ''}
+
+
+ )
+}
diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts
index 987bb91c040e..3020048273e0 100644
--- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts
+++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts
@@ -78,11 +78,19 @@ function usageSettings(overrides: Partial = {}): UsagePro
opencodeGoApiKeyConfigured: false,
grokAuthConfigured: false,
cursorAuthConfigured: false,
+ zcodePlanApiKeyConfigured: false,
...overrides
}
}
describe('hasUsageProviderSettings', () => {
+ it('keeps a linked GLM plan visible without CLI detection or a first quota result', () => {
+ const settings = usageSettings({ zcodePlanApiKeyConfigured: true })
+ expect(hasUsageProviderSettings(settings)).toBe(true)
+ expect(getVisibleUsageProvider('zcode', null, settings)?.status).toBe('fetching')
+ expect(getVisibleUsageProvider('zcode', null, usageSettings())).toBeNull()
+ })
+
it('treats persisted managed accounts as configured usage providers', () => {
expect(
hasUsageProviderSettings(
diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts
index a49a9fe3a59b..6f199db68f04 100644
--- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts
+++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts
@@ -17,6 +17,9 @@ export type UsageProviderSettings = Pick<
// Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll.
minimaxCookieConfigured: boolean
minimaxApiKeyConfigured: boolean
+ // Why: the GLM Coding Plan key lives in its own safeStorage file; main
+ // reports presence so the ZCode bar survives reloads before the first poll.
+ zcodePlanApiKeyConfigured: boolean
// Why: the OpenCode Go key can live in OPENCODE_API_KEY or in OpenCode's own
// store, neither of which the renderer can see; main reports presence.
opencodeGoApiKeyConfigured: boolean
@@ -87,7 +90,8 @@ export function hasUsageProviderSettings(
settings?.minimaxCookieConfigured === true ||
settings?.minimaxApiKeyConfigured === true ||
settings?.grokAuthConfigured === true ||
- settings?.cursorAuthConfigured === true
+ settings?.cursorAuthConfigured === true ||
+ settings?.zcodePlanApiKeyConfigured === true
)
}
@@ -128,6 +132,9 @@ export function hasUsageProviderSettingsForProvider(
if (providerId === 'cursor') {
return settings.cursorAuthConfigured === true
}
+ if (providerId === 'zcode') {
+ return settings.zcodePlanApiKeyConfigured === true
+ }
return false
}
diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts
index b531bffd161e..6da92332d443 100644
--- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts
+++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts
@@ -21,9 +21,9 @@ export function getUsageProviderAccountsSectionId(
case 'cursor':
return 'accounts-cursor'
case 'kimi':
- case 'zcode':
// Why: Orca must not mutate Kimi's CLI-owned credential lifecycle.
- // ZCode likewise owns its Coding Plan credential in ~/.zcode/cli/config.json.
return null
+ case 'zcode':
+ return 'accounts-zcode'
}
}
diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts
index 8ec0bcfce437..bd7b9aa4258a 100644
--- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts
+++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts
@@ -108,7 +108,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) {
minimaxApiKeyConfigured: rateLimits.minimaxApiKeyConfigured,
opencodeGoApiKeyConfigured: rateLimits.opencodeGoApiKeyConfigured,
grokAuthConfigured: rateLimits.grokAuthConfigured,
- cursorAuthConfigured: rateLimits.cursorAuthConfigured
+ cursorAuthConfigured: rateLimits.cursorAuthConfigured,
+ zcodePlanApiKeyConfigured: rateLimits.zcodePlanApiKeyConfigured
}
const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings)
const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings)
@@ -148,10 +149,12 @@ export function useStatusBarController(floatingTerminalOpen: boolean) {
// Why: a Cursor session can come from the IDE alone, so PATH detection of
// cursor-agent would hide a real meter from IDE-only users.
const showCursor = visibleCursor !== null && statusBarItems.includes('cursor')
+ // Why: a saved Coding Plan key is site-auth, not a CLI on PATH — a subscriber
+ // without the ZCode CLI must still earn the meter (same exemption as MiniMax/Cursor).
const showZcode =
visibleZcode !== null &&
statusBarItems.includes('zcode') &&
- isStatusBarItemAvailable('zcode', detectedAgentIds)
+ (rateLimits.zcodePlanApiKeyConfigured || isStatusBarItemAvailable('zcode', detectedAgentIds))
// Why: OpenCode Go is web/cookie-auth, not a CLI on PATH, so detection-gating doesn't apply.
const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings)
const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go')
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 2f29e560803a..e52ec89d5f75 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -9145,6 +9145,18 @@
"rateLimit": "rate limit",
"statusBar": "status bar"
}
+ },
+ "zcodePlan": {
+ "title": "GLM Coding Plan",
+ "description": "Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage. Pick the site and save the plan API key.",
+ "kw": {
+ "glm": "glm",
+ "zai": "zai",
+ "zhipu": "zhipu",
+ "bigmodel": "bigmodel",
+ "codingPlan": "coding plan",
+ "rateLimit": "rate limit"
+ }
}
}
},
@@ -12259,6 +12271,45 @@
},
"UnsealedCredentialNotice": {
"body": "{{credential}} is stored unencrypted — this system has no OS keyring Orca can use. Anyone who can read your disk or a backup of it can read the credential. Install and unlock gnome-keyring or kwallet, then save it again to seal it."
+ },
+ "ZcodePlanAccountsSection": {
+ "site": {
+ "bigmodel": "Zhipu · BigModel (open.bigmodel.cn)",
+ "zai": "Z.AI (z.ai)"
+ },
+ "window": {
+ "session": "5 hours",
+ "weekly": "Weekly",
+ "mcp": "MCP monthly"
+ },
+ "resetIn": " — resets in {{value0}}",
+ "keyRequired": "GLM Coding Plan API key is required.",
+ "keySaved": "GLM Coding Plan API key saved.",
+ "keySaveFailed": "GLM Coding Plan credential update failed.",
+ "title": "GLM Coding Plan",
+ "subtitle": "Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage in the status bar. Save the plan API key here — no ZCode CLI setup needed.",
+ "consoleLink": "Get API key",
+ "keyStored": "API key saved · {{value0}}",
+ "keyStoredHelp": "Stored locally and sent only to the selected site for usage refreshes. It takes priority over the ZCode CLI sign-in.",
+ "usingCli": "Using the ZCode CLI sign-in",
+ "usingCliHelp": "Orca reads the Coding Plan key from ~/.zcode/cli/config.json. Save an API key below to link the plan here instead.",
+ "notConfigured": "No GLM Coding Plan linked",
+ "notConfiguredHelp": "Save the plan API key below, or sign in with the ZCode CLI on this computer.",
+ "refreshUsage": "Refresh usage",
+ "siteTitle": "Plan site",
+ "siteDescription": "Pick the console your Coding Plan belongs to: Z.AI for the international site, Zhipu BigModel for the mainland site.",
+ "keyTitle": "API key",
+ "keyDescription": "Paste the API key from the selected console’s API Keys page. Stored locally, encrypted when the OS supports it, and sent only to that site for usage refreshes.",
+ "saved": "Saved",
+ "notSaved": "Not saved",
+ "keyPlaceholder": "Paste your GLM Coding Plan API key",
+ "replace": "Replace",
+ "save": "Save",
+ "forgetKey": "Forget key",
+ "keyHelp": "The same key your coding tools use for the plan (for example Claude Code with ANTHROPIC_BASE_URL pointed at the site). Switching the site above changes which host receives it.",
+ "usageTitle": "Plan usage",
+ "usageDescription": "Live quota windows for the linked Coding Plan, refreshed with the status bar usage cycle.",
+ "planLevel": "Plan: {{value0}}"
}
},
"right": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 63fc2b87de52..f0bbca866f3d 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -8898,6 +8898,18 @@
"usage": "用量"
},
"title": "Cursor 用量"
+ },
+ "zcodePlan": {
+ "title": "GLM Coding Plan 用量",
+ "description": "追踪 Z.AI 或智谱(BigModel)GLM Coding Plan 的用量。选择站点并保存套餐 API key。",
+ "kw": {
+ "glm": "glm",
+ "zai": "zai",
+ "zhipu": "zhipu",
+ "bigmodel": "bigmodel",
+ "codingPlan": "coding plan",
+ "rateLimit": "rate limit"
+ }
}
}
},
@@ -12058,6 +12070,45 @@
"usageDescription": "Cursor 按两个随账单周期重置的额度池计费,用完后按需计费。",
"usageLabel": "用量",
"usageTitle": "每月套餐用量"
+ },
+ "ZcodePlanAccountsSection": {
+ "site": {
+ "bigmodel": "智谱 · BigModel(open.bigmodel.cn)",
+ "zai": "Z.AI(z.ai)"
+ },
+ "window": {
+ "session": "5 小时",
+ "weekly": "每周",
+ "mcp": "MCP 月度"
+ },
+ "resetIn": " — {{value0}}后重置",
+ "keyRequired": "请填写 GLM Coding Plan API key。",
+ "keySaved": "GLM Coding Plan API key 已保存。",
+ "keySaveFailed": "GLM Coding Plan 凭据更新失败。",
+ "title": "GLM Coding Plan",
+ "subtitle": "在状态栏追踪 Z.AI 或智谱(BigModel)GLM Coding Plan 用量。在此保存套餐 API key,无需安装 ZCode CLI。",
+ "consoleLink": "获取 API key",
+ "keyStored": "API key 已保存 · {{value0}}",
+ "keyStoredHelp": "仅保存在本地,并只发送到所选站点用于用量刷新。优先级高于 ZCode CLI 登录。",
+ "usingCli": "正在使用 ZCode CLI 登录",
+ "usingCliHelp": "Orca 从 ~/.zcode/cli/config.json 读取 Coding Plan key。在下方保存 API key 即可改为在此关联套餐。",
+ "notConfigured": "尚未关联 GLM Coding Plan",
+ "notConfiguredHelp": "在下方保存套餐 API key,或在这台电脑上通过 ZCode CLI 登录。",
+ "refreshUsage": "刷新用量",
+ "siteTitle": "套餐站点",
+ "siteDescription": "选择你的 Coding Plan 所属控制台:国际站选 Z.AI,国内站选智谱 BigModel。",
+ "keyTitle": "API key",
+ "keyDescription": "粘贴所选控制台 API Keys 页面中的 API key。保存在本地,系统支持时加密存储,并只发送到该站点用于用量刷新。",
+ "saved": "已保存",
+ "notSaved": "未保存",
+ "keyPlaceholder": "粘贴你的 GLM Coding Plan API key",
+ "replace": "替换",
+ "save": "保存",
+ "forgetKey": "清除 key",
+ "keyHelp": "与编码工具使用同一把套餐 key(例如 Claude Code 将 ANTHROPIC_BASE_URL 指向对应站点)。切换上方站点会变更接收该 key 的主机。",
+ "usageTitle": "套餐用量",
+ "usageDescription": "已关联 Coding Plan 的实时配额窗口,随状态栏用量周期刷新。",
+ "planLevel": "套餐:{{value0}}"
}
},
"right": {
diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts
index 3c6a9d2aa591..cb085cfd7284 100644
--- a/src/shared/default-global-settings.ts
+++ b/src/shared/default-global-settings.ts
@@ -215,6 +215,7 @@ export function buildDefaultSettings(args: {
minimaxGroupId: '',
minimaxUsageModels: 'general',
minimaxEndpoint: 'overseas',
+ zcodePlanSite: 'zai',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS },
diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts
index c1a9b7d2df12..7ff724d43ce9 100644
--- a/src/shared/global-settings-types.ts
+++ b/src/shared/global-settings-types.ts
@@ -26,6 +26,7 @@ import type { CtrlTabOrderMode } from './tab-types'
import type { TerminalColorOverrides } from './terminal-color-overrides'
import type { TerminalQuickCommand } from './terminal-quick-command-types'
import type { TuiAgent } from './tui-agent'
+import type { ZcodePlanSite } from './zcode-plan-sites'
import type {
AgentDashboardMode,
BranchPrefixStrategy,
@@ -400,6 +401,8 @@ export type GlobalSettings = {
minimaxUsageModels: string
/** MiniMax account region; defaults to overseas for existing users. */
minimaxEndpoint: MiniMaxEndpoint
+ /** GLM Coding Plan site whose API key is saved in AI Provider Accounts; defaults to the international Z.AI console. */
+ zcodePlanSite: ZcodePlanSite
/** Extract OAuth credentials from the local Gemini CLI for rate-limit fetching. Off by default (explicit opt-in). */
geminiCliOAuthEnabled: boolean
/** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */
diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts
index 494f56e4dff1..b9af7fe1b68c 100644
--- a/src/shared/rate-limit-state-factory.ts
+++ b/src/shared/rate-limit-state-factory.ts
@@ -18,6 +18,7 @@ export function createEmptyRateLimitState(overrides: Partial = {
opencodeGoApiKeyConfigured: false,
grokAuthConfigured: false,
cursorAuthConfigured: false,
+ zcodePlanApiKeyConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
inactiveClaudeAccounts: [],
diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts
index 6b06f09d10e9..7f69b33264ba 100644
--- a/src/shared/rate-limit-types.ts
+++ b/src/shared/rate-limit-types.ts
@@ -170,6 +170,12 @@ export type RateLimitState = {
* stored login. The token itself never leaves main.
*/
cursorAuthConfigured: boolean
+ /**
+ * True when a GLM Coding Plan API key is saved in Orca's AI Provider
+ * Accounts. The key itself never leaves main; the status bar uses this to
+ * keep the ZCode bar visible across reloads between snapshot refreshes.
+ */
+ zcodePlanApiKeyConfigured?: boolean
claudeTarget: RateLimitRuntimeTarget
codexTarget: RateLimitRuntimeTarget
inactiveClaudeAccounts: InactiveAccountUsage[]
diff --git a/src/shared/rpc-contract/client-settings-params.ts b/src/shared/rpc-contract/client-settings-params.ts
index fd0c2f986a93..6574c89a8bb7 100644
--- a/src/shared/rpc-contract/client-settings-params.ts
+++ b/src/shared/rpc-contract/client-settings-params.ts
@@ -115,6 +115,7 @@ export const SettingsUpdate = z
minimaxGroupId: z.string().optional(),
minimaxUsageModels: z.string().optional(),
minimaxEndpoint: z.enum(['overseas', 'cn']).optional(),
+ zcodePlanSite: z.enum(['zai', 'bigmodel']).optional(),
githubProjects: GitHubProjectSettings.optional(),
prBotAuthorOverrides: z
.unknown()
diff --git a/src/shared/zcode-plan-sites.ts b/src/shared/zcode-plan-sites.ts
new file mode 100644
index 000000000000..fe4088838426
--- /dev/null
+++ b/src/shared/zcode-plan-sites.ts
@@ -0,0 +1,31 @@
+import type { SecretAtRestProtection } from './secret-at-rest-protection'
+
+/**
+ * GLM Coding Plan site table shared by the zcode credential store, the usage
+ * fetcher, and the Accounts settings section. The provider id stays `zcode`;
+ * the two sites are the international Z.AI console and Zhipu's mainland
+ * BigModel platform.
+ */
+export type ZcodePlanSite = 'zai' | 'bigmodel'
+
+export const ZCODE_PLAN_SITES: readonly ZcodePlanSite[] = ['zai', 'bigmodel']
+
+export function isZcodePlanSite(value: unknown): value is ZcodePlanSite {
+ return value === 'zai' || value === 'bigmodel'
+}
+
+export const ZCODE_PLAN_SITE_BASE_URLS: Record = {
+ zai: 'https://api.z.ai',
+ bigmodel: 'https://open.bigmodel.cn'
+}
+
+export const ZCODE_PLAN_SITE_CONSOLE_URLS: Record = {
+ zai: 'https://z.ai/manage-apikey',
+ bigmodel: 'https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys'
+}
+
+export type ZcodePlanCredentialsStatus = {
+ apiKeyConfigured: boolean
+ zcodeCliConfigured: boolean
+ apiKeyProtection: SecretAtRestProtection | null
+}
From f541989f6a1ae709f3fca649b4ff7bb94959e727 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 01:36:49 -0700
Subject: [PATCH 2/9] fix(accounts): retain GLM credential results during quota
refresh
---
.../ZcodePlanAccountsSection.test.tsx | 9 +-
.../use-zcode-plan-credentials.test.tsx | 86 +++++++++++++++++++
.../settings/use-zcode-plan-credentials.ts | 28 ++++--
3 files changed, 112 insertions(+), 11 deletions(-)
create mode 100644 src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx
diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
index bc52724e40b8..5358eb262ad4 100644
--- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
+++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
@@ -52,8 +52,8 @@ import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection'
describe('ZcodePlanAccountsSection', () => {
beforeEach(() => {
mocks.getStatus.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: false })
- mocks.saveApiKey.mockResolvedValue({ apiKeyConfigured: true })
- mocks.clearApiKey.mockResolvedValue({ apiKeyConfigured: false })
+ mocks.saveApiKey.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false })
+ mocks.clearApiKey.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: false })
mocks.refreshRateLimits.mockResolvedValue(undefined)
mocks.updateSettings.mockResolvedValue(undefined)
mocks.recordFeatureInteraction.mockReset()
@@ -193,6 +193,11 @@ describe('GLM credential status races', () => {
it('keeps the saved status when an earlier status read finishes late', async () => {
let finishRead: ((value: unknown) => void) | undefined
+ mocks.getStatus.mockResolvedValue({
+ apiKeyConfigured: true,
+ zcodeCliConfigured: false,
+ apiKeyProtection: 'sealed'
+ })
mocks.getStatus.mockImplementationOnce(
() =>
new Promise((resolve) => {
diff --git a/src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx b/src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx
new file mode 100644
index 000000000000..5e2d91e778a2
--- /dev/null
+++ b/src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx
@@ -0,0 +1,86 @@
+// @vitest-environment happy-dom
+import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { ZcodePlanCredentialsStatus } from '../../../../shared/zcode-plan-sites'
+import { useZcodePlanCredentials } from './use-zcode-plan-credentials'
+
+const mocks = vi.hoisted(() => ({ interaction: vi.fn(), success: vi.fn(), error: vi.fn() }))
+vi.mock('../../store', () => ({
+ useAppStore: (select: (s: { recordFeatureInteraction: typeof mocks.interaction }) => unknown) =>
+ select({ recordFeatureInteraction: mocks.interaction })
+}))
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+vi.mock('sonner', () => ({ toast: { success: mocks.success, error: mocks.error } }))
+const unlinked: ZcodePlanCredentialsStatus = {
+ apiKeyConfigured: false,
+ zcodeCliConfigured: false,
+ apiKeyProtection: null
+}
+const linked: ZcodePlanCredentialsStatus = {
+ ...unlinked,
+ apiKeyConfigured: true,
+ apiKeyProtection: 'sealed'
+}
+
+afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+})
+
+describe('GLM credential mutation refresh races', () => {
+ it.each(['save', 'clear', 'failed-save', 'failed-clear'] as const)(
+ 'applies %s while quota changes and rereads after settling',
+ async (action) => {
+ let complete: ((status: ZcodePlanCredentialsStatus) => void) | undefined
+ let reject: ((error: Error) => void) | undefined
+ const pending = new Promise((resolve, fail) => {
+ complete = resolve
+ reject = fail
+ })
+ const initial = action.endsWith('clear') ? linked : unlinked
+ const final = action === 'save' ? linked : action === 'clear' ? unlinked : initial
+ let stored = initial
+ const api = {
+ getStatus: vi.fn(async () => stored),
+ saveApiKey: vi.fn(() => pending),
+ clearApiKey: vi.fn(() => pending)
+ }
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { zcodePlanCredentials: api }
+ })
+ const { result, rerender } = renderHook(({ time }) => useZcodePlanCredentials(time), {
+ initialProps: { time: 1 }
+ })
+ await waitFor(() => expect(result.current.status).toEqual(initial))
+ act(() => result.current.setApiKeyDraft('synthetic-key'))
+ let mutation: Promise | undefined
+ act(() => {
+ mutation = action.endsWith('clear')
+ ? result.current.clearApiKey()
+ : result.current.saveApiKey()
+ })
+ rerender({ time: 2 })
+ expect(api.getStatus).toHaveBeenCalledTimes(1)
+ await act(async () => {
+ stored = final
+ if (action.startsWith('failed')) {
+ reject?.(new Error('Synthetic failure'))
+ } else {
+ complete?.(final)
+ }
+ await mutation
+ })
+ await waitFor(() => expect(result.current.status).toEqual(final))
+ expect(api.getStatus).toHaveBeenCalledTimes(2)
+ expect(result.current.credentialBusy).toBe(false)
+ if (action.startsWith('failed')) {
+ expect(result.current.apiKeyDraft).toBe('synthetic-key')
+ expect(mocks.error).toHaveBeenCalledTimes(1)
+ expect(mocks.success).not.toHaveBeenCalled()
+ } else {
+ expect(result.current.apiKeyDraft).toBe('')
+ }
+ }
+ )
+})
diff --git a/src/renderer/src/components/settings/use-zcode-plan-credentials.ts b/src/renderer/src/components/settings/use-zcode-plan-credentials.ts
index e14f975798fa..e01b30cccc18 100644
--- a/src/renderer/src/components/settings/use-zcode-plan-credentials.ts
+++ b/src/renderer/src/components/settings/use-zcode-plan-credentials.ts
@@ -9,33 +9,36 @@ export function useZcodePlanCredentials(updatedAt: number | undefined) {
const [status, setStatus] = useState(null)
const [apiKeyDraft, setApiKeyDraft] = useState('')
const [credentialBusy, setCredentialBusy] = useState(false)
- const generation = useRef(0)
+ const readGeneration = useRef(0)
const mutationPending = useRef(false)
useEffect(() => {
if (mutationPending.current) {
return
}
- const request = ++generation.current
+ const request = ++readGeneration.current
void window.api.zcodePlanCredentials.getStatus().then(
(next) => {
- if (request === generation.current) {
+ if (request === readGeneration.current) {
setStatus(next)
}
},
() => {
- if (request === generation.current) {
+ if (request === readGeneration.current) {
setStatus(null)
}
}
)
return () => {
- generation.current += 1
+ readGeneration.current += 1
}
- }, [updatedAt])
+ }, [updatedAt, credentialBusy])
const updateCredential = async (action: 'save' | 'clear'): Promise => {
- const request = ++generation.current
+ if (mutationPending.current) {
+ return
+ }
+ readGeneration.current += 1
mutationPending.current = true
setCredentialBusy(true)
try {
@@ -43,9 +46,16 @@ export function useZcodePlanCredentials(updatedAt: number | undefined) {
action === 'save'
? await window.api.zcodePlanCredentials.saveApiKey(apiKeyDraft.trim())
: await window.api.zcodePlanCredentials.clearApiKey()
- if (request === generation.current) {
- setStatus(next)
+ if (
+ !next ||
+ typeof next.apiKeyConfigured !== 'boolean' ||
+ typeof next.zcodeCliConfigured !== 'boolean'
+ ) {
+ throw new Error(
+ 'GLM Coding Plan keys can only be changed in the desktop app on the computer running Orca.'
+ )
}
+ setStatus(next)
setApiKeyDraft('')
recordFeatureInteraction('usage-tracking')
if (action === 'save') {
From 3b84874eee4f07152f5f0f6fca1ce58c87abebbe Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 01:38:40 -0700
Subject: [PATCH 3/9] fix(accounts): make GLM credential editing desktop-only
---
.../ZcodePlanAccountsSection.test.tsx | 17 +++++++++++++
.../settings/ZcodePlanAccountsSection.tsx | 25 +++++++++++++++----
.../use-zcode-plan-credentials.test.tsx | 14 +++++++++++
src/renderer/src/i18n/locales/en.json | 3 ++-
src/renderer/src/i18n/locales/zh.json | 3 ++-
.../web/preload-api/web-agent-accounts-api.ts | 15 +++++++++++
src/renderer/src/web/web-preload-api.ts | 2 ++
7 files changed, 72 insertions(+), 7 deletions(-)
diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
index 5358eb262ad4..ecfc43f25d0b 100644
--- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
+++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const settings: { zcodePlanSite: ZcodePlanSite } = { zcodePlanSite: 'zai' }
return {
+ isWeb: vi.fn(() => false),
getStatus: vi.fn(),
saveApiKey: vi.fn(),
clearApiKey: vi.fn(),
@@ -21,6 +22,8 @@ const mocks = vi.hoisted(() => {
}
})
+vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: mocks.isWeb }))
+
vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: () => React.createElement('span', { 'data-testid': 'zcode-icon' })
}))
@@ -51,6 +54,7 @@ import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection'
describe('ZcodePlanAccountsSection', () => {
beforeEach(() => {
+ mocks.isWeb.mockReturnValue(false)
mocks.getStatus.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: false })
mocks.saveApiKey.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false })
mocks.clearApiKey.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: false })
@@ -76,6 +80,19 @@ describe('ZcodePlanAccountsSection', () => {
vi.clearAllMocks()
})
+ it('disables site and secret editing on a paired web client', async () => {
+ mocks.isWeb.mockReturnValue(true)
+ render( )
+ expect(
+ await screen.findByText(
+ 'Change the plan site and API key in the desktop app on the computer running Orca.'
+ )
+ ).toBeInTheDocument()
+ expect(screen.getByRole('combobox')).toBeDisabled()
+ expect(screen.getByPlaceholderText('Paste your GLM Coding Plan API key')).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
+ })
+
it('shows the unlinked state when neither an API key nor a CLI config exists', async () => {
render( )
diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx
index 0cb66df2832c..0e716a0e29d3 100644
--- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx
+++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx
@@ -1,4 +1,5 @@
import { useState } from 'react'
+import { isWebClientLocation } from '@/lib/web-client-location'
import { ExternalLink, Loader2, Lock, LockOpen, RefreshCw, ShieldCheck } from 'lucide-react'
import { AgentIcon } from '@/lib/agent-catalog'
import { translate } from '@/i18n/i18n'
@@ -51,12 +52,13 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {
const [refreshing, setRefreshing] = useState(false)
const now = useNow(60_000)
+ const credentialEditable = !isWebClientLocation()
const site = settings?.zcodePlanSite ?? 'zai'
const consoleUrl = ZCODE_PLAN_SITE_CONSOLE_URLS[site]
const apiKeyConfigured = status?.apiKeyConfigured === true
const handleSiteChange = (value: string): void => {
- if ((value !== 'zai' && value !== 'bigmodel') || value === site) {
+ if (!credentialEditable || (value !== 'zai' && value !== 'bigmodel') || value === site) {
return
}
// Why: main invalidates and refreshes on this settings change, so no local refresh is needed.
@@ -189,6 +191,15 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {
+ {!credentialEditable ? (
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.hostOnly',
+ 'Change the plan site and API key in the desktop app on the computer running Orca.'
+ )}
+
+ ) : null}
+
{translate('auto.components.settings.ZcodePlanAccountsSection.siteTitle', 'Plan site')}
-
+
@@ -254,7 +269,7 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {
setApiKeyDraft(e.target.value)}
placeholder={translate(
@@ -267,7 +282,7 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {
void saveApiKey()}
- disabled={credentialBusy || !apiKeyDraft.trim()}
+ disabled={credentialBusy || !credentialEditable || !apiKeyDraft.trim()}
className="shrink-0"
>
{credentialBusy ? : null}
@@ -280,7 +295,7 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {
variant="ghost"
size="xs"
onClick={() => void clearApiKey()}
- disabled={credentialBusy}
+ disabled={credentialBusy || !credentialEditable}
className="shrink-0"
>
{translate(
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
index 5e2d91e778a2..f2a295380ac4 100644
--- a/src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx
+++ b/src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx
@@ -2,6 +2,7 @@
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() }))
@@ -83,4 +84,17 @@ describe('GLM credential mutation refresh races', () => {
}
}
)
+ 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))
+ 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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index e52ec89d5f75..f57aa8126f1e 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -12309,7 +12309,8 @@
"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}}"
+ "planLevel": "Plan: {{value0}}",
+ "hostOnly": "Change the plan site and API key in the desktop app on the computer running Orca."
}
},
"right": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index f0bbca866f3d..459c7876ae73 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -12108,7 +12108,8 @@
"keyHelp": "与编码工具使用同一把套餐 key(例如 Claude Code 将 ANTHROPIC_BASE_URL 指向对应站点)。切换上方站点会变更接收该 key 的主机。",
"usageTitle": "套餐用量",
"usageDescription": "已关联 Coding Plan 的实时配额窗口,随状态栏用量周期刷新。",
- "planLevel": "套餐:{{value0}}"
+ "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..04ab39be6eb4 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,21 @@ export function createMiniMaxCredentialsApi(): NonNullable<
}
}
+export function createZcodePlanCredentialsApi(): PreloadApi['zcodePlanCredentials'] {
+ const status = { 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/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(),
From 3208cdbc1311ff8374019555770c223ca4535ad8 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 02:10:02 -0700
Subject: [PATCH 4/9] fix(accounts): mirror the host GLM site in paired clients
---
.../ZcodePlanAccountsSection.test.tsx | 2 ++
.../web/preload-api/web-preferences-store.ts | 3 ++
.../src/web/web-preload-api-settings.test.ts | 28 +++++++++++++++++++
3 files changed, 33 insertions(+)
diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
index ecfc43f25d0b..c9473ab8e934 100644
--- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
+++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
@@ -82,6 +82,7 @@ describe('ZcodePlanAccountsSection', () => {
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(
@@ -89,6 +90,7 @@ describe('ZcodePlanAccountsSection', () => {
)
).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()
})
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..fd31c1aa9ffb 100644
--- a/src/renderer/src/web/preload-api/web-preferences-store.ts
+++ b/src/renderer/src/web/preload-api/web-preferences-store.ts
@@ -139,6 +139,9 @@ export async function getRuntimeBackedStoredSettings(): Promise
if (typeof result.settings.minimaxUsageModels === 'string') {
runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels
}
+ if (result.settings.zcodePlanSite === 'zai' || result.settings.zcodePlanSite === 'bigmodel') {
+ runtimeSettings.zcodePlanSite = result.settings.zcodePlanSite
+ }
if (
result.settings.minimaxEndpoint === 'overseas' ||
result.settings.minimaxEndpoint === 'cn'
diff --git a/src/renderer/src/web/web-preload-api-settings.test.ts b/src/renderer/src/web/web-preload-api-settings.test.ts
index cbaff1f70c96..bde0fdfe16d7 100644
--- a/src/renderer/src/web/web-preload-api-settings.test.ts
+++ b/src/renderer/src/web/web-preload-api-settings.test.ts
@@ -256,6 +256,34 @@ describe('web settings preload API', () => {
expect(stored.autoRenameBranchFromWorkDefaultedOn).toBe(true)
})
+ 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')
+ await globals.window.api.settings.set({ zcodePlanSite: 'zai' })
+ await expect(
+ globals.window.api.zcodePlanCredentials.saveApiKey('synthetic-key')
+ ).rejects.toThrow()
+ expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }])
+ })
+
it('hydrates compact worktree cards from paired runtime settings', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
From 8dc022eadea736100caa6e41371a289a48787033 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 03:22:11 -0700
Subject: [PATCH 5/9] fix(accounts): report unknown GLM host details and split
web settings tests
Apply the independently reviewed Accounts correction from697284a without the v2 adapter commits. Preserve the saved-key store and serialized write behavior.
---
.../startup/main-process-account-services.ts | 2 +-
.../ZcodePlanAccountsSection.test.tsx | 32 ++++-
.../settings/ZcodePlanAccountsSection.tsx | 71 ++++++-----
.../use-zcode-plan-credentials.test.tsx | 4 +-
.../web/preload-api/web-agent-accounts-api.ts | 7 +-
.../web/preload-api/web-preferences-store.ts | 20 +++-
.../web/preload-api/web-runtime-session.ts | 5 +
.../web/preload-api/web-zcode-plan-site.ts | 23 ++++
.../src/web/web-preload-api-settings.test.ts | 28 -----
.../src/web/web-zcode-plan-settings.test.ts | 111 ++++++++++++++++++
src/shared/global-settings-types.ts | 2 +-
src/shared/zcode-plan-sites.ts | 1 +
12 files changed, 240 insertions(+), 66 deletions(-)
create mode 100644 src/renderer/src/web/preload-api/web-zcode-plan-site.ts
create mode 100644 src/renderer/src/web/web-zcode-plan-settings.test.ts
diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts
index dc7071bd9e50..d6c4d5f8d093 100644
--- a/src/main/startup/main-process-account-services.ts
+++ b/src/main/startup/main-process-account-services.ts
@@ -142,7 +142,7 @@ export function initializeMainProcessAccountServices(): void {
}
})
state.rateLimits.setZcodePlanConfigResolver(() => ({
- site: store.getSettings().zcodePlanSite,
+ site: store.getSettings().zcodePlanSite ?? 'zai',
apiKey: readZcodePlanApiKey() ?? ''
}))
state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled)
diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
index c9473ab8e934..32c277b72ced 100644
--- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
+++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx
@@ -8,7 +8,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
- const settings: { zcodePlanSite: ZcodePlanSite } = { zcodePlanSite: 'zai' }
+ const settings: { zcodePlanSite?: ZcodePlanSite } = { zcodePlanSite: 'zai' }
return {
isWeb: vi.fn(() => false),
getStatus: vi.fn(),
@@ -95,6 +95,36 @@ describe('ZcodePlanAccountsSection', () => {
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( )
diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx
index 0e716a0e29d3..be3d5e1010ef 100644
--- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx
+++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx
@@ -53,8 +53,9 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {
const now = useNow(60_000)
const credentialEditable = !isWebClientLocation()
- const site = settings?.zcodePlanSite ?? 'zai'
- const consoleUrl = ZCODE_PLAN_SITE_CONSOLE_URLS[site]
+ 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 => {
@@ -96,18 +97,20 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {
)}
-
- {translate(
- 'auto.components.settings.ZcodePlanAccountsSection.consoleLink',
- 'Get API key'
- )}
-
-
+ {consoleUrl ? (
+
+ {translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.consoleLink',
+ 'Get API key'
+ )}
+
+
+ ) : null}
- {apiKeyConfigured ? (
+ {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) }
+ { value0: siteLabel(site ?? 'zai') }
)}
@@ -216,12 +226,17 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {
{translate('auto.components.settings.ZcodePlanAccountsSection.siteTitle', 'Plan site')}
-
+
{(['zai', 'bigmodel'] as const).map((option) => (
@@ -247,15 +262,17 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {
{translate('auto.components.settings.ZcodePlanAccountsSection.keyTitle', 'API key')}
-
- {apiKeyConfigured ? : }
- {apiKeyConfigured
- ? translate('auto.components.settings.ZcodePlanAccountsSection.saved', 'Saved')
- : translate(
- 'auto.components.settings.ZcodePlanAccountsSection.notSaved',
- 'Not saved'
- )}
-
+ {!detailsUnavailable ? (
+
+ {apiKeyConfigured ? : }
+ {apiKeyConfigured
+ ? translate('auto.components.settings.ZcodePlanAccountsSection.saved', 'Saved')
+ : translate(
+ 'auto.components.settings.ZcodePlanAccountsSection.notSaved',
+ 'Not saved'
+ )}
+
+ ) : null}
{
value: { zcodePlanCredentials: createZcodePlanCredentialsApi() }
})
const { result } = renderHook(() => useZcodePlanCredentials(1))
- await waitFor(() => expect(result.current.status).toEqual(unlinked))
+ 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')
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 04ab39be6eb4..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
@@ -24,7 +24,12 @@ export function createMiniMaxCredentialsApi(): NonNullable<
}
export function createZcodePlanCredentialsApi(): PreloadApi['zcodePlanCredentials'] {
- const status = { apiKeyConfigured: false, zcodeCliConfigured: false, apiKeyProtection: null }
+ const status = {
+ detailsUnavailable: true,
+ apiKeyConfigured: false,
+ zcodeCliConfigured: false,
+ apiKeyProtection: null
+ }
const unsupported = () =>
Promise.reject(
new Error(
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 fd31c1aa9ffb..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,8 +143,11 @@ export async function getRuntimeBackedStoredSettings(): Promise
if (typeof result.settings.minimaxUsageModels === 'string') {
runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels
}
- if (result.settings.zcodePlanSite === 'zai' || result.settings.zcodePlanSite === 'bigmodel') {
- runtimeSettings.zcodePlanSite = result.settings.zcodePlanSite
+ 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' ||
@@ -172,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-settings.test.ts b/src/renderer/src/web/web-preload-api-settings.test.ts
index bde0fdfe16d7..cbaff1f70c96 100644
--- a/src/renderer/src/web/web-preload-api-settings.test.ts
+++ b/src/renderer/src/web/web-preload-api-settings.test.ts
@@ -256,34 +256,6 @@ describe('web settings preload API', () => {
expect(stored.autoRenameBranchFromWorkDefaultedOn).toBe(true)
})
- 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')
- await globals.window.api.settings.set({ zcodePlanSite: 'zai' })
- await expect(
- globals.window.api.zcodePlanCredentials.saveApiKey('synthetic-key')
- ).rejects.toThrow()
- expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }])
- })
-
it('hydrates compact worktree cards from paired runtime settings', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
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/global-settings-types.ts b/src/shared/global-settings-types.ts
index 7ff724d43ce9..e5fd7b098a3e 100644
--- a/src/shared/global-settings-types.ts
+++ b/src/shared/global-settings-types.ts
@@ -402,7 +402,7 @@ export type GlobalSettings = {
/** 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
+ zcodePlanSite?: ZcodePlanSite
/** Extract OAuth credentials from the local Gemini CLI for rate-limit fetching. Off by default (explicit opt-in). */
geminiCliOAuthEnabled: boolean
/** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */
diff --git a/src/shared/zcode-plan-sites.ts b/src/shared/zcode-plan-sites.ts
index fe4088838426..ba1770cf9562 100644
--- a/src/shared/zcode-plan-sites.ts
+++ b/src/shared/zcode-plan-sites.ts
@@ -25,6 +25,7 @@ export const ZCODE_PLAN_SITE_CONSOLE_URLS: Record = {
}
export type ZcodePlanCredentialsStatus = {
+ detailsUnavailable?: boolean
apiKeyConfigured: boolean
zcodeCliConfigured: boolean
apiKeyProtection: SecretAtRestProtection | null
From 299aab88680a6d14c893b934c9d782fc81f03b88 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 04:33:37 -0700
Subject: [PATCH 6/9] fix(zcode): ship required GLM account translation entries
---
src/renderer/src/i18n/en-runtime-required.json | 3 +++
src/renderer/src/i18n/locales/en.json | 4 +++-
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json
index 72118c9ab68d..cd2ff2c68a5f 100644
--- a/src/renderer/src/i18n/en-runtime-required.json
+++ b/src/renderer/src/i18n/en-runtime-required.json
@@ -1563,6 +1563,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 f57aa8126f1e..9d22ebe1f1d9 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -12310,7 +12310,9 @@
"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."
+ "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": {
From e378fb2d10f34958b34159d72df1b3ebceaefe89 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 06:12:44 -0700
Subject: [PATCH 7/9] chore: record GLM reconciliation hook validation
From ce1f85a75a4cd27da984eeb5ff1648d3db4d3995 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 06:13:08 -0700
Subject: [PATCH 8/9] chore: validate installed GLM commit hooks
From 38f19b0abc4f4383cea40c33fda0d534b7d7622c Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 17:35:10 -0700
Subject: [PATCH 9/9] test: complete GLM account fixtures and web API inventory
---
.../settings/AccountsPane.section-lifetime.test.tsx | 7 +++++++
src/renderer/src/web/web-preload-api-composition.test.ts | 1 +
2 files changed, 8 insertions(+)
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/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',