diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 2ab91843515..343be9cb6c4 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -498,7 +498,7 @@ function parseValidateConfigFromRawOrRespond( : restored.result; const validationCandidate = stripBundledProviderRuntimeDefaults({ candidate: projectedValidationCandidate, - sourceConfig: snapshot.parsed, + sourceConfig: snapshot.sourceConfig, }); const sourceValidated = validateConfigObjectRawWithPlugins(validationCandidate); if (!sourceValidated.ok) { @@ -859,7 +859,27 @@ export const configHandlers: GatewayRequestHandlers = { }); return; } - const validated = validateConfigObjectWithPlugins(restoredMerge.result); + const validationCandidate = stripBundledProviderRuntimeDefaults({ + candidate: restoredMerge.result, + sourceConfig: snapshot.sourceConfig, + }); + const sourceValidated = validateConfigObjectRawWithPlugins(validationCandidate); + if (!sourceValidated.ok) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + summarizeConfigValidationIssues(sourceValidated.issues), + { + details: { issues: sourceValidated.issues }, + }, + ), + ); + return; + } + const writeConfig = validationCandidate as OpenClawConfig; + const validated = validateConfigObjectWithPlugins(validationCandidate); if (!validated.ok) { respond( false, @@ -908,7 +928,7 @@ export const configHandlers: GatewayRequestHandlers = { const writeResult = await commitGatewayConfigWrite({ snapshot, writeOptions, - nextConfig: validated.config, + nextConfig: writeConfig, context, disconnectSharedAuthClients, }); diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index 571d46787f5..0676467426c 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -280,6 +280,82 @@ describe("gateway config methods", () => { } }); + it("accepts config.patch when bundled provider baseUrl was only defaulted", async () => { + const { createConfigIO, resetConfigRuntimeState } = await import("../config/config.js"); + const configPath = createConfigIO().configPath; + try { + await writeJsonFile(configPath, { + models: { + providers: { + openai: { + agentRuntime: { id: "openclaw" }, + }, + }, + }, + }); + resetConfigRuntimeState(); + + const current = await getCurrentConfigObject(); + + const res = await rpcReq<{ + ok?: boolean; + error?: { message?: string }; + }>(requireWs(), "config.patch", { + raw: JSON.stringify({ gateway: { port: 19003 } }), + baseHash: current.hash, + }); + + expect(res.error).toBeUndefined(); + expect(res.ok).toBe(true); + const persisted = await fs.readFile(configPath, "utf-8"); + expect(persisted).toContain('"port": 19003'); + expect(persisted).not.toContain('"baseUrl"'); + expect(persisted).not.toContain('"models": []'); + } finally { + await fs.rm(configPath, { force: true }); + resetConfigRuntimeState(); + } + }); + + it("preserves authored empty bundled provider models during config.patch", async () => { + const { createConfigIO, resetConfigRuntimeState } = await import("../config/config.js"); + const configPath = createConfigIO().configPath; + try { + await writeJsonFile(configPath, { + models: { + providers: { + openai: { + agentRuntime: { id: "openclaw" }, + models: [], + }, + }, + }, + }); + resetConfigRuntimeState(); + + const current = await getCurrentConfigObject(); + + const res = await rpcReq<{ + ok?: boolean; + error?: { message?: string }; + }>(requireWs(), "config.patch", { + raw: JSON.stringify({ gateway: { port: 19004 } }), + baseHash: current.hash, + }); + + expect(res.error).toBeUndefined(); + expect(res.ok).toBe(true); + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + models?: { providers?: { openai?: { baseUrl?: unknown; models?: unknown } } }; + }; + expect(persisted.models?.providers?.openai?.baseUrl).toBeUndefined(); + expect(persisted.models?.providers?.openai?.models).toEqual([]); + } finally { + await fs.rm(configPath, { force: true }); + resetConfigRuntimeState(); + } + }); + it("redacts browser cdpUrl credentials from config.get responses", async () => { const { createConfigIO, resetConfigRuntimeState } = await import("../config/config.js"); const configPath = createConfigIO().configPath;