diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c3f654a0c9..d56a20ac995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Plugins/startup: treat manifestless Claude bundles as valid installed-plugin registry entries instead of stale missing manifests, so workspace bundles no longer force repeated derived registry rebuilds or noisy `plugins.entries.workspace` warnings during Gateway startup. Fixes #73433. Thanks @AnneVoss. - Agents/subagents: preserve `sessions_yield` as a paused subagent state and ignore its wait text while freezing completion output, so parent sessions wait for the final post-compaction answer instead of receiving intermediate progress or `(no output)`. Fixes #73413. Thanks @Ask-sola. - Plugins/startup: precompute bundled runtime mirror fingerprints before taking the mirror lock and keep Docker bundled plugin runtime deps/mirrors in a Docker-managed volume instead of the Windows/WSL config bind mount, so cold starts avoid slow host-volume mirror writes. Fixes #73339. Thanks @1yihui. +- Plugins/runtime deps: refresh bundled runtime mirrors without deleting active import trees, so config-triggered restarts do not see transient missing plugin files during registration. Thanks @shakkernerd. - Channels/LINE: persist inbound image, video, audio, and file downloads in `~/.openclaw/media/inbound/` instead of temporary files so agents can still read LINE media after `/tmp` cleanup. Fixes #73370. Thanks @hijirii and @wenxu007. - CLI/plugins: keep bundled plugin installs out of `plugins.load.paths` while preserving install records, so install/inspect/doctor loops no longer warn about the current bundled plugin directory. Thanks @vincentkoc. - CLI/plugins: scope `plugins inspect ` runtime loading to the matched plugin so single-plugin inspection does not load every plugin before checking the target. Thanks @shakkernerd. diff --git a/src/plugins/bundled-runtime-mirror.test.ts b/src/plugins/bundled-runtime-mirror.test.ts new file mode 100644 index 00000000000..31986c47a4c --- /dev/null +++ b/src/plugins/bundled-runtime-mirror.test.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { refreshBundledPluginRuntimeMirrorRoot } from "./bundled-runtime-mirror.js"; + +const tempRoots: string[] = []; + +function makeTempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-runtime-mirror-")); + tempRoots.push(root); + return root; +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("refreshBundledPluginRuntimeMirrorRoot", () => { + it("refreshes stale mirrors without deleting the active target root", () => { + const root = makeTempRoot(); + const sourceRoot = path.join(root, "source"); + const targetRoot = path.join(root, "target"); + fs.mkdirSync(sourceRoot, { recursive: true }); + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(path.join(sourceRoot, "index.js"), "export const value = 'v1';\n", "utf8"); + + expect( + refreshBundledPluginRuntimeMirrorRoot({ + pluginId: "demo", + sourceRoot, + targetRoot, + }), + ).toBe(true); + + fs.writeFileSync(path.join(sourceRoot, "index.js"), "export const value = 'v2';\n", "utf8"); + fs.writeFileSync(path.join(targetRoot, "inflight-import.js"), "still readable\n", "utf8"); + + expect( + refreshBundledPluginRuntimeMirrorRoot({ + pluginId: "demo", + sourceRoot, + targetRoot, + }), + ).toBe(true); + + expect(fs.readFileSync(path.join(targetRoot, "index.js"), "utf8")).toContain("v2"); + expect(fs.readFileSync(path.join(targetRoot, "inflight-import.js"), "utf8")).toBe( + "still readable\n", + ); + }); +}); diff --git a/src/plugins/bundled-runtime-mirror.ts b/src/plugins/bundled-runtime-mirror.ts index b9215a9f3d4..36506424e04 100644 --- a/src/plugins/bundled-runtime-mirror.ts +++ b/src/plugins/bundled-runtime-mirror.ts @@ -31,22 +31,9 @@ export function refreshBundledPluginRuntimeMirrorRoot(params: { if (isBundledRuntimeMirrorRootFresh(params.targetRoot, metadata)) { return false; } - const tempDir = fs.mkdtempSync( - path.join( - params.tempDirParent ?? path.dirname(params.targetRoot), - `.plugin-${sanitizeBundledRuntimeMirrorTempId(params.pluginId)}-`, - ), - ); - const stagedRoot = path.join(tempDir, "plugin"); - try { - copyBundledPluginRuntimeRoot(params.sourceRoot, stagedRoot); - writeBundledRuntimeMirrorMetadata(stagedRoot, metadata); - fs.rmSync(params.targetRoot, { recursive: true, force: true }); - fs.renameSync(stagedRoot, params.targetRoot); - return true; - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } + copyBundledPluginRuntimeRoot(params.sourceRoot, params.targetRoot); + writeBundledRuntimeMirrorMetadata(params.targetRoot, metadata); + return true; } export function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: string): void { @@ -54,24 +41,29 @@ export function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: str return; } fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 }); + const mirroredNames = new Set(); for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) { if (shouldIgnoreBundledRuntimeMirrorEntry(entry.name)) { continue; } + if (!entry.isDirectory() && !entry.isSymbolicLink() && !entry.isFile()) { + continue; + } + mirroredNames.add(entry.name); const sourcePath = path.join(sourceRoot, entry.name); const targetPath = path.join(targetRoot, entry.name); if (entry.isDirectory()) { + removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "directory"); copyBundledPluginRuntimeRoot(sourcePath, targetPath); continue; } if (entry.isSymbolicLink()) { - fs.symlinkSync(fs.readlinkSync(sourcePath), targetPath); + removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "symlink"); + replaceBundledRuntimeMirrorSymlinkAtomic(fs.readlinkSync(sourcePath), targetPath); continue; } - if (!entry.isFile()) { - continue; - } - fs.copyFileSync(sourcePath, targetPath); + removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "file"); + copyBundledRuntimeMirrorFileAtomic(sourcePath, targetPath); try { const sourceMode = fs.statSync(sourcePath).mode; fs.chmodSync(targetPath, sourceMode | 0o600); @@ -79,6 +71,69 @@ export function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: str // Readable copied files are enough for plugin loading. } } + pruneStaleBundledRuntimeMirrorEntries(targetRoot, mirroredNames); +} + +function pruneStaleBundledRuntimeMirrorEntries(targetRoot: string, mirroredNames: Set): void { + for (const entry of fs.readdirSync(targetRoot, { withFileTypes: true })) { + if (shouldIgnoreBundledRuntimeMirrorEntry(entry.name)) { + continue; + } + if (mirroredNames.has(entry.name)) { + continue; + } + fs.rmSync(path.join(targetRoot, entry.name), { recursive: true, force: true }); + } +} + +function removeBundledRuntimeMirrorPathIfTypeChanged( + targetPath: string, + expectedType: "directory" | "file" | "symlink", +): void { + let stat: fs.Stats; + try { + stat = fs.lstatSync(targetPath); + } catch { + return; + } + const matches = + expectedType === "directory" + ? stat.isDirectory() + : expectedType === "symlink" + ? stat.isSymbolicLink() + : stat.isFile(); + if (!matches) { + fs.rmSync(targetPath, { recursive: true, force: true }); + } +} + +function replaceBundledRuntimeMirrorSymlinkAtomic(linkTarget: string, targetPath: string): void { + fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o755 }); + const tempPath = createBundledRuntimeMirrorTempPath(targetPath); + try { + fs.symlinkSync(linkTarget, tempPath); + fs.renameSync(tempPath, targetPath); + } finally { + fs.rmSync(tempPath, { force: true }); + } +} + +function copyBundledRuntimeMirrorFileAtomic(sourcePath: string, targetPath: string): void { + fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o755 }); + const tempPath = createBundledRuntimeMirrorTempPath(targetPath); + try { + fs.copyFileSync(sourcePath, tempPath); + fs.renameSync(tempPath, targetPath); + } finally { + fs.rmSync(tempPath, { force: true }); + } +} + +function createBundledRuntimeMirrorTempPath(targetPath: string): string { + return path.join( + path.dirname(targetPath), + `.openclaw-mirror-${process.pid}-${process.hrtime.bigint()}-${path.basename(targetPath)}.tmp`, + ); } export function precomputeBundledRuntimeMirrorMetadata(params: { @@ -235,7 +290,3 @@ function resolveBundledRuntimeMirrorSourceRootId(sourceRoot: string): string { function shouldIgnoreBundledRuntimeMirrorEntry(name: string): boolean { return name === "node_modules" || name === BUNDLED_RUNTIME_MIRROR_METADATA_FILE; } - -function sanitizeBundledRuntimeMirrorTempId(pluginId: string): string { - return pluginId.replaceAll(/[^a-zA-Z0-9._-]/g, "_"); -}