mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
c1191f5794
commit
8d251f8ab4
20 changed files with 749 additions and 38 deletions
7
.changeset/tolerate-invalid-config.md
Normal file
7
.changeset/tolerate-invalid-config.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
110
packages/agent-core/test/rpc/config-rpc.test.ts
Normal file
110
packages/agent-core/test/rpc/config-rpc.test.ts
Normal 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: [] });
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export type {
|
|||
BackgroundConfig,
|
||||
BackgroundTaskInfo,
|
||||
BackgroundTaskStatus,
|
||||
ConfigDiagnostics,
|
||||
ContextMessage,
|
||||
ExperimentalFeatureState,
|
||||
ExperimentalFlagMap,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue