diff --git a/CHANGELOG.md b/CHANGELOG.md index adf60bea616..0dc2b4dff71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Gateway/performance: lazy-load the heavy cron runtime after the rest of Gateway startup, defer restart-sentinel refresh after readiness, and let the Gateway startup benchmark write per-run V8 CPU profiles with `--cpu-prof-dir`. - Gateway/performance: keep raw channel-config schema parsing from discovering bundled plugin runtime metadata, and add `pnpm gateway:watch --benchmark-no-force` for profiling startup without the default port cleanup. - Plugins/onboarding: let Manual setup install optional official plugins, including ClawHub-backed diagnostics with npm fallback, and expose the external Codex plugin as a selectable provider setup choice. Thanks @vincentkoc. +- Plugins/update: treat official externalized bundled npm migrations and ClawHub-to-npm fallbacks as trusted source-linked installs, so prerelease-only official plugin packages can migrate from bundled builds without being rejected as unsafe prerelease resolutions. Thanks @vincentkoc. - Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins. - Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant. - Providers/OpenRouter: add opt-in response caching params that send OpenRouter's `X-OpenRouter-Cache`, `X-OpenRouter-Cache-TTL`, and cache-clear headers only on verified OpenRouter routes. Thanks @vincentkoc. diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index b3cba4ff4f1..8682ba0551a 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -2079,6 +2079,53 @@ describe("syncPluginsForUpdateChannel", () => { }); }); + it("marks official externalized bundled npm installs as trusted", async () => { + resolveBundledPluginSourcesMock.mockReturnValue(new Map()); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "voice-call", + targetDir: "/tmp/openclaw-plugins/voice-call", + version: "0.0.2-beta.1", + }), + ); + + await syncPluginsForUpdateChannel({ + channel: "stable", + externalizedBundledPluginBridges: [ + { + bundledPluginId: "voice-call", + npmSpec: "@openclaw/voice-call", + channelIds: ["voice-call"], + }, + ], + config: { + channels: { + "voice-call": { + enabled: true, + }, + }, + plugins: { + load: { paths: [appBundledPluginRoot("voice-call")] }, + installs: { + "voice-call": { + source: "path", + sourcePath: appBundledPluginRoot("voice-call"), + installPath: appBundledPluginRoot("voice-call"), + }, + }, + }, + }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/voice-call", + expectedPluginId: "voice-call", + trustedSourceLinkedOfficialInstall: true, + }), + ); + }); + it("installs a ClawHub-preferred externalized bundled plugin", async () => { resolveBundledPluginSourcesMock.mockReturnValue(new Map()); installPluginFromClawHubMock.mockResolvedValue( @@ -2229,6 +2276,60 @@ describe("syncPluginsForUpdateChannel", () => { }); }); + it("marks official externalized ClawHub-to-npm fallbacks as trusted", async () => { + resolveBundledPluginSourcesMock.mockReturnValue(new Map()); + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "package_not_found", + error: "Package not found on ClawHub.", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "voice-call", + targetDir: "/tmp/openclaw-plugins/voice-call", + version: "0.0.2-beta.1", + }), + ); + + await syncPluginsForUpdateChannel({ + channel: "stable", + externalizedBundledPluginBridges: [ + { + bundledPluginId: "voice-call", + preferredSource: "clawhub", + clawhubSpec: "clawhub:@openclaw/voice-call", + npmSpec: "@openclaw/voice-call", + channelIds: ["voice-call"], + }, + ], + config: { + channels: { + "voice-call": { + enabled: true, + }, + }, + plugins: { + load: { paths: [appBundledPluginRoot("voice-call")] }, + installs: { + "voice-call": { + source: "path", + sourcePath: appBundledPluginRoot("voice-call"), + installPath: appBundledPluginRoot("voice-call"), + }, + }, + }, + }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/voice-call", + expectedPluginId: "voice-call", + trustedSourceLinkedOfficialInstall: true, + }), + ); + }); + it("fails closed without npm fallback when ClawHub returns integrity drift", async () => { resolveBundledPluginSourcesMock.mockReturnValue(new Map()); installPluginFromClawHubMock.mockResolvedValue({ diff --git a/src/plugins/update.ts b/src/plugins/update.ts index a29e5b30462..68fec4c02fa 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -477,6 +477,21 @@ function isTrustedSourceLinkedOfficialNpmUpdate(params: { return recordedPackageNames.includes(officialPackageName); } +function isTrustedSourceLinkedOfficialBridgeNpmInstall(params: { + targetPluginId: string; + npmSpec: string | undefined; +}): boolean { + const entry = getOfficialExternalPluginCatalogEntry(params.targetPluginId); + if (!entry) { + return false; + } + const officialPackageName = resolveNpmSpecPackageName( + resolveOfficialExternalPluginInstall(entry)?.npmSpec, + ); + const requestedPackageName = resolveNpmSpecPackageName(params.npmSpec); + return Boolean(officialPackageName && requestedPackageName === officialPackageName); +} + function resolveNpmUpdateSpecs(params: { record: PluginInstallRecord; specOverride?: string; @@ -1427,6 +1442,10 @@ export async function syncPluginsForUpdateChannel(params: { const preferredSource = getExternalizedBundledPluginPreferredSource(bridge); const npmSpec = getExternalizedBundledPluginNpmSpec(bridge); const clawhubSpec = getExternalizedBundledPluginClawHubSpec(bridge); + const trustedSourceLinkedOfficialInstall = isTrustedSourceLinkedOfficialBridgeNpmInstall({ + targetPluginId, + npmSpec, + }); let installSource = preferredSource; let installSpec = preferredSource === "clawhub" ? clawhubSpec : npmSpec; let result: @@ -1458,6 +1477,7 @@ export async function syncPluginsForUpdateChannel(params: { spec: npmSpec, mode: "update", expectedPluginId: targetPluginId, + trustedSourceLinkedOfficialInstall, logger, }); } @@ -1466,6 +1486,7 @@ export async function syncPluginsForUpdateChannel(params: { spec: npmSpec, mode: "update", expectedPluginId: targetPluginId, + trustedSourceLinkedOfficialInstall, logger, }); }