diff --git a/src/commands/onboard-remote-url.ts b/src/commands/onboard-remote-url.ts index c59857eb7c7d..5fcc2428e836 100644 --- a/src/commands/onboard-remote-url.ts +++ b/src/commands/onboard-remote-url.ts @@ -1,4 +1,4 @@ -import { gatewayOriginScope } from "../../packages/gateway-client/src/gateway-origin-scope.js"; +import { gatewayCredentialScope } from "../../packages/gateway-client/src/gateway-origin-scope.js"; /** Returns whether an explicit remote Gateway URL selects a different endpoint. */ export function remoteGatewayUrlChanged( @@ -6,6 +6,7 @@ export function remoteGatewayUrlChanged( previousUrl: string | undefined, ): boolean { return ( - nextUrl !== undefined && gatewayOriginScope(nextUrl) !== gatewayOriginScope(previousUrl ?? "") + nextUrl !== undefined && + gatewayCredentialScope(nextUrl) !== gatewayCredentialScope(previousUrl ?? "") ); } diff --git a/src/commands/onboard-remote.test.ts b/src/commands/onboard-remote.test.ts index 4a4c62d2a47b..fc233a8c188c 100644 --- a/src/commands/onboard-remote.test.ts +++ b/src/commands/onboard-remote.test.ts @@ -116,6 +116,84 @@ describe("promptRemoteGatewayConfig", () => { expect(next.gateway?.remote?.edgeAuth).toEqual(expected); }); + it("preserves credentials and target settings for an equivalently spelled URL", async () => { + const cfg: OpenClawConfig = { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example/rpc/", + token: "stored-token", + transport: "ssh", + remotePort: 18790, + tlsFingerprint: "sha256:stored", + sshTarget: "user@gateway.example", + sshIdentity: "~/.ssh/id_gateway", + sshHostKeyPolicy: "strict", + }, + }, + }; + const prompter = createPrompter({ + confirm: vi.fn(async (params) => params.message.startsWith("Use existing gateway token")), + select: createSelectPrompter({ + "Gateway auth": "token", + "How do you want to provide this gateway token?": "plaintext", + }), + text: vi.fn(async (params) => + params.message === "Gateway WebSocket URL" ? "wss://gateway.example/rpc" : "", + ) as WizardPrompter["text"], + }); + + const next = await promptRemoteGatewayConfig(cfg, prompter); + + expect(next.gateway?.remote).toEqual({ + ...cfg.gateway?.remote, + url: "wss://gateway.example/rpc", + }); + }); + + it("clears credentials and target settings when the URL query changes", async () => { + const cfg: OpenClawConfig = { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example/rpc?account=personal", + token: "stored-token", + transport: "ssh", + remotePort: 18790, + tlsFingerprint: "sha256:stored", + sshTarget: "user@gateway.example", + sshIdentity: "~/.ssh/id_gateway", + sshHostKeyPolicy: "strict", + }, + }, + }; + const text: WizardPrompter["text"] = vi.fn(async (params) => { + if (params.message === "Gateway WebSocket URL") { + return "wss://gateway.example/rpc?account=work"; + } + if (params.message === "Gateway token") { + return "replacement-token"; + } + return ""; + }) as WizardPrompter["text"]; + const prompter = createPrompter({ + confirm: vi.fn(async () => false), + select: createSelectPrompter({ + "Gateway auth": "token", + "How do you want to provide this gateway token?": "plaintext", + }), + text, + }); + + const next = await promptRemoteGatewayConfig(cfg, prompter); + + expect(next.gateway?.remote).toEqual({ + url: "wss://gateway.example/rpc?account=work", + token: "replacement-token", + }); + expect(vi.mocked(text).mock.calls.map(([params]) => params.message)).toContain("Gateway token"); + }); + it("defaults discovered direct remote URLs to wss://", async () => { detectBinary.mockResolvedValue(true); discoverGatewayBeacons.mockResolvedValue([createGatewayDiscoveryBeacon()]); @@ -419,7 +497,12 @@ describe("promptRemoteGatewayConfig", () => { }); const cfg = { - gateway: { remote: { token: "preexisting-remote-token" } }, + gateway: { + remote: { + url: "wss://remote.example.com:18789", + token: "preexisting-remote-token", + }, + }, } as OpenClawConfig; const prompter = createPrompter({ confirm, select, text }); @@ -457,7 +540,12 @@ describe("promptRemoteGatewayConfig", () => { }); const cfg = { - gateway: { remote: { password: "preexisting-remote-password" } }, + gateway: { + remote: { + url: "wss://remote.example.com:18789", + password: "preexisting-remote-password", + }, + }, } as OpenClawConfig; const prompter = createPrompter({ confirm, select, text }); diff --git a/src/commands/onboard-remote.ts b/src/commands/onboard-remote.ts index 9dca7cdf8fcc..34f994b5e7db 100644 --- a/src/commands/onboard-remote.ts +++ b/src/commands/onboard-remote.ts @@ -1,5 +1,8 @@ import { parseStrictNonNegativeInteger } from "@openclaw/normalization-core/number-coercion"; -import { gatewayOriginScope } from "../../packages/gateway-client/src/gateway-origin-scope.js"; +import { + gatewayCredentialScope, + gatewayOriginScope, +} from "../../packages/gateway-client/src/gateway-origin-scope.js"; /** * Interactive remote gateway onboarding. * @@ -175,6 +178,10 @@ export async function promptRemoteGatewayConfig( validate: (value) => validateGatewayWebSocketUrl(value), }); const url = ensureWsUrl(urlInput); + const storedRemote = cfg.gateway?.remote; + const sameCredentialTarget = + storedRemote?.url !== undefined && + gatewayCredentialScope(url) === gatewayCredentialScope(storedRemote.url); const pinnedDiscoveryFingerprint = discoveryTlsFingerprint && url === trustedDiscoveryUrl ? discoveryTlsFingerprint : undefined; @@ -187,8 +194,8 @@ export async function promptRemoteGatewayConfig( ], }); - let token: SecretInput | undefined = cfg.gateway?.remote?.token; - let password: SecretInput | undefined = cfg.gateway?.remote?.password; + let token: SecretInput | undefined = sameCredentialTarget ? storedRemote.token : undefined; + let password: SecretInput | undefined = sameCredentialTarget ? storedRemote.password : undefined; if (authChoice === "token") { const selectedMode = await resolveSecretInputModeForEnvSelection({ prompter, @@ -290,6 +297,15 @@ export async function promptRemoteGatewayConfig( edgeAuthOriginUrl && gatewayOriginScope(url) === gatewayOriginScope(edgeAuthOriginUrl) ? cfg.gateway?.remote?.edgeAuth : undefined; + const { + url: _storedUrl, + token: _storedToken, + password: _storedPassword, + edgeAuth: _storedEdgeAuth, + tlsFingerprint: storedTlsFingerprint, + ...storedTargetSettings + } = sameCredentialTarget && storedRemote ? storedRemote : {}; + const tlsFingerprint = pinnedDiscoveryFingerprint ?? storedTlsFingerprint; return { ...cfg, @@ -297,11 +313,12 @@ export async function promptRemoteGatewayConfig( ...cfg.gateway, mode: "remote", remote: { + ...storedTargetSettings, url, ...(edgeAuth !== undefined ? { edgeAuth } : {}), ...(token !== undefined ? { token } : {}), ...(password !== undefined ? { password } : {}), - ...(pinnedDiscoveryFingerprint ? { tlsFingerprint: pinnedDiscoveryFingerprint } : {}), + ...(tlsFingerprint ? { tlsFingerprint } : {}), }, }, };