From a525f358b525ac25a2dd4bc3c51108e6ae1ba13f Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 20 Aug 2026 16:14:04 +0800 Subject: [PATCH 1/6] fix(agent-core-v2): guard config persistence against lossy writes - A failed load no longer clears the in-memory snapshot: the service keeps the last-known-good config, reports an error diagnostic, and taints. set/replace/replaceSections on the persisted layer then fail fast with Error2(config.persist_blocked) instead of erasing the file; memory-layer overrides stay available, and a successful reload clears the taint. - persistDomains is now read-modify-write: the file is re-read and only the domains being written are applied on top of current disk content, so external edits are merged instead of clobbered, and an external delete is honored instead of resurrected. - External changes absorbed at persist time trigger a full reload so change events fire for domains the writer did not touch. --- .changeset/config-persist-guard.md | 5 + .../src/app/config/configService.ts | 51 ++++++- .../agent-core-v2/src/app/config/errors.ts | 1 + .../test/app/config/config.test.ts | 135 ++++++++++++++++++ 4 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 .changeset/config-persist-guard.md diff --git a/.changeset/config-persist-guard.md b/.changeset/config-persist-guard.md new file mode 100644 index 00000000000..4f0dd2d4a05 --- /dev/null +++ b/.changeset/config-persist-guard.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix config.toml entries being lost when the file had a syntax error or was edited outside the app. diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index d71e79c2daf..283fdd351e2 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -3,7 +3,7 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; -import { BugIndicatingError, onUnexpectedError } from '#/errors'; +import { BugIndicatingError, Error2, ErrorCodes, onUnexpectedError } from '#/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { ILogService } from '#/_base/log/log'; import { @@ -309,6 +309,7 @@ export class ConfigService extends Disposable implements IConfigService { private readonly diagnosticsList: ConfigDiagnostic[] = []; private lastDiagnosticsSnapshot = '[]'; private readonly configKey: string; + private tainted = false; constructor( @IConfigRegistry private readonly registry: IConfigRegistry, @@ -402,6 +403,7 @@ export class ConfigService extends Disposable implements IConfigService { return; } await this.enqueueStateTransition(async () => { + this.assertPersistable(); const base = this.raw[domain]; const next = this.registry.merge(domain, base, patch); const validated = this.registry.validate(domain, next); @@ -434,6 +436,7 @@ export class ConfigService extends Disposable implements IConfigService { return; } await this.enqueueStateTransition(async () => { + this.assertPersistable(); const stripped = this.stripEnv(domain, effectiveValue); if (stripped === undefined) { delete this.raw[domain]; @@ -467,6 +470,7 @@ export class ConfigService extends Disposable implements IConfigService { return; } await this.enqueueStateTransition(async () => { + this.assertPersistable(); const staged: ResolvedConfig = { ...this.raw }; for (const domain of domains) { const value = sections[domain] === null ? undefined : sections[domain]; @@ -526,7 +530,11 @@ export class ConfigService extends Disposable implements IConfigService { : describeUnknownError(error); this.pushDiagnostic({ severity: 'error', message }); this.log.warn('config load failed', { error: describeUnknownError(error) }); + this.tainted = true; + this.emitDiagnosticsIfChanged(); + return; } + this.tainted = false; const nextRawSnake = cloneRecord(fileData); for (const diagnostic of collectKeyDeprecations(nextRawSnake, this.registry.listSections())) { this.pushDiagnostic(diagnostic); @@ -732,15 +740,52 @@ export class ConfigService extends Disposable implements IConfigService { this.commit('reload', [domain]); } + private assertPersistable(): void { + if (!this.tainted) return; + throw new Error2( + ErrorCodes.CONFIG_PERSIST_BLOCKED, + `Refusing to persist config: ${this.bootstrap.configPath} could not be read; fix the file and reload before writing.`, + ); + } + private async persist(domain: string): Promise { await this.persistDomains([domain]); } private async persistDomains(domains: readonly string[]): Promise { + this.assertPersistable(); + let onDisk: ResolvedConfig = {}; + try { + const data = await this.documentStore.get(CONFIG_SCOPE, this.configKey); + onDisk = data !== undefined && isPlainObject(data) ? data : {}; + } catch (error) { + const message = + error instanceof TomlError + ? `Failed to parse ${this.bootstrap.configPath}: ${describeTomlSyntaxError(error)}` + : describeUnknownError(error); + this.pushDiagnostic({ severity: 'error', message }); + this.emitDiagnosticsIfChanged(); + this.log.warn('config persist aborted: re-read failed', { + error: describeUnknownError(error), + }); + this.tainted = true; + throw new Error2( + ErrorCodes.CONFIG_PERSIST_BLOCKED, + `Refusing to persist config: ${this.bootstrap.configPath} could not be read; fix the file and reload before writing.`, + { cause: error }, + ); + } + const absorbedExternal = JSON.stringify(onDisk) !== JSON.stringify(this.rawSnake); + const nextRawSnake = cloneRecord(onDisk); for (const domain of domains) { - applySectionToToml(this.rawSnake, domain, this.raw[domain], this.registry); + applySectionToToml(nextRawSnake, domain, this.raw[domain], this.registry); + } + await this.documentStore.set(CONFIG_SCOPE, this.configKey, nextRawSnake); + if (absorbedExternal) { + await this.load('reload'); + return; } - await this.documentStore.set(CONFIG_SCOPE, this.configKey, this.rawSnake); + this.rawSnake = nextRawSnake; } } diff --git a/packages/agent-core-v2/src/app/config/errors.ts b/packages/agent-core-v2/src/app/config/errors.ts index f0dd785f8a7..63c01cb813a 100644 --- a/packages/agent-core-v2/src/app/config/errors.ts +++ b/packages/agent-core-v2/src/app/config/errors.ts @@ -4,6 +4,7 @@ import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; export const ConfigErrors = { codes: { CONFIG_INVALID: CONFIG_INVALID_ERROR_CODE, + CONFIG_PERSIST_BLOCKED: 'config.persist_blocked', }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 513421ce317..ed61091d6b0 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -24,6 +24,7 @@ import { createDecorator, type ProvideHandle } from '#/_base/di/instantiation'; import { DisposableStore } from '#/_base/di/lifecycle'; import { Service } from '#/_base/di/service'; import { TestInstantiationService } from '#/_base/di/test'; +import { Event } from '#/_base/event'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { type ConfigSchema, @@ -2596,3 +2597,137 @@ describe('ConfigService replaceSections', () => { disposables.dispose(); }); }); + +describe('ConfigService persistence guards', () => { + class SilentStorage extends InMemoryStorageService { + override watch(): Event { + return Event.None as Event; + } + } + + async function createGuardedConfig(toml: string) { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new SilentStorage(); + await storage.write('', 'config.toml', new TextEncoder().encode(toml)); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg-guards')); + ix.stub(IFileSystemStorageService, storage); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + return { config, disposables, storage }; + } + + async function overwrite(storage: InMemoryStorageService, toml: string): Promise { + await storage.write('', 'config.toml', new TextEncoder().encode(toml)); + } + + async function stored(storage: InMemoryStorageService): Promise { + const bytes = await storage.read('', 'config.toml'); + return new TextDecoder().decode(bytes); + } + + async function expectPersistBlocked(promise: Promise): Promise { + const error = await promise.then( + () => undefined, + (e: unknown) => e, + ); + expect(isError2(error)).toBe(true); + expect((error as Error2).code).toBe(ErrorCodes.CONFIG_PERSIST_BLOCKED); + } + + it('refuses to persist when the initial load fails and keeps the file untouched', async () => { + const broken = '[providers\nbroken'; + const { config, disposables, storage } = await createGuardedConfig(broken); + + expect(config.diagnostics().some((d) => d.severity === 'error')).toBe(true); + + await expectPersistBlocked(config.set(THINKING_SECTION, { enabled: true })); + await expectPersistBlocked(config.replace(THINKING_SECTION, { enabled: true })); + await expectPersistBlocked(config.replaceSections({ [THINKING_SECTION]: { enabled: true } })); + + expect(await stored(storage)).toBe(broken); + + await config.set(THINKING_SECTION, { enabled: true }, ConfigTarget.Memory); + expect(config.get(THINKING_SECTION)).toEqual({ enabled: true }); + + disposables.dispose(); + }); + + it('keeps last-known-good values when a reload hits a broken file, and recovers after the file is fixed', async () => { + const { config, disposables, storage } = await createGuardedConfig( + '[providers.acme]\ntype = "openai"\napi_key = "sk-acme"\n', + ); + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + acme: { type: 'openai', apiKey: 'sk-acme' }, + }); + + await overwrite(storage, '= broken ='); + await config.reload(); + + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + acme: { type: 'openai', apiKey: 'sk-acme' }, + }); + await expectPersistBlocked(config.set(THINKING_SECTION, { enabled: true })); + expect(await stored(storage)).toBe('= broken ='); + + await overwrite(storage, '[providers.beta]\ntype = "openai"\napi_key = "sk-beta"\n'); + await config.reload(); + + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + beta: { type: 'openai', apiKey: 'sk-beta' }, + }); + await config.set(THINKING_SECTION, { enabled: true }); + expect(config.get(THINKING_SECTION)).toEqual({ enabled: true }); + + disposables.dispose(); + }); + + it('merges external edits observed at persist time instead of clobbering them', async () => { + const { config, disposables, storage } = await createGuardedConfig( + 'default_model = "acme/m1"\n\n[providers.acme]\ntype = "openai"\napi_key = "sk-acme"\n', + ); + + await overwrite( + storage, + 'default_model = "acme/m1"\n\n[providers.acme]\ntype = "openai"\napi_key = "sk-acme-2"\n\n[providers.beta]\ntype = "openai"\napi_key = "sk-beta"\n', + ); + + const changed: string[] = []; + config.onDidSectionChange((e) => changed.push(e.domain)); + await config.set(THINKING_SECTION, { enabled: true }); + + const doc = await stored(storage); + expect(doc).toContain('sk-acme-2'); + expect(doc).toContain('[providers.beta]'); + expect(doc).toContain('[thinking]'); + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + acme: { type: 'openai', apiKey: 'sk-acme-2' }, + beta: { type: 'openai', apiKey: 'sk-beta' }, + }); + expect(config.get(THINKING_SECTION)).toEqual({ enabled: true }); + expect(changed).toContain(PROVIDERS_SECTION); + expect(changed).toContain(THINKING_SECTION); + + disposables.dispose(); + }); + + it('honors an external delete instead of resurrecting the in-memory copy', async () => { + const { config, disposables, storage } = await createGuardedConfig( + '[providers.acme]\ntype = "openai"\napi_key = "sk-acme"\n', + ); + + await storage.delete('', 'config.toml'); + await config.set(THINKING_SECTION, { enabled: true }); + + const doc = await stored(storage); + expect(doc).toContain('[thinking]'); + expect(doc).not.toContain('[providers.acme]'); + expect(config.inspect(PROVIDERS_SECTION).userValue).toBeUndefined(); + + disposables.dispose(); + }); +}); From f59526ddf8840624be99d93b215ee7ef614c21d9 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 20 Aug 2026 16:25:32 +0800 Subject: [PATCH 2/6] fix(agent-core-v2): rebase set() merges onto re-read config state set(domain, patch) now merges the patch against the freshly re-read file content and refreshes the in-memory snapshot from the same read, so external edits to the same section survive a concurrent write instead of being overwritten by the stale in-memory copy. --- .../src/app/config/configService.ts | 35 +++++++++++-------- .../test/app/config/config.test.ts | 23 ++++++++++++ 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 283fdd351e2..b760c077ba9 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -404,17 +404,18 @@ export class ConfigService extends Disposable implements IConfigService { } await this.enqueueStateTransition(async () => { this.assertPersistable(); - const base = this.raw[domain]; - const next = this.registry.merge(domain, base, patch); - const validated = this.registry.validate(domain, next); - const stripped = this.stripEnv(domain, validated); - if (stripped === undefined) { - delete this.raw[domain]; - } else { - this.registry.validate(domain, stripped); - this.raw[domain] = stripped; - } - await this.persist(domain); + await this.persist(domain, (freshRaw) => { + this.raw = freshRaw; + const next = this.registry.merge(domain, freshRaw[domain], patch); + const validated = this.registry.validate(domain, next); + const stripped = this.stripEnv(domain, validated); + if (stripped === undefined) { + delete this.raw[domain]; + } else { + this.registry.validate(domain, stripped); + this.raw[domain] = stripped; + } + }); this.rebuildEffective('set', [domain]); }); } @@ -748,11 +749,14 @@ export class ConfigService extends Disposable implements IConfigService { ); } - private async persist(domain: string): Promise { - await this.persistDomains([domain]); + private async persist(domain: string, rebase?: (onDiskRaw: ResolvedConfig) => void): Promise { + await this.persistDomains([domain], rebase); } - private async persistDomains(domains: readonly string[]): Promise { + private async persistDomains( + domains: readonly string[], + rebase?: (onDiskRaw: ResolvedConfig) => void, + ): Promise { this.assertPersistable(); let onDisk: ResolvedConfig = {}; try { @@ -775,6 +779,9 @@ export class ConfigService extends Disposable implements IConfigService { { cause: error }, ); } + if (rebase !== undefined) { + rebase(transformTomlData(onDisk, this.registry)); + } const absorbedExternal = JSON.stringify(onDisk) !== JSON.stringify(this.rawSnake); const nextRawSnake = cloneRecord(onDisk); for (const domain of domains) { diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index ed61091d6b0..45a8d97fec8 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -2730,4 +2730,27 @@ describe('ConfigService persistence guards', () => { disposables.dispose(); }); + + it('rebases a set() merge onto external edits of the same section', async () => { + const { config, disposables, storage } = await createGuardedConfig( + '[providers.acme]\ntype = "openai"\napi_key = "sk-acme"\n', + ); + + await overwrite( + storage, + '[providers.acme]\ntype = "openai"\napi_key = "sk-acme"\n\n[providers.beta]\ntype = "openai"\napi_key = "sk-beta"\n', + ); + await config.set(PROVIDERS_SECTION, { gamma: { type: 'openai', apiKey: 'sk-gamma' } }); + + const doc = await stored(storage); + expect(doc).toContain('[providers.beta]'); + expect(doc).toContain('[providers.gamma]'); + expect(config.get>(PROVIDERS_SECTION)).toEqual({ + acme: { type: 'openai', apiKey: 'sk-acme' }, + beta: { type: 'openai', apiKey: 'sk-beta' }, + gamma: { type: 'openai', apiKey: 'sk-gamma' }, + }); + + disposables.dispose(); + }); }); From 0514b763d3516e25c107450e9a7903ccb337a693 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 20 Aug 2026 16:25:32 +0800 Subject: [PATCH 3/6] fix(protocol): register config.persist_blocked in KimiErrorCode Add the new code to the KimiErrorCode union and kimiErrorCodeSchema so the persist-refusal error payload passes protocol validation across RPC boundaries. --- packages/protocol/src/events.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 57febf10554..78cf6a3bc81 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -229,6 +229,7 @@ export interface GoalChange { export type KimiErrorCode = | 'config.invalid' + | 'config.persist_blocked' | 'session.not_found' | 'session.already_exists' | 'session.id_invalid' @@ -1259,6 +1260,7 @@ export const goalChangeSchema = z.object({ export const kimiErrorCodeSchema = z.enum([ 'config.invalid', + 'config.persist_blocked', 'session.not_found', 'session.already_exists', 'session.id_invalid', From 18f3866a57b082d7146e6acf42ffc14bcd60f92c Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 20 Aug 2026 17:22:51 +0800 Subject: [PATCH 4/6] fix(agent-core-v2): compute every config write against the re-read file Move strip/merge/validate for set/replace/replaceSections into the persist rebase callback so each write is derived from the file content re-read at persist time. Overlay strip handlers (e.g. the KIMI_MODEL_* mask restoring default_model) now read the fresh snapshot instead of the stale in-memory one, and the unconditional snapshot sync makes the separate absorbed-external reload redundant. --- .../src/app/config/configService.ts | 67 ++++++++----------- .../test/app/config/config.test.ts | 25 ++++++- 2 files changed, 52 insertions(+), 40 deletions(-) diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index b760c077ba9..17b30803883 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -404,9 +404,8 @@ export class ConfigService extends Disposable implements IConfigService { } await this.enqueueStateTransition(async () => { this.assertPersistable(); - await this.persist(domain, (freshRaw) => { - this.raw = freshRaw; - const next = this.registry.merge(domain, freshRaw[domain], patch); + await this.persist(domain, () => { + const next = this.registry.merge(domain, this.raw[domain], patch); const validated = this.registry.validate(domain, next); const stripped = this.stripEnv(domain, validated); if (stripped === undefined) { @@ -438,13 +437,14 @@ export class ConfigService extends Disposable implements IConfigService { } await this.enqueueStateTransition(async () => { this.assertPersistable(); - const stripped = this.stripEnv(domain, effectiveValue); - if (stripped === undefined) { - delete this.raw[domain]; - } else { - this.raw[domain] = this.registry.validate(domain, stripped); - } - await this.persist(domain); + await this.persist(domain, () => { + const stripped = this.stripEnv(domain, effectiveValue); + if (stripped === undefined) { + delete this.raw[domain]; + } else { + this.raw[domain] = this.registry.validate(domain, stripped); + } + }); this.rebuildEffective('set', [domain]); }); } @@ -472,18 +472,19 @@ export class ConfigService extends Disposable implements IConfigService { } await this.enqueueStateTransition(async () => { this.assertPersistable(); - const staged: ResolvedConfig = { ...this.raw }; - for (const domain of domains) { - const value = sections[domain] === null ? undefined : sections[domain]; - const stripped = this.stripEnv(domain, value); - if (stripped === undefined) { - delete staged[domain]; - } else { - staged[domain] = this.registry.validate(domain, stripped); + await this.persistDomains(domains, () => { + const staged: ResolvedConfig = { ...this.raw }; + for (const domain of domains) { + const value = sections[domain] === null ? undefined : sections[domain]; + const stripped = this.stripEnv(domain, value); + if (stripped === undefined) { + delete staged[domain]; + } else { + staged[domain] = this.registry.validate(domain, stripped); + } } - } - this.raw = staged; - await this.persistDomains(domains); + this.raw = staged; + }); this.rebuildEffective('set', domains); }); } @@ -749,14 +750,11 @@ export class ConfigService extends Disposable implements IConfigService { ); } - private async persist(domain: string, rebase?: (onDiskRaw: ResolvedConfig) => void): Promise { + private async persist(domain: string, rebase: () => void): Promise { await this.persistDomains([domain], rebase); } - private async persistDomains( - domains: readonly string[], - rebase?: (onDiskRaw: ResolvedConfig) => void, - ): Promise { + private async persistDomains(domains: readonly string[], rebase: () => void): Promise { this.assertPersistable(); let onDisk: ResolvedConfig = {}; try { @@ -779,20 +777,13 @@ export class ConfigService extends Disposable implements IConfigService { { cause: error }, ); } - if (rebase !== undefined) { - rebase(transformTomlData(onDisk, this.registry)); - } - const absorbedExternal = JSON.stringify(onDisk) !== JSON.stringify(this.rawSnake); - const nextRawSnake = cloneRecord(onDisk); + this.rawSnake = cloneRecord(onDisk); + this.raw = transformTomlData(onDisk, this.registry); + rebase(); for (const domain of domains) { - applySectionToToml(nextRawSnake, domain, this.raw[domain], this.registry); - } - await this.documentStore.set(CONFIG_SCOPE, this.configKey, nextRawSnake); - if (absorbedExternal) { - await this.load('reload'); - return; + applySectionToToml(this.rawSnake, domain, this.raw[domain], this.registry); } - this.rawSnake = nextRawSnake; + await this.documentStore.set(CONFIG_SCOPE, this.configKey, this.rawSnake); } } diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 45a8d97fec8..ba1551fcbbd 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -67,6 +67,7 @@ import { PROVIDERS_SECTION, THINKING_SECTION, } from '#/app/kosongConfig/configSection'; +import '#/app/kosongConfig/envOverlay'; import { type ThinkingConfig } from '#/kosong/model/thinking'; import { KEEP_ALIVE_ON_EXIT_ENV, @@ -2605,13 +2606,13 @@ describe('ConfigService persistence guards', () => { } } - async function createGuardedConfig(toml: string) { + async function createGuardedConfig(toml: string, env: NodeJS.ProcessEnv = {}) { const disposables = new DisposableStore(); const ix = disposables.add(new TestInstantiationService()); const storage = new SilentStorage(); await storage.write('', 'config.toml', new TextEncoder().encode(toml)); ix.stub(ILogService, stubLog()); - ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg-guards')); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg-guards', env)); ix.stub(IFileSystemStorageService, storage); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); @@ -2753,4 +2754,24 @@ describe('ConfigService persistence guards', () => { disposables.dispose(); }); + + it('restores env-masked values from the freshly re-read file instead of the stale snapshot', async () => { + const { config, disposables, storage } = await createGuardedConfig( + 'default_model = "acme/m1"\n\n[providers.acme]\ntype = "openai"\napi_key = "sk-acme"\n\n[models."acme/m1"]\nprovider = "acme"\nmodel = "m1"\n', + { KIMI_MODEL_NAME: 'env-model' }, + ); + expect(config.get(DEFAULT_MODEL_SECTION)).toBe('__kimi_env_model__'); + + await overwrite( + storage, + 'default_model = "acme/m2"\n\n[providers.acme]\ntype = "openai"\napi_key = "sk-acme"\n\n[models."acme/m2"]\nprovider = "acme"\nmodel = "m2"\n', + ); + await config.replace(DEFAULT_MODEL_SECTION, config.get(DEFAULT_MODEL_SECTION)); + + const doc = await stored(storage); + expect(doc).toContain('default_model = "acme/m2"'); + expect(doc).not.toContain('default_model = "acme/m1"'); + + disposables.dispose(); + }); }); From a73f93cbb7dc232445c4d3db9bc86c4c07f630aa Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 20 Aug 2026 17:40:40 +0800 Subject: [PATCH 5/6] fix(agent-core-v2): build defaults when the initial config load fails A failed first load has no last-known-good state worth preserving, so fall through with an empty document: registered section defaults are still validated and applied (consumers of defaulted sections keep working), while the taint keeps blocking persisted writes until a reload succeeds. Only reload failures preserve the previous in-memory state. --- .../agent-core-v2/src/app/config/configService.ts | 12 ++++++++---- .../agent-core-v2/test/app/config/config.test.ts | 5 +++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 17b30803883..079db7e287f 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -522,21 +522,25 @@ export class ConfigService extends Disposable implements IConfigService { private async load(source: ConfigChangeSource): Promise { this.diagnosticsList.length = 0; let fileData: ResolvedConfig = {}; + let failed = false; try { const data = await this.documentStore.get(CONFIG_SCOPE, this.configKey); fileData = data !== undefined && isPlainObject(data) ? data : {}; } catch (error) { + failed = true; const message = error instanceof TomlError ? `Failed to parse ${this.bootstrap.configPath}: ${describeTomlSyntaxError(error)}` : describeUnknownError(error); this.pushDiagnostic({ severity: 'error', message }); this.log.warn('config load failed', { error: describeUnknownError(error) }); - this.tainted = true; - this.emitDiagnosticsIfChanged(); - return; + if (source !== 'load') { + this.tainted = true; + this.emitDiagnosticsIfChanged(); + return; + } } - this.tainted = false; + this.tainted = failed; const nextRawSnake = cloneRecord(fileData); for (const diagnostic of collectKeyDeprecations(nextRawSnake, this.registry.listSections())) { this.pushDiagnostic(diagnostic); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index ba1551fcbbd..d8c1a82c89c 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -35,8 +35,7 @@ import { } from '#/app/config/config'; import { ConfigRegistry, ConfigService } from '#/app/config/configService'; import { ConfigSectionContribution } from '#/app/config/configSectionContributions'; -import '#/app/cron/configSection'; -import type { CronConfig } from '#/app/cron/configSection'; +import { CRON_SECTION, DEFAULT_CRON_CONFIG, type CronConfig } from '#/app/cron/configSection'; import '#/app/skillCatalog/configSection'; import { BUILTIN_PRODUCT_SKILLS_SECTION } from '#/app/skillCatalog/configSection'; import { @@ -2645,6 +2644,8 @@ describe('ConfigService persistence guards', () => { const { config, disposables, storage } = await createGuardedConfig(broken); expect(config.diagnostics().some((d) => d.severity === 'error')).toBe(true); + expect(config.get(PROVIDERS_SECTION)).toEqual({}); + expect(config.get(CRON_SECTION)).toEqual(DEFAULT_CRON_CONFIG); await expectPersistBlocked(config.set(THINKING_SECTION, { enabled: true })); await expectPersistBlocked(config.replace(THINKING_SECTION, { enabled: true })); From 97974811fed12e00feb5723e0dc5bb1b2544eea7 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 20 Aug 2026 17:55:09 +0800 Subject: [PATCH 6/6] fix(agent-core-v2): stage re-read config snapshots until the write succeeds Build the rebased raw/rawSnake snapshots in locals and publish them only after the rebase and documentStore.set both succeed, so a validation error or a storage failure cannot leave userValue and effective pointing at different snapshots. stripEnv now takes the staged snapshots explicitly. --- .../src/app/config/configService.ts | 61 +++++++++++-------- .../test/app/config/config.test.ts | 14 +++++ 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 079db7e287f..83c8e80c124 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -404,15 +404,15 @@ export class ConfigService extends Disposable implements IConfigService { } await this.enqueueStateTransition(async () => { this.assertPersistable(); - await this.persist(domain, () => { - const next = this.registry.merge(domain, this.raw[domain], patch); + await this.persist(domain, (stagedRaw, stagedRawSnake) => { + const next = this.registry.merge(domain, stagedRaw[domain], patch); const validated = this.registry.validate(domain, next); - const stripped = this.stripEnv(domain, validated); + const stripped = this.stripEnv(domain, validated, stagedRaw, stagedRawSnake); if (stripped === undefined) { - delete this.raw[domain]; + delete stagedRaw[domain]; } else { this.registry.validate(domain, stripped); - this.raw[domain] = stripped; + stagedRaw[domain] = stripped; } }); this.rebuildEffective('set', [domain]); @@ -437,12 +437,12 @@ export class ConfigService extends Disposable implements IConfigService { } await this.enqueueStateTransition(async () => { this.assertPersistable(); - await this.persist(domain, () => { - const stripped = this.stripEnv(domain, effectiveValue); + await this.persist(domain, (stagedRaw, stagedRawSnake) => { + const stripped = this.stripEnv(domain, effectiveValue, stagedRaw, stagedRawSnake); if (stripped === undefined) { - delete this.raw[domain]; + delete stagedRaw[domain]; } else { - this.raw[domain] = this.registry.validate(domain, stripped); + stagedRaw[domain] = this.registry.validate(domain, stripped); } }); this.rebuildEffective('set', [domain]); @@ -472,34 +472,37 @@ export class ConfigService extends Disposable implements IConfigService { } await this.enqueueStateTransition(async () => { this.assertPersistable(); - await this.persistDomains(domains, () => { - const staged: ResolvedConfig = { ...this.raw }; + await this.persistDomains(domains, (stagedRaw, stagedRawSnake) => { for (const domain of domains) { const value = sections[domain] === null ? undefined : sections[domain]; - const stripped = this.stripEnv(domain, value); + const stripped = this.stripEnv(domain, value, stagedRaw, stagedRawSnake); if (stripped === undefined) { - delete staged[domain]; + delete stagedRaw[domain]; } else { - staged[domain] = this.registry.validate(domain, stripped); + stagedRaw[domain] = this.registry.validate(domain, stripped); } } - this.raw = staged; }); this.rebuildEffective('set', domains); }); } - private stripEnv(domain: string, value: unknown): unknown { + private stripEnv( + domain: string, + value: unknown, + raw: ResolvedConfig, + rawSnake: ResolvedConfig, + ): unknown { let result = value; const section = this.registry.getSection(domain); if (section?.stripEnv !== undefined) { const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); - result = section.stripEnv(result, this.raw[domain], getEnv); + result = section.stripEnv(result, raw[domain], getEnv); } if (result === undefined) return result; for (const overlay of this.registry.listEffectiveOverlays()) { if (overlay.strip === undefined) continue; - result = overlay.strip(domain, result, this.rawSnake); + result = overlay.strip(domain, result, rawSnake); if (result === undefined) return result; } return result; @@ -754,11 +757,17 @@ export class ConfigService extends Disposable implements IConfigService { ); } - private async persist(domain: string, rebase: () => void): Promise { + private async persist( + domain: string, + rebase: (stagedRaw: ResolvedConfig, stagedRawSnake: ResolvedConfig) => void, + ): Promise { await this.persistDomains([domain], rebase); } - private async persistDomains(domains: readonly string[], rebase: () => void): Promise { + private async persistDomains( + domains: readonly string[], + rebase: (stagedRaw: ResolvedConfig, stagedRawSnake: ResolvedConfig) => void, + ): Promise { this.assertPersistable(); let onDisk: ResolvedConfig = {}; try { @@ -781,13 +790,15 @@ export class ConfigService extends Disposable implements IConfigService { { cause: error }, ); } - this.rawSnake = cloneRecord(onDisk); - this.raw = transformTomlData(onDisk, this.registry); - rebase(); + const stagedRawSnake = cloneRecord(onDisk); + const stagedRaw = transformTomlData(onDisk, this.registry); + rebase(stagedRaw, stagedRawSnake); for (const domain of domains) { - applySectionToToml(this.rawSnake, domain, this.raw[domain], this.registry); + applySectionToToml(stagedRawSnake, domain, stagedRaw[domain], this.registry); } - await this.documentStore.set(CONFIG_SCOPE, this.configKey, this.rawSnake); + await this.documentStore.set(CONFIG_SCOPE, this.configKey, stagedRawSnake); + this.rawSnake = stagedRawSnake; + this.raw = stagedRaw; } } diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index d8c1a82c89c..8ce62e43eea 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -2775,4 +2775,18 @@ describe('ConfigService persistence guards', () => { disposables.dispose(); }); + + it('keeps the in-memory snapshots untouched when a write fails validation', async () => { + const { config, disposables, storage } = await createGuardedConfig( + '[thinking]\nenabled = true\n', + ); + + await overwrite(storage, '[thinking]\nenabled = false\n'); + await expect(config.set(THINKING_SECTION, { enabled: 'yes' })).rejects.toThrow(); + + expect(config.inspect(THINKING_SECTION).userValue).toEqual({ enabled: true }); + expect(await stored(storage)).toBe('[thinking]\nenabled = false\n'); + + disposables.dispose(); + }); });