Skip to content

Commit c6f5725

Browse files
committed
fix(openshell): pin local mirror fs mutations
1 parent f47fb91 commit c6f5725

2 files changed

Lines changed: 446 additions & 9 deletions

File tree

extensions/openshell/src/fs-bridge.ts

Lines changed: 181 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,18 @@ import type {
88
SandboxResolvedPath,
99
} from "openclaw/plugin-sdk/sandbox";
1010
import { createWritableRenameTargetResolver } from "openclaw/plugin-sdk/sandbox";
11-
import { isPathInside } from "openclaw/plugin-sdk/security-runtime";
11+
import { FsSafeError, isPathInside } from "openclaw/plugin-sdk/security-runtime";
1212
import type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js";
13-
import { movePathWithCopyFallback } from "./mirror.js";
1413

1514
type ResolvedMountPath = SandboxResolvedPath & {
1615
mountHostRoot: string;
1716
writable: boolean;
1817
source: "workspace" | "agent" | "protectedSkill";
1918
};
2019

20+
type FsSafeRoot = Awaited<ReturnType<typeof fsRoot>>;
21+
type FsSafeStat = Awaited<ReturnType<FsSafeRoot["stat"]>>;
22+
2123
const MATERIALIZED_SKILLS_CONTAINER_PARTS = [".openclaw", "sandbox-skills", "skills"] as const;
2224

2325
export function createOpenShellFsBridge(params: {
@@ -117,7 +119,7 @@ class OpenShellFsBridge implements SandboxFsBridge {
117119
allowFinalSymlinkForUnlink: false,
118120
});
119121
await this.backend.mkdirpRemotePath(target.containerPath, params.signal);
120-
await fsPromises.mkdir(hostPath, { recursive: true });
122+
await mkdirLocalRootPath({ hostPath, target });
121123
}
122124

123125
async remove(params: {
@@ -141,9 +143,11 @@ class OpenShellFsBridge implements SandboxFsBridge {
141143
signal: params.signal,
142144
ignoreMissing: params.force !== false,
143145
});
144-
await fsPromises.rm(hostPath, {
145-
recursive: params.recursive ?? false,
146-
force: params.force !== false,
146+
await removeLocalRootPath({
147+
force: params.force,
148+
hostPath,
149+
recursive: params.recursive,
150+
target,
147151
});
148152
}
149153

@@ -168,9 +172,17 @@ class OpenShellFsBridge implements SandboxFsBridge {
168172
allowMissingLeaf: true,
169173
allowFinalSymlinkForUnlink: false,
170174
});
175+
await assertRenameSourceSupported(fromHostPath);
176+
if (from.mountHostRoot !== to.mountHostRoot) {
177+
throw new Error("OpenShell cross-root mirror renames require pinned fs-safe support");
178+
}
179+
await assertSameDeviceRenameSupported({
180+
fromHostPath,
181+
root: from.mountHostRoot,
182+
toHostPath,
183+
});
171184
await this.backend.renameRemotePath(from.containerPath, to.containerPath, params.signal);
172-
await fsPromises.mkdir(path.dirname(toHostPath), { recursive: true });
173-
await movePathWithCopyFallback({ from: fromHostPath, to: toHostPath });
185+
await moveLocalRootPath({ from, fromHostPath, to, toHostPath });
174186
}
175187

