From a5b9fb9a399ce483be215f7aef8319a333925526 Mon Sep 17 00:00:00 2001 From: Cong <7380929+robotlearning123@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:41:17 -0400 Subject: [PATCH 1/3] fix(cli): seed personal provider config for app-server model selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3.14.x app-server builds its Provider Registry without the standalone options (runZCodeProtocolAgent -> Ykt(env)): personal providers come only from ZCODE_PERSONAL_PROVIDER_CONFIG_FILE (default ~/.zcode/v2/provider_config.json) and the legacy cli config is never imported there. A host with only ~/.zcode/cli/config.json therefore answered every model-bearing session/create from -p --model with model-not-found, while the kernel's own -p path worked because IT runs the legacy import and persists the migrated file. Mirror that migration in zagent: convert the cli config exactly like the kernel importer (NHa/MHa/FHa/LHa parity incl. the builtin:zai and builtin:bigmodel family mapping and the apiKeyRequired:false refusal) and write it only when the target file does not exist — a desktop-written or kernel-migrated file always wins. Atomic 0600 write under the shared interprocess lock, best-effort, before the app-server spawn on the -p selection path, commit-msg, and models test. doctor now resolves the configured model against the same effective source (existing file wins, else the derivation) and exits 1 when it cannot resolve, instead of reporting a healthy credential-only setup. --- CHANGELOG.md | 7 + package.json | 1 + packages/cli/test-cli-ux.mjs | 3 +- packages/cli/test-personal-provider.mjs | 237 ++++++++++++++++++++++++ packages/cli/zagent-commit-msg.mjs | 2 + packages/cli/zagent-models.mjs | 2 + packages/cli/zagent-print.mjs | 12 +- packages/cli/zagent.mjs | 16 ++ packages/driver/personal-provider.mjs | 224 ++++++++++++++++++++++ packages/driver/test-doctor.mjs | 8 +- scripts/test-ux-audit.mjs | 3 +- 11 files changed, 507 insertions(+), 8 deletions(-) create mode 100644 packages/cli/test-personal-provider.mjs create mode 100644 packages/driver/personal-provider.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index da60733..3ab79a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ This file is the public-facing release log for the npm package `zagent`. ## Unreleased +- fix(cli): seed the kernel's personal provider config from the cli config before + app-server backed runs (`-p --model/--effort`, `commit-msg`, `models test`) — + the 3.14.x app-server registry never imports the legacy cli config itself, so + a fresh host answered every model-bearing `session/create` with + model-not-found until the kernel's own `-p` path happened to migrate it. + `doctor` now flags a configured model the runtime registry cannot resolve. + ## 0.0.238 — 2026-09-21 - Live-user-testing fixes: `quota reset use` checks credentials before its diff --git a/package.json b/package.json index 055a469..9e1be63 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "packages/driver/memory.mjs", "packages/driver/offpeak.mjs", "packages/driver/permissions.mjs", + "packages/driver/personal-provider.mjs", "packages/driver/plugins.mjs", "packages/driver/provider-errors.mjs", "packages/driver/providers.mjs", diff --git a/packages/cli/test-cli-ux.mjs b/packages/cli/test-cli-ux.mjs index 669c46f..ccad3f3 100644 --- a/packages/cli/test-cli-ux.mjs +++ b/packages/cli/test-cli-ux.mjs @@ -43,7 +43,8 @@ writeFileSync(path.join(catalogDir, 'models_catalog_1.json'), JSON.stringify({ mkdirSync(path.join(home, '.zcode', 'cli'), { recursive: true }); writeFileSync(path.join(home, '.zcode', 'cli', 'config.json'), JSON.stringify({ model: { main: 'zai/glm-5.3', lite: 'zai/glm-5.3-flash' }, - provider: { zai: { kind: 'anthropic', options: { baseURL: 'https://api.z.ai/api/anthropic/', apiKey: 'fixture' } } }, + provider: { zai: { kind: 'anthropic', options: { baseURL: 'https://api.z.ai/api/anthropic/', apiKey: 'fixture' }, + models: { 'glm-5.3': {}, 'glm-5.3-flash': {} } } }, })); const env = { diff --git a/packages/cli/test-personal-provider.mjs b/packages/cli/test-personal-provider.mjs new file mode 100644 index 0000000..4b692eb --- /dev/null +++ b/packages/cli/test-personal-provider.mjs @@ -0,0 +1,237 @@ +// Personal provider config provisioning — 3.14.x kernel parity. +// +// Oracle: the file the 3.14.4 kernel itself writes when its own -p path +// migrates ~/.zcode/cli/config.json into ~/.zcode/v2/provider_config.json +// (measured live on a fresh HOME; kernel Cpe.#f -> qL, JSON.stringify(...,2)). +// The app-server registry reads personal providers ONLY from that file and +// never runs the legacy import (runZCodeProtocolAgent -> Ykt(env), no +// standalone options), which is why `-p --model` failed on fresh hosts. +import { EventEmitter } from 'node:events'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, statSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + importLegacyCliConfig, personalProviderConfigDocument, personalProviderConfigPath, + provisionPersonalProviderConfig, modelResolutionCheck, UnsupportedLegacyCliProviderConfigError, +} from '../driver/personal-provider.mjs'; +import { runPrintOnce } from './zagent-print.mjs'; + +let fails = 0; +const ok = (c, m) => { if (!c) { console.error('FAIL:', m); fails++; } else console.log('ok -', m); }; +const throws = (fn, re, m) => { try { fn(); ok(false, `${m} (no throw)`); } catch (e) { ok(re.test(e.message), `${m} (${e.message.slice(0, 60)})`); } }; + +// The public-acceptance cli config shape (fake key — the real one never enters +// the repo) that reproduces the fresh-host model-not-found bug. +const KEY = 'sk-test-zagent-000'; +const CLI_FIXTURE = { + provider: { + zai: { + kind: 'anthropic', + name: 'Z.AI Coding Plan', + options: { apiKeyRequired: true, apiKey: KEY, baseURL: 'https://api.z.ai/api/anthropic' }, + models: { + 'glm-5.3': { name: 'GLM-5.3' }, + 'glm-5.3-flash': { name: 'GLM-5.3-Flash', limit: { context: 1000000, output: 128000 }, + modalities: { input: ['text', 'image', 'video'], output: ['text'] } }, + }, + }, + }, + model: { main: 'zai/glm-5.3', lite: 'zai/glm-5.3-flash' }, +}; + +// What the 3.14.4 kernel wrote on its first -p run against CLI_FIXTURE's real +// twin ( apiKey redacted there, fake here): same keys, same order, same +// indent — byte parity with the kernel's own migration output. +const KERNEL_SEEDED = { + schemaVersion: 1, + config: { + providerConfigRules: { providerRules: [{ + providerId: 'zai', + providerName: 'Z.AI Coding Plan', + config: { + group: 'standard-personal', + access: { type: 'api-key', apiKey: KEY }, + api: { type: 'anthropic-messages', baseUrl: 'https://api.z.ai/api/anthropic' }, + personalModelIds: ['glm-5.3', 'glm-5.3-flash'], + modelOrder: ['glm-5.3', 'glm-5.3-flash'], + }, + }] }, + modelConfigRules: { providerModelRules: [ + { modelId: 'glm-5.3-flash', config: { properties: { contextWindow: 1000000 } }, providerId: 'zai' }, + ], manualProviderModelRules: [] }, + defaultModelSelection: { providerId: 'zai', modelId: 'glm-5.3' }, + }, +}; +const KERNEL_SEEDED_BYTES = JSON.stringify(KERNEL_SEEDED, null, 2); + +// --- importLegacyCliConfig: kernel NHa/MHa/FHa/LHa parity --- +ok(JSON.stringify(personalProviderConfigDocument(importLegacyCliConfig(CLI_FIXTURE))) === JSON.stringify(KERNEL_SEEDED), + 'repro cli config converts to the kernel-measured personal provider document'); +throws(() => importLegacyCliConfig({ provider: { x: { options: { apiKeyRequired: false } } } }), + /cannot express/, 'apiKeyRequired:false provider refuses the whole import (kernel Gkt parity)'); +ok(importLegacyCliConfig({ provider: {} }) === null, 'nothing importable -> null (nothing written)'); +ok(importLegacyCliConfig(null) === null, 'missing config -> null'); +{ + const r = importLegacyCliConfig({ provider: { + 'builtin:zai': { options: { apiKey: ' k ' } }, // -> zai-api family rule (aee parity) + 'builtin:other': { options: { apiKey: 'k' } }, // unrelated builtin id -> skipped + 'account:zai-individual-coding-plan': {}, // account id -> skipped + gui: { source: 'gui', options: { apiKey: 'k' } }, // non-custom source -> skipped + openai: { kind: 'openai', models: { m1: {}, m2: { deleted: true }, ' m3 ': {} } }, + } }); + const ids = r.providers.map(p => p.providerId); + ok(JSON.stringify(ids) === JSON.stringify(['zai-api', 'openai']), `builtin/source/account skips applied (got ${ids})`); + const fam = r.providers[0]; + ok(fam.templateId === 'zai-api' && fam.config.access.apiKey === 'k' && !('api' in fam.config), + 'builtin:zai maps to the zai-api family rule, key trimmed, no api block'); + const oi = r.providers[1]; + ok(oi.config.api.type === 'openai-responses' && JSON.stringify(oi.config.personalModelIds) === JSON.stringify(['m1', 'm3']), + 'kind->api type, deleted+blank model entries skipped'); + ok(!('providerName' in oi), 'providerName omitted when equal/absent'); + ok(importLegacyCliConfig({ provider: { x: {} }, model: { main: 'builtin:zapi/m' } }).defaultModelSelection === null, + 'builtin:zapi default (OHa) is not imported'); +} + +// --- provisioning on a temp home --- +const tempHome = (cli = CLI_FIXTURE) => { + const home = mkdtempSync(path.join(tmpdir(), 'zagent-pp-')); + if (cli) { + mkdirSync(path.join(home, '.zcode', 'cli'), { recursive: true }); + writeFileSync(path.join(home, '.zcode', 'cli', 'config.json'), JSON.stringify(cli), { mode: 0o600 }); + } + return home; +}; +{ + const home = tempHome(); + const r = provisionPersonalProviderConfig({ home, env: {} }); + const target = personalProviderConfigPath({ home, env: {} }); + ok(r.provisioned && r.path === target, `fresh home provisions (${r.reason ?? 'written'})`); + ok(readFileSync(target, 'utf8') === KERNEL_SEEDED_BYTES, 'written file is byte-identical to the kernel migration output'); + ok((statSync(target).mode & 0o777) === 0o600, 'provisioned file is 0600'); + ok(!existsSync(`${target}.lock`), 'lock dir cleaned up after provisioning'); + rmSync(home, { recursive: true, force: true }); +} +{ + const home = tempHome(); + const target = personalProviderConfigPath({ home, env: {} }); + mkdirSync(path.dirname(target), { recursive: true }); + const desktop = '{"schemaVersion":1,"config":{"providerConfigRules":{"providerRules":[]}}}'; + writeFileSync(target, desktop, { mode: 0o600 }); + const r = provisionPersonalProviderConfig({ home, env: {} }); + ok(!r.provisioned && /already present/.test(r.reason), 'an existing (desktop-written) file is authoritative'); + ok(readFileSync(target, 'utf8') === desktop, 'existing file bytes untouched'); + rmSync(home, { recursive: true, force: true }); +} +{ + const home = tempHome(null); + const r = provisionPersonalProviderConfig({ home, env: {} }); + ok(!r.provisioned && !existsSync(personalProviderConfigPath({ home, env: {} })), + `no cli config -> nothing written (${r.reason})`); + rmSync(home, { recursive: true, force: true }); +} +{ + const home = tempHome({ provider: { x: { options: { apiKeyRequired: false } } } }); + const r = provisionPersonalProviderConfig({ home, env: {} }); + ok(!r.provisioned && !existsSync(personalProviderConfigPath({ home, env: {} })), + `unsupported provider -> nothing written (${r.reason})`); + rmSync(home, { recursive: true, force: true }); +} +{ + const home = tempHome(); + const custom = path.join(home, 'personal.json'); + const r = provisionPersonalProviderConfig({ home, env: { ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: custom } }); + ok(r.provisioned && r.path === custom && existsSync(custom), + 'ZCODE_PERSONAL_PROVIDER_CONFIG_FILE preset targets the write (dQi passthrough parity)'); + ok(personalProviderConfigPath({ home: '/nope', env: { ZCODE_DATA_BASE_DIR: home } }) + === path.join(home, '.zcode', 'v2', 'provider_config.json'), 'ZCODE_DATA_BASE_DIR relocates the default target'); + rmSync(home, { recursive: true, force: true }); +} + +// --- the CLI -p --model path seeds the registry BEFORE the app-server spawn --- +// This is the fresh-host bug's oracle: before the fix runPrintOnce had no +// provisioning step, so the file did not exist when the client opened. +{ + const home = tempHome(); + const target = personalProviderConfigPath({ home, env: {} }); + let existedAtCreate = null; + const client = { + onNotify() {}, close() {}, child: new EventEmitter(), dead: false, + async call(method) { + if (method === 'session/create') return { session: { sessionId: 'sess_pp' } }; + if (method === 'session/read') return { messages: [] }; + if (method === 'session/send') { + client.onNotify({ method: 'computer-use/operation-event', + params: { sessionId: 'sess_pp', turnId: 't1', kind: 'turn-started' } }); + client.onNotify({ method: 'computer-use/operation-event', + params: { sessionId: 'sess_pp', turnId: 't1', kind: 'turn-completed' } }); + return { turnId: 't1' }; + } + return {}; + }, + }; + await runPrintOnce({ prompt: 'hi', model: 'zai/glm-5.3', cwd: '/tmp' }, { + createClient: async () => { existedAtCreate = existsSync(target); return client; }, + provision: () => provisionPersonalProviderConfig({ home, env: {} }), + }); + ok(existedAtCreate === true, 'runPrintOnce seeds the personal provider config before opening the app-server client'); + ok(readFileSync(target, 'utf8') === KERNEL_SEEDED_BYTES, 'seeded file matches the kernel migration output'); + rmSync(home, { recursive: true, force: true }); +} +{ + // An injected client (tests, daemon reuse) owns its own setup: the default + // provision step must not fire against the host's real home. + let called = false; + const client = { + onNotify() {}, close() {}, child: new EventEmitter(), dead: false, + async call(method) { + if (method === 'session/create') return { session: { sessionId: 'sess_np' } }; + if (method === 'session/read') return { messages: [] }; + if (method === 'session/send') { + client.onNotify({ method: 'computer-use/operation-event', + params: { sessionId: 'sess_np', turnId: 't1', kind: 'turn-started' } }); + client.onNotify({ method: 'computer-use/operation-event', + params: { sessionId: 'sess_np', turnId: 't1', kind: 'turn-completed' } }); + return { turnId: 't1' }; + } + return {}; + }, + }; + await runPrintOnce({ prompt: 'hi' }, { client, provision: () => { called = true; } }); + ok(!called, 'injected client path skips provisioning'); +} + +// --- doctor's read-only resolution check --- +{ + const home = tempHome(); + const env = {}; + const r = modelResolutionCheck({ env, home, config: CLI_FIXTURE }); + ok(r?.ok === true && r.detail === 'zai/glm-5.3' && /cli config/.test(r.source), + 'derivable selection resolves (source names the cli-config derivation)'); + const target = personalProviderConfigPath({ home, env }); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, JSON.stringify({ + schemaVersion: 1, + config: { providerConfigRules: { providerRules: [ + { providerId: 'other', config: { personalModelIds: ['x'] } }, + { providerId: 'zai', config: { personalModelIds: ['glm-5.3-flash'] } }, + ] } }, + }), { mode: 0o600 }); + const miss = modelResolutionCheck({ env, home, config: CLI_FIXTURE }); + ok(miss?.ok === false && /not in the provider's model list/.test(miss.detail), + `existing file wins: a missing model is flagged (${miss?.detail})`); + ok(modelResolutionCheck({ env, home, config: { model: { main: 'other/m' } } })?.ok === false, + 'provider absent from the existing file is flagged'); + ok(modelResolutionCheck({ env, home, config: { model: { main: 'account:zai-individual-coding-plan/glm-5.3' } } }) === null, + 'account/builtin selections cannot be judged statically -> null'); + ok(modelResolutionCheck({ env, home, config: { model: { main: 'builtin:zapi/m' } } }) === null, + 'builtin:zapi default -> null'); + const bad = modelResolutionCheck({ env, home: tempHome({ provider: { x: { options: { apiKeyRequired: false } } } }), + config: { provider: { x: { options: { apiKeyRequired: false } } }, model: { main: 'x/m' } } }); + ok(bad?.ok === false && /not expressible/.test(bad.detail), `unimportable provider flagged (${bad?.detail})`); + ok(modelResolutionCheck({ env, home, config: {} }) === null, 'no model.main -> nothing to check'); + rmSync(home, { recursive: true, force: true }); +} + +if (fails) { console.error(`${fails} FAILURE(S)`); process.exit(1); } +console.log('personal-provider: all checks passed'); +process.exit(0); diff --git a/packages/cli/zagent-commit-msg.mjs b/packages/cli/zagent-commit-msg.mjs index dc384cc..5e4bfd7 100644 --- a/packages/cli/zagent-commit-msg.mjs +++ b/packages/cli/zagent-commit-msg.mjs @@ -154,8 +154,10 @@ if (effort) { } const { ZCodeProtocolClient } = await import('../driver/zcode-protocol.mjs'); +const { provisionPersonalProviderConfig } = await import('../driver/personal-provider.mjs'); let client, code = 0; try { + try { provisionPersonalProviderConfig(); } catch {} // fresh-host registry seed (see personal-provider.mjs) client = new ZCodeProtocolClient({ cwd: process.cwd() }); await client.ready; // The registry is GUI-pushed (provider/updateAccountConfig); a headless spawn diff --git a/packages/cli/zagent-models.mjs b/packages/cli/zagent-models.mjs index d6aa462..4cf2310 100644 --- a/packages/cli/zagent-models.mjs +++ b/packages/cli/zagent-models.mjs @@ -139,8 +139,10 @@ if (process.argv[2] === 'test') { [[providerId, modelId]] = hits; } const { ZCodeProtocolClient } = await import('../driver/zcode-protocol.mjs'); + const { provisionPersonalProviderConfig } = await import('../driver/personal-provider.mjs'); let client, code = 0; try { + try { provisionPersonalProviderConfig(); } catch {} // fresh-host registry seed (see personal-provider.mjs) client = new ZCodeProtocolClient({ cwd: process.cwd() }); await client.ready; // The registry is GUI-pushed (provider/updateAccountConfig); a headless diff --git a/packages/cli/zagent-print.mjs b/packages/cli/zagent-print.mjs index e33be51..bc22846 100644 --- a/packages/cli/zagent-print.mjs +++ b/packages/cli/zagent-print.mjs @@ -17,6 +17,7 @@ import { ZCodeProtocolClient, runTurn, sessionSid, currentAnswer, extractUsage } import { setMode, MODES } from '../driver/session-control.mjs'; import { autoAllow } from '../driver/permissions.mjs'; import { modelReasoningLevels } from '../driver/providers.mjs'; +import { provisionPersonalProviderConfig } from '../driver/personal-provider.mjs'; import { KERNEL_VALUE_FLAGS, KERNEL_LIST_FLAGS } from './commands.mjs'; const VALUE_FLAGS = new Set(['--model', '--effort', '--mode', '--cwd', '--output-format', '--locale']); @@ -151,7 +152,11 @@ async function openPrintClient(cwd) { // --prompt" and zagentd does the same. A user-chosen --mode still applies via // session/setMode (kernel-side enforcement); plan approval prompts hit the // client's default decline handler, so headless plan mode produces the plan. -export async function runPrintOnce(sel, { client, createClient = openPrintClient, timeoutMs = 600_000, catalog, providerConfig } = {}) { +// `provision` seeds the kernel's personal provider config from the legacy cli +// config before the app-server spawn — the app-server registry never runs the +// kernel's own legacy import (see personal-provider.mjs), so without this a +// fresh host answers every model-bearing session/create with model-not-found. +export async function runPrintOnce(sel, { client, createClient = openPrintClient, timeoutMs = 600_000, catalog, providerConfig, provision = provisionPersonalProviderConfig } = {}) { const t0 = Date.now(); const cwd = path.resolve(sel.cwd ?? process.cwd()); const key = path.normalize(cwd); @@ -185,7 +190,10 @@ export async function runPrintOnce(sel, { client, createClient = openPrintClient params.thoughtLevel = sel.effort; } const own = !client; - if (own) client = await createClient(cwd); + if (own) { + try { provision(); } catch {} // best-effort registry seed; a failure must not block the run + client = await createClient(cwd); + } let sid = null; try { const created = await client.call('session/create', params); diff --git a/packages/cli/zagent.mjs b/packages/cli/zagent.mjs index 8cf5bf7..85d57fa 100755 --- a/packages/cli/zagent.mjs +++ b/packages/cli/zagent.mjs @@ -19,6 +19,7 @@ import { findRuntime, kernelEnv, kernelResolves } from '../driver/runtime.mjs'; import { runtimeCapabilities, capabilityLine } from '../driver/runtime-info.mjs'; import { buildLaunchArgs, tuiPreference, tuiNodeSupported, TUI_NODE_FLOOR, nodeSqliteSupported, NODE_SQLITE_FLOOR } from '../driver/tui-launch.mjs'; import { provisionStandaloneAccounts } from '../driver/account-provider.mjs'; +import { modelResolutionCheck, personalProviderConfigPath } from '../driver/personal-provider.mjs'; import { explainProviderError, formatProviderError } from '../driver/provider-errors.mjs'; import { nodeLine, doctorCredential, extensionCounts, hooksLine, diskLine, logDirLine, displayPath, displayText } from '../driver/doctor.mjs'; import { snapshotStatus, snapshotLine } from '../driver/snapshot-guard.mjs'; @@ -621,6 +622,21 @@ if (args[0] === 'doctor' || !rt) { const cred = doctorCredential({ config: cfgJson, hasConfig: existsSync(cfg) }); console.log(`credential: ${cred ?? 'NONE'}`); if (!cred && existsSync(cfg)) warnings.push('no Coding Plan credential in any source — turns will stop at the sign-in card'); + // The credential can be fine while the model still cannot resolve: the + // app-server registry reads personal providers from v2/provider_config.json + // only, so a config whose selected provider/model is absent there (and not + // derivable from the cli config) fails every -p run with model-not-found. + // Doctor reports that class ahead of the turn instead of exiting 0. + if (cfgJson) { + const res = modelResolutionCheck({ config: cfgJson }); + if (res) { + console.log(`model: ${res.detail}${res.ok ? '' : ' — NOT RESOLVABLE'}`); + if (!res.ok) { + warnings.push(`${res.detail} — cannot resolve in the runtime registry; fix the provider/model list in the cli config or in ${displayPath(personalProviderConfigPath())}`); + unhealthy = true; + } + } + } const ext = extensionCounts({ config: cfgJson }); console.log(`plugins: ${ext.plugins} installed`); console.log(hooksLine(ext)); diff --git a/packages/driver/personal-provider.mjs b/packages/driver/personal-provider.mjs new file mode 100644 index 0000000..cefd4ab --- /dev/null +++ b/packages/driver/personal-provider.mjs @@ -0,0 +1,224 @@ +// 3.14.x personal provider config provisioning (kernel symbols NHa/Jkt/Cpe/ +// dQi/Ykt in zcode.cjs; byte offsets from the 3.14.4 bundle). +// +// The app-server boots its Provider Registry WITHOUT the standalone options +// (runZCodeProtocolAgent calls Ykt(env) with no `standalone` key — unlike the +// CLI -p runner R2n, which passes `standalone` and with it `importLegacy`). +// The registry therefore reads personal providers ONLY from +// ZCODE_PERSONAL_PROVIDER_CONFIG_FILE (default ~/.zcode/v2/provider_config.json) +// and never imports the legacy CLI config — on a host that has only +// ~/.zcode/cli/config.json, every model-bearing session/create fails with +// "Provider Registry 中不存在 Model: zai/glm-5.3" until that file exists. The +// kernel's own -p path migrates the CLI config into it on first run (Cpe.#f +// persists the importLegacy result via the atomic qL write, mode 0600); the +// app-server path never does. +// +// This mirrors that migration so a fresh host works on the FIRST app-server +// backed run (-p --model, commit-msg, models): convert the CLI config exactly +// like the kernel's NHa/MHa/FHa/LHa, write it only when the target file does +// not exist (a desktop-written or kernel-migrated file always wins), under the +// shared interprocess lock, atomically, mode 0600. Best-effort like +// provisionStandaloneAccounts: never throws, never blocks the launch. +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { atomicWriteFileSync, withFileLockSync } from './credentials.mjs'; + +// dQi parity: preset ZCODE_PERSONAL_PROVIDER_CONFIG_FILE passes through; +// otherwise the personal config lives under the kernel's data base dir. +export function personalProviderConfigPath({ env = process.env, home = os.homedir() } = {}) { + const preset = env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE?.trim(); + if (preset) return preset; + const dataBaseDir = env.ZCODE_DATA_BASE_DIR?.trim() || home; + return path.join(dataBaseDir, '.zcode', 'v2', 'provider_config.json'); +} + +// MHa parity: legacy `kind` -> personal provider api type. +const API_TYPE = { + anthropic: 'anthropic-messages', + openai: 'openai-responses', + 'openai-compatible': 'openai-chat-completions', +}; +const apiType = kind => API_TYPE[kind] ?? 'openai-chat-completions'; + +// aee parity: the two legacy builtin ids that map onto builtin api families. +const LEGACY_BUILTIN_FAMILY = { 'builtin:zai': 'zai-api', 'builtin:bigmodel': 'bigmodel-api' }; + +// A provider the kernel's importer refuses: no-auth entries have no personal +// representation, and the kernel aborts the WHOLE import on one (Gkt -> Jkt +// returns null -> nothing is written). Mirrored: throw, provisioner abstains. +export class UnsupportedLegacyCliProviderConfigError extends Error { + constructor(providerId) { + super(`legacy CLI provider ${providerId} carries a field the formal config cannot express`); + this.name = 'UnsupportedLegacyCliProviderConfigError'; + this.providerId = providerId; + } +} + +const positiveInt = v => typeof v === 'number' && Number.isInteger(v) && v > 0; + +// FHa parity: the provider's model members in declared order, skipping +// deleted entries, with a positive-integer context window when carried +// (contextWindow wins over limit.context). +function modelMembers(provider) { + const seen = new Set(); + const out = []; + for (const [key, m] of Object.entries(provider?.models ?? {})) { + if (m?.deleted === true) continue; + const id = (typeof m?.id === 'string' ? m.id : key).trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + const cw = positiveInt(m.contextWindow) ? m.contextWindow + : positiveInt(m.limit?.context) ? m.limit.context : undefined; + out.push(cw === undefined ? { modelId: id } : { modelId: id, contextWindow: cw }); + } + return out; +} + +/** + * Convert a parsed ~/.zcode/cli/config.json into the personal provider rules + * the kernel's registry reads (NHa parity). Returns null when there is nothing + * importable (kernel Jkt parity: missing/unparseable input imports nothing); + * throws UnsupportedLegacyCliProviderConfigError on a no-auth provider. + * @returns {{providers:Array, models:Array, defaultModelSelection:{providerId,modelId}|null}|null} + */ +export function importLegacyCliConfig(cli) { + if (!cli || typeof cli !== 'object' || Array.isArray(cli)) return null; + const providers = [], models = []; + for (const [rawId, p] of Object.entries(cli.provider ?? {})) { + const id = rawId.trim(); + if (!id) continue; + const family = LEGACY_BUILTIN_FAMILY[id]; + if (family) { + // NHa: builtin:zai/builtin:bigmodel become api-key rules on the builtin + // family — and only when they carry a key. + const key = p?.options?.apiKey?.trim(); + if (key) providers.push({ providerId: family, templateId: family, + config: { group: 'standard-personal', access: { apiKey: key } } }); + continue; + } + if (id.startsWith('builtin:') || id.startsWith('account:')) continue; + if (p?.source !== undefined && p?.source !== 'custom') continue; + if (p?.options?.apiKeyRequired === false) throw new UnsupportedLegacyCliProviderConfigError(id); + const members = modelMembers(p); + const ids = members.map(m => m.modelId); + const name = p?.name?.trim(); + const headers = { ...p?.headers, ...p?.options?.headers }; + providers.push({ + providerId: id, + ...(name && name !== id ? { providerName: name } : {}), + config: { + group: 'standard-personal', + access: { type: 'api-key', ...(p?.options?.apiKey !== undefined ? { apiKey: p.options.apiKey } : {}) }, + api: { + type: apiType(p?.kind), + ...(p?.options?.baseURL !== undefined ? { baseUrl: p.options.baseURL } : {}), + ...(Object.keys(headers).length ? { headers } : {}), + }, + ...(ids.length ? { personalModelIds: ids, modelOrder: ids } : {}), + }, + }); + for (const m of members) + if (m.contextWindow !== undefined) + models.push({ modelId: m.modelId, config: { properties: { contextWindow: m.contextWindow } }, providerId: id }); + } + if (!providers.length) return null; + // LHa parity: model.main is the configured default; the kernel's own + // builtin-default provider id (OHa) is not imported. + const main = typeof cli.model?.main === 'string' && /^[^/]+\/.+$/.test(cli.model.main) + ? cli.model.main : null; + const defaultModelSelection = main && main.split('/')[0] !== 'builtin:zapi' + ? { providerId: main.split('/')[0], modelId: main.slice(main.indexOf('/') + 1) } : null; + return { providers, models, defaultModelSelection }; +} + +/** The on-disk document shape (schemaVersion 1, strict M3i schema). */ +export function personalProviderConfigDocument({ providers, models, defaultModelSelection }) { + return { + schemaVersion: 1, + config: { + providerConfigRules: { providerRules: providers }, + modelConfigRules: { providerModelRules: models, manualProviderModelRules: [] }, + ...(defaultModelSelection ? { defaultModelSelection } : {}), + }, + }; +} + +/** + * Best-effort provisioning of the personal provider config from the legacy + * CLI config. Never throws. An existing target file is authoritative (the + * desktop or a prior kernel run owns it — provisioning must never overwrite). + * @returns {{provisioned:boolean, path:string, reason:string|null}} + */ +export function provisionPersonalProviderConfig({ + env = process.env, home = os.homedir(), + read = p => readFileSync(p, 'utf8'), exists = existsSync, + write = atomicWriteFileSync, lock = withFileLockSync, lockOptions = {}, +} = {}) { + const result = { provisioned: false, path: personalProviderConfigPath({ env, home }), reason: null }; + if (exists(result.path)) { result.reason = 'personal provider config already present'; return result; } + let cli; + try { cli = JSON.parse(read(path.join(home, '.zcode', 'cli', 'config.json'))); } + catch { result.reason = 'no readable cli config to migrate'; return result; } + let imported; + try { imported = importLegacyCliConfig(cli); } + catch (e) { + result.reason = e?.name === 'UnsupportedLegacyCliProviderConfigError' + ? `cli provider '${e.providerId}' cannot be expressed in the personal config` : `legacy import failed: ${e?.message ?? e}`; + return result; + } + if (!imported) { result.reason = 'cli config has no importable providers'; return result; } + try { + return lock(result.path, () => { + // Re-check under the lock: a concurrent kernel/GUI writer wins silently. + if (exists(result.path)) { result.reason = 'personal provider config already present'; return result; } + write(result.path, JSON.stringify(personalProviderConfigDocument(imported), null, 2)); + result.provisioned = true; + return result; + }, lockOptions); + } catch (e) { + result.reason = e?.code === 'ELOCKTIMEOUT' + ? `personal provider config lock unavailable: ${e.message}` + : `personal provider config provisioning failed: ${e?.message ?? e}`; + return result; + } +} + +/** + * Read-only resolution check for doctor: can the app-server registry resolve + * the cli config's model.main? The effective personal source is an existing + * provider_config.json (file wins) or what provisioning would derive from the + * cli config. Provider ids whose resolution depends on builtin entitlements + * or the runtime's bundled catalog cannot be judged statically -> null. + * @returns {{ok:boolean, source:string, detail:string}|null} + */ +export function modelResolutionCheck({ env = process.env, home = os.homedir(), + config = null, read = p => readFileSync(p, 'utf8'), exists = existsSync } = {}) { + const main = typeof config?.model?.main === 'string' && /^[^/]+\/.+$/.test(config.model.main) + ? config.model.main : null; + if (!main) return null; + const providerId = main.split('/')[0], modelId = main.slice(main.indexOf('/') + 1); + if (providerId.startsWith('builtin:') || providerId.startsWith('account:')) return null; + const file = personalProviderConfigPath({ env, home }); + let rules = null, source; + if (exists(file)) { + source = 'personal provider config'; + try { + const parsed = JSON.parse(read(file)); + rules = { providers: parsed?.config?.providerConfigRules?.providerRules ?? [] }; + } catch { return { ok: false, source, detail: 'unreadable' }; } + } else { + source = 'cli config (provisioned on first -p run)'; + let imported = null; + try { imported = importLegacyCliConfig(config); } + catch { return { ok: false, source, detail: `provider '${providerId}' is not expressible` }; } + if (imported) rules = imported; + } + if (!rules) return { ok: false, source, detail: `provider '${providerId}' is not configured` }; + const rule = rules.providers.find(p => p?.providerId === providerId); + if (!rule) return { ok: false, source, detail: `provider '${providerId}' is not configured` }; + const ids = rule.config?.personalModelIds ?? []; + if (!ids.includes(modelId)) + return { ok: false, source, detail: `model ${providerId}/${modelId} is not in the provider's model list` }; + return { ok: true, source, detail: `${providerId}/${modelId}` }; +} diff --git a/packages/driver/test-doctor.mjs b/packages/driver/test-doctor.mjs index 287b66c..cf5d6b7 100644 --- a/packages/driver/test-doctor.mjs +++ b/packages/driver/test-doctor.mjs @@ -22,7 +22,7 @@ try { assert.equal(readFileSync(config, 'utf8'), value, 'doctor must preserve corrupt user config'); } } - writeFileSync(config, JSON.stringify({ model: { main: 'zai/model' }, provider: { zai: { options: { apiKey: 'fixture-key' } } } })); + writeFileSync(config, JSON.stringify({ model: { main: 'zai/model' }, provider: { zai: { options: { apiKey: 'fixture-key' }, models: { model: {} } } } })); const r = spawnSync(process.execPath, [new URL('../cli/zagent.mjs', import.meta.url).pathname, 'doctor'], { env, encoding: 'utf8', cwd: home }); assert.equal(r.status, 0, 'valid object remains accepted with an environment key'); // doctor reports environment depth — node build, credential source, config @@ -44,7 +44,7 @@ try { writeFileSync(path.join(home, '.zcode/cli/plugins/data/seeded/.zcode-plugin/plugin.json'), JSON.stringify({ name: 'seeded', version: '1.0.0' })); writeFileSync(config, JSON.stringify({ - model: { main: 'zai/model' }, provider: { zai: { options: { apiKey: 'fixture-key' } } }, + model: { main: 'zai/model' }, provider: { zai: { options: { apiKey: 'fixture-key' }, models: { model: {} } } }, hooks: { enabled: true, events: { UserPromptSubmit: [{ type: 'command', command: 'echo hi' }] } }, mcp: { servers: { fs: { command: 'mcp-fs' }, db: { command: 'mcp-db' } } }, })); @@ -61,7 +61,7 @@ try { // warn line (the exit contract is unchanged: config presence means set up ran). mkdirSync(path.join(home, '.config', 'ccz'), { recursive: true }); writeFileSync(path.join(home, '.config', 'ccz', '.api_key'), 'fallback-key'); - writeFileSync(config, JSON.stringify({ model: { main: 'zai/model' }, provider: { zai: { options: {} } } })); + writeFileSync(config, JSON.stringify({ model: { main: 'zai/model' }, provider: { zai: { options: {}, models: { model: {} } } } })); const r3 = spawnSync(process.execPath, [new URL('../cli/zagent.mjs', import.meta.url).pathname, 'doctor'], { env: envNoKey, encoding: 'utf8', cwd: home }); assert.equal(r3.status, 0, r3.stderr); assert.match(r3.stdout, /^credential: NONE$/m, 'doctor reports no credential honestly'); @@ -70,7 +70,7 @@ try { rmSync(config); const r4 = spawnSync(process.execPath, [new URL('../cli/zagent.mjs', import.meta.url).pathname, 'doctor'], { env: envNoKey, encoding: 'utf8', cwd: home }); assert.match(r4.stdout, /^credential: ccz fallback/m, 'fallback file is claimed only when no cli config exists'); - writeFileSync(config, JSON.stringify({ model: { main: 'zai/model' }, provider: { zai: { options: { apiKey: 'fixture-key' } } } })); + writeFileSync(config, JSON.stringify({ model: { main: 'zai/model' }, provider: { zai: { options: { apiKey: 'fixture-key' }, models: { model: {} } } } })); // a staged desktop update is reported read-only and never blocks a healthy verdict const pendingDir = path.join(home, '.cache', '@zcodedesktop-updater', 'pending'); mkdirSync(pendingDir, { recursive: true }); diff --git a/scripts/test-ux-audit.mjs b/scripts/test-ux-audit.mjs index 51f1467..7a4ffff 100644 --- a/scripts/test-ux-audit.mjs +++ b/scripts/test-ux-audit.mjs @@ -38,7 +38,8 @@ writeFileSync(path.join(catalogDir, 'models_catalog_1.json'), JSON.stringify({ mkdirSync(path.join(home, '.zcode', 'cli'), { recursive: true }); writeFileSync(path.join(home, '.zcode', 'cli', 'config.json'), JSON.stringify({ model: { main: 'zai/glm-5.3', lite: 'zai/glm-5.3-flash' }, - provider: { zai: { kind: 'anthropic', options: { baseURL: 'https://api.z.ai/api/anthropic/', apiKey: 'fixture' } } }, + provider: { zai: { kind: 'anthropic', options: { baseURL: 'https://api.z.ai/api/anthropic/', apiKey: 'fixture' }, + models: { 'glm-5.3': {}, 'glm-5.3-flash': {} } } }, })); const env = { From c860a4dfc88d9beafcd299fbaad52e17d5b39ee3 Mon Sep 17 00:00:00 2001 From: Cong <7380929+robotlearning123@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:35:35 -0400 Subject: [PATCH 2/3] fix(driver): harden personal provider provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the provisioning module: - Never seed a permanent empty-key config. NHa stores a custom provider's apiKey verbatim (xz ApiKeyAccessConfig, offset 547578, keeps "" through toJSON), and ensureConfig writes apiKey:'' during the OAuth window — a create-if-missing seed of that state could never be repaired (the kernel's import skips existing files and the OAuth backfill reads this very file). The converter keeps the kernel parity, and the provisioner now refuses to persist when the SELECTED provider's key is unusable (empty/whitespace/absent); the next run after the key lands seeds correctly. - Make the source/target root rule explicit. The kernel's legacy import reads the cli config from the REAL home (U7, offset 4090234) even when ZCODE_DATA_BASE_DIR relocates the personal target (dQi, offset 1068307) — measured live on 3.14.4: HOME's key lands in the data-root file and a data-root cli config is never consulted. That asymmetry is now a documented, named helper (legacyCliConfigPath) with pinning tests instead of an implicit join. - Skip null/primitive model-map entries like deleted ones instead of throwing, and make modelResolutionCheck's refusal reasons honest (expressibility vs malformed input). - Treat a structurally wrong existing provider_config.json (non-array providerRules / provider rule config / personalModelIds) as a NOT-RESOLVABLE verdict — the kernel's strict parser does the same — so doctor prints a model: line instead of crashing. - Drop an unused chmodSync import in the test. Each class has a unit test that fails on the pre-fix module and passes now. --- packages/cli/test-personal-provider.mjs | 111 +++++++++++++++++++++++- packages/driver/personal-provider.mjs | 83 +++++++++++++++--- 2 files changed, 178 insertions(+), 16 deletions(-) diff --git a/packages/cli/test-personal-provider.mjs b/packages/cli/test-personal-provider.mjs index 4b692eb..a2f7e30 100644 --- a/packages/cli/test-personal-provider.mjs +++ b/packages/cli/test-personal-provider.mjs @@ -7,11 +7,12 @@ // never runs the legacy import (runZCodeProtocolAgent -> Ykt(env), no // standalone options), which is why `-p --model` failed on fresh hosts. import { EventEmitter } from 'node:events'; -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, statSync, chmodSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { - importLegacyCliConfig, personalProviderConfigDocument, personalProviderConfigPath, + importLegacyCliConfig, personalProviderConfigDocument, personalProviderConfigPath, legacyCliConfigPath, provisionPersonalProviderConfig, modelResolutionCheck, UnsupportedLegacyCliProviderConfigError, } from '../driver/personal-provider.mjs'; import { runPrintOnce } from './zagent-print.mjs'; @@ -232,6 +233,112 @@ const tempHome = (cli = CLI_FIXTURE) => { rmSync(home, { recursive: true, force: true }); } +// --- never persist an unusable selected key (OAuth apiKey:'' window) --- +// ensureConfig writes apiKey:'' until the kernel provisions the real key; a +// create-if-missing seed of that config would be permanent (nothing rewrites +// an existing personal file), so the provisioner must abstain instead. +{ + const withKey = apiKey => { + const c = JSON.parse(JSON.stringify(CLI_FIXTURE)); + if (apiKey === undefined) delete c.provider.zai.options.apiKey; + else c.provider.zai.options.apiKey = apiKey; + return c; + }; + for (const bad of ['', ' ', undefined]) { + const home = tempHome(withKey(bad)); + const r = provisionPersonalProviderConfig({ home, env: {} }); + ok(!r.provisioned && !existsSync(personalProviderConfigPath({ home, env: {} })), + `selected provider key ${JSON.stringify(bad)} -> nothing seeded (${r.reason})`); + rmSync(home, { recursive: true, force: true }); + } + // NHa stores a custom provider's key verbatim (xz ApiKeyAccessConfig keeps + // "" — offset 547578): a NON-selected provider's empty key still migrates; + // the safety net is the selected-provider gate, not converter divergence. + const both = JSON.parse(JSON.stringify(CLI_FIXTURE)); + both.provider.other = { kind: 'openai', options: { apiKey: '' }, models: { m1: {} } }; + const home = tempHome(both); + const r = provisionPersonalProviderConfig({ home, env: {} }); + const doc = JSON.parse(readFileSync(personalProviderConfigPath({ home, env: {} }), 'utf8')); + const other = doc.config.providerConfigRules.providerRules.find(p => p.providerId === 'other'); + ok(r.provisioned && other?.config?.access?.apiKey === '', + "NHa parity: a non-selected provider's empty key is stored verbatim; the gate scopes to the selection"); + rmSync(home, { recursive: true, force: true }); +} + +// --- source/target root rule is the kernel's measured asymmetry --- +// The kernel's legacy import reads ~/.zcode/cli/config.json from the REAL home +// (U7, offset 4090234) while the personal target follows ZCODE_DATA_BASE_DIR +// (dQi, offset 1068307). Measured live on 3.14.4: HOME's key lands in the +// data-root file; a data-root cli config is never consulted. +{ + const home = tempHome(); + const data = mkdtempSync(path.join(tmpdir(), 'zagent-pp-')); + mkdirSync(path.join(data, '.zcode', 'cli'), { recursive: true }); + const dataCli = JSON.parse(JSON.stringify(CLI_FIXTURE)); + dataCli.provider.zai.options.apiKey = 'DATA-ROOT-KEY'; + writeFileSync(path.join(data, '.zcode', 'cli', 'config.json'), JSON.stringify(dataCli)); + const env = { ZCODE_DATA_BASE_DIR: data }; + ok(legacyCliConfigPath({ home }) === path.join(home, '.zcode', 'cli', 'config.json'), + 'legacy source path helper is HOME-rooted (U7 parity)'); + const r = provisionPersonalProviderConfig({ home, env }); + const target = path.join(data, '.zcode', 'v2', 'provider_config.json'); + ok(r.provisioned && r.path === target && existsSync(target), + 'target follows ZCODE_DATA_BASE_DIR (dQi parity)'); + ok(JSON.parse(readFileSync(target, 'utf8')).config.providerConfigRules.providerRules[0].config.access.apiKey === KEY, + 'source stays the HOME cli config — the data root cli config is never read'); + ok(!existsSync(path.join(home, '.zcode', 'v2', 'provider_config.json')), + 'nothing is written under HOME when the data root relocates the target'); + rmSync(home, { recursive: true, force: true }); + rmSync(data, { recursive: true, force: true }); +} + +// --- garbage model entries are skipped, not fatal --- +{ + const cli = JSON.parse(JSON.stringify(CLI_FIXTURE)); + cli.provider.zai.models['glm-5.3'] = null; // hand-edited config debris + const conv = importLegacyCliConfig(cli); + ok(JSON.stringify(conv?.providers[0]?.config?.personalModelIds) === JSON.stringify(['glm-5.3-flash']), + 'null model entries are skipped like deleted ones (no throw)'); + const home = tempHome(cli); + const r = provisionPersonalProviderConfig({ home, env: {} }); + ok(r.provisioned, 'a null model entry does not abort provisioning'); + const res = modelResolutionCheck({ env: {}, home, config: cli }); + ok(res?.ok === false && /not in the provider's model list/.test(res.detail), + `resolution verdict stays honest for the dropped model (${res?.detail}) — not "not expressible"`); + rmSync(home, { recursive: true, force: true }); +} + +// --- a malformed existing file is a verdict, never a crash --- +{ + const home = tempHome(); + const env = {}; + const target = personalProviderConfigPath({ home, env }); + mkdirSync(path.dirname(target), { recursive: true }); + const write = doc => writeFileSync(target, JSON.stringify(doc), { mode: 0o600 }); + let res = null, threw = false; + write({ schemaVersion: 1, config: { providerConfigRules: { providerRules: { zai: {} } } } }); + try { res = modelResolutionCheck({ env, home, config: CLI_FIXTURE }); } catch { threw = true; } + ok(!threw && res?.ok === false && /malformed \(providerRules\)/.test(res.detail), + `non-array providerRules -> malformed verdict, not a crash (${res?.detail})`); + threw = false; + write({ schemaVersion: 1, config: { providerConfigRules: { providerRules: [ + { providerId: 'zai', config: { personalModelIds: { 'glm-5.3': 1 } } } ] } } }); + try { res = modelResolutionCheck({ env, home, config: CLI_FIXTURE }); } catch { threw = true; } + ok(!threw && res?.ok === false && /malformed \(personalModelIds\)/.test(res.detail), + `non-array personalModelIds -> malformed verdict, not a crash (${res?.detail})`); + // end-to-end through doctor: a model: line and exit 1, never an uncaught throw + write({ schemaVersion: 1, config: { providerConfigRules: { providerRules: { zai: {} } } } }); + const runtime = path.join(home, 'runtime.cjs'); + writeFileSync(runtime, 'throw new Error("doctor must not start runtime");'); + const doc2 = spawnSync(process.execPath, [new URL('./zagent.mjs', import.meta.url).pathname, 'doctor'], { + env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, ZAGENT_TEST_SANDBOX: home, ZCODE_RUNTIME: runtime }, + encoding: 'utf8', cwd: home, timeout: 30000, + }); + ok(doc2.status === 1 && /^model: .+NOT RESOLVABLE$/m.test(doc2.stdout) && !/TypeError/.test(doc2.stdout + doc2.stderr), + 'doctor prints a model: verdict for a malformed personal config instead of crashing'); + rmSync(home, { recursive: true, force: true }); +} + if (fails) { console.error(`${fails} FAILURE(S)`); process.exit(1); } console.log('personal-provider: all checks passed'); process.exit(0); diff --git a/packages/driver/personal-provider.mjs b/packages/driver/personal-provider.mjs index cefd4ab..f06c3ab 100644 --- a/packages/driver/personal-provider.mjs +++ b/packages/driver/personal-provider.mjs @@ -24,8 +24,9 @@ import path from 'node:path'; import os from 'node:os'; import { atomicWriteFileSync, withFileLockSync } from './credentials.mjs'; -// dQi parity: preset ZCODE_PERSONAL_PROVIDER_CONFIG_FILE passes through; -// otherwise the personal config lives under the kernel's data base dir. +// dQi parity (offset 1068307): preset ZCODE_PERSONAL_PROVIDER_CONFIG_FILE +// passes through; otherwise the personal config lives under the kernel's data +// base dir (ZCODE_DATA_BASE_DIR replaces HOME). export function personalProviderConfigPath({ env = process.env, home = os.homedir() } = {}) { const preset = env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE?.trim(); if (preset) return preset; @@ -33,6 +34,18 @@ export function personalProviderConfigPath({ env = process.env, home = os.homedi return path.join(dataBaseDir, '.zcode', 'v2', 'provider_config.json'); } +// U7 parity (offset 4090234; tHr="~/.zcode/cli" at 4092479, zg at 4086419 +// expands "~/" via os.homedir()): the kernel's legacy import ALWAYS reads the +// cli config from the real home, even when ZCODE_DATA_BASE_DIR relocates the +// data root. Measured live on 3.14.4 (HOME=A with key K1, ZCODE_DATA_BASE_DIR=B +// with cli config key K2, kernel-native -p): the migration wrote +// B/.zcode/v2/provider_config.json carrying K1 — B's cli config was never +// read, and A kept the cli state. Source and target are therefore rooted +// differently ON PURPOSE; do not "fix" one to match the other. +export function legacyCliConfigPath({ home = os.homedir() } = {}) { + return path.join(home, '.zcode', 'cli', 'config.json'); +} + // MHa parity: legacy `kind` -> personal provider api type. const API_TYPE = { anthropic: 'anthropic-messages', @@ -57,15 +70,29 @@ export class UnsupportedLegacyCliProviderConfigError extends Error { const positiveInt = v => typeof v === 'number' && Number.isInteger(v) && v > 0; +// A credential the provider could actually authenticate with. The kernel's +// family entries require one (NHa trims and skips when empty, ~14109800); a +// custom provider's key is stored VERBATIM by NHa (xz ApiKeyAccessConfig, +// offset 547578: `this.apiKey = t.apiKey`, no trim/validation; toJSON's dAe +// strips only undefined, so "" serializes). zagent keeps the converter at that +// parity but the provisioner refuses to PERSIST a selection whose key is not +// usable: ensureConfig writes apiKey:'' during the OAuth window +// (zagent.mjs — backfilled only once the kernel's provider_config carries the +// key), and a create-if-missing seed of an empty-key file can never be +// repaired — neither the kernel's import (file exists) nor the backfill +// (it reads this very file) rewrites it. +const usableKey = provider => typeof provider?.options?.apiKey === 'string' && provider.options.apiKey.trim() !== ''; + // FHa parity: the provider's model members in declared order, skipping -// deleted entries, with a positive-integer context window when carried +// deleted entries and null/primitive garbage (FHa guards with zod upstream; +// we guard at read), with a positive-integer context window when carried // (contextWindow wins over limit.context). function modelMembers(provider) { const seen = new Set(); const out = []; for (const [key, m] of Object.entries(provider?.models ?? {})) { - if (m?.deleted === true) continue; - const id = (typeof m?.id === 'string' ? m.id : key).trim(); + if (m?.deleted === true || !m || typeof m !== 'object') continue; + const id = (typeof m.id === 'string' ? m.id : key).trim(); if (!id || seen.has(id)) continue; seen.add(id); const cw = positiveInt(m.contextWindow) ? m.contextWindow @@ -97,13 +124,15 @@ export function importLegacyCliConfig(cli) { config: { group: 'standard-personal', access: { apiKey: key } } }); continue; } - if (id.startsWith('builtin:') || id.startsWith('account:')) continue; - if (p?.source !== undefined && p?.source !== 'custom') continue; + if (id.startsWith('builtin:') || id.startsWith('account:') || p?.source !== undefined && p?.source !== 'custom') continue; if (p?.options?.apiKeyRequired === false) throw new UnsupportedLegacyCliProviderConfigError(id); const members = modelMembers(p); const ids = members.map(m => m.modelId); const name = p?.name?.trim(); const headers = { ...p?.headers, ...p?.options?.headers }; + // NHa stores a custom provider's apiKey verbatim ("" and whitespace + // included — see usableKey); the safety net is the provisioner's + // selected-provider gate, not a divergence here. providers.push({ providerId: id, ...(name && name !== id ? { providerName: name } : {}), @@ -157,8 +186,10 @@ export function provisionPersonalProviderConfig({ } = {}) { const result = { provisioned: false, path: personalProviderConfigPath({ env, home }), reason: null }; if (exists(result.path)) { result.reason = 'personal provider config already present'; return result; } + // U7 parity: the legacy source is always the real home's cli config — even + // when ZCODE_DATA_BASE_DIR relocates the target (see legacyCliConfigPath). let cli; - try { cli = JSON.parse(read(path.join(home, '.zcode', 'cli', 'config.json'))); } + try { cli = JSON.parse(read(legacyCliConfigPath({ home }))); } catch { result.reason = 'no readable cli config to migrate'; return result; } let imported; try { imported = importLegacyCliConfig(cli); } @@ -168,6 +199,15 @@ export function provisionPersonalProviderConfig({ return result; } if (!imported) { result.reason = 'cli config has no importable providers'; return result; } + // Never seed a permanent unusable selection: the file is create-if-missing, + // so an empty-key write could never be repaired afterwards (neither the + // kernel's import — the file exists — nor the OAuth backfill — it reads this + // very file). An OAuth-window config (apiKey:'') must abstain, not brick. + const selected = imported.defaultModelSelection?.providerId; + if (selected !== undefined && cli.provider?.[selected] !== undefined && !usableKey(cli.provider[selected])) { + result.reason = `selected provider '${selected}' has no usable API key — refusing to seed an empty-key personal config`; + return result; + } try { return lock(result.path, () => { // Re-check under the lock: a concurrent kernel/GUI writer wins silently. @@ -203,21 +243,36 @@ export function modelResolutionCheck({ env = process.env, home = os.homedir(), let rules = null, source; if (exists(file)) { source = 'personal provider config'; - try { - const parsed = JSON.parse(read(file)); - rules = { providers: parsed?.config?.providerConfigRules?.providerRules ?? [] }; - } catch { return { ok: false, source, detail: 'unreadable' }; } + let parsed; + try { parsed = JSON.parse(read(file)); } + catch { return { ok: false, source, detail: 'the personal provider config is unreadable' }; } + // The kernel's strict parser (yAe) rejects a structurally wrong file and + // the registry falls back to no personal providers — so a wrong shape is + // a NOT-RESOLVABLE verdict, never a crash in doctor. + const providerRules = parsed?.config?.providerConfigRules?.providerRules; + if (!Array.isArray(providerRules)) + return { ok: false, source, detail: 'the personal provider config is malformed (providerRules)' }; + for (const r of providerRules) + if (r?.config !== undefined && (typeof r.config !== 'object' || Array.isArray(r.config))) + return { ok: false, source, detail: 'the personal provider config is malformed (provider rule)' }; + else if (r?.config?.personalModelIds !== undefined && !Array.isArray(r.config.personalModelIds)) + return { ok: false, source, detail: 'the personal provider config is malformed (personalModelIds)' }; + rules = { providers: providerRules }; } else { source = 'cli config (provisioned on first -p run)'; let imported = null; try { imported = importLegacyCliConfig(config); } - catch { return { ok: false, source, detail: `provider '${providerId}' is not expressible` }; } + catch (e) { + return { ok: false, source, detail: e?.name === 'UnsupportedLegacyCliProviderConfigError' + ? `provider '${e.providerId}' is not expressible in the personal config` + : `the cli config is malformed (${e?.message ?? e})` }; + } if (imported) rules = imported; } if (!rules) return { ok: false, source, detail: `provider '${providerId}' is not configured` }; const rule = rules.providers.find(p => p?.providerId === providerId); if (!rule) return { ok: false, source, detail: `provider '${providerId}' is not configured` }; - const ids = rule.config?.personalModelIds ?? []; + const ids = Array.isArray(rule.config?.personalModelIds) ? rule.config.personalModelIds : []; if (!ids.includes(modelId)) return { ok: false, source, detail: `model ${providerId}/${modelId} is not in the provider's model list` }; return { ok: true, source, detail: `${providerId}/${modelId}` }; From a982ef835a4e1a094c6105a77a58c964427c0c5b Mon Sep 17 00:00:00 2001 From: Cong <7380929+robotlearning123@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:14:52 -0400 Subject: [PATCH 3/3] fix(driver): one predicate for seeding and doctor's model verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit planPersonalProviderConfig is now the single answer to "would the -p path make this host's selection resolvable, and what would it write"; provisioning and doctor's modelResolutionCheck both consume it, so doctor can never report ok for a state the -p path refuses to seed (the OAuth/ZAI_API_KEY empty-key states: credential lines look fine, yet no registry file can be created). The plan also refuses to seed a document that would not resolve the selected model — a usable key with no models map used to leave a rule without personalModelIds behind forever (nothing rewrites an existing personal file), turning a wait-for-the-config-to-grow state into a permanent miss on the first failed run. Dangling selections are the same class. Family rules (templateId) resolve through the builtin template's model list and stay seedable. Wrong-typed fields (name:{}, apiKey:5, baseURL:7) now degrade to absent for that entry alone instead of aborting the whole import. The kernel's zod layer (QAn -> RHa) is strict — one bad field rejects the entire config — but it also has a desktop to repair the file; zagent's create-if-missing seed does not, so per-entry leniency is the safer failure mode for the converter. doctor's exit contract changes accordingly for keyless configs: a selection the -p path cannot seed is NOT RESOLVABLE and unhealthy (exit 1), matching what provisioning refuses — previously a credential-less config kept exit 0 while -p --model failed. --- packages/cli/test-personal-provider.mjs | 151 ++++++++++++++- packages/driver/personal-provider.mjs | 234 ++++++++++++++++-------- packages/driver/test-doctor.mjs | 9 +- 3 files changed, 301 insertions(+), 93 deletions(-) diff --git a/packages/cli/test-personal-provider.mjs b/packages/cli/test-personal-provider.mjs index a2f7e30..4d914cb 100644 --- a/packages/cli/test-personal-provider.mjs +++ b/packages/cli/test-personal-provider.mjs @@ -13,7 +13,7 @@ import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { importLegacyCliConfig, personalProviderConfigDocument, personalProviderConfigPath, legacyCliConfigPath, - provisionPersonalProviderConfig, modelResolutionCheck, UnsupportedLegacyCliProviderConfigError, + provisionPersonalProviderConfig, planPersonalProviderConfig, modelResolutionCheck, UnsupportedLegacyCliProviderConfigError, } from '../driver/personal-provider.mjs'; import { runPrintOnce } from './zagent-print.mjs'; @@ -244,7 +244,7 @@ const tempHome = (cli = CLI_FIXTURE) => { else c.provider.zai.options.apiKey = apiKey; return c; }; - for (const bad of ['', ' ', undefined]) { + for (const bad of ['', ' ', undefined, 5, {}]) { const home = tempHome(withKey(bad)); const r = provisionPersonalProviderConfig({ home, env: {} }); ok(!r.provisioned && !existsSync(personalProviderConfigPath({ home, env: {} })), @@ -265,6 +265,95 @@ const tempHome = (cli = CLI_FIXTURE) => { rmSync(home, { recursive: true, force: true }); } +// --- doctor and provisioning share ONE predicate --- +// modelResolutionCheck may never claim ok for a state the -p path refuses to +// seed (the OAuth/ZAI_API_KEY empty-key states): both consume +// planPersonalProviderConfig, so their answers move together. +{ + const withKey = apiKey => { + const c = JSON.parse(JSON.stringify(CLI_FIXTURE)); + if (apiKey === undefined) delete c.provider.zai.options.apiKey; + else c.provider.zai.options.apiKey = apiKey; + return c; + }; + for (const bad of ['', undefined]) { + const home = tempHome(withKey(bad)); + const env = {}; + const res = modelResolutionCheck({ env, home, config: withKey(bad) }); + ok(res?.ok === false && /has no usable API key/.test(res.detail), + `empty-key state: doctor says NOT resolvable, not ok (${res?.detail})`); + const r = provisionPersonalProviderConfig({ home, env }); + const plan = planPersonalProviderConfig({ env, home, config: withKey(bad) }); + ok(!r.provisioned && plan.write === false && plan.resolves?.ok === false, + 'provisioner and plan agree the empty-key state is unseedable'); + rmSync(home, { recursive: true, force: true }); + } + // The agreement invariant on a healthy state too: plan.write, the seeded + // file, and the doctor verdict all line up. + const home = tempHome(); + const env = {}; + const plan = planPersonalProviderConfig({ env, home, config: CLI_FIXTURE }); + const r = provisionPersonalProviderConfig({ home, env }); + ok(plan.write === true && plan.resolves?.ok === true && r.provisioned + && modelResolutionCheck({ env, home, config: CLI_FIXTURE })?.ok === true, + 'healthy state: plan.write, provisioning, and the doctor verdict agree'); + rmSync(home, { recursive: true, force: true }); +} +{ + // End-to-end: the OAuth-window config (apiKey:'') with a signed-in OAuth + // store and even ZAI_API_KEY in the env — the credential lines look fine, + // yet -p --model cannot seed the registry, so doctor must not exit 0. + const cliEmptyKey = JSON.parse(JSON.stringify(CLI_FIXTURE)); + cliEmptyKey.provider.zai.options.apiKey = ''; + const home = tempHome(cliEmptyKey); + mkdirSync(path.join(home, '.zcode', 'v2'), { recursive: true }); + writeFileSync(path.join(home, '.zcode', 'v2', 'credentials.json'), + JSON.stringify({ 'oauth:zai:access_token': 'fixture-token' }), { mode: 0o600 }); + const runtime = path.join(home, 'runtime.cjs'); + writeFileSync(runtime, 'throw new Error("doctor must not start runtime");'); + const r = spawnSync(process.execPath, [new URL('./zagent.mjs', import.meta.url).pathname, 'doctor'], { + env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, ZAGENT_TEST_SANDBOX: home, + ZCODE_RUNTIME: runtime, ZAI_API_KEY: 'fixture-env-key' }, + encoding: 'utf8', cwd: home, timeout: 30000, + }); + ok(r.status === 1 && /^model: .+has no usable API key — NOT RESOLVABLE$/m.test(r.stdout), + `doctor flags the unseedable OAuth-window config instead of exiting 0 (status ${r.status})`); + ok(!existsSync(personalProviderConfigPath({ home, env: {} })), 'doctor seeded nothing (read-only)'); + rmSync(home, { recursive: true, force: true }); +} + +// --- the seeded document must resolve the selection (no permanent miss) --- +{ + // Usable key but no models map: the derived rule would carry no + // personalModelIds, so the selection could never resolve — seeding that + // would be a permanent miss (nothing rewrites an existing personal file). + const noModels = JSON.parse(JSON.stringify(CLI_FIXTURE)); + delete noModels.provider.zai.models; + const home = tempHome(noModels); + const env = {}; + const r = provisionPersonalProviderConfig({ home, env }); + ok(!r.provisioned && !existsSync(personalProviderConfigPath({ home, env })), + `models-less selection refuses to seed (${r.reason})`); + const res = modelResolutionCheck({ env, home, config: noModels }); + ok(res?.ok === false && /not in the provider's model list/.test(res.detail), + `doctor agrees the models-less selection will not resolve (${res?.detail})`); + // Recovery: once the config grows the model list, the same host seeds fine — + // the refusal kept the state repairable instead of bricking it. + writeFileSync(path.join(home, '.zcode', 'cli', 'config.json'), JSON.stringify(CLI_FIXTURE)); + const r2 = provisionPersonalProviderConfig({ home, env }); + ok(r2.provisioned && existsSync(personalProviderConfigPath({ home, env })), + 'after the config grows its model list, seeding succeeds (state was not bricked)'); + rmSync(home, { recursive: true, force: true }); + // A selection naming a provider the config does not carry is the same class. + const dangling = JSON.parse(JSON.stringify(CLI_FIXTURE)); + dangling.model.main = 'ghost/glm-5.3'; + const ghome = tempHome(dangling); + const gr = provisionPersonalProviderConfig({ home: ghome, env: {} }); + ok(!gr.provisioned && !existsSync(personalProviderConfigPath({ home: ghome, env: {} })), + `dangling selection refuses to seed (${gr.reason})`); + rmSync(ghome, { recursive: true, force: true }); +} + // --- source/target root rule is the kernel's measured asymmetry --- // The kernel's legacy import reads ~/.zcode/cli/config.json from the REAL home // (U7, offset 4090234) while the personal target follows ZCODE_DATA_BASE_DIR @@ -292,20 +381,64 @@ const tempHome = (cli = CLI_FIXTURE) => { rmSync(data, { recursive: true, force: true }); } -// --- garbage model entries are skipped, not fatal --- +// --- garbage entries degrade per provider, never abort the import --- +// Wrong-typed fields (name:{}, apiKey:5) make THAT field unusable; the other +// providers keep their migration. The kernel's zod layer (QAn -> RHa) is +// strict — one bad field rejects the whole config — but it has a desktop to +// repair the file; zagent's create-if-missing seed does not. { const cli = JSON.parse(JSON.stringify(CLI_FIXTURE)); - cli.provider.zai.models['glm-5.3'] = null; // hand-edited config debris + cli.provider.zai.name = {}; // wrong-typed name on the SELECTED provider const conv = importLegacyCliConfig(cli); - ok(JSON.stringify(conv?.providers[0]?.config?.personalModelIds) === JSON.stringify(['glm-5.3-flash']), + ok(conv && !('providerName' in conv.providers[0]) && conv.providers[0].providerId === 'zai', + 'a non-string name degrades to no providerName (no throw)'); + const home = tempHome(cli); + const r = provisionPersonalProviderConfig({ home, env: {} }); + ok(r.provisioned, 'a wrong-typed name does not abort provisioning of the selection'); + rmSync(home, { recursive: true, force: true }); + + const fam = { provider: { 'builtin:zai': { options: { apiKey: 5 } }, zai: CLI_FIXTURE.provider.zai }, + model: { main: 'zai/glm-5.3' } }; + const fconv = importLegacyCliConfig(fam); + ok(fconv && !fconv.providers.some(p => p.templateId === 'zai-api'), + 'a wrong-typed family key skips that family rule (no throw)'); + ok(fconv.providers.some(p => p.providerId === 'zai'), 'the healthy sibling provider still migrates'); + + const mixed = JSON.parse(JSON.stringify(CLI_FIXTURE)); + mixed.provider.junk = { kind: 'anthropic', name: { bad: 1 }, options: { apiKey: { bad: 1 }, baseURL: 7 }, models: { m: {} } }; + const home2 = tempHome(mixed); + const r2 = provisionPersonalProviderConfig({ home: home2, env: {} }); + const doc = r2.provisioned ? JSON.parse(readFileSync(personalProviderConfigPath({ home: home2, env: {} }), 'utf8')) : null; + const junk = doc?.config.providerConfigRules.providerRules.find(p => p.providerId === 'junk'); + ok(r2.provisioned && junk && !('providerName' in junk) && !('apiKey' in junk.config.access) && !('baseUrl' in junk.config.api), + 'one garbage provider degrades field-by-field; the import and the selection survive'); + rmSync(home2, { recursive: true, force: true }); +} + +// --- null model entries are skipped, not fatal --- +{ + const cli = JSON.parse(JSON.stringify(CLI_FIXTURE)); + cli.provider.zai.models['glm-5.3-flash'] = null; // debris on a NON-selected model + const conv = importLegacyCliConfig(cli); + ok(JSON.stringify(conv?.providers[0]?.config?.personalModelIds) === JSON.stringify(['glm-5.3']), 'null model entries are skipped like deleted ones (no throw)'); const home = tempHome(cli); const r = provisionPersonalProviderConfig({ home, env: {} }); - ok(r.provisioned, 'a null model entry does not abort provisioning'); - const res = modelResolutionCheck({ env: {}, home, config: cli }); - ok(res?.ok === false && /not in the provider's model list/.test(res.detail), - `resolution verdict stays honest for the dropped model (${res?.detail}) — not "not expressible"`); + ok(r.provisioned, 'a null model entry on an unselected model does not abort provisioning'); rmSync(home, { recursive: true, force: true }); + + // The selected model itself nullled: the derivation would drop it, so the + // seed is refused (same permanent-miss class) and the verdict says so. + const dropped = JSON.parse(JSON.stringify(CLI_FIXTURE)); + dropped.provider.zai.models['glm-5.3'] = null; + const dhome = tempHome(dropped); + const dr = provisionPersonalProviderConfig({ home: dhome, env: {} }); + const dres = modelResolutionCheck({ env: {}, home: dhome, config: dropped }); + ok(!dr.provisioned && !existsSync(personalProviderConfigPath({ home: dhome, env: {} })), + `a nullned selected model refuses to seed (${dr.reason})`); + ok(dres?.ok === false && /not in the provider's model list/.test(dres.detail), + `resolution verdict stays honest for the dropped model (${dres?.detail}) — not "not expressible"`); + rmSync(dhome, { recursive: true, force: true }); } // --- a malformed existing file is a verdict, never a crash --- diff --git a/packages/driver/personal-provider.mjs b/packages/driver/personal-provider.mjs index f06c3ab..b60173b 100644 --- a/packages/driver/personal-provider.mjs +++ b/packages/driver/personal-provider.mjs @@ -70,6 +70,16 @@ export class UnsupportedLegacyCliProviderConfigError extends Error { const positiveInt = v => typeof v === 'number' && Number.isInteger(v) && v > 0; +// Wrong-typed fields degrade to absent instead of throwing: hand-edited +// configs carry garbage ("name": {}, "apiKey": 5) and one bad provider must +// not cost the others their migration. The kernel is strict here — its zod +// layer (QAn -> RHa: name m.string(), options.apiKey m.string()) rejects the +// whole config — but it also has a desktop to repair the file; zagent's +// create-if-missing seed has no such repair path, so leniency is the safer +// failure mode for the converter. +const strField = v => (typeof v === 'string' ? v : undefined); +const strRecord = v => (v && typeof v === 'object' && !Array.isArray(v) ? v : {}); + // A credential the provider could actually authenticate with. The kernel's // family entries require one (NHa trims and skips when empty, ~14109800); a // custom provider's key is stored VERBATIM by NHa (xz ApiKeyAccessConfig, @@ -118,8 +128,12 @@ export function importLegacyCliConfig(cli) { const family = LEGACY_BUILTIN_FAMILY[id]; if (family) { // NHa: builtin:zai/builtin:bigmodel become api-key rules on the builtin - // family — and only when they carry a key. - const key = p?.options?.apiKey?.trim(); + // family — and only when they carry a (string) key. A wrong-typed key + // degrades to "no key" for this entry alone; the kernel's zod layer + // (QAn -> RHa, options.apiKey m.string().optional()) instead rejects + // the WHOLE config on a type violation — zagent's converter has no + // schema layer, so one garbage provider must not cost the others. + const key = strField(p?.options?.apiKey)?.trim(); if (key) providers.push({ providerId: family, templateId: family, config: { group: 'standard-personal', access: { apiKey: key } } }); continue; @@ -128,20 +142,23 @@ export function importLegacyCliConfig(cli) { if (p?.options?.apiKeyRequired === false) throw new UnsupportedLegacyCliProviderConfigError(id); const members = modelMembers(p); const ids = members.map(m => m.modelId); - const name = p?.name?.trim(); - const headers = { ...p?.headers, ...p?.options?.headers }; + const name = strField(p?.name)?.trim(); + const headers = { ...strRecord(p?.headers), ...strRecord(p?.options?.headers) }; + const apiKey = strField(p?.options?.apiKey); + const baseURL = strField(p?.options?.baseURL); // NHa stores a custom provider's apiKey verbatim ("" and whitespace // included — see usableKey); the safety net is the provisioner's - // selected-provider gate, not a divergence here. + // selected-provider gate, not a divergence here. Wrong-typed values + // degrade to absent (per-entry, kernel-zod-strict but converter-lenient). providers.push({ providerId: id, ...(name && name !== id ? { providerName: name } : {}), config: { group: 'standard-personal', - access: { type: 'api-key', ...(p?.options?.apiKey !== undefined ? { apiKey: p.options.apiKey } : {}) }, + access: { type: 'api-key', ...(apiKey !== undefined ? { apiKey } : {}) }, api: { type: apiType(p?.kind), - ...(p?.options?.baseURL !== undefined ? { baseUrl: p.options.baseURL } : {}), + ...(baseURL !== undefined ? { baseUrl: baseURL } : {}), ...(Object.keys(headers).length ? { headers } : {}), }, ...(ids.length ? { personalModelIds: ids, modelOrder: ids } : {}), @@ -173,10 +190,127 @@ export function personalProviderConfigDocument({ providers, models, defaultModel }; } +// The selection a cli config asks for (model.main), or null when it names +// none. Selections under builtin:/account: ids resolve through builtin +// entitlements the personal config knows nothing about -> not judgeable. +function selectedModel(cli) { + const main = typeof cli?.model?.main === 'string' && /^[^/]+\/.+$/.test(cli.model.main) + ? cli.model.main : null; + if (!main) return null; + const providerId = main.split('/')[0]; + if (providerId.startsWith('builtin:') || providerId.startsWith('account:')) return null; + return { providerId, modelId: main.slice(main.indexOf('/') + 1) }; +} + +// Judge a selection against personal provider rules: the provider must be +// configured and carry the model in personalModelIds. A family rule +// (templateId set) resolves through the builtin template's model list, which +// the document does not carry — it counts as resolvable. +function selectionVerdict(rules, { providerId, modelId }) { + const rule = rules.find(p => p?.providerId === providerId); + if (!rule) return { ok: false, detail: `provider '${providerId}' is not configured` }; + if (rule.templateId) return { ok: true, detail: `${providerId}/${modelId}` }; + const ids = Array.isArray(rule.config?.personalModelIds) ? rule.config.personalModelIds : []; + if (!ids.includes(modelId)) + return { ok: false, detail: `model ${providerId}/${modelId} is not in the provider's model list` }; + return { ok: true, detail: `${providerId}/${modelId}` }; +} + +const DERIVED_SOURCE = 'cli config (provisioned on first -p run)'; +const FILE_SOURCE = 'personal provider config'; + +/** + * THE predicate both provisioning and doctor use — one answer to "would the + * -p path make this host's selection resolvable, and what would it write". + * Doctor can therefore never report ok for a state the provisioning refuses + * to seed. Refusals (write:false) always pair `reason` (the provisioning + * wording) with the matching `resolves` verdict (the doctor wording) when a + * selection is judgeable at all. + * @returns {{write:boolean, doc:Object|null, reason:string|null, + * source:string, resolves:{ok:boolean, detail:string}|null}} + */ +export function planPersonalProviderConfig({ + env = process.env, home = os.homedir(), + read = p => readFileSync(p, 'utf8'), exists = existsSync, config = null, +} = {}) { + const path = personalProviderConfigPath({ env, home }); + // U7 parity: the legacy source is always the real home's cli config — even + // when ZCODE_DATA_BASE_DIR relocates the target (see legacyCliConfigPath). + // Doctor hands in the config it already parsed; everyone else reads it. + let cli = config; + let cliUnreadable = false; + if (cli == null) { + try { cli = JSON.parse(read(legacyCliConfigPath({ home }))); } + catch { cli = null; cliUnreadable = true; } + } + const sel = selectedModel(cli); + + if (exists(path)) { + // An existing file is authoritative: never overwritten, judged as-is. + let parsed; + try { parsed = JSON.parse(read(path)); } + catch { return { write: false, doc: null, reason: 'personal provider config already present', + source: FILE_SOURCE, resolves: sel ? { ok: false, detail: 'the personal provider config is unreadable' } : null }; } + // The kernel's strict parser (yAe) rejects a structurally wrong file and + // the registry falls back to no personal providers — so a wrong shape is + // a NOT-RESOLVABLE verdict, never a crash in doctor. + const providerRules = parsed?.config?.providerConfigRules?.providerRules; + const malformed = detail => ({ write: false, doc: null, + reason: 'personal provider config already present', source: FILE_SOURCE, + resolves: sel ? { ok: false, detail } : null }); + if (!Array.isArray(providerRules)) return malformed('the personal provider config is malformed (providerRules)'); + for (const r of providerRules) + if (r?.config !== undefined && (typeof r.config !== 'object' || Array.isArray(r.config))) + return malformed('the personal provider config is malformed (provider rule)'); + else if (r?.config?.personalModelIds !== undefined && !Array.isArray(r.config.personalModelIds)) + return malformed('the personal provider config is malformed (personalModelIds)'); + return { write: false, doc: null, reason: 'personal provider config already present', + source: FILE_SOURCE, resolves: sel ? selectionVerdict(providerRules, sel) : null }; + } + + const refuse = (reason, resolves) => ({ write: false, doc: null, reason, source: DERIVED_SOURCE, resolves: sel ? resolves : null }); + if (cli == null) + return refuse('no readable cli config to migrate', { ok: false, detail: 'the cli config is unreadable' }); + let imported = null; + try { imported = importLegacyCliConfig(cli); } + catch (e) { + const detail = e?.name === 'UnsupportedLegacyCliProviderConfigError' + ? `provider '${e.providerId}' is not expressible in the personal config` + : `the cli config is malformed (${e?.message ?? e})`; + return refuse(e?.name === 'UnsupportedLegacyCliProviderConfigError' + ? `cli provider '${e.providerId}' cannot be expressed in the personal config` + : `legacy import failed: ${e?.message ?? e}`, { ok: false, detail }); + } + if (!imported) + return refuse('cli config has no importable providers', + sel ? { ok: false, detail: `provider '${sel.providerId}' is not configured` } : null); + if (!sel) + return { write: true, doc: personalProviderConfigDocument(imported), reason: null, source: DERIVED_SOURCE, resolves: null }; + // Gate 1 — never seed a permanent unusable selection: the file is + // create-if-missing, so an empty-key write could never be repaired + // afterwards (neither the kernel's import — the file exists — nor the OAuth + // backfill — it reads this very file). An OAuth-window config (apiKey:'') + // must abstain, not brick. + if (cli.provider?.[sel.providerId] !== undefined && !usableKey(cli.provider[sel.providerId])) + return refuse(`selected provider '${sel.providerId}' has no usable API key — refusing to seed an empty-key personal config`, + { ok: false, detail: `provider '${sel.providerId}' has no usable API key` }); + // Gate 2 — the document we are about to leave behind forever must resolve + // the selection: a create-if-missing seed whose rules lack the selected + // model turns a wait-for-the-config-to-grow state into a permanent miss + // (nothing rewrites an existing personal file). + const verdict = selectionVerdict(imported.providers, sel); + if (!verdict.ok) + return refuse(`selected model ${sel.providerId}/${sel.modelId} would not resolve in the seeded config — refusing to create a permanent miss (${verdict.detail})`, + verdict); + return { write: true, doc: personalProviderConfigDocument(imported), reason: null, source: DERIVED_SOURCE, resolves: verdict }; +} + /** * Best-effort provisioning of the personal provider config from the legacy * CLI config. Never throws. An existing target file is authoritative (the - * desktop or a prior kernel run owns it — provisioning must never overwrite). + * desktop or a prior kernel run owns it — provisioning must never overwrite); + * every other refusal comes from planPersonalProviderConfig, so what doctor + * reports and what the -p path seeds cannot diverge. * @returns {{provisioned:boolean, path:string, reason:string|null}} */ export function provisionPersonalProviderConfig({ @@ -185,34 +319,13 @@ export function provisionPersonalProviderConfig({ write = atomicWriteFileSync, lock = withFileLockSync, lockOptions = {}, } = {}) { const result = { provisioned: false, path: personalProviderConfigPath({ env, home }), reason: null }; - if (exists(result.path)) { result.reason = 'personal provider config already present'; return result; } - // U7 parity: the legacy source is always the real home's cli config — even - // when ZCODE_DATA_BASE_DIR relocates the target (see legacyCliConfigPath). - let cli; - try { cli = JSON.parse(read(legacyCliConfigPath({ home }))); } - catch { result.reason = 'no readable cli config to migrate'; return result; } - let imported; - try { imported = importLegacyCliConfig(cli); } - catch (e) { - result.reason = e?.name === 'UnsupportedLegacyCliProviderConfigError' - ? `cli provider '${e.providerId}' cannot be expressed in the personal config` : `legacy import failed: ${e?.message ?? e}`; - return result; - } - if (!imported) { result.reason = 'cli config has no importable providers'; return result; } - // Never seed a permanent unusable selection: the file is create-if-missing, - // so an empty-key write could never be repaired afterwards (neither the - // kernel's import — the file exists — nor the OAuth backfill — it reads this - // very file). An OAuth-window config (apiKey:'') must abstain, not brick. - const selected = imported.defaultModelSelection?.providerId; - if (selected !== undefined && cli.provider?.[selected] !== undefined && !usableKey(cli.provider[selected])) { - result.reason = `selected provider '${selected}' has no usable API key — refusing to seed an empty-key personal config`; - return result; - } + const plan = planPersonalProviderConfig({ env, home, read, exists }); + if (!plan.write) { result.reason = plan.reason; return result; } try { return lock(result.path, () => { // Re-check under the lock: a concurrent kernel/GUI writer wins silently. if (exists(result.path)) { result.reason = 'personal provider config already present'; return result; } - write(result.path, JSON.stringify(personalProviderConfigDocument(imported), null, 2)); + write(result.path, JSON.stringify(plan.doc, null, 2)); result.provisioned = true; return result; }, lockOptions); @@ -225,55 +338,14 @@ export function provisionPersonalProviderConfig({ } /** - * Read-only resolution check for doctor: can the app-server registry resolve - * the cli config's model.main? The effective personal source is an existing - * provider_config.json (file wins) or what provisioning would derive from the - * cli config. Provider ids whose resolution depends on builtin entitlements - * or the runtime's bundled catalog cannot be judged statically -> null. + * Read-only resolution check for doctor: can the -p path make the app-server + * registry resolve the cli config's model.main? Thin wrapper over + * planPersonalProviderConfig — the same predicate provisioning obeys — so the + * verdict can never claim ok for a state provisioning refuses to seed. * @returns {{ok:boolean, source:string, detail:string}|null} */ -export function modelResolutionCheck({ env = process.env, home = os.homedir(), - config = null, read = p => readFileSync(p, 'utf8'), exists = existsSync } = {}) { - const main = typeof config?.model?.main === 'string' && /^[^/]+\/.+$/.test(config.model.main) - ? config.model.main : null; - if (!main) return null; - const providerId = main.split('/')[0], modelId = main.slice(main.indexOf('/') + 1); - if (providerId.startsWith('builtin:') || providerId.startsWith('account:')) return null; - const file = personalProviderConfigPath({ env, home }); - let rules = null, source; - if (exists(file)) { - source = 'personal provider config'; - let parsed; - try { parsed = JSON.parse(read(file)); } - catch { return { ok: false, source, detail: 'the personal provider config is unreadable' }; } - // The kernel's strict parser (yAe) rejects a structurally wrong file and - // the registry falls back to no personal providers — so a wrong shape is - // a NOT-RESOLVABLE verdict, never a crash in doctor. - const providerRules = parsed?.config?.providerConfigRules?.providerRules; - if (!Array.isArray(providerRules)) - return { ok: false, source, detail: 'the personal provider config is malformed (providerRules)' }; - for (const r of providerRules) - if (r?.config !== undefined && (typeof r.config !== 'object' || Array.isArray(r.config))) - return { ok: false, source, detail: 'the personal provider config is malformed (provider rule)' }; - else if (r?.config?.personalModelIds !== undefined && !Array.isArray(r.config.personalModelIds)) - return { ok: false, source, detail: 'the personal provider config is malformed (personalModelIds)' }; - rules = { providers: providerRules }; - } else { - source = 'cli config (provisioned on first -p run)'; - let imported = null; - try { imported = importLegacyCliConfig(config); } - catch (e) { - return { ok: false, source, detail: e?.name === 'UnsupportedLegacyCliProviderConfigError' - ? `provider '${e.providerId}' is not expressible in the personal config` - : `the cli config is malformed (${e?.message ?? e})` }; - } - if (imported) rules = imported; - } - if (!rules) return { ok: false, source, detail: `provider '${providerId}' is not configured` }; - const rule = rules.providers.find(p => p?.providerId === providerId); - if (!rule) return { ok: false, source, detail: `provider '${providerId}' is not configured` }; - const ids = Array.isArray(rule.config?.personalModelIds) ? rule.config.personalModelIds : []; - if (!ids.includes(modelId)) - return { ok: false, source, detail: `model ${providerId}/${modelId} is not in the provider's model list` }; - return { ok: true, source, detail: `${providerId}/${modelId}` }; +export function modelResolutionCheck(opts = {}) { + if (opts.config == null) return null; // doctor judges a parsed config; none -> nothing to check + const plan = planPersonalProviderConfig(opts); + return plan.resolves ? { ok: plan.resolves.ok, source: plan.source, detail: plan.resolves.detail } : null; } diff --git a/packages/driver/test-doctor.mjs b/packages/driver/test-doctor.mjs index cf5d6b7..9527f8a 100644 --- a/packages/driver/test-doctor.mjs +++ b/packages/driver/test-doctor.mjs @@ -57,15 +57,18 @@ try { assert.match(r2.stdout, /^mcp: 2 configured$/m); // a keyless config + a fallback key file: that key is only a bootstrap // source (ensureConfig never reads it once config.json exists) so doctor must - // NOT claim it — and a present-but-credential-less config keeps exit 0 with a - // warn line (the exit contract is unchanged: config presence means set up ran). + // NOT claim it — and since the -p path cannot seed a registry for a + // keyless selection either, the model line flags it and the diagnosis is + // unhealthy (exit 1), consistent with what provisioning would refuse. mkdirSync(path.join(home, '.config', 'ccz'), { recursive: true }); writeFileSync(path.join(home, '.config', 'ccz', '.api_key'), 'fallback-key'); writeFileSync(config, JSON.stringify({ model: { main: 'zai/model' }, provider: { zai: { options: {}, models: { model: {} } } } })); const r3 = spawnSync(process.execPath, [new URL('../cli/zagent.mjs', import.meta.url).pathname, 'doctor'], { env: envNoKey, encoding: 'utf8', cwd: home }); - assert.equal(r3.status, 0, r3.stderr); + assert.equal(r3.status, 1, r3.stderr); assert.match(r3.stdout, /^credential: NONE$/m, 'doctor reports no credential honestly'); assert.match(r3.stdout, /^warn: no Coding Plan credential/m, 'a credential-less config is warned, not silent'); + assert.match(r3.stdout, /^model: provider 'zai' has no usable API key — NOT RESOLVABLE$/m, + 'the keyless selection is reported unresolvable, matching the provisioning refusal'); // with no config file at all the fallback file IS the bootstrap source rmSync(config); const r4 = spawnSync(process.execPath, [new URL('../cli/zagent.mjs', import.meta.url).pathname, 'doctor'], { env: envNoKey, encoding: 'utf8', cwd: home });