mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
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:
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,9 +9,10 @@ title: "Bonjour discovery"
|
||||
# Bonjour / mDNS discovery
|
||||
|
||||
OpenClaw uses Bonjour (mDNS / DNS‑SD) 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 tailnet‑only 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 non‑secret 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
41
extensions/bonjour/index.ts
Normal file
41
extensions/bonjour/index.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
11
extensions/bonjour/openclaw.plugin.json
Normal file
11
extensions/bonjour/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
20
extensions/bonjour/package.json
Normal file
20
extensions/bonjour/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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) {
|
||||
@@ -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", () => {
|
||||
@@ -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 =
|
||||
@@ -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", () => {
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
13
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
196
src/gateway/server-discovery-runtime.test.ts
Normal file
196
src/gateway/server-discovery-runtime.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
gatewayDiscoveryServices: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -627,6 +627,7 @@ export async function startGatewayServer(
|
||||
log,
|
||||
logDiscovery,
|
||||
nodeRegistry,
|
||||
pluginRegistry,
|
||||
broadcast,
|
||||
nodeSendToAllSubscribed,
|
||||
getPresenceVersion,
|
||||
|
||||
@@ -31,6 +31,7 @@ function createStubPluginRegistry(): PluginRegistry {
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
gatewayDiscoveryServices: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -163,6 +163,7 @@ function createCapabilityPluginRecord(params: {
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
gatewayDiscoveryServiceIds: [],
|
||||
commands: [],
|
||||
httpRoutes: 0,
|
||||
hookCount: 0,
|
||||
|
||||
@@ -45,6 +45,7 @@ export function createMockPluginRegistry(
|
||||
gatewayHandlers: {},
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
gatewayDiscoveryServices: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
} as unknown as PluginRegistry;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1467,6 +1467,7 @@ function createPluginRecord(params: {
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
gatewayDiscoveryServiceIds: [],
|
||||
commands: [],
|
||||
httpRoutes: 0,
|
||||
hookCount: 0,
|
||||
|
||||
@@ -32,6 +32,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
nodeHostCommands: [],
|
||||
securityAuditCollectors: [],
|
||||
services: [],
|
||||
gatewayDiscoveryServices: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -48,6 +48,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
||||
nodeHostCommands: [],
|
||||
securityAuditCollectors: [],
|
||||
services: [],
|
||||
gatewayDiscoveryServices: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -101,6 +101,7 @@ describe("trajectory metadata", () => {
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
gatewayDiscoveryServiceIds: [],
|
||||
commands: [],
|
||||
httpRoutes: 0,
|
||||
hookCount: 0,
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
|
||||
registerCliBackend() {},
|
||||
registerTextTransforms() {},
|
||||
registerService() {},
|
||||
registerGatewayDiscoveryService() {},
|
||||
registerReload() {},
|
||||
registerNodeHostCommand() {},
|
||||
registerSecurityAuditCollector() {},
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -138,6 +138,7 @@ function createTestRegistryForSetup(
|
||||
nodeHostCommands: [],
|
||||
securityAuditCollectors: [],
|
||||
services: [],
|
||||
gatewayDiscoveryServices: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
|
||||
Reference in New Issue
Block a user