diff --git a/CHANGELOG.md b/CHANGELOG.md index 336ad99..fa9a825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.1] - 2026-01-07 + +### Enhancements + +- **Auto-download sandbox binary**: `shannot run` now automatically downloads the PyPy sandbox binary on first run (with graceful failure for unsupported platforms) +- **macOS runtime support**: Platform-specific PyPy versions (Linux: PyPy 3.6, macOS: PyPy 3.8) with automatic detection and download + ## [0.10.0] - 2026-01-06 ### Features diff --git a/pyproject.toml b/pyproject.toml index f996f10..ba4de22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "shannot" -version = "0.10.0" +version = "0.10.1" description = "Sandboxed system administration for LLM agents" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/shannot/cli.py b/shannot/cli.py index 60e8e80..6fb8875 100644 --- a/shannot/cli.py +++ b/shannot/cli.py @@ -294,7 +294,13 @@ def cmd_run(args: argparse.Namespace) -> int: # Local execution from .config import RUNTIME_DIR from .interact import main as interact_main - from .runtime import SetupError, find_pypy_sandbox, get_runtime_path, setup_runtime + from .runtime import ( + SetupError, + download_sandbox, + find_pypy_sandbox, + get_runtime_path, + setup_runtime, + ) argv = [] @@ -334,6 +340,15 @@ def cmd_run(args: argparse.Namespace) -> int: return 1 argv.append(f"--lib-path={runtime_path}") + + # Also try to download sandbox binary (graceful failure) + try: + download_sandbox(verbose=True) + except SetupError as e: + # Graceful: continue without binary, user will get instructions later + print(f"Note: Could not download sandbox binary: {e}", file=sys.stderr) + print("You can download it manually with: shannot setup runtime", file=sys.stderr) + print("", file=sys.stderr) print("Setup complete! Running script...", file=sys.stderr) print("", file=sys.stderr) diff --git a/shannot/config.py b/shannot/config.py index 3587acb..b178680 100644 --- a/shannot/config.py +++ b/shannot/config.py @@ -74,26 +74,69 @@ def _xdg_config_home() -> Path: CONFIG_DIR = _xdg_config_home() / "shannot" CONFIG_FILENAME = "config.toml" -# PyPy download source -PYPY_VERSION = "7.3.3" -PYPY_DOWNLOAD_URL = "https://downloads.python.org/pypy/pypy3.6-v7.3.3-src.tar.bz2" -PYPY_SHA256 = "a23d21ca0de0f613732af4b4abb0b0db1cc56134b5bf0e33614eca87ab8805af" - -# PyPy sandbox binary download -SANDBOX_VERSION = "pypy3-sandbox-7.3.6" # Release tag -SANDBOX_RELEASES_URL = "https://github.com/corv89/pypy/releases/download" +# Platform-specific PyPy stdlib (Linux=PyPy 3.6, macOS=PyPy 3.8) +PYPY_CONFIG: dict[str, dict[str, str]] = { + "linux": { + "version": "7.3.3", + "url": "https://downloads.python.org/pypy/pypy3.6-v7.3.3-src.tar.bz2", + "sha256": "a23d21ca0de0f613732af4b4abb0b0db1cc56134b5bf0e33614eca87ab8805af", + }, + "darwin": { + "version": "7.3.17", # PyPy 3.8 + "url": "https://downloads.python.org/pypy/pypy3.8-v7.3.17-src.tar.bz2", + "sha256": "7491a669e3abc3420aca0cfb58cc69f8e0defda4469f503fd6cb415ec93d6b13", + }, +} + + +def get_pypy_config() -> dict[str, str]: + """Get PyPy stdlib config for current platform.""" + import platform + + system = platform.system().lower() + return PYPY_CONFIG.get(system, PYPY_CONFIG["linux"]) + + +# Platform-specific sandbox binary configuration +SANDBOX_CONFIG: dict[str, dict[str, str]] = { + "linux-amd64": { + "version": "pypy3-sandbox-7.3.6", + "url": "https://github.com/corv89/pypy/releases/download/pypy3-sandbox-7.3.6/pypy3-sandbox-linux-amd64.tar.gz", + "sha256": "b5498d3ea1bd3d4d9de337e57e0784ed6bcb5ff669f160f9bc3e789d64aa812a", + }, + "linux-arm64": { + "version": "pypy3-sandbox-7.3.6", + "url": "https://github.com/corv89/pypy/releases/download/pypy3-sandbox-7.3.6/pypy3-sandbox-linux-arm64.tar.gz", + "sha256": "ee4423ae2fc40ed65bf563568d1c05edfbe4e33e43c958c40f876583005688a6", + }, + "darwin-amd64": { + "version": "pypy3.8-sandbox-7.3.17", + "url": "https://github.com/corv89/pypy/releases/download/pypy3.8-sandbox-7.3.17/pypy3.8-sandbox-darwin-amd64.tar.gz", + "sha256": "93308fb70339eb1dc6b59c0c5cb57dfe8562a11131f3ebdd5c992dfc7fa3289d", + }, + "darwin-arm64": { + "version": "pypy3.8-sandbox-7.3.17", + "url": "https://github.com/corv89/pypy/releases/download/pypy3.8-sandbox-7.3.17/pypy3.8-sandbox-darwin-arm64.tar.gz", + "sha256": "f874a0b00283d8abc87ee87b54e01676c639876bf15fd07865b7e5d2b319085c", + }, +} + + +def get_sandbox_lib_name() -> str: + """Get platform-specific shared library name.""" + import platform + + if platform.system() == "Darwin": + return "libpypy3-c.dylib" + return "libpypy3-c.so" + + +# Sandbox binary paths SANDBOX_BINARY_NAME = "pypy3-c" # Binary name inside tarball -SANDBOX_LIB_NAME = "libpypy3-c.so" # Shared library +SANDBOX_LIB_NAME = get_sandbox_lib_name() SANDBOX_BINARY_PATH = RUNTIME_DIR / SANDBOX_BINARY_NAME SANDBOX_LIB_PATH = RUNTIME_DIR / SANDBOX_LIB_NAME -# Platform-specific checksums -SANDBOX_CHECKSUMS: dict[str, str] = { - "linux-amd64": "b5498d3ea1bd3d4d9de337e57e0784ed6bcb5ff669f160f9bc3e789d64aa812a", - "linux-arm64": "ee4423ae2fc40ed65bf563568d1c05edfbe4e33e43c958c40f876583005688a6", - # "darwin-arm64": "", # Future -} - # ============================================================================ # Default values diff --git a/shannot/deploy.py b/shannot/deploy.py index 8d0fe7c..54948eb 100644 --- a/shannot/deploy.py +++ b/shannot/deploy.py @@ -13,12 +13,9 @@ from .config import ( DATA_DIR, - PYPY_DOWNLOAD_URL, - PYPY_SHA256, + PYPY_CONFIG, RELEASE_PATH_ENV, - SANDBOX_CHECKSUMS, - SANDBOX_RELEASES_URL, - SANDBOX_VERSION, + SANDBOX_CONFIG, SHANNOT_RELEASES_URL, get_remote_deploy_dir, get_version, @@ -176,20 +173,21 @@ def _get_sandbox_binary(arch: str) -> Path: Downloads from corv89/pypy releases. """ platform_tag = _arch_to_platform_tag(arch) - archive_name = f"pypy3-sandbox-{platform_tag}.tar.gz" + + # Get platform-specific config + sandbox_config = SANDBOX_CONFIG.get(platform_tag) + if not sandbox_config or not sandbox_config.get("sha256"): + raise FileNotFoundError(f"No pre-built sandbox for {platform_tag}") + + version = sandbox_config["version"] + url = sandbox_config["url"] + expected_sha256 = sandbox_config["sha256"] + archive_name = url.rsplit("/", 1)[-1] # Check cache - cached_binary = CACHE_DIR / "pypy" / SANDBOX_VERSION / f"pypy3-c-{arch}" + cached_binary = CACHE_DIR / "pypy" / version / f"pypy3-c-{arch}" if cached_binary.exists(): return cached_binary - - # Check for checksum - expected_sha256 = SANDBOX_CHECKSUMS.get(platform_tag, "") - if not expected_sha256: - raise FileNotFoundError(f"No pre-built sandbox for {platform_tag}") - - # Download archive - url = f"{SANDBOX_RELEASES_URL}/{SANDBOX_VERSION}/{archive_name}" with tempfile.TemporaryDirectory() as tmpdir: archive_path = Path(tmpdir) / archive_name _download_file(url, archive_path, f"Downloading pypy3-sandbox for {platform_tag}") @@ -229,20 +227,20 @@ def _get_sandbox_lib(arch: str) -> Path | None: Returns None if not present in archive (statically linked). """ platform_tag = _arch_to_platform_tag(arch) - archive_name = f"pypy3-sandbox-{platform_tag}.tar.gz" + + # Get platform-specific config + sandbox_config = SANDBOX_CONFIG.get(platform_tag) + if not sandbox_config or not sandbox_config.get("sha256"): + return None + + version = sandbox_config["version"] + url = sandbox_config["url"] + archive_name = url.rsplit("/", 1)[-1] # Check cache - cached_lib = CACHE_DIR / "pypy" / SANDBOX_VERSION / f"libpypy3-c-{arch}.so" + cached_lib = CACHE_DIR / "pypy" / version / f"libpypy3-c-{arch}.so" if cached_lib.exists(): return cached_lib - - # The library should have been extracted when we got the binary - # If not cached, try to extract from archive - expected_sha256 = SANDBOX_CHECKSUMS.get(platform_tag, "") - if not expected_sha256: - return None - - url = f"{SANDBOX_RELEASES_URL}/{SANDBOX_VERSION}/{archive_name}" with tempfile.TemporaryDirectory() as tmpdir: archive_path = Path(tmpdir) / archive_name _download_file(url, archive_path, f"Downloading sandbox lib for {platform_tag}") @@ -273,18 +271,25 @@ def _get_stdlib_archive() -> Path: Get PyPy stdlib archive, downloading if needed. Downloads from official PyPy downloads (python.org). + For remote deployment, always use Linux config. """ + # Get Linux PyPy config (remote deployment is always Linux) + pypy_config = PYPY_CONFIG["linux"] + url = pypy_config["url"] + expected_sha256 = pypy_config["sha256"] + archive_name = url.rsplit("/", 1)[-1] + # Cache the source archive - cached = CACHE_DIR / "pypy" / "pypy3.6-v7.3.3-src.tar.bz2" + cached = CACHE_DIR / "pypy" / archive_name if cached.exists(): return cached - _download_file(PYPY_DOWNLOAD_URL, cached, "Downloading PyPy stdlib") + _download_file(url, cached, "Downloading PyPy stdlib") # Verify checksum sys.stderr.write("[DEPLOY] Verifying checksum... ") sys.stderr.flush() - if not _verify_checksum(cached, PYPY_SHA256): + if not _verify_checksum(cached, expected_sha256): sys.stderr.write("FAILED\n") cached.unlink() raise RuntimeError("Checksum verification failed") diff --git a/shannot/runtime.py b/shannot/runtime.py index c7f722f..1cd9da6 100644 --- a/shannot/runtime.py +++ b/shannot/runtime.py @@ -14,19 +14,15 @@ from pathlib import Path from .config import ( - PYPY_DOWNLOAD_URL, - PYPY_SHA256, - PYPY_VERSION, RUNTIME_DIR, RUNTIME_LIB_PYPY, RUNTIME_LIB_PYTHON, SANDBOX_BINARY_NAME, SANDBOX_BINARY_PATH, - SANDBOX_CHECKSUMS, + SANDBOX_CONFIG, SANDBOX_LIB_NAME, SANDBOX_LIB_PATH, - SANDBOX_RELEASES_URL, - SANDBOX_VERSION, + get_pypy_config, ) @@ -67,8 +63,8 @@ def get_platform_tag() -> str | None: if system == "linux": return f"linux-{arch}" - # elif system == "darwin": - # return f"darwin-{arch}" # Future + elif system == "darwin": + return f"darwin-{arch}" return None @@ -185,8 +181,8 @@ def extract_runtime( def setup_runtime( force: bool = False, verbose: bool = True, - download_url: str = PYPY_DOWNLOAD_URL, - expected_sha256: str = PYPY_SHA256, + download_url: str | None = None, + expected_sha256: str | None = None, ) -> bool: """ Download and install PyPy runtime. @@ -194,12 +190,19 @@ def setup_runtime( Args: force: Reinstall even if already present verbose: Print progress to stdout - download_url: URL to download from - expected_sha256: Expected SHA256 checksum + download_url: URL to download from (uses platform-specific default) + expected_sha256: Expected SHA256 checksum (uses platform-specific default) Returns: True if installation succeeded """ + # Get platform-specific config + pypy_config = get_pypy_config() + if download_url is None: + download_url = pypy_config["url"] + if expected_sha256 is None: + expected_sha256 = pypy_config["sha256"] + # Check if already installed if is_runtime_installed() and not force: if verbose: @@ -215,7 +218,7 @@ def setup_runtime( # Download if verbose: - print(f"Downloading PyPy {PYPY_VERSION} stdlib from pypy.org...") + print(f"Downloading PyPy {pypy_config['version']} stdlib from pypy.org...") print(f" URL: {download_url}") with tempfile.TemporaryDirectory() as tmpdir: @@ -301,7 +304,6 @@ def remove_runtime(verbose: bool = True) -> bool: def download_sandbox( force: bool = False, verbose: bool = True, - version: str = SANDBOX_VERSION, ) -> bool: """ Download pre-built PyPy sandbox binary from GitHub releases. @@ -309,7 +311,6 @@ def download_sandbox( Args: force: Reinstall even if already present verbose: Print progress to stdout - version: Release version/tag to download Returns: True if installation succeeded @@ -329,22 +330,22 @@ def download_sandbox( if not platform_tag: raise SetupError( f"Unsupported platform: {platform.system()} {platform.machine()}\n" - "Supported: Linux x86_64, Linux aarch64\n" + "Supported: Linux x86_64, Linux aarch64, macOS x86_64, macOS arm64\n" "You can build from source: https://github.com/corv89/pypy" ) - # Check if we have a checksum for this platform - expected_sha256 = SANDBOX_CHECKSUMS.get(platform_tag, "") - if not expected_sha256: + # Get platform-specific config + sandbox_config = SANDBOX_CONFIG.get(platform_tag) + if not sandbox_config or not sandbox_config.get("sha256"): raise SetupError( f"No pre-built binary available for {platform_tag}\n" "You can build from source: https://github.com/corv89/pypy" ) - # Construct download URL - # Format: https://github.com/corv89/pypy/releases/download/pypy3-sandbox-7.3.6/pypy3-sandbox-linux-amd64.tar.gz - archive_name = f"pypy3-sandbox-{platform_tag}.tar.gz" - download_url = f"{SANDBOX_RELEASES_URL}/{version}/{archive_name}" + version = sandbox_config["version"] + download_url = sandbox_config["url"] + expected_sha256 = sandbox_config["sha256"] + archive_name = download_url.rsplit("/", 1)[-1] # Extract filename from URL if verbose: print(f"Downloading PyPy sandbox ({version}) for {platform_tag}...")