feat(plugins): move Bonjour discovery into bundled plugin

* fix(deps): detect constant dynamic imports in ownership audit

* feat(plugins): move bonjour discovery into bundled plugin

* test(plugins): remove moved bonjour core tests

* fix(plugins): harden bonjour disable and console restore

* fix(plugins): split gateway discovery ids from services

* fix(plugins): harden bonjour advertiser shutdown

* fix(plugins): clean up bonjour split lint
This commit is contained in:
Vincent Koc
2026-04-23 23:29:51 -07:00
committed by GitHub
parent 564f820efa
commit cb4fc58547
42 changed files with 849 additions and 204 deletions

5
.github/labeler.yml vendored
View File

@@ -29,6 +29,11 @@
- any-glob-to-any-file:
- "extensions/google-meet/**"
- "docs/plugins/google-meet.md"
"plugin: bonjour":
- changed-files:
- any-glob-to-any-file:
- "extensions/bonjour/**"
- "docs/gateway/bonjour.md"
"channel: imessage":
- changed-files:
- any-glob-to-any-file:

View File

@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Google Meet: add a bundled participant plugin with personal Google auth, explicit meeting URL joins, Chrome and Twilio transports, and realtime voice support. (#70765) Thanks @steipete.
- Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`.
- Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key.
- Plugins/Bonjour: move LAN Gateway discovery advertising into a default-enabled bundled plugin with its own `@homebridge/ciao` dependency, so users can disable Bonjour without cutting wide-area discovery. Thanks @vincentkoc.
- Providers/OpenAI: add image generation and reference-image editing through Codex OAuth, so `openai/gpt-image-2` works without an `OPENAI_API_KEY`. Fixes #70703.
- Providers/OpenRouter: add image generation and reference-image editing through `image_generate`, so OpenRouter image models work with `OPENROUTER_API_KEY`. Fixes #55066 via #67668. Thanks @notamicrodose.
- Image generation: let agents request provider-supported quality and output format hints, and pass OpenAI-specific background, moderation, compression, and user hints through the `image_generate` tool. (#70503) Thanks @ottodeng.

View File

@@ -1,2 +1,2 @@
3ce0dadfe0cac406051ff95ee8201a508d588e634b98ac22659e6b010c3641f6 plugin-sdk-api-baseline.json
69c9058277b146196a3a3ef49fe193e42987a3642a233732370c9ddae60ddf62 plugin-sdk-api-baseline.jsonl
ad7ec565b1702a76a87b1a08904445c9838e10d4d41fb1c58909af886b702d80 plugin-sdk-api-baseline.json
907a07c206dd52ebd910793fab7bca8640c37cf82ff7e7cca88ab1b12b4fbdfe plugin-sdk-api-baseline.jsonl

View File

@@ -9,9 +9,10 @@ title: "Bonjour discovery"
# Bonjour / mDNS discovery
OpenClaw uses Bonjour (mDNS / DNSSD) to discover an active Gateway (WebSocket endpoint).
Multicast `local.` browsing is a **LAN-only convenience**. For cross-network discovery, the
same beacon can also be published through a configured wide-area DNS-SD domain. Discovery is
still best-effort and does **not** replace SSH or Tailnet-based connectivity.
Multicast `local.` browsing is a **LAN-only convenience**. The bundled `bonjour`
plugin owns LAN advertising and is enabled by default. For cross-network discovery,
the same beacon can also be published through a configured wide-area DNS-SD domain.
Discovery is still best-effort and does **not** replace SSH or Tailnet-based connectivity.
## Wide-area Bonjour (Unicast DNS-SD) over Tailscale
@@ -79,7 +80,9 @@ For tailnetonly setups:
## What advertises
Only the Gateway advertises `_openclaw-gw._tcp`.
Only the Gateway advertises `_openclaw-gw._tcp`. LAN multicast advertising is
provided by the bundled `bonjour` plugin; wide-area DNS-SD publishing remains
Gateway-owned.
## Service types
@@ -97,7 +100,7 @@ The Gateway advertises small nonsecret hints to make UI flows convenient:
- `gatewayTlsSha256=<sha256>` (only when TLS is enabled and fingerprint is available)
- `canvasPort=<port>` (only when the canvas host is enabled; currently the same as `gatewayPort`)
- `transport=gateway`
- `tailnetDns=<magicdns>` (optional hint when Tailnet is available)
- `tailnetDns=<magicdns>` (mDNS full mode only, optional hint when Tailnet is available)
- `sshPort=<port>` (mDNS full mode only; wide-area DNS-SD may omit it)
- `cliPath=<path>` (mDNS full mode only; wide-area DNS-SD still writes it as a remote-install hint)
@@ -167,10 +170,12 @@ sequences (e.g. spaces become `\032`).
## Disabling / configuration
- `OPENCLAW_DISABLE_BONJOUR=1` disables advertising (legacy: `OPENCLAW_DISABLE_BONJOUR`).
- `openclaw plugins disable bonjour` disables LAN multicast advertising by disabling the bundled plugin.
- `openclaw plugins enable bonjour` restores the default LAN discovery plugin.
- `OPENCLAW_DISABLE_BONJOUR=1` disables LAN multicast advertising without changing plugin config; accepted truthy values are `1`, `true`, `yes`, and `on` (legacy: `OPENCLAW_DISABLE_BONJOUR`).
- `gateway.bind` in `~/.openclaw/openclaw.json` controls the Gateway bind mode.
- `OPENCLAW_SSH_PORT` overrides the SSH port when `sshPort` is advertised (legacy: `OPENCLAW_SSH_PORT`).
- `OPENCLAW_TAILNET_DNS` publishes a MagicDNS hint in TXT (legacy: `OPENCLAW_TAILNET_DNS`).
- `OPENCLAW_TAILNET_DNS` publishes a MagicDNS hint in TXT when mDNS full mode is enabled (legacy: `OPENCLAW_TAILNET_DNS`).
- `OPENCLAW_CLI_PATH` overrides the advertised CLI path (legacy: `OPENCLAW_CLI_PATH`).
## Related docs

View File

@@ -49,9 +49,11 @@ native OpenClaw plugin registers against one or more capability types:
| Web fetch | `api.registerWebFetchProvider(...)` | `firecrawl` |
| Web search | `api.registerWebSearchProvider(...)` | `google` |
| Channel / messaging | `api.registerChannel(...)` | `msteams`, `matrix` |
| Gateway discovery | `api.registerGatewayDiscoveryService(...)` | `bonjour` |
A plugin that registers zero capabilities but provides hooks, tools, or
services is a **legacy hook-only** plugin. That pattern is still fully supported.
A plugin that registers zero capabilities but provides hooks, tools, discovery
services, or background services is a **legacy hook-only** plugin. That pattern
is still fully supported.
### External compatibility stance

View File

@@ -94,6 +94,7 @@ methods:
| `api.registerHook(events, handler, opts?)` | Event hook |
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
| `api.registerGatewayDiscoveryService(service)` | Local Gateway discovery advertiser |
| `api.registerCli(registrar, opts?)` | CLI subcommand |
| `api.registerService(service)` | Background service |
| `api.registerInteractiveHandler(registration)` | Interactive handler |
@@ -119,6 +120,32 @@ and they must declare `contracts.embeddedExtensionFactories: ["pi"]` in
does not require that lower-level seam.
</Accordion>
### Gateway discovery registration
`api.registerGatewayDiscoveryService(...)` lets a plugin advertise the active
Gateway on a local discovery transport such as mDNS/Bonjour. OpenClaw calls the
service during Gateway startup when local discovery is enabled, passes the
current Gateway ports and non-secret TXT hint data, and calls the returned
`stop` handler during Gateway shutdown.
```typescript
api.registerGatewayDiscoveryService({
id: "my-discovery",
async advertise(ctx) {
const handle = await startMyAdvertiser({
gatewayPort: ctx.gatewayPort,
tls: ctx.gatewayTlsEnabled,
displayName: ctx.machineDisplayName,
});
return { stop: () => handle.stop() };
},
});
```
Gateway discovery plugins must not treat advertised TXT values as secrets or
authentication. Discovery is a routing hint; Gateway auth and TLS pinning still
own trust.
### CLI registration metadata
`api.registerCli(registrar, opts?)` accepts two kinds of top-level metadata:

View File

@@ -0,0 +1,41 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { startGatewayBonjourAdvertiser } from "./src/advertiser.js";
function formatBonjourInstanceName(displayName: string) {
const trimmed = displayName.trim();
if (!trimmed) {
return "OpenClaw";
}
if (/openclaw/i.test(trimmed)) {
return trimmed;
}
return `${trimmed} (OpenClaw)`;
}
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 advertiser = await startGatewayBonjourAdvertiser(
{
instanceName: formatBonjourInstanceName(ctx.machineDisplayName),
gatewayPort: ctx.gatewayPort,
gatewayTlsEnabled: ctx.gatewayTlsEnabled,
gatewayTlsFingerprintSha256: ctx.gatewayTlsFingerprintSha256,
canvasPort: ctx.canvasPort,
sshPort: ctx.sshPort,
tailnetDns: ctx.tailnetDns,
cliPath: ctx.cliPath,
minimal: ctx.minimal,
},
{ logger: api.logger },
);
return { stop: advertiser.stop };
},
});
},
});

View File

@@ -0,0 +1,11 @@
{
"id": "bonjour",
"enabledByDefault": true,
"name": "Bonjour Gateway Discovery",
"description": "Advertise the local OpenClaw gateway over Bonjour/mDNS.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "@openclaw/bonjour",
"version": "2026.4.24",
"description": "OpenClaw Bonjour/mDNS gateway discovery",
"type": "module",
"dependencies": {
"@homebridge/ciao": "^1.3.6"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"bundle": {
"stageRuntimeDependencies": true
}
}
}

View File

@@ -1,16 +1,18 @@
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as logging from "../logging.js";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createService: vi.fn(),
getResponder: vi.fn(),
shutdown: vi.fn(),
registerUnhandledRejectionHandler: vi.fn(),
logWarn: vi.fn(),
logDebug: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));
const { createService, shutdown, registerUnhandledRejectionHandler, logWarn, logDebug } = mocks;
const getLoggerInfo = vi.fn();
const { createService, getResponder, shutdown, registerUnhandledRejectionHandler, logger } = mocks;
const asString = (value: unknown, fallback: string) =>
typeof value === "string" && value.trim() ? value : fallback;
@@ -29,6 +31,7 @@ function mockCiaoService(params?: {
serviceState?: string;
stateRef?: { value: string };
on?: ReturnType<typeof vi.fn>;
responder?: Record<string, unknown>;
}) {
const advertise = params?.advertise ?? vi.fn().mockResolvedValue(undefined);
const destroy = params?.destroy ?? vi.fn().mockResolvedValue(undefined);
@@ -54,39 +57,28 @@ function mockCiaoService(params?: {
});
return service;
});
getResponder.mockReturnValue(params?.responder ?? { createService, shutdown });
return { advertise, destroy, on };
}
vi.mock("../logger.js", async () => {
const actual = await vi.importActual<typeof import("../logger.js")>("../logger.js");
return {
...actual,
logWarn: (message: string) => logWarn(message),
logDebug: (message: string) => logDebug(message),
logInfo: vi.fn(),
logError: vi.fn(),
logSuccess: vi.fn(),
};
});
vi.mock("@homebridge/ciao", () => {
return {
Protocol: { TCP: "tcp" },
getResponder: () => ({
createService,
shutdown,
}),
getResponder,
};
});
vi.mock("./unhandled-rejections.js", () => {
return {
registerUnhandledRejectionHandler: (handler: (reason: unknown) => boolean) =>
registerUnhandledRejectionHandler(handler),
};
});
const { startGatewayBonjourAdvertiser } = await import("./advertiser.js");
const { startGatewayBonjourAdvertiser } = await import("./bonjour.js");
type StartGatewayBonjourAdvertiser = typeof startGatewayBonjourAdvertiser;
const startAdvertiser = (
opts: Parameters<StartGatewayBonjourAdvertiser>[0],
): ReturnType<StartGatewayBonjourAdvertiser> =>
startGatewayBonjourAdvertiser(opts, {
logger,
registerUnhandledRejectionHandler: (handler) => registerUnhandledRejectionHandler(handler),
});
describe("gateway bonjour advertiser", () => {
type ServiceCall = {
@@ -98,12 +90,6 @@ describe("gateway bonjour advertiser", () => {
const prevEnv = { ...process.env };
beforeEach(() => {
vi.spyOn(logging, "getLogger").mockReturnValue({
info: (...args: unknown[]) => getLoggerInfo(...args),
} as unknown as ReturnType<typeof logging.getLogger>);
});
afterEach(() => {
for (const key of Object.keys(process.env)) {
if (!(key in prevEnv)) {
@@ -115,10 +101,12 @@ describe("gateway bonjour advertiser", () => {
}
createService.mockClear();
getResponder.mockReset();
shutdown.mockClear();
registerUnhandledRejectionHandler.mockClear();
logWarn.mockClear();
logDebug.mockClear();
logger.info.mockClear();
logger.warn.mockClear();
logger.debug.mockClear();
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -136,11 +124,12 @@ describe("gateway bonjour advertiser", () => {
);
mockCiaoService({ advertise, destroy });
const started = await startGatewayBonjourAdvertiser({
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
tailnetDns: "host.tailnet.ts.net",
cliPath: "/opt/homebrew/bin/openclaw",
minimal: false,
});
expect(createService).toHaveBeenCalledTimes(1);
@@ -154,6 +143,9 @@ describe("gateway bonjour advertiser", () => {
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayPort).toBe("18789");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.tailnetDns).toBe(
"host.tailnet.ts.net",
);
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
"/opt/homebrew/bin/openclaw",
);
@@ -176,20 +168,35 @@ describe("gateway bonjour advertiser", () => {
const advertise = vi.fn().mockResolvedValue(undefined);
mockCiaoService({ advertise, destroy });
const started = await startGatewayBonjourAdvertiser({
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
cliPath: "/opt/homebrew/bin/openclaw",
tailnetDns: "host.tailnet.ts.net",
minimal: true,
});
const [gatewayCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBeUndefined();
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.cliPath).toBeUndefined();
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.tailnetDns).toBeUndefined();
await started.stop();
});
it("honors truthy OPENCLAW_DISABLE_BONJOUR values", async () => {
enableAdvertiserUnitMode();
process.env.OPENCLAW_DISABLE_BONJOUR = "true";
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
expect(createService).not.toHaveBeenCalled();
await expect(started.stop()).resolves.toBeUndefined();
});
it("attaches conflict listeners for services", async () => {
enableAdvertiserUnitMode();
@@ -202,7 +209,7 @@ describe("gateway bonjour advertiser", () => {
});
mockCiaoService({ advertise, destroy, on });
const started = await startGatewayBonjourAdvertiser({
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
@@ -213,6 +220,27 @@ describe("gateway bonjour advertiser", () => {
await started.stop();
});
it("does not install a process-level unhandled rejection handler by default", async () => {
enableAdvertiserUnitMode();
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockResolvedValue(undefined);
mockCiaoService({ advertise, destroy });
const processOn = vi.spyOn(process, "on");
const started = await startGatewayBonjourAdvertiser(
{
gatewayPort: 18789,
sshPort: 2222,
},
{ logger },
);
expect(processOn).not.toHaveBeenCalledWith("unhandledRejection", expect.any(Function));
await started.stop();
});
it("cleans up unhandled rejection handler after shutdown", async () => {
enableAdvertiserUnitMode();
@@ -229,7 +257,7 @@ describe("gateway bonjour advertiser", () => {
});
registerUnhandledRejectionHandler.mockImplementation(() => cleanup);
const started = await startGatewayBonjourAdvertiser({
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
@@ -248,7 +276,7 @@ describe("gateway bonjour advertiser", () => {
const advertise = vi.fn().mockResolvedValue(undefined);
mockCiaoService({ advertise, destroy });
const started = await startGatewayBonjourAdvertiser({
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
@@ -259,15 +287,15 @@ describe("gateway bonjour advertiser", () => {
expect(handler).toBeTypeOf("function");
expect(handler?.(new Error("CIAO PROBING CANCELLED"))).toBe(true);
expect(logDebug).toHaveBeenCalledWith(
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining("ignoring unhandled ciao rejection"),
);
logDebug.mockClear();
logger.debug.mockClear();
expect(
handler?.(new Error("Reached illegal state! IPV4 address change from defined to undefined!")),
).toBe(true);
expect(logWarn).toHaveBeenCalledWith(
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("suppressing ciao interface assertion"),
);
@@ -285,7 +313,7 @@ describe("gateway bonjour advertiser", () => {
.mockResolvedValue(undefined); // watchdog retry succeeds
mockCiaoService({ advertise, destroy, serviceState: "unannounced" });
const started = await startGatewayBonjourAdvertiser({
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
@@ -295,7 +323,7 @@ describe("gateway bonjour advertiser", () => {
// allow promise rejection handler to run
await Promise.resolve();
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("advertise failed"));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("advertise failed"));
// watchdog first retries, then recreates the advertiser after the service
// stays unhealthy across multiple 5s ticks.
@@ -318,13 +346,13 @@ describe("gateway bonjour advertiser", () => {
});
mockCiaoService({ advertise, destroy, serviceState: "unannounced" });
const started = await startGatewayBonjourAdvertiser({
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
expect(advertise).toHaveBeenCalledTimes(1);
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("advertise threw"));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("advertise threw"));
await started.stop();
});
@@ -341,7 +369,7 @@ describe("gateway bonjour advertiser", () => {
console.log = baseConsoleLog as typeof console.log;
try {
const started = await startGatewayBonjourAdvertiser({
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
@@ -360,6 +388,66 @@ describe("gateway bonjour advertiser", () => {
}
});
it("does not monkey-patch responder methods during shutdown", async () => {
enableAdvertiserUnitMode();
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockResolvedValue(undefined);
const responder = {
createService,
shutdown,
advertiseService: vi.fn(),
announce: vi.fn(),
probe: vi.fn(),
republishService: vi.fn(),
};
const originalMethods = {
advertiseService: responder.advertiseService,
announce: responder.announce,
probe: responder.probe,
republishService: responder.republishService,
};
mockCiaoService({ advertise, destroy, responder });
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
await started.stop();
expect(responder.advertiseService).toBe(originalMethods.advertiseService);
expect(responder.announce).toBe(originalMethods.announce);
expect(responder.probe).toBe(originalMethods.probe);
expect(responder.republishService).toBe(originalMethods.republishService);
});
it("does not clobber console.log if another wrapper replaced it before shutdown", async () => {
enableAdvertiserUnitMode();
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockResolvedValue(undefined);
mockCiaoService({ advertise, destroy });
const originalConsoleLog = console.log;
const baseConsoleLog = vi.fn();
const replacementConsoleLog = vi.fn();
console.log = baseConsoleLog as typeof console.log;
try {
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
console.log = replacementConsoleLog as typeof console.log;
await started.stop();
expect(console.log).toBe(replacementConsoleLog);
} finally {
console.log = originalConsoleLog;
}
});
it("recreates the advertiser when ciao gets stuck announcing", async () => {
enableAdvertiserUnitMode();
vi.useFakeTimers();
@@ -382,7 +470,7 @@ describe("gateway bonjour advertiser", () => {
});
mockCiaoService({ advertise, destroy, stateRef });
const started = await startGatewayBonjourAdvertiser({
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
@@ -392,7 +480,7 @@ describe("gateway bonjour advertiser", () => {
await vi.advanceTimersByTimeAsync(15_000);
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("restarting advertiser"));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("restarting advertiser"));
expect(createService).toHaveBeenCalledTimes(2);
expect(advertise).toHaveBeenCalledTimes(2);
expect(destroy).toHaveBeenCalledTimes(1);
@@ -423,7 +511,7 @@ describe("gateway bonjour advertiser", () => {
});
mockCiaoService({ advertise, destroy, stateRef });
const started = await startGatewayBonjourAdvertiser({
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
@@ -433,7 +521,9 @@ describe("gateway bonjour advertiser", () => {
await vi.advanceTimersByTimeAsync(15_000);
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("service stuck in announcing"));
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("service stuck in announcing"),
);
expect(createService).toHaveBeenCalledTimes(2);
expect(advertise).toHaveBeenCalledTimes(3);
expect(destroy).toHaveBeenCalledTimes(1);
@@ -453,7 +543,7 @@ describe("gateway bonjour advertiser", () => {
const advertise = vi.fn().mockResolvedValue(undefined);
mockCiaoService({ advertise, destroy });
const started = await startGatewayBonjourAdvertiser({
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});

View File

@@ -1,9 +1,7 @@
import { logDebug, logWarn } from "../logger.js";
import { getLogger } from "../logging.js";
import { classifyCiaoUnhandledRejection } from "./bonjour-ciao.js";
import { formatBonjourError } from "./bonjour-errors.js";
import { isTruthyEnvValue } from "./env.js";
import { registerUnhandledRejectionHandler } from "./unhandled-rejections.js";
import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry";
import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
import { classifyCiaoUnhandledRejection } from "./ciao.js";
import { formatBonjourError } from "./errors.js";
export type GatewayBonjourAdvertiser = {
stop: () => Promise<void>;
@@ -18,36 +16,9 @@ export type GatewayBonjourAdvertiseOpts = {
canvasPort?: number;
tailnetDns?: string;
cliPath?: string;
/**
* Minimal mode - omit sensitive fields (cliPath, sshPort) from TXT records.
* Reduces information disclosure for better operational security.
*/
minimal?: boolean;
};
function isDisabledByEnv() {
if (isTruthyEnvValue(process.env.OPENCLAW_DISABLE_BONJOUR)) {
return true;
}
if (process.env.NODE_ENV === "test") {
return true;
}
if (process.env.VITEST) {
return true;
}
return false;
}
function safeServiceName(name: string) {
const trimmed = name.trim();
return trimmed.length > 0 ? trimmed : "OpenClaw";
}
function prettifyInstanceName(name: string) {
const normalized = name.trim().replace(/\s+/g, " ");
return normalized.replace(/\s+\(OpenClaw\)\s*$/i, "").trim() || normalized;
}
type BonjourService = {
serviceState?: unknown;
advertise: () => Promise<void>;
@@ -88,6 +59,12 @@ type ServiceStateTracker = {
};
type ConsoleLogFn = (...args: unknown[]) => void;
type UnhandledRejectionHandler = (reason: unknown) => boolean;
type BonjourAdvertiserDeps = {
logger?: Pick<PluginLogger, "info" | "warn" | "debug">;
registerUnhandledRejectionHandler?: (handler: UnhandledRejectionHandler) => () => void;
};
const WATCHDOG_INTERVAL_MS = 5_000;
const REPAIR_DEBOUNCE_MS = 30_000;
@@ -96,6 +73,43 @@ 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";
let ciaoModulePromise: Promise<CiaoModule> | null = null;
async function loadCiaoModule(): Promise<CiaoModule> {
ciaoModulePromise ??= import(CIAO_MODULE_ID) as Promise<CiaoModule>;
return ciaoModulePromise;
}
function isDisabledByEnv() {
if (isTruthyEnvValue(process.env.OPENCLAW_DISABLE_BONJOUR)) {
return true;
}
if (process.env.NODE_ENV === "test") {
return true;
}
if (process.env.VITEST) {
return true;
}
return false;
}
function safeServiceName(name: string) {
const trimmed = name.trim();
return trimmed.length > 0 ? 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";
@@ -123,21 +137,6 @@ function isAnnouncedState(state: string) {
return state === BONJOUR_ANNOUNCED_STATE;
}
function handleCiaoUnhandledRejection(reason: unknown): boolean {
const classification = classifyCiaoUnhandledRejection(reason);
if (!classification) {
return false;
}
if (classification.kind === "interface-assertion") {
logWarn(`bonjour: suppressing ciao interface assertion: ${classification.formatted}`);
return true;
}
logDebug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`);
return true;
}
function shouldSuppressCiaoConsoleLog(args: unknown[]): boolean {
return args.some(
(arg) => typeof arg === "string" && arg.includes(CIAO_SELF_PROBE_RETRY_FRAGMENT),
@@ -145,42 +144,53 @@ function shouldSuppressCiaoConsoleLog(args: unknown[]): boolean {
}
function installCiaoConsoleNoiseFilter(): () => void {
const originalConsoleLog = console.log as ConsoleLogFn;
console.log = ((...args: unknown[]) => {
const previousConsoleLog = console.log as ConsoleLogFn;
const wrapper = ((...args: unknown[]) => {
if (shouldSuppressCiaoConsoleLog(args)) {
return;
}
originalConsoleLog(...args);
previousConsoleLog(...args);
}) as ConsoleLogFn;
console.log = wrapper;
return () => {
if (console.log === originalConsoleLog) {
return;
if (console.log === wrapper) {
console.log = previousConsoleLog;
}
console.log = originalConsoleLog;
};
}
const CIAO_MODULE_ID = "@homebridge/ciao";
let ciaoModulePromise: Promise<CiaoModule> | null = null;
async function loadCiaoModule(): Promise<CiaoModule> {
ciaoModulePromise ??= import(CIAO_MODULE_ID) as Promise<CiaoModule>;
return ciaoModulePromise;
}
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 { getResponder, Protocol } = await loadCiaoModule();
const restoreConsoleLog = installCiaoConsoleNoiseFilter();
const handleCiaoUnhandledRejection = (reason: unknown): boolean => {
const classification = classifyCiaoUnhandledRejection(reason);
if (!classification) {
return false;
}
if (classification.kind === "interface-assertion") {
logger.warn(`bonjour: suppressing ciao interface assertion: ${classification.formatted}`);
return true;
}
logger.debug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`);
return true;
};
try {
// mDNS service instance names are single DNS labels; dots in hostnames (like
// `Mac.localdomain`) can confuse some resolvers/browsers and break discovery.
// Keep only the first label and normalize away a trailing `.local`.
const hostnameRaw = process.env.OPENCLAW_MDNS_HOSTNAME?.trim() || "openclaw";
const hostname =
hostnameRaw
@@ -208,17 +218,13 @@ export async function startGatewayBonjourAdvertiser(
if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) {
txtBase.canvasPort = String(opts.canvasPort);
}
if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
if (!opts.minimal && typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
txtBase.tailnetDns = opts.tailnetDns.trim();
}
// In minimal mode, omit cliPath to avoid exposing filesystem structure.
// This info can be obtained via the authenticated WebSocket if needed.
if (!opts.minimal && typeof opts.cliPath === "string" && opts.cliPath.trim()) {
txtBase.cliPath = opts.cliPath.trim();
}
// Build TXT record for the gateway service.
// In minimal mode, omit sshPort to avoid advertising SSH availability.
const gatewayTxt: Record<string, string> = {
...txtBase,
transport: "gateway",
@@ -246,8 +252,8 @@ export async function startGatewayBonjourAdvertiser(
});
const cleanupUnhandledRejection =
services.length > 0
? registerUnhandledRejectionHandler(handleCiaoUnhandledRejection)
services.length > 0 && deps.registerUnhandledRejectionHandler
? deps.registerUnhandledRejectionHandler(handleCiaoUnhandledRejection)
: undefined;
return { responder, services, cleanupUnhandledRejection };
@@ -257,20 +263,6 @@ export async function startGatewayBonjourAdvertiser(
if (!cycle) {
return;
}
const responder = cycle.responder as unknown as {
advertiseService?: (...args: unknown[]) => unknown;
announce?: (...args: unknown[]) => unknown;
probe?: (...args: unknown[]) => unknown;
republishService?: (...args: unknown[]) => unknown;
};
const noopAsync = async () => {};
// ciao schedules its own 2s retry timers after failed probe/announce attempts.
// Those callbacks target the original responder instance, so disarm it before
// destroy/shutdown to prevent a dead cycle from re-entering advertise/probe.
responder.advertiseService = noopAsync;
responder.announce = noopAsync;
responder.probe = noopAsync;
responder.republishService = noopAsync;
for (const { svc } of cycle.services) {
try {
await svc.destroy();
@@ -292,16 +284,18 @@ export async function startGatewayBonjourAdvertiser(
try {
svc.on("name-change", (name: unknown) => {
const next = typeof name === "string" ? name : String(name);
logWarn(`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`);
logger.warn(
`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`,
);
});
svc.on("hostname-change", (nextHostname: unknown) => {
const next = typeof nextHostname === "string" ? nextHostname : String(nextHostname);
logWarn(
logger.warn(
`bonjour: ${label} hostname conflict resolved; newHostname=${JSON.stringify(next)}`,
);
});
} catch (err) {
logDebug(`bonjour: failed to attach listeners for ${label}: ${String(err)}`);
logger.debug(`bonjour: failed to attach listeners for ${label}: ${String(err)}`);
}
}
}
@@ -312,23 +306,22 @@ export async function startGatewayBonjourAdvertiser(
void svc
.advertise()
.then(() => {
// Keep this out of stdout/stderr (menubar + tests) but capture in the rolling log.
getLogger().info(`bonjour: advertised ${serviceSummary(label, svc)}`);
logger.info(`bonjour: advertised ${serviceSummary(label, svc)}`);
})
.catch((err) => {
logWarn(
logger.warn(
`bonjour: advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
});
} catch (err) {
logWarn(
logger.warn(
`bonjour: advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
}
}
}
logDebug(
logger.debug(
`bonjour: starting (hostname=${hostname}, instance=${JSON.stringify(
safeServiceName(instanceName),
)}, gatewayPort=${opts.gatewayPort}${opts.minimal ? ", minimal=true" : `, sshPort=${opts.sshPort ?? 22}`})`,
@@ -364,7 +357,7 @@ export async function startGatewayBonjourAdvertiser(
return recreatePromise;
}
recreatePromise = (async () => {
logWarn(`bonjour: restarting advertiser (${reason})`);
logger.warn(`bonjour: restarting advertiser (${reason})`);
const previous = cycle;
await stopCycle(previous);
cycle = createCycle();
@@ -377,8 +370,6 @@ export async function startGatewayBonjourAdvertiser(
return recreatePromise;
};
// Watchdog: if we ever end up in an unannounced state (e.g. after sleep/wake or
// interface churn), try to re-advertise instead of requiring a full gateway restart.
const lastRepairAttempt = new Map<string, number>();
const watchdog = setInterval(() => {
if (stopped || recreatePromise) {
@@ -421,7 +412,7 @@ export async function startGatewayBonjourAdvertiser(
}
lastRepairAttempt.set(key, now);
logWarn(
logger.warn(
`bonjour: watchdog detected non-announced service; attempting re-advertise (${serviceSummary(
label,
svc,
@@ -429,13 +420,13 @@ export async function startGatewayBonjourAdvertiser(
);
try {
void svc.advertise().catch((err) => {
logWarn(
`bonjour: watchdog advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
logger.warn(
`bonjour: watchdog re-advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
});
} catch (err) {
logWarn(
`bonjour: watchdog advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
logger.warn(
`bonjour: watchdog re-advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
}
}
@@ -445,13 +436,14 @@ export async function startGatewayBonjourAdvertiser(
return {
stop: async () => {
stopped = true;
clearInterval(watchdog);
try {
clearInterval(watchdog);
await recreatePromise;
await stopCycle(cycle);
} finally {
restoreConsoleLog();
} catch {
// ignore
}
await stopCycle(cycle);
restoreConsoleLog();
},
};
} catch (err) {

View File

@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
const { classifyCiaoUnhandledRejection, ignoreCiaoUnhandledRejection } =
await import("./bonjour-ciao.js");
const { classifyCiaoUnhandledRejection, ignoreCiaoUnhandledRejection } = await import("./ciao.js");
describe("bonjour-ciao", () => {
it("classifies ciao cancellation rejections separately from side effects", () => {

View File

@@ -1,4 +1,4 @@
import { formatBonjourError } from "./bonjour-errors.js";
import { formatBonjourError } from "./errors.js";
const CIAO_CANCELLATION_MESSAGE_RE = /^CIAO (?:ANNOUNCEMENT|PROBING) CANCELLED\b/u;
const CIAO_INTERFACE_ASSERTION_MESSAGE_RE =

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatBonjourError } from "./bonjour-errors.js";
import { formatBonjourError } from "./errors.js";
describe("formatBonjourError", () => {
it("formats named errors with their type prefix", () => {

View File

@@ -1,9 +1,7 @@
import { normalizeOptionalString } from "../shared/string-coerce.js";
export function formatBonjourError(err: unknown): string {
if (err instanceof Error) {
const trimmedMessage = err.message.trim();
const msg = trimmedMessage || err.name || (normalizeOptionalString(String(err)) ?? "");
const msg = trimmedMessage || err.name || String(err).trim();
if (err.name && err.name !== "Error") {
return msg === err.name ? err.name : `${err.name}: ${msg}`;
}

View File

@@ -1581,7 +1581,6 @@
"@agentclientprotocol/sdk": "0.19.0",
"@anthropic-ai/vertex-sdk": "^0.16.0",
"@clack/prompts": "^1.2.0",
"@homebridge/ciao": "^1.3.6",
"@lydell/node-pty": "1.2.0-beta.12",
"@mariozechner/pi-agent-core": "0.70.0",
"@mariozechner/pi-ai": "0.70.0",

13
pnpm-lock.yaml generated
View File

@@ -48,9 +48,6 @@ importers:
'@clack/prompts':
specifier: ^1.2.0
version: 1.2.0
'@homebridge/ciao':
specifier: ^1.3.6
version: 1.3.6
'@lydell/node-pty':
specifier: 1.2.0-beta.12
version: 1.2.0-beta.12
@@ -318,6 +315,16 @@ importers:
specifier: workspace:*
version: link:../..
extensions/bonjour:
dependencies:
'@homebridge/ciao':
specifier: ^1.3.6
version: 1.3.6
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/brave:
dependencies:
typebox:

View File

@@ -17,6 +17,12 @@ const IMPORT_PATTERNS = [
/\brequire\s*\(\s*["']([^"']+)["']\s*\)/g,
/\b(?:require|[_$A-Za-z][\w$]*require[\w$]*)\.resolve\s*\(\s*["']([^"']+)["']\s*\)/gi,
];
const STRING_CONSTANT_PATTERN = /\b(?:const|let|var)\s+([_$A-Za-z][\w$]*)\s*=\s*["']([^"']+)["']/g;
const DYNAMIC_CONSTANT_IMPORT_PATTERNS = [
/\bimport\s*\(\s*([_$A-Za-z][\w$]*)\s*\)/g,
/\brequire\s*\(\s*([_$A-Za-z][\w$]*)\s*\)/g,
/\b(?:require|[_$A-Za-z][\w$]*require[\w$]*)\.resolve\s*\(\s*([_$A-Za-z][\w$]*)\s*\)/gi,
];
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
@@ -73,6 +79,20 @@ export function collectModuleSpecifiers(source) {
}
}
}
const stringConstants = new Map();
for (const match of source.matchAll(STRING_CONSTANT_PATTERN)) {
if (match[1] && match[2]) {
stringConstants.set(match[1], match[2]);
}
}
for (const pattern of DYNAMIC_CONSTANT_IMPORT_PATTERNS) {
for (const match of source.matchAll(pattern)) {
const specifier = match[1] ? stringConstants.get(match[1]) : undefined;
if (specifier) {
specifiers.add(specifier);
}
}
}
return specifiers;
}

View File

@@ -0,0 +1,196 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginGatewayDiscoveryServiceRegistration } from "../plugins/registry-types.js";
const mocks = vi.hoisted(() => ({
pickPrimaryTailnetIPv4: vi.fn(() => "100.64.0.10"),
pickPrimaryTailnetIPv6: vi.fn(() => undefined as string | undefined),
resolveWideAreaDiscoveryDomain: vi.fn(() => "openclaw.internal."),
writeWideAreaGatewayZone: vi.fn(async () => ({
changed: true,
zonePath: "/tmp/openclaw.internal.db",
})),
formatBonjourInstanceName: vi.fn((name: string) => `${name} (OpenClaw)`),
resolveBonjourCliPath: vi.fn(() => "/usr/local/bin/openclaw"),
resolveTailnetDnsHint: vi.fn(async () => "gateway.tailnet.example.ts.net"),
}));
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: mocks.pickPrimaryTailnetIPv4,
pickPrimaryTailnetIPv6: mocks.pickPrimaryTailnetIPv6,
}));
vi.mock("../infra/widearea-dns.js", () => ({
resolveWideAreaDiscoveryDomain: mocks.resolveWideAreaDiscoveryDomain,
writeWideAreaGatewayZone: mocks.writeWideAreaGatewayZone,
}));
vi.mock("./server-discovery.js", () => ({
formatBonjourInstanceName: mocks.formatBonjourInstanceName,
resolveBonjourCliPath: mocks.resolveBonjourCliPath,
resolveTailnetDnsHint: mocks.resolveTailnetDnsHint,
}));
const { startGatewayDiscovery } = await import("./server-discovery-runtime.js");
const makeLogs = () => ({
info: vi.fn(),
warn: vi.fn(),
});
const makeDiscoveryService = (params: {
id: string;
pluginId?: string;
stop?: () => void | Promise<void>;
advertise?: PluginGatewayDiscoveryServiceRegistration["service"]["advertise"];
}): PluginGatewayDiscoveryServiceRegistration => ({
pluginId: params.pluginId ?? params.id,
pluginName: params.pluginId ?? params.id,
source: "test",
service: {
id: params.id,
advertise: params.advertise ?? vi.fn(async () => ({ stop: params.stop })),
},
});
describe("startGatewayDiscovery", () => {
const prevEnv = { ...process.env };
afterEach(() => {
for (const key of Object.keys(process.env)) {
if (!(key in prevEnv)) {
delete process.env[key];
}
}
for (const [key, value] of Object.entries(prevEnv)) {
process.env[key] = value;
}
vi.clearAllMocks();
});
it("starts registered local discovery services with gateway advertisement context", async () => {
process.env.NODE_ENV = "development";
delete process.env.VITEST;
process.env.OPENCLAW_SSH_PORT = "2222";
const stopped: string[] = [];
const bonjour = makeDiscoveryService({
id: "bonjour",
pluginId: "bonjour",
stop: () => {
stopped.push("bonjour");
},
});
const peer = makeDiscoveryService({
id: "peer-discovery",
pluginId: "peer",
stop: () => {
stopped.push("peer");
},
});
const logs = makeLogs();
const result = await startGatewayDiscovery({
machineDisplayName: "Lab Mac",
port: 18789,
gatewayTls: { enabled: true, fingerprintSha256: "abc123" },
canvasPort: 18789,
wideAreaDiscoveryEnabled: false,
tailscaleMode: "serve",
mdnsMode: "full",
gatewayDiscoveryServices: [bonjour, peer],
logDiscovery: logs,
});
expect(bonjour.service.advertise).toHaveBeenCalledWith({
machineDisplayName: "Lab Mac",
gatewayPort: 18789,
gatewayTlsEnabled: true,
gatewayTlsFingerprintSha256: "abc123",
canvasPort: 18789,
sshPort: 2222,
tailnetDns: "gateway.tailnet.example.ts.net",
cliPath: "/usr/local/bin/openclaw",
minimal: false,
});
expect(peer.service.advertise).toHaveBeenCalledTimes(1);
expect(logs.warn).not.toHaveBeenCalled();
await result.bonjourStop?.();
expect(stopped).toEqual(["peer", "bonjour"]);
});
it("skips local discovery services when mDNS mode is off", async () => {
process.env.NODE_ENV = "development";
delete process.env.VITEST;
const service = makeDiscoveryService({ id: "bonjour" });
const result = await startGatewayDiscovery({
machineDisplayName: "Lab Mac",
port: 18789,
wideAreaDiscoveryEnabled: false,
tailscaleMode: "off",
mdnsMode: "off",
gatewayDiscoveryServices: [service],
logDiscovery: makeLogs(),
});
expect(service.service.advertise).not.toHaveBeenCalled();
expect(mocks.resolveTailnetDnsHint).not.toHaveBeenCalled();
expect(result.bonjourStop).toBeNull();
});
it("skips local discovery services for truthy OPENCLAW_DISABLE_BONJOUR values", async () => {
process.env.NODE_ENV = "development";
delete process.env.VITEST;
process.env.OPENCLAW_DISABLE_BONJOUR = "yes";
const service = makeDiscoveryService({ id: "bonjour" });
const result = await startGatewayDiscovery({
machineDisplayName: "Lab Mac",
port: 18789,
wideAreaDiscoveryEnabled: false,
tailscaleMode: "serve",
mdnsMode: "full",
gatewayDiscoveryServices: [service],
logDiscovery: makeLogs(),
});
expect(service.service.advertise).not.toHaveBeenCalled();
expect(result.bonjourStop).toBeNull();
});
it("keeps wide-area DNS-SD publishing active when local discovery is off", async () => {
process.env.NODE_ENV = "development";
delete process.env.VITEST;
const service = makeDiscoveryService({ id: "bonjour" });
const logs = makeLogs();
const result = await startGatewayDiscovery({
machineDisplayName: "Lab Mac",
port: 18789,
gatewayTls: { enabled: false },
wideAreaDiscoveryEnabled: true,
wideAreaDiscoveryDomain: "openclaw.internal.",
tailscaleMode: "serve",
mdnsMode: "off",
gatewayDiscoveryServices: [service],
logDiscovery: logs,
});
expect(service.service.advertise).not.toHaveBeenCalled();
expect(mocks.resolveTailnetDnsHint).toHaveBeenCalledWith({ enabled: true });
expect(mocks.writeWideAreaGatewayZone).toHaveBeenCalledWith(
expect.objectContaining({
domain: "openclaw.internal.",
gatewayPort: 18789,
displayName: "Lab Mac (OpenClaw)",
tailnetIPv4: "100.64.0.10",
tailnetDns: "gateway.tailnet.example.ts.net",
}),
);
expect(logs.info).toHaveBeenCalledWith(expect.stringContaining("wide-area DNS-SD updated"));
expect(result.bonjourStop).toBeNull();
});
});

View File

@@ -1,6 +1,7 @@
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
import { resolveWideAreaDiscoveryDomain, writeWideAreaGatewayZone } from "../infra/widearea-dns.js";
import type { PluginGatewayDiscoveryServiceRegistration } from "../plugins/registry-types.js";
import {
formatBonjourInstanceName,
resolveBonjourCliPath,
@@ -17,19 +18,20 @@ export async function startGatewayDiscovery(params: {
tailscaleMode: "off" | "serve" | "funnel";
/** mDNS/Bonjour discovery mode (default: minimal). */
mdnsMode?: "off" | "minimal" | "full";
gatewayDiscoveryServices?: readonly PluginGatewayDiscoveryServiceRegistration[];
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
}) {
let bonjourStop: (() => Promise<void>) | null = null;
const mdnsMode = params.mdnsMode ?? "minimal";
// mDNS can be disabled via config (mdnsMode: off) or env var.
const bonjourEnabled =
// Local discovery can be disabled via config (mdnsMode: off) or env var.
const localDiscoveryEnabled =
mdnsMode !== "off" &&
process.env.OPENCLAW_DISABLE_BONJOUR !== "1" &&
!isTruthyEnvValue(process.env.OPENCLAW_DISABLE_BONJOUR) &&
process.env.NODE_ENV !== "test" &&
!process.env.VITEST;
const mdnsMinimal = mdnsMode !== "full";
const tailscaleEnabled = params.tailscaleMode !== "off";
const needsTailnetDns = bonjourEnabled || params.wideAreaDiscoveryEnabled;
const needsTailnetDns = localDiscoveryEnabled || params.wideAreaDiscoveryEnabled;
const tailnetDns = needsTailnetDns
? await resolveTailnetDnsHint({ enabled: tailscaleEnabled })
: undefined;
@@ -38,22 +40,40 @@ export async function startGatewayDiscovery(params: {
const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : undefined;
const cliPath = mdnsMinimal ? undefined : resolveBonjourCliPath();
if (bonjourEnabled) {
try {
const bonjour = await startGatewayBonjourAdvertiser({
instanceName: formatBonjourInstanceName(params.machineDisplayName),
gatewayPort: params.port,
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
canvasPort: params.canvasPort,
sshPort,
tailnetDns,
cliPath,
minimal: mdnsMinimal,
});
bonjourStop = bonjour.stop;
} catch (err) {
params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`);
if (localDiscoveryEnabled) {
const stops: Array<() => void | Promise<void>> = [];
for (const entry of params.gatewayDiscoveryServices ?? []) {
try {
const started = await entry.service.advertise({
machineDisplayName: params.machineDisplayName,
gatewayPort: params.port,
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
canvasPort: params.canvasPort,
sshPort,
tailnetDns,
cliPath,
minimal: mdnsMinimal,
});
if (started?.stop) {
stops.push(started.stop);
}
} catch (err) {
params.logDiscovery.warn(
`gateway discovery service failed (${entry.service.id}, plugin=${entry.pluginId}): ${String(err)}`,
);
}
}
if (stops.length > 0) {
bonjourStop = async () => {
for (const stop of stops.toReversed()) {
try {
await stop();
} catch (err) {
params.logDiscovery.warn(`gateway discovery stop failed: ${String(err)}`);
}
}
};
}
}

View File

@@ -96,6 +96,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
httpRoutes: [],
cliRegistrars: [],
services: [],
gatewayDiscoveryServices: [],
conversationBindingResolvedHandlers: [],
diagnostics,
});

View File

@@ -7,6 +7,7 @@ import {
refreshRemoteBinsForConnectedNodes,
setSkillsRemoteRegistry,
} from "../infra/skills-remote.js";
import type { PluginRegistry } from "../plugins/registry-types.js";
import { startTaskRegistryMaintenance } from "../tasks/task-registry.maintenance.js";
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
import { startGatewayMaintenanceTimers } from "./server-maintenance.js";
@@ -26,6 +27,7 @@ export async function startGatewayEarlyRuntime(params: {
warn: (msg: string) => void;
};
nodeRegistry: Parameters<typeof setSkillsRemoteRegistry>[0];
pluginRegistry?: PluginRegistry;
broadcast: Parameters<typeof startGatewayMaintenanceTimers>[0]["broadcast"];
nodeSendToAllSubscribed: Parameters<
typeof startGatewayMaintenanceTimers
@@ -66,6 +68,7 @@ export async function startGatewayEarlyRuntime(params: {
wideAreaDiscoveryDomain: params.cfgAtStart.discovery?.wideArea?.domain,
tailscaleMode: params.tailscaleMode,
mdnsMode: params.cfgAtStart.discovery?.mdns?.mode,
gatewayDiscoveryServices: params.pluginRegistry?.gatewayDiscoveryServices,
logDiscovery: params.logDiscovery,
});
bonjourStop = discovery.bonjourStop;

View File

@@ -627,6 +627,7 @@ export async function startGatewayServer(
log,
logDiscovery,
nodeRegistry,
pluginRegistry,
broadcast,
nodeSendToAllSubscribed,
getPresenceVersion,

View File

@@ -31,6 +31,7 @@ function createStubPluginRegistry(): PluginRegistry {
httpRoutes: [],
cliRegistrars: [],
services: [],
gatewayDiscoveryServices: [],
commands: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],

View File

@@ -68,6 +68,8 @@ import type {
ProviderValidateReplayTurnsContext,
ProviderWebSocketSessionPolicy,
ProviderWrapStreamFnContext,
OpenClawGatewayDiscoveryAdvertiseContext,
OpenClawGatewayDiscoveryService,
SpeechProviderPlugin,
PluginCommandContext,
PluginCommandResult,
@@ -134,6 +136,8 @@ export type {
ProviderValidateReplayTurnsContext,
ProviderWebSocketSessionPolicy,
ProviderWrapStreamFnContext,
OpenClawGatewayDiscoveryAdvertiseContext,
OpenClawGatewayDiscoveryService,
OpenClawPluginService,
OpenClawPluginServiceContext,
ProviderAuthContext,

View File

@@ -28,6 +28,7 @@ export type BuildPluginApiParams = {
| "registerNodeHostCommand"
| "registerSecurityAuditCollector"
| "registerService"
| "registerGatewayDiscoveryService"
| "registerCliBackend"
| "registerTextTransforms"
| "registerConfigMigration"
@@ -74,6 +75,8 @@ const noopRegisterNodeHostCommand: OpenClawPluginApi["registerNodeHostCommand"]
const noopRegisterSecurityAuditCollector: OpenClawPluginApi["registerSecurityAuditCollector"] =
() => {};
const noopRegisterService: OpenClawPluginApi["registerService"] = () => {};
const noopRegisterGatewayDiscoveryService: OpenClawPluginApi["registerGatewayDiscoveryService"] =
() => {};
const noopRegisterCliBackend: OpenClawPluginApi["registerCliBackend"] = () => {};
const noopRegisterTextTransforms: OpenClawPluginApi["registerTextTransforms"] = () => {};
const noopRegisterConfigMigration: OpenClawPluginApi["registerConfigMigration"] = () => {};
@@ -143,6 +146,8 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
registerSecurityAuditCollector:
handlers.registerSecurityAuditCollector ?? noopRegisterSecurityAuditCollector,
registerService: handlers.registerService ?? noopRegisterService,
registerGatewayDiscoveryService:
handlers.registerGatewayDiscoveryService ?? noopRegisterGatewayDiscoveryService,
registerCliBackend: handlers.registerCliBackend ?? noopRegisterCliBackend,
registerTextTransforms: handlers.registerTextTransforms ?? noopRegisterTextTransforms,
registerConfigMigration: handlers.registerConfigMigration ?? noopRegisterConfigMigration,

View File

@@ -163,6 +163,7 @@ function createCapabilityPluginRecord(params: {
gatewayMethods: [],
cliCommands: [],
services: [],
gatewayDiscoveryServiceIds: [],
commands: [],
httpRoutes: 0,
hookCount: 0,

View File

@@ -45,6 +45,7 @@ export function createMockPluginRegistry(
gatewayHandlers: {},
cliRegistrars: [],
services: [],
gatewayDiscoveryServices: [],
commands: [],
diagnostics: [],
} as unknown as PluginRegistry;

View File

@@ -65,6 +65,7 @@ export function derivePluginInspectShape(params: {
commandCount: number;
cliCount: number;
serviceCount: number;
gatewayDiscoveryServiceCount: number;
gatewayMethodCount: number;
httpRouteCount: number;
}): PluginInspectShape {
@@ -80,6 +81,7 @@ export function derivePluginInspectShape(params: {
params.commandCount === 0 &&
params.cliCount === 0 &&
params.serviceCount === 0 &&
params.gatewayDiscoveryServiceCount === 0 &&
params.gatewayMethodCount === 0 &&
params.httpRouteCount === 0;
if (hasOnlyHooks) {
@@ -111,6 +113,7 @@ export function buildPluginShapeSummary(params: {
commandCount: params.plugin.commands.length,
cliCount: params.plugin.cliCommands.length,
serviceCount: params.plugin.services.length,
gatewayDiscoveryServiceCount: params.plugin.gatewayDiscoveryServiceIds.length,
gatewayMethodCount: params.plugin.gatewayMethods.length,
httpRouteCount: params.plugin.httpRoutes,
});

View File

@@ -4117,6 +4117,27 @@ module.exports = { id: "throws-after-import", register() {} };`,
duplicateMessage: "service already registered: shared-service (service-owner-a)",
assert: expectDuplicateRegistrationResult,
},
{
label: "gateway discovery service ids",
ownerA: "discovery-owner-a",
ownerB: "discovery-owner-b",
buildBody: (ownerId: string) => `module.exports = { id: "${ownerId}", register(api) {
api.registerGatewayDiscoveryService({ id: "shared-discovery", advertise() {} });
} };`,
selectCount: (registry: ReturnType<typeof loadOpenClawPlugins>) =>
registry.gatewayDiscoveryServices.filter(
(entry) => entry.service.id === "shared-discovery",
).length,
duplicateMessage:
"gateway discovery service already registered: shared-discovery (discovery-owner-a)",
assertPrimaryOwner: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
expect(
registry.plugins.find((entry) => entry.id === "discovery-owner-a")
?.gatewayDiscoveryServiceIds,
).toEqual(["shared-discovery"]);
},
assert: expectDuplicateRegistrationResult,
},
{
label: "plugin context engine ids",
ownerA: "context-engine-owner-a",
@@ -4195,6 +4216,32 @@ module.exports = { id: "throws-after-import", register() {} };`,
).toBe(false);
});
it("tracks regular services and gateway discovery services separately", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "split-service-owner",
filename: "split-service-owner.cjs",
body: `module.exports = { id: "split-service-owner", register(api) {
api.registerService({ id: "shared-service", start() {} });
api.registerGatewayDiscoveryService({ id: "shared-service", advertise() {} });
} };`,
});
const registry = loadRegistryFromSinglePlugin({
plugin,
pluginConfig: {
allow: ["split-service-owner"],
},
});
const record = registry.plugins.find((entry) => entry.id === "split-service-owner");
expect(record?.services).toEqual(["shared-service"]);
expect(record?.gatewayDiscoveryServiceIds).toEqual(["shared-service"]);
expect(registry.services).toHaveLength(1);
expect(registry.gatewayDiscoveryServices).toHaveLength(1);
expect(registry.diagnostics).toEqual([]);
});
it("rewrites removed registerHttpHandler failures into migration diagnostics", () => {
useNoBundledPlugins();
const plugin = writePlugin({

View File

@@ -1467,6 +1467,7 @@ function createPluginRecord(params: {
gatewayMethods: [],
cliCommands: [],
services: [],
gatewayDiscoveryServiceIds: [],
commands: [],
httpRoutes: 0,
hookCount: 0,

View File

@@ -32,6 +32,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
nodeHostCommands: [],
securityAuditCollectors: [],
services: [],
gatewayDiscoveryServices: [],
commands: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],

View File

@@ -27,6 +27,7 @@ import type {
OpenClawPluginCliRegistrar,
OpenClawPluginCommandDefinition,
OpenClawPluginGatewayRuntimeScopeSurface,
OpenClawGatewayDiscoveryService,
OpenClawPluginHttpRouteAuth,
OpenClawPluginHttpRouteHandler,
OpenClawPluginHttpRouteMatch,
@@ -187,6 +188,14 @@ export type PluginServiceRegistration = {
rootDir?: string;
};
export type PluginGatewayDiscoveryServiceRegistration = {
pluginId: string;
pluginName?: string;
service: OpenClawGatewayDiscoveryService;
source: string;
rootDir?: string;
};
export type PluginReloadRegistration = {
pluginId: string;
pluginName?: string;
@@ -271,6 +280,7 @@ export type PluginRecord = {
gatewayMethods: string[];
cliCommands: string[];
services: string[];
gatewayDiscoveryServiceIds: string[];
commands: string[];
httpRoutes: number;
hookCount: number;
@@ -312,6 +322,7 @@ export type PluginRegistry = {
nodeHostCommands?: PluginNodeHostCommandRegistration[];
securityAuditCollectors?: PluginSecurityAuditCollectorRegistration[];
services: PluginServiceRegistration[];
gatewayDiscoveryServices: PluginGatewayDiscoveryServiceRegistration[];
commands: PluginCommandRegistration[];
conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[];
diagnostics: PluginDiagnostic[];

View File

@@ -99,6 +99,7 @@ import type {
OpenClawPluginCommandDefinition,
PluginConversationBindingResolvedEvent,
OpenClawPluginGatewayRuntimeScopeSurface,
OpenClawGatewayDiscoveryService,
OpenClawPluginHttpRouteParams,
OpenClawPluginHookOptions,
OpenClawPluginNodeHostCommand,
@@ -1118,6 +1119,37 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
};
const registerGatewayDiscoveryService = (
record: PluginRecord,
service: OpenClawGatewayDiscoveryService,
) => {
const id = service.id.trim();
if (!id) {
return;
}
const existing = registry.gatewayDiscoveryServices.find((entry) => entry.service.id === id);
if (existing) {
if (existing.pluginId === record.id) {
return;
}
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `gateway discovery service already registered: ${id} (${existing.pluginId})`,
});
return;
}
record.gatewayDiscoveryServiceIds.push(id);
registry.gatewayDiscoveryServices.push({
pluginId: record.id,
pluginName: record.name,
service,
source: record.source,
rootDir: record.rootDir,
});
};
const registerCommand = (record: PluginRecord, command: OpenClawPluginCommandDefinition) => {
const name = command.name.trim();
if (!name) {
@@ -1359,6 +1391,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerGatewayMethod: (method, handler, opts) =>
registerGatewayMethod(record, method, handler, opts),
registerService: (service) => registerService(record, service),
registerGatewayDiscoveryService: (service) =>
registerGatewayDiscoveryService(record, service),
registerCliBackend: (backend) => registerCliBackend(record, backend),
registerTextTransforms: (transforms) => registerTextTransforms(record, transforms),
registerReload: (registration) => registerReload(record, registration),

View File

@@ -65,6 +65,7 @@ export function createPluginRecord(
gatewayMethods: [],
cliCommands: [],
services: [],
gatewayDiscoveryServiceIds: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
@@ -143,6 +144,7 @@ export function createPluginLoadResult(
commands: [],
conversationBindingResolvedHandlers: [],
...rest,
gatewayDiscoveryServices: rest.gatewayDiscoveryServices ?? [],
realtimeTranscriptionProviders: realtimeTranscriptionProviders ?? [],
realtimeVoiceProviders: realtimeVoiceProviders ?? [],
};

View File

@@ -68,6 +68,7 @@ export type PluginInspectReport = {
commands: string[];
cliCommands: string[];
services: string[];
gatewayDiscoveryServices: string[];
gatewayMethods: string[];
mcpServers: Array<{
name: string;
@@ -341,6 +342,7 @@ export function buildPluginInspectReport(params: {
commands: [...plugin.commands],
cliCommands: [...plugin.cliCommands],
services: [...plugin.services],
gatewayDiscoveryServices: [...plugin.gatewayDiscoveryServiceIds],
gatewayMethods: [...plugin.gatewayMethods],
mcpServers,
lspServers,

View File

@@ -1852,6 +1852,25 @@ export type OpenClawPluginSecurityAuditCollector = (
ctx: OpenClawPluginSecurityAuditContext,
) => SecurityAuditFinding[] | Promise<SecurityAuditFinding[]>;
export type OpenClawGatewayDiscoveryAdvertiseContext = {
machineDisplayName: string;
gatewayPort: number;
gatewayTlsEnabled: boolean;
gatewayTlsFingerprintSha256?: string;
canvasPort?: number;
tailnetDns?: string;
sshPort?: number;
cliPath?: string;
minimal: boolean;
};
export type OpenClawGatewayDiscoveryService = {
id: string;
advertise: (
ctx: OpenClawGatewayDiscoveryAdvertiseContext,
) => void | Promise<void | { stop?: () => void | Promise<void> }>;
};
/** Context passed to long-lived plugin services. */
export type OpenClawPluginServiceContext = {
config: OpenClawConfig;
@@ -1969,6 +1988,8 @@ export type OpenClawPluginApi = {
registerNodeHostCommand: (command: OpenClawPluginNodeHostCommand) => void;
registerSecurityAuditCollector: (collector: OpenClawPluginSecurityAuditCollector) => void;
registerService: (service: OpenClawPluginService) => void;
/** Register a local gateway discovery advertiser such as mDNS/Bonjour. */
registerGatewayDiscoveryService: (service: OpenClawGatewayDiscoveryService) => void;
/** Register a text-only CLI backend used by the local CLI runner. */
registerCliBackend: (backend: CliBackendPlugin) => void;
/** Register plugin-owned prompt/message compatibility text transforms. */

View File

@@ -48,6 +48,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
nodeHostCommands: [],
securityAuditCollectors: [],
services: [],
gatewayDiscoveryServices: [],
commands: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],

View File

@@ -101,6 +101,7 @@ describe("trajectory metadata", () => {
gatewayMethods: [],
cliCommands: [],
services: [],
gatewayDiscoveryServiceIds: [],
commands: [],
httpRoutes: 0,
hookCount: 0,

View File

@@ -20,6 +20,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
registerCliBackend() {},
registerTextTransforms() {},
registerService() {},
registerGatewayDiscoveryService() {},
registerReload() {},
registerNodeHostCommand() {},
registerSecurityAuditCollector() {},

View File

@@ -40,6 +40,26 @@ describe("collectModuleSpecifiers", () => {
`),
]).toEqual(["gaxios", "openshell/package.json"]);
});
it("resolves simple string constants used by lazy runtime imports", () => {
expect([
...collectModuleSpecifiers(`
const READABILITY_MODULE = "@mozilla/readability";
const PDFJS_MODULE = "pdfjs-dist/legacy/build/pdf.mjs";
const CIAO_MODULE_ID = "@homebridge/ciao";
let SQLITE_VEC_MODULE_ID = "sqlite-vec";
import(READABILITY_MODULE);
import(PDFJS_MODULE);
require(CIAO_MODULE_ID);
require.resolve(SQLITE_VEC_MODULE_ID);
`),
]).toEqual([
"@mozilla/readability",
"pdfjs-dist/legacy/build/pdf.mjs",
"@homebridge/ciao",
"sqlite-vec",
]);
});
});
describe("classifyRootDependencyOwnership", () => {
@@ -132,6 +152,55 @@ describe("collectRootDependencyOwnershipCheckErrors", () => {
]);
});
it("classifies root dependencies referenced through constant dynamic imports", () => {
const repoRoot = makeTempRepo();
writeRepoFile(
repoRoot,
"package.json",
JSON.stringify({ dependencies: { "pdfjs-dist": "^5.0.0", "sqlite-vec": "0.1.9" } }),
);
writeRepoFile(
repoRoot,
"src/media/pdf-extract.ts",
`
const PDFJS_MODULE = "pdfjs-dist/legacy/build/pdf.mjs";
export async function loadPdf() {
return import(PDFJS_MODULE);
}
`,
);
writeRepoFile(
repoRoot,
"packages/memory-host-sdk/src/host/sqlite-vec.ts",
`
const SQLITE_VEC_MODULE_ID = "sqlite-vec";
export async function loadSqliteVecModule() {
return import(SQLITE_VEC_MODULE_ID);
}
`,
);
const records = collectRootDependencyOwnershipAudit({
repoRoot,
scanRoots: ["src", "packages"],
});
expect(records).toMatchObject([
{
category: "core_runtime",
depName: "pdfjs-dist",
sampleFiles: ["src/media/pdf-extract.ts"],
sections: ["src"],
},
{
category: "core_runtime",
depName: "sqlite-vec",
sampleFiles: ["packages/memory-host-sdk/src/host/sqlite-vec.ts"],
sections: ["packages"],
},
]);
});
it("fails only extension-owned root dependencies", () => {
expect(
collectRootDependencyOwnershipCheckErrors([

View File

@@ -138,6 +138,7 @@ function createTestRegistryForSetup(
nodeHostCommands: [],
securityAuditCollectors: [],
services: [],
gatewayDiscoveryServices: [],
commands: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],