Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 93 additions & 1 deletion .github/workflows/tests/test_update_versions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
"""Tests for update_versions.py."""

import json
import urllib.error
from pathlib import Path
from unittest.mock import patch

import pytest

from update_versions import check_agent_version, is_prerelease, make_request
from update_versions import (
VersionUpdate,
apply_update,
check_agent_version,
is_prerelease,
make_request,
)


class TestMakeRequestServerErrors:
Expand Down Expand Up @@ -266,3 +273,88 @@ def test_returns_error_when_sources_have_no_common_version(
assert update is None
assert error is not None
assert error.error == "Version mismatch across distributions: binary=7.2.1, npx=7.2.4"


class TestApplyUpdateSha256Refresh:
"""apply_update refreshes sha256 fields when the archive URL changes."""

def _write_agent(self, tmp_path: Path, sha256: str | None) -> Path:
target: dict = {
"archive": "https://github.com/owner/repo/releases/download/v1.0.0/agent-linux.tar.gz",
"cmd": "./agent",
}
if sha256 is not None:
target["sha256"] = sha256
agent_data = {
"id": "demo",
"name": "Demo",
"version": "1.0.0",
"description": "",
"repository": "https://github.com/owner/repo",
"distribution": {"binary": {"linux-x86_64": target}},
}
agent_path = tmp_path / "agent.json"
agent_path.write_text(json.dumps(agent_data))
return agent_path

def _update(self, agent_path: Path) -> VersionUpdate:
return VersionUpdate(
agent_id="demo",
agent_path=agent_path,
current_version="1.0.0",
latest_version="2.0.0",
distribution_type="binary",
source_url="https://github.com/owner/repo",
repository="https://github.com/owner/repo",
)

@patch("update_versions.get_github_release_digests")
def test_refreshes_sha256_from_asset_digest(self, mock_digests, tmp_path: Path):
new_digest = "a" * 64
mock_digests.return_value = {"agent-linux.tar.gz": new_digest}

agent_path = self._write_agent(tmp_path, sha256="0" * 64)

assert apply_update(self._update(agent_path)) is True

updated = json.loads(agent_path.read_text())
target = updated["distribution"]["binary"]["linux-x86_64"]
assert target["sha256"] == new_digest
assert "v2.0.0" in target["archive"]
mock_digests.assert_called_once_with("https://github.com/owner/repo", "2.0.0")

@patch("update_versions.get_github_release_digests")
def test_adds_sha256_when_absent_and_digest_available(self, mock_digests, tmp_path: Path):
new_digest = "a" * 64
mock_digests.return_value = {"agent-linux.tar.gz": new_digest}

agent_path = self._write_agent(tmp_path, sha256=None)

assert apply_update(self._update(agent_path)) is True

updated = json.loads(agent_path.read_text())
assert updated["distribution"]["binary"]["linux-x86_64"]["sha256"] == new_digest

@patch("update_versions.get_github_release_digests", return_value={})
def test_warns_and_leaves_target_untouched_when_no_digest(
self, _mock_digests, tmp_path: Path, capsys
):
agent_path = self._write_agent(tmp_path, sha256=None)

assert apply_update(self._update(agent_path)) is True

updated = json.loads(agent_path.read_text())
assert "sha256" not in updated["distribution"]["binary"]["linux-x86_64"]
assert "no release digest for demo" in capsys.readouterr().err

@patch("update_versions.get_github_release_digests")
def test_skips_digest_lookup_for_non_github_repo(self, mock_digests, tmp_path: Path, capsys):
agent_path = self._write_agent(tmp_path, sha256=None)
update = self._update(agent_path)._replace(
repository="https://cursor.com/docs/cli/acp",
)

assert apply_update(update) is True

mock_digests.assert_not_called()
assert "no release digest" not in capsys.readouterr().err
25 changes: 25 additions & 0 deletions .github/workflows/tests/test_verify_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
from verify_agents import (
build_agent_process_env,
build_installed_npx_command,
compute_archive_sha256,
ensure_executable,
extract_archive,
npm_package_bin_name,
prepare_npx_package,
resolve_binary_executable,
run_process,
sha256_matches,
should_retry_npx_auth_with_install,
)

Expand Down Expand Up @@ -245,3 +247,26 @@ def test_should_retry_npx_auth_with_install_on_shim_error():
"Timeout after 120s waiting for initialize response",
"[Junie] Shim not found at /tmp/home/.local/bin/junie\nPlease reinstall: npm install",
)


def test_sha256_matches_computed_digest(tmp_path: Path):
archive = tmp_path / "payload.bin"
archive.write_bytes(b"hello world")
expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"

assert sha256_matches(compute_archive_sha256(archive), expected)


def test_sha256_matches_computed_digest_case_insensitive(tmp_path: Path):
archive = tmp_path / "payload.bin"
archive.write_bytes(b"hello world")
expected = "B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9"

