fix(device-pair): reject invalid remote setup URLs

Fail setup-code generation when gateway.remote.url is configured but malformed, instead of falling back to a bind-derived URL and issuing a bootstrap token.
This commit is contained in:
clawsweeper[bot]
2026-04-29 20:45:33 +01:00
committed by GitHub
parent 21b3eb5c34
commit 7c51cd2baf
5 changed files with 80 additions and 10 deletions

View File

@@ -70,6 +70,7 @@ type ApprovedPairingDevice = ApprovedPairingResult["device"];
const INTERNAL_PAIRING_SCOPES = ["operator.write", "operator.pairing"];
function createApi(params?: {
config?: OpenClawPluginApi["config"];
runtime?: OpenClawPluginApi["runtime"];
pluginConfig?: Record<string, unknown>;
registerCommand?: (command: OpenClawPluginCommandDefinition) => void;
@@ -78,7 +79,7 @@ function createApi(params?: {
id: "device-pair",
name: "device-pair",
source: "test",
config: {
config: params?.config ?? {
gateway: {
auth: {
mode: "token",
@@ -96,6 +97,7 @@ function createApi(params?: {
}
function registerPairCommand(params?: {
config?: OpenClawPluginApi["config"];
runtime?: OpenClawPluginApi["runtime"];
pluginConfig?: Record<string, unknown>;
}): OpenClawPluginCommandDefinition {
@@ -649,6 +651,36 @@ describe("device-pair /pair default setup code", () => {
expect(result).toEqual({ text: "Error: Configured publicUrl is invalid." });
});
it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => {
const command = registerPairCommand({
config: {
gateway: {
bind: "custom",
customBindHost: "127.0.0.1",
remote: { url: "http://localhost:notaport" },
auth: {
mode: "token",
token: "gateway-token",
},
},
},
pluginConfig: {
publicUrl: undefined,
},
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "",
commandBody: "/pair",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(result).toEqual({ text: "Error: Configured gateway.remote.url is invalid." });
});
it.each([
"http://localhost:notaport",
"http:gateway.example.test",

View File

@@ -321,6 +321,12 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResu
return { error: "Configured publicUrl is invalid." };
}
const configuredRemoteUrl = normalizeOptionalString(cfg.gateway?.remote?.url);
const remoteUrl = configuredRemoteUrl ? normalizeUrl(configuredRemoteUrl, scheme) : null;
if (configuredRemoteUrl && !remoteUrl) {
return { error: "Configured gateway.remote.url is invalid." };
}
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
if (tailscaleMode === "serve" || tailscaleMode === "funnel") {
const host = await resolveTailnetHost();
@@ -330,12 +336,8 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResu
return { url: `wss://${host}`, source: `gateway.tailscale.mode=${tailscaleMode}` };
}
const remoteUrl = normalizeOptionalString(cfg.gateway?.remote?.url);
if (remoteUrl) {
const url = normalizeUrl(remoteUrl, scheme);
if (url) {
return { url, source: "gateway.remote.url" };
}
return { url: remoteUrl, source: "gateway.remote.url" };
}
const bindResult = resolveGatewayBindUrl({

View File

@@ -413,6 +413,23 @@ describe("registerQrCli", () => {
);
});
it("rejects invalid gateway.remote.url before printing remote setup codes", async () => {
loadConfig.mockReturnValue({
gateway: {
bind: "custom",
customBindHost: "127.0.0.1",
remote: { url: "http://localhost:notaport", token: "remote-tok" },
auth: { mode: "token", token: "local-tok" },
},
});
await expectQrExit(["--setup-code-only", "--remote"]);
const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
expect(output).toContain("Configured gateway.remote.url is invalid.");
expect(runtime.log).not.toHaveBeenCalled();
});
it("logs remote secret diagnostics in non-json output mode", async () => {
loadConfig.mockReturnValue(createRemoteQrConfig());
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({

View File

@@ -222,6 +222,24 @@ describe("pairing setup code", () => {
});
});
it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => {
await expectResolvedSetupFailureCase({
config: {
gateway: {
bind: "custom",
customBindHost: "127.0.0.1",
remote: { url: "http://localhost:notaport" },
auth: { mode: "token", token: "tok_123" },
},
},
options: {
preferRemoteUrl: true,
},
expectedError: "Configured gateway.remote.url is invalid.",
});
expect(issueDeviceBootstrapTokenMock).not.toHaveBeenCalled();
});
it.each([
"localhost:notaport",
"http://localhost:notaport",

View File

@@ -285,10 +285,11 @@ async function resolveGatewayUrl(
}
const remoteUrlRaw = cfg.gateway?.remote?.url;
const remoteUrl =
typeof remoteUrlRaw === "string" && remoteUrlRaw.trim()
? normalizeUrl(remoteUrlRaw, scheme)
: null;
const hasRemoteUrl = typeof remoteUrlRaw === "string" && remoteUrlRaw.trim();
const remoteUrl = hasRemoteUrl ? normalizeUrl(remoteUrlRaw, scheme) : null;
if (hasRemoteUrl && !remoteUrl) {
return { error: "Configured gateway.remote.url is invalid." };
}
if (opts.preferRemoteUrl && remoteUrl) {
return { url: remoteUrl, source: "gateway.remote.url" };
}