fix(plugins): harden runtime mirrors

This commit is contained in:
Peter Steinberger
2026-04-30 15:55:52 +01:00
parent a3228977fb
commit eb8e892df9
3 changed files with 87 additions and 10 deletions

View File

@@ -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.

View File

@@ -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,
});
});
});

View File

@@ -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<string>();
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);