From 1346a318611c360445d5a702b4f3552b88057c72 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 10:33:54 +0100 Subject: [PATCH] fix(plugins): keep manifestless bundles indexed --- CHANGELOG.md | 1 + .../installed-plugin-index-manifest.ts | 16 +++++ .../installed-plugin-index-record-builder.ts | 43 +++++++++-- src/plugins/installed-plugin-index.test.ts | 69 ++++++++++++++++++ src/plugins/plugin-registry-snapshot.test.ts | 72 +++++++++++++++++++ src/plugins/plugin-registry-snapshot.ts | 3 +- 6 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 src/plugins/installed-plugin-index-manifest.ts create mode 100644 src/plugins/plugin-registry-snapshot.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc264865e3..cee5949a9d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Agents/media: qualify bare `agents.defaults.imageModel` and `pdfModel` refs from unique configured image-capable providers, so Ollama vision models such as `moondream` and `qwen2.5vl:7b` do not fall through to the default provider. Fixes #38816; supersedes #73396. Thanks @alainasclaw and @vincentkoc. - Agents/Anthropic: send implicit Anthropic beta headers only to direct public Anthropic endpoints, including OAuth, so custom Anthropic-compatible providers no longer mis-handle unsupported beta flags unless explicitly configured. Refs #73346. Thanks @byBrodowski. - Skills: require explicit `skills.entries.coding-agent.enabled` before exposing the bundled coding-agent skill, so installs with Codex on PATH but no OpenAI auth do not silently offer Codex delegation. Fixes #73358. Thanks @LaFleurAdvertising and @Sanjays2402. +- Plugins/startup: treat manifestless Claude bundles as valid installed-plugin registry entries instead of stale missing manifests, so workspace bundles no longer force repeated derived registry rebuilds or noisy `plugins.entries.workspace` warnings during Gateway startup. Fixes #73433. Thanks @AnneVoss. - Agents/subagents: preserve `sessions_yield` as a paused subagent state and ignore its wait text while freezing completion output, so parent sessions wait for the final post-compaction answer instead of receiving intermediate progress or `(no output)`. Fixes #73413. Thanks @Ask-sola. - Plugins/startup: precompute bundled runtime mirror fingerprints before taking the mirror lock and keep Docker bundled plugin runtime deps/mirrors in a Docker-managed volume instead of the Windows/WSL config bind mount, so cold starts avoid slow host-volume mirror writes. Fixes #73339. Thanks @1yihui. - Channels/LINE: persist inbound image, video, audio, and file downloads in `~/.openclaw/media/inbound/` instead of temporary files so agents can still read LINE media after `/tmp` cleanup. Fixes #73370. Thanks @hijirii and @wenxu007. diff --git a/src/plugins/installed-plugin-index-manifest.ts b/src/plugins/installed-plugin-index-manifest.ts new file mode 100644 index 00000000000..abd1e951b60 --- /dev/null +++ b/src/plugins/installed-plugin-index-manifest.ts @@ -0,0 +1,16 @@ +import fs from "node:fs"; +import type { InstalledPluginIndexRecord } from "./installed-plugin-index-types.js"; +import type { PluginManifestRecord } from "./manifest-registry.js"; + +type ManifestBackedRecord = Pick< + PluginManifestRecord | InstalledPluginIndexRecord, + "bundleFormat" | "format" | "manifestPath" +>; + +export function hasOptionalMissingPluginManifestFile(record: ManifestBackedRecord): boolean { + return ( + record.format === "bundle" && + record.bundleFormat === "claude" && + !fs.existsSync(record.manifestPath) + ); +} diff --git a/src/plugins/installed-plugin-index-record-builder.ts b/src/plugins/installed-plugin-index-record-builder.ts index 2135adab3c4..144eef2f356 100644 --- a/src/plugins/installed-plugin-index-record-builder.ts +++ b/src/plugins/installed-plugin-index-record-builder.ts @@ -7,6 +7,7 @@ import type { PluginCandidate } from "./discovery.js"; import type { PluginInstallSourceInfo } from "./install-source-info.js"; import { describePluginInstallSource } from "./install-source-info.js"; import { hashJson, safeHashFile } from "./installed-plugin-index-hash.js"; +import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js"; import type { InstalledPluginIndexRecord, InstalledPluginInstallRecordInfo, @@ -219,6 +220,40 @@ function normalizePackageChannel( }; } +function hashManifestlessBundleRecord(record: PluginManifestRecord): string { + return hashJson({ + id: record.id, + name: record.name, + description: record.description, + version: record.version, + format: record.format, + bundleFormat: record.bundleFormat, + bundleCapabilities: record.bundleCapabilities ?? [], + skills: record.skills ?? [], + settingsFiles: record.settingsFiles ?? [], + hooks: record.hooks ?? [], + }); +} + +function resolveManifestHash(params: { + record: PluginManifestRecord; + diagnostics: PluginDiagnostic[]; +}): string { + if (hasOptionalMissingPluginManifestFile(params.record)) { + return hashManifestlessBundleRecord(params.record); + } + const hash = safeHashFile({ + filePath: params.record.manifestPath, + pluginId: params.record.id, + diagnostics: params.diagnostics, + required: true, + }); + if (hash) { + return hash; + } + return ""; +} + function buildCandidateLookup( candidates: readonly PluginCandidate[], ): Map { @@ -244,13 +279,7 @@ export function buildInstalledPluginIndexRecords(params: { const installRecord = params.installRecords[record.id]; const packageInstall = describePackageInstallSource(candidate); const packageChannel = normalizePackageChannel(candidate?.packageManifest?.channel); - const manifestHash = - safeHashFile({ - filePath: record.manifestPath, - pluginId: record.id, - diagnostics: params.diagnostics, - required: true, - }) ?? ""; + const manifestHash = resolveManifestHash({ record, diagnostics: params.diagnostics }); const packageJson = resolvePackageJsonRecord({ candidate, packageJsonPath, diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 8c0faaf5385..20ec5b93caf 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -49,6 +49,13 @@ function writeRuntimeEntry(rootDir: string) { ); } +function writeManifestlessClaudeBundle(rootDir: string, entries: readonly string[] = ["skills"]) { + for (const entry of entries) { + fs.mkdirSync(path.join(rootDir, entry), { recursive: true }); + fs.writeFileSync(path.join(rootDir, entry, "README.md"), `# ${entry}\n`, "utf-8"); + } +} + function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, @@ -327,6 +334,68 @@ describe("installed plugin index", () => { }); }); + it("indexes manifestless Claude bundles without missing-manifest diagnostics", () => { + const rootDir = path.join(makeTempDir(), "workspace"); + writeManifestlessClaudeBundle(rootDir); + + const index = loadInstalledPluginIndex({ + candidates: [ + createPluginCandidate({ + rootDir, + idHint: "workspace", + format: "bundle", + bundleFormat: "claude", + origin: "config", + }), + ], + env: hermeticEnv(), + }); + + expect(index.diagnostics).toEqual([]); + expect(index.plugins[0]).toMatchObject({ + pluginId: "workspace", + manifestPath: path.join(rootDir, ".claude-plugin", "plugin.json"), + manifestHash: expect.stringMatching(/^[a-f0-9]{64}$/u), + source: rootDir, + format: "bundle", + bundleFormat: "claude", + }); + }); + + it("changes manifestless Claude bundle hashes when derived metadata changes", () => { + const rootDir = path.join(makeTempDir(), "workspace"); + writeManifestlessClaudeBundle(rootDir, ["skills"]); + + const first = loadInstalledPluginIndex({ + candidates: [ + createPluginCandidate({ + rootDir, + idHint: "workspace", + format: "bundle", + bundleFormat: "claude", + origin: "config", + }), + ], + env: hermeticEnv(), + }); + + writeManifestlessClaudeBundle(rootDir, ["commands"]); + const second = loadInstalledPluginIndex({ + candidates: [ + createPluginCandidate({ + rootDir, + idHint: "workspace", + format: "bundle", + bundleFormat: "claude", + origin: "config", + }), + ], + env: hermeticEnv(), + }); + + expect(second.plugins[0]?.manifestHash).not.toBe(first.plugins[0]?.manifestHash); + }); + it("does not classify or tag explicit startup opt-outs as deprecated implicit sidecars", () => { const rootDir = makeTempDir(); writeRuntimeEntry(rootDir); diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts new file mode 100644 index 00000000000..ac7ae035379 --- /dev/null +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js"; +import { loadInstalledPluginIndex, type InstalledPluginIndex } from "./installed-plugin-index.js"; +import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js"; +import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + cleanupTrackedTempDirs(tempDirs); +}); + +function makeTempDir() { + return makeTrackedTempDir("openclaw-plugin-registry-snapshot", tempDirs); +} + +function createHermeticEnv(rootDir: string): NodeJS.ProcessEnv { + return { + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(rootDir, "bundled"), + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + OPENCLAW_VERSION: "2026.4.26", + VITEST: "true", + }; +} + +function writeManifestlessClaudeBundle(rootDir: string) { + fs.mkdirSync(path.join(rootDir, "skills"), { recursive: true }); + fs.writeFileSync(path.join(rootDir, "skills", "SKILL.md"), "# Workspace skill\n", "utf8"); +} + +function createManifestlessClaudeBundleIndex(params: { + rootDir: string; + env: NodeJS.ProcessEnv; +}): InstalledPluginIndex { + return loadInstalledPluginIndex({ + config: { + plugins: { + load: { paths: [params.rootDir] }, + }, + }, + env: params.env, + }); +} + +describe("loadPluginRegistrySnapshotWithMetadata", () => { + it("keeps persisted manifestless Claude bundles on the fast path", () => { + const tempRoot = makeTempDir(); + const rootDir = path.join(tempRoot, "workspace"); + const stateDir = path.join(tempRoot, "state"); + const env = createHermeticEnv(tempRoot); + const config = { + plugins: { + load: { paths: [rootDir] }, + }, + }; + writeManifestlessClaudeBundle(rootDir); + const index = createManifestlessClaudeBundleIndex({ rootDir, env }); + writePersistedInstalledPluginIndexSync(index, { stateDir }); + + const result = loadPluginRegistrySnapshotWithMetadata({ + config, + env, + stateDir, + }); + + expect(result.source).toBe("persisted"); + expect(result.diagnostics).toEqual([]); + }); +}); diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index 6a459e667e8..e8f68bd02f7 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { resolveCompatibilityHostVersion } from "../version.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; import { normalizePluginsConfig } from "./config-state.js"; +import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js"; import { inspectPersistedInstalledPluginIndex, readPersistedInstalledPluginIndexSync, @@ -85,7 +86,7 @@ function hasMissingPersistedPluginSource(index: InstalledPluginIndex): boolean { } return ( !fs.existsSync(plugin.rootDir) || - !fs.existsSync(plugin.manifestPath) || + (!hasOptionalMissingPluginManifestFile(plugin) && !fs.existsSync(plugin.manifestPath)) || (plugin.source ? !fs.existsSync(plugin.source) : false) || (plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false) );