@@ -286,6 +286,16 @@ const resolveSkillTargetAppFromRequest = instantiateFunction(
286286 normalizeSkillTargetApp
287287 }
288288) ;
289+ const importSkillsSource = extractFunctionBySignature (
290+ cliContent ,
291+ 'function importSkills(params = {}) {' ,
292+ 'importSkills'
293+ ) ;
294+ const importSkillsFromZipFileSource = extractFunctionBySignature (
295+ cliContent ,
296+ 'async function importSkillsFromZipFile(zipPath, options = {}) {' ,
297+ 'importSkillsFromZipFile'
298+ ) ;
289299const handleImportSkillsZipUploadSource = extractFunctionBySignature (
290300 cliContent ,
291301 'async function handleImportSkillsZipUpload(req, res, options = {}) {' ,
@@ -402,6 +412,175 @@ test('handleImportSkillsZipUpload derives fallback zip name from the resolved ta
402412 assert . strictEqual ( res . statusCode , 200 ) ;
403413} ) ;
404414
415+ test ( 'importSkills rejects target roots nested inside the source skill before ensuring the destination root' , ( ) => {
416+ let copyCalls = 0 ;
417+ const ensureDirCalls = [ ] ;
418+ const importSkills = instantiateFunction ( importSkillsSource , 'importSkills' , {
419+ resolveSkillTarget ( ) {
420+ return { app : 'codex' , label : 'Codex' , dir : '/tmp/source/nested' } ;
421+ } ,
422+ normalizeCodexSkillName ( name ) {
423+ return { name : String ( name || '' ) } ;
424+ } ,
425+ getSkillImportSourceByApp ( ) {
426+ return { app : 'claude' , label : 'Claude' , dir : '/tmp' } ;
427+ } ,
428+ ensureDir ( dir ) {
429+ ensureDirCalls . push ( dir ) ;
430+ } ,
431+ path,
432+ fs : {
433+ existsSync ( targetPath ) {
434+ return targetPath === '/tmp/source' ;
435+ } ,
436+ lstatSync ( ) {
437+ return {
438+ isDirectory : ( ) => true ,
439+ isSymbolicLink : ( ) => false
440+ } ;
441+ } ,
442+ statSync ( ) {
443+ return {
444+ isDirectory : ( ) => true
445+ } ;
446+ }
447+ } ,
448+ copyDirRecursive ( ) {
449+ copyCalls += 1 ;
450+ } ,
451+ removeDirectoryRecursive ( ) { } ,
452+ isPathInside ( targetPath , rootPath ) {
453+ return targetPath === '/tmp/source/nested' && rootPath === '/tmp/source' ;
454+ }
455+ } ) ;
456+
457+ const result = importSkills ( {
458+ targetApp : 'codex' ,
459+ items : [ { name : 'source' , sourceApp : 'claude' } ]
460+ } ) ;
461+
462+ assert . strictEqual ( copyCalls , 0 ) ;
463+ assert . deepStrictEqual ( ensureDirCalls , [ ] ) ;
464+ assert . deepStrictEqual ( result . imported , [ ] ) ;
465+ assert . strictEqual ( result . failed . length , 1 ) ;
466+ assert . strictEqual ( result . failed [ 0 ] . error , '目标路径不能位于来源 skill 目录内' ) ;
467+ } ) ;
468+
469+ test ( 'importSkillsFromZipFile rejects target roots nested inside extracted skills before ensuring the destination root' , async ( ) => {
470+ let copyCalls = 0 ;
471+ const ensureDirCalls = [ ] ;
472+ const cleanupCalls = [ ] ;
473+ const importSkillsFromZipFile = instantiateFunction ( importSkillsFromZipFileSource , 'importSkillsFromZipFile' , {
474+ resolveSkillTarget ( ) {
475+ return { app : 'codex' , label : 'Codex' , dir : '/tmp/upload/extract/source/nested' } ;
476+ } ,
477+ path,
478+ fs : {
479+ realpathSync ( targetPath ) {
480+ return targetPath ;
481+ } ,
482+ statSync ( ) {
483+ return {
484+ isDirectory : ( ) => true
485+ } ;
486+ } ,
487+ existsSync ( ) {
488+ return false ;
489+ } ,
490+ rmSync ( targetPath , options ) {
491+ cleanupCalls . push ( { targetPath, options } ) ;
492+ }
493+ } ,
494+ inspectZipArchiveLimits : async ( ) => { } ,
495+ extractUploadZip : async ( ) => { } ,
496+ collectSkillDirectoriesFromRoot ( ) {
497+ return {
498+ results : [ '/tmp/upload/extract/source' ] ,
499+ truncated : false
500+ } ;
501+ } ,
502+ resolveSkillNameFromImportedDirectory ( ) {
503+ return { name : 'source' } ;
504+ } ,
505+ ensureDir ( dir ) {
506+ ensureDirCalls . push ( dir ) ;
507+ } ,
508+ copyDirRecursive ( ) {
509+ copyCalls += 1 ;
510+ } ,
511+ removeDirectoryRecursive ( ) { } ,
512+ isPathInside ( targetPath , rootPath ) {
513+ return targetPath === '/tmp/upload/extract/source/nested' && rootPath === '/tmp/upload/extract/source' ;
514+ } ,
515+ MAX_SKILLS_ZIP_ENTRY_COUNT : 100 ,
516+ MAX_SKILLS_ZIP_UNCOMPRESSED_BYTES : 1024
517+ } ) ;
518+
519+ const result = await importSkillsFromZipFile ( '/tmp/upload/archive.zip' , {
520+ tempDir : '/tmp/upload' ,
521+ targetApp : 'codex'
522+ } ) ;
523+
524+ assert . strictEqual ( copyCalls , 0 ) ;
525+ assert . deepStrictEqual ( ensureDirCalls , [ ] ) ;
526+ assert . deepStrictEqual ( result . imported , [ ] ) ;
527+ assert . strictEqual ( result . failed . length , 1 ) ;
528+ assert . strictEqual ( result . failed [ 0 ] . error , '目标路径不能位于来源 skill 目录内' ) ;
529+ assert . deepStrictEqual ( cleanupCalls , [ {
530+ targetPath : '/tmp/upload' ,
531+ options : { recursive : true , force : true }
532+ } ] ) ;
533+ } ) ;
534+
535+ test ( 'importSkillsFromZipFile still cleans tempDir when target app is unsupported' , async ( ) => {
536+ const cleanupCalls = [ ] ;
537+ const importSkillsFromZipFile = instantiateFunction ( importSkillsFromZipFileSource , 'importSkillsFromZipFile' , {
538+ resolveSkillTarget ( ) {
539+ return null ;
540+ } ,
541+ path,
542+ fs : {
543+ existsSync ( ) {
544+ return false ;
545+ } ,
546+ rmSync ( targetPath , options ) {
547+ cleanupCalls . push ( { targetPath, options } ) ;
548+ }
549+ } ,
550+ inspectZipArchiveLimits : async ( ) => {
551+ throw new Error ( 'inspectZipArchiveLimits should not run' ) ;
552+ } ,
553+ extractUploadZip : async ( ) => {
554+ throw new Error ( 'extractUploadZip should not run' ) ;
555+ } ,
556+ collectSkillDirectoriesFromRoot ( ) {
557+ return { results : [ ] , truncated : false } ;
558+ } ,
559+ resolveSkillNameFromImportedDirectory ( ) {
560+ return { name : 'demo' } ;
561+ } ,
562+ ensureDir ( ) { } ,
563+ copyDirRecursive ( ) { } ,
564+ removeDirectoryRecursive ( ) { } ,
565+ isPathInside ( ) {
566+ return false ;
567+ } ,
568+ MAX_SKILLS_ZIP_ENTRY_COUNT : 100 ,
569+ MAX_SKILLS_ZIP_UNCOMPRESSED_BYTES : 1024
570+ } ) ;
571+
572+ const result = await importSkillsFromZipFile ( '/tmp/upload/archive.zip' , {
573+ tempDir : '/tmp/upload' ,
574+ targetApp : 'invalid'
575+ } ) ;
576+
577+ assert . deepStrictEqual ( result , { error : '目标宿主不支持' } ) ;
578+ assert . deepStrictEqual ( cleanupCalls , [ {
579+ targetPath : '/tmp/upload' ,
580+ options : { recursive : true , force : true }
581+ } ] ) ;
582+ } ) ;
583+
405584test ( 'codex-only zip upload route pins target app before request fallback resolution' , ( ) => {
406585 assert . match (
407586 cliContent ,
0 commit comments