mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 01:01:58 +08:00
Compare commits
7 Commits
v2026.6.10
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58e75bbff2 | ||
|
|
2ebe4e7675 | ||
|
|
c6f1d27733 | ||
|
|
9ecdb94e9d | ||
|
|
ea9824d5d2 | ||
|
|
5399058c7f | ||
|
|
3a4078cc40 |
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginAutoEnableResult } from "../../config/plugin-auto-enable.js";
|
||||
|
||||
const loadPluginManifestRegistry = vi.hoisted(() => vi.fn());
|
||||
const loadInstalledPluginIndex = vi.hoisted(() => vi.fn());
|
||||
const listChannelPluginCatalogEntries = vi.hoisted(() => vi.fn((): unknown[] => []));
|
||||
const listChatChannels = vi.hoisted(() => vi.fn((): Array<Record<string, string>> => []));
|
||||
const applyPluginAutoEnable = vi.hoisted(() =>
|
||||
@@ -14,8 +14,8 @@ const applyPluginAutoEnable = vi.hoisted(() =>
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("../../plugins/manifest-registry.js", () => ({
|
||||
loadPluginManifestRegistry: (...args: unknown[]) => loadPluginManifestRegistry(...args),
|
||||
vi.mock("../../plugins/installed-plugin-index.js", () => ({
|
||||
loadInstalledPluginIndex: (...args: unknown[]) => loadInstalledPluginIndex(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
||||
@@ -35,7 +35,7 @@ import { listManifestInstalledChannelIds, resolveChannelSetupEntries } from "./d
|
||||
|
||||
describe("listManifestInstalledChannelIds", () => {
|
||||
beforeEach(() => {
|
||||
loadPluginManifestRegistry.mockReset().mockReturnValue({
|
||||
loadInstalledPluginIndex.mockReset().mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
@@ -61,8 +61,8 @@ describe("listManifestInstalledChannelIds", () => {
|
||||
slack: ["slack configured"],
|
||||
},
|
||||
});
|
||||
loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [{ id: "slack", channels: ["slack"] }],
|
||||
loadInstalledPluginIndex.mockReturnValue({
|
||||
plugins: [{ pluginId: "slack", contributions: { channels: ["slack"] } }],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("listManifestInstalledChannelIds", () => {
|
||||
config: {},
|
||||
env: { OPENCLAW_HOME: "/tmp/home" },
|
||||
});
|
||||
expect(loadPluginManifestRegistry).toHaveBeenCalledWith({
|
||||
expect(loadInstalledPluginIndex).toHaveBeenCalledWith({
|
||||
config: autoEnabledConfig,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
env: { OPENCLAW_HOME: "/tmp/home" },
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
|
||||
import type { ChannelMeta } from "../../channels/plugins/types.public.js";
|
||||
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
|
||||
import { loadInstalledPluginIndex } from "../../plugins/installed-plugin-index.js";
|
||||
import type { ChannelChoice } from "../onboard-types.js";
|
||||
import {
|
||||
listSetupDiscoveryChannelPluginCatalogEntries,
|
||||
@@ -48,11 +48,11 @@ export function listManifestInstalledChannelIds(params: {
|
||||
}).config;
|
||||
const workspaceDir = resolveWorkspaceDir(resolvedConfig, params.workspaceDir);
|
||||
return new Set(
|
||||
loadPluginManifestRegistry({
|
||||
loadInstalledPluginIndex({
|
||||
config: resolvedConfig,
|
||||
workspaceDir,
|
||||
env: params.env ?? process.env,
|
||||
}).plugins.flatMap((plugin) => plugin.channels as ChannelChoice[]),
|
||||
}).plugins.flatMap((plugin) => plugin.contributions.channels as ChannelChoice[]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,19 @@ import {
|
||||
} from "./list.provider-catalog.js";
|
||||
|
||||
const providerDiscoveryMocks = vi.hoisted(() => ({
|
||||
loadInstalledPluginIndex: vi.fn(),
|
||||
resolveBundledProviderCompatPluginIds: vi.fn(),
|
||||
resolveInstalledPluginContributions: vi.fn(),
|
||||
resolveOwningPluginIdsForProvider: vi.fn(),
|
||||
resolvePluginDiscoveryProviders: vi.fn(),
|
||||
resolveProviderContractPluginIdsForProviderAlias: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/installed-plugin-index.js", () => ({
|
||||
loadInstalledPluginIndex: providerDiscoveryMocks.loadInstalledPluginIndex,
|
||||
resolveInstalledPluginContributions: providerDiscoveryMocks.resolveInstalledPluginContributions,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/providers.js", () => ({
|
||||
resolveBundledProviderCompatPluginIds:
|
||||
providerDiscoveryMocks.resolveBundledProviderCompatPluginIds,
|
||||
@@ -102,9 +109,34 @@ const catalogOnlyProvider = {
|
||||
|
||||
const defaultProviders = [chutesProvider, moonshotProvider, openaiProvider];
|
||||
|
||||
function createContributionMaps(params: {
|
||||
providers?: ReadonlyMap<string, readonly string[]>;
|
||||
cliBackends?: ReadonlyMap<string, readonly string[]>;
|
||||
}) {
|
||||
return {
|
||||
providers: params.providers ?? new Map(),
|
||||
channels: new Map(),
|
||||
channelConfigs: new Map(),
|
||||
setupProviders: new Map(),
|
||||
cliBackends: params.cliBackends ?? new Map(),
|
||||
modelCatalogProviders: new Map(),
|
||||
commandAliases: new Map(),
|
||||
contracts: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("loadProviderCatalogModelsForList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
providerDiscoveryMocks.loadInstalledPluginIndex.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
providerDiscoveryMocks.resolveInstalledPluginContributions.mockReturnValue(
|
||||
createContributionMaps({
|
||||
providers: new Map(defaultProviders.map((provider) => [provider.id, [provider.pluginId]])),
|
||||
}),
|
||||
);
|
||||
providerDiscoveryMocks.resolveBundledProviderCompatPluginIds.mockReturnValue([
|
||||
"chutes",
|
||||
"moonshot",
|
||||
@@ -167,6 +199,22 @@ describe("loadProviderCatalogModelsForList", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves provider owners from the installed plugin index before manifest fallback", async () => {
|
||||
await expect(
|
||||
resolveProviderCatalogPluginIdsForFilter({
|
||||
cfg: baseParams.cfg,
|
||||
env: baseParams.env,
|
||||
providerFilter: "moonshot",
|
||||
}),
|
||||
).resolves.toEqual(["moonshot"]);
|
||||
|
||||
expect(providerDiscoveryMocks.loadInstalledPluginIndex).toHaveBeenCalledWith({
|
||||
config: baseParams.cfg,
|
||||
env: baseParams.env,
|
||||
});
|
||||
expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an empty catalog when a static provider catalog throws", async () => {
|
||||
providerDiscoveryMocks.resolvePluginDiscoveryProviders.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -224,6 +272,9 @@ describe("loadProviderCatalogModelsForList", () => {
|
||||
});
|
||||
|
||||
it("does not skip registry for non-bundled static catalog owners", async () => {
|
||||
providerDiscoveryMocks.resolveInstalledPluginContributions.mockReturnValueOnce(
|
||||
createContributionMaps({}),
|
||||
);
|
||||
providerDiscoveryMocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce([
|
||||
"workspace-static-provider",
|
||||
]);
|
||||
@@ -241,6 +292,10 @@ describe("loadProviderCatalogModelsForList", () => {
|
||||
});
|
||||
|
||||
it("recognizes bundled provider hook aliases before the unknown-provider short-circuit", async () => {
|
||||
providerDiscoveryMocks.resolveInstalledPluginContributions.mockReturnValueOnce(
|
||||
createContributionMaps({}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveProviderCatalogPluginIdsForFilter({
|
||||
cfg: baseParams.cfg,
|
||||
@@ -291,6 +346,10 @@ describe("loadProviderCatalogModelsForList", () => {
|
||||
});
|
||||
|
||||
it("keeps unknown provider filters eligible for early empty results", async () => {
|
||||
providerDiscoveryMocks.resolveInstalledPluginContributions.mockReturnValueOnce(
|
||||
createContributionMaps({}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveProviderCatalogPluginIdsForFilter({
|
||||
cfg: baseParams.cfg,
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { ModelProviderConfig } from "../../config/types.models.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import {
|
||||
loadInstalledPluginIndex,
|
||||
resolveInstalledPluginContributions,
|
||||
} from "../../plugins/installed-plugin-index.js";
|
||||
import {
|
||||
groupPluginDiscoveryProvidersByOrder,
|
||||
normalizePluginDiscoveryResult,
|
||||
@@ -31,6 +35,38 @@ function providerMatchesFilter(params: {
|
||||
].some((providerId) => normalizeProviderId(providerId) === params.providerFilter);
|
||||
}
|
||||
|
||||
function collectMatchingContributionPluginIds(
|
||||
contributions: ReadonlyMap<string, readonly string[]>,
|
||||
providerFilter: string,
|
||||
): string[] {
|
||||
const pluginIds: string[] = [];
|
||||
for (const [contributionId, ownerPluginIds] of contributions) {
|
||||
if (normalizeProviderId(contributionId) === providerFilter) {
|
||||
pluginIds.push(...ownerPluginIds);
|
||||
}
|
||||
}
|
||||
return [...new Set(pluginIds)].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolveInstalledIndexPluginIdsForProviderFilter(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
providerFilter: string;
|
||||
}): string[] | undefined {
|
||||
const index = loadInstalledPluginIndex({
|
||||
config: params.cfg,
|
||||
env: params.env,
|
||||
});
|
||||
const contributions = resolveInstalledPluginContributions(index);
|
||||
const pluginIds = [
|
||||
...collectMatchingContributionPluginIds(contributions.providers, params.providerFilter),
|
||||
...collectMatchingContributionPluginIds(contributions.cliBackends, params.providerFilter),
|
||||
];
|
||||
return pluginIds.length > 0
|
||||
? [...new Set(pluginIds)].toSorted((left, right) => left.localeCompare(right))
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export async function resolveProviderCatalogPluginIdsForFilter(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -40,6 +76,14 @@ export async function resolveProviderCatalogPluginIdsForFilter(params: {
|
||||
if (!providerFilter) {
|
||||
return undefined;
|
||||
}
|
||||
const installedIndexPluginIds = resolveInstalledIndexPluginIdsForProviderFilter({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
providerFilter,
|
||||
});
|
||||
if (installedIndexPluginIds) {
|
||||
return installedIndexPluginIds;
|
||||
}
|
||||
const manifestPluginIds = resolveOwningPluginIdsForProvider({
|
||||
provider: providerFilter,
|
||||
config: params.cfg,
|
||||
|
||||
502
src/plugins/installed-plugin-index.test.ts
Normal file
502
src/plugins/installed-plugin-index.test.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import {
|
||||
diffInstalledPluginIndexInvalidationReasons,
|
||||
loadInstalledPluginIndex,
|
||||
refreshInstalledPluginIndex,
|
||||
resolveInstalledPluginContributions,
|
||||
} from "./installed-plugin-index.js";
|
||||
import { recordPluginInstall } from "./installs.js";
|
||||
import type { OpenClawPackageManifest } from "./manifest.js";
|
||||
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
|
||||
|
||||
vi.unmock("../version.js");
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTrackedTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
function makeTempDir() {
|
||||
return makeTrackedTempDir("openclaw-installed-plugin-index", tempDirs);
|
||||
}
|
||||
|
||||
function writePluginManifest(rootDir: string, manifest: Record<string, unknown>) {
|
||||
fs.writeFileSync(path.join(rootDir, "openclaw.plugin.json"), JSON.stringify(manifest), "utf-8");
|
||||
}
|
||||
|
||||
function writePackageJson(rootDir: string, packageJson: Record<string, unknown>) {
|
||||
fs.writeFileSync(path.join(rootDir, "package.json"), JSON.stringify(packageJson), "utf-8");
|
||||
}
|
||||
|
||||
function writeRuntimeEntry(rootDir: string) {
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "index.ts"),
|
||||
"throw new Error('runtime entry should not load while building installed plugin index');\n",
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
||||
return {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
|
||||
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
|
||||
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
|
||||
OPENCLAW_VERSION: "2026.4.25",
|
||||
VITEST: "true",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginCandidate(params: {
|
||||
rootDir: string;
|
||||
idHint?: string;
|
||||
origin?: PluginCandidate["origin"];
|
||||
packageName?: string;
|
||||
packageVersion?: string;
|
||||
packageManifest?: OpenClawPackageManifest;
|
||||
}): PluginCandidate {
|
||||
return {
|
||||
idHint: params.idHint ?? "demo",
|
||||
source: path.join(params.rootDir, "index.ts"),
|
||||
rootDir: params.rootDir,
|
||||
origin: params.origin ?? "global",
|
||||
packageName: params.packageName,
|
||||
packageVersion: params.packageVersion,
|
||||
packageDir: params.rootDir,
|
||||
packageManifest: params.packageManifest,
|
||||
};
|
||||
}
|
||||
|
||||
function createRichPluginFixture(params: { packageVersion?: string } = {}) {
|
||||
const rootDir = makeTempDir();
|
||||
writeRuntimeEntry(rootDir);
|
||||
writePackageJson(rootDir, {
|
||||
name: "@vendor/demo-plugin",
|
||||
version: params.packageVersion ?? "1.2.3",
|
||||
});
|
||||
writePluginManifest(rootDir, {
|
||||
id: "demo",
|
||||
name: "Demo",
|
||||
configSchema: { type: "object" },
|
||||
providers: ["demo"],
|
||||
channels: ["demo-chat"],
|
||||
cliBackends: ["demo-cli"],
|
||||
channelConfigs: {
|
||||
"demo-chat": {
|
||||
schema: { type: "object" },
|
||||
},
|
||||
},
|
||||
modelCatalog: {
|
||||
providers: {
|
||||
demo: {
|
||||
models: [{ id: "demo-model" }],
|
||||
},
|
||||
},
|
||||
discovery: {
|
||||
demo: "static",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
providers: [{ id: "demo", envVars: ["DEMO_API_KEY"] }],
|
||||
cliBackends: ["setup-cli"],
|
||||
},
|
||||
commandAliases: [{ name: "demo-command" }],
|
||||
contracts: {
|
||||
tools: ["demo-tool"],
|
||||
},
|
||||
providerAuthEnvVars: {
|
||||
demo: ["DEMO_API_KEY"],
|
||||
},
|
||||
channelEnvVars: {
|
||||
"demo-chat": ["DEMO_CHAT_TOKEN"],
|
||||
},
|
||||
activation: {
|
||||
onProviders: ["demo"],
|
||||
onChannels: ["demo-chat"],
|
||||
},
|
||||
});
|
||||
return {
|
||||
rootDir,
|
||||
candidate: createPluginCandidate({
|
||||
rootDir,
|
||||
packageName: "@vendor/demo-plugin",
|
||||
packageVersion: params.packageVersion ?? "1.2.3",
|
||||
packageManifest: {
|
||||
install: {
|
||||
npmSpec: "@vendor/demo-plugin@1.2.3",
|
||||
expectedIntegrity: "sha512-demo",
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("installed plugin index", () => {
|
||||
it("builds a runtime-free installed plugin snapshot from manifest and package metadata", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
|
||||
const index = loadInstalledPluginIndex({
|
||||
candidates: [fixture.candidate],
|
||||
env: hermeticEnv(),
|
||||
now: () => new Date("2026-04-25T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(index).toMatchObject({
|
||||
version: 1,
|
||||
generatedAt: "2026-04-25T12:00:00.000Z",
|
||||
plugins: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
packageName: "@vendor/demo-plugin",
|
||||
packageVersion: "1.2.3",
|
||||
origin: "global",
|
||||
rootDir: fixture.rootDir,
|
||||
enabled: true,
|
||||
packageInstall: {
|
||||
defaultChoice: "npm",
|
||||
npm: {
|
||||
spec: "@vendor/demo-plugin@1.2.3",
|
||||
packageName: "@vendor/demo-plugin",
|
||||
selector: "1.2.3",
|
||||
selectorKind: "exact-version",
|
||||
exactVersion: true,
|
||||
expectedIntegrity: "sha512-demo",
|
||||
pinState: "exact-with-integrity",
|
||||
},
|
||||
warnings: [],
|
||||
},
|
||||
contributions: {
|
||||
providers: ["demo"],
|
||||
channels: ["demo-chat"],
|
||||
channelConfigs: ["demo-chat"],
|
||||
setupProviders: ["demo"],
|
||||
cliBackends: ["demo-cli", "setup-cli"],
|
||||
modelCatalogProviders: ["demo"],
|
||||
commandAliases: ["demo-command"],
|
||||
contracts: ["tools"],
|
||||
},
|
||||
compat: [
|
||||
"activation-channel-hint",
|
||||
"activation-provider-hint",
|
||||
"channel-env-vars",
|
||||
"provider-auth-env-vars",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(index.plugins[0]?.manifestHash).toMatch(/^[a-f0-9]{64}$/u);
|
||||
expect(index.plugins[0]?.packageJsonHash).toMatch(/^[a-f0-9]{64}$/u);
|
||||
expect(index.plugins[0]?.packageJsonPath).toBe(path.join(fixture.rootDir, "package.json"));
|
||||
expect(index.plugins[0]?.installRecord).toBeUndefined();
|
||||
expect(index.plugins[0]?.installRecordHash).toBeUndefined();
|
||||
|
||||
const contributions = resolveInstalledPluginContributions(index);
|
||||
expect(contributions.providers.get("demo")).toEqual(["demo"]);
|
||||
expect(contributions.channels.get("demo-chat")).toEqual(["demo"]);
|
||||
expect(contributions.contracts.get("tools")).toEqual(["demo"]);
|
||||
});
|
||||
|
||||
it("records the config install ledger separately from package install intent", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
|
||||
const index = loadInstalledPluginIndex({
|
||||
candidates: [fixture.candidate],
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
demo: {
|
||||
source: "npm",
|
||||
spec: "@vendor/demo-plugin@latest",
|
||||
installPath: "plugins/demo",
|
||||
resolvedName: "@vendor/demo-plugin",
|
||||
resolvedVersion: "1.2.3",
|
||||
resolvedSpec: "@vendor/demo-plugin@1.2.3",
|
||||
integrity: "sha512-installed",
|
||||
shasum: "abc123",
|
||||
resolvedAt: "2026-04-25T11:00:00.000Z",
|
||||
installedAt: "2026-04-25T11:01:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: hermeticEnv(),
|
||||
});
|
||||
|
||||
expect(index.plugins[0]).toMatchObject({
|
||||
installRecord: {
|
||||
source: "npm",
|
||||
spec: "@vendor/demo-plugin@latest",
|
||||
installPath: "plugins/demo",
|
||||
resolvedName: "@vendor/demo-plugin",
|
||||
resolvedVersion: "1.2.3",
|
||||
resolvedSpec: "@vendor/demo-plugin@1.2.3",
|
||||
integrity: "sha512-installed",
|
||||
shasum: "abc123",
|
||||
resolvedAt: "2026-04-25T11:00:00.000Z",
|
||||
installedAt: "2026-04-25T11:01:00.000Z",
|
||||
},
|
||||
packageInstall: {
|
||||
npm: {
|
||||
spec: "@vendor/demo-plugin@1.2.3",
|
||||
expectedIntegrity: "sha512-demo",
|
||||
pinState: "exact-with-integrity",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(index.plugins[0]?.installRecordHash).toMatch(/^[a-f0-9]{64}$/u);
|
||||
});
|
||||
|
||||
it("indexes npm install ledger records written before a process reload", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
const cfg = recordPluginInstall(
|
||||
{},
|
||||
{
|
||||
pluginId: "demo",
|
||||
source: "npm",
|
||||
spec: "@vendor/demo-plugin@latest",
|
||||
installPath: fixture.rootDir,
|
||||
version: "1.2.3",
|
||||
resolvedName: "@vendor/demo-plugin",
|
||||
resolvedVersion: "1.2.3",
|
||||
resolvedSpec: "@vendor/demo-plugin@1.2.3",
|
||||
integrity: "sha512-installed",
|
||||
shasum: "abc123",
|
||||
resolvedAt: "2026-04-25T11:00:00.000Z",
|
||||
installedAt: "2026-04-25T11:01:00.000Z",
|
||||
},
|
||||
);
|
||||
|
||||
const index = loadInstalledPluginIndex({
|
||||
candidates: [fixture.candidate],
|
||||
config: cfg,
|
||||
env: hermeticEnv(),
|
||||
});
|
||||
|
||||
expect(index.plugins[0]).toMatchObject({
|
||||
pluginId: "demo",
|
||||
installRecord: {
|
||||
source: "npm",
|
||||
spec: "@vendor/demo-plugin@latest",
|
||||
installPath: fixture.rootDir,
|
||||
version: "1.2.3",
|
||||
resolvedName: "@vendor/demo-plugin",
|
||||
resolvedVersion: "1.2.3",
|
||||
resolvedSpec: "@vendor/demo-plugin@1.2.3",
|
||||
integrity: "sha512-installed",
|
||||
shasum: "abc123",
|
||||
resolvedAt: "2026-04-25T11:00:00.000Z",
|
||||
installedAt: "2026-04-25T11:01:00.000Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("indexes local fallback install ledger records written before a process reload", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
const cfg = recordPluginInstall(
|
||||
{},
|
||||
{
|
||||
pluginId: "demo",
|
||||
source: "path",
|
||||
sourcePath: "./plugins/demo",
|
||||
spec: "@vendor/demo-plugin@1.2.3",
|
||||
installedAt: "2026-04-25T11:01:00.000Z",
|
||||
},
|
||||
);
|
||||
|
||||
const index = loadInstalledPluginIndex({
|
||||
candidates: [fixture.candidate],
|
||||
config: cfg,
|
||||
env: hermeticEnv(),
|
||||
});
|
||||
|
||||
expect(index.plugins[0]).toMatchObject({
|
||||
pluginId: "demo",
|
||||
installRecord: {
|
||||
source: "path",
|
||||
sourcePath: "./plugins/demo",
|
||||
spec: "@vendor/demo-plugin@1.2.3",
|
||||
installedAt: "2026-04-25T11:01:00.000Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat package install intent as source invalidation", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
const previous = loadInstalledPluginIndex({
|
||||
candidates: [fixture.candidate],
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
demo: {
|
||||
source: "npm",
|
||||
resolvedName: "@vendor/demo-plugin",
|
||||
resolvedVersion: "1.2.3",
|
||||
resolvedSpec: "@vendor/demo-plugin@1.2.3",
|
||||
integrity: "sha512-installed",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: hermeticEnv(),
|
||||
});
|
||||
const current = {
|
||||
...previous,
|
||||
plugins: previous.plugins.map((plugin) => ({
|
||||
...plugin,
|
||||
packageInstall: {
|
||||
...plugin.packageInstall,
|
||||
warnings: ["npm-spec-missing-integrity" as const],
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats install ledger changes as source invalidation", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
const previous = loadInstalledPluginIndex({
|
||||
candidates: [fixture.candidate],
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
demo: {
|
||||
source: "npm",
|
||||
resolvedName: "@vendor/demo-plugin",
|
||||
resolvedVersion: "1.2.3",
|
||||
resolvedSpec: "@vendor/demo-plugin@1.2.3",
|
||||
integrity: "sha512-old",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: hermeticEnv(),
|
||||
});
|
||||
const current = loadInstalledPluginIndex({
|
||||
candidates: [fixture.candidate],
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
demo: {
|
||||
source: "npm",
|
||||
resolvedName: "@vendor/demo-plugin",
|
||||
resolvedVersion: "1.2.3",
|
||||
resolvedSpec: "@vendor/demo-plugin@1.2.3",
|
||||
integrity: "sha512-new",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: hermeticEnv(),
|
||||
});
|
||||
|
||||
expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([
|
||||
"source-changed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks disabled plugins without dropping their cold contributions", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
|
||||
const index = loadInstalledPluginIndex({
|
||||
candidates: [fixture.candidate],
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: hermeticEnv(),
|
||||
});
|
||||
|
||||
expect(index.plugins[0]?.enabled).toBe(false);
|
||||
expect(index.plugins[0]?.contributions.providers).toEqual(["demo"]);
|
||||
});
|
||||
|
||||
it("tracks refresh reason without using the manifest cache", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
|
||||
const index = refreshInstalledPluginIndex({
|
||||
reason: "manual",
|
||||
candidates: [fixture.candidate],
|
||||
env: hermeticEnv(),
|
||||
});
|
||||
|
||||
expect(index.refreshReason).toBe("manual");
|
||||
});
|
||||
|
||||
it("diffs invalidation reasons for manifest, package, source, host, and compat changes", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
const previous = loadInstalledPluginIndex({
|
||||
candidates: [fixture.candidate],
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
demo: {
|
||||
source: "npm",
|
||||
resolvedVersion: "1.2.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.25" }),
|
||||
});
|
||||
|
||||
writePackageJson(fixture.rootDir, {
|
||||
name: "@vendor/demo-plugin",
|
||||
version: "1.2.4",
|
||||
});
|
||||
writePluginManifest(fixture.rootDir, {
|
||||
id: "demo",
|
||||
configSchema: { type: "object" },
|
||||
providers: ["demo", "demo-next"],
|
||||
});
|
||||
const current = {
|
||||
...loadInstalledPluginIndex({
|
||||
candidates: [
|
||||
{
|
||||
...fixture.candidate,
|
||||
packageVersion: "1.2.4",
|
||||
},
|
||||
],
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
demo: {
|
||||
source: "npm",
|
||||
resolvedVersion: "1.2.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.26" }),
|
||||
}),
|
||||
compatRegistryVersion: "different-compat-registry",
|
||||
};
|
||||
|
||||
expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([
|
||||
"compat-registry-changed",
|
||||
"host-contract-changed",
|
||||
"source-changed",
|
||||
"stale-manifest",
|
||||
"stale-package",
|
||||
]);
|
||||
|
||||
const moved = {
|
||||
...current,
|
||||
plugins: current.plugins.map((plugin) => ({
|
||||
...plugin,
|
||||
rootDir: path.join(plugin.rootDir, "moved"),
|
||||
})),
|
||||
};
|
||||
expect(diffInstalledPluginIndexInvalidationReasons(current, moved)).toContain("source-changed");
|
||||
});
|
||||
});
|
||||
572
src/plugins/installed-plugin-index.ts
Normal file
572
src/plugins/installed-plugin-index.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { resolveCompatibilityHostVersion } from "../version.js";
|
||||
import { listPluginCompatRecords, type PluginCompatCode } from "./compat/registry.js";
|
||||
import {
|
||||
normalizePluginsConfigWithResolver,
|
||||
resolveEffectiveEnableState,
|
||||
} from "./config-policy.js";
|
||||
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
|
||||
import {
|
||||
describePluginInstallSource,
|
||||
type PluginInstallSourceInfo,
|
||||
} from "./install-source-info.js";
|
||||
import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js";
|
||||
import {
|
||||
loadPluginManifestRegistry,
|
||||
type PluginManifestRecord,
|
||||
type PluginManifestRegistry,
|
||||
} from "./manifest-registry.js";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
|
||||
export const INSTALLED_PLUGIN_INDEX_VERSION = 1;
|
||||
|
||||
export type InstalledPluginIndexRefreshReason =
|
||||
| "missing"
|
||||
| "stale-manifest"
|
||||
| "stale-package"
|
||||
| "source-changed"
|
||||
| "host-contract-changed"
|
||||
| "compat-registry-changed"
|
||||
| "manual";
|
||||
|
||||
export type InstalledPluginIndexContributions = {
|
||||
providers: readonly string[];
|
||||
channels: readonly string[];
|
||||
channelConfigs: readonly string[];
|
||||
setupProviders: readonly string[];
|
||||
cliBackends: readonly string[];
|
||||
modelCatalogProviders: readonly string[];
|
||||
commandAliases: readonly string[];
|
||||
contracts: readonly string[];
|
||||
};
|
||||
|
||||
export type InstalledPluginInstallRecordInfo = Pick<
|
||||
PluginInstallRecord,
|
||||
| "source"
|
||||
| "spec"
|
||||
| "sourcePath"
|
||||
| "installPath"
|
||||
| "version"
|
||||
| "resolvedName"
|
||||
| "resolvedVersion"
|
||||
| "resolvedSpec"
|
||||
| "integrity"
|
||||
| "shasum"
|
||||
| "resolvedAt"
|
||||
| "installedAt"
|
||||
| "clawhubUrl"
|
||||
| "clawhubPackage"
|
||||
| "clawhubFamily"
|
||||
| "clawhubChannel"
|
||||
| "marketplaceName"
|
||||
| "marketplaceSource"
|
||||
| "marketplacePlugin"
|
||||
>;
|
||||
|
||||
export type InstalledPluginIndexRecord = {
|
||||
pluginId: string;
|
||||
packageName?: string;
|
||||
packageVersion?: string;
|
||||
/**
|
||||
* Actual install ledger entry recorded by OpenClaw under
|
||||
* cfg.plugins.installs[pluginId]. This is the durable source of truth for
|
||||
* what onboarding/update installed.
|
||||
*/
|
||||
installRecord?: InstalledPluginInstallRecordInfo;
|
||||
/** Hash of installRecord; used to detect source-changed invalidation. */
|
||||
installRecordHash?: string;
|
||||
/**
|
||||
* Package-authored openclaw.install metadata. This describes catalog/package
|
||||
* install intent and must not be treated as the durable install ledger.
|
||||
*/
|
||||
packageInstall?: PluginInstallSourceInfo;
|
||||
manifestPath: string;
|
||||
manifestHash: string;
|
||||
packageJsonPath?: string;
|
||||
packageJsonHash?: string;
|
||||
rootDir: string;
|
||||
origin: PluginManifestRecord["origin"];
|
||||
enabled: boolean;
|
||||
contributions: InstalledPluginIndexContributions;
|
||||
compat: readonly PluginCompatCode[];
|
||||
};
|
||||
|
||||
export type InstalledPluginIndex = {
|
||||
version: typeof INSTALLED_PLUGIN_INDEX_VERSION;
|
||||
hostContractVersion: string;
|
||||
compatRegistryVersion: string;
|
||||
generatedAt: string;
|
||||
refreshReason?: InstalledPluginIndexRefreshReason;
|
||||
plugins: readonly InstalledPluginIndexRecord[];
|
||||
diagnostics: readonly PluginDiagnostic[];
|
||||
};
|
||||
|
||||
export type InstalledPluginContributions = {
|
||||
providers: ReadonlyMap<string, readonly string[]>;
|
||||
channels: ReadonlyMap<string, readonly string[]>;
|
||||
channelConfigs: ReadonlyMap<string, readonly string[]>;
|
||||
setupProviders: ReadonlyMap<string, readonly string[]>;
|
||||
cliBackends: ReadonlyMap<string, readonly string[]>;
|
||||
modelCatalogProviders: ReadonlyMap<string, readonly string[]>;
|
||||
commandAliases: ReadonlyMap<string, readonly string[]>;
|
||||
contracts: ReadonlyMap<string, readonly string[]>;
|
||||
};
|
||||
|
||||
export type LoadInstalledPluginIndexParams = {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cache?: boolean;
|
||||
candidates?: PluginCandidate[];
|
||||
diagnostics?: PluginDiagnostic[];
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
export type RefreshInstalledPluginIndexParams = LoadInstalledPluginIndexParams & {
|
||||
reason: InstalledPluginIndexRefreshReason;
|
||||
};
|
||||
|
||||
function hashString(value: string): string {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function hashJson(value: unknown): string {
|
||||
return hashString(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function safeHashFile(params: {
|
||||
filePath: string;
|
||||
pluginId?: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
required: boolean;
|
||||
}): string | undefined {
|
||||
try {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(params.filePath)).digest("hex");
|
||||
} catch (err) {
|
||||
if (params.required) {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
...(params.pluginId ? { pluginId: params.pluginId } : {}),
|
||||
source: params.filePath,
|
||||
message: `installed plugin index could not hash ${params.filePath}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sortUnique(values: readonly string[] | undefined): readonly string[] {
|
||||
if (!values || values.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).toSorted(
|
||||
(left, right) => left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function collectObjectKeys(value: Record<string, unknown> | undefined): readonly string[] {
|
||||
return sortUnique(value ? Object.keys(value) : []);
|
||||
}
|
||||
|
||||
function collectCommandAliasNames(
|
||||
aliases: readonly PluginManifestCommandAlias[] | undefined,
|
||||
): readonly string[] {
|
||||
return sortUnique(aliases?.map((alias) => alias.name) ?? []);
|
||||
}
|
||||
|
||||
function collectContractKeys(record: PluginManifestRecord): readonly string[] {
|
||||
const contracts = record.contracts;
|
||||
if (!contracts) {
|
||||
return [];
|
||||
}
|
||||
return sortUnique(
|
||||
Object.entries(contracts).flatMap(([key, value]) =>
|
||||
Array.isArray(value) && value.length > 0 ? [key] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompatCode[] {
|
||||
const codes: PluginCompatCode[] = [];
|
||||
if (record.providerAuthEnvVars && Object.keys(record.providerAuthEnvVars).length > 0) {
|
||||
codes.push("provider-auth-env-vars");
|
||||
}
|
||||
if (record.channelEnvVars && Object.keys(record.channelEnvVars).length > 0) {
|
||||
codes.push("channel-env-vars");
|
||||
}
|
||||
if (record.activation?.onProviders?.length) {
|
||||
codes.push("activation-provider-hint");
|
||||
}
|
||||
if (record.activation?.onChannels?.length) {
|
||||
codes.push("activation-channel-hint");
|
||||
}
|
||||
if (record.activation?.onCommands?.length) {
|
||||
codes.push("activation-command-hint");
|
||||
}
|
||||
if (record.activation?.onRoutes?.length) {
|
||||
codes.push("activation-route-hint");
|
||||
}
|
||||
if (record.activation?.onCapabilities?.length) {
|
||||
codes.push("activation-capability-hint");
|
||||
}
|
||||
return sortUnique(codes) as readonly PluginCompatCode[];
|
||||
}
|
||||
|
||||
function buildContributions(record: PluginManifestRecord): InstalledPluginIndexContributions {
|
||||
return {
|
||||
providers: sortUnique(record.providers),
|
||||
channels: sortUnique(record.channels),
|
||||
channelConfigs: collectObjectKeys(record.channelConfigs),
|
||||
setupProviders: sortUnique(record.setup?.providers?.map((provider) => provider.id) ?? []),
|
||||
cliBackends: sortUnique([...(record.cliBackends ?? []), ...(record.setup?.cliBackends ?? [])]),
|
||||
modelCatalogProviders: collectObjectKeys(record.modelCatalog?.providers),
|
||||
commandAliases: collectCommandAliasNames(record.commandAliases),
|
||||
contracts: collectContractKeys(record),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePackageJsonPath(candidate: PluginCandidate | undefined): string | undefined {
|
||||
if (!candidate?.packageDir) {
|
||||
return undefined;
|
||||
}
|
||||
const packageJsonPath = path.join(candidate.packageDir, "package.json");
|
||||
return fs.existsSync(packageJsonPath) ? packageJsonPath : undefined;
|
||||
}
|
||||
|
||||
function describePackageInstallSource(
|
||||
candidate: PluginCandidate | undefined,
|
||||
): PluginInstallSourceInfo | undefined {
|
||||
const install = candidate?.packageManifest?.install;
|
||||
if (!install) {
|
||||
return undefined;
|
||||
}
|
||||
return describePluginInstallSource(install, {
|
||||
expectedPackageName: candidate?.packageName,
|
||||
});
|
||||
}
|
||||
|
||||
function setInstallStringField<Key extends keyof Omit<InstalledPluginInstallRecordInfo, "source">>(
|
||||
target: InstalledPluginInstallRecordInfo,
|
||||
key: Key,
|
||||
value: PluginInstallRecord[Key],
|
||||
): void {
|
||||
if (typeof value !== "string") {
|
||||
return;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (normalized) {
|
||||
target[key] = normalized as InstalledPluginInstallRecordInfo[Key];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInstallRecord(
|
||||
record: PluginInstallRecord | undefined,
|
||||
): InstalledPluginInstallRecordInfo | undefined {
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized: InstalledPluginInstallRecordInfo = {
|
||||
source: record.source,
|
||||
};
|
||||
setInstallStringField(normalized, "spec", record.spec);
|
||||
setInstallStringField(normalized, "sourcePath", record.sourcePath);
|
||||
setInstallStringField(normalized, "installPath", record.installPath);
|
||||
setInstallStringField(normalized, "version", record.version);
|
||||
setInstallStringField(normalized, "resolvedName", record.resolvedName);
|
||||
setInstallStringField(normalized, "resolvedVersion", record.resolvedVersion);
|
||||
setInstallStringField(normalized, "resolvedSpec", record.resolvedSpec);
|
||||
setInstallStringField(normalized, "integrity", record.integrity);
|
||||
setInstallStringField(normalized, "shasum", record.shasum);
|
||||
setInstallStringField(normalized, "resolvedAt", record.resolvedAt);
|
||||
setInstallStringField(normalized, "installedAt", record.installedAt);
|
||||
setInstallStringField(normalized, "clawhubUrl", record.clawhubUrl);
|
||||
setInstallStringField(normalized, "clawhubPackage", record.clawhubPackage);
|
||||
setInstallStringField(normalized, "clawhubFamily", record.clawhubFamily);
|
||||
setInstallStringField(normalized, "clawhubChannel", record.clawhubChannel);
|
||||
setInstallStringField(normalized, "marketplaceName", record.marketplaceName);
|
||||
setInstallStringField(normalized, "marketplaceSource", record.marketplaceSource);
|
||||
setInstallStringField(normalized, "marketplacePlugin", record.marketplacePlugin);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildCandidateLookup(
|
||||
candidates: readonly PluginCandidate[],
|
||||
): Map<string, PluginCandidate> {
|
||||
const byRootDir = new Map<string, PluginCandidate>();
|
||||
for (const candidate of candidates) {
|
||||
byRootDir.set(candidate.rootDir, candidate);
|
||||
}
|
||||
return byRootDir;
|
||||
}
|
||||
|
||||
function resolveCompatRegistryVersion(): string {
|
||||
return hashJson(
|
||||
listPluginCompatRecords().map((record) => ({
|
||||
code: record.code,
|
||||
status: record.status,
|
||||
deprecated: record.deprecated,
|
||||
warningStarts: record.warningStarts,
|
||||
removeAfter: record.removeAfter,
|
||||
replacement: record.replacement,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRegistry(params: LoadInstalledPluginIndexParams): {
|
||||
registry: PluginManifestRegistry;
|
||||
candidates: readonly PluginCandidate[];
|
||||
} {
|
||||
if (params.candidates) {
|
||||
return {
|
||||
candidates: params.candidates,
|
||||
registry: loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cache: false,
|
||||
env: params.env,
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = normalizePluginsConfigWithResolver(params.config?.plugins);
|
||||
const discovery = discoverOpenClawPlugins({
|
||||
workspaceDir: params.workspaceDir,
|
||||
extraPaths: normalized.loadPaths,
|
||||
cache: params.cache,
|
||||
env: params.env,
|
||||
});
|
||||
return {
|
||||
candidates: discovery.candidates,
|
||||
registry: loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cache: false,
|
||||
env: params.env,
|
||||
candidates: discovery.candidates,
|
||||
diagnostics: discovery.diagnostics,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildInstalledPluginIndex(
|
||||
params: LoadInstalledPluginIndexParams & { refreshReason?: InstalledPluginIndexRefreshReason },
|
||||
): InstalledPluginIndex {
|
||||
const env = params.env ?? process.env;
|
||||
const { candidates, registry } = resolveRegistry(params);
|
||||
const candidateByRootDir = buildCandidateLookup(candidates);
|
||||
const normalizedConfig = normalizePluginsConfigWithResolver(params.config?.plugins);
|
||||
const diagnostics: PluginDiagnostic[] = [...registry.diagnostics];
|
||||
const generatedAt = (params.now?.() ?? new Date()).toISOString();
|
||||
const plugins = registry.plugins.map((record): InstalledPluginIndexRecord => {
|
||||
const candidate = candidateByRootDir.get(record.rootDir);
|
||||
const packageJsonPath = resolvePackageJsonPath(candidate);
|
||||
const installRecord = normalizeInstallRecord(params.config?.plugins?.installs?.[record.id]);
|
||||
const packageInstall = describePackageInstallSource(candidate);
|
||||
const manifestHash =
|
||||
safeHashFile({
|
||||
filePath: record.manifestPath,
|
||||
pluginId: record.id,
|
||||
diagnostics,
|
||||
required: true,
|
||||
}) ?? "";
|
||||
const packageJsonHash = packageJsonPath
|
||||
? safeHashFile({
|
||||
filePath: packageJsonPath,
|
||||
pluginId: record.id,
|
||||
diagnostics,
|
||||
required: false,
|
||||
})
|
||||
: undefined;
|
||||
const enabled = resolveEffectiveEnableState({
|
||||
id: record.id,
|
||||
origin: record.origin,
|
||||
config: normalizedConfig,
|
||||
rootConfig: params.config,
|
||||
enabledByDefault: record.enabledByDefault,
|
||||
}).enabled;
|
||||
|
||||
const indexRecord: InstalledPluginIndexRecord = {
|
||||
pluginId: record.id,
|
||||
manifestPath: record.manifestPath,
|
||||
manifestHash,
|
||||
rootDir: record.rootDir,
|
||||
origin: record.origin,
|
||||
enabled,
|
||||
contributions: buildContributions(record),
|
||||
compat: collectCompatCodes(record),
|
||||
};
|
||||
if (candidate?.packageName) {
|
||||
indexRecord.packageName = candidate.packageName;
|
||||
}
|
||||
if (candidate?.packageVersion) {
|
||||
indexRecord.packageVersion = candidate.packageVersion;
|
||||
}
|
||||
if (installRecord) {
|
||||
indexRecord.installRecord = installRecord;
|
||||
indexRecord.installRecordHash = hashJson(installRecord);
|
||||
}
|
||||
if (packageInstall) {
|
||||
indexRecord.packageInstall = packageInstall;
|
||||
}
|
||||
if (packageJsonPath) {
|
||||
indexRecord.packageJsonPath = packageJsonPath;
|
||||
}
|
||||
if (packageJsonHash) {
|
||||
indexRecord.packageJsonHash = packageJsonHash;
|
||||
}
|
||||
return indexRecord;
|
||||
});
|
||||
|
||||
return {
|
||||
version: INSTALLED_PLUGIN_INDEX_VERSION,
|
||||
hostContractVersion: resolveCompatibilityHostVersion(env),
|
||||
compatRegistryVersion: resolveCompatRegistryVersion(),
|
||||
generatedAt,
|
||||
...(params.refreshReason ? { refreshReason: params.refreshReason } : {}),
|
||||
plugins,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadInstalledPluginIndex(
|
||||
params: LoadInstalledPluginIndexParams = {},
|
||||
): InstalledPluginIndex {
|
||||
return buildInstalledPluginIndex(params);
|
||||
}
|
||||
|
||||
export function refreshInstalledPluginIndex(
|
||||
params: RefreshInstalledPluginIndexParams,
|
||||
): InstalledPluginIndex {
|
||||
return buildInstalledPluginIndex({ ...params, cache: false, refreshReason: params.reason });
|
||||
}
|
||||
|
||||
function addContribution(
|
||||
target: Map<string, string[]>,
|
||||
contributionId: string,
|
||||
pluginId: string,
|
||||
): void {
|
||||
const existing = target.get(contributionId);
|
||||
if (existing) {
|
||||
existing.push(pluginId);
|
||||
} else {
|
||||
target.set(contributionId, [pluginId]);
|
||||
}
|
||||
}
|
||||
|
||||
function freezeContributionMap(
|
||||
source: Map<string, string[]>,
|
||||
): ReadonlyMap<string, readonly string[]> {
|
||||
const frozen = new Map<string, readonly string[]>();
|
||||
for (const [key, pluginIds] of source) {
|
||||
frozen.set(key, sortUnique(pluginIds));
|
||||
}
|
||||
return frozen;
|
||||
}
|
||||
|
||||
export function resolveInstalledPluginContributions(
|
||||
index: InstalledPluginIndex,
|
||||
): InstalledPluginContributions {
|
||||
const providers = new Map<string, string[]>();
|
||||
const channels = new Map<string, string[]>();
|
||||
const channelConfigs = new Map<string, string[]>();
|
||||
const setupProviders = new Map<string, string[]>();
|
||||
const cliBackends = new Map<string, string[]>();
|
||||
const modelCatalogProviders = new Map<string, string[]>();
|
||||
const commandAliases = new Map<string, string[]>();
|
||||
const contracts = new Map<string, string[]>();
|
||||
|
||||
for (const plugin of index.plugins) {
|
||||
for (const provider of plugin.contributions.providers) {
|
||||
addContribution(providers, provider, plugin.pluginId);
|
||||
}
|
||||
for (const channel of plugin.contributions.channels) {
|
||||
addContribution(channels, channel, plugin.pluginId);
|
||||
}
|
||||
for (const channelConfig of plugin.contributions.channelConfigs) {
|
||||
addContribution(channelConfigs, channelConfig, plugin.pluginId);
|
||||
}
|
||||
for (const setupProvider of plugin.contributions.setupProviders) {
|
||||
addContribution(setupProviders, setupProvider, plugin.pluginId);
|
||||
}
|
||||
for (const cliBackend of plugin.contributions.cliBackends) {
|
||||
addContribution(cliBackends, cliBackend, plugin.pluginId);
|
||||
}
|
||||
for (const modelCatalogProvider of plugin.contributions.modelCatalogProviders) {
|
||||
addContribution(modelCatalogProviders, modelCatalogProvider, plugin.pluginId);
|
||||
}
|
||||
for (const commandAlias of plugin.contributions.commandAliases) {
|
||||
addContribution(commandAliases, commandAlias, plugin.pluginId);
|
||||
}
|
||||
for (const contract of plugin.contributions.contracts) {
|
||||
addContribution(contracts, contract, plugin.pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
providers: freezeContributionMap(providers),
|
||||
channels: freezeContributionMap(channels),
|
||||
channelConfigs: freezeContributionMap(channelConfigs),
|
||||
setupProviders: freezeContributionMap(setupProviders),
|
||||
cliBackends: freezeContributionMap(cliBackends),
|
||||
modelCatalogProviders: freezeContributionMap(modelCatalogProviders),
|
||||
commandAliases: freezeContributionMap(commandAliases),
|
||||
contracts: freezeContributionMap(contracts),
|
||||
};
|
||||
}
|
||||
|
||||
export function diffInstalledPluginIndexInvalidationReasons(
|
||||
previous: InstalledPluginIndex,
|
||||
current: InstalledPluginIndex,
|
||||
): readonly InstalledPluginIndexRefreshReason[] {
|
||||
const reasons = new Set<InstalledPluginIndexRefreshReason>();
|
||||
if (previous.version !== current.version) {
|
||||
reasons.add("missing");
|
||||
}
|
||||
if (previous.hostContractVersion !== current.hostContractVersion) {
|
||||
reasons.add("host-contract-changed");
|
||||
}
|
||||
if (previous.compatRegistryVersion !== current.compatRegistryVersion) {
|
||||
reasons.add("compat-registry-changed");
|
||||
}
|
||||
|
||||
const previousByPluginId = new Map(previous.plugins.map((plugin) => [plugin.pluginId, plugin]));
|
||||
const currentByPluginId = new Map(current.plugins.map((plugin) => [plugin.pluginId, plugin]));
|
||||
for (const [pluginId, previousPlugin] of previousByPluginId) {
|
||||
const currentPlugin = currentByPluginId.get(pluginId);
|
||||
if (!currentPlugin) {
|
||||
reasons.add("source-changed");
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
previousPlugin.rootDir !== currentPlugin.rootDir ||
|
||||
previousPlugin.manifestPath !== currentPlugin.manifestPath ||
|
||||
previousPlugin.installRecordHash !== currentPlugin.installRecordHash
|
||||
) {
|
||||
reasons.add("source-changed");
|
||||
}
|
||||
if (previousPlugin.manifestHash !== currentPlugin.manifestHash) {
|
||||
reasons.add("stale-manifest");
|
||||
}
|
||||
if (
|
||||
previousPlugin.packageVersion !== currentPlugin.packageVersion ||
|
||||
previousPlugin.packageJsonHash !== currentPlugin.packageJsonHash
|
||||
) {
|
||||
reasons.add("stale-package");
|
||||
}
|
||||
}
|
||||
for (const pluginId of currentByPluginId.keys()) {
|
||||
if (!previousByPluginId.has(pluginId)) {
|
||||
reasons.add("source-changed");
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(reasons).toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
@@ -1,13 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { ModelProviderConfig } from "../config/types.js";
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import {
|
||||
groupPluginDiscoveryProvidersByOrder,
|
||||
normalizePluginDiscoveryResult,
|
||||
resolveInstalledPluginProviderContributionIds,
|
||||
runProviderCatalog,
|
||||
runProviderStaticCatalog,
|
||||
} from "./provider-discovery.js";
|
||||
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
|
||||
import type { ProviderCatalogResult, ProviderDiscoveryOrder, ProviderPlugin } from "./types.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTrackedTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
function makeTempDir() {
|
||||
return makeTrackedTempDir("openclaw-provider-discovery", tempDirs);
|
||||
}
|
||||
|
||||
function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
||||
return {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
|
||||
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
|
||||
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
|
||||
OPENCLAW_VERSION: "2026.4.25",
|
||||
VITEST: "true",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderContributionCandidate(params: {
|
||||
pluginId?: string;
|
||||
providerIds?: readonly string[];
|
||||
}): PluginCandidate {
|
||||
const rootDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "index.ts"),
|
||||
"throw new Error('runtime provider entry should not load for cold contribution ids');\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: params.pluginId ?? "demo",
|
||||
configSchema: { type: "object" },
|
||||
providers: params.providerIds ?? ["demo"],
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
return {
|
||||
idHint: params.pluginId ?? "demo",
|
||||
source: path.join(rootDir, "index.ts"),
|
||||
rootDir,
|
||||
origin: "global",
|
||||
};
|
||||
}
|
||||
|
||||
function makeProvider(params: {
|
||||
id: string;
|
||||
label?: string;
|
||||
@@ -112,6 +165,50 @@ async function expectProviderCatalogResult(params: {
|
||||
).resolves.toEqual(params.expected);
|
||||
}
|
||||
|
||||
describe("resolveInstalledPluginProviderContributionIds", () => {
|
||||
it("reads provider ids from the installed plugin index without importing runtime entries", () => {
|
||||
const candidate = createProviderContributionCandidate({
|
||||
pluginId: "demo",
|
||||
providerIds: ["demo", "demo-alias"],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveInstalledPluginProviderContributionIds({
|
||||
candidates: [candidate],
|
||||
env: hermeticEnv(),
|
||||
}),
|
||||
).toEqual(["demo", "demo-alias"]);
|
||||
});
|
||||
|
||||
it("omits disabled plugin provider ids unless explicitly requested", () => {
|
||||
const candidate = createProviderContributionCandidate({
|
||||
pluginId: "demo",
|
||||
providerIds: ["demo"],
|
||||
});
|
||||
const params = {
|
||||
candidates: [candidate],
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: hermeticEnv(),
|
||||
};
|
||||
|
||||
expect(resolveInstalledPluginProviderContributionIds(params)).toEqual([]);
|
||||
expect(
|
||||
resolveInstalledPluginProviderContributionIds({
|
||||
...params,
|
||||
includeDisabled: true,
|
||||
}),
|
||||
).toEqual(["demo"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupPluginDiscoveryProvidersByOrder", () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import type { ModelProviderConfig } from "../config/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
loadInstalledPluginIndex,
|
||||
type InstalledPluginIndex,
|
||||
type LoadInstalledPluginIndexParams,
|
||||
} from "./installed-plugin-index.js";
|
||||
import type { ProviderDiscoveryOrder, ProviderPlugin } from "./types.js";
|
||||
|
||||
const DISCOVERY_ORDER: readonly ProviderDiscoveryOrder[] = ["simple", "profile", "paired", "late"];
|
||||
@@ -28,7 +33,7 @@ function isSafeProviderConfigKey(value: string): boolean {
|
||||
return value !== "" && !DANGEROUS_PROVIDER_KEYS.has(value);
|
||||
}
|
||||
|
||||
export async function resolvePluginDiscoveryProviders(params: {
|
||||
export type ResolveRuntimePluginDiscoveryProvidersParams = {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -36,12 +41,45 @@ export async function resolvePluginDiscoveryProviders(params: {
|
||||
includeUntrustedWorkspacePlugins?: boolean;
|
||||
requireCompleteDiscoveryEntryCoverage?: boolean;
|
||||
discoveryEntriesOnly?: boolean;
|
||||
}): Promise<ProviderPlugin[]> {
|
||||
};
|
||||
|
||||
export type ResolveInstalledPluginProviderContributionIdsParams = LoadInstalledPluginIndexParams & {
|
||||
index?: InstalledPluginIndex;
|
||||
includeDisabled?: boolean;
|
||||
};
|
||||
|
||||
function sortedValues(values: Iterable<string>): string[] {
|
||||
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function resolveInstalledPluginProviderContributionIds(
|
||||
params: ResolveInstalledPluginProviderContributionIdsParams = {},
|
||||
): string[] {
|
||||
const index = params.index ?? loadInstalledPluginIndex(params);
|
||||
const providerIds: string[] = [];
|
||||
for (const plugin of index.plugins) {
|
||||
if (!params.includeDisabled && !plugin.enabled) {
|
||||
continue;
|
||||
}
|
||||
providerIds.push(...plugin.contributions.providers);
|
||||
}
|
||||
return sortedValues(providerIds);
|
||||
}
|
||||
|
||||
export async function resolveRuntimePluginDiscoveryProviders(
|
||||
params: ResolveRuntimePluginDiscoveryProvidersParams,
|
||||
): Promise<ProviderPlugin[]> {
|
||||
return (await loadProviderRuntime())
|
||||
.resolvePluginDiscoveryProvidersRuntime(params)
|
||||
.filter((provider) => resolveProviderCatalogOrderHook(provider));
|
||||
}
|
||||
|
||||
export async function resolvePluginDiscoveryProviders(
|
||||
params: ResolveRuntimePluginDiscoveryProvidersParams,
|
||||
): Promise<ProviderPlugin[]> {
|
||||
return resolveRuntimePluginDiscoveryProviders(params);
|
||||
}
|
||||
|
||||
export function groupPluginDiscoveryProvidersByOrder(
|
||||
providers: ProviderPlugin[],
|
||||
): Record<ProviderDiscoveryOrder, ProviderPlugin[]> {
|
||||
|
||||
Reference in New Issue
Block a user