Files
crewAI/lib/crewai/tests/telemetry/test_project_id.py
João Moura b10c4ffcdc
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
feat: add project_id to link OSS usage to an enterprise account (#6791)
* feat: add project_id to link OSS usage to an enterprise account

Adds a stable per-project identifier so a project's OSS traces and runs can
be attributed to an account after signup. There was no such identifier
before: [tool.crewai] held only `type`, the deploy UUID was printed to the
console but never persisted, Settings.org_uuid is global rather than
per-project, and trace batches carried only crew_fingerprint/crew_name.

The id lives in the project's pyproject.toml, so it is committed with the
repository and stays stable across machines, teammates, CI, and containers -
unlike a machine- or user-derived identifier, which is unstable in exactly
the containerized production environments that matter most.

crewai-core:
- get_project_id(): read-only lookup of [tool.crewai].project_id. Safe for
  library code; never creates or modifies anything.
- get_or_create_project_id(): mints a uuid4 and persists it, returning
  (id, created) so callers can tell the user. Best-effort - returns
  (None, False) for a missing, malformed, or read-only pyproject.toml rather
  than raising.
- Insertion edits the raw TOML text instead of round-tripping through a
  writer, so comments, key order, and formatting elsewhere survive. The key
  is placed at the end of the [tool.crewai] table, before the next table
  header, so it cannot land in a neighbouring section.
- LoginPayload and TraceExecutionContext gain optional project_id.

Sent on two paths:
- Traces: project_id is added to execution_context, which is sent on both
  the ephemeral and authenticated paths, so a project's traces remain
  attributable before and after the user creates an account.
- Login: `crewai login` already sends the pseudonymous user_identifier on an
  authenticated request; adding project_id means one request carries account
  + user + project, which is the link itself.

Minting is restricted to CLI commands the user explicitly invoked - `crewai
create` for new projects and `crewai run` to backfill existing ones - and is
announced when it happens. Library code only ever reads. Silently rewriting
a user's pyproject.toml during Crew.kickoff() would be surprising.

Privacy: project_id is a random uuid4 in a file the user commits. It is
visible in a diff, contains nothing personal, and identifies a project
rather than a person - so this needs none of the notice changes that
attaching a user identifier to all telemetry would require.

Tests: 18 new tests covering minting, stability, table placement, comment
and formatting preservation, five pyproject layouts, the neighbouring-table
regression, and graceful handling of missing/malformed/read-only files.
Verified end-to-end that both create paths mint distinct ids, that the trace
payload carries project_id on both the ephemeral and authenticated paths,
and that the login payload carries user_identifier and project_id together.

Follow-ups, deliberately not included: adding project_id to telemetry spans,
and backend persistence of the (account, user_identifier, project_id) triple.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t

* refactor: drop the console announcement when minting project_id

Minting now happens silently. With no message to print, the (id, created)
tuple had no consumer, so simplify the API rather than keep the flag around
for a hypothetical caller:

- get_or_create_project_id() returns `str | None` instead of
  `tuple[str | None, bool]`.
- Remove crewai_cli.utils.ensure_project_id, which existed only to print the
  message and discard the flag. The four call sites (crewai create crew,
  crewai create flow, crewai run, and tool-repository login) now call
  get_or_create_project_id directly.
- Update tests for the simplified signature; still 18 tests covering minting,
  stability, table placement, formatting preservation, five pyproject
  layouts, and missing/malformed/read-only handling.

Behaviour is otherwise unchanged: minting stays restricted to CLI commands
the user invoked, library code still only reads via get_project_id, and a
missing or read-only pyproject.toml still returns None rather than raising.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t

* fix: harden project_id minting against TOML corruption; address review

Several reviewers found ways the raw-text edit could produce invalid TOML.
Each is now fixed and covered by a test that fails without the fix.

Duplicate project_id key (Cursor bugbot, Copilot x2):
- get_project_id() reports a blank or non-string value as "absent", so a file
  containing `project_id = ""` took the insert path and gained a second
  project_id line - a duplicate key, and therefore invalid TOML that no
  tomli-based tool could read afterwards.
- _insert_project_id is now _set_project_id: it replaces an existing
  assignment inside [tool.crewai] instead of appending unconditionally.

Table header with a trailing comment (CodeRabbit major, Cursor bugbot):
- `[tool.crewai]  # config` is valid TOML but failed exact string equality,
  so the fallback appended a second [tool.crewai] header - a redefined table,
  also invalid TOML, and silent because get_project_id swallows the resulting
  decode error.
- Added _is_table_header(), which tolerates a trailing comment and does not
  match similar names such as [tool.crewai-extra].

Writing into malformed TOML (Cursor bugbot, Copilot):
- get_or_create_project_id relied on get_project_id, which cannot distinguish
  "no id" from "unparsable file", so it appended to files it could not parse.
- The locked path now parses explicitly and bails on a decode error, and
  re-parses the updated content before writing, so this feature can never be
  the reason a project's pyproject.toml stops parsing.

Concurrency and atomicity (CodeRabbit major):
- Two CLI processes could both see no id, mint different uuids, and clobber
  each other, leaving a caller holding an id that is not on disk. Minting now
  takes the existing crewai_core cross-process lock, re-reads under it, and
  returns the id that persists.
- Writes go through a temp file in the same directory plus os.replace, so an
  interruption cannot truncate pyproject.toml. File mode is copied across, and
  the temp file is removed on failure.
- os.replace only needs a writable directory, which would have let an atomic
  write silently overwrite a file the user marked read-only; writability is
  now checked explicitly so that case still returns None.

Line endings (CodeRabbit):
- Path.read_text/write_text normalized CRLF to LF, so minting would rewrite a
  CRLF-committed file entirely. Read and write now use newline="" and the
  inserted line ending is derived from the existing content.

Default create path skipped minting (Cursor bugbot):
- `crewai create crew` defaults to create_json_crew; only the --classic and
  flow paths minted, so most new projects had no id until a later command.
  Wired into create_json_crew as well. Verified all three paths now mint
  distinct ids.

Do not mint during login (CodeRabbit major):
- ToolCommand.login ran get_or_create_project_id, which is outside the
  sanctioned minting commands and is invoked by `crewai tools create` from a
  freshly scaffolded directory before the project is persisted. It now uses
  the read-only get_project_id. Verified login leaves pyproject.toml
  untouched.

Not applied: Copilot asked for a console message when an id is written, in
create_crew and create_flow. Minting was made deliberately silent in the
previous commit, so the (id, created) tuple and the announcement are both
gone by design.

Tests: 32 in test_project_id.py, up from 18. New cases cover blank and
non-string existing ids, three commented-header forms, similar table names,
malformed input, CRLF and LF preservation, concurrent minting convergence,
file-mode preservation, and temp-file cleanup. Confirmed the header and
duplicate-key tests fail when the fixes are reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t

* fix: never create [tool.crewai], treat whitespace ids as absent, harden test

`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

* chore(deps): force gitpython 3.1.57+ for GHSA-p538-c434-8v24 and GHSA-3f7w-8rr8-f37f

Unrelated to project_id; bundled here only because it blocks this PR's
vulnerability scan. Two advisories were published for gitpython 3.1.55 after
main last passed the scan:

- GHSA-p538-c434-8v24: arbitrary file truncation via `git rev-list --output`
  argument injection. Fixed in 3.1.56.
- GHSA-3f7w-8rr8-f37f: unguarded git option forwarding in
  IndexFile.checkout() and TagReference. Fixed in 3.1.57.

- Bump the override floor to gitpython>=3.1.57 and declare the same floor in
  crewai-tools, so consumers installing the published package are covered and
  not only this repo's lock.
- 3.1.57 was published 2026-07-26, past gitpython's exclude-newer-package
  cutoff of 2026-07-24, so that cutoff moves to 2026-07-27. Without it the
  floor is unresolvable.

pip-audit against the updated lock reports no known vulnerabilities.
Verified gitpython 3.1.57 resolves and that crewai_tools and crewai_cli.git
still import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:25:06 -07:00

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