assert sha256_matches(compute_archive_sha256(archive), expected)


def test_sha256_does_not_match_wrong_expected(tmp_path: Path):
archive = tmp_path / "payload.bin"
archive.write_bytes(b"hello world")

assert not sha256_matches(compute_archive_sha256(archive), "0" * 64)
82 changes: 65 additions & 17 deletions .github/workflows/update_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class VersionUpdate(NamedTuple):
latest_version: str
distribution_type: str # 'npx', 'uvx', 'binary', or combined like 'binary+npx'
source_url: str # URL where version was fetched from
repository: str # Agent's `repository` field (empty when unset)


class UpdateError(NamedTuple):
Expand Down Expand Up @@ -188,31 +189,63 @@ def get_pypi_versions(package_name: str) -> set[str] | None:
return None


def get_github_latest_release(repo_url: str) -> tuple[str | None, list[str]]:
"""Get latest release version and asset names from GitHub repo.
def _is_github_repo(repo_url: str) -> bool:
return "github.com" in repo_url

Returns: (version, [asset_names])
"""
# Extract owner/repo from URL

def _github_owner_repo(repo_url: str) -> tuple[str, str] | None:
"""Extract (owner, repo) from a GitHub repository URL, stripping any `.git`."""
match = re.search(r"github\.com/([^/]+)/([^/]+)", repo_url)
if not match:
return None, []

return None
owner, repo = match.groups()
if repo.endswith(".git"):
repo = repo[:-4]
return owner, repo

api_url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
data = make_request(api_url)

def _parse_release_digests(data: dict) -> dict[str, str]:
"""Extract {asset_name: hex_sha256} from a GitHub release payload."""
digests: dict[str, str] = {}
for a in data.get("assets", []):
if not isinstance(a, dict):
continue
name = a.get("name")
digest = a.get("digest", "")
if name and isinstance(digest, str) and digest.startswith("sha256:"):
digests[name] = digest.removeprefix("sha256:")
return digests


def get_github_latest_version(repo_url: str) -> str | None:
parsed = _github_owner_repo(repo_url)
if not parsed:
return None
owner, repo = parsed
data = make_request(f"https://api.github.com/repos/{owner}/{repo}/releases/latest")
if isinstance(data, dict):
tag = data.get("tag_name", "")
# Strip 'v' prefix if present
version = normalize_release_version(tag.lstrip("v") if tag else None)
assets = [a["name"] for a in data.get("assets", [])]
return version, assets
return normalize_release_version(tag.lstrip("v") if tag else None)
return None

return None, []

def get_github_release_digests(repo_url: str, version: str) -> dict[str, str]:
"""Return {asset_filename: hex_sha256} for the release tagged `version`.

Keys are asset filenames exactly as GitHub returns them (last path segment of
the asset's `browser_download_url`). Values are lowercase hex with the
`sha256:` prefix stripped.
"""
parsed = _github_owner_repo(repo_url)
if not parsed:
return {}
owner, repo = parsed
# Tries `v{version}` first (the common tag convention), then bare `{version}`.
for tag in (f"v{version}", version):
data = make_request(f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}")
if isinstance(data, dict):
return _parse_release_digests(data)
return {}


def get_github_release_versions(repo_url: str) -> set[str] | None:
Expand Down Expand Up @@ -241,7 +274,7 @@ def get_github_release_versions(repo_url: str) -> set[str] | None:
if versions:
return versions

latest, _assets = get_github_latest_release(repo_url)
latest = get_github_latest_version(repo_url)
if latest:
return {latest}

