Skip to content

feat: add automatic binary download for capiscio-core#32

Merged
beonde merged 7 commits intomainfrom
feature/binary-auto-download
Feb 24, 2026
Merged

feat: add automatic binary download for capiscio-core#32
beonde merged 7 commits intomainfrom
feature/binary-auto-download

Conversation

@beonde
Copy link
Member

@beonde 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
Copilot AI review requested due to automatic review settings February 24, 2026 16:14
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ 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
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-core binary (fallback when not found locally).
  • Adjust RPC auto-start usage in BadgeKeeper to allow CapiscioRPCClient to 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)]

Comment on lines +165 to +180
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)

Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
" 2. Install capiscio-core and add to PATH\n"
" 3. Build capiscio-core locally"
)
binary = self._download_binary()
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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."
)

Copilot uses AI. Check for mistakes.
Comment on lines 365 to +370

# 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()

Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +607 to +609
2. `capiscio-core/bin/capiscio` relative to SDK (development mode)
3. System PATH (`capiscio-core` command)
4. Previously cached binary in `~/.capiscio/bin/`
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +560 to +562
2. `capiscio-core/bin/capiscio` relative to SDK (development mode)
3. System PATH (`capiscio-core` command)
4. Previously downloaded binary in `~/.capiscio/bin/`
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link

✅ All checks passed! Ready for review.

@codecov
Copy link

codecov bot commented Feb 24, 2026

Codecov Report

❌ Patch coverage is 43.24324% with 21 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
capiscio_sdk/connect.py 44.44% 20 Missing ⚠️
capiscio_sdk/badge_keeper.py 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions
Copy link

✅ 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
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests.

Copilot AI review requested due to automatic review settings February 24, 2026 16:38
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

- 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)
@github-actions
Copy link

✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests.

@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ All checks passed! Ready for review.

@github-actions
Copy link

✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

logger.debug(f"Could not fetch agent for activation: {resp.status_code}")
return

agent_data = resp.json().get("data", resp.json())
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
agent_data = resp.json().get("data", resp.json())
resp_json = resp.json()
agent_data = resp_json.get("data", resp_json)

Copilot uses AI. Check for mistakes.
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)
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
5. Auto-download from GitHub releases (latest compatible version)
5. Auto-download from GitHub releases (SDK-pinned `capiscio-core` version)

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +100
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
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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

Copilot uses AI. Check for mistakes.
# 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()
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
"""Unit tests for capiscio_sdk._rpc.process module."""

import os
import platform
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
import platform

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +176
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)

Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests.

Copilot AI review requested due to automatic review settings February 24, 2026 16:52
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ All checks passed! Ready for review.

@github-actions
Copy link

✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests.

@beonde beonde merged commit 1efb18e into main Feb 24, 2026
14 of 15 checks passed
@beonde beonde deleted the feature/binary-auto-download branch February 24, 2026 16:57
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Comment on lines +107 to +109
with patch("shutil.which", return_value="/usr/local/bin/capiscio-core"):
result = pm.find_binary()
assert result == Path("/usr/local/bin/capiscio-core")
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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")

Copilot uses AI. Check for mistakes.
Comment on lines 107 to 110
# Check system PATH
which_result = shutil.which("capiscio")
if which_result:
return Path(which_result)
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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)
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
domain: Agent domain for badge issuance (default: derived from server_url host)
domain: Optional agent domain metadata (currently does not affect badge issuance)

Copilot uses AI. Check for mistakes.
agent_data["status"] = "active"
if self.name:
agent_data["name"] = self.name
if self.domain:
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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"):

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants