-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add automatic binary download for capiscio-core #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2930cd8
67e7849
dcfbf19
b605187
0f69637
cc11925
6189576
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -547,10 +547,21 @@ if pm.is_running(): | |||||
|
|
||||||
| **Auto-Start Behavior:** | ||||||
| - ✅ Automatically downloads `capiscio-core` binary if not found | ||||||
| - Downloads from GitHub releases (capiscio/capiscio-core) | ||||||
| - Supports macOS (arm64/x86_64), Linux (arm64/x86_64), and Windows | ||||||
| - Caches binary in `~/.capiscio/bin/` for reuse | ||||||
| - Sets executable permissions automatically on Unix-like systems | ||||||
| - ✅ Starts on Unix socket by default (`~/.capiscio/rpc.sock`) | ||||||
| - ✅ Handles server crashes and restarts | ||||||
| - ✅ Cleans up on process exit | ||||||
|
|
||||||
| **Binary Search Order:** | ||||||
| 1. `CAPISCIO_BINARY` environment variable (if set) | ||||||
| 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) | ||||||
|
||||||
| 5. Auto-download from GitHub releases (latest compatible version) | |
| 5. Auto-download from GitHub releases (SDK-pinned `capiscio-core` version) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,17 +1,29 @@ | ||||||||||||||||
| """Process manager for the capiscio-core gRPC server.""" | ||||||||||||||||
|
|
||||||||||||||||
| import atexit | ||||||||||||||||
| import logging | ||||||||||||||||
| import os | ||||||||||||||||
| import platform | ||||||||||||||||
| import shutil | ||||||||||||||||
| import stat | ||||||||||||||||
| import subprocess | ||||||||||||||||
| import time | ||||||||||||||||
| from pathlib import Path | ||||||||||||||||
| from typing import Optional | ||||||||||||||||
| from typing import Optional, Tuple | ||||||||||||||||
|
|
||||||||||||||||
| import httpx | ||||||||||||||||
|
|
||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||
|
|
||||||||||||||||
| # Default socket path | ||||||||||||||||
| DEFAULT_SOCKET_DIR = Path.home() / ".capiscio" | ||||||||||||||||
| DEFAULT_SOCKET_PATH = DEFAULT_SOCKET_DIR / "rpc.sock" | ||||||||||||||||
|
|
||||||||||||||||
| # Binary download configuration | ||||||||||||||||
| CORE_VERSION = "2.4.0" | ||||||||||||||||
| GITHUB_REPO = "capiscio/capiscio-core" | ||||||||||||||||
| CACHE_DIR = DEFAULT_SOCKET_DIR / "bin" | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class ProcessManager: | ||||||||||||||||
| """Manages the capiscio-core gRPC server process. | ||||||||||||||||
|
|
@@ -72,8 +84,9 @@ def find_binary(self) -> Optional[Path]: | |||||||||||||||
|
|
||||||||||||||||
| Search order: | ||||||||||||||||
| 1. CAPISCIO_BINARY environment variable | ||||||||||||||||
| 2. capiscio-core/bin/capiscio relative to SDK | ||||||||||||||||
| 2. capiscio-core/bin/capiscio relative to SDK (development) | ||||||||||||||||
| 3. System PATH | ||||||||||||||||
| 4. Downloaded binary in ~/.capiscio/bin/ | ||||||||||||||||
| """ | ||||||||||||||||
| # Check environment variable | ||||||||||||||||
| env_path = os.environ.get("CAPISCIO_BINARY") | ||||||||||||||||
|
|
@@ -96,7 +109,85 @@ def find_binary(self) -> Optional[Path]: | |||||||||||||||
| if which_result: | ||||||||||||||||
| return Path(which_result) | ||||||||||||||||
|
|
||||||||||||||||
| # Check previously downloaded binary | ||||||||||||||||
| cached = self._get_cached_binary_path() | ||||||||||||||||
| if cached.exists(): | ||||||||||||||||
| return cached | ||||||||||||||||
|
|
||||||||||||||||
| return None | ||||||||||||||||
|
|
||||||||||||||||
| @staticmethod | ||||||||||||||||
| def _get_platform_info() -> Tuple[str, str]: | ||||||||||||||||
| """Determine OS and architecture for binary download.""" | ||||||||||||||||
| system = platform.system().lower() | ||||||||||||||||
| machine = platform.machine().lower() | ||||||||||||||||
|
|
||||||||||||||||
| if system == "darwin": | ||||||||||||||||
| os_name = "darwin" | ||||||||||||||||
| elif system == "linux": | ||||||||||||||||
| os_name = "linux" | ||||||||||||||||
| elif system == "windows": | ||||||||||||||||
| os_name = "windows" | ||||||||||||||||
| else: | ||||||||||||||||
| raise RuntimeError(f"Unsupported operating system: {system}") | ||||||||||||||||
|
|
||||||||||||||||
| if machine in ("x86_64", "amd64"): | ||||||||||||||||
| arch_name = "amd64" | ||||||||||||||||
| elif machine in ("arm64", "aarch64"): | ||||||||||||||||
| arch_name = "arm64" | ||||||||||||||||
| else: | ||||||||||||||||
| raise RuntimeError(f"Unsupported architecture: {machine}") | ||||||||||||||||
|
|
||||||||||||||||
| return os_name, arch_name | ||||||||||||||||
|
|
||||||||||||||||
| @staticmethod | ||||||||||||||||
| def _get_cached_binary_path() -> Path: | ||||||||||||||||
| """Get the path where the downloaded binary would be cached.""" | ||||||||||||||||
| os_name, arch_name = ProcessManager._get_platform_info() | ||||||||||||||||
| ext = ".exe" if os_name == "windows" else "" | ||||||||||||||||
| filename = f"capiscio-{os_name}-{arch_name}{ext}" | ||||||||||||||||
| return CACHE_DIR / CORE_VERSION / filename | ||||||||||||||||
|
|
||||||||||||||||
| 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) | ||||||||||||||||
|
|
||||||||||||||||
|
Comment on lines
+151
to
+176
|
||||||||||||||||
| # Make executable | ||||||||||||||||
| st = os.stat(target_path) | ||||||||||||||||
| os.chmod(target_path, st.st_mode | stat.S_IEXEC) | ||||||||||||||||
|
|
||||||||||||||||
|
Comment on lines
+165
to
+180
|
||||||||||||||||
| logger.info("Installed capiscio-core v%s at %s", CORE_VERSION, target_path) | ||||||||||||||||
| return target_path | ||||||||||||||||
|
|
||||||||||||||||
| except Exception as e: | ||||||||||||||||
| if target_path.exists(): | ||||||||||||||||
| target_path.unlink() | ||||||||||||||||
| raise RuntimeError( | ||||||||||||||||
| f"Failed to download capiscio-core from {url}: {e}\n" | ||||||||||||||||
| "You can also set CAPISCIO_BINARY to point to an existing binary." | ||||||||||||||||
| ) from e | ||||||||||||||||
|
|
||||||||||||||||
| def ensure_running( | ||||||||||||||||
| self, | ||||||||||||||||
|
|
@@ -129,12 +220,7 @@ def ensure_running( | |||||||||||||||
| # Find binary | ||||||||||||||||
| binary = self.find_binary() | ||||||||||||||||
| if binary is None: | ||||||||||||||||
| raise RuntimeError( | ||||||||||||||||
| "capiscio binary not found. Please either:\n" | ||||||||||||||||
| " 1. Set CAPISCIO_BINARY environment variable\n" | ||||||||||||||||
| " 2. Install capiscio-core and add to PATH\n" | ||||||||||||||||
| " 3. Build capiscio-core locally" | ||||||||||||||||
| ) | ||||||||||||||||
| binary = self._download_binary() | ||||||||||||||||
|
||||||||||||||||
| 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." | |
| ) |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -109,7 +109,9 @@ def _ensure_did_registered( | |||||||||||||
| } | ||||||||||||||
| payload = {"did": did} | ||||||||||||||
| if public_key_jwk: | ||||||||||||||
| payload["publicKey"] = public_key_jwk | ||||||||||||||
| # Server expects publicKey as a JSON string (Go *string), not a raw object. | ||||||||||||||
| # The string must contain a valid Ed25519 JWK per RFC-003. | ||||||||||||||
| payload["publicKey"] = json.dumps(public_key_jwk) if isinstance(public_key_jwk, dict) else public_key_jwk | ||||||||||||||
|
|
||||||||||||||
| try: | ||||||||||||||
| resp = httpx.patch(url, headers=headers, json=payload, timeout=30.0) | ||||||||||||||
|
|
@@ -220,6 +222,8 @@ def connect( | |||||||||||||
| keys_dir: Optional[Path] = None, | ||||||||||||||
| auto_badge: bool = True, | ||||||||||||||
| dev_mode: bool = False, | ||||||||||||||
| domain: Optional[str] = None, | ||||||||||||||
| agent_card: Optional[dict] = None, | ||||||||||||||
| ) -> AgentIdentity: | ||||||||||||||
| """ | ||||||||||||||
| Connect to CapiscIO and get a fully-configured agent identity. | ||||||||||||||
|
|
@@ -239,6 +243,8 @@ def connect( | |||||||||||||
| 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) | ||||||||||||||
|
||||||||||||||
| domain: Agent domain for badge issuance (default: derived from server_url host) | |
| domain: Optional agent domain metadata (currently does not affect badge issuance) |
Copilot
AI
Feb 24, 2026
There was a problem hiding this comment.
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.
| 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
AI
Feb 24, 2026
There was a problem hiding this comment.
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
AI
Feb 24, 2026
There was a problem hiding this comment.
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.
| agent_data = resp.json().get("data", resp.json()) | |
| resp_json = resp.json() | |
| agent_data = resp_json.get("data", resp_json) |
Copilot
AI
Feb 24, 2026
There was a problem hiding this comment.
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).
| 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"): |
There was a problem hiding this comment.
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-corecommand, but the SDK looks forcapiscio(see ProcessManager.find_binary() using shutil.which("capiscio")). Update the README to reflect the actual executable name to avoid confusing installation/debugging.