feat(plugins): plan gateway startup from registry

This commit is contained in:
Vincent Koc
2026-04-25 05:37:14 -07:00
parent feb8d3a4bd
commit 674d188153
11 changed files with 175 additions and 81 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Diagnostics/OTEL: align model-call GenAI span attributes with OpenTelemetry stability opt-in semantics, keeping legacy `gen_ai.system` by default while emitting `gen_ai.provider.name` under `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental`. Thanks @vincentkoc.
- Plugins/CLI: add `openclaw plugins registry` for explicit persisted-registry inspection and `--refresh` repair without making normal startup rescan plugin locations. Thanks @vincentkoc.
- Plugins/CLI: make `openclaw plugins list` read the cold persisted registry snapshot by default, leaving module-aware diagnostics to `plugins doctor` and `plugins inspect`. Thanks @vincentkoc.
- Plugins/startup: move gateway startup plugin planning onto the versioned cold registry index, with postinstall repair for older registry files that predate startup metadata. Thanks @vincentkoc.
- Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna.
- Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#71451) Thanks @vincentkoc and @jlapenna.
- Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#71450) Thanks @vincentkoc and @jlapenna.

View File

@@ -502,7 +502,7 @@ export function resetPluginsCliTestState() {
version: 1,
hostContractVersion: "2026.4.25",
compatRegistryVersion: "compat-v1",
migrationVersion: 1,
migrationVersion: 2,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
plugins: [],

View File

@@ -2,7 +2,11 @@ import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginCandidate } from "../../../plugins/discovery.js";
import { readPersistedInstalledPluginIndex } from "../../../plugins/installed-plugin-index-store.js";
import {
readPersistedInstalledPluginIndex,
writePersistedInstalledPluginIndex,
} from "../../../plugins/installed-plugin-index-store.js";
import type { InstalledPluginIndex } from "../../../plugins/installed-plugin-index.js";
import {
cleanupTrackedTempDirs,
makeTrackedTempDir,
@@ -58,12 +62,24 @@ function createCandidate(rootDir: string, id = "demo"): PluginCandidate {
};
}
function createCurrentIndex(): InstalledPluginIndex {
return {
version: 1,
hostContractVersion: "2026.4.25",
compatRegistryVersion: "compat-v1",
migrationVersion: 2,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
plugins: [],
diagnostics: [],
};
}
describe("plugin registry install migration", () => {
it("short-circuits when a registry file already exists", async () => {
it("short-circuits when a current registry file already exists", async () => {
const stateDir = makeTempDir();
const filePath = path.join(stateDir, "plugins", "installed-index.json");
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "{}\n", "utf8");
await writePersistedInstalledPluginIndex(createCurrentIndex(), { stateDir });
const readConfig = vi.fn(async () => ({}));
await expect(
@@ -83,6 +99,34 @@ describe("plugin registry install migration", () => {
expect(readConfig).not.toHaveBeenCalled();
});
it("migrates when an existing registry file is not current", async () => {
const stateDir = makeTempDir();
const filePath = path.join(stateDir, "plugins", "installed-index.json");
const pluginDir = path.join(stateDir, "plugins", "demo");
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ version: 1, migrationVersion: 1 }), "utf8");
await expect(
migratePluginRegistryForInstall({
stateDir,
candidates: [createCandidate(pluginDir)],
readConfig: async () => ({}),
env: hermeticEnv(),
}),
).resolves.toMatchObject({
status: "migrated",
preflight: {
action: "migrate",
},
});
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
migrationVersion: 2,
plugins: [expect.objectContaining({ pluginId: "demo" })],
});
});
it("persists only plugins enabled by the central config policy", async () => {
const stateDir = makeTempDir();
const enabledDir = path.join(stateDir, "plugins", "enabled-demo");
@@ -172,7 +216,7 @@ describe("plugin registry install migration", () => {
migrated: true,
current: {
refreshReason: "migration",
migrationVersion: 1,
migrationVersion: 2,
plugins: [
expect.objectContaining({
pluginId: "demo",

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import {
inspectPersistedInstalledPluginIndex,
readPersistedInstalledPluginIndexSync,
resolveInstalledPluginIndexStorePath,
writePersistedInstalledPluginIndex,
type InstalledPluginIndexStoreInspection,
@@ -75,12 +76,15 @@ export function preflightPluginRegistryInstallMigration(
}
const pathExists = params.existsSync ?? fs.existsSync;
if (!force && pathExists(filePath)) {
return {
action: "skip-existing",
filePath,
force,
deprecationWarnings,
};
const currentRegistry = readPersistedInstalledPluginIndexSync(params);
if (currentRegistry) {
return {
action: "skip-existing",
filePath,
force,
deprecationWarnings,
};
}
}
return {
action: "migrate",

View File

@@ -31,6 +31,14 @@ vi.mock("./manifest-registry.js", async (importOriginal) => {
};
});
vi.mock("./installed-plugin-index-store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./installed-plugin-index-store.js")>();
return {
...actual,
readPersistedInstalledPluginIndexSync: vi.fn(() => null),
};
});
import {
hasConfiguredChannelsForReadOnlyScope,
listConfiguredAnnounceChannelIdsForConfig,

View File

@@ -8,7 +8,6 @@ import {
resolveMemoryDreamingPluginId,
} from "../memory-host-sdk/dreaming.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { resolveManifestActivationPluginIds } from "./activation-planner.js";
import { hasExplicitChannelConfig } from "./channel-presence-policy.js";
import {
createPluginActivationSource,
@@ -16,8 +15,8 @@ import {
normalizePluginsConfig,
resolveEffectivePluginActivationState,
} from "./config-state.js";
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
import { hasKind } from "./slots.js";
import type { InstalledPluginIndexRecord } from "./installed-plugin-index.js";
import { loadPluginRegistrySnapshot } from "./plugin-registry.js";
function listDisabledChannelIds(config: OpenClawConfig): Set<string> {
const channels = config.channels;
@@ -46,30 +45,12 @@ function listPotentialEnabledChannelIds(config: OpenClawConfig, env: NodeJS.Proc
.filter((id) => id && !disabled.has(id));
}
function hasRuntimeContractSurface(plugin: PluginManifestRecord): boolean {
return Boolean(
plugin.providers.length > 0 ||
plugin.cliBackends.length > 0 ||
plugin.contracts?.speechProviders?.length ||
plugin.contracts?.mediaUnderstandingProviders?.length ||
plugin.contracts?.documentExtractors?.length ||
plugin.contracts?.imageGenerationProviders?.length ||
plugin.contracts?.videoGenerationProviders?.length ||
plugin.contracts?.musicGenerationProviders?.length ||
plugin.contracts?.webContentExtractors?.length ||
plugin.contracts?.webFetchProviders?.length ||
plugin.contracts?.webSearchProviders?.length ||
plugin.contracts?.memoryEmbeddingProviders?.length ||
hasKind(plugin.kind, "memory"),
);
function isGatewayStartupMemoryPlugin(plugin: InstalledPluginIndexRecord): boolean {
return plugin.startup.memory;
}
function isGatewayStartupMemoryPlugin(plugin: PluginManifestRecord): boolean {
return hasKind(plugin.kind, "memory");
}
function isGatewayStartupSidecar(plugin: PluginManifestRecord): boolean {
return plugin.channels.length === 0 && !hasRuntimeContractSurface(plugin);
function isGatewayStartupSidecar(plugin: InstalledPluginIndexRecord): boolean {
return plugin.startup.sidecar;
}
function resolveGatewayStartupDreamingPluginIds(config: OpenClawConfig): Set<string> {
@@ -92,7 +73,7 @@ function resolveExplicitMemorySlotStartupPluginId(config: OpenClawConfig): strin
}
function shouldConsiderForGatewayStartup(params: {
plugin: PluginManifestRecord;
plugin: InstalledPluginIndexRecord;
startupDreamingPluginIds: ReadonlySet<string>;
explicitMemorySlotStartupPluginId?: string;
}): boolean {
@@ -102,21 +83,23 @@ function shouldConsiderForGatewayStartup(params: {
if (!isGatewayStartupMemoryPlugin(params.plugin)) {
return false;
}
if (params.startupDreamingPluginIds.has(params.plugin.id)) {
if (params.startupDreamingPluginIds.has(params.plugin.pluginId)) {
return true;
}
return params.explicitMemorySlotStartupPluginId === params.plugin.id;
return params.explicitMemorySlotStartupPluginId === params.plugin.pluginId;
}
function hasConfiguredStartupChannel(params: {
plugin: PluginManifestRecord;
plugin: InstalledPluginIndexRecord;
configuredChannelIds: ReadonlySet<string>;
}): boolean {
return params.plugin.channels.some((channelId) => params.configuredChannelIds.has(channelId));
return params.plugin.contributions.channels.some((channelId) =>
params.configuredChannelIds.has(channelId),
);
}
function canStartConfiguredChannelPlugin(params: {
plugin: PluginManifestRecord;
plugin: InstalledPluginIndexRecord;
config: OpenClawConfig;
pluginsConfig: ReturnType<typeof normalizePluginsConfig>;
activationSource: ReturnType<typeof createPluginActivationSource>;
@@ -124,15 +107,15 @@ function canStartConfiguredChannelPlugin(params: {
if (!params.pluginsConfig.enabled) {
return false;
}
if (params.pluginsConfig.deny.includes(params.plugin.id)) {
if (params.pluginsConfig.deny.includes(params.plugin.pluginId)) {
return false;
}
if (params.pluginsConfig.entries[params.plugin.id]?.enabled === false) {
if (params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false) {
return false;
}
const explicitBundledChannelConfig =
params.plugin.origin === "bundled" &&
params.plugin.channels.some((channelId) =>
params.plugin.contributions.channels.some((channelId) =>
hasExplicitChannelConfig({
config: params.activationSource.rootConfig ?? params.config,
channelId,
@@ -140,7 +123,7 @@ function canStartConfiguredChannelPlugin(params: {
);
if (
params.pluginsConfig.allow.length > 0 &&
!params.pluginsConfig.allow.includes(params.plugin.id) &&
!params.pluginsConfig.allow.includes(params.plugin.pluginId) &&
!explicitBundledChannelConfig
) {
return false;
@@ -149,7 +132,7 @@ function canStartConfiguredChannelPlugin(params: {
return true;
}
const activationState = resolveEffectivePluginActivationState({
id: params.plugin.id,
id: params.plugin.pluginId,
origin: params.plugin.origin,
config: params.pluginsConfig,
rootConfig: params.config,
@@ -164,13 +147,14 @@ export function resolveChannelPluginIds(params: {
workspaceDir?: string;
env: NodeJS.ProcessEnv;
}): string[] {
return loadPluginManifestRegistry({
const index = loadPluginRegistrySnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.plugins.filter((plugin) => plugin.channels.length > 0)
.map((plugin) => plugin.id);
});
return index.plugins
.filter((plugin) => plugin.contributions.channels.length > 0)
.map((plugin) => plugin.pluginId);
}
export function resolveConfiguredDeferredChannelPluginIds(params: {
@@ -186,15 +170,16 @@ export function resolveConfiguredDeferredChannelPluginIds(params: {
const activationSource = createPluginActivationSource({
config: params.config,
});
return loadPluginManifestRegistry({
const index = loadPluginRegistrySnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.plugins.filter(
});
return index.plugins
.filter(
(plugin) =>
hasConfiguredStartupChannel({ plugin, configuredChannelIds }) &&
plugin.startupDeferConfiguredChannelFullLoadUntilAfterListen === true &&
plugin.startup.deferConfiguredChannelFullLoadUntilAfterListen &&
canStartConfiguredChannelPlugin({
plugin,
config: params.config,
@@ -202,7 +187,7 @@ export function resolveConfiguredDeferredChannelPluginIds(params: {
activationSource,
}),
)
.map((plugin) => plugin.id);
.map((plugin) => plugin.pluginId);
}
export function resolveGatewayStartupPluginIds(params: {
@@ -219,33 +204,23 @@ export function resolveGatewayStartupPluginIds(params: {
const activationSource = createPluginActivationSource({
config: params.activationSourceConfig ?? params.config,
});
const requiredAgentHarnessPluginIds = new Set(
const requiredAgentHarnessRuntimes = new Set(
collectConfiguredAgentHarnessRuntimes(
params.activationSourceConfig ?? params.config,
params.env,
).flatMap((runtime) =>
resolveManifestActivationPluginIds({
trigger: {
kind: "agentHarness",
runtime,
},
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
cache: true,
}),
),
);
const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config);
const explicitMemorySlotStartupPluginId = resolveExplicitMemorySlotStartupPluginId(
params.activationSourceConfig ?? params.config,
);
return loadPluginManifestRegistry({
const index = loadPluginRegistrySnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.plugins.filter((plugin) => {
});
return index.plugins
.filter((plugin) => {
if (hasConfiguredStartupChannel({ plugin, configuredChannelIds })) {
return canStartConfiguredChannelPlugin({
plugin,
@@ -254,9 +229,11 @@ export function resolveGatewayStartupPluginIds(params: {
activationSource,
});
}
if (requiredAgentHarnessPluginIds.has(plugin.id)) {
if (
plugin.startup.agentHarnesses.some((runtime) => requiredAgentHarnessRuntimes.has(runtime))
) {
const activationState = resolveEffectivePluginActivationState({
id: plugin.id,
id: plugin.pluginId,
origin: plugin.origin,
config: pluginsConfig,
rootConfig: params.config,
@@ -275,7 +252,7 @@ export function resolveGatewayStartupPluginIds(params: {
return false;
}
const activationState = resolveEffectivePluginActivationState({
id: plugin.id,
id: plugin.pluginId,
origin: plugin.origin,
config: pluginsConfig,
rootConfig: params.config,
@@ -290,5 +267,5 @@ export function resolveGatewayStartupPluginIds(params: {
}
return activationState.source === "explicit" || activationState.source === "default";
})
.map((plugin) => plugin.id);
.map((plugin) => plugin.pluginId);
}

View File

@@ -27,7 +27,7 @@ function createIndex(overrides: Partial<InstalledPluginIndex> = {}): InstalledPl
version: 1,
hostContractVersion: "2026.4.25",
compatRegistryVersion: "compat-v1",
migrationVersion: 1,
migrationVersion: 2,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
plugins: [
@@ -48,6 +48,12 @@ function createIndex(overrides: Partial<InstalledPluginIndex> = {}): InstalledPl
commandAliases: [],
contracts: [],
},
startup: {
sidecar: false,
memory: false,
deferConfiguredChannelFullLoadUntilAfterListen: false,
agentHarnesses: [],
},
compat: [],
},
],

View File

@@ -47,6 +47,15 @@ const InstalledPluginIndexContributionsSchema = z
})
.passthrough();
const InstalledPluginIndexStartupSchema = z
.object({
sidecar: z.boolean(),
memory: z.boolean(),
deferConfiguredChannelFullLoadUntilAfterListen: z.boolean(),
agentHarnesses: ContributionArraySchema,
})
.passthrough();
const InstalledPluginIndexRecordSchema = z
.object({
pluginId: z.string(),
@@ -68,6 +77,7 @@ const InstalledPluginIndexRecordSchema = z
enabled: z.boolean(),
enabledByDefault: z.boolean().optional(),
contributions: InstalledPluginIndexContributionsSchema,
startup: InstalledPluginIndexStartupSchema,
compat: z.array(z.string()),
})
.passthrough();

View File

@@ -154,7 +154,7 @@ describe("installed plugin index", () => {
expect(index).toMatchObject({
version: 1,
migrationVersion: 1,
migrationVersion: 2,
generatedAtMs: 1777118400000,
plugins: [
{
@@ -679,7 +679,7 @@ describe("installed plugin index", () => {
env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.26" }),
}),
compatRegistryVersion: "different-compat-registry",
migrationVersion: 2 as 1,
migrationVersion: 3 as 2,
};
expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([

View File

@@ -18,9 +18,10 @@ import {
type PluginManifestRegistry,
} from "./manifest-registry.js";
import type { PluginDiagnostic } from "./manifest-types.js";
import { hasKind } from "./slots.js";
export const INSTALLED_PLUGIN_INDEX_VERSION = 1;
export const INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION = 1;
export const INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION = 2;
export type InstalledPluginIndexRefreshReason =
| "missing"
@@ -44,6 +45,13 @@ export type InstalledPluginIndexContributions = {
contracts: readonly string[];
};
export type InstalledPluginStartupInfo = {
sidecar: boolean;
memory: boolean;
deferConfiguredChannelFullLoadUntilAfterListen: boolean;
agentHarnesses: readonly string[];
};
export type InstalledPluginInstallRecordInfo = Pick<
PluginInstallRecord,
| "source"
@@ -95,6 +103,7 @@ export type InstalledPluginIndexRecord = {
enabled: boolean;
enabledByDefault?: boolean;
contributions: InstalledPluginIndexContributions;
startup: InstalledPluginStartupInfo;
compat: readonly PluginCompatCode[];
};
@@ -199,6 +208,34 @@ function collectContractKeys(record: PluginManifestRecord): readonly string[] {
);
}
function hasRuntimeContractSurface(record: PluginManifestRecord): boolean {
return Boolean(
record.providers.length > 0 ||
record.cliBackends.length > 0 ||
record.contracts?.speechProviders?.length ||
record.contracts?.mediaUnderstandingProviders?.length ||
record.contracts?.documentExtractors?.length ||
record.contracts?.imageGenerationProviders?.length ||
record.contracts?.videoGenerationProviders?.length ||
record.contracts?.musicGenerationProviders?.length ||
record.contracts?.webContentExtractors?.length ||
record.contracts?.webFetchProviders?.length ||
record.contracts?.webSearchProviders?.length ||
record.contracts?.memoryEmbeddingProviders?.length ||
hasKind(record.kind, "memory"),
);
}
function buildStartupInfo(record: PluginManifestRecord): InstalledPluginStartupInfo {
return {
sidecar: record.channels.length === 0 && !hasRuntimeContractSurface(record),
memory: hasKind(record.kind, "memory"),
deferConfiguredChannelFullLoadUntilAfterListen:
record.startupDeferConfiguredChannelFullLoadUntilAfterListen === true,
agentHarnesses: sortUnique(record.activation?.onAgentHarnesses ?? []),
};
}
function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompatCode[] {
const codes: PluginCompatCode[] = [];
if (record.providerAuthEnvVars && Object.keys(record.providerAuthEnvVars).length > 0) {
@@ -463,6 +500,7 @@ function buildInstalledPluginIndex(
origin: record.origin,
enabled,
contributions: buildContributions(record),
startup: buildStartupInfo(record),
compat: collectCompatCodes(record),
};
if (record.enabledByDefault === true) {

View File

@@ -94,7 +94,7 @@ function createIndex(pluginId = "demo"): InstalledPluginIndex {
version: 1,
hostContractVersion: "2026.4.25",
compatRegistryVersion: "compat-v1",
migrationVersion: 1,
migrationVersion: 2,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
plugins: [
@@ -115,6 +115,12 @@ function createIndex(pluginId = "demo"): InstalledPluginIndex {
commandAliases: [],
contracts: [],
},
startup: {
sidecar: false,
memory: false,
deferConfiguredChannelFullLoadUntilAfterListen: false,
agentHarnesses: [],
},
compat: [],
},
],