diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 1623a148509..b4ac8c32d32 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -1,4 +1,5 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { isLoopbackHost } from "../net.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { CONFIG_PATH, @@ -249,7 +250,7 @@ function loadSchemaWithPlugins(): ConfigSchemaResponse { * which defaults to "loopback"). Rejecting at config-write time prevents the * gateway from entering an unrecoverable crash loop on next restart. */ -function validateTailscaleBindCompat(config: OpenClawConfig): string | null { +export function validateTailscaleBindCompat(config: OpenClawConfig): string | null { const tailscaleMode = config.gateway?.tailscale?.mode; if (tailscaleMode !== "serve" && tailscaleMode !== "funnel") { return null; @@ -258,6 +259,15 @@ function validateTailscaleBindCompat(config: OpenClawConfig): string | null { if (bind === "loopback") { return null; } + // A custom bind with a loopback IP is equivalent to bind=loopback at runtime + // (server-runtime-config.ts uses isLoopbackHost on the resolved IP). Allow it + // at write-time too so we don't reject a valid config. + if (bind === "custom") { + const customBindHost = config.gateway?.customBindHost?.trim(); + if (customBindHost && isLoopbackHost(customBindHost)) { + return null; + } + } return `gateway.tailscale.mode="${tailscaleMode}" requires gateway.bind="loopback", but gateway.bind="${bind}". Change gateway.bind to "loopback" or set gateway.tailscale.mode to "off".`; }