fix(bonjour): default LAN discovery on macOS only

Summary:
- add manifest-backed platform-specific default enablement for bundled plugins
- auto-start Bonjour LAN discovery on macOS hosts only
- keep Linux, Windows, and containerized Gateway deployments opt-in while preserving explicit enablement

Verification:
- pnpm test extensions/bonjour/src/advertiser.test.ts src/plugins/bundled-plugin-metadata.test.ts src/plugins/manifest-registry.test.ts src/plugins/channel-plugin-ids.test.ts
- pnpm exec oxfmt --check --threads=1 CHANGELOG.md docs/gateway/bonjour.md docs/gateway/configuration-reference.md docs/gateway/discovery.md docs/gateway/security/index.md docs/plugins/manifest.md extensions/bonjour/openclaw.plugin.json src/plugin-sdk/facade-activation-check.runtime.ts src/plugins/bundled-manifest-contract-plugins.ts src/plugins/bundled-plugin-metadata.test.ts src/plugins/channel-presence-policy.ts src/plugins/default-enablement.ts src/plugins/gateway-startup-plugin-ids.ts src/plugins/installed-plugin-index-record-builder.ts src/plugins/installed-plugin-index-store.ts src/plugins/installed-plugin-index-types.ts src/plugins/installed-plugin-index.ts src/plugins/loader.ts src/plugins/manifest-contract-eligibility.ts src/plugins/manifest-owner-policy.ts src/plugins/manifest-registry-installed.ts src/plugins/manifest-registry.test.ts src/plugins/manifest-registry.ts src/plugins/manifest.ts src/plugins/providers.ts
- git diff --check
- Testbox: pnpm check:changed via Blacksmith Testbox tbx_01kqqf3f8rbrt8afjtcg0ck7qs

Refs #74209
This commit is contained in:
Peter Steinberger
2026-05-03 19:07:27 +01:00
committed by GitHub
parent fa98d01aa1
commit ee6052a169
25 changed files with 231 additions and 81 deletions

View File

@@ -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 <plugin>` 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.

View File

@@ -8,11 +8,12 @@ title: "Bonjour discovery"
# Bonjour / mDNS discovery
OpenClaw uses Bonjour (mDNS / DNSSD) 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 tailnetonly 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 resultset 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`).

View File

@@ -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)

View File

@@ -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).

View File

@@ -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)

View File

@@ -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.<id>`. |
| `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.*`. |

View File

@@ -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": {

View File

@@ -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],
});

View File

@@ -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;
});

View File

@@ -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");

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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<string>;
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];
}

View File

@@ -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];
}

View File

@@ -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,
})),
};

View File

@@ -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[];

View File

@@ -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);
}

View File

@@ -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];

View File

@@ -11,7 +11,10 @@ import type {
export function isManifestPluginAvailableForControlPlane(params: {
snapshot: Pick<PluginMetadataSnapshot, "index">;
plugin: Pick<PluginManifestRecord, "id" | "origin" | "enabledByDefault">;
plugin: Pick<
PluginManifestRecord,
"id" | "origin" | "enabledByDefault" | "enabledByDefaultOnPlatforms"
>;
config?: OpenClawConfig;
}): boolean {
if (params.plugin.origin === "bundled") {

View File

@@ -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<PluginManifestRecord, "id" | "origin" | "enabledByDefault">;
type OwnerPlugin = Pick<
PluginManifestRecord,
"id" | "origin" | "enabledByDefault" | "enabledByDefaultOnPlatforms"
>;
type NormalizedPluginsConfig = ReturnType<typeof normalizePluginsConfig>;
@@ -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;
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<PluginManifestDefaultPlatform>([
"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 }

View File

@@ -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),
};
}