diff --git a/CHANGELOG.md b/CHANGELOG.md index 0432b2f2d86..f165aa6172b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Memory/dreaming: strip managed Light Sleep and REM blocks before daily-note ingestion so dreaming summaries stop re-ingesting their own staged output into new candidates. (#61720) Thanks @MonkeyLeeT. - Plugins/Windows: disable native Jiti loading for setup and doctor contract registries on Windows so onboarding and config-doctor plugin probes stop crashing with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. (#61836, #61853) - Agents/context overflow: combine oversized and aggregate tool-result recovery in one repair pass, and restore a total-context overflow backstop during tool loops so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman. +- Gateway/containers: auto-bind to `0.0.0.0` during container startup for Docker and Podman compatibility, while keeping host-side status and doctor checks on the hardened loopback default when `gateway.bind` is unset. (#61818) Thanks @openperf. ## 2026.4.5 ### Breaking diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index e14f01ec55d..5d09407f3fd 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -241,6 +241,24 @@ describe("gatherDaemonStatus", () => { expect(loadConfigCalls).not.toHaveBeenCalled(); }); + it("defaults unset daemon bind mode to loopback for host-side status reporting", async () => { + daemonLoadedConfig = { + gateway: { + tls: { enabled: true }, + auth: { token: "daemon-token" }, + }, + }; + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(resolveGatewayBindHost).toHaveBeenCalledWith("loopback", undefined); + expect(status.gateway?.bindMode).toBe("loopback"); + }); + it("does not force local TLS fingerprint when probe URL is explicitly overridden", async () => { const status = await gatherDaemonStatus({ rpc: { url: "wss://override.example:18790" }, @@ -474,7 +492,9 @@ describe("gatherDaemonStatus", () => { }); expect(status.rpc?.ok).toBe(false); - expect(status.rpc?.authWarning).toContain("gateway.auth.token SecretRef is unavailable"); + expect(status.rpc?.authWarning).toContain( + "gateway.auth.token SecretRef is unresolved in this command path", + ); expect(status.rpc?.authWarning).toContain("probing without configured auth credentials"); }); diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index bd79691685a..86985e39080 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -16,7 +16,6 @@ import type { ServiceConfigAudit } from "../../daemon/service-audit.js"; import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { trimToUndefined } from "../../gateway/credentials.js"; -import { defaultGatewayBindMode } from "../../gateway/net.js"; import { inspectBestEffortPrimaryTailnetIPv4, resolveBestEffortGatewayBindHostForDisplay, @@ -261,9 +260,7 @@ async function resolveGatewayStatusSummary(params: { const portSource: GatewayStatusSummary["portSource"] = portFromArgs ? "service args" : "env/config"; - const statusTailscaleMode = params.daemonCfg.gateway?.tailscale?.mode ?? "off"; - const bindMode: GatewayBindMode = - params.daemonCfg.gateway?.bind ?? defaultGatewayBindMode(statusTailscaleMode); + const bindMode: GatewayBindMode = params.daemonCfg.gateway?.bind ?? "loopback"; const customBindHost = params.daemonCfg.gateway?.customBindHost; const { bindHost, warning: bindHostWarning } = await resolveBestEffortGatewayBindHostForDisplay({ bindMode, diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index af436ddcbe6..085b5fdf170 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -118,6 +118,14 @@ describe("noteSecurityWarnings gateway exposure", () => { expect(message).not.toContain("Gateway bound"); }); + it("treats unset bind as loopback for host-side doctor checks", async () => { + const cfg = { gateway: {} } as OpenClawConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).toContain("No channel security warnings detected"); + expect(message).not.toContain("Gateway bound"); + }); + it("shows explicit dmScope config command for multi-user DMs", async () => { pluginRegistry.list = [ { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 443f4bbe330..1bf0ef1fb64 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -5,7 +5,7 @@ import type { OpenClawConfig, GatewayBindMode } from "../config/config.js"; import type { AgentConfig } from "../config/types.agents.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; -import { defaultGatewayBindMode, isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; +import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; import { resolveExecPolicyScopeSnapshot } from "../infra/exec-approvals-effective.js"; import { loadExecApprovals, type ExecAsk, type ExecSecurity } from "../infra/exec-approvals.js"; import { resolveDmAllowState } from "../security/dm-policy-shared.js"; @@ -184,7 +184,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { // that expose the gateway to network without proper auth const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; - const gatewayBind = (cfg.gateway?.bind ?? defaultGatewayBindMode(tailscaleMode)) as string; + const gatewayBind = (cfg.gateway?.bind ?? "loopback") as string; const customBindHost = cfg.gateway?.customBindHost?.trim(); const bindModes: GatewayBindMode[] = ["auto", "lan", "loopback", "custom", "tailnet"]; const bindMode = bindModes.includes(gatewayBind as GatewayBindMode) diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 319f1b54f2c..5122be8f2b3 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -483,6 +483,17 @@ describe("isContainerEnvironment", () => { expect(isContainerEnvironment()).toBe(true); }); + it("returns true when /run/.containerenv exists", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation((filePath: unknown) => { + if (filePath === "/run/.containerenv") { + return undefined; + } + throw new Error("ENOENT"); + }); + expect(isContainerEnvironment()).toBe(true); + }); + it("returns true when /proc/1/cgroup contains docker marker", () => { const fs = require("node:fs"); vi.spyOn(fs, "accessSync").mockImplementation(() => { diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 3b7e714c874..b4696a91bf4 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -228,7 +228,8 @@ export function isLocalGatewayAddress(ip: string | undefined): boolean { * (Docker, Podman, or Kubernetes). * * Uses two reliable heuristics: - * 1. Presence of `/.dockerenv` (set by Docker and Podman). + * 1. Presence of well-known container sentinel files such as `/.dockerenv` + * (Docker) or `/run/.containerenv` (Podman). * 2. Presence of container-related cgroup entries in `/proc/1/cgroup` * (covers Docker, containerd, and Kubernetes pods). * @@ -245,12 +246,14 @@ export function isContainerEnvironment(): boolean { } function detectContainerEnvironment(): boolean { - // 1. /.dockerenv exists in Docker and Podman containers. - try { - fs.accessSync("/.dockerenv", fs.constants.F_OK); - return true; - } catch { - // not present — continue + // 1. Check common Docker/Podman container sentinel files. + for (const sentinelPath of ["/.dockerenv", "/run/.containerenv", "/var/run/.containerenv"]) { + try { + fs.accessSync(sentinelPath, fs.constants.F_OK); + return true; + } catch { + // not present — continue + } } // 2. /proc/1/cgroup contains docker, containerd, kubepods, or lxc markers. // Covers both cgroup v1 (/docker/, /kubepods/...) and cgroup v2 @@ -354,9 +357,9 @@ export async function resolveGatewayBindHost( * returns `"loopback"` because Tailscale serve/funnel architecturally requires * a loopback bind — container auto-detection must never override this. * - * This function is the **single source of truth** for the unset-bind default - * and MUST be used by all codepaths that need the effective bind mode (runtime - * config, CLI startup, doctor diagnostics, status gathering, etc.). + * Use this only in gateway startup codepaths that execute in the same + * environment as the eventual bind decision. Host-side diagnostics should keep + * their own explicit defaults instead of inferring from the caller process. */ export function defaultGatewayBindMode( tailscaleMode?: string,