diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 3acba242f19..b019b8442ee 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -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 ", + ); + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(originalRaw); }); }); diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index addbd411766..6f81f57d818 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -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>; + }; + }; + + 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; + + 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; - - 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 "); }); it("does not restore root $schema when the next config explicitly clears it", () => { diff --git a/src/config/io.write-prepare.ts b/src/config/io.write-prepare.ts index 9eeb73162e2..8ef00b162dd 100644 --- a/src/config/io.write-prepare.ts +++ b/src/config/io.write-prepare.ts @@ -54,6 +54,7 @@ export function projectSourceOntoRuntimeShape(source: unknown, runtime: unknown) const next: Record = {}; 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 { + 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(".") : ""; +} + +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,