* docs: add ACP thread-bound agents plan doc * docs: expand ACP implementation specification * feat(acp): route ACP sessions through core dispatch and lifecycle cleanup * feat(acp): add /acp commands and Discord spawn gate * ACP: add acpx runtime plugin backend * fix(subagents): defer transient lifecycle errors before announce * Agents: harden ACP sessions_spawn and tighten spawn guidance * Agents: require explicit ACP target for runtime spawns * docs: expand ACP control-plane implementation plan * ACP: harden metadata seeding and spawn guidance * ACP: centralize runtime control-plane manager and fail-closed dispatch * ACP: harden runtime manager and unify spawn helpers * Commands: route ACP sessions through ACP runtime in agent command * ACP: require persisted metadata for runtime spawns * Sessions: preserve ACP metadata when updating entries * Plugins: harden ACP backend registry across loaders * ACPX: make availability probe compatible with adapters * E2E: add manual Discord ACP plain-language smoke script * ACPX: preserve streamed spacing across Discord delivery * Docs: add ACP Discord streaming strategy * ACP: harden Discord stream buffering for thread replies * ACP: reuse shared block reply pipeline for projector * ACP: unify streaming config and adopt coalesceIdleMs * Docs: add temporary ACP production hardening plan * Docs: trim temporary ACP hardening plan goals * Docs: gate ACP thread controls by backend capabilities * ACP: add capability-gated runtime controls and /acp operator commands * Docs: remove temporary ACP hardening plan * ACP: fix spawn target validation and close cache cleanup * ACP: harden runtime dispatch and recovery paths * ACP: split ACP command/runtime internals and centralize policy * ACP: harden runtime lifecycle, validation, and observability * ACP: surface runtime and backend session IDs in thread bindings * docs: add temp plan for binding-service migration * ACP: migrate thread binding flows to SessionBindingService * ACP: address review feedback and preserve prompt wording * ACPX plugin: pin runtime dependency and prefer bundled CLI * Discord: complete binding-service migration cleanup and restore ACP plan * Docs: add standalone ACP agents guide * ACP: route harness intents to thread-bound ACP sessions * ACP: fix spawn thread routing and queue-owner stall * ACP: harden startup reconciliation and command bypass handling * ACP: fix dispatch bypass type narrowing * ACP: align runtime metadata to agentSessionId * ACP: normalize session identifier handling and labels * ACP: mark thread banner session ids provisional until first reply * ACP: stabilize session identity mapping and startup reconciliation * ACP: add resolved session-id notices and cwd in thread intros * Discord: prefix thread meta notices consistently * Discord: unify ACP/thread meta notices with gear prefix * Discord: split thread persona naming from meta formatting * Extensions: bump acpx plugin dependency to 0.1.9 * Agents: gate ACP prompt guidance behind acp.enabled * Docs: remove temp experiment plan docs * Docs: scope streaming plan to holy grail refactor * Docs: refactor ACP agents guide for human-first flow * Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow * Docs/Skill: add OpenCode and Pi to ACP harness lists * Docs/Skill: align ACP harness list with current acpx registry * Dev/Test: move ACP plain-language smoke script and mark as keep * Docs/Skill: reorder ACP harness lists with Pi first * ACP: split control-plane manager into core/types/utils modules * Docs: refresh ACP thread-bound agents plan * ACP: extract dispatch lane and split manager domains * ACP: centralize binding context and remove reverse deps * Infra: unify system message formatting * ACP: centralize error boundaries and session id rendering * ACP: enforce init concurrency cap and strict meta clear * Tests: fix ACP dispatch binding mock typing * Tests: fix Discord thread-binding mock drift and ACP request id * ACP: gate slash bypass and persist cleared overrides * ACPX: await pre-abort cancel before runTurn return * Extension: pin acpx runtime dependency to 0.1.11 * Docs: add pinned acpx install strategy for ACP extension * Extensions/acpx: enforce strict local pinned startup * Extensions/acpx: tighten acp-router install guidance * ACPX: retry runtime test temp-dir cleanup * Extensions/acpx: require proactive ACPX repair for thread spawns * Extensions/acpx: require restart offer after acpx reinstall * extensions/acpx: remove workspace protocol devDependency * extensions/acpx: bump pinned acpx to 0.1.13 * extensions/acpx: sync lockfile after dependency bump * ACPX: make runtime spawn Windows-safe * fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
690 lines
20 KiB
TypeScript
690 lines
20 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
|
import { withEnv } from "../test-utils/env.js";
|
|
import { __testing, loadOpenClawPlugins } from "./loader.js";
|
|
|
|
type TempPlugin = { dir: string; file: string; id: string };
|
|
|
|
const fixtureRoot = path.join(os.tmpdir(), `openclaw-plugin-${randomUUID()}`);
|
|
let tempDirIndex = 0;
|
|
const prevBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
|
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
|
|
const BUNDLED_TELEGRAM_PLUGIN_BODY = `export default { id: "telegram", register(api) {
|
|
api.registerChannel({
|
|
plugin: {
|
|
id: "telegram",
|
|
meta: {
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
selectionLabel: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "telegram channel"
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => [],
|
|
resolveAccount: () => ({ accountId: "default" })
|
|
},
|
|
outbound: { deliveryMode: "direct" }
|
|
}
|
|
});
|
|
} };`;
|
|
|
|
function makeTempDir() {
|
|
const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
function writePlugin(params: {
|
|
id: string;
|
|
body: string;
|
|
dir?: string;
|
|
filename?: string;
|
|
}): TempPlugin {
|
|
const dir = params.dir ?? makeTempDir();
|
|
const filename = params.filename ?? `${params.id}.js`;
|
|
const file = path.join(dir, filename);
|
|
fs.writeFileSync(file, params.body, "utf-8");
|
|
fs.writeFileSync(
|
|
path.join(dir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: params.id,
|
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
return { dir, file, id: params.id };
|
|
}
|
|
|
|
function loadBundledMemoryPluginRegistry(options?: {
|
|
packageMeta?: { name: string; version: string; description?: string };
|
|
pluginBody?: string;
|
|
pluginFilename?: string;
|
|
}) {
|
|
const bundledDir = makeTempDir();
|
|
let pluginDir = bundledDir;
|
|
let pluginFilename = options?.pluginFilename ?? "memory-core.js";
|
|
|
|
if (options?.packageMeta) {
|
|
pluginDir = path.join(bundledDir, "memory-core");
|
|
pluginFilename = "index.js";
|
|
fs.mkdirSync(pluginDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: options.packageMeta.name,
|
|
version: options.packageMeta.version,
|
|
description: options.packageMeta.description,
|
|
openclaw: { extensions: ["./index.js"] },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
writePlugin({
|
|
id: "memory-core",
|
|
body:
|
|
options?.pluginBody ?? `export default { id: "memory-core", kind: "memory", register() {} };`,
|
|
dir: pluginDir,
|
|
filename: pluginFilename,
|
|
});
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
|
|
return loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
slots: {
|
|
memory: "memory-core",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function setupBundledTelegramPlugin() {
|
|
const bundledDir = makeTempDir();
|
|
writePlugin({
|
|
id: "telegram",
|
|
body: BUNDLED_TELEGRAM_PLUGIN_BODY,
|
|
dir: bundledDir,
|
|
filename: "telegram.js",
|
|
});
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
}
|
|
|
|
function expectTelegramLoaded(registry: ReturnType<typeof loadOpenClawPlugins>) {
|
|
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
|
|
expect(telegram?.status).toBe("loaded");
|
|
expect(registry.channels.some((entry) => entry.plugin.id === "telegram")).toBe(true);
|
|
}
|
|
|
|
afterEach(() => {
|
|
if (prevBundledDir === undefined) {
|
|
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
|
} else {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = prevBundledDir;
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
try {
|
|
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore cleanup failures
|
|
}
|
|
});
|
|
|
|
describe("loadOpenClawPlugins", () => {
|
|
it("disables bundled plugins by default", () => {
|
|
const bundledDir = makeTempDir();
|
|
writePlugin({
|
|
id: "bundled",
|
|
body: `export default { id: "bundled", register() {} };`,
|
|
dir: bundledDir,
|
|
filename: "bundled.js",
|
|
});
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
allow: ["bundled"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const bundled = registry.plugins.find((entry) => entry.id === "bundled");
|
|
expect(bundled?.status).toBe("disabled");
|
|
|
|
const enabledRegistry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
allow: ["bundled"],
|
|
entries: {
|
|
bundled: { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const enabled = enabledRegistry.plugins.find((entry) => entry.id === "bundled");
|
|
expect(enabled?.status).toBe("loaded");
|
|
});
|
|
|
|
it("loads bundled telegram plugin when enabled", () => {
|
|
setupBundledTelegramPlugin();
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
allow: ["telegram"],
|
|
entries: {
|
|
telegram: { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expectTelegramLoaded(registry);
|
|
});
|
|
|
|
it("loads bundled channel plugins when channels.<id>.enabled=true", () => {
|
|
setupBundledTelegramPlugin();
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
channels: {
|
|
telegram: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
plugins: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
expectTelegramLoaded(registry);
|
|
});
|
|
|
|
it("still respects explicit disable via plugins.entries for bundled channels", () => {
|
|
setupBundledTelegramPlugin();
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
channels: {
|
|
telegram: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
plugins: {
|
|
entries: {
|
|
telegram: { enabled: false },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
|
|
expect(telegram?.status).toBe("disabled");
|
|
expect(telegram?.error).toBe("disabled in config");
|
|
});
|
|
|
|
it("enables bundled memory plugin when selected by slot", () => {
|
|
const registry = loadBundledMemoryPluginRegistry();
|
|
|
|
const memory = registry.plugins.find((entry) => entry.id === "memory-core");
|
|
expect(memory?.status).toBe("loaded");
|
|
});
|
|
|
|
it("preserves package.json metadata for bundled memory plugins", () => {
|
|
const registry = loadBundledMemoryPluginRegistry({
|
|
packageMeta: {
|
|
name: "@openclaw/memory-core",
|
|
version: "1.2.3",
|
|
description: "Memory plugin package",
|
|
},
|
|
pluginBody:
|
|
'export default { id: "memory-core", kind: "memory", name: "Memory (Core)", register() {} };',
|
|
});
|
|
|
|
const memory = registry.plugins.find((entry) => entry.id === "memory-core");
|
|
expect(memory?.status).toBe("loaded");
|
|
expect(memory?.origin).toBe("bundled");
|
|
expect(memory?.name).toBe("Memory (Core)");
|
|
expect(memory?.version).toBe("1.2.3");
|
|
});
|
|
it("loads plugins from config paths", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const plugin = writePlugin({
|
|
id: "allowed",
|
|
body: `export default { id: "allowed", register(api) { api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); } };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["allowed"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const loaded = registry.plugins.find((entry) => entry.id === "allowed");
|
|
expect(loaded?.status).toBe("loaded");
|
|
expect(Object.keys(registry.gatewayHandlers)).toContain("allowed.ping");
|
|
});
|
|
|
|
it("denylist disables plugins even if allowed", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const plugin = writePlugin({
|
|
id: "blocked",
|
|
body: `export default { id: "blocked", register() {} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["blocked"],
|
|
deny: ["blocked"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const blocked = registry.plugins.find((entry) => entry.id === "blocked");
|
|
expect(blocked?.status).toBe("disabled");
|
|
});
|
|
|
|
it("fails fast on invalid plugin config", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const plugin = writePlugin({
|
|
id: "configurable",
|
|
body: `export default { id: "configurable", register() {} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
entries: {
|
|
configurable: {
|
|
config: "nope" as unknown as Record<string, unknown>,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const configurable = registry.plugins.find((entry) => entry.id === "configurable");
|
|
expect(configurable?.status).toBe("error");
|
|
expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true);
|
|
});
|
|
|
|
it("registers channel plugins", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const plugin = writePlugin({
|
|
id: "channel-demo",
|
|
body: `export default { id: "channel-demo", register(api) {
|
|
api.registerChannel({
|
|
plugin: {
|
|
id: "demo",
|
|
meta: {
|
|
id: "demo",
|
|
label: "Demo",
|
|
selectionLabel: "Demo",
|
|
docsPath: "/channels/demo",
|
|
blurb: "demo channel"
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => [],
|
|
resolveAccount: () => ({ accountId: "default" })
|
|
},
|
|
outbound: { deliveryMode: "direct" }
|
|
}
|
|
});
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["channel-demo"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const channel = registry.channels.find((entry) => entry.plugin.id === "demo");
|
|
expect(channel).toBeDefined();
|
|
});
|
|
|
|
it("registers http handlers", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const plugin = writePlugin({
|
|
id: "http-demo",
|
|
body: `export default { id: "http-demo", register(api) {
|
|
api.registerHttpHandler(async () => false);
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["http-demo"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const handler = registry.httpHandlers.find((entry) => entry.pluginId === "http-demo");
|
|
expect(handler).toBeDefined();
|
|
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-demo");
|
|
expect(httpPlugin?.httpHandlers).toBe(1);
|
|
});
|
|
|
|
it("registers http routes", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const plugin = writePlugin({
|
|
id: "http-route-demo",
|
|
body: `export default { id: "http-route-demo", register(api) {
|
|
api.registerHttpRoute({ path: "/demo", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } });
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["http-route-demo"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-route-demo");
|
|
expect(route).toBeDefined();
|
|
expect(route?.path).toBe("/demo");
|
|
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-route-demo");
|
|
expect(httpPlugin?.httpHandlers).toBe(1);
|
|
});
|
|
|
|
it("respects explicit disable in config", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const plugin = writePlugin({
|
|
id: "config-disable",
|
|
body: `export default { id: "config-disable", register() {} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
entries: {
|
|
"config-disable": { enabled: false },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const disabled = registry.plugins.find((entry) => entry.id === "config-disable");
|
|
expect(disabled?.status).toBe("disabled");
|
|
});
|
|
|
|
it("enforces memory slot selection", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const memoryA = writePlugin({
|
|
id: "memory-a",
|
|
body: `export default { id: "memory-a", kind: "memory", register() {} };`,
|
|
});
|
|
const memoryB = writePlugin({
|
|
id: "memory-b",
|
|
body: `export default { id: "memory-b", kind: "memory", register() {} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [memoryA.file, memoryB.file] },
|
|
slots: { memory: "memory-b" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const a = registry.plugins.find((entry) => entry.id === "memory-a");
|
|
const b = registry.plugins.find((entry) => entry.id === "memory-b");
|
|
expect(b?.status).toBe("loaded");
|
|
expect(a?.status).toBe("disabled");
|
|
});
|
|
|
|
it("disables memory plugins when slot is none", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const memory = writePlugin({
|
|
id: "memory-off",
|
|
body: `export default { id: "memory-off", kind: "memory", register() {} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [memory.file] },
|
|
slots: { memory: "none" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const entry = registry.plugins.find((item) => item.id === "memory-off");
|
|
expect(entry?.status).toBe("disabled");
|
|
});
|
|
|
|
it("prefers higher-precedence plugins with the same id", () => {
|
|
const bundledDir = makeTempDir();
|
|
writePlugin({
|
|
id: "shadow",
|
|
body: `export default { id: "shadow", register() {} };`,
|
|
dir: bundledDir,
|
|
filename: "shadow.js",
|
|
});
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
|
|
const override = writePlugin({
|
|
id: "shadow",
|
|
body: `export default { id: "shadow", register() {} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [override.file] },
|
|
entries: {
|
|
shadow: { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const entries = registry.plugins.filter((entry) => entry.id === "shadow");
|
|
const loaded = entries.find((entry) => entry.status === "loaded");
|
|
const overridden = entries.find((entry) => entry.status === "disabled");
|
|
expect(loaded?.origin).toBe("config");
|
|
expect(overridden?.origin).toBe("bundled");
|
|
});
|
|
it("warns when plugins.allow is empty and non-bundled plugins are discoverable", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const plugin = writePlugin({
|
|
id: "warn-open-allow",
|
|
body: `export default { id: "warn-open-allow", register() {} };`,
|
|
});
|
|
const warnings: string[] = [];
|
|
loadOpenClawPlugins({
|
|
cache: false,
|
|
logger: {
|
|
info: () => {},
|
|
warn: (msg) => warnings.push(msg),
|
|
error: () => {},
|
|
},
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
},
|
|
},
|
|
});
|
|
expect(
|
|
warnings.some((msg) => msg.includes("plugins.allow is empty") && msg.includes(plugin.id)),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("warns when loaded non-bundled plugin has no install/load-path provenance", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const stateDir = makeTempDir();
|
|
withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => {
|
|
const globalDir = path.join(stateDir, "extensions", "rogue");
|
|
fs.mkdirSync(globalDir, { recursive: true });
|
|
writePlugin({
|
|
id: "rogue",
|
|
body: `export default { id: "rogue", register() {} };`,
|
|
dir: globalDir,
|
|
filename: "index.js",
|
|
});
|
|
|
|
const warnings: string[] = [];
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
logger: {
|
|
info: () => {},
|
|
warn: (msg) => warnings.push(msg),
|
|
error: () => {},
|
|
},
|
|
config: {
|
|
plugins: {
|
|
allow: ["rogue"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const rogue = registry.plugins.find((entry) => entry.id === "rogue");
|
|
expect(rogue?.status).toBe("loaded");
|
|
expect(
|
|
warnings.some(
|
|
(msg) =>
|
|
msg.includes("rogue") && msg.includes("loaded without install/load-path provenance"),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("rejects plugin entry files that escape plugin root via symlink", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const pluginDir = makeTempDir();
|
|
const outsideDir = makeTempDir();
|
|
const outsideEntry = path.join(outsideDir, "outside.js");
|
|
const linkedEntry = path.join(pluginDir, "entry.js");
|
|
fs.writeFileSync(
|
|
outsideEntry,
|
|
'export default { id: "symlinked", register() { throw new Error("should not run"); } };',
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: "symlinked",
|
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
try {
|
|
fs.symlinkSync(outsideEntry, linkedEntry);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [linkedEntry] },
|
|
allow: ["symlinked"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const record = registry.plugins.find((entry) => entry.id === "symlinked");
|
|
expect(record?.status).not.toBe("loaded");
|
|
expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true);
|
|
});
|
|
|
|
it("prefers dist plugin-sdk alias when loader runs from dist", () => {
|
|
const root = makeTempDir();
|
|
const srcFile = path.join(root, "src", "plugin-sdk", "index.ts");
|
|
const distFile = path.join(root, "dist", "plugin-sdk", "index.js");
|
|
fs.mkdirSync(path.dirname(srcFile), { recursive: true });
|
|
fs.mkdirSync(path.dirname(distFile), { recursive: true });
|
|
fs.writeFileSync(srcFile, "export {};\n", "utf-8");
|
|
fs.writeFileSync(distFile, "export {};\n", "utf-8");
|
|
|
|
const resolved = __testing.resolvePluginSdkAliasFile({
|
|
srcFile: "index.ts",
|
|
distFile: "index.js",
|
|
modulePath: path.join(root, "dist", "plugins", "loader.js"),
|
|
});
|
|
expect(resolved).toBe(distFile);
|
|
});
|
|
|
|
it("prefers src plugin-sdk alias when loader runs from src in non-production", () => {
|
|
const root = makeTempDir();
|
|
const srcFile = path.join(root, "src", "plugin-sdk", "index.ts");
|
|
const distFile = path.join(root, "dist", "plugin-sdk", "index.js");
|
|
fs.mkdirSync(path.dirname(srcFile), { recursive: true });
|
|
fs.mkdirSync(path.dirname(distFile), { recursive: true });
|
|
fs.writeFileSync(srcFile, "export {};\n", "utf-8");
|
|
fs.writeFileSync(distFile, "export {};\n", "utf-8");
|
|
|
|
const resolved = withEnv({ NODE_ENV: undefined }, () =>
|
|
__testing.resolvePluginSdkAliasFile({
|
|
srcFile: "index.ts",
|
|
distFile: "index.js",
|
|
modulePath: path.join(root, "src", "plugins", "loader.ts"),
|
|
}),
|
|
);
|
|
expect(resolved).toBe(srcFile);
|
|
});
|
|
});
|