fix(config): preserve authored config writes

This commit is contained in:
Peter Steinberger
2026-04-22 21:50:28 +01:00
parent 5f7b44045d
commit f70a46b703
3 changed files with 209 additions and 20 deletions

View File

@@ -313,7 +313,7 @@ describe("config io write", () => {
});
});
it("does not inject include-only $schema into the root config during partial writes", async () => {
it("rejects root-include partial writes instead of flattening the root config", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const includePath = path.join(home, ".openclaw", "extra.json5");
@@ -328,10 +328,12 @@ describe("config io write", () => {
`{\n "$include": "./extra.json5",\n "gateway": { "mode": "local" }\n}\n`,
"utf-8",
);
const originalRaw = await fs.readFile(configPath, "utf-8");
const persisted = await writeGatewayPortAndReadConfig(home, configPath);
expect(persisted).not.toHaveProperty("$schema");
expect(persisted.gateway).toEqual({ mode: "local", port: 18789 });
await expect(writeGatewayPortAndReadConfig(home, configPath)).rejects.toThrow(
"Config write would flatten $include-owned config at <root>",
);
await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(originalRaw);
});
});

View File

@@ -38,6 +38,111 @@ describe("config io write prepare", () => {
expect(persisted).not.toHaveProperty("sessions.persistence");
});
it("preserves authored source-only nested fields during partial writes", () => {
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: {
plugins: {
entries: {},
},
},
sourceConfig: {
plugins: {
entries: {},
installs: {
"openclaw-web-search": {
source: "npm",
spec: "@ollama/openclaw-web-search",
installPath: "/tmp/openclaw-web-search",
resolvedName: "@ollama/openclaw-web-search",
resolvedVersion: "0.2.2",
},
},
},
},
nextConfig: {
plugins: {
entries: {},
installs: {
"openclaw-web-search": {
source: "npm",
spec: "@ollama/openclaw-web-search@0.2.2",
installPath: "/tmp/openclaw-web-search",
resolvedName: "@ollama/openclaw-web-search",
resolvedVersion: "0.2.2",
},
},
},
},
}) as {
plugins?: {
installs?: Record<string, Record<string, unknown>>;
};
};
expect(persisted.plugins?.installs?.["openclaw-web-search"]).toEqual({
source: "npm",
spec: "@ollama/openclaw-web-search@0.2.2",
installPath: "/tmp/openclaw-web-search",
resolvedName: "@ollama/openclaw-web-search",
resolvedVersion: "0.2.2",
});
});
it("preserves untouched include-owned subtrees during unrelated writes", () => {
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: {
agents: {
defaults: { model: "openai/gpt-5.4" },
},
gateway: { mode: "local" },
},
sourceConfig: {
agents: {
defaults: { model: "openai/gpt-5.4" },
},
gateway: { mode: "local" },
},
rootAuthoredConfig: {
agents: { $include: "./config/agents.json" },
gateway: { mode: "local" },
},
nextConfig: {
agents: {
defaults: { model: "openai/gpt-5.4" },
},
gateway: { mode: "local", port: 18789 },
},
}) as Record<string, unknown>;
expect(persisted.agents).toEqual({ $include: "./config/agents.json" });
expect(persisted.gateway).toEqual({ mode: "local", port: 18789 });
});
it("rejects writes that would flatten include-owned subtrees", () => {
expect(() =>
resolvePersistCandidateForWrite({
runtimeConfig: {
agents: {
defaults: { model: "openai/gpt-5.4" },
},
},
sourceConfig: {
agents: {
defaults: { model: "openai/gpt-5.4" },
},
},
rootAuthoredConfig: {
agents: { $include: "./config/agents.json" },
},
nextConfig: {
agents: {
defaults: { model: "anthropic/sonnet-4.5" },
},
},
}),
).toThrow("Config write would flatten $include-owned config at agents");
});
it('formats actionable guidance for dmPolicy="open" without wildcard allowFrom', () => {
const message = formatConfigValidationFailure(
"channels.telegram.allowFrom",
@@ -434,26 +539,25 @@ describe("config io write prepare", () => {
expect(persisted.gateway).toEqual({ mode: "local", port: 18789 });
});
it("does not preserve include-only $schema into the root persisted candidate", () => {
it("rejects writes that would flatten a root include", () => {
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 });
expect(() =>
resolvePersistCandidateForWrite({
runtimeConfig: sourceConfig,
sourceConfig,
rootAuthoredConfig: {
$include: "./extra.json5",
gateway: { mode: "local" },
},
nextConfig: {
gateway: { mode: "local", port: 18789 },
},
}),
).toThrow("Config write would flatten $include-owned config at <root>");
});
it("does not restore root $schema when the next config explicitly clears it", () => {

View File

@@ -54,6 +54,7 @@ export function projectSourceOntoRuntimeShape(source: unknown, runtime: unknown)
const next: Record<string, unknown> = {};
for (const [key, sourceValue] of Object.entries(source)) {
if (!(key in runtime)) {
next[key] = cloneUnknown(sourceValue);
continue;
}
next[key] = projectSourceOntoRuntimeShape(sourceValue, runtime[key]);
@@ -61,6 +62,84 @@ export function projectSourceOntoRuntimeShape(source: unknown, runtime: unknown)
return next;
}
function hasOwnIncludeKey(value: unknown): value is Record<string, unknown> {
return isRecord(value) && Object.prototype.hasOwnProperty.call(value, "$include");
}
function collectIncludeOwnedPaths(value: unknown, path: string[] = []): string[][] {
if (!isRecord(value)) {
return [];
}
if (hasOwnIncludeKey(value)) {
return [path];
}
return Object.entries(value).flatMap(([key, child]) =>
collectIncludeOwnedPaths(child, [...path, key]),
);
}
function patchTouchesPath(patch: unknown, path: string[]): boolean {
if (path.length === 0) {
return isRecord(patch) ? Object.keys(patch).length > 0 : true;
}
if (!isRecord(patch)) {
return true;
}
const [head, ...tail] = path;
if (!Object.prototype.hasOwnProperty.call(patch, head)) {
return false;
}
return patchTouchesPath(patch[head], tail);
}
function formatConfigPath(path: string[]): string {
return path.length > 0 ? path.join(".") : "<root>";
}
function getPathValue(value: unknown, path: string[]): unknown {
let current = value;
for (const segment of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[segment];
}
return current;
}
function setPathValue(value: unknown, path: string[], nextValue: unknown): unknown {
if (path.length === 0) {
return cloneUnknown(nextValue);
}
if (!isRecord(value)) {
return value;
}
const [head, ...tail] = path;
return {
...value,
[head]: setPathValue(value[head], tail, nextValue),
};
}
function preserveUntouchedIncludes(params: {
patch: unknown;
rootAuthoredConfig: unknown;
persistedCandidate: unknown;
}): unknown {
let next = params.persistedCandidate;
for (const includePath of collectIncludeOwnedPaths(params.rootAuthoredConfig)) {
if (patchTouchesPath(params.patch, includePath)) {
throw new Error(
`Config write would flatten $include-owned config at ${formatConfigPath(
includePath,
)}; edit that include file directly or remove the $include first.`,
);
}
next = setPathValue(next, includePath, getPathValue(params.rootAuthoredConfig, includePath));
}
return next;
}
export function resolvePersistCandidateForWrite(params: {
runtimeConfig: unknown;
sourceConfig: unknown;
@@ -69,7 +148,11 @@ export function resolvePersistCandidateForWrite(params: {
}): unknown {
const patch = createMergePatch(params.runtimeConfig, params.nextConfig);
const projectedSource = projectSourceOntoRuntimeShape(params.sourceConfig, params.runtimeConfig);
const persisted = applyMergePatch(projectedSource, patch);
const persisted = preserveUntouchedIncludes({
patch,
rootAuthoredConfig: params.rootAuthoredConfig ?? params.sourceConfig,
persistedCandidate: applyMergePatch(projectedSource, patch),
});
return preserveRootSchemaUri({
rootAuthoredConfig: params.rootAuthoredConfig ?? params.sourceConfig,
nextConfig: params.nextConfig,