diff --git a/CHANGELOG.md b/CHANGELOG.md index 61abd167cd3..e5a25154cb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/runtime-deps: replace stale symlinked mirror target roots before writing runtime-mirror temp files and skip rewriting already materialized hardlinks, so cross-version container upgrades no longer crash-loop on read-only image-layer paths while warm mirrors do less churn. Fixes #75108; refs #75069. Thanks @coletebou and @xiaohuaxi. - Auto-reply/group chats: fall back to automatic source delivery when a channel precomputes message-tool-only replies but the `message` tool is unavailable, so Discord/Slack-style group turns do not silently complete without a visible reply. Fixes #74868. Thanks @kagura-agent. - Browser/gateway: share one browser control runtime across the HTTP control server and `browser.request`, and refresh browser profile config from the source snapshot, so CLI status/start honors configured `browser.executablePath`, `headless`, and `noSandbox` instead of falling back to stale auto-detection. Fixes #75087; repairs #73617. Thanks @civiltox and @martingarramon. - Agents/subagents: bound automatic orphan recovery with persisted recovery attempts and a wedged-session tombstone, and teach task maintenance/doctor to reconcile those sessions so restart loops no longer require manual `sessions.json` surgery. Fixes #74864. Thanks @solosage1. diff --git a/src/plugins/bundled-runtime-mirror.test.ts b/src/plugins/bundled-runtime-mirror.test.ts index 5f264769e0c..0ac93736a1d 100644 --- a/src/plugins/bundled-runtime-mirror.test.ts +++ b/src/plugins/bundled-runtime-mirror.test.ts @@ -1,8 +1,11 @@ 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"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + materializeBundledRuntimeMirrorFile, + refreshBundledPluginRuntimeMirrorRoot, +} from "./bundled-runtime-mirror.js"; const tempRoots: string[] = []; @@ -13,6 +16,7 @@ function makeTempRoot(): string { } afterEach(() => { + vi.restoreAllMocks(); for (const root of tempRoots.splice(0)) { fs.rmSync(root, { recursive: true, force: true }); } @@ -89,4 +93,50 @@ describe("refreshBundledPluginRuntimeMirrorRoot", () => { expect(fs.lstatSync(path.join(targetRoot, "entry")).isFile()).toBe(true); expect(fs.readFileSync(path.join(targetRoot, "entry"), "utf8")).toContain("2"); }); + + it("replaces stale symlinked mirror roots before creating temp files", () => { + const root = makeTempRoot(); + const sourceRoot = path.join(root, "source"); + const targetRoot = path.join(root, "target"); + const staleRoot = path.join(root, "stale-image-layer"); + fs.mkdirSync(sourceRoot, { recursive: true }); + fs.mkdirSync(staleRoot, { recursive: true }); + fs.writeFileSync(path.join(sourceRoot, "fresh.js"), "export const value = 'fresh';\n", "utf8"); + fs.symlinkSync(staleRoot, targetRoot, "dir"); + + expect( + refreshBundledPluginRuntimeMirrorRoot({ + pluginId: "demo", + sourceRoot, + targetRoot, + }), + ).toBe(true); + + expect(fs.lstatSync(targetRoot).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(path.join(targetRoot, "fresh.js"), "utf8")).toContain("fresh"); + expect(fs.existsSync(path.join(staleRoot, "fresh.js"))).toBe(false); + }); + + it("does not rewrite already materialized hardlinks", () => { + const root = makeTempRoot(); + const sourcePath = path.join(root, "source.js"); + const targetPath = path.join(root, "target.js"); + fs.writeFileSync(sourcePath, "export const value = 1;\n", "utf8"); + fs.linkSync(sourcePath, targetPath); + const linkSpy = vi.spyOn(fs, "linkSync"); + const copySpy = vi.spyOn(fs, "copyFileSync"); + const renameSpy = vi.spyOn(fs, "renameSync"); + + materializeBundledRuntimeMirrorFile(sourcePath, targetPath); + + expect(linkSpy).not.toHaveBeenCalled(); + expect(copySpy).not.toHaveBeenCalled(); + expect(renameSpy).not.toHaveBeenCalled(); + const sourceStat = fs.lstatSync(sourcePath); + const targetStat = fs.lstatSync(targetPath); + expect({ dev: targetStat.dev, ino: targetStat.ino }).toEqual({ + dev: sourceStat.dev, + ino: sourceStat.ino, + }); + }); }); diff --git a/src/plugins/bundled-runtime-mirror.ts b/src/plugins/bundled-runtime-mirror.ts index e382d17cad5..c5b88e13a58 100644 --- a/src/plugins/bundled-runtime-mirror.ts +++ b/src/plugins/bundled-runtime-mirror.ts @@ -47,7 +47,7 @@ export function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: str if (path.resolve(sourceRoot) === path.resolve(targetRoot)) { return; } - fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 }); + ensureBundledRuntimeMirrorDirectory(targetRoot); const mirroredNames = new Set(); for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) { if (shouldIgnoreBundledRuntimeMirrorEntry(entry.name)) { @@ -81,16 +81,13 @@ export function materializeBundledRuntimeMirrorFile(sourcePath: string, targetPa return; } try { - if ( - fs.realpathSync(sourcePath) === fs.realpathSync(targetPath) && - !fs.lstatSync(targetPath).isSymbolicLink() - ) { + if (isBundledRuntimeMirrorFileAlreadyMaterialized(sourcePath, targetPath)) { return; } } catch { // Missing targets are expected before the mirror file is materialized. } - fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o755 }); + ensureBundledRuntimeMirrorDirectory(path.dirname(targetPath)); removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "file"); const tempPath = createBundledRuntimeMirrorTempPath(targetPath); try { @@ -107,6 +104,20 @@ export function materializeBundledRuntimeMirrorFile(sourcePath: string, targetPa } } +function isBundledRuntimeMirrorFileAlreadyMaterialized( + sourcePath: string, + targetPath: string, +): boolean { + const sourceStat = fs.lstatSync(sourcePath); + const targetStat = fs.lstatSync(targetPath); + return ( + sourceStat.isFile() && + targetStat.isFile() && + sourceStat.dev === targetStat.dev && + sourceStat.ino === targetStat.ino + ); +} + function chmodBundledRuntimeMirrorFileReadable(sourcePath: string, targetPath: string): void { try { const sourceMode = fs.statSync(sourcePath).mode; @@ -131,6 +142,21 @@ function pruneStaleBundledRuntimeMirrorEntries( } } +function ensureBundledRuntimeMirrorDirectory(targetRoot: string): void { + try { + const stat = fs.lstatSync(targetRoot); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + return; + } + fs.rmSync(targetRoot, { recursive: true, force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 }); +} + function removeBundledRuntimeMirrorPathIfTypeChanged( targetPath: string, expectedType: "directory" | "file" | "symlink", @@ -153,7 +179,7 @@ function removeBundledRuntimeMirrorPathIfTypeChanged( } function replaceBundledRuntimeMirrorSymlinkAtomic(linkTarget: string, targetPath: string): void { - fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o755 }); + ensureBundledRuntimeMirrorDirectory(path.dirname(targetPath)); const tempPath = createBundledRuntimeMirrorTempPath(targetPath); try { fs.symlinkSync(linkTarget, tempPath); @@ -164,7 +190,7 @@ function replaceBundledRuntimeMirrorSymlinkAtomic(linkTarget: string, targetPath } function copyBundledRuntimeMirrorFileAtomic(sourcePath: string, targetPath: string): void { - fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o755 }); + ensureBundledRuntimeMirrorDirectory(path.dirname(targetPath)); const tempPath = createBundledRuntimeMirrorTempPath(targetPath); try { fs.copyFileSync(sourcePath, tempPath);