fix(plugins): keep manifestless bundles indexed

This commit is contained in:
Peter Steinberger
2026-04-28 10:33:54 +01:00
parent f5922e6eb1
commit 1346a31861
6 changed files with 196 additions and 8 deletions

View File

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

View File

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

View File

@@ -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<string, PluginCandidate> {
@@ -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,

View File

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

View File

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

View File

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