@@ -31,6 +31,7 @@ import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from
3131import { createOpenShellFsBridge } from "./fs-bridge.js" ;
3232import {
3333 DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS ,
34+ movePathWithCopyFallback ,
3435 replaceDirectoryContents ,
3536 stageDirectoryContents ,
3637} from "./mirror.js" ;
@@ -43,6 +44,48 @@ type PendingExec = {
4344 sshSession : SshSandboxSession ;
4445} ;
4546
47+ const MATERIALIZED_SKILLS_REMOTE_PARTS = [ ".openclaw" , "sandbox-skills" ] as const ;
48+ const ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT = [
49+ "set -e" ,
50+ 'target="$1"' ,
51+ 'root="${2:-$1}"' ,
52+ 'case "$target" in /*) ;; *) echo "remote directory must be absolute: $target" >&2; exit 1 ;; esac' ,
53+ 'case "$root" in /*) ;; *) echo "remote root must be absolute: $root" >&2; exit 1 ;; esac' ,
54+ 'target="${target%/}"' ,
55+ 'root="${root%/}"' ,
56+ '[ -n "$target" ] || target="/"' ,
57+ '[ -n "$root" ] || root="/"' ,
58+ 'case "$target/" in "$root"/*|"$root/") ;; *) echo "remote directory must stay under root: $target" >&2; exit 1 ;; esac' ,
59+ 'old_ifs="$IFS"' ,
60+ 'IFS="/"' ,
61+ "set -- ${target#/} ${root#/}" ,
62+ 'IFS="$old_ifs"' ,
63+ "for part do" ,
64+ ' [ -n "$part" ] || continue' ,
65+ ' case "$part" in "."|"..") echo "unsafe remote directory component: $part" >&2; exit 1 ;; esac' ,
66+ "done" ,
67+ 'if [ -L "$root" ]; then echo "unsafe remote root symlink: $root" >&2; exit 1; fi' ,
68+ 'mkdir -p -- "$root"' ,
69+ 'canonical_root="$(cd "$root" && pwd -P)"' ,
70+ 'relative="${target#"$root"}"' ,
71+ 'relative="${relative#/}"' ,
72+ 'current="$canonical_root"' ,
73+ 'IFS="/"' ,
74+ "set -- $relative" ,
75+ 'IFS="$old_ifs"' ,
76+ "for part do" ,
77+ ' [ -n "$part" ] || continue' ,
78+ ' if [ "$current" = "/" ]; then next="/$part"; else next="$current/$part"; fi' ,
79+ ' if [ -L "$next" ]; then echo "unsafe remote directory symlink: $next" >&2; exit 1; fi' ,
80+ ' if [ -e "$next" ]; then' ,
81+ ' if [ ! -d "$next" ]; then echo "unsafe remote directory component: $next" >&2; exit 1; fi' ,
82+ " else" ,
83+ ' mkdir -- "$next"' ,
84+ " fi" ,
85+ ' current="$next"' ,
86+ "done" ,
87+ ] . join ( "\n" ) ;
88+
4689export function buildOpenShellSshExecEnv ( ) : NodeJS . ProcessEnv {
4790 return sanitizeEnvVars ( process . env ) . allowed ;
4891}
@@ -221,7 +264,10 @@ class OpenShellSandboxBackendImpl {
221264 if ( this . params . execContext . config . mode === "mirror" ) {
222265 await this . syncWorkspaceToRemote ( ) ;
223266 } else {
224- await this . maybeSeedRemoteWorkspace ( ) ;
267+ const seeded = await this . maybeSeedRemoteWorkspace ( ) ;
268+ if ( ! seeded ) {
269+ await this . syncSkillsWorkspaceToRemote ( ) ;
270+ }
225271 }
226272 const sshSession = await createOpenShellSshSession ( {
227273 context : this . params . execContext ,
@@ -257,7 +303,10 @@ class OpenShellSandboxBackendImpl {
257303 params : SandboxBackendCommandParams ,
258304 ) : Promise < SandboxBackendCommandResult > {
259305 await this . ensureSandboxExists ( ) ;
260- await this . maybeSeedRemoteWorkspace ( ) ;
306+ const seeded = await this . maybeSeedRemoteWorkspace ( ) ;
307+ if ( ! seeded ) {
308+ await this . syncSkillsWorkspaceToRemote ( ) ;
309+ }
261310 return await this . runRemoteShellScriptInternal ( params ) ;
262311 }
263312
@@ -410,6 +459,31 @@ class OpenShellSandboxBackendImpl {
410459 this . params . remoteAgentWorkspaceDir ,
411460 ) ;
412461 }
462+ await this . syncSkillsWorkspaceToRemote ( ) ;
463+ }
464+
465+ private async syncSkillsWorkspaceToRemote ( ) : Promise < void > {
466+ if (
467+ this . params . createParams . cfg . workspaceAccess !== "rw" ||
468+ ! this . params . createParams . skillsWorkspaceDir
469+ ) {
470+ return ;
471+ }
472+ const remoteSkillsWorkspaceDir = resolveRemoteMaterializedSkillsWorkspaceDir (
473+ this . params . remoteWorkspaceDir ,
474+ ) ;
475+ await this . runRemoteShellScriptInternal ( {
476+ script : `${ ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT } \nfind "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +` ,
477+ args : [ remoteSkillsWorkspaceDir , this . params . remoteWorkspaceDir ] ,
478+ } ) ;
479+ const stats = await fs . lstat ( this . params . createParams . skillsWorkspaceDir ) . catch ( ( ) => null ) ;
480+ if ( ! stats ?. isDirectory ( ) || stats . isSymbolicLink ( ) ) {
481+ return ;
482+ }
483+ await this . uploadPathToRemote (
484+ this . params . createParams . skillsWorkspaceDir ,
485+ remoteSkillsWorkspaceDir ,
486+ ) ;
413487 }
414488
415489 private async syncWorkspaceFromRemote ( ) : Promise < void > {
@@ -430,13 +504,25 @@ class OpenShellSandboxBackendImpl {
430504 if ( result . code !== 0 ) {
431505 throw new Error ( result . stderr . trim ( ) || "openshell sandbox download failed" ) ;
432506 }
433- await replaceDirectoryContents ( {
434- sourceDir : tmpDir ,
435- targetDir : this . params . createParams . workspaceDir ,
436- // Never sync trusted host hook directories or repository metadata from
437- // the remote sandbox.
438- excludeDirs : DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS ,
507+ await removeMaterializedSkillsFromDownloadedWorkspace ( tmpDir ) ;
508+ const preservedSandboxSkills = await moveMaterializedSkillsShadowAside ( {
509+ workspaceDir : this . params . createParams . workspaceDir ,
510+ tmpDir,
439511 } ) ;
512+ try {
513+ await replaceDirectoryContents ( {
514+ sourceDir : tmpDir ,
515+ targetDir : this . params . createParams . workspaceDir ,
516+ // Never sync trusted host hook directories or repository metadata from
517+ // the remote sandbox.
518+ excludeDirs : DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS ,
519+ } ) ;
520+ } finally {
521+ await restoreMaterializedSkillsShadow ( {
522+ workspaceDir : this . params . createParams . workspaceDir ,
523+ preserved : preservedSandboxSkills ,
524+ } ) ;
525+ }
440526 } ,
441527 ) ;
442528 }
@@ -470,13 +556,14 @@ class OpenShellSandboxBackendImpl {
470556 ) ;
471557 }
472558
473- private async maybeSeedRemoteWorkspace ( ) : Promise < void > {
559+ private async maybeSeedRemoteWorkspace ( ) : Promise < boolean > {
474560 if ( ! this . remoteSeedPending ) {
475- return ;
561+ return false ;
476562 }
477563 this . remoteSeedPending = false ;
478564 try {
479565 await this . syncWorkspaceToRemote ( ) ;
566+ return true ;
480567 } catch ( error ) {
481568 this . remoteSeedPending = true ;
482569 throw error ;
@@ -508,6 +595,84 @@ export function buildOpenShellSandboxName(scopeKey: string): string {
508595 return `openclaw-${ safe || "session" } -${ hash . toString ( 16 ) . slice ( 0 , 8 ) } ` ;
509596}
510597
598+ function resolveRemoteMaterializedSkillsWorkspaceDir ( remoteWorkspaceDir : string ) : string {
599+ const root = remoteWorkspaceDir . replace ( / \\ / g, "/" ) . replace ( / \/ + $ / , "" ) || "/" ;
600+ return path . posix . join ( root , ...MATERIALIZED_SKILLS_REMOTE_PARTS ) ;
601+ }
602+
603+ async function removeMaterializedSkillsFromDownloadedWorkspace ( tmpDir : string ) : Promise < void > {
604+ let cursor = tmpDir ;
605+ for ( const [ index , part ] of MATERIALIZED_SKILLS_REMOTE_PARTS . entries ( ) ) {
606+ const next = path . join ( cursor , part ) ;
607+ const stats = await fs . lstat ( next ) . catch ( ( ) => null ) ;
608+ if ( ! stats ) {
609+ return ;
610+ }
611+ if ( index === MATERIALIZED_SKILLS_REMOTE_PARTS . length - 1 ) {
612+ await fs . rm ( next , { recursive : true , force : true } ) ;
613+ return ;
614+ }
615+ if ( stats . isSymbolicLink ( ) || ! stats . isDirectory ( ) ) {
616+ await fs . rm ( next , { recursive : true , force : true } ) ;
617+ return ;
618+ }
619+ cursor = next ;
620+ }
621+ }
622+
623+ async function moveMaterializedSkillsShadowAside ( params : {
624+ workspaceDir : string ;
625+ tmpDir : string ;
626+ } ) : Promise < { preservedPath : string ; preserveRoot : string } | undefined > {
627+ const shadowPath = path . join ( params . workspaceDir , ...MATERIALIZED_SKILLS_REMOTE_PARTS ) ;
628+ const parentStats = await fs . lstat ( path . dirname ( shadowPath ) ) . catch ( ( ) => null ) ;
629+ if ( ! parentStats ?. isDirectory ( ) || parentStats . isSymbolicLink ( ) ) {
630+ return undefined ;
631+ }
632+ const shadowStats = await fs . lstat ( shadowPath ) . catch ( ( ) => null ) ;
633+ if ( ! shadowStats || shadowStats . isSymbolicLink ( ) ) {
634+ return undefined ;
635+ }
636+ const preserveRoot = await fs . mkdtemp (
637+ path . join ( path . dirname ( params . tmpDir ) , "openclaw-openshell-preserve-" ) ,
638+ ) ;
639+ const preservedPath = path . join ( preserveRoot , "sandbox-skills" ) ;
640+ await movePathWithCopyFallback ( { from : shadowPath , to : preservedPath } ) ;
641+ return { preservedPath, preserveRoot } ;
642+ }
643+
644+ async function restoreMaterializedSkillsShadow ( params : {
645+ workspaceDir : string ;
646+ preserved ?: { preservedPath : string ; preserveRoot : string } ;
647+ } ) : Promise < void > {
648+ if ( ! params . preserved ) {
649+ return ;
650+ }
651+ let restored = false ;
652+ try {
653+ const shadowPath = path . join ( params . workspaceDir , ...MATERIALIZED_SKILLS_REMOTE_PARTS ) ;
654+ const parentPath = path . dirname ( shadowPath ) ;
655+ const parentStats = await fs . lstat ( parentPath ) . catch ( ( ) => null ) ;
656+ if ( parentStats ?. isSymbolicLink ( ) ) {
657+ throw new Error ( `Refusing to restore sandbox skills through symlink parent: ${ parentPath } ` ) ;
658+ }
659+ if ( parentStats && ! parentStats . isDirectory ( ) ) {
660+ await fs . rm ( parentPath , { recursive : true , force : true } ) ;
661+ }
662+ await fs . mkdir ( parentPath , { recursive : true } ) ;
663+ await fs . rm ( shadowPath , { recursive : true , force : true } ) ;
664+ await movePathWithCopyFallback ( {
665+ from : params . preserved . preservedPath ,
666+ to : shadowPath ,
667+ } ) ;
668+ restored = true ;
669+ } finally {
670+ if ( restored ) {
671+ await fs . rm ( params . preserved . preserveRoot , { recursive : true , force : true } ) ;
672+ }
673+ }
674+ }
675+
511676function resolveOpenShellTmpRoot ( ) : string {
512677 return path . resolve ( resolvePreferredOpenClawTmpDir ( ) ) ;
513678}
0 commit comments