Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
17 changes: 16 additions & 1 deletion shannot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand Down Expand Up @@ -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)
Expand Down
75 changes: 59 additions & 16 deletions shannot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 33 additions & 28 deletions shannot/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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")
Expand Down
47 changes: 24 additions & 23 deletions shannot/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -185,21 +181,28 @@ 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.

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:
Expand All @@ -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:
Expand Down Expand Up @@ -301,15 +304,13 @@ 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.

Args:
force: Reinstall even if already present
verbose: Print progress to stdout
version: Release version/tag to download

Returns:
True if installation succeeded
Expand All @@ -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}...")
Expand Down
Loading