@@ -154,10 +154,13 @@ type ResolvedSkillsLimits = {
154154type LoadedSkillRecord = {
155155 skill : Skill ;
156156 frontmatter ?: ParsedSkillFrontmatter ;
157+ syncSourceDir ?: string ;
158+ syncDirName ?: string ;
157159} ;
158160
159161type CandidateSkillDir = {
160162 skillDir : string ;
163+ skillDirRealPath : string ;
161164 name : string ;
162165 skillMdRealPath : string ;
163166} ;
@@ -388,16 +391,53 @@ function loadContainedSkillRecords(params: {
388391 skillDir : string ;
389392 source : string ;
390393 maxSkillFileBytes : number ;
394+ canonicalSkillDir ?: string ;
391395} ) : LoadedSkillRecord [ ] {
392396 const expectedBaseDir = path . resolve ( params . skillDir ) ;
393397 const loaded = loadSkillsFromDirSafe ( {
394398 dir : params . skillDir ,
395399 source : params . source ,
396400 maxBytes : params . maxSkillFileBytes ,
397401 } ) ;
398- return unwrapLoadedSkillRecords ( loaded ) . filter (
402+ const records = unwrapLoadedSkillRecords ( loaded ) . filter (
399403 ( record ) => path . resolve ( record . skill . baseDir ) === expectedBaseDir ,
400404 ) ;
405+ const canonicalSkillDir = params . canonicalSkillDir ;
406+ return canonicalSkillDir
407+ ? records . map ( ( record ) => canonicalizeLoadedSkillRecord ( record , canonicalSkillDir ) )
408+ : records ;
409+ }
410+
411+ function canonicalizeLoadedSkillRecord (
412+ record : LoadedSkillRecord ,
413+ canonicalSkillDir : string ,
414+ ) : LoadedSkillRecord {
415+ const originalBaseDir = path . resolve ( record . skill . baseDir ) ;
416+ const canonicalBaseDir = path . resolve ( canonicalSkillDir ) ;
417+ if ( originalBaseDir === canonicalBaseDir ) {
418+ return record ;
419+ }
420+ const filePath = path . join (
421+ canonicalBaseDir ,
422+ path . relative ( originalBaseDir , record . skill . filePath ) ,
423+ ) ;
424+ return {
425+ ...record ,
426+ syncSourceDir : canonicalBaseDir ,
427+ syncDirName : path . basename ( originalBaseDir ) ,
428+ skill : {
429+ ...record . skill ,
430+ filePath,
431+ baseDir : canonicalBaseDir ,
432+ sourceInfo : record . skill . sourceInfo
433+ ? {
434+ ...record . skill . sourceInfo ,
435+ path : filePath ,
436+ baseDir : canonicalBaseDir ,
437+ }
438+ : record . skill . sourceInfo ,
439+ } ,
440+ } ;
401441}
402442
403443function isPathInsideAnyRoot ( rootRealPaths : readonly string [ ] , candidateRealPath : string ) : boolean {
@@ -437,6 +477,10 @@ function resolveSkillRootCandidatePath(params: {
437477 } ) ;
438478}
439479
480+ function canonicalSkillDirForSource ( source : string , skillDirRealPath : string ) : string | undefined {
481+ return shouldEnforceConfiguredSkillRootContainment ( source ) ? undefined : skillDirRealPath ;
482+ }
483+
440484function resolveSkillFilePath ( params : {
441485 source : string ;
442486 skillDir : string ;
@@ -624,6 +668,7 @@ function loadSkillEntries(
624668 skillDir : baseDir ,
625669 source : params . source ,
626670 maxSkillFileBytes : limits . maxSkillFileBytes ,
671+ canonicalSkillDir : canonicalSkillDirForSource ( params . source , baseDirRealPath ) ,
627672 } ) ;
628673 }
629674
@@ -658,7 +703,12 @@ function loadSkillEntries(
658703 }
659704
660705 const loadedSkills : LoadedSkillRecord [ ] = [ ] ;
661- const loadCandidateSkill = ( { skillDir, name, skillMdRealPath } : CandidateSkillDir ) => {
706+ const loadCandidateSkill = ( {
707+ skillDir,
708+ skillDirRealPath,
709+ name,
710+ skillMdRealPath,
711+ } : CandidateSkillDir ) => {
662712 try {
663713 const size = fs . statSync ( skillMdRealPath ) . size ;
664714 if ( size > limits . maxSkillFileBytes ) {
@@ -679,6 +729,7 @@ function loadSkillEntries(
679729 skillDir,
680730 source : params . source ,
681731 maxSkillFileBytes : limits . maxSkillFileBytes ,
732+ canonicalSkillDir : canonicalSkillDirForSource ( params . source , skillDirRealPath ) ,
682733 } ) ,
683734 ) ;
684735 } ;
@@ -707,7 +758,7 @@ function loadSkillEntries(
707758 candidatePath : skillMd ,
708759 } ) ;
709760 if ( skillMdRealPath ) {
710- loadCandidateSkill ( { skillDir, name, skillMdRealPath } ) ;
761+ loadCandidateSkill ( { skillDir, skillDirRealPath , name, skillMdRealPath } ) ;
711762 }
712763 } else {
713764 // No SKILL.md here — check one level deeper for grouped skill directories.
@@ -764,6 +815,7 @@ function loadSkillEntries(
764815 if ( nestedDirRealPath && nestedSkillMdRealPath ) {
765816 loadCandidateSkill ( {
766817 skillDir : nestedDir ,
818+ skillDirRealPath : nestedDirRealPath ,
767819 name : `${ name } /${ nestedName } ` ,
768820 skillMdRealPath : nestedSkillMdRealPath ,
769821 } ) ;
@@ -884,6 +936,8 @@ function loadSkillEntries(
884936 frontmatter,
885937 metadata : resolveOpenClawMetadata ( frontmatter ) ,
886938 invocation,
939+ ...( record . syncSourceDir !== undefined ? { syncSourceDir : record . syncSourceDir } : { } ) ,
940+ ...( record . syncDirName !== undefined ? { syncDirName : record . syncDirName } : { } ) ,
887941 exposure : {
888942 includeInRuntimeRegistry : true ,
889943 // Freshly loaded entries preserve the documented disable-model-invocation
@@ -1169,7 +1223,9 @@ function resolveSyncedSkillDestinationPath(params: {
11691223 entry : SkillEntry ;
11701224 usedDirNames : Set < string > ;
11711225} ) : string | null {
1172- const sourceDirName = path . basename ( params . entry . skill . baseDir ) . trim ( ) ;
1226+ const sourceDirName = (
1227+ params . entry . syncDirName ?? path . basename ( params . entry . skill . baseDir )
1228+ ) . trim ( ) ;
11731229 if ( ! sourceDirName || sourceDirName === "." || sourceDirName === ".." ) {
11741230 return null ;
11751231 }
@@ -1233,7 +1289,7 @@ export async function syncSkillsToWorkspace(params: {
12331289 continue ;
12341290 }
12351291 try {
1236- await fsp . cp ( entry . skill . baseDir , dest , {
1292+ await fsp . cp ( entry . syncSourceDir ?? entry . skill . baseDir , dest , {
12371293 recursive : true ,
12381294 force : true ,
12391295 filter : ( src ) => {
0 commit comments