diff --git a/CHANGELOG.md b/CHANGELOG.md index 6832703579f..39b36a559fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Channels/setup: treat bundled channel plugins as already bundled during `channels add` and onboarding, enabling them without writing redundant `plugins.load.paths` entries or path install records. Fixes #72740. Thanks @iCodePoet. - WhatsApp: honor gateway `HTTPS_PROXY` / `HTTP_PROXY` env vars for QR-login WebSocket connections, while respecting `NO_PROXY`, so proxied networks no longer fall back to direct `mmg.whatsapp.net` connections that time out with 408. Fixes #72547; supersedes #72692. Thanks @mebusw and @SymbolStar. +- Bonjour: default mDNS advertisements to the system hostname when it is DNS-safe, avoiding `openclaw.local` probing conflicts and Gateway restart loops on hosts such as `Lobster` or `ubuntu`. Fixes #72355 and #72689; supersedes #72694. Thanks @mscheuerlein-bot, @gcusms, @moyuwuhen601, @pavan987, @zml-0912, @hhq365, and @SymbolStar. - Agents/OpenAI-compatible: retry replay-safe empty `stop` turns once for `openai-completions` endpoints, so transient empty local backend responses no longer surface as “Agent couldn't generate a response” when a continuation succeeds. Fixes #72751. Thanks @moooV252. - Git hooks: skip ignored staged paths when formatting and restaging pre-commit files, so merge commits no longer abort when `.gitignore` newly ignores staged merged content. Fixes #72744. Thanks @100yenadmin. - Memory-core/dreaming: add a supported `dreaming.model` knob for Dream Diary narrative subagents, wired through phase config and the existing plugin subagent model-override trust gate. Refs #65963. Thanks @esqandil and @mjamiv. diff --git a/docs/gateway/bonjour.md b/docs/gateway/bonjour.md index a8c7935c719..d6d2602684d 100644 --- a/docs/gateway/bonjour.md +++ b/docs/gateway/bonjour.md @@ -141,6 +141,12 @@ The Gateway writes a rolling log file (printed on startup as - `bonjour: watchdog detected non-announced service ...` - `bonjour: disabling advertiser after ... failed restarts ...` +Bonjour uses the system hostname for the advertised `.local` host when it is a +valid DNS label. If the system hostname contains spaces, underscores, or another +invalid DNS-label character, OpenClaw falls back to `openclaw.local`. Set +`OPENCLAW_MDNS_HOSTNAME=` before starting the Gateway when you need an +explicit host label. + ## Debugging on iOS node The iOS node uses `NWBrowser` to discover `_openclaw-gw._tcp`. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index c4c3f996df5..0fcb4fea6b2 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -649,7 +649,7 @@ Validation and safety notes: - `minimal` (default): omit `cliPath` + `sshPort` from TXT records. - `full`: include `cliPath` + `sshPort`. -- Hostname defaults to `openclaw`. Override with `OPENCLAW_MDNS_HOSTNAME`. +- Hostname defaults to the system hostname when it is a valid DNS label, falling back to `openclaw`. Override with `OPENCLAW_MDNS_HOSTNAME`. ### Wide-area (DNS-SD) diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index 18dfaa8d4fc..219b608b083 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -704,11 +704,57 @@ describe("gateway bonjour advertiser", () => { }); const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>; - expect(gatewayCall?.[0]?.name).toBe("openclaw (OpenClaw)"); + expect(gatewayCall?.[0]?.name).toBe("Mac (OpenClaw)"); expect(gatewayCall?.[0]?.domain).toBe("local"); + expect(gatewayCall?.[0]?.hostname).toBe("Mac"); + expect((gatewayCall?.[0]?.txt as Record)?.lanHost).toBe("Mac.local"); + + await started.stop(); + }); + + it("falls back to openclaw when system hostname is invalid for DNS", async () => { + // Allow advertiser to run in unit tests. + delete process.env.VITEST; + process.env.NODE_ENV = "development"; + delete process.env.OPENCLAW_MDNS_HOSTNAME; + vi.spyOn(os, "hostname").mockReturnValue("My_Lobster Host"); + + const destroy = vi.fn().mockResolvedValue(undefined); + const advertise = vi.fn().mockResolvedValue(undefined); + mockCiaoService({ advertise, destroy }); + + const started = await startAdvertiser({ + gatewayPort: 18789, + sshPort: 2222, + }); + + const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>; expect(gatewayCall?.[0]?.hostname).toBe("openclaw"); expect((gatewayCall?.[0]?.txt as Record)?.lanHost).toBe("openclaw.local"); await started.stop(); }); + + it("uses system hostname when OPENCLAW_MDNS_HOSTNAME is unset", async () => { + // Allow advertiser to run in unit tests. + delete process.env.VITEST; + process.env.NODE_ENV = "development"; + delete process.env.OPENCLAW_MDNS_HOSTNAME; + vi.spyOn(os, "hostname").mockReturnValue("Lobster"); + + const destroy = vi.fn().mockResolvedValue(undefined); + const advertise = vi.fn().mockResolvedValue(undefined); + mockCiaoService({ advertise, destroy }); + + const started = await startAdvertiser({ + gatewayPort: 18789, + sshPort: 2222, + }); + + const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>; + expect(gatewayCall?.[0]?.hostname).toBe("Lobster"); + expect((gatewayCall?.[0]?.txt as Record)?.lanHost).toBe("Lobster.local"); + + await started.stop(); + }); }); diff --git a/extensions/bonjour/src/advertiser.ts b/extensions/bonjour/src/advertiser.ts index caa61022119..f258c22d158 100644 --- a/extensions/bonjour/src/advertiser.ts +++ b/extensions/bonjour/src/advertiser.ts @@ -1,6 +1,7 @@ import type { ChildProcess } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; +import os from "node:os"; import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; import { classifyCiaoProcessError, type CiaoProcessErrorClassification } from "./ciao.js"; @@ -160,6 +161,28 @@ function isDisabledByEnv() { return false; } +function resolveSystemMdnsHostname(): string | null { + let raw: string; + try { + raw = os.hostname(); + } catch { + return null; + } + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const firstLabel = + trimmed + .replace(/\.local$/i, "") + .split(".")[0] + ?.trim() ?? ""; + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(firstLabel)) { + return null; + } + return firstLabel; +} + function safeServiceName(name: string) { const trimmed = name.trim(); return trimmed.length > 0 ? trimmed : "OpenClaw"; @@ -328,7 +351,8 @@ export async function startGatewayBonjourAdvertiser( cleanupUnhandledRejection = deps.registerUnhandledRejectionHandler?.(handleCiaoProcessError); cleanupUncaughtException = deps.registerUncaughtExceptionHandler?.(handleCiaoProcessError); - const hostnameRaw = process.env.OPENCLAW_MDNS_HOSTNAME?.trim() || "openclaw"; + const hostnameRaw = + process.env.OPENCLAW_MDNS_HOSTNAME?.trim() || resolveSystemMdnsHostname() || "openclaw"; const hostname = hostnameRaw .replace(/\.local$/i, "")