From 276291d399edb8043dc0fbeae91cd1d5eeea09d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 09:08:32 +0100 Subject: [PATCH] fix: hide bonjour Windows ARP shell probe --- CHANGELOG.md | 1 + extensions/bonjour/src/advertiser.test.ts | 43 ++++++++ extensions/bonjour/src/advertiser.ts | 117 ++++++++++++++++++---- 3 files changed, 140 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e9114c6372..bf63c53511c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Gateway/startup: extend `OPENCLAW_GATEWAY_STARTUP_TRACE=1` with per-phase event-loop delay plus plugin lookup-table timing and count metrics for installed-index, manifest, startup-plan, and owner-map work, and include the new timing fields in startup benchmark summaries. Thanks @shakkernerd. - Plugins/registry: resolve lookup-table owner maps for providers, CLI backends, setup providers, command aliases, model catalogs, channel configs, and manifest contracts while preserving setup-only CLI backend ownership. Thanks @shakkernerd. - Process/Windows: decode command stdout and stderr from raw bytes with console-codepage awareness, while preserving valid UTF-8 output and multibyte characters split across chunks. Fixes #50519. Thanks @iready, @kevinten10, @zhangyongjie1997, @knightplat-blip, @heiqishi666, and @slepybear. +- Bonjour/Windows: hide the bundled mDNS advertiser's Windows ARP shell probe so Gateway startup no longer flashes command-prompt windows. Fixes #70238. Thanks @alexandre-leng, @PratikRai0101, @infinitypacific, and @tomerpeled. - Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666. - Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when `AGENTS.md`, `SOUL.md`, `MEMORY.md`, or `TOOLS.md` change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon. - macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar. diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index 25a205e6771..18dfaa8d4fc 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -1,7 +1,14 @@ +import type { ChildProcess } from "node:child_process"; import fs from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; +const nodeRequire = createRequire(import.meta.url); +const childProcessModule = nodeRequire("node:child_process") as { + exec: typeof import("node:child_process").exec; +}; + const mocks = vi.hoisted(() => ({ createService: vi.fn(), getResponder: vi.fn(), @@ -240,6 +247,42 @@ describe("gateway bonjour advertiser", () => { await started.stop(); }); + it("hides ciao Windows ARP probe shell while advertiser is active", async () => { + enableAdvertiserUnitMode(); + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const originalExec = childProcessModule.exec; + const execMock = vi.fn((command: string, options?: unknown, callback?: unknown) => { + const cb = typeof options === "function" ? options : callback; + if (typeof cb === "function") { + cb(null, "", ""); + } + return { kill: vi.fn() } as unknown as ChildProcess; + }); + childProcessModule.exec = execMock as unknown as typeof childProcessModule.exec; + + const destroy = vi.fn().mockResolvedValue(undefined); + const advertise = vi.fn().mockResolvedValue(undefined); + mockCiaoService({ advertise, destroy }); + + try { + const started = await startAdvertiser({ gatewayPort: 18789 }); + childProcessModule.exec('arp -a | findstr /C:"---"', () => {}); + + expect(execMock).toHaveBeenCalledWith( + 'arp -a | findstr /C:"---"', + { windowsHide: true }, + expect.any(Function), + ); + + await started.stop(); + childProcessModule.exec('arp -a | findstr /C:"---"', () => {}); + const afterStopOptions = execMock.mock.calls.at(-1)?.[1]; + expect(afterStopOptions).toEqual(expect.any(Function)); + } finally { + childProcessModule.exec = originalExec; + } + }); + it("attaches conflict listeners for services", async () => { enableAdvertiserUnitMode(); diff --git a/extensions/bonjour/src/advertiser.ts b/extensions/bonjour/src/advertiser.ts index 1908d69aa1b..caa61022119 100644 --- a/extensions/bonjour/src/advertiser.ts +++ b/extensions/bonjour/src/advertiser.ts @@ -1,9 +1,16 @@ +import type { ChildProcess } from "node:child_process"; import fs from "node:fs"; +import { createRequire } from "node:module"; import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; import { classifyCiaoProcessError, type CiaoProcessErrorClassification } from "./ciao.js"; import { formatBonjourError } from "./errors.js"; +const nodeRequire = createRequire(import.meta.url); +const childProcessModule = nodeRequire("node:child_process") as { + exec: typeof import("node:child_process").exec; +}; + export type GatewayBonjourAdvertiser = { stop: () => Promise; }; @@ -61,6 +68,8 @@ type ServiceStateTracker = { type ConsoleLogFn = (...args: unknown[]) => void; type UncaughtExceptionHandler = (error: unknown) => boolean; type UnhandledRejectionHandler = (reason: unknown) => boolean; +type ExecBridge = (command: string, options?: unknown, callback?: unknown) => ChildProcess; +type ExecOptionsRecord = Record & { windowsHide?: boolean }; type BonjourAdvertiserDeps = { logger?: Pick; @@ -83,7 +92,10 @@ const defaultLogger = { }; const CIAO_MODULE_ID = "@homebridge/ciao"; +const CIAO_WINDOWS_SHELL_COMMANDS = new Set(['arp -a | findstr /C:"---"']); let ciaoModulePromise: Promise | null = null; +let ciaoExecHidePatchDepth = 0; +let restoreCiaoExecHidePatchOnce: (() => void) | null = null; async function loadCiaoModule(): Promise { ciaoModulePromise ??= import(CIAO_MODULE_ID) as Promise; @@ -207,6 +219,64 @@ function installCiaoConsoleNoiseFilter(): () => void { }; } +function isExecOptionsRecord(value: unknown): value is ExecOptionsRecord { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function shouldHideCiaoWindowsShell(command: string): boolean { + return process.platform === "win32" && CIAO_WINDOWS_SHELL_COMMANDS.has(command.trim()); +} + +function installCiaoWindowsExecHidePatch(): () => void { + if (process.platform !== "win32") { + return () => {}; + } + + ciaoExecHidePatchDepth += 1; + if (!restoreCiaoExecHidePatchOnce) { + const previousExec = childProcessModule.exec as ExecBridge; + const wrapper = ((command: string, options?: unknown, callback?: unknown) => { + if (shouldHideCiaoWindowsShell(command)) { + if (typeof options === "function") { + return previousExec.call(childProcessModule, command, { windowsHide: true }, options); + } + if (options == null) { + return previousExec.call(childProcessModule, command, { windowsHide: true }, callback); + } + if (isExecOptionsRecord(options) && options.windowsHide === undefined) { + return previousExec.call( + childProcessModule, + command, + { ...options, windowsHide: true }, + callback, + ); + } + } + return previousExec.call(childProcessModule, command, options, callback); + }) as typeof childProcessModule.exec; + childProcessModule.exec = wrapper; + restoreCiaoExecHidePatchOnce = () => { + if (childProcessModule.exec === wrapper) { + childProcessModule.exec = previousExec as typeof childProcessModule.exec; + } + }; + } + + let active = true; + return () => { + if (!active) { + return; + } + active = false; + ciaoExecHidePatchDepth = Math.max(0, ciaoExecHidePatchDepth - 1); + if (ciaoExecHidePatchDepth > 0) { + return; + } + restoreCiaoExecHidePatchOnce?.(); + restoreCiaoExecHidePatchOnce = null; + }; +} + export async function startGatewayBonjourAdvertiser( opts: GatewayBonjourAdvertiseOpts, deps: BonjourAdvertiserDeps = {}, @@ -220,8 +290,8 @@ export async function startGatewayBonjourAdvertiser( warn: deps.logger?.warn ?? defaultLogger.warn, debug: deps.logger?.debug ?? defaultLogger.debug, }; - const { getResponder, Protocol } = await loadCiaoModule(); - const restoreConsoleLog = installCiaoConsoleNoiseFilter(); + const restoreCiaoExecHidePatch = installCiaoWindowsExecHidePatch(); + let restoreConsoleLog: () => void = () => {}; let requestCiaoRecovery: ((classification: CiaoProcessErrorClassification) => void) | undefined; let cleanupUnhandledRejection: (() => void) | undefined; let cleanupUncaughtException: (() => void) | undefined; @@ -236,26 +306,28 @@ export async function startGatewayBonjourAdvertiser( cleanupUnhandledRejection?.(); } - const handleCiaoProcessError = (reason: unknown): boolean => { - const classification = classifyCiaoProcessError(reason); - if (!classification) { - return false; - } - - if (classification.kind === "cancellation") { - logger.debug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`); - } else { - const label = - classification.kind === "netmask-assertion" ? "netmask assertion" : "interface assertion"; - logger.warn(`bonjour: suppressing ciao ${label}: ${classification.formatted}`); - requestCiaoRecovery?.(classification); - } - return true; - }; - cleanupUnhandledRejection = deps.registerUnhandledRejectionHandler?.(handleCiaoProcessError); - cleanupUncaughtException = deps.registerUncaughtExceptionHandler?.(handleCiaoProcessError); - try { + const { getResponder, Protocol } = await loadCiaoModule(); + restoreConsoleLog = installCiaoConsoleNoiseFilter(); + const handleCiaoProcessError = (reason: unknown): boolean => { + const classification = classifyCiaoProcessError(reason); + if (!classification) { + return false; + } + + if (classification.kind === "cancellation") { + logger.debug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`); + } else { + const label = + classification.kind === "netmask-assertion" ? "netmask assertion" : "interface assertion"; + logger.warn(`bonjour: suppressing ciao ${label}: ${classification.formatted}`); + requestCiaoRecovery?.(classification); + } + return true; + }; + cleanupUnhandledRejection = deps.registerUnhandledRejectionHandler?.(handleCiaoProcessError); + cleanupUncaughtException = deps.registerUncaughtExceptionHandler?.(handleCiaoProcessError); + const hostnameRaw = process.env.OPENCLAW_MDNS_HOSTNAME?.trim() || "openclaw"; const hostname = hostnameRaw @@ -431,6 +503,7 @@ export async function startGatewayBonjourAdvertiser( stateTracker.clear(); await stopCycle(previous, { shutdownResponder: true }); restoreConsoleLog(); + restoreCiaoExecHidePatch(); return; } logger.warn(`bonjour: restarting advertiser (${reason})`); @@ -529,11 +602,13 @@ export async function startGatewayBonjourAdvertiser( } await stopCycle(cycle, { shutdownResponder: true }); restoreConsoleLog(); + restoreCiaoExecHidePatch(); cleanupProcessHandlers(); }, }; } catch (err) { restoreConsoleLog(); + restoreCiaoExecHidePatch(); cleanupProcessHandlers(); throw err; }