From ec3937696791cce3b7271ab94b7352b65ef2e327 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 21:59:01 -0700 Subject: [PATCH] fix(onboard): reject --gateway-bind custom without a valid customBindHost (#127779) Non-interactive onboarding accepted `--gateway-bind custom` with no `gateway.customBindHost`, wrote `bind: "custom"` to the config, and reported success. The Gateway refuses that config outright: Gateway failed to start: gateway.bind=custom requires gateway.customBindHost. `doctor` and `config validate` both call the written config healthy, so the first sign of trouble is a Gateway that will not start. Every other onboarding flag already rejects a mode whose companion value is missing (--gateway-auth password, --auth-choice openai-api-key, --auth-choice custom-api-key). This restores the same contract for bind, reusing the same validateDottedDecimalIPv4Input the interactive wizard and `openclaw configure` apply to this field. Pre-seeding gateway.customBindHost keeps working. --- .../local/gateway-config.test.ts | 56 +++++++++++++++++++ .../local/gateway-config.ts | 19 +++++++ 2 files changed, 75 insertions(+) diff --git a/src/commands/onboard-non-interactive/local/gateway-config.test.ts b/src/commands/onboard-non-interactive/local/gateway-config.test.ts index f5bf344dae47..3f487e422703 100644 --- a/src/commands/onboard-non-interactive/local/gateway-config.test.ts +++ b/src/commands/onboard-non-interactive/local/gateway-config.test.ts @@ -366,4 +366,60 @@ describe("applyNonInteractiveGatewayConfig auth resolution", () => { expect(result?.nextConfig.gateway?.auth).toEqual({ mode: "password" }); }); + + it("rejects --gateway-bind custom when no gateway.customBindHost is configured", () => { + const runtime = createRuntime(); + + const result = applyGatewayConfig({ + opts: { gatewayBind: "custom" } as OnboardOptions, + runtime, + }); + + expect(result).toBeNull(); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("--gateway-bind custom requires gateway.customBindHost"), + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + + it("rejects --gateway-bind custom when gateway.customBindHost is not a dotted-decimal IPv4", () => { + const runtime = createRuntime(); + + const result = applyGatewayConfig({ + nextConfig: { gateway: { customBindHost: "not-an-ip" } } as OpenClawConfig, + opts: { gatewayBind: "custom" } as OnboardOptions, + runtime, + }); + + expect(result).toBeNull(); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Invalid IPv4 address")); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + + it("accepts --gateway-bind custom when gateway.customBindHost is already configured", () => { + const runtime = createRuntime(); + + const result = applyGatewayConfig({ + nextConfig: { gateway: { customBindHost: "192.168.1.100" } } as OpenClawConfig, + opts: { gatewayBind: "custom" } as OnboardOptions, + runtime, + }); + + expect(result?.nextConfig.gateway?.bind).toBe("custom"); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it("keeps loopback normalization ahead of the custom bind guard when Tailscale is enabled", () => { + const runtime = createRuntime(); + + const result = applyGatewayConfig({ + opts: { gatewayBind: "custom", tailscale: "serve" } as OnboardOptions, + runtime, + }); + + expect(result?.nextConfig.gateway?.bind).toBe("loopback"); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/onboard-non-interactive/local/gateway-config.ts b/src/commands/onboard-non-interactive/local/gateway-config.ts index 3e2955c63fc8..eff569b589a4 100644 --- a/src/commands/onboard-non-interactive/local/gateway-config.ts +++ b/src/commands/onboard-non-interactive/local/gateway-config.ts @@ -4,6 +4,7 @@ * This module owns port/bind/auth validation and existing-setting preservation * before the final config write happens. */ +import { validateDottedDecimalIPv4Input } from "@openclaw/net-policy/ipv4"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { formatCliCommand } from "../../../cli/command-format.js"; import { formatInvalidPortOption } from "../../../cli/error-format.js"; @@ -97,6 +98,24 @@ export function applyNonInteractiveGatewayConfig(params: { if (changesBindOrTailscale && tailscaleMode !== "off" && bind !== "loopback") { bind = "loopback"; } + + // bind=custom is only startable alongside a valid gateway.customBindHost, and the non-interactive + // path has no prompt to collect one. Checked after the Tailscale normalization above so a bind + // forced back to loopback never trips it. Without this, setup writes a config the Gateway refuses. + if (bind === "custom") { + const customBindHostIssue = validateDottedDecimalIPv4Input( + normalizeOptionalString(existingGateway?.customBindHost ?? ""), + ); + if (customBindHostIssue) { + const setCommand = formatCliCommand("openclaw config set gateway.customBindHost "); + const interactiveCommand = formatCliCommand("openclaw onboard"); + runtime.error( + `--gateway-bind custom requires gateway.customBindHost: ${customBindHostIssue}. Set it with ${setCommand} and rerun, or run ${interactiveCommand} interactively to be prompted for it.`, + ); + runtime.exit(1); + return null; + } + } const changesAuthOrTailscale = explicitAuthMode !== undefined || hasExplicitTokenAuthInput || opts.tailscale !== undefined; if (changesAuthOrTailscale && tailscaleMode === "serve" && authMode === "none") {