mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
11
src/plugins/setup-descriptors.ts
Normal file
11
src/plugins/setup-descriptors.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user