mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:10:45 +00:00
fix(plugins): keep manifestless bundles indexed
This commit is contained in:
@@ -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.
|
||||
|
||||
16
src/plugins/installed-plugin-index-manifest.ts
Normal file
16
src/plugins/installed-plugin-index-manifest.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
72
src/plugins/plugin-registry-snapshot.test.ts
Normal file
72
src/plugins/plugin-registry-snapshot.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user