




















@@ -8,16 +8,18 @@ import type {
88SandboxResolvedPath,
99} from "openclaw/plugin-sdk/sandbox";
1010import { 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";
1212import type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js";
13-import { movePathWithCopyFallback } from "./mirror.js";
14131514type ResolvedMountPath = SandboxResolvedPath & {
1615mountHostRoot: string;
1716writable: boolean;
1817source: "workspace" | "agent" | "protectedSkill";
1918};
201920+type FsSafeRoot = Awaited<ReturnType<typeof fsRoot>>;
21+type FsSafeStat = Awaited<ReturnType<FsSafeRoot["stat"]>>;
22+2123const MATERIALIZED_SKILLS_CONTAINER_PARTS = [".openclaw", "sandbox-skills", "skills"] as const;
22242325export function createOpenShellFsBridge(params: {
@@ -117,7 +119,7 @@ class OpenShellFsBridge implements SandboxFsBridge {
117119allowFinalSymlinkForUnlink: false,
118120});
119121await this.backend.mkdirpRemotePath(target.containerPath, params.signal);
120-await fsPromises.mkdir(hostPath, { recursive: true });
122+await mkdirLocalRootPath({ hostPath, target });
121123}
122124123125async remove(params: {
@@ -141,9 +143,11 @@ class OpenShellFsBridge implements SandboxFsBridge {
141143signal: params.signal,
142144ignoreMissing: 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 {
168172allowMissingLeaf: true,
169173allowFinalSymlinkForUnlink: 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+});
171184await 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}
175187176188async stat(params: {
@@ -343,6 +355,162 @@ class OpenShellFsBridge implements SandboxFsBridge {
343355}
344356}
345357358+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+346514function resolveProtectedSkillTarget(params: {
347515input: string;
348516skillsRoot: string;
@@ -421,7 +589,11 @@ async function assertLocalPathSafety(params: {
421589const 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);
425597if (!isPathInside(canonicalRoot, candidate)) {
426598throw new Error(
427599`Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。