Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
2 changes: 1 addition & 1 deletion index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { default } from "./src/agent-control-plugin.ts";
export { default } from "./src/plugin-entry.ts";
65 changes: 65 additions & 0 deletions src/plugin-entry.ts
Original file line number Diff line number Diff line change
@@ -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);
104 changes: 104 additions & 0 deletions test/plugin-entry.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 14 additions & 0 deletions types/openclaw-plugin-sdk-plugin-entry.d.ts
Original file line number Diff line number Diff line change
@@ -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<TEntry extends OpenClawDefinedPluginEntry>(
entry: TEntry,
): TEntry;
}
Loading