176188
async stat(params: {
@@ -343,6 +355,162 @@ class OpenShellFsBridge implements SandboxFsBridge {
343355
}
344356
}
345357

358+
async function mkdirLocalRootPath(params: {
359+
target: ResolvedMountPath;
360+
hostPath: string;
361+
}): Promise<void> {
362+
const relativePath = relativeToRoot(params.target, params.hostPath);
363+
if (!relativePath) {
364+
return;
365+
}
366+
const root = await fsRoot(params.target.mountHostRoot);
367+
await root.mkdir(relativePath);
368+
}
369+
370+
async function removeLocalRootPath(params: {
371+
target: ResolvedMountPath;
372+
hostPath: string;
373+
recursive?: boolean;
374+
force?: boolean;
375+
}): Promise<void> {
376+
const root = await fsRoot(params.target.mountHostRoot);
377+
const relativePath = relativeToRoot(params.target, params.hostPath);
378+
try {
379+
if (params.force === false) {
380+
await fsPromises.lstat(params.hostPath);
381+
}
382+
if (params.recursive) {
383+
const stats = await fsPromises.lstat(params.hostPath).catch((err: unknown) => {
384+
if (isNotFoundError(err)) {
385+
return null;
386+
}
387+
throw err;
388+
});
389+
if (stats?.isSymbolicLink()) {
390+
await root.remove(relativePath);
391+
return;
392+
}
393+
await removeRootTree(root, relativePath);
394+
return;
395+
}
396+
await root.remove(relativePath);
397+
} catch (err) {
398+
if (params.force !== false && isNotFoundError(err)) {
399+
return;
400+
}
401+
throw err;
402+
}
403+
}
404+
405+
async function removeRootTree(
406+
root: FsSafeRoot,
407+
relativePath: string,
408+
knownStats?: FsSafeStat,
409+
): Promise<void> {
410+
const stats = knownStats ?? (await root.stat(relativePath));
411+
if (stats.isDirectory && !stats.isSymbolicLink) {
412+
const entries = await root.list(relativePath, { withFileTypes: true });
413+
for (const entry of entries) {
414+
await removeRootTree(root, path.join(relativePath, entry.name), entry);
415+
}
416+
if (!relativePath) {
417+
return;
418+
}
419+
}
420+
await root.remove(relativePath);
421+
}
422+
423+
async function moveLocalRootPath(params: {
424+
from: ResolvedMountPath;
425+
fromHostPath: string;
426+
to: ResolvedMountPath;
427+
toHostPath: string;
428+
}): Promise<void> {
429+
const root = await fsRoot(params.from.mountHostRoot);
430+
const fromRelativePath = relativeToRoot(params.from, params.fromHostPath);
431+
const toRelativePath = relativeToRoot(params.to, params.toHostPath);
432+
await mkdirParentPath(root, toRelativePath);
433+
await root.move(fromRelativePath, toRelativePath, { overwrite: true });
434+
}
435+
436+
async function mkdirParentPath(root: FsSafeRoot, relativePath: string): Promise<void> {
437+
const parentPath = path.dirname(relativePath);
438+
if (parentPath === "." || parentPath === "") {
439+
return;
440+
}
441+
await root.mkdir(parentPath);
442+
}
443+
444+
function relativeToRoot(target: ResolvedMountPath, hostPath: string): string {
445+
const relativePath = path.relative(target.mountHostRoot, hostPath);
446+
return relativePath === "." ? "" : relativePath;
447+
}
448+
449+
async function assertRenameSourceSupported(fromHostPath: string): Promise<void> {
450+
const stats = await fsPromises.lstat(fromHostPath);
451+
if (stats.isSymbolicLink()) {
452+
throw new Error("Sandbox symlink rename sources are not supported by the local mirror bridge");
453+
}
454+
if (stats.isFile() && stats.nlink > 1) {
455+
throw new Error(
456+
"Sandbox hardlinked rename sources are not supported by the local mirror bridge",
457+
);
458+
}
459+
}
460+
461+
async function assertSameDeviceRenameSupported(params: {
462+
fromHostPath: string;
463+
root: string;
464+
toHostPath: string;
465+
}): Promise<void> {
466+
const sourceStats = await fsPromises.lstat(params.fromHostPath);
467+
const destinationParentStats = await nearestExistingDirectoryStats({
468+
root: params.root,
469+
targetPath: path.dirname(params.toHostPath),
470+
});
471+
if (sourceStats.dev !== destinationParentStats.dev) {
472+
throw new Error("OpenShell cross-device mirror renames require pinned fs-safe support");
473+
}
474+
}
475+
476+
async function nearestExistingDirectoryStats(params: {
477+
root: string;
478+
targetPath: string;
479+
}): Promise<Awaited<ReturnType<typeof fsPromises.lstat>>> {
480+
const rootPath = path.resolve(params.root);
481+
let cursor = path.resolve(params.targetPath);
482+
while (isPathInside(rootPath, cursor)) {
483+
const stats = await fsPromises.lstat(cursor).catch((err: unknown) => {
484+
if (isNotFoundError(err)) {
485+
return null;
486+
}
487+
throw err;
488+
});
489+
if (stats) {
490+
if (!stats.isDirectory()) {
491+
throw new Error(`Sandbox rename destination parent is not a directory: ${cursor}`);
492+
}
493+
return stats;
494+
}
495+
const next = path.dirname(cursor);
496+
if (next === cursor) {
497+
break;
498+
}
499+
cursor = next;
500+
}
501+
return await fsPromises.lstat(rootPath);
502+
}
503+
504+
function isNotFoundError(err: unknown): boolean {
505+
return (
506+
(err instanceof FsSafeError && err.code === "not-found") ||
507+
(typeof err === "object" &&
508+
err !== null &&
509+
"code" in err &&
510+
(err as { code?: unknown }).code === "ENOENT")
511+
);
512+
}
513+
346514
function resolveProtectedSkillTarget(params: {
347515
input: string;
348516
skillsRoot: string;
@@ -421,7 +589,11 @@ async function assertLocalPathSafety(params: {
421589
const canonicalRoot = await fsPromises
422590
.realpath(params.root)
423591
.catch(() => path.resolve(params.root));
424-
const candidate = await resolveCanonicalCandidate(params.target.hostPath);
592+
const targetStats = await fsPromises.lstat(params.target.hostPath).catch(() => null);
593+
const candidate =
594+
params.allowFinalSymlinkForUnlink && targetStats?.isSymbolicLink()
595+
? path.resolve(canonicalRoot, path.relative(params.root, params.target.hostPath))
596+
: await resolveCanonicalCandidate(params.target.hostPath);
425597
if (!isPathInside(canonicalRoot, candidate)) {
426598
throw new Error(
427599
`Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`,

0 commit comments

Comments
 (0)