From a2eb8fa48f41232ccfead89b925d46e4003c20ae Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:32:50 +0300 Subject: [PATCH] fix(config): preserve $schema field across config rewrites (#47322) * fix(config): preserve \$schema field across config rewrites Add \$schema to the OpenClawConfig TypeScript type so it survives the config write-back cycle. The Zod schema already accepted it (added in #14998) but the TypeScript type omitted it, causing the field to be silently stripped during config serialization. Adds a round-trip test through validateConfigObject to prevent regression. Closes #43578 * fix(config): preserve root $schema during partial writes * fix(config): preserve root $schema only when omitted * fix(config): preserve root-authored $schema only --------- Co-authored-by: Altay --- src/config/config-misc.test.ts | 10 ++++ src/config/io.ts | 1 + src/config/io.write-config.test.ts | 71 ++++++++++++++++++++++++++ src/config/io.write-prepare.test.ts | 78 +++++++++++++++++++++++++++++ src/config/io.write-prepare.ts | 37 +++++++++++++- src/config/types.openclaw.ts | 2 + 6 files changed, 198 insertions(+), 1 deletion(-) diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index d59735657be..4ffd561478d 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -47,6 +47,16 @@ describe("$schema key in config (#14998)", () => { }); expect(result.ok).toBe(true); }); + + it("preserves $schema through validateConfigObject round-trip", () => { + const res = validateConfigObject({ + $schema: "https://openclaw.ai/config.json", + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.$schema).toBe("https://openclaw.ai/config.json"); + } + }); }); describe("plugins.slots.contextEngine", () => { diff --git a/src/config/io.ts b/src/config/io.ts index f3111d732d5..3a0d8f583d2 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1469,6 +1469,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { runtimeConfig: snapshot.config, sourceConfig: snapshot.resolved, nextConfig: cfg, + rootAuthoredConfig: snapshot.parsed, }); try { const resolvedIncludes = resolveConfigIncludes(snapshot.parsed, configPath, { diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index c0c1f3a0654..bfa600a50e7 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -190,6 +190,77 @@ describe("config io write", () => { }); }); + it("preserves root $schema during partial writes", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + $schema: "https://openclaw.ai/config.json", + gateway: { mode: "local" }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + await io.writeConfigFile({ + gateway: { mode: "local", port: 18789 }, + }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + $schema?: string; + gateway?: { mode?: string; port?: number }; + }; + expect(persisted.$schema).toBe("https://openclaw.ai/config.json"); + expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); + }); + }); + + it("does not inject include-only $schema into the root config during partial writes", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const includePath = path.join(home, ".openclaw", "extra.json5"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + includePath, + `${JSON.stringify({ $schema: "https://openclaw.ai/config-from-include.json" }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + configPath, + `{\n "$include": "./extra.json5",\n "gateway": { "mode": "local" }\n}\n`, + "utf-8", + ); + + const io = createConfigIO({ + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + await io.writeConfigFile({ + gateway: { mode: "local", port: 18789 }, + }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + $schema?: string; + gateway?: { mode?: string; port?: number }; + }; + expect(persisted).not.toHaveProperty("$schema"); + expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); + }); + }); + it("writes disabled plugin entries without requiring plugin config", async () => { mockLoadPluginManifestRegistry.mockReturnValue({ diagnostics: [], diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index 95870b4c901..addbd411766 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -415,4 +415,82 @@ describe("config io write prepare", () => { enabled: false, }); }); + + it("preserves root $schema during unrelated partial writes", () => { + const sourceConfig: OpenClawConfig = { + $schema: "https://openclaw.ai/config.json", + gateway: { mode: "local" }, + } satisfies OpenClawConfig; + + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: sourceConfig, + sourceConfig, + nextConfig: { + gateway: { mode: "local", port: 18789 }, + } satisfies OpenClawConfig, + }) as OpenClawConfig; + + expect(persisted.$schema).toBe("https://openclaw.ai/config.json"); + expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); + }); + + it("does not preserve include-only $schema into the root persisted candidate", () => { + const sourceConfig = { + $schema: "https://openclaw.ai/config-from-include.json", + gateway: { mode: "local" }, + }; + + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: sourceConfig, + sourceConfig, + rootAuthoredConfig: { + $include: "./extra.json5", + gateway: { mode: "local" }, + }, + nextConfig: { + gateway: { mode: "local", port: 18789 }, + }, + }) as Record; + + expect(persisted).not.toHaveProperty("$schema"); + expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); + }); + + it("does not restore root $schema when the next config explicitly clears it", () => { + const sourceConfig = { + $schema: "https://openclaw.ai/config.json", + gateway: { mode: "local" }, + }; + + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: sourceConfig, + sourceConfig, + nextConfig: { + $schema: null, + gateway: { mode: "local", port: 18789 }, + }, + }) as Record; + + expect(persisted).not.toHaveProperty("$schema"); + expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); + }); + + it("does not restore root $schema when the next config sets an invalid value", () => { + const sourceConfig = { + $schema: "https://openclaw.ai/config.json", + gateway: { mode: "local" }, + }; + + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: sourceConfig, + sourceConfig, + nextConfig: { + $schema: 123, + gateway: { mode: "local", port: 18789 }, + }, + }) as Record; + + expect(persisted.$schema).toBe(123); + expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); + }); }); diff --git a/src/config/io.write-prepare.ts b/src/config/io.write-prepare.ts index 1346028aa79..9eeb73162e2 100644 --- a/src/config/io.write-prepare.ts +++ b/src/config/io.write-prepare.ts @@ -65,10 +65,45 @@ export function resolvePersistCandidateForWrite(params: { runtimeConfig: unknown; sourceConfig: unknown; nextConfig: unknown; + rootAuthoredConfig?: unknown; }): unknown { const patch = createMergePatch(params.runtimeConfig, params.nextConfig); const projectedSource = projectSourceOntoRuntimeShape(params.sourceConfig, params.runtimeConfig); - return applyMergePatch(projectedSource, patch); + const persisted = applyMergePatch(projectedSource, patch); + return preserveRootSchemaUri({ + rootAuthoredConfig: params.rootAuthoredConfig ?? params.sourceConfig, + nextConfig: params.nextConfig, + persistedCandidate: persisted, + }); +} + +function readRootSchemaUri(value: unknown): string | undefined { + if (!isRecord(value) || typeof value.$schema !== "string") { + return undefined; + } + return value.$schema; +} + +function hasOwnRootSchemaKey(value: unknown): boolean { + return isRecord(value) && Object.prototype.hasOwnProperty.call(value, "$schema"); +} + +function preserveRootSchemaUri(params: { + rootAuthoredConfig: unknown; + nextConfig: unknown; + persistedCandidate: unknown; +}): unknown { + if (hasOwnRootSchemaKey(params.nextConfig)) { + return params.persistedCandidate; + } + const sourceSchema = readRootSchemaUri(params.rootAuthoredConfig); + if (sourceSchema === undefined || !isRecord(params.persistedCandidate)) { + return params.persistedCandidate; + } + return { + ...params.persistedCandidate, + $schema: sourceSchema, + }; } export function formatConfigValidationFailure(pathLabel: string, issueMessage: string): string { diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 2b74a315c18..bac95be0d48 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -30,6 +30,8 @@ import type { SkillsConfig } from "./types.skills.js"; import type { ToolsConfig } from "./types.tools.js"; export type OpenClawConfig = { + /** JSON Schema URL for editor tooling (VS Code, etc.). Preserved across config rewrites. */ + $schema?: string; meta?: { /** Last OpenClaw version that wrote this config. */ lastTouchedVersion?: string;