fix(build): harden bundled plugin runtime staging

Copy bundled plugin skill trees into dist-runtime, broaden Windows symlink-copy fallbacks, and harden runtime-deps fingerprinting.
This commit is contained in:
Vincent Koc
2026-04-25 04:27:17 -07:00
committed by GitHub
parent f408bba9de
commit 443b837bd5
6 changed files with 169 additions and 35 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Agents/replies: let pending group chat history trigger bare mentioned turns without treating metadata-only inbound context as user input. Fixes #71489. (#71520) Thanks @SymbolStar.
- Google media generation: strip a configured trailing `/v1beta` from Google music/video provider base URLs before calling the Google GenAI SDK, preventing doubled `/v1beta/v1beta` paths. Fixes #63240. (#63258) Thanks @Hybirdss.
- Discord: restore direct-message voice-note preflight transcription and classify URL-only Ogg/Opus voice attachments as audio while skipping partial attachments without usable URLs. Fixes #61314 and #64803.
- Plugins/build: copy bundled plugin skill trees into `dist-runtime`, broaden Windows symlink-copy fallbacks, and fingerprint runtime dependencies from `lstat` so symlink-like directory entries cannot crash staging.
- Google Chat: preserve reply text when a typing indicator message is deleted or can no longer be updated, so media captions and first text chunks are resent instead of silently disappearing. (#71498) Thanks @colin-lgtm.
- Cron: tolerate malformed legacy job rows in startup, main-session system-event payloads, and human-readable `cron list` output so missing `state`, `payload.text`, or display fields no longer crash the scheduler or CLI. Fixes #66016, #65916, #64137, #57872, #59968, #63813, #52804, and #43163. (#71509) Thanks @vincentkoc.
- CLI/models: make `openclaw models scan` fall back to public OpenRouter free-model metadata when no `OPENROUTER_API_KEY` is configured, avoid config secret resolution for explicit `--no-probe` scans, and apply the scan timeout to the OpenRouter catalog request.

View File

@@ -457,16 +457,17 @@ function appendDirectoryFingerprint(hash, rootDir, currentDir = rootDir) {
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
if (entry.isSymbolicLink()) {
const stats = fs.lstatSync(fullPath);
if (stats.isSymbolicLink()) {
hash.update(`symlink:${relativePath}->${fs.readlinkSync(fullPath).replace(/\\/g, "/")}\n`);
continue;
}
if (entry.isDirectory()) {
if (stats.isDirectory()) {
hash.update(`dir:${relativePath}\n`);
appendDirectoryFingerprint(hash, rootDir, fullPath);
continue;
}
if (!entry.isFile()) {
if (!stats.isFile()) {
continue;
}
const stat = fs.statSync(fullPath);

View File

@@ -15,7 +15,11 @@ function relativeSymlinkTarget(sourcePath, targetPath) {
function shouldFallbackToCopy(error) {
return (
process.platform === "win32" &&
(error?.code === "EPERM" || error?.code === "EINVAL" || error?.code === "UNKNOWN")
(error?.code === "EACCES" ||
error?.code === "EINVAL" ||
error?.code === "ENOSYS" ||
error?.code === "EPERM" ||
error?.code === "UNKNOWN")
);
}
@@ -128,15 +132,23 @@ function shouldWrapRuntimeJsFile(sourcePath) {
return path.extname(sourcePath) === ".js";
}
function shouldCopyRuntimeFile(sourcePath) {
const relativePath = sourcePath.replace(/\\/g, "/");
function isBundledSkillRuntimePath(relativePath) {
return relativePath === "skills" || relativePath.startsWith("skills/");
}
function isPathOrNestedPath(relativePath, nestedPath) {
return relativePath === nestedPath || relativePath.endsWith(`/${nestedPath}`);
}
function shouldCopyRuntimeFile(relativePath) {
return (
relativePath.endsWith("/package.json") ||
relativePath.endsWith("/openclaw.plugin.json") ||
relativePath.endsWith("/.codex-plugin/plugin.json") ||
relativePath.endsWith("/.claude-plugin/plugin.json") ||
relativePath.endsWith("/.cursor-plugin/plugin.json") ||
relativePath.endsWith("/SKILL.md")
isBundledSkillRuntimePath(relativePath) ||
isPathOrNestedPath(relativePath, "package.json") ||
isPathOrNestedPath(relativePath, "openclaw.plugin.json") ||
isPathOrNestedPath(relativePath, ".codex-plugin/plugin.json") ||
isPathOrNestedPath(relativePath, ".claude-plugin/plugin.json") ||
isPathOrNestedPath(relativePath, ".cursor-plugin/plugin.json") ||
isPathOrNestedPath(relativePath, "SKILL.md")
);
}
@@ -175,7 +187,7 @@ function writeRuntimeModuleWrapper(sourcePath, targetPath) {
);
}
function stagePluginRuntimeOverlay(sourceDir, targetDir) {
function stagePluginRuntimeOverlay(sourceDir, targetDir, relativeDir = "") {
fs.mkdirSync(targetDir, { recursive: true });
for (const dirent of fs.readdirSync(sourceDir, { withFileTypes: true })) {
@@ -185,13 +197,18 @@ function stagePluginRuntimeOverlay(sourceDir, targetDir) {
const sourcePath = path.join(sourceDir, dirent.name);
const targetPath = path.join(targetDir, dirent.name);
const relativePath = path.join(relativeDir, dirent.name).replace(/\\/g, "/");
if (dirent.isDirectory()) {
stagePluginRuntimeOverlay(sourcePath, targetPath);
stagePluginRuntimeOverlay(sourcePath, targetPath, relativePath);
continue;
}
if (dirent.isSymbolicLink()) {
if (isBundledSkillRuntimePath(relativePath)) {
copyPathFallback(sourcePath, targetPath);
continue;
}
ensureSymlink(fs.readlinkSync(sourcePath), targetPath, undefined, sourcePath);
continue;
}
@@ -205,7 +222,7 @@ function stagePluginRuntimeOverlay(sourceDir, targetDir) {
continue;
}
if (shouldCopyRuntimeFile(sourcePath)) {
if (shouldCopyRuntimeFile(relativePath)) {
fs.copyFileSync(sourcePath, targetPath);
continue;
}

View File

@@ -384,6 +384,35 @@ describe("stageBundledPluginRuntime", () => {
expect(fs.readFileSync(runtimePackagePath, "utf8")).toContain('"extensions": [');
});
it("copies bundled plugin skill trees into the runtime overlay", () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-skills-");
createDistPluginDir(repoRoot, "feishu");
setupRepoFiles(repoRoot, {
[bundledDistPluginFile("feishu", "index.js")]: "export default {}\n",
[bundledDistPluginFile("feishu", "skills/feishu-doc/SKILL.md")]:
"---\nname: feishu-doc\n---\n",
[bundledDistPluginFile("feishu", "skills/feishu-doc/fixture.txt")]: "# Feishu Doc\n",
});
stageBundledPluginRuntime({ repoRoot });
const runtimeRoot = path.join(repoRoot, "dist-runtime");
const runtimeSkillPath = path.join(
runtimeRoot,
"extensions",
"feishu",
"skills",
"feishu-doc",
"fixture.txt",
);
expect(fs.lstatSync(runtimeSkillPath).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(runtimeSkillPath, "utf8")).toBe("# Feishu Doc\n");
expect(path.relative(fs.realpathSync(runtimeRoot), fs.realpathSync(runtimeSkillPath))).toBe(
path.join("extensions", "feishu", "skills", "feishu-doc", "fixture.txt"),
);
});
it("preserves package metadata needed for bundled plugin discovery from dist-runtime", () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-discovery-");
const runtimeExtensionsDir = path.join(repoRoot, "dist-runtime", "extensions");
@@ -480,13 +509,13 @@ describe("stageBundledPluginRuntime", () => {
createDistPluginDir(repoRoot, "feishu");
setupRepoFiles(repoRoot, {
[bundledDistPluginFile("feishu", "index.js")]: "export default {}\n",
[bundledDistPluginFile("feishu", "skills/feishu-doc/fixture.txt")]: "# Feishu Doc\n",
[bundledDistPluginFile("feishu", "assets/fixture.txt")]: "# Feishu Doc\n",
});
const realSymlinkSync = fs.symlinkSync.bind(fs);
const symlinkSpy = vi.spyOn(fs, "symlinkSync").mockImplementation(((target, link, type) => {
const linkPath = String(link);
if (linkPath.endsWith(path.join("skills", "feishu-doc", "fixture.txt"))) {
if (linkPath.endsWith(path.join("assets", "fixture.txt"))) {
const err = Object.assign(new Error("file already exists"), { code: "EEXIST" });
realSymlinkSync(String(target), linkPath, type);
throw err;
@@ -496,18 +525,55 @@ describe("stageBundledPluginRuntime", () => {
expect(() => stageBundledPluginRuntime({ repoRoot })).not.toThrow();
const runtimeSkillPath = path.join(
const runtimeAssetPath = path.join(
repoRoot,
"dist-runtime",
"extensions",
"feishu",
"skills",
"feishu-doc",
"assets",
"fixture.txt",
);
expect(fs.lstatSync(runtimeSkillPath).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(runtimeSkillPath, "utf8")).toBe("# Feishu Doc\n");
expect(fs.lstatSync(runtimeAssetPath).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(runtimeAssetPath, "utf8")).toBe("# Feishu Doc\n");
symlinkSpy.mockRestore();
});
it.each(["EACCES", "ENOSYS"] as const)(
"falls back to copying runtime assets when Windows symlink creation fails with %s",
(code) => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-win-copy-");
createDistPluginDir(repoRoot, "feishu");
setupRepoFiles(repoRoot, {
[bundledDistPluginFile("feishu", "index.js")]: "export default {}\n",
[bundledDistPluginFile("feishu", "assets/fixture.txt")]: "# Feishu Doc\n",
});
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const realSymlinkSync = fs.symlinkSync.bind(fs);
const symlinkSpy = vi.spyOn(fs, "symlinkSync").mockImplementation(((target, link, type) => {
const linkPath = String(link);
if (linkPath.endsWith(path.join("assets", "fixture.txt"))) {
throw Object.assign(new Error("symlink failed"), { code });
}
return realSymlinkSync(String(target), linkPath, type);
}) as typeof fs.symlinkSync);
stageBundledPluginRuntime({ repoRoot });
const runtimeAssetPath = path.join(
repoRoot,
"dist-runtime",
"extensions",
"feishu",
"assets",
"fixture.txt",
);
expect(fs.lstatSync(runtimeAssetPath).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(runtimeAssetPath, "utf8")).toBe("# Feishu Doc\n");
symlinkSpy.mockRestore();
platformSpy.mockRestore();
},
);
});

View File

@@ -332,6 +332,64 @@ describe("stageBundledPluginRuntimeDeps", () => {
).toBe("module.exports = 'second';\n");
});
it("fingerprints regular files when readdir reports symlink-like entries", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {
name: "@openclaw/fixture-plugin",
version: "1.0.0",
dependencies: { direct: "1.0.0" },
openclaw: { bundle: { stageRuntimeDependencies: true } },
},
});
const directDir = path.join(repoRoot, "node_modules", "direct");
fs.mkdirSync(directDir, { recursive: true });
fs.writeFileSync(
path.join(directDir, "package.json"),
'{ "name": "direct", "version": "1.0.0" }\n',
"utf8",
);
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8");
const realReaddirSync = fs.readdirSync.bind(fs);
vi.spyOn(fs, "readdirSync").mockImplementation(((target, options) => {
const result = realReaddirSync(target, options as never);
if (
String(target) !== directDir ||
typeof options !== "object" ||
options === null ||
!("withFileTypes" in options) ||
options.withFileTypes !== true
) {
return result;
}
return (result as fs.Dirent[]).map((entry) => {
if (entry.name !== "package.json") {
return entry;
}
return {
...entry,
isSymbolicLink: () => true,
isDirectory: () => false,
isFile: () => false,
} as fs.Dirent;
}) as never;
}) as typeof fs.readdirSync);
let installCount = 0;
stageBundledPluginRuntimeDeps({
cwd: repoRoot,
installPluginRuntimeDepsImpl: () => {
installCount += 1;
throw new Error("unexpected fallback install");
},
});
expect(installCount).toBe(0);
expect(
fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"),
).toBe("module.exports = 'direct';\n");
});
it("refuses to replace a symlinked plugin node_modules directory", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {

View File

@@ -20,17 +20,9 @@ describe("stageBundledPluginRuntime", () => {
it("copies files when Windows rejects runtime overlay symlinks", async () => {
await withTempDir(async (repoRoot) => {
const sourceFile = path.join(
repoRoot,
"dist",
"extensions",
"acpx",
"skills",
"acp-router",
"fixture.txt",
);
const sourceFile = path.join(repoRoot, "dist", "extensions", "acpx", "assets", "fixture.txt");
await fs.promises.mkdir(path.dirname(sourceFile), { recursive: true });
await fs.promises.writeFile(sourceFile, "skill-body\n", "utf8");
await fs.promises.writeFile(sourceFile, "asset-body\n", "utf8");
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const symlinkSpy = vi
@@ -54,11 +46,10 @@ describe("stageBundledPluginRuntime", () => {
"dist-runtime",
"extensions",
"acpx",
"skills",
"acp-router",
"assets",
"fixture.txt",
);
expect(await fs.promises.readFile(runtimeFile, "utf8")).toBe("skill-body\n");
expect(await fs.promises.readFile(runtimeFile, "utf8")).toBe("asset-body\n");
expect(fs.lstatSync(runtimeFile).isSymbolicLink()).toBe(false);
expect(symlinkSpy).toHaveBeenCalled();
});