From 3f0e8a93bd38943c61f27e239aef0c706e71a041 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 15:18:08 -0700 Subject: [PATCH 1/4] fix(zcode): isolate read-only account discovery and quota ownership Co-authored-by: Luchong Co-authored-by: GuanBear --- .../rate-limits/service-zcode-usage.test.ts | 37 ++ .../service/service-full-cycle-application.ts | 13 +- .../rate-limits/zcode-usage-credentials.ts | 142 ++++++ src/main/rate-limits/zcode-usage-fetcher.ts | 154 ++---- .../v2-upstream/bigmodel/credentials.json | 4 + .../v2-upstream/bigmodel/provider_config.json | 17 + .../fallback-host/credentials.json | 4 + .../fallback-host/provider_config.json | 17 + .../fixtures/v2-upstream/provenance.json | 12 + .../v2-upstream/zai-other/credentials.json | 4 + .../zai-other/provider_config.json | 17 + .../fixtures/v2-upstream/zai/credentials.json | 4 + .../v2-upstream/zai/provider_config.json | 17 + ...zcode-v2-personal-overlay-raw-json.test.ts | 124 +++++ src/main/zcode/zcode-v2-personal-overlay.ts | 73 +++ .../zcode/zcode-v2-plan-credentials.test.ts | 471 ++++++++++++++++++ src/main/zcode/zcode-v2-plan-credentials.ts | 135 +++++ .../settings/ZcodePlanAccountsSection.tsx | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- .../node-bounded-file-reader-sync.test.ts | 39 +- src/shared/node-bounded-file-reader.ts | 17 +- tests/e2e/helpers/electron-home-isolation.ts | 7 +- .../electron-home-isolation.unit.test.ts | 139 +++++- tests/e2e/helpers/orca-app.ts | 2 +- tests/e2e/zcode-plan-credentials.spec.ts | 93 ++++ 26 files changed, 1425 insertions(+), 123 deletions(-) create mode 100644 src/main/rate-limits/zcode-usage-credentials.ts create mode 100644 src/main/zcode/fixtures/v2-upstream/bigmodel/credentials.json create mode 100644 src/main/zcode/fixtures/v2-upstream/bigmodel/provider_config.json create mode 100644 src/main/zcode/fixtures/v2-upstream/fallback-host/credentials.json create mode 100644 src/main/zcode/fixtures/v2-upstream/fallback-host/provider_config.json create mode 100644 src/main/zcode/fixtures/v2-upstream/provenance.json create mode 100644 src/main/zcode/fixtures/v2-upstream/zai-other/credentials.json create mode 100644 src/main/zcode/fixtures/v2-upstream/zai-other/provider_config.json create mode 100644 src/main/zcode/fixtures/v2-upstream/zai/credentials.json create mode 100644 src/main/zcode/fixtures/v2-upstream/zai/provider_config.json create mode 100644 src/main/zcode/zcode-v2-personal-overlay-raw-json.test.ts create mode 100644 src/main/zcode/zcode-v2-personal-overlay.ts create mode 100644 src/main/zcode/zcode-v2-plan-credentials.test.ts create mode 100644 src/main/zcode/zcode-v2-plan-credentials.ts create mode 100644 tests/e2e/zcode-plan-credentials.spec.ts diff --git a/src/main/rate-limits/service-zcode-usage.test.ts b/src/main/rate-limits/service-zcode-usage.test.ts index 7aa9e911c602..1a775d67ccaf 100644 --- a/src/main/rate-limits/service-zcode-usage.test.ts +++ b/src/main/rate-limits/service-zcode-usage.test.ts @@ -1,3 +1,7 @@ +import { cpSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readZcodeUsageCredentials } from './zcode-usage-credentials' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { RateLimitService } from './service' @@ -186,4 +190,37 @@ describe('RateLimitService zcode plan credentials', () => { expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(2) expect(state.zcode?.session?.usedPercent).toBe(10) }) + it('rejects an old CLI account result after sibling-provider waits', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-zcode-service-v2-')) + const fixtures = join(import.meta.dirname, '..', 'zcode', 'fixtures', 'v2-upstream') + vi.stubEnv('ZCODE_DATA_BASE_DIR', home) + vi.stubEnv('ZCODE_CREDENTIAL_SECRET', 'synthetic-secret') + try { + cpSync(join(fixtures, 'zai'), join(home, '.zcode', 'v2'), { recursive: true }) + const selected = readZcodeUsageCredentials() + if (selected.status !== 'ok') { + throw new Error('Synthetic fixture could not be read') + } + const claude = deferred() + vi.mocked(fetchClaudeRateLimits).mockImplementationOnce(() => claude.promise) + vi.mocked(fetchZcodeRateLimits).mockResolvedValueOnce({ + ...okProvider('zcode', 44, Date.now()), + usageMetadata: { + authProvenance: selected.credentials.authProvenance, + credentialSource: selected.source + } + }) + const service = new RateLimitService() + const refreshing = service.refresh() + await vi.waitFor(() => expect(fetchZcodeRateLimits).toHaveBeenCalled()) + cpSync(join(fixtures, 'zai-other'), join(home, '.zcode', 'v2'), { recursive: true }) + claude.resolve(okProvider('claude', 7)) + await refreshing + expect(service.getState().zcode?.status).toBe('unavailable') + expect(service.getState().zcode?.session).toBeNull() + } finally { + vi.unstubAllEnvs() + rmSync(home, { recursive: true, force: true }) + } + }) }) 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 2731f882be5a..7181b0ddf9cc 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,4 +1,5 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' +import { isZcodeUsageCredentialCurrent } from '../zcode-usage-credentials' import { settleSiblingProviderResult } from './service-sibling-provider-result' import type { ProviderRateLimits } from './service-types' @@ -202,7 +203,17 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ } const grok = settleSiblingProviderResult('grok', grokSettled) const cursor = settleSiblingProviderResult('cursor', cursorSettled) - const zcode = settleSiblingProviderResult('zcode', zcodeSettled) + const settledZcode = settleSiblingProviderResult('zcode', zcodeSettled) + const zcode = isZcodeUsageCredentialCurrent(settledZcode) + ? settledZcode + : { + ...settledZcode, + status: 'unavailable' as const, + session: null, + weekly: null, + monthly: null, + error: 'ZCode Coding Plan account changed during refresh' + } const shouldApplyZcode = zcodeGeneration === this.zcodeFetchGeneration const antigravity = settleSiblingProviderResult('antigravity', antigravitySettled) // Why: the stale policy keeps a recent snapshot through a failed refresh, but diff --git a/src/main/rate-limits/zcode-usage-credentials.ts b/src/main/rate-limits/zcode-usage-credentials.ts new file mode 100644 index 000000000000..d2aa3669e90c --- /dev/null +++ b/src/main/rate-limits/zcode-usage-credentials.ts @@ -0,0 +1,142 @@ +import { createHmac, randomBytes } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + readZcodeV2PlanCredential, + localZcodeCredentialHost, + type ZcodeCredentialHost +} from '../zcode/zcode-v2-plan-credentials' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' + +const SUPPORTED_HOSTS = new Set(['api.z.ai', 'open.bigmodel.cn', 'dev.bigmodel.cn']) +const CREDENTIAL_IDENTITY_KEY = randomBytes(32) +export const ZCODE_PLAN_CREDENTIAL_SOURCE = 'orca-plan' +export type ZcodePlanCredential = { apiKey: string; baseUrl: string } +export type ZcodeUsageCredentials = { apiKey: string; quotaUrl: string; authProvenance: string } +export type ZcodeUsageCredentialOptions = { + configPath?: string + credentialHost?: ZcodeCredentialHost +} +export type ZcodeUsageCredentialRead = + | { status: 'unavailable' | 'error' } + | { status: 'ok'; credentials: ZcodeUsageCredentials; source: string } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readRecord(value: unknown): Record | null { + return isRecord(value) ? value : null +} + +function readMainProvider(model: unknown): string | null { + const name = typeof model === 'string' ? model : readRecord(model)?.main + if (typeof name !== 'string') { + return null + } + const delimiter = name.indexOf('/') + return delimiter > 0 && delimiter < name.length - 1 ? name.slice(0, delimiter) : null +} +function readCredentials(configPath: string): ZcodeUsageCredentials | null { + let config: Record | null + try { + config = readRecord(JSON.parse(readFileSync(configPath, 'utf8'))) + } catch { + return null + } + + if (!config) { + return null + } + // A quota from another configured account must never appear as the selected model's quota. + const mainProvider = readMainProvider(config.model) + if (!mainProvider) { + return null + } + const options = readRecord(readRecord(readRecord(config.provider)?.[mainProvider])?.options) + const apiKey = options?.apiKey + const baseURL = options?.baseURL + if ( + typeof apiKey !== 'string' || + !apiKey.trim() || + /[\r\n]/.test(apiKey) || + typeof baseURL !== 'string' + ) { + return null + } + return resolveUsageCredentials(apiKey, baseURL, mainProvider) +} + +export 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) + if ( + parsed.protocol !== 'https:' || + !SUPPORTED_HOSTS.has(parsed.hostname) || + (parsed.port !== '' && parsed.port !== '443') + ) { + return null + } + return { + apiKey, + quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`, + authProvenance: createHmac('sha256', CREDENTIAL_IDENTITY_KEY) + .update(JSON.stringify([identity, parsed.origin, apiKey])) + .digest('hex') + } + } catch { + return null + } +} + +export function readZcodeUsageCredentials( + options: ZcodeUsageCredentialOptions = {} +): ZcodeUsageCredentialRead { + const host = options.credentialHost ?? localZcodeCredentialHost() + const configPath = options.configPath ?? join(host.home, '.zcode', 'cli', 'config.json') + // An explicit legacy fixture path never probes the developer's credential store. + const v2 = + options.configPath && !options.credentialHost + ? { status: 'absent' as const } + : readZcodeV2PlanCredential(host) + if (v2.status === 'error') { + return { status: 'error' } + } + if (v2.status === 'unavailable') { + return { status: 'unavailable' } + } + const credentials = + v2.status === 'ok' + ? resolveUsageCredentials(v2.apiKey, v2.baseUrl, v2.identity) + : readCredentials(configPath) + return credentials + ? { status: 'ok', credentials, source: v2.status === 'ok' ? v2.configPath : configPath } + : { status: 'unavailable' } +} + +export function hasZcodeCliPlanCredentials(configPath?: string): boolean { + return readZcodeUsageCredentials({ configPath }).status === 'ok' +} + +export function isZcodeUsageCredentialCurrent(usage: ProviderRateLimits): boolean { + if ( + !usage.usageMetadata?.authProvenance || + !usage.usageMetadata.credentialSource || + usage.usageMetadata.credentialSource === ZCODE_PLAN_CREDENTIAL_SOURCE + ) { + return true + } + const current = readZcodeUsageCredentials() + return ( + current.status === 'ok' && + current.credentials.authProvenance === usage.usageMetadata.authProvenance + ) +} diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts index 2eba29a4f98a..a8b8a4d00ae8 100644 --- a/src/main/rate-limits/zcode-usage-fetcher.ts +++ b/src/main/rate-limits/zcode-usage-fetcher.ts @@ -1,13 +1,20 @@ -import { createHmac, randomBytes } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' +import { + readZcodeUsageCredentials, + resolveUsageCredentials, + ZCODE_PLAN_CREDENTIAL_SOURCE, + type ZcodePlanCredential, + type ZcodeUsageCredentials, + type ZcodeUsageCredentialOptions +} from './zcode-usage-credentials' +export { + hasZcodeCliPlanCredentials, + ZCODE_PLAN_CREDENTIAL_SOURCE, + type ZcodePlanCredential +} from './zcode-usage-credentials' import { cancelUnreadResponseBody } from '../lib/unread-response-body' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' const API_TIMEOUT_MS = 15_000 -const SUPPORTED_HOSTS = new Set(['api.z.ai', 'open.bigmodel.cn', 'dev.bigmodel.cn']) -const CREDENTIAL_IDENTITY_KEY = randomBytes(32) type QuotaLimit = { type?: unknown @@ -20,20 +27,6 @@ type QuotaLimit = { nextResetTime?: unknown } -type ZcodeUsageCredentials = { - apiKey: string - quotaUrl: string - 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 { @@ -44,15 +37,6 @@ function readRecord(value: unknown): Record | null { return isRecord(value) ? value : null } -function readMainProvider(model: unknown): string | null { - const name = typeof model === 'string' ? model : readRecord(model)?.main - if (typeof name !== 'string') { - return null - } - const delimiter = name.indexOf('/') - return delimiter > 0 && delimiter < name.length - 1 ? name.slice(0, delimiter) : null -} - function unavailable(error: string): ProviderRateLimits { return { provider: 'zcode', @@ -87,72 +71,6 @@ function redactCredential(error: string, apiKey: string): string { return error.replaceAll(apiKey, '[redacted]') } -function readCredentials(configPath: string): ZcodeUsageCredentials | null { - let config: Record | null - try { - config = readRecord(JSON.parse(readFileSync(configPath, 'utf8'))) - } catch { - return null - } - - if (!config) { - return null - } - // A quota from another configured account must never appear as the selected model's quota. - const mainProvider = readMainProvider(config.model) - if (!mainProvider) { - return null - } - const options = readRecord(readRecord(readRecord(config.provider)?.[mainProvider])?.options) - const apiKey = options?.apiKey - const baseURL = options?.baseURL - if ( - typeof apiKey !== 'string' || - !apiKey.trim() || - /[\r\n]/.test(apiKey) || - typeof baseURL !== 'string' - ) { - 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) - if ( - parsed.protocol !== 'https:' || - !SUPPORTED_HOSTS.has(parsed.hostname) || - (parsed.port !== '' && parsed.port !== '443') - ) { - return null - } - return { - apiKey, - quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`, - authProvenance: createHmac('sha256', CREDENTIAL_IDENTITY_KEY) - .update(JSON.stringify([identity, parsed.origin, apiKey])) - .digest('hex') - } - } catch { - return null - } -} - -export function hasZcodeCliPlanCredentials( - configPath = join(homedir(), '.zcode', 'cli', 'config.json') -): boolean { - return readCredentials(configPath) !== null -} - function asNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } @@ -210,35 +128,49 @@ function asWindow(limit: QuotaLimit | undefined): RateLimitWindow | null { } export async function fetchZcodeRateLimits( - options: { - configPath?: string + options: ZcodeUsageCredentialOptions & { planCredential?: ZcodePlanCredential | null signal?: AbortSignal } = {} ): Promise { - const configPath = options.configPath ?? join(homedir(), '.zcode', 'cli', 'config.json') - const planCredentials = options.planCredential - ? resolveUsageCredentials( - options.planCredential.apiKey, - options.planCredential.baseUrl, - ZCODE_PLAN_CREDENTIAL_SOURCE - ) + const plan = options.planCredential + const saved = plan + ? resolveUsageCredentials(plan.apiKey, plan.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. + if (plan && !saved) { return failed('The saved GLM Coding Plan API key is unusable', 'parse', '') } - const credentials = planCredentials ?? readCredentials(configPath) + const cli = saved ? null : readZcodeUsageCredentials(options) + if (cli?.status === 'error') { + return failed('Could not read the selected ZCode CLI Coding Plan credential', 'parse', '') + } + const credentials = saved ?? (cli?.status === 'ok' ? cli.credentials : null) if (!credentials) { return unavailable('ZCode Coding Plan credentials are not configured') } - const credentialSource = planCredentials ? ZCODE_PLAN_CREDENTIAL_SOURCE : configPath + const source = saved ? ZCODE_PLAN_CREDENTIAL_SOURCE : cli?.status === 'ok' ? cli.source : '' + const result = await fetchUsage(credentials, source, options.signal) + if (!saved) { + const current = readZcodeUsageCredentials(options) + if ( + current.status !== 'ok' || + current.credentials.authProvenance !== credentials.authProvenance + ) { + return unavailable('ZCode Coding Plan account changed during refresh') + } + } + return { ...result, usageMetadata: { ...result.usageMetadata, credentialSource: source } } +} +async function fetchUsage( + credentials: ZcodeUsageCredentials, + credentialSource: string, + callerSignal?: AbortSignal +): Promise { let response: Response try { - const signal = options.signal - ? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + const signal = callerSignal + ? AbortSignal.any([callerSignal, AbortSignal.timeout(API_TIMEOUT_MS)]) : AbortSignal.timeout(API_TIMEOUT_MS) response = await fetch(credentials.quotaUrl, { method: 'GET', diff --git a/src/main/zcode/fixtures/v2-upstream/bigmodel/credentials.json b/src/main/zcode/fixtures/v2-upstream/bigmodel/credentials.json new file mode 100644 index 000000000000..d692311885b4 --- /dev/null +++ b/src/main/zcode/fixtures/v2-upstream/bigmodel/credentials.json @@ -0,0 +1,4 @@ +{ + "account-provider:account:bigmodel-individual-coding-plan:identity": "enc:v1:Xa4djzkgGVuA9hgY.lE9U5VVGy6u5-yeidwVjNg.ryVJkols9xwgTv_GHgdb_WR0TGNVXofq9oDHoDXp", + "account-provider:coding-plan:account:bigmodel-individual-coding-plan:account:synthetic%20account%2F%40with%20spaces:api-key": "enc:v1:KncfBBD-_1mECFii.umdw6EsApWFPzaDSEJpvjQ.AkEUDOTLiKzFRhTvRScxXwEPoXPNAg" +} diff --git a/src/main/zcode/fixtures/v2-upstream/bigmodel/provider_config.json b/src/main/zcode/fixtures/v2-upstream/bigmodel/provider_config.json new file mode 100644 index 000000000000..e6c6091aa5d2 --- /dev/null +++ b/src/main/zcode/fixtures/v2-upstream/bigmodel/provider_config.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "config": { + "providerOrder": [], + "providerConfigRules": { + "providerRules": [] + }, + "modelConfigRules": { + "providerModelRules": [], + "manualProviderModelRules": [] + }, + "defaultModelSelection": { + "providerId": "account:bigmodel-individual-coding-plan", + "modelId": "GLM-5.3" + } + } +} diff --git a/src/main/zcode/fixtures/v2-upstream/fallback-host/credentials.json b/src/main/zcode/fixtures/v2-upstream/fallback-host/credentials.json new file mode 100644 index 000000000000..3399c21a9a01 --- /dev/null +++ b/src/main/zcode/fixtures/v2-upstream/fallback-host/credentials.json @@ -0,0 +1,4 @@ +{ + "account-provider:account:zai-individual-coding-plan:identity": "enc:v1:HfDnNz2oziizSVMW.YDBr9NKtzDAYF3nwluPqgA._K06iVA8mswh6ObM0TtvfG9ZMtvHhr0ytYc-Ky4d", + "account-provider:coding-plan:account:zai-individual-coding-plan:account:synthetic%20account%2F%40with%20spaces:api-key": "enc:v1:LIO_RzkH-jSL81wl.x3plNd8js7D5rDQDMF7_zg.T_1k-zSDyAeWgRyCO-erPSD28pcKyj4ABbAl" +} diff --git a/src/main/zcode/fixtures/v2-upstream/fallback-host/provider_config.json b/src/main/zcode/fixtures/v2-upstream/fallback-host/provider_config.json new file mode 100644 index 000000000000..3878761449d4 --- /dev/null +++ b/src/main/zcode/fixtures/v2-upstream/fallback-host/provider_config.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "config": { + "providerOrder": [], + "providerConfigRules": { + "providerRules": [] + }, + "modelConfigRules": { + "providerModelRules": [], + "manualProviderModelRules": [] + }, + "defaultModelSelection": { + "providerId": "account:zai-individual-coding-plan", + "modelId": "GLM-5.3" + } + } +} diff --git a/src/main/zcode/fixtures/v2-upstream/provenance.json b/src/main/zcode/fixtures/v2-upstream/provenance.json new file mode 100644 index 000000000000..67a9176bc578 --- /dev/null +++ b/src/main/zcode/fixtures/v2-upstream/provenance.json @@ -0,0 +1,12 @@ +{ + "revision": "29628c9acdb81b703bbd4080c207a0e7ce5e276e", + "cliVersion": "0.16.9", + "writer": "SharedZCodeCredentialStore + NodePersonalProviderConfigRepository + NodeModelSelectionConfigRepository", + "secret": "synthetic-secret", + "fallbackHost": { + "platform": "linux", + "home": "/synthetic/home", + "username": "synthetic-user" + }, + "identity": "synthetic account/@with spaces" +} diff --git a/src/main/zcode/fixtures/v2-upstream/zai-other/credentials.json b/src/main/zcode/fixtures/v2-upstream/zai-other/credentials.json new file mode 100644 index 000000000000..a7c30f564fad --- /dev/null +++ b/src/main/zcode/fixtures/v2-upstream/zai-other/credentials.json @@ -0,0 +1,4 @@ +{ + "account-provider:account:zai-individual-coding-plan:identity": "enc:v1:nydk9ZyxEN9uPpkb.HIVQq-Bb7vg50t_Oh31x6A.oTB8Ws2qrMfs8-eXZzP7khi8lCziuH5B", + "account-provider:coding-plan:account:zai-individual-coding-plan:account:synthetic%20account%2Fsecond:api-key": "enc:v1:kTCOzlXEAs738Y0_.G9qnJJ66F2jZIRBycu-0ag.zz0wpl0fQTqz9ilAR08Ufg_2EIIxIlg" +} diff --git a/src/main/zcode/fixtures/v2-upstream/zai-other/provider_config.json b/src/main/zcode/fixtures/v2-upstream/zai-other/provider_config.json new file mode 100644 index 000000000000..3878761449d4 --- /dev/null +++ b/src/main/zcode/fixtures/v2-upstream/zai-other/provider_config.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "config": { + "providerOrder": [], + "providerConfigRules": { + "providerRules": [] + }, + "modelConfigRules": { + "providerModelRules": [], + "manualProviderModelRules": [] + }, + "defaultModelSelection": { + "providerId": "account:zai-individual-coding-plan", + "modelId": "GLM-5.3" + } + } +} diff --git a/src/main/zcode/fixtures/v2-upstream/zai/credentials.json b/src/main/zcode/fixtures/v2-upstream/zai/credentials.json new file mode 100644 index 000000000000..33ba957578db --- /dev/null +++ b/src/main/zcode/fixtures/v2-upstream/zai/credentials.json @@ -0,0 +1,4 @@ +{ + "account-provider:account:zai-individual-coding-plan:identity": "enc:v1:f7YgLlGR-NmwR8sb.OH3Pnq4j0CoNjKnjPei8Sw.cN-lfdrwUsEE4UlnS9xTFoFgwhQqyY7OQy64_TQ8", + "account-provider:coding-plan:account:zai-individual-coding-plan:account:synthetic%20account%2F%40with%20spaces:api-key": "enc:v1:BMaY1kv0yy5BA1Sp.YMe8YhN_Q8KVb6RI6zKR8Q.BwywWeSZjU6r01MsI81TZOo" +} diff --git a/src/main/zcode/fixtures/v2-upstream/zai/provider_config.json b/src/main/zcode/fixtures/v2-upstream/zai/provider_config.json new file mode 100644 index 000000000000..3878761449d4 --- /dev/null +++ b/src/main/zcode/fixtures/v2-upstream/zai/provider_config.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "config": { + "providerOrder": [], + "providerConfigRules": { + "providerRules": [] + }, + "modelConfigRules": { + "providerModelRules": [], + "manualProviderModelRules": [] + }, + "defaultModelSelection": { + "providerId": "account:zai-individual-coding-plan", + "modelId": "GLM-5.3" + } + } +} diff --git a/src/main/zcode/zcode-v2-personal-overlay-raw-json.test.ts b/src/main/zcode/zcode-v2-personal-overlay-raw-json.test.ts new file mode 100644 index 000000000000..1e920f7af2ff --- /dev/null +++ b/src/main/zcode/zcode-v2-personal-overlay-raw-json.test.ts @@ -0,0 +1,124 @@ +import * as fs 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 '../rate-limits/zcode-usage-fetcher' +import { readZcodeUsageCredentials } from '../rate-limits/zcode-usage-credentials' +import { readZcodeV2PlanCredential, type ZcodeCredentialHost } from './zcode-v2-plan-credentials' + +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()) +})) + +let home: string +let host: ZcodeCredentialHost +let configPath: string +let vaultPath: string + +beforeEach(() => { + home = fs.mkdtempSync(join(tmpdir(), 'orca-zcode-raw-json-')) + fs.cpSync( + join(import.meta.dirname, 'fixtures', 'v2-upstream', 'bigmodel'), + join(home, '.zcode', 'v2'), + { + recursive: true + } + ) + configPath = join(home, '.zcode', 'v2', 'provider_config.json') + vaultPath = join(home, '.zcode', 'v2', 'credentials.json') + host = { + home, + platform: 'linux', + username: 'synthetic-user', + env: { ZCODE_DATA_BASE_DIR: home, ZCODE_CREDENTIAL_SECRET: 'synthetic-secret' } + } + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + success: true, + data: { + limits: [{ type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 12 }] + } + }) + ) + ) + ) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + fs.rmSync(home, { recursive: true, force: true }) +}) + +function installRawLeaf(group: string, rawLeaf: string): void { + const document = JSON.parse(fs.readFileSync(configPath, 'utf8')) + const selection = document.config.defaultModelSelection + const rule = { + providerId: selection.providerId, + ...(group === 'providerRules' ? {} : { modelId: selection.modelId }), + config: JSON.parse(rawLeaf) + } + const rules = + group === 'providerRules' + ? document.config.providerConfigRules + : document.config.modelConfigRules + rules[group] = [rule] + fs.writeFileSync(configPath, JSON.stringify(document)) +} + +const cases = ['providerRules', 'providerModelRules', 'manualProviderModelRules'].flatMap((group) => + ['__proto__', 'constructor', 'prototype'].map((key) => ({ group, key })) +) + +describe('raw persisted personal rule keys', () => { + it.each(cases)( + 'refuses $group with raw $key before vault access or quota', + async ({ group, key }) => { + installRawLeaf(group, `{"${key}":{"syntheticUnknownLeaf":true}}`) + const configBefore = fs.readFileSync(configPath) + const vaultBefore = fs.readFileSync(vaultPath) + const stat = vi.spyOn(fs, 'statSync') + const lstat = vi.spyOn(fs, 'lstatSync') + const open = vi.spyOn(fs, 'openSync') + const read = vi.spyOn(fs, 'readFileSync') + + expect(readZcodeV2PlanCredential(host).status).toBe('unavailable') + expect(readZcodeUsageCredentials({ credentialHost: host }).status).toBe('unavailable') + expect((await fetchZcodeRateLimits({ credentialHost: host })).status).toBe('unavailable') + expect(fetch).not.toHaveBeenCalled() + for (const calls of [stat.mock.calls, lstat.mock.calls, open.mock.calls, read.mock.calls]) { + expect(calls.filter(([path]) => path === vaultPath)).toHaveLength(0) + } + expect(fs.readFileSync(configPath)).toEqual(configBefore) + expect(fs.readFileSync(vaultPath)).toEqual(vaultBefore) + + const saved = await fetchZcodeRateLimits({ + credentialHost: host, + planCredential: { + apiKey: 'synthetic-orca-key', + baseUrl: 'https://api.z.ai' + } + }) + expect(saved.status).toBe('ok') + expect(saved.usageMetadata?.credentialSource).toBe('orca-plan') + expect(JSON.stringify(saved)).not.toContain('synthetic-orca-key') + } + ) + + it.each(['providerRules', 'providerModelRules'])('accepts a known empty %s config', (group) => { + installRawLeaf(group, '{}') + expect(readZcodeV2PlanCredential(host)).toMatchObject({ + status: 'ok', + baseUrl: 'https://open.bigmodel.cn' + }) + }) + + it.each(['null', '[]', '42'])('rejects a malformed raw config %s', (raw) => { + installRawLeaf('providerRules', raw) + expect(readZcodeV2PlanCredential(host).status).toBe('error') + }) +}) diff --git a/src/main/zcode/zcode-v2-personal-overlay.ts b/src/main/zcode/zcode-v2-personal-overlay.ts new file mode 100644 index 000000000000..cd883f798322 --- /dev/null +++ b/src/main/zcode/zcode-v2-personal-overlay.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' + +// Preserve raw own keys: Zod records erase __proto__ before emptiness checks. +const rawConfigSchema = z.custom>( + (value) => typeof value === 'object' && value !== null && !Array.isArray(value) +) + +const modelRuleSchema = z + .object({ + providerId: z.string().min(1), + modelId: z.string().min(1), + config: rawConfigSchema + }) + .strict() +const providerRuleSchema = z + .object({ + providerId: z.string().min(1), + templateId: z.string().min(1).nullable().optional(), + providerName: z.string().min(1).nullable().optional(), + enabled: z.boolean().optional(), + config: rawConfigSchema + }) + .strict() +const selectionSchema = z + .object({ + schemaVersion: z.literal(1), + config: z + .object({ + providerOrder: z.array(z.string().min(1)).optional(), + providerConfigRules: z.object({ providerRules: z.array(providerRuleSchema) }).strict(), + modelConfigRules: z + .object({ + providerModelRules: z.array(modelRuleSchema), + manualProviderModelRules: z.array(modelRuleSchema) + }) + .strict(), + defaultModelSelection: z + .object({ + providerId: z.string().trim().min(1), + modelId: z.string().trim().min(1), + options: z + .object({ reasoningLevel: z.string().trim().min(1).optional() }) + .strict() + .optional() + }) + .strict() + .optional() + }) + .strict() + }) + .strict() + +// ZCode 29628c9 validates the whole overlay before accepting its selected account. +export function readSupportedZcodeV2Selection(input: unknown) { + const { config } = selectionSchema.parse(input) + const providerRules = config.providerConfigRules.providerRules + const providerIds = new Set(providerRules.map((rule) => rule.providerId)) + if (providerIds.size !== providerRules.length) { + throw new Error('Invalid personal provider rules') + } + + // Empty smart rules are validated; richer leaves and manual normalization are unsupported. + if ( + providerRules.some((rule) => Object.keys(rule.config).length > 0) || + config.modelConfigRules.providerModelRules.some( + (rule) => Object.keys(rule.config).length > 0 + ) || + config.modelConfigRules.manualProviderModelRules.length > 0 + ) { + return undefined + } + return config.defaultModelSelection +} diff --git a/src/main/zcode/zcode-v2-plan-credentials.test.ts b/src/main/zcode/zcode-v2-plan-credentials.test.ts new file mode 100644 index 000000000000..34058bfdf670 --- /dev/null +++ b/src/main/zcode/zcode-v2-plan-credentials.test.ts @@ -0,0 +1,471 @@ +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { buildSync } from 'esbuild' +import { runProcess } from '@orca/process-host' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readZcodeV2PlanCredential, type ZcodeCredentialHost } from './zcode-v2-plan-credentials' +import { readZcodeUsageCredentials } from '../rate-limits/zcode-usage-credentials' +import { fetchZcodeRateLimits } from '../rate-limits/zcode-usage-fetcher' + +let dir: string +let host: ZcodeCredentialHost +const fixtureRoot = join(import.meta.dirname, 'fixtures', 'v2-upstream') +function install(name = 'zai') { + cpSync(join(fixtureRoot, name), join(dir, '.zcode', 'v2'), { recursive: true }) +} +function legacy() { + mkdirSync(join(dir, '.zcode', 'cli'), { recursive: true }) + writeFileSync( + join(dir, '.zcode', 'cli', 'config.json'), + JSON.stringify({ + model: 'legacy/GLM', + provider: { + legacy: { options: { apiKey: 'synthetic-legacy-key', baseURL: 'https://open.bigmodel.cn' } } + } + }) + ) +} +function quota() { + return new Response( + JSON.stringify({ + success: true, + data: { limits: [{ type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 12 }] } + }) + ) +} +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-zcode-v2-')) + host = { + home: dir, + platform: 'linux', + username: 'synthetic-user', + env: { ZCODE_DATA_BASE_DIR: dir, ZCODE_CREDENTIAL_SECRET: 'synthetic-secret' } + } + vi.stubGlobal( + 'fetch', + vi.fn(async () => quota()) + ) +}) +afterEach(() => { + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('upstream-written ZCode v2 stores', () => { + it.each(['zai', 'bigmodel'])( + 'reads selected %s plan, exact encoded identity key and site without writing', + async (name) => { + install(name) + const before = readFileSync(join(dir, '.zcode', 'v2', 'credentials.json')) + expect(readZcodeV2PlanCredential(host)).toMatchObject({ + status: 'ok', + apiKey: `synthetic-${name}-key`, + baseUrl: name === 'zai' ? 'https://api.z.ai' : 'https://open.bigmodel.cn' + }) + const result = await fetchZcodeRateLimits({ credentialHost: host }) + expect(result.status).toBe('ok') + const call = vi.mocked(fetch).mock.calls[0] + expect(String(call[0])).toContain(name === 'zai' ? 'api.z.ai' : 'open.bigmodel.cn') + expect(new Headers(call[1]?.headers).get('Authorization')).toBe(`synthetic-${name}-key`) + expect(JSON.stringify(result)).not.toContain(`synthetic-${name}-key`) + expect(JSON.stringify(result)).not.toContain('synthetic account') + expect(readFileSync(join(dir, '.zcode', 'v2', 'credentials.json'))).toEqual(before) + expect(readdirSync(join(dir, '.zcode', 'v2')).sort()).toEqual([ + 'credentials.json', + 'provider_config.json' + ]) + } + ) + + it('prefers migrated credentials over a stale legacy account', () => { + legacy() + install() + const result = readZcodeUsageCredentials({ credentialHost: host }) + expect(result).toMatchObject({ status: 'ok', credentials: { apiKey: 'synthetic-zai-key' } }) + }) + + it('keeps legacy support when no personal v2 file exists', () => { + legacy() + expect(readZcodeUsageCredentials({ credentialHost: host })).toMatchObject({ + status: 'ok', + credentials: { apiKey: 'synthetic-legacy-key' } + }) + }) + + it.each([ + 'unsupported', + 'missing-selection', + 'future-schema', + 'invalid-json', + 'bad-ciphertext', + 'missing-key' + ])('never substitutes legacy credentials for %s v2 state', async (kind) => { + legacy() + install() + const path = join(dir, '.zcode', 'v2', 'provider_config.json') + if (kind === 'unsupported') { + writeFileSync( + path, + JSON.stringify({ + schemaVersion: 1, + config: { + defaultModelSelection: { providerId: 'account:zai-team-coding-plan', modelId: 'GLM' } + } + }) + ) + } + if (kind === 'missing-selection') { + writeFileSync(path, JSON.stringify({ schemaVersion: 1, config: {} })) + } + if (kind === 'future-schema') { + writeFileSync(path, JSON.stringify({ schemaVersion: 2, config: {} })) + } + if (kind === 'invalid-json') { + writeFileSync(path, 'not-json') + } + if (kind === 'bad-ciphertext') { + writeFileSync( + join(dir, '.zcode', 'v2', 'credentials.json'), + JSON.stringify({ + 'account-provider:account:zai-individual-coding-plan:identity': 'enc:v1:broken' + }) + ) + } + if (kind === 'missing-key') { + writeFileSync(join(dir, '.zcode', 'v2', 'credentials.json'), '{}') + } + const result = await fetchZcodeRateLimits({ credentialHost: host }) + expect(['unavailable', 'error']).toContain(result.status) + expect(result.session).toBeNull() + expect(fetch).not.toHaveBeenCalled() + expect(JSON.stringify(result)).not.toContain('synthetic-legacy-key') + }) + + it('honors custom secret and does not decrypt another host secret', () => { + install() + expect(readZcodeV2PlanCredential(host).status).toBe('ok') + expect( + readZcodeV2PlanCredential({ + ...host, + env: { ...host.env, ZCODE_CREDENTIAL_SECRET: 'wrong-secret' } + }) + ).toEqual({ status: 'error' }) + }) + + it('honors the upstream personal config override independently of the credential directory', () => { + install() + const selected = join(dir, 'selected.json') + cpSync(join(dir, '.zcode', 'v2', 'provider_config.json'), selected) + writeFileSync(join(dir, '.zcode', 'v2', 'provider_config.json'), 'invalid-default') + expect( + readZcodeV2PlanCredential({ + ...host, + env: { ...host.env, ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: '~/selected.json' } + }) + ).toMatchObject({ status: 'ok', configPath: selected }) + }) + + it('accepts upstream plaintext compatibility and rejects header injection without a request', async () => { + install() + const path = join(dir, '.zcode', 'v2', 'credentials.json') + const identityKey = 'account-provider:account:zai-individual-coding-plan:identity' + const apiKey = + 'account-provider:coding-plan:account:zai-individual-coding-plan:account:synthetic:api-key' + writeFileSync( + path, + JSON.stringify({ [identityKey]: 'synthetic', [apiKey]: 'synthetic-plain-key' }) + ) + expect(readZcodeV2PlanCredential(host)).toMatchObject({ + status: 'ok', + apiKey: 'synthetic-plain-key' + }) + writeFileSync( + path, + JSON.stringify({ [identityKey]: 'synthetic', [apiKey]: 'key\r\nInjected: value' }) + ) + expect((await fetchZcodeRateLimits({ credentialHost: host })).status).toBe('error') + expect(fetch).not.toHaveBeenCalled() + }) + + it('uses execution-host identity, not data-directory identity, for the fallback cipher', () => { + install('fallback-host') + const fallback = { ...host, home: '/synthetic/home', env: { ZCODE_DATA_BASE_DIR: dir } } + expect(readZcodeV2PlanCredential(fallback)).toMatchObject({ + status: 'ok', + apiKey: 'synthetic-fallback-host-key' + }) + for (const changed of [ + { home: '/other/home' }, + { platform: 'win32' }, + { username: 'other-user' } + ]) { + expect(readZcodeV2PlanCredential({ ...fallback, ...changed })).toEqual({ status: 'error' }) + } + }) + + it('does not borrow credentials from another execution host', () => { + install() + const other = join(dir, 'other-host') + mkdirSync(other) + expect( + readZcodeV2PlanCredential({ + ...host, + home: other, + env: { ZCODE_DATA_BASE_DIR: other, ZCODE_CREDENTIAL_SECRET: 'synthetic-secret' } + }) + ).toEqual({ status: 'absent' }) + }) + + it('preserves the protected Orca key priority even if v2 is corrupt', async () => { + install() + writeFileSync(join(dir, '.zcode', 'v2', 'credentials.json'), 'broken') + const result = await fetchZcodeRateLimits({ + credentialHost: host, + planCredential: { apiKey: 'synthetic-orca-key', baseUrl: 'https://open.bigmodel.cn' } + }) + expect(result.status).toBe('ok') + expect(result.usageMetadata?.credentialSource).toBe('orca-plan') + }) + + it.each(['site', 'account', 'corrupt', 'network-error'])( + 'drops an in-flight quota when %s changes', + async (change) => { + install() + vi.mocked(fetch).mockImplementationOnce(async () => { + if (change === 'site') { + install('bigmodel') + } else if (change === 'account') { + install('zai-other') + } else { + writeFileSync(join(dir, '.zcode', 'v2', 'credentials.json'), 'broken') + } + if (change === 'network-error') { + throw new Error('Synthetic transport failure') + } + return quota() + }) + const result = await fetchZcodeRateLimits({ credentialHost: host }) + expect(result.status).toBe('unavailable') + expect(result.session).toBeNull() + } + ) +}) + +describe('external v2 file boundaries', () => { + it.each(['provider_config.json', 'credentials.json'])( + 'refuses oversized %s without legacy fallback', + (name) => { + install() + legacy() + const path = join(dir, '.zcode', 'v2', name) + const record = JSON.parse(readFileSync(path, 'utf8')) + record['unrelated'] = 'x'.repeat(4 * 1024 * 1024) + writeFileSync(path, JSON.stringify(record)) + expect(readZcodeUsageCredentials({ credentialHost: host }).status).toBe('error') + } + ) + + it.each(['provider_config.json', 'credentials.json'])('refuses a directory at %s', (name) => { + install() + const path = join(dir, '.zcode', 'v2', name) + rmSync(path) + mkdirSync(path) + expect(readZcodeV2PlanCredential(host).status).toBe('error') + }) + + it.skipIf(process.platform === 'win32').each([ + { name: 'provider_config.json', replace: false }, + { name: 'credentials.json', replace: false }, + { name: 'provider_config.json', replace: true }, + { name: 'credentials.json', replace: true } + ])( + 'settles FIFO $name with replacement $replace in a timeout-fenced child', + async ({ name, replace }) => { + install() + const path = join(dir, '.zcode', 'v2', name) + const fifoPath = replace ? join(dir, 'replacement.fifo') : path + if (!replace) { + rmSync(path) + } + expect((await runProcess({ program: 'mkfifo', args: [fifoPath] })).code).toBe(0) + const bundle = buildSync({ + entryPoints: [join(import.meta.dirname, 'zcode-v2-plan-credentials.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false + }).outputFiles[0]!.text + const setup = replace + ? `const fs=require('node:fs'),originalStat=fs.statSync;fs.statSync=(path,...args)=>{const evidence=originalStat(path,...args);if(path===${JSON.stringify(path)})fs.renameSync(${JSON.stringify(fifoPath)},path);return evidence};` + : '' + const script = join(dir, 'read.cjs') + writeFileSync( + script, + `${bundle}\n${setup}\nconsole.log(module.exports.readZcodeV2PlanCredential(${JSON.stringify(host)}).status)` + ) + const result = await runProcess({ + program: process.execPath, + args: [script], + timeoutMs: 2000 + }) + expect(result.timedOut).toBe(false) + expect(result.code).toBe(0) + expect(result.stdout.trim()).toBe('error') + } + ) + + it.each([ + { options: { reasoningLevel: 42 } }, + { options: { reasoningLevel: '' } }, + { options: { reasoningLevel: ' ' } }, + { options: { extra: true } }, + { extra: true }, + { options: null } + ])('rejects upstream-invalid selection %j without legacy fallback', (changed) => { + install() + legacy() + const path = join(dir, '.zcode', 'v2', 'provider_config.json') + const config = JSON.parse(readFileSync(path, 'utf8')) + Object.assign(config.config.defaultModelSelection, changed) + writeFileSync(path, JSON.stringify(config)) + expect(readZcodeUsageCredentials({ credentialHost: host }).status).toBe('error') + }) + + it('accepts a valid optional reasoning level', () => { + install() + const path = join(dir, '.zcode', 'v2', 'provider_config.json') + const config = JSON.parse(readFileSync(path, 'utf8')) + config.config.defaultModelSelection.options = { reasoningLevel: ' high ' } + writeFileSync(path, JSON.stringify(config)) + expect(readZcodeV2PlanCredential(host).status).toBe('ok') + }) + + it.each([ + { providerConfigRules: null }, + { providerConfigRules: { providerRules: 42 } }, + { providerConfigRules: { providerRules: [], unknown: true } }, + { modelConfigRules: null }, + { modelConfigRules: { providerModelRules: 42, manualProviderModelRules: [] } }, + { modelConfigRules: { providerModelRules: [], manualProviderModelRules: [null] } }, + { modelConfigRules: { providerModelRules: [{}], manualProviderModelRules: [] } } + ])('rejects upstream-invalid rule containers %j without legacy fallback', (changed) => { + install() + legacy() + const path = join(dir, '.zcode', 'v2', 'provider_config.json') + const config = JSON.parse(readFileSync(path, 'utf8')) + Object.assign(config.config, changed) + writeFileSync(path, JSON.stringify(config)) + expect(readZcodeUsageCredentials({ credentialHost: host }).status).toBe('error') + }) + + it('accepts a valid selected-provider empty personal rule and smart model rule', () => { + install('bigmodel') + const path = join(dir, '.zcode', 'v2', 'provider_config.json') + const config = JSON.parse(readFileSync(path, 'utf8')) + const selection = config.config.defaultModelSelection + config.config.providerConfigRules.providerRules = [ + { providerId: selection.providerId, config: {} } + ] + config.config.modelConfigRules.providerModelRules = [{ ...selection, config: {} }] + writeFileSync(path, JSON.stringify(config)) + expect(readZcodeV2PlanCredential(host)).toMatchObject({ + status: 'ok', + baseUrl: 'https://open.bigmodel.cn' + }) + }) + + it.each([ + { access: { type: 'api-key', apiKey: 'synthetic-personal-key' } }, + { access: null }, + { api: { type: 'bad-api-type', baseUrl: 'invalid-url' } }, + { api: { baseUrl: 'https://valid.example' } }, + { visibility: 'hidden' } + ])( + 'refuses unvalidated provider config %j without reading the vault or querying quota', + async (personal) => { + install('bigmodel') + legacy() + const path = join(dir, '.zcode', 'v2', 'provider_config.json') + const config = JSON.parse(readFileSync(path, 'utf8')) + config.config.providerConfigRules.providerRules = [ + { providerId: config.config.defaultModelSelection.providerId, config: personal } + ] + writeFileSync(path, JSON.stringify(config)) + rmSync(join(dir, '.zcode', 'v2', 'credentials.json')) + expect(readZcodeUsageCredentials({ credentialHost: host }).status).toBe('unavailable') + expect((await fetchZcodeRateLimits({ credentialHost: host })).status).toBe('unavailable') + expect(fetch).not.toHaveBeenCalled() + expect(readFileSync(path, 'utf8')).toBe(JSON.stringify(config)) + expect( + ( + await fetchZcodeRateLimits({ + credentialHost: host, + planCredential: { + apiKey: 'synthetic-orca-key', + baseUrl: 'https://api.z.ai' + } + }) + ).status + ).toBe('ok') + } + ) + + it.each(['duplicate-provider', 'malformed-model', 'valid-unsupported-model', 'manual-model'])( + 'cannot establish selected credentials from %s rules', + (kind) => { + install('bigmodel') + legacy() + const path = join(dir, '.zcode', 'v2', 'provider_config.json') + const config = JSON.parse(readFileSync(path, 'utf8')) + const selection = config.config.defaultModelSelection + if (kind === 'duplicate-provider') { + config.config.providerConfigRules.providerRules = Array.from({ length: 2 }, () => ({ + providerId: selection.providerId, + config: {} + })) + } else { + const group = kind === 'manual-model' ? 'manualProviderModelRules' : 'providerModelRules' + config.config.modelConfigRules[group] = [ + { + ...selection, + config: + kind === 'manual-model' + ? {} + : { + optionSpecs: { + reasoningLevel: { values: kind === 'malformed-model' ? 42 : ['high'] } + } + } + } + ] + } + writeFileSync(path, JSON.stringify(config)) + expect(readZcodeUsageCredentials({ credentialHost: host }).status).toBe( + kind === 'duplicate-provider' ? 'error' : 'unavailable' + ) + } + ) +}) + +it('leaves encrypted CLI files unchanged across repeated account reads', () => { + install() + const root = join(dir, '.zcode', 'v2') + const before = new Map(readdirSync(root).map((name) => [name, readFileSync(join(root, name))])) + expect(before.get('credentials.json')?.toString()).toContain('enc:v1:') + for (let index = 0; index < 25; index++) { + expect(readZcodeV2PlanCredential(host).status).toBe('ok') + } + expect(readdirSync(root)).toEqual([...before.keys()]) + for (const [name, bytes] of before) { + expect(readFileSync(join(root, name))).toEqual(bytes) + } +}) diff --git a/src/main/zcode/zcode-v2-plan-credentials.ts b/src/main/zcode/zcode-v2-plan-credentials.ts new file mode 100644 index 000000000000..fa552888ff11 --- /dev/null +++ b/src/main/zcode/zcode-v2-plan-credentials.ts @@ -0,0 +1,135 @@ +import { createDecipheriv, createHash } from 'node:crypto' +import { homedir, platform, userInfo } from 'node:os' +import { join, resolve } from 'node:path' +import { z } from 'zod' +import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' +import { ZCODE_PLAN_SITE_BASE_URLS } from '../../shared/zcode-plan-sites' +import { readSupportedZcodeV2Selection } from './zcode-v2-personal-overlay' + +const MAX_V2_FILE_BYTES = 4 * 1024 * 1024 +function readV2File(path: string): string { + return readNodeFileSyncWithinLimit(path, MAX_V2_FILE_BYTES, { + regularFileOnly: true + }).buffer.toString('utf8') +} + +const credentialSchema = z.record(z.string(), z.string()) +const sites = new Map([ + ['account:zai-individual-coding-plan', 'zai'], + ['account:bigmodel-individual-coding-plan', 'bigmodel'] +] as const) + +export type ZcodeCredentialHost = { + home: string + platform: string + username: string + env: NodeJS.ProcessEnv +} + +export type ZcodeV2PlanCredential = + | { status: 'absent' | 'unavailable' | 'error' } + | { status: 'ok'; apiKey: string; baseUrl: string; identity: string; configPath: string } + +export function localZcodeCredentialHost(): ZcodeCredentialHost { + let username = 'unknown' + try { + username = userInfo().username + } catch { + // Upstream uses the same fallback in sandboxed hosts. + } + return { home: homedir(), platform: platform(), username, env: process.env } +} + +function userPath(value: string, home: string): string { + if (value === '~') { + return home + } + return value.startsWith('~/') ? join(home, value.slice(2)) : resolve(value) +} + +// Read-only enc:v1 compatibility with ZCode 29628c9 credential-cipher.ts. +function decrypt(value: string, host: ZcodeCredentialHost): string { + if (!value.startsWith('enc:v1:')) { + return value + } + const parts = value.slice(7).split('.') + const [nonce, tag, ciphertext] = parts + if (parts.length !== 3 || !nonce || !tag || !ciphertext) { + throw new Error('Invalid credential') + } + const iv = Buffer.from(nonce, 'base64url') + const authTag = Buffer.from(tag, 'base64url') + if (iv.length !== 12 || authTag.length !== 16) { + throw new Error('Invalid credential') + } + const secret = + host.env.ZCODE_CREDENTIAL_SECRET?.trim() || + `zcode-credential-fallback:${host.platform}:${host.home}:${host.username}` + const key = createHash('sha256').update(secret).digest() + const cipher = createDecipheriv('aes-256-gcm', key, iv) + cipher.setAuthTag(authTag) + return Buffer.concat([ + cipher.update(Buffer.from(ciphertext, 'base64url')), + cipher.final() + ]).toString('utf8') +} + +export function readZcodeV2PlanCredential( + host = localZcodeCredentialHost() +): ZcodeV2PlanCredential { + const base = userPath(host.env.ZCODE_DATA_BASE_DIR ?? host.home, host.home) + const configPath = + (host.env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE?.trim() + ? userPath(host.env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE.trim(), host.home) + : '') || join(base, '.zcode', 'v2', 'provider_config.json') + let rawConfig: string + try { + rawConfig = readV2File(configPath) + } catch (error) { + return { + status: + error instanceof Error && 'code' in error && error.code === 'ENOENT' ? 'absent' : 'error' + } + } + try { + const selection = readSupportedZcodeV2Selection(JSON.parse(rawConfig)) + if (!selection) { + return { status: 'unavailable' } + } + const site = [...sites].find(([provider]) => provider === selection.providerId)?.[1] + if (!site) { + return { status: 'unavailable' } + } + const store = credentialSchema.parse( + JSON.parse(readV2File(join(base, '.zcode', 'v2', 'credentials.json'))) + ) + const rawIdentity = store[`account-provider:${selection.providerId}:identity`] + if (!rawIdentity) { + return { status: 'unavailable' } + } + const identity = decrypt(rawIdentity, host).trim() + if (!identity) { + return { status: 'unavailable' } + } + const rawKey = + store[ + `account-provider:coding-plan:${selection.providerId}:account:${encodeURIComponent(identity)}:api-key` + ] + if (!rawKey) { + return { status: 'unavailable' } + } + const apiKey = decrypt(rawKey, host).trim() + if (!apiKey || /[\r\n]/.test(apiKey)) { + return { status: 'error' } + } + return { + status: 'ok', + apiKey, + baseUrl: ZCODE_PLAN_SITE_BASE_URLS[site], + identity: JSON.stringify([selection.providerId, identity]), + configPath + } + } catch { + return { status: 'error' } + } +} diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx index be3d5e1010ef..973396b9f885 100644 --- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx @@ -160,7 +160,7 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {

