mirror of https://github.com/openclaw/openclaw.git
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:
parent
7931f06c00
commit
47fd8558cd
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export function rewritePackageExtensions(entries: unknown): string[] | undefined;
|
||||
|
||||
export function copyBundledPluginMetadata(params?: { repoRoot?: string }): void;
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue