mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
fix(zcode): isolate read-only account discovery and quota ownership
Co-authored-by: Luchong <lu740528977@gmail.com> Co-authored-by: GuanBear <guanbear@users.noreply.github.com>
This commit is contained in:
committed by
Neil
co-authored by
Luchong
GuanBear
parent
3f7943c411
commit
87da8432cd
@@ -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<ProviderRateLimits>()
|
||||
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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | 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<string, unknown> | 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
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
@@ -44,15 +37,6 @@ function readRecord(value: unknown): Record<string, unknown> | 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<string, unknown> | 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<ProviderRateLimits> {
|
||||
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<ProviderRateLimits> {
|
||||
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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"config": {
|
||||
"providerOrder": [],
|
||||
"providerConfigRules": {
|
||||
"providerRules": []
|
||||
},
|
||||
"modelConfigRules": {
|
||||
"providerModelRules": [],
|
||||
"manualProviderModelRules": []
|
||||
},
|
||||
"defaultModelSelection": {
|
||||
"providerId": "account:bigmodel-individual-coding-plan",
|
||||
"modelId": "GLM-5.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"config": {
|
||||
"providerOrder": [],
|
||||
"providerConfigRules": {
|
||||
"providerRules": []
|
||||
},
|
||||
"modelConfigRules": {
|
||||
"providerModelRules": [],
|
||||
"manualProviderModelRules": []
|
||||
},
|
||||
"defaultModelSelection": {
|
||||
"providerId": "account:zai-individual-coding-plan",
|
||||
"modelId": "GLM-5.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"config": {
|
||||
"providerOrder": [],
|
||||
"providerConfigRules": {
|
||||
"providerRules": []
|
||||
},
|
||||
"modelConfigRules": {
|
||||
"providerModelRules": [],
|
||||
"manualProviderModelRules": []
|
||||
},
|
||||
"defaultModelSelection": {
|
||||
"providerId": "account:zai-individual-coding-plan",
|
||||
"modelId": "GLM-5.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"config": {
|
||||
"providerOrder": [],
|
||||
"providerConfigRules": {
|
||||
"providerRules": []
|
||||
},
|
||||
"modelConfigRules": {
|
||||
"providerModelRules": [],
|
||||
"manualProviderModelRules": []
|
||||
},
|
||||
"defaultModelSelection": {
|
||||
"providerId": "account:zai-individual-coding-plan",
|
||||
"modelId": "GLM-5.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof fs>())
|
||||
}))
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Preserve raw own keys: Zod records erase __proto__ before emptiness checks.
|
||||
const rawConfigSchema = z.custom<Record<string, unknown>>(
|
||||
(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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
|
||||
@@ -12654,7 +12654,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",
|
||||
|
||||
@@ -12268,7 +12268,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": "刷新用量",
|
||||
|
||||
@@ -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<typeof fs>())
|
||||
}))
|
||||
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -340,7 +340,7 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user