diff --git a/README.md b/README.md index 99f1403..5304bd4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ This plugin integrates OpenClaw with [Agent Control](https://github.com/agentcon > [!WARNING] > Experimental plugin: this may break across OpenClaw updates. Use in non-production or pinned environments. +> +> The package exports a modern `definePluginEntry(...)` descriptor when the host SDK exposes it, and falls back to the legacy raw `register(api)` export on older gateways. ## Why use this? diff --git a/index.ts b/index.ts index fd79c2b..4c59d4d 100644 --- a/index.ts +++ b/index.ts @@ -1 +1 @@ -export { default } from "./src/agent-control-plugin.ts"; +export { default } from "./src/plugin-entry.ts"; diff --git a/src/plugin-entry.ts b/src/plugin-entry.ts new file mode 100644 index 0000000..e3e6bcd --- /dev/null +++ b/src/plugin-entry.ts @@ -0,0 +1,65 @@ +import { createRequire } from "node:module"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import register from "./agent-control-plugin.ts"; + +export const AGENT_CONTROL_PLUGIN_ID = "agent-control-openclaw-plugin"; +export const AGENT_CONTROL_PLUGIN_NAME = "Agent Control"; +export const AGENT_CONTROL_PLUGIN_DESCRIPTION = + "Registers OpenClaw tools with Agent Control and blocks unsafe tool invocations."; + +export type AgentControlPluginEntry = { + id: string; + name: string; + description: string; + register(api: OpenClawPluginApi): void; +}; + +type DefinePluginEntry = (entry: AgentControlPluginEntry) => AgentControlPluginEntry; +type RequireLike = (specifier: string) => unknown; +type CreateRequireLike = (path: string | URL) => RequireLike; + +export const agentControlPluginEntry: AgentControlPluginEntry = { + id: AGENT_CONTROL_PLUGIN_ID, + name: AGENT_CONTROL_PLUGIN_NAME, + description: AGENT_CONTROL_PLUGIN_DESCRIPTION, + register, +}; + +function tryReadDefinePluginEntryFromModule( + requireFn: RequireLike, + specifier: string, +): DefinePluginEntry | null { + try { + const loaded = requireFn(specifier) as { definePluginEntry?: unknown }; + return typeof loaded.definePluginEntry === "function" + ? (loaded.definePluginEntry as DefinePluginEntry) + : null; + } catch { + return null; + } +} + +export function loadDefinePluginEntry( + createRequireImpl: CreateRequireLike = createRequire as CreateRequireLike, +): DefinePluginEntry | null { + const requireFn = createRequireImpl(import.meta.url); + + // Prefer the dedicated modern helper module when it exists, but also accept + // the helper from core because some gateways exposed it there during the + // migration window. + return ( + tryReadDefinePluginEntryFromModule(requireFn, "openclaw/plugin-sdk/plugin-entry") ?? + tryReadDefinePluginEntryFromModule(requireFn, "openclaw/plugin-sdk/core") + ); +} + +export function createPluginEntry( + entry: AgentControlPluginEntry, + definePluginEntry: DefinePluginEntry | null, +) { + return definePluginEntry ? definePluginEntry(entry) : entry.register; +} + +const definePluginEntry = loadDefinePluginEntry(); + +export default createPluginEntry(agentControlPluginEntry, definePluginEntry); diff --git a/test/plugin-entry.test.ts b/test/plugin-entry.test.ts new file mode 100644 index 0000000..9ff0fd5 --- /dev/null +++ b/test/plugin-entry.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createPluginEntry, + loadDefinePluginEntry, + type AgentControlPluginEntry, +} from "../src/plugin-entry.ts"; + +describe("plugin entry compatibility", () => { + it("returns the legacy register function when no modern helper is available", () => { + // Given a plugin entry and a gateway without definePluginEntry support + const legacyRegister = vi.fn(); + const entry: AgentControlPluginEntry = { + id: "agent-control-openclaw-plugin", + name: "Agent Control", + description: "test entry", + register: legacyRegister, + }; + + // When the plugin entry is created without a modern helper + const resolved = createPluginEntry(entry, null); + + // Then the legacy raw register function is exported + expect(resolved).toBe(legacyRegister); + }); + + it("wraps the entry with definePluginEntry when the helper is available", () => { + // Given a plugin entry and a gateway that exposes definePluginEntry + const legacyRegister = vi.fn(); + const entry: AgentControlPluginEntry = { + id: "agent-control-openclaw-plugin", + name: "Agent Control", + description: "test entry", + register: legacyRegister, + }; + const definePluginEntry = vi.fn((value: AgentControlPluginEntry) => ({ + ...value, + wrapped: true, + })); + + // When the plugin entry is created with the helper + const resolved = createPluginEntry(entry, definePluginEntry); + + // Then the modern helper receives the descriptor and its result is exported + expect(definePluginEntry).toHaveBeenCalledWith(entry); + expect(resolved).toEqual({ + ...entry, + wrapped: true, + }); + }); + + it("prefers the dedicated plugin-entry module when it is present", () => { + // Given a createRequire implementation that exposes the dedicated helper module + const defineFromPluginEntry = vi.fn(); + const createRequireImpl = vi.fn(() => + vi.fn((specifier: string) => { + if (specifier === "openclaw/plugin-sdk/plugin-entry") { + return { definePluginEntry: defineFromPluginEntry }; + } + throw new Error(`unexpected module lookup: ${specifier}`); + }), + ); + + // When definePluginEntry is loaded from the gateway SDK + const loaded = loadDefinePluginEntry(createRequireImpl); + + // Then the dedicated helper is returned without probing fallback modules + expect(loaded).toBe(defineFromPluginEntry); + }); + + it("falls back to the core helper during the SDK migration window", () => { + // Given a createRequire implementation without the dedicated helper module + const defineFromCore = vi.fn(); + const requireFn = vi.fn((specifier: string) => { + if (specifier === "openclaw/plugin-sdk/plugin-entry") { + throw new Error("module not found"); + } + if (specifier === "openclaw/plugin-sdk/core") { + return { definePluginEntry: defineFromCore }; + } + throw new Error(`unexpected module lookup: ${specifier}`); + }); + + // When definePluginEntry is loaded from the gateway SDK + const loaded = loadDefinePluginEntry(vi.fn(() => requireFn)); + + // Then the core-exported helper is used as a migration fallback + expect(loaded).toBe(defineFromCore); + expect(requireFn).toHaveBeenCalledWith("openclaw/plugin-sdk/plugin-entry"); + expect(requireFn).toHaveBeenCalledWith("openclaw/plugin-sdk/core"); + }); + + it("returns null when neither modern helper is available", () => { + // Given a createRequire implementation where both helper lookups fail + const requireFn = vi.fn(() => { + throw new Error("module not found"); + }); + + // When definePluginEntry is loaded from the gateway SDK + const loaded = loadDefinePluginEntry(vi.fn(() => requireFn)); + + // Then the plugin can fall back to the legacy raw register export + expect(loaded).toBeNull(); + }); +}); diff --git a/types/openclaw-plugin-sdk-plugin-entry.d.ts b/types/openclaw-plugin-sdk-plugin-entry.d.ts new file mode 100644 index 0000000..1355e5c --- /dev/null +++ b/types/openclaw-plugin-sdk-plugin-entry.d.ts @@ -0,0 +1,14 @@ +declare module "openclaw/plugin-sdk/plugin-entry" { + import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; + + export type OpenClawDefinedPluginEntry = { + id: string; + name: string; + description?: string; + register(api: OpenClawPluginApi): unknown; + }; + + export function definePluginEntry( + entry: TEntry, + ): TEntry; +}