diff --git a/.github/labeler.yml b/.github/labeler.yml index 38ef333b691..bce17938942 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index acb13e8ae6d..be7752c3952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 2ee0b745120..e476d320f86 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/gateway/bonjour.md b/docs/gateway/bonjour.md index 31738f80bbe..90ef9cefd85 100644 --- a/docs/gateway/bonjour.md +++ b/docs/gateway/bonjour.md @@ -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=` (only when TLS is enabled and fingerprint is available) - `canvasPort=` (only when the canvas host is enabled; currently the same as `gatewayPort`) - `transport=gateway` -- `tailnetDns=` (optional hint when Tailnet is available) +- `tailnetDns=` (mDNS full mode only, optional hint when Tailnet is available) - `sshPort=` (mDNS full mode only; wide-area DNS-SD may omit it) - `cliPath=` (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 diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 071a5a0cc98..dbb27714b93 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -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 diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index fbbb8872802..7c99488a92f 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -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. +### 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: diff --git a/extensions/bonjour/index.ts b/extensions/bonjour/index.ts new file mode 100644 index 00000000000..a02c7e2e612 --- /dev/null +++ b/extensions/bonjour/index.ts @@ -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 }; + }, + }); + }, +}); diff --git a/extensions/bonjour/openclaw.plugin.json b/extensions/bonjour/openclaw.plugin.json new file mode 100644 index 00000000000..085f0a83f88 --- /dev/null +++ b/extensions/bonjour/openclaw.plugin.json @@ -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": {} + } +} diff --git a/extensions/bonjour/package.json b/extensions/bonjour/package.json new file mode 100644 index 00000000000..5a0f367c030 --- /dev/null +++ b/extensions/bonjour/package.json @@ -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 + } + } +} diff --git a/src/infra/bonjour.test.ts b/extensions/bonjour/src/advertiser.test.ts similarity index 72% rename from src/infra/bonjour.test.ts rename to extensions/bonjour/src/advertiser.test.ts index 8a24f2a50d6..089cbd0056b 100644 --- a/src/infra/bonjour.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -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; + responder?: Record; }) { 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("../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[0], +): ReturnType => + 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); - }); - 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)?.lanHost).toBe("test-host.local"); expect((gatewayCall?.[0]?.txt as Record)?.gatewayPort).toBe("18789"); expect((gatewayCall?.[0]?.txt as Record)?.sshPort).toBe("2222"); + expect((gatewayCall?.[0]?.txt as Record)?.tailnetDns).toBe( + "host.tailnet.ts.net", + ); expect((gatewayCall?.[0]?.txt as Record)?.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]>; expect((gatewayCall?.[0]?.txt as Record)?.sshPort).toBeUndefined(); expect((gatewayCall?.[0]?.txt as Record)?.cliPath).toBeUndefined(); + expect((gatewayCall?.[0]?.txt as Record)?.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, }); diff --git a/src/infra/bonjour.ts b/extensions/bonjour/src/advertiser.ts similarity index 75% rename from src/infra/bonjour.ts rename to extensions/bonjour/src/advertiser.ts index 51bc46b50ec..c2972ca8873 100644 --- a/src/infra/bonjour.ts +++ b/extensions/bonjour/src/advertiser.ts @@ -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; @@ -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; @@ -88,6 +59,12 @@ type ServiceStateTracker = { }; type ConsoleLogFn = (...args: unknown[]) => void; +type UnhandledRejectionHandler = (reason: unknown) => boolean; + +type BonjourAdvertiserDeps = { + logger?: Pick; + 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 | null = null; + +async function loadCiaoModule(): Promise { + ciaoModulePromise ??= import(CIAO_MODULE_ID) as Promise; + 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 | null = null; - -async function loadCiaoModule(): Promise { - ciaoModulePromise ??= import(CIAO_MODULE_ID) as Promise; - return ciaoModulePromise; -} - export async function startGatewayBonjourAdvertiser( opts: GatewayBonjourAdvertiseOpts, + deps: BonjourAdvertiserDeps = {}, ): Promise { 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 = { ...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(); 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) { diff --git a/src/infra/bonjour-ciao.test.ts b/extensions/bonjour/src/ciao.test.ts similarity index 97% rename from src/infra/bonjour-ciao.test.ts rename to extensions/bonjour/src/ciao.test.ts index 9f0ccc6d3e4..ce2299ae26d 100644 --- a/src/infra/bonjour-ciao.test.ts +++ b/extensions/bonjour/src/ciao.test.ts @@ -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", () => { diff --git a/src/infra/bonjour-ciao.ts b/extensions/bonjour/src/ciao.ts similarity index 94% rename from src/infra/bonjour-ciao.ts rename to extensions/bonjour/src/ciao.ts index 34ddb4b75fe..013aace0722 100644 --- a/src/infra/bonjour-ciao.ts +++ b/extensions/bonjour/src/ciao.ts @@ -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 = diff --git a/src/infra/bonjour-errors.test.ts b/extensions/bonjour/src/errors.test.ts similarity index 94% rename from src/infra/bonjour-errors.test.ts rename to extensions/bonjour/src/errors.test.ts index 2d25ddefae9..a7a66c2c12c 100644 --- a/src/infra/bonjour-errors.test.ts +++ b/extensions/bonjour/src/errors.test.ts @@ -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", () => { diff --git a/src/infra/bonjour-errors.ts b/extensions/bonjour/src/errors.ts similarity index 64% rename from src/infra/bonjour-errors.ts rename to extensions/bonjour/src/errors.ts index 5db4d89451e..1703f2696bf 100644 --- a/src/infra/bonjour-errors.ts +++ b/extensions/bonjour/src/errors.ts @@ -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}`; } diff --git a/package.json b/package.json index 47dde53f6d5..140344d0df1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20e2d4471ea..ebad2250558 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/root-dependency-ownership-audit.mjs b/scripts/root-dependency-ownership-audit.mjs index 6aba86c6a3c..10e2806adf9 100644 --- a/scripts/root-dependency-ownership-audit.mjs +++ b/scripts/root-dependency-ownership-audit.mjs @@ -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; } diff --git a/src/gateway/server-discovery-runtime.test.ts b/src/gateway/server-discovery-runtime.test.ts new file mode 100644 index 00000000000..1d58152749a --- /dev/null +++ b/src/gateway/server-discovery-runtime.test.ts @@ -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; + 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(); + }); +}); diff --git a/src/gateway/server-discovery-runtime.ts b/src/gateway/server-discovery-runtime.ts index 31b26270542..43a41d5c8b4 100644 --- a/src/gateway/server-discovery-runtime.ts +++ b/src/gateway/server-discovery-runtime.ts @@ -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) | 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> = []; + 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)}`); + } + } + }; } } diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 4e2451fe6a2..dc93df0bb26 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -96,6 +96,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ httpRoutes: [], cliRegistrars: [], services: [], + gatewayDiscoveryServices: [], conversationBindingResolvedHandlers: [], diagnostics, }); diff --git a/src/gateway/server-startup-early.ts b/src/gateway/server-startup-early.ts index 1d09585c859..a00ba32bdbd 100644 --- a/src/gateway/server-startup-early.ts +++ b/src/gateway/server-startup-early.ts @@ -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[0]; + pluginRegistry?: PluginRegistry; broadcast: Parameters[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; diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 5c3bceeedc8..4872810fed9 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -627,6 +627,7 @@ export async function startGatewayServer( log, logDiscovery, nodeRegistry, + pluginRegistry, broadcast, nodeSendToAllSubscribed, getPresenceVersion, diff --git a/src/gateway/test-helpers.plugin-registry.ts b/src/gateway/test-helpers.plugin-registry.ts index ff47a1de71f..696dc3c7bd2 100644 --- a/src/gateway/test-helpers.plugin-registry.ts +++ b/src/gateway/test-helpers.plugin-registry.ts @@ -31,6 +31,7 @@ function createStubPluginRegistry(): PluginRegistry { httpRoutes: [], cliRegistrars: [], services: [], + gatewayDiscoveryServices: [], commands: [], conversationBindingResolvedHandlers: [], diagnostics: [], diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index f00e1e8788f..8242c8f33bf 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -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, diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index c03ffff7ac9..323571ad4b8 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -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, diff --git a/src/plugins/bundled-capability-runtime.ts b/src/plugins/bundled-capability-runtime.ts index 5e72ead4d09..d8f9e5c8c63 100644 --- a/src/plugins/bundled-capability-runtime.ts +++ b/src/plugins/bundled-capability-runtime.ts @@ -163,6 +163,7 @@ function createCapabilityPluginRecord(params: { gatewayMethods: [], cliCommands: [], services: [], + gatewayDiscoveryServiceIds: [], commands: [], httpRoutes: 0, hookCount: 0, diff --git a/src/plugins/hooks.test-helpers.ts b/src/plugins/hooks.test-helpers.ts index b9b9a168f4f..fe38eeee902 100644 --- a/src/plugins/hooks.test-helpers.ts +++ b/src/plugins/hooks.test-helpers.ts @@ -45,6 +45,7 @@ export function createMockPluginRegistry( gatewayHandlers: {}, cliRegistrars: [], services: [], + gatewayDiscoveryServices: [], commands: [], diagnostics: [], } as unknown as PluginRegistry; diff --git a/src/plugins/inspect-shape.ts b/src/plugins/inspect-shape.ts index 417b00b11ef..ed7adee7dc0 100644 --- a/src/plugins/inspect-shape.ts +++ b/src/plugins/inspect-shape.ts @@ -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, }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index b92bd553e8a..650fccfd25f 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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) => + registry.gatewayDiscoveryServices.filter( + (entry) => entry.service.id === "shared-discovery", + ).length, + duplicateMessage: + "gateway discovery service already registered: shared-discovery (discovery-owner-a)", + assertPrimaryOwner: (registry: ReturnType) => { + 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({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index e3e42a684b1..09c121859ec 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1467,6 +1467,7 @@ function createPluginRecord(params: { gatewayMethods: [], cliCommands: [], services: [], + gatewayDiscoveryServiceIds: [], commands: [], httpRoutes: 0, hookCount: 0, diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index c718743c81c..d64306b4439 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -32,6 +32,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { nodeHostCommands: [], securityAuditCollectors: [], services: [], + gatewayDiscoveryServices: [], commands: [], conversationBindingResolvedHandlers: [], diagnostics: [], diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 4a77f0501d6..5e9606e8784 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -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[]; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 150f4ebebe2..71cf1876fde 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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), diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts index 342d84c4a92..8db63c3ad0d 100644 --- a/src/plugins/status.test-helpers.ts +++ b/src/plugins/status.test-helpers.ts @@ -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 ?? [], }; diff --git a/src/plugins/status.ts b/src/plugins/status.ts index a239c0fd0b0..46c136a454e 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -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, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 203d654330a..edbebf54499 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1852,6 +1852,25 @@ export type OpenClawPluginSecurityAuditCollector = ( ctx: OpenClawPluginSecurityAuditContext, ) => SecurityAuditFinding[] | Promise; +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 | Promise }>; +}; + /** 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. */ diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 433d5f73691..49a58abacb9 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -48,6 +48,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl nodeHostCommands: [], securityAuditCollectors: [], services: [], + gatewayDiscoveryServices: [], commands: [], conversationBindingResolvedHandlers: [], diagnostics: [], diff --git a/src/trajectory/metadata.test.ts b/src/trajectory/metadata.test.ts index f4b9da362d9..5156a9140e4 100644 --- a/src/trajectory/metadata.test.ts +++ b/src/trajectory/metadata.test.ts @@ -101,6 +101,7 @@ describe("trajectory metadata", () => { gatewayMethods: [], cliCommands: [], services: [], + gatewayDiscoveryServiceIds: [], commands: [], httpRoutes: 0, hookCount: 0, diff --git a/test/helpers/plugins/plugin-api.ts b/test/helpers/plugins/plugin-api.ts index 8e9f2ed95e5..34df5d4bc38 100644 --- a/test/helpers/plugins/plugin-api.ts +++ b/test/helpers/plugins/plugin-api.ts @@ -20,6 +20,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi registerCliBackend() {}, registerTextTransforms() {}, registerService() {}, + registerGatewayDiscoveryService() {}, registerReload() {}, registerNodeHostCommand() {}, registerSecurityAuditCollector() {}, diff --git a/test/scripts/root-dependency-ownership-audit.test.ts b/test/scripts/root-dependency-ownership-audit.test.ts index 3160bdca03e..e848059fbb2 100644 --- a/test/scripts/root-dependency-ownership-audit.test.ts +++ b/test/scripts/root-dependency-ownership-audit.test.ts @@ -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([ diff --git a/test/setup-openclaw-runtime.ts b/test/setup-openclaw-runtime.ts index 6aae77f19cd..20f835ccd72 100644 --- a/test/setup-openclaw-runtime.ts +++ b/test/setup-openclaw-runtime.ts @@ -138,6 +138,7 @@ function createTestRegistryForSetup( nodeHostCommands: [], securityAuditCollectors: [], services: [], + gatewayDiscoveryServices: [], commands: [], conversationBindingResolvedHandlers: [], diagnostics: [],