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
This commit is contained in:
Joao Moura
2026-08-03 10:06:57 -07:00
parent 26518e0dec
commit 7f51bb6137
9 changed files with 362 additions and 3 deletions

View File

@@ -14,6 +14,7 @@ from crewai_cli.provider import (
)
from crewai_cli.utils import (
copy_template,
get_or_create_project_id,
is_dmn_mode_enabled,
load_env_vars,
write_env_file,
@@ -320,6 +321,8 @@ def create_crew(
copy_template(src_file, dst_file, name, class_name, folder_name)
if not parent_folder:
# Minted at creation so the project has a stable identity from run one.
get_or_create_project_id(folder_path / "pyproject.toml")
initialize_if_git_available(folder_path)
click.secho(f"Crew {name} created successfully!", fg="green", bold=True)

View File

@@ -5,6 +5,7 @@ import click
from crewai_core.telemetry import Telemetry
from crewai_cli.git import initialize_if_git_available
from crewai_cli.utils import get_or_create_project_id
from crewai_cli.version import get_crewai_tools_dependency
@@ -31,6 +32,8 @@ def create_flow(name: str, *, declarative: bool = False) -> None:
else:
_create_python_flow(name, class_name, folder_name, project_root)
# Minted at creation so the project has a stable identity from run one.
get_or_create_project_id(project_root / "pyproject.toml")
initialize_if_git_available(project_root)
click.secho(f"Flow {name} created successfully!", fg="green", bold=True)

View File

@@ -20,6 +20,7 @@ from crewai_cli.input_prompt import (
)
from crewai_cli.utils import (
build_env_with_all_tool_credentials,
ensure_project_id,
is_dmn_mode_enabled,
)
from crewai_cli.version import get_crewai_tools_dependency, get_crewai_version
@@ -617,6 +618,10 @@ def run_crew(
or declarative (JSON) crew. Layered over the definition's own
defaults; missing required values are prompted for interactively.
"""
# Backfills projects created before project_id existed. Only here, in a
# command the user explicitly invoked - never from the SDK during kickoff.
ensure_project_id()
# --definition is a pure override: run that flow directly.
if definition is not None:
_run_explicit_declarative_flow(

View File

@@ -16,6 +16,7 @@ from crewai_cli.config import Settings
from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
from crewai_cli.utils import (
build_env_with_tool_repository_credentials,
ensure_project_id,
get_project_description,
get_project_name,
get_project_version,
@@ -229,7 +230,8 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
def login(self) -> None:
get_user_id = _require_get_user_id()
login_response = self.plus_api_client.login_to_tool_repository(
user_identifier=get_user_id()
user_identifier=get_user_id(),
project_id=ensure_project_id(),
)
if login_response.status_code != 200:

View File

@@ -9,7 +9,9 @@ from typing import Any
import click
from crewai_core.project import (
get_or_create_project_id as get_or_create_project_id,
get_project_description as get_project_description,
get_project_id as get_project_id,
get_project_name as get_project_name,
get_project_version as get_project_version,
parse_toml as parse_toml,
@@ -29,8 +31,11 @@ __all__ = [
"build_env_with_tool_repository_credentials",
"copy_template",
"enable_prompt_line_editing",
"ensure_project_id",
"fetch_and_json_env_file",
"get_or_create_project_id",
"get_project_description",
"get_project_id",
"get_project_name",
"get_project_version",
"is_dmn_mode_enabled",
@@ -48,6 +53,32 @@ console = Console()
_TEMPLATE_TOKEN_RE = re.compile(r"{{([a-zA-Z_][a-zA-Z0-9_]*)}}")
def ensure_project_id(pyproject_path: str | Path = "pyproject.toml") -> str | None:
"""Return the project's id, minting one and telling the user if it was added.
Safe to call from any CLI command: returns None rather than raising when
there is no project, or when ``pyproject.toml`` is not writable.
Args:
pyproject_path: Path to the project's ``pyproject.toml``.
Returns:
The project id, or None if one could not be read or created.
"""
project_id, created = get_or_create_project_id(pyproject_path)
if created:
console.print(
f"Added [bold]project_id[/bold] to {pyproject_path} under "
# Escaped: Rich would otherwise parse [tool.crewai] as a style tag.
r"[bold]\[tool.crewai][/bold] so this project's runs and traces stay "
"linked. Commit it to share that link with your team.",
style="dim",
)
return project_id
def is_dmn_mode_enabled() -> bool:
"""Return True when the enterprise non-interactive mode is enabled."""
value = os.environ.get("CREWAI_DMN")

View File

@@ -69,7 +69,7 @@ class _WithUserIdentifier(TypedDict):
class LoginPayload(_WithUserIdentifier):
pass
project_id: NotRequired[str]
class TraceExecutionContext(TypedDict):
@@ -78,6 +78,7 @@ class TraceExecutionContext(TypedDict):
flow_name: str | None
crewai_version: str
privacy_level: str
project_id: NotRequired[str | None]
class TraceExecutionMetadata(TypedDict):
@@ -229,11 +230,24 @@ class PlusAPI:
return client.request(method, url, files=files, **request_kwargs)
def login_to_tool_repository(
self, user_identifier: str | None = None
self, user_identifier: str | None = None, project_id: str | None = None
) -> httpx.Response:
"""Log in to the tool repository.
This request is authenticated, so sending user_identifier and project_id
alongside it links the account to the local pseudonymous user id and to
the project the command was run from - letting prior anonymous usage of
that project be attributed after signup.
Args:
user_identifier: Local pseudonymous user id.
project_id: ``[tool.crewai].project_id`` of the current project.
"""
payload: LoginPayload = {}
if user_identifier:
payload["user_identifier"] = user_identifier
if project_id:
payload["project_id"] = project_id
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
def get_tool(self, handle: str) -> httpx.Response:

View File

@@ -6,6 +6,7 @@ from functools import reduce
from pathlib import Path, PureWindowsPath
import sys
from typing import Any
import uuid
from rich.console import Console
import tomli
@@ -221,3 +222,121 @@ def get_project_description(
return _get_project_attribute(
pyproject_path, ["project", "description"], require=require
)
_PROJECT_ID_KEY = "project_id"
def get_project_id(pyproject_path: str | Path = "pyproject.toml") -> str | None:
"""Return ``[tool.crewai].project_id`` if the project has one.
Read-only and safe to call from library code: it never creates or modifies
anything. Use this everywhere except the CLI commands that are allowed to
mint an id (see :func:`get_or_create_project_id`).
Args:
pyproject_path: Path to the project's ``pyproject.toml``.
Returns:
The project id, or None when the file is missing, unreadable, or has
no id configured.
"""
try:
pyproject_data = read_toml(pyproject_path)
except (OSError, tomli.TOMLDecodeError):
return None
project_id = get_crewai_project_config(pyproject_data).get(_PROJECT_ID_KEY)
return project_id if isinstance(project_id, str) and project_id else None
def get_or_create_project_id(
pyproject_path: str | Path = "pyproject.toml",
) -> tuple[str | None, bool]:
"""Return the project's id, minting and persisting one if absent.
Writes ``project_id`` into the ``[tool.crewai]`` table so it is committed
with the repository. That makes it stable across machines, teammates, CI,
and containers - unlike a machine- or user-derived identifier.
Only CLI commands the user explicitly invoked should call this. Library
code must use :func:`get_project_id` instead; silently rewriting a user's
``pyproject.toml`` during ``Crew.kickoff()`` would be surprising.
Args:
pyproject_path: Path to the project's ``pyproject.toml``.
Returns:
A ``(project_id, created)`` tuple. ``created`` is True only when an id
was minted and written on this call, so callers can tell the user. Both
values are ``(None, False)`` when the file is missing or not writable -
this is best-effort and never raises.
"""
existing = get_project_id(pyproject_path)
if existing:
return existing, False
path = Path(pyproject_path)
if not path.is_file():
return None, False
try:
content = path.read_text(encoding="utf-8")
except OSError:
return None, False
project_id = str(uuid.uuid4())
updated = _insert_project_id(content, project_id)
if updated is None:
return None, False
try:
path.write_text(updated, encoding="utf-8")
except OSError:
# Read-only checkout, permissions, container FS - not worth failing over.
return None, False
return project_id, True
def _insert_project_id(content: str, project_id: str) -> str | None:
"""Add ``project_id`` to the ``[tool.crewai]`` table in TOML source text.
Edits the raw text rather than round-tripping through a TOML writer so
formatting, ordering, and comments in the rest of the file are preserved.
Args:
content: Full contents of a ``pyproject.toml``.
project_id: The id to insert.
Returns:
Updated file contents, or None if the edit could not be made safely.
"""
lines = content.splitlines(keepends=True)
entry = f'{_PROJECT_ID_KEY} = "{project_id}"\n'
for index, line in enumerate(lines):
if line.strip() != "[tool.crewai]":
continue
# Insert at the end of the table, before the next table header, so the
# key cannot land inside a different section.
insert_at = len(lines)
for offset in range(index + 1, len(lines)):
if lines[offset].lstrip().startswith("["):
insert_at = offset
break
# Step back over trailing blank lines so the key stays in the table.
while insert_at > index + 1 and not lines[insert_at - 1].strip():
insert_at -= 1
if insert_at > 0 and not lines[insert_at - 1].endswith("\n"):
lines[insert_at - 1] += "\n"
lines.insert(insert_at, entry)
return "".join(lines)
# No [tool.crewai] table: append one rather than guessing where it belongs.
suffix = "" if content.endswith("\n") or not content else "\n"
return f"{content}{suffix}\n[tool.crewai]\n{entry}"

View File

@@ -14,6 +14,7 @@ from crewai_core.plus_api import (
TraceExecutionMetadata,
TraceFinalizePayload,
)
from crewai_core.project import get_project_id
from crewai_core.settings import Settings
from rich.console import Console
from rich.panel import Panel
@@ -145,6 +146,10 @@ class TraceBatchManager:
"flow_name": execution_metadata.get("flow_name", None),
"crewai_version": self.current_batch.version,
"privacy_level": user_context.get("privacy_level", "standard"),
# Read-only: never mints an id. Sent on both the ephemeral and
# authenticated paths, so a project's traces stay attributable
# to it before and after the user creates an account.
"project_id": get_project_id(),
}
execution_metadata_payload: TraceExecutionMetadata = {
"expected_duration_estimate": execution_metadata.get(

View File

@@ -0,0 +1,177 @@
"""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, created = get_or_create_project_id(pyproject)
assert created is True
assert uuid.UUID(project_id)
assert get_project_id(pyproject) == project_id
def test_id_is_stable_across_calls(pyproject):
first, created_first = get_or_create_project_id(pyproject)
second, created_second = get_or_create_project_id(pyproject)
assert created_first is True
assert created_second is False, "must not mint a second id"
assert first == second
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', "no tool.crewai table"),
('[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, created = get_or_create_project_id(path)
assert created is True, 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_missing_file_is_not_an_error(tmp_path):
project_id, created = get_or_create_project_id(tmp_path / "nope.toml")
assert project_id is None
assert created is False
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, created = get_or_create_project_id(pyproject)
finally:
pyproject.chmod(0o644)
assert project_id is None
assert created is False
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
def test_blank_id_is_treated_as_absent(tmp_path):
path = tmp_path / "pyproject.toml"
path.write_text('[tool.crewai]\ntype = "crew"\nproject_id = ""\n')
assert get_project_id(path) is None
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