mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-10-22 09:53:07 +02:00
fix(cli): use login token for platform integrations
This commit is contained in:
1 parent
3cece14e9b
commit
6df5b70657
8 files changed
+146
-71
No files matched your search
@@ -12,6 +12,7 @@ from crewai_core.auth.oauth2 import (
|
||||
ProviderFactory as ProviderFactory,
|
||||
console,
|
||||
)
|
||||
from crewai_core.constants import DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
from crewai_core.settings import Settings
|
||||
|
||||
from crewai_cli.authentication.token import get_auth_token
|
||||
@@ -32,7 +33,11 @@ class AuthenticationCommand(_BaseAuthenticationCommand):
|
||||
def _select_organization(self) -> None:
|
||||
"""Let the user choose the organization for this CLI login."""
|
||||
try:
|
||||
response = PlusAPI(api_key=get_auth_token()).get_organizations()
|
||||
settings = Settings()
|
||||
response = PlusAPI(
|
||||
api_key=get_auth_token(),
|
||||
base_url=settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL,
|
||||
).get_organizations()
|
||||
response.raise_for_status()
|
||||
organizations = response.json()
|
||||
except Exception as error:
|
||||
@@ -84,7 +89,10 @@ class AuthenticationCommand(_BaseAuthenticationCommand):
|
||||
end="",
|
||||
)
|
||||
|
||||
ToolCommand().login()
|
||||
settings = Settings()
|
||||
ToolCommand(
|
||||
base_url=settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
).login()
|
||||
|
||||
console.print(
|
||||
"Success!\n",
|
||||
|
||||
@@ -24,10 +24,10 @@ class BaseCommand:
|
||||
|
||||
|
||||
class PlusAPIMixin:
|
||||
def __init__(self, telemetry: Telemetry) -> None:
|
||||
def __init__(self, telemetry: Telemetry, *, base_url: str | None = None) -> None:
|
||||
try:
|
||||
telemetry.set_tracer()
|
||||
self.plus_api_client = PlusAPI(api_key=get_auth_token())
|
||||
self.plus_api_client = PlusAPI(api_key=get_auth_token(), base_url=base_url)
|
||||
except Exception:
|
||||
telemetry.deploy_signup_error_span()
|
||||
console.print(
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
import warnings
|
||||
|
||||
import click
|
||||
from crewai_core.constants import DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
from crewai_core.platform_app_defaults import PLATFORM_APP_DEFAULT_TOOLS
|
||||
from crewai_core.platform_apps import (
|
||||
PLATFORM_APPLICATION_CATALOG,
|
||||
@@ -1043,12 +1044,16 @@ def _platform_app_name(app: str) -> str:
|
||||
def _get_logged_in_platform_token() -> str:
|
||||
"""Fetch the selected organization's integration token from the saved login."""
|
||||
login_token = get_auth_token()
|
||||
organization_id = Settings().org_uuid
|
||||
settings = Settings()
|
||||
organization_id = settings.org_uuid
|
||||
if not organization_id:
|
||||
raise _NoOrganizationSelectedError("No CrewAI organization is selected")
|
||||
return PlusAPI(
|
||||
api_key=login_token, organization_id=organization_id
|
||||
).get_integration_token()
|
||||
api = PlusAPI(
|
||||
api_key=login_token,
|
||||
organization_id=organization_id,
|
||||
base_url=settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL,
|
||||
)
|
||||
return api.get_integration_token()
|
||||
|
||||
|
||||
def _get_platform_token_from_login() -> str:
|
||||
@@ -1084,24 +1089,35 @@ def _get_platform_token_from_login() -> str:
|
||||
|
||||
|
||||
def _check_platform_app(
|
||||
app: str, application_selector: Any, client_for_selector: Any
|
||||
app: str,
|
||||
application_selector: Any,
|
||||
client_for_selector: Any,
|
||||
base_url: str | None = None,
|
||||
) -> tuple[Any | None, Exception | None]:
|
||||
"""Check one AMP application with the synchronous platform client."""
|
||||
try:
|
||||
selector = application_selector.from_string(app)
|
||||
return client_for_selector(selector).get_actions([selector]), None
|
||||
client = client_for_selector(selector, base_url=base_url)
|
||||
return client.get_actions([selector]), None
|
||||
except Exception as error:
|
||||
return None, error
|
||||
|
||||
|
||||
async def _check_platform_apps_concurrently(
|
||||
apps: list[str], application_selector: Any, client_for_selector: Any
|
||||
apps: list[str],
|
||||
application_selector: Any,
|
||||
client_for_selector: Any,
|
||||
base_url: str | None = None,
|
||||
) -> list[tuple[Any | None, Exception | None]]:
|
||||
"""Run independent AMP application checks concurrently."""
|
||||
return await asyncio.gather(
|
||||
*(
|
||||
asyncio.to_thread(
|
||||
_check_platform_app, app, application_selector, client_for_selector
|
||||
_check_platform_app,
|
||||
app,
|
||||
application_selector,
|
||||
client_for_selector,
|
||||
base_url,
|
||||
)
|
||||
for app in apps
|
||||
)
|
||||
@@ -1146,7 +1162,10 @@ def _report_platform_app_validation(
|
||||
|
||||
|
||||
def _validate_platform_apps(
|
||||
apps: list[str], application_selector: Any, client_for_selector: Any
|
||||
apps: list[str],
|
||||
application_selector: Any,
|
||||
client_for_selector: Any,
|
||||
base_url: str | None = None,
|
||||
) -> tuple[list[str], bool]:
|
||||
"""Check selected AMP applications concurrently when no event loop is running."""
|
||||
failed: list[str] = []
|
||||
@@ -1163,7 +1182,7 @@ def _validate_platform_apps(
|
||||
)
|
||||
results = asyncio.run(
|
||||
_check_platform_apps_concurrently(
|
||||
apps, application_selector, client_for_selector
|
||||
apps, application_selector, client_for_selector, base_url
|
||||
)
|
||||
)
|
||||
for app, (actions, error) in zip(apps, results, strict=True):
|
||||
@@ -1179,7 +1198,7 @@ def _validate_platform_apps(
|
||||
fg="cyan",
|
||||
)
|
||||
actions, error = _check_platform_app(
|
||||
app, application_selector, client_for_selector
|
||||
app, application_selector, client_for_selector, base_url
|
||||
)
|
||||
if _report_platform_app_validation(app, actions, error, failed):
|
||||
return failed, True
|
||||
@@ -1187,42 +1206,33 @@ def _validate_platform_apps(
|
||||
|
||||
|
||||
def _show_platform_validation_guidance(
|
||||
failed_apps: list[str], token_invalid: bool
|
||||
failed_apps: list[str], token_invalid: bool, base_url: str
|
||||
) -> None:
|
||||
"""Tell the user what to fix before revalidating AMP integrations."""
|
||||
click.echo()
|
||||
if token_invalid:
|
||||
click.secho(
|
||||
" Check your Enterprise Action Auth Token in AMP.",
|
||||
" Your CrewAI login could not validate the Enterprise Action Auth Token.",
|
||||
fg="yellow",
|
||||
)
|
||||
return
|
||||
|
||||
failed_app_names = [_platform_app_name(app) for app in failed_apps]
|
||||
click.secho(
|
||||
" Check the "
|
||||
" Connect the "
|
||||
f"{', '.join(failed_app_names)} integration"
|
||||
f"{'s' if len(failed_app_names) != 1 else ''} and your CrewAI "
|
||||
"Enterprise Action Auth Token in AMP.",
|
||||
f"{'s' if len(failed_app_names) != 1 else ''} in CrewAI AMP:",
|
||||
fg="yellow",
|
||||
)
|
||||
click.secho(f" {base_url.rstrip('/')}", fg="cyan")
|
||||
|
||||
|
||||
def _prompt_platform_revalidation_token() -> str:
|
||||
"""Prompt for an optional replacement token before the next validation pass."""
|
||||
def _confirm_platform_revalidation() -> bool:
|
||||
"""Ask whether to retry integration validation after AMP is updated."""
|
||||
click.echo()
|
||||
return str(
|
||||
click.prompt(
|
||||
click.style(
|
||||
" Press Enter to revalidate, or enter a replacement token",
|
||||
fg="cyan",
|
||||
),
|
||||
default="",
|
||||
show_default=False,
|
||||
hide_input=False,
|
||||
prompt_suffix=click.style(" > ", fg="bright_white"),
|
||||
)
|
||||
).strip()
|
||||
return click.confirm(
|
||||
click.style(" Revalidate integrations?", fg="cyan"), default=True
|
||||
)
|
||||
|
||||
|
||||
def _setup_platform_auth(agents: list[dict[str, Any]]) -> str | None:
|
||||
@@ -1265,8 +1275,13 @@ def _setup_platform_auth(agents: list[dict[str, Any]]) -> str | None:
|
||||
f"Enterprise Action Auth Token found via {credential_location}", dim=True
|
||||
)
|
||||
os.environ["CREWAI_ENTERPRISE_ACTION_AUTH_TOKEN"] = token
|
||||
settings = Settings()
|
||||
base_url = settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
failed_apps, token_invalid = _validate_platform_apps(
|
||||
apps, ApplicationSelector, client_for_selector
|
||||
apps,
|
||||
ApplicationSelector,
|
||||
client_for_selector,
|
||||
base_url,
|
||||
)
|
||||
if not failed_apps and not token_invalid:
|
||||
_success("Enterprise Action Auth Token set", bold=True)
|
||||
@@ -1276,12 +1291,9 @@ def _setup_platform_auth(agents: list[dict[str, Any]]) -> str | None:
|
||||
)
|
||||
return token
|
||||
|
||||
_show_platform_validation_guidance(failed_apps, token_invalid)
|
||||
replacement_token = _prompt_platform_revalidation_token()
|
||||
if replacement_token:
|
||||
token = replacement_token
|
||||
credential_location = "interactive replacement prompt"
|
||||
os.environ["CREWAI_ENTERPRISE_ACTION_AUTH_TOKEN"] = token
|
||||
_show_platform_validation_guidance(failed_apps, token_invalid, base_url)
|
||||
if not _confirm_platform_revalidation():
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,9 +61,9 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
|
||||
A class to handle tool repository related operations for CrewAI projects.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, *, base_url: str | None = None) -> None:
|
||||
BaseCommand.__init__(self)
|
||||
PlusAPIMixin.__init__(self, telemetry=self._telemetry)
|
||||
PlusAPIMixin.__init__(self, telemetry=self._telemetry, base_url=base_url)
|
||||
|
||||
def create(self, handle: str) -> None:
|
||||
self._ensure_not_in_project()
|
||||
|
||||
@@ -174,11 +174,12 @@ class TestAuthenticationCommand:
|
||||
mock_settings_instance = MagicMock()
|
||||
mock_settings_instance.org_name = "Test Org"
|
||||
mock_settings_instance.org_uuid = "test-uuid-123"
|
||||
mock_settings_instance.enterprise_base_url = "https://app.crewai.com"
|
||||
mock_settings.return_value = mock_settings_instance
|
||||
|
||||
self.auth_command._login_to_tool_repository()
|
||||
|
||||
mock_tool_command.assert_called_once()
|
||||
mock_tool_command.assert_called_once_with(base_url="https://app.crewai.com")
|
||||
mock_tool_instance.login.assert_called_once()
|
||||
|
||||
expected_calls = [
|
||||
@@ -332,10 +333,13 @@ class TestAuthenticationCommand:
|
||||
{"name": "Research", "uuid": "org-research"},
|
||||
]
|
||||
mock_plus_api.return_value.get_organizations.return_value = response
|
||||
mock_settings.return_value.enterprise_base_url = "https://app.crewai.com"
|
||||
|
||||
self.auth_command._select_organization()
|
||||
|
||||
mock_plus_api.assert_called_once_with(api_key="token")
|
||||
mock_plus_api.assert_called_once_with(
|
||||
api_key="token", base_url="https://app.crewai.com"
|
||||
)
|
||||
response.raise_for_status.assert_called_once()
|
||||
mock_pick.assert_called_once_with(
|
||||
"Select an organization (Name / ID):",
|
||||
@@ -360,7 +364,7 @@ class TestAuthenticationCommand:
|
||||
|
||||
self.auth_command._select_organization()
|
||||
|
||||
mock_settings.assert_not_called()
|
||||
mock_settings.return_value.dump.assert_not_called()
|
||||
|
||||
@patch("crewai_cli.authentication.main.pick", return_value="org-unknown")
|
||||
@patch("crewai_cli.authentication.main.console")
|
||||
|
||||
@@ -785,17 +785,27 @@ def test_platform_validation_deduplicates_actions_by_application() -> None:
|
||||
assert apps == ["github", "gmail"]
|
||||
|
||||
|
||||
def test_platform_revalidation_prompt_shows_entered_value(monkeypatch) -> None:
|
||||
prompt_options: list[dict[str, object]] = []
|
||||
def test_platform_revalidation_requires_confirmation(monkeypatch) -> None:
|
||||
confirm_options: list[dict[str, object]] = []
|
||||
|
||||
def prompt(_label: str, **kwargs: object) -> str:
|
||||
prompt_options.append(kwargs)
|
||||
return "token"
|
||||
def confirm(_label: str, **kwargs: object) -> bool:
|
||||
confirm_options.append(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(json_crew.click, "prompt", prompt)
|
||||
monkeypatch.setattr(json_crew.click, "confirm", confirm)
|
||||
|
||||
assert json_crew._prompt_platform_revalidation_token() == "token"
|
||||
assert [options["hide_input"] for options in prompt_options] == [False]
|
||||
assert json_crew._confirm_platform_revalidation()
|
||||
assert confirm_options == [{"default": True}]
|
||||
|
||||
|
||||
def test_platform_validation_guidance_links_to_amp(capsys) -> None:
|
||||
json_crew._show_platform_validation_guidance(
|
||||
["whatsapp"], False, "https://app.crewai.com/"
|
||||
)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Connect the WhatsApp integration in CrewAI AMP" in output
|
||||
assert "https://app.crewai.com" in output
|
||||
|
||||
|
||||
def test_get_logged_in_platform_token_uses_saved_login_and_organization(
|
||||
@@ -804,8 +814,14 @@ def test_get_logged_in_platform_token_uses_saved_login_and_organization(
|
||||
client = mock.Mock()
|
||||
client.get_integration_token.return_value = "integration-token"
|
||||
monkeypatch.setattr(json_crew, "get_auth_token", lambda: "login-token")
|
||||
monkeypatch.setenv("CREWAI_PLUS_URL", "https://fake.crewai.com")
|
||||
monkeypatch.setattr(
|
||||
json_crew, "Settings", lambda: mock.Mock(org_uuid="organization-id")
|
||||
json_crew,
|
||||
"Settings",
|
||||
lambda: mock.Mock(
|
||||
org_uuid="organization-id",
|
||||
enterprise_base_url="https://app.crewai.com",
|
||||
),
|
||||
)
|
||||
plus_api = mock.Mock(return_value=client)
|
||||
monkeypatch.setattr(json_crew, "PlusAPI", plus_api)
|
||||
@@ -813,7 +829,9 @@ def test_get_logged_in_platform_token_uses_saved_login_and_organization(
|
||||
assert json_crew._get_logged_in_platform_token() == "integration-token"
|
||||
|
||||
plus_api.assert_called_once_with(
|
||||
api_key="login-token", organization_id="organization-id"
|
||||
api_key="login-token",
|
||||
organization_id="organization-id",
|
||||
base_url="https://app.crewai.com",
|
||||
)
|
||||
client.get_integration_token.assert_called_once_with()
|
||||
|
||||
@@ -934,7 +952,7 @@ def test_platform_auth_suppresses_warnings_only_while_importing_tools(
|
||||
fake_client.get_actions.return_value = [object()]
|
||||
fake_integrations_client = mock.Mock(
|
||||
ApplicationSelector=FakeApplicationSelector,
|
||||
client_for_selector=lambda _selector: fake_client,
|
||||
client_for_selector=lambda _selector, **_kwargs: fake_client,
|
||||
)
|
||||
original_import = builtins.__import__
|
||||
|
||||
@@ -976,7 +994,7 @@ def test_platform_auth_fetches_token_from_login_when_env_is_missing(monkeypatch)
|
||||
fake_client.get_actions.return_value = [object()]
|
||||
fake_integrations_client = mock.Mock(
|
||||
ApplicationSelector=FakeApplicationSelector,
|
||||
client_for_selector=lambda _selector: fake_client,
|
||||
client_for_selector=lambda _selector, **_kwargs: fake_client,
|
||||
)
|
||||
original_import = builtins.__import__
|
||||
|
||||
@@ -1013,7 +1031,7 @@ def test_platform_validation_checks_apps_concurrently():
|
||||
json_crew._check_platform_apps_concurrently(
|
||||
["github", "gmail"],
|
||||
FakeApplicationSelector,
|
||||
lambda _selector: FakeClient(),
|
||||
lambda _selector, **_kwargs: FakeClient(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1033,7 +1051,7 @@ def test_platform_validation_announces_concurrent_apps_together(capsys):
|
||||
failed_apps, token_invalid = json_crew._validate_platform_apps(
|
||||
["github", "gmail", "google_calendar"],
|
||||
FakeApplicationSelector,
|
||||
lambda _selector: FakeClient(),
|
||||
lambda _selector, **_kwargs: FakeClient(),
|
||||
)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
@@ -1042,6 +1060,31 @@ def test_platform_validation_announces_concurrent_apps_together(capsys):
|
||||
assert "Checking Enterprise Action Auth Token and GitHub" not in output
|
||||
|
||||
|
||||
def test_platform_validation_passes_configured_amp_url_to_client_factory() -> None:
|
||||
class FakeApplicationSelector:
|
||||
@classmethod
|
||||
def from_string(cls, value: str) -> str:
|
||||
return value
|
||||
|
||||
received_base_urls: list[str | None] = []
|
||||
|
||||
class FakeClient:
|
||||
def get_actions(self, _selectors):
|
||||
return [object()]
|
||||
|
||||
def client_factory(_selector, *, base_url=None):
|
||||
received_base_urls.append(base_url)
|
||||
return FakeClient()
|
||||
|
||||
assert json_crew._validate_platform_apps(
|
||||
["github"],
|
||||
FakeApplicationSelector,
|
||||
client_factory,
|
||||
"https://app.crewai.com",
|
||||
) == ([], False)
|
||||
assert received_base_urls == ["https://app.crewai.com"]
|
||||
|
||||
|
||||
def test_platform_validation_falls_back_to_sequential_checks(monkeypatch, capsys):
|
||||
class FakeApplicationSelector:
|
||||
@classmethod
|
||||
@@ -1063,7 +1106,7 @@ def test_platform_validation_falls_back_to_sequential_checks(monkeypatch, capsys
|
||||
failed_apps, token_invalid = json_crew._validate_platform_apps(
|
||||
["github", "gmail"],
|
||||
FakeApplicationSelector,
|
||||
lambda app: FakeClient(app),
|
||||
lambda app, **_kwargs: FakeClient(app),
|
||||
)
|
||||
|
||||
assert (failed_apps, token_invalid) == ([], False)
|
||||
|
||||
+15
-7
@@ -132,9 +132,12 @@ class ClipperClient:
|
||||
|
||||
_RESOURCE = "/clipper/v1"
|
||||
|
||||
def __init__(self, *, base_url: str | None = None) -> None:
|
||||
self._base_url = base_url
|
||||
|
||||
def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]:
|
||||
"""Get the actions available for the selected applications."""
|
||||
plus_api = PlusAPI()
|
||||
plus_api = PlusAPI(base_url=self._base_url)
|
||||
base_url = f"{plus_api.base_url.rstrip('/')}{self._RESOURCE}"
|
||||
headers = self._headers()
|
||||
tool_infos: list[ToolInfo] = []
|
||||
@@ -177,7 +180,7 @@ class ClipperClient:
|
||||
self, tool: ToolInfo, arguments: dict[str, Any]
|
||||
) -> ToolExecutionResult:
|
||||
"""Execute an action with the given arguments."""
|
||||
plus_api = PlusAPI()
|
||||
plus_api = PlusAPI(base_url=self._base_url)
|
||||
payload: dict[str, Any] = {"arguments": arguments}
|
||||
if tool.connection_id is not None:
|
||||
payload["connection_id"] = str(tool.connection_id)
|
||||
@@ -230,9 +233,12 @@ class ClipperClient:
|
||||
class LegacyClient:
|
||||
"""Use the existing CrewAI platform integrations API."""
|
||||
|
||||
def __init__(self, *, base_url: str | None = None) -> None:
|
||||
self._base_url = base_url
|
||||
|
||||
def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]:
|
||||
"""Get the actions available for the selected applications."""
|
||||
plus_api = PlusAPI()
|
||||
plus_api = PlusAPI(base_url=self._base_url)
|
||||
apps = [
|
||||
f"{selector.app}/{selector.action}"
|
||||
if selector.action is not None
|
||||
@@ -279,7 +285,7 @@ class LegacyClient:
|
||||
self, tool: ToolInfo, arguments: dict[str, Any]
|
||||
) -> ToolExecutionResult:
|
||||
"""Execute an action with the given arguments."""
|
||||
plus_api = PlusAPI()
|
||||
plus_api = PlusAPI(base_url=self._base_url)
|
||||
response = requests.post(
|
||||
url=(
|
||||
f"{plus_api.base_url.rstrip('/')}{plus_api.INTEGRATIONS_RESOURCE}"
|
||||
@@ -314,8 +320,10 @@ class LegacyClient:
|
||||
return ToolExecutionSuccess(output=data)
|
||||
|
||||
|
||||
def client_for_selector(selector: ApplicationSelector) -> IntegrationsClient:
|
||||
def client_for_selector(
|
||||
selector: ApplicationSelector, *, base_url: str | None = None
|
||||
) -> IntegrationsClient:
|
||||
"""Select the integrations client for an application selector."""
|
||||
if selector.connection_id is not None:
|
||||
return ClipperClient()
|
||||
return LegacyClient()
|
||||
return ClipperClient(base_url=base_url)
|
||||
return LegacyClient(base_url=base_url)
|
||||
@@ -53,7 +53,7 @@ def test_clipper_client_discovers_selected_actions(mock_get: Mock) -> None:
|
||||
mock_get.side_effect = [index_response, show_response]
|
||||
connection_id = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
tools = ClipperClient().get_actions(
|
||||
tools = ClipperClient(base_url="https://configured.example.test/").get_actions(
|
||||
[
|
||||
ApplicationSelector.from_string(f"github@{connection_id}"),
|
||||
ApplicationSelector.from_string("github/create_issue"),
|
||||
@@ -83,14 +83,14 @@ def test_clipper_client_discovers_selected_actions(mock_get: Mock) -> None:
|
||||
}
|
||||
assert mock_get.call_args_list == [
|
||||
call(
|
||||
"https://platform.example.test/clipper/v1/applications/github/tools",
|
||||
"https://configured.example.test/clipper/v1/applications/github/tools",
|
||||
headers=headers,
|
||||
params={"connection_id": str(connection_id)},
|
||||
timeout=30,
|
||||
verify=True,
|
||||
),
|
||||
call(
|
||||
"https://platform.example.test/clipper/v1/applications/github/tools/create_issue",
|
||||
"https://configured.example.test/clipper/v1/applications/github/tools/create_issue",
|
||||
headers=headers,
|
||||
params={},
|
||||
timeout=30,
|
||||
|
||||
Reference in new issue
Block a user