2222import static org .junit .jupiter .api .Assertions .assertTrue ;
2323
2424import io .agentscope .core .skill .AgentSkill ;
25+ import java .io .ByteArrayOutputStream ;
2526import java .io .IOException ;
27+ import java .io .InputStream ;
28+ import java .net .URI ;
2629import java .net .URL ;
2730import java .net .URLClassLoader ;
31+ import java .net .URLConnection ;
32+ import java .net .URLStreamHandler ;
33+ import java .nio .channels .SeekableByteChannel ;
2834import java .nio .charset .StandardCharsets ;
35+ import java .nio .file .AccessMode ;
36+ import java .nio .file .CopyOption ;
37+ import java .nio .file .DirectoryStream ;
38+ import java .nio .file .FileStore ;
39+ import java .nio .file .FileSystem ;
2940import java .nio .file .Files ;
41+ import java .nio .file .LinkOption ;
42+ import java .nio .file .OpenOption ;
3043import java .nio .file .Path ;
44+ import java .nio .file .attribute .BasicFileAttributes ;
45+ import java .nio .file .attribute .FileAttribute ;
46+ import java .nio .file .attribute .FileAttributeView ;
47+ import java .nio .file .spi .FileSystemProvider ;
3148import java .util .List ;
49+ import java .util .Map ;
50+ import java .util .Set ;
3251import java .util .jar .JarEntry ;
52+ import java .util .jar .JarFile ;
3353import java .util .jar .JarOutputStream ;
3454import org .junit .jupiter .api .AfterEach ;
3555import org .junit .jupiter .api .DisplayName ;
@@ -232,6 +252,70 @@ public URL getResource(String name) {
232252 }
233253 }
234254
255+ @ Test
256+ @ DisplayName ("Should load skills from Spring Boot nested lib JAR (BOOT-INF/lib/)" )
257+ void testLoadFromSpringBootNestedLibJar () throws Exception {
258+ // Simulates the URL pattern used by Spring Boot 3.2+ for multi-module projects:
259+ // jar:nested:/opt/app/xxx-app.jar/!BOOT-INF/lib/nested-skill.jar!/jar-skills
260+ Path outerJarPath =
261+ createSpringBootNestedLibTestJar (
262+ "nested-lib-skill" , "Nested Lib Skill" , "Nested lib content" );
263+
264+ // Extract the inner JAR from the outer to a temp file,
265+ // simulating Spring Boot's runtime resolution of nested JARs
266+ Path innerJarPath = extractInnerJar (outerJarPath , "BOOT-INF/lib/nested-skill.jar" );
267+
268+ // Configure the test FileSystemProvider so that ZipFileSystemProvider can
269+ // resolve the nested: URI to the extracted inner JAR path.
270+ // In production, Spring Boot's NestedFileSystemProvider handles this.
271+ TestNestedFileSystemProvider .configuredInnerJarPath = innerJarPath ;
272+ try {
273+ // Build a ClassLoader that returns a jar:nested: format URL.
274+ ClassLoader nestedClassLoader =
275+ new ClassLoader (ClassLoader .getSystemClassLoader ()) {
276+ @ Override
277+ public URL getResource (String name ) {
278+ if ("jar-skills" .equals (name )) {
279+ try {
280+ String nestedUrlStr =
281+ "jar:nested:"
282+ + outerJarPath
283+ + "/!BOOT-INF/lib/nested-skill.jar!/"
284+ + name ;
285+ return new URL (
286+ null ,
287+ nestedUrlStr ,
288+ new URLStreamHandler () {
289+ @ Override
290+ protected URLConnection openConnection (URL u )
291+ throws IOException {
292+ throw new UnsupportedOperationException (
293+ "nested URL for test only" );
294+ }
295+ });
296+ } catch (Exception e ) {
297+ throw new RuntimeException (e );
298+ }
299+ }
300+ return super .getResource (name );
301+ }
302+ };
303+
304+ repository =
305+ new ClasspathSkillRepositoryWithClassLoader ("jar-skills" , nestedClassLoader );
306+
307+ assertTrue (repository .isJarEnvironment (), "Should detect JAR environment" );
308+
309+ AgentSkill skill = repository .getSkill ("nested-lib-skill" );
310+ assertNotNull (skill );
311+ assertEquals ("nested-lib-skill" , skill .getName ());
312+ assertEquals ("Nested Lib Skill" , skill .getDescription ());
313+ assertTrue (skill .getSkillContent ().contains ("Nested lib content" ));
314+ } finally {
315+ TestNestedFileSystemProvider .configuredInnerJarPath = null ;
316+ }
317+ }
318+
235319 // ==================== getSource Tests ====================
236320
237321 @ Test
@@ -517,6 +601,99 @@ private Path createSpringBootTestJar(String skillName, String description, Strin
517601 return jarPath ;
518602 }
519603
604+ /**
605+ * Creates a test fat JAR simulating a Spring Boot multi-module project. The
606+ * outer JAR contains
607+ * BOOT-INF/lib/nested-skill.jar, which itself contains
608+ * jar-skills/{skillName}/SKILL.md.
609+ *
610+ * <p>
611+ * This simulates the URL pattern:
612+ * jar:nested:/opt/app/xxx-app.jar/!BOOT-INF/lib/nested-skill.jar!/jar-skills
613+ */
614+ private Path createSpringBootNestedLibTestJar (
615+ String skillName , String description , String content ) throws IOException {
616+ // First, create the inner JAR (the library module jar)
617+ byte [] innerJarBytes = createInnerSkillJar (skillName , description , content );
618+
619+ // Then, create the outer fat JAR containing the inner JAR at BOOT-INF/lib/
620+ Path outerJarPath = tempDir .resolve (skillName + "-nested-springboot.jar" );
621+ try (JarOutputStream jos = new JarOutputStream (Files .newOutputStream (outerJarPath ))) {
622+ // Add Spring Boot directory structure
623+ jos .putNextEntry (new JarEntry ("BOOT-INF/" ));
624+ jos .closeEntry ();
625+ jos .putNextEntry (new JarEntry ("BOOT-INF/lib/" ));
626+ jos .closeEntry ();
627+
628+ // Embed the inner JAR as a nested entry (STORED, not compressed)
629+ JarEntry nestedJarEntry = new JarEntry ("BOOT-INF/lib/nested-skill.jar" );
630+ nestedJarEntry .setMethod (JarEntry .STORED );
631+ nestedJarEntry .setSize (innerJarBytes .length );
632+ nestedJarEntry .setCompressedSize (innerJarBytes .length );
633+ java .util .zip .CRC32 crc = new java .util .zip .CRC32 ();
634+ crc .update (innerJarBytes );
635+ nestedJarEntry .setCrc (crc .getValue ());
636+ jos .putNextEntry (nestedJarEntry );
637+ jos .write (innerJarBytes );
638+ jos .closeEntry ();
639+ }
640+
641+ return outerJarPath ;
642+ }
643+
644+ /**
645+ * Creates an inner JAR byte array containing skills at
646+ * jar-skills/{skillName}/SKILL.md.
647+ */
648+ private byte [] createInnerSkillJar (String skillName , String description , String content )
649+ throws IOException {
650+ ByteArrayOutputStream baos = new ByteArrayOutputStream ();
651+ try (JarOutputStream jos = new JarOutputStream (baos )) {
652+ // Add parent directory
653+ jos .putNextEntry (new JarEntry ("jar-skills/" ));
654+ jos .closeEntry ();
655+
656+ // Add skill directory
657+ jos .putNextEntry (new JarEntry ("jar-skills/" + skillName + "/" ));
658+ jos .closeEntry ();
659+
660+ // Add SKILL.md
661+ String skillMd =
662+ "---\n "
663+ + "name: "
664+ + skillName
665+ + "\n "
666+ + "description: "
667+ + description
668+ + "\n "
669+ + "---\n "
670+ + content ;
671+
672+ JarEntry entry = new JarEntry ("jar-skills/" + skillName + "/SKILL.md" );
673+ jos .putNextEntry (entry );
674+ jos .write (skillMd .getBytes (StandardCharsets .UTF_8 ));
675+ jos .closeEntry ();
676+ }
677+ return baos .toByteArray ();
678+ }
679+
680+ /**
681+ * Extracts an inner JAR from an outer JAR to a temp file. This simulates how
682+ * Spring Boot
683+ * resolves nested JARs at runtime.
684+ */
685+ private Path extractInnerJar (Path outerJarPath , String innerEntryName ) throws IOException {
686+ Path innerJarPath = tempDir .resolve ("extracted-inner.jar" );
687+ try (JarFile outerJar = new JarFile (outerJarPath .toFile ())) {
688+ JarEntry innerEntry = outerJar .getJarEntry (innerEntryName );
689+ assertNotNull (innerEntry , "Inner JAR entry should exist: " + innerEntryName );
690+ try (InputStream is = outerJar .getInputStream (innerEntry )) {
691+ Files .copy (is , innerJarPath );
692+ }
693+ }
694+ return innerJarPath ;
695+ }
696+
520697 /**
521698 * Custom adapter that uses a specific ClassLoader for testing JAR loading.
522699 */
@@ -527,4 +704,141 @@ public ClasspathSkillRepositoryWithClassLoader(String resourcePath, ClassLoader
527704 super (resourcePath , classLoader );
528705 }
529706 }
707+
708+ /**
709+ * Test-only {@link FileSystemProvider} for the {@code nested:} scheme.
710+ *
711+ * <p>
712+ * In Spring Boot 3.2+, the {@code nested:} scheme is handled by Spring Boot's
713+ * {@code NestedFileSystemProvider} from {@code spring-boot-loader}. In our test
714+ * environment (without Spring Boot), this provider simulates the same behavior.
715+ *
716+ * <p>
717+ * When {@link ClasspathSkillRepository} processes a {@code jar:nested:} URI,
718+ * the
719+ * JDK's {@code ZipFileSystemProvider} internally calls
720+ * {@code Path.of(new URI("nested:..."))} to locate the JAR file. This provider
721+ * intercepts that call and returns the path to the extracted inner JAR.
722+ *
723+ * <p>
724+ * Registered via SPI in
725+ * {@code META-INF/services/java.nio.file.spi.FileSystemProvider}.
726+ */
727+ public static class TestNestedFileSystemProvider extends FileSystemProvider {
728+
729+ /**
730+ * Path to the extracted inner JAR. Must be set before creating a
731+ * {@link ClasspathSkillRepository} with a {@code jar:nested:} URL.
732+ */
733+ static volatile Path configuredInnerJarPath ;
734+
735+ @ Override
736+ public String getScheme () {
737+ return "nested" ;
738+ }
739+
740+ /**
741+ * Returns the configured inner JAR path for the given {@code nested:} URI.
742+ *
743+ * <p>
744+ * Called by {@code ZipFileSystemProvider.uriToPath()} when it encounters
745+ * a {@code nested:} URI like
746+ * {@code nested:/path/outer.jar/!BOOT-INF/lib/inner.jar}.
747+ */
748+ @ Override
749+ public Path getPath (URI uri ) {
750+ if (configuredInnerJarPath == null ) {
751+ throw new IllegalStateException (
752+ "TestNestedFileSystemProvider.configuredInnerJarPath not set" );
753+ }
754+ return configuredInnerJarPath ;
755+ }
756+
757+ // ---- All methods below are not used; required by abstract contract ----
758+
759+ @ Override
760+ public FileSystem newFileSystem (URI uri , Map <String , ?> env ) {
761+ throw new UnsupportedOperationException ();
762+ }
763+
764+ @ Override
765+ public FileSystem getFileSystem (URI uri ) {
766+ throw new UnsupportedOperationException ();
767+ }
768+
769+ @ Override
770+ public SeekableByteChannel newByteChannel (
771+ Path path , Set <? extends OpenOption > options , FileAttribute <?>... attrs ) {
772+ throw new UnsupportedOperationException ();
773+ }
774+
775+ @ Override
776+ public DirectoryStream <Path > newDirectoryStream (
777+ Path dir , DirectoryStream .Filter <? super Path > filter ) {
778+ throw new UnsupportedOperationException ();
779+ }
780+
781+ @ Override
782+ public void createDirectory (Path dir , FileAttribute <?>... attrs ) {
783+ throw new UnsupportedOperationException ();
784+ }
785+
786+ @ Override
787+ public void delete (Path path ) {
788+ throw new UnsupportedOperationException ();
789+ }
790+
791+ @ Override
792+ public void copy (Path source , Path target , CopyOption ... options ) {
793+ throw new UnsupportedOperationException ();
794+ }
795+
796+ @ Override
797+ public void move (Path source , Path target , CopyOption ... options ) {
798+ throw new UnsupportedOperationException ();
799+ }
800+
801+ @ Override
802+ public boolean isSameFile (Path path , Path path2 ) {
803+ throw new UnsupportedOperationException ();
804+ }
805+
806+ @ Override
807+ public boolean isHidden (Path path ) {
808+ throw new UnsupportedOperationException ();
809+ }
810+
811+ @ Override
812+ public FileStore getFileStore (Path path ) {
813+ throw new UnsupportedOperationException ();
814+ }
815+
816+ @ Override
817+ public void checkAccess (Path path , AccessMode ... modes ) {
818+ throw new UnsupportedOperationException ();
819+ }
820+
821+ @ Override
822+ public <V extends FileAttributeView > V getFileAttributeView (
823+ Path path , Class <V > type , LinkOption ... options ) {
824+ throw new UnsupportedOperationException ();
825+ }
826+
827+ @ Override
828+ public <A extends BasicFileAttributes > A readAttributes (
829+ Path path , Class <A > type , LinkOption ... options ) {
830+ throw new UnsupportedOperationException ();
831+ }
832+
833+ @ Override
834+ public Map <String , Object > readAttributes (
835+ Path path , String attributes , LinkOption ... options ) {
836+ throw new UnsupportedOperationException ();
837+ }
838+
839+ @ Override
840+ public void setAttribute (Path path , String attribute , Object value , LinkOption ... options ) {
841+ throw new UnsupportedOperationException ();
842+ }
843+ }
530844}
0 commit comments