Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
// Bonjour tests cover index plugin behavior.
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { afterAll, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
advertiserModuleLoaded: vi.fn(),
runtimeModuleLoaded: vi.fn(),
startGatewayBonjourAdvertiser: vi.fn(async () => ({ stop: vi.fn() })),
registerUncaughtExceptionHandler: vi.fn(),
registerUnhandledRejectionHandler: vi.fn(),
}));
vi.mock("./src/advertiser.js", () => {
mocks.advertiserModuleLoaded();
return {
startGatewayBonjourAdvertiser: mocks.startGatewayBonjourAdvertiser,
};
});
vi.mock("openclaw/plugin-sdk/runtime", () => {
mocks.runtimeModuleLoaded();
return {
registerUncaughtExceptionHandler: mocks.registerUncaughtExceptionHandler,
registerUnhandledRejectionHandler: mocks.registerUnhandledRejectionHandler,
};
});
const { default: bonjourPlugin } = await import("./index.js");
afterAll(() => {
vi.doUnmock("./src/advertiser.js");
vi.doUnmock("openclaw/plugin-sdk/runtime");
vi.resetModules();
});
describe("bonjour plugin entry", () => {
it("lazy-loads advertiser runtime when gateway discovery advertises", async () => {
let discoveryService:
| Parameters<ReturnType<typeof createTestPluginApi>["registerGatewayDiscoveryService"]>[0]
| undefined;
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
const api = createTestPluginApi({
logger,
registerGatewayDiscoveryService(service) {
discoveryService = service;
},
});
expect(mocks.advertiserModuleLoaded).not.toHaveBeenCalled();
expect(mocks.runtimeModuleLoaded).not.toHaveBeenCalled();
bonjourPlugin.register(api);
expect(discoveryService?.id).toBe("bonjour");
expect(mocks.advertiserModuleLoaded).not.toHaveBeenCalled();
expect(mocks.runtimeModuleLoaded).not.toHaveBeenCalled();
if (!discoveryService) {
throw new Error("expected bonjour plugin to register a discovery service");
}
const stop = vi.fn();
mocks.startGatewayBonjourAdvertiser.mockResolvedValueOnce({ stop });
await expect(
discoveryService.advertise({
machineDisplayName: "Dev Box",
gatewayPort: 3210,
gatewayTlsEnabled: true,
gatewayTlsFingerprintSha256: "abc123",
gatewayDirectReachable: true,
canvasPort: 9876,
sshPort: 22,
tailnetDns: "dev.tailnet.ts.net",
cliPath: "/usr/local/bin/openclaw",
minimal: false,
}),
).resolves.toEqual({ stop });
expect(mocks.advertiserModuleLoaded).toHaveBeenCalledTimes(1);
expect(mocks.runtimeModuleLoaded).toHaveBeenCalledTimes(1);
expect(mocks.startGatewayBonjourAdvertiser).toHaveBeenCalledWith(
{
instanceName: "Dev Box (OpenClaw)",
gatewayPort: 3210,
gatewayTlsEnabled: true,
gatewayTlsFingerprintSha256: "abc123",
gatewayDirectReachable: true,
canvasPort: 9876,
sshPort: 22,
tailnetDns: "dev.tailnet.ts.net",
cliPath: "/usr/local/bin/openclaw",
minimal: false,
},
{
logger,
registerUncaughtExceptionHandler: mocks.registerUncaughtExceptionHandler,
registerUnhandledRejectionHandler: mocks.registerUnhandledRejectionHandler,
},
);
});
});

View File

@@ -0,0 +1,57 @@
/**
* Bonjour gateway-discovery plugin entry. It advertises the local gateway over
* mDNS and lazily loads the ciao-based advertiser.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
function formatBonjourInstanceName(displayName: string) {
const trimmed = displayName.trim();
if (!trimmed) {
return "OpenClaw";
}
if (/openclaw/i.test(trimmed)) {
return trimmed;
}
return `${trimmed} (OpenClaw)`;
}
/** Plugin entry for Bonjour/mDNS gateway discovery. */
export default definePluginEntry({
id: "bonjour",
name: "Bonjour Gateway Discovery",
description: "Advertise the local OpenClaw gateway over Bonjour/mDNS.",
register(api) {
api.registerGatewayDiscoveryService({
id: "bonjour",
advertise: async (ctx) => {
const [
{ startGatewayBonjourAdvertiser },
{ registerUncaughtExceptionHandler, registerUnhandledRejectionHandler },
] = await Promise.all([
import("./src/advertiser.js"),
import("openclaw/plugin-sdk/runtime"),
]);
const advertiser = await startGatewayBonjourAdvertiser(
{
instanceName: formatBonjourInstanceName(ctx.machineDisplayName),
gatewayPort: ctx.gatewayPort,
gatewayTlsEnabled: ctx.gatewayTlsEnabled,
gatewayTlsFingerprintSha256: ctx.gatewayTlsFingerprintSha256,
gatewayDirectReachable: ctx.gatewayDirectReachable,
canvasPort: ctx.canvasPort,
sshPort: ctx.sshPort,
tailnetDns: ctx.tailnetDns,
cliPath: ctx.cliPath,
minimal: ctx.minimal,
},
{
logger: api.logger,
registerUncaughtExceptionHandler,
registerUnhandledRejectionHandler,
},
);
return { stop: advertiser.stop };
},
});
},
});

View File

@@ -0,0 +1,23 @@
// Bonjour tests cover manifest plugin behavior.
import fs from "node:fs";
import { describe, expect, it } from "vitest";
type PackageManifest = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
describe("bonjour package manifest", () => {
it("keeps ciao available in packaged startup runtimes", () => {
const pluginPackageJson = JSON.parse(
fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"),
) as PackageManifest;
const rootPackageJson = JSON.parse(
fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
) as PackageManifest;
expect(pluginPackageJson.dependencies?.["@homebridge/ciao"]).toBe("1.3.9");
expect(rootPackageJson.dependencies?.["@homebridge/ciao"]).toBe("1.3.9");
expect(pluginPackageJson.devDependencies?.["@homebridge/ciao"]).toBeUndefined();
});
});

View File

@@ -0,0 +1,14 @@
{
"id": "bonjour",
"activation": {
"onStartup": true
},
"enabledByDefaultOnPlatforms": ["darwin"],
"name": "Bonjour Gateway Discovery",
"description": "Advertise the local OpenClaw gateway over Bonjour/mDNS.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,17 @@
{
"name": "@openclaw/bonjour",
"version": "2026.6.11",
"description": "OpenClaw Bonjour/mDNS gateway discovery",
"type": "module",
"dependencies": {
"@homebridge/ciao": "1.3.9"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,789 @@
/**
* Bonjour advertiser runtime. It publishes gateway/canvas/SSH service records,
* watches ciao state, and repairs stuck or conflicting advertisements.
*/
import type { ChildProcess } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
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;
};
/** Running Bonjour advertiser handle. */
export type GatewayBonjourAdvertiser = {
stop: () => Promise<void>;
};
/** Input data used to publish OpenClaw gateway Bonjour records. */
export type GatewayBonjourAdvertiseOpts = {
instanceName?: string;
gatewayPort: number;
sshPort?: number;
gatewayTlsEnabled?: boolean;
gatewayTlsFingerprintSha256?: string;
gatewayDirectReachable?: boolean;
canvasPort?: number;
tailnetDns?: string;
cliPath?: string;
minimal?: boolean;
};
type BonjourService = {
serviceState?: unknown;
advertise: () => Promise<void>;
destroy: () => Promise<void>;
getFQDN: () => string;
getHostname: () => string;
getPort: () => number;
on: (event: "name-change" | "hostname-change", listener: (value: unknown) => void) => unknown;
};
type BonjourResponder = {
createService: (options: {
name: string;
type: string;
protocol: unknown;
port: number;
domain: string;
hostname: string;
txt: Record<string, string>;
}) => BonjourService;
shutdown: () => Promise<void>;
};
type CiaoModule = {
getResponder: () => BonjourResponder;
Protocol: { TCP: unknown };
};
type BonjourCycle = {
responder: BonjourResponder;
services: Array<{ label: string; svc: BonjourService }>;
};
type ServiceStateTracker = {
state: string;
sinceMs: number;
};
type ConsoleLogFn = (...args: unknown[]) => void;
type UncaughtExceptionHandler = (error: unknown) => boolean;
type UnhandledRejectionHandler = (reason: unknown) => boolean;
type ProcessUnhandledRejectionListener = (reason: unknown, promise: Promise<unknown>) => void;
type ExecBridge = (command: string, options?: unknown, callback?: unknown) => ChildProcess;
type ExecOptionsRecord = Record<string, unknown> & { windowsHide?: boolean };
type BonjourAdvertiserDeps = {
logger?: Pick<PluginLogger, "info" | "warn" | "debug">;
registerUncaughtExceptionHandler?: (handler: UncaughtExceptionHandler) => () => void;
registerUnhandledRejectionHandler?: (handler: UnhandledRejectionHandler) => () => void;
};
const WATCHDOG_INTERVAL_MS = 5_000;
const REPAIR_DEBOUNCE_MS = 30_000;
const CONFLICT_SETTLE_MS = 30_000;
// Real-world LAN announce phase typically takes 12-13s on Mac/iOS networks. The
// previous 8s threshold was triggering false-positive teardowns on every gateway
// restart in such environments. 20s gives healthy networks plenty of room while
// still catching genuinely stuck advertisers (announce that never completes).
// See https://github.com/openclaw/openclaw/issues/72481
const STUCK_ANNOUNCING_MS = 20_000;
const MAX_CONSECUTIVE_RESTARTS = 3;
const MAX_CONSECUTIVE_STUCK_STATE_RESTARTS = 1;
// A flapping advertiser can briefly reach "announced" between probing
// failures, which resets the consecutive counter. Bound total restarts too.
const RESTART_WINDOW_MS = 30 * 60_000;
const MAX_RESTARTS_IN_WINDOW = 5;
const BONJOUR_ANNOUNCED_STATE = "announced";
const CIAO_SELF_PROBE_RETRY_FRAGMENT =
"failed probing with reason: Error: Can't probe for a service which is announced already.";
const defaultLogger = {
info: (_msg: string) => {},
warn: (_msg: string) => {},
debug: (_msg: string) => {},
};
const CIAO_MODULE_ID = "@homebridge/ciao";
const CIAO_WINDOWS_SHELL_COMMANDS = new Set(['arp -a | findstr /C:"---"']);
let ciaoExecHidePatchDepth = 0;
let restoreCiaoExecHidePatchOnce: (() => void) | null = null;
const loadCiaoModule = createLazyRuntimeModule(() => import(CIAO_MODULE_ID) as Promise<CiaoModule>);
function readBonjourDisableOverride(): boolean | null {
const raw = process.env.OPENCLAW_DISABLE_BONJOUR;
const normalized = raw?.trim().toLowerCase();
if (!normalized) {
return null;
}
if (isTruthyEnvValue(raw)) {
return true;
}
switch (normalized) {
case "0":
case "false":
case "no":
case "off":
return false;
default:
return null;
}
}
function isContainerEnvironment() {
if (process.env.FLY_MACHINE_ID?.trim() && process.env.FLY_APP_NAME?.trim()) {
return true;
}
for (const sentinelPath of ["/.dockerenv", "/run/.containerenv", "/var/run/.containerenv"]) {
try {
if (fs.existsSync(sentinelPath)) {
return true;
}
} catch {
// ignore
}
}
try {
const cgroup = fs.readFileSync("/proc/1/cgroup", "utf8");
return /\/docker\/|cri-containerd-[0-9a-f]|containerd\/[0-9a-f]{64}|\/kubepods[/.]|\blxc\b/u.test(
cgroup,
);
} catch {
return false;
}
}
function isDisabledByEnv() {
if (process.env.NODE_ENV === "test") {
return true;
}
if (process.env.VITEST) {
return true;
}
const envOverride = readBonjourDisableOverride();
if (envOverride !== null) {
return envOverride;
}
if (isContainerEnvironment()) {
return true;
}
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;
}
const MAX_DNS_LABEL_BYTES = 63;
const utf8Encoder = new TextEncoder();
function truncateToDnsLabel(name: string, fallback = "OpenClaw"): string {
const encoded = utf8Encoder.encode(name);
if (encoded.byteLength <= MAX_DNS_LABEL_BYTES) {
return name;
}
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) {
const trimmed = name.trim();
return trimmed.length > 0 ? truncateToDnsLabel(trimmed) : "OpenClaw";
}
function prettifyInstanceName(name: string) {
const normalized = name.trim().replace(/\s+/g, " ");
return normalized.replace(/\s+\(OpenClaw\)\s*$/i, "").trim() || normalized;
}
function serviceSummary(label: string, svc: BonjourService): string {
let fqdn = "unknown";
let hostname = "unknown";
let port = -1;
try {
fqdn = svc.getFQDN();
} catch {
// ignore
}
try {
hostname = svc.getHostname();
} catch {
// ignore
}
try {
port = svc.getPort();
} catch {
// ignore
}
const state = typeof svc.serviceState === "string" ? svc.serviceState : "unknown";
return `${label} fqdn=${fqdn} host=${hostname} port=${port} state=${state}`;
}
function isAnnouncedState(state: string) {
return state === BONJOUR_ANNOUNCED_STATE;
}
function isAdvertisingInProgressState(state: string) {
return state === "probing" || state === "announcing";
}
function shouldSuppressCiaoConsoleLog(args: unknown[]): boolean {
return args.some(
(arg) => typeof arg === "string" && arg.includes(CIAO_SELF_PROBE_RETRY_FRAGMENT),
);
}
function installCiaoConsoleNoiseFilter(): () => void {
const previousConsoleLog = console.log as ConsoleLogFn;
const wrapper = ((...args: unknown[]) => {
if (shouldSuppressCiaoConsoleLog(args)) {
return;
}
previousConsoleLog(...args);
}) as ConsoleLogFn;
console.log = wrapper;
return () => {
if (console.log === wrapper) {
console.log = previousConsoleLog;
}
};
}
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;
};
}
function installCiaoUnhandledRejectionListener(handler: UnhandledRejectionHandler): () => void {
const hadOtherListeners = process.listenerCount("unhandledRejection") > 0;
const listener: ProcessUnhandledRejectionListener = (reason) => {
if (handler(reason)) {
return;
}
if (hadOtherListeners) {
return;
}
queueMicrotask(() => {
throw reason instanceof Error ? reason : new Error(String(reason));
});
};
process.on("unhandledRejection", listener);
return () => {
process.off("unhandledRejection", listener);
};
}
/** Start Bonjour advertisements for the local gateway services. */
export async function startGatewayBonjourAdvertiser(
opts: GatewayBonjourAdvertiseOpts,
deps: BonjourAdvertiserDeps = {},
): Promise<GatewayBonjourAdvertiser> {
if (isDisabledByEnv()) {
return { stop: async () => {} };
}
const logger = {
info: deps.logger?.info ?? defaultLogger.info,
warn: deps.logger?.warn ?? defaultLogger.warn,
debug: deps.logger?.debug ?? defaultLogger.debug,
};
const restoreCiaoExecHidePatch = installCiaoWindowsExecHidePatch();
let restoreConsoleLog: () => void = () => {};
let requestCiaoRecovery: ((classification: CiaoProcessErrorClassification) => void) | undefined;
let cleanupUnhandledRejection: (() => void) | undefined;
let cleanupDirectUnhandledRejection: (() => void) | undefined;
let cleanupUncaughtException: (() => void) | undefined;
let processHandlersCleaned = false;
function cleanupProcessHandlers() {
if (processHandlersCleaned) {
return;
}
processHandlersCleaned = true;
cleanupDirectUnhandledRejection?.();
cleanupUncaughtException?.();
cleanupUnhandledRejection?.();
}
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.warn(`bonjour: suppressing ciao cancellation: ${classification.formatted}`);
requestCiaoRecovery?.(classification);
} else if (classification.kind === "interface-enumeration-failure") {
// Restricted sandboxes can refuse os.networkInterfaces(); mDNS cannot
// function without it, so surface a single warning and skip recovery.
// Recovery would just re-enter the same failing syscall.
logger.warn(
`bonjour: disabling mDNS — networkInterfaces() unavailable in this environment: ${classification.formatted}`,
);
} else {
const label =
classification.kind === "netmask-assertion"
? "netmask assertion"
: classification.kind === "self-probe"
? "self-probe race"
: "interface assertion";
logger.warn(`bonjour: suppressing ciao ${label}: ${classification.formatted}`);
requestCiaoRecovery?.(classification);
}
return true;
};
cleanupDirectUnhandledRejection = installCiaoUnhandledRejectionListener(handleCiaoProcessError);
cleanupUnhandledRejection = deps.registerUnhandledRejectionHandler?.(handleCiaoProcessError);
cleanupUncaughtException = deps.registerUncaughtExceptionHandler?.(handleCiaoProcessError);
const hostnameRaw =
process.env.OPENCLAW_MDNS_HOSTNAME?.trim() || resolveSystemMdnsHostname() || "openclaw";
const hostname = truncateToDnsLabel(
hostnameRaw
.replace(/\.local$/i, "")
.split(".")[0]
.trim() || "openclaw",
"openclaw",
);
const instanceName =
typeof opts.instanceName === "string" && opts.instanceName.trim()
? opts.instanceName.trim()
: `${hostname} (OpenClaw)`;
const displayName = prettifyInstanceName(instanceName);
const txtBase: Record<string, string> = {
role: "gateway",
gatewayPort: String(opts.gatewayPort),
lanHost: `${hostname}.local`,
displayName,
};
if (opts.gatewayTlsEnabled) {
txtBase.gatewayTls = "1";
if (opts.gatewayTlsFingerprintSha256) {
txtBase.gatewayTlsSha256 = opts.gatewayTlsFingerprintSha256;
}
}
if (opts.gatewayDirectReachable) {
txtBase.gatewayDirectReachable = "1";
}
if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) {
txtBase.canvasPort = String(opts.canvasPort);
}
if (!opts.minimal && typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
txtBase.tailnetDns = opts.tailnetDns.trim();
}
if (!opts.minimal && typeof opts.cliPath === "string" && opts.cliPath.trim()) {
txtBase.cliPath = opts.cliPath.trim();
}
const gatewayTxt: Record<string, string> = {
...txtBase,
transport: "gateway",
};
if (!opts.minimal) {
gatewayTxt.sshPort = String(opts.sshPort ?? 22);
}
const responder = getResponder();
function createCycle(): BonjourCycle {
const services: Array<{ label: string; svc: BonjourService }> = [];
const gateway = responder.createService({
name: safeServiceName(instanceName),
type: "openclaw-gw",
protocol: Protocol.TCP,
port: opts.gatewayPort,
domain: "local",
hostname,
txt: gatewayTxt,
});
services.push({
label: "gateway",
svc: gateway as unknown as BonjourService,
});
return { responder, services };
}
async function stopCycle(
cycle: BonjourCycle | null,
optsValue?: { shutdownResponder?: boolean },
) {
if (!cycle) {
return;
}
for (const { svc } of cycle.services) {
try {
await svc.destroy();
} catch {
/* ignore */
}
}
try {
if (optsValue?.shutdownResponder) {
await cycle.responder.shutdown();
}
} catch {
/* ignore */
}
}
function attachConflictListeners(services: Array<{ label: string; svc: BonjourService }>) {
for (const { label, svc } of services) {
try {
svc.on("name-change", (name: unknown) => {
markConflictObserved(label, svc);
const next = typeof name === "string" ? name : String(name);
logger.warn(
`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`,
);
});
svc.on("hostname-change", (nextHostname: unknown) => {
markConflictObserved(label, svc);
const next = typeof nextHostname === "string" ? nextHostname : String(nextHostname);
logger.warn(
`bonjour: ${label} hostname conflict resolved; newHostname=${JSON.stringify(next)}`,
);
});
} catch (err) {
logger.debug(`bonjour: failed to attach listeners for ${label}: ${String(err)}`);
}
}
}
function handleAdvertiseFailure(
label: string,
svc: BonjourService,
err: unknown,
action: "failed" | "threw",
) {
const classification = classifyCiaoProcessError(err);
if (classification) {
logger.warn(
`bonjour: advertise ${action} with ciao ${classification.kind} (${serviceSummary(
label,
svc,
)}): ${classification.formatted}`,
);
requestCiaoRecovery?.(classification);
return;
}
logger.warn(
`bonjour: advertise ${action} (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
}
function startAdvertising(services: Array<{ label: string; svc: BonjourService }>) {
for (const { label, svc } of services) {
try {
void svc
.advertise()
.then(() => {
logger.info(`bonjour: advertised ${serviceSummary(label, svc)}`);
})
.catch((err: unknown) => {
handleAdvertiseFailure(label, svc, err, "failed");
});
} catch (err) {
handleAdvertiseFailure(label, svc, err, "threw");
}
}
}
logger.debug(
`bonjour: starting (hostname=${hostname}, instance=${JSON.stringify(
safeServiceName(instanceName),
)}, gatewayPort=${opts.gatewayPort}${opts.minimal ? ", minimal=true" : `, sshPort=${opts.sshPort ?? 22}`})`,
);
let stopped = false;
let recreatePromise: Promise<void> | null = null;
let disabled = false;
let consecutiveRestarts = 0;
let consecutiveStuckStateRestarts = 0;
const restartTimestamps: number[] = [];
let cycle: BonjourCycle | null = createCycle();
const stateTracker = new Map<string, ServiceStateTracker>();
const conflictTracker = new Map<string, number>();
const markConflictObserved = (label: string, svc: BonjourService) => {
const now = Date.now();
conflictTracker.set(label, now);
const nextState = typeof svc.serviceState === "string" ? svc.serviceState : "unknown";
stateTracker.set(label, { state: nextState, sinceMs: now });
};
const updateStateTrackers = (services: Array<{ label: string; svc: BonjourService }>) => {
const now = Date.now();
for (const { label, svc } of services) {
const nextState = typeof svc.serviceState === "string" ? svc.serviceState : "unknown";
const current = stateTracker.get(label);
const nextEnteredAt =
current && !isAnnouncedState(current.state) && !isAnnouncedState(nextState)
? current.sinceMs
: now;
if (!current || current.state !== nextState || current.sinceMs !== nextEnteredAt) {
stateTracker.set(label, { state: nextState, sinceMs: nextEnteredAt });
}
}
};
const recreateAdvertiser = async (reason: string, optsLocal?: { stuckState?: boolean }) => {
if (stopped || disabled) {
return;
}
if (recreatePromise) {
return recreatePromise;
}
recreatePromise = (async () => {
consecutiveRestarts += 1;
consecutiveStuckStateRestarts = optsLocal?.stuckState
? consecutiveStuckStateRestarts + 1
: 0;
const now = Date.now();
while (
restartTimestamps.length > 0 &&
now - (restartTimestamps[0] ?? 0) > RESTART_WINDOW_MS
) {
restartTimestamps.shift();
}
restartTimestamps.push(now);
const tooManyConsecutive = consecutiveRestarts > MAX_CONSECUTIVE_RESTARTS;
const tooManyStuckStates =
consecutiveStuckStateRestarts > MAX_CONSECUTIVE_STUCK_STATE_RESTARTS;
const tooManyInWindow = restartTimestamps.length >= MAX_RESTARTS_IN_WINDOW;
if (tooManyConsecutive || tooManyStuckStates || tooManyInWindow) {
disabled = true;
const detail = tooManyConsecutive
? `${MAX_CONSECUTIVE_RESTARTS} failed restarts`
: tooManyStuckStates
? `${MAX_CONSECUTIVE_STUCK_STATE_RESTARTS} stuck-state restart`
: `${MAX_RESTARTS_IN_WINDOW} restarts within ${Math.round(
RESTART_WINDOW_MS / 60_000,
)} minutes`;
logger.warn(
`bonjour: disabling advertiser after ${detail} (${reason}); set discovery.mdns.mode="off" or OPENCLAW_DISABLE_BONJOUR=1 to disable mDNS discovery`,
);
const previous = cycle;
cycle = null;
stateTracker.clear();
conflictTracker.clear();
await stopCycle(previous, { shutdownResponder: true });
restoreConsoleLog();
restoreCiaoExecHidePatch();
return;
}
logger.warn(`bonjour: restarting advertiser (${reason})`);
const previous = cycle;
await stopCycle(previous);
cycle = createCycle();
stateTracker.clear();
conflictTracker.clear();
attachConflictListeners(cycle.services);
startAdvertising(cycle.services);
})().finally(() => {
recreatePromise = null;
});
return recreatePromise;
};
requestCiaoRecovery = (classification) => {
void recreateAdvertiser(`ciao ${classification.kind}: ${classification.formatted}`);
};
attachConflictListeners(cycle.services);
startAdvertising(cycle.services);
const lastRepairAttempt = new Map<string, number>();
const watchdog = setInterval(() => {
if (stopped || recreatePromise) {
return;
}
if (disabled || !cycle) {
return;
}
updateStateTrackers(cycle.services);
for (const { label, svc } of cycle.services) {
const now = Date.now();
const stateUnknown = (svc as { serviceState?: unknown }).serviceState;
if (typeof stateUnknown !== "string") {
continue;
}
if (stateUnknown === "announced") {
consecutiveRestarts = 0;
consecutiveStuckStateRestarts = 0;
conflictTracker.delete(label);
}
const lastConflictAt = conflictTracker.get(label);
if (lastConflictAt !== undefined && now - lastConflictAt >= CONFLICT_SETTLE_MS) {
conflictTracker.delete(label);
}
if (lastConflictAt !== undefined && now - lastConflictAt < CONFLICT_SETTLE_MS) {
continue;
}
const tracked = stateTracker.get(label);
if (
stateUnknown !== "announced" &&
tracked &&
now - tracked.sinceMs >= STUCK_ANNOUNCING_MS
) {
void recreateAdvertiser(
`service stuck in ${stateUnknown} for ${now - tracked.sinceMs}ms (${serviceSummary(
label,
svc,
)})`,
{ stuckState: true },
);
return;
}
if (stateUnknown === "announced" || isAdvertisingInProgressState(stateUnknown)) {
continue;
}
let key = label;
try {
key = `${label}:${svc.getFQDN()}`;
} catch {
// ignore
}
const last = lastRepairAttempt.get(key) ?? 0;
if (now - last < REPAIR_DEBOUNCE_MS) {
continue;
}
lastRepairAttempt.set(key, now);
logger.warn(
`bonjour: watchdog detected non-announced service; attempting re-advertise (${serviceSummary(
label,
svc,
)})`,
);
try {
void svc.advertise().catch((err: unknown) => {
logger.warn(
`bonjour: watchdog re-advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
});
} catch (err) {
logger.warn(
`bonjour: watchdog re-advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
}
}
}, WATCHDOG_INTERVAL_MS);
watchdog.unref?.();
return {
stop: async () => {
stopped = true;
clearInterval(watchdog);
try {
await recreatePromise;
} catch {
// ignore
}
await stopCycle(cycle, { shutdownResponder: true });
restoreConsoleLog();
restoreCiaoExecHidePatch();
cleanupProcessHandlers();
},
};
} catch (err) {
restoreConsoleLog();
restoreCiaoExecHidePatch();
cleanupProcessHandlers();
throw err;
}
}

View File

@@ -0,0 +1,153 @@
// Bonjour tests cover ciao plugin behavior.
import { describe, expect, it } from "vitest";
const { classifyCiaoProcessError } = await import("./ciao.js");
describe("bonjour-ciao", () => {
it("classifies ciao cancellation rejections separately from side effects", () => {
expect(classifyCiaoProcessError(new Error("CIAO PROBING CANCELLED"))).toEqual({
kind: "cancellation",
formatted: "CIAO PROBING CANCELLED",
});
});
it("classifies ciao interface assertions separately from side effects", () => {
expect(
classifyCiaoProcessError(
new Error("Reached illegal state! IPV4 address change from defined to undefined!"),
),
).toEqual({
kind: "interface-assertion",
formatted: "Reached illegal state! IPV4 address change from defined to undefined!",
});
});
it("classifies ciao interface assertions using changed wording", () => {
expect(
classifyCiaoProcessError(
new Error("Reached illegal state! IPv4 address changed from undefined to defined!"),
),
).toEqual({
kind: "interface-assertion",
formatted: "Reached illegal state! IPv4 address changed from undefined to defined!",
});
});
it("classifies ciao netmask assertions separately from side effects", () => {
expect(
classifyCiaoProcessError(
Object.assign(
new Error(
"IP address version must match. Netmask cannot have a version different from the address!",
),
{ name: "AssertionError" },
),
),
).toEqual({
kind: "netmask-assertion",
formatted:
"AssertionError: IP address version must match. Netmask cannot have a version different from the address!",
});
});
it("classifies ciao self-probe races separately from side effects", () => {
expect(
classifyCiaoProcessError(
new Error(
"Can't probe for a service which is announced already. Received announcing for service OpenClaw Gateway._openclaw._tcp.local.",
),
),
).toEqual({
kind: "self-probe",
formatted:
"Can't probe for a service which is announced already. Received announcing for service OpenClaw Gateway._openclaw._tcp.local.",
});
});
it("suppresses ciao announcement cancellation rejections", () => {
expect(classifyCiaoProcessError(new Error("Ciao announcement cancelled by shutdown"))).not.toBe(
null,
);
});
it("suppresses ciao probing cancellation rejections", () => {
expect(classifyCiaoProcessError(new Error("CIAO PROBING CANCELLED"))).not.toBe(null);
});
it("suppresses wrapped ciao cancellation rejections", () => {
expect(
classifyCiaoProcessError({
reason: new Error("CIAO ANNOUNCEMENT CANCELLED"),
}),
).toEqual({
kind: "cancellation",
formatted: "CIAO ANNOUNCEMENT CANCELLED",
});
});
it("suppresses aggregate ciao assertion rejections", () => {
expect(
classifyCiaoProcessError(
new AggregateError([
Object.assign(
new Error("Reached illegal state! IPV4 address change from defined to undefined!"),
{ name: "AssertionError" },
),
]),
),
).toEqual({
kind: "interface-assertion",
formatted:
"AssertionError: Reached illegal state! IPV4 address change from defined to undefined!",
});
});
it("suppresses lower-case string cancellation reasons too", () => {
expect(classifyCiaoProcessError("ciao announcement cancelled during cleanup")).not.toBe(null);
});
it("suppresses ciao interface assertion rejections as non-fatal", () => {
const error = Object.assign(
new Error("Reached illegal state! IPV4 address change from defined to undefined!"),
{ name: "AssertionError" },
);
expect(classifyCiaoProcessError(error)).not.toBe(null);
});
it("suppresses ciao netmask assertion errors as non-fatal", () => {
const error = Object.assign(
new Error(
"IP address version must match. Netmask cannot have a version different from the address!",
),
{ name: "AssertionError" },
);
expect(classifyCiaoProcessError(error)).not.toBe(null);
});
it("classifies networkInterfaces SystemError failures (restricted sandboxes)", () => {
const err = Object.assign(
new Error("A system error occurred: uv_interface_addresses returned Unknown system error 1"),
{ name: "SystemError" },
);
expect(classifyCiaoProcessError(err)).toEqual({
kind: "interface-enumeration-failure",
formatted:
"SystemError: A system error occurred: uv_interface_addresses returned Unknown system error 1",
});
});
it("suppresses networkInterfaces failures wrapped in cause chains", () => {
const inner = Object.assign(
new Error("A system error occurred: uv_interface_addresses returned Unknown system error 1"),
{ name: "SystemError" },
);
const wrapper = new Error("ciao NetworkManager init failed", { cause: inner });
expect(classifyCiaoProcessError(wrapper)).not.toBe(null);
});
it("keeps unrelated rejections visible", () => {
expect(classifyCiaoProcessError(new Error("boom"))).toBe(null);
});
});

View File

@@ -0,0 +1,57 @@
/**
* Ciao process-error classifier. It recognizes known noisy ciao failures so
* the Bonjour plugin can suppress or repair expected mDNS lifecycle issues.
*/
import { collectErrorGraphCandidates } from "openclaw/plugin-sdk/error-runtime";
import { formatBonjourError } from "./errors.js";
const CIAO_CANCELLATION_MESSAGE_RE = /^CIAO (?:ANNOUNCEMENT|PROBING) CANCELLED\b/u;
const CIAO_INTERFACE_ASSERTION_MESSAGE_RE =
/REACHED ILLEGAL STATE!?\s+IPV4 ADDRESS CHANGED? FROM (?:DEFINED TO UNDEFINED|UNDEFINED TO DEFINED)!?/u;
const CIAO_NETMASK_ASSERTION_MESSAGE_RE =
/IP ADDRESS VERSION MUST MATCH\.\s+NETMASK CANNOT HAVE A VERSION DIFFERENT FROM THE ADDRESS!?/u;
const CIAO_SELF_PROBE_MESSAGE_RE =
/CAN'T PROBE FOR A SERVICE WHICH IS ANNOUNCED ALREADY\.\s+RECEIVED (?:PROBING|ANNOUNCING|ANNOUNCED) FOR SERVICE\b/u;
// Restricted sandboxes (NemoClaw, Docker-in-Docker, k3s with locked-down policy)
// can refuse os.networkInterfaces(), which ciao calls during NetworkManager init.
// Node surfaces this as a SystemError mentioning the libuv syscall by name.
const CIAO_INTERFACE_ENUMERATION_FAILURE_RE = /\bUV_INTERFACE_ADDRESSES\b/u;
/** Known ciao process-level errors that OpenClaw handles specially. */
export type CiaoProcessErrorClassification =
| { kind: "cancellation"; formatted: string }
| { kind: "interface-assertion"; formatted: string }
| { kind: "netmask-assertion"; formatted: string }
| { kind: "self-probe"; formatted: string }
| { kind: "interface-enumeration-failure"; formatted: string };
/** Classify a ciao error/rejection chain into a known category. */
export function classifyCiaoProcessError(reason: unknown): CiaoProcessErrorClassification | null {
for (const candidate of collectErrorGraphCandidates(reason, (current) => [
current.cause,
current.reason,
current.original,
current.error,
current.data,
...(Array.isArray(current.errors) ? current.errors : []),
])) {
const formatted = formatBonjourError(candidate);
const message = formatted.toUpperCase();
if (CIAO_CANCELLATION_MESSAGE_RE.test(message)) {
return { kind: "cancellation", formatted };
}
if (CIAO_INTERFACE_ASSERTION_MESSAGE_RE.test(message)) {
return { kind: "interface-assertion", formatted };
}
if (CIAO_NETMASK_ASSERTION_MESSAGE_RE.test(message)) {
return { kind: "netmask-assertion", formatted };
}
if (CIAO_SELF_PROBE_MESSAGE_RE.test(message)) {
return { kind: "self-probe", formatted };
}
if (CIAO_INTERFACE_ENUMERATION_FAILURE_RE.test(message)) {
return { kind: "interface-enumeration-failure", formatted };
}
}
return null;
}

View File

@@ -0,0 +1,31 @@
// Bonjour tests cover errors plugin behavior.
import { describe, expect, it } from "vitest";
import { formatBonjourError } from "./errors.js";
describe("formatBonjourError", () => {
it("formats named errors with their type prefix", () => {
const err = new Error("timed out");
err.name = "AbortError";
expect(formatBonjourError(err)).toBe("AbortError: timed out");
});
it("avoids duplicating named errors with blank messages", () => {
const err = new Error("");
err.name = "AbortError";
expect(formatBonjourError(err)).toBe("AbortError");
});
it("treats whitespace-only messages as blank", () => {
const named = new Error(" ");
named.name = "AbortError";
expect(formatBonjourError(named)).toBe("AbortError");
expect(formatBonjourError(new Error(" "))).toBe("Error");
});
it("falls back to plain error strings and non-error values", () => {
expect(formatBonjourError(new Error(""))).toBe("Error");
expect(formatBonjourError("boom")).toBe("boom");
expect(formatBonjourError(42)).toBe("42");
});
});

View File

@@ -0,0 +1,16 @@
/**
* Bonjour error formatting helper. It normalizes Error and non-Error values
* into concise messages for gateway discovery logs.
*/
/** Format an unknown Bonjour/ciao error value for logs. */
export function formatBonjourError(err: unknown): string {
if (err instanceof Error) {
const trimmedMessage = err.message.trim();
const msg = trimmedMessage || err.name || String(err).trim();
if (err.name && err.name !== "Error") {
return msg === err.name ? err.name : `${err.name}: ${msg}`;
}
return msg;
}
return String(err);
}