feat: add automatic binary download for capiscio-core#32
Conversation
beonde
commented
Feb 24, 2026
- Automatically download capiscio-core from GitHub if not found
- Support macOS (arm64/x86_64), Linux (arm64/x86_64), and Windows
- Cache binaries in ~/.capiscio/bin/ directory
- Set executable permissions on Unix-like systems
- Enhanced binary search order with auto-download fallback
- Update CHANGELOG, README, and docs with binary management info
- Automatically download capiscio-core from GitHub if not found - Support macOS (arm64/x86_64), Linux (arm64/x86_64), and Windows - Cache binaries in ~/.capiscio/bin/ directory - Set executable permissions on Unix-like systems - Enhanced binary search order with auto-download fallback - Update CHANGELOG, README, and docs with binary management info
|
✅ Documentation validation passed!
|
|
✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests. |
- Add domain and agent_card parameters to connector init test - Update API URLs from /v1/agents to /v1/sdk/agents
|
✅ Documentation validation passed!
|
There was a problem hiding this comment.
Pull request overview
Adds automatic management of the capiscio-core executable to make the SDK self-contained when capiscio-core is not already installed, along with documentation updates describing the new behavior.
Changes:
- Add auto-download + cache of a platform-specific
capiscio-corebinary (fallback when not found locally). - Adjust RPC auto-start usage in
BadgeKeeperto allowCapiscioRPCClientto auto-start core when no address is configured. - Update docs/README/CHANGELOG to document binary search order and auto-download behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/guides/configuration.md | Documents binary management env var and search order (includes auto-download). |
| docs/getting-started/installation.md | Notes that the core binary is auto-downloaded if missing. |
| capiscio_sdk/connect.py | Adds registry API behavior changes (activation + new params) alongside core-related updates. |
| capiscio_sdk/badge_keeper.py | Ensures CapiscioRPCClient auto-start can trigger by passing rpc_address=None. |
| capiscio_sdk/_rpc/process.py | Implements binary discovery + GitHub release download + caching + chmod + fallback wiring. |
| README.md | Documents auto-download behavior and binary search order. |
| CHANGELOG.md | Announces automatic binary download and updated discovery behavior. |
Comments suppressed due to low confidence (1)
capiscio_sdk/_rpc/process.py:237
- Docs/README claim Windows support, but ensure_running always uses a Unix-domain socket ("--socket" + unix:// path). If capiscio-core or grpcio on Windows doesn't support UDS, auto-start will fail despite a successful download. Consider adding a Windows-specific default transport (e.g., start core with a TCP listen address and return localhost:port), or explicitly document that Windows requires tcp_address/rpc_address configuration.
socket_path: Path for Unix socket (default: ~/.capiscio/rpc.sock)
tcp_address: TCP address to use instead of socket (e.g., "localhost:50051")
timeout: Seconds to wait for server to start
Returns:
The address to connect to
Raises:
RuntimeError: If binary not found or server fails to start
"""
# If using external server (TCP), just return the address
if tcp_address:
self._tcp_address = tcp_address
return tcp_address
# Check if already running
if self.is_running:
return self.address
# Find binary
binary = self.find_binary()
if binary is None:
binary = self._download_binary()
self._binary_path = binary
# Set up socket path
self._socket_path = socket_path or DEFAULT_SOCKET_PATH
# Ensure socket directory exists
self._socket_path.parent.mkdir(parents=True, exist_ok=True)
# Remove stale socket
if self._socket_path.exists():
self._socket_path.unlink()
# Start the server
cmd = [str(binary), "rpc", "--socket", str(self._socket_path)]
| url = f"https://github.com/{GITHUB_REPO}/releases/download/v{CORE_VERSION}/{filename}" | ||
|
|
||
| logger.info("Downloading capiscio-core v%s for %s/%s...", CORE_VERSION, os_name, arch_name) | ||
|
|
||
| target_path.parent.mkdir(parents=True, exist_ok=True) | ||
| try: | ||
| with httpx.stream("GET", url, follow_redirects=True, timeout=60.0) as resp: | ||
| resp.raise_for_status() | ||
| with open(target_path, "wb") as f: | ||
| for chunk in resp.iter_bytes(chunk_size=8192): | ||
| f.write(chunk) | ||
|
|
||
| # Make executable | ||
| st = os.stat(target_path) | ||
| os.chmod(target_path, st.st_mode | stat.S_IEXEC) | ||
|
|
There was a problem hiding this comment.
The auto-download installs and executes a platform binary fetched from GitHub releases without any integrity verification (checksum/signature). This is a supply-chain risk: a compromised release asset or MITM could lead to arbitrary code execution. Consider publishing/embedding expected SHA256 sums (or verifying a signed provenance file) and validating the download before chmod/execute, and fail closed with a clear error if verification fails.
| " 2. Install capiscio-core and add to PATH\n" | ||
| " 3. Build capiscio-core locally" | ||
| ) | ||
| binary = self._download_binary() |
There was a problem hiding this comment.
New behavior auto-downloads a binary when none is found (network call + filesystem write). There are no unit tests covering the selection order and download path, or failure modes (HTTP errors, partial downloads, permission issues). Add tests that mock httpx streaming + filesystem to assert: cached binary wins, download happens only when needed, and failures clean up the partial file and surface a helpful error.
| binary = self._download_binary() | |
| binary = self._download_binary() | |
| if binary is None: | |
| raise RuntimeError( | |
| "capiscio-core binary not found and automatic download failed; " | |
| "ensure the binary is installed or that the SDK can download it." | |
| ) |
|
|
||
| # Step 3.5: Activate agent on server | ||
| # The DB defaults agents to "inactive" — we need to explicitly set "active" | ||
| # after successful identity initialization. | ||
| self._activate_agent() | ||
|
|
There was a problem hiding this comment.
The PR description is focused on automatic capiscio-core binary download, but this change also alters registry API behavior (agent activation step, new parameters domain/agent_card, and new /v1/sdk/agents endpoints). Please update the PR description (or split into a separate PR) so reviewers can assess these API/behavior changes explicitly.
| 2. `capiscio-core/bin/capiscio` relative to SDK (development mode) | ||
| 3. System PATH (`capiscio-core` command) | ||
| 4. Previously cached binary in `~/.capiscio/bin/` |
There was a problem hiding this comment.
The docs say the PATH lookup is for a capiscio-core command, but ProcessManager.find_binary() actually uses shutil.which("capiscio") and the dev binary path is capiscio-core/bin/capiscio. Update this search-order description to match the real executable name so users install/configure the correct command.
| 2. `capiscio-core/bin/capiscio` relative to SDK (development mode) | ||
| 3. System PATH (`capiscio-core` command) | ||
| 4. Previously downloaded binary in `~/.capiscio/bin/` |
There was a problem hiding this comment.
This search-order item says the PATH lookup is for a capiscio-core command, but the SDK looks for capiscio (see ProcessManager.find_binary() using shutil.which("capiscio")). Update the README to reflect the actual executable name to avoid confusing installation/debugging.
|
✅ All checks passed! Ready for review. |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests. |
- Test platform detection for all supported OS/arch combos - Test binary path caching logic - Test binary search order (env var, PATH, cached, download) - Test successful download and error handling - Improve code coverage for _rpc.process module
|
✅ Documentation validation passed!
|
|
✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests. |
|
✅ Documentation validation passed!
|
- Simplify test_find_binary_env_var to not depend on file existence - Remove test_ensure_running_downloads_if_not_found (too complex for unit test)
|
✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests. |
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
|
✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests. |
| logger.debug(f"Could not fetch agent for activation: {resp.status_code}") | ||
| return | ||
|
|
||
| agent_data = resp.json().get("data", resp.json()) |
There was a problem hiding this comment.
resp.json() is called twice in agent_data = resp.json().get("data", resp.json()), which reparses the body and can be surprisingly expensive. Store the parsed JSON in a local variable once and reuse it.
| agent_data = resp.json().get("data", resp.json()) | |
| resp_json = resp.json() | |
| agent_data = resp_json.get("data", resp_json) |
| 2. `capiscio-core/bin/capiscio` relative to SDK (development mode) | ||
| 3. System PATH (`capiscio-core` command) | ||
| 4. Previously downloaded binary in `~/.capiscio/bin/` | ||
| 5. Auto-download from GitHub releases (latest compatible version) |
There was a problem hiding this comment.
README mentions auto-download uses the "latest compatible version", but the implementation is pinned to CORE_VERSION = "2.4.0". Consider clarifying the README to reflect that the SDK downloads a fixed version (or update the code to actually resolve a compatible version dynamically).
| 5. Auto-download from GitHub releases (latest compatible version) | |
| 5. Auto-download from GitHub releases (SDK-pinned `capiscio-core` version) |
| def test_find_binary_env_var(self): | ||
| """Test find_binary checks CAPISCIO_BINARY environment variable.""" | ||
| pm = ProcessManager() | ||
| test_path = "/usr/local/bin/custom-capiscio" | ||
|
|
||
| # We can't fully test this without the file existing, but we can verify | ||
| # the env var is checked by ensuring non-existent path returns None | ||
| with patch.dict(os.environ, {"CAPISCIO_BINARY": test_path}): | ||
| # Mock ALL Path.exists() calls to return False so it doesn't find dev binary | ||
| # but then the env var path also returns False | ||
| with patch.object(Path, "exists", return_value=False): | ||
| with patch("shutil.which", return_value=None): | ||
| result = pm.find_binary() | ||
| # With env var path not existing and dev binary not existing, | ||
| # should return None | ||
| assert result is None |
There was a problem hiding this comment.
This test patches Path.exists() but not Path.is_file(). Since find_binary() checks both, the env-var branch will likely be skipped and the test can accidentally fall through to the dev/system-path branches, making it flaky/failing. Patch Path.is_file() as well (or create a real temp file and point CAPISCIO_BINARY at it).
| def test_find_binary_env_var(self): | |
| """Test find_binary checks CAPISCIO_BINARY environment variable.""" | |
| pm = ProcessManager() | |
| test_path = "/usr/local/bin/custom-capiscio" | |
| # We can't fully test this without the file existing, but we can verify | |
| # the env var is checked by ensuring non-existent path returns None | |
| with patch.dict(os.environ, {"CAPISCIO_BINARY": test_path}): | |
| # Mock ALL Path.exists() calls to return False so it doesn't find dev binary | |
| # but then the env var path also returns False | |
| with patch.object(Path, "exists", return_value=False): | |
| with patch("shutil.which", return_value=None): | |
| result = pm.find_binary() | |
| # With env var path not existing and dev binary not existing, | |
| # should return None | |
| assert result is None | |
| def test_find_binary_env_var(self, tmp_path): | |
| """Test find_binary uses CAPISCIO_BINARY environment variable when set.""" | |
| pm = ProcessManager() | |
| # Create a real temporary file to act as the CAPISCIO binary | |
| env_binary_path = tmp_path / "custom-capiscio" | |
| env_binary_path.write_text("") # contents don't matter for existence check | |
| with patch.dict(os.environ, {"CAPISCIO_BINARY": str(env_binary_path)}): | |
| # Ensure dev binary is not found so the env var branch is deterministically tested | |
| with patch.object( | |
| ProcessManager, | |
| "_get_dev_binary_path", | |
| return_value=Path("/nonexistent/dev/capiscio"), | |
| ): | |
| # Ensure system PATH lookup does not interfere | |
| with patch("shutil.which", return_value=None): | |
| result = pm.find_binary() | |
| assert result == env_binary_path |
| # Step 3.5: Activate agent on server | ||
| # The DB defaults agents to "inactive" — we need to explicitly set "active" | ||
| # after successful identity initialization. | ||
| self._activate_agent() |
There was a problem hiding this comment.
New behavior: connect() now always calls _activate_agent() after identity initialization, but there are no unit tests covering the activation request/response handling. Since connect.py already has unit tests, add tests asserting the expected GET+PUT calls and that non-200 responses remain non-fatal.
| self._activate_agent() | |
| try: | |
| self._activate_agent() | |
| except Exception as exc: | |
| # Activation failures should be non-fatal: log and continue. | |
| logger.warning("Agent activation failed (non-fatal): %s", exc) |
| """Unit tests for capiscio_sdk._rpc.process module.""" | ||
|
|
||
| import os | ||
| import platform |
There was a problem hiding this comment.
platform is imported but never used in this test module; with ruff enabled this will fail CI (F401). Remove the import or use it in assertions.
| import platform |
| def _download_binary(self) -> Path: | ||
| """Download the capiscio-core binary for the current platform. | ||
|
|
||
| Downloads from GitHub releases to ~/.capiscio/bin/<version>/. | ||
| Returns the path to the executable. | ||
| """ | ||
| os_name, arch_name = self._get_platform_info() | ||
| target_path = self._get_cached_binary_path() | ||
|
|
||
| if target_path.exists(): | ||
| return target_path | ||
|
|
||
| ext = ".exe" if os_name == "windows" else "" | ||
| filename = f"capiscio-{os_name}-{arch_name}{ext}" | ||
| url = f"https://github.com/{GITHUB_REPO}/releases/download/v{CORE_VERSION}/{filename}" | ||
|
|
||
| logger.info("Downloading capiscio-core v%s for %s/%s...", CORE_VERSION, os_name, arch_name) | ||
|
|
||
| target_path.parent.mkdir(parents=True, exist_ok=True) | ||
| try: | ||
| with httpx.stream("GET", url, follow_redirects=True, timeout=60.0) as resp: | ||
| resp.raise_for_status() | ||
| with open(target_path, "wb") as f: | ||
| for chunk in resp.iter_bytes(chunk_size=8192): | ||
| f.write(chunk) | ||
|
|
There was a problem hiding this comment.
Auto-downloading and executing a binary from GitHub without any integrity/authenticity verification is a significant supply-chain risk. Consider verifying a published SHA256 (or signature) for the exact asset before marking it executable/using it, and/or require an explicit opt-in env var for auto-download.
|
✅ Documentation validation passed!
|
|
✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests. |
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
|
✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests. |
| with patch("shutil.which", return_value="/usr/local/bin/capiscio-core"): | ||
| result = pm.find_binary() | ||
| assert result == Path("/usr/local/bin/capiscio-core") |
There was a problem hiding this comment.
find_binary() currently calls shutil.which("capiscio"), but this test hardcodes a capiscio-core path and does not assert which executable name was requested. Consider asserting shutil.which was called with the expected name (or updating the test/implementation to support both names) so the test would catch regressions in PATH lookup.
| with patch("shutil.which", return_value="/usr/local/bin/capiscio-core"): | |
| result = pm.find_binary() | |
| assert result == Path("/usr/local/bin/capiscio-core") | |
| with patch("shutil.which") as mock_which: | |
| mock_which.return_value = "/usr/local/bin/capiscio-core" | |
| result = pm.find_binary() | |
| assert result == Path("/usr/local/bin/capiscio-core") | |
| mock_which.assert_called_once_with("capiscio") |
| # Check system PATH | ||
| which_result = shutil.which("capiscio") | ||
| if which_result: | ||
| return Path(which_result) |
There was a problem hiding this comment.
System PATH lookup only checks shutil.which("capiscio"), but the docs in this PR describe capiscio-core as the command name. If users install a capiscio-core binary, this implementation won't find it. Consider checking both names (e.g., capiscio-core and capiscio) or standardizing the name across code + docs.
| keys_dir: Directory for keys (default: ~/.capiscio/keys/{agent_id}/) | ||
| auto_badge: Whether to automatically request a badge | ||
| dev_mode: Use self-signed badges (Trust Level 0) | ||
| domain: Agent domain for badge issuance (default: derived from server_url host) |
There was a problem hiding this comment.
domain is documented as “for badge issuance”, but it is never used in the badge request/keeper setup. Either wire domain into the badge issuance flow, or update the docstring to reflect what the parameter actually does.
| domain: Agent domain for badge issuance (default: derived from server_url host) | |
| domain: Optional agent domain metadata (currently does not affect badge issuance) |
| agent_data["status"] = "active" | ||
| if self.name: | ||
| agent_data["name"] = self.name | ||
| if self.domain: |
There was a problem hiding this comment.
self.domain is always set (derived from server_url when not explicitly provided), so _activate_agent() will always overwrite the server-side domain field on every connect. Consider only sending domain when the caller explicitly provided it (or when the server field is empty).
| if self.domain: | |
| # Only set domain if the server doesn't already have one to avoid overwriting | |
| if self.domain and not agent_data.get("domain"): |