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..4d914cb --- /dev/null +++ b/packages/cli/test-personal-provider.mjs @@ -0,0 +1,477 @@ +// 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 } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { + importLegacyCliConfig, personalProviderConfigDocument, personalProviderConfigPath, legacyCliConfigPath, + provisionPersonalProviderConfig, planPersonalProviderConfig, 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 }); +} + +// --- 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, 5, {}]) { + 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 }); +} + +// --- 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 +// (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 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.name = {}; // wrong-typed name on the SELECTED provider + const conv = importLegacyCliConfig(cli); + 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 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 --- +{ + 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/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..b60173b --- /dev/null +++ b/packages/driver/personal-provider.mjs @@ -0,0 +1,351 @@ +// 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 (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; + const dataBaseDir = env.ZCODE_DATA_BASE_DIR?.trim() || home; + 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', + 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; + +// 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, +// 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 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 || !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 + : 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 (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; + } + 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 = 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. 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', ...(apiKey !== undefined ? { apiKey } : {}) }, + api: { + type: apiType(p?.kind), + ...(baseURL !== undefined ? { baseUrl: 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 } : {}), + }, + }; +} + +// 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); + * 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({ + 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 }; + 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(plan.doc, 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 -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(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 287b66c..9527f8a 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' } } }, })); @@ -57,20 +57,23 @@ 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: {} } } })); + 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 }); 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 = {