diff --git a/src/config/io.ts b/src/config/io.ts index 4864407976a..be29c72dd74 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -215,6 +215,11 @@ export type ConfigWriteOptions = { * Normal writers must keep this false so clobbers are rejected before disk commit. */ allowDestructiveWrite?: boolean; + /** + * Allow an intentional large config size drop while keeping other destructive + * guards active. Used by repair flows that remove stale or legacy config. + */ + allowConfigSizeDrop?: boolean; /** * Suppress human-readable output logs (overwrite/anomaly messages). * Useful when the caller wants machine-readable output only (--json mode). @@ -399,9 +404,14 @@ function resolveConfigWriteSuspiciousReasons(params: { return reasons; } -function resolveConfigWriteBlockingReasons(suspicious: string[]): string[] { +function resolveConfigWriteBlockingReasons( + suspicious: string[], + options: Pick = {}, +): string[] { return suspicious.filter( - (reason) => reason.startsWith("size-drop:") || reason === "gateway-mode-removed", + (reason) => + (reason.startsWith("size-drop:") && options.allowConfigSizeDrop !== true) || + reason === "gateway-mode-removed", ); } @@ -2165,7 +2175,7 @@ export function createConfigIO( }), }); }; - const blockingReasons = resolveConfigWriteBlockingReasons(suspiciousReasons); + const blockingReasons = resolveConfigWriteBlockingReasons(suspiciousReasons, options); if (blockingReasons.length > 0 && options.allowDestructiveWrite !== true) { const rejectedPath = `${configPath}.rejected.${formatConfigArtifactTimestamp(new Date().toISOString())}`; await deps.fs.promises @@ -2426,6 +2436,7 @@ export async function writeConfigFile( }), unsetPaths: resolveManagedUnsetPathsForWrite(options.unsetPaths), allowDestructiveWrite: options.allowDestructiveWrite, + allowConfigSizeDrop: options.allowConfigSizeDrop, skipRuntimeSnapshotRefresh: options.skipRuntimeSnapshotRefresh, skipOutputLogs: options.skipOutputLogs, }); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 16095b48372..a3b40fbba32 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -602,6 +602,68 @@ describe("config io write", () => { }); }); + it("allows intentional size-drop writes without disabling gateway-mode protection", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + const original = { + meta: { lastTouchedVersion: "2026.4.30" }, + gateway: { mode: "local" }, + channels: { + telegram: { + enabled: true, + allowFrom: Array.from({ length: 80 }, (_, index) => `telegram:${index}`), + }, + }, + } satisfies ConfigFileSnapshot["config"]; + const originalRaw = `${JSON.stringify(original, null, 2)}\n`; + await fs.writeFile(configPath, originalRaw, "utf-8"); + const io = createConfigIO({ + env: { VITEST: "true" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const baseSnapshot = { + path: configPath, + exists: true, + raw: originalRaw, + parsed: original, + sourceConfig: original, + resolved: original, + valid: true, + runtimeConfig: original, + config: original, + issues: [], + warnings: [], + legacyIssues: [], + } satisfies ConfigFileSnapshot; + + await expect( + io.writeConfigFile( + { meta: original.meta, gateway: { mode: "local" } }, + { + allowConfigSizeDrop: true, + baseSnapshot, + }, + ), + ).resolves.toMatchObject({ + persistedConfig: expect.objectContaining({ gateway: { mode: "local" } }), + }); + + await expect( + io.writeConfigFile( + { meta: original.meta }, + { + allowConfigSizeDrop: true, + baseSnapshot, + }, + ), + ).rejects.toMatchObject({ + code: "CONFIG_WRITE_REJECTED", + }); + }); + }); + it("keeps authored agent provider params during narrowed internal agent writes", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index bbdc303a23c..8ffba8d6681 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -526,6 +526,9 @@ async function runWriteConfigHealth(ctx: DoctorHealthFlowContext): Promise await replaceConfigFile({ nextConfig: ctx.cfg, afterWrite: { mode: "auto" }, + writeOptions: { + allowConfigSizeDrop: ctx.configResult.shouldWriteConfig === true, + }, }); logConfigUpdated(ctx.runtime); const backupPath = `${CONFIG_PATH}.bak`;