diff --git a/libs/arcade-cli/arcade_cli/authn.py b/libs/arcade-cli/arcade_cli/authn.py index 621d8c3f..3073770f 100644 --- a/libs/arcade-cli/arcade_cli/authn.py +++ b/libs/arcade-cli/arcade_cli/authn.py @@ -1,150 +1,663 @@ +""" +OAuth authentication module for Arcade CLI. + +Implements OAuth 2.0 Authorization Code flow with PKCE for secure CLI authentication. +Uses authlib for OAuth protocol handling. +""" + import os +import secrets import threading +import uuid +import webbrowser +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timedelta from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import Any +from pathlib import Path +from typing import Any, Callable from urllib.parse import parse_qs +import httpx import yaml -from arcade_core.constants import ( - ARCADE_CONFIG_PATH, - CREDENTIALS_FILE_PATH, +from arcade_core.auth_tokens import ( + CLIConfig, + TokenResponse, + fetch_cli_config, + get_valid_access_token, ) +from arcade_core.config_model import AuthConfig, Config, ContextConfig, UserConfig +from arcade_core.constants import ARCADE_CONFIG_PATH, CREDENTIALS_FILE_PATH +from authlib.integrations.httpx_client import OAuth2Client +from jinja2 import Environment, FileSystemLoader +from pydantic import AliasChoices, BaseModel, Field from rich.console import Console -from arcade_cli.constants import ( - LOGIN_FAILED_HTML, - LOGIN_SUCCESS_HTML, -) +# Set up Jinja2 templates +_TEMPLATES_DIR = Path(__file__).parent / "templates" +_jinja_env = Environment(loader=FileSystemLoader(_TEMPLATES_DIR), autoescape=True) + + +def _render_template(template_name: str, **context: Any) -> bytes: + """Render a Jinja2 template and return as bytes.""" + template = _jinja_env.get_template(template_name) + return template.render(**context).encode("utf-8") + console = Console() +# OAuth constants +DEFAULT_SCOPES = "openid offline_access" +LOCAL_CALLBACK_PORT = 9905 -class LoginCallbackHandler(BaseHTTPRequestHandler): - def __init__(self, *args, state: str, **kwargs): # type: ignore[no-untyped-def] - self.state = state # Simple CSRF protection + +def create_oauth_client(cli_config: CLIConfig) -> OAuth2Client: # type: ignore[no-any-unimported] + """ + Create an authlib OAuth2Client configured for the CLI. + + Args: + cli_config: OAuth configuration from Coordinator + + Returns: + Configured OAuth2Client with PKCE support + """ + return OAuth2Client( + client_id=cli_config.client_id, + token_endpoint=cli_config.token_endpoint, + code_challenge_method="S256", + ) + + +def generate_authorization_url( # type: ignore[no-any-unimported] + client: OAuth2Client, + cli_config: CLIConfig, + redirect_uri: str, + state: str, +) -> tuple[str, str]: + """ + Generate OAuth authorization URL with PKCE. + + Args: + client: OAuth2Client instance + cli_config: OAuth configuration from Coordinator + redirect_uri: Callback URL for the authorization response + state: Random state for CSRF protection + + Returns: + Tuple of (authorization_url, code_verifier) + """ + # Generate PKCE code verifier + code_verifier = secrets.token_urlsafe(64) + + url, _ = client.create_authorization_url( + cli_config.authorization_endpoint, + redirect_uri=redirect_uri, + scope=DEFAULT_SCOPES, + state=state, + code_verifier=code_verifier, + ) + return url, code_verifier + + +def exchange_code_for_tokens( # type: ignore[no-any-unimported] + client: OAuth2Client, + code: str, + redirect_uri: str, + code_verifier: str, +) -> TokenResponse: + """ + Exchange authorization code for tokens using authlib. + + Args: + client: OAuth2Client instance + code: Authorization code from callback + redirect_uri: Same redirect URI used in authorization request + code_verifier: PKCE code verifier from authorization request + + Returns: + TokenResponse with access and refresh tokens + """ + token = client.fetch_token( + client.session.metadata["token_endpoint"], + grant_type="authorization_code", + code=code, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + ) + + return TokenResponse( + access_token=token["access_token"], + refresh_token=token["refresh_token"], + expires_in=token["expires_in"], + token_type=token["token_type"], + ) + + +class OrgInfo(BaseModel): + """Organization info from Coordinator.""" + + org_id: str = Field(validation_alias=AliasChoices("org_id", "organization_id")) + name: str + is_default: bool = False + + +class ProjectInfo(BaseModel): + """Project info from Coordinator.""" + + project_id: str + name: str + is_default: bool = False + + +def select_default_org(orgs: list[OrgInfo]) -> OrgInfo | None: + """ + Select the default organization. + + Args: + orgs: List of organizations + + Returns: + Default org, or first org, or None if empty + """ + if not orgs: + return None + for org in orgs: + if org.is_default: + return org + return orgs[0] + + +def select_default_project(projects: list[ProjectInfo]) -> ProjectInfo | None: + """ + Select the default project. + + Args: + projects: List of projects + + Returns: + Default project, or first project, or None if empty + """ + if not projects: + return None + for project in projects: + if project.is_default: + return project + return projects[0] + + +class WhoAmIResponse(BaseModel): + """Response from Coordinator /whoami endpoint.""" + + account_id: str + email: str + organizations: list[OrgInfo] = [] + projects: list[ProjectInfo] = [] + + def get_selected_org(self) -> OrgInfo | None: + """Get the org to use: default if available, otherwise first in list.""" + return select_default_org(self.organizations) + + def get_selected_project(self) -> ProjectInfo | None: + """Get the project to use: default if available, otherwise first in list.""" + return select_default_project(self.projects) + + +def fetch_whoami(coordinator_url: str, access_token: str) -> WhoAmIResponse: + """ + Fetch user info and all orgs/projects from the Coordinator. + + This is the preferred way to get user info after OAuth login, as it: + - Only accepts short-lived access tokens (not API keys) + - Returns user email and account ID + - Returns all orgs and projects the user has access to + + Args: + coordinator_url: Base URL of the Coordinator + access_token: Valid OAuth access token + + Returns: + WhoAmIResponse with account info and all orgs/projects + """ + url = f"{coordinator_url}/api/v1/auth/whoami" + response = httpx.get( + url, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + response.raise_for_status() + data = response.json().get("data", {}) + + return WhoAmIResponse.model_validate(data) + + +def fetch_organizations(coordinator_url: str) -> list[OrgInfo]: + """ + Fetch organizations the user belongs to. + + Args: + coordinator_url: Base URL of the Coordinator + access_token: Valid access token + + Returns: + List of organizations + """ + url = f"{coordinator_url}/api/v1/orgs" + access_token = get_valid_access_token(coordinator_url) + response = httpx.get( + url, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + return [OrgInfo.model_validate(item) for item in data.get("data", {}).get("items", [])] + + +def fetch_projects(coordinator_url: str, org_id: str) -> list[ProjectInfo]: + """ + Fetch projects in an organization. + + Args: + coordinator_url: Base URL of the Coordinator + access_token: Valid access token + org_id: Organization ID + + Returns: + List of projects + """ + url = f"{coordinator_url}/api/v1/orgs/{org_id}/projects" + access_token = get_valid_access_token(coordinator_url) + response = httpx.get( + url, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + return [ProjectInfo.model_validate(item) for item in data.get("data", {}).get("items", [])] + + +class OAuthCallbackHandler(BaseHTTPRequestHandler): + """HTTP request handler for OAuth callback.""" + + def __init__(self, *args, state: str, result_holder: dict, **kwargs): # type: ignore[no-untyped-def] + self.state = state + self.result_holder = result_holder + # Store error details for template rendering + self._error: str | None = None + self._error_description: str | None = None + self._returned_state: str | None = None super().__init__(*args, **kwargs) - def log_message(self, format: str, *args: Any) -> None: # noqa: A002 Argument `format` is shadowing a Python builtin - # Override to suppress logging to stdout + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + # Suppress logging to stdout pass - def _parse_login_response(self) -> tuple[str, str, str] | None: - # Parse the query string from the URL - query_string = self.path.split("?", 1)[-1] + def do_GET(self) -> None: + """Handle GET request (OAuth callback).""" + query_string = self.path.split("?", 1)[-1] if "?" in self.path else "" params = parse_qs(query_string) - returned_state = params.get("state", [None])[0] - if returned_state != self.state: - console.print( - "❌ Login failed: Invalid login attempt. Please try again.", style="bold red" + self._returned_state = params.get("state", [None])[0] + code = params.get("code", [None])[0] + self._error = params.get("error", [None])[0] + self._error_description = params.get("error_description", [None])[0] + + if self._returned_state != self.state: + self.result_holder["error"] = "Invalid state parameter. Possible CSRF attack." + self._send_error_response( + message="Invalid state parameter. This may be a security issue." ) - return None + return - api_key = params.get("api_key", [None])[0] or "" - email = params.get("email", [None])[0] or "" - warning = params.get("warning", [None])[0] or "" + if self._error: + self.result_holder["error"] = self._error_description or self._error + self._send_error_response() + return - return api_key, email, warning + if not code: + self.result_holder["error"] = "No authorization code received." + self._send_error_response(message="No authorization code was received from the server.") + return - def _handle_login_response(self) -> bool: - result = self._parse_login_response() - if result is None: - return False - api_key, email, warning = result + self.result_holder["code"] = code + self._send_success_response() - if warning: - console.print(warning, style="bold yellow") + def _send_success_response(self) -> None: + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(_render_template("cli_login_success.jinja")) + threading.Thread(target=self.server.shutdown).start() - # If API key and email are received, store them in a file - if not api_key or not email: - console.print( - "❌ Login failed: No credentials received. Please try again.", style="bold red" + def _send_error_response(self, message: str | None = None) -> None: + self.send_response(400) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write( + _render_template( + "cli_login_failed.jinja", + message=message, + error=self._error, + error_description=self._error_description, + state=self._returned_state, ) - return False - - # ensure the ARCADE_CONFIG_PATH directory exists - if not os.path.exists(ARCADE_CONFIG_PATH): - os.makedirs(ARCADE_CONFIG_PATH, exist_ok=True) - - # TODO don't overwrite existing config - new_config = {"cloud": {"api": {"key": api_key}, "user": {"email": email}}} - with open(CREDENTIALS_FILE_PATH, "w") as f: - yaml.dump(new_config, f) - - # Send a success response to the browser - console.print( - f"""✅ Hi there, {email}! - -Your Arcade API key is: {api_key} -Stored in: {CREDENTIALS_FILE_PATH}""", - style="bold green", ) - return True - - def do_GET(self) -> None: # This naming is correct, required by BaseHTTPRequestHandler - success = self._handle_login_response() - if success: - self.send_response(200) - self.end_headers() - self.wfile.write(LOGIN_SUCCESS_HTML) - else: - self.send_response(400) - self.end_headers() - self.wfile.write(LOGIN_FAILED_HTML) - - # Always shut down the server so it doesn't keep running threading.Thread(target=self.server.shutdown).start() -class LocalAuthCallbackServer: - def __init__(self, state: str, port: int = 9905): +class OAuthCallbackServer: + """Local HTTP server for OAuth callback.""" + + def __init__(self, state: str, port: int = LOCAL_CALLBACK_PORT): self.state = state self.port = port self.httpd: HTTPServer | None = None + self.result: dict[str, Any] = {} def run_server(self) -> None: - # Initialize and run the server + """Start the callback server.""" server_address = ("", self.port) - handler = lambda *args, **kwargs: LoginCallbackHandler(*args, state=self.state, **kwargs) + handler = lambda *args, **kwargs: OAuthCallbackHandler( + *args, state=self.state, result_holder=self.result, **kwargs + ) self.httpd = HTTPServer(server_address, handler) self.httpd.serve_forever() def shutdown_server(self) -> None: - # Shut down the server gracefully + """Shut down the callback server.""" if self.httpd: self.httpd.shutdown() + def get_redirect_uri(self) -> str: + """Get the redirect URI for this server.""" + return f"http://localhost:{self.port}/callback" + + +def save_credentials_from_whoami( + tokens: TokenResponse, + whoami: WhoAmIResponse, + coordinator_url: str, +) -> None: + """ + Save OAuth credentials to the config file using WhoAmI response. + + Picks the org/project marked as default, or falls back to the first one + in the list if none are marked as default. + + Args: + tokens: OAuth tokens + whoami: Response from /whoami endpoint with user and orgs/projects + """ + # Ensure config directory exists + os.makedirs(ARCADE_CONFIG_PATH, exist_ok=True) + + expires_at = datetime.now() + timedelta(seconds=tokens.expires_in) + + context = None + selected_org = whoami.get_selected_org() + selected_project = whoami.get_selected_project() + + if selected_org and selected_project: + context = ContextConfig( + org_id=selected_org.org_id, + org_name=selected_org.name, + project_id=selected_project.project_id, + project_name=selected_project.name, + ) + + config = Config( + coordinator_url=coordinator_url, + auth=AuthConfig( + access_token=tokens.access_token, + refresh_token=tokens.refresh_token, + expires_at=expires_at, + ), + user=UserConfig(email=whoami.email), + context=context, + ) + + config.save_to_file() + + +def get_active_context() -> tuple[str, str]: + """ + Get the active org and project IDs. + + Returns: + Tuple of (org_id, project_id) + + Raises: + ValueError: If not logged in or no context set + """ + try: + config = Config.load_from_file() + except FileNotFoundError: + raise ValueError("Not logged in. Please run 'arcade login' first.") + + if not config.context: + raise ValueError("No active organization/project. Please run 'arcade login' first.") + + return config.context.org_id, config.context.project_id + + +# ============================================================================= +# High-level OAuth login flow +# ============================================================================= + + +class OAuthLoginError(Exception): + """Error during OAuth login flow.""" + + pass + + +@dataclass +class OAuthLoginResult: + """Result of a successful OAuth login flow.""" + + tokens: TokenResponse + whoami: WhoAmIResponse + + @property + def email(self) -> str: + return self.whoami.email + + @property + def selected_org(self) -> OrgInfo | None: + return self.whoami.get_selected_org() + + @property + def selected_project(self) -> ProjectInfo | None: + return self.whoami.get_selected_project() + + +def build_coordinator_url(host: str, port: int | None) -> str: + """ + Build the Coordinator URL from host and optional port. + + Args: + host: The Arcade Coordinator host + port: Optional port (used for local development) + + Returns: + Full coordinator URL (e.g., https://api.arcade.dev) + """ + if port: + scheme = "http" if host == "localhost" else "https" + return f"{scheme}://{host}:{port}" + else: + scheme = "http" if host == "localhost" else "https" + default_port = ":8000" if host == "localhost" else "" + return f"{scheme}://{host}{default_port}" + + +@contextmanager +def oauth_callback_server(state: str) -> Generator[OAuthCallbackServer, None, None]: + """ + Context manager for the OAuth callback server. + + Ensures the server is properly shut down even if an error occurs. + Waits for the callback to be received before exiting. + + Usage: + with oauth_callback_server(state) as server: + # server is running and waiting for callback + ... + # After the with block, the server has received the callback + """ + server = OAuthCallbackServer(state) + server_thread = threading.Thread(target=server.run_server) + server_thread.start() + try: + yield server + # Wait for the callback to be received (server shuts itself down after handling) + server_thread.join() + finally: + # Clean up if interrupted or if something went wrong + if server_thread.is_alive(): + server.shutdown_server() + server_thread.join(timeout=2) + + +def perform_oauth_login( + coordinator_url: str, + on_status: Callable[[str], None] | None = None, +) -> OAuthLoginResult: + """ + Perform the complete OAuth login flow. + + This function: + 1. Fetches OAuth config from the Coordinator + 2. Starts a local callback server + 3. Opens browser for user authentication + 4. Exchanges authorization code for tokens + 5. Fetches user info and validates org/project + + Args: + coordinator_url: Base URL of the Coordinator + on_status: Optional callback for status messages (e.g., console.print) + + Returns: + OAuthLoginResult with tokens and user info + + Raises: + OAuthLoginError: If any step of the login flow fails + """ + + def status(msg: str) -> None: + if on_status: + on_status(msg) + + # Step 1: Fetch OAuth config + try: + cli_config = fetch_cli_config(coordinator_url) + except Exception as e: + raise OAuthLoginError(f"Could not connect to Arcade at {coordinator_url}") from e + + # Step 2: Create OAuth client and prepare PKCE + oauth_client = create_oauth_client(cli_config) + state = str(uuid.uuid4()) + + # Step 3: Start local callback server and run browser auth + with oauth_callback_server(state) as server: + redirect_uri = server.get_redirect_uri() + + # Step 4: Generate authorization URL and open browser + auth_url, code_verifier = generate_authorization_url( + oauth_client, cli_config, redirect_uri, state + ) + + status("Opening a browser to log you in...") + if not webbrowser.open(auth_url): + status(f"Copy this URL into your browser:\n{auth_url}") + + # Step 5: Wait for callback (server thread handles this via serve_forever) + # The thread will exit when the callback handler calls server.shutdown() + + # Check for errors from callback + if "error" in server.result: + raise OAuthLoginError(f"Login failed: {server.result['error']}") + + if "code" not in server.result: + raise OAuthLoginError("No authorization code received") + + # Step 6: Exchange code for tokens + code = server.result["code"] + tokens = exchange_code_for_tokens(oauth_client, code, redirect_uri, code_verifier) + + # Step 7: Fetch user info + whoami = fetch_whoami(coordinator_url, tokens.access_token) + + # Validate org/project exist + if not whoami.get_selected_org(): + raise OAuthLoginError( + "No organizations found for your account. " + "Please contact support@arcade.dev for assistance." + ) + + if not whoami.get_selected_project(): + org_name = whoami.get_selected_org().name # type: ignore[union-attr] + raise OAuthLoginError( + f"No projects found in organization '{org_name}'. " + "Please contact support@arcade.dev for assistance." + ) + + return OAuthLoginResult(tokens=tokens, whoami=whoami) + + +def _credentials_file_contains_legacy() -> bool: + """ + Detect legacy (API key) credentials in the credentials file. + """ + try: + with open(CREDENTIALS_FILE_PATH) as f: + data = yaml.safe_load(f) or {} + cloud = data.get("cloud", {}) + return isinstance(cloud, dict) and "api" in cloud + except Exception: + return False + def check_existing_login(suppress_message: bool = False) -> bool: """ - Check if the user is already logged in by verifying the config file. + Check if the user is already logged in. Args: - suppress_message (bool): If True, suppress the logged in message. + suppress_message: If True, suppress the logged in message. Returns: - bool: True if the user is already logged in, False otherwise. + True if the user is already logged in, False otherwise. """ if not os.path.exists(CREDENTIALS_FILE_PATH): return False - if os.path.exists(CREDENTIALS_FILE_PATH): - try: - with open(CREDENTIALS_FILE_PATH) as f: - config: dict[str, Any] = yaml.safe_load(f) - cloud_config = config.get("cloud", {}) - api_key = cloud_config.get("api", {}).get("key") - email = cloud_config.get("user", {}).get("email") + try: + with open(CREDENTIALS_FILE_PATH) as f: + config_data: dict[str, Any] = yaml.safe_load(f) - if api_key and email: - if not suppress_message: - console.print(f"You're already logged in as {email}. ", style="bold green") - return True - except yaml.YAMLError: - console.print( - f"Error: Invalid configuration file at {CREDENTIALS_FILE_PATH}", style="bold red" - ) - except Exception as e: - console.print(f"Error: Unable to read configuration file: {e!s}", style="bold red") + cloud_config = config_data.get("cloud", {}) if isinstance(config_data, dict) else {} - return True + auth = cloud_config.get("auth", {}) + if auth.get("access_token"): + email = cloud_config.get("user", {}).get("email", "unknown") + context = cloud_config.get("context", {}) + org_name = context.get("org_name", "unknown") + project_name = context.get("project_name", "unknown") + + if not suppress_message: + console.print(f"You're already logged in as {email}.", style="bold green") + console.print(f"Active: {org_name} / {project_name}", style="dim") + return True + + except yaml.YAMLError: + console.print( + f"Error: Invalid configuration file at {CREDENTIALS_FILE_PATH}", style="bold red" + ) + except Exception as e: + console.print(f"Error: Unable to read configuration file: {e!s}", style="bold red") + + return False diff --git a/libs/arcade-cli/arcade_cli/constants.py b/libs/arcade-cli/arcade_cli/constants.py index e5dd097f..bc8be0a4 100644 --- a/libs/arcade-cli/arcade_cli/constants.py +++ b/libs/arcade-cli/arcade_cli/constants.py @@ -1,148 +1,7 @@ -PROD_CLOUD_HOST = "cloud.arcade.dev" -PROD_ENGINE_HOST = "api.arcade.dev" -LOCALHOST = "localhost" -LOCAL_AUTH_CALLBACK_PORT = 9905 +from arcade_core.constants import LOCALHOST, PROD_COORDINATOR_HOST, PROD_ENGINE_HOST -_style_block = b""" - - - - - - - -""" - -LOGIN_SUCCESS_HTML = ( - b""" - - - - - - Success! - """ - + _style_block - + b""" - - -
- -

Log in to Arcade CLI

-

Success! You can close this window.

-
- - -""" -) - -LOGIN_FAILED_HTML = ( - b""" - - - - - - Login failed - """ - + _style_block - + b""" - - -
- -

Log in to Arcade CLI

-

Something went wrong. Please close this window and try again.

-
- - -""" -) +__all__ = [ + "LOCALHOST", + "PROD_COORDINATOR_HOST", + "PROD_ENGINE_HOST", +] diff --git a/libs/arcade-cli/arcade_cli/deploy.py b/libs/arcade-cli/arcade_cli/deploy.py index 927701a3..82a64ff0 100644 --- a/libs/arcade-cli/arcade_cli/deploy.py +++ b/libs/arcade-cli/arcade_cli/deploy.py @@ -23,7 +23,12 @@ from rich.text import Text from typing_extensions import Literal from arcade_cli.secret import load_env_file -from arcade_cli.utils import compute_base_url, validate_and_get_config +from arcade_cli.utils import ( + compute_base_url, + get_auth_headers, + get_org_scoped_url, + validate_and_get_config, +) console = Console() @@ -89,7 +94,7 @@ class UpdateDeploymentRequest(BaseModel): # Deployment Status Functions -def get_deployment_status(engine_url: str, api_key: str, server_name: str) -> str: +def _get_deployment_status(engine_url: str, server_name: str) -> str: """ Get the status of a deployment. @@ -101,12 +106,9 @@ def get_deployment_status(engine_url: str, api_key: str, server_name: str) -> st The status of the deployment. Possible values are: "pending", "updating", "unknown", "running", "failed". """ - client = httpx.Client( - base_url=engine_url, - headers={"Authorization": f"Bearer {api_key}"}, - timeout=360, - ) - response = client.get(f"/v1/deployments/{server_name}/status") + url = get_org_scoped_url(engine_url, f"/deployments/{server_name}/status") + client = httpx.Client(headers=get_auth_headers(), timeout=360) + response = client.get(url) response.raise_for_status() status = cast(str, response.json().get("status", "unknown")) return status @@ -114,7 +116,6 @@ def get_deployment_status(engine_url: str, api_key: str, server_name: str) -> st async def _poll_deployment_status( engine_url: str, - api_key: str, server_name: str, state: dict, debug: bool = False, @@ -122,7 +123,7 @@ async def _poll_deployment_status( """Poll deployment status until it's running or error.""" while state["status"] in ["pending", "unknown", "updating"]: try: - status = get_deployment_status(engine_url, api_key, server_name) + status = _get_deployment_status(engine_url, server_name) state["status"] = status if status in ["running", "failed"]: break @@ -134,21 +135,20 @@ async def _poll_deployment_status( async def _stream_deployment_logs_to_deque( engine_url: str, - api_key: str, server_name: str, log_deque: deque, state: dict, debug: bool = False, ) -> None: """Stream deployment logs into a deque with retry logic.""" - stream_url = f"{engine_url}/v1/deployments/{server_name}/logs/stream" - headers = {"Authorization": f"Bearer {api_key}"} + stream_url = get_org_scoped_url(engine_url, f"/deployments/{server_name}/logs/stream") while state["status"] in ["pending", "unknown", "updating"]: try: + auth_headers = get_auth_headers() async with ( httpx.AsyncClient(timeout=None) as client, # noqa: S113 - expected indefinite log stream - client.stream("GET", stream_url, headers=headers) as response, + client.stream("GET", stream_url, headers=auth_headers) as response, ): response.raise_for_status() async for line in response.aiter_lines(): @@ -169,7 +169,6 @@ async def _stream_deployment_logs_to_deque( async def _monitor_deployment_with_logs( engine_url: str, - api_key: str, server_name: str, debug: bool = False, is_update: bool = False, @@ -179,7 +178,6 @@ async def _monitor_deployment_with_logs( Args: engine_url: The base URL of the Arcade Engine - api_key: The API key for authentication server_name: The name of the server to monitor debug: Whether to show debug information is_update: If True, wait for status to be 'updating' before streaming logs or 'failed' before exiting @@ -199,7 +197,7 @@ async def _monitor_deployment_with_logs( ] status_task = asyncio.create_task( - _poll_deployment_status(engine_url, api_key, server_name, state, debug) + _poll_deployment_status(engine_url, server_name, state, debug) ) # Don't stream logs until the deployment is 'updating' or 'failed' otherwise we will get logs from the previous deployment @@ -209,7 +207,7 @@ async def _monitor_deployment_with_logs( # Start log streaming task logs_task = asyncio.create_task( - _stream_deployment_logs_to_deque(engine_url, api_key, server_name, log_deque, state, debug) + _stream_deployment_logs_to_deque(engine_url, server_name, log_deque, state, debug) ) # Live display with spinner and logs @@ -281,10 +279,11 @@ async def _monitor_deployment_with_logs( # Create Deployment Functions -def server_already_exists(engine_url: str, api_key: str, server_name: str) -> bool: +def server_already_exists(engine_url: str, server_name: str) -> bool: """Check if a server already exists in the Arcade Engine.""" - client = httpx.Client(base_url=engine_url, headers={"Authorization": f"Bearer {api_key}"}) - response = client.get(f"/v1/workers/{server_name}") + url = get_org_scoped_url(engine_url, f"/workers/{server_name}") + client = httpx.Client(headers=get_auth_headers()) + response = client.get(url) if response.status_code == 404: return False @@ -294,11 +293,14 @@ def server_already_exists(engine_url: str, api_key: str, server_name: str) -> bo def update_deployment( - engine_url: str, api_key: str, server_name: str, update_deployment_request: dict + engine_url: str, + server_name: str, + update_deployment_request: dict, ) -> None: """Update a deployment in the Arcade Engine.""" - client = httpx.Client(base_url=engine_url, headers={"Authorization": f"Bearer {api_key}"}) - response = client.put(f"/v1/deployments/{server_name}", json=update_deployment_request) + url = get_org_scoped_url(engine_url, f"/deployments/{server_name}") + client = httpx.Client(headers=get_auth_headers()) + response = client.put(url, json=update_deployment_request) response.raise_for_status() @@ -590,21 +592,22 @@ def verify_server_and_get_metadata( def upsert_secrets_to_engine( - engine_url: str, api_key: str, secrets: set[str], debug: bool = False + engine_url: str, + secrets: set[str], + debug: bool = False, ) -> None: """ Upsert secrets to the Arcade Engine. Args: engine_url: The base URL of the Arcade Engine - api_key: The API key for authentication secrets: Set of secret keys to upsert debug: Whether to show debug information """ if not secrets: return - client = httpx.Client(base_url=engine_url, headers={"Authorization": f"Bearer {api_key}"}) + client = httpx.Client(headers=get_auth_headers()) for secret_key in sorted(secrets): secret_value = os.getenv(secret_key) @@ -623,8 +626,9 @@ def upsert_secrets_to_engine( try: # Upsert secret to engine + url = get_org_scoped_url(engine_url, f"/secrets/{secret_key}") response = client.put( - f"/v1/admin/secrets/{secret_key}", + url, json={"description": "Secret set via CLI", "value": secret_value}, timeout=30, ) @@ -644,14 +648,15 @@ def upsert_secrets_to_engine( def deploy_server_to_engine( - engine_url: str, api_key: str, deployment_request: dict, debug: bool = False + engine_url: str, + deployment_request: dict, + debug: bool = False, ) -> dict: """ Deploy the server to Arcade Engine. Args: engine_url: The base URL of the Arcade Engine - api_key: The API key for authentication deployment_request: The deployment request payload debug: Whether to show debug information @@ -662,14 +667,11 @@ def deploy_server_to_engine( httpx.HTTPStatusError: If the deployment request fails httpx.ConnectError: If connection to the engine fails """ - client = httpx.Client( - base_url=engine_url, - headers={"Authorization": f"Bearer {api_key}"}, - timeout=360, - ) + url = get_org_scoped_url(engine_url, "/deployments") + client = httpx.Client(headers=get_auth_headers(), timeout=360) try: - response = client.post("/v1/deployments", json=deployment_request) + response = client.post(url, json=deployment_request) response.raise_for_status() return cast(dict, response.json()) except httpx.ConnectError as e: @@ -793,7 +795,7 @@ def deploy_server_logic( secrets_to_upsert = set(load_env_file(str(env_path)).keys()) if secrets_to_upsert: console.print(f"✓ Found {len(secrets_to_upsert)} secret(s) in .env file", style="green") - upsert_secrets_to_engine(engine_url, config.api.key, secrets_to_upsert, debug) + upsert_secrets_to_engine(engine_url, secrets_to_upsert, debug) else: console.print("[!] No secrets found in .env file", style="yellow") elif secrets == "auto": @@ -803,9 +805,7 @@ def deploy_server_logic( f"\nUploading {len(required_secrets_from_validation)} required secret(s) to Arcade...", style="dim", ) - upsert_secrets_to_engine( - engine_url, config.api.key, required_secrets_from_validation, debug - ) + upsert_secrets_to_engine(engine_url, required_secrets_from_validation, debug) else: console.print("\n✓ No required secrets found", style="green") @@ -830,26 +830,26 @@ def deploy_server_logic( ) deployment_toolkits = DeploymentToolkits(bundles=[toolkit_bundle]) - if server_already_exists(engine_url, config.api.key, server_name): + if server_already_exists(engine_url, server_name): is_update = True update_request = UpdateDeploymentRequest( description="MCP Server deployed via CLI", toolkits=deployment_toolkits, ) - update_deployment(engine_url, config.api.key, server_name, update_request.model_dump()) + update_deployment(engine_url, server_name, update_request.model_dump()) else: create_request = CreateDeploymentRequest( name=server_name, description="MCP Server deployed via CLI", toolkits=deployment_toolkits, ) - deploy_server_to_engine(engine_url, config.api.key, create_request.model_dump(), debug) + deploy_server_to_engine(engine_url, create_request.model_dump(), debug) except Exception as e: raise ValueError(f"Deployment failed: {e}") from e # Step 8: Monitor deployment with live status and logs final_status, all_logs = asyncio.run( - _monitor_deployment_with_logs(engine_url, config.api.key, server_name, debug, is_update) + _monitor_deployment_with_logs(engine_url, server_name, debug, is_update) ) if final_status == "running": diff --git a/libs/arcade-cli/arcade_cli/main.py b/libs/arcade-cli/arcade_cli/main.py index d9515928..d9ad8913 100644 --- a/libs/arcade-cli/arcade_cli/main.py +++ b/libs/arcade-cli/arcade_cli/main.py @@ -2,28 +2,31 @@ import asyncio import os import subprocess import sys -import threading -import uuid import webbrowser from pathlib import Path from typing import Optional import click import typer -from arcade_core.constants import CREDENTIALS_FILE_PATH +from arcade_core.constants import CREDENTIALS_FILE_PATH, PROD_COORDINATOR_HOST, PROD_ENGINE_HOST from arcadepy import Arcade from rich.console import Console from rich.text import Text from tqdm import tqdm -from arcade_cli.authn import LocalAuthCallbackServer, check_existing_login -from arcade_cli.constants import ( - PROD_CLOUD_HOST, - PROD_ENGINE_HOST, +from arcade_cli.authn import ( + OAuthLoginError, + _credentials_file_contains_legacy, + build_coordinator_url, + check_existing_login, + perform_oauth_login, + save_credentials_from_whoami, ) from arcade_cli.display import ( display_eval_results, ) +from arcade_cli.org import app as org_app +from arcade_cli.project import app as project_app from arcade_cli.secret import app as secret_app from arcade_cli.server import app as server_app from arcade_cli.show import show_logic @@ -31,14 +34,12 @@ from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup from arcade_cli.utils import ( Provider, compute_base_url, - compute_login_url, get_eval_files, handle_cli_error, load_eval_suites, log_engine_health, require_dependency, resolve_provider_api_key, - validate_and_get_config, version_callback, ) @@ -74,71 +75,70 @@ cli.add_typer( console = Console() -@cli.command(help="Log in to Arcade Cloud", rich_help_panel="User") +@cli.command(help="Log in to Arcade", rich_help_panel="User") def login( host: str = typer.Option( - PROD_CLOUD_HOST, + PROD_COORDINATOR_HOST, "-h", "--host", - help="The Arcade Cloud host to log in to.", + help="The Arcade Coordinator host to log in to.", ), port: Optional[int] = typer.Option( None, "-p", "--port", - help="The port of the Arcade Cloud host (if running locally).", - ), - callback_host: str = typer.Option( - None, - "--callback-host", - help="The host to use to complete the auth flow - this should be the same as the host that the CLI is running on. Include the port if needed.", + help="The port of the Arcade Coordinator host (if running locally).", ), debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), ) -> None: """ - Logs the user into Arcade Cloud. + Logs the user into Arcade using OAuth. """ - if check_existing_login(): console.print("\nTo log out and delete your locally-stored credentials, use ", end="") console.print("arcade logout", style="bold green", end="") console.print(".\n") return - # Start the HTTP server in a new thread - state = str(uuid.uuid4()) - auth_server = LocalAuthCallbackServer(state) - server_thread = threading.Thread(target=auth_server.run_server) - server_thread.start() + coordinator_url = build_coordinator_url(host, port) try: - # Open the browser for user login - login_url = compute_login_url(host, state, port, callback_host) + result = perform_oauth_login( + coordinator_url, + on_status=lambda msg: console.print(msg, style="dim"), + ) - console.print("Opening a browser to log you in...") - if not webbrowser.open(login_url): + # Save credentials + save_credentials_from_whoami(result.tokens, result.whoami, coordinator_url) + + # Success message + console.print(f"\n✅ Logged in as {result.email}.", style="bold green") + if result.selected_org and result.selected_project: console.print( - f"If a browser doesn't open automatically, copy this URL and paste it into your browser: {login_url}", + f"\nActive project: {result.selected_org.name} / {result.selected_project.name}", style="dim", ) + console.print( + "Run 'arcade org list' or 'arcade project list' to see available options.", + style="dim", + ) - # Wait for the server thread to finish - server_thread.join() + except OAuthLoginError as e: + if debug: + console.print(f"Debug: {e.__cause__}", style="dim") + handle_cli_error(str(e), should_exit=False) except KeyboardInterrupt: - auth_server.shutdown_server() + console.print("\nLogin cancelled.", style="yellow") except Exception as e: handle_cli_error("Login failed", e, debug) - finally: - if server_thread.is_alive(): - server_thread.join() # Ensure the server thread completes and cleans up -@cli.command(help="Log out of Arcade Cloud", rich_help_panel="User") +@cli.command(help="Log out of Arcade", rich_help_panel="User") def logout( debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), ) -> None: """ - Logs the user out of Arcade Cloud. + Logs the user out of Arcade. """ try: # If the credentials file exists, delete it @@ -151,6 +151,55 @@ def logout( handle_cli_error("Logout failed", e, debug) +@cli.command(help="Show current login status and active context", rich_help_panel="User") +def whoami( + debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), +) -> None: + """ + Display the current logged-in user and active organization/project. + """ + from arcade_core.config_model import Config + + try: + config = Config.load_from_file() + except Exception as e: + handle_cli_error("Failed to read credentials", e, debug) + return + + # Defensive - should not happen, because the main() callback prevents this: + if not config.auth: + console.print("Not logged in. Run 'arcade login' to authenticate.", style="bold red") + return + + email = config.user.email if config.user else "unknown" + console.print(f"Logged in as: {email}", style="bold green") + + if config.context: + console.print(f"\nActive organization: {config.context.org_name}", style="bold") + console.print(f" ID: {config.context.org_id}", style="dim") + console.print(f"\nActive project: {config.context.project_name}", style="bold") + console.print(f" ID: {config.context.project_id}", style="dim") + else: + console.print("\nNo active organization/project set.", style="yellow") + + console.print("\nRun 'arcade org list' or 'arcade project list' to see options.", style="dim") + + +cli.add_typer( + org_app, + name="org", + help="Manage organizations (list, set active)", + rich_help_panel="User", +) + +cli.add_typer( + project_app, + name="project", + help="Manage projects (list, set active)", + rich_help_panel="User", +) + + @cli.command( help="Create a new server package directory. Example usage: `arcade new my_mcp_server`", rich_help_panel="Build", @@ -714,8 +763,7 @@ def dashboard( dashboard_url = f"{base_url}/dashboard" # Try to hit /health endpoint on engine and warn if it is down - config = validate_and_get_config() - with Arcade(api_key=config.api.key, base_url=base_url) as client: + with Arcade(api_key="", base_url=base_url) as client: log_engine_health(client) # Open the dashboard in a browser @@ -755,5 +803,22 @@ def main_callback( if ctx.invoked_subcommand in public_commands: return + if _credentials_file_contains_legacy(): + console.print( + "\nYour credentials are from an older CLI version and are no longer supported.", + style="bold yellow", + ) + console.print( + "Run `arcade logout` to remove the old credentials, " + "then run `arcade login` to sign back in.", + style="bold yellow", + ) + console.print( + "\nNote: `arcade logout` will delete your API key from ~/.arcade/credentials.yaml. " + "If you need to preserve it, copy it before logging out.", + style="bold yellow", + ) + handle_cli_error("Legacy credentials detected. Please re-authenticate.") + if not check_existing_login(suppress_message=True): handle_cli_error("Not logged in to Arcade CLI. Use `arcade login` to log in.") diff --git a/libs/arcade-cli/arcade_cli/org.py b/libs/arcade-cli/arcade_cli/org.py new file mode 100644 index 00000000..fd870fdf --- /dev/null +++ b/libs/arcade-cli/arcade_cli/org.py @@ -0,0 +1,163 @@ +import typer +from arcade_core.constants import PROD_COORDINATOR_HOST +from rich.console import Console + +from arcade_cli.authn import ( + fetch_organizations, + fetch_projects, + select_default_project, +) +from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup +from arcade_cli.utils import ( + compute_base_url, + handle_cli_error, +) + +console = Console() + + +app = TrackedTyper( + cls=TrackedTyperGroup, + add_completion=False, + no_args_is_help=True, + pretty_exceptions_enable=True, + pretty_exceptions_show_locals=False, + pretty_exceptions_short=True, +) + +state = { + "coordinator_url": compute_base_url( + force_tls=False, + force_no_tls=False, + host=PROD_COORDINATOR_HOST, + port=None, + default_port=None, + ) +} + + +@app.callback() +def main( + host: str = typer.Option( + PROD_COORDINATOR_HOST, + "--host", + "-h", + help="The Arcade Coordinator host.", + ), + port: int = typer.Option( + None, + "--port", + "-p", + help="The port of the Arcade Coordinator host.", + ), + force_tls: bool = typer.Option( + False, + "--tls", + help="Whether to force TLS for the connection to Arcade Coordinator.", + ), + force_no_tls: bool = typer.Option( + False, + "--no-tls", + help="Whether to disable TLS for the connection to Arcade Coordinator.", + ), +) -> None: + """Configure Coordinator connection options for organization commands.""" + coordinator_url = compute_base_url(force_tls, force_no_tls, host, port, default_port=None) + state["coordinator_url"] = coordinator_url + + +@app.command("list", help="List organizations you belong to") +def org_list( + debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), +) -> None: + """List all organizations the current user belongs to.""" + + from arcade_core.config_model import Config + from rich.table import Table + + try: + coordinator_url = state["coordinator_url"] + orgs = fetch_organizations(coordinator_url) + + if not orgs: + console.print("No organizations found.", style="yellow") + return + + # Get current active org + config = Config.load_from_file() + active_org_id = config.get_active_org_id() + + table = Table() + table.add_column("Name", style="cyan") + table.add_column("ID", style="dim") + table.add_column("Default", style="green") + table.add_column("Active", style="bold yellow") + + for org in orgs: + is_active = "✓" if org.org_id == active_org_id else "" + is_default = "✓" if org.is_default else "" + table.add_row(org.name, org.org_id, is_default, is_active) + + console.print(table) + console.print("\nUse 'arcade org set ' to switch organizations.\n") + + except ValueError as e: + handle_cli_error(str(e)) + except Exception as e: + handle_cli_error("Failed to list organizations", e, debug) + + +@app.command("set", help="Set the active organization") +def org_set( + org_id: str = typer.Argument(..., help="Organization ID to set as active"), + debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), +) -> None: + """Set the active organization and reset project to its default.""" + + from arcade_core.config_model import Config, ContextConfig + + try: + coordinator_url = state["coordinator_url"] + + # Verify org exists and user has access + orgs = fetch_organizations(coordinator_url) + target_org = next((o for o in orgs if o.org_id == org_id), None) + + if not target_org: + console.print( + f"Organization '{org_id}' not found or you don't have access.", style="bold red" + ) + console.print("Run 'arcade org list' to see available organizations.", style="dim") + return + + # Fetch projects and select default + projects = fetch_projects(coordinator_url, org_id) + if not projects: + handle_cli_error( + f"No projects found in organization '{target_org.name}'. " + "Contact support@arcade.dev for assistance." + ) + return + + selected_project = select_default_project(projects) + if not selected_project: + handle_cli_error("Could not select a default project.") + return + + # Update config + config = Config.load_from_file() + config.context = ContextConfig( + org_id=target_org.org_id, + org_name=target_org.name, + project_id=selected_project.project_id, + project_name=selected_project.name, + ) + config.save_to_file() + + console.print(f"✓ Switched to organization: {target_org.name}", style="bold green") + console.print(f" Active project: {selected_project.name}", style="dim") + + except ValueError as e: + handle_cli_error(str(e)) + except Exception as e: + handle_cli_error("Failed to set organization", e, debug) diff --git a/libs/arcade-cli/arcade_cli/project.py b/libs/arcade-cli/arcade_cli/project.py new file mode 100644 index 00000000..2104e8c9 --- /dev/null +++ b/libs/arcade-cli/arcade_cli/project.py @@ -0,0 +1,156 @@ +import typer +from arcade_core.constants import PROD_COORDINATOR_HOST +from rich.console import Console + +from arcade_cli.authn import fetch_projects +from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup +from arcade_cli.utils import ( + compute_base_url, + handle_cli_error, +) + +console = Console() + + +app = TrackedTyper( + cls=TrackedTyperGroup, + add_completion=False, + no_args_is_help=True, + pretty_exceptions_enable=True, + pretty_exceptions_show_locals=False, + pretty_exceptions_short=True, +) + +state = { + "coordinator_url": compute_base_url( + force_tls=False, + force_no_tls=False, + host=PROD_COORDINATOR_HOST, + port=None, + default_port=None, + ) +} + + +@app.callback() +def main( + host: str = typer.Option( + PROD_COORDINATOR_HOST, + "--host", + "-h", + help="The Arcade Coordinator host.", + ), + port: int = typer.Option( + None, + "--port", + "-p", + help="The port of the Arcade Coordinator host.", + ), + force_tls: bool = typer.Option( + False, + "--tls", + help="Whether to force TLS for the connection to Arcade Coordinator.", + ), + force_no_tls: bool = typer.Option( + False, + "--no-tls", + help="Whether to disable TLS for the connection to Arcade Coordinator.", + ), +) -> None: + """Configure Coordinator connection options for project commands.""" + coordinator_url = compute_base_url(force_tls, force_no_tls, host, port, default_port=None) + state["coordinator_url"] = coordinator_url + + +@app.command("list", help="List projects in the active organization") +def project_list( + debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), +) -> None: + """List all projects in the current active organization.""" + from arcade_core.config_model import Config + from rich.table import Table + + try: + config = Config.load_from_file() + + if not config.context: + console.print("No active organization set. Run 'arcade login' first.", style="bold red") + return + + coordinator_url = state["coordinator_url"] + projects = fetch_projects(coordinator_url, config.context.org_id) + + if not projects: + console.print( + f"No projects found in organization '{config.context.org_name}'.", + style="yellow", + ) + return + + active_project_id = config.get_active_project_id() + + console.print( + f"\nActive organization: {config.context.org_name}\n" + "Use 'arcade org list' and 'arcade org set ' to switch organizations.\n", + ) + + table = Table() + table.add_column("Name", style="cyan") + table.add_column("ID", style="dim") + table.add_column("Default", style="green") + table.add_column("Active", style="bold yellow") + + for project in projects: + is_active = "✓" if project.project_id == active_project_id else "" + is_default = "✓" if project.is_default else "" + table.add_row(project.name, project.project_id, is_default, is_active) + + console.print(table) + console.print("\nUse 'arcade project set ' to switch projects.\n") + + except ValueError as e: + handle_cli_error(str(e)) + except Exception as e: + handle_cli_error("Failed to list projects", e, debug) + + +@app.command("set", help="Set the active project") +def project_set( + project_id: str = typer.Argument(..., help="Project ID to set as active"), + debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), +) -> None: + """Set the active project within the current organization.""" + from arcade_core.config_model import Config + + try: + config = Config.load_from_file() + + if not config.context: + console.print("No active organization set. Run 'arcade login' first.", style="bold red") + return + + coordinator_url = state["coordinator_url"] + + # Verify project exists in current org + projects = fetch_projects(coordinator_url, config.context.org_id) + target_project = next((p for p in projects if p.project_id == project_id), None) + + if not target_project: + console.print( + f"Project '{project_id}' not found in organization '{config.context.org_name}'.", + style="bold red", + ) + console.print("Run 'arcade project list' to see available projects.", style="dim") + return + + # Update config + config.context.project_id = target_project.project_id + config.context.project_name = target_project.name + config.save_to_file() + + console.print(f"✓ Switched to project: {target_project.name}", style="bold green") + + except ValueError as e: + handle_cli_error(str(e)) + except Exception as e: + handle_cli_error("Failed to set project", e, debug) diff --git a/libs/arcade-cli/arcade_cli/secret.py b/libs/arcade-cli/arcade_cli/secret.py index 29b2fb75..47a72cfd 100644 --- a/libs/arcade-cli/arcade_cli/secret.py +++ b/libs/arcade-cli/arcade_cli/secret.py @@ -1,15 +1,14 @@ import httpx import typer +from arcade_core.constants import PROD_ENGINE_HOST from rich.console import Console from rich.table import Table -from arcade_cli.constants import ( - PROD_ENGINE_HOST, -) from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup from arcade_cli.utils import ( compute_base_url, - validate_and_get_config, + get_auth_headers, + get_org_scoped_url, ) console = Console() @@ -96,8 +95,6 @@ def set_secret( if from_env and key_value_pairs: raise typer.BadParameter("Cannot use both KEY=VALUE pairs and --from-env at the same time.") - config = validate_and_get_config() - if from_env: secrets = load_env_file(env_file) else: @@ -116,11 +113,9 @@ def set_secret( value = value # keep the value as is, including the whitespace secrets[key] = value - engine_url = state["engine_url"] - for secret_key, secret_value in secrets.items(): try: - _upsert_secret_to_engine(engine_url, config.api.key, secret_key, secret_value) + _upsert_secret(secret_key, secret_value) except Exception as e: console.print(f"Error setting secret '{secret_key}': {e}", style="bold red") continue @@ -129,13 +124,10 @@ def set_secret( ) -@app.command("list", help="List all tool secrets in Arcade Cloud") +@app.command("list", help="List all tool secrets in Arcade") def list_secrets() -> None: """List all secrets (keys only, values are masked).""" - config = validate_and_get_config() - engine_url = state["engine_url"] - - secrets = _get_secrets_from_engine(engine_url, config.api.key) + secrets = _get_secrets() print_secret_table(secrets) @@ -147,9 +139,7 @@ def unset_secret( ), ) -> None: """Delete tool secrets.""" - config = validate_and_get_config() - engine_url = state["engine_url"] - secrets = _get_secrets_from_engine(engine_url, config.api.key) + secrets = _get_secrets() key_to_id = {secret["key"]: secret["id"] for secret in secrets} @@ -160,7 +150,7 @@ def unset_secret( continue try: - _delete_secret_from_engine(engine_url, config.api.key, secret_id) + _delete_secret(secret_id) console.print(f"Secret '{key}' deleted successfully") except Exception: console.print( @@ -258,29 +248,30 @@ def _remove_inline_comment(value: str) -> str: return value -def _upsert_secret_to_engine( - engine_url: str, api_key: str, secret_id: str, secret_value: str -) -> None: +def _upsert_secret(secret_key: str, secret_value: str) -> None: + """Upsert a secret to the engine.""" + engine_url = state["engine_url"] + url = get_org_scoped_url(engine_url, f"/secrets/{secret_key}") response = httpx.put( - f"{engine_url}/v1/admin/secrets/{secret_id}", - headers={"Authorization": f"Bearer {api_key}"}, + url, + headers=get_auth_headers(), json={"description": "Secret set via CLI", "value": secret_value}, ) response.raise_for_status() -def _get_secrets_from_engine(engine_url: str, api_key: str) -> list[dict]: - response = httpx.get( - f"{engine_url}/v1/admin/secrets", - headers={"Authorization": f"Bearer {api_key}"}, - ) +def _get_secrets() -> list[dict]: + """Get all secrets from the engine.""" + engine_url = state["engine_url"] + url = get_org_scoped_url(engine_url, "/secrets") + response = httpx.get(url, headers=get_auth_headers()) response.raise_for_status() return response.json()["items"] # type: ignore[no-any-return] -def _delete_secret_from_engine(engine_url: str, api_key: str, secret_id: str) -> None: - response = httpx.delete( - f"{engine_url}/v1/admin/secrets/{secret_id}", - headers={"Authorization": f"Bearer {api_key}"}, - ) +def _delete_secret(secret_id: str) -> None: + """Delete a secret from the engine.""" + engine_url = state["engine_url"] + url = get_org_scoped_url(engine_url, f"/secrets/{secret_id}") + response = httpx.delete(url, headers=get_auth_headers()) response.raise_for_status() diff --git a/libs/arcade-cli/arcade_cli/server.py b/libs/arcade-cli/arcade_cli/server.py index bdf2cfab..592c48a4 100644 --- a/libs/arcade-cli/arcade_cli/server.py +++ b/libs/arcade-cli/arcade_cli/server.py @@ -6,20 +6,20 @@ from typing import Optional import httpx import typer -from arcadepy import Arcade, NotFoundError +from arcade_core.constants import PROD_ENGINE_HOST +from arcadepy import NotFoundError from arcadepy.types import WorkerHealthResponse, WorkerResponse from dateutil import parser from rich.console import Console from rich.table import Table -from arcade_cli.constants import ( - PROD_ENGINE_HOST, -) from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup from arcade_cli.utils import ( compute_base_url, + get_arcade_client, + get_auth_headers, + get_org_scoped_url, handle_cli_error, - validate_and_get_config, ) console = Console() @@ -159,9 +159,8 @@ def list_servers( help="Show debug information", ), ) -> None: - config = validate_and_get_config() base_url = state["engine_url"] - client = Arcade(api_key=config.api.key, base_url=base_url) + client = get_arcade_client(base_url) try: servers = client.workers.list(limit=100) _print_servers_table(servers.items) @@ -179,9 +178,8 @@ def get_server( help="Show debug information", ), ) -> None: - config = validate_and_get_config() base_url = state["engine_url"] - client = Arcade(api_key=config.api.key, base_url=base_url) + client = get_arcade_client(base_url) try: server = client.workers.get(server_name) server_health = client.workers.health(server_name) @@ -200,9 +198,8 @@ def enable_server( help="Show debug information", ), ) -> None: - config = validate_and_get_config() engine_url = state["engine_url"] - arcade = Arcade(api_key=config.api.key, base_url=engine_url) + arcade = get_arcade_client(engine_url) try: arcade.workers.update(server_name, enabled=True) except Exception as e: @@ -219,9 +216,8 @@ def disable_server( help="Show debug information", ), ) -> None: - config = validate_and_get_config() engine_url = state["engine_url"] - arcade = Arcade(api_key=config.api.key, base_url=engine_url) + arcade = get_arcade_client(engine_url) try: arcade.workers.update(server_name, enabled=False) except Exception as e: @@ -238,11 +234,10 @@ def delete_server( help="Show debug information", ), ) -> None: - config = validate_and_get_config() engine_url = state["engine_url"] try: - arcade = Arcade(api_key=config.api.key, base_url=engine_url) + arcade = get_arcade_client(engine_url) arcade.workers.delete(server_name) console.print(f"✓ Server '{server_name}' deleted successfully", style="green") except NotFoundError as e: @@ -287,8 +282,8 @@ def get_server_logs( help="Show debug information", ), ) -> None: - config = validate_and_get_config() - headers = {"Authorization": f"Bearer {config.api.key}", "Content-Type": "application/json"} + auth_headers = get_auth_headers() + headers = {**auth_headers, "Content-Type": "application/json"} # Set defaults based on whether we're following or not if since is None: @@ -307,14 +302,16 @@ def get_server_logs( except ValueError as e: handle_cli_error(f"Invalid time format: {e}", debug=debug) + base_url = state["engine_url"] + if follow: # Use the streaming endpoint - engine_url = state["engine_url"] + f"/v1/deployments/{server_name}/logs/stream" - asyncio.run(_stream_deployment_logs(engine_url, headers, since_dt, until_dt, debug=debug)) + logs_url = get_org_scoped_url(base_url, f"/deployments/{server_name}/logs/stream") + asyncio.run(_stream_deployment_logs(logs_url, headers, since_dt, until_dt, debug=debug)) else: # Use the non-streaming endpoint - engine_url = state["engine_url"] + f"/v1/deployments/{server_name}/logs" - _display_deployment_logs(engine_url, headers, since_dt, until_dt, debug=debug) + logs_url = get_org_scoped_url(base_url, f"/deployments/{server_name}/logs") + _display_deployment_logs(logs_url, headers, since_dt, until_dt, debug=debug) def _display_deployment_logs( diff --git a/libs/arcade-cli/arcade_cli/templates/base.jinja b/libs/arcade-cli/arcade_cli/templates/base.jinja new file mode 100644 index 00000000..c18f73e5 --- /dev/null +++ b/libs/arcade-cli/arcade_cli/templates/base.jinja @@ -0,0 +1,152 @@ + + + + + + {% block title %}{% endblock %} + + + + + + + + {% block scripts %}{% endblock %} + + +
+
+ + {% set heading_block %}{% block heading %}{% endblock %}{% endset %} + {% if heading_block | trim %} +

{{ heading_block | trim | safe }}

+ {% endif %} + {% block content %}{% endblock %} +
+
+ + + diff --git a/libs/arcade-cli/arcade_cli/templates/cli_login_failed.jinja b/libs/arcade-cli/arcade_cli/templates/cli_login_failed.jinja new file mode 100644 index 00000000..b2b6cfbd --- /dev/null +++ b/libs/arcade-cli/arcade_cli/templates/cli_login_failed.jinja @@ -0,0 +1,152 @@ +{% extends "base.jinja" %} + +{% block title %}Arcade.dev - Authorization failed{% endblock %} + +{% block styles %} +.arc-stack { + display: flex; + flex-direction: column; + gap: 24px; + text-align: center; + align-items: center; +} +.arc-stack p { + color: var(--text-secondary, #d6d6d6); +} +.message-error { + color: #ff4d4d; + font-weight: 500; +} +.developer-section { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + width: 100%; +} +.developer-toggle { + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--border-default, #2f2f2f), transparent 10%); + background: transparent; + color: var(--text-secondary, #d6d6d6); + font-weight: 500; + font-size: 0.875rem; + padding: 10px 20px; + cursor: pointer; + transition: all 0.2s ease; +} +.developer-toggle:hover { + border-color: color-mix(in srgb, var(--border-default, #2f2f2f), #ffffff 15%); + background: rgba(255, 255, 255, 0.03); +} +.developer-details { + width: 100%; + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease, opacity 0.3s ease; + opacity: 0; +} +.developer-details.show { + grid-template-rows: 1fr; + opacity: 1; +} +.developer-details-inner { + overflow: hidden; + display: flex; + flex-direction: column; + gap: 16px; +} +.arc-panel { + padding: clamp(16px, 4vw, 24px); + border-radius: 12px; + background: color-mix(in srgb, var(--surface-card, #141414) 85%, transparent); + border: 1px solid color-mix(in srgb, var(--border-default, #2d2d2d), transparent 25%); + text-align: left; +} +.arc-panel pre { + margin: 0; + font-family: 'JetBrains Mono', 'SFMono-Regular', monospace; + font-size: 0.8rem; + color: var(--text-secondary, #d6d6d6); + white-space: pre-wrap; + word-break: break-word; +} +.error-label { + color: var(--text-muted, #999); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; +} +.error-value { + color: var(--text-primary, #fff); + font-family: 'JetBrains Mono', 'SFMono-Regular', monospace; + font-size: 0.85rem; +} +.error-block { + margin-bottom: 12px; +} +.error-block:last-child { + margin-bottom: 0; +} +{% endblock %} + +{% block scripts %} + +{% endblock %} + +{% block content %} +
+

Authorization failed

+

{{ message if message else 'Something went wrong. Please close this window and try again.' }}

+ +
+

Powered by Arcade.dev

+
+ + {% if error or error_description %} +
+ +
+
+
+ {% if error %} +
+
Error
+
{{ error }}
+
+ {% endif %} + {% if error_description %} +
+
Description
+
{{ error_description }}
+
+ {% endif %} + {% if state %} +
+
State
+
{{ state }}
+
+ {% endif %} +
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/libs/arcade-cli/arcade_cli/templates/cli_login_success.jinja b/libs/arcade-cli/arcade_cli/templates/cli_login_success.jinja new file mode 100644 index 00000000..3bea178d --- /dev/null +++ b/libs/arcade-cli/arcade_cli/templates/cli_login_success.jinja @@ -0,0 +1,28 @@ +{% extends "base.jinja" %} + +{% block title %}Arcade.dev - Authorization successful{% endblock %} + +{% block styles %} +.arc-stack { + display: flex; + flex-direction: column; + gap: 24px; + text-align: center; + align-items: center; +} +.arc-stack p { + color: var(--text-secondary, #d6d6d6); +} +{% endblock %} + +{% block content %} +
+

Authorization successful.

+

You can close this window and return to your terminal.

+ +
+

Powered by Arcade.dev

+
+
+{% endblock %} + diff --git a/libs/arcade-cli/arcade_cli/usage/command_tracker.py b/libs/arcade-cli/arcade_cli/usage/command_tracker.py index 5dc1ad92..ba5633cc 100644 --- a/libs/arcade-cli/arcade_cli/usage/command_tracker.py +++ b/libs/arcade-cli/arcade_cli/usage/command_tracker.py @@ -12,6 +12,8 @@ from arcade_cli.usage.constants import ( EVENT_CLI_COMMAND_FAILED, PROP_CLI_VERSION, PROP_COMMAND_NAME, + PROP_ORG_ID, + PROP_PROJECT_ID, ) from arcade_core.constants import ARCADE_CONFIG_PATH from arcade_core.usage import UsageIdentity, UsageService, is_tracking_enabled @@ -81,6 +83,24 @@ class CommandTracker: current_ctx = current_ctx.parent return ".".join(reversed(command_parts)) + def _get_org_project_context(self) -> tuple[str | None, str | None]: + """Get org_id and project_id from config if available.""" + try: + from arcade_core.config_model import Config + + config = Config.load_from_file() + if config.context: + return config.context.org_id, config.context.project_id + except FileNotFoundError: + # No config file - user isn't logged in, which is fine + pass + except Exception as e: + console.print( + f"[yellow]Warning: Failed to load Arcade config: {e}[/yellow]\n" + "[yellow]Run 'arcade logout' then 'arcade login' to fix this.[/yellow]" + ) + return None, None + def _handle_successful_login(self) -> None: """Handle a successful login event. @@ -196,6 +216,13 @@ class CommandTracker: PROP_OS_RELEASE: platform.release(), } + # Add org/project context when available (many commands operate within a project) + org_id, project_id = self._get_org_project_context() + if org_id: + properties[PROP_ORG_ID] = org_id + if project_id: + properties[PROP_PROJECT_ID] = project_id + if not success and error_message: properties[PROP_ERROR_MESSAGE] = error_message diff --git a/libs/arcade-cli/arcade_cli/usage/constants.py b/libs/arcade-cli/arcade_cli/usage/constants.py index 651084c8..f092a13f 100644 --- a/libs/arcade-cli/arcade_cli/usage/constants.py +++ b/libs/arcade-cli/arcade_cli/usage/constants.py @@ -5,3 +5,5 @@ EVENT_CLI_COMMAND_FAILED = "CLI execution failed" # CLI Specific Property Names PROP_COMMAND_NAME = "command_name" PROP_CLI_VERSION = "cli_version" +PROP_ORG_ID = "org_id" +PROP_PROJECT_ID = "project_id" diff --git a/libs/arcade-cli/arcade_cli/utils.py b/libs/arcade-cli/arcade_cli/utils.py index d3a94546..256be8e5 100644 --- a/libs/arcade-cli/arcade_cli/utils.py +++ b/libs/arcade-cli/arcade_cli/utils.py @@ -12,11 +12,12 @@ from importlib import metadata from pathlib import Path from textwrap import dedent from typing import Any, Callable, Union, cast -from urllib.parse import urlencode, urlparse +from urllib.parse import urlparse import idna from arcade_core import ToolCatalog, Toolkit from arcade_core.config_model import Config +from arcade_core.constants import LOCALHOST from arcade_core.discovery import ( analyze_files_for_tools, build_minimal_toolkit, @@ -24,6 +25,7 @@ from arcade_core.discovery import ( find_candidate_tool_files, ) from arcade_core.errors import ToolkitLoadError +from arcade_core.network.org_transport import build_org_scoped_http_client from arcade_core.schema import ToolDefinition from arcadepy import ( NOT_GIVEN, @@ -48,8 +50,6 @@ from rich.text import Text from typer.core import TyperGroup from typer.models import Context -from arcade_cli.constants import LOCAL_AUTH_CALLBACK_PORT, LOCALHOST - console = Console() @@ -203,9 +203,10 @@ def compute_base_url( force_no_tls: bool, host: str, port: int | None, + default_port: int | None = 9099, ) -> str: """ - Compute the base URL for the Arcade Engine from the provided overrides. + Compute the base URL for an Arcade service from the provided overrides. Treats 127.0.0.1 and 0.0.0.0 as aliases for localhost. @@ -217,8 +218,8 @@ def compute_base_url( hostnames with underscores. This property exists to provide a consistent and correctly formatted URL for - connecting to the Arcade Engine, taking into account various configuration - options and edge cases. It ensures that: + connecting to Arcade services (Engine, Coordinator), taking into account various + configuration options and edge cases. It ensures that: 1. The correct protocol (http/https) is used based on the TLS setting. 2. IPv4 and IPv6 addresses are properly formatted. @@ -228,10 +229,16 @@ def compute_base_url( 6. Hostnames with underscores (common in development environments) are supported. 7. Pre-existing port specifications in the host are respected. - The resulting URL is always suffixed with api_version to specify the API version. + Args: + force_tls: Force HTTPS protocol. + force_no_tls: Force HTTP protocol (takes precedence over force_tls). + host: The hostname or IP address. + port: The port number (optional). + default_port: The default port for localhost if none specified. + Use 9099 for Engine, None for Coordinator (standard HTTPS). Returns: - str: The fully constructed URL for the Arcade Engine. + str: The fully constructed URL for the Arcade service. """ # "Use 127.0.0.1" and "0.0.0.0" as aliases for "localhost" host = LOCALHOST if host in ["127.0.0.1", "0.0.0.0"] else host # noqa: S104 @@ -244,9 +251,9 @@ def compute_base_url( else: is_tls = host != LOCALHOST - # "localhost" defaults to dev port if not specified - if host == LOCALHOST and port is None: - port = 9099 + # "localhost" defaults to dev port if not specified and a default is provided + if host == LOCALHOST and port is None and default_port is not None: + port = default_port protocol = "https" if is_tls else "http" @@ -284,39 +291,6 @@ def compute_base_url( return f"{protocol}://{encoded_host}" -def compute_login_url( - host: str, state: str, port: int | None, callback_host: str | None = None -) -> str: - """ - Compute the full URL for the CLI login endpoint. - """ - if callback_host: - if not (callback_host.startswith("http://") or callback_host.startswith("https://")): - callback_uri = f"http://{callback_host}" - else: - callback_uri = callback_host - if not callback_uri.rstrip("/").endswith("/callback"): - if port: - callback_uri = callback_uri.rstrip("/") + f":{port}" + "/callback" - else: - callback_uri = callback_uri.rstrip("/") + "/callback" - else: - callback_uri = f"http://{LOCALHOST}:{LOCAL_AUTH_CALLBACK_PORT}/callback" - - params = urlencode({"callback_uri": callback_uri, "state": state}) - - port = port if port else 8000 - - login_base_url = ( - f"http://{LOCALHOST}:{port}" - if host in [LOCALHOST, "127.0.0.1", "0.0.0.0"] # noqa: S104 - else f"https://{host}" - ) - endpoint = "/api/v1/auth/cli_login" - - return f"{login_base_url}{endpoint}?{params}" - - def get_tools_from_engine( host: str, port: int | None = None, @@ -324,9 +298,8 @@ def get_tools_from_engine( force_no_tls: bool = False, toolkit: str | None = None, ) -> list[ToolDefinition]: - config = validate_and_get_config() base_url = compute_base_url(force_tls, force_no_tls, host, port) - client = Arcade(api_key=config.api.key, base_url=base_url) + client = get_arcade_client(base_url) tools = [] try: @@ -422,18 +395,15 @@ def validate_and_get_config( validate_user: bool = True, ) -> Config: """ - Validates the configuration, user, and returns the Config object + Validates the configuration, user, and returns the Config object. """ - try: from arcade_core.config import config except Exception as e: handle_cli_error("Not logged in", e, debug=False) - if validate_api and (not config.api or not config.api.key): - handle_cli_error( - "API configuration not found or key is missing. Please run `arcade login`." - ) + if validate_api and not config.auth: + handle_cli_error("Authentication not configured. Please run `arcade login`.") if validate_user and (not config.user or not config.user.email): handle_cli_error("User email not found in configuration. Please run `arcade login`.") @@ -441,6 +411,120 @@ def validate_and_get_config( return config +def get_org_project_context() -> tuple[str, str]: + """ + Get the active org_id and project_id from config. + + Returns: + Tuple of (org_id, project_id) + + Raises: + CLIError if no active org/project context is set. + """ + config = validate_and_get_config() + + if not config.context or not config.context.org_id or not config.context.project_id: + handle_cli_error("No active organization/project set. Please run `arcade login` first.") + raise AssertionError("unreachable") # handle_cli_error raises CLIError + + return config.context.org_id, config.context.project_id + + +def get_auth_headers(coordinator_url: str | None = None) -> dict[str, str]: + """ + Get authorization headers for API calls. + + Args: + coordinator_url: Coordinator URL for token refresh (optional for legacy) + + Returns: + Dictionary with Authorization header + """ + from arcade_core.constants import PROD_COORDINATOR_HOST + + from arcade_cli.authn import get_valid_access_token + + config = validate_and_get_config() + resolved_coordinator_url = ( + coordinator_url + or (getattr(config, "coordinator_url", None) or None) + or f"https://{PROD_COORDINATOR_HOST}" + ) + + try: + access_token = get_valid_access_token(resolved_coordinator_url) + except ValueError as e: + handle_cli_error(str(e)) + raise AssertionError("unreachable") # handle_cli_error raises CLIError + + return {"Authorization": f"Bearer {access_token}"} + + +def get_org_scoped_url(base_url: str, path: str) -> str: + """ + Build an org-scoped URL using the active context. + + Args: + base_url: Base URL of the API (e.g., https://api.arcade.dev) + path: Path suffix after the org/project prefix (e.g., "/secrets/KEY") + + Returns: + Full URL with org/project path prefix + + Raises: + CLIError if no active context is set + + Example: + get_org_scoped_url("https://api.arcade.dev", "/secrets/MY_KEY") + # Returns: "https://api.arcade.dev/v1/orgs/ORG_ID/projects/PROJECT_ID/secrets/MY_KEY" + """ + config = validate_and_get_config() + + if not config.context: + handle_cli_error("No active organization/project. Please run `arcade login` first.") + raise AssertionError("unreachable") # handle_cli_error raises CLIError + + org_id = config.context.org_id + project_id = config.context.project_id + + return f"{base_url}/v1/orgs/{org_id}/projects/{project_id}{path}" + + +def get_arcade_client(base_url: str) -> Arcade: + """ + Create an Arcade client with proper authentication and org-scoped URL rewriting. + Requests are automatically rewritten to include org/project scope in URLs. + + Args: + base_url: Base URL of the Arcade Engine + + Returns: + Configured Arcade client + + Example: + client = get_arcade_client("https://api.arcade.dev") + servers = client.workers.list() # Automatically uses org-scoped URLs + """ + config = validate_and_get_config() + + # OAuth mode: need to rewrite URLs to include org/project scope + from arcade_cli.authn import get_valid_access_token + + access_token = get_valid_access_token() + + # Get org/project context for URL rewriting + if not config.context or not config.context.org_id or not config.context.project_id: + handle_cli_error("No active organization/project set. Please run `arcade login` first.") + raise AssertionError("unreachable") # handle_cli_error raises CLIError + + org_id = config.context.org_id + project_id = config.context.project_id + + http_client = build_org_scoped_http_client(org_id, project_id) + + return Arcade(api_key=access_token, base_url=base_url, http_client=http_client) + + def log_engine_health(client: Arcade) -> None: try: result = client.health.check(timeout=2) diff --git a/libs/arcade-core/arcade_core/auth_tokens.py b/libs/arcade-core/arcade_core/auth_tokens.py new file mode 100644 index 00000000..bad2f60d --- /dev/null +++ b/libs/arcade-core/arcade_core/auth_tokens.py @@ -0,0 +1,110 @@ +""" +Shared access-token utilities used by both the CLI and MCP server. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +import httpx +from pydantic import BaseModel + +from arcade_core.config_model import Config +from arcade_core.constants import PROD_COORDINATOR_HOST + + +class CLIConfig(BaseModel): + """OAuth configuration returned by the Coordinator.""" + + client_id: str + authorization_endpoint: str + token_endpoint: str + + +class TokenResponse(BaseModel): + """OAuth token response.""" + + access_token: str + refresh_token: str + expires_in: int + token_type: str + + +def fetch_cli_config(coordinator_url: str) -> CLIConfig: + """Fetch OAuth configuration from the Coordinator.""" + url = f"{coordinator_url}/api/v1/auth/cli_config" + response = httpx.get(url, timeout=30) + response.raise_for_status() + return CLIConfig.model_validate(response.json()) + + +def refresh_access_token( + cli_config: CLIConfig, + refresh_token: str, +) -> TokenResponse: + """Refresh the access token using authlib-compatible token endpoint.""" + response = httpx.post( + cli_config.token_endpoint, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": cli_config.client_id, + }, + timeout=30, + ) + response.raise_for_status() + token = response.json() + return TokenResponse( + access_token=token["access_token"], + refresh_token=token.get("refresh_token", refresh_token), + expires_in=token["expires_in"], + token_type=token["token_type"], + ) + + +def get_valid_access_token(coordinator_url: str | None = None) -> str: + """ + Get a valid access token, refreshing if necessary. + + Returns: + Valid access token + + Raises: + ValueError: If not logged in or token refresh fails + """ + try: + config = Config.load_from_file() + except FileNotFoundError: + raise ValueError("Not logged in. Please run 'arcade login' first.") + + resolved_coordinator_url = ( + coordinator_url + or (config.coordinator_url if config.coordinator_url else None) + or f"https://{PROD_COORDINATOR_HOST}" + ) + + if not config.auth: + raise ValueError("Not logged in. Please run 'arcade login' first.") + + # Check if token needs refresh + if config.is_token_expired(): + cli_config = fetch_cli_config(resolved_coordinator_url) + + try: + new_tokens = refresh_access_token(cli_config, config.auth.refresh_token) + except httpx.HTTPError as e: + raise ValueError( + f"Failed to refresh token: {e}. Please run 'arcade login' to re-authenticate." + ) + + # Update stored credentials + expires_at = datetime.now() + timedelta(seconds=new_tokens.expires_in) + config.coordinator_url = resolved_coordinator_url + config.auth.access_token = new_tokens.access_token + config.auth.refresh_token = new_tokens.refresh_token + config.auth.expires_at = expires_at + config.save_to_file() + + return new_tokens.access_token + + return config.auth.access_token diff --git a/libs/arcade-core/arcade_core/config_model.py b/libs/arcade-core/arcade_core/config_model.py index 54e96db3..7080037c 100644 --- a/libs/arcade-core/arcade_core/config_model.py +++ b/libs/arcade-core/arcade_core/config_model.py @@ -1,4 +1,5 @@ import os +from datetime import datetime, timedelta from pathlib import Path from typing import Any @@ -10,18 +11,22 @@ class BaseConfig(BaseModel): model_config = ConfigDict(extra="ignore") -class ApiConfig(BaseConfig): +class AuthConfig(BaseConfig): """ - Arcade API configuration. + OAuth authentication configuration. """ - key: str + access_token: str """ - Arcade API key. + OAuth access token (JWT). """ - version: str = "v1" + refresh_token: str """ - Arcade API version. + OAuth refresh token for obtaining new access tokens. + """ + expires_at: datetime + """ + When the access token expires. """ @@ -36,15 +41,51 @@ class UserConfig(BaseConfig): """ -class Config(BaseConfig): +class ContextConfig(BaseConfig): """ - Configuration for Arcade. + Active organization and project context. """ - api: ApiConfig + org_id: str """ - Arcade API configuration. + Active organization ID. """ + org_name: str + """ + Active organization name. + """ + project_id: str + """ + Active project ID. + """ + project_name: str + """ + Active project name. + """ + + +class Config(BaseConfig): + """ + Configuration for Arcade CLI. + """ + + coordinator_url: str | None = None + """ + Base URL of the Arcade Coordinator used for authentication flows. + """ + + auth: AuthConfig | None = None + """ + OAuth authentication configuration. + """ + + # Active org/project context + context: ContextConfig | None = None + """ + Active organization and project context. + """ + + # User info user: UserConfig | None = None """ Arcade user configuration. @@ -53,6 +94,48 @@ class Config(BaseConfig): def __init__(self, **data: Any): super().__init__(**data) + def is_authenticated(self) -> bool: + """ + Check if the user is authenticated (has valid auth config). + """ + return self.auth is not None + + def is_token_expired(self) -> bool: + """ + Check if the access token is expired or will expire within 5 minutes. + """ + if not self.auth: + return True + # Consider expired if less than 5 minutes remaining + buffer_seconds = 300 + return datetime.now() >= self.auth.expires_at.replace(tzinfo=None) - timedelta( + seconds=buffer_seconds + ) + + def get_access_token(self) -> str | None: + """ + Get the current access token if available. + """ + if self.auth: + return self.auth.access_token + return None + + def get_active_org_id(self) -> str | None: + """ + Get the active organization ID. + """ + if self.context: + return self.context.org_id + return None + + def get_active_project_id(self) -> str | None: + """ + Get the active project ID. + """ + if self.context: + return self.context.project_id + return None + @classmethod def get_config_dir_path(cls) -> Path: """ @@ -82,16 +165,11 @@ class Config(BaseConfig): """ Load the configuration from the YAML file in the configuration directory. - If no configuration file exists, this method will create a new one with default values. - The default configuration includes: - - An empty API configuration - - A default Engine configuration (host: "api.arcade.dev", port: None, tls: True) - - No user configuration - Returns: - Config: The loaded or newly created configuration. + Config: The loaded configuration. Raises: + FileNotFoundError: If no configuration file exists. ValueError: If the existing configuration file is invalid. """ cls.ensure_config_dir_exists() @@ -108,13 +186,13 @@ class Config(BaseConfig): if config_data is None: raise ValueError( - "Invalid credentials.yaml file. Please ensure it is a valid YAML file or" + "Invalid credentials.yaml file. Please ensure it is a valid YAML file or " "run `arcade logout`, then `arcade login` to start from a clean slate." ) if "cloud" not in config_data: raise ValueError( - "Invalid credentials.yaml file. Expected a 'cloud' key." + "Invalid credentials.yaml file. Expected a 'cloud' key. " "Run `arcade logout`, then `arcade login` to start from a clean slate." ) @@ -123,7 +201,6 @@ class Config(BaseConfig): except ValidationError as e: # Get only the errors with {type:missing} and combine them # into a nicely-formatted string message. - # Any other errors without {type:missing} should just be str()ed missing_field_errors = [ ".".join(map(str, error["loc"])) for error in e.errors() @@ -145,7 +222,15 @@ class Config(BaseConfig): def save_to_file(self) -> None: """ Save the configuration to the YAML file in the configuration directory. + + Sets file permissions to 600 (owner read/write only) for security. """ Config.ensure_config_dir_exists() config_file_path = Config.get_config_file_path() - config_file_path.write_text(yaml.dump(self.model_dump())) + + # Convert to dict, excluding None values for cleaner output + data = {"cloud": self.model_dump(exclude_none=True, mode="json")} + config_file_path.write_text(yaml.dump(data, default_flow_style=False)) + + # Set restrictive permissions (owner read/write only) + config_file_path.chmod(0o600) diff --git a/libs/arcade-core/arcade_core/constants.py b/libs/arcade-core/arcade_core/constants.py index 7e749b0b..8554039c 100644 --- a/libs/arcade-core/arcade_core/constants.py +++ b/libs/arcade-core/arcade_core/constants.py @@ -4,3 +4,8 @@ import os ARCADE_CONFIG_PATH = os.path.join(os.path.expanduser(os.getenv("ARCADE_WORK_DIR", "~")), ".arcade") # The path to the file containing the user's Arcade-related credentials (e.g., ARCADE_API_KEY). CREDENTIALS_FILE_PATH = os.path.join(ARCADE_CONFIG_PATH, "credentials.yaml") + +# Host defaults used by both the CLI and MCP server +PROD_COORDINATOR_HOST = "cloud.arcade.dev" +PROD_ENGINE_HOST = "api.arcade.dev" +LOCALHOST = "localhost" diff --git a/libs/arcade-core/arcade_core/network/__init__.py b/libs/arcade-core/arcade_core/network/__init__.py new file mode 100644 index 00000000..f2be2777 --- /dev/null +++ b/libs/arcade-core/arcade_core/network/__init__.py @@ -0,0 +1 @@ +# Network utilities shared across Arcade components. diff --git a/libs/arcade-core/arcade_core/network/org_transport.py b/libs/arcade-core/arcade_core/network/org_transport.py new file mode 100644 index 00000000..55bdb0e2 --- /dev/null +++ b/libs/arcade-core/arcade_core/network/org_transport.py @@ -0,0 +1,99 @@ +""" +Shared HTTP transport helpers for org-scoped Arcade API access. +""" + +from __future__ import annotations + +import httpx + + +def _rewrite_request_path(request: httpx.Request, org_id: str, project_id: str) -> httpx.Request: + """Return a request with its path rewritten to include org/project scope.""" + path = request.url.path + if path.startswith("/v1/") and "/v1/orgs/" not in path: + scoped_path = path.replace("/v1/", f"/v1/orgs/{org_id}/projects/{project_id}/", 1) + scoped_url = request.url.copy_with(path=scoped_path) + return httpx.Request( + method=request.method, + url=scoped_url, + headers=request.headers, + content=request.content, + extensions=request.extensions, + ) + return request + + +class OrgScopedTransport(httpx.BaseTransport): + """Sync transport that rewrites requests to include org/project scope.""" + + def __init__( + self, + wrapped_transport: httpx.BaseTransport, + org_id: str, + project_id: str, + ) -> None: + self.wrapped = wrapped_transport + self.org_id = org_id + self.project_id = project_id + + def handle_request(self, request: httpx.Request) -> httpx.Response: + scoped_request = _rewrite_request_path(request, self.org_id, self.project_id) + return self.wrapped.handle_request(scoped_request) + + def close(self) -> None: + self.wrapped.close() + + +class AsyncOrgScopedTransport(httpx.AsyncBaseTransport): + """Async transport that rewrites requests to include org/project scope.""" + + def __init__( + self, + wrapped_transport: httpx.AsyncBaseTransport, + org_id: str, + project_id: str, + ) -> None: + self.wrapped = wrapped_transport + self.org_id = org_id + self.project_id = project_id + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + scoped_request = _rewrite_request_path(request, self.org_id, self.project_id) + return await self.wrapped.handle_async_request(scoped_request) + + async def aclose(self) -> None: + await self.wrapped.aclose() + + +def build_org_scoped_http_client( + org_id: str, + project_id: str, + *, + base_transport: httpx.BaseTransport | None = None, + client_kwargs: dict | None = None, +) -> httpx.Client: + """ + Build a sync httpx.Client that rewrites /v1 requests with org/project scope. + """ + client_kwargs = client_kwargs or {} + transport = OrgScopedTransport( + base_transport or httpx.HTTPTransport(), org_id=org_id, project_id=project_id + ) + return httpx.Client(transport=transport, **client_kwargs) + + +def build_org_scoped_async_http_client( + org_id: str, + project_id: str, + *, + base_transport: httpx.AsyncBaseTransport | None = None, + client_kwargs: dict | None = None, +) -> httpx.AsyncClient: + """ + Build an async httpx.AsyncClient that rewrites /v1 requests with org/project scope. + """ + client_kwargs = client_kwargs or {} + transport = AsyncOrgScopedTransport( + base_transport or httpx.AsyncHTTPTransport(), org_id=org_id, project_id=project_id + ) + return httpx.AsyncClient(transport=transport, **client_kwargs) diff --git a/libs/arcade-core/arcade_core/usage/identity.py b/libs/arcade-core/arcade_core/usage/identity.py index 6d75bfe2..4418f1ca 100644 --- a/libs/arcade-core/arcade_core/usage/identity.py +++ b/libs/arcade-core/arcade_core/usage/identity.py @@ -124,34 +124,68 @@ class UsageIdentity: return str(data[KEY_ANON_ID]) def get_principal_id(self) -> str | None: - """Fetch principal_id from Arcade Cloud API. + """Fetch principal_id (account_id) from Arcade Cloud API. + + Prefers any linked principal already stored in usage.json. If not + found, attempts to fetch via OAuth access token and falls + back to legacy API key validation if present. Returns: str | None: Principal ID if authenticated and API call succeeds, None otherwise """ + # Prefer already-linked principal_id in usage.json + data = self.load_or_create() + linked_principal_id = data.get(KEY_LINKED_PRINCIPAL_ID) + if linked_principal_id: + return str(linked_principal_id) + if not os.path.exists(CREDENTIALS_FILE_PATH): return None try: with open(CREDENTIALS_FILE_PATH) as f: - config = yaml.safe_load(f) + config = yaml.safe_load(f) or {} - cloud_config = config.get("cloud", {}) - api_key = cloud_config.get("api", {}).get("key") + cloud_config = config.get("cloud", {}) if isinstance(config, dict) else {} - if not api_key: - return None + # Determine coordinator/authority URL for auth calls + coordinator_url = cloud_config.get("coordinator_url") or "https://cloud.arcade.dev" + whoami_url = f"{coordinator_url}/api/v1/auth/whoami" + validate_url = f"{coordinator_url}/api/v1/auth/validate" - response = httpx.get( - "https://cloud.arcade.dev/api/v1/auth/validate", - headers={"accept": "application/json", "Authorization": f"Bearer {api_key}"}, - timeout=TIMEOUT_ARCADE_API, + # OAuth credentials: use access_token to call /whoami + auth_config = cloud_config.get("auth", {}) if isinstance(cloud_config, dict) else {} + access_token = auth_config.get("access_token") + if access_token: + response = httpx.get( + whoami_url, + headers={ + "accept": "application/json", + "Authorization": f"Bearer {access_token}", + }, + timeout=TIMEOUT_ARCADE_API, + ) + if response.status_code == 200: + data = response.json().get("data", {}) + principal_id = data.get("account_id") or data.get("principal_id") + if principal_id: + return str(principal_id) + + # Legacy API key credentials (deprecated) + api_key = ( + cloud_config.get("api", {}).get("key") if isinstance(cloud_config, dict) else None ) - - if response.status_code == 200: - data = response.json() - principal_id = data.get("data", {}).get("principal_id") - return str(principal_id) if principal_id else None + if api_key: + response = httpx.get( + validate_url, + headers={"accept": "application/json", "Authorization": f"Bearer {api_key}"}, + timeout=TIMEOUT_ARCADE_API, + ) + if response.status_code == 200: + data = response.json() + principal_id = data.get("data", {}).get("principal_id") + if principal_id: + return str(principal_id) except Exception: # noqa: S110 # Silent failure - don't disrupt CLI diff --git a/libs/arcade-core/pyproject.toml b/libs/arcade-core/pyproject.toml index 7f5b8293..7a8c5f2b 100644 --- a/libs/arcade-core/pyproject.toml +++ b/libs/arcade-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-core" -version = "3.4.0" +version = "4.0.0" description = "Arcade Core - Core library for Arcade platform" readme = "README.md" license = {text = "MIT"} @@ -24,6 +24,7 @@ dependencies = [ "loguru>=0.7.0", "pyjwt>=2.8.0", "toml>=0.10.2", + "httpx>=0.27.0", "packaging>=24.1", "types-python-dateutil==2.9.0.20241003", "types-pytz==2024.2.0.20241003", diff --git a/libs/arcade-mcp-server/arcade_mcp_server/server.py b/libs/arcade-mcp-server/arcade_mcp_server/server.py index e7342133..6ce1ccdd 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/server.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/server.py @@ -19,8 +19,10 @@ import logging import os from typing import Any, Callable, cast +from arcade_core.auth_tokens import get_valid_access_token from arcade_core.catalog import MaterializedTool, ToolCatalog from arcade_core.executor import ToolExecutor +from arcade_core.network.org_transport import build_org_scoped_async_http_client from arcade_core.schema import ToolAuthorizationContext, ToolContext from arcade_core.schema import ToolAuthRequirement as CoreToolAuthRequirement from arcadepy import ArcadeError, AsyncArcade @@ -216,31 +218,49 @@ class MCPServer: self._handlers = self._register_handlers() def _load_config_values(self) -> tuple[str | None, str | None]: - """Load API key and user_id from credentials file. + """Load access token and user_id from credentials file. Returns: - Tuple of (api_key, user_id) from credentials file, or (None, None) if not available + Tuple of (access_token, user_id) from credentials file, or (None, None) if not available """ try: from arcade_core.config import config - api_key = config.api.key if config.api else None + access_token = get_valid_access_token() user_id = config.user.email if config.user else None - if api_key or user_id: + if access_token or user_id: config_path = config.get_config_file_path() - if api_key: - logger.info(f"Loaded Arcade API key from {config_path}") + if access_token: + logger.info(f"Loaded Arcade access token from {config_path}") if user_id: logger.debug(f"Loaded user_id '{user_id}' from {config_path}") - return api_key, user_id + return access_token, user_id else: - logger.debug("No API key or user_id found in credentials file") + logger.debug( + "No access token or user_id found in credentials file. If this is unexpected, run 'arcade login' to authenticate." + ) return None, None except Exception as e: logger.debug(f"Could not load values from credentials file: {e}") return None, None + def _load_org_project_context(self) -> tuple[str, str] | None: + """ + Load org/project context from the shared Arcade config (same source as the CLI). + Returns (org_id, project_id) when both are available; otherwise None. + """ + try: + from arcade_core.config import config + + context = getattr(config, "context", None) + if context and context.org_id and context.project_id: + return context.org_id, context.project_id + logger.debug("Org/project context not found in arcade_core.config") + except Exception as e: + logger.debug(f"Could not load org/project context from config: {e}") + return None + def _init_arcade_client(self, api_key: str | None, api_url: str | None) -> None: """Initialize Arcade client for runtime authorization.""" self.arcade: AsyncArcade | None = None @@ -257,10 +277,31 @@ class MCPServer: if final_api_key: logger.info(f"Using Arcade client with API URL: {api_url}") - self.arcade = AsyncArcade(api_key=final_api_key, base_url=api_url) + client_kwargs: dict[str, Any] = {"api_key": final_api_key, "base_url": api_url} + + # Non-service keys need org/project URL rewriting + if not final_api_key.startswith("arc_"): + context = self._load_org_project_context() + if context: + org_id, project_id = context + client_kwargs["http_client"] = build_org_scoped_async_http_client( + org_id, project_id + ) + logger.info( + "Configured org-scoped Arcade client for org '%s' project '%s'", + org_id, + project_id, + ) + else: + logger.warning( + "Expected to find org/project context in arcade_core.config but no org/project context " + "was found; using non-scoped Arcade client." + ) + + self.arcade = AsyncArcade(**client_kwargs) else: logger.warning( - "Arcade API key not configured. Tools requiring auth will return a login instruction." + "Arcade access token not configured. Tools requiring auth will return a login instruction." ) def _init_middleware(self, custom_middleware: list[Middleware] | None) -> None: diff --git a/libs/arcade-mcp-server/pyproject.toml b/libs/arcade-mcp-server/pyproject.toml index 531eed34..1537bd0f 100644 --- a/libs/arcade-mcp-server/pyproject.toml +++ b/libs/arcade-mcp-server/pyproject.toml @@ -21,9 +21,9 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "arcade-core>=3.4.0,<4.0.0", - "arcade-serve>=3.1.5,<4.0.0", - "arcade-tdk>=3.2.2,<4.0.0", + "arcade-core>=4.0.0,<5.0.0", + "arcade-serve>=3.2.0,<4.0.0", + "arcade-tdk>=3.3.0,<4.0.0", "arcadepy>=1.5.0", "pydantic>=2.0.0", "fastapi>=0.100.0", diff --git a/libs/arcade-serve/pyproject.toml b/libs/arcade-serve/pyproject.toml index 41fffb88..7092f2dc 100644 --- a/libs/arcade-serve/pyproject.toml +++ b/libs/arcade-serve/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-serve" -version = "3.1.5" +version = "3.2.0" description = "Arcade Serve - Serving infrastructure for Arcade tools and workers" readme = "README.md" license = {text = "MIT"} @@ -19,7 +19,7 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "arcade-core>=3.4.0,<4.0.0", + "arcade-core>=4.0.0,<5.0.0", "fastapi>=0.115.3", "uvicorn>=0.30.0", "watchfiles>=1.0.5", diff --git a/libs/arcade-tdk/pyproject.toml b/libs/arcade-tdk/pyproject.toml index f85730b3..e8a8e6d2 100644 --- a/libs/arcade-tdk/pyproject.toml +++ b/libs/arcade-tdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-tdk" -version = "3.2.2" +version = "3.3.0" description = "Arcade TDK - Toolkit Development Kit for building Arcade tools" readme = "README.md" license = {text = "MIT"} @@ -19,7 +19,7 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "arcade-core>=3.4.0,<4.0.0", + "arcade-core>=4.0.0,<5.0.0", "pydantic>=2.7.0", ] diff --git a/libs/tests/cli/test_dashboard.py b/libs/tests/cli/test_dashboard.py index 20298009..fcb1cd58 100644 --- a/libs/tests/cli/test_dashboard.py +++ b/libs/tests/cli/test_dashboard.py @@ -1,7 +1,7 @@ from unittest.mock import MagicMock, patch import pytest -from arcade_cli.constants import PROD_ENGINE_HOST +from arcade_core.constants import PROD_ENGINE_HOST from arcade_cli.main import cli from typer.testing import CliRunner @@ -24,7 +24,7 @@ def test_dashboard_url_construction(args, expected_url): """Test that the dashboard command constructs the correct URL with various args.""" with ( patch("webbrowser.open") as mock_open, - patch("arcade_cli.main.validate_and_get_config") as mock_validate, + patch("arcade_cli.utils.validate_and_get_config") as mock_validate, patch("arcade_cli.main.log_engine_health") as mock_health_check, ): # Setup mocks @@ -44,7 +44,7 @@ def test_fallback_when_browser_fails(): """Test fallback message when browser.open fails.""" with ( patch("webbrowser.open") as mock_open, - patch("arcade_cli.main.validate_and_get_config") as mock_validate, + patch("arcade_cli.utils.validate_and_get_config") as mock_validate, patch("arcade_cli.main.log_engine_health") as mock_health_check, patch("arcade_cli.main.console.print") as mock_print, ): @@ -65,7 +65,7 @@ def test_health_check_success(): """Test successful health check.""" with ( patch("webbrowser.open") as mock_open, - patch("arcade_cli.main.validate_and_get_config") as mock_validate, + patch("arcade_cli.utils.validate_and_get_config") as mock_validate, patch("arcade_cli.main.log_engine_health") as mock_health_check, ): mock_open.return_value = True diff --git a/libs/tests/cli/test_secret.py b/libs/tests/cli/test_secret.py index 935b2510..3ce78167 100644 --- a/libs/tests/cli/test_secret.py +++ b/libs/tests/cli/test_secret.py @@ -5,10 +5,10 @@ from unittest.mock import MagicMock, patch import httpx import pytest from arcade_cli.secret import ( - _delete_secret_from_engine, - _get_secrets_from_engine, + _delete_secret, + _get_secrets, _remove_inline_comment, - _upsert_secret_to_engine, + _upsert_secret, load_env_file, print_secret_table, ) @@ -144,46 +144,52 @@ class TestRemoveInlineComment: class TestUpsertSecretToEngine: - """Tests for _upsert_secret_to_engine function.""" + """Tests for _upsert_secret function.""" + @patch("arcade_cli.secret.get_auth_headers") + @patch("arcade_cli.secret.get_org_scoped_url") @patch("arcade_cli.secret.httpx.put") - def test_upsert_secret_success(self, mock_put): + def test_upsert_secret_success(self, mock_put, mock_get_url, mock_get_headers): """Test successful secret upsert.""" mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_put.return_value = mock_response + mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/SECRET_KEY" + mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"} - _upsert_secret_to_engine( - "https://api.example.com", "test-api-key", "SECRET_KEY", "secret-value" - ) + _upsert_secret("SECRET_KEY", "secret-value") mock_put.assert_called_once_with( - "https://api.example.com/v1/admin/secrets/SECRET_KEY", + "https://api.example.com/v1/org/test-org/secrets/SECRET_KEY", headers={"Authorization": "Bearer test-api-key"}, json={"description": "Secret set via CLI", "value": "secret-value"}, ) mock_response.raise_for_status.assert_called_once() + @patch("arcade_cli.secret.get_auth_headers") + @patch("arcade_cli.secret.get_org_scoped_url") @patch("arcade_cli.secret.httpx.put") - def test_upsert_secret_http_error(self, mock_put): + def test_upsert_secret_http_error(self, mock_put, mock_get_url, mock_get_headers): """Test secret upsert with HTTP error.""" mock_response = MagicMock() mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Bad Request", request=MagicMock(), response=MagicMock() ) mock_put.return_value = mock_response + mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/SECRET_KEY" + mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"} with pytest.raises(httpx.HTTPStatusError): - _upsert_secret_to_engine( - "https://api.example.com", "test-api-key", "SECRET_KEY", "secret-value" - ) + _upsert_secret("SECRET_KEY", "secret-value") class TestGetSecretsFromEngine: - """Tests for _get_secrets_from_engine function.""" + """Tests for _get_secrets function.""" + @patch("arcade_cli.secret.get_auth_headers") + @patch("arcade_cli.secret.get_org_scoped_url") @patch("arcade_cli.secret.httpx.get") - def test_get_secrets_success(self, mock_get): + def test_get_secrets_success(self, mock_get, mock_get_url, mock_get_headers): """Test successful secrets retrieval.""" mock_response = MagicMock() mock_response.raise_for_status.return_value = None @@ -194,58 +200,72 @@ class TestGetSecretsFromEngine: ] } mock_get.return_value = mock_response + mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets" + mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"} - secrets = _get_secrets_from_engine("https://api.example.com", "test-api-key") + secrets = _get_secrets() assert secrets == [ {"key": "SECRET1", "id": "id1"}, {"key": "SECRET2", "id": "id2"}, ] mock_get.assert_called_once_with( - "https://api.example.com/v1/admin/secrets", + "https://api.example.com/v1/org/test-org/secrets", headers={"Authorization": "Bearer test-api-key"}, ) mock_response.raise_for_status.assert_called_once() + @patch("arcade_cli.secret.get_auth_headers") + @patch("arcade_cli.secret.get_org_scoped_url") @patch("arcade_cli.secret.httpx.get") - def test_get_secrets_http_error(self, mock_get): + def test_get_secrets_http_error(self, mock_get, mock_get_url, mock_get_headers): """Test secrets retrieval with HTTP error.""" mock_response = MagicMock() mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Unauthorized", request=MagicMock(), response=MagicMock() ) mock_get.return_value = mock_response + mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets" + mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"} with pytest.raises(httpx.HTTPStatusError): - _get_secrets_from_engine("https://api.example.com", "test-api-key") + _get_secrets() class TestDeleteSecretFromEngine: - """Tests for _delete_secret_from_engine function.""" + """Tests for _delete_secret function.""" + @patch("arcade_cli.secret.get_auth_headers") + @patch("arcade_cli.secret.get_org_scoped_url") @patch("arcade_cli.secret.httpx.delete") - def test_delete_secret_success(self, mock_delete): + def test_delete_secret_success(self, mock_delete, mock_get_url, mock_get_headers): """Test successful secret deletion.""" mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_delete.return_value = mock_response + mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/secret-id-123" + mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"} - _delete_secret_from_engine("https://api.example.com", "test-api-key", "secret-id-123") + _delete_secret("secret-id-123") mock_delete.assert_called_once_with( - "https://api.example.com/v1/admin/secrets/secret-id-123", + "https://api.example.com/v1/org/test-org/secrets/secret-id-123", headers={"Authorization": "Bearer test-api-key"}, ) mock_response.raise_for_status.assert_called_once() + @patch("arcade_cli.secret.get_auth_headers") + @patch("arcade_cli.secret.get_org_scoped_url") @patch("arcade_cli.secret.httpx.delete") - def test_delete_secret_http_error(self, mock_delete): + def test_delete_secret_http_error(self, mock_delete, mock_get_url, mock_get_headers): """Test secret deletion with HTTP error.""" mock_response = MagicMock() mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Not Found", request=MagicMock(), response=MagicMock() ) mock_delete.return_value = mock_response + mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/secret-id-123" + mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"} with pytest.raises(httpx.HTTPStatusError): - _delete_secret_from_engine("https://api.example.com", "test-api-key", "secret-id-123") + _delete_secret("secret-id-123") diff --git a/libs/tests/cli/test_utils.py b/libs/tests/cli/test_utils.py index e67d8ad7..a51bc53b 100644 --- a/libs/tests/cli/test_utils.py +++ b/libs/tests/cli/test_utils.py @@ -1,5 +1,5 @@ import pytest -from arcade_cli.utils import Provider, compute_base_url, compute_login_url, resolve_provider_api_key +from arcade_cli.utils import Provider, compute_base_url, resolve_provider_api_key DEFAULT_CLOUD_HOST = "cloud.arcade.dev" DEFAULT_ENGINE_HOST = "api.arcade.dev" @@ -195,50 +195,6 @@ def test_compute_base_url(inputs: dict, expected_output: str): assert base_url == expected_output -@pytest.mark.parametrize( - "inputs, expected_output", - [ - pytest.param( - {"host_input": DEFAULT_CLOUD_HOST, "port_input": DEFAULT_PORT, "state": "123"}, - "https://cloud.arcade.dev/api/v1/auth/cli_login?callback_uri=http%3A%2F%2Flocalhost%3A9905%2Fcallback&state=123", - id="default", - ), - pytest.param( - {"host_input": "localhost", "port_input": 9099, "state": "123"}, - "http://localhost:9099/api/v1/auth/cli_login?callback_uri=http%3A%2F%2Flocalhost%3A9905%2Fcallback&state=123", - id="localhost with custom port", - ), - pytest.param( - {"host_input": "localhost", "port_input": DEFAULT_PORT, "state": "123"}, - "http://localhost:8000/api/v1/auth/cli_login?callback_uri=http%3A%2F%2Flocalhost%3A9905%2Fcallback&state=123", - id="localhost", - ), - pytest.param( - {"host_input": DEFAULT_CLOUD_HOST, "port_input": 8000, "state": "123"}, - "https://cloud.arcade.dev/api/v1/auth/cli_login?callback_uri=http%3A%2F%2Flocalhost%3A9905%2Fcallback&state=123", - id="cloud host with an ignored custom port", - ), - pytest.param( - { - "host_input": DEFAULT_CLOUD_HOST, - "port_input": DEFAULT_PORT, - "state": "123", - "callback_host": "other-host.com/123", - }, - "https://cloud.arcade.dev/api/v1/auth/cli_login?callback_uri=http%3A%2F%2Fother-host.com%2F123%2Fcallback&state=123", - id="cloud host with a custom callback host", - ), - ], -) -def test_compute_login_url(inputs: dict, expected_output: str): - callback_host = inputs.get("callback_host") - login_url = compute_login_url( - inputs["host_input"], inputs["state"], inputs["port_input"], callback_host - ) - - assert login_url == expected_output - - def test_resolve_provider_api_key(monkeypatch): resolved_api_key = resolve_provider_api_key(Provider.OPENAI, "123") assert resolved_api_key == "123" diff --git a/libs/tests/cli/usage/test_identity.py b/libs/tests/cli/usage/test_identity.py index 91f4108f..1dd6e64e 100644 --- a/libs/tests/cli/usage/test_identity.py +++ b/libs/tests/cli/usage/test_identity.py @@ -253,6 +253,30 @@ class TestGetPrincipalId: assert principal_id is None + @patch("httpx.get") + def test_returns_account_id_from_oauth_whoami( + self, mock_get: MagicMock, identity: UsageIdentity, temp_config_path: Path + ) -> None: + """Test that get_principal_id returns account_id using OAuth access token.""" + credentials_file = temp_config_path / "credentials.yaml" + credentials_file.write_text( + yaml.dump({"cloud": {"auth": {"access_token": "oauth-token", "refresh_token": "x"}}}) + ) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {"account_id": "acct-123"}} + mock_get.return_value = mock_response + + principal_id = identity.get_principal_id() + + assert principal_id == "acct-123" + mock_get.assert_called_once_with( + "https://cloud.arcade.dev/api/v1/auth/whoami", + headers={"accept": "application/json", "Authorization": "Bearer oauth-token"}, + timeout=2.0, + ) + class TestShouldAlias: """Tests for should_alias() method.""" diff --git a/pyproject.toml b/pyproject.toml index 497fb1ce..de00c1b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-mcp" -version = "1.5.9" +version = "1.6.0" description = "Arcade.dev - Tool Calling platform for Agents" readme = "README.md" license = {file = "LICENSE"} @@ -21,11 +21,12 @@ requires-python = ">=3.10" dependencies = [ # CLI dependencies - "arcade-mcp-server>=1.11.1,<2.0.0", - "arcade-core>=3.4.0,<4.0.0", + "arcade-mcp-server>=1.12.0,<2.0.0", + "arcade-core>=4.0.0,<5.0.0", "typer==0.10.0", "rich==13.9.4", "Jinja2==3.1.6", + "authlib==1.3.0", "arcadepy==1.8.0", "tqdm==4.67.1", "openai==1.82.1", @@ -42,11 +43,11 @@ all = [ "pytz>=2024.1", "python-dateutil>=2.8.2", # mcp - "arcade-mcp-server>=1.11.1,<2.0.0", + "arcade-mcp-server>=1.12.0,<2.0.0", # serve - "arcade-serve>=3.1.5,<4.0.0", + "arcade-serve>=3.2.0,<4.0.0", # tdk - "arcade-tdk>=3.2.2,<4.0.0", + "arcade-tdk>=3.3.0,<4.0.0", ] # Evals also depends on arcade-core and openai, but they are already required deps evals = [ @@ -65,6 +66,7 @@ dev-dependencies = [ "mypy>=1.5.1", "pre-commit>=3.4.0", "ruff>=0.4.0", + "types-Authlib>=1.3.0", "types-PyYAML>=6.0.0", "types-python-dateutil>=2.8.2", "types-pytz>=2024.1", @@ -92,6 +94,10 @@ packages = [ "libs/arcade-evals/arcade_evals", ] +# Include Jinja templates in the CLI package +[tool.hatch.build.targets.wheel.force-include] +"libs/arcade-cli/arcade_cli/templates" = "arcade_cli/templates" + [tool.uv.workspace] members = [ "libs/arcade-core",