Skip to content

Commit 7afb2bc

Browse files
authored
Merge branch 'main' into vincentkoc-code/package-main-install-paths
2 parents 2494e16 + 47fd855 commit 7afb2bc

File tree

6 files changed

+251
-4
lines changed

6 files changed

+251
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
3232
- 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)
3333
- 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.
3434
- 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`.
35+
- 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.
3536
- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc.
3637
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc.
3738
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.

extensions/acpx/src/config.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,44 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
13
import path from "node:path";
4+
import { pathToFileURL } from "node:url";
25
import { describe, expect, it } from "vitest";
36
import {
47
ACPX_BUNDLED_BIN,
58
ACPX_PINNED_VERSION,
69
createAcpxPluginConfigSchema,
10+
resolveAcpxPluginRoot,
711
resolveAcpxPluginConfig,
812
} from "./config.js";
913

1014
describe("acpx plugin config parsing", () => {
15+
it("resolves source-layout plugin root from a file under src", () => {
16+
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-source-"));
17+
try {
18+
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
19+
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
20+
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
21+
22+
const moduleUrl = pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href;
23+
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot);
24+
} finally {
25+
fs.rmSync(pluginRoot, { recursive: true, force: true });
26+
}
27+
});
28+
29+
it("resolves bundled-layout plugin root from the dist entry file", () => {
30+
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-dist-"));
31+
try {
32+
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
33+
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
34+
35+
const moduleUrl = pathToFileURL(path.join(pluginRoot, "index.js")).href;
36+
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot);
37+
} finally {
38+
fs.rmSync(pluginRoot, { recursive: true, force: true });
39+
}
40+
});
41+
1142
it("resolves bundled acpx with pinned version by default", () => {
1243
const resolved = resolveAcpxPluginConfig({
1344
rawConfig: {

extensions/acpx/src/config.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from "node:fs";
12
import path from "node:path";
23
import { fileURLToPath } from "node:url";
34
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/acpx";
@@ -11,7 +12,27 @@ export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_PO
1112
export const ACPX_PINNED_VERSION = "0.1.16";
1213
export const ACPX_VERSION_ANY = "any";
1314
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
14-
export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15+
16+
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
17+
let cursor = path.dirname(fileURLToPath(moduleUrl));
18+
for (let i = 0; i < 3; i += 1) {
19+
// Bundled entries live at the plugin root while source files still live under src/.
20+
if (
21+
fs.existsSync(path.join(cursor, "openclaw.plugin.json")) &&
22+
fs.existsSync(path.join(cursor, "package.json"))
23+
) {
24+
return cursor;
25+
}
26+
const parent = path.dirname(cursor);
27+
if (parent === cursor) {
28+
break;
29+
}
30+
cursor = parent;
31+
}
32+
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
33+
}
34+
35+
export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot();
1536
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
1637
export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string {
1738
return `npm install --omit=dev --no-save acpx@${version}`;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function rewritePackageExtensions(entries: unknown): string[] | undefined;
2+
3+
export function copyBundledPluginMetadata(params?: { repoRoot?: string }): void;

scripts/copy-bundled-plugin-metadata.mjs

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path from "node:path";
33
import { pathToFileURL } from "node:url";
44
import { removeFileIfExists, writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
55

6-
function rewritePackageExtensions(entries) {
6+
export function rewritePackageExtensions(entries) {
77
if (!Array.isArray(entries)) {
88
return undefined;
99
}
@@ -17,8 +17,50 @@ function rewritePackageExtensions(entries) {
1717
});
1818
}
1919

20+
function ensurePathInsideRoot(rootDir, rawPath) {
21+
const resolved = path.resolve(rootDir, rawPath);
22+
const relative = path.relative(rootDir, resolved);
23+
if (
24+
relative === "" ||
25+
relative === "." ||
26+
(!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
27+
) {
28+
return resolved;
29+
}
30+
throw new Error(`path escapes plugin root: ${rawPath}`);
31+
}
32+
33+
function copyDeclaredPluginSkillPaths(params) {
34+
const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : [];
35+
const copiedSkills = [];
36+
for (const raw of skills) {
37+
if (typeof raw !== "string" || raw.trim().length === 0) {
38+
continue;
39+
}
40+
const normalized = raw.replace(/^\.\//u, "");
41+
const sourcePath = ensurePathInsideRoot(params.pluginDir, raw);
42+
if (!fs.existsSync(sourcePath)) {
43+
// Some Docker/lightweight builds intentionally omit optional plugin-local
44+
// dependencies. Only advertise skill paths that were actually bundled.
45+
console.warn(
46+
`[bundled-plugin-metadata] skipping missing skill path ${sourcePath} (plugin ${params.manifest.id ?? path.basename(params.pluginDir)})`,
47+
);
48+
continue;
49+
}
50+
const targetPath = ensurePathInsideRoot(params.distPluginDir, normalized);
51+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
52+
fs.cpSync(sourcePath, targetPath, {
53+
dereference: true,
54+
force: true,
55+
recursive: true,
56+
});
57+
copiedSkills.push(raw);
58+
}
59+
return copiedSkills;
60+
}
61+
2062
export function copyBundledPluginMetadata(params = {}) {
21-
const repoRoot = params.cwd ?? process.cwd();
63+
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
2264
const extensionsRoot = path.join(repoRoot, "extensions");
2365
const distExtensionsRoot = path.join(repoRoot, "dist", "extensions");
2466
if (!fs.existsSync(extensionsRoot)) {
@@ -44,7 +86,12 @@ export function copyBundledPluginMetadata(params = {}) {
4486
continue;
4587
}
4688

47-
writeTextFileIfChanged(distManifestPath, fs.readFileSync(manifestPath, "utf8"));
89+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
90+
const copiedSkills = copyDeclaredPluginSkillPaths({ manifest, pluginDir, distPluginDir });
91+
const bundledManifest = Array.isArray(manifest.skills)
92+
? { ...manifest, skills: copiedSkills }
93+
: manifest;
94+
writeTextFileIfChanged(distManifestPath, `${JSON.stringify(bundledManifest, null, 2)}\n`);
4895

4996
const packageJsonPath = path.join(pluginDir, "package.json");
5097
if (!fs.existsSync(packageJsonPath)) {
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import {
6+
copyBundledPluginMetadata,
7+
rewritePackageExtensions,
8+
} from "../../scripts/copy-bundled-plugin-metadata.mjs";
9+
10+
const tempDirs: string[] = [];
11+
12+
function makeRepoRoot(prefix: string): string {
13+
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
14+
tempDirs.push(repoRoot);
15+
return repoRoot;
16+
}
17+
18+
function writeJson(filePath: string, value: unknown): void {
19+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
20+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
21+
}
22+
23+
afterEach(() => {
24+
for (const dir of tempDirs.splice(0, tempDirs.length)) {
25+
fs.rmSync(dir, { recursive: true, force: true });
26+
}
27+
});
28+
29+
describe("rewritePackageExtensions", () => {
30+
it("rewrites TypeScript extension entries to built JS paths", () => {
31+
expect(rewritePackageExtensions(["./index.ts", "./nested/entry.mts"])).toEqual([
32+
"./index.js",
33+
"./nested/entry.js",
34+
]);
35+
});
36+
});
37+
38+
describe("copyBundledPluginMetadata", () => {
39+
it("copies plugin manifests, package metadata, and local skill directories", () => {
40+
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-meta-");
41+
const pluginDir = path.join(repoRoot, "extensions", "acpx");
42+
fs.mkdirSync(path.join(pluginDir, "skills", "acp-router"), { recursive: true });
43+
fs.writeFileSync(
44+
path.join(pluginDir, "skills", "acp-router", "SKILL.md"),
45+
"# ACP Router\n",
46+
"utf8",
47+
);
48+
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
49+
id: "acpx",
50+
configSchema: { type: "object" },
51+
skills: ["./skills"],
52+
});
53+
writeJson(path.join(pluginDir, "package.json"), {
54+
name: "@openclaw/acpx",
55+
openclaw: { extensions: ["./index.ts"] },
56+
});
57+
58+
copyBundledPluginMetadata({ repoRoot });
59+
60+
expect(
61+
fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx", "openclaw.plugin.json")),
62+
).toBe(true);
63+
expect(
64+
fs.readFileSync(
65+
path.join(repoRoot, "dist", "extensions", "acpx", "skills", "acp-router", "SKILL.md"),
66+
"utf8",
67+
),
68+
).toContain("ACP Router");
69+
const packageJson = JSON.parse(
70+
fs.readFileSync(path.join(repoRoot, "dist", "extensions", "acpx", "package.json"), "utf8"),
71+
) as { openclaw?: { extensions?: string[] } };
72+
expect(packageJson.openclaw?.extensions).toEqual(["./index.js"]);
73+
});
74+
75+
it("dereferences node_modules-backed skill paths into the bundled dist tree", () => {
76+
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-node-modules-");
77+
const pluginDir = path.join(repoRoot, "extensions", "tlon");
78+
const storeSkillDir = path.join(
79+
repoRoot,
80+
"node_modules",
81+
".pnpm",
82+
83+
"node_modules",
84+
"@tloncorp",
85+
"tlon-skill",
86+
);
87+
fs.mkdirSync(storeSkillDir, { recursive: true });
88+
fs.writeFileSync(path.join(storeSkillDir, "SKILL.md"), "# Tlon Skill\n", "utf8");
89+
fs.mkdirSync(path.join(pluginDir, "node_modules", "@tloncorp"), { recursive: true });
90+
fs.symlinkSync(
91+
storeSkillDir,
92+
path.join(pluginDir, "node_modules", "@tloncorp", "tlon-skill"),
93+
process.platform === "win32" ? "junction" : "dir",
94+
);
95+
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
96+
id: "tlon",
97+
configSchema: { type: "object" },
98+
skills: ["node_modules/@tloncorp/tlon-skill"],
99+
});
100+
writeJson(path.join(pluginDir, "package.json"), {
101+
name: "@openclaw/tlon",
102+
openclaw: { extensions: ["./index.ts"] },
103+
});
104+
105+
copyBundledPluginMetadata({ repoRoot });
106+
107+
const copiedSkillDir = path.join(
108+
repoRoot,
109+
"dist",
110+
"extensions",
111+
"tlon",
112+
"node_modules",
113+
"@tloncorp",
114+
"tlon-skill",
115+
);
116+
expect(fs.existsSync(path.join(copiedSkillDir, "SKILL.md"))).toBe(true);
117+
expect(fs.lstatSync(copiedSkillDir).isSymbolicLink()).toBe(false);
118+
});
119+
120+
it("omits missing declared skill paths from the bundled manifest", () => {
121+
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-missing-skill-");
122+
const pluginDir = path.join(repoRoot, "extensions", "tlon");
123+
fs.mkdirSync(pluginDir, { recursive: true });
124+
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
125+
id: "tlon",
126+
configSchema: { type: "object" },
127+
skills: ["node_modules/@tloncorp/tlon-skill"],
128+
});
129+
writeJson(path.join(pluginDir, "package.json"), {
130+
name: "@openclaw/tlon",
131+
openclaw: { extensions: ["./index.ts"] },
132+
});
133+
134+
copyBundledPluginMetadata({ repoRoot });
135+
136+
const bundledManifest = JSON.parse(
137+
fs.readFileSync(
138+
path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"),
139+
"utf8",
140+
),
141+
) as { skills?: string[] };
142+
expect(bundledManifest.skills).toEqual([]);
143+
});
144+
});

0 commit comments

Comments
 (0)