From 05f34eccd07cb453219d56a1ced6a20a10f45db8 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 15 Mar 2026 21:40:21 +0200 Subject: [PATCH] fix(acpx): resolve bundled plugin root correctly --- CHANGELOG.md | 1 + extensions/acpx/src/config.test.ts | 31 ++++++++++++++++++++++++++++++ extensions/acpx/src/config.ts | 23 +++++++++++++++++++++- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc9aa9435ae..a21cfd81989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146) - Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts. - Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. +- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup. - Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc. - Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc. - Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc. diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts index 45be08e3edf..5a19d6f43e8 100644 --- a/extensions/acpx/src/config.test.ts +++ b/extensions/acpx/src/config.test.ts @@ -1,13 +1,44 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { ACPX_BUNDLED_BIN, ACPX_PINNED_VERSION, createAcpxPluginConfigSchema, + resolveAcpxPluginRoot, resolveAcpxPluginConfig, } from "./config.js"; describe("acpx plugin config parsing", () => { + it("resolves source-layout plugin root from a file under src", () => { + const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-source-")); + try { + fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8"); + + const moduleUrl = pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href; + expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot); + } finally { + fs.rmSync(pluginRoot, { recursive: true, force: true }); + } + }); + + it("resolves bundled-layout plugin root from the dist entry file", () => { + const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-dist-")); + try { + fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8"); + + const moduleUrl = pathToFileURL(path.join(pluginRoot, "index.js")).href; + expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot); + } finally { + fs.rmSync(pluginRoot, { recursive: true, force: true }); + } + }); + it("resolves bundled acpx with pinned version by default", () => { const resolved = resolveAcpxPluginConfig({ rawConfig: { diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index ef0207a1365..d6bfb3a44db 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/acpx"; @@ -11,7 +12,27 @@ export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_PO export const ACPX_PINNED_VERSION = "0.1.16"; export const ACPX_VERSION_ANY = "any"; const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx"; -export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string { + let cursor = path.dirname(fileURLToPath(moduleUrl)); + for (let i = 0; i < 3; i += 1) { + // Bundled entries live at the plugin root while source files still live under src/. + if ( + fs.existsSync(path.join(cursor, "openclaw.plugin.json")) && + fs.existsSync(path.join(cursor, "package.json")) + ) { + return cursor; + } + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + return path.resolve(path.dirname(fileURLToPath(moduleUrl)), ".."); +} + +export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot(); export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME); export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string { return `npm install --omit=dev --no-save acpx@${version}`;