diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e5ee04cb23..183350853f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - CLI/message: exit cleanly with a nonzero status when message-command plugin registry loading fails before dispatch, preventing `openclaw-message` children from staying alive after plugin load errors. Fixes #76168. - Gateway/update: recover an installed-but-unloaded macOS LaunchAgent after package updates, rerun Gateway health/version/channel readiness checks, and print restart, reinstall, and rollback guidance before reporting update failure. (#76790) Thanks @jonathanlindsay. - CLI/plugins: explain when a missing plugin command alias belongs to a bundled plugin that is disabled by default, including the `openclaw plugins enable ` repair command. (#76835) +- Gateway/Bonjour: auto-start LAN multicast discovery only on macOS hosts while preserving explicit `openclaw plugins enable bonjour` startup elsewhere, so Linux servers and containers that do not need LAN discovery avoid default mDNS probing and watchdog churn. Refs #74209. - Google Meet: route stateful CLI session commands through the gateway-owned runtime so joined realtime sessions survive after the starting CLI process exits. Fixes #76344. Thanks @coltonharris-wq. - Memory/status: split builtin sqlite-vec store readiness from embedding-provider readiness in `memory status --deep` and `openclaw status`, so local vector-store failures no longer look like provider failures and provider failures no longer hide a healthy local vector store. - CLI/doctor: trust a ready gateway memory probe when CLI-side active memory backend resolution is unavailable, preventing false "No active memory plugin is registered" warnings for healthy runtime setups. Fixes #76792. Thanks @som-686. diff --git a/docs/gateway/bonjour.md b/docs/gateway/bonjour.md index 0e11c39a5a9..9f06cd18ca1 100644 --- a/docs/gateway/bonjour.md +++ b/docs/gateway/bonjour.md @@ -8,11 +8,12 @@ title: "Bonjour discovery" # Bonjour / mDNS discovery -OpenClaw uses Bonjour (mDNS / DNS‑SD) to discover an active Gateway (WebSocket endpoint). +OpenClaw can use Bonjour (mDNS / DNS-SD) to discover an active Gateway (WebSocket endpoint). 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. +plugin owns LAN advertising. It auto-starts on macOS hosts and is opt-in on +Linux, Windows, and containerized Gateway deployments. 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 @@ -81,8 +82,8 @@ For tailnet‑only setups: ## What advertises 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. +provided by the bundled `bonjour` plugin when the plugin is enabled; wide-area +DNS-SD publishing remains Gateway-owned. ## Service types @@ -159,13 +160,30 @@ To capture logs: The log includes browser state transitions and result‑set changes. +## When to enable Bonjour + +Bonjour auto-starts for empty-config Gateway startup on macOS hosts because the +local app and nearby iOS/Android nodes commonly rely on same-LAN discovery. + +Enable Bonjour explicitly when same-LAN auto-discovery is useful on Linux, +Windows, or another non-macOS host: + +```bash +openclaw plugins enable bonjour +``` + +When enabled, Bonjour uses `discovery.mdns.mode` to decide how much TXT metadata +to publish. The default mode is `minimal`; use `full` only when local clients need +`cliPath` or `sshPort` hints, and use `off` to suppress LAN multicast without +changing plugin enablement. + ## When to disable Bonjour -Disable Bonjour only when LAN multicast advertising is unavailable or harmful. -The common case is a Gateway running behind Docker bridge networking, WSL, or a -network policy that drops mDNS multicast. In those environments the Gateway is -still reachable through its published URL, SSH, Tailnet, or wide-area DNS-SD, -but LAN auto-discovery is not reliable. +Leave Bonjour disabled when LAN multicast advertising is unnecessary, unavailable, +or harmful. The common cases are non-macOS servers, Docker bridge networking, +WSL, or a network policy that drops mDNS multicast. In those environments the +Gateway is still reachable through its published URL, SSH, Tailnet, or wide-area +DNS-SD, but LAN auto-discovery is not reliable. Prefer the existing environment override when the problem is deployment-scoped: @@ -177,8 +195,8 @@ That disables LAN multicast advertising without changing plugin configuration. It is safe for Docker images, service files, launch scripts, and one-off debugging because the setting disappears when the environment does. -Use plugin configuration only when you intentionally want to turn off the -bundled LAN discovery plugin for that OpenClaw config: +Use plugin configuration when you intentionally want to turn off the bundled LAN +discovery plugin for that OpenClaw config: ```bash openclaw plugins disable bonjour @@ -193,8 +211,8 @@ and the LAN, so advertising from the container rarely makes discovery work. Important gotchas: -- Disabling Bonjour does not stop the Gateway. It only stops LAN multicast - advertising. +- Bonjour auto-starts on macOS hosts and is opt-in elsewhere. Leaving it + disabled does not stop the Gateway; it only skips LAN multicast advertising. - Disabling Bonjour does not change `gateway.bind`; Docker still defaults to `OPENCLAW_GATEWAY_BIND=lan` so the published host port can work. - Disabling Bonjour does not disable wide-area DNS-SD. Use wide-area discovery @@ -226,8 +244,8 @@ If a node no longer auto-discovers the Gateway after Docker setup: - Cross-network clients: Tailnet MagicDNS, Tailnet IP, SSH tunnel, or wide-area DNS-SD -4. If you deliberately enabled Bonjour in Docker with - `OPENCLAW_DISABLE_BONJOUR=0`, test multicast from the host: +4. If you deliberately enabled the Bonjour plugin in Docker and forced advertising + with `OPENCLAW_DISABLE_BONJOUR=0`, test multicast from the host: ```bash dns-sd -B _openclaw-gw._tcp local. @@ -261,13 +279,14 @@ sequences (e.g. spaces become `\032`). - This is normal at the protocol level. - UIs should decode for display (iOS uses `BonjourEscapes.decode`). -## Disabling / configuration +## Enabling / disabling / configuration +- macOS hosts auto-start the bundled LAN discovery plugin by default. +- `openclaw plugins enable bonjour` enables the bundled LAN discovery plugin on hosts where it is not default-enabled. - `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`). - `OPENCLAW_DISABLE_BONJOUR=0` forces LAN multicast advertising on, including inside detected containers; accepted falsy values are `0`, `false`, `no`, and `off`. -- When `OPENCLAW_DISABLE_BONJOUR` is unset, Bonjour advertises on normal hosts and auto-disables inside detected containers. +- When the Bonjour plugin is enabled and `OPENCLAW_DISABLE_BONJOUR` is unset, Bonjour advertises on normal hosts and auto-disables inside detected containers. - `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 when mDNS full mode is enabled (legacy: `OPENCLAW_TAILNET_DNS`). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 52e95d5d04b..1afaa8d8581 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -687,8 +687,10 @@ Validation and safety notes: } ``` -- `minimal` (default): omit `cliPath` + `sshPort` from TXT records. -- `full`: include `cliPath` + `sshPort`. +- `minimal` (default when the bundled `bonjour` plugin is enabled): omit `cliPath` + `sshPort` from TXT records. +- `full`: include `cliPath` + `sshPort`; LAN multicast advertising still requires the bundled `bonjour` plugin to be enabled. +- `off`: suppress LAN multicast advertising without changing plugin enablement. +- The bundled `bonjour` plugin auto-starts on macOS hosts and is opt-in on Linux, Windows, and containerized Gateway deployments. - Hostname defaults to the system hostname when it is a valid DNS label, falling back to `openclaw`. Override with `OPENCLAW_MDNS_HOSTNAME`. ### Wide-area (DNS-SD) diff --git a/docs/gateway/discovery.md b/docs/gateway/discovery.md index 94e06581d22..f64501468d6 100644 --- a/docs/gateway/discovery.md +++ b/docs/gateway/discovery.md @@ -54,7 +54,9 @@ same gateway beacon via a configured wide-area DNS-SD domain, so discovery can c Target direction: -- The **gateway** advertises its WS endpoint via Bonjour. +- The **gateway** advertises its WS endpoint via Bonjour when the bundled + `bonjour` plugin is enabled. The plugin auto-starts on macOS hosts and is + opt-in elsewhere. - Clients browse and show a “pick a gateway” list, then store the chosen endpoint. Troubleshooting and beacon details: [Bonjour](/gateway/bonjour). @@ -83,12 +85,16 @@ Security notes: - TLS pinning must never allow an advertised `gatewayTlsSha256` to override a previously stored pin. - iOS/Android nodes should require an explicit “trust this fingerprint” confirmation before storing a first-time pin (out-of-band verification) whenever the chosen route is secure/TLS-based. -Disable/override: +Enable/disable/override: +- `openclaw plugins enable bonjour` enables LAN multicast advertising. - `OPENCLAW_DISABLE_BONJOUR=1` disables advertising. -- When `OPENCLAW_DISABLE_BONJOUR` is unset, Bonjour advertises on normal hosts - and auto-disables inside detected containers. Use `0` only on host, macvlan, - or another mDNS-capable network; use `1` to force-disable. +- When the Bonjour plugin is enabled and `OPENCLAW_DISABLE_BONJOUR` is unset, + Bonjour advertises on normal hosts and auto-disables inside detected containers. + Empty-config macOS Gateway startup enables the plugin automatically; Linux, + Windows, and containerized deployments need explicit enablement. + Use `0` only on host, macvlan, or another mDNS-capable network; use `1` to + force-disable. - `gateway.bind` in `~/.openclaw/openclaw.json` controls the Gateway bind mode. - `OPENCLAW_SSH_PORT` overrides the SSH port advertised when `sshPort` is emitted. - `OPENCLAW_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS). diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index d46d32ee44e..e52c4534fa9 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -796,7 +796,7 @@ setups: SSH + your reverse proxy ports). ### mDNS/Bonjour discovery -The Gateway broadcasts its presence via mDNS (`_openclaw-gw._tcp` on port 5353) for local device discovery. In full mode, this includes TXT records that may expose operational details: +When the bundled `bonjour` plugin is enabled, the Gateway broadcasts its presence via mDNS (`_openclaw-gw._tcp` on port 5353) for local device discovery. In full mode, this includes TXT records that may expose operational details: - `cliPath`: full filesystem path to the CLI binary (reveals username and install location) - `sshPort`: advertises SSH availability on the host @@ -806,7 +806,9 @@ The Gateway broadcasts its presence via mDNS (`_openclaw-gw._tcp` on port 5353) **Recommendations:** -1. **Minimal mode** (default, recommended for exposed gateways): omit sensitive fields from mDNS broadcasts: +1. **Keep Bonjour disabled unless LAN discovery is needed.** Bonjour auto-starts on macOS hosts and is opt-in elsewhere; direct Gateway URLs, Tailnet, SSH, or wide-area DNS-SD avoid local multicast. + +2. **Minimal mode** (default when Bonjour is enabled, recommended for exposed gateways): omit sensitive fields from mDNS broadcasts: ```json5 { @@ -816,7 +818,7 @@ The Gateway broadcasts its presence via mDNS (`_openclaw-gw._tcp` on port 5353) } ``` -2. **Disable entirely** if you don't need local device discovery: +3. **Disable mDNS mode** if you want to keep the plugin enabled but suppress local device discovery: ```json5 { @@ -826,7 +828,7 @@ The Gateway broadcasts its presence via mDNS (`_openclaw-gw._tcp` on port 5353) } ``` -3. **Full mode** (opt-in): include `cliPath` + `sshPort` in TXT records: +4. **Full mode** (opt-in): include `cliPath` + `sshPort` in TXT records: ```json5 { @@ -836,9 +838,9 @@ The Gateway broadcasts its presence via mDNS (`_openclaw-gw._tcp` on port 5353) } ``` -4. **Environment variable** (alternative): set `OPENCLAW_DISABLE_BONJOUR=1` to disable mDNS without config changes. +5. **Environment variable** (alternative): set `OPENCLAW_DISABLE_BONJOUR=1` to disable mDNS without config changes. -In minimal mode, the Gateway still broadcasts enough for device discovery (`role`, `gatewayPort`, `transport`) but omits `cliPath` and `sshPort`. Apps that need CLI path information can fetch it via the authenticated WebSocket connection instead. +When Bonjour is enabled in minimal mode, the Gateway broadcasts enough for device discovery (`role`, `gatewayPort`, `transport`) but omits `cliPath` and `sshPort`. Apps that need CLI path information can fetch it via the authenticated WebSocket connection instead. ### Lock down the Gateway WebSocket (local auth) diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index bdb88364a0c..8fcb884b716 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -150,6 +150,7 @@ or npm install metadata. Those belong in your plugin code and `package.json`. | `id` | Yes | `string` | Canonical plugin id. This is the id used in `plugins.entries.`. | | `configSchema` | Yes | `object` | Inline JSON Schema for this plugin's config. | | `enabledByDefault` | No | `true` | Marks a bundled plugin as enabled by default. Omit it, or set any non-`true` value, to leave the plugin disabled by default. | +| `enabledByDefaultOnPlatforms` | No | `string[]` | Marks a bundled plugin as enabled by default only on the listed Node.js platforms, for example `["darwin"]`. Explicit config still wins. | | `legacyPluginIds` | No | `string[]` | Legacy ids that normalize to this canonical plugin id. | | `autoEnableWhenConfiguredProviders` | No | `string[]` | Provider ids that should auto-enable this plugin when auth, config, or model refs mention them. | | `kind` | No | `"memory"` \| `"context-engine"` | Declares an exclusive plugin kind used by `plugins.slots.*`. | diff --git a/extensions/bonjour/openclaw.plugin.json b/extensions/bonjour/openclaw.plugin.json index 8ebf9a045ab..5b2d7803f56 100644 --- a/extensions/bonjour/openclaw.plugin.json +++ b/extensions/bonjour/openclaw.plugin.json @@ -3,7 +3,7 @@ "activation": { "onStartup": true }, - "enabledByDefault": true, + "enabledByDefaultOnPlatforms": ["darwin"], "name": "Bonjour Gateway Discovery", "description": "Advertise the local OpenClaw gateway over Bonjour/mDNS.", "configSchema": { diff --git a/src/plugin-sdk/facade-activation-check.runtime.ts b/src/plugin-sdk/facade-activation-check.runtime.ts index 83a59391202..fd8845a9605 100644 --- a/src/plugin-sdk/facade-activation-check.runtime.ts +++ b/src/plugin-sdk/facade-activation-check.runtime.ts @@ -15,6 +15,7 @@ import { normalizePluginsConfig, resolveEffectivePluginActivationState, } from "../plugins/config-state.js"; +import { isPluginEnabledByDefaultForPlatform } from "../plugins/default-enablement.js"; import { loadPluginManifestRegistry, type PluginManifestRecord, @@ -31,7 +32,7 @@ const EMPTY_FACADE_BOUNDARY_CONFIG: OpenClawConfig = {}; export type FacadePluginManifestLike = Pick< PluginManifestRecord, - "id" | "origin" | "enabledByDefault" | "rootDir" | "channels" + "id" | "origin" | "enabledByDefault" | "enabledByDefaultOnPlatforms" | "rootDir" | "channels" >; type FacadeModuleLocation = { @@ -286,7 +287,7 @@ export function evaluateBundledPluginPublicSurfaceAccess(params: { origin: params.manifestRecord.origin, config: params.normalizedPluginsConfig, rootConfig: params.config, - enabledByDefault: params.manifestRecord.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(params.manifestRecord), activationSource: params.activationSource, autoEnabledReason: params.autoEnabledReasons[params.manifestRecord.id]?.[0], }); diff --git a/src/plugins/bundled-manifest-contract-plugins.ts b/src/plugins/bundled-manifest-contract-plugins.ts index 065ee321420..9b46a898085 100644 --- a/src/plugins/bundled-manifest-contract-plugins.ts +++ b/src/plugins/bundled-manifest-contract-plugins.ts @@ -8,6 +8,7 @@ import { normalizePluginsConfig, resolveEffectivePluginActivationState, } from "./config-state.js"; +import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import { loadManifestContractSnapshot } from "./manifest-contract-eligibility.js"; import type { PluginManifestContractListKey, PluginManifestRecord } from "./manifest-registry.js"; @@ -85,7 +86,7 @@ export function resolveEnabledBundledManifestContractPlugins(params: { origin: plugin.origin, config: normalizedPlugins, rootConfig: activation.config, - enabledByDefault: plugin.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin), activationSource, }).enabled; }); diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index a4300756f66..9a419366b61 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -49,7 +49,6 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [ ] as const; const EXPECTED_EMPTY_CONFIG_GATEWAY_STARTUP_PLUGIN_IDS = [ "acpx", - "bonjour", "browser", "device-pair", "file-transfer", @@ -132,6 +131,35 @@ function listRepoBundledPluginManifests() { }); } +function createRepoBundledManifestRegistry(): PluginManifestRegistry { + return { + plugins: listRepoBundledPluginManifests().map(({ manifest, dirName }) => ({ + id: manifest.id, + name: manifest.name, + description: manifest.description, + version: manifest.version, + enabledByDefault: manifest.enabledByDefault === true ? true : undefined, + enabledByDefaultOnPlatforms: manifest.enabledByDefaultOnPlatforms, + kind: manifest.kind, + channels: manifest.channels ?? [], + providers: manifest.providers ?? [], + cliBackends: manifest.cliBackends ?? [], + syntheticAuthRefs: manifest.syntheticAuthRefs ?? [], + nonSecretAuthMarkers: manifest.nonSecretAuthMarkers ?? [], + skills: manifest.skills ?? [], + origin: "bundled", + rootDir: path.join(repoRoot, "extensions", dirName), + source: path.join(repoRoot, "extensions", dirName, "index.ts"), + manifestPath: path.join(repoRoot, "extensions", dirName, "openclaw.plugin.json"), + activation: manifest.activation, + setup: manifest.setup, + hooks: [], + contracts: manifest.contracts, + })), + diagnostics: [], + }; +} + function readPackageManifest(pluginDir: string): PackageManifest | undefined { const packagePath = path.join(pluginDir, "package.json"); return fs.existsSync(packagePath) @@ -186,6 +214,9 @@ function createInstalledPluginRecordForManifest( origin: record.origin, enabled: record.enabledByDefault === true, ...(record.enabledByDefault === true ? { enabledByDefault: true } : {}), + ...(record.enabledByDefaultOnPlatforms?.length + ? { enabledByDefaultOnPlatforms: record.enabledByDefaultOnPlatforms } + : {}), startup: { sidecar: record.activation?.onStartup === true, memory: hasPluginKind(record, "memory"), @@ -432,31 +463,7 @@ describe("bundled plugin metadata", () => { }); it("keeps empty-config Gateway startup narrower than declared startup sidecars", () => { - const manifestRegistry = { - plugins: listRepoBundledPluginManifests().map(({ manifest, dirName }) => ({ - id: manifest.id, - name: manifest.name, - description: manifest.description, - version: manifest.version, - enabledByDefault: manifest.enabledByDefault === true ? true : undefined, - kind: manifest.kind, - channels: manifest.channels ?? [], - providers: manifest.providers ?? [], - cliBackends: manifest.cliBackends ?? [], - syntheticAuthRefs: manifest.syntheticAuthRefs ?? [], - nonSecretAuthMarkers: manifest.nonSecretAuthMarkers ?? [], - skills: manifest.skills ?? [], - origin: "bundled", - rootDir: path.join(repoRoot, "extensions", dirName), - source: path.join(repoRoot, "extensions", dirName, "index.ts"), - manifestPath: path.join(repoRoot, "extensions", dirName, "openclaw.plugin.json"), - activation: manifest.activation, - setup: manifest.setup, - hooks: [], - contracts: manifest.contracts, - })), - diagnostics: [], - } satisfies PluginManifestRegistry; + const manifestRegistry = createRepoBundledManifestRegistry(); const index = createInstalledPluginIndexForManifests(manifestRegistry); expect( @@ -465,10 +472,41 @@ describe("bundled plugin metadata", () => { env: process.env, index, manifestRegistry, + platform: "linux", }), ).toEqual(EXPECTED_EMPTY_CONFIG_GATEWAY_STARTUP_PLUGIN_IDS); }); + it("auto-starts Bonjour for empty-config macOS Gateway startup", () => { + const manifestRegistry = createRepoBundledManifestRegistry(); + const index = createInstalledPluginIndexForManifests(manifestRegistry); + + expect( + resolveGatewayStartupPluginIdsFromRegistry({ + config: {}, + env: process.env, + index, + manifestRegistry, + platform: "darwin", + }), + ).toContain("bonjour"); + }); + + it("starts Bonjour when explicitly enabled", () => { + const manifestRegistry = createRepoBundledManifestRegistry(); + const index = createInstalledPluginIndexForManifests(manifestRegistry); + + expect( + resolveGatewayStartupPluginIdsFromRegistry({ + config: { plugins: { entries: { bonjour: { enabled: true } } } }, + env: process.env, + index, + manifestRegistry, + platform: "linux", + }), + ).toContain("bonjour"); + }); + it("prefers built generated paths when present and falls back to source paths", () => { const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-metadata-"); const pluginRoot = path.join(tempRoot, "extensions", "plugin"); diff --git a/src/plugins/channel-presence-policy.ts b/src/plugins/channel-presence-policy.ts index eaf72152b40..1f24bbd477a 100644 --- a/src/plugins/channel-presence-policy.ts +++ b/src/plugins/channel-presence-policy.ts @@ -14,6 +14,7 @@ import { normalizePluginsConfig, resolveEffectivePluginActivationState, } from "./config-state.js"; +import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import { hasExplicitManifestOwnerTrust, isActivatedManifestOwner, @@ -280,7 +281,7 @@ function evaluateEffectiveChannelPlugin(params: { origin: params.plugin.origin, config: params.normalizedConfig, rootConfig: params.config, - enabledByDefault: params.plugin.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(params.plugin), activationSource: params.activationSource, }); return activationState.enabled diff --git a/src/plugins/default-enablement.ts b/src/plugins/default-enablement.ts new file mode 100644 index 00000000000..2c1f6b90204 --- /dev/null +++ b/src/plugins/default-enablement.ts @@ -0,0 +1,14 @@ +export type PluginDefaultEnablement = { + enabledByDefault?: boolean; + enabledByDefaultOnPlatforms?: readonly string[]; +}; + +export function isPluginEnabledByDefaultForPlatform( + plugin: PluginDefaultEnablement, + platform: NodeJS.Platform = process.platform, +): boolean { + if (plugin.enabledByDefault === true) { + return true; + } + return plugin.enabledByDefaultOnPlatforms?.includes(platform) === true; +} diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index dc1eb18a271..bebe155353d 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -14,6 +14,7 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { hasExplicitChannelConfig } from "./channel-presence-policy.js"; import { collectPluginConfigContractMatches } from "./config-contracts.js"; import { resolveEffectivePluginActivationState } from "./config-state.js"; +import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import { collectConfiguredSpeechProviderIds, normalizeConfiguredSpeechProviderIdForStartup, @@ -218,6 +219,7 @@ function canStartConfiguredSpeechProviderPlugin(params: { rootConfig?: OpenClawConfig; }; configuredSpeechProviderIds: ReadonlySet; + platform?: NodeJS.Platform; }): boolean { if ( !manifestOwnsConfiguredSpeechProvider({ @@ -247,7 +249,7 @@ function canStartConfiguredSpeechProviderPlugin(params: { origin: params.plugin.origin, config: params.pluginsConfig, rootConfig: params.config, - enabledByDefault: params.plugin.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(params.plugin, params.platform), activationSource: params.activationSource, }); return activationState.enabled && activationState.explicitlyEnabled; @@ -315,6 +317,7 @@ function canStartExplicitHookPlugin(params: { rootConfig?: OpenClawConfig; }; activationSourcePlugins: NormalizedPluginsConfig; + platform?: NodeJS.Platform; }): boolean { const hasHookPolicyIntent = hasExplicitHookPolicyConfig( params.activationSourcePlugins.entries[params.plugin.pluginId], @@ -348,7 +351,7 @@ function canStartExplicitHookPlugin(params: { origin: params.plugin.origin, config: params.pluginsConfig, rootConfig: params.config, - enabledByDefault: params.plugin.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(params.plugin, params.platform), activationSource: params.activationSource, }); return activationState.enabled && (activationState.explicitlyEnabled || hasHookPolicyIntent); @@ -363,6 +366,7 @@ function canStartConfiguredChannelPlugin(params: { rootConfig?: OpenClawConfig; }; manifestLookup: ManifestRegistryLookup; + platform?: NodeJS.Platform; }): boolean { if (!params.pluginsConfig.enabled) { return false; @@ -396,7 +400,7 @@ function canStartConfiguredChannelPlugin(params: { origin: params.plugin.origin, config: params.pluginsConfig, rootConfig: params.config, - enabledByDefault: params.plugin.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(params.plugin, params.platform), activationSource: params.activationSource, }); return activationState.enabled && activationState.explicitlyEnabled; @@ -471,6 +475,7 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { env: NodeJS.ProcessEnv; index: PluginRegistrySnapshot; manifestRegistry: PluginManifestRegistry; + platform?: NodeJS.Platform; }): GatewayStartupPluginPlan { const channelPluginIds = resolveChannelPluginIdsFromRegistry({ manifestRegistry: params.manifestRegistry, @@ -533,6 +538,7 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { pluginsConfig, activationSource, manifestLookup, + platform: params.platform, }); } if ( @@ -543,7 +549,7 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { origin: plugin.origin, config: pluginsConfig, rootConfig: params.config, - enabledByDefault: plugin.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin, params.platform), activationSource, }); return activationState.enabled; @@ -567,6 +573,7 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { pluginsConfig, activationSource, configuredSpeechProviderIds, + platform: params.platform, }) ) { return true; @@ -579,6 +586,7 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { pluginsConfig, activationSource, activationSourcePlugins, + platform: params.platform, }) ) { return true; @@ -599,7 +607,7 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { origin: plugin.origin, config: pluginsConfig, rootConfig: params.config, - enabledByDefault: plugin.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin, params.platform), activationSource, }); if (!activationState.enabled) { @@ -624,6 +632,7 @@ export function resolveGatewayStartupPluginIdsFromRegistry(params: { env: NodeJS.ProcessEnv; index: PluginRegistrySnapshot; manifestRegistry: PluginManifestRegistry; + platform?: NodeJS.Platform; }): string[] { return [...resolveGatewayStartupPluginPlanFromRegistry(params).pluginIds]; } @@ -635,6 +644,7 @@ export function loadGatewayStartupPluginPlan(params: { env: NodeJS.ProcessEnv; index?: PluginRegistrySnapshot; metadataSnapshot?: PluginMetadataSnapshot; + platform?: NodeJS.Platform; }): GatewayStartupPluginPlan { const snapshotConfig = params.activationSourceConfig ?? params.config; const metadataSnapshot = @@ -661,6 +671,7 @@ export function loadGatewayStartupPluginPlan(params: { env: params.env, index: metadataSnapshot.index, manifestRegistry: metadataSnapshot.manifestRegistry, + platform: params.platform, }); } @@ -669,6 +680,7 @@ export function resolveGatewayStartupPluginIds(params: { activationSourceConfig?: OpenClawConfig; workspaceDir?: string; env: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; }): string[] { return [...loadGatewayStartupPluginPlan(params).pluginIds]; } diff --git a/src/plugins/installed-plugin-index-record-builder.ts b/src/plugins/installed-plugin-index-record-builder.ts index 2504c6f2698..0b100dc216f 100644 --- a/src/plugins/installed-plugin-index-record-builder.ts +++ b/src/plugins/installed-plugin-index-record-builder.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/types.js"; import type { PluginCompatCode } from "./compat/registry.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; +import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import type { PluginCandidate } from "./discovery.js"; import type { PluginInstallSourceInfo } from "./install-source-info.js"; import { describePluginInstallSource } from "./install-source-info.js"; @@ -226,7 +227,7 @@ export function buildInstalledPluginIndexRecords(params: { origin: record.origin, config: normalizedConfig, rootConfig: params.config, - enabledByDefault: record.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(record), }).enabled; const indexRecord: InstalledPluginIndexRecord = { pluginId: record.id, @@ -249,6 +250,9 @@ export function buildInstalledPluginIndexRecords(params: { if (record.enabledByDefault === true) { indexRecord.enabledByDefault = true; } + if (record.enabledByDefaultOnPlatforms?.length) { + indexRecord.enabledByDefaultOnPlatforms = [...record.enabledByDefaultOnPlatforms]; + } if (record.syntheticAuthRefs?.length) { indexRecord.syntheticAuthRefs = [...record.syntheticAuthRefs]; } diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index a0c3d31619f..1044be8fc2e 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -6,6 +6,7 @@ import { safeParseWithSchema } from "../utils/zod-parse.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { clearCurrentPluginMetadataSnapshotState } from "./current-plugin-metadata-state.js"; +import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import { hashJson } from "./installed-plugin-index-hash.js"; import { resolveCompatRegistryVersion } from "./installed-plugin-index-policy.js"; import { @@ -82,6 +83,7 @@ const InstalledPluginIndexRecordSchema = z.object({ origin: z.string(), enabled: z.boolean(), enabledByDefault: z.boolean().optional(), + enabledByDefaultOnPlatforms: StringArraySchema.optional(), syntheticAuthRefs: StringArraySchema.optional(), startup: InstalledPluginIndexStartupSchema, compat: z.array(z.string()), @@ -251,7 +253,7 @@ function refreshPersistedPolicyState( origin: plugin.origin, config: normalizedConfig, rootConfig: params.config, - enabledByDefault: plugin.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin), }).enabled, })), }; diff --git a/src/plugins/installed-plugin-index-types.ts b/src/plugins/installed-plugin-index-types.ts index 7fc86f2586c..6b308ac9534 100644 --- a/src/plugins/installed-plugin-index-types.ts +++ b/src/plugins/installed-plugin-index-types.ts @@ -101,6 +101,7 @@ export type InstalledPluginIndexRecord = { origin: PluginManifestRecord["origin"]; enabled: boolean; enabledByDefault?: boolean; + enabledByDefaultOnPlatforms?: readonly string[]; syntheticAuthRefs?: readonly string[]; startup: InstalledPluginStartupInfo; compat: readonly PluginCompatCode[]; diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index 5a9b9724bce..0ed43460bea 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/types.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { normalizePluginsConfig, resolveEffectivePluginActivationState } from "./config-state.js"; +import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import { normalizeInstallRecordMap } from "./installed-plugin-index-install-records.js"; import { resolveCompatRegistryVersion, @@ -131,7 +132,7 @@ export function isInstalledPluginEnabled( origin: record.origin, config: normalizedConfig, rootConfig: config, - enabledByDefault: record.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(record), }); return state.enabled && (record.enabled || state.explicitlyEnabled); } diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index d8e6393f2b6..cc9bd8e2a13 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -46,6 +46,7 @@ import { type PluginActivationConfigSource, type NormalizedPluginsConfig, } from "./config-state.js"; +import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; import { getGlobalHookRunner, initializeGlobalHookRunner } from "./hook-runner-global.js"; import { toSafeImportPath } from "./import-specifier.js"; @@ -1690,7 +1691,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi origin: candidate.origin, config: normalized, rootConfig: cfg, - enabledByDefault: manifestRecord.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(manifestRecord), activationSource, autoEnabledReason: formatAutoEnabledActivationReason(autoEnabledReasons[pluginId]), }); @@ -1729,7 +1730,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi origin: candidate.origin, config: normalized, rootConfig: cfg, - enabledByDefault: manifestRecord.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(manifestRecord), activationSource, }); const entry = normalized.entries[pluginId]; @@ -2512,7 +2513,7 @@ export async function loadOpenClawPluginCliRegistry( origin: candidate.origin, config: normalized, rootConfig: cfg, - enabledByDefault: manifestRecord.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(manifestRecord), activationSource, autoEnabledReason: formatAutoEnabledActivationReason(autoEnabledReasons[pluginId]), }); @@ -2551,7 +2552,7 @@ export async function loadOpenClawPluginCliRegistry( origin: candidate.origin, config: normalized, rootConfig: cfg, - enabledByDefault: manifestRecord.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(manifestRecord), activationSource, }); const entry = normalized.entries[pluginId]; diff --git a/src/plugins/manifest-contract-eligibility.ts b/src/plugins/manifest-contract-eligibility.ts index 7813324c96d..9579474e3fc 100644 --- a/src/plugins/manifest-contract-eligibility.ts +++ b/src/plugins/manifest-contract-eligibility.ts @@ -11,7 +11,10 @@ import type { export function isManifestPluginAvailableForControlPlane(params: { snapshot: Pick; - plugin: Pick; + plugin: Pick< + PluginManifestRecord, + "id" | "origin" | "enabledByDefault" | "enabledByDefaultOnPlatforms" + >; config?: OpenClawConfig; }): boolean { if (params.plugin.origin === "bundled") { diff --git a/src/plugins/manifest-owner-policy.ts b/src/plugins/manifest-owner-policy.ts index 15ca5d772b8..8d2f91c3a18 100644 --- a/src/plugins/manifest-owner-policy.ts +++ b/src/plugins/manifest-owner-policy.ts @@ -1,8 +1,12 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginsConfig, resolveEffectivePluginActivationState } from "./config-state.js"; +import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; -type OwnerPlugin = Pick; +type OwnerPlugin = Pick< + PluginManifestRecord, + "id" | "origin" | "enabledByDefault" | "enabledByDefaultOnPlatforms" +>; type NormalizedPluginsConfig = ReturnType; @@ -73,6 +77,6 @@ export function isActivatedManifestOwner(params: { origin: params.plugin.origin, config: params.normalizedConfig, rootConfig: params.rootConfig, - enabledByDefault: params.plugin.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(params.plugin), }).activated; } diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index 1aec8245b56..76ebc6b199a 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -76,6 +76,9 @@ function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) { origin: record.origin, enabled: record.enabled, enabledByDefault: record.enabledByDefault, + enabledByDefaultOnPlatforms: record.enabledByDefaultOnPlatforms + ? [...record.enabledByDefaultOnPlatforms] + : undefined, syntheticAuthRefs: record.syntheticAuthRefs, startup: record.startup, compat: record.compat, diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index f14cf15f706..6053fca63fc 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -471,6 +471,7 @@ describe("loadPluginManifestRegistry", () => { writeManifest(dir, { id: "openai", enabledByDefault: true, + enabledByDefaultOnPlatforms: ["darwin", "not-a-platform"], providers: ["openai", "openai-codex"], providerAuthEnvVars: { openai: ["OPENAI_API_KEY"], @@ -594,6 +595,7 @@ describe("loadPluginManifestRegistry", () => { "openai-codex": "openai", }); expect(registry.plugins[0]?.enabledByDefault).toBe(true); + expect(registry.plugins[0]?.enabledByDefaultOnPlatforms).toEqual(["darwin"]); expect(registry.plugins[0]?.providerAuthChoices).toEqual([ { provider: "openai", diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 98431c5d66b..b0f05bef7c6 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -105,6 +105,7 @@ export type PluginManifestRecord = { packageVersion?: string; packageDescription?: string; enabledByDefault?: boolean; + enabledByDefaultOnPlatforms?: string[]; autoEnableWhenConfiguredProviders?: string[]; legacyPluginIds?: string[]; format?: PluginFormat; @@ -290,6 +291,7 @@ function buildRecord(params: { packageVersion: params.candidate.packageVersion, packageDescription: params.candidate.packageDescription, enabledByDefault: params.manifest.enabledByDefault === true ? true : undefined, + enabledByDefaultOnPlatforms: params.manifest.enabledByDefaultOnPlatforms, autoEnableWhenConfiguredProviders: params.manifest.autoEnableWhenConfiguredProviders, legacyPluginIds: params.manifest.legacyPluginIds, format: params.candidate.format ?? "openclaw", diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index cc7c05e1e8e..6992b6863f2 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -182,6 +182,8 @@ export type PluginManifestActivation = { onCapabilities?: PluginManifestActivationCapability[]; }; +export type PluginManifestDefaultPlatform = NodeJS.Platform; + export type PluginManifestSetupProvider = { /** Provider id surfaced during setup/onboarding. */ id: string; @@ -290,6 +292,7 @@ export type PluginManifest = { id: string; configSchema: JsonSchemaObject; enabledByDefault?: boolean; + enabledByDefaultOnPlatforms?: PluginManifestDefaultPlatform[]; /** Legacy plugin ids that should normalize to this plugin id. */ legacyPluginIds?: string[]; /** Provider ids that should auto-enable this plugin when referenced in auth/config/models. */ @@ -1156,6 +1159,27 @@ function normalizeManifestActivation(value: unknown): PluginManifestActivation | return Object.keys(activation).length > 0 ? activation : undefined; } +const MANIFEST_DEFAULT_ENABLEMENT_PLATFORMS = new Set([ + "aix", + "android", + "darwin", + "freebsd", + "haiku", + "linux", + "openbsd", + "sunos", + "win32", + "cygwin", + "netbsd", +]); + +function normalizeManifestDefaultPlatforms(value: unknown): PluginManifestDefaultPlatform[] { + return normalizeTrimmedStringList(value).filter( + (platform): platform is PluginManifestDefaultPlatform => + MANIFEST_DEFAULT_ENABLEMENT_PLATFORMS.has(platform as PluginManifestDefaultPlatform), + ); +} + function normalizeManifestSetupProviders( value: unknown, ): PluginManifestSetupProvider[] | undefined { @@ -1520,6 +1544,9 @@ export function loadPluginManifest( const kind = parsePluginKind(raw.kind); const enabledByDefault = raw.enabledByDefault === true; + const enabledByDefaultOnPlatforms = normalizeManifestDefaultPlatforms( + raw.enabledByDefaultOnPlatforms, + ); const legacyPluginIds = normalizeTrimmedStringList(raw.legacyPluginIds); const autoEnableWhenConfiguredProviders = normalizeTrimmedStringList( raw.autoEnableWhenConfiguredProviders, @@ -1584,6 +1611,7 @@ export function loadPluginManifest( id, configSchema, ...(enabledByDefault ? { enabledByDefault } : {}), + ...(enabledByDefaultOnPlatforms.length > 0 ? { enabledByDefaultOnPlatforms } : {}), ...(legacyPluginIds.length > 0 ? { legacyPluginIds } : {}), ...(autoEnableWhenConfiguredProviders.length > 0 ? { autoEnableWhenConfiguredProviders } diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index e992f1a3642..b9903b5109b 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -2,6 +2,7 @@ import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { normalizeProviderId } from "../agents/provider-id.js"; import { withBundledPluginVitestCompat } from "./bundled-compat.js"; import { resolveEffectivePluginActivationState } from "./config-state.js"; +import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import type { PluginLoadOptions } from "./loader.js"; import { isActivatedManifestOwner, @@ -106,7 +107,7 @@ function resolveEffectiveRegistryPluginActivation(params: { origin: params.plugin.origin, config: params.normalizedConfig, rootConfig: params.rootConfig, - enabledByDefault: params.plugin.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(params.plugin), }); } @@ -114,7 +115,7 @@ function toManifestOwnerRecord(plugin: PluginRegistryRecord) { return { id: plugin.pluginId, origin: plugin.origin, - enabledByDefault: plugin.enabledByDefault, + enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin), }; }