From 47fd8558cd5a3299d27c5cd254482a8bfa476642 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 15 Mar 2026 23:00:30 +0200 Subject: [PATCH] fix(plugins): fix bundled plugin roots and skill assets (#47601) * fix(acpx): resolve bundled plugin root correctly * fix(plugins): copy bundled plugin skill assets * fix(plugins): tolerate missing bundled skill paths --- CHANGELOG.md | 1 + extensions/acpx/src/config.test.ts | 31 ++++ extensions/acpx/src/config.ts | 23 ++- scripts/copy-bundled-plugin-metadata.d.mts | 3 + scripts/copy-bundled-plugin-metadata.mjs | 53 ++++++- .../copy-bundled-plugin-metadata.test.ts | 144 ++++++++++++++++++ 6 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 scripts/copy-bundled-plugin-metadata.d.mts create mode 100644 src/plugins/copy-bundled-plugin-metadata.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 15521744304..2b4546d49d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,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}`; diff --git a/scripts/copy-bundled-plugin-metadata.d.mts b/scripts/copy-bundled-plugin-metadata.d.mts new file mode 100644 index 00000000000..1b2d0e4836d --- /dev/null +++ b/scripts/copy-bundled-plugin-metadata.d.mts @@ -0,0 +1,3 @@ +export function rewritePackageExtensions(entries: unknown): string[] | undefined; + +export function copyBundledPluginMetadata(params?: { repoRoot?: string }): void; diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index a137872d421..af8612a3465 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -3,7 +3,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { removeFileIfExists, writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; -function rewritePackageExtensions(entries) { +export function rewritePackageExtensions(entries) { if (!Array.isArray(entries)) { return undefined; } @@ -17,8 +17,50 @@ function rewritePackageExtensions(entries) { }); } +function ensurePathInsideRoot(rootDir, rawPath) { + const resolved = path.resolve(rootDir, rawPath); + const relative = path.relative(rootDir, resolved); + if ( + relative === "" || + relative === "." || + (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) + ) { + return resolved; + } + throw new Error(`path escapes plugin root: ${rawPath}`); +} + +function copyDeclaredPluginSkillPaths(params) { + const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : []; + const copiedSkills = []; + for (const raw of skills) { + if (typeof raw !== "string" || raw.trim().length === 0) { + continue; + } + const normalized = raw.replace(/^\.\//u, ""); + const sourcePath = ensurePathInsideRoot(params.pluginDir, raw); + if (!fs.existsSync(sourcePath)) { + // Some Docker/lightweight builds intentionally omit optional plugin-local + // dependencies. Only advertise skill paths that were actually bundled. + console.warn( + `[bundled-plugin-metadata] skipping missing skill path ${sourcePath} (plugin ${params.manifest.id ?? path.basename(params.pluginDir)})`, + ); + continue; + } + const targetPath = ensurePathInsideRoot(params.distPluginDir, normalized); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.cpSync(sourcePath, targetPath, { + dereference: true, + force: true, + recursive: true, + }); + copiedSkills.push(raw); + } + return copiedSkills; +} + export function copyBundledPluginMetadata(params = {}) { - const repoRoot = params.cwd ?? process.cwd(); + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); const extensionsRoot = path.join(repoRoot, "extensions"); const distExtensionsRoot = path.join(repoRoot, "dist", "extensions"); if (!fs.existsSync(extensionsRoot)) { @@ -44,7 +86,12 @@ export function copyBundledPluginMetadata(params = {}) { continue; } - writeTextFileIfChanged(distManifestPath, fs.readFileSync(manifestPath, "utf8")); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const copiedSkills = copyDeclaredPluginSkillPaths({ manifest, pluginDir, distPluginDir }); + const bundledManifest = Array.isArray(manifest.skills) + ? { ...manifest, skills: copiedSkills } + : manifest; + writeTextFileIfChanged(distManifestPath, `${JSON.stringify(bundledManifest, null, 2)}\n`); const packageJsonPath = path.join(pluginDir, "package.json"); if (!fs.existsSync(packageJsonPath)) { diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts new file mode 100644 index 00000000000..46036dc45d9 --- /dev/null +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -0,0 +1,144 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + copyBundledPluginMetadata, + rewritePackageExtensions, +} from "../../scripts/copy-bundled-plugin-metadata.mjs"; + +const tempDirs: string[] = []; + +function makeRepoRoot(prefix: string): string { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(repoRoot); + return repoRoot; +} + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("rewritePackageExtensions", () => { + it("rewrites TypeScript extension entries to built JS paths", () => { + expect(rewritePackageExtensions(["./index.ts", "./nested/entry.mts"])).toEqual([ + "./index.js", + "./nested/entry.js", + ]); + }); +}); + +describe("copyBundledPluginMetadata", () => { + it("copies plugin manifests, package metadata, and local skill directories", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-meta-"); + const pluginDir = path.join(repoRoot, "extensions", "acpx"); + fs.mkdirSync(path.join(pluginDir, "skills", "acp-router"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "skills", "acp-router", "SKILL.md"), + "# ACP Router\n", + "utf8", + ); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), { + id: "acpx", + configSchema: { type: "object" }, + skills: ["./skills"], + }); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/acpx", + openclaw: { extensions: ["./index.ts"] }, + }); + + copyBundledPluginMetadata({ repoRoot }); + + expect( + fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx", "openclaw.plugin.json")), + ).toBe(true); + expect( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "acpx", "skills", "acp-router", "SKILL.md"), + "utf8", + ), + ).toContain("ACP Router"); + const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, "dist", "extensions", "acpx", "package.json"), "utf8"), + ) as { openclaw?: { extensions?: string[] } }; + expect(packageJson.openclaw?.extensions).toEqual(["./index.js"]); + }); + + it("dereferences node_modules-backed skill paths into the bundled dist tree", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-node-modules-"); + const pluginDir = path.join(repoRoot, "extensions", "tlon"); + const storeSkillDir = path.join( + repoRoot, + "node_modules", + ".pnpm", + "@tloncorp+tlon-skill@0.2.2", + "node_modules", + "@tloncorp", + "tlon-skill", + ); + fs.mkdirSync(storeSkillDir, { recursive: true }); + fs.writeFileSync(path.join(storeSkillDir, "SKILL.md"), "# Tlon Skill\n", "utf8"); + fs.mkdirSync(path.join(pluginDir, "node_modules", "@tloncorp"), { recursive: true }); + fs.symlinkSync( + storeSkillDir, + path.join(pluginDir, "node_modules", "@tloncorp", "tlon-skill"), + process.platform === "win32" ? "junction" : "dir", + ); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), { + id: "tlon", + configSchema: { type: "object" }, + skills: ["node_modules/@tloncorp/tlon-skill"], + }); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/tlon", + openclaw: { extensions: ["./index.ts"] }, + }); + + copyBundledPluginMetadata({ repoRoot }); + + const copiedSkillDir = path.join( + repoRoot, + "dist", + "extensions", + "tlon", + "node_modules", + "@tloncorp", + "tlon-skill", + ); + expect(fs.existsSync(path.join(copiedSkillDir, "SKILL.md"))).toBe(true); + expect(fs.lstatSync(copiedSkillDir).isSymbolicLink()).toBe(false); + }); + + it("omits missing declared skill paths from the bundled manifest", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-missing-skill-"); + const pluginDir = path.join(repoRoot, "extensions", "tlon"); + fs.mkdirSync(pluginDir, { recursive: true }); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), { + id: "tlon", + configSchema: { type: "object" }, + skills: ["node_modules/@tloncorp/tlon-skill"], + }); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/tlon", + openclaw: { extensions: ["./index.ts"] }, + }); + + copyBundledPluginMetadata({ repoRoot }); + + const bundledManifest = JSON.parse( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"), + "utf8", + ), + ) as { skills?: string[] }; + expect(bundledManifest.skills).toEqual([]); + }); +});