Validate JSON crews in project environments (#7171)

* Validate JSON crews in project environments

* fix(cli): address standalone deploy review feedback

---------

Co-authored-by: Vidit Ostwal <[email protected]>
This commit is contained in:
Lorenze Jay
2026-09-01 16:00:59 +00:00
committed by GitHub
co-authored by Vidit Ostwal
parent ec53d6f534
commit 917b9df6d7
5 changed files with 339 additions and 21 deletions
+155 -19
View File
@@ -38,11 +38,6 @@ import subprocess
import sys
from typing import Any
from crewai.project.json_loader import (
JSONProjectValidationError,
find_json_project_file,
validate_crew_project,
)
from crewai_core.project import (
ProjectDefinitionError,
configured_project_definition,
@@ -52,6 +47,8 @@ from crewai_core.project import (
)
from rich.console import Console
from crewai_cli.utils import normalize_package_name
console = Console()
logger = logging.getLogger(__name__)
@@ -108,15 +105,143 @@ _KNOWN_API_KEY_HINTS: dict[str, str] = {
}
def normalize_package_name(project_name: str) -> str:
"""Normalize a pyproject project.name into a Python package directory name.
_JSON_VALIDATION_MARKER = "CREWAI_JSON_VALIDATION_RESULT="
_JSON_VALIDATOR_SCRIPT = f"""
import json
import sys
Mirrors the rules in ``crewai.cli.create_crew.create_crew`` so the
validator agrees with the scaffolder about where ``src/<pkg>/`` should
live.
"""
folder = project_name.replace(" ", "_").replace("-", "_").lower()
return re.sub(r"[^a-zA-Z0-9_]", "", folder)
try:
from crewai.project.json_loader import validate_crew_project
project = validate_crew_project(sys.argv[1], agents_dir=sys.argv[2])
payload = {{"ok": True, "agent_names": project.agent_names}}
except BaseException as exc:
errors = getattr(exc, "errors", None)
payload = {{
"ok": False,
"error_type": type(exc).__name__,
"error": str(exc),
"errors": errors if isinstance(errors, list) else None,
}}
print({_JSON_VALIDATION_MARKER!r} + json.dumps(payload))
""".strip()
class _JSONProjectValidationError(ValueError):
def __init__(self, errors: list[str]) -> None:
self.errors = errors
super().__init__("\n".join(errors))
class _JSONProjectEnvironmentError(RuntimeError):
"""JSON validation could not run in the project's environment."""
hint = (
"Install `uv` if needed, run `uv sync` in the project directory, "
"then retry with `uv run crewai deploy validate`."
)
def _find_json_project_file(directory: Path, stem: str) -> Path | None:
for extension in (".jsonc", ".json"):
candidate = directory / f"{stem}{extension}"
if candidate.exists():
return candidate
return None
def _validate_json_project_in_project_env(
crew_path: Path, agents_dir: Path, project_root: Path
) -> list[str]:
"""Validate a JSON crew with the full CrewAI package from its project env."""
uv_path = shutil.which("uv")
if uv_path is None:
raise _JSONProjectEnvironmentError(
"The `uv` executable is required to validate JSON crews from a "
"standalone CLI installation."
)
try:
proc = subprocess.run( # noqa: S603 - fixed command plus trusted paths
[
uv_path,
"run",
"python",
"-c",
_JSON_VALIDATOR_SCRIPT,
str(crew_path),
str(agents_dir),
],
cwd=project_root,
capture_output=True,
text=True,
timeout=120,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise _JSONProjectEnvironmentError(
"JSON crew validation timed out after 120s."
) from exc
except OSError as exc:
raise _JSONProjectEnvironmentError(
f"Could not start JSON crew validation: {exc}"
) from exc
payload: dict[str, Any] | None = None
for line in reversed(proc.stdout.splitlines()):
if not line.startswith(_JSON_VALIDATION_MARKER):
continue
try:
payload = json.loads(line.removeprefix(_JSON_VALIDATION_MARKER))
except json.JSONDecodeError:
pass
break
if payload is None:
detail = (proc.stderr or proc.stdout or "").strip()
raise _JSONProjectEnvironmentError(
detail or "JSON crew validation produced no result."
)
if not payload.get("ok"):
errors = payload.get("errors")
if isinstance(errors, list) and all(isinstance(error, str) for error in errors):
raise _JSONProjectValidationError(errors)
error_type = payload.get("error_type", "Error")
error = payload.get("error", "JSON crew validation failed")
raise _JSONProjectEnvironmentError(f"{error_type}: {error}")
agent_names = payload.get("agent_names")
if not isinstance(agent_names, list) or not all(
isinstance(name, str) for name in agent_names
):
raise _JSONProjectEnvironmentError(
"JSON crew validation returned invalid agent names."
)
return agent_names
def _validate_json_project(
crew_path: Path, agents_dir: Path, project_root: Path
) -> list[str]:
"""Validate locally when possible, otherwise use the project's environment."""
try:
from crewai.project.json_loader import (
JSONProjectValidationError,
validate_crew_project,
)
except ModuleNotFoundError as exc:
if exc.name and (exc.name == "crewai" or exc.name.startswith("crewai.")):
return _validate_json_project_in_project_env(
crew_path, agents_dir, project_root
)
raise
try:
project = validate_crew_project(crew_path, agents_dir)
except JSONProjectValidationError as exc:
raise _JSONProjectValidationError(exc.errors) from exc
return project.agent_names
class DeployValidator:
@@ -232,11 +357,13 @@ class DeployValidator:
agents_dir = crew_path.parent / "agents"
agents_dir_ok = self._check_json_agents_dir(agents_dir)
project = None
agent_names: list[str] | None = None
try:
if agents_dir_ok:
project = validate_crew_project(crew_path, agents_dir)
except JSONProjectValidationError as e:
agent_names = _validate_json_project(
crew_path, agents_dir, self.project_root
)
except _JSONProjectValidationError as e:
self._add(
Severity.ERROR,
"invalid_crew_json",
@@ -245,6 +372,15 @@ class DeployValidator:
hint="Fix the JSON crew, agent, and task references before deploying.",
)
return self.results
except _JSONProjectEnvironmentError as e:
self._add(
Severity.ERROR,
"json_validation_environment_failed",
"Could not validate the JSON crew in the project environment",
detail=str(e),
hint=e.hint,
)
return self.results
except Exception as e:
self._add(
Severity.ERROR,
@@ -254,8 +390,8 @@ class DeployValidator:
)
return self.results
if project is not None:
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
if agent_names is not None:
self._check_env_vars_json(crew_path, agents_dir, agent_names)
self._check_version_vs_lockfile()
return self.results
@@ -288,7 +424,7 @@ class DeployValidator:
logger.debug("Skipping unreadable crew file %s: %s", crew_path, exc)
for name in agent_names:
agent_path = find_json_project_file(agents_dir, name)
agent_path = _find_json_project_file(agents_dir, name)
if agent_path is None:
continue
try:
+1 -2
View File
@@ -4,8 +4,7 @@ import subprocess
import click
from crewai_core.project import configured_project_definition, read_toml
from crewai_cli.deploy.validate import normalize_package_name
from crewai_cli.utils import build_env_with_all_tool_credentials
from crewai_cli.utils import build_env_with_all_tool_credentials, normalize_package_name
def _is_json_crew_project(project_root: Path | None = None) -> bool:
+7
View File
@@ -39,6 +39,7 @@ __all__ = [
"get_project_version",
"is_dmn_mode_enabled",
"load_env_vars",
"normalize_package_name",
"parse_toml",
"read_toml",
"render_template",
@@ -67,6 +68,12 @@ console = Console()
_TEMPLATE_TOKEN_RE = re.compile(r"{{([a-zA-Z_][a-zA-Z0-9_]*)}}")
def normalize_package_name(project_name: str) -> str:
"""Normalize a project name into its scaffolded Python package name."""
folder = project_name.replace(" ", "_").replace("-", "_").lower()
return re.sub(r"[^a-zA-Z0-9_]", "", folder)
def is_dmn_mode_enabled() -> bool:
"""Return True when the enterprise non-interactive mode is enabled."""
value = os.environ.get("CREWAI_DMN")
@@ -0,0 +1,148 @@
"""Regression coverage for crewai-cli installed without the full crewai package."""
import builtins
import json
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Any
import pytest
import crewai_cli.deploy.validate as validate_module
def test_reported_commands_run_without_crewai() -> None:
script = r"""
import builtins
import os
from pathlib import Path
import subprocess
from click.testing import CliRunner
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
real_import = builtins.__import__
def import_without_crewai(name, *args, **kwargs):
if name == "crewai" or name.startswith("crewai."):
raise ModuleNotFoundError("No module named 'crewai'", name="crewai")
return real_import(name, *args, **kwargs)
builtins.__import__ = import_without_crewai
from crewai_cli.cli import crewai
import crewai_cli.command as command_module
import crewai_cli.install_crew as install_module
def not_logged_in():
raise RuntimeError("not logged in")
command_module.get_auth_token = not_logged_in
install_module.build_env_with_all_tool_credentials = lambda: {}
install_module.subprocess.run = lambda *args, **kwargs: subprocess.CompletedProcess(
args[0], 0
)
runner = CliRunner()
with runner.isolated_filesystem():
Path("pyproject.toml").write_text('[project]\nname = "demo"\n')
deploy_result = runner.invoke(crewai, ["deploy", "list"])
assert deploy_result.exit_code == 0, deploy_result.output
assert "Please sign up/login" in deploy_result.output
assert not isinstance(deploy_result.exception, ModuleNotFoundError)
install_result = runner.invoke(crewai, ["install"])
assert install_result.exit_code == 0, install_result.output
assert not isinstance(install_result.exception, ModuleNotFoundError)
"""
proc = subprocess.run( # noqa: S603
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=False,
)
assert proc.returncode == 0, proc.stderr
def test_json_validation_uses_project_environment_without_crewai(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
real_import = builtins.__import__
def import_without_crewai(name: str, *args: Any, **kwargs: Any) -> Any:
if name == "crewai" or name.startswith("crewai."):
raise ModuleNotFoundError("No module named 'crewai'", name="crewai")
return real_import(name, *args, **kwargs)
captured: dict[str, Any] = {}
def fake_run(
command: list[str], **kwargs: Any
) -> subprocess.CompletedProcess[str]:
captured["command"] = command
captured["kwargs"] = kwargs
payload = {"ok": True, "agent_names": ["researcher"]}
return subprocess.CompletedProcess(
command,
0,
stdout=(
"uv output\n"
f"{validate_module._JSON_VALIDATION_MARKER}{json.dumps(payload)}\n"
),
stderr="",
)
monkeypatch.setattr(builtins, "__import__", import_without_crewai)
monkeypatch.setattr(shutil, "which", lambda command: "/usr/bin/uv")
monkeypatch.setattr(subprocess, "run", fake_run)
crew_path = tmp_path / "crew.jsonc"
agents_dir = tmp_path / "agents"
assert validate_module._validate_json_project(
crew_path, agents_dir, tmp_path
) == ["researcher"]
assert captured["command"][:4] == ["/usr/bin/uv", "run", "python", "-c"]
assert captured["kwargs"]["cwd"] == tmp_path
def test_project_environment_preserves_json_validation_errors(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
payload = {"ok": False, "errors": ["tasks[0] references missing_agent"]}
proc = subprocess.CompletedProcess(
[],
0,
stdout=f"{validate_module._JSON_VALIDATION_MARKER}{json.dumps(payload)}\n",
stderr="",
)
monkeypatch.setattr(shutil, "which", lambda command: "/usr/bin/uv")
monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: proc)
with pytest.raises(validate_module._JSONProjectValidationError) as exc_info:
validate_module._validate_json_project_in_project_env(
tmp_path / "crew.jsonc", tmp_path / "agents", tmp_path
)
assert exc_info.value.errors == ["tasks[0] references missing_agent"]
def test_missing_uv_has_an_actionable_project_environment_error(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(shutil, "which", lambda command: None)
with pytest.raises(validate_module._JSONProjectEnvironmentError) as exc_info:
validate_module._validate_json_project_in_project_env(
tmp_path / "crew.jsonc", tmp_path / "agents", tmp_path
)
assert "required" in str(exc_info.value)
assert "Install `uv`" in exc_info.value.hint
assert "uv sync" in exc_info.value.hint
+28
View File
@@ -16,6 +16,7 @@ import pytest
from crewai_cli.deploy.validate import (
DeployValidator,
Severity,
_JSONProjectEnvironmentError,
normalize_package_name,
)
@@ -205,6 +206,33 @@ def test_json_runtime_fields_are_deploy_errors(tmp_path: Path) -> None:
assert "runtime-only" in finding.detail
def test_json_project_environment_failure_has_actionable_hint(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_scaffold_json_crew(tmp_path)
def fail_in_project_environment(*args: object) -> list[str]:
raise _JSONProjectEnvironmentError("uv run failed")
monkeypatch.setattr(
"crewai_cli.deploy.validate._validate_json_project",
fail_in_project_environment,
)
validator = DeployValidator(project_root=tmp_path)
validator.run()
finding = next(
result
for result in validator.results
if result.code == "json_validation_environment_failed"
)
assert finding.title == "Could not validate the JSON crew in the project environment"
assert finding.detail == "uv run failed"
assert "Install `uv`" in finding.hint
assert "uv sync" in finding.hint
def test_json_crew_requires_agents_dir_without_classic_errors(tmp_path: Path) -> None:
_scaffold_json_crew(tmp_path)
for path in (tmp_path / "agents").iterdir():