mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix(config): preserve authored config writes
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user