Expand Down Expand Up @@ -324,7 +357,7 @@ def check_agent_version(
return None, UpdateError(agent_id, f"Could not fetch PyPI versions for {package_name}")
source_versions["uvx"] = (versions, f"https://pypi.org/pypi/{package_name}/json")

if "binary" in distribution and repository and "github.com" in repository:
if "binary" in distribution and _is_github_repo(repository):
versions = get_github_release_versions(repository)
if not versions:
return None, UpdateError(
Expand Down Expand Up @@ -365,6 +398,7 @@ def check_agent_version(
latest_version=latest_version,
distribution_type=dist_types,
source_url=primary_source_url,
repository=repository,
), None


Expand Down Expand Up @@ -402,7 +436,10 @@ def apply_update(update: VersionUpdate) -> bool:
old_short = re.sub(r"\.0$", "", old_version) # 1.6.0 -> 1.6
new_short = re.sub(r"\.0$", "", new_version) # 1.7.0 -> 1.7

for _platform, target in distribution["binary"].items():
is_github_repo = _is_github_repo(update.repository)
asset_digests: dict[str, str] | None = None

for platform_name, target in distribution["binary"].items():
if "archive" in target:
original_url = target["archive"]
url = original_url
Expand All @@ -423,6 +460,17 @@ def apply_update(update: VersionUpdate) -> bool:
url = url.replace(f"-{old_short}-", f"-{new_short}-")
target["archive"] = url

if is_github_repo:
if asset_digests is None:
asset_digests = get_github_release_digests(update.repository, new_version)
digest = asset_digests.get(url.rsplit("/", 1)[-1])
if digest:
target["sha256"] = digest
else:
print(
f"WARN: no release digest for {update.agent_id} ({platform_name})",
file=sys.stderr,
)
# Write back
try:
with open(update.agent_path, "w") as f:
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/verify_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import argparse
import hashlib
import json
import os
import platform
Expand Down Expand Up @@ -138,6 +139,22 @@ def download_file(url: str, dest: Path) -> bool:
return False


def compute_archive_sha256(archive: Path) -> str:
hasher = hashlib.sha256()
with archive.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
hasher.update(chunk)
return hasher.hexdigest()


def sha256_matches(actual: str, expected: str) -> bool:
return actual.lower() == expected.lower()


def sha256_mismatch_message(expected: str, actual: str) -> str:
return f"SHA-256 mismatch: expected {expected.lower()}, got {actual.lower()}"


def is_safe_relative_path(path: str) -> bool:
"""Return whether a registry-supplied path stays relative inside the sandbox."""
normalized = path.replace("\\", "/")
Expand Down Expand Up @@ -447,6 +464,7 @@ def verify_binary(agent: dict, sandbox: Path, timeout: int, verbose: bool) -> Re

target = binary_dist[current_platform]
archive_url = target["archive"]
expected_sha256 = target.get("sha256")
cmd = target["cmd"]
args = target.get("args", [])
env = target.get("env", {})
Expand All @@ -463,6 +481,18 @@ def verify_binary(agent: dict, sandbox: Path, timeout: int, verbose: bool) -> Re
else:
print(f" → Using cached archive: {archive_name}")

if expected_sha256:
print(" → Verifying SHA-256...")
actual_sha256 = compute_archive_sha256(archive_path)
if not sha256_matches(actual_sha256, expected_sha256):
archive_path.unlink(missing_ok=True)
return Result(
agent_id,
"binary",
False,
sha256_mismatch_message(expected_sha256, actual_sha256),
)

# Extract (skip if already extracted)
if not extract_dir.exists():
print(" → Extracting archive...")
Expand Down Expand Up @@ -613,6 +643,7 @@ def prepare_binary(agent: dict, sandbox: Path) -> tuple[bool, str]:

target = binary_dist[current_platform]
archive_url = target["archive"]
expected_sha256 = target.get("sha256")

# Download (skip if already exists)
archive_name = archive_url.split("/")[-1]
Expand All @@ -624,6 +655,13 @@ def prepare_binary(agent: dict, sandbox: Path) -> tuple[bool, str]:
if not download_file(archive_url, archive_path):
return False, "Download failed"

if expected_sha256:
print(" → Verifying SHA-256...")
actual_sha256 = compute_archive_sha256(archive_path)
if not sha256_matches(actual_sha256, expected_sha256):
archive_path.unlink(missing_ok=True)
return False, sha256_mismatch_message(expected_sha256, actual_sha256)

# Extract (skip if already extracted)
if not extract_dir.exists():
print(" → Extracting archive...")
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ This is a registry of ACP (Agent Client Protocol) agents. The structure is:
- `distribution`: at least one of `binary`, `npx`, `uvx`
- `binary` distribution: builds for all operating systems (darwin, linux, windows) are recommended; missing OS families produce a warning
- `binary` archives must use supported formats (`.zip`, `.tar.gz`, `.tgz`, `.tar.bz2`, `.tbz2`, or raw binaries); installer formats (`.dmg`, `.pkg`, `.deb`, `.rpm`, `.msi`, `.appimage`) are rejected
- `binary` archives should pin the archive SHA-256 checksum with `sha256` field
- `icon.svg`: must be SVG format, 16x16, monochrome using `currentColor` (enables theming)
- **URL validation**: All distribution URLs must be accessible (binary archives, npm/PyPI packages)

Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ For standalone executables. At least one platform is required; providing builds
"binary": {
"darwin-aarch64": {
"archive": "https://github.com/.../darwin-arm64.tar.gz",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"cmd": "./your-binary",
"args": ["acp"]
},
Expand Down Expand Up @@ -187,6 +188,7 @@ Entries are validated against the [JSON Schema](agent.schema.json).

- At least one distribution method required (`binary`, `npx`, or `uvx`)
- Binary distributions require `archive` and `cmd` fields per platform
- Binary distributions should pin the archive SHA-256 checksum with `sha256` field
- Package distributions (`npx`, `uvx`) require `package` field

**Platforms** (for binary):
Expand Down
Loading