diff --git a/.changeset/tolerate-invalid-config.md b/.changeset/tolerate-invalid-config.md new file mode 100644 index 000000000..0cae217e9 --- /dev/null +++ b/.changeset/tolerate-invalid-config.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/kimi-code": patch +--- + +Drop invalid config.toml sections with a warning instead of failing to start. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index d5253531e..f7cef067d 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -112,6 +112,9 @@ export async function runPrompt( try { await harness.ensureConfigFile(); const config = await harness.getConfig(); + for (const warning of (await harness.getConfigDiagnostics()).warnings) { + stderr.write(`Warning: ${warning}\n`); + } const { session, resumed, restorePermission, telemetryModel, goalModel } = await resolvePromptSession( harness, diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index b3443ee99..e5bdfef24 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -23,6 +23,7 @@ import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; import { KimiTUI } from '#/tui/index'; import { currentTheme, getColorPalette } from '#/tui/theme'; +import { combineStartupNotice } from '#/tui/utils/startup'; import type { CLIOptions } from './options'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; @@ -91,6 +92,9 @@ export async function runShell( return; } const config = await harness.getConfig(); + for (const warning of (await harness.getConfigDiagnostics()).warnings) { + configWarning = combineStartupNotice(configWarning, warning); + } const configMs = Date.now() - configStartedAt; const tui = new KimiTUI(harness, { cliOptions: opts, diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index 38cb75521..b712891b2 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -410,13 +410,26 @@ export function registerProviderCommand(parent: Command, deps?: Partial Promise): Promise => { + try { + await run(); + } catch (error) { + resolved.stderr.write(`${errorMessage(error)}\n`); + resolved.exit(1); + } + }; + provider .command('add ') .description('Import every provider listed in a custom registry (api.json).') .option('--api-key ', 'Registry API key. Falls back to KIMI_REGISTRY_API_KEY.') .action(async (url: string, options: { apiKey?: string }) => { const resolved = resolveDeps(deps); - await handleProviderAdd(resolved, url, { apiKey: options.apiKey }); + await runAction(resolved, () => handleProviderAdd(resolved, url, { apiKey: options.apiKey })); }); provider @@ -424,7 +437,7 @@ export function registerProviderCommand(parent: Command, deps?: Partial { const resolved = resolveDeps(deps); - await handleProviderRemove(resolved, providerId); + await runAction(resolved, () => handleProviderRemove(resolved, providerId)); }); provider @@ -433,7 +446,7 @@ export function registerProviderCommand(parent: Command, deps?: Partial { const resolved = resolveDeps(deps); - await handleProviderList(resolved, { json: options.json === true }); + await runAction(resolved, () => handleProviderList(resolved, { json: options.json === true })); }); const catalog = provider @@ -452,11 +465,13 @@ export function registerProviderCommand(parent: Command, deps?: Partial { const resolved = resolveDeps(deps); - await handleCatalogList(resolved, providerId, { - json: options.json === true, - ...(options.filter === undefined ? {} : { filter: options.filter }), - ...(options.url === undefined ? {} : { url: options.url }), - }); + await runAction(resolved, () => + handleCatalogList(resolved, providerId, { + json: options.json === true, + ...(options.filter === undefined ? {} : { filter: options.filter }), + ...(options.url === undefined ? {} : { url: options.url }), + }), + ); }, ); @@ -472,11 +487,13 @@ export function registerProviderCommand(parent: Command, deps?: Partial { const resolved = resolveDeps(deps); - await handleCatalogAdd(resolved, providerId, { - ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), - ...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }), - ...(options.url === undefined ? {} : { url: options.url }), - }); + await runAction(resolved, () => + handleCatalogAdd(resolved, providerId, { + ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), + ...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }), + ...(options.url === undefined ? {} : { url: options.url }), + }), + ); }, ); } diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 55f9817fa..242252bfb 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -50,10 +50,14 @@ function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOpt providers: host.state.appState.availableProviders, activeProviderId, onAdd: () => { - void handleProviderAdd(host); + void handleProviderAdd(host).catch((error: unknown) => { + host.showError(`Add provider failed: ${formatErrorMessage(error)}`); + }); }, onDeleteSource: (providerIds) => { - void handleProviderManagerDeleteSource(host, providerIds); + void handleProviderManagerDeleteSource(host, providerIds).catch((error: unknown) => { + host.showError(`Remove provider failed: ${formatErrorMessage(error)}`); + }); }, onClose: () => { host.restoreEditor(); @@ -233,7 +237,9 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { initialTabId: providerId, onSelect: ({ alias, thinking }) => { host.restoreEditor(); - void setDefaultModel(host, alias, thinking); + void setDefaultModel(host, alias, thinking).catch((error: unknown) => { + host.showError(`Set default model failed: ${formatErrorMessage(error)}`); + }); }, onCancel: () => { host.restoreEditor(); @@ -269,8 +275,8 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise let entries: Awaited>; try { entries = await fetchCustomRegistry(source); - } catch (err) { - host.showError(`Failed to import registry: ${formatErrorMessage(err)}`); + } catch (error) { + host.showError(`Failed to import registry: ${formatErrorMessage(error)}`); return false; } @@ -287,8 +293,8 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise models: config.models, }); await host.authFlow.refreshConfigAfterLogin(); - } catch (err) { - host.showError(`Failed to apply registry: ${formatErrorMessage(err)}`); + } catch (error) { + host.showError(`Failed to apply registry: ${formatErrorMessage(error)}`); return false; } @@ -321,7 +327,9 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise initialTabId: firstNewProvider, onSelect: ({ alias, thinking }) => { host.restoreEditor(); - void setDefaultModel(host, alias, thinking); + void setDefaultModel(host, alias, thinking).catch((error: unknown) => { + host.showError(`Set default model failed: ${formatErrorMessage(error)}`); + }); }, onCancel: () => { host.restoreEditor(); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 759a92667..569e68070 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1312,6 +1312,19 @@ export class KimiTUI { this.sessionEventHandler.startSubscription(); this.clearTranscriptAndRedraw(); this.showStatus(`Started a new session (${session.id}).`); + void this.showConfigWarningsIfAny(); + } + + /** Surface config.toml load warnings (degraded or kept-previous config) in the status bar. */ + private async showConfigWarningsIfAny(): Promise { + try { + const { warnings } = await this.harness.getConfigDiagnostics(); + for (const warning of warnings) { + this.showStatus(warning, 'warning'); + } + } catch { + /* diagnostics are best-effort */ + } } // ========================================================================= diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts index e8a9955d7..04780bd26 100644 --- a/apps/kimi-code/test/cli/goal-prompt.test.ts +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -116,6 +116,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { auth: { getCachedAccessToken: vi.fn() }, ensureConfigFile: vi.fn(), getConfig: vi.fn(async () => ({ providers: {}, defaultModel: 'k2', telemetry: true })), + getConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })), getExperimentalFeatures: vi.fn(async () => mocks.experimentalFeatures), createSession: vi.fn(async () => mocks.session), resumeSession: vi.fn(async () => mocks.session), diff --git a/apps/kimi-code/test/cli/provider.test.ts b/apps/kimi-code/test/cli/provider.test.ts index d1f6a05e6..56768a78c 100644 --- a/apps/kimi-code/test/cli/provider.test.ts +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -546,6 +546,30 @@ describe('registerProviderCommand', () => { expect(Object.keys(current().providers).toSorted()).toEqual(['kohub', 'kohub-responses']); expect(stdout.join('')).toContain('Imported 2 providers'); }); + + it('reports write failures on stderr and exits 1 instead of crashing', async () => { + const { harness } = makeHarness({ + providers: { kimi: { type: 'kimi' } }, + } as unknown as KimiConfig); + // Simulate the strict write path rejecting because config.toml is invalid. + harness.removeProvider = async () => { + throw new Error( + 'Cannot change settings while config.toml is invalid — fix it first (run `kimi doctor` for details).', + ); + }; + const { deps, stderr, exitCodes } = makeDeps(harness); + + const program = new Command('kimi'); + registerProviderCommand(program, deps); + + await tryRun(() => + program.parseAsync(['node', 'kimi', 'provider', 'remove', 'kimi'], { from: 'node' }), + ); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('Cannot change settings'); + expect(stderr.join('')).not.toContain(' at '); // no stack trace dump + }); }); describe('kimi provider catalog list', () => { diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 9913dd926..a3620aa35 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -54,6 +54,7 @@ const mocks = vi.hoisted(() => { telemetry: true, }), ), + harnessGetConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })), harnessGetExperimentalFeatures: vi.fn(async () => []), harnessCreateSession: vi.fn(async () => session), harnessResumeSession: vi.fn(async () => session), @@ -91,6 +92,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { auth: { getCachedAccessToken: mocks.harnessGetCachedAccessToken }, ensureConfigFile: mocks.harnessEnsureConfigFile, getConfig: mocks.harnessGetConfig, + getConfigDiagnostics: mocks.harnessGetConfigDiagnostics, getExperimentalFeatures: mocks.harnessGetExperimentalFeatures, createSession: mocks.harnessCreateSession, resumeSession: mocks.harnessResumeSession, diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index b61641dd0..bab4fb152 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -37,6 +37,7 @@ const mocks = vi.hoisted(() => { defaultModel: 'k2', telemetry: true, })), + harnessGetConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })), harnessGetCachedAccessToken: vi.fn(), harnessClose: vi.fn(), detectPendingMigration: vi.fn<() => Promise>(async () => null), @@ -82,6 +83,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { }, ensureConfigFile: mocks.harnessEnsureConfigFile, getConfig: mocks.harnessGetConfig, + getConfigDiagnostics: mocks.harnessGetConfigDiagnostics, close: mocks.harnessClose, track: mocks.harnessTrack, }; @@ -483,6 +485,38 @@ describe('runShell', () => { }); }); + it('forwards config.toml diagnostics as startup notices', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.harnessGetConfigDiagnostics.mockResolvedValue({ + warnings: ['Ignored invalid config in config.toml: loop_control.'], + }); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: '', + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + + const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; + expect(startupInput).toMatchObject({ + startupNotice: 'Ignored invalid config in config.toml: loop_control.', + }); + }); + it('closes the harness when TUI startup fails', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 56452e41a..172e97cfc 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -23,7 +23,7 @@ import { validateConfig, } from '#/config/schema'; import { atomicWrite } from '#/utils/fs'; -import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; +import { parse as parseToml, stringify as stringifyToml, TomlError } from 'smol-toml'; /* ------------------------------------------------------------------ */ /* Key helpers – reuse generic snake / camel conversion instead of */ @@ -70,6 +70,27 @@ export function readConfigFile(filePath: string): KimiConfig { return parseConfigString(text, filePath); } +/** + * Strict read for write paths (read-merge-write must never use a salvaged + * config as its base, or the rewrite would drop the user's broken-but-fixable + * sections). Re-throws validation failures with a short actionable message — + * UIs surface it directly — instead of the raw validation details. + */ +export function readConfigFileForUpdate(filePath: string): KimiConfig { + try { + return readConfigFile(filePath); + } catch (error) { + if (error instanceof KimiError && error.code === ErrorCodes.CONFIG_INVALID) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Cannot change settings while ${filePath} is invalid — fix it first (run \`kimi doctor\` for details).`, + { cause: error }, + ); + } + throw error; + } +} + /** * Load the config for runtime consumption: the on-disk config plus any model * synthesized from `KIMI_MODEL_*` environment variables. Use this everywhere a @@ -83,6 +104,164 @@ export function loadRuntimeConfig( return applyEnvModelConfig(readConfigFile(filePath), env); } +export interface RuntimeConfigLoadResult { + readonly config: KimiConfig; + /** Problems in config.toml itself; non-empty means parts (or all) of the file were ignored. */ + readonly fileWarnings: readonly string[]; + /** Problems applying KIMI_MODEL_* env overrides; the overlay was skipped. */ + readonly envWarnings: readonly string[]; + /** + * Set when the file is entirely unusable (unreadable, TOML syntax error, or + * nothing salvageable) and `config` is pure defaults. Startup fails fast on + * this — defaults-only means the user looks logged out, which is worse than + * an actionable parse error. Mid-run reloads ignore it and keep the last + * good config instead. + */ + readonly fileError?: KimiError; +} + +/** + * Lenient variant of `loadRuntimeConfig` that never throws: schema errors + * drop only the offending sections (whole entry for `providers`/`models`, + * whole top-level section otherwise) and a bad KIMI_MODEL_* env overlay is + * skipped, each reported as a warning. A file that cannot be used at all + * additionally sets `fileError` so startup can fail fast while mid-run + * reloads degrade. Runtime read paths use this; write paths must keep using + * the strict readers so a broken file is never silently rewritten. + */ +export function loadRuntimeConfigSafe( + filePath: string, + env: Readonly> = process.env, +): RuntimeConfigLoadResult { + const fileWarnings: string[] = []; + let fileError: KimiError | undefined; + let config = getDefaultConfig(); + + let text: string | undefined; + try { + text = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : undefined; + } catch (error) { + fileError = new KimiError( + ErrorCodes.CONFIG_INVALID, + `Failed to read ${filePath}: ${describeUnknownError(error)}`, + { cause: error }, + ); + fileWarnings.push(`Failed to read ${filePath}: ${describeUnknownError(error)}.`); + } + + if (text !== undefined && text.trim().length > 0) { + let data: Record | undefined; + try { + data = parseToml(text) as Record; + } catch (error) { + // Same message as the strict parser, code frame included, so failing + // startup points straight at the offending line. + fileError = new KimiError( + ErrorCodes.CONFIG_INVALID, + `Invalid TOML in ${filePath}: ${describeUnknownError(error)}`, + { cause: error }, + ); + fileWarnings.push(`Invalid TOML in ${filePath}: ${describeTomlSyntaxError(error)}.`); + } + if (data !== undefined) { + const raw = cloneRecord(data); + const transformed = transformTomlData(data); + transformed['raw'] = raw; + const salvaged = salvageConfigData(transformed); + if (salvaged.config === undefined) { + fileError = new KimiError( + ErrorCodes.CONFIG_INVALID, + `Invalid configuration in ${filePath}: ${formatConfigValidationError(salvaged.error)}`, + { cause: salvaged.error }, + ); + fileWarnings.push( + `Invalid configuration in ${filePath}: ${formatConfigValidationError(salvaged.error)}.`, + ); + } else { + config = salvaged.config; + if (salvaged.dropped.length > 0) { + fileWarnings.push( + `Ignored invalid config in ${filePath}: ${salvaged.dropped.join(', ')}. Run \`kimi doctor\` for details.`, + ); + } + } + } + } + + const envWarnings: string[] = []; + try { + config = applyEnvModelConfig(config, env); + } catch (error) { + envWarnings.push( + `Ignoring KIMI_MODEL_* environment overrides: ${describeUnknownError(error)}`, + ); + } + + return { config, fileWarnings, envWarnings, fileError }; +} + +/** Sections keyed by user-chosen names where single entries can be dropped. */ +const ENTRY_KEYED_SECTIONS = new Set(['providers', 'models']); + +interface SalvageResult { + readonly config: KimiConfig | undefined; + readonly dropped: readonly string[]; + readonly error?: unknown; +} + +function salvageConfigData(transformed: Record): SalvageResult { + const dropped: string[] = []; + for (;;) { + const result = KimiConfigSchema.safeParse(transformed); + if (result.success) { + return { config: result.data, dropped }; + } + let deletedAny = false; + for (const issue of result.error.issues) { + const [section, entry] = issue.path; + if (typeof section !== 'string' || !(section in transformed)) continue; + const sectionValue = transformed[section]; + if ( + ENTRY_KEYED_SECTIONS.has(section) && + typeof entry === 'string' && + isPlainObject(sectionValue) + ) { + // Issues on entry-keyed sections only ever drop that entry. An entry + // with several issues is deleted by the first one; later issues are + // no-ops and must not escalate to deleting the whole section. + if (entry in sectionValue) { + delete sectionValue[entry]; + dropped.push(`${camelToSnake(section)}.${entry}`); + deletedAny = true; + } + continue; + } + delete transformed[section]; + dropped.push(camelToSnake(section)); + deletedAny = true; + } + if (!deletedAny) { + return { config: undefined, dropped, error: result.error }; + } + } +} + +function describeUnknownError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * One-line summary of a smol-toml parse error: first message line plus the + * line/column location, without the multi-line code-frame block. + */ +function describeTomlSyntaxError(error: unknown): string { + const firstLine = describeUnknownError(error).split('\n', 1)[0] ?? ''; + if (error instanceof TomlError) { + return `${firstLine} (line ${error.line}, column ${error.column})`; + } + return firstLine; +} + export function parseConfigString(tomlText: string, filePath = 'config.toml'): KimiConfig { if (tomlText.trim().length === 0) { return getDefaultConfig(); diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index af2e00a4d..b080802ee 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -294,6 +294,11 @@ export interface GetKimiConfigPayload { readonly reload?: boolean; } +export interface ConfigDiagnostics { + /** Warnings from the most recent config.toml load attempt; empty when the config is fully valid. */ + readonly warnings: readonly string[]; +} + export type SetKimiConfigPayload = KimiConfigPatch; export interface RemoveKimiProviderPayload { @@ -358,6 +363,7 @@ export interface CoreAPI extends SessionAPIWithId { getCoreInfo: (payload: EmptyPayload) => CoreInfo; getExperimentalFeatures: (payload: EmptyPayload) => readonly ExperimentalFeatureState[]; getKimiConfig: (payload: GetKimiConfigPayload) => KimiConfig; + getConfigDiagnostics: (payload: EmptyPayload) => ConfigDiagnostics; setKimiConfig: (payload: SetKimiConfigPayload) => KimiConfig; removeKimiProvider: (payload: RemoveKimiProviderPayload) => KimiConfig; createSession: (payload: CreateSessionPayload) => SessionSummary; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index c8edc631d..204715da6 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -13,9 +13,9 @@ import { resolveThinkingLevel } from '../agent/config/thinking'; import { Agent } from '../agent'; import { ensureKimiHome, - loadRuntimeConfig, + loadRuntimeConfigSafe, mergeConfigPatch, - readConfigFile, + readConfigFileForUpdate, resolveConfigPath, resolveKimiHome, writeConfigFile, @@ -46,6 +46,7 @@ import type { CancelPayload, CancelPlanPayload, CloseSessionPayload, + ConfigDiagnostics, CoreAPI, CoreInfo, CreateGoalPayload, @@ -129,6 +130,7 @@ export class KimiCore implements PromisableMethods { private kaos: Promise | undefined; private runtime: ToolServices | undefined; private config: KimiConfig; + private configWarnings: readonly string[] = []; private readonly runtimeOverride: ToolServices | undefined; private readonly userHomeDir: string; private readonly kimiRequestHeaders: Record | undefined; @@ -159,7 +161,19 @@ export class KimiCore implements PromisableMethods { this.telemetry = options.telemetry ?? noopTelemetryClient; this.appVersion = options.appVersion; ensureKimiHome(this.homeDir); - this.config = loadRuntimeConfig(this.configPath); + // Schema errors degrade (invalid sections are dropped with warnings) so a + // typo cannot prevent startup, but a file that cannot be used at all — + // TOML syntax error, unreadable — fails fast: defaults-only would start + // the app looking logged out, which is worse than the parse error. + const loaded = loadRuntimeConfigSafe(this.configPath); + if (loaded.fileError !== undefined) { + throw loaded.fileError; + } + this.config = loaded.config; + this.configWarnings = [...loaded.fileWarnings, ...loaded.envWarnings]; + if (this.configWarnings.length > 0) { + log.warn('config load degraded', { warnings: this.configWarnings }); + } this.experimentalFlags = new FlagResolver( process.env, FLAG_DEFINITIONS, @@ -447,19 +461,23 @@ export class KimiCore implements PromisableMethods { async getKimiConfig(input?: GetKimiConfigPayload): Promise { if (input?.reload) { - this.setRuntimeConfig(loadRuntimeConfig(this.configPath)); + this.reloadRuntimeConfig(); } return this.config; } + async getConfigDiagnostics(_input?: EmptyPayload): Promise { + return { warnings: this.configWarnings }; + } + async setKimiConfig(input: SetKimiConfigPayload): Promise { - const config = mergeConfigPatch(readConfigFile(this.configPath), input); + const config = mergeConfigPatch(this.readConfigForWrite(), input); await writeConfigFile(this.configPath, config); - return this.setRuntimeConfig(loadRuntimeConfig(this.configPath)); + return this.reloadRuntimeConfig(); } async removeKimiProvider(input: RemoveKimiProviderPayload): Promise { - const config = readConfigFile(this.configPath); + const config = this.readConfigForWrite(); delete config.providers[input.providerId]; let removedDefault = false; @@ -486,7 +504,7 @@ export class KimiCore implements PromisableMethods { } await writeConfigFile(this.configPath, config); - return this.setRuntimeConfig(loadRuntimeConfig(this.configPath)); + return this.reloadRuntimeConfig(); } prompt({ sessionId, ...payload }: SessionAgentPayload) { @@ -860,7 +878,30 @@ export class KimiCore implements PromisableMethods { } private reloadProviderManager(): KimiConfig { - return this.setRuntimeConfig(loadRuntimeConfig(this.configPath)); + return this.reloadRuntimeConfig(); + } + + private readConfigForWrite(): KimiConfig { + return readConfigFileForUpdate(this.configPath); + } + + private reloadRuntimeConfig(): KimiConfig { + const loaded = loadRuntimeConfigSafe(this.configPath); + if (loaded.fileWarnings.length > 0) { + // Keep the last good config: adopting a salvaged config mid-run could + // silently drop providers or models a live session depends on. + this.configWarnings = [ + ...loaded.fileWarnings, + ...loaded.envWarnings, + 'config.toml has errors; keeping the previously loaded configuration.', + ]; + log.warn('config reload degraded; keeping previous config', { + warnings: loaded.fileWarnings, + }); + return this.config; + } + this.configWarnings = loaded.envWarnings; + return this.setRuntimeConfig(loaded.config); } private setRuntimeConfig(config: KimiConfig): KimiConfig { diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 3eb85fe98..091eee384 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -9,10 +9,13 @@ import { ErrorCodes, KimiError } from '../../src/errors'; import { KimiConfigSchema, ensureConfigFile, + loadRuntimeConfig, + loadRuntimeConfigSafe, mergeConfigPatch, parseConfigString, parseBooleanEnv, readConfigFile, + readConfigFileForUpdate, resolveConfigPath, resolveConfigValue, resolveKimiHome, @@ -661,3 +664,209 @@ describe('config value env override helpers', () => { ).toBe(false); }); }); + +describe('loadRuntimeConfigSafe', () => { + const VALID_TOML = ` +default_model = "k2" + +[providers.kimi] +type = "kimi" +api_key = "sk-good" + +[models.k2] +provider = "kimi" +model = "kimi-for-coding" +max_context_size = 128000 +`; + + async function writeTempConfig(text: string): Promise { + const configPath = join(makeTempDir(), 'config.toml'); + await writeFile(configPath, text, 'utf-8'); + return configPath; + } + + it('loads a valid file with no warnings, matching the strict loader', async () => { + const configPath = await writeTempConfig(VALID_TOML); + const result = loadRuntimeConfigSafe(configPath, {}); + expect(result.fileWarnings).toEqual([]); + expect(result.envWarnings).toEqual([]); + expect(result.config).toEqual(loadRuntimeConfig(configPath, {})); + }); + + it('returns defaults with no warnings when the file is missing', () => { + const configPath = join(makeTempDir(), 'config.toml'); + const result = loadRuntimeConfigSafe(configPath, {}); + expect(result.fileWarnings).toEqual([]); + expect(result.envWarnings).toEqual([]); + expect(result.config.providers).toEqual({}); + }); + + it('reports a fileError and defaults on invalid TOML syntax', async () => { + const configPath = await writeTempConfig('[[['); + const result = loadRuntimeConfigSafe(configPath, {}); + expect(result.config.providers).toEqual({}); + // The whole file is unusable: callers decide to fail startup (fileError) + // or keep the last good config mid-run (fileWarnings). + expect(result.fileError).toBeInstanceOf(KimiError); + expect(result.fileError?.code).toBe(ErrorCodes.CONFIG_INVALID); + expect(result.fileError?.message).toContain('Invalid TOML'); + expect(result.fileError?.message).toContain(configPath); + expect(result.fileWarnings).toHaveLength(1); + const warning = result.fileWarnings[0]!; + expect(warning).toContain('Invalid TOML'); + // Single-line summary with the error location, not the multi-line code frame. + expect(warning).not.toContain('\n'); + expect(warning).toContain('line 1'); + }); + + it('does not set fileError when only sections are dropped', async () => { + const configPath = await writeTempConfig(`${VALID_TOML} +[loop_control] +max_steps_per_turn = "nope" +`); + const result = loadRuntimeConfigSafe(configPath, {}); + expect(result.fileError).toBeUndefined(); + expect(result.fileWarnings).toHaveLength(1); + }); + + it('drops only an invalid section on schema errors and keeps the rest', async () => { + const configPath = await writeTempConfig(`${VALID_TOML} +[loop_control] +max_steps_per_turn = "not-a-number" +`); + const result = loadRuntimeConfigSafe(configPath, {}); + expect(result.config.loopControl).toBeUndefined(); + expect(result.config.providers['kimi']).toMatchObject({ type: 'kimi', apiKey: 'sk-good' }); + expect(result.config.models?.['k2']).toMatchObject({ maxContextSize: 128000 }); + expect(result.config.defaultModel).toBe('k2'); + expect(result.fileWarnings).toHaveLength(1); + expect(result.fileWarnings[0]).toContain('loop_control'); + // The original file content stays visible in raw so nothing is lost. + expect(result.config.raw?.['loop_control']).toEqual({ max_steps_per_turn: 'not-a-number' }); + }); + + it('drops only the broken provider entry, keeping other providers', async () => { + const configPath = await writeTempConfig(`${VALID_TOML} +[providers.bad] +type = "not-a-provider" +`); + const result = loadRuntimeConfigSafe(configPath, {}); + expect(result.config.providers['bad']).toBeUndefined(); + expect(result.config.providers['kimi']).toMatchObject({ type: 'kimi' }); + expect(result.fileWarnings).toHaveLength(1); + expect(result.fileWarnings[0]).toContain('providers.bad'); + }); + + it('keeps other providers when one entry has multiple validation issues', async () => { + // Two issues on the same entry: the second must not escalate to + // deleting the whole providers section after the first dropped the entry. + const configPath = await writeTempConfig(`${VALID_TOML} +[providers.bad] +type = "not-a-provider" +api_key = 123 +`); + const result = loadRuntimeConfigSafe(configPath, {}); + expect(result.config.providers['bad']).toBeUndefined(); + expect(result.config.providers['kimi']).toMatchObject({ type: 'kimi' }); + expect(result.fileWarnings).toHaveLength(1); + expect(result.fileWarnings[0]).toContain('providers.bad'); + expect(result.fileWarnings[0]).not.toMatch(/providers[,.]? /); + }); + + it('drops only the broken model entry', async () => { + const configPath = await writeTempConfig(`${VALID_TOML} +[models.broken] +provider = "kimi" +model = "x" +max_context_size = -5 +`); + const result = loadRuntimeConfigSafe(configPath, {}); + expect(result.config.models?.['broken']).toBeUndefined(); + expect(result.config.models?.['k2']).toBeDefined(); + expect(result.fileWarnings[0]).toContain('models.broken'); + }); + + it('drops the whole hooks list when one hook is invalid', async () => { + const configPath = await writeTempConfig(`${VALID_TOML} +[[hooks]] +event = "NotARealEvent" +command = "echo hi" +`); + const result = loadRuntimeConfigSafe(configPath, {}); + expect(result.config.hooks).toBeUndefined(); + expect(result.config.providers['kimi']).toBeDefined(); + expect(result.fileWarnings[0]).toContain('hooks'); + }); + + it('reports every dropped section in the warning', async () => { + const configPath = await writeTempConfig(`${VALID_TOML} +[loop_control] +max_steps_per_turn = "nope" + +[background] +max_running_tasks = 0 +`); + const result = loadRuntimeConfigSafe(configPath, {}); + expect(result.config.loopControl).toBeUndefined(); + expect(result.config.background).toBeUndefined(); + expect(result.fileWarnings).toHaveLength(1); + expect(result.fileWarnings[0]).toContain('loop_control'); + expect(result.fileWarnings[0]).toContain('background'); + }); + + it('applies KIMI_MODEL_* env overrides on top of a salvaged config', async () => { + const configPath = await writeTempConfig(`${VALID_TOML} +[loop_control] +max_steps_per_turn = "nope" +`); + const result = loadRuntimeConfigSafe(configPath, { + KIMI_MODEL_NAME: 'env-model', + KIMI_MODEL_API_KEY: 'sk-env', + KIMI_MODEL_MAX_CONTEXT_SIZE: '262144', + }); + expect(result.envWarnings).toEqual([]); + expect(result.config.models?.['__kimi_env_model__']).toBeDefined(); + expect(result.config.providers['kimi']).toBeDefined(); + expect(result.fileWarnings).toHaveLength(1); + }); + + it('skips KIMI_MODEL_* overrides with an env warning instead of throwing', async () => { + const configPath = await writeTempConfig(VALID_TOML); + const result = loadRuntimeConfigSafe(configPath, { + KIMI_MODEL_NAME: 'env-model', + }); + expect(result.fileWarnings).toEqual([]); + expect(result.envWarnings).toHaveLength(1); + expect(result.envWarnings[0]).toContain('KIMI_MODEL'); + expect(result.config).toEqual(readConfigFile(configPath)); + }); + + it('readConfigFileForUpdate rewraps validation errors with an actionable message', async () => { + const configPath = await writeTempConfig(`${VALID_TOML} +[loop_control] +max_steps_per_turn = "nope" +`); + try { + readConfigFileForUpdate(configPath); + throw new Error('expected readConfigFileForUpdate to throw'); + } catch (error) { + expect(error).toBeInstanceOf(KimiError); + expect((error as KimiError).message).toContain('fix it first'); + expect((error as KimiError).message).toContain('kimi doctor'); + expect((error as KimiError).message).not.toContain('invalid_type'); + } + + const goodPath = await writeTempConfig(VALID_TOML); + expect(readConfigFileForUpdate(goodPath)).toEqual(readConfigFile(goodPath)); + }); + + it('drops invalid top-level scalars and keeps the rest', async () => { + const configPath = await writeTempConfig(`default_thinking = "not-a-boolean" +${VALID_TOML}`); + const result = loadRuntimeConfigSafe(configPath, {}); + expect(result.config.defaultThinking).toBeUndefined(); + expect(result.config.providers['kimi']).toBeDefined(); + expect(result.fileWarnings).toHaveLength(1); + expect(result.fileWarnings[0]).toContain('default_thinking'); + }); +}); diff --git a/packages/agent-core/test/rpc/config-rpc.test.ts b/packages/agent-core/test/rpc/config-rpc.test.ts new file mode 100644 index 000000000..06ceb42a6 --- /dev/null +++ b/packages/agent-core/test/rpc/config-rpc.test.ts @@ -0,0 +1,110 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { KimiCore } from '../../src/rpc/core-impl'; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function makeHome(configToml?: string): Promise { + const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-')); + tempDirs.push(home); + if (configToml !== undefined) { + await writeFile(path.join(home, 'config.toml'), configToml, 'utf-8'); + } + return home; +} + +function makeCore(home: string): KimiCore { + return new KimiCore(async () => ({}) as never, { homeDir: home }); +} + +const VALID_TOML = ` +default_model = "k2" + +[providers.kimi] +type = "kimi" +api_key = "sk-good" + +[models.k2] +provider = "kimi" +model = "kimi-for-coding" +max_context_size = 128000 +`; + +describe('KimiCore degraded config loading', () => { + it('reports no diagnostics for a valid config', async () => { + const core = makeCore(await makeHome(VALID_TOML)); + const config = await core.getKimiConfig({}); + expect(config.providers['kimi']).toBeDefined(); + await expect(core.getConfigDiagnostics({})).resolves.toEqual({ warnings: [] }); + }); + + it('refuses to start when the TOML cannot be parsed at all', async () => { + const home = await makeHome('[[['); + // A fully unusable file means defaults-only (looks logged out), which is + // worse than failing fast with the parse location. + expect(() => makeCore(home)).toThrow(/Invalid TOML/); + }); + + it('starts with a partially invalid config, keeping the valid sections', async () => { + const core = makeCore( + await makeHome(`${VALID_TOML} +[loop_control] +max_steps_per_turn = "nope" +`), + ); + const config = await core.getKimiConfig({}); + expect(config.providers['kimi']).toBeDefined(); + expect(config.loopControl).toBeUndefined(); + const diagnostics = await core.getConfigDiagnostics({}); + expect(diagnostics.warnings).toHaveLength(1); + expect(diagnostics.warnings[0]).toContain('loop_control'); + }); + + it('rejects config writes with an actionable error while the file is invalid', async () => { + const home = await makeHome(`${VALID_TOML} +[loop_control] +max_steps_per_turn = "nope" +`); + const core = makeCore(home); + const before = await readFile(path.join(home, 'config.toml'), 'utf-8'); + + // Write paths stay strict: changing settings on top of a broken file + // must fail with a short, actionable message — not raw validation JSON — + // and must leave the file untouched. + const write = core.setKimiConfig({ defaultThinking: true }); + await expect(write).rejects.toThrow(/fix it first/i); + await expect(write).rejects.toThrow(/kimi doctor/); + await expect(write).rejects.not.toThrow(/invalid_type/); + + const after = await readFile(path.join(home, 'config.toml'), 'utf-8'); + expect(after).toBe(before); + }); + + it('keeps the last good config when the file breaks mid-run', async () => { + const home = await makeHome(VALID_TOML); + const core = makeCore(home); + const configPath = path.join(home, 'config.toml'); + + await writeFile(configPath, '[[[', 'utf-8'); + const kept = await core.getKimiConfig({ reload: true }); + expect(kept.providers['kimi']).toBeDefined(); + const degraded = await core.getConfigDiagnostics({}); + expect(degraded.warnings.some((w) => w.includes('Invalid TOML'))).toBe(true); + expect(degraded.warnings.some((w) => w.includes('previous'))).toBe(true); + + await writeFile(configPath, `default_thinking = true\n${VALID_TOML}`, 'utf-8'); + const adopted = await core.getKimiConfig({ reload: true }); + expect(adopted.defaultThinking).toBe(true); + await expect(core.getConfigDiagnostics({})).resolves.toEqual({ warnings: [] }); + }); +}); diff --git a/packages/node-sdk/src/auth.ts b/packages/node-sdk/src/auth.ts index 5443464a2..28d13ed3c 100644 --- a/packages/node-sdk/src/auth.ts +++ b/packages/node-sdk/src/auth.ts @@ -1,4 +1,11 @@ -import { readConfigFile, writeConfigFile, type KimiConfig, type OAuthRef } from '@moonshot-ai/agent-core'; +import { + loadRuntimeConfigSafe, + readConfigFile, + readConfigFileForUpdate, + writeConfigFile, + type KimiConfig, + type OAuthRef, +} from '@moonshot-ai/agent-core'; import { applyManagedKimiCodeConfig, applyManagedKimiCodeLogoutConfig, @@ -59,7 +66,9 @@ export class KimiAuthFacade { onRefresh: options.onRefresh, configAdapter: { configPath: options.configPath, - read: () => readConfigFile(options.configPath) as SDKManagedConfig, + // Write-path base read: strict (a salvaged base would drop the user's + // broken-but-fixable sections on rewrite) with an actionable message. + read: () => readConfigFileForUpdate(options.configPath) as SDKManagedConfig, write: async (config) => { await writeConfigFile(options.configPath, config); }, @@ -169,7 +178,10 @@ export class KimiAuthFacade { readonly baseUrl?: string | undefined; } { const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - const config = readConfigFile(this.options.configPath); + // Read path: token/status resolution must work off a degraded config + // instead of failing the session when an unrelated section is broken. + // Write paths (the toolkit's configAdapter.read) stay strict. + const config = loadRuntimeConfigSafe(this.options.configPath).config; const provider = config.providers[name]; return { oauthRef: provider?.oauth, diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index d54d989d0..1dbd1bdf8 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -10,6 +10,7 @@ import { Session } from '#/session'; import type { KimiAuthFacade } from '#/auth'; import type { SDKRpcClientBase } from '#/rpc'; import type { + ConfigDiagnostics, CreateSessionOptions, ExportSessionInput, ExportSessionResult, @@ -215,6 +216,11 @@ export class KimiHarness { return this.rpc.getConfig(options); } + /** Warnings from the most recent config.toml load; empty when the config is fully valid. */ + async getConfigDiagnostics(): Promise { + return this.rpc.getConfigDiagnostics(); + } + async getExperimentalFeatures(): Promise { return this.rpc.getExperimentalFeatures(); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index ebebf9d90..10ab9cec5 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -22,6 +22,7 @@ import type { Kaos } from '@moonshot-ai/kaos'; import type { ApprovalHandler, QuestionHandler } from '#/events'; import type { BackgroundTaskInfo, + ConfigDiagnostics, CreateSessionOptions, ExportSessionInput, ExportSessionResult, @@ -197,6 +198,11 @@ export abstract class SDKRpcClientBase { return rpc.getKimiConfig(input ?? {}); } + async getConfigDiagnostics(): Promise { + const rpc = await this.getRpc(); + return rpc.getConfigDiagnostics({}); + } + async getExperimentalFeatures(): Promise { const rpc = await this.getRpc(); return rpc.getExperimentalFeatures({}); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 3d4896257..041d78495 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -22,6 +22,7 @@ export type { BackgroundConfig, BackgroundTaskInfo, BackgroundTaskStatus, + ConfigDiagnostics, ContextMessage, ExperimentalFeatureState, ExperimentalFlagMap, diff --git a/packages/node-sdk/test/auth-facade.test.ts b/packages/node-sdk/test/auth-facade.test.ts index 0c6209090..59d267b0b 100644 --- a/packages/node-sdk/test/auth-facade.test.ts +++ b/packages/node-sdk/test/auth-facade.test.ts @@ -62,6 +62,29 @@ describe('KimiHarness.auth', () => { await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token'); }); + it('resolves managed auth from a partially invalid config without throwing', async () => { + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); + await writeFile( + join(homeDir, 'config.toml'), + ` +[providers."managed:kimi-code"] +type = "kimi" +api_key = "" + +[loop_control] +max_steps_per_turn = "abc" +`, + ); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + // Token resolution is a read path: a broken section elsewhere in + // config.toml must degrade, not break OAuth-backed sessions. + await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token'); + await expect(harness.auth.status()).resolves.toMatchObject({ + providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], + }); + }); + it('resolves cached access tokens from the configured scoped OAuth ref', async () => { const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', @@ -367,7 +390,7 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ }); }); - it('fails clearly when a configured model alias does not have max_context_size', async () => { + it('starts degraded when a configured model alias does not have max_context_size', async () => { await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); await writeFile( join(homeDir, 'config.toml'), @@ -404,9 +427,14 @@ model = "kimi-for-coding" ), ); - expect(() => createKimiHarness({ homeDir, identity: TEST_IDENTITY })).toThrow( - /Model "kimi-code\/kimi-for-coding" must define a positive max_context_size/, - ); + // A broken config must not prevent startup: the invalid model alias is + // dropped, the rest of the config survives, and a warning is reported. + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + const config = await harness.getConfig(); + expect(config.models?.['kimi-code/kimi-for-coding']).toBeUndefined(); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeDefined(); + const { warnings } = await harness.getConfigDiagnostics(); + expect(warnings.some((w) => w.includes('models.kimi-code/kimi-for-coding'))).toBe(true); }); it('removes managed Kimi config on logout', async () => {