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
This commit is contained in:
Nimrod Gutman 2026-03-15 23:00:30 +02:00 committed by GitHub
parent 7931f06c00
commit 47fd8558cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 251 additions and 4 deletions

View File

@ -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.

View File

@ -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: {

View File

@ -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}`;

View File

@ -0,0 +1,3 @@
export function rewritePackageExtensions(entries: unknown): string[] | undefined;
export function copyBundledPluginMetadata(params?: { repoRoot?: string }): void;

View File

@ -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)) {

View File

@ -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([]);
});
});