{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.' + 'Orca reads the selected Coding Plan from the ZCode CLI credential store on this computer. Save an API key below to link the plan here instead.' )}

diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5b9378d6efea..b8ddcbe70c37 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12652,7 +12652,7 @@ "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.", + "usingCliHelp": "Orca reads the selected Coding Plan from the ZCode CLI credential store on this computer. 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", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a78155345c8d..77bfcfd9de95 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12266,7 +12266,7 @@ "keyStored": "API key 已保存 · {{value0}}", "keyStoredHelp": "仅保存在本地,并只发送到所选站点用于用量刷新。优先级高于 ZCode CLI 登录。", "usingCli": "正在使用 ZCode CLI 登录", - "usingCliHelp": "Orca 从 ~/.zcode/cli/config.json 读取 Coding Plan key。在下方保存 API key 即可改为在此关联套餐。", + "usingCliHelp": "Orca 会从此电脑上的 ZCode CLI 凭据存储中读取所选编程套餐。您也可以在下方保存 API 密钥以直接关联套餐。", "notConfigured": "尚未关联 GLM Coding Plan", "notConfiguredHelp": "在下方保存套餐 API key,或在这台电脑上通过 ZCode CLI 登录。", "refreshUsage": "刷新用量", diff --git a/src/shared/node-bounded-file-reader-sync.test.ts b/src/shared/node-bounded-file-reader-sync.test.ts index 748969e13ba8..02a7d5c60dd9 100644 --- a/src/shared/node-bounded-file-reader-sync.test.ts +++ b/src/shared/node-bounded-file-reader-sync.test.ts @@ -1,9 +1,14 @@ import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' +import * as fs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { NodeFileReadTooLargeError, readNodeFileSyncWithinLimit } from './node-bounded-file-reader' +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()) +})) + const tempDirectories: string[] = [] function createTempFile(content: string): string { @@ -15,6 +20,7 @@ function createTempFile(content: string): string { } afterEach(() => { + vi.restoreAllMocks() for (const directory of tempDirectories.splice(0)) { rmSync(directory, { recursive: true }) } @@ -36,3 +42,34 @@ describe('readNodeFileSyncWithinLimit', () => { expect(() => readNodeFileSyncWithinLimit(path, 1024)).toThrow(NodeFileReadTooLargeError) }) }) + +describe('regular file growth evidence', () => { + it('refuses growth beyond the byte limit during a read', () => { + const path = createTempFile('abcd') + const original = fs.readSync + const spy = vi.spyOn(fs, 'readSync').mockImplementation((...args) => { + const bytes = original(...args) + if (spy.mock.calls.length === 1) { + fs.appendFileSync(path, 'x'.repeat(1024)) + } + return bytes + }) + expect(() => readNodeFileSyncWithinLimit(path, 1024, { regularFileOnly: true })).toThrow( + NodeFileReadTooLargeError + ) + }) + + it('validates the opened descriptor when the path evidence changes', () => { + const path = createTempFile('abcd') + const original = fs.statSync + vi.spyOn(fs, 'statSync').mockImplementation((...args) => { + const evidence = original(...args) + fs.unlinkSync(path) + fs.mkdirSync(path) + return evidence + }) + expect(() => readNodeFileSyncWithinLimit(path, 1024, { regularFileOnly: true })).toThrow( + 'Expected a regular file' + ) + }) +}) diff --git a/src/shared/node-bounded-file-reader.ts b/src/shared/node-bounded-file-reader.ts index a8206b1a142a..8255ba7993fc 100644 --- a/src/shared/node-bounded-file-reader.ts +++ b/src/shared/node-bounded-file-reader.ts @@ -1,4 +1,4 @@ -import { closeSync, constants, fstatSync, openSync, readSync, type Stats } from 'node:fs' +import { closeSync, constants, fstatSync, openSync, readSync, statSync, type Stats } from 'node:fs' import { open, stat, type FileHandle } from 'node:fs/promises' const MIN_GROWTH_BYTES = 64 * 1024 @@ -108,15 +108,26 @@ export async function readNodeFileHandleWithinLimit( export function readNodeFileSyncWithinLimit( filePath: string, - maxBytes: number + maxBytes: number, + options: { regularFileOnly?: boolean } = {} ): BoundedNodeFileRead { if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { throw new RangeError('File read limit must be a non-negative safe integer') } - const descriptor = openSync(filePath, 'r') + if (options.regularFileOnly && !statSync(filePath).isFile()) { + throw new Error('Expected a regular file') + } + // Nonblocking open also fences replacement with a FIFO after the path check. + const flags = options.regularFileOnly + ? constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK) + : 'r' + const descriptor = openSync(filePath, flags) try { const stats = fstatSync(descriptor) + if (options.regularFileOnly && !stats.isFile()) { + throw new Error('Expected a regular file') + } validateSize(stats.size, maxBytes) let buffer = Buffer.allocUnsafe(stats.size) diff --git a/tests/e2e/helpers/electron-home-isolation.ts b/tests/e2e/helpers/electron-home-isolation.ts index 2d3db0098aed..dfb2ee3d12b3 100644 --- a/tests/e2e/helpers/electron-home-isolation.ts +++ b/tests/e2e/helpers/electron-home-isolation.ts @@ -9,10 +9,13 @@ const RESTRICTED_ENV_KEYS = new Set([ 'HOMEPATH', 'CODEX_HOME', 'ORCA_CODEX_HOME', + 'ZCODE_DATA_BASE_DIR', + 'ZCODE_PERSONAL_PROVIDER_CONFIG_FILE', // Why: Orca's spawn hook writes Claude folder trust into the config this names. 'CLAUDE_CONFIG_DIR', 'ORCA_E2E_USER_DATA_DIR', 'ORCA_E2E_HOME_DIR', + 'ORCA_DISABLE_MACOS_LOGIN_SHELL', 'ZDOTDIR', 'ORCA_ORIG_ZDOTDIR', 'BASH_ENV', @@ -98,7 +101,9 @@ export function createElectronHomeIsolation({ HOME: isolatedHome, USERPROFILE: isolatedHome, ORCA_E2E_USER_DATA_DIR: userDataDir, - ORCA_E2E_HOME_DIR: isolatedHome + ORCA_E2E_HOME_DIR: isolatedHome, + // macOS login resets HOME to the native account before the shell starts. + ...(process.platform === 'darwin' ? { ORCA_DISABLE_MACOS_LOGIN_SHELL: '1' } : {}) } } } diff --git a/tests/e2e/helpers/electron-home-isolation.unit.test.ts b/tests/e2e/helpers/electron-home-isolation.unit.test.ts index 3d2e9a4adb62..b57455971bd9 100644 --- a/tests/e2e/helpers/electron-home-isolation.unit.test.ts +++ b/tests/e2e/helpers/electron-home-isolation.unit.test.ts @@ -1,7 +1,8 @@ -import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readZcodeV2PlanCredential } from '../../../src/main/zcode/zcode-v2-plan-credentials' import { areSameHomePath, assertElectronResolvedIsolatedHome, @@ -11,6 +12,7 @@ import { const tempDirs: string[] = [] afterEach(() => { + vi.unstubAllGlobals() for (const tempDir of tempDirs.splice(0)) { rmSync(tempDir, { recursive: true, force: true }) } @@ -33,6 +35,7 @@ describe('createElectronHomeIsolation', () => { ORCA_CODEX_HOME: '/real/orca-codex', CLAUDE_CONFIG_DIR: '/real/claude', ZDOTDIR: '/real/zdotdir', + ORCA_DISABLE_MACOS_LOGIN_SHELL: 'false', PATH: '/bin' }, launchEnv: { TEST_TOKEN: 'safe' }, @@ -57,6 +60,9 @@ describe('createElectronHomeIsolation', () => { expect(isolation.env.ORCA_CODEX_HOME).toBeUndefined() expect(isolation.env.CLAUDE_CONFIG_DIR).toBeUndefined() expect(isolation.env.ZDOTDIR).toBeUndefined() + expect(isolation.env.ORCA_DISABLE_MACOS_LOGIN_SHELL).toBe( + process.platform === 'darwin' ? '1' : undefined + ) // Codex always routes to the resolved home, so the post-launch guard must // accept the boundary this env produces. expect(() => @@ -89,4 +95,133 @@ describe('createElectronHomeIsolation', () => { it('compares Windows home paths case-insensitively', () => { expect(areSameHomePath('C:\\Users\\Alice', 'c:\\users\\alice', 'win32')).toBe(true) }) + + it.each(['launchEnv', 'extraEnv'] as const)( + 'rejects %s attempts to restore the macOS login wrapper', + (overlay) => { + expect(() => + createElectronHomeIsolation({ + inheritedEnv: {}, + launchEnv: overlay === 'launchEnv' ? { ORCA_DISABLE_MACOS_LOGIN_SHELL: '0' } : {}, + extraEnv: overlay === 'extraEnv' ? { ORCA_DISABLE_MACOS_LOGIN_SHELL: '0' } : {}, + userDataDir: createUserDataDir(), + realHome: '/real/home' + }) + ).toThrow(/ORCA_DISABLE_MACOS_LOGIN_SHELL/) + } + ) +}) + +function installSyntheticPlan(home: string, apiKey: string): string { + const dir = path.join(home, '.zcode', 'v2') + mkdirSync(dir, { recursive: true }) + const providerId = 'account:bigmodel-individual-coding-plan' + const configPath = path.join(dir, 'provider_config.json') + writeFileSync( + configPath, + JSON.stringify({ + schemaVersion: 1, + config: { + providerConfigRules: { providerRules: [] }, + modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] }, + defaultModelSelection: { providerId, modelId: 'GLM' } + } + }) + ) + writeFileSync( + path.join(dir, 'credentials.json'), + JSON.stringify({ + [`account-provider:${providerId}:identity`]: 'synthetic', + [`account-provider:coding-plan:${providerId}:account:synthetic:api-key`]: apiKey + }) + ) + return configPath +} + +describe('ZCode credentials in the disposable home', () => { + const credentialRoots = ['ZCODE_DATA_BASE_DIR', 'ZCODE_PERSONAL_PROVIDER_CONFIG_FILE'] as const + + it.each(credentialRoots)('strips inherited %s before reading an isolated plan', (key) => { + const externalHome = createUserDataDir() + const externalConfig = installSyntheticPlan(externalHome, 'synthetic-external-key') + const isolation = createElectronHomeIsolation({ + inheritedEnv: { + [key]: key === 'ZCODE_DATA_BASE_DIR' ? externalHome : externalConfig + }, + launchEnv: {}, + extraEnv: {}, + userDataDir: createUserDataDir(), + realHome: externalHome + }) + const configPath = installSyntheticPlan(isolation.isolatedHome, 'synthetic-isolated-key') + const fetch = vi.fn(() => { + throw new Error('Credential isolation must not use the network') + }) + vi.stubGlobal('fetch', fetch) + expect( + readZcodeV2PlanCredential({ + home: isolation.isolatedHome, + platform: process.platform, + username: 'synthetic-user', + env: isolation.env + }) + ).toMatchObject({ status: 'ok', apiKey: 'synthetic-isolated-key', configPath }) + expect(isolation.env[key]).toBeUndefined() + expect(fetch).not.toHaveBeenCalled() + }) + + it.each(['launchEnv', 'extraEnv'] as const)('rejects %s credential-root overlays', (overlay) => { + const externalHome = createUserDataDir() + const externalConfig = installSyntheticPlan(externalHome, 'synthetic-external-key') + for (const key of credentialRoots) { + for (const spelling of [key, key.toLowerCase()]) { + const override = { + [spelling]: key === 'ZCODE_DATA_BASE_DIR' ? externalHome : externalConfig + } + expect(() => + createElectronHomeIsolation({ + inheritedEnv: {}, + launchEnv: overlay === 'launchEnv' ? override : {}, + extraEnv: overlay === 'extraEnv' ? override : {}, + userDataDir: createUserDataDir(), + realHome: externalHome + }) + ).toThrow( + `${overlay === 'launchEnv' ? 'launchEnv' : 'orcaAppExtraEnv'}.${spelling} cannot override the E2E home boundary` + ) + } + } + }) + + it('reads ordinary home fixtures while retaining legitimate launch overrides', () => { + const isolation = createElectronHomeIsolation({ + inheritedEnv: { PATH: '/synthetic/bin' }, + launchEnv: { ORCA_DISABLE_AGENT_HOOKS: '1', TEST_TOKEN: 'launch' }, + extraEnv: { TEST_TOKEN: 'extra', ZCODE_CREDENTIAL_SECRET: 'synthetic-secret' }, + userDataDir: createUserDataDir(), + realHome: createUserDataDir() + }) + const configPath = installSyntheticPlan(isolation.isolatedHome, 'synthetic-ordinary-key') + vi.stubGlobal( + 'fetch', + vi.fn(() => { + throw new Error('Network forbidden') + }) + ) + expect(isolation.env).toMatchObject({ + PATH: '/synthetic/bin', + ORCA_DISABLE_AGENT_HOOKS: '1', + TEST_TOKEN: 'extra', + ZCODE_CREDENTIAL_SECRET: 'synthetic-secret' + }) + expect( + readZcodeV2PlanCredential({ + home: isolation.isolatedHome, + platform: process.platform, + username: 'synthetic-user', + env: isolation.env + }) + ).toMatchObject({ status: 'ok', apiKey: 'synthetic-ordinary-key', configPath }) + expect(fetch).not.toHaveBeenCalled() + }) }) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index e1d34d722293..edda9b620893 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -340,7 +340,7 @@ export const test = base.extend({ if (!store) { return false } - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) const repo = store.getState().repos.find((candidate) => candidate.id === repoId) if (!repo) { return false diff --git a/tests/e2e/zcode-plan-credentials.spec.ts b/tests/e2e/zcode-plan-credentials.spec.ts new file mode 100644 index 000000000000..19162bb0ab78 --- /dev/null +++ b/tests/e2e/zcode-plan-credentials.spec.ts @@ -0,0 +1,93 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +function selectPlan(home: string, site: 'zai' | 'bigmodel'): void { + const root = path.join(home, '.zcode', 'v2') + mkdirSync(root, { recursive: true }) + const providerId = `account:${site}-individual-coding-plan` + writeFileSync( + path.join(root, 'provider_config.json'), + JSON.stringify({ + schemaVersion: 1, + config: { + providerOrder: [], + providerConfigRules: { providerRules: [] }, + modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] }, + defaultModelSelection: { providerId, modelId: 'GLM-test' } + } + }) + ) + writeFileSync( + path.join(root, 'credentials.json'), + JSON.stringify({ + [`account-provider:${providerId}:identity`]: 'synthetic-account', + [`account-provider:coding-plan:${providerId}:account:synthetic-account:api-key`]: `synthetic-${site}-key` + }) + ) +} + +test('Accounts recognizes the selected CLI plan and preserves a separately saved quota key', async ({ + orcaPage, + electronApp +}, testInfo) => { + await waitForSessionReady(orcaPage) + const home = await electronApp.evaluate(({ app }) => app.getPath('home')) + await electronApp.evaluate(() => { + const realFetch = globalThis.fetch + globalThis.fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.endsWith('/api/monitor/usage/quota/limit')) { + const key = new Headers(init?.headers).get('Authorization') + const percentage = + key === 'synthetic-zai-key' ? 12 : key === 'synthetic-bigmodel-key' ? 67 : 33 + return new Response( + JSON.stringify({ + success: true, + data: { limits: [{ type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage }] } + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + } + return realFetch(input, init) + } + }) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'accounts-zcode' }) + }) + const section = orcaPage.locator('#accounts-zcode') + await expect(section).toBeVisible({ timeout: 30_000 }) + await expect(section.getByText('No GLM Coding Plan linked', { exact: true })).toBeVisible() + await section.screenshot({ path: testInfo.outputPath('01-no-cli-plan.png') }) + + selectPlan(home, 'zai') + await orcaPage.evaluate(() => window.__store?.getState().refreshRateLimits()) + await expect(section.getByText('Using the ZCode CLI sign-in', { exact: true })).toBeVisible({ + timeout: 30_000 + }) + await expect(section.getByText('12%', { exact: true })).toBeVisible() + await section.screenshot({ path: testInfo.outputPath('02-cli-plan.png') }) + + selectPlan(home, 'bigmodel') + await orcaPage.evaluate(() => window.__store?.getState().refreshRateLimits()) + await expect(section.getByText('67%', { exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(section.getByText('12%', { exact: true })).toHaveCount(0) + await section.screenshot({ path: testInfo.outputPath('03-selected-account.png') }) + + await section.getByPlaceholder('Paste your GLM Coding Plan API key').fill('synthetic-saved-key') + await section.getByRole('button', { name: 'Save', exact: true }).click() + await expect(section.getByText(/API key saved ·/)).toBeVisible({ timeout: 15_000 }) + await expect(section.getByText('33%', { exact: true })).toBeVisible({ timeout: 30_000 }) + const savedPath = path.join(home, '.orca', 'zcode-plan-api-key.enc') + expect(existsSync(savedPath)).toBe(true) + await section.screenshot({ path: testInfo.outputPath('04-saved-key-priority.png') }) + await section.getByRole('button', { name: 'Forget key', exact: true }).click() + await expect(section.getByText('Using the ZCode CLI sign-in', { exact: true })).toBeVisible({ + timeout: 15_000 + }) + await expect(section.getByText('67%', { exact: true })).toBeVisible({ timeout: 30_000 }) + expect(existsSync(savedPath)).toBe(false) +}) From ec535f478062449d90e510991cd258c2de8fd709 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 15:25:57 -0700 Subject: [PATCH 2/4] test(accounts): keep quota fixtures consistent with saved state --- .../components/settings/ZcodePlanAccountsSection.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx index 32c277b72ced..7daba9167a06 100644 --- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx @@ -138,13 +138,16 @@ describe('ZcodePlanAccountsSection', () => { render() expect(await screen.findByText('Using the ZCode CLI sign-in')).toBeInTheDocument() - expect(screen.getByText(/~\/\.zcode\/cli\/config\.json/)).toBeInTheDocument() + expect( + screen.getByText(/selected Coding Plan from the ZCode CLI credential store/) + ).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') + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false }) fireEvent.change(input, { target: { value: ' glm-secret ' } }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) From 37ecfef1475560a7952eddc6c999db0e19ef4132 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 15:29:50 -0700 Subject: [PATCH 3/4] test(zcode): verify read-only account contracts across platforms --- .github/workflows/zcode-account-contract.yml | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/zcode-account-contract.yml diff --git a/.github/workflows/zcode-account-contract.yml b/.github/workflows/zcode-account-contract.yml new file mode 100644 index 000000000000..082648142f2c --- /dev/null +++ b/.github/workflows/zcode-account-contract.yml @@ -0,0 +1,36 @@ +name: ZCode read-only account contract +on: + pull_request: + paths: + - 'src/main/zcode/**' + - 'src/shared/node-bounded-file-reader*' + - 'src/main/rate-limits/**' + - '.github/workflows/zcode-account-contract.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: zcode-account-contract-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + credential-files: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-15, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: config/.bun-version + - name: Verify read-only credentials and account changes + run: pnpm test src/main/zcode/zcode-v2-plan-credentials.test.ts src/main/zcode/zcode-v2-personal-overlay-raw-json.test.ts src/main/rate-limits/service-zcode-usage.test.ts src/shared/node-bounded-file-reader-sync.test.ts From 3bc0200f9b390b1054303a5240007eeda0417b86 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 15:39:10 -0700 Subject: [PATCH 4/4] test(composer): reuse fixtures to restore the main lint gate --- .../use-automations-page-setup-state.ts | 3 +- ...lder-workspace-composer-submit-fixtures.ts | 36 ++++++++++++++++++ ...pace-composer-submit-host-platform.test.ts | 36 +----------------- .../folder-workspace-composer-submit.test.ts | 37 +------------------ 4 files changed, 39 insertions(+), 73 deletions(-) create mode 100644 src/renderer/src/components/sidebar/folder-workspace-composer-submit-fixtures.ts diff --git a/src/renderer/src/components/automations/use-automations-page-setup-state.ts b/src/renderer/src/components/automations/use-automations-page-setup-state.ts index ae0939085fe3..5ada1d683d28 100644 --- a/src/renderer/src/components/automations/use-automations-page-setup-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.ts @@ -150,8 +150,7 @@ export function useAutomationsPageSetupState({ automationYamlHooksByRepoKey, getAutomationHooksCacheKey, repos, - setAutomationYamlHooksByRepoKey, - settings + setAutomationYamlHooksByRepoKey ] ) const getDraftSetupDecisionDefaultSignature = useCallback( diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-fixtures.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-fixtures.ts new file mode 100644 index 000000000000..303445980ab1 --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-fixtures.ts @@ -0,0 +1,36 @@ +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' + +export function makeProjectGroup(): ProjectGroup { + return { + id: 'group-1', + name: 'Platform', + parentPath: '/repo/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } +} + +export function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + id: 'folder-workspace-1', + projectGroupId: 'group-1', + name: 'hi', + folderPath: '/repo/platform/hi', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts index f1fcb5e6f008..95a01c86f40b 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' +import { makeProjectGroup, makeFolderWorkspace } from './folder-workspace-composer-submit-fixtures' const mocks = vi.hoisted(() => ({ activateAndRevealFolderWorkspace: vi.fn() })) @@ -14,39 +13,6 @@ vi.mock('@/lib/worktree-activation', async (importOriginal) => { import { useAppStore } from '@/store' import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' -function makeProjectGroup(): ProjectGroup { - return { - id: 'group-1', - name: 'Platform', - parentPath: '/repo/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } -} - -function makeFolderWorkspace(): FolderWorkspace { - return { - id: 'folder-workspace-1', - projectGroupId: 'group-1', - name: 'hi', - folderPath: '/repo/platform/hi', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 - } -} - // Why: a folder group on an Orca server runs its agents there, so the server's OS decides quoting. describe('submitFolderWorkspaceCreate on an Orca server', () => { afterEach(() => { diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 85d5f2020be0..0964e076f87a 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' +import { makeProjectGroup, makeFolderWorkspace } from './folder-workspace-composer-submit-fixtures' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type * as NewWorkspaceModule from '@/lib/new-workspace' @@ -30,40 +29,6 @@ import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' -function makeProjectGroup(): ProjectGroup { - return { - id: 'group-1', - name: 'Platform', - parentPath: '/repo/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } -} - -function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { - return { - id: 'folder-workspace-1', - projectGroupId: 'group-1', - name: 'hi', - folderPath: '/repo/platform/hi', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1, - ...overrides - } -} - describe('submitFolderWorkspaceCreate', () => { it.each(['347', '002'])('keeps manual folder name %s with its linked source', async (name) => { const linkedWorkItem = {