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.
This commit is contained in:
Peter Steinberger
2026-08-21 21:59:01 -07:00
committed by GitHub
parent de4fbf964f
commit ec39376967
2 changed files with 75 additions and 0 deletions
@@ -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();
});
});
@@ -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 <ipv4>");
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") {