mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix: keep source plugins from install version gating
This commit is contained in:
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- TTS/Telegram: keep trusted local audio generated by the TTS tool queued for voice-note delivery even when the run-level built-in tool list omits the raw `tts` name. Fixes #74752. Thanks @Loveworld3033 and @andyliu.
|
||||
- TTS: require explicit user or config audio intent for the agent speech tool so dashboard chats stay text unless audio is requested. Fixes #69777. Thanks @alexandre-leng.
|
||||
- Plugins/config: keep bundled source-checkout plugins from being runtime-gated by install-only `minHostVersion` metadata, accept prerelease host floors, trim plugin-service startup failures to one log line, and avoid broad channel-runtime loading during base config parsing. Thanks @vincentkoc.
|
||||
- Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570.
|
||||
- macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc.
|
||||
- Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129.
|
||||
|
||||
@@ -1022,7 +1022,7 @@ Important examples:
|
||||
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
|
||||
| `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. |
|
||||
| `openclaw.install.defaultChoice` | Preferred install path when multiple install sources are available. |
|
||||
| `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22`. |
|
||||
| `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22` or `>=2026.5.1-beta.1`. |
|
||||
| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. |
|
||||
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
|
||||
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-only channel surfaces load before the full channel plugin during startup. |
|
||||
@@ -1033,8 +1033,9 @@ onboarding how to fetch or enable that plugin when the user picks one of those
|
||||
choices. Do not move install hints into `openclaw.plugin.json`.
|
||||
|
||||
`openclaw.install.minHostVersion` is enforced during install and manifest
|
||||
registry loading. Invalid values are rejected; newer-but-valid values skip the
|
||||
plugin on older hosts.
|
||||
registry loading for non-bundled plugin sources. Invalid values are rejected;
|
||||
newer-but-valid values skip external plugins on older hosts. Bundled source
|
||||
plugins are assumed to be co-versioned with the host checkout.
|
||||
|
||||
Exact npm version pinning already lives in `npmSpec`, for example
|
||||
`"npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3"`. Official external catalog
|
||||
|
||||
@@ -154,21 +154,21 @@ Example:
|
||||
|
||||
`openclaw.install` is package metadata, not manifest metadata.
|
||||
|
||||
| Field | Type | What it means |
|
||||
| ---------------------------- | -------------------- | -------------------------------------------------------------------------------- |
|
||||
| `npmSpec` | `string` | Canonical npm spec for install/update flows. |
|
||||
| `localPath` | `string` | Local development or bundled install path. |
|
||||
| `defaultChoice` | `"npm"` \| `"local"` | Preferred install source when both are available. |
|
||||
| `minHostVersion` | `string` | Minimum supported OpenClaw version in the form `>=x.y.z`. |
|
||||
| `expectedIntegrity` | `string` | Expected npm dist integrity string, usually `sha512-...`, for pinned installs. |
|
||||
| `allowInvalidConfigRecovery` | `boolean` | Lets bundled-plugin reinstall flows recover from specific stale-config failures. |
|
||||
| Field | Type | What it means |
|
||||
| ---------------------------- | -------------------- | --------------------------------------------------------------------------------- |
|
||||
| `npmSpec` | `string` | Canonical npm spec for install/update flows. |
|
||||
| `localPath` | `string` | Local development or bundled install path. |
|
||||
| `defaultChoice` | `"npm"` \| `"local"` | Preferred install source when both are available. |
|
||||
| `minHostVersion` | `string` | Minimum supported OpenClaw version in the form `>=x.y.z` or `>=x.y.z-prerelease`. |
|
||||
| `expectedIntegrity` | `string` | Expected npm dist integrity string, usually `sha512-...`, for pinned installs. |
|
||||
| `allowInvalidConfigRecovery` | `boolean` | Lets bundled-plugin reinstall flows recover from specific stale-config failures. |
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Onboarding behavior">
|
||||
Interactive onboarding also uses `openclaw.install` for install-on-demand surfaces. If your plugin exposes provider auth choices or channel setup/catalog metadata before runtime loads, onboarding can show that choice, prompt for npm vs local install, install or enable the plugin, then continue the selected flow. Npm onboarding choices require trusted catalog metadata with a registry `npmSpec`; exact versions and `expectedIntegrity` are optional pins. If `expectedIntegrity` is present, install/update flows enforce it. Keep the "what to show" metadata in `openclaw.plugin.json` and the "how to install it" metadata in `package.json`.
|
||||
</Accordion>
|
||||
<Accordion title="minHostVersion enforcement">
|
||||
If `minHostVersion` is set, install and manifest-registry loading both enforce it. Older hosts skip the plugin; invalid version strings are rejected.
|
||||
If `minHostVersion` is set, install and non-bundled manifest-registry loading both enforce it. Older hosts skip external plugins; invalid version strings are rejected. Bundled source plugins are assumed to be co-versioned with the host checkout.
|
||||
</Accordion>
|
||||
<Accordion title="Pinned npm installs">
|
||||
For pinned npm installs, keep the exact version in `npmSpec` and add the expected artifact integrity:
|
||||
|
||||
@@ -88,7 +88,6 @@ describe("ChannelsSchema bundled runtime loading", () => {
|
||||
expect(loadPluginManifestRegistryMock.mock.calls).toContainEqual([
|
||||
expect.objectContaining({
|
||||
includeDisabled: true,
|
||||
bundledChannelConfigCollector: expect.any(Function),
|
||||
}),
|
||||
]);
|
||||
expect(collectBundledChannelConfigsMock).not.toHaveBeenCalled();
|
||||
@@ -103,21 +102,15 @@ describe("ChannelsSchema bundled runtime loading", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
loadPluginManifestRegistryMock.mockImplementationOnce((options) => ({
|
||||
loadPluginManifestRegistryMock.mockImplementationOnce(() => ({
|
||||
diagnostics: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "discord",
|
||||
origin: "bundled",
|
||||
rootDir: "/repo/extensions/discord",
|
||||
channels: ["discord"],
|
||||
channelConfigs: (
|
||||
options?.bundledChannelConfigCollector as
|
||||
| ((params: unknown) => Record<string, PluginManifestChannelConfig> | undefined)
|
||||
| undefined
|
||||
)?.({
|
||||
pluginDir: "/repo/extensions/discord",
|
||||
manifest: { id: "discord", channels: ["discord"] },
|
||||
}),
|
||||
channelConfigs: {},
|
||||
} as unknown as PluginManifestRegistry["plugins"][number],
|
||||
],
|
||||
}));
|
||||
@@ -134,9 +127,17 @@ describe("ChannelsSchema bundled runtime loading", () => {
|
||||
expect(loadPluginManifestRegistryMock.mock.calls).toContainEqual([
|
||||
expect.objectContaining({
|
||||
includeDisabled: true,
|
||||
bundledChannelConfigCollector: expect.any(Function),
|
||||
}),
|
||||
]);
|
||||
expect(collectBundledChannelConfigsMock).toHaveBeenCalledTimes(1);
|
||||
expect(collectBundledChannelConfigsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginDir: "/repo/extensions/discord",
|
||||
manifest: expect.objectContaining({
|
||||
id: "discord",
|
||||
channels: ["discord"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { collectBundledChannelConfigs } from "../plugins/bundled-channel-config-metadata.js";
|
||||
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import type { PluginManifest } from "../plugins/manifest.js";
|
||||
import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js";
|
||||
import type { ChannelsConfig } from "./types.channels.js";
|
||||
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
||||
@@ -13,12 +15,27 @@ const ChannelModelByChannelSchema = z
|
||||
.record(z.string(), z.record(z.string(), z.string()))
|
||||
.optional();
|
||||
|
||||
function getDirectChannelRuntimeSchema(channelId: string) {
|
||||
return loadPluginManifestRegistryForPluginRegistry({
|
||||
includeDisabled: true,
|
||||
bundledChannelConfigCollector: collectBundledChannelConfigs,
|
||||
}).plugins.find((plugin) => plugin.origin === "bundled" && plugin.channelConfigs?.[channelId])
|
||||
?.channelConfigs?.[channelId]?.runtime;
|
||||
function getDirectChannelRuntimeSchema(channelId: string, registry: PluginManifestRegistry) {
|
||||
const record = registry.plugins.find(
|
||||
(plugin) => plugin.origin === "bundled" && plugin.channels.includes(channelId),
|
||||
);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const manifestRuntime = record.channelConfigs?.[channelId]?.runtime;
|
||||
if (manifestRuntime) {
|
||||
return manifestRuntime;
|
||||
}
|
||||
return collectBundledChannelConfigs({
|
||||
pluginDir: record.rootDir,
|
||||
manifest: {
|
||||
id: record.id,
|
||||
configSchema: record.configSchema ?? {},
|
||||
channels: record.channels,
|
||||
channelConfigs: record.channelConfigs,
|
||||
} as PluginManifest,
|
||||
packageManifest: record.packageManifest,
|
||||
})?.[channelId]?.runtime;
|
||||
}
|
||||
|
||||
function hasPluginOwnedChannelConfig(
|
||||
@@ -68,8 +85,12 @@ function normalizeBundledChannelConfigs(
|
||||
}
|
||||
|
||||
let next: ChannelsConfig | undefined;
|
||||
let registry: PluginManifestRegistry | undefined;
|
||||
for (const channelId of Object.keys(value)) {
|
||||
const runtimeSchema = getDirectChannelRuntimeSchema(channelId);
|
||||
registry ??= loadPluginManifestRegistryForPluginRegistry({
|
||||
includeDisabled: true,
|
||||
});
|
||||
const runtimeSchema = getDirectChannelRuntimeSchema(channelId, registry);
|
||||
if (!runtimeSchema) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1496,7 +1496,14 @@ describe("loadPluginManifestRegistry", () => {
|
||||
minHostVersion: ">=2026.3.22",
|
||||
env: { OPENCLAW_VERSION: "2026.3.21" } as NodeJS.ProcessEnv,
|
||||
expectedMessage: "plugin requires OpenClaw >=2026.3.22, but this host is 2026.3.21",
|
||||
expectWarn: false,
|
||||
expectWarn: true,
|
||||
},
|
||||
{
|
||||
name: "skips plugins whose beta minHostVersion is newer than the current host",
|
||||
minHostVersion: ">=2026.5.1-beta.1",
|
||||
env: { OPENCLAW_VERSION: "2026.4.30" } as NodeJS.ProcessEnv,
|
||||
expectedMessage: "plugin requires OpenClaw >=2026.5.1-beta.1, but this host is 2026.4.30",
|
||||
expectWarn: true,
|
||||
},
|
||||
{
|
||||
name: "rejects invalid minHostVersion metadata",
|
||||
@@ -1563,6 +1570,34 @@ describe("loadPluginManifestRegistry", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not runtime-gate bundled source plugins by install minHostVersion", () => {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, { id: "codex", configSchema: { type: "object" } });
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [
|
||||
createPluginCandidate({
|
||||
idHint: "codex",
|
||||
rootDir: dir,
|
||||
packageDir: dir,
|
||||
origin: "bundled",
|
||||
packageManifest: {
|
||||
install: {
|
||||
npmSpec: "@openclaw/codex",
|
||||
minHostVersion: ">=2026.5.1-beta.1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
env: { OPENCLAW_VERSION: "2026.4.30" } as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(registry.plugins.some((plugin) => plugin.id === "codex")).toBe(true);
|
||||
expect(registry.diagnostics.some((diag) => diag.message.includes("requires OpenClaw"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "reports bundled plugins as the duplicate winner for auto-discovered globals",
|
||||
|
||||
@@ -617,37 +617,39 @@ export function loadPluginManifestRegistry(
|
||||
continue;
|
||||
}
|
||||
const manifest = manifestRes.manifest;
|
||||
const allowLegacyBareMinHostVersion =
|
||||
candidate.origin === "global" &&
|
||||
matchesInstalledPluginRecord({
|
||||
pluginId: manifest.id,
|
||||
candidate,
|
||||
config,
|
||||
env,
|
||||
installRecords: getInstallRecords(),
|
||||
if (candidate.origin !== "bundled") {
|
||||
const allowLegacyBareMinHostVersion =
|
||||
candidate.origin === "global" &&
|
||||
matchesInstalledPluginRecord({
|
||||
pluginId: manifest.id,
|
||||
candidate,
|
||||
config,
|
||||
env,
|
||||
installRecords: getInstallRecords(),
|
||||
});
|
||||
const minHostVersionCheck = checkMinHostVersion({
|
||||
currentVersion: currentHostVersion,
|
||||
minHostVersion: candidate.packageManifest?.install?.minHostVersion,
|
||||
allowLegacyBareSemver: allowLegacyBareMinHostVersion,
|
||||
});
|
||||
const minHostVersionCheck = checkMinHostVersion({
|
||||
currentVersion: currentHostVersion,
|
||||
minHostVersion: candidate.packageManifest?.install?.minHostVersion,
|
||||
allowLegacyBareSemver: allowLegacyBareMinHostVersion,
|
||||
});
|
||||
if (!minHostVersionCheck.ok) {
|
||||
const packageManifestSource = path.join(
|
||||
candidate.packageDir ?? candidate.rootDir,
|
||||
"package.json",
|
||||
);
|
||||
diagnostics.push({
|
||||
level: minHostVersionCheck.kind === "unknown_host_version" ? "warn" : "error",
|
||||
pluginId: manifest.id,
|
||||
source: packageManifestSource,
|
||||
message:
|
||||
minHostVersionCheck.kind === "invalid"
|
||||
? `plugin manifest invalid | ${minHostVersionCheck.error}`
|
||||
: minHostVersionCheck.kind === "unknown_host_version"
|
||||
? `plugin requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host version could not be determined; skipping load`
|
||||
: `plugin requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host is ${minHostVersionCheck.currentVersion}; skipping load`,
|
||||
});
|
||||
continue;
|
||||
if (!minHostVersionCheck.ok) {
|
||||
const packageManifestSource = path.join(
|
||||
candidate.packageDir ?? candidate.rootDir,
|
||||
"package.json",
|
||||
);
|
||||
diagnostics.push({
|
||||
level: minHostVersionCheck.kind === "invalid" ? "error" : "warn",
|
||||
pluginId: manifest.id,
|
||||
source: packageManifestSource,
|
||||
message:
|
||||
minHostVersionCheck.kind === "invalid"
|
||||
? `plugin manifest invalid | ${minHostVersionCheck.error}`
|
||||
: minHostVersionCheck.kind === "unknown_host_version"
|
||||
? `plugin requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host version could not be determined; skipping load`
|
||||
: `plugin requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host is ${minHostVersionCheck.currentVersion}; skipping load`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const configSchema = "configSchema" in manifest ? manifest.configSchema : undefined;
|
||||
|
||||
@@ -10,6 +10,10 @@ const MIN_HOST_REQUIREMENT = {
|
||||
raw: ">=2026.3.22",
|
||||
minimumLabel: "2026.3.22",
|
||||
};
|
||||
const BETA_MIN_HOST_REQUIREMENT = {
|
||||
raw: ">=2026.5.1-beta.1",
|
||||
minimumLabel: "2026.5.1-beta.1",
|
||||
};
|
||||
|
||||
function expectValidHostCheck(currentVersion: string, minHostVersion?: string) {
|
||||
expectHostCheckResult({
|
||||
@@ -57,6 +61,7 @@ describe("min-host-version", () => {
|
||||
|
||||
it("parses semver floors", () => {
|
||||
expect(parseMinHostVersionRequirement(">=2026.3.22")).toEqual(MIN_HOST_REQUIREMENT);
|
||||
expect(parseMinHostVersionRequirement(">=2026.5.1-beta.1")).toEqual(BETA_MIN_HOST_REQUIREMENT);
|
||||
});
|
||||
|
||||
it("can parse legacy bare semver floors for runtime upgrade compatibility", () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isAtLeast, parseSemver } from "../infra/runtime-guard.js";
|
||||
|
||||
export const MIN_HOST_VERSION_FORMAT =
|
||||
'openclaw.install.minHostVersion must use a semver floor in the form ">=x.y.z"';
|
||||
const MIN_HOST_VERSION_RE = /^>=(\d+)\.(\d+)\.(\d+)$/;
|
||||
'openclaw.install.minHostVersion must use a semver floor in the form ">=x.y.z" or ">=x.y.z-prerelease"';
|
||||
const MIN_HOST_VERSION_RE = /^>=(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/;
|
||||
const LEGACY_MIN_HOST_VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
|
||||
export type MinHostVersionRequirement = {
|
||||
@@ -40,7 +40,7 @@ export function parseMinHostVersionRequirement(
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const minimumLabel = `${match[1]}.${match[2]}.${match[3]}`;
|
||||
const minimumLabel = `${match[1]}.${match[2]}.${match[3]}${match[4] ?? ""}`;
|
||||
if (!parseSemver(minimumLabel)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ describe("startPluginServices", () => {
|
||||
"plugin service failed (service-start-fail, plugin=plugin:test, root=/plugins/test-plugin):",
|
||||
),
|
||||
);
|
||||
expect(mockedLogger.error.mock.calls[0]?.[0]).not.toContain("\n");
|
||||
expect(mockedLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("plugin service stop failed (service-stop-fail):"),
|
||||
);
|
||||
|
||||
@@ -74,9 +74,8 @@ export async function startPluginServices(params: {
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
const stack = error?.stack?.trim();
|
||||
log.error(
|
||||
`plugin service failed (${service.id}, plugin=${entry.pluginId}, root=${entry.rootDir ?? "unknown"}): ${error?.message ?? String(err)}${stack ? `\n${stack}` : ""}`,
|
||||
`plugin service failed (${service.id}, plugin=${entry.pluginId}, root=${entry.rootDir ?? "unknown"}): ${error?.message ?? String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user