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 <altay@uinaf.dev>
This commit is contained in:
Efe Baran Durmaz
2026-04-18 13:32:50 +03:00
committed by GitHub
parent 26cc1bc681
commit a2eb8fa48f
6 changed files with 198 additions and 1 deletions

View File

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

View File

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

View File

@@ -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: [],

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(persisted.$schema).toBe(123);
expect(persisted.gateway).toEqual({ mode: "local", port: 18789 });
});
});

View File

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

View File

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