mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:40:43 +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) => {
|
await withSuiteHome(async (home) => {
|
||||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||||
const includePath = path.join(home, ".openclaw", "extra.json5");
|
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`,
|
`{\n "$include": "./extra.json5",\n "gateway": { "mode": "local" }\n}\n`,
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
const originalRaw = await fs.readFile(configPath, "utf-8");
|
||||||
|
|
||||||
const persisted = await writeGatewayPortAndReadConfig(home, configPath);
|
await expect(writeGatewayPortAndReadConfig(home, configPath)).rejects.toThrow(
|
||||||
expect(persisted).not.toHaveProperty("$schema");
|
"Config write would flatten $include-owned config at <root>",
|
||||||
expect(persisted.gateway).toEqual({ mode: "local", port: 18789 });
|
);
|
||||||
|
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");
|
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', () => {
|
it('formats actionable guidance for dmPolicy="open" without wildcard allowFrom', () => {
|
||||||
const message = formatConfigValidationFailure(
|
const message = formatConfigValidationFailure(
|
||||||
"channels.telegram.allowFrom",
|
"channels.telegram.allowFrom",
|
||||||
@@ -434,26 +539,25 @@ describe("config io write prepare", () => {
|
|||||||
expect(persisted.gateway).toEqual({ mode: "local", port: 18789 });
|
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 = {
|
const sourceConfig = {
|
||||||
$schema: "https://openclaw.ai/config-from-include.json",
|
$schema: "https://openclaw.ai/config-from-include.json",
|
||||||
gateway: { mode: "local" },
|
gateway: { mode: "local" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const persisted = resolvePersistCandidateForWrite({
|
expect(() =>
|
||||||
runtimeConfig: sourceConfig,
|
resolvePersistCandidateForWrite({
|
||||||
sourceConfig,
|
runtimeConfig: sourceConfig,
|
||||||
rootAuthoredConfig: {
|
sourceConfig,
|
||||||
$include: "./extra.json5",
|
rootAuthoredConfig: {
|
||||||
gateway: { mode: "local" },
|
$include: "./extra.json5",
|
||||||
},
|
gateway: { mode: "local" },
|
||||||
nextConfig: {
|
},
|
||||||
gateway: { mode: "local", port: 18789 },
|
nextConfig: {
|
||||||
},
|
gateway: { mode: "local", port: 18789 },
|
||||||
}) as Record<string, unknown>;
|
},
|
||||||
|
}),
|
||||||
expect(persisted).not.toHaveProperty("$schema");
|
).toThrow("Config write would flatten $include-owned config at <root>");
|
||||||
expect(persisted.gateway).toEqual({ mode: "local", port: 18789 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not restore root $schema when the next config explicitly clears it", () => {
|
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> = {};
|
const next: Record<string, unknown> = {};
|
||||||
for (const [key, sourceValue] of Object.entries(source)) {
|
for (const [key, sourceValue] of Object.entries(source)) {
|
||||||
if (!(key in runtime)) {
|
if (!(key in runtime)) {
|
||||||
|
next[key] = cloneUnknown(sourceValue);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
next[key] = projectSourceOntoRuntimeShape(sourceValue, runtime[key]);
|
next[key] = projectSourceOntoRuntimeShape(sourceValue, runtime[key]);
|
||||||
@@ -61,6 +62,84 @@ export function projectSourceOntoRuntimeShape(source: unknown, runtime: unknown)
|
|||||||
return next;
|
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: {
|
export function resolvePersistCandidateForWrite(params: {
|
||||||
runtimeConfig: unknown;
|
runtimeConfig: unknown;
|
||||||
sourceConfig: unknown;
|
sourceConfig: unknown;
|
||||||
@@ -69,7 +148,11 @@ export function resolvePersistCandidateForWrite(params: {
|
|||||||
}): unknown {
|
}): unknown {
|
||||||
const patch = createMergePatch(params.runtimeConfig, params.nextConfig);
|
const patch = createMergePatch(params.runtimeConfig, params.nextConfig);
|
||||||
const projectedSource = projectSourceOntoRuntimeShape(params.sourceConfig, params.runtimeConfig);
|
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({
|
return preserveRootSchemaUri({
|
||||||
rootAuthoredConfig: params.rootAuthoredConfig ?? params.sourceConfig,
|
rootAuthoredConfig: params.rootAuthoredConfig ?? params.sourceConfig,
|
||||||
nextConfig: params.nextConfig,
|
nextConfig: params.nextConfig,
|
||||||
|
|||||||
Reference in New Issue
Block a user