mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user