From b2a143a4a8e1c26db3f8d96b201ea4f1affde7d1 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Fri, 19 Dec 2025 16:41:09 -0800 Subject: [PATCH 01/22] prime tunnel --- packages/prime-tunnel/LICENSE | 21 ++ packages/prime-tunnel/README.md | 51 ++++ packages/prime-tunnel/pyproject.toml | 62 +++++ .../prime-tunnel/src/prime_tunnel/__init__.py | 31 +++ .../prime-tunnel/src/prime_tunnel/binary.py | 135 +++++++++ .../src/prime_tunnel/core/__init__.py | 6 + .../src/prime_tunnel/core/client.py | 262 +++++++++++++++++ .../src/prime_tunnel/core/config.py | 77 +++++ .../src/prime_tunnel/exceptions.py | 28 ++ .../prime-tunnel/src/prime_tunnel/models.py | 59 ++++ .../prime-tunnel/src/prime_tunnel/py.typed | 0 .../prime-tunnel/src/prime_tunnel/tunnel.py | 263 ++++++++++++++++++ packages/prime-tunnel/tests/test_tunnel.py | 74 +++++ packages/prime/pyproject.toml | 2 + .../prime/src/prime_cli/commands/tunnel.py | 151 ++++++++++ packages/prime/src/prime_cli/main.py | 2 + uv.lock | 45 +++ 17 files changed, 1269 insertions(+) create mode 100644 packages/prime-tunnel/LICENSE create mode 100644 packages/prime-tunnel/README.md create mode 100644 packages/prime-tunnel/pyproject.toml create mode 100644 packages/prime-tunnel/src/prime_tunnel/__init__.py create mode 100644 packages/prime-tunnel/src/prime_tunnel/binary.py create mode 100644 packages/prime-tunnel/src/prime_tunnel/core/__init__.py create mode 100644 packages/prime-tunnel/src/prime_tunnel/core/client.py create mode 100644 packages/prime-tunnel/src/prime_tunnel/core/config.py create mode 100644 packages/prime-tunnel/src/prime_tunnel/exceptions.py create mode 100644 packages/prime-tunnel/src/prime_tunnel/models.py create mode 100644 packages/prime-tunnel/src/prime_tunnel/py.typed create mode 100644 packages/prime-tunnel/src/prime_tunnel/tunnel.py create mode 100644 packages/prime-tunnel/tests/test_tunnel.py create mode 100644 packages/prime/src/prime_cli/commands/tunnel.py diff --git a/packages/prime-tunnel/LICENSE b/packages/prime-tunnel/LICENSE new file mode 100644 index 00000000..44a4d7b1 --- /dev/null +++ b/packages/prime-tunnel/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Prime Intellect + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/prime-tunnel/README.md b/packages/prime-tunnel/README.md new file mode 100644 index 00000000..62e7c6fb --- /dev/null +++ b/packages/prime-tunnel/README.md @@ -0,0 +1,51 @@ +# Prime Tunnel SDK + +Expose local services via secure tunnels on Prime infrastructure. + +## Installation + +```bash +uv pip install prime-tunnel +``` + +Or with pip: + +```bash +pip install prime-tunnel +``` + +## Quick Start + +```python +from prime_tunnel import Tunnel + +# Create and start a tunnel +async with Tunnel(local_port=8765) as tunnel: + print(f"Tunnel URL: {tunnel.url}") + # Your local service on port 8765 is now accessible at tunnel.url +``` + +## CLI Usage + +```bash +# Start a tunnel +prime tunnel start --port 8765 + +# List active tunnels +prime tunnel list + +# Get tunnel status +prime tunnel status +``` + +## Documentation + +Full API reference: https://github.com/PrimeIntellect-ai/prime-cli/tree/main/packages/prime-tunnel + +## Related Packages + +- **`prime`** - Full CLI + SDK with pods, inference, and more (includes this package) + +## License + +MIT License - see LICENSE file for details diff --git a/packages/prime-tunnel/pyproject.toml b/packages/prime-tunnel/pyproject.toml new file mode 100644 index 00000000..62808361 --- /dev/null +++ b/packages/prime-tunnel/pyproject.toml @@ -0,0 +1,62 @@ +[project] +name = "prime-tunnel" +# Version is single-sourced from src/prime_tunnel/__init__.py via Hatch +dynamic = ["version"] +description = "Prime Intellect Tunnel SDK - Expose local services via secure tunnels" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [ + { name = "Prime Intellect", email = "contact@primeintellect.ai" } +] +dependencies = [ + "httpx>=0.25.0", + "pydantic>=2.0.0", + "tenacity>=8.0.0", +] +keywords = ["tunnel", "reverse-proxy", "networking", "frp", "sdk"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules" +] + +[project.urls] +Homepage = "https://github.com/PrimeIntellect-ai/prime-cli" +Documentation = "https://github.com/PrimeIntellect-ai/prime-cli/tree/main/packages/prime-tunnel" +Repository = "https://github.com/PrimeIntellect-ai/prime-cli.git" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-xdist>=3.0.0", + "pytest-asyncio>=0.23.0", + "ruff>=0.13.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/prime_tunnel/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/prime_tunnel"] + +[tool.pytest.ini_options] +addopts = "-v" +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +extend-select = ["E", "F", "I"] diff --git a/packages/prime-tunnel/src/prime_tunnel/__init__.py b/packages/prime-tunnel/src/prime_tunnel/__init__.py new file mode 100644 index 00000000..9644bf5a --- /dev/null +++ b/packages/prime-tunnel/src/prime_tunnel/__init__.py @@ -0,0 +1,31 @@ +"""Prime Tunnel SDK - Expose local services via secure tunnels.""" + +__version__ = "0.1.0" + +from prime_tunnel.core import Config, TunnelClient +from prime_tunnel.exceptions import ( + TunnelAuthError, + TunnelConnectionError, + TunnelError, + TunnelTimeoutError, +) +from prime_tunnel.models import TunnelConfig, TunnelInfo, TunnelStatus +from prime_tunnel.tunnel import Tunnel + +__all__ = [ + "__version__", + # Core + "Config", + "TunnelClient", + # Main interface + "Tunnel", + # Models + "TunnelConfig", + "TunnelInfo", + "TunnelStatus", + # Exceptions + "TunnelError", + "TunnelAuthError", + "TunnelConnectionError", + "TunnelTimeoutError", +] diff --git a/packages/prime-tunnel/src/prime_tunnel/binary.py b/packages/prime-tunnel/src/prime_tunnel/binary.py new file mode 100644 index 00000000..b3aed779 --- /dev/null +++ b/packages/prime-tunnel/src/prime_tunnel/binary.py @@ -0,0 +1,135 @@ +import hashlib +import platform +import shutil +import stat +import tarfile +import tempfile +from pathlib import Path + +import httpx + +from prime_tunnel.core.config import Config +from prime_tunnel.exceptions import BinaryDownloadError + +FRPC_VERSION = "0.65.0" +FRPC_URLS = { + ( + "Darwin", + "arm64", + ): f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_darwin_arm64.tar.gz", + ( + "Darwin", + "x86_64", + ): f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_darwin_amd64.tar.gz", + ( + "Linux", + "x86_64", + ): f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_amd64.tar.gz", + ( + "Linux", + "aarch64", + ): f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_arm64.tar.gz", + ( + "Linux", + "arm64", + ): f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_arm64.tar.gz", +} +FRPC_CHECKSUMS = { + ("Darwin", "arm64"): None, # TODO: Add checksums + ("Darwin", "x86_64"): None, + ("Linux", "x86_64"): None, + ("Linux", "aarch64"): None, + ("Linux", "arm64"): None, +} + + +def _get_platform_key() -> tuple[str, str]: + system = platform.system() + machine = platform.machine() + + if machine in ("AMD64", "x86_64"): + machine = "x86_64" + elif machine in ("arm64", "aarch64"): + machine = "arm64" if system == "Darwin" else "aarch64" + + return (system, machine) + + +def _download_frpc(dest: Path) -> None: + platform_key = _get_platform_key() + url = FRPC_URLS.get(platform_key) + + if not url: + raise BinaryDownloadError(f"Unsupported platform: {platform_key[0]} {platform_key[1]}") + + expected_checksum = FRPC_CHECKSUMS.get(platform_key) + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + archive_path = tmpdir_path / "frp.tar.gz" + + try: + with httpx.stream("GET", url, follow_redirects=True, timeout=120.0) as response: + response.raise_for_status() + downloaded = 0 + + with open(archive_path, "wb") as f: + for chunk in response.iter_bytes(chunk_size=8192): + f.write(chunk) + downloaded += len(chunk) + + except httpx.HTTPError as e: + raise BinaryDownloadError(f"Failed to download frpc: {e}") from e + + if expected_checksum: + actual_checksum = _compute_sha256(archive_path) + if actual_checksum != expected_checksum: + raise BinaryDownloadError( + f"Checksum mismatch: expected {expected_checksum}, got {actual_checksum}" + ) + + try: + with tarfile.open(archive_path, "r:gz") as tar: + for member in tar.getmembers(): + if member.name.endswith("/frpc") or member.name == "frpc": + member.name = "frpc" + tar.extract(member, tmpdir_path) + break + else: + raise BinaryDownloadError("frpc binary not found in archive") + + except tarfile.TarError as e: + raise BinaryDownloadError(f"Failed to extract frpc: {e}") from e + + extracted_path = tmpdir_path / "frpc" + if not extracted_path.exists(): + raise BinaryDownloadError("frpc binary not found after extraction") + + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(extracted_path, dest) + dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def _compute_sha256(path: Path) -> str: + sha256 = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + return sha256.hexdigest() + + +def get_frpc_path() -> Path: + config = Config() + frpc_path = config.bin_dir / "frpc" + version_file = config.bin_dir / ".frpc_version" + + if frpc_path.exists(): + if version_file.exists(): + current_version = version_file.read_text().strip() + if current_version == FRPC_VERSION: + return frpc_path + + _download_frpc(frpc_path) + version_file.write_text(FRPC_VERSION) + + return frpc_path diff --git a/packages/prime-tunnel/src/prime_tunnel/core/__init__.py b/packages/prime-tunnel/src/prime_tunnel/core/__init__.py new file mode 100644 index 00000000..9e728ea4 --- /dev/null +++ b/packages/prime-tunnel/src/prime_tunnel/core/__init__.py @@ -0,0 +1,6 @@ +"""Prime Tunnel Core - HTTP client and configuration.""" + +from prime_tunnel.core.client import TunnelClient +from prime_tunnel.core.config import Config + +__all__ = ["Config", "TunnelClient"] diff --git a/packages/prime-tunnel/src/prime_tunnel/core/client.py b/packages/prime-tunnel/src/prime_tunnel/core/client.py new file mode 100644 index 00000000..95bbf691 --- /dev/null +++ b/packages/prime-tunnel/src/prime_tunnel/core/client.py @@ -0,0 +1,262 @@ +import sys +from typing import Any, Dict, Optional + +import httpx +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from prime_tunnel.core.config import Config +from prime_tunnel.exceptions import TunnelAuthError, TunnelError, TunnelTimeoutError +from prime_tunnel.models import TunnelInfo, TunnelRegistrationResponse + +# Retry configuration for transient connection errors +RETRYABLE_EXCEPTIONS = ( + httpx.RemoteProtocolError, + httpx.ConnectError, + httpx.PoolTimeout, +) + + +def _default_user_agent() -> str: + """Build default User-Agent string.""" + from prime_tunnel import __version__ + + python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + return f"prime-tunnel/{__version__} python/{python_version}" + + +class TunnelClient: + """Client for interacting with Prime Tunnel API.""" + + def __init__( + self, + api_key: Optional[str] = None, + user_agent: Optional[str] = None, + timeout: float = 30.0, + ): + """ + Initialize the tunnel client. + + Args: + api_key: Optional API key (defaults to config) + user_agent: Optional custom User-Agent string + timeout: Request timeout in seconds + """ + self.config = Config() + self.api_key = api_key or self.config.api_key + self.base_url = self.config.base_url + self._timeout = timeout + + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + headers["User-Agent"] = user_agent if user_agent else _default_user_agent() + + self._client: Optional[httpx.AsyncClient] = None + self._headers = headers + + def _check_auth_required(self) -> None: + """Check if API key is configured.""" + if not self.api_key: + raise TunnelError("No API key configured. Set PRIME_API_KEY environment variable.") + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create async HTTP client.""" + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + timeout=self._timeout, + headers=self._headers, + follow_redirects=True, + ) + return self._client + + async def close(self) -> None: + """Close the HTTP client.""" + if self._client and not self._client.is_closed: + await self._client.aclose() + self._client = None + + @retry( + retry=retry_if_exception_type(RETRYABLE_EXCEPTIONS), + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=0.1, min=0.1, max=2), + reraise=True, + ) + async def _request_with_retry( + self, + method: str, + url: str, + json: Optional[Dict[str, Any]] = None, + ) -> httpx.Response: + """Make async HTTP request with retry on transient connection errors.""" + client = await self._get_client() + return await client.request(method, url, json=json) + + async def _handle_response(self, response: httpx.Response, operation: str) -> Dict[str, Any]: + """Handle response and raise appropriate errors.""" + if response.status_code == 401: + raise TunnelAuthError("API key unauthorized. Check PRIME_API_KEY.") + elif response.status_code == 402: + raise TunnelAuthError("Payment required. Check billing status.") + elif response.status_code == 404: + return {} # Handle 404 specially in callers + elif response.status_code >= 400: + try: + error_detail = response.json().get("detail", response.text) + except Exception: + error_detail = response.text + raise TunnelError(f"Failed to {operation}: {error_detail}") + + return response.json() + + async def create_tunnel( + self, + local_port: int, + name: Optional[str] = None, + ) -> TunnelInfo: + """ + Register a new tunnel with the backend. + + Args: + local_port: Local port the tunnel will forward to + name: Optional friendly name for the tunnel + + Returns: + TunnelInfo with connection details + + Raises: + TunnelAuthError: If authentication fails + TunnelError: If registration fails + """ + self._check_auth_required() + + url = f"{self.base_url}/api/v1/tunnel" + payload: Dict[str, Any] = {"local_port": local_port} + if name: + payload["name"] = name + + try: + response = await self._request_with_retry("POST", url, json=payload) + except httpx.TimeoutException as e: + raise TunnelTimeoutError(f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise TunnelError(f"Failed to connect to API: {e}") from e + + data = await self._handle_response(response, "create tunnel") + registration = TunnelRegistrationResponse(**data) + + return TunnelInfo( + tunnel_id=registration.tunnel_id, + subdomain=registration.subdomain, + url=registration.url, + frp_token=registration.frp_token, + server_addr=registration.server_addr, + server_port=registration.server_port, + expires_at=registration.expires_at, + ) + + async def get_tunnel(self, tunnel_id: str) -> Optional[TunnelInfo]: + """ + Get tunnel status by ID. + + Args: + tunnel_id: The tunnel identifier + + Returns: + TunnelInfo if found, None otherwise + """ + self._check_auth_required() + + url = f"{self.base_url}/api/v1/tunnel/{tunnel_id}" + + try: + response = await self._request_with_retry("GET", url) + except httpx.TimeoutException as e: + raise TunnelTimeoutError(f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise TunnelError(f"Failed to connect to API: {e}") from e + + if response.status_code == 404: + return None + + data = await self._handle_response(response, "get tunnel") + return TunnelInfo( + tunnel_id=data["tunnel_id"], + subdomain=data["subdomain"], + url=data["url"], + frp_token="", # Token not returned on status check + server_addr="", + server_port=7000, + expires_at=data["expires_at"], + ) + + async def delete_tunnel(self, tunnel_id: str) -> bool: + """ + Delete a tunnel. + + Args: + tunnel_id: The tunnel identifier + + Returns: + True if deleted successfully + """ + self._check_auth_required() + + url = f"{self.base_url}/api/v1/tunnel/{tunnel_id}" + + try: + response = await self._request_with_retry("DELETE", url) + except httpx.TimeoutException as e: + raise TunnelTimeoutError(f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise TunnelError(f"Failed to connect to API: {e}") from e + + if response.status_code == 404: + return False + + await self._handle_response(response, "delete tunnel") + return True + + async def list_tunnels(self) -> list[TunnelInfo]: + """ + List all tunnels for the current user. + + Returns: + List of TunnelInfo objects + """ + self._check_auth_required() + + url = f"{self.base_url}/api/v1/tunnel" + + try: + response = await self._request_with_retry("GET", url) + except httpx.TimeoutException as e: + raise TunnelTimeoutError(f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise TunnelError(f"Failed to connect to API: {e}") from e + + data = await self._handle_response(response, "list tunnels") + tunnels = [] + for t in data.get("tunnels", []): + tunnels.append( + TunnelInfo( + tunnel_id=t["tunnel_id"], + subdomain=t["subdomain"], + url=t["url"], + frp_token="", + server_addr="", + server_port=7000, + expires_at=t["expires_at"], + ) + ) + return tunnels + + async def __aenter__(self) -> "TunnelClient": + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + await self.close() diff --git a/packages/prime-tunnel/src/prime_tunnel/core/config.py b/packages/prime-tunnel/src/prime_tunnel/core/config.py new file mode 100644 index 00000000..83900af2 --- /dev/null +++ b/packages/prime-tunnel/src/prime_tunnel/core/config.py @@ -0,0 +1,77 @@ +import json +import os +from pathlib import Path +from typing import Optional + + +class Config: + """Minimal configuration class for Prime Tunnel SDK. + + Reads from ~/.prime/config.json and environment variables. + This is a simplified version that doesn't write configs. + """ + + DEFAULT_BASE_URL: str = "https://api.primeintellect.ai" + + def __init__(self) -> None: + self.config_dir = Path.home() / ".prime" + self.config_file = self.config_dir / "config.json" + self._load_config() + + def _load_config(self) -> None: + """Load configuration from file.""" + if self.config_file.exists(): + try: + config_data = json.loads(self.config_file.read_text()) + self.config = config_data + except (json.JSONDecodeError, IOError): + self.config = {} + else: + self.config = {} + + @staticmethod + def _strip_api_v1(url: str) -> str: + return url.rstrip("/").removesuffix("/api/v1") + + @property + def api_key(self) -> str: + """Get API key with precedence: env > file > empty.""" + return os.getenv("PRIME_API_KEY") or self.config.get("api_key", "") + + @property + def team_id(self) -> Optional[str]: + """Get team ID with precedence: env > file > None.""" + team_id = os.getenv("PRIME_TEAM_ID") + if team_id is not None: + return team_id + return self.config.get("team_id") or None + + @property + def user_id(self) -> Optional[str]: + """Get user ID with precedence: env > file > None.""" + user_id = os.getenv("PRIME_USER_ID") + if user_id is not None: + return user_id + return self.config.get("user_id") or None + + @property + def base_url(self) -> str: + """Get API base URL with precedence: env > file > default.""" + env_val = os.getenv("PRIME_API_BASE_URL") or os.getenv("PRIME_BASE_URL") + if env_val: + return self._strip_api_v1(env_val) + return self._strip_api_v1(self.config.get("base_url", self.DEFAULT_BASE_URL)) + + @property + def bin_dir(self) -> Path: + """Directory for binary files (frpc).""" + path = self.config_dir / "bin" + path.mkdir(parents=True, exist_ok=True) + return path + + @property + def cache_dir(self) -> Path: + """Directory for cache files.""" + path = self.config_dir / "cache" + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/packages/prime-tunnel/src/prime_tunnel/exceptions.py b/packages/prime-tunnel/src/prime_tunnel/exceptions.py new file mode 100644 index 00000000..e95d2992 --- /dev/null +++ b/packages/prime-tunnel/src/prime_tunnel/exceptions.py @@ -0,0 +1,28 @@ +class TunnelError(Exception): + """Base exception for tunnel errors.""" + + pass + + +class TunnelAuthError(TunnelError): + """Authentication failed when registering tunnel.""" + + pass + + +class TunnelConnectionError(TunnelError): + """Failed to establish tunnel connection.""" + + pass + + +class TunnelTimeoutError(TunnelError): + """Tunnel operation timed out.""" + + pass + + +class BinaryDownloadError(TunnelError): + """Failed to download frpc binary.""" + + pass diff --git a/packages/prime-tunnel/src/prime_tunnel/models.py b/packages/prime-tunnel/src/prime_tunnel/models.py new file mode 100644 index 00000000..42d7d697 --- /dev/null +++ b/packages/prime-tunnel/src/prime_tunnel/models.py @@ -0,0 +1,59 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +class TunnelStatus(str, Enum): + """Tunnel connection status.""" + + PENDING = "pending" + CONNECTED = "connected" + DISCONNECTED = "disconnected" + EXPIRED = "expired" + + +class TunnelConfig(BaseModel): + """Configuration for a tunnel.""" + + local_port: int = Field(8765, description="Local port to tunnel") + local_addr: str = Field("127.0.0.1", description="Local address to tunnel") + name: Optional[str] = Field(None, description="Friendly name for the tunnel") + + +class TunnelInfo(BaseModel): + """Information about a registered tunnel.""" + + tunnel_id: str = Field(..., description="Unique tunnel identifier") + subdomain: str = Field(..., description="Tunnel subdomain") + url: str = Field(..., description="Full HTTPS URL") + frp_token: str = Field(..., description="Authentication token for frpc") + server_addr: str = Field(..., description="frps server address") + server_port: int = Field(7000, description="frps server port") + expires_at: datetime = Field(..., description="Token expiration time") + + class Config: + from_attributes = True + + +class TunnelRegistrationRequest(BaseModel): + """Request to register a new tunnel.""" + + name: Optional[str] = None + local_port: int = 8765 + + +class TunnelRegistrationResponse(BaseModel): + """Response from tunnel registration.""" + + tunnel_id: str + subdomain: str + url: str + frp_token: str + server_addr: str + server_port: int + expires_at: datetime + + class Config: + from_attributes = True diff --git a/packages/prime-tunnel/src/prime_tunnel/py.typed b/packages/prime-tunnel/src/prime_tunnel/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py new file mode 100644 index 00000000..0e326aa6 --- /dev/null +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -0,0 +1,263 @@ +import asyncio +import fcntl +import os +import select +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Optional + +from prime_tunnel.binary import get_frpc_path +from prime_tunnel.core.client import TunnelClient +from prime_tunnel.exceptions import TunnelConnectionError, TunnelError, TunnelTimeoutError +from prime_tunnel.models import TunnelInfo + + +class Tunnel: + """Tunnel interface for exposing local services.""" + + def __init__( + self, + local_port: int, + local_addr: str = "127.0.0.1", + name: Optional[str] = None, + connection_timeout: float = 30.0, + ): + """ + Initialize a tunnel. + + Args: + local_port: Local port to tunnel + local_addr: Local address to tunnel (default: 127.0.0.1) + name: Optional friendly name for the tunnel + connection_timeout: Timeout for establishing connection (seconds) + """ + self.local_port = local_port + self.local_addr = local_addr + self.name = name + self.connection_timeout = connection_timeout + + self._client = TunnelClient() + self._process: Optional[subprocess.Popen] = None + self._tunnel_info: Optional[TunnelInfo] = None + self._config_file: Optional[Path] = None + self._started = False + + @property + def tunnel_id(self) -> Optional[str]: + """Get the tunnel ID.""" + return self._tunnel_info.tunnel_id if self._tunnel_info else None + + @property + def url(self) -> Optional[str]: + """Get the tunnel URL.""" + return self._tunnel_info.url if self._tunnel_info else None + + @property + def subdomain(self) -> Optional[str]: + """Get the tunnel subdomain.""" + return self._tunnel_info.subdomain if self._tunnel_info else None + + @property + def is_running(self) -> bool: + """Check if the tunnel is running.""" + if self._process is None: + return False + return self._process.poll() is None + + async def start(self) -> str: + """ + Start the tunnel. + + Returns: + The tunnel URL + + Raises: + TunnelError: If tunnel registration fails + TunnelConnectionError: If frpc fails to connect + TunnelTimeoutError: If connection times out + """ + if self._started: + raise TunnelError("Tunnel is already started") + + # 1. Get frpc binary + frpc_path = get_frpc_path() + + # 2. Register tunnel with backend + try: + self._tunnel_info = await self._client.create_tunnel( + local_port=self.local_port, + name=self.name, + ) + except Exception as e: + raise TunnelError(f"Failed to register tunnel: {e}") from e + + # 3. Generate frpc config + self._config_file = self._write_frpc_config() + + # 4. Start frpc process + try: + self._process = subprocess.Popen( + [str(frpc_path), "-c", str(self._config_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except Exception as e: + await self._cleanup() + raise TunnelConnectionError(f"Failed to start frpc: {e}") from e + + # 5. Wait for connection + try: + await self._wait_for_connection() + except Exception: + await self._cleanup() + raise + + self._started = True + + return self.url + + async def stop(self) -> None: + """Stop the tunnel and cleanup resources.""" + if not self._started: + return + + await self._cleanup() + self._started = False + + async def _cleanup(self) -> None: + """Clean up tunnel resources.""" + # Stop frpc process + if self._process is not None: + try: + self._process.terminate() + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait(timeout=2) + except Exception: + pass + finally: + self._process = None + + # Delete tunnel registration + if self._tunnel_info is not None: + try: + await self._client.delete_tunnel(self._tunnel_info.tunnel_id) + except Exception: + pass + finally: + self._tunnel_info = None + + # Clean up config file + if self._config_file is not None: + try: + if self._config_file.exists(): + self._config_file.unlink() + except Exception: + pass + finally: + self._config_file = None + + # Close HTTP client + try: + await self._client.close() + except Exception: + pass + + def _write_frpc_config(self) -> Path: + """Generate and write frpc configuration file.""" + if self._tunnel_info is None: + raise TunnelError("Tunnel not registered") + + # Parse server address + server_parts = self._tunnel_info.server_addr.split(":") + server_host = server_parts[0] + server_port = int(server_parts[1]) if len(server_parts) > 1 else 7000 + + # Generate config content + config = f"""# Prime Tunnel frpc configuration +# Tunnel ID: {self._tunnel_info.tunnel_id} + +serverAddr = "{server_host}" +serverPort = {server_port} + +# Authentication +user = "{self._tunnel_info.tunnel_id}" +auth.method = "token" +auth.token = "{self._tunnel_info.frp_token}" + +# Transport settings +transport.tcpMux = true +transport.tcpMuxKeepaliveInterval = 30 +transport.poolCount = 5 + +# Logging +log.to = "console" +log.level = "info" + +# HTTP proxy configuration +[[proxies]] +name = "http" +type = "http" +localIP = "{self.local_addr}" +localPort = {self.local_port} +subdomain = "{self._tunnel_info.tunnel_id}" +""" + + # Write to temp file + config_dir = Path(tempfile.gettempdir()) / "prime-tunnel" + config_dir.mkdir(parents=True, exist_ok=True) + config_file = config_dir / f"{self._tunnel_info.tunnel_id}.toml" + config_file.write_text(config) + + return config_file + + async def _wait_for_connection(self) -> None: + """Wait for frpc to establish connection.""" + start_time = time.time() + + while time.time() - start_time < self.connection_timeout: + if self._process is None: + raise TunnelConnectionError("frpc process not running") + + return_code = self._process.poll() + if return_code is not None: + stderr = "" + if self._process.stderr: + stderr = self._process.stderr.read() + raise TunnelConnectionError(f"frpc exited with code {return_code}: {stderr}") + + if self._process.stderr: + if hasattr(select, "poll"): # Check if on Unix + fd = self._process.stderr.fileno() + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + + try: + line = self._process.stderr.readline() + if line: + if "start proxy success" in line.lower(): + return + if "login failed" in line.lower(): + raise TunnelConnectionError(f"frpc login failed: {line.strip()}") + except (BlockingIOError, IOError): + pass + finally: + fcntl.fcntl(fd, fcntl.F_SETFL, fl) + + await asyncio.sleep(0.1) + + raise TunnelTimeoutError(f"Tunnel connection timed out after {self.connection_timeout}s") + + async def __aenter__(self) -> "Tunnel": + """Async context manager entry.""" + await self.start() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + """Async context manager exit.""" + await self.stop() diff --git a/packages/prime-tunnel/tests/test_tunnel.py b/packages/prime-tunnel/tests/test_tunnel.py new file mode 100644 index 00000000..9cb8fde5 --- /dev/null +++ b/packages/prime-tunnel/tests/test_tunnel.py @@ -0,0 +1,74 @@ +from prime_tunnel import Config, Tunnel, TunnelClient, TunnelConfig + + +def test_tunnel_init(): + """Test Tunnel initialization.""" + tunnel = Tunnel(local_port=8080) + assert tunnel.local_port == 8080 + assert tunnel.local_addr == "127.0.0.1" + assert tunnel.name is None + assert not tunnel.is_running + + +def test_tunnel_init_with_name(): + """Test Tunnel initialization with name.""" + tunnel = Tunnel(local_port=9000, name="my-tunnel") + assert tunnel.local_port == 9000 + assert tunnel.name == "my-tunnel" + + +def test_tunnel_config(): + """Test TunnelConfig model.""" + config = TunnelConfig(local_port=8888, local_addr="0.0.0.0", name="test") + assert config.local_port == 8888 + assert config.local_addr == "0.0.0.0" + assert config.name == "test" + + +def test_tunnel_config_defaults(): + """Test TunnelConfig default values.""" + config = TunnelConfig() + assert config.local_port == 8765 + assert config.local_addr == "127.0.0.1" + assert config.name is None + + +def test_config_default_base_url(): + """Test Config default base URL.""" + config = Config() + assert config.DEFAULT_BASE_URL == "https://api.primeintellect.ai" + + +def test_config_base_url_from_env(monkeypatch): + """Test Config base_url from environment variable.""" + monkeypatch.setenv("PRIME_BASE_URL", "https://custom.example.com") + config = Config() + assert config.base_url == "https://custom.example.com" + + +def test_config_base_url_strips_api_v1(monkeypatch): + """Test Config strips /api/v1 from base URL.""" + monkeypatch.setenv("PRIME_BASE_URL", "https://example.com/api/v1") + config = Config() + assert config.base_url == "https://example.com" + + +def test_config_api_key_from_env(monkeypatch): + """Test Config api_key from environment variable.""" + monkeypatch.setenv("PRIME_API_KEY", "test-key-123") + config = Config() + assert config.api_key == "test-key-123" + + +def test_config_bin_dir(): + """Test Config bin_dir property.""" + config = Config() + assert config.bin_dir.name == "bin" + assert ".prime" in str(config.bin_dir) + + +def test_tunnel_client_init(): + """Test TunnelClient initialization.""" + client = TunnelClient(api_key="test-key") + assert client.api_key == "test-key" + assert client.base_url == Config.DEFAULT_BASE_URL diff --git a/packages/prime/pyproject.toml b/packages/prime/pyproject.toml index f9003433..268fbe5a 100644 --- a/packages/prime/pyproject.toml +++ b/packages/prime/pyproject.toml @@ -12,6 +12,7 @@ authors = [ dependencies = [ "prime-sandboxes>=0.1.0", "prime-evals>=0.1.3", + "prime-tunnel>=0.1.0", "httpx>=0.25.0", "pydantic>=2.0.0", "typer>=0.9.0", @@ -55,6 +56,7 @@ dev = [ [tool.uv.sources] prime-sandboxes = { workspace = true } prime-evals = { workspace = true } +prime-tunnel = { workspace = true } [build-system] requires = ["hatchling"] diff --git a/packages/prime/src/prime_cli/commands/tunnel.py b/packages/prime/src/prime_cli/commands/tunnel.py new file mode 100644 index 00000000..cf19d43d --- /dev/null +++ b/packages/prime/src/prime_cli/commands/tunnel.py @@ -0,0 +1,151 @@ +import asyncio +import signal +from typing import Optional + +import typer +from prime_tunnel import Tunnel +from prime_tunnel.core.client import TunnelClient +from rich.console import Console +from rich.table import Table + +app = typer.Typer(help="Manage tunnels for exposing local services") +console = Console() + + +@app.command("start") +def start_tunnel( + port: int = typer.Option(8765, "--port", "-p", help="Local port to tunnel"), + name: Optional[str] = typer.Option(None, "--name", "-n", help="Friendly name for the tunnel"), +) -> None: + """Start a tunnel to expose a local port.""" + + async def run_tunnel(): + tunnel = Tunnel(local_port=port, name=name) + + shutdown_event = asyncio.Event() + + def signal_handler(): + console.print("\n[yellow]Shutting down tunnel...[/yellow]") + shutdown_event.set() + + loop = asyncio.get_event_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, signal_handler) + except NotImplementedError: + # Windows doesn't support add_signal_handler + pass + + try: + url = await tunnel.start() + console.print("\n[green]Tunnel started successfully![/green]") + console.print(f"[bold]URL:[/bold] {url}") + console.print(f"[bold]Tunnel ID:[/bold] {tunnel.tunnel_id}") + console.print(f"\n[dim]Forwarding to localhost:{port}[/dim]") + console.print("[dim]Press Ctrl+C to stop the tunnel[/dim]\n") + + await shutdown_event.wait() + + except Exception as e: + console.print(f"[red]Error:[/red] {e}", style="bold") + raise typer.Exit(1) + finally: + await tunnel.stop() + console.print("[green]Tunnel stopped[/green]") + + try: + asyncio.run(run_tunnel()) + except KeyboardInterrupt: + pass + + +@app.command("list") +def list_tunnels() -> None: + """List active tunnels.""" + + async def fetch_tunnels(): + client = TunnelClient() + try: + tunnels = await client.list_tunnels() + return tunnels + finally: + await client.close() + + try: + tunnels = asyncio.run(fetch_tunnels()) + except Exception as e: + console.print(f"[red]Error:[/red] {e}", style="bold") + raise typer.Exit(1) + + if not tunnels: + console.print("[dim]No active tunnels[/dim]") + return + + table = Table(title="Active Tunnels") + table.add_column("Tunnel ID", style="cyan") + table.add_column("URL", style="green") + table.add_column("Expires At") + + for tunnel in tunnels: + table.add_row( + tunnel.tunnel_id, + tunnel.url, + str(tunnel.expires_at), + ) + + console.print(table) + + +@app.command("status") +def tunnel_status( + tunnel_id: str = typer.Argument(..., help="Tunnel ID to check"), +) -> None: + """Get status of a specific tunnel.""" + + async def fetch_status(): + client = TunnelClient() + try: + return await client.get_tunnel(tunnel_id) + finally: + await client.close() + + try: + tunnel = asyncio.run(fetch_status()) + except Exception as e: + console.print(f"[red]Error:[/red] {e}", style="bold") + raise typer.Exit(1) + + if not tunnel: + console.print(f"[red]Tunnel not found:[/red] {tunnel_id}") + raise typer.Exit(1) + + console.print(f"[bold]Tunnel ID:[/bold] {tunnel.tunnel_id}") + console.print(f"[bold]URL:[/bold] {tunnel.url}") + console.print(f"[bold]Subdomain:[/bold] {tunnel.subdomain}") + console.print(f"[bold]Expires At:[/bold] {tunnel.expires_at}") + + +@app.command("stop") +def stop_tunnel( + tunnel_id: str = typer.Argument(..., help="Tunnel ID to stop"), +) -> None: + """Stop and delete a tunnel.""" + + async def delete_tunnel(): + client = TunnelClient() + try: + return await client.delete_tunnel(tunnel_id) + finally: + await client.close() + + try: + success = asyncio.run(delete_tunnel()) + except Exception as e: + console.print(f"[red]Error:[/red] {e}", style="bold") + raise typer.Exit(1) + + if success: + console.print(f"[green]Tunnel deleted:[/green] {tunnel_id}") + else: + console.print(f"[red]Tunnel not found:[/red] {tunnel_id}") + raise typer.Exit(1) diff --git a/packages/prime/src/prime_cli/main.py b/packages/prime/src/prime_cli/main.py index 7535cc24..363b224e 100644 --- a/packages/prime/src/prime_cli/main.py +++ b/packages/prime/src/prime_cli/main.py @@ -13,6 +13,7 @@ from .commands.pods import app as pods_app from .commands.sandbox import app as sandbox_app from .commands.teams import app as teams_app +from .commands.tunnel import app as tunnel_app from .commands.whoami import app as whoami_app from .core import Config @@ -35,6 +36,7 @@ app.add_typer(inference_app, name="inference") app.add_typer(whoami_app, name="whoami") app.add_typer(teams_app, name="teams") +app.add_typer(tunnel_app, name="tunnel") app.add_typer(evals_app, name="eval") diff --git a/uv.lock b/uv.lock index e59fc1a7..51b3ceb0 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ members = [ "prime-evals", "prime-mcp-server", "prime-sandboxes", + "prime-tunnel", ] [manifest.dependency-groups] @@ -1618,6 +1619,7 @@ dependencies = [ { name = "httpx" }, { name = "prime-evals" }, { name = "prime-sandboxes" }, + { name = "prime-tunnel" }, { name = "pydantic" }, { name = "rich" }, { name = "toml" }, @@ -1640,6 +1642,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.25.0" }, { name = "prime-evals", editable = "packages/prime-evals" }, { name = "prime-sandboxes", editable = "packages/prime-sandboxes" }, + { name = "prime-tunnel", editable = "packages/prime-tunnel" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "rich", specifier = ">=13.3.1" }, @@ -1709,11 +1712,13 @@ dependencies = [ { name = "aiofiles" }, { name = "httpx" }, { name = "pydantic" }, + { name = "tenacity" }, ] [package.optional-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -1724,8 +1729,39 @@ requires-dist = [ { name = "httpx", specifier = ">=0.25.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.13.1" }, + { name = "tenacity", specifier = ">=8.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "prime-tunnel" +source = { editable = "packages/prime-tunnel" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "tenacity" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-xdist" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.25.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.13.1" }, + { name = "tenacity", specifier = ">=8.0.0" }, ] provides-extras = ["dev"] @@ -2481,6 +2517,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + [[package]] name = "textual" version = "6.2.1" From eb02169261637a449988faa75101cb8cf471bcda Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 1 Jan 2026 12:11:44 -0600 Subject: [PATCH 02/22] bug bot --- packages/prime-tunnel/src/prime_tunnel/models.py | 4 ++-- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 12 +++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/models.py b/packages/prime-tunnel/src/prime_tunnel/models.py index 42d7d697..3689ab4d 100644 --- a/packages/prime-tunnel/src/prime_tunnel/models.py +++ b/packages/prime-tunnel/src/prime_tunnel/models.py @@ -29,7 +29,7 @@ class TunnelInfo(BaseModel): subdomain: str = Field(..., description="Tunnel subdomain") url: str = Field(..., description="Full HTTPS URL") frp_token: str = Field(..., description="Authentication token for frpc") - server_addr: str = Field(..., description="frps server address") + server_host: str = Field(..., description="frps server hostname") server_port: int = Field(7000, description="frps server port") expires_at: datetime = Field(..., description="Token expiration time") @@ -51,7 +51,7 @@ class TunnelRegistrationResponse(BaseModel): subdomain: str url: str frp_token: str - server_addr: str + server_host: str server_port: int expires_at: datetime diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 0e326aa6..1a3c351d 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -1,7 +1,5 @@ import asyncio -import fcntl import os -import select import subprocess import tempfile import time @@ -173,10 +171,8 @@ def _write_frpc_config(self) -> Path: if self._tunnel_info is None: raise TunnelError("Tunnel not registered") - # Parse server address - server_parts = self._tunnel_info.server_addr.split(":") - server_host = server_parts[0] - server_port = int(server_parts[1]) if len(server_parts) > 1 else 7000 + server_host = self._tunnel_info.server_host + server_port = self._tunnel_info.server_port # Generate config content config = f"""# Prime Tunnel frpc configuration @@ -232,7 +228,9 @@ async def _wait_for_connection(self) -> None: raise TunnelConnectionError(f"frpc exited with code {return_code}: {stderr}") if self._process.stderr: - if hasattr(select, "poll"): # Check if on Unix + if os.name == "posix": + import fcntl + fd = self._process.stderr.fileno() fl = fcntl.fcntl(fd, fcntl.F_GETFL) fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) From ac862a35261485f97e164fbbbc749fb686e21a4c Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 1 Jan 2026 12:24:37 -0600 Subject: [PATCH 03/22] more bugbot --- packages/prime-tunnel/src/prime_tunnel/core/client.py | 6 +++--- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/core/client.py b/packages/prime-tunnel/src/prime_tunnel/core/client.py index 95bbf691..afc8ce86 100644 --- a/packages/prime-tunnel/src/prime_tunnel/core/client.py +++ b/packages/prime-tunnel/src/prime_tunnel/core/client.py @@ -154,7 +154,7 @@ async def create_tunnel( subdomain=registration.subdomain, url=registration.url, frp_token=registration.frp_token, - server_addr=registration.server_addr, + server_host=registration.server_host, server_port=registration.server_port, expires_at=registration.expires_at, ) @@ -189,7 +189,7 @@ async def get_tunnel(self, tunnel_id: str) -> Optional[TunnelInfo]: subdomain=data["subdomain"], url=data["url"], frp_token="", # Token not returned on status check - server_addr="", + server_host="", server_port=7000, expires_at=data["expires_at"], ) @@ -248,7 +248,7 @@ async def list_tunnels(self) -> list[TunnelInfo]: subdomain=t["subdomain"], url=t["url"], frp_token="", - server_addr="", + server_host="", server_port=7000, expires_at=t["expires_at"], ) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 1a3c351d..c17381a2 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -92,7 +92,11 @@ async def start(self) -> str: raise TunnelError(f"Failed to register tunnel: {e}") from e # 3. Generate frpc config - self._config_file = self._write_frpc_config() + try: + self._config_file = self._write_frpc_config() + except Exception as e: + await self._cleanup() + raise TunnelError(f"Failed to write frpc config: {e}") from e # 4. Start frpc process try: From 8a4ad4c3be4638d60cac19e19f663b01ff9ab0ac Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 1 Jan 2026 12:42:31 -0600 Subject: [PATCH 04/22] update schema and frp_token perms --- .../prime-tunnel/src/prime_tunnel/core/client.py | 6 +++--- packages/prime-tunnel/src/prime_tunnel/models.py | 4 ++-- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 14 ++++++++++---- packages/prime/src/prime_cli/commands/tunnel.py | 2 +- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/core/client.py b/packages/prime-tunnel/src/prime_tunnel/core/client.py index afc8ce86..911dfac7 100644 --- a/packages/prime-tunnel/src/prime_tunnel/core/client.py +++ b/packages/prime-tunnel/src/prime_tunnel/core/client.py @@ -151,7 +151,7 @@ async def create_tunnel( return TunnelInfo( tunnel_id=registration.tunnel_id, - subdomain=registration.subdomain, + hostname=registration.hostname, url=registration.url, frp_token=registration.frp_token, server_host=registration.server_host, @@ -186,7 +186,7 @@ async def get_tunnel(self, tunnel_id: str) -> Optional[TunnelInfo]: data = await self._handle_response(response, "get tunnel") return TunnelInfo( tunnel_id=data["tunnel_id"], - subdomain=data["subdomain"], + hostname=data["hostname"], url=data["url"], frp_token="", # Token not returned on status check server_host="", @@ -245,7 +245,7 @@ async def list_tunnels(self) -> list[TunnelInfo]: tunnels.append( TunnelInfo( tunnel_id=t["tunnel_id"], - subdomain=t["subdomain"], + hostname=t["hostname"], url=t["url"], frp_token="", server_host="", diff --git a/packages/prime-tunnel/src/prime_tunnel/models.py b/packages/prime-tunnel/src/prime_tunnel/models.py index 3689ab4d..eca43fab 100644 --- a/packages/prime-tunnel/src/prime_tunnel/models.py +++ b/packages/prime-tunnel/src/prime_tunnel/models.py @@ -26,7 +26,7 @@ class TunnelInfo(BaseModel): """Information about a registered tunnel.""" tunnel_id: str = Field(..., description="Unique tunnel identifier") - subdomain: str = Field(..., description="Tunnel subdomain") + hostname: str = Field(..., description="Tunnel hostname") url: str = Field(..., description="Full HTTPS URL") frp_token: str = Field(..., description="Authentication token for frpc") server_host: str = Field(..., description="frps server hostname") @@ -48,7 +48,7 @@ class TunnelRegistrationResponse(BaseModel): """Response from tunnel registration.""" tunnel_id: str - subdomain: str + hostname: str url: str frp_token: str server_host: str diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index c17381a2..eb1cb695 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -53,9 +53,9 @@ def url(self) -> Optional[str]: return self._tunnel_info.url if self._tunnel_info else None @property - def subdomain(self) -> Optional[str]: - """Get the tunnel subdomain.""" - return self._tunnel_info.subdomain if self._tunnel_info else None + def hostname(self) -> Optional[str]: + """Get the tunnel hostname.""" + return self._tunnel_info.hostname if self._tunnel_info else None @property def is_running(self) -> bool: @@ -212,7 +212,13 @@ def _write_frpc_config(self) -> Path: config_dir = Path(tempfile.gettempdir()) / "prime-tunnel" config_dir.mkdir(parents=True, exist_ok=True) config_file = config_dir / f"{self._tunnel_info.tunnel_id}.toml" - config_file.write_text(config) + + # Create file with 0600 permissions + fd = os.open(str(config_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, config.encode()) + finally: + os.close(fd) return config_file diff --git a/packages/prime/src/prime_cli/commands/tunnel.py b/packages/prime/src/prime_cli/commands/tunnel.py index cf19d43d..183bc4d6 100644 --- a/packages/prime/src/prime_cli/commands/tunnel.py +++ b/packages/prime/src/prime_cli/commands/tunnel.py @@ -121,7 +121,7 @@ async def fetch_status(): console.print(f"[bold]Tunnel ID:[/bold] {tunnel.tunnel_id}") console.print(f"[bold]URL:[/bold] {tunnel.url}") - console.print(f"[bold]Subdomain:[/bold] {tunnel.subdomain}") + console.print(f"[bold]Hostname:[/bold] {tunnel.hostname}") console.print(f"[bold]Expires At:[/bold] {tunnel.expires_at}") From 81e64921f95d5a0a8fa6e3657948895ca1df97f1 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 1 Jan 2026 13:05:10 -0600 Subject: [PATCH 05/22] better err handling --- packages/prime-tunnel/src/prime_tunnel/core/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/prime-tunnel/src/prime_tunnel/core/client.py b/packages/prime-tunnel/src/prime_tunnel/core/client.py index 911dfac7..fdb6ec06 100644 --- a/packages/prime-tunnel/src/prime_tunnel/core/client.py +++ b/packages/prime-tunnel/src/prime_tunnel/core/client.py @@ -147,6 +147,8 @@ async def create_tunnel( raise TunnelError(f"Failed to connect to API: {e}") from e data = await self._handle_response(response, "create tunnel") + if not data: + raise TunnelError("Failed to create tunnel: unexpected empty response") registration = TunnelRegistrationResponse(**data) return TunnelInfo( From 86044f7bb6615d9c98cc88416fa97b2a69d70fda Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 1 Jan 2026 16:51:49 -0600 Subject: [PATCH 06/22] cleanup --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index eb1cb695..8d0f57ad 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -89,6 +89,7 @@ async def start(self) -> str: name=self.name, ) except Exception as e: + await self._cleanup() raise TunnelError(f"Failed to register tunnel: {e}") from e # 3. Generate frpc config From ad09465449a01c618895a4f71d799d63b0a72713 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Mon, 12 Jan 2026 15:34:25 -0800 Subject: [PATCH 07/22] v0.66.0 --- packages/prime-tunnel/src/prime_tunnel/binary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/binary.py b/packages/prime-tunnel/src/prime_tunnel/binary.py index b3aed779..66e804fe 100644 --- a/packages/prime-tunnel/src/prime_tunnel/binary.py +++ b/packages/prime-tunnel/src/prime_tunnel/binary.py @@ -11,7 +11,7 @@ from prime_tunnel.core.config import Config from prime_tunnel.exceptions import BinaryDownloadError -FRPC_VERSION = "0.65.0" +FRPC_VERSION = "0.66.0" FRPC_URLS = { ( "Darwin", From de33a9e77031a3000100556d28bd40d1ddea7215 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Tue, 13 Jan 2026 13:45:30 -0800 Subject: [PATCH 08/22] add binding secret --- .../src/prime_tunnel/core/client.py | 1 + .../prime-tunnel/src/prime_tunnel/models.py | 2 + .../prime-tunnel/src/prime_tunnel/tunnel.py | 58 ++++++++++++++----- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/core/client.py b/packages/prime-tunnel/src/prime_tunnel/core/client.py index fdb6ec06..91829e21 100644 --- a/packages/prime-tunnel/src/prime_tunnel/core/client.py +++ b/packages/prime-tunnel/src/prime_tunnel/core/client.py @@ -156,6 +156,7 @@ async def create_tunnel( hostname=registration.hostname, url=registration.url, frp_token=registration.frp_token, + binding_secret=registration.binding_secret, server_host=registration.server_host, server_port=registration.server_port, expires_at=registration.expires_at, diff --git a/packages/prime-tunnel/src/prime_tunnel/models.py b/packages/prime-tunnel/src/prime_tunnel/models.py index eca43fab..88f6324e 100644 --- a/packages/prime-tunnel/src/prime_tunnel/models.py +++ b/packages/prime-tunnel/src/prime_tunnel/models.py @@ -29,6 +29,7 @@ class TunnelInfo(BaseModel): hostname: str = Field(..., description="Tunnel hostname") url: str = Field(..., description="Full HTTPS URL") frp_token: str = Field(..., description="Authentication token for frpc") + binding_secret: str = Field("", description="Per-tunnel secret for frpc metadata") server_host: str = Field(..., description="frps server hostname") server_port: int = Field(7000, description="frps server port") expires_at: datetime = Field(..., description="Token expiration time") @@ -51,6 +52,7 @@ class TunnelRegistrationResponse(BaseModel): hostname: str url: str frp_token: str + binding_secret: str server_host: str server_port: int expires_at: datetime diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 8d0f57ad..ced07a7f 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -41,6 +41,7 @@ def __init__( self._tunnel_info: Optional[TunnelInfo] = None self._config_file: Optional[Path] = None self._started = False + self._output_lines: list[str] = [] @property def tunnel_id(self) -> Optional[str]: @@ -191,6 +192,9 @@ def _write_frpc_config(self) -> Path: auth.method = "token" auth.token = "{self._tunnel_info.frp_token}" +# Per-tunnel binding secret +metadatas.binding_secret = "{self._tunnel_info.binding_secret}" + # Transport settings transport.tcpMux = true transport.tcpMuxKeepaliveInterval = 30 @@ -226,6 +230,7 @@ def _write_frpc_config(self) -> Path: async def _wait_for_connection(self) -> None: """Wait for frpc to establish connection.""" start_time = time.time() + self._output_lines = [] while time.time() - start_time < self.connection_timeout: if self._process is None: @@ -233,26 +238,46 @@ async def _wait_for_connection(self) -> None: return_code = self._process.poll() if return_code is not None: - stderr = "" + remaining_output = [] + if self._process.stdout: + remaining_output.extend(self._process.stdout.readlines()) if self._process.stderr: - stderr = self._process.stderr.read() - raise TunnelConnectionError(f"frpc exited with code {return_code}: {stderr}") - - if self._process.stderr: + remaining_output.extend(self._process.stderr.readlines()) + self._output_lines.extend(line.strip() for line in remaining_output if line.strip()) + + # Build detailed error message + output_text = ( + "\n".join(self._output_lines) if self._output_lines else "(no output captured)" + ) + raise TunnelConnectionError( + f"frpc exited with code {return_code}\n" + f"--- frpc output ---\n{output_text}\n-------------------" + ) + + if self._process.stdout: if os.name == "posix": import fcntl - fd = self._process.stderr.fileno() + fd = self._process.stdout.fileno() fl = fcntl.fcntl(fd, fcntl.F_GETFL) fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) try: - line = self._process.stderr.readline() - if line: - if "start proxy success" in line.lower(): - return - if "login failed" in line.lower(): - raise TunnelConnectionError(f"frpc login failed: {line.strip()}") + while True: + line = self._process.stdout.readline() + if not line: + break + line = line.strip() + if line: + self._output_lines.append(line) + if "start proxy success" in line.lower(): + return + if "login failed" in line.lower(): + raise TunnelConnectionError(f"frpc login failed: {line}") + if "authorization failed" in line.lower(): + raise TunnelConnectionError( + f"frpc authorization failed: {line}" + ) except (BlockingIOError, IOError): pass finally: @@ -260,7 +285,14 @@ async def _wait_for_connection(self) -> None: await asyncio.sleep(0.1) - raise TunnelTimeoutError(f"Tunnel connection timed out after {self.connection_timeout}s") + # Timeout - include any captured output + output_text = ( + "\n".join(self._output_lines) if self._output_lines else "(no output captured)" + ) + raise TunnelTimeoutError( + f"Tunnel connection timed out after {self.connection_timeout}s\n" + f"--- frpc output ---\n{output_text}\n-------------------" + ) async def __aenter__(self) -> "Tunnel": """Async context manager entry.""" From f73bb1a782ff77dee663d6a10b9647fbcd5ab86c Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Tue, 20 Jan 2026 17:38:25 -0800 Subject: [PATCH 09/22] add log level and file --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index ced07a7f..13d88b43 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -21,6 +21,8 @@ def __init__( local_addr: str = "127.0.0.1", name: Optional[str] = None, connection_timeout: float = 30.0, + log_level: str = "info", + log_file: Optional[str] = None, ): """ Initialize a tunnel. @@ -30,11 +32,15 @@ def __init__( local_addr: Local address to tunnel (default: 127.0.0.1) name: Optional friendly name for the tunnel connection_timeout: Timeout for establishing connection (seconds) + log_level: frpc log level (trace, debug, info, warn, error) + log_file: Path to write frpc logs (default: console) """ self.local_port = local_port self.local_addr = local_addr self.name = name self.connection_timeout = connection_timeout + self.log_level = log_level + self.log_file = log_file self._client = TunnelClient() self._process: Optional[subprocess.Popen] = None @@ -201,8 +207,8 @@ def _write_frpc_config(self) -> Path: transport.poolCount = 5 # Logging -log.to = "console" -log.level = "info" +log.to = "{self.log_file or "console"}" +log.level = "{self.log_level}" # HTTP proxy configuration [[proxies]] From 1dc438adffab68f5c69098c6b60dec244ecabebf Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 21 Jan 2026 21:54:23 -0800 Subject: [PATCH 10/22] bug bot --- .../prime-tunnel/src/prime_tunnel/binary.py | 29 +++++++++++-- .../src/prime_tunnel/core/config.py | 2 +- .../prime-tunnel/src/prime_tunnel/tunnel.py | 42 ++++++++++++++++++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/binary.py b/packages/prime-tunnel/src/prime_tunnel/binary.py index 66e804fe..b2fb7d69 100644 --- a/packages/prime-tunnel/src/prime_tunnel/binary.py +++ b/packages/prime-tunnel/src/prime_tunnel/binary.py @@ -1,4 +1,5 @@ import hashlib +import os import platform import shutil import stat @@ -106,8 +107,22 @@ def _download_frpc(dest: Path) -> None: raise BinaryDownloadError("frpc binary not found after extraction") dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(extracted_path, dest) - dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + # Set executable permissions on extracted file before moving + extracted_path.chmod( + extracted_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) + + # Copy to temp file in same directory, then rename + # This prevents corruption if multiple processes download simultaneously + temp_dest = dest.parent / f".frpc.{os.getpid()}.tmp" + try: + shutil.copy2(extracted_path, temp_dest) + os.replace(temp_dest, dest) # Atomic on POSIX + finally: + # Clean up temp file if rename failed + if temp_dest.exists(): + temp_dest.unlink() def _compute_sha256(path: Path) -> str: @@ -130,6 +145,14 @@ def get_frpc_path() -> Path: return frpc_path _download_frpc(frpc_path) - version_file.write_text(FRPC_VERSION) + + # Write to temp file in same directory, then rename to prevent partial reads + temp_version = version_file.parent / f".frpc_version.{os.getpid()}.tmp" + try: + temp_version.write_text(FRPC_VERSION) + os.replace(temp_version, version_file) + finally: + if temp_version.exists(): + temp_version.unlink() return frpc_path diff --git a/packages/prime-tunnel/src/prime_tunnel/core/config.py b/packages/prime-tunnel/src/prime_tunnel/core/config.py index 83900af2..157f3bf0 100644 --- a/packages/prime-tunnel/src/prime_tunnel/core/config.py +++ b/packages/prime-tunnel/src/prime_tunnel/core/config.py @@ -23,7 +23,7 @@ def _load_config(self) -> None: if self.config_file.exists(): try: config_data = json.loads(self.config_file.read_text()) - self.config = config_data + self.config = config_data if isinstance(config_data, dict) else {} except (json.JSONDecodeError, IOError): self.config = {} else: diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 13d88b43..d8795dd2 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -2,6 +2,7 @@ import os import subprocess import tempfile +import threading import time from pathlib import Path from typing import Optional @@ -48,6 +49,7 @@ def __init__( self._config_file: Optional[Path] = None self._started = False self._output_lines: list[str] = [] + self._drain_thread: Optional[threading.Thread] = None @property def tunnel_id(self) -> Optional[str]: @@ -87,7 +89,7 @@ async def start(self) -> str: raise TunnelError("Tunnel is already started") # 1. Get frpc binary - frpc_path = get_frpc_path() + frpc_path = await asyncio.to_thread(get_frpc_path) # 2. Register tunnel with backend try: @@ -125,6 +127,9 @@ async def start(self) -> str: await self._cleanup() raise + # 6. Start background thread to drain pipes (prevents buffer exhaustion) + self._start_pipe_drain() + self._started = True return self.url @@ -139,7 +144,7 @@ async def stop(self) -> None: async def _cleanup(self) -> None: """Clean up tunnel resources.""" - # Stop frpc process + # Stop frpc process (this will cause drain threads to exit via EOF) if self._process is not None: try: self._process.terminate() @@ -152,6 +157,7 @@ async def _cleanup(self) -> None: pass finally: self._process = None + self._drain_thread = None # Thread exits when process pipes close # Delete tunnel registration if self._tunnel_info is not None: @@ -178,6 +184,38 @@ async def _cleanup(self) -> None: except Exception: pass + def _start_pipe_drain(self) -> None: + """Start background threads to drain subprocess pipes. + + This prevents the pipe buffer from filling up and blocking frpc + when it produces output (logs, reconnection attempts, etc.). + """ + if self._process is None: + return + + def drain_pipe(pipe): + """Read and discard output from a pipe until EOF.""" + if pipe is None: + return + try: + for _ in pipe: + pass # Discard all output + except (OSError, ValueError): + pass # Pipe closed + + # Use separate threads for stdout/stderr to avoid blocking on one + stdout_thread = threading.Thread( + target=drain_pipe, args=(self._process.stdout,), daemon=True + ) + stderr_thread = threading.Thread( + target=drain_pipe, args=(self._process.stderr,), daemon=True + ) + stdout_thread.start() + stderr_thread.start() + + # Store one thread for join during cleanup (both will exit when process dies) + self._drain_thread = stdout_thread + def _write_frpc_config(self) -> Path: """Generate and write frpc configuration file.""" if self._tunnel_info is None: From fb585f1e69c29eb98031b98eba859f67ec3c8324 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 21 Jan 2026 22:08:49 -0800 Subject: [PATCH 11/22] rm dead code --- packages/prime-tunnel/src/prime_tunnel/models.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/models.py b/packages/prime-tunnel/src/prime_tunnel/models.py index 88f6324e..e63b124e 100644 --- a/packages/prime-tunnel/src/prime_tunnel/models.py +++ b/packages/prime-tunnel/src/prime_tunnel/models.py @@ -38,13 +38,6 @@ class Config: from_attributes = True -class TunnelRegistrationRequest(BaseModel): - """Request to register a new tunnel.""" - - name: Optional[str] = None - local_port: int = 8765 - - class TunnelRegistrationResponse(BaseModel): """Response from tunnel registration.""" From db1faf8a7853da9d9f9111564d24c674064083e0 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 21 Jan 2026 22:20:18 -0800 Subject: [PATCH 12/22] more dead code --- packages/prime-tunnel/src/prime_tunnel/__init__.py | 3 +-- packages/prime-tunnel/src/prime_tunnel/models.py | 10 ---------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/__init__.py b/packages/prime-tunnel/src/prime_tunnel/__init__.py index 9644bf5a..9405bbfb 100644 --- a/packages/prime-tunnel/src/prime_tunnel/__init__.py +++ b/packages/prime-tunnel/src/prime_tunnel/__init__.py @@ -9,7 +9,7 @@ TunnelError, TunnelTimeoutError, ) -from prime_tunnel.models import TunnelConfig, TunnelInfo, TunnelStatus +from prime_tunnel.models import TunnelConfig, TunnelInfo from prime_tunnel.tunnel import Tunnel __all__ = [ @@ -22,7 +22,6 @@ # Models "TunnelConfig", "TunnelInfo", - "TunnelStatus", # Exceptions "TunnelError", "TunnelAuthError", diff --git a/packages/prime-tunnel/src/prime_tunnel/models.py b/packages/prime-tunnel/src/prime_tunnel/models.py index e63b124e..98ba887b 100644 --- a/packages/prime-tunnel/src/prime_tunnel/models.py +++ b/packages/prime-tunnel/src/prime_tunnel/models.py @@ -1,19 +1,9 @@ from datetime import datetime -from enum import Enum from typing import Optional from pydantic import BaseModel, Field -class TunnelStatus(str, Enum): - """Tunnel connection status.""" - - PENDING = "pending" - CONNECTED = "connected" - DISCONNECTED = "disconnected" - EXPIRED = "expired" - - class TunnelConfig(BaseModel): """Configuration for a tunnel.""" From f5da99c517f994ecb93a7fb34a64894d002a9cd0 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 22 Jan 2026 11:31:45 -0800 Subject: [PATCH 13/22] remove log file --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index d8795dd2..0c0d4339 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -23,7 +23,6 @@ def __init__( name: Optional[str] = None, connection_timeout: float = 30.0, log_level: str = "info", - log_file: Optional[str] = None, ): """ Initialize a tunnel. @@ -34,14 +33,12 @@ def __init__( name: Optional friendly name for the tunnel connection_timeout: Timeout for establishing connection (seconds) log_level: frpc log level (trace, debug, info, warn, error) - log_file: Path to write frpc logs (default: console) """ self.local_port = local_port self.local_addr = local_addr self.name = name self.connection_timeout = connection_timeout self.log_level = log_level - self.log_file = log_file self._client = TunnelClient() self._process: Optional[subprocess.Popen] = None @@ -244,8 +241,8 @@ def _write_frpc_config(self) -> Path: transport.tcpMuxKeepaliveInterval = 30 transport.poolCount = 5 -# Logging -log.to = "{self.log_file or "console"}" +# Logging - always use console so we can detect connection via stdout +log.to = "console" log.level = "{self.log_level}" # HTTP proxy configuration From 3ff4eac8b2a8fa60e721d5490dde83b38c583f98 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 22 Jan 2026 12:00:31 -0800 Subject: [PATCH 14/22] dead code --- packages/prime-tunnel/src/prime_tunnel/binary.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/binary.py b/packages/prime-tunnel/src/prime_tunnel/binary.py index b2fb7d69..f7bfaec1 100644 --- a/packages/prime-tunnel/src/prime_tunnel/binary.py +++ b/packages/prime-tunnel/src/prime_tunnel/binary.py @@ -72,12 +72,9 @@ def _download_frpc(dest: Path) -> None: try: with httpx.stream("GET", url, follow_redirects=True, timeout=120.0) as response: response.raise_for_status() - downloaded = 0 - with open(archive_path, "wb") as f: for chunk in response.iter_bytes(chunk_size=8192): f.write(chunk) - downloaded += len(chunk) except httpx.HTTPError as e: raise BinaryDownloadError(f"Failed to download frpc: {e}") from e From 3ba86e0d4cd54c5ee67469d1b012582eb8c120b7 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 22 Jan 2026 12:10:45 -0800 Subject: [PATCH 15/22] fix exception handling --- .../prime-tunnel/src/prime_tunnel/core/client.py | 3 +++ packages/prime-tunnel/src/prime_tunnel/tunnel.py | 14 ++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/core/client.py b/packages/prime-tunnel/src/prime_tunnel/core/client.py index 91829e21..cd4f9521 100644 --- a/packages/prime-tunnel/src/prime_tunnel/core/client.py +++ b/packages/prime-tunnel/src/prime_tunnel/core/client.py @@ -111,6 +111,9 @@ async def _handle_response(self, response: httpx.Response, operation: str) -> Di error_detail = response.text raise TunnelError(f"Failed to {operation}: {error_detail}") + if response.status_code == 204: + return {} + return response.json() async def create_tunnel( diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 0c0d4339..fce43490 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -94,15 +94,19 @@ async def start(self) -> str: local_port=self.local_port, name=self.name, ) - except Exception as e: + except BaseException as e: await self._cleanup() + if isinstance(e, asyncio.CancelledError): + raise raise TunnelError(f"Failed to register tunnel: {e}") from e # 3. Generate frpc config try: self._config_file = self._write_frpc_config() - except Exception as e: + except BaseException as e: await self._cleanup() + if isinstance(e, asyncio.CancelledError): + raise raise TunnelError(f"Failed to write frpc config: {e}") from e # 4. Start frpc process @@ -113,14 +117,16 @@ async def start(self) -> str: stderr=subprocess.PIPE, text=True, ) - except Exception as e: + except BaseException as e: await self._cleanup() + if isinstance(e, asyncio.CancelledError): + raise raise TunnelConnectionError(f"Failed to start frpc: {e}") from e # 5. Wait for connection try: await self._wait_for_connection() - except Exception: + except BaseException: await self._cleanup() raise From 8564585214517395b7cdd6ee38c342df3de3f671 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 29 Jan 2026 14:30:17 -0800 Subject: [PATCH 16/22] drain both pipes --- .../prime-tunnel/src/prime_tunnel/tunnel.py | 71 +++++++++++-------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index fce43490..438931f3 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -1,4 +1,5 @@ import asyncio +import fcntl import os import subprocess import tempfile @@ -301,34 +302,48 @@ async def _wait_for_connection(self) -> None: f"--- frpc output ---\n{output_text}\n-------------------" ) - if self._process.stdout: - if os.name == "posix": - import fcntl - - fd = self._process.stdout.fileno() - fl = fcntl.fcntl(fd, fcntl.F_GETFL) - fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) - - try: - while True: - line = self._process.stdout.readline() - if not line: - break - line = line.strip() - if line: - self._output_lines.append(line) - if "start proxy success" in line.lower(): - return - if "login failed" in line.lower(): - raise TunnelConnectionError(f"frpc login failed: {line}") - if "authorization failed" in line.lower(): - raise TunnelConnectionError( - f"frpc authorization failed: {line}" - ) - except (BlockingIOError, IOError): - pass - finally: - fcntl.fcntl(fd, fcntl.F_SETFL, fl) + if os.name == "posix": + # Set both pipes to non-blocking mode to drain them without deadlock + pipes_to_drain = [] + original_flags = {} + + for pipe in (self._process.stdout, self._process.stderr): + if pipe: + fd = pipe.fileno() + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + original_flags[fd] = fl + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + pipes_to_drain.append(pipe) + + try: + # Drain both stdout and stderr to prevent buffer exhaustion + for pipe in pipes_to_drain: + try: + while True: + line = pipe.readline() + if not line: + break + line = line.strip() + if line: + self._output_lines.append(line) + # Check for success/failure indicators + if "start proxy success" in line.lower(): + return + if "login failed" in line.lower(): + raise TunnelConnectionError(f"frpc login failed: {line}") + if "authorization failed" in line.lower(): + raise TunnelConnectionError( + f"frpc authorization failed: {line}" + ) + except (BlockingIOError, IOError): + pass # No more data available on this pipe + finally: + # Restore original flags + for fd, fl in original_flags.items(): + try: + fcntl.fcntl(fd, fcntl.F_SETFL, fl) + except (OSError, ValueError): + pass # Pipe may have closed await asyncio.sleep(0.1) From 0115edf4b741761a581742c104ca77cb03803348 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 29 Jan 2026 14:54:49 -0800 Subject: [PATCH 17/22] bug bot --- .../prime-tunnel/src/prime_tunnel/__init__.py | 3 +-- .../src/prime_tunnel/core/client.py | 14 ++--------- .../src/prime_tunnel/core/config.py | 7 ------ .../prime-tunnel/src/prime_tunnel/models.py | 25 ------------------- .../prime-tunnel/src/prime_tunnel/tunnel.py | 8 +++++- packages/prime-tunnel/tests/test_tunnel.py | 18 +------------ 6 files changed, 11 insertions(+), 64 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/__init__.py b/packages/prime-tunnel/src/prime_tunnel/__init__.py index 9405bbfb..5d045e66 100644 --- a/packages/prime-tunnel/src/prime_tunnel/__init__.py +++ b/packages/prime-tunnel/src/prime_tunnel/__init__.py @@ -9,7 +9,7 @@ TunnelError, TunnelTimeoutError, ) -from prime_tunnel.models import TunnelConfig, TunnelInfo +from prime_tunnel.models import TunnelInfo from prime_tunnel.tunnel import Tunnel __all__ = [ @@ -20,7 +20,6 @@ # Main interface "Tunnel", # Models - "TunnelConfig", "TunnelInfo", # Exceptions "TunnelError", diff --git a/packages/prime-tunnel/src/prime_tunnel/core/client.py b/packages/prime-tunnel/src/prime_tunnel/core/client.py index cd4f9521..33093113 100644 --- a/packages/prime-tunnel/src/prime_tunnel/core/client.py +++ b/packages/prime-tunnel/src/prime_tunnel/core/client.py @@ -11,7 +11,7 @@ from prime_tunnel.core.config import Config from prime_tunnel.exceptions import TunnelAuthError, TunnelError, TunnelTimeoutError -from prime_tunnel.models import TunnelInfo, TunnelRegistrationResponse +from prime_tunnel.models import TunnelInfo # Retry configuration for transient connection errors RETRYABLE_EXCEPTIONS = ( @@ -152,18 +152,8 @@ async def create_tunnel( data = await self._handle_response(response, "create tunnel") if not data: raise TunnelError("Failed to create tunnel: unexpected empty response") - registration = TunnelRegistrationResponse(**data) - return TunnelInfo( - tunnel_id=registration.tunnel_id, - hostname=registration.hostname, - url=registration.url, - frp_token=registration.frp_token, - binding_secret=registration.binding_secret, - server_host=registration.server_host, - server_port=registration.server_port, - expires_at=registration.expires_at, - ) + return TunnelInfo(**data) async def get_tunnel(self, tunnel_id: str) -> Optional[TunnelInfo]: """ diff --git a/packages/prime-tunnel/src/prime_tunnel/core/config.py b/packages/prime-tunnel/src/prime_tunnel/core/config.py index 157f3bf0..b2982822 100644 --- a/packages/prime-tunnel/src/prime_tunnel/core/config.py +++ b/packages/prime-tunnel/src/prime_tunnel/core/config.py @@ -68,10 +68,3 @@ def bin_dir(self) -> Path: path = self.config_dir / "bin" path.mkdir(parents=True, exist_ok=True) return path - - @property - def cache_dir(self) -> Path: - """Directory for cache files.""" - path = self.config_dir / "cache" - path.mkdir(parents=True, exist_ok=True) - return path diff --git a/packages/prime-tunnel/src/prime_tunnel/models.py b/packages/prime-tunnel/src/prime_tunnel/models.py index 98ba887b..7b1c8ee5 100644 --- a/packages/prime-tunnel/src/prime_tunnel/models.py +++ b/packages/prime-tunnel/src/prime_tunnel/models.py @@ -1,17 +1,8 @@ from datetime import datetime -from typing import Optional from pydantic import BaseModel, Field -class TunnelConfig(BaseModel): - """Configuration for a tunnel.""" - - local_port: int = Field(8765, description="Local port to tunnel") - local_addr: str = Field("127.0.0.1", description="Local address to tunnel") - name: Optional[str] = Field(None, description="Friendly name for the tunnel") - - class TunnelInfo(BaseModel): """Information about a registered tunnel.""" @@ -26,19 +17,3 @@ class TunnelInfo(BaseModel): class Config: from_attributes = True - - -class TunnelRegistrationResponse(BaseModel): - """Response from tunnel registration.""" - - tunnel_id: str - hostname: str - url: str - frp_token: str - binding_secret: str - server_host: str - server_port: int - expires_at: datetime - - class Config: - from_attributes = True diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 438931f3..9c36d253 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -132,7 +132,13 @@ async def start(self) -> str: raise # 6. Start background thread to drain pipes (prevents buffer exhaustion) - self._start_pipe_drain() + try: + self._start_pipe_drain() + except BaseException as e: + await self._cleanup() + if isinstance(e, asyncio.CancelledError): + raise + raise TunnelConnectionError(f"Failed to start pipe drain: {e}") from e self._started = True diff --git a/packages/prime-tunnel/tests/test_tunnel.py b/packages/prime-tunnel/tests/test_tunnel.py index 9cb8fde5..aff22966 100644 --- a/packages/prime-tunnel/tests/test_tunnel.py +++ b/packages/prime-tunnel/tests/test_tunnel.py @@ -1,4 +1,4 @@ -from prime_tunnel import Config, Tunnel, TunnelClient, TunnelConfig +from prime_tunnel import Config, Tunnel, TunnelClient def test_tunnel_init(): @@ -17,22 +17,6 @@ def test_tunnel_init_with_name(): assert tunnel.name == "my-tunnel" -def test_tunnel_config(): - """Test TunnelConfig model.""" - config = TunnelConfig(local_port=8888, local_addr="0.0.0.0", name="test") - assert config.local_port == 8888 - assert config.local_addr == "0.0.0.0" - assert config.name == "test" - - -def test_tunnel_config_defaults(): - """Test TunnelConfig default values.""" - config = TunnelConfig() - assert config.local_port == 8765 - assert config.local_addr == "127.0.0.1" - assert config.name is None - - def test_config_default_base_url(): """Test Config default base URL.""" config = Config() From b8dd787efd93448525848f593b21430113d37b21 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 29 Jan 2026 15:26:40 -0800 Subject: [PATCH 18/22] remove checksum check --- .../prime-tunnel/src/prime_tunnel/binary.py | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/binary.py b/packages/prime-tunnel/src/prime_tunnel/binary.py index f7bfaec1..9e2fbfb2 100644 --- a/packages/prime-tunnel/src/prime_tunnel/binary.py +++ b/packages/prime-tunnel/src/prime_tunnel/binary.py @@ -1,4 +1,3 @@ -import hashlib import os import platform import shutil @@ -35,13 +34,6 @@ "arm64", ): f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_arm64.tar.gz", } -FRPC_CHECKSUMS = { - ("Darwin", "arm64"): None, # TODO: Add checksums - ("Darwin", "x86_64"): None, - ("Linux", "x86_64"): None, - ("Linux", "aarch64"): None, - ("Linux", "arm64"): None, -} def _get_platform_key() -> tuple[str, str]: @@ -63,8 +55,6 @@ def _download_frpc(dest: Path) -> None: if not url: raise BinaryDownloadError(f"Unsupported platform: {platform_key[0]} {platform_key[1]}") - expected_checksum = FRPC_CHECKSUMS.get(platform_key) - with tempfile.TemporaryDirectory() as tmpdir: tmpdir_path = Path(tmpdir) archive_path = tmpdir_path / "frp.tar.gz" @@ -79,13 +69,6 @@ def _download_frpc(dest: Path) -> None: except httpx.HTTPError as e: raise BinaryDownloadError(f"Failed to download frpc: {e}") from e - if expected_checksum: - actual_checksum = _compute_sha256(archive_path) - if actual_checksum != expected_checksum: - raise BinaryDownloadError( - f"Checksum mismatch: expected {expected_checksum}, got {actual_checksum}" - ) - try: with tarfile.open(archive_path, "r:gz") as tar: for member in tar.getmembers(): @@ -122,14 +105,6 @@ def _download_frpc(dest: Path) -> None: temp_dest.unlink() -def _compute_sha256(path: Path) -> str: - sha256 = hashlib.sha256() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(8192), b""): - sha256.update(chunk) - return sha256.hexdigest() - - def get_frpc_path() -> Path: config = Config() frpc_path = config.bin_dir / "frpc" From 44e5dc185b299148da1134c3890dc682636369f6 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 29 Jan 2026 15:46:26 -0800 Subject: [PATCH 19/22] remove deadcode --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 9c36d253..86994609 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -47,7 +47,6 @@ def __init__( self._config_file: Optional[Path] = None self._started = False self._output_lines: list[str] = [] - self._drain_thread: Optional[threading.Thread] = None @property def tunnel_id(self) -> Optional[str]: @@ -167,7 +166,6 @@ async def _cleanup(self) -> None: pass finally: self._process = None - self._drain_thread = None # Thread exits when process pipes close # Delete tunnel registration if self._tunnel_info is not None: @@ -223,9 +221,6 @@ def drain_pipe(pipe): stdout_thread.start() stderr_thread.start() - # Store one thread for join during cleanup (both will exit when process dies) - self._drain_thread = stdout_thread - def _write_frpc_config(self) -> Path: """Generate and write frpc configuration file.""" if self._tunnel_info is None: From 112d8259a169d10c13fbf02e54b4ae6393b2d3df Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 29 Jan 2026 16:01:01 -0800 Subject: [PATCH 20/22] more deadcode --- packages/prime-tunnel/src/prime_tunnel/binary.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/binary.py b/packages/prime-tunnel/src/prime_tunnel/binary.py index 9e2fbfb2..bdee5247 100644 --- a/packages/prime-tunnel/src/prime_tunnel/binary.py +++ b/packages/prime-tunnel/src/prime_tunnel/binary.py @@ -29,10 +29,6 @@ "Linux", "aarch64", ): f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_arm64.tar.gz", - ( - "Linux", - "arm64", - ): f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_arm64.tar.gz", } From b9df43bedf9b2128e80f5f410e55e0d94b35841d Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Fri, 30 Jan 2026 11:37:39 -0800 Subject: [PATCH 21/22] checksums --- .../prime-tunnel/src/prime_tunnel/binary.py | 29 +++++++++++++++++++ .../prime/src/prime_cli/commands/tunnel.py | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/binary.py b/packages/prime-tunnel/src/prime_tunnel/binary.py index bdee5247..6910734b 100644 --- a/packages/prime-tunnel/src/prime_tunnel/binary.py +++ b/packages/prime-tunnel/src/prime_tunnel/binary.py @@ -1,3 +1,4 @@ +import hashlib import os import platform import shutil @@ -12,6 +13,14 @@ from prime_tunnel.exceptions import BinaryDownloadError FRPC_VERSION = "0.66.0" + +FRPC_CHECKSUMS = { + ("Darwin", "arm64"): "eb24c3c172a20056d83379496500b92600a992f68e8ae2e27d128ce1f36d7a92", + ("Darwin", "x86_64"): "9558d55a9d8bc40e22018379ea645251f803f9e2d69e7a7a2fd1588f98f8ef43", + ("Linux", "x86_64"): "317a17a7adac2e6bed2d7a83dc077da91ced0d110e1636373ece8ae5ac8b578b", + ("Linux", "aarch64"): "196ddaa51b716c2e99aeb2916b0a2bf55bb317494c4acdcefab36c383de950ba", +} + FRPC_URLS = { ( "Darwin", @@ -44,12 +53,30 @@ def _get_platform_key() -> tuple[str, str]: return (system, machine) +def _verify_checksum(file_path: Path, expected_checksum: str) -> None: + """Verify SHA256 checksum of downloaded file.""" + sha256 = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + actual_checksum = sha256.hexdigest() + if actual_checksum != expected_checksum: + raise BinaryDownloadError( + f"Checksum verification failed: expected {expected_checksum}, got {actual_checksum}" + ) + + def _download_frpc(dest: Path) -> None: platform_key = _get_platform_key() url = FRPC_URLS.get(platform_key) + expected_checksum = FRPC_CHECKSUMS.get(platform_key) if not url: raise BinaryDownloadError(f"Unsupported platform: {platform_key[0]} {platform_key[1]}") + if not expected_checksum: + raise BinaryDownloadError( + f"No checksum available for platform: {platform_key[0]} {platform_key[1]}" + ) with tempfile.TemporaryDirectory() as tmpdir: tmpdir_path = Path(tmpdir) @@ -65,6 +92,8 @@ def _download_frpc(dest: Path) -> None: except httpx.HTTPError as e: raise BinaryDownloadError(f"Failed to download frpc: {e}") from e + _verify_checksum(archive_path, expected_checksum) + try: with tarfile.open(archive_path, "r:gz") as tar: for member in tar.getmembers(): diff --git a/packages/prime/src/prime_cli/commands/tunnel.py b/packages/prime/src/prime_cli/commands/tunnel.py index 183bc4d6..d06280da 100644 --- a/packages/prime/src/prime_cli/commands/tunnel.py +++ b/packages/prime/src/prime_cli/commands/tunnel.py @@ -8,7 +8,7 @@ from rich.console import Console from rich.table import Table -app = typer.Typer(help="Manage tunnels for exposing local services") +app = typer.Typer(help="Manage tunnels for exposing local services", no_args_is_help=True) console = Console() From 66440bb0d85585632d10c7c288e35da0a006f485 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Fri, 30 Jan 2026 12:06:36 -0800 Subject: [PATCH 22/22] rich help panel --- packages/prime/src/prime_cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/prime/src/prime_cli/main.py b/packages/prime/src/prime_cli/main.py index b7607874..20c92431 100644 --- a/packages/prime/src/prime_cli/main.py +++ b/packages/prime/src/prime_cli/main.py @@ -52,7 +52,7 @@ app.add_typer(sandbox_app, name="sandbox", rich_help_panel="Compute") app.add_typer(images_app, name="images", rich_help_panel="Compute") app.add_typer(registry_app, name="registry", rich_help_panel="Compute") -app.add_typer(tunnel_app, name="tunnel") +app.add_typer(tunnel_app, name="tunnel", rich_help_panel="Compute") app.add_typer(inference_app, name="inference", rich_help_panel="Compute")