diff --git a/src/commands/onboard-non-interactive/remote.test.ts b/src/commands/onboard-non-interactive/remote.test.ts index 2a22eb23e6f1..1b6bc8bf6766 100644 --- a/src/commands/onboard-non-interactive/remote.test.ts +++ b/src/commands/onboard-non-interactive/remote.test.ts @@ -201,6 +201,34 @@ describe("runNonInteractiveRemoteSetup", () => { expect(commit?.nextConfig.gateway?.remote).toEqual(remote); }); + it("preserves endpoint-bound config when the remote URL has equivalent spelling", async () => { + const existingRemote = { + url: `${remoteUrl}/`, + token: { source: "env" as const, provider: "default", id: "EXISTING_REMOTE_TOKEN" }, + tlsFingerprint: "sha256:test-fingerprint", + sshTarget: "operator@gateway.example.test", + edgeAuth: { "X-Edge-Auth": "existing-edge-secret" }, + }; + + await runNonInteractiveRemoteSetup({ + opts: { + nonInteractive: true, + mode: "remote", + remoteUrl, + secretInputMode: "ref", + skipHooks: true, + }, + runtime, + baseConfig: { gateway: { mode: "remote", remote: existingRemote } }, + }); + + const commit = commitNonInteractiveOnboardConfigMock.mock.calls[0]?.[0]; + expect(commit?.nextConfig.gateway?.remote).toEqual({ + ...existingRemote, + url: remoteUrl, + }); + }); + it("preserves an existing remote password SecretRef when no replacement is provided", async () => { const remote = { url: remoteUrl, diff --git a/src/commands/onboard-non-interactive/remote.ts b/src/commands/onboard-non-interactive/remote.ts index 6dd88f83260f..618785a2731f 100644 --- a/src/commands/onboard-non-interactive/remote.ts +++ b/src/commands/onboard-non-interactive/remote.ts @@ -13,6 +13,7 @@ import { createGatewayEnvSecretRef } from "../../secrets/ref-contract.js"; import { applySkipBootstrapConfig } from "../onboard-config.js"; import { applyWizardMetadata } from "../onboard-helpers.js"; import { enableDefaultOnboardingInternalHooks } from "../onboard-hooks.js"; +import { remoteGatewayUrlChanged } from "../onboard-remote-url.js"; import type { OnboardOptions } from "../onboard-types.js"; import { commitNonInteractiveOnboardConfig } from "./config-write.js"; @@ -54,7 +55,7 @@ export async function runNonInteractiveRemoteSetup(params: { return; } const existingRemote = baseConfig.gateway?.remote; - const remoteUrlChanged = normalizeOptionalString(existingRemote?.url) !== remoteUrl; + const remoteUrlChanged = remoteGatewayUrlChanged(remoteUrl, existingRemote?.url); // 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 }; diff --git a/src/commands/onboard-remote-url.ts b/src/commands/onboard-remote-url.ts new file mode 100644 index 000000000000..c59857eb7c7d --- /dev/null +++ b/src/commands/onboard-remote-url.ts @@ -0,0 +1,11 @@ +import { gatewayOriginScope } from "../../packages/gateway-client/src/gateway-origin-scope.js"; + +/** Returns whether an explicit remote Gateway URL selects a different endpoint. */ +export function remoteGatewayUrlChanged( + nextUrl: string | undefined, + previousUrl: string | undefined, +): boolean { + return ( + nextUrl !== undefined && gatewayOriginScope(nextUrl) !== gatewayOriginScope(previousUrl ?? "") + ); +} diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 9c0262cc3134..91cba9281639 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -1287,6 +1287,45 @@ describe("runSetupWizard", () => { ); }); + it("reuses stored remote credentials for an equivalent URL spelling", async () => { + const storedRemote = { + url: "wss://stored.example.com:18789", + token: { source: "env" as const, provider: "default", id: "STORED_GATEWAY_TOKEN" }, + tlsFingerprint: "sha256:test-fingerprint", + edgeAuth: { "X-Edge-Auth": "test-secret" }, + }; + readConfigFileSnapshot.mockResolvedValueOnce( + configSnapshot({ gateway: { mode: "remote", remote: storedRemote } }), + ); + + await runSetupWizard( + { + acceptRisk: true, + flow: "advanced", + mode: "remote", + remoteUrl: `${storedRemote.url}/`, + }, + createRuntime(), + buildWizardPrompter({}), + ); + + expect(promptRemoteGatewayConfig).toHaveBeenCalledWith( + expect.objectContaining({ + gateway: expect.objectContaining({ + remote: { + ...storedRemote, + url: `${storedRemote.url}/`, + }, + }), + }), + expect.any(Object), + { + secretInputMode: undefined, + edgeAuthOriginUrl: storedRemote.url, + }, + ); + }); + it("does not probe an invalid CLI remote URL with its token", async () => { const remoteToken = "REDACTED"; validateGatewayWebSocketUrl.mockReturnValueOnce("Use wss:// for public gateways"); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 2387a85aed5f..9f1c5e2faeee 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -4,6 +4,7 @@ import { listAgentEntries } from "../agents/agent-scope-config.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveOnboardingSetupTarget } from "../commands/onboard-agent-target.js"; import * as firstAgentOnboarding from "../commands/onboard-first-agent.js"; +import { remoteGatewayUrlChanged } from "../commands/onboard-remote-url.js"; import type { OnboardMode, OnboardOptions } from "../commands/onboard-types.js"; import { hasResolvedRosterBeforeMigrations } from "../config/agent-roster-provenance.js"; import { ConfigMutationConflictError } from "../config/config.js"; @@ -16,8 +17,7 @@ import { buildPluginCompatibilitySnapshotNotices, formatPluginCompatibilityNotice, } from "../plugins/status.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { defaultRuntime } from "../runtime.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { resolveUserPath } from "../utils.js"; import { t } from "./i18n/index.js"; @@ -366,7 +366,7 @@ async function runSetupWizardOnce( const optionRemoteUrl = normalizeOptionalString(opts.remoteUrl); const optionRemoteToken = normalizeOptionalString(opts.remoteToken); const optionRemotePassword = normalizeOptionalString(opts.remotePassword); - const remoteUrlChanged = opts.remoteUrl !== undefined && optionRemoteUrl !== storedRemoteUrl; + const remoteUrlChanged = remoteGatewayUrlChanged(opts.remoteUrl, storedRemoteUrl); const remoteSeedConfig: OpenClawConfig = opts.remoteUrl === undefined && opts.remoteToken === undefined &&