diff --git a/lib/cli/src/crewai_cli/config.py b/lib/cli/src/crewai_cli/config.py index 40a0db412..7c72c3347 100644 --- a/lib/cli/src/crewai_cli/config.py +++ b/lib/cli/src/crewai_cli/config.py @@ -1,221 +1,18 @@ -import json -from logging import getLogger -from pathlib import Path -import tempfile -from typing import Any +"""Re-exports of shared settings from ``crewai_core.settings``. -from pydantic import BaseModel, Field +Kept as a stable import path for the CLI; new code should import from +``crewai_core.settings`` directly. +""" -from crewai_cli.constants import ( - CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE, - CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID, - CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN, - CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER, - DEFAULT_CREWAI_ENTERPRISE_URL, +from __future__ import annotations + +from crewai_core.settings import ( + CLI_SETTINGS_KEYS as CLI_SETTINGS_KEYS, + DEFAULT_CLI_SETTINGS as DEFAULT_CLI_SETTINGS, + DEFAULT_CONFIG_PATH as DEFAULT_CONFIG_PATH, + HIDDEN_SETTINGS_KEYS as HIDDEN_SETTINGS_KEYS, + READONLY_SETTINGS_KEYS as READONLY_SETTINGS_KEYS, + USER_SETTINGS_KEYS as USER_SETTINGS_KEYS, + Settings as Settings, + get_writable_config_path as get_writable_config_path, ) -from crewai_cli.shared.token_manager import TokenManager - - -logger = getLogger(__name__) - -DEFAULT_CONFIG_PATH = Path.home() / ".config" / "crewai" / "settings.json" - - -def get_writable_config_path() -> Path | None: - """ - Find a writable location for the config file with fallback options. - - Tries in order: - 1. Default: ~/.config/crewai/settings.json - 2. Temp directory: /tmp/crewai_settings.json (or OS equivalent) - 3. Current directory: ./crewai_settings.json - 4. In-memory only (returns None) - - Returns: - Path object for writable config location, or None if no writable location found - """ - fallback_paths = [ - DEFAULT_CONFIG_PATH, # Default location - Path(tempfile.gettempdir()) / "crewai_settings.json", # Temporary directory - Path.cwd() / "crewai_settings.json", # Current working directory - ] - - for config_path in fallback_paths: - try: - config_path.parent.mkdir(parents=True, exist_ok=True) - test_file = config_path.parent / ".crewai_write_test" - try: - test_file.write_text("test") - test_file.unlink() # Clean up test file - logger.info(f"Using config path: {config_path}") - return config_path - except Exception: # noqa: S112 - continue - - except Exception: # noqa: S112 - continue - - return None - - -# Settings that are related to the user's account -USER_SETTINGS_KEYS = [ - "tool_repository_username", - "tool_repository_password", - "org_name", - "org_uuid", -] - -# Settings that are related to the CLI -CLI_SETTINGS_KEYS = [ - "enterprise_base_url", - "oauth2_provider", - "oauth2_audience", - "oauth2_client_id", - "oauth2_domain", - "oauth2_extra", -] - -# Default values for CLI settings -DEFAULT_CLI_SETTINGS: dict[str, Any] = { - "enterprise_base_url": DEFAULT_CREWAI_ENTERPRISE_URL, - "oauth2_provider": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER, - "oauth2_audience": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE, - "oauth2_client_id": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID, - "oauth2_domain": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN, - "oauth2_extra": {}, -} - -# Readonly settings - cannot be set by the user -READONLY_SETTINGS_KEYS = [ - "org_name", - "org_uuid", -] - -# Hidden settings - not displayed by the 'list' command and cannot be set by the user -HIDDEN_SETTINGS_KEYS = [ - "config_path", - "tool_repository_username", - "tool_repository_password", -] - - -class Settings(BaseModel): - enterprise_base_url: str | None = Field( - default=DEFAULT_CLI_SETTINGS["enterprise_base_url"], - description="Base URL of the CrewAI AMP instance", - ) - tool_repository_username: str | None = Field( - None, description="Username for interacting with the Tool Repository" - ) - tool_repository_password: str | None = Field( - None, description="Password for interacting with the Tool Repository" - ) - org_name: str | None = Field( - None, description="Name of the currently active organization" - ) - org_uuid: str | None = Field( - None, description="UUID of the currently active organization" - ) - config_path: Path = Field(default=DEFAULT_CONFIG_PATH, frozen=True, exclude=True) - - oauth2_provider: str = Field( - description="OAuth2 provider used for authentication (e.g., workos, okta, auth0).", - default=DEFAULT_CLI_SETTINGS["oauth2_provider"], - ) - - oauth2_audience: str | None = Field( - description="OAuth2 audience value, typically used to identify the target API or resource.", - default=DEFAULT_CLI_SETTINGS["oauth2_audience"], - ) - - oauth2_client_id: str = Field( - default=DEFAULT_CLI_SETTINGS["oauth2_client_id"], - description="OAuth2 client ID issued by the provider, used during authentication requests.", - ) - - oauth2_domain: str = Field( - description="OAuth2 provider's domain (e.g., your-org.auth0.com) used for issuing tokens.", - default=DEFAULT_CLI_SETTINGS["oauth2_domain"], - ) - - oauth2_extra: dict[str, Any] = Field( - description="Extra configuration for the OAuth2 provider.", - default={}, - ) - - def __init__(self, config_path: Path | None = None, **data: dict[str, Any]) -> None: - """Load Settings from config path with fallback support""" - if config_path is None: - config_path = get_writable_config_path() - - # If config_path is None, we're in memory-only mode - if config_path is None: - merged_data = {**data} - # Dummy path for memory-only mode - super().__init__(config_path=Path("/dev/null"), **merged_data) - return - - try: - config_path.parent.mkdir(parents=True, exist_ok=True) - except Exception: - merged_data = {**data} - # Dummy path for memory-only mode - super().__init__(config_path=Path("/dev/null"), **merged_data) - return - - file_data = {} - if config_path.is_file(): - try: - with config_path.open("r") as f: - file_data = json.load(f) - except Exception: - file_data = {} - - merged_data = {**file_data, **data} - super().__init__(config_path=config_path, **merged_data) - - def clear_user_settings(self) -> None: - """Clear all user settings""" - self._reset_user_settings() - self.dump() - - def reset(self) -> None: - """Reset all settings to default values""" - self._reset_user_settings() - self._reset_cli_settings() - self._clear_auth_tokens() - self.dump() - - def dump(self) -> None: - """Save current settings to settings.json""" - if str(self.config_path) == "/dev/null": - return - - try: - if self.config_path.is_file(): - with self.config_path.open("r") as f: - existing_data = json.load(f) - else: - existing_data = {} - - updated_data = {**existing_data, **self.model_dump(exclude_unset=True)} - with self.config_path.open("w") as f: - json.dump(updated_data, f, indent=4) - - except Exception: # noqa: S110 - pass - - def _reset_user_settings(self) -> None: - """Reset all user settings to default values""" - for key in USER_SETTINGS_KEYS: - setattr(self, key, None) - - def _reset_cli_settings(self) -> None: - """Reset all CLI settings to default values""" - for key in CLI_SETTINGS_KEYS: - setattr(self, key, DEFAULT_CLI_SETTINGS.get(key)) - - def _clear_auth_tokens(self) -> None: - """Clear all authentication tokens""" - TokenManager().clear_tokens() diff --git a/lib/cli/src/crewai_cli/shared/token_manager.py b/lib/cli/src/crewai_cli/shared/token_manager.py index 02c176924..b1f1c91b3 100644 --- a/lib/cli/src/crewai_cli/shared/token_manager.py +++ b/lib/cli/src/crewai_cli/shared/token_manager.py @@ -1,186 +1,15 @@ -from datetime import datetime -import json -import os -from pathlib import Path -import sys -import tempfile -from typing import Final, Literal, cast +"""Deprecated: use ``crewai_core.token_manager`` instead.""" -from cryptography.fernet import Fernet +from __future__ import annotations + +import warnings + +from crewai_core.token_manager import TokenManager as TokenManager -_FERNET_KEY_LENGTH: Final[Literal[44]] = 44 - - -class TokenManager: - """Manages encrypted token storage.""" - - def __init__(self, file_path: str = "tokens.enc") -> None: - """Initialize the TokenManager. - - Args: - file_path: The file path to store encrypted tokens. - """ - self.file_path = file_path - self.key = self._get_or_create_key() - self.fernet = Fernet(self.key) - - def _get_or_create_key(self) -> bytes: - """Get or create the encryption key. - - Returns: - The encryption key as bytes. - """ - key_filename: str = "secret.key" - - key = self._read_secure_file(key_filename) - if key is not None and len(key) == _FERNET_KEY_LENGTH: - return key - - new_key = Fernet.generate_key() - if self._atomic_create_secure_file(key_filename, new_key): - return new_key - - key = self._read_secure_file(key_filename) - if key is not None and len(key) == _FERNET_KEY_LENGTH: - return key - - raise RuntimeError("Failed to create or read encryption key") - - def save_tokens(self, access_token: str, expires_at: int) -> None: - """Save the access token and its expiration time. - - Args: - access_token: The access token to save. - expires_at: The UNIX timestamp of the expiration time. - """ - expiration_time = datetime.fromtimestamp(expires_at) - data = { - "access_token": access_token, - "expiration": expiration_time.isoformat(), - } - encrypted_data = self.fernet.encrypt(json.dumps(data).encode()) - self._atomic_write_secure_file(self.file_path, encrypted_data) - - def get_token(self) -> str | None: - """Get the access token if it is valid and not expired. - - Returns: - The access token if valid and not expired, otherwise None. - """ - encrypted_data = self._read_secure_file(self.file_path) - if encrypted_data is None: - return None - - decrypted_data = self.fernet.decrypt(encrypted_data) - data = json.loads(decrypted_data) - - expiration = datetime.fromisoformat(data["expiration"]) - if expiration <= datetime.now(): - return None - - return cast(str | None, data.get("access_token")) - - def clear_tokens(self) -> None: - """Clear the stored tokens.""" - self._delete_secure_file(self.file_path) - - @staticmethod - def _get_secure_storage_path() -> Path: - """Get the secure storage path based on the operating system. - - Returns: - The secure storage path. - """ - if sys.platform == "win32": - base_path = os.environ.get("LOCALAPPDATA") - elif sys.platform == "darwin": - base_path = os.path.expanduser("~/Library/Application Support") - else: - base_path = os.path.expanduser("~/.local/share") - - app_name = "crewai/credentials" - storage_path = Path(base_path) / app_name - - storage_path.mkdir(parents=True, exist_ok=True) - - return storage_path - - def _atomic_create_secure_file(self, filename: str, content: bytes) -> bool: - """Create a file only if it doesn't exist. - - Args: - filename: The name of the file. - content: The content to write. - - Returns: - True if file was created, False if it already exists. - """ - storage_path = self._get_secure_storage_path() - file_path = storage_path / filename - - try: - fd = os.open(file_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) - try: - os.write(fd, content) - finally: - os.close(fd) - return True - except FileExistsError: - return False - - def _atomic_write_secure_file(self, filename: str, content: bytes) -> None: - """Write content to a secure file. - - Args: - filename: The name of the file. - content: The content to write. - """ - storage_path = self._get_secure_storage_path() - file_path = storage_path / filename - - fd, temp_path = tempfile.mkstemp(dir=storage_path, prefix=f".{filename}.") - fd_closed = False - try: - os.write(fd, content) - os.close(fd) - fd_closed = True - os.chmod(temp_path, 0o600) - os.replace(temp_path, file_path) - except Exception: - if not fd_closed: - os.close(fd) - if os.path.exists(temp_path): - os.unlink(temp_path) - raise - - def _read_secure_file(self, filename: str) -> bytes | None: - """Read the content of a secure file. - - Args: - filename: The name of the file. - - Returns: - The content of the file if it exists, otherwise None. - """ - storage_path = self._get_secure_storage_path() - file_path = storage_path / filename - - try: - with open(file_path, "rb") as f: - return f.read() - except FileNotFoundError: - return None - - def _delete_secure_file(self, filename: str) -> None: - """Delete a secure file. - - Args: - filename: The name of the file. - """ - storage_path = self._get_secure_storage_path() - file_path = storage_path / filename - try: - file_path.unlink() - except FileNotFoundError: - pass +warnings.warn( + "crewai_cli.shared.token_manager is deprecated; " + "import from crewai_core.token_manager.", + DeprecationWarning, + stacklevel=2, +) diff --git a/lib/cli/src/crewai_cli/utils.py b/lib/cli/src/crewai_cli/utils.py index 50b36d486..fb344ed0c 100644 --- a/lib/cli/src/crewai_cli/utils.py +++ b/lib/cli/src/crewai_cli/utils.py @@ -15,9 +15,12 @@ from crewai_core.project import ( parse_toml as parse_toml, read_toml as read_toml, ) +from crewai_core.tool_credentials import ( + build_env_with_all_tool_credentials as build_env_with_all_tool_credentials, + build_env_with_tool_repository_credentials as build_env_with_tool_repository_credentials, +) from rich.console import Console -from crewai_cli.config import Settings from crewai_cli.constants import ENV_VARS @@ -154,46 +157,3 @@ def write_env_file(folder_path: Path, env_vars: dict[str, Any]) -> None: with open(env_file_path, "w") as file: for key, value in env_vars.items(): file.write(f"{key.upper()}={value}\n") - - -def build_env_with_tool_repository_credentials( - repository_handle: str, -) -> dict[str, Any]: - repository_handle = repository_handle.upper().replace("-", "_") - settings = Settings() - - env = os.environ.copy() - env[f"UV_INDEX_{repository_handle}_USERNAME"] = str( - settings.tool_repository_username or "" - ) - env[f"UV_INDEX_{repository_handle}_PASSWORD"] = str( - settings.tool_repository_password or "" - ) - - return env - - -def build_env_with_all_tool_credentials() -> dict[str, Any]: - """Build environment dict with credentials for all tool repository indexes. - - Reads ``[tool.uv.sources]`` from ``pyproject.toml`` and merges credentials - for each private index into a copy of the current environment. - - Returns: - Environment variables with credentials for all private indexes. - """ - env = os.environ.copy() - try: - pyproject_data = read_toml() - sources = pyproject_data.get("tool", {}).get("uv", {}).get("sources", {}) - - for source_config in sources.values(): - if isinstance(source_config, dict): - index = source_config.get("index") - if index: - index_env = build_env_with_tool_repository_credentials(index) - env.update(index_env) - except Exception: # noqa: S110 - pass - - return env diff --git a/lib/cli/tests/test_config.py b/lib/cli/tests/test_config.py index 46b4a6c81..b8e5ba989 100644 --- a/lib/cli/tests/test_config.py +++ b/lib/cli/tests/test_config.py @@ -12,7 +12,7 @@ from crewai_cli.config import ( USER_SETTINGS_KEYS, Settings, ) -from crewai_cli.shared.token_manager import TokenManager +from crewai_core.token_manager import TokenManager class TestSettings(unittest.TestCase): @@ -69,7 +69,7 @@ class TestSettings(unittest.TestCase): for key in user_settings.keys(): self.assertEqual(getattr(settings, key), None) - @patch("crewai_cli.config.TokenManager") + @patch("crewai_core.settings.TokenManager") def test_reset_settings(self, mock_token_manager): user_settings = {key: f"value_for_{key}" for key in USER_SETTINGS_KEYS} cli_settings = {key: f"value_for_{key}" for key in CLI_SETTINGS_KEYS if key != "oauth2_extra"} diff --git a/lib/cli/tests/test_token_manager.py b/lib/cli/tests/test_token_manager.py index 84ca65889..2d03d8601 100644 --- a/lib/cli/tests/test_token_manager.py +++ b/lib/cli/tests/test_token_manager.py @@ -9,20 +9,20 @@ from unittest.mock import patch from cryptography.fernet import Fernet -from crewai_cli.shared.token_manager import TokenManager +from crewai_core.token_manager import TokenManager class TestTokenManager(unittest.TestCase): """Test cases for TokenManager.""" - @patch("crewai_cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def setUp(self, mock_get_key: unittest.mock.MagicMock) -> None: """Set up test fixtures.""" mock_get_key.return_value = Fernet.generate_key() self.token_manager = TokenManager() - @patch("crewai_cli.shared.token_manager.TokenManager._read_secure_file") - @patch("crewai_cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_get_or_create_key_existing( self, mock_get_or_create: unittest.mock.MagicMock, @@ -44,7 +44,7 @@ class TestTokenManager(unittest.TestCase): with ( patch.object(self.token_manager, "_read_secure_file", return_value=None) as mock_read, patch.object(self.token_manager, "_atomic_create_secure_file", return_value=True) as mock_atomic_create, - patch("crewai_cli.shared.token_manager.Fernet.generate_key", return_value=mock_key) as mock_generate, + patch("crewai_core.token_manager.Fernet.generate_key", return_value=mock_key) as mock_generate, ): result = self.token_manager._get_or_create_key() @@ -61,14 +61,14 @@ class TestTokenManager(unittest.TestCase): with ( patch.object(self.token_manager, "_read_secure_file", side_effect=[None, their_key]) as mock_read, patch.object(self.token_manager, "_atomic_create_secure_file", return_value=False) as mock_atomic_create, - patch("crewai_cli.shared.token_manager.Fernet.generate_key", return_value=our_key), + patch("crewai_core.token_manager.Fernet.generate_key", return_value=our_key), ): result = self.token_manager._get_or_create_key() self.assertEqual(result, their_key) self.assertEqual(mock_read.call_count, 2) - @patch("crewai_cli.shared.token_manager.TokenManager._atomic_write_secure_file") + @patch("crewai_core.token_manager.TokenManager._atomic_write_secure_file") def test_save_tokens( self, mock_write: unittest.mock.MagicMock ) -> None: @@ -87,7 +87,7 @@ class TestTokenManager(unittest.TestCase): expiration = datetime.fromisoformat(data["expiration"]) self.assertEqual(expiration, datetime.fromtimestamp(expires_at)) - @patch("crewai_cli.shared.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._read_secure_file") def test_get_token_valid( self, mock_read: unittest.mock.MagicMock ) -> None: @@ -102,7 +102,7 @@ class TestTokenManager(unittest.TestCase): self.assertEqual(result, access_token) - @patch("crewai_cli.shared.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._read_secure_file") def test_get_token_expired( self, mock_read: unittest.mock.MagicMock ) -> None: @@ -117,7 +117,7 @@ class TestTokenManager(unittest.TestCase): self.assertIsNone(result) - @patch("crewai_cli.shared.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._read_secure_file") def test_get_token_not_found( self, mock_read: unittest.mock.MagicMock ) -> None: @@ -128,7 +128,7 @@ class TestTokenManager(unittest.TestCase): self.assertIsNone(result) - @patch("crewai_cli.shared.token_manager.TokenManager._delete_secure_file") + @patch("crewai_core.token_manager.TokenManager._delete_secure_file") def test_clear_tokens( self, mock_delete: unittest.mock.MagicMock ) -> None: @@ -158,7 +158,7 @@ class TestAtomicFileOperations(unittest.TestCase): import shutil shutil.rmtree(self.temp_dir, ignore_errors=True) - @patch("crewai_cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_create_new_file( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -174,7 +174,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertEqual(file_path.read_bytes(), b"content") self.assertEqual(file_path.stat().st_mode & 0o777, 0o600) - @patch("crewai_cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_create_existing_file( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -191,7 +191,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertFalse(result) self.assertEqual(file_path.read_bytes(), b"original") - @patch("crewai_cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_write_new_file( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -206,7 +206,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertEqual(file_path.read_bytes(), b"content") self.assertEqual(file_path.stat().st_mode & 0o777, 0o600) - @patch("crewai_cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_write_overwrites( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -221,7 +221,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertEqual(file_path.read_bytes(), b"new content") - @patch("crewai_cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_write_no_temp_file_on_success( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -235,7 +235,7 @@ class TestAtomicFileOperations(unittest.TestCase): temp_files = list(Path(self.temp_dir).glob(".test.txt.*")) self.assertEqual(len(temp_files), 0) - @patch("crewai_cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_read_secure_file_exists( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -250,7 +250,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertEqual(result, b"content") - @patch("crewai_cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_read_secure_file_not_exists( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -262,7 +262,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertIsNone(result) - @patch("crewai_cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_delete_secure_file_exists( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -277,7 +277,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertFalse(file_path.exists()) - @patch("crewai_cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_delete_secure_file_not_exists( self, mock_get_key: unittest.mock.MagicMock ) -> None: diff --git a/lib/crewai-core/pyproject.toml b/lib/crewai-core/pyproject.toml index b38072479..f3d386dd5 100644 --- a/lib/crewai-core/pyproject.toml +++ b/lib/crewai-core/pyproject.toml @@ -9,11 +9,14 @@ authors = [ requires-python = ">=3.10, <3.14" dependencies = [ "appdirs~=1.4.4", + "cryptography>=42.0", "portalocker~=2.7.0", + "pydantic>=2.11.9,<2.13", "rich>=13.7.1", "opentelemetry-api~=1.34.0", "opentelemetry-sdk~=1.34.0", "opentelemetry-exporter-otlp-proto-http~=1.34.0", + "tomli~=2.0.2", ] [project.urls] diff --git a/lib/crewai-core/src/crewai_core/constants.py b/lib/crewai-core/src/crewai_core/constants.py index 06aeb69e0..20ae27c48 100644 --- a/lib/crewai-core/src/crewai_core/constants.py +++ b/lib/crewai-core/src/crewai_core/constants.py @@ -10,3 +10,13 @@ TRAINING_DATA_FILE: Final[str] = "training_data.pkl" TRAINED_AGENTS_DATA_FILE: Final[str] = "trained_agents_data.pkl" KNOWLEDGE_DIRECTORY: Final[str] = "knowledge" MAX_FILE_NAME_LENGTH: Final[int] = 255 + +DEFAULT_CREWAI_ENTERPRISE_URL: Final[str] = "https://app.crewai.com" +CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER: Final[str] = "workos" +CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE: Final[str] = ( + "client_01JNJQWBJ4SPFN3SWJM5T7BDG8" +) +CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID: Final[str] = ( + "client_01JYT06R59SP0NXYGD994NFXXX" +) +CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN: Final[str] = "login.crewai.com" diff --git a/lib/crewai-core/src/crewai_core/settings.py b/lib/crewai-core/src/crewai_core/settings.py new file mode 100644 index 000000000..083a9e259 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/settings.py @@ -0,0 +1,215 @@ +"""CrewAI platform settings — shared by crewai and crewai-cli.""" + +from __future__ import annotations + +import json +from logging import getLogger +from pathlib import Path +import tempfile +from typing import Any + +from pydantic import BaseModel, Field + +from crewai_core.constants import ( + CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE, + CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID, + CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN, + CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER, + DEFAULT_CREWAI_ENTERPRISE_URL, +) +from crewai_core.token_manager import TokenManager + + +logger = getLogger(__name__) + +DEFAULT_CONFIG_PATH = Path.home() / ".config" / "crewai" / "settings.json" + + +def get_writable_config_path() -> Path | None: + """Find a writable location for the config file with fallback options. + + Tries in order: + 1. Default: ``~/.config/crewai/settings.json`` + 2. Temp directory: ``/tmp/crewai_settings.json`` (or OS equivalent) + 3. Current directory: ``./crewai_settings.json`` + 4. In-memory only (returns ``None``) + """ + fallback_paths = [ + DEFAULT_CONFIG_PATH, + Path(tempfile.gettempdir()) / "crewai_settings.json", + Path.cwd() / "crewai_settings.json", + ] + + for config_path in fallback_paths: + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + test_file = config_path.parent / ".crewai_write_test" + try: + test_file.write_text("test") + test_file.unlink() + logger.info(f"Using config path: {config_path}") + return config_path + except Exception: # noqa: S112 + continue + + except Exception: # noqa: S112 + continue + + return None + + +USER_SETTINGS_KEYS = [ + "tool_repository_username", + "tool_repository_password", + "org_name", + "org_uuid", +] + +CLI_SETTINGS_KEYS = [ + "enterprise_base_url", + "oauth2_provider", + "oauth2_audience", + "oauth2_client_id", + "oauth2_domain", + "oauth2_extra", +] + +DEFAULT_CLI_SETTINGS: dict[str, Any] = { + "enterprise_base_url": DEFAULT_CREWAI_ENTERPRISE_URL, + "oauth2_provider": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER, + "oauth2_audience": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE, + "oauth2_client_id": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID, + "oauth2_domain": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN, + "oauth2_extra": {}, +} + +READONLY_SETTINGS_KEYS = [ + "org_name", + "org_uuid", +] + +HIDDEN_SETTINGS_KEYS = [ + "config_path", + "tool_repository_username", + "tool_repository_password", +] + + +class Settings(BaseModel): + """CrewAI platform settings persisted to ``~/.config/crewai/settings.json``.""" + + enterprise_base_url: str | None = Field( + default=DEFAULT_CLI_SETTINGS["enterprise_base_url"], + description="Base URL of the CrewAI AMP instance", + ) + tool_repository_username: str | None = Field( + None, description="Username for interacting with the Tool Repository" + ) + tool_repository_password: str | None = Field( + None, description="Password for interacting with the Tool Repository" + ) + org_name: str | None = Field( + None, description="Name of the currently active organization" + ) + org_uuid: str | None = Field( + None, description="UUID of the currently active organization" + ) + config_path: Path = Field(default=DEFAULT_CONFIG_PATH, frozen=True, exclude=True) + + oauth2_provider: str = Field( + description="OAuth2 provider used for authentication (e.g., workos, okta, auth0).", + default=DEFAULT_CLI_SETTINGS["oauth2_provider"], + ) + + oauth2_audience: str | None = Field( + description="OAuth2 audience value, typically used to identify the target API or resource.", + default=DEFAULT_CLI_SETTINGS["oauth2_audience"], + ) + + oauth2_client_id: str = Field( + default=DEFAULT_CLI_SETTINGS["oauth2_client_id"], + description="OAuth2 client ID issued by the provider, used during authentication requests.", + ) + + oauth2_domain: str = Field( + description="OAuth2 provider's domain (e.g., your-org.auth0.com) used for issuing tokens.", + default=DEFAULT_CLI_SETTINGS["oauth2_domain"], + ) + + oauth2_extra: dict[str, Any] = Field( + description="Extra configuration for the OAuth2 provider.", + default={}, + ) + + def __init__(self, config_path: Path | None = None, **data: dict[str, Any]) -> None: + """Load Settings from config path with fallback support.""" + if config_path is None: + config_path = get_writable_config_path() + + if config_path is None: + merged_data = {**data} + super().__init__(config_path=Path("/dev/null"), **merged_data) + return + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + except Exception: + merged_data = {**data} + super().__init__(config_path=Path("/dev/null"), **merged_data) + return + + file_data = {} + if config_path.is_file(): + try: + with config_path.open("r") as f: + file_data = json.load(f) + except Exception: + file_data = {} + + merged_data = {**file_data, **data} + super().__init__(config_path=config_path, **merged_data) + + def clear_user_settings(self) -> None: + """Clear all user settings.""" + self._reset_user_settings() + self.dump() + + def reset(self) -> None: + """Reset all settings to default values.""" + self._reset_user_settings() + self._reset_cli_settings() + self._clear_auth_tokens() + self.dump() + + def dump(self) -> None: + """Save current settings to settings.json.""" + if str(self.config_path) == "/dev/null": + return + + try: + if self.config_path.is_file(): + with self.config_path.open("r") as f: + existing_data = json.load(f) + else: + existing_data = {} + + updated_data = {**existing_data, **self.model_dump(exclude_unset=True)} + with self.config_path.open("w") as f: + json.dump(updated_data, f, indent=4) + + except Exception: # noqa: S110 + pass + + def _reset_user_settings(self) -> None: + """Reset all user settings to default values.""" + for key in USER_SETTINGS_KEYS: + setattr(self, key, None) + + def _reset_cli_settings(self) -> None: + """Reset all CLI settings to default values.""" + for key in CLI_SETTINGS_KEYS: + setattr(self, key, DEFAULT_CLI_SETTINGS.get(key)) + + def _clear_auth_tokens(self) -> None: + """Clear all authentication tokens.""" + TokenManager().clear_tokens() diff --git a/lib/crewai-core/src/crewai_core/token_manager.py b/lib/crewai-core/src/crewai_core/token_manager.py new file mode 100644 index 000000000..06f2e7b18 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/token_manager.py @@ -0,0 +1,151 @@ +"""Encrypted token storage shared by crewai and crewai-cli.""" + +from __future__ import annotations + +from datetime import datetime +import json +import os +from pathlib import Path +import sys +import tempfile +from typing import Final, Literal, cast + +from cryptography.fernet import Fernet + + +_FERNET_KEY_LENGTH: Final[Literal[44]] = 44 + + +class TokenManager: + """Manages encrypted token storage on disk under platform-appropriate paths.""" + + def __init__(self, file_path: str = "tokens.enc") -> None: + """Initialize the TokenManager. + + Args: + file_path: The file path to store encrypted tokens. + """ + self.file_path = file_path + self.key = self._get_or_create_key() + self.fernet = Fernet(self.key) + + def _get_or_create_key(self) -> bytes: + """Get or create the encryption key.""" + key_filename: str = "secret.key" + + key = self._read_secure_file(key_filename) + if key is not None and len(key) == _FERNET_KEY_LENGTH: + return key + + new_key = Fernet.generate_key() + if self._atomic_create_secure_file(key_filename, new_key): + return new_key + + key = self._read_secure_file(key_filename) + if key is not None and len(key) == _FERNET_KEY_LENGTH: + return key + + raise RuntimeError("Failed to create or read encryption key") + + def save_tokens(self, access_token: str, expires_at: int) -> None: + """Save the access token and its expiration time. + + Args: + access_token: The access token to save. + expires_at: The UNIX timestamp of the expiration time. + """ + expiration_time = datetime.fromtimestamp(expires_at) + data = { + "access_token": access_token, + "expiration": expiration_time.isoformat(), + } + encrypted_data = self.fernet.encrypt(json.dumps(data).encode()) + self._atomic_write_secure_file(self.file_path, encrypted_data) + + def get_token(self) -> str | None: + """Return the access token if valid and not expired, else None.""" + encrypted_data = self._read_secure_file(self.file_path) + if encrypted_data is None: + return None + + decrypted_data = self.fernet.decrypt(encrypted_data) + data = json.loads(decrypted_data) + + expiration = datetime.fromisoformat(data["expiration"]) + if expiration <= datetime.now(): + return None + + return cast(str | None, data.get("access_token")) + + def clear_tokens(self) -> None: + """Remove the stored token file (no-op if absent).""" + self._delete_secure_file(self.file_path) + + @staticmethod + def _get_secure_storage_path() -> Path: + """Platform-appropriate per-user credential directory (mode 0o700).""" + if sys.platform == "win32": + base_path = os.environ.get("LOCALAPPDATA") + elif sys.platform == "darwin": + base_path = os.path.expanduser("~/Library/Application Support") + else: + base_path = os.path.expanduser("~/.local/share") + + app_name = "crewai/credentials" + storage_path = Path(base_path) / app_name + + storage_path.mkdir(parents=True, exist_ok=True) + + return storage_path + + def _atomic_create_secure_file(self, filename: str, content: bytes) -> bool: + """Create a file only if it doesn't already exist.""" + storage_path = self._get_secure_storage_path() + file_path = storage_path / filename + + try: + fd = os.open(file_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + try: + os.write(fd, content) + finally: + os.close(fd) + return True + except FileExistsError: + return False + + def _atomic_write_secure_file(self, filename: str, content: bytes) -> None: + """Write content to a secure file via tempfile + os.replace.""" + storage_path = self._get_secure_storage_path() + file_path = storage_path / filename + + fd, temp_path = tempfile.mkstemp(dir=storage_path, prefix=f".{filename}.") + fd_closed = False + try: + os.write(fd, content) + os.close(fd) + fd_closed = True + os.chmod(temp_path, 0o600) + os.replace(temp_path, file_path) + except Exception: + if not fd_closed: + os.close(fd) + if os.path.exists(temp_path): + os.unlink(temp_path) + raise + + def _read_secure_file(self, filename: str) -> bytes | None: + """Read raw bytes from a secure file, or None if absent.""" + storage_path = self._get_secure_storage_path() + file_path = storage_path / filename + + try: + with open(file_path, "rb") as f: + return f.read() + except FileNotFoundError: + return None + + def _delete_secure_file(self, filename: str) -> None: + """Delete a secure file (no-op if absent).""" + storage_path = self._get_secure_storage_path() + file_path = storage_path / filename + file_path.unlink(missing_ok=True) diff --git a/lib/crewai-core/src/crewai_core/tool_credentials.py b/lib/crewai-core/src/crewai_core/tool_credentials.py new file mode 100644 index 000000000..b4789cd19 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/tool_credentials.py @@ -0,0 +1,56 @@ +"""Tool-repository credential helpers shared by crewai and crewai-cli.""" + +from __future__ import annotations + +import os +from typing import Any + +from crewai_core.project import read_toml +from crewai_core.settings import Settings + + +def build_env_with_tool_repository_credentials( + repository_handle: str, +) -> dict[str, Any]: + """Return a copy of ``os.environ`` augmented with UV_INDEX_* credentials + for ``repository_handle``. + + The handle is normalized to upper-case with hyphens replaced by underscores + (matching ``uv``'s env-var convention). + """ + repository_handle = repository_handle.upper().replace("-", "_") + settings = Settings() + + env = os.environ.copy() + env[f"UV_INDEX_{repository_handle}_USERNAME"] = str( + settings.tool_repository_username or "" + ) + env[f"UV_INDEX_{repository_handle}_PASSWORD"] = str( + settings.tool_repository_password or "" + ) + + return env + + +def build_env_with_all_tool_credentials() -> dict[str, Any]: + """Return ``os.environ`` augmented with UV_INDEX_* credentials for every + private index referenced under ``[tool.uv.sources]`` in ``pyproject.toml``. + + Errors reading ``pyproject.toml`` are swallowed — the un-augmented + environment is returned in that case. + """ + env = os.environ.copy() + try: + pyproject_data = read_toml() + sources = pyproject_data.get("tool", {}).get("uv", {}).get("sources", {}) + + for source_config in sources.values(): + if isinstance(source_config, dict): + index = source_config.get("index") + if index: + index_env = build_env_with_tool_repository_credentials(index) + env.update(index_env) + except Exception: # noqa: S110 + pass + + return env diff --git a/lib/crewai/src/crewai/auth/oauth2.py b/lib/crewai/src/crewai/auth/oauth2.py index 170f804f8..8500a2ede 100644 --- a/lib/crewai/src/crewai/auth/oauth2.py +++ b/lib/crewai/src/crewai/auth/oauth2.py @@ -4,13 +4,13 @@ import time from typing import TYPE_CHECKING, Any, TypeVar, cast import webbrowser +from crewai_core.settings import Settings +from crewai_core.token_manager import TokenManager import httpx from pydantic import BaseModel, Field from rich.console import Console -from crewai.auth.token_manager import TokenManager from crewai.auth.utils import validate_jwt_token -from crewai.settings import Settings console = Console() diff --git a/lib/crewai/src/crewai/auth/token.py b/lib/crewai/src/crewai/auth/token.py index bc9501502..c3eac3927 100644 --- a/lib/crewai/src/crewai/auth/token.py +++ b/lib/crewai/src/crewai/auth/token.py @@ -1,6 +1,6 @@ """Authentication token retrieval.""" -from crewai.auth.token_manager import TokenManager +from crewai_core.token_manager import TokenManager class AuthError(Exception): diff --git a/lib/crewai/src/crewai/auth/token_manager.py b/lib/crewai/src/crewai/auth/token_manager.py index 5ab5eb801..73a1862d8 100644 --- a/lib/crewai/src/crewai/auth/token_manager.py +++ b/lib/crewai/src/crewai/auth/token_manager.py @@ -1,185 +1,14 @@ -"""Manages encrypted token storage.""" +"""Deprecated: use ``crewai_core.token_manager`` instead.""" -from datetime import datetime -import json -import os -from pathlib import Path -import sys -import tempfile -from typing import Final, Literal, cast +from __future__ import annotations -from cryptography.fernet import Fernet +import warnings + +from crewai_core.token_manager import TokenManager as TokenManager -_FERNET_KEY_LENGTH: Final[Literal[44]] = 44 - - -class TokenManager: - """Manages encrypted token storage.""" - - def __init__(self, file_path: str = "tokens.enc") -> None: - """Initialize the TokenManager. - - Args: - file_path: The file path to store encrypted tokens. - """ - self.file_path = file_path - self.key = self._get_or_create_key() - self.fernet = Fernet(self.key) - - def _get_or_create_key(self) -> bytes: - """Get or create the encryption key. - - Returns: - The encryption key as bytes. - """ - key_filename: str = "secret.key" - - key = self._read_secure_file(key_filename) - if key is not None and len(key) == _FERNET_KEY_LENGTH: - return key - - new_key = Fernet.generate_key() - if self._atomic_create_secure_file(key_filename, new_key): - return new_key - - key = self._read_secure_file(key_filename) - if key is not None and len(key) == _FERNET_KEY_LENGTH: - return key - - raise RuntimeError("Failed to create or read encryption key") - - def save_tokens(self, access_token: str, expires_at: int) -> None: - """Save the access token and its expiration time. - - Args: - access_token: The access token to save. - expires_at: The UNIX timestamp of the expiration time. - """ - expiration_time = datetime.fromtimestamp(expires_at) - data = { - "access_token": access_token, - "expiration": expiration_time.isoformat(), - } - encrypted_data = self.fernet.encrypt(json.dumps(data).encode()) - self._atomic_write_secure_file(self.file_path, encrypted_data) - - def get_token(self) -> str | None: - """Get the access token if it is valid and not expired. - - Returns: - The access token if valid and not expired, otherwise None. - """ - encrypted_data = self._read_secure_file(self.file_path) - if encrypted_data is None: - return None - - decrypted_data = self.fernet.decrypt(encrypted_data) - data = json.loads(decrypted_data) - - expiration = datetime.fromisoformat(data["expiration"]) - if expiration <= datetime.now(): - return None - - return cast(str | None, data.get("access_token")) - - def clear_tokens(self) -> None: - """Clear the stored tokens.""" - self._delete_secure_file(self.file_path) - - @staticmethod - def _get_secure_storage_path() -> Path: - """Get the secure storage path based on the operating system. - - Returns: - The secure storage path. - """ - if sys.platform == "win32": - base_path = os.environ.get("LOCALAPPDATA") - elif sys.platform == "darwin": - base_path = os.path.expanduser("~/Library/Application Support") - else: - base_path = os.path.expanduser("~/.local/share") - - app_name = "crewai/credentials" - storage_path = Path(base_path) / app_name - - storage_path.mkdir(parents=True, exist_ok=True) - - return storage_path - - def _atomic_create_secure_file(self, filename: str, content: bytes) -> bool: - """Create a file only if it doesn't exist. - - Args: - filename: The name of the file. - content: The content to write. - - Returns: - True if file was created, False if it already exists. - """ - storage_path = self._get_secure_storage_path() - file_path = storage_path / filename - - try: - fd = os.open(file_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) - try: - os.write(fd, content) - finally: - os.close(fd) - return True - except FileExistsError: - return False - - def _atomic_write_secure_file(self, filename: str, content: bytes) -> None: - """Write content to a secure file. - - Args: - filename: The name of the file. - content: The content to write. - """ - storage_path = self._get_secure_storage_path() - file_path = storage_path / filename - - fd, temp_path = tempfile.mkstemp(dir=storage_path, prefix=f".{filename}.") - fd_closed = False - try: - os.write(fd, content) - os.close(fd) - fd_closed = True - os.chmod(temp_path, 0o600) - os.replace(temp_path, file_path) - except Exception: - if not fd_closed: - os.close(fd) - if os.path.exists(temp_path): - os.unlink(temp_path) - raise - - def _read_secure_file(self, filename: str) -> bytes | None: - """Read the content of a secure file. - - Args: - filename: The name of the file. - - Returns: - The content of the file if it exists, otherwise None. - """ - storage_path = self._get_secure_storage_path() - file_path = storage_path / filename - - try: - with open(file_path, "rb") as f: - return f.read() - except FileNotFoundError: - return None - - def _delete_secure_file(self, filename: str) -> None: - """Delete a secure file. - - Args: - filename: The name of the file. - """ - storage_path = self._get_secure_storage_path() - file_path = storage_path / filename - file_path.unlink(missing_ok=True) +warnings.warn( + "crewai.auth.token_manager is deprecated; import from crewai_core.token_manager.", + DeprecationWarning, + stacklevel=2, +) diff --git a/lib/crewai/src/crewai/constants.py b/lib/crewai/src/crewai/constants.py index 33b012666..81000cc21 100644 --- a/lib/crewai/src/crewai/constants.py +++ b/lib/crewai/src/crewai/constants.py @@ -2,12 +2,14 @@ from typing import Any +from crewai_core.constants import ( + CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE as CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE, + CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID as CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID, + CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN as CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN, + CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER as CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER, + DEFAULT_CREWAI_ENTERPRISE_URL as DEFAULT_CREWAI_ENTERPRISE_URL, +) -DEFAULT_CREWAI_ENTERPRISE_URL = "https://app.crewai.com" -CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER = "workos" -CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE = "client_01JNJQWBJ4SPFN3SWJM5T7BDG8" -CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID = "client_01JYT06R59SP0NXYGD994NFXXX" -CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN = "login.crewai.com" ENV_VARS: dict[str, list[dict[str, Any]]] = { "openai": [ diff --git a/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py b/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py index 70d91c61d..72dbb21a2 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py @@ -6,6 +6,7 @@ import time from typing import Any import uuid +from crewai_core.settings import Settings from rich.console import Console from rich.panel import Panel @@ -18,7 +19,6 @@ from crewai.events.listeners.tracing.utils import ( should_auto_collect_first_time_traces, ) from crewai.plus_api import PlusAPI -from crewai.settings import Settings from crewai.version import get_crewai_version diff --git a/lib/crewai/src/crewai/plus_api.py b/lib/crewai/src/crewai/plus_api.py index a8c498a5b..3dd7c87d1 100644 --- a/lib/crewai/src/crewai/plus_api.py +++ b/lib/crewai/src/crewai/plus_api.py @@ -4,10 +4,10 @@ import os from typing import Any from urllib.parse import urljoin +from crewai_core.settings import Settings import httpx from crewai.constants import DEFAULT_CREWAI_ENTERPRISE_URL -from crewai.settings import Settings from crewai.version import get_crewai_version diff --git a/lib/crewai/src/crewai/settings.py b/lib/crewai/src/crewai/settings.py index 66ee7080d..3e3911152 100644 --- a/lib/crewai/src/crewai/settings.py +++ b/lib/crewai/src/crewai/settings.py @@ -1,216 +1,18 @@ -"""CrewAI platform settings management.""" +"""Re-exports of shared settings from ``crewai_core.settings``. -import json -from logging import getLogger -from pathlib import Path -import tempfile -from typing import Any +Existing imports from ``crewai.settings`` continue to work; new code should +import from ``crewai_core.settings`` directly. +""" -from pydantic import BaseModel, Field +from __future__ import annotations -from crewai.auth.token_manager import TokenManager -from crewai.constants import ( - CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE, - CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID, - CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN, - CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER, - DEFAULT_CREWAI_ENTERPRISE_URL, +from crewai_core.settings import ( + CLI_SETTINGS_KEYS as CLI_SETTINGS_KEYS, + DEFAULT_CLI_SETTINGS as DEFAULT_CLI_SETTINGS, + DEFAULT_CONFIG_PATH as DEFAULT_CONFIG_PATH, + HIDDEN_SETTINGS_KEYS as HIDDEN_SETTINGS_KEYS, + READONLY_SETTINGS_KEYS as READONLY_SETTINGS_KEYS, + USER_SETTINGS_KEYS as USER_SETTINGS_KEYS, + Settings as Settings, + get_writable_config_path as get_writable_config_path, ) - - -logger = getLogger(__name__) - -DEFAULT_CONFIG_PATH = Path.home() / ".config" / "crewai" / "settings.json" - - -def get_writable_config_path() -> Path | None: - """Find a writable location for the config file with fallback options. - - Tries in order: - 1. Default: ~/.config/crewai/settings.json - 2. Temp directory: /tmp/crewai_settings.json (or OS equivalent) - 3. Current directory: ./crewai_settings.json - 4. In-memory only (returns None) - - Returns: - Path object for writable config location, or None if no writable location found. - """ - fallback_paths = [ - DEFAULT_CONFIG_PATH, - Path(tempfile.gettempdir()) / "crewai_settings.json", - Path.cwd() / "crewai_settings.json", - ] - - for config_path in fallback_paths: - try: - config_path.parent.mkdir(parents=True, exist_ok=True) - test_file = config_path.parent / ".crewai_write_test" - try: - test_file.write_text("test") - test_file.unlink() - logger.info(f"Using config path: {config_path}") - return config_path - except Exception: # noqa: S112 - continue - - except Exception: # noqa: S112 - continue - - return None - - -USER_SETTINGS_KEYS = [ - "tool_repository_username", - "tool_repository_password", - "org_name", - "org_uuid", -] - -CLI_SETTINGS_KEYS = [ - "enterprise_base_url", - "oauth2_provider", - "oauth2_audience", - "oauth2_client_id", - "oauth2_domain", - "oauth2_extra", -] - -DEFAULT_CLI_SETTINGS = { - "enterprise_base_url": DEFAULT_CREWAI_ENTERPRISE_URL, - "oauth2_provider": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER, - "oauth2_audience": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE, - "oauth2_client_id": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID, - "oauth2_domain": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN, - "oauth2_extra": {}, -} - -READONLY_SETTINGS_KEYS = [ - "org_name", - "org_uuid", -] - -HIDDEN_SETTINGS_KEYS = [ - "config_path", - "tool_repository_username", - "tool_repository_password", -] - - -class Settings(BaseModel): - """CrewAI platform settings.""" - - enterprise_base_url: str | None = Field( - default=DEFAULT_CREWAI_ENTERPRISE_URL, - description="Base URL of the CrewAI AMP instance", - ) - tool_repository_username: str | None = Field( - None, description="Username for interacting with the Tool Repository" - ) - tool_repository_password: str | None = Field( - None, description="Password for interacting with the Tool Repository" - ) - org_name: str | None = Field( - None, description="Name of the currently active organization" - ) - org_uuid: str | None = Field( - None, description="UUID of the currently active organization" - ) - config_path: Path = Field(default=DEFAULT_CONFIG_PATH, frozen=True, exclude=True) - - oauth2_provider: str = Field( - description="OAuth2 provider used for authentication (e.g., workos, okta, auth0).", - default=CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER, - ) - - oauth2_audience: str | None = Field( - description="OAuth2 audience value, typically used to identify the target API or resource.", - default=CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE, - ) - - oauth2_client_id: str = Field( - default=CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID, - description="OAuth2 client ID issued by the provider, used during authentication requests.", - ) - - oauth2_domain: str = Field( - description="OAuth2 provider's domain (e.g., your-org.auth0.com) used for issuing tokens.", - default=CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN, - ) - - oauth2_extra: dict[str, Any] = Field( - description="Extra configuration for the OAuth2 provider.", - default={}, - ) - - def __init__(self, config_path: Path | None = None, **data: dict[str, Any]) -> None: - """Load Settings from config path with fallback support.""" - if config_path is None: - config_path = get_writable_config_path() - - if config_path is None: - merged_data = {**data} - super().__init__(config_path=Path("/dev/null"), **merged_data) - return - - try: - config_path.parent.mkdir(parents=True, exist_ok=True) - except Exception: - merged_data = {**data} - super().__init__(config_path=Path("/dev/null"), **merged_data) - return - - file_data = {} - if config_path.is_file(): - try: - with config_path.open("r") as f: - file_data = json.load(f) - except Exception: - file_data = {} - - merged_data = {**file_data, **data} - super().__init__(config_path=config_path, **merged_data) - - def clear_user_settings(self) -> None: - """Clear all user settings.""" - self._reset_user_settings() - self.dump() - - def reset(self) -> None: - """Reset all settings to default values.""" - self._reset_user_settings() - self._reset_cli_settings() - self._clear_auth_tokens() - self.dump() - - def dump(self) -> None: - """Save current settings to settings.json.""" - if str(self.config_path) == "/dev/null": - return - - try: - if self.config_path.is_file(): - with self.config_path.open("r") as f: - existing_data = json.load(f) - else: - existing_data = {} - - updated_data = {**existing_data, **self.model_dump(exclude_unset=True)} - with self.config_path.open("w") as f: - json.dump(updated_data, f, indent=4) - - except Exception: # noqa: S110 - pass - - def _reset_user_settings(self) -> None: - """Reset all user settings to default values.""" - for key in USER_SETTINGS_KEYS: - setattr(self, key, None) - - def _reset_cli_settings(self) -> None: - """Reset all CLI settings to default values.""" - for key in CLI_SETTINGS_KEYS: - setattr(self, key, DEFAULT_CLI_SETTINGS.get(key)) - - def _clear_auth_tokens(self) -> None: - """Clear all authentication tokens.""" - TokenManager().clear_tokens() diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index 4255449c0..3cb72331c 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -13,6 +13,7 @@ import re from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict from crewai_core.printer import PRINTER, ColoredText, Printer +from crewai_core.settings import Settings from pydantic import BaseModel from rich.console import Console @@ -24,7 +25,6 @@ from crewai.agents.parser import ( parse, ) from crewai.llms.base_llm import BaseLLM, call_stop_override -from crewai.settings import Settings from crewai.tools import BaseTool as CrewAITool from crewai.tools.base_tool import BaseTool from crewai.tools.structured_tool import CrewStructuredTool diff --git a/lib/crewai/src/crewai/utilities/project_utils.py b/lib/crewai/src/crewai/utilities/project_utils.py index 7bcb9b4e0..fa3e8a2e1 100644 --- a/lib/crewai/src/crewai/utilities/project_utils.py +++ b/lib/crewai/src/crewai/utilities/project_utils.py @@ -22,11 +22,14 @@ from crewai_core.project import ( parse_toml as parse_toml, read_toml as read_toml, ) +from crewai_core.tool_credentials import ( + build_env_with_all_tool_credentials as build_env_with_all_tool_credentials, + build_env_with_tool_repository_credentials as build_env_with_tool_repository_credentials, +) from rich.console import Console from crewai.crew import Crew from crewai.flow import Flow -from crewai.settings import Settings console = Console() @@ -289,49 +292,6 @@ def extract_available_exports(dir_path: str = "src") -> list[dict[str, Any]]: raise SystemExit(1) from e -def build_env_with_tool_repository_credentials( - repository_handle: str, -) -> dict[str, Any]: - """Build environment variables with tool repository credentials.""" - repository_handle = repository_handle.upper().replace("-", "_") - settings = Settings() - - env = os.environ.copy() - env[f"UV_INDEX_{repository_handle}_USERNAME"] = str( - settings.tool_repository_username or "" - ) - env[f"UV_INDEX_{repository_handle}_PASSWORD"] = str( - settings.tool_repository_password or "" - ) - - return env - - -def build_env_with_all_tool_credentials() -> dict[str, Any]: - """ - Build environment dict with credentials for all tool repository indexes - found in pyproject.toml's [tool.uv.sources] section. - - Returns: - dict: Environment variables with credentials for all private indexes. - """ - env = os.environ.copy() - try: - pyproject_data = read_toml() - sources = pyproject_data.get("tool", {}).get("uv", {}).get("sources", {}) - - for source_config in sources.values(): - if isinstance(source_config, dict): - index = source_config.get("index") - if index: - index_env = build_env_with_tool_repository_credentials(index) - env.update(index_env) - except Exception: # noqa: S110 - pass - - return env - - @contextmanager def _load_module_from_file( init_file: Path, module_name: str | None = None diff --git a/lib/crewai/tests/cli/test_config.py b/lib/crewai/tests/cli/test_config.py index eefdbdecd..a07e0971c 100644 --- a/lib/crewai/tests/cli/test_config.py +++ b/lib/crewai/tests/cli/test_config.py @@ -12,7 +12,7 @@ from crewai.settings import ( USER_SETTINGS_KEYS, Settings, ) -from crewai.auth.token_manager import TokenManager +from crewai_core.token_manager import TokenManager class TestSettings(unittest.TestCase): @@ -69,7 +69,7 @@ class TestSettings(unittest.TestCase): for key in user_settings.keys(): self.assertEqual(getattr(settings, key), None) - @patch("crewai.settings.TokenManager") + @patch("crewai_core.settings.TokenManager") def test_reset_settings(self, mock_token_manager): user_settings = {key: f"value_for_{key}" for key in USER_SETTINGS_KEYS} cli_settings = {key: f"value_for_{key}" for key in CLI_SETTINGS_KEYS if key != "oauth2_extra"} diff --git a/lib/crewai/tests/cli/test_token_manager.py b/lib/crewai/tests/cli/test_token_manager.py index 12407ae01..791de53c7 100644 --- a/lib/crewai/tests/cli/test_token_manager.py +++ b/lib/crewai/tests/cli/test_token_manager.py @@ -10,20 +10,20 @@ from unittest.mock import patch from cryptography.fernet import Fernet -from crewai.auth.token_manager import TokenManager +from crewai_core.token_manager import TokenManager class TestTokenManager(unittest.TestCase): """Test cases for TokenManager.""" - @patch("crewai.auth.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def setUp(self, mock_get_key: unittest.mock.MagicMock) -> None: """Set up test fixtures.""" mock_get_key.return_value = Fernet.generate_key() self.token_manager = TokenManager() - @patch("crewai.auth.token_manager.TokenManager._read_secure_file") - @patch("crewai.auth.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_get_or_create_key_existing( self, mock_get_or_create: unittest.mock.MagicMock, @@ -45,7 +45,7 @@ class TestTokenManager(unittest.TestCase): with ( patch.object(self.token_manager, "_read_secure_file", return_value=None) as mock_read, patch.object(self.token_manager, "_atomic_create_secure_file", return_value=True) as mock_atomic_create, - patch("crewai.auth.token_manager.Fernet.generate_key", return_value=mock_key) as mock_generate, + patch("crewai_core.token_manager.Fernet.generate_key", return_value=mock_key) as mock_generate, ): result = self.token_manager._get_or_create_key() @@ -62,14 +62,14 @@ class TestTokenManager(unittest.TestCase): with ( patch.object(self.token_manager, "_read_secure_file", side_effect=[None, their_key]) as mock_read, patch.object(self.token_manager, "_atomic_create_secure_file", return_value=False) as mock_atomic_create, - patch("crewai.auth.token_manager.Fernet.generate_key", return_value=our_key), + patch("crewai_core.token_manager.Fernet.generate_key", return_value=our_key), ): result = self.token_manager._get_or_create_key() self.assertEqual(result, their_key) self.assertEqual(mock_read.call_count, 2) - @patch("crewai.auth.token_manager.TokenManager._atomic_write_secure_file") + @patch("crewai_core.token_manager.TokenManager._atomic_write_secure_file") def test_save_tokens( self, mock_write: unittest.mock.MagicMock ) -> None: @@ -88,7 +88,7 @@ class TestTokenManager(unittest.TestCase): expiration = datetime.fromisoformat(data["expiration"]) self.assertEqual(expiration, datetime.fromtimestamp(expires_at)) - @patch("crewai.auth.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._read_secure_file") def test_get_token_valid( self, mock_read: unittest.mock.MagicMock ) -> None: @@ -103,7 +103,7 @@ class TestTokenManager(unittest.TestCase): self.assertEqual(result, access_token) - @patch("crewai.auth.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._read_secure_file") def test_get_token_expired( self, mock_read: unittest.mock.MagicMock ) -> None: @@ -118,7 +118,7 @@ class TestTokenManager(unittest.TestCase): self.assertIsNone(result) - @patch("crewai.auth.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._read_secure_file") def test_get_token_not_found( self, mock_read: unittest.mock.MagicMock ) -> None: @@ -129,7 +129,7 @@ class TestTokenManager(unittest.TestCase): self.assertIsNone(result) - @patch("crewai.auth.token_manager.TokenManager._delete_secure_file") + @patch("crewai_core.token_manager.TokenManager._delete_secure_file") def test_clear_tokens( self, mock_delete: unittest.mock.MagicMock ) -> None: @@ -159,7 +159,7 @@ class TestAtomicFileOperations(unittest.TestCase): import shutil shutil.rmtree(self.temp_dir, ignore_errors=True) - @patch("crewai.auth.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_create_new_file( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -175,7 +175,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertEqual(file_path.read_bytes(), b"content") self.assertEqual(file_path.stat().st_mode & 0o777, 0o600) - @patch("crewai.auth.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_create_existing_file( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -192,7 +192,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertFalse(result) self.assertEqual(file_path.read_bytes(), b"original") - @patch("crewai.auth.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_write_new_file( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -207,7 +207,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertEqual(file_path.read_bytes(), b"content") self.assertEqual(file_path.stat().st_mode & 0o777, 0o600) - @patch("crewai.auth.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_write_overwrites( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -222,7 +222,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertEqual(file_path.read_bytes(), b"new content") - @patch("crewai.auth.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_write_no_temp_file_on_success( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -236,7 +236,7 @@ class TestAtomicFileOperations(unittest.TestCase): temp_files = list(Path(self.temp_dir).glob(".test.txt.*")) self.assertEqual(len(temp_files), 0) - @patch("crewai.auth.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_read_secure_file_exists( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -251,7 +251,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertEqual(result, b"content") - @patch("crewai.auth.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_read_secure_file_not_exists( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -263,7 +263,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertIsNone(result) - @patch("crewai.auth.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_delete_secure_file_exists( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -278,7 +278,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertFalse(file_path.exists()) - @patch("crewai.auth.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_delete_secure_file_not_exists( self, mock_get_key: unittest.mock.MagicMock ) -> None: diff --git a/uv.lock b/uv.lock index 1a03b6ec4..8d767547d 100644 --- a/uv.lock +++ b/uv.lock @@ -1476,21 +1476,27 @@ name = "crewai-core" source = { editable = "lib/crewai-core" } dependencies = [ { name = "appdirs" }, + { name = "cryptography" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-sdk" }, { name = "portalocker" }, + { name = "pydantic" }, { name = "rich" }, + { name = "tomli" }, ] [package.metadata] requires-dist = [ { name = "appdirs", specifier = "~=1.4.4" }, + { name = "cryptography", specifier = ">=42.0" }, { name = "opentelemetry-api", specifier = "~=1.34.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.34.0" }, { name = "opentelemetry-sdk", specifier = "~=1.34.0" }, { name = "portalocker", specifier = "~=2.7.0" }, + { name = "pydantic", specifier = ">=2.11.9,<2.13" }, { name = "rich", specifier = ">=13.7.1" }, + { name = "tomli", specifier = "~=2.0.2" }, ] [[package]]