fix: hide bonjour Windows ARP shell probe

This commit is contained in:
Peter Steinberger
2026-04-27 09:08:32 +01:00
parent 8bdfa58cbb
commit 276291d399
3 changed files with 140 additions and 21 deletions

View File

@@ -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();

View File

@@ -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<void>;
};
@@ -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<string, unknown> & { windowsHide?: boolean };
type BonjourAdvertiserDeps = {
logger?: Pick<PluginLogger, "info" | "warn" | "debug">;
@@ -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<CiaoModule> | null = null;
let ciaoExecHidePatchDepth = 0;
let restoreCiaoExecHidePatchOnce: (() => void) | null = null;
async function loadCiaoModule(): Promise<CiaoModule> {
ciaoModulePromise ??= import(CIAO_MODULE_ID) as Promise<CiaoModule>;
@@ -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;
}