mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-08-12 09:18:04 +00:00
`crewai run` could rewrite unrelated projects (Cursor bugbot, high):
- get_or_create_project_id ran before the cwd was established as a CrewAI
project, and _set_project_id appended a [tool.crewai] table when none
existed. Any directory with a pyproject.toml could therefore gain one -
including on `crewai run --definition`, which may otherwise succeed.
- _set_project_id no longer creates the table; it returns None when
[tool.crewai] is absent, so a key is only ever added to a table the project
already declares. The templates all ship the table, so no create path needs
the old fallback.
- The minting call in run_crew moved after the --definition early return, so
an explicit-flow run does not touch the cwd at all.
- Presence is checked, not truthiness: an empty [tool.crewai] is still a
CrewAI marker, and get_crewai_project_config returns {} both for that and
for an absent table.
- Verified an unrelated project's pyproject.toml is byte-identical after a
mint attempt.
Whitespace-only project_id accepted as valid (CodeRabbit):
- `project_id = " "` is truthy, so it was returned as an identity and would
have propagated into login payloads and tracing context. It also meant the
'" "' parameter of the replacement test asserted nothing.
- Added _usable_project_id, which strips before deciding, used by both
get_project_id and the locked mint path.
Concurrency test could hang CI (CodeRabbit, major):
- Neither the barrier nor the joins had timeouts, so a thread dying early or
blocking on the lock would hang the job rather than fail it. The result
count was also unchecked, so a dead thread still passed.
- Added timeouts, an explicit liveness assertion, a result-count assertion, a
lock around the shared result list, and corrected the docstring: this covers
the read-modify-write race with threads, not the cross-process backend.
Tests: 35, up from 32. New coverage for the absent-table refusal and three
whitespace forms; the blank-id replacement case now asserts a real uuid
replaced the blank value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t
343 lines
11 KiB
Python
343 lines
11 KiB
Python
"""Tests for the project_id used to link OSS usage to an enterprise account."""
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
from crewai_core.project import (
|
|
get_or_create_project_id,
|
|
get_project_id,
|
|
parse_toml,
|
|
)
|
|
|
|
|
|
CREW_PYPROJECT = """\
|
|
[project]
|
|
name = "my_crew"
|
|
version = "0.1.0"
|
|
dependencies = ["crewai"]
|
|
|
|
[tool.crewai]
|
|
type = "crew"
|
|
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def pyproject(tmp_path):
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text(CREW_PYPROJECT)
|
|
return path
|
|
|
|
|
|
def test_returns_none_when_no_id_configured(pyproject):
|
|
assert get_project_id(pyproject) is None
|
|
|
|
|
|
def test_mints_and_persists_an_id(pyproject):
|
|
project_id = get_or_create_project_id(pyproject)
|
|
|
|
assert uuid.UUID(project_id)
|
|
assert get_project_id(pyproject) == project_id
|
|
|
|
|
|
def test_id_is_stable_across_calls(pyproject):
|
|
first = get_or_create_project_id(pyproject)
|
|
second = get_or_create_project_id(pyproject)
|
|
|
|
assert first == second, "must not mint a second id"
|
|
assert uuid.UUID(first)
|
|
|
|
|
|
def test_id_lands_in_the_tool_crewai_table(pyproject):
|
|
project_id = get_or_create_project_id(pyproject)
|
|
|
|
data = parse_toml(pyproject.read_text())
|
|
assert data["tool"]["crewai"]["project_id"] == project_id
|
|
assert data["tool"]["crewai"]["type"] == "crew", "existing keys must survive"
|
|
|
|
|
|
def test_other_tables_are_preserved(pyproject):
|
|
get_or_create_project_id(pyproject)
|
|
|
|
data = parse_toml(pyproject.read_text())
|
|
assert data["project"]["name"] == "my_crew"
|
|
assert data["project"]["dependencies"] == ["crewai"]
|
|
assert data["build-system"]["build-backend"] == "hatchling.build"
|
|
|
|
|
|
def test_comments_and_formatting_are_preserved(tmp_path):
|
|
"""Raw-text editing rather than a TOML round-trip, so comments survive."""
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text(
|
|
'# top comment\n[project]\nname = "x" # inline comment\n\n[tool.crewai]\ntype = "flow"\n'
|
|
)
|
|
|
|
get_or_create_project_id(path)
|
|
|
|
content = path.read_text()
|
|
assert "# top comment" in content
|
|
assert "# inline comment" in content
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source", "label"),
|
|
[
|
|
('[project]\nname = "x"\n\n[tool.crewai]\ntype = "crew"\n', "table then EOF"),
|
|
('[tool.crewai]\ntype = "crew"', "no trailing newline"),
|
|
('[project]\nname = "x"\n[tool.crewai]\n[other]\na = 1\n', "empty table"),
|
|
(
|
|
'[tool.crewai]\ntype = "crew"\n\n\n[build-system]\nrequires = []\n',
|
|
"blank lines before next table",
|
|
),
|
|
],
|
|
)
|
|
def test_produces_valid_toml_for_varied_layouts(tmp_path, source, label):
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text(source)
|
|
|
|
project_id = get_or_create_project_id(path)
|
|
|
|
assert project_id is not None, label
|
|
data = parse_toml(path.read_text())
|
|
assert data["tool"]["crewai"]["project_id"] == project_id, label
|
|
|
|
|
|
def test_id_does_not_leak_into_a_neighbouring_table(tmp_path):
|
|
"""The key must never land under [build-system]."""
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text(
|
|
'[tool.crewai]\ntype = "crew"\n\n[build-system]\nrequires = ["hatchling"]\n'
|
|
)
|
|
|
|
get_or_create_project_id(path)
|
|
|
|
data = parse_toml(path.read_text())
|
|
assert "project_id" in data["tool"]["crewai"]
|
|
assert "project_id" not in data["build-system"]
|
|
|
|
|
|
def test_absent_tool_crewai_table_is_never_created(tmp_path):
|
|
"""Refuse to mint rather than rewrite a non-CrewAI project's pyproject.toml.
|
|
|
|
`crewai run` in any directory that merely happens to have a pyproject.toml
|
|
must not gain a [tool.crewai] table as a side effect.
|
|
"""
|
|
path = tmp_path / "pyproject.toml"
|
|
original = '[project]\nname = "unrelated"\n'
|
|
path.write_text(original)
|
|
|
|
assert get_or_create_project_id(path) is None
|
|
assert path.read_text() == original, "unrelated project was modified"
|
|
|
|
|
|
def test_missing_file_is_not_an_error(tmp_path):
|
|
assert get_or_create_project_id(tmp_path / "nope.toml") is None
|
|
|
|
|
|
def test_malformed_toml_is_not_an_error(tmp_path):
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text("this is not [valid toml")
|
|
|
|
assert get_project_id(path) is None
|
|
|
|
|
|
def test_read_only_file_is_not_an_error(pyproject):
|
|
"""A read-only checkout must not break the command that called this."""
|
|
pyproject.chmod(0o444)
|
|
try:
|
|
project_id = get_or_create_project_id(pyproject)
|
|
finally:
|
|
pyproject.chmod(0o644)
|
|
|
|
assert project_id is None
|
|
|
|
|
|
def test_get_project_id_never_creates_anything(pyproject):
|
|
"""Library code calls the read-only variant; it must not mutate the file."""
|
|
before = pyproject.read_text()
|
|
|
|
assert get_project_id(pyproject) is None
|
|
|
|
assert pyproject.read_text() == before
|
|
|
|
|
|
@pytest.mark.parametrize("blank", ['""', "' '", '"\\t"'])
|
|
def test_blank_or_whitespace_id_is_treated_as_absent(tmp_path, blank):
|
|
"""Whitespace is truthy in Python but is not an identity.
|
|
|
|
Accepting it would propagate a useless value into login payloads and
|
|
tracing context.
|
|
"""
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text(f'[tool.crewai]\ntype = "crew"\nproject_id = {blank}\n')
|
|
|
|
assert get_project_id(path) is None
|
|
|
|
|
|
def test_malformed_toml_is_never_written_to(tmp_path):
|
|
"""Appending to a file we cannot parse would corrupt it further."""
|
|
path = tmp_path / "pyproject.toml"
|
|
original = 'this is not [valid toml\nproject_id = "x'
|
|
path.write_text(original)
|
|
|
|
assert get_or_create_project_id(path) is None
|
|
assert path.read_text() == original, "malformed file must be left untouched"
|
|
|
|
|
|
@pytest.mark.parametrize("blank", ['""', "''", '" "', '"\\t\\t"'])
|
|
def test_blank_existing_id_is_replaced_not_duplicated(tmp_path, blank):
|
|
"""A blank id reads as absent; appending would make a duplicate key."""
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text(f'[tool.crewai]\ntype = "crew"\nproject_id = {blank}\n')
|
|
|
|
project_id = get_or_create_project_id(path)
|
|
|
|
content = path.read_text()
|
|
assert content.count("project_id") == 1, f"duplicate key: {content!r}"
|
|
data = parse_toml(content) # would raise on a duplicate key
|
|
assert data["tool"]["crewai"]["project_id"] == project_id
|
|
assert data["tool"]["crewai"]["type"] == "crew"
|
|
assert uuid.UUID(project_id), "must mint a real id, not keep the blank one"
|
|
|
|
|
|
def test_non_string_existing_id_is_replaced(tmp_path):
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text("[tool.crewai]\nproject_id = 42\n")
|
|
|
|
project_id = get_or_create_project_id(path)
|
|
|
|
data = parse_toml(path.read_text())
|
|
assert data["tool"]["crewai"]["project_id"] == project_id
|
|
assert isinstance(project_id, str)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"header",
|
|
[
|
|
"[tool.crewai] # crewai config",
|
|
"[tool.crewai]# no space",
|
|
"[tool.crewai]\t# tab then comment",
|
|
],
|
|
)
|
|
def test_table_header_with_trailing_comment_is_found(tmp_path, header):
|
|
"""A commented header is valid TOML; missing it appends a duplicate table."""
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text(f'{header}\ntype = "crew"\n')
|
|
|
|
project_id = get_or_create_project_id(path)
|
|
|
|
content = path.read_text()
|
|
assert content.count("[tool.crewai]") == 1, f"duplicate table: {content!r}"
|
|
data = parse_toml(content) # would raise on a redefined table
|
|
assert data["tool"]["crewai"]["project_id"] == project_id
|
|
assert data["tool"]["crewai"]["type"] == "crew"
|
|
|
|
|
|
def test_similar_table_names_are_not_matched(tmp_path):
|
|
"""[tool.crewai-extra] must not be mistaken for [tool.crewai]."""
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text('[tool.crewai-extra]\nfoo = 1\n\n[tool.crewai]\ntype = "crew"\n')
|
|
|
|
project_id = get_or_create_project_id(path)
|
|
|
|
data = parse_toml(path.read_text())
|
|
assert data["tool"]["crewai"]["project_id"] == project_id
|
|
assert "project_id" not in data["tool"]["crewai-extra"]
|
|
|
|
|
|
def test_crlf_line_endings_are_preserved(tmp_path):
|
|
"""read_text/write_text would silently rewrite the whole file as LF."""
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_bytes(b'[project]\r\nname = "x"\r\n\r\n[tool.crewai]\r\ntype = "crew"\r\n')
|
|
|
|
project_id = get_or_create_project_id(path)
|
|
|
|
raw = path.read_bytes()
|
|
assert b"\r\n" in raw
|
|
assert raw.count(b"\n") == raw.count(b"\r\n"), "mixed line endings introduced"
|
|
assert parse_toml(raw.decode())["tool"]["crewai"]["project_id"] == project_id
|
|
|
|
|
|
def test_lf_file_stays_lf(tmp_path):
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_bytes(b'[tool.crewai]\ntype = "crew"\n')
|
|
|
|
get_or_create_project_id(path)
|
|
|
|
assert b"\r\n" not in path.read_bytes()
|
|
|
|
|
|
def test_concurrent_minting_converges_on_one_id(tmp_path):
|
|
"""Concurrent minters must all return the id that ends up on disk.
|
|
|
|
Uses threads in one process, so it covers the read-modify-write race rather
|
|
than the cross-process lock backend itself.
|
|
"""
|
|
import threading
|
|
|
|
workers = 8
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text(CREW_PYPROJECT)
|
|
|
|
returned: list[str | None] = []
|
|
results_lock = threading.Lock()
|
|
# Timed out rather than unbounded: a thread dying before the barrier, or
|
|
# blocking on the lock, would otherwise hang CI instead of failing.
|
|
start = threading.Barrier(workers, timeout=30)
|
|
|
|
def mint() -> None:
|
|
start.wait()
|
|
project_id = get_or_create_project_id(path)
|
|
with results_lock:
|
|
returned.append(project_id)
|
|
|
|
threads = [threading.Thread(target=mint) for _ in range(workers)]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join(timeout=30)
|
|
|
|
assert not [t for t in threads if t.is_alive()], "thread did not finish in time"
|
|
assert len(returned) == workers, f"only {len(returned)}/{workers} threads returned"
|
|
|
|
persisted = parse_toml(path.read_text())["tool"]["crewai"]["project_id"]
|
|
assert set(returned) == {persisted}, (
|
|
f"callers disagreed with disk: returned={set(returned)} persisted={persisted}"
|
|
)
|
|
|
|
|
|
def test_file_mode_is_preserved(tmp_path):
|
|
"""The atomic replace must not widen permissions on pyproject.toml."""
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text(CREW_PYPROJECT)
|
|
path.chmod(0o600)
|
|
|
|
get_or_create_project_id(path)
|
|
|
|
assert path.stat().st_mode & 0o777 == 0o600
|
|
|
|
|
|
def test_no_temp_files_left_behind(tmp_path):
|
|
path = tmp_path / "pyproject.toml"
|
|
path.write_text(CREW_PYPROJECT)
|
|
|
|
get_or_create_project_id(path)
|
|
|
|
assert [p.name for p in tmp_path.iterdir()] == ["pyproject.toml"]
|
|
|
|
|
|
def test_ids_are_unique_across_projects(tmp_path):
|
|
ids = set()
|
|
for name in ("a", "b", "c"):
|
|
path = tmp_path / name / "pyproject.toml"
|
|
path.parent.mkdir()
|
|
path.write_text(CREW_PYPROJECT)
|
|
project_id = get_or_create_project_id(path)
|
|
ids.add(project_id)
|
|
|
|
assert len(ids) == 3
|