|
5 | 5 | import os |
6 | 6 | import re |
7 | 7 | import sys |
| 8 | +import urllib.request |
| 9 | +import urllib.error |
8 | 10 | from pathlib import Path |
9 | 11 |
|
10 | 12 | try: |
|
32 | 34 | # Icon requirements |
33 | 35 | PREFERRED_ICON_SIZE = 16 |
34 | 36 |
|
| 37 | +# URL validation |
| 38 | +SKIP_URL_VALIDATION = os.environ.get("SKIP_URL_VALIDATION", "").lower() in ("1", "true", "yes") |
| 39 | + |
| 40 | + |
| 41 | +def url_exists(url: str, method: str = "HEAD") -> bool: |
| 42 | + """Check if a URL exists using HEAD or GET request.""" |
| 43 | + try: |
| 44 | + req = urllib.request.Request(url, method=method) |
| 45 | + req.add_header("User-Agent", "ACP-Registry-Validator/1.0") |
| 46 | + with urllib.request.urlopen(req, timeout=15) as response: |
| 47 | + return response.status in (200, 301, 302) |
| 48 | + except urllib.error.HTTPError as e: |
| 49 | + # Some servers don't support HEAD, try GET |
| 50 | + if method == "HEAD" and e.code in (403, 405): |
| 51 | + return url_exists(url, method="GET") |
| 52 | + return False |
| 53 | + except (urllib.error.URLError, TimeoutError, OSError): |
| 54 | + return False |
| 55 | + |
| 56 | + |
| 57 | +def extract_npm_package_name(package_spec: str) -> str: |
| 58 | + """Extract npm package name from spec like @scope/name@version.""" |
| 59 | + # Handle scoped packages: @scope/name@version -> @scope/name |
| 60 | + if package_spec.startswith("@"): |
| 61 | + # Find the second @ (version separator) if it exists |
| 62 | + at_positions = [i for i, c in enumerate(package_spec) if c == "@"] |
| 63 | + if len(at_positions) > 1: |
| 64 | + return package_spec[:at_positions[1]] |
| 65 | + return package_spec |
| 66 | + else: |
| 67 | + # Unscoped: name@version -> name |
| 68 | + return package_spec.split("@")[0] |
| 69 | + |
| 70 | + |
| 71 | +def validate_distribution_urls(distribution: dict) -> list[str]: |
| 72 | + """Validate that distribution URLs exist.""" |
| 73 | + if SKIP_URL_VALIDATION: |
| 74 | + return [] |
| 75 | + |
| 76 | + errors = [] |
| 77 | + |
| 78 | + # Check binary archive URLs |
| 79 | + if "binary" in distribution: |
| 80 | + for platform, target in distribution["binary"].items(): |
| 81 | + if "archive" in target: |
| 82 | + url = target["archive"] |
| 83 | + if not url_exists(url): |
| 84 | + errors.append(f"Binary archive URL not accessible for {platform}: {url}") |
| 85 | + |
| 86 | + # Check npm package URLs (registry.npmjs.org) |
| 87 | + seen_npm = set() |
| 88 | + for dist_type in ("npx", "bunx"): |
| 89 | + if dist_type in distribution: |
| 90 | + package = distribution[dist_type].get("package", "") |
| 91 | + pkg_name = extract_npm_package_name(package) |
| 92 | + if pkg_name and pkg_name not in seen_npm: |
| 93 | + seen_npm.add(pkg_name) |
| 94 | + npm_url = f"https://registry.npmjs.org/{pkg_name}" |
| 95 | + if not url_exists(npm_url): |
| 96 | + errors.append(f"npm package not found: {pkg_name}") |
| 97 | + |
| 98 | + # Check PyPI package URLs |
| 99 | + if "uvx" in distribution: |
| 100 | + package = distribution["uvx"].get("package", "") |
| 101 | + # Extract package name without version specifier |
| 102 | + pkg_name = re.split(r'[<>=!@]', package)[0] |
| 103 | + pypi_url = f"https://pypi.org/pypi/{pkg_name}/json" |
| 104 | + if not url_exists(pypi_url): |
| 105 | + errors.append(f"PyPI package not found: {pkg_name}") |
| 106 | + |
| 107 | + return errors |
| 108 | + |
35 | 109 |
|
36 | 110 | def validate_icon(icon_path: Path) -> list[str]: |
37 | 111 | """Validate icon.svg and return list of warnings/errors.""" |
@@ -227,6 +301,16 @@ def build_registry(): |
227 | 301 | continue |
228 | 302 | seen_ids[agent_id] = agent_dir.name |
229 | 303 |
|
| 304 | + # Validate distribution URLs |
| 305 | + if "distribution" in agent: |
| 306 | + url_errors = validate_distribution_urls(agent["distribution"]) |
| 307 | + if url_errors: |
| 308 | + print(f"Error: {agent_dir.name} distribution URL validation failed:") |
| 309 | + for error in url_errors: |
| 310 | + print(f" - {error}") |
| 311 | + has_errors = True |
| 312 | + continue |
| 313 | + |
230 | 314 | # Validate and set icon URL if icon exists |
231 | 315 | icon_path = agent_dir / "icon.svg" |
232 | 316 | if icon_path.exists(): |
|
0 commit comments