diff --git a/CHANGELOG.md b/CHANGELOG.md index 737ababe3bc..2d12a496ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ Docs: https://docs.openclaw.ai - Memory-core/dreaming: give narrative generation a 60-second timeout so slower local or remote models can finish instead of timing out at 15 seconds. Fixes #72837. (#72852) Thanks @RayWoo. - Plugins/hooks: inject each plugin's resolved config into internal hook event context without mutating the shared event object. (#72888) Thanks @jalapeno777. - Agents/ACP: pass the resolved ACP agent directory into media understanding so per-agent media caches and config are used for ACP-dispatched image turns. (#72832) Thanks @luyao618. -- Gateway/Bonjour: truncate mDNS service names and host labels to the 63-byte DNS label limit without splitting multibyte characters. (#72809) Thanks @luyao618. +- Gateway/Bonjour: truncate mDNS service names and host labels to the 63-byte DNS label limit at valid UTF-8 boundaries. (#72809) Thanks @luyao618. - Feishu: treat groups explicitly configured under channels.feishu.groups as admitted even when groupAllowFrom is empty, so per-group mention overrides work with the default allowlist policy. Fixes #67687. (#72789) Thanks @MoerAI. - Gateway/startup: keep hot Gateway boot paths on leaf config imports and add max-RSS reporting to the gateway startup bench so low-memory startup regressions are visible before release. Thanks @vincentkoc. - WebChat: read `chat.history` from active transcript branches, drop stale streamed assistant tails once final history catches up, and coalesce duplicate in-flight Control UI submits, so rewritten prompts, completed replies, and rapid send events no longer render or process twice. Fixes #72975, #72963, and #72974. Thanks @dmagdici, @lhtpluto, and @Benjamin5281999. diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index 939082acba1..36fe6332d4a 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -29,10 +29,19 @@ const { registerUnhandledRejectionHandler, logger, } = mocks; +const dnsLabelEncoder = new TextEncoder(); const asString = (value: unknown, fallback: string) => typeof value === "string" && value.trim() ? value : fallback; +function expectDnsLabelByteLength(value: string, expected: number) { + expect(dnsLabelEncoder.encode(value).byteLength).toBe(expected); +} + +function expectDnsLabelWithinLimit(value: string) { + expect(dnsLabelEncoder.encode(value).byteLength).toBeLessThanOrEqual(63); +} + function enableAdvertiserUnitMode(hostname = "test-host") { // Allow advertiser to run in unit tests. delete process.env.VITEST; @@ -735,8 +744,32 @@ describe("gateway bonjour advertiser", () => { await started.stop(); }); - it("truncates service name exceeding 63-byte DNS label limit", async () => { - const longHostname = "app-41627eae5842473f9e05f139ea307277-7f9477f4d6-lqqzf-abcdefghi"; + it("truncates reported Kubernetes service name at the DNS label byte limit", async () => { + const reportedHostname = "app-41627eae5842473f9e05f139ea307277-7f9477f4d6-lqqzf"; + enableAdvertiserUnitMode(reportedHostname); + + 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]>; + const serviceName = gatewayCall?.[0]?.name as string; + const hostname = gatewayCall?.[0]?.hostname as string; + + expectDnsLabelByteLength(`${reportedHostname} (OpenClaw)`, 64); + expect(hostname).toBe(reportedHostname); + expectDnsLabelWithinLimit(serviceName); + + await started.stop(); + }); + + it("truncates host labels exceeding the 63-byte DNS label limit", async () => { + const longHostname = "app-41627eae5842473f9e05f139ea307277-7f9477f4d6-lqqzf-abcdefghij"; enableAdvertiserUnitMode(longHostname); const destroy = vi.fn().mockResolvedValue(undefined); @@ -752,10 +785,11 @@ describe("gateway bonjour advertiser", () => { const serviceName = gatewayCall?.[0]?.name as string; const hostname = gatewayCall?.[0]?.hostname as string; - // Both name and hostname must be within the 63-byte DNS label limit - expect(new TextEncoder().encode(serviceName).byteLength).toBeLessThanOrEqual(63); - expect(new TextEncoder().encode(hostname).byteLength).toBeLessThanOrEqual(63); + expectDnsLabelByteLength(longHostname, 64); + expectDnsLabelByteLength(hostname, 63); + expect(hostname).toBe(longHostname.slice(0, -1)); expect(hostname).not.toMatch(/-$/); + expectDnsLabelWithinLimit(serviceName); await started.stop(); }); @@ -777,8 +811,7 @@ describe("gateway bonjour advertiser", () => { const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>; const serviceName = gatewayCall?.[0]?.name as string; - expect(new TextEncoder().encode(serviceName).byteLength).toBeLessThanOrEqual(63); - // Should not end with a replacement character from incomplete multi-byte truncation + expectDnsLabelWithinLimit(serviceName); expect(serviceName).not.toMatch(/\uFFFD$/); await started.stop(); diff --git a/extensions/bonjour/src/advertiser.ts b/extensions/bonjour/src/advertiser.ts index b162212568b..76a1f4f6fd2 100644 --- a/extensions/bonjour/src/advertiser.ts +++ b/extensions/bonjour/src/advertiser.ts @@ -184,23 +184,22 @@ function resolveSystemMdnsHostname(): string | null { } const MAX_DNS_LABEL_BYTES = 63; +const utf8Encoder = new TextEncoder(); function truncateToDnsLabel(name: string, fallback = "OpenClaw"): string { - const encoder = new TextEncoder(); - const encoded = encoder.encode(name); + const encoded = utf8Encoder.encode(name); if (encoded.byteLength <= MAX_DNS_LABEL_BYTES) { return name; } - // Truncate at byte boundary, then decode back (TextDecoder handles incomplete sequences) - const truncated = encoded.slice(0, MAX_DNS_LABEL_BYTES); - const decoded = new TextDecoder("utf-8", { fatal: false }).decode(truncated); - // Strip any replacement character from incomplete multi-byte sequence at the end - return ( - decoded - .replace(/\uFFFD$/, "") - .replace(/-+$/, "") - .trim() || fallback - ); + for (let end = MAX_DNS_LABEL_BYTES; end > 0; end -= 1) { + try { + const decoded = new TextDecoder("utf-8", { fatal: true }).decode(encoded.subarray(0, end)); + return decoded.replace(/-+$/, "").trim() || fallback; + } catch { + // Try the next shorter prefix until the byte slice ends on a UTF-8 boundary. + } + } + return fallback; } function safeServiceName(name: string) {