mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-31 20:01:36 +00:00
fix: harden ACP plugin tools bridge (#56867) (thanks @joe2643)
This commit is contained in:
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
||||
- WhatsApp/reactions: agents can now react with emoji on incoming WhatsApp messages, enabling more natural conversational interactions like acknowledging a photo with ❤️ instead of typing a reply. Thanks @mcaxtr.
|
||||
- MCP: add remote HTTP/SSE server support for `mcp.servers` URL configs, including auth headers and safer config redaction for MCP credentials. (#50396) Thanks @dhananjai1729.
|
||||
- Plugins/hooks: add a `before_install` hook with structured request provenance, built-in scan status, and install-target metadata so external security scanners and policy engines can review and block skill, plugin package, plugin bundle, and single-file plugin installs. (#56050) thanks @odysseus0.
|
||||
- ACP/plugins: add an explicit default-off ACPX plugin-tools MCP bridge config, document the trust boundary, and harden the built-in bridge packaging/logging path so global installs and stdio MCP sessions work reliably. (#56867) Thanks @joe2643.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -147,6 +147,10 @@ Per-session `mcpServers` are not supported in bridge mode. If an ACP client
|
||||
sends them during `newSession` or `loadSession`, the bridge returns a clear
|
||||
error instead of silently ignoring them.
|
||||
|
||||
If you want ACPX-backed sessions to see OpenClaw plugin tools, enable the
|
||||
gateway-side ACPX plugin bridge instead of trying to pass per-session
|
||||
`mcpServers`. See [ACP Agents](/tools/acp-agents#plugin-tools-mcp-bridge).
|
||||
|
||||
## Use from `acpx` (Codex, Claude, other ACP clients)
|
||||
|
||||
If you want a coding agent such as Codex or Claude Code to talk to your
|
||||
|
||||
@@ -672,6 +672,37 @@ Notes:
|
||||
|
||||
See [Plugins](/tools/plugin).
|
||||
|
||||
### Plugin tools MCP bridge
|
||||
|
||||
By default, ACPX sessions do **not** expose OpenClaw plugin-registered tools to
|
||||
the ACP harness.
|
||||
|
||||
If you want ACP agents such as Codex or Claude Code to call installed
|
||||
OpenClaw plugin tools such as memory recall/store, enable the dedicated bridge:
|
||||
|
||||
```bash
|
||||
openclaw config set plugins.entries.acpx.config.pluginToolsMcpBridge true
|
||||
```
|
||||
|
||||
What this does:
|
||||
|
||||
- Injects a built-in MCP server named `openclaw-plugin-tools` into ACPX session
|
||||
bootstrap.
|
||||
- Exposes plugin tools already registered by installed and enabled OpenClaw
|
||||
plugins.
|
||||
- Keeps the feature explicit and default-off.
|
||||
|
||||
Security and trust notes:
|
||||
|
||||
- This expands the ACP harness tool surface.
|
||||
- ACP agents get access only to plugin tools already active in the gateway.
|
||||
- Treat this as the same trust boundary as letting those plugins execute in
|
||||
OpenClaw itself.
|
||||
- Review installed plugins before enabling it.
|
||||
|
||||
Custom `mcpServers` still work as before. The built-in plugin-tools bridge is an
|
||||
additional opt-in convenience, not a replacement for generic MCP server config.
|
||||
|
||||
## Permission configuration
|
||||
|
||||
ACP sessions run non-interactively — there is no TTY to approve or deny file-write and shell-exec permission prompts. The acpx plugin provides two config keys that control how permissions are handled:
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
"type": "string",
|
||||
"enum": ["deny", "fail"]
|
||||
},
|
||||
"pluginToolsMcpBridge": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"strictWindowsCmdWrapper": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -85,6 +88,11 @@
|
||||
"label": "Non-Interactive Permission Policy",
|
||||
"help": "acpx policy when interactive permission prompts are unavailable."
|
||||
},
|
||||
"pluginToolsMcpBridge": {
|
||||
"label": "Plugin Tools MCP Bridge",
|
||||
"help": "Default off. When enabled, inject the built-in OpenClaw plugin-tools MCP server into ACPX sessions so ACP agents can call plugin-registered tools.",
|
||||
"advanced": true
|
||||
},
|
||||
"strictWindowsCmdWrapper": {
|
||||
"label": "Strict Windows cmd Wrapper",
|
||||
"help": "Enabled by default. On Windows, reject unresolved .cmd/.bat wrappers instead of shell fallback. Disable only for compatibility with non-standard wrappers.",
|
||||
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
} from "../../../test/helpers/bundled-plugin-paths.js";
|
||||
import {
|
||||
ACPX_BUNDLED_BIN,
|
||||
ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME,
|
||||
ACPX_PINNED_VERSION,
|
||||
createAcpxPluginConfigSchema,
|
||||
resolveAcpxPluginRoot,
|
||||
resolveAcpxPluginConfig,
|
||||
resolvePluginToolsMcpServerConfig,
|
||||
} from "./config.js";
|
||||
|
||||
describe("acpx plugin config parsing", () => {
|
||||
@@ -75,6 +77,8 @@ describe("acpx plugin config parsing", () => {
|
||||
expect(resolved.allowPluginLocalInstall).toBe(true);
|
||||
expect(resolved.stripProviderAuthEnvVars).toBe(true);
|
||||
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
|
||||
expect(resolved.pluginToolsMcpBridge).toBe(false);
|
||||
expect(resolved.mcpServers).toEqual({});
|
||||
expect(resolved.strictWindowsCmdWrapper).toBe(true);
|
||||
});
|
||||
|
||||
@@ -170,6 +174,79 @@ describe("acpx plugin config parsing", () => {
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("injects the built-in plugin-tools MCP server only when explicitly enabled", () => {
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-plugin-tools-dist-"));
|
||||
const pluginRoot = path.join(repoRoot, "extensions", "acpx");
|
||||
const distEntry = path.join(repoRoot, "dist", "mcp", "plugin-tools-serve.js");
|
||||
try {
|
||||
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(distEntry), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(pluginRoot, "src", "config.ts"), "// test\n", "utf8");
|
||||
fs.writeFileSync(distEntry, "// built entry\n", "utf8");
|
||||
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
pluginToolsMcpBridge: true,
|
||||
},
|
||||
workspaceDir: repoRoot,
|
||||
moduleUrl: pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href,
|
||||
});
|
||||
|
||||
expect(resolved.pluginToolsMcpBridge).toBe(true);
|
||||
expect(resolved.mcpServers[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]).toEqual({
|
||||
command: process.execPath,
|
||||
args: [distEntry],
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the source plugin-tools MCP server entry when dist is absent", () => {
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-plugin-tools-src-"));
|
||||
const pluginRoot = path.join(repoRoot, "extensions", "acpx");
|
||||
const sourceConfigUrl = pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href;
|
||||
try {
|
||||
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, "src", "mcp"), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(pluginRoot, "src", "config.ts"), "// test\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, "src", "mcp", "plugin-tools-serve.ts"),
|
||||
"// test\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(resolvePluginToolsMcpServerConfig(sourceConfigUrl)).toEqual({
|
||||
command: process.execPath,
|
||||
args: ["--import", "tsx", path.join(repoRoot, "src", "mcp", "plugin-tools-serve.ts")],
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects reserved MCP server name collisions when the plugin-tools bridge is enabled", () => {
|
||||
expect(() =>
|
||||
resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
pluginToolsMcpBridge: true,
|
||||
mcpServers: {
|
||||
[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]: {
|
||||
command: "node",
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: "/tmp/workspace",
|
||||
}),
|
||||
).toThrow(
|
||||
`mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts strictWindowsCmdWrapper override", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
|
||||
export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
|
||||
|
||||
export const ACPX_VERSION_ANY = "any";
|
||||
export const ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME = "openclaw-plugin-tools";
|
||||
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
|
||||
|
||||
function isAcpxPluginRoot(dir: string): boolean {
|
||||
@@ -90,6 +91,7 @@ export type AcpxPluginConfig = {
|
||||
cwd?: string;
|
||||
permissionMode?: AcpxPermissionMode;
|
||||
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
|
||||
pluginToolsMcpBridge?: boolean;
|
||||
strictWindowsCmdWrapper?: boolean;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds?: number;
|
||||
@@ -105,6 +107,7 @@ export type ResolvedAcpxPluginConfig = {
|
||||
cwd: string;
|
||||
permissionMode: AcpxPermissionMode;
|
||||
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
|
||||
pluginToolsMcpBridge: boolean;
|
||||
strictWindowsCmdWrapper: boolean;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds: number;
|
||||
@@ -155,6 +158,7 @@ const AcpxPluginConfigSchema = z.strictObject({
|
||||
error: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
|
||||
})
|
||||
.optional(),
|
||||
pluginToolsMcpBridge: z.boolean({ error: "pluginToolsMcpBridge must be a boolean" }).optional(),
|
||||
strictWindowsCmdWrapper: z
|
||||
.boolean({ error: "strictWindowsCmdWrapper must be a boolean" })
|
||||
.optional(),
|
||||
@@ -208,6 +212,57 @@ function resolveConfiguredCommand(params: { configured?: string; workspaceDir?:
|
||||
return configured;
|
||||
}
|
||||
|
||||
function resolveOpenClawRoot(currentRoot: string): string {
|
||||
if (
|
||||
path.basename(currentRoot) === "acpx" &&
|
||||
path.basename(path.dirname(currentRoot)) === "extensions"
|
||||
) {
|
||||
const parent = path.dirname(path.dirname(currentRoot));
|
||||
if (path.basename(parent) === "dist") {
|
||||
return path.dirname(parent);
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
return path.resolve(currentRoot, "..");
|
||||
}
|
||||
|
||||
export function resolvePluginToolsMcpServerConfig(
|
||||
moduleUrl: string = import.meta.url,
|
||||
): McpServerConfig {
|
||||
const pluginRoot = resolveAcpxPluginRoot(moduleUrl);
|
||||
const openClawRoot = resolveOpenClawRoot(pluginRoot);
|
||||
const distEntry = path.join(openClawRoot, "dist", "mcp", "plugin-tools-serve.js");
|
||||
if (fs.existsSync(distEntry)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [distEntry],
|
||||
};
|
||||
}
|
||||
const sourceEntry = path.join(openClawRoot, "src", "mcp", "plugin-tools-serve.ts");
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: ["--import", "tsx", sourceEntry],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfiguredMcpServers(params: {
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
pluginToolsMcpBridge: boolean;
|
||||
moduleUrl?: string;
|
||||
}): Record<string, McpServerConfig> {
|
||||
const resolved = { ...(params.mcpServers ?? {}) };
|
||||
if (!params.pluginToolsMcpBridge) {
|
||||
return resolved;
|
||||
}
|
||||
if (resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) {
|
||||
throw new Error(
|
||||
`mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`,
|
||||
);
|
||||
}
|
||||
resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(params.moduleUrl);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
|
||||
return buildPluginConfigSchema(AcpxPluginConfigSchema);
|
||||
}
|
||||
@@ -227,6 +282,7 @@ export function toAcpMcpServers(mcpServers: Record<string, McpServerConfig>): Ac
|
||||
export function resolveAcpxPluginConfig(params: {
|
||||
rawConfig: unknown;
|
||||
workspaceDir?: string;
|
||||
moduleUrl?: string;
|
||||
}): ResolvedAcpxPluginConfig {
|
||||
const parsed = parseAcpxPluginConfig(params.rawConfig);
|
||||
if (!parsed.ok) {
|
||||
@@ -247,6 +303,12 @@ export function resolveAcpxPluginConfig(params: {
|
||||
? undefined
|
||||
: (configuredExpectedVersion ?? (allowPluginLocalInstall ? ACPX_PINNED_VERSION : undefined));
|
||||
const installCommand = buildAcpxLocalInstallCommand(expectedVersion ?? ACPX_PINNED_VERSION);
|
||||
const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true;
|
||||
const mcpServers = resolveConfiguredMcpServers({
|
||||
mcpServers: normalized.mcpServers,
|
||||
pluginToolsMcpBridge,
|
||||
moduleUrl: params.moduleUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
command,
|
||||
@@ -258,10 +320,11 @@ export function resolveAcpxPluginConfig(params: {
|
||||
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
|
||||
nonInteractivePermissions:
|
||||
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
|
||||
pluginToolsMcpBridge,
|
||||
strictWindowsCmdWrapper:
|
||||
normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
|
||||
timeoutSeconds: normalized.timeoutSeconds,
|
||||
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
|
||||
mcpServers: normalized.mcpServers ?? {},
|
||||
mcpServers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
|
||||
import { resolveAcpxPluginConfig } from "./config.js";
|
||||
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
||||
import {
|
||||
cleanupMockRuntimeFixtures,
|
||||
@@ -24,6 +27,7 @@ beforeAll(async () => {
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
pluginToolsMcpBridge: false,
|
||||
strictWindowsCmdWrapper: true,
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
mcpServers: {},
|
||||
@@ -612,6 +616,72 @@ describe("AcpxRuntime", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("routes ACPX commands through the built-in plugin-tools bridge only when explicitly enabled", async () => {
|
||||
process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS = JSON.stringify({
|
||||
codex: {
|
||||
command: "npx custom-codex-acp",
|
||||
},
|
||||
});
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-acpx-plugin-tools-"));
|
||||
const pluginRoot = path.join(repoRoot, "extensions", "acpx");
|
||||
const distEntry = path.join(repoRoot, "dist", "mcp", "plugin-tools-serve.js");
|
||||
try {
|
||||
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(distEntry), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(pluginRoot, "src", "config.ts"), "// test\n", "utf8");
|
||||
fs.writeFileSync(distEntry, "// built entry\n", "utf8");
|
||||
|
||||
const fixture = await createMockRuntimeFixture();
|
||||
const runtime = new AcpxRuntime(
|
||||
resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
command: fixture.config.command,
|
||||
pluginToolsMcpBridge: true,
|
||||
},
|
||||
workspaceDir: repoRoot,
|
||||
moduleUrl: pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href,
|
||||
}),
|
||||
{ logger: NOOP_LOGGER },
|
||||
);
|
||||
|
||||
await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:plugin-tools-bridge",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
const logs = await readMockRuntimeLogEntries(fixture.logPath);
|
||||
const ensureArgs = (logs.find((entry) => entry.kind === "ensure")?.args as string[]) ?? [];
|
||||
const agentFlagIndex = ensureArgs.indexOf("--agent");
|
||||
expect(agentFlagIndex).toBeGreaterThanOrEqual(0);
|
||||
const rawAgentCommand = ensureArgs[agentFlagIndex + 1];
|
||||
expect(rawAgentCommand).toContain("mcp-proxy.mjs");
|
||||
const payloadMatch = rawAgentCommand.match(/--payload\s+([A-Za-z0-9_-]+)/);
|
||||
expect(payloadMatch?.[1]).toBeDefined();
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(String(payloadMatch?.[1]), "base64url").toString("utf8"),
|
||||
) as {
|
||||
targetCommand: string;
|
||||
mcpServers: Array<{ name: string; command: string; args: string[] }>;
|
||||
};
|
||||
expect(payload.targetCommand).toContain("custom-codex-acp");
|
||||
expect(payload.mcpServers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "openclaw-plugin-tools",
|
||||
command: process.execPath,
|
||||
args: [distEntry],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS;
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not pass unknown agent ids through acpx --agent when MCP servers are configured", async () => {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture({
|
||||
mcpServers: {
|
||||
|
||||
@@ -357,6 +357,7 @@ export async function createMockRuntimeFixture(params?: {
|
||||
cwd: dir,
|
||||
permissionMode: params?.permissionMode ?? "approve-all",
|
||||
nonInteractivePermissions: "fail",
|
||||
pluginToolsMcpBridge: false,
|
||||
strictWindowsCmdWrapper: true,
|
||||
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1,
|
||||
mcpServers: params?.mcpServers ?? {},
|
||||
|
||||
@@ -6,37 +6,57 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const isGlobal = process.env.npm_config_global === "true";
|
||||
if (!isGlobal) {
|
||||
process.exit(0);
|
||||
}
|
||||
export const BUNDLED_PLUGIN_INSTALL_TARGETS = [
|
||||
{
|
||||
pluginId: "acpx",
|
||||
sentinelPath: join("node_modules", "acpx", "package.json"),
|
||||
},
|
||||
];
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const extensionsDir = join(__dirname, "..", "dist", "extensions");
|
||||
const DEFAULT_EXTENSIONS_DIR = join(__dirname, "..", "dist", "extensions");
|
||||
|
||||
// Extensions whose runtime deps include platform-specific binaries and therefore
|
||||
// cannot be pre-bundled. Add entries here if new extensions share this pattern.
|
||||
const NEEDS_INSTALL = ["acpx"];
|
||||
export function createNestedNpmInstallEnv(env = process.env) {
|
||||
const nextEnv = { ...env };
|
||||
delete nextEnv.npm_config_global;
|
||||
delete nextEnv.npm_config_prefix;
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
for (const ext of NEEDS_INSTALL) {
|
||||
const extDir = join(extensionsDir, ext);
|
||||
if (!existsSync(join(extDir, "package.json"))) {
|
||||
continue;
|
||||
export function runBundledPluginPostinstall(params = {}) {
|
||||
const env = params.env ?? process.env;
|
||||
if (env.npm_config_global !== "true") {
|
||||
return;
|
||||
}
|
||||
// Skip if already installed (node_modules/.bin present).
|
||||
if (existsSync(join(extDir, "node_modules", ".bin"))) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
execSync("npm install --omit=dev --no-save --package-lock=false", {
|
||||
cwd: extDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
console.log(`[postinstall] installed bundled plugin deps: ${ext}`);
|
||||
} catch (e) {
|
||||
// Non-fatal: gateway will surface the missing dep via doctor.
|
||||
console.warn(`[postinstall] could not install deps for ${ext}: ${String(e)}`);
|
||||
const extensionsDir = params.extensionsDir ?? DEFAULT_EXTENSIONS_DIR;
|
||||
const exec = params.execSync ?? execSync;
|
||||
const pathExists = params.existsSync ?? existsSync;
|
||||
const log = params.log ?? console;
|
||||
|
||||
for (const target of BUNDLED_PLUGIN_INSTALL_TARGETS) {
|
||||
const extDir = join(extensionsDir, target.pluginId);
|
||||
if (!pathExists(join(extDir, "package.json"))) {
|
||||
continue;
|
||||
}
|
||||
if (pathExists(join(extDir, target.sentinelPath))) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
exec("npm install --omit=dev --no-save --package-lock=false", {
|
||||
cwd: extDir,
|
||||
env: createNestedNpmInstallEnv(env),
|
||||
stdio: "pipe",
|
||||
});
|
||||
log.log(`[postinstall] installed bundled plugin deps: ${target.pluginId}`);
|
||||
} catch (e) {
|
||||
// Non-fatal: gateway will surface the missing dep via doctor.
|
||||
log.warn(`[postinstall] could not install deps for ${target.pluginId}: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
runBundledPluginPostinstall();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
*
|
||||
* Each entry: { src: repo-root-relative source, dest: dist-relative dest }
|
||||
*/
|
||||
const STATIC_EXTENSION_ASSETS = [
|
||||
export const STATIC_EXTENSION_ASSETS = [
|
||||
// acpx MCP proxy — co-deployed alongside the acpx index bundle so that
|
||||
// `path.resolve(dirname(import.meta.url), "mcp-proxy.mjs")` resolves correctly
|
||||
// at runtime (see extensions/acpx/src/runtime-internals/mcp-agent-command.ts).
|
||||
@@ -25,15 +25,19 @@ const STATIC_EXTENSION_ASSETS = [
|
||||
},
|
||||
];
|
||||
|
||||
function copyStaticExtensionAssets() {
|
||||
for (const { src, dest } of STATIC_EXTENSION_ASSETS) {
|
||||
const srcPath = path.join(ROOT, src);
|
||||
const destPath = path.join(ROOT, dest);
|
||||
if (fs.existsSync(srcPath)) {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
export function copyStaticExtensionAssets(params = {}) {
|
||||
const rootDir = params.rootDir ?? ROOT;
|
||||
const assets = params.assets ?? STATIC_EXTENSION_ASSETS;
|
||||
const fsImpl = params.fs ?? fs;
|
||||
const warn = params.warn ?? console.warn;
|
||||
for (const { src, dest } of assets) {
|
||||
const srcPath = path.join(rootDir, src);
|
||||
const destPath = path.join(rootDir, dest);
|
||||
if (fsImpl.existsSync(srcPath)) {
|
||||
fsImpl.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fsImpl.copyFileSync(srcPath, destPath);
|
||||
} else {
|
||||
console.warn(`[runtime-postbuild] static asset not found, skipping: ${src}`);
|
||||
warn(`[runtime-postbuild] static asset not found, skipping: ${src}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
112
src/mcp/plugin-tools-serve.test.ts
Normal file
112
src/mcp/plugin-tools-serve.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import { createPluginToolsMcpServer } from "./plugin-tools-serve.js";
|
||||
|
||||
async function connectPluginToolsServer(tools: AnyAgentTool[]) {
|
||||
const server = createPluginToolsMcpServer({ tools });
|
||||
const client = new Client({ name: "plugin-tools-test-client", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return {
|
||||
client,
|
||||
close: async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("plugin tools MCP server", () => {
|
||||
it("lists registered plugin tools with their input schema", async () => {
|
||||
const tool = {
|
||||
name: "memory_recall",
|
||||
description: "Recall stored memory",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
execute: vi.fn(),
|
||||
} as unknown as AnyAgentTool;
|
||||
|
||||
const session = await connectPluginToolsServer([tool]);
|
||||
try {
|
||||
const listed = await session.client.listTools();
|
||||
expect(listed.tools).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "memory_recall",
|
||||
description: "Recall stored memory",
|
||||
inputSchema: expect.objectContaining({
|
||||
type: "object",
|
||||
required: ["query"],
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes non-array tool content as text for MCP callers", async () => {
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
content: "Stored.",
|
||||
});
|
||||
const tool = {
|
||||
name: "memory_store",
|
||||
description: "Store memory",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute,
|
||||
} as unknown as AnyAgentTool;
|
||||
|
||||
const session = await connectPluginToolsServer([tool]);
|
||||
try {
|
||||
const result = await session.client.callTool({
|
||||
name: "memory_store",
|
||||
arguments: { text: "remember this" },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledWith(expect.stringMatching(/^mcp-\d+$/), {
|
||||
text: "remember this",
|
||||
});
|
||||
expect(result.content).toEqual([{ type: "text", text: "Stored." }]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns MCP errors for unknown tools and thrown tool errors", async () => {
|
||||
const failingTool = {
|
||||
name: "memory_forget",
|
||||
description: "Forget memory",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
} as unknown as AnyAgentTool;
|
||||
|
||||
const session = await connectPluginToolsServer([failingTool]);
|
||||
try {
|
||||
const unknown = await session.client.callTool({
|
||||
name: "missing_tool",
|
||||
arguments: {},
|
||||
});
|
||||
expect(unknown.isError).toBe(true);
|
||||
expect(unknown.content).toEqual([{ type: "text", text: "Unknown tool: missing_tool" }]);
|
||||
|
||||
const failed = await session.client.callTool({
|
||||
name: "memory_forget",
|
||||
arguments: {},
|
||||
});
|
||||
expect(failed.isError).toBe(true);
|
||||
expect(failed.content).toEqual([{ type: "text", text: "Tool error: boom" }]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,14 @@
|
||||
* Run via: node --import tsx src/mcp/plugin-tools-serve.ts
|
||||
* Or: bun src/mcp/plugin-tools-serve.ts
|
||||
*/
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { routeLogsToStderr } from "../logging/console.js";
|
||||
import { resolvePluginTools } from "../plugins/tools.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
@@ -23,16 +26,21 @@ function resolveJsonSchemaForTool(tool: AnyAgentTool): Record<string, unknown> {
|
||||
return { type: "object", properties: {} };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
const tools = resolvePluginTools({
|
||||
context: { config: cfg },
|
||||
function resolveTools(config: OpenClawConfig): AnyAgentTool[] {
|
||||
return resolvePluginTools({
|
||||
context: { config },
|
||||
suppressNameConflicts: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (tools.length === 0) {
|
||||
process.stderr.write("plugin-tools-serve: no plugin tools found\n");
|
||||
}
|
||||
export function createPluginToolsMcpServer(
|
||||
params: {
|
||||
config?: OpenClawConfig;
|
||||
tools?: AnyAgentTool[];
|
||||
} = {},
|
||||
): Server {
|
||||
const cfg = params.config ?? loadConfig();
|
||||
const tools = params.tools ?? resolveTools(cfg);
|
||||
|
||||
const toolMap = new Map<string, AnyAgentTool>();
|
||||
for (const tool of tools) {
|
||||
@@ -77,6 +85,20 @@ async function main(): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function servePluginToolsMcp(): Promise<void> {
|
||||
// MCP stdio requires stdout to stay protocol-only.
|
||||
routeLogsToStderr();
|
||||
|
||||
const config = loadConfig();
|
||||
const tools = resolveTools(config);
|
||||
const server = createPluginToolsMcpServer({ config, tools });
|
||||
if (tools.length === 0) {
|
||||
process.stderr.write("plugin-tools-serve: no plugin tools found\n");
|
||||
}
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
let shuttingDown = false;
|
||||
@@ -100,7 +122,11 @@ async function main(): Promise<void> {
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`plugin-tools-serve: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
servePluginToolsMcp().catch((err) => {
|
||||
process.stderr.write(
|
||||
`plugin-tools-serve: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
95
test/scripts/postinstall-bundled-plugins.test.ts
Normal file
95
test/scripts/postinstall-bundled-plugins.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createNestedNpmInstallEnv,
|
||||
runBundledPluginPostinstall,
|
||||
} from "../../scripts/postinstall-bundled-plugins.mjs";
|
||||
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
cleanupDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function createExtensionsDir() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-postinstall-"));
|
||||
cleanupDirs.push(root);
|
||||
const extensionsDir = path.join(root, "dist", "extensions");
|
||||
await fs.mkdir(path.join(extensionsDir, "acpx"), { recursive: true });
|
||||
await fs.writeFile(path.join(extensionsDir, "acpx", "package.json"), "{}\n", "utf8");
|
||||
return extensionsDir;
|
||||
}
|
||||
|
||||
describe("bundled plugin postinstall", () => {
|
||||
it("clears global npm config before nested installs", () => {
|
||||
expect(
|
||||
createNestedNpmInstallEnv({
|
||||
npm_config_global: "true",
|
||||
npm_config_prefix: "/opt/homebrew",
|
||||
HOME: "/tmp/home",
|
||||
}),
|
||||
).toEqual({
|
||||
HOME: "/tmp/home",
|
||||
});
|
||||
});
|
||||
|
||||
it("installs bundled plugin deps only during global installs", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
const execSync = vi.fn();
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: { npm_config_global: "false" },
|
||||
extensionsDir,
|
||||
execSync,
|
||||
});
|
||||
|
||||
expect(execSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs nested local installs with sanitized env when the sentinel package is missing", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
const execSync = vi.fn();
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: {
|
||||
npm_config_global: "true",
|
||||
npm_config_prefix: "/opt/homebrew",
|
||||
HOME: "/tmp/home",
|
||||
},
|
||||
extensionsDir,
|
||||
execSync,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
});
|
||||
|
||||
expect(execSync).toHaveBeenCalledWith("npm install --omit=dev --no-save --package-lock=false", {
|
||||
cwd: path.join(extensionsDir, "acpx"),
|
||||
env: {
|
||||
HOME: "/tmp/home",
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips reinstall when the bundled sentinel package already exists", async () => {
|
||||
const extensionsDir = await createExtensionsDir();
|
||||
await fs.mkdir(path.join(extensionsDir, "acpx", "node_modules", "acpx"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(extensionsDir, "acpx", "node_modules", "acpx", "package.json"),
|
||||
"{}\n",
|
||||
"utf8",
|
||||
);
|
||||
const execSync = vi.fn();
|
||||
|
||||
runBundledPluginPostinstall({
|
||||
env: { npm_config_global: "true" },
|
||||
extensionsDir,
|
||||
execSync,
|
||||
});
|
||||
|
||||
expect(execSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
53
test/scripts/runtime-postbuild.test.ts
Normal file
53
test/scripts/runtime-postbuild.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { copyStaticExtensionAssets } from "../../scripts/runtime-postbuild.mjs";
|
||||
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
cleanupDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function createTempRoot() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-runtime-postbuild-"));
|
||||
cleanupDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("runtime postbuild static assets", () => {
|
||||
it("copies declared static assets into dist", async () => {
|
||||
const rootDir = await createTempRoot();
|
||||
const src = "extensions/acpx/src/runtime-internals/mcp-proxy.mjs";
|
||||
const dest = "dist/extensions/acpx/mcp-proxy.mjs";
|
||||
const sourcePath = path.join(rootDir, src);
|
||||
const destPath = path.join(rootDir, dest);
|
||||
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await fs.writeFile(sourcePath, "proxy-data\n", "utf8");
|
||||
|
||||
copyStaticExtensionAssets({
|
||||
rootDir,
|
||||
assets: [{ src, dest }],
|
||||
});
|
||||
|
||||
expect(await fs.readFile(destPath, "utf8")).toBe("proxy-data\n");
|
||||
});
|
||||
|
||||
it("warns when a declared static asset is missing", async () => {
|
||||
const rootDir = await createTempRoot();
|
||||
const warn = vi.fn();
|
||||
|
||||
copyStaticExtensionAssets({
|
||||
rootDir,
|
||||
assets: [{ src: "missing/file.mjs", dest: "dist/file.mjs" }],
|
||||
warn,
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[runtime-postbuild] static asset not found, skipping: missing/file.mjs",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user