mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user