fix(cli): use login token for platform integrations

This commit is contained in:
ViditOstwal authored and GitHub committed 2026-10-21 13:53:44 +05:30
1 parent 3cece14e9b
commit 6df5b70657
8 files changed
+146 -71

No files matched your search

+10 -2
View File
@@ -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",
+2 -2
View File
@@ -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(
+49 -37
View File
@@ -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 ────────────────────────────────────────────────────────
+2 -2
View File
@@ -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")
+58 -15
View File
@@ -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)
@@ -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,