refactor(plugins): prefer setup descriptors for setup lookup (#64786)

* refactor(plugins): prefer setup descriptors for setup lookup

* fix(plugins): harden setup descriptor lookup

* fix(plugins): keep sync cli backend setup results

* fix(plugins): resolve setup registry rebase

* fix(plugins): preserve fail-closed cli backend lookup

* fix(plugins): fail closed on shadowed setup owners

* fix(plugins): swallow async setup register rejections
This commit is contained in:
Vincent Koc
2026-04-12 03:29:24 +01:00
committed by GitHub
parent d00ab06048
commit 8ba50aa23e
7 changed files with 824 additions and 238 deletions

View File

@@ -528,6 +528,13 @@ Optional manifest `activation` and `setup` blocks stay on the control plane.
They are metadata-only descriptors for activation planning and setup discovery;
they do not replace runtime registration, `register(...)`, or `setupEntry`.
Setup discovery now prefers descriptor-owned ids such as `setup.providers` and
`setup.cliBackends` to narrow candidate plugins before it falls back to
`setup-api` for plugins that still need setup-time runtime hooks. If more than
one discovered plugin claims the same normalized setup provider or CLI backend
id, setup lookup refuses the ambiguous owner instead of relying on discovery
order.
### What the loader caches
OpenClaw keeps short in-process caches for:

View File

@@ -268,22 +268,33 @@ Top-level `cliBackends` stays valid and continues to describe CLI inference
backends. `setup.cliBackends` is the setup-specific descriptor surface for
control-plane/setup flows that should stay metadata-only.
When present, `setup.providers` and `setup.cliBackends` are the preferred
descriptor-first lookup surface for setup discovery. If the descriptor only
narrows the candidate plugin and setup still needs richer setup-time runtime
hooks, set `requiresRuntime: true` and keep `setup-api` in place as the
fallback execution path.
Because setup lookup can execute plugin-owned `setup-api` code, normalized
`setup.providers[].id` and `setup.cliBackends[]` values must stay unique across
discovered plugins. Ambiguous ownership fails closed instead of picking a
winner from discovery order.
### setup.providers reference
| Field | Required | Type | What it means |
| ------------- | -------- | ---------- | ---------------------------------------------------------------------------------- |
| `id` | Yes | `string` | Provider id exposed during setup or onboarding. |
| `authMethods` | No | `string[]` | Setup/auth method ids this provider supports without loading full runtime. |
| `envVars` | No | `string[]` | Env vars that generic setup/status surfaces can check before plugin runtime loads. |
| Field | Required | Type | What it means |
| ------------- | -------- | ---------- | ------------------------------------------------------------------------------------ |
| `id` | Yes | `string` | Provider id exposed during setup or onboarding. Keep normalized ids globally unique. |
| `authMethods` | No | `string[]` | Setup/auth method ids this provider supports without loading full runtime. |
| `envVars` | No | `string[]` | Env vars that generic setup/status surfaces can check before plugin runtime loads. |
### setup fields
| Field | Required | Type | What it means |
| ------------------ | -------- | ---------- | --------------------------------------------------------------------------- |
| `providers` | No | `object[]` | Provider setup descriptors exposed during setup and onboarding. |
| `cliBackends` | No | `string[]` | Setup-time backend ids available without full runtime activation. |
| `configMigrations` | No | `string[]` | Config migration ids owned by this plugin's setup surface. |
| `requiresRuntime` | No | `boolean` | Whether setup still needs plugin runtime execution after descriptor lookup. |
| Field | Required | Type | What it means |
| ------------------ | -------- | ---------- | --------------------------------------------------------------------------------------------------- |
| `providers` | No | `object[]` | Provider setup descriptors exposed during setup and onboarding. |
| `cliBackends` | No | `string[]` | Setup-time backend ids used for descriptor-first setup lookup. Keep normalized ids globally unique. |
| `configMigrations` | No | `string[]` | Config migration ids owned by this plugin's setup surface. |
| `requiresRuntime` | No | `boolean` | Whether setup still needs `setup-api` execution after descriptor lookup. |
## uiHints reference

View File

@@ -0,0 +1,11 @@
import type { PluginManifestRecord } from "./manifest-registry.js";
type SetupDescriptorRecord = Pick<PluginManifestRecord, "providers" | "cliBackends" | "setup">;
export function listSetupProviderIds(record: SetupDescriptorRecord): readonly string[] {
return record.setup?.providers?.map((entry) => entry.id) ?? record.providers;
}
export function listSetupCliBackendIds(record: SetupDescriptorRecord): readonly string[] {
return record.setup?.cliBackends ?? record.cliBackends;
}

View File

@@ -11,14 +11,18 @@ afterEach(() => {
});
describe("setup-registry runtime fallback", () => {
it("uses bundled manifest cliBackends when the runtime registry has no match", async () => {
it("uses bundled manifest cliBackends when the setup-registry runtime is unavailable", async () => {
loadPluginManifestRegistryMock.mockReturnValue({
diagnostics: [],
plugins: [
{
id: "openai",
origin: "bundled",
cliBackends: ["Codex-CLI"],
cliBackends: ["legacy-openai-cli"],
setup: {
cliBackends: ["Codex-CLI"],
requiresRuntime: true,
},
},
{
id: "local",
@@ -31,9 +35,7 @@ describe("setup-registry runtime fallback", () => {
const { __testing, resolvePluginSetupCliBackendRuntime } =
await import("./setup-registry.runtime.js");
__testing.resetRuntimeState();
__testing.setRuntimeModuleForTest({
resolvePluginSetupCliBackend: () => undefined,
});
__testing.setRuntimeModuleForTest(null);
expect(resolvePluginSetupCliBackendRuntime({ backend: "codex-cli" })).toEqual({
pluginId: "openai",
@@ -43,4 +45,31 @@ describe("setup-registry runtime fallback", () => {
expect(loadPluginManifestRegistryMock).toHaveBeenCalledTimes(1);
expect(loadPluginManifestRegistryMock).toHaveBeenCalledWith({ cache: true });
});
it("preserves fail-closed setup lookup when the runtime module explicitly declines to resolve", async () => {
loadPluginManifestRegistryMock.mockReturnValue({
diagnostics: [],
plugins: [
{
id: "openai",
origin: "bundled",
cliBackends: ["legacy-openai-cli"],
setup: {
cliBackends: ["Codex-CLI"],
requiresRuntime: true,
},
},
],
});
const { __testing, resolvePluginSetupCliBackendRuntime } =
await import("./setup-registry.runtime.js");
__testing.resetRuntimeState();
__testing.setRuntimeModuleForTest({
resolvePluginSetupCliBackend: () => undefined,
});
expect(resolvePluginSetupCliBackendRuntime({ backend: "codex-cli" })).toBeUndefined();
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,7 @@
import { createRequire } from "node:module";
import { normalizeProviderId } from "../agents/provider-id.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { listSetupCliBackendIds } from "./setup-descriptors.js";
type SetupRegistryRuntimeModule = Pick<
typeof import("./setup-registry.js"),
@@ -17,7 +18,7 @@ type SetupCliBackendRuntimeEntry = {
const require = createRequire(import.meta.url);
const SETUP_REGISTRY_RUNTIME_CANDIDATES = ["./setup-registry.js", "./setup-registry.ts"] as const;
let setupRegistryRuntimeModule: SetupRegistryRuntimeModule | undefined;
let setupRegistryRuntimeModule: SetupRegistryRuntimeModule | null | undefined;
let bundledSetupCliBackendsCache: SetupCliBackendRuntimeEntry[] | undefined;
export const __testing = {
@@ -25,7 +26,7 @@ export const __testing = {
setupRegistryRuntimeModule = undefined;
bundledSetupCliBackendsCache = undefined;
},
setRuntimeModuleForTest(module: SetupRegistryRuntimeModule | undefined): void {
setRuntimeModuleForTest(module: SetupRegistryRuntimeModule | null | undefined): void {
setupRegistryRuntimeModule = module;
},
};
@@ -34,22 +35,29 @@ function resolveBundledSetupCliBackends(): SetupCliBackendRuntimeEntry[] {
if (bundledSetupCliBackendsCache) {
return bundledSetupCliBackendsCache;
}
bundledSetupCliBackendsCache = loadPluginManifestRegistry({ cache: true })
.plugins.filter((plugin) => plugin.origin === "bundled" && plugin.cliBackends.length > 0)
.flatMap((plugin) =>
plugin.cliBackends.map(
bundledSetupCliBackendsCache = loadPluginManifestRegistry({ cache: true }).plugins.flatMap(
(plugin) => {
if (plugin.origin !== "bundled") {
return [];
}
const backendIds = listSetupCliBackendIds(plugin);
if (backendIds.length === 0) {
return [];
}
return backendIds.map(
(backendId) =>
({
pluginId: plugin.id,
backend: { id: backendId },
}) satisfies SetupCliBackendRuntimeEntry,
),
);
);
},
);
return bundledSetupCliBackendsCache;
}
function loadSetupRegistryRuntime(): SetupRegistryRuntimeModule | null {
if (setupRegistryRuntimeModule) {
if (setupRegistryRuntimeModule !== undefined) {
return setupRegistryRuntimeModule;
}
for (const candidate of SETUP_REGISTRY_RUNTIME_CANDIDATES) {
@@ -66,11 +74,8 @@ function loadSetupRegistryRuntime(): SetupRegistryRuntimeModule | null {
export function resolvePluginSetupCliBackendRuntime(params: { backend: string }) {
const normalized = normalizeProviderId(params.backend);
const runtime = loadSetupRegistryRuntime();
if (runtime) {
const resolved = runtime.resolvePluginSetupCliBackend(params);
if (resolved) {
return resolved;
}
if (runtime !== null) {
return runtime.resolvePluginSetupCliBackend(params);
}
return resolveBundledSetupCliBackends().find(
(entry) => normalizeProviderId(entry.backend.id) === normalized,

View File

@@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
import {
getRegistryJitiMocks,
@@ -11,25 +11,48 @@ const tempDirs: string[] = [];
const mocks = getRegistryJitiMocks();
let clearPluginSetupRegistryCache: typeof import("./setup-registry.js").clearPluginSetupRegistryCache;
let setupRegistryTesting: typeof import("./setup-registry.js").__testing;
let resolvePluginSetupRegistry: typeof import("./setup-registry.js").resolvePluginSetupRegistry;
let resolvePluginSetupProvider: typeof import("./setup-registry.js").resolvePluginSetupProvider;
let resolvePluginSetupCliBackend: typeof import("./setup-registry.js").resolvePluginSetupCliBackend;
let runPluginSetupConfigMigrations: typeof import("./setup-registry.js").runPluginSetupConfigMigrations;
function makeTempDir(): string {
return makeTrackedTempDir("openclaw-setup-registry", tempDirs);
}
async function expectNoUnhandledRejection(run: () => void | Promise<void>): Promise<void> {
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
try {
await run();
await Promise.resolve();
await Promise.resolve();
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
expect(unhandledRejections).toEqual([]);
}
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
describe("setup-registry getJiti", () => {
beforeAll(async () => {
({ clearPluginSetupRegistryCache, resolvePluginSetupRegistry, runPluginSetupConfigMigrations } =
await import("./setup-registry.js"));
});
beforeEach(() => {
beforeEach(async () => {
resetRegistryJitiMocks();
vi.resetModules();
({
__testing: setupRegistryTesting,
clearPluginSetupRegistryCache,
resolvePluginSetupRegistry,
resolvePluginSetupProvider,
resolvePluginSetupCliBackend,
runPluginSetupConfigMigrations,
} = await import("./setup-registry.js"));
clearPluginSetupRegistryCache();
});
@@ -195,4 +218,445 @@ describe("setup-registry getJiti", () => {
expect(result.changes).toEqual(["voice-call"]);
expect(mocks.createJiti).toHaveBeenCalledTimes(1);
});
it("prefers setup provider descriptors over top-level provider ids", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "amazon-bedrock",
rootDir: pluginRoot,
providers: ["legacy-bedrock"],
setup: {
providers: [{ id: "amazon-bedrock" }],
requiresRuntime: true,
},
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerProvider: (provider: { id: string; label: string; auth: [] }) => void;
}) {
api.registerProvider({
id: "amazon-bedrock",
label: "Amazon Bedrock",
auth: [],
});
},
},
});
});
expect(resolvePluginSetupProvider({ provider: "amazon-bedrock", env: {} })).toEqual(
expect.objectContaining({
id: "amazon-bedrock",
label: "Amazon Bedrock",
}),
);
expect(resolvePluginSetupProvider({ provider: "legacy-bedrock", env: {} })).toBeUndefined();
expect(mocks.createJiti).toHaveBeenCalledTimes(1);
expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(pluginRoot, "setup-api.js"));
});
it("resolves setup cli backends from descriptors without loading every setup-api", () => {
const openaiRoot = makeTempDir();
const anthropicRoot = makeTempDir();
fs.writeFileSync(path.join(openaiRoot, "setup-api.js"), "export default {};\n", "utf-8");
fs.writeFileSync(path.join(anthropicRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
rootDir: openaiRoot,
cliBackends: ["legacy-openai-cli"],
setup: {
cliBackends: ["codex-cli"],
requiresRuntime: true,
},
},
{
id: "anthropic",
rootDir: anthropicRoot,
cliBackends: ["claude-cli"],
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation((modulePath: string) => {
return () => ({
default: {
register(api: {
registerCliBackend: (backend: { id: string; config: { command: string } }) => void;
}) {
api.registerCliBackend(
modulePath.includes(openaiRoot)
? { id: "codex-cli", config: { command: "codex" } }
: { id: "claude-cli", config: { command: "claude" } },
);
},
},
});
});
const first = resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} });
const second = resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} });
expect(first).toEqual({
pluginId: "openai",
backend: {
id: "codex-cli",
config: {
command: "codex",
},
},
});
expect(second).toEqual(first);
expect(resolvePluginSetupCliBackend({ backend: "legacy-openai-cli", env: {} })).toBeUndefined();
expect(mocks.createJiti).toHaveBeenCalledTimes(1);
expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(openaiRoot, "setup-api.js"));
});
it("keeps synchronously registered cli backends even when register returns a promise", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
rootDir: pluginRoot,
setup: {
cliBackends: ["codex-cli"],
requiresRuntime: true,
},
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerCliBackend: (backend: { id: string; config: { command: string } }) => void;
}) {
api.registerCliBackend({
id: "codex-cli",
config: { command: "codex" },
});
return Promise.resolve();
},
},
});
});
expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })).toEqual({
pluginId: "openai",
backend: {
id: "codex-cli",
config: {
command: "codex",
},
},
});
});
it("swallows rejected async setup provider registration returns", async () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
rootDir: pluginRoot,
setup: {
providers: [{ id: "openai" }],
},
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerProvider: (provider: { id: string; label: string; auth: [] }) => void;
}) {
api.registerProvider({
id: "openai",
label: "OpenAI",
auth: [],
});
return Promise.reject(new Error("async provider register failed"));
},
},
});
});
await expectNoUnhandledRejection(() => {
expect(resolvePluginSetupProvider({ provider: "openai", env: {} })).toEqual(
expect.objectContaining({
id: "openai",
label: "OpenAI",
}),
);
});
});
it("swallows rejected async setup cli backend registration returns", async () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
rootDir: pluginRoot,
setup: {
cliBackends: ["codex-cli"],
},
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerCliBackend: (backend: { id: string; config: { command: string } }) => void;
}) {
api.registerCliBackend({
id: "codex-cli",
config: { command: "codex" },
});
return Promise.reject(new Error("async cli backend register failed"));
},
},
});
});
await expectNoUnhandledRejection(() => {
expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })).toEqual({
pluginId: "openai",
backend: {
id: "codex-cli",
config: {
command: "codex",
},
},
});
});
});
it("swallows rejected async setup registry registration returns", async () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "voice-call", rootDir: pluginRoot }],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerConfigMigration: (migrate: (config: unknown) => unknown) => void;
}) {
api.registerConfigMigration((config) => ({ config, changes: ["voice-call"] }));
return Promise.reject(new Error("async setup registry register failed"));
},
},
});
});
await expectNoUnhandledRejection(() => {
expect(resolvePluginSetupRegistry({ env: {} }).configMigrations).toHaveLength(1);
});
});
it("fails closed when multiple plugins claim the same setup provider id", () => {
const bundledRoot = makeTempDir();
const workspaceRoot = makeTempDir();
fs.writeFileSync(path.join(bundledRoot, "setup-api.js"), "export default {};\n", "utf-8");
fs.writeFileSync(path.join(workspaceRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
rootDir: bundledRoot,
setup: {
providers: [{ id: "openai" }],
},
},
{
id: "workspace-shadow",
origin: "workspace",
rootDir: workspaceRoot,
setup: {
providers: [{ id: "OpenAI" }],
},
},
],
diagnostics: [],
});
expect(resolvePluginSetupProvider({ provider: "openai", env: {} })).toBeUndefined();
expect(mocks.createJiti).not.toHaveBeenCalled();
});
it("fails closed when duplicate plugin ids shadow the same setup provider id", () => {
const bundledRoot = makeTempDir();
const workspaceRoot = makeTempDir();
fs.writeFileSync(path.join(bundledRoot, "setup-api.js"), "export default {};\n", "utf-8");
fs.writeFileSync(path.join(workspaceRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
rootDir: bundledRoot,
setup: {
providers: [{ id: "openai" }],
},
},
{
id: "openai",
origin: "workspace",
rootDir: workspaceRoot,
setup: {
providers: [{ id: "OpenAI" }],
},
},
],
diagnostics: [],
});
expect(resolvePluginSetupProvider({ provider: "openai", env: {} })).toBeUndefined();
expect(mocks.createJiti).not.toHaveBeenCalled();
});
it("fails closed when multiple plugins claim the same setup cli backend id", () => {
const bundledRoot = makeTempDir();
const workspaceRoot = makeTempDir();
fs.writeFileSync(path.join(bundledRoot, "setup-api.js"), "export default {};\n", "utf-8");
fs.writeFileSync(path.join(workspaceRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
rootDir: bundledRoot,
setup: {
cliBackends: ["codex-cli"],
},
},
{
id: "workspace-shadow",
origin: "workspace",
rootDir: workspaceRoot,
setup: {
cliBackends: ["CODEX-CLI"],
},
},
],
diagnostics: [],
});
expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })).toBeUndefined();
expect(mocks.createJiti).not.toHaveBeenCalled();
});
it("fails closed when duplicate plugin ids shadow the same setup cli backend id", () => {
const bundledRoot = makeTempDir();
const workspaceRoot = makeTempDir();
fs.writeFileSync(path.join(bundledRoot, "setup-api.js"), "export default {};\n", "utf-8");
fs.writeFileSync(path.join(workspaceRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
rootDir: bundledRoot,
setup: {
cliBackends: ["codex-cli"],
},
},
{
id: "openai",
origin: "workspace",
rootDir: workspaceRoot,
setup: {
cliBackends: ["CODEX-CLI"],
},
},
],
diagnostics: [],
});
expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })).toBeUndefined();
expect(mocks.createJiti).not.toHaveBeenCalled();
});
it("bounds setup lookup caches with least-recently-used eviction", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
setupRegistryTesting.setMaxSetupLookupCacheEntriesForTest(1);
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
rootDir: pluginRoot,
setup: {
providers: [{ id: "openai" }, { id: "anthropic" }],
cliBackends: ["codex-cli", "claude-cli"],
requiresRuntime: true,
},
},
],
diagnostics: [],
});
const loadSetupModule = vi.fn(() => ({
default: {
register(api: {
registerProvider: (provider: { id: string; label: string; auth: [] }) => void;
registerCliBackend: (backend: { id: string; config: { command: string } }) => void;
}) {
api.registerProvider({ id: "openai", label: "OpenAI", auth: [] });
api.registerProvider({ id: "anthropic", label: "Anthropic", auth: [] });
api.registerCliBackend({ id: "codex-cli", config: { command: "codex" } });
api.registerCliBackend({ id: "claude-cli", config: { command: "claude" } });
},
},
}));
mocks.createJiti.mockImplementation(() => loadSetupModule);
expect(resolvePluginSetupProvider({ provider: "openai", env: {} })?.id).toBe("openai");
expect(resolvePluginSetupProvider({ provider: "anthropic", env: {} })?.id).toBe("anthropic");
expect(setupRegistryTesting.getCacheSizes().setupProvider).toBe(1);
expect(resolvePluginSetupProvider({ provider: "openai", env: {} })?.id).toBe("openai");
expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })?.backend.id).toBe(
"codex-cli",
);
expect(resolvePluginSetupCliBackend({ backend: "claude-cli", env: {} })?.backend.id).toBe(
"claude-cli",
);
expect(setupRegistryTesting.getCacheSizes().setupCliBackend).toBe(1);
expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })?.backend.id).toBe(
"codex-cli",
);
resolvePluginSetupRegistry({
env: {},
pluginIds: ["openai"],
});
resolvePluginSetupRegistry({
env: {},
pluginIds: ["anthropic"],
});
expect(setupRegistryTesting.getCacheSizes().setupRegistry).toBe(1);
expect(loadSetupModule).toHaveBeenCalledTimes(7);
});
});

