From dcec0f7e310a9b4905e68437f83f54e2accd4cd9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 00:43:23 -0700 Subject: [PATCH] fix(onboard): remove stale password when switching remote auth (#112544) * fix(onboard): clear stale remote password * test(onboard): isolate remote auth regression --- .../onboard-non-interactive/remote.test.ts | 76 +++++++++++++++++++ .../onboard-non-interactive/remote.ts | 5 +- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/commands/onboard-non-interactive/remote.test.ts diff --git a/src/commands/onboard-non-interactive/remote.test.ts b/src/commands/onboard-non-interactive/remote.test.ts new file mode 100644 index 000000000000..d50f95f5fb59 --- /dev/null +++ b/src/commands/onboard-non-interactive/remote.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { RuntimeEnv } from "../../runtime.js"; + +const commitNonInteractiveOnboardConfigMock = vi.hoisted(() => + vi.fn(async (_params: { nextConfig: OpenClawConfig }) => undefined), +); + +vi.mock("./config-write.js", () => ({ + commitNonInteractiveOnboardConfig: commitNonInteractiveOnboardConfigMock, +})); +vi.mock("../../config/logging.js", () => ({ logConfigUpdated: vi.fn() })); + +const { runNonInteractiveRemoteSetup } = await import("./remote.js"); + +describe("runNonInteractiveRemoteSetup", () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: (code) => { + throw new Error(`unexpected exit ${code}`); + }, + }; + const remoteUrl = "wss://gateway.example.test"; + + beforeEach(() => { + commitNonInteractiveOnboardConfigMock.mockClear(); + }); + + it("clears a stale password when a token replaces auth for the same endpoint", async () => { + await runNonInteractiveRemoteSetup({ + opts: { + nonInteractive: true, + mode: "remote", + remoteUrl, + remoteToken: "replacement-token", + skipHooks: true, + }, + runtime, + baseConfig: { + gateway: { + mode: "remote", + remote: { + url: remoteUrl, + password: "old-password", + tlsFingerprint: "sha256:test-fingerprint", + }, + }, + }, + }); + + const commit = commitNonInteractiveOnboardConfigMock.mock.calls[0]?.[0]; + expect(commit?.nextConfig.gateway?.remote).toEqual({ + url: remoteUrl, + token: "replacement-token", + tlsFingerprint: "sha256:test-fingerprint", + }); + }); + + it("preserves existing auth when no replacement token is provided", async () => { + const remote = { + url: remoteUrl, + token: "existing-token", + password: "existing-password", + }; + + await runNonInteractiveRemoteSetup({ + opts: { nonInteractive: true, mode: "remote", remoteUrl, skipHooks: true }, + runtime, + baseConfig: { gateway: { mode: "remote", remote } }, + }); + + const commit = commitNonInteractiveOnboardConfigMock.mock.calls[0]?.[0]; + expect(commit?.nextConfig.gateway?.remote).toEqual(remote); + }); +}); diff --git a/src/commands/onboard-non-interactive/remote.ts b/src/commands/onboard-non-interactive/remote.ts index 0c9e862dde94..f49a2f1c2e3e 100644 --- a/src/commands/onboard-non-interactive/remote.ts +++ b/src/commands/onboard-non-interactive/remote.ts @@ -45,7 +45,10 @@ export async function runNonInteractiveRemoteSetup(params: { const remoteUrlChanged = normalizeOptionalString(existingRemote?.url) !== remoteUrl; // A remote block belongs to one endpoint. Reusing it for a different URL can // send old credentials or keep routing through the old SSH target. - const preservedRemote = remoteUrlChanged ? {} : existingRemote; + const preservedRemote = remoteUrlChanged ? {} : { ...existingRemote }; + if (remoteToken) { + delete preservedRemote.password; + } let nextConfig: OpenClawConfig = { ...baseConfig,