From 6b1197f2e1279778d94570f84bc1baccdf683973 Mon Sep 17 00:00:00 2001 From: Corv Date: Wed, 7 Jan 2026 12:19:06 +0100 Subject: [PATCH 1/3] feat: auto-download sandbox binary and add macOS support - Auto-download PyPy sandbox binary on first `shannot run` - Add macOS support with platform-specific PyPy versions: - Linux: PyPy 3.6, sandbox 7.3.6 (stable) - macOS: PyPy 3.8, sandbox 7.3.17 (experimental) - Add platform-aware library naming (.so vs .dylib) - Graceful failure when binary unavailable (user gets instructions) - Update deploy.py to use new platform-specific config --- shannot/cli.py | 17 ++++++++++- shannot/config.py | 75 ++++++++++++++++++++++++++++++++++++---------- shannot/deploy.py | 61 ++++++++++++++++++++----------------- shannot/runtime.py | 47 +++++++++++++++-------------- 4 files changed, 132 insertions(+), 68 deletions(-) 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}...") From 295bd325df09fd9286e166feac23aea2ef24b857 Mon Sep 17 00:00:00 2001 From: Corv Date: Wed, 7 Jan 2026 12:20:11 +0100 Subject: [PATCH 2/3] Bump version to 0.10.1 and update changelog --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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"} From f311d77fd3b2e73990896eb9ab3dd9d92f47719e Mon Sep 17 00:00:00 2001 From: Corv Date: Wed, 7 Jan 2026 12:42:58 +0100 Subject: [PATCH 3/3] Fix SSL certificate verification for Nuitka binaries on macOS Add get_ssl_context() helper that tries known certificate locations when the default SSL context fails to find system certificates. Also fix macOS stdlib URL to use v7.3.11 (v7.3.17 doesn't exist). Bump version to 0.10.2. --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- shannot/config.py | 6 +++--- shannot/deploy.py | 4 +++- shannot/runtime.py | 34 +++++++++++++++++++++++++++++++++- uv.lock | 2 +- 6 files changed, 48 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa9a825..be088a8 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.2] - 2026-01-07 + +### Bug Fixes + +- Fix SSL certificate verification for Nuitka binaries on macOS +- Fix macOS stdlib download URL (use PyPy 3.8 v7.3.11) + ## [0.10.1] - 2026-01-07 ### Enhancements diff --git a/pyproject.toml b/pyproject.toml index ba4de22..bb092d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "shannot" -version = "0.10.1" +version = "0.10.2" description = "Sandboxed system administration for LLM agents" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/shannot/config.py b/shannot/config.py index b178680..86d7dfe 100644 --- a/shannot/config.py +++ b/shannot/config.py @@ -82,9 +82,9 @@ def _xdg_config_home() -> Path: "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", + "version": "7.3.11", # PyPy 3.8 + "url": "https://downloads.python.org/pypy/pypy3.8-v7.3.11-src.tar.bz2", + "sha256": "4d6769bfca73734e8666fd70503b7ceb06a6e259110e617331bb3899ca4e6058", }, } diff --git a/shannot/deploy.py b/shannot/deploy.py index 54948eb..250fd38 100644 --- a/shannot/deploy.py +++ b/shannot/deploy.py @@ -20,6 +20,7 @@ get_remote_deploy_dir, get_version, ) +from .runtime import get_ssl_context from .ssh import SSHConnection if TYPE_CHECKING: @@ -77,7 +78,8 @@ def _download_file(url: str, dest: Path, desc: str = "Downloading") -> None: try: request = urllib.request.Request(url, headers={"User-Agent": "shannot/1.0"}) - with urllib.request.urlopen(request) as response: + ssl_context = get_ssl_context() + with urllib.request.urlopen(request, context=ssl_context) as response: total_size = int(response.headers.get("Content-Length", 0)) downloaded = 0 diff --git a/shannot/runtime.py b/shannot/runtime.py index 1cd9da6..d123fc1 100644 --- a/shannot/runtime.py +++ b/shannot/runtime.py @@ -6,6 +6,7 @@ import os import platform import shutil +import ssl import sys import tarfile import tempfile @@ -32,6 +33,36 @@ class SetupError(Exception): pass +def get_ssl_context() -> ssl.SSLContext: + """Get SSL context that works on macOS with Nuitka binaries. + + Nuitka-compiled binaries on macOS can't find system SSL certificates. + This function tries multiple certificate locations. + """ + # Try default context first (works on most systems) + ctx = ssl.create_default_context() + + # On macOS, try known certificate locations if default fails verification + if platform.system() == "Darwin": + # Common certificate locations on macOS + cert_paths = [ + "/etc/ssl/cert.pem", # Homebrew OpenSSL + "/opt/homebrew/etc/openssl@3/cert.pem", # Homebrew ARM64 + "/usr/local/etc/openssl@3/cert.pem", # Homebrew x86_64 + "/opt/homebrew/etc/openssl/cert.pem", + "/usr/local/etc/openssl/cert.pem", + ] + for cert_path in cert_paths: + if os.path.exists(cert_path): + try: + ctx = ssl.create_default_context(cafile=cert_path) + break + except ssl.SSLError: + continue + + return ctx + + def is_runtime_installed() -> bool: """Check if runtime is installed and valid.""" return RUNTIME_LIB_PYTHON.is_dir() and RUNTIME_LIB_PYPY.is_dir() @@ -114,8 +145,9 @@ def download_with_progress( ) -> None: """Download URL to dest with optional progress reporting.""" request = urllib.request.Request(url, headers={"User-Agent": "shannot/1.0"}) + ssl_context = get_ssl_context() - with urllib.request.urlopen(request) as response: + with urllib.request.urlopen(request, context=ssl_context) as response: total_size = int(response.headers.get("Content-Length", 0)) downloaded = 0 diff --git a/uv.lock b/uv.lock index 47eba34..15c2168 100644 --- a/uv.lock +++ b/uv.lock @@ -793,7 +793,7 @@ wheels = [ [[package]] name = "shannot" -version = "0.10.0" +version = "0.10.2" source = { editable = "." } [package.optional-dependencies]