@@ -8,16 +8,18 @@ import type {
88 SandboxResolvedPath ,
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" ;
1413
1514type 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+
2123const MATERIALIZED_SKILLS_CONTAINER_PARTS = [ ".openclaw" , "sandbox-skills" , "skills" ] as const ;
2224
2325export 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+
346514function 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