This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new ae5e4399 [runtime] Resolve relative file: URLs when loading classpath 
skills (#966) (#1033)
ae5e4399 is described below

commit ae5e4399d3152aca84c7d662199aee73d63317e9
Author: Femi <[email protected]>
AuthorDate: Wed Aug 26 08:00:41 2026 +0100

    [runtime] Resolve relative file: URLs when loading classpath skills (#966) 
(#1033)
---
 .../skill/repository/ClasspathSkillRepository.java | 11 ++-
 .../agents/runtime/skill/repository/LocalUrls.java | 74 +++++++++++++++++++
 .../skill/repository/SkillMaterializer.java        | 11 ++-
 .../skill/ClasspathSkillRepositoryTest.java        | 82 ++++++++++++++++++++++
 .../runtime/skill/repository/LocalUrlsTest.java    | 81 +++++++++++++++++++++
 5 files changed, 247 insertions(+), 12 deletions(-)

diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/ClasspathSkillRepository.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/ClasspathSkillRepository.java
index 6dcdd863..4a5dcda7 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/ClasspathSkillRepository.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/ClasspathSkillRepository.java
@@ -23,12 +23,10 @@ import org.slf4j.LoggerFactory;
 
 import java.io.File;
 import java.io.IOException;
-import java.net.URISyntaxException;
 import java.net.URL;
 import java.net.URLClassLoader;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Enumeration;
 import java.util.LinkedHashSet;
@@ -136,8 +134,8 @@ public final class ClasspathSkillRepository extends 
AbstractMaterializedSkillRep
             throws IOException {
         Path p;
         try {
-            p = Paths.get(url.toURI());
-        } catch (URISyntaxException e) {
+            p = LocalUrls.toLocalFile(url).toPath();
+        } catch (IOException e) {
             throw new IOException("Bad classpath URL: " + url, e);
         }
         if (Files.isDirectory(p)) {
@@ -171,8 +169,9 @@ public final class ClasspathSkillRepository extends 
AbstractMaterializedSkillRep
                 }
                 File jarFileObj;
                 try {
-                    jarFileObj = new File(u.toURI());
-                } catch (URISyntaxException e) {
+                    jarFileObj = LocalUrls.toLocalFile(u);
+                } catch (IOException e) {
+                    // Skip URLs we can't resolve to a local file (non-file 
protocols, malformed).
                     continue;
                 }
                 if (!jarFileObj.isFile()) {
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/LocalUrls.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/LocalUrls.java
new file mode 100644
index 00000000..8b98addb
--- /dev/null
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/LocalUrls.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.runtime.skill.repository;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+
+/**
+ * Centralizes the conversion of local {@code file:} URLs to {@link File} for 
the classpath skill
+ * loading path (direct resource materialization, JAR extraction, and the 
{@code URLClassLoader}
+ * fallback scan). Keeping this in one place ensures the three call sites 
agree on how relative
+ * {@code file:} URLs are resolved.
+ */
+final class LocalUrls {
+
+    private LocalUrls() {}
+
+    /**
+     * Resolve a local {@code file:} URL to a {@link File}.
+     *
+     * <p>A Flink deployment may add user-code JARs relative to the 
TaskManager working directory,
+     * producing relative {@code file:} URLs such as {@code 
file:../../flink/usrlib/job.jar}. Such a
+     * URL parses to an <em>opaque</em> URI (its scheme-specific part is not 
an absolute path),
+     * which {@code new File(URI)} rejects with {@code "URI is not 
hierarchical"}. This method
+     * resolves the opaque URI's decoded scheme-specific part against the 
process working directory
+     * instead.
+     *
+     * <p>Absolute hierarchical {@code file:} URLs keep their existing {@code 
new File(uri)}
+     * behavior. Non-{@code file} URLs are rejected explicitly.
+     *
+     * @throws IOException if the URL is not a {@code file:} URL, is a 
malformed URI, or cannot be
+     *     represented as a local {@link File}.
+     */
+    static File toLocalFile(URL url) throws IOException {
+        if (!"file".equals(url.getProtocol())) {
+            throw new IOException("Not a local file URL: " + url);
+        }
+        try {
+            URI uri = url.toURI();
+            if (uri.isOpaque()) {
+                // Relative file: URL (e.g. file:../../flink/usrlib/job.jar). 
new File(URI)
+                // rejects opaque URIs, so resolve the decoded scheme-specific 
part relative to
+                // the working directory, which is exactly how a relative File 
is interpreted.
+                return new File(uri.getSchemeSpecificPart());
+            }
+            return new File(uri);
+        } catch (URISyntaxException | IllegalArgumentException e) {
+            // URISyntaxException: url.toURI() rejected the URL. 
IllegalArgumentException:
+            // new File(URI) cannot represent this file: URL as a local path, 
e.g. one carrying an
+            // authority component like file://host/share/job.jar. Callers 
only catch IOException,
+            // so wrap both so such a URL is skipped rather than escaping 
unchecked.
+            throw new IOException("Malformed file URL: " + url, e);
+        }
+    }
+}
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/SkillMaterializer.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/SkillMaterializer.java
index 300f3225..c05f7919 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/SkillMaterializer.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/SkillMaterializer.java
@@ -25,7 +25,6 @@ import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.HttpURLConnection;
-import java.net.URISyntaxException;
 import java.net.URL;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -204,11 +203,11 @@ public final class SkillMaterializer {
         URL innerUrl = new URL(innerSpec);
         File jarFileObj;
         try {
-            jarFileObj = new File(innerUrl.toURI());
-        } catch (URISyntaxException | IllegalArgumentException e) {
-            // IllegalArgumentException is thrown by File(URI) when the URI 
scheme is not "file"
-            // (e.g. a JAR nested behind http://). Surface both as IOException 
so callers that
-            // catch IOException for graceful failure handling see them.
+            jarFileObj = LocalUrls.toLocalFile(innerUrl);
+        } catch (IOException e) {
+            // toLocalFile rejects a non-file inner URL (e.g. a JAR nested 
behind http://) and
+            // malformed URLs. Re-wrap with the outer jar URL for context so 
callers that catch
+            // IOException for graceful failure handling see it.
             throw new IOException("Invalid JAR URL: " + jarUrl, e);
         }
         try (JarFile jarFile = new JarFile(jarFileObj)) {
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/ClasspathSkillRepositoryTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/ClasspathSkillRepositoryTest.java
index 7f0e99cc..74141946 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/ClasspathSkillRepositoryTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/ClasspathSkillRepositoryTest.java
@@ -22,11 +22,14 @@ import 
org.apache.flink.agents.runtime.skill.repository.ClasspathSkillRepository
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
+import java.io.File;
 import java.io.IOException;
 import java.net.URL;
 import java.net.URLClassLoader;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Collections;
+import java.util.Enumeration;
 import java.util.List;
 import java.util.jar.JarEntry;
 import java.util.jar.JarOutputStream;
@@ -89,6 +92,85 @@ class ClasspathSkillRepositoryTest {
                         .collect(Collectors.toList()));
     }
 
+    @Test
+    void loadFromRelativeJarUrl(@TempDir Path tempDir) throws IOException {
+        // A Flink deployment can add user-code JARs relative to the 
TaskManager working directory,
+        // so the class loader exposes the jar through a relative file: URL 
such as
+        // file:../../flink/usrlib/job.jar. new File(URI) rejects that opaque 
URI; the repository
+        // must still resolve and load the skills. Regression test for GH-966.
+        Path jar = tempDir.resolve("relative-skills.jar");
+        zipDirIntoJarUnderPrefix(resourcesRoot(), jar, "embedded-skills");
+
+        Path relativeJar = Path.of("").toAbsolutePath().relativize(jar);
+        URL relativeUrl =
+                new URL("file:" + 
relativeJar.toString().replace(File.separatorChar, '/'));
+        URLClassLoader loader = new URLClassLoader(new URL[] {relativeUrl}, /* 
parent */ null);
+
+        ClasspathSkillRepository repo = new 
ClasspathSkillRepository("embedded-skills", loader);
+        assertEquals(
+                List.of("github", "nano-banana-pro"),
+                repo.getSkills().stream()
+                        .map(AgentSkill::getName)
+                        .sorted()
+                        .collect(Collectors.toList()));
+    }
+
+    @Test
+    void loadFromDirectRelativeJarUrl(@TempDir Path tempDir) throws 
IOException {
+        // Some class loaders return from getResources() the exact (relative) 
jar: URL they were
+        // configured with, rather than an absolute one. This drives the jar 
branch of materialize
+        // straight into SkillMaterializer.copyJarEntries with a relative 
inner file: URL, without
+        // going through the URLClassLoader fallback scan. Regression test for 
GH-966.
+        Path jar = tempDir.resolve("relative-skills.jar");
+        zipDirIntoJarUnderPrefix(resourcesRoot(), jar, "embedded-skills");
+
+        Path relativeJar = Path.of("").toAbsolutePath().relativize(jar);
+        String relativeFileUrl = "file:" + 
relativeJar.toString().replace(File.separatorChar, '/');
+        URL directJarUrl = new URL("jar:" + relativeFileUrl + 
"!/embedded-skills");
+        ClassLoader loader = resourcesReturning("embedded-skills", 
directJarUrl);
+
+        ClasspathSkillRepository repo = new 
ClasspathSkillRepository("embedded-skills", loader);
+        assertEquals(
+                List.of("github", "nano-banana-pro"),
+                repo.getSkills().stream()
+                        .map(AgentSkill::getName)
+                        .sorted()
+                        .collect(Collectors.toList()));
+    }
+
+    @Test
+    void loadFromDirectRelativeDirectoryUrl() throws IOException {
+        // getResources() returns a relative file: directory URL, driving
+        // ClasspathSkillRepository.materializeFileUrl through LocalUrls. 
Regression test for
+        // GH-966. src/test/resources/skills is the module-relative directory 
holding the skills.
+        URL directDirUrl = new URL("file:src/test/resources/skills");
+        ClassLoader loader = resourcesReturning("skills", directDirUrl);
+
+        ClasspathSkillRepository repo = new ClasspathSkillRepository("skills", 
loader);
+        assertEquals(
+                List.of("github", "nano-banana-pro"),
+                repo.getSkills().stream()
+                        .map(AgentSkill::getName)
+                        .sorted()
+                        .collect(Collectors.toList()));
+    }
+
+    /**
+     * A plain (non-{@link URLClassLoader}) class loader whose {@link 
ClassLoader#getResources}
+     * hands back exactly {@code url} for {@code expectedResource}. Being 
non-{@code URLClassLoader}
+     * suppresses the fallback scan, so a test exercises only the direct 
{@code getResources} path.
+     */
+    private static ClassLoader resourcesReturning(String expectedResource, URL 
url) {
+        return new ClassLoader(null) {
+            @Override
+            public Enumeration<URL> getResources(String name) {
+                return expectedResource.equals(name)
+                        ? Collections.enumeration(List.of(url))
+                        : Collections.emptyEnumeration();
+            }
+        };
+    }
+
     @Test
     void loadFromMultipleJarsMergesSkills(@TempDir Path tempDir) throws 
IOException {
         // Two jars on the classpath, each carrying a different skill under 
the same prefix.
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/repository/LocalUrlsTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/repository/LocalUrlsTest.java
new file mode 100644
index 00000000..e18cd4b6
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/repository/LocalUrlsTest.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.runtime.skill.repository;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class LocalUrlsTest {
+
+    @Test
+    void resolvesAbsoluteFileUrl(@TempDir Path tempDir) throws IOException {
+        Path file = Files.createFile(tempDir.resolve("job.jar"));
+        File resolved = LocalUrls.toLocalFile(file.toUri().toURL());
+        assertEquals(file.toFile(), resolved);
+    }
+
+    @Test
+    void resolvesRelativeFileUrlAgainstWorkingDir(@TempDir Path tempDir) 
throws IOException {
+        // file:../../.../job.jar parses to an opaque URI that new File(URI) 
rejects; it must be
+        // resolved relative to the process working directory instead. 
Regression test for GH-966.
+        Path file = Files.createFile(tempDir.resolve("job.jar"));
+        Path relative = Path.of("").toAbsolutePath().relativize(file);
+        URL relativeUrl = new URL("file:" + 
relative.toString().replace(File.separatorChar, '/'));
+
+        File resolved = LocalUrls.toLocalFile(relativeUrl);
+
+        assertTrue(!resolved.isAbsolute(), "a relative file URL should stay a 
relative File");
+        assertEquals(
+                file.toFile().getCanonicalFile(),
+                resolved.getCanonicalFile(),
+                "relative File must resolve to the same location as the 
absolute path");
+    }
+
+    @Test
+    void rejectsNonFileUrl() throws IOException {
+        URL httpUrl = new URL("http://example.com/skills.jar";);
+        IOException ex = assertThrows(IOException.class, () -> 
LocalUrls.toLocalFile(httpUrl));
+        assertTrue(
+                ex.getMessage().contains("Not a local file URL"),
+                "expected 'Not a local file URL' in message, got: " + 
ex.getMessage());
+    }
+
+    @Test
+    void wrapsFileUrlWithAuthorityAsIoException() throws IOException {
+        // file://host/share/job.jar is hierarchical (not opaque) but new 
File(URI) rejects it with
+        // "URI has an authority component". Callers only catch IOException, 
so it must be wrapped
+        // rather than escaping as an unchecked IllegalArgumentException.
+        URL authorityUrl = new URL("file://host/share/job.jar");
+        IOException ex = assertThrows(IOException.class, () -> 
LocalUrls.toLocalFile(authorityUrl));
+        assertTrue(
+                ex.getCause() instanceof IllegalArgumentException,
+                "expected the IllegalArgumentException from new File(URI) as 
cause, got: "
+                        + ex.getCause());
+    }
+}

Reply via email to