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
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:
107
extensions/bonjour/index.test.ts
Normal file
107
extensions/bonjour/index.test.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
57
extensions/bonjour/index.ts
Normal file
57
extensions/bonjour/index.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
23
extensions/bonjour/manifest.test.ts
Normal file
23
extensions/bonjour/manifest.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
14
extensions/bonjour/openclaw.plugin.json
Normal file
14
extensions/bonjour/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
17
extensions/bonjour/package.json
Normal file
17
extensions/bonjour/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
1023
extensions/bonjour/src/advertiser.test.ts
Normal file
1023
extensions/bonjour/src/advertiser.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
789
extensions/bonjour/src/advertiser.ts
Normal file
789
extensions/bonjour/src/advertiser.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
153
extensions/bonjour/src/ciao.test.ts
Normal file
153
extensions/bonjour/src/ciao.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
57
extensions/bonjour/src/ciao.ts
Normal file
57
extensions/bonjour/src/ciao.ts
Normal 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;
|
||||
}
|
||||
31
extensions/bonjour/src/errors.test.ts
Normal file
31
extensions/bonjour/src/errors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
16
extensions/bonjour/src/errors.ts
Normal file
16
extensions/bonjour/src/errors.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user