Compare commits

...

7 Commits

Author SHA1 Message Date
Vincent Koc
58e75bbff2 test(plugins): cover install ledger reload indexing 2026-04-25 00:21:36 -07:00
Vincent Koc
2ebe4e7675 test(plugins): lock installed index source contract 2026-04-25 00:08:31 -07:00
Vincent Koc
c6f1d27733 fix(plugins): index install ledger source facts 2026-04-25 00:03:49 -07:00
Vincent Koc
9ecdb94e9d feat(channels): read setup discovery from installed index 2026-04-25 00:03:49 -07:00
Vincent Koc
ea9824d5d2 feat(models): read provider owners from installed index 2026-04-25 00:03:49 -07:00
Vincent Koc
5399058c7f feat(plugins): split cold provider contributions 2026-04-25 00:03:48 -07:00
Vincent Koc
3a4078cc40 feat(plugins): add installed plugin index 2026-04-25 00:03:48 -07:00
8 changed files with 1325 additions and 13 deletions

View File

@@ -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" },

View File

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

View File

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

View File

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

View 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");
});
});

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

View File

@@ -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([
{

View File

@@ -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[]> {