diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e138e53b5c..1724f0e614c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 17c23527594..6674cc1a7d8 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -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 diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 7d172ae49b9..d1040c1a415 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -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: diff --git a/extensions/acpx/openclaw.plugin.json b/extensions/acpx/openclaw.plugin.json index 6f35b7ebf57..8029c1fd0dc 100644 --- a/extensions/acpx/openclaw.plugin.json +++ b/extensions/acpx/openclaw.plugin.json @@ -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.", diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts index e3c79711084..9a69e067c9d 100644 --- a/extensions/acpx/src/config.test.ts +++ b/extensions/acpx/src/config.test.ts @@ -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: { diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index 64c9329bed3..661c58c5556 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -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; + pluginToolsMcpBridge: boolean; + moduleUrl?: string; +}): Record { + 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): 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, }; } diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 24d0e1bcee4..3515d712aa6 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -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: { diff --git a/extensions/acpx/src/test-utils/runtime-fixtures.ts b/extensions/acpx/src/test-utils/runtime-fixtures.ts index 7ddc6298e95..68bc20a6b7e 100644 --- a/extensions/acpx/src/test-utils/runtime-fixtures.ts +++ b/extensions/acpx/src/test-utils/runtime-fixtures.ts @@ -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 ?? {}, diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 6034d8d81bd..e1607b0ba11 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -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(); +} diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index bd444350bda..8d18a2115a3 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -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}`); } } } diff --git a/src/mcp/plugin-tools-serve.test.ts b/src/mcp/plugin-tools-serve.test.ts new file mode 100644 index 00000000000..9f7c239c8ff --- /dev/null +++ b/src/mcp/plugin-tools-serve.test.ts @@ -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(); + } + }); +}); diff --git a/src/mcp/plugin-tools-serve.ts b/src/mcp/plugin-tools-serve.ts index be78accd8c5..4f4dfb8d17c 100644 --- a/src/mcp/plugin-tools-serve.ts +++ b/src/mcp/plugin-tools-serve.ts @@ -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 { return { type: "object", properties: {} }; } -async function main(): Promise { - 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(); for (const tool of tools) { @@ -77,6 +85,20 @@ async function main(): Promise { } }); + return server; +} + +export async function servePluginToolsMcp(): Promise { + // 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 { 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); + }); +} diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts new file mode 100644 index 00000000000..746a061ad70 --- /dev/null +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -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(); + }); +}); diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts new file mode 100644 index 00000000000..1d184670fb5 --- /dev/null +++ b/test/scripts/runtime-postbuild.test.ts @@ -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", + ); + }); +});