View File

@@ -3,20 +3,22 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { CliBackendPlugin } from "./cli-backend.types.js";
import { buildPluginApi } from "./api-builder.js";
import { collectPluginConfigContractMatches } from "./config-contracts.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
import { resolvePluginCacheInputs } from "./roots.js";
import type { PluginRuntime } from "./runtime/types.js";
import { listSetupCliBackendIds, listSetupProviderIds } from "./setup-descriptors.js";
import type {
SetupOnlyPluginApi,
SetupOnlyPluginModule,
SetupPluginAutoEnableProbe,
SetupPluginConfigMigration,
SetupPluginLogger,
SetupProviderPlugin,
} from "./setup-registry.types.js";
CliBackendPlugin,
OpenClawPluginModule,
PluginConfigMigration,
PluginLogger,
PluginSetupAutoEnableProbe,
ProviderPlugin,
} from "./types.js";
const SETUP_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const;
const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url);
@@ -26,7 +28,7 @@ const RUNNING_FROM_BUILT_ARTIFACT =
type SetupProviderEntry = {
pluginId: string;
provider: SetupProviderPlugin;
provider: ProviderPlugin;
};
type SetupCliBackendEntry = {
@@ -36,12 +38,12 @@ type SetupCliBackendEntry = {
type SetupConfigMigrationEntry = {
pluginId: string;
migrate: SetupPluginConfigMigration;
migrate: PluginConfigMigration;
};
type SetupAutoEnableProbeEntry = {
pluginId: string;
probe: SetupPluginAutoEnableProbe;
probe: PluginSetupAutoEnableProbe;
};
type PluginSetupRegistry = {
@@ -56,21 +58,45 @@ type SetupAutoEnableReason = {
reason: string;
};
const EMPTY_SETUP_RUNTIME = {};
const NOOP_LOGGER: SetupPluginLogger = {
const EMPTY_RUNTIME = {} as PluginRuntime;
const NOOP_LOGGER: PluginLogger = {
info() {},
warn() {},
error() {},
};
const MAX_SETUP_LOOKUP_CACHE_ENTRIES = 128;
const jitiLoaders: PluginJitiLoaderCache = new Map();
const setupRegistryCache = new Map<string, PluginSetupRegistry>();
const setupProviderCache = new Map<string, SetupProviderPlugin | null>();
const setupProviderCache = new Map<string, ProviderPlugin | null>();
const setupCliBackendCache = new Map<string, SetupCliBackendEntry | null>();
let setupLookupCacheEntryCap = MAX_SETUP_LOOKUP_CACHE_ENTRIES;
export const __testing = {
get maxSetupLookupCacheEntries() {
return setupLookupCacheEntryCap;
},
setMaxSetupLookupCacheEntriesForTest(value?: number) {
setupLookupCacheEntryCap =
typeof value === "number" && Number.isFinite(value) && value > 0
? Math.max(1, Math.floor(value))
: MAX_SETUP_LOOKUP_CACHE_ENTRIES;
},
getCacheSizes() {
return {
setupRegistry: setupRegistryCache.size,
setupProvider: setupProviderCache.size,
setupCliBackend: setupCliBackendCache.size,
};
},
} as const;
export function clearPluginSetupRegistryCache(): void {
jitiLoaders.clear();
setupRegistryCache.clear();
setupProviderCache.clear();
setupCliBackendCache.clear();
}
function getJiti(modulePath: string) {
@@ -81,6 +107,33 @@ function getJiti(modulePath: string) {
});
}
function getCachedSetupValue<T>(
cache: Map<string, T>,
key: string,
): { hit: true; value: T } | { hit: false } {
if (!cache.has(key)) {
return { hit: false };
}
const cached = cache.get(key) as T;
cache.delete(key);
cache.set(key, cached);
return { hit: true, value: cached };
}
function setCachedSetupValue<T>(cache: Map<string, T>, key: string, value: T): void {
if (cache.has(key)) {
cache.delete(key);
}
cache.set(key, value);
while (cache.size > setupLookupCacheEntryCap) {
const oldestKey = cache.keys().next().value;
if (typeof oldestKey !== "string") {
break;
}
cache.delete(oldestKey);
}
}
function buildSetupRegistryCacheKey(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
@@ -108,6 +161,17 @@ function buildSetupProviderCacheKey(params: {
});
}
function buildSetupCliBackendCacheKey(params: {
backend: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): string {
return JSON.stringify({
backend: normalizeProviderId(params.backend),
registry: buildSetupRegistryCacheKey(params),
});
}
function resolveSetupApiPath(rootDir: string): string | null {
const orderedExtensions = RUNNING_FROM_BUILT_ARTIFACT
? SETUP_API_EXTENSIONS
@@ -189,9 +253,9 @@ function resolveRelevantSetupMigrationPluginIds(params: {
return [...ids].toSorted();
}
function resolveRegister(mod: SetupOnlyPluginModule): {
function resolveRegister(mod: OpenClawPluginModule): {
definition?: { id?: string };
register?: (api: SetupOnlyPluginApi) => void | Promise<void>;
register?: (api: ReturnType<typeof buildPluginApi>) => void | Promise<void>;
} {
if (typeof mod === "function") {
return { register: mod };
@@ -205,7 +269,16 @@ function resolveRegister(mod: SetupOnlyPluginModule): {
return {};
}
function matchesProvider(provider: SetupProviderPlugin, providerId: string): boolean {
function ignoreAsyncSetupRegisterResult(result: void | Promise<void>): void {
if (!result || typeof result.then !== "function") {
return;
}
// Setup-only registration is sync-only. Swallow async rejections so they do
// not trip the global unhandledRejection fatal path.
void Promise.resolve(result).catch(() => undefined);
}
function matchesProvider(provider: ProviderPlugin, providerId: string): boolean {
const normalized = normalizeProviderId(providerId);
if (normalizeProviderId(provider.id) === normalized) {
return true;
@@ -215,71 +288,36 @@ function matchesProvider(provider: SetupProviderPlugin, providerId: string): boo
);
}
function createSetupOnlyPluginApi(params: {
id: string;
name: string;
version?: string;
description?: string;
source: string;
rootDir?: string;
config?: OpenClawConfig;
registerProvider?: (provider: SetupProviderPlugin) => void;
registerCliBackend?: (backend: CliBackendPlugin) => void;
registerConfigMigration?: (migrate: SetupPluginConfigMigration) => void;
registerAutoEnableProbe?: (probe: SetupPluginAutoEnableProbe) => void;
}): SetupOnlyPluginApi {
const noop = (..._args: unknown[]) => {};
return {
id: params.id,
name: params.name,
version: params.version,
description: params.description,
source: params.source,
rootDir: params.rootDir,
registrationMode: "setup-only",
config: params.config ?? ({} as OpenClawConfig),
runtime: EMPTY_SETUP_RUNTIME,
logger: NOOP_LOGGER,
resolvePath: (input) => input,
registerProvider: params.registerProvider ?? noop,
registerCliBackend: params.registerCliBackend ?? noop,
registerConfigMigration: params.registerConfigMigration ?? noop,
registerAutoEnableProbe: params.registerAutoEnableProbe ?? noop,
registerTool: noop,
registerHook: noop,
registerHttpRoute: noop,
registerChannel: noop,
registerGatewayMethod: noop,
registerCli: noop,
registerReload: noop,
registerNodeHostCommand: noop,
registerSecurityAuditCollector: noop,
registerService: noop,
registerTextTransforms: noop,
registerSpeechProvider: noop,
registerRealtimeTranscriptionProvider: noop,
registerRealtimeVoiceProvider: noop,
registerMediaUnderstandingProvider: noop,
registerImageGenerationProvider: noop,
registerVideoGenerationProvider: noop,
registerMusicGenerationProvider: noop,
registerWebFetchProvider: noop,
registerWebSearchProvider: noop,
registerInteractiveHandler: noop,
onConversationBindingResolved: noop,
registerCommand: noop,
registerContextEngine: noop,
registerCompactionProvider: noop,
registerAgentHarness: noop,
registerMemoryCapability: noop,
registerMemoryPromptSection: noop,
registerMemoryPromptSupplement: noop,
registerMemoryCorpusSupplement: noop,
registerMemoryFlushPlan: noop,
registerMemoryRuntime: noop,
registerMemoryEmbeddingProvider: noop,
on: noop,
};
function loadSetupManifestRegistry(params?: { workspaceDir?: string; env?: NodeJS.ProcessEnv }) {
const env = params?.env ?? process.env;
const discovery = discoverOpenClawPlugins({
workspaceDir: params?.workspaceDir,
env,
cache: true,
});
return loadPluginManifestRegistry({
workspaceDir: params?.workspaceDir,
env,
cache: true,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
}
function findUniqueSetupManifestOwner(params: {
registry: ReturnType<typeof loadSetupManifestRegistry>;
normalizedId: string;
listIds: (record: PluginManifestRecord) => readonly string[];
}): PluginManifestRecord | undefined {
const matches = params.registry.plugins.filter((entry) =>
params.listIds(entry).some((id) => normalizeProviderId(id) === params.normalizedId),
);
if (matches.length === 0) {
return undefined;
}
// Setup lookup can execute plugin code. Refuse ambiguous ownership instead of
// depending on manifest ordering across bundled/workspace/global sources.
return matches.length === 1 ? matches[0] : undefined;
}
export function resolvePluginSetupRegistry(params?: {
@@ -293,9 +331,9 @@ export function resolvePluginSetupRegistry(params?: {
env,
pluginIds: params?.pluginIds,
});
const cached = setupRegistryCache.get(cacheKey);
if (cached) {
return cached;
const cached = getCachedSetupValue(setupRegistryCache, cacheKey);
if (cached.hit) {
return cached.value;
}
const selectedPluginIds = params?.pluginIds
@@ -308,7 +346,7 @@ export function resolvePluginSetupRegistry(params?: {
configMigrations: [],
autoEnableProbes: [],
} satisfies PluginSetupRegistry;
setupRegistryCache.set(cacheKey, empty);
setCachedSetupValue(setupRegistryCache, cacheKey, empty);
return empty;
}
@@ -319,17 +357,9 @@ export function resolvePluginSetupRegistry(params?: {
const providerKeys = new Set<string>();
const cliBackendKeys = new Set<string>();
const discovery = discoverOpenClawPlugins({
const manifestRegistry = loadSetupManifestRegistry({
workspaceDir: params?.workspaceDir,
env,
cache: true,
});
const manifestRegistry = loadPluginManifestRegistry({
workspaceDir: params?.workspaceDir,
env,
cache: true,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
for (const record of manifestRegistry.plugins) {
@@ -341,14 +371,14 @@ export function resolvePluginSetupRegistry(params?: {
continue;
}
let mod: SetupOnlyPluginModule;
let mod: OpenClawPluginModule;
try {
mod = getJiti(setupSource)(setupSource) as SetupOnlyPluginModule;
mod = getJiti(setupSource)(setupSource) as OpenClawPluginModule;
} catch {
continue;
}
const resolved = resolveRegister((mod as { default?: SetupOnlyPluginModule }).default ?? mod);
const resolved = resolveRegister((mod as { default?: OpenClawPluginModule }).default ?? mod);
if (!resolved.register) {
continue;
}
@@ -356,46 +386,53 @@ export function resolvePluginSetupRegistry(params?: {
continue;
}
const api = createSetupOnlyPluginApi({
const api = buildPluginApi({
id: record.id,
name: record.name ?? record.id,
version: record.version,
description: record.description,
source: setupSource,
rootDir: record.rootDir,
registerProvider(provider) {
const key = `${record.id}:${normalizeProviderId(provider.id)}`;
if (providerKeys.has(key)) {
return;
}
providerKeys.add(key);
providers.push({
pluginId: record.id,
provider,
});
},
registerCliBackend(backend) {
const key = `${record.id}:${normalizeProviderId(backend.id)}`;
if (cliBackendKeys.has(key)) {
return;
}
cliBackendKeys.add(key);
cliBackends.push({
pluginId: record.id,
backend,
});
},
registerConfigMigration(migrate) {
configMigrations.push({
pluginId: record.id,
migrate,
});
},
registerAutoEnableProbe(probe) {
autoEnableProbes.push({
pluginId: record.id,
probe,
});
registrationMode: "setup-only",
config: {} as OpenClawConfig,
runtime: EMPTY_RUNTIME,
logger: NOOP_LOGGER,
resolvePath: (input) => input,
handlers: {
registerProvider(provider) {
const key = `${record.id}:${normalizeProviderId(provider.id)}`;
if (providerKeys.has(key)) {
return;
}
providerKeys.add(key);
providers.push({
pluginId: record.id,
provider,
});
},
registerCliBackend(backend) {
const key = `${record.id}:${normalizeProviderId(backend.id)}`;
if (cliBackendKeys.has(key)) {
return;
}
cliBackendKeys.add(key);
cliBackends.push({
pluginId: record.id,
backend,
});
},
registerConfigMigration(migrate) {
configMigrations.push({
pluginId: record.id,
migrate,
});
},
registerAutoEnableProbe(probe) {
autoEnableProbes.push({
pluginId: record.id,
probe,
});
},
},
});
@@ -403,6 +440,7 @@ export function resolvePluginSetupRegistry(params?: {
const result = resolved.register(api);
if (result && typeof result.then === "function") {
// Keep setup registration sync-only.
ignoreAsyncSetupRegisterResult(result);
}
} catch {
continue;
@@ -415,7 +453,7 @@ export function resolvePluginSetupRegistry(params?: {
configMigrations,
autoEnableProbes,
} satisfies PluginSetupRegistry;
setupRegistryCache.set(cacheKey, registry);
setCachedSetupValue(setupRegistryCache, cacheKey, registry);
return registry;
}
@@ -423,76 +461,80 @@ export function resolvePluginSetupProvider(params: {
provider: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): SetupProviderPlugin | undefined {
}): ProviderPlugin | undefined {
const cacheKey = buildSetupProviderCacheKey(params);
if (setupProviderCache.has(cacheKey)) {
return setupProviderCache.get(cacheKey) ?? undefined;
const cached = getCachedSetupValue(setupProviderCache, cacheKey);
if (cached.hit) {
return cached.value ?? undefined;
}
const env = params.env ?? process.env;
const normalizedProvider = normalizeProviderId(params.provider);
const discovery = discoverOpenClawPlugins({
const manifestRegistry = loadSetupManifestRegistry({
workspaceDir: params.workspaceDir,
env,
cache: true,
});
const manifestRegistry = loadPluginManifestRegistry({
workspaceDir: params.workspaceDir,
env,
cache: true,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
const record = findUniqueSetupManifestOwner({
registry: manifestRegistry,
normalizedId: normalizedProvider,
listIds: listSetupProviderIds,
});
const record = manifestRegistry.plugins.find((entry) =>
entry.providers.some((providerId) => normalizeProviderId(providerId) === normalizedProvider),
);
if (!record) {
setupProviderCache.set(cacheKey, null);
setCachedSetupValue(setupProviderCache, cacheKey, null);
return undefined;
}
const setupSource = record.setupSource ?? resolveSetupApiPath(record.rootDir);
if (!setupSource) {
setupProviderCache.set(cacheKey, null);
setCachedSetupValue(setupProviderCache, cacheKey, null);
return undefined;
}
let mod: SetupOnlyPluginModule;
let mod: OpenClawPluginModule;
try {
mod = getJiti(setupSource)(setupSource) as SetupOnlyPluginModule;
mod = getJiti(setupSource)(setupSource) as OpenClawPluginModule;
} catch {
setupProviderCache.set(cacheKey, null);
setCachedSetupValue(setupProviderCache, cacheKey, null);
return undefined;
}
const resolved = resolveRegister((mod as { default?: SetupOnlyPluginModule }).default ?? mod);
const resolved = resolveRegister((mod as { default?: OpenClawPluginModule }).default ?? mod);
if (!resolved.register) {
setupProviderCache.set(cacheKey, null);
setCachedSetupValue(setupProviderCache, cacheKey, null);
return undefined;
}
if (resolved.definition?.id && resolved.definition.id !== record.id) {
setupProviderCache.set(cacheKey, null);
setCachedSetupValue(setupProviderCache, cacheKey, null);
return undefined;
}
let matchedProvider: SetupProviderPlugin | undefined;
let matchedProvider: ProviderPlugin | undefined;
const localProviderKeys = new Set<string>();
const api = createSetupOnlyPluginApi({
const api = buildPluginApi({
id: record.id,
name: record.name ?? record.id,
version: record.version,
description: record.description,
source: setupSource,
rootDir: record.rootDir,
registerProvider(provider) {
const key = normalizeProviderId(provider.id);
if (localProviderKeys.has(key)) {
return;
}
localProviderKeys.add(key);
if (matchesProvider(provider, normalizedProvider)) {
matchedProvider = provider;
}
registrationMode: "setup-only",
config: {} as OpenClawConfig,
runtime: EMPTY_RUNTIME,
logger: NOOP_LOGGER,
resolvePath: (input) => input,
handlers: {
registerProvider(provider) {
const key = normalizeProviderId(provider.id);
if (localProviderKeys.has(key)) {
return;
}
localProviderKeys.add(key);
if (matchesProvider(provider, normalizedProvider)) {
matchedProvider = provider;
}
},
registerConfigMigration() {},
registerAutoEnableProbe() {},
},
});
@@ -500,13 +542,14 @@ export function resolvePluginSetupProvider(params: {
const result = resolved.register(api);
if (result && typeof result.then === "function") {
// Keep setup registration sync-only.
ignoreAsyncSetupRegisterResult(result);
}
} catch {
setupProviderCache.set(cacheKey, null);
setCachedSetupValue(setupProviderCache, cacheKey, null);
return undefined;
}
setupProviderCache.set(cacheKey, matchedProvider ?? null);
setCachedSetupValue(setupProviderCache, cacheKey, matchedProvider ?? null);
return matchedProvider;
}
@@ -515,84 +558,100 @@ export function resolvePluginSetupCliBackend(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): SetupCliBackendEntry | undefined {
const normalized = normalizeProviderId(params.backend);
const direct = resolvePluginSetupRegistry(params).cliBackends.find(
(entry) => normalizeProviderId(entry.backend.id) === normalized,
);
if (direct) {
return direct;
const cacheKey = buildSetupCliBackendCacheKey(params);
const cached = getCachedSetupValue(setupCliBackendCache, cacheKey);
if (cached.hit) {
return cached.value ?? undefined;
}
const normalized = normalizeProviderId(params.backend);
const env = params.env ?? process.env;
const discovery = discoverOpenClawPlugins({
// Narrow setup lookup from manifest-owned descriptors before executing any
// plugin setup module. This avoids booting every setup-api just to find one
// backend owner.
const manifestRegistry = loadSetupManifestRegistry({
workspaceDir: params.workspaceDir,
env,
cache: true,
});
const manifestRegistry = loadPluginManifestRegistry({
workspaceDir: params.workspaceDir,
env,
cache: true,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
const record = findUniqueSetupManifestOwner({
registry: manifestRegistry,
normalizedId: normalized,
listIds: listSetupCliBackendIds,
});
const record = manifestRegistry.plugins.find((entry) =>
entry.cliBackends.some((backendId) => normalizeProviderId(backendId) === normalized),
);
if (!record) {
setCachedSetupValue(setupCliBackendCache, cacheKey, null);
return undefined;
}
const setupSource = record.setupSource ?? resolveSetupApiPath(record.rootDir);
if (!setupSource) {
setCachedSetupValue(setupCliBackendCache, cacheKey, null);
return undefined;
}
let mod: SetupOnlyPluginModule;
let mod: OpenClawPluginModule;
try {
mod = getJiti(setupSource)(setupSource) as SetupOnlyPluginModule;
mod = getJiti(setupSource)(setupSource) as OpenClawPluginModule;
} catch {
setCachedSetupValue(setupCliBackendCache, cacheKey, null);
return undefined;
}
const resolved = resolveRegister((mod as { default?: SetupOnlyPluginModule }).default ?? mod);
const resolved = resolveRegister((mod as { default?: OpenClawPluginModule }).default ?? mod);
if (!resolved.register) {
setCachedSetupValue(setupCliBackendCache, cacheKey, null);
return undefined;
}
if (resolved.definition?.id && resolved.definition.id !== record.id) {
setCachedSetupValue(setupCliBackendCache, cacheKey, null);
return undefined;
}
let matchedBackend: CliBackendPlugin | undefined;
const localBackendKeys = new Set<string>();
const api = createSetupOnlyPluginApi({
const api = buildPluginApi({
id: record.id,
name: record.name ?? record.id,
version: record.version,
description: record.description,
source: setupSource,
rootDir: record.rootDir,
registerCliBackend(backend) {
const key = normalizeProviderId(backend.id);
if (localBackendKeys.has(key)) {
return;
}
localBackendKeys.add(key);
if (key === normalized) {
matchedBackend = backend;
}
registrationMode: "setup-only",
config: {} as OpenClawConfig,
runtime: EMPTY_RUNTIME,
logger: NOOP_LOGGER,
resolvePath: (input) => input,
handlers: {
registerProvider() {},
registerConfigMigration() {},
registerAutoEnableProbe() {},
registerCliBackend(backend) {
const key = normalizeProviderId(backend.id);
if (localBackendKeys.has(key)) {
return;
}
localBackendKeys.add(key);
if (key === normalized) {
matchedBackend = backend;
}
},
},
});
try {
const result = resolved.register(api);
if (result && typeof result.then === "function") {
return undefined;
// Keep setup registration sync-only.
ignoreAsyncSetupRegisterResult(result);
}
} catch {
setCachedSetupValue(setupCliBackendCache, cacheKey, null);
return undefined;
}
return matchedBackend ? { pluginId: record.id, backend: matchedBackend } : undefined;
const resolvedEntry = matchedBackend ? { pluginId: record.id, backend: matchedBackend } : null;
setCachedSetupValue(setupCliBackendCache, cacheKey, resolvedEntry);
return resolvedEntry ?? undefined;
}
export function runPluginSetupConfigMigrations(params: {