feat(config): tolerate invalid config.toml sections instead of failing startup (#689)

* feat(config): tolerate invalid config.toml sections instead of failing startup

Schema errors now drop only the offending sections (single entries for
providers/models) with a warning, so a typo no longer prevents startup or
drops the login state. TOML syntax errors still fail fast with the parse
location. Mid-run reloads keep the last good config when the file breaks.

Warnings surface via the new getConfigDiagnostics API: as a startup notice
in the TUI, on stderr in print mode, and in the status bar after /new.

Write paths stay strict so a broken file is never silently rewritten, and
now fail with a short actionable message instead of raw validation JSON;
the /provider TUI flow and the kimi provider CLI report these errors
instead of crashing on an unhandled rejection.

* fix(config): keep entry-keyed sections when one entry has multiple issues

A providers/models entry with several validation issues was deleted by the
first issue, and the remaining issues from the same safeParse pass then
escalated to deleting the entire section — one badly-typed custom provider
could drop every provider, including the managed OAuth login. Issues on
entry-keyed sections now only ever target the entry itself; once it is
gone, later issues are no-ops.
This commit is contained in:
liruifengv 2026-06-12 15:56:13 +08:00 committed by GitHub
parent c1191f5794
commit 8d251f8ab4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 749 additions and 38 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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,

View file

@ -410,13 +410,26 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
.command('provider')
.description('Manage LLM providers non-interactively.');
// Last-resort boundary: handlers report expected failures themselves, but
// anything that escapes (e.g. a config write rejected because config.toml
// is invalid) must end as a one-line error + exit 1, not an unhandled
// rejection dumping a stack trace.
const runAction = async (resolved: ProviderDeps, run: () => Promise<void>): Promise<void> => {
try {
await run();
} catch (error) {
resolved.stderr.write(`${errorMessage(error)}\n`);
resolved.exit(1);
}
};
provider
.command('add <url>')
.description('Import every provider listed in a custom registry (api.json).')
.option('--api-key <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<Provider
.description('Remove a provider and every model alias that referenced it.')
.action(async (providerId: string) => {
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<Provider
.option('--json', 'Emit the raw providers/models config as JSON.', false)
.action(async (options: { json?: boolean }) => {
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<Provider
options: { filter?: string; url?: string; json?: boolean },
) => {
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<Provider
options: { apiKey?: string; defaultModel?: string; url?: string },
) => {
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 }),
}),
);
},
);
}

View file

@ -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<void> {
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<ReturnType<typeof fetchCustomRegistry>>;
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();

View file

@ -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<void> {
try {
const { warnings } = await this.harness.getConfigDiagnostics();
for (const warning of warnings) {
this.showStatus(warning, 'warning');
}
} catch {
/* diagnostics are best-effort */
}
}
// =========================================================================

View file

@ -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),

View file

@ -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', () => {

View file

@ -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,

View file

@ -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<unknown>>(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',

View file

@ -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<Record<string, string | undefined>> = 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<string, unknown> | undefined;
try {
data = parseToml(text) as Record<string, unknown>;
} 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<string, unknown>): 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();

View file

@ -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;

View file

@ -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<CoreAPI> {
private kaos: Promise<Kaos> | 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<string, string> | undefined;
@ -159,7 +161,19 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
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<CoreAPI> {
async getKimiConfig(input?: GetKimiConfigPayload): Promise<KimiConfig> {
if (input?.reload) {
this.setRuntimeConfig(loadRuntimeConfig(this.configPath));
this.reloadRuntimeConfig();
}
return this.config;
}
async getConfigDiagnostics(_input?: EmptyPayload): Promise<ConfigDiagnostics> {
return { warnings: this.configWarnings };
}
async setKimiConfig(input: SetKimiConfigPayload): Promise<KimiConfig> {
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<KimiConfig> {
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<CoreAPI> {
}
await writeConfigFile(this.configPath, config);
return this.setRuntimeConfig(loadRuntimeConfig(this.configPath));
return this.reloadRuntimeConfig();
}
prompt({ sessionId, ...payload }: SessionAgentPayload<PromptPayload>) {
@ -860,7 +878,30 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
}
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 {

View file

@ -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<string> {
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');
});
});

View file

@ -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<string> {
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: [] });
});
});

View file

@ -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,

View file

@ -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<ConfigDiagnostics> {
return this.rpc.getConfigDiagnostics();
}
async getExperimentalFeatures(): Promise<readonly ExperimentalFeatureState[]> {
return this.rpc.getExperimentalFeatures();
}

View file

@ -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<ConfigDiagnostics> {
const rpc = await this.getRpc();
return rpc.getConfigDiagnostics({});
}
async getExperimentalFeatures(): Promise<readonly ExperimentalFeatureState[]> {
const rpc = await this.getRpc();
return rpc.getExperimentalFeatures({});

View file

@ -22,6 +22,7 @@ export type {
BackgroundConfig,
BackgroundTaskInfo,
BackgroundTaskStatus,
ConfigDiagnostics,
ContextMessage,
ExperimentalFeatureState,
ExperimentalFlagMap,

View file

@ -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 () => {