Skip to content

Commit b10c4ff

Browse files
joaomdmouraclaude
andauthored
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>
1 parent 26518e0 commit b10c4ff

13 files changed

Lines changed: 691 additions & 12 deletions

File tree

lib/cli/src/crewai_cli/create_crew.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515
from crewai_cli.utils import (
1616
copy_template,
17+
get_or_create_project_id,
1718
is_dmn_mode_enabled,
1819
load_env_vars,
1920
write_env_file,
@@ -320,6 +321,8 @@ def create_crew(
320321
copy_template(src_file, dst_file, name, class_name, folder_name)
321322

322323
if not parent_folder:
324+
# Minted at creation so the project has a stable identity from run one.
325+
get_or_create_project_id(folder_path / "pyproject.toml")
323326
initialize_if_git_available(folder_path)
324327

325328
click.secho(f"Crew {name} created successfully!", fg="green", bold=True)

lib/cli/src/crewai_cli/create_flow.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from crewai_core.telemetry import Telemetry
66

77
from crewai_cli.git import initialize_if_git_available
8+
from crewai_cli.utils import get_or_create_project_id
89
from crewai_cli.version import get_crewai_tools_dependency
910

1011

@@ -31,6 +32,8 @@ def create_flow(name: str, *, declarative: bool = False) -> None:
3132
else:
3233
_create_python_flow(name, class_name, folder_name, project_root)
3334

35+
# Minted at creation so the project has a stable identity from run one.
36+
get_or_create_project_id(project_root / "pyproject.toml")
3437
initialize_if_git_available(project_root)
3538

3639
click.secho(f"Flow {name} created successfully!", fg="green", bold=True)

lib/cli/src/crewai_cli/create_json_crew.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from crewai_cli.tui_picker import pick_many, pick_one
1919
from crewai_cli.utils import (
2020
enable_prompt_line_editing,
21+
get_or_create_project_id,
2122
is_dmn_mode_enabled,
2223
load_env_vars,
2324
render_template,
@@ -968,6 +969,9 @@ def create_json_crew(
968969
for model in models:
969970
_setup_env(folder_path, model)
970971

972+
# Minted at creation so the project has a stable identity from run one.
973+
# This is the default `crewai create crew` path, not just --classic.
974+
get_or_create_project_id(folder_path / "pyproject.toml")
971975
initialize_if_git_available(folder_path)
972976

973977
click.echo()

lib/cli/src/crewai_cli/run_crew.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from crewai_cli.utils import (
2222
build_env_with_all_tool_credentials,
23+
get_or_create_project_id,
2324
is_dmn_mode_enabled,
2425
)
2526
from crewai_cli.version import get_crewai_tools_dependency, get_crewai_version
@@ -627,6 +628,15 @@ def run_crew(
627628
return
628629

629630
pyproject_data = read_toml()
631+
632+
# Backfills projects created before project_id existed. Only here, in a
633+
# command the user explicitly invoked - never from the SDK during kickoff.
634+
# Placed after the --definition early return so an explicit-flow run does
635+
# not touch the cwd; get_or_create_project_id itself refuses to act unless
636+
# [tool.crewai] is already present, so an unrelated project is never
637+
# rewritten.
638+
get_or_create_project_id()
639+
630640
if json_crew_definition := configured_project_json_crew(pyproject_data):
631641
# Declarative (JSON) crews resolve inputs the same way flows do: --inputs
632642
# layers over the crew's declared defaults, missing {placeholder}s are

lib/cli/src/crewai_cli/tools/main.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from crewai_cli.utils import (
1818
build_env_with_tool_repository_credentials,
1919
get_project_description,
20+
get_project_id,
2021
get_project_name,
2122
get_project_version,
2223
read_toml,
@@ -228,8 +229,12 @@ def install(self, handle: str) -> None:
228229

229230
def login(self) -> None:
230231
get_user_id = _require_get_user_id()
232+
# Read-only: login is not one of the sanctioned minting commands, and
233+
# `crewai tools create` calls it from inside a freshly scaffolded
234+
# directory before the tool project is persisted.
231235
login_response = self.plus_api_client.login_to_tool_repository(
232-
user_identifier=get_user_id()
236+
user_identifier=get_user_id(),
237+
project_id=get_project_id(),
233238
)
234239

235240
if login_response.status_code != 200:

lib/cli/src/crewai_cli/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
import click
1111
from crewai_core.project import (
12+
get_or_create_project_id as get_or_create_project_id,
1213
get_project_description as get_project_description,
14+
get_project_id as get_project_id,
1315
get_project_name as get_project_name,
1416
get_project_version as get_project_version,
1517
parse_toml as parse_toml,
@@ -30,7 +32,9 @@
3032
"copy_template",
3133
"enable_prompt_line_editing",
3234
"fetch_and_json_env_file",
35+
"get_or_create_project_id",
3336
"get_project_description",
37+
"get_project_id",
3438
"get_project_name",
3539
"get_project_version",
3640
"is_dmn_mode_enabled",

lib/crewai-core/src/crewai_core/plus_api.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class _WithUserIdentifier(TypedDict):
6969

7070

7171
class LoginPayload(_WithUserIdentifier):
72-
pass
72+
project_id: NotRequired[str]
7373

7474

7575
class TraceExecutionContext(TypedDict):
@@ -78,6 +78,7 @@ class TraceExecutionContext(TypedDict):
7878
flow_name: str | None
7979
crewai_version: str
8080
privacy_level: str
81+
project_id: NotRequired[str | None]
8182

8283

8384
class TraceExecutionMetadata(TypedDict):
@@ -229,11 +230,24 @@ def _make_multipart_request(
229230
return client.request(method, url, files=files, **request_kwargs)
230231

231232
def login_to_tool_repository(
232-
self, user_identifier: str | None = None
233+
self, user_identifier: str | None = None, project_id: str | None = None
233234
) -> httpx.Response:
235+
"""Log in to the tool repository.
236+
237+
This request is authenticated, so sending user_identifier and project_id
238+
alongside it links the account to the local pseudonymous user id and to
239+
the project the command was run from - letting prior anonymous usage of
240+
that project be attributed after signup.
241+
242+
Args:
243+
user_identifier: Local pseudonymous user id.
244+
project_id: ``[tool.crewai].project_id`` of the current project.
245+
"""
234246
payload: LoginPayload = {}
235247
if user_identifier:
236248
payload["user_identifier"] = user_identifier
249+
if project_id:
250+
payload["project_id"] = project_id
237251
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
238252

239253
def get_tool(self, handle: str) -> httpx.Response:

0 commit comments

Comments
 (0)