From 1878272f67c24b37c5094b49e7cdb8a9c100144b Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:05:43 -0500 Subject: [PATCH] CLI: prune inactive gateway auth credentials on mode set (#50639) --- src/cli/config-cli.test.ts | 88 ++++++++++++++++++++++++++++++++++++++ src/cli/config-cli.ts | 54 +++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index ded6ad806da..d30a476004d 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -209,6 +209,94 @@ describe("config cli", () => { apiKey: "ollama-local", // pragma: allowlist secret }); }); + + it("drops gateway.auth.password when switching mode to token", async () => { + const resolved: OpenClawConfig = { + gateway: { + auth: { + mode: "password", + token: "token-keep", + password: "password-drop", // pragma: allowlist secret + allowTailscale: true, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "set", "gateway.auth.mode", "token"]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.gateway?.auth).toEqual({ + mode: "token", + token: "token-keep", + allowTailscale: true, + }); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + "Removed inactive gateway.auth.password for gateway.auth.mode=token", + ), + ); + }); + + it("drops gateway.auth.token when switching mode to password", async () => { + const resolved: OpenClawConfig = { + gateway: { + auth: { + mode: "token", + token: "token-drop", + password: "password-keep", // pragma: allowlist secret + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "set", "gateway.auth.mode", "password"]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.gateway?.auth).toEqual({ + mode: "password", + password: "password-keep", // pragma: allowlist secret + }); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + "Removed inactive gateway.auth.token for gateway.auth.mode=password", + ), + ); + }); + + it("applies mode-based credential cleanup using the final batch result", async () => { + const resolved: OpenClawConfig = { + gateway: { + auth: { + mode: "password", + token: "token-keep", + password: "password-drop", // pragma: allowlist secret + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "--batch-json", + '[{"path":"gateway.auth.password","value":"password-updated"},{"path":"gateway.auth.mode","value":"token"}]', + ]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.gateway?.auth).toEqual({ + mode: "token", + token: "token-keep", + }); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + "Removed inactive gateway.auth.password for gateway.auth.mode=token", + ), + ); + }); }); describe("config get", () => { diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 8ec98f1804d..604e27666c9 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -69,6 +69,7 @@ type ConfigSetOperation = { const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"]; const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"]; +const GATEWAY_AUTH_MODE_PATH: PathSegment[] = ["gateway", "auth", "mode"]; const SECRET_PROVIDER_PATH_PREFIX: PathSegment[] = ["secrets", "providers"]; const CONFIG_SET_EXAMPLE_VALUE = formatCliCommand( "openclaw config set gateway.port 19001 --strict-json", @@ -352,6 +353,48 @@ function ensureValidOllamaProviderForApiKeySet( }); } +function pruneInactiveGatewayAuthCredentials(params: { + root: Record; + operations: ConfigSetOperation[]; +}): string[] { + const touchedGatewayAuthMode = params.operations.some((operation) => + pathEquals(operation.requestedPath, GATEWAY_AUTH_MODE_PATH), + ); + if (!touchedGatewayAuthMode) { + return []; + } + + const gatewayRaw = params.root.gateway; + if (!gatewayRaw || typeof gatewayRaw !== "object" || Array.isArray(gatewayRaw)) { + return []; + } + const gateway = gatewayRaw as Record; + const authRaw = gateway.auth; + if (!authRaw || typeof authRaw !== "object" || Array.isArray(authRaw)) { + return []; + } + const auth = authRaw as Record; + const mode = typeof auth.mode === "string" ? auth.mode.trim() : ""; + + const removedPaths: string[] = []; + const remove = (key: "token" | "password") => { + if (Object.hasOwn(auth, key)) { + delete auth[key]; + removedPaths.push(`gateway.auth.${key}`); + } + }; + + if (mode === "token") { + remove("password"); + } else if (mode === "password") { + remove("token"); + } else if (mode === "trusted-proxy") { + remove("token"); + remove("password"); + } + return removedPaths; +} + function toDotPath(path: PathSegment[]): string { return path.join("."); } @@ -964,6 +1007,10 @@ export async function runConfigSet(opts: { ensureValidOllamaProviderForApiKeySet(next, operation.setPath); setAtPath(next, operation.setPath, operation.value); } + const removedGatewayAuthPaths = pruneInactiveGatewayAuthCredentials({ + root: next, + operations, + }); const nextConfig = next as OpenClawConfig; if (opts.cliOptions.dryRun) { @@ -1051,6 +1098,13 @@ export async function runConfigSet(opts: { } await writeConfigFile(next); + if (removedGatewayAuthPaths.length > 0) { + runtime.log( + info( + `Removed inactive ${removedGatewayAuthPaths.join(", ")} for gateway.auth.mode=${String(nextConfig.gateway?.auth?.mode ?? "")}.`, + ), + ); + } if (operations.length === 1) { runtime.log( info(