diff --git a/CHANGELOG.md b/CHANGELOG.md index 1182205ee12..d543501ae4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index ee74ab0dcb0..56d44f1f44f 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -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); diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index d9713959780..bda39188175 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -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; } diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index ad74c6be129..a1920506680 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -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(); + }, + ); }); diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index 4d7838e63ea..378b83927a0 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -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: { diff --git a/test/scripts/stage-bundled-plugin-runtime.test.ts b/test/scripts/stage-bundled-plugin-runtime.test.ts index 21f57661b3e..702a8ef112e 100644 --- a/test/scripts/stage-bundled-plugin-runtime.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime.test.ts @@ -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(); });