mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-09 08:55:09 +00:00
Compare commits
4 Commits
matcha/log
...
devin/1779
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5951226965 | ||
|
|
83c59b38dd | ||
|
|
aae1ccbef4 | ||
|
|
b4bc026c77 |
@@ -4,64 +4,6 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="19 مايو 2026">
|
||||
## v1.14.5
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### الميزات
|
||||
- إلغاء استخدام `CrewAgentExecutor`، وتعيين وكلاء الطاقم الافتراضيين إلى `AgentExecutor`
|
||||
- تحسين أدوات صندوق الرمل Daytona
|
||||
- إضافة معلمة بدء `restore_from_state_id`
|
||||
- إضافة تسليط الضوء على `ExaSearchTool`، وإعادة تسميته من `EXASearchTool`
|
||||
|
||||
### إصلاحات الأخطاء
|
||||
- إصلاح تسرب الذاكرة في `git.py` باستخدام `cached_property`
|
||||
- عرض استدعاءات الأدوات المتدفقة عندما تكون `available_functions` غائبة
|
||||
- ضمان تحميل أحداث `skills` للتتبع
|
||||
- تصحيح مسار نقطة النهاية للحالة من `/{kickoff_id}/status` إلى `/status/{kickoff_id}`
|
||||
- استعادة كتلة الشيفرة المفقودة في دليل التدفق الأول للغة البرتغالية (pt-BR)
|
||||
- منع `result_as_answer` من إرجاع رسائل الخطأ أو الكتل المرتبطة كإجابة نهائية
|
||||
- الحفاظ على مخرجات المهام عبر تفريغ الدفعات غير المتزامنة
|
||||
- دائمًا استعادة `task.output_pydantic` في كتلة finally
|
||||
- التعامل مع إدخال `BaseModel` في `convert_to_model`
|
||||
|
||||
### الوثائق
|
||||
- تحديث سجل التغييرات والإصدار لـ v1.14.5
|
||||
- إضافة دليل ترقية OSS و انتقال الطاقم إلى التدفق
|
||||
- توثيق متغيرات البيئة الإضافية لأدوات المطور
|
||||
- إضافة وثائق لـ `TavilyGetResearch`
|
||||
|
||||
### إعادة الهيكلة
|
||||
- استخراج واجهة سطر الأوامر إلى حزمة مستقلة `crewai-cli`
|
||||
|
||||
## المساهمون
|
||||
|
||||
@NIK-TIGER-BILL, @akaKuruma, @cgoeppinger, @github-actions[bot], @greysonlalonde, @heitorado, @irfaan101, @iris-clawd, @lorenzejay, @manisrinivasan2k1, @minasami-pr, @mislavivanda, @theCyberTech, @theishangoswami, @wishhyt
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="18 مايو 2026">
|
||||
## v1.14.5a7
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a7)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### الوثائق
|
||||
- تحديث سجل التغييرات والإصدار لـ v1.14.5a6
|
||||
|
||||
### تغييرات كسرية
|
||||
- إلغاء حقل function_calling_llm
|
||||
|
||||
## المساهمون
|
||||
|
||||
@greysonlalonde, @heitorado
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="15 مايو 2026">
|
||||
## v1.14.5a6
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ Crew Studio هو طريقة مبتكرة لإنشاء طواقم وكلاء ال
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="الإجابة على الأسئلة">
|
||||
أجب على أسئلة التوضيح من مساعد الطاقم لتنقيح
|
||||
متطلباتك.
|
||||
@@ -160,10 +161,12 @@ Crew Studio هو طريقة مبتكرة لإنشاء طواقم وكلاء ال
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="الموافقة أو التعديل">
|
||||
وافق على الخطة أو اطلب تغييرات إذا لزم الأمر.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="التنزيل أو النشر">
|
||||
نزّل الكود للتخصيص أو انشر مباشرة على المنصة.
|
||||
</Step>
|
||||
|
||||
@@ -802,6 +802,7 @@ The tables below show a representative sample of current top-performing models a
|
||||
Begin with well-established models like **GPT-4.1**, **Claude 3.7 Sonnet**, or **Gemini 2.0 Flash** that offer good performance across multiple dimensions and have extensive real-world validation.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Identify Specialized Needs">
|
||||
Determine if your crew has specific requirements (coding, reasoning, speed)
|
||||
that would benefit from specialized models like **Claude 4 Sonnet** for
|
||||
@@ -809,6 +810,7 @@ The tables below show a representative sample of current top-performing models a
|
||||
consider fast inference providers like **Groq** alongside model selection.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Implement Multi-Model Strategy">
|
||||
Use different models for different agents based on their roles.
|
||||
High-capability models for managers and complex tasks, efficient models for
|
||||
|
||||
1899
docs/docs.json
1899
docs/docs.json
File diff suppressed because it is too large
Load Diff
@@ -4,64 +4,6 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="May 19, 2026">
|
||||
## v1.14.5
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
- Deprecate `CrewAgentExecutor`, default Crew agents to `AgentExecutor`
|
||||
- Improve Daytona sandbox tools
|
||||
- Add `restore_from_state_id` kickoff parameter
|
||||
- Add highlights to `ExaSearchTool`, rename from `EXASearchTool`
|
||||
|
||||
### Bug Fixes
|
||||
- Fix memory leak in `git.py` by using `cached_property`
|
||||
- Surface streamed tool calls when `available_functions` is absent
|
||||
- Ensure `skills` loading events for traces
|
||||
- Correct status endpoint path from `/{kickoff_id}/status` to `/status/{kickoff_id}`
|
||||
- Restore missing code block in pt-BR first-flow guide
|
||||
- Prevent `result_as_answer` from returning hook-block or error messages as final answer
|
||||
- Preserve task outputs across async batch flush
|
||||
- Always restore `task.output_pydantic` in finally block
|
||||
- Handle `BaseModel` input in `convert_to_model`
|
||||
|
||||
### Documentation
|
||||
- Update changelog and version for v1.14.5
|
||||
- Add OSS upgrade & crew-to-flow migration guide
|
||||
- Document additional env vars for devtools
|
||||
- Add docs for `TavilyGetResearch`
|
||||
|
||||
### Refactoring
|
||||
- Extract CLI into standalone `crewai-cli` package
|
||||
|
||||
## Contributors
|
||||
|
||||
@NIK-TIGER-BILL, @akaKuruma, @cgoeppinger, @github-actions[bot], @greysonlalonde, @heitorado, @irfaan101, @iris-clawd, @lorenzejay, @manisrinivasan2k1, @minasami-pr, @mislavivanda, @theCyberTech, @theishangoswami, @wishhyt
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="May 18, 2026">
|
||||
## v1.14.5a7
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a7)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Documentation
|
||||
- Update changelog and version for v1.14.5a6
|
||||
|
||||
### Breaking Changes
|
||||
- Deprecate function_calling_llm field
|
||||
|
||||
## Contributors
|
||||
|
||||
@greysonlalonde, @heitorado
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="May 15, 2026">
|
||||
## v1.14.5a6
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ Here's a typical workflow for creating a crew with Crew Studio:
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Answer Questions">
|
||||
Respond to clarifying questions from the Crew Assistant to refine your
|
||||
requirements.
|
||||
@@ -160,10 +161,12 @@ Here's a typical workflow for creating a crew with Crew Studio:
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Approve or Modify">
|
||||
Approve the plan or request changes if necessary.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Download or Deploy">
|
||||
Download the code for customization or deploy directly to the platform.
|
||||
</Step>
|
||||
|
||||
@@ -805,6 +805,7 @@ The tables below show a representative sample of current top-performing models a
|
||||
Begin with well-established models like **GPT-4.1**, **Claude 3.7 Sonnet**, or **Gemini 2.0 Flash** that offer good performance across multiple dimensions and have extensive real-world validation.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Identify Specialized Needs">
|
||||
Determine if your crew has specific requirements (coding, reasoning, speed)
|
||||
that would benefit from specialized models like **Claude 4 Sonnet** for
|
||||
@@ -812,6 +813,7 @@ The tables below show a representative sample of current top-performing models a
|
||||
consider fast inference providers like **Groq** alongside model selection.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Implement Multi-Model Strategy">
|
||||
Use different models for different agents based on their roles.
|
||||
High-capability models for managers and complex tasks, efficient models for
|
||||
|
||||
@@ -4,64 +4,6 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="2026년 5월 19일">
|
||||
## v1.14.5
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 기능
|
||||
- `CrewAgentExecutor` 사용 중단, 기본 Crew 에이전트를 `AgentExecutor`로 설정
|
||||
- Daytona 샌드박스 도구 개선
|
||||
- `restore_from_state_id` 시작 매개변수 추가
|
||||
- `ExaSearchTool`에 하이라이트 추가, 이름을 `EXASearchTool`에서 변경
|
||||
|
||||
### 버그 수정
|
||||
- `git.py`에서 `cached_property`를 사용하여 메모리 누수 수정
|
||||
- `available_functions`가 없을 때 스트리밍 도구 호출 표시
|
||||
- 추적을 위한 `skills` 로딩 이벤트 보장
|
||||
- 상태 엔드포인트 경로를 `/{kickoff_id}/status`에서 `/status/{kickoff_id}`로 수정
|
||||
- pt-BR 첫 흐름 가이드에서 누락된 코드 블록 복원
|
||||
- `result_as_answer`가 후크 블록이나 오류 메시지를 최종 답변으로 반환하지 않도록 방지
|
||||
- 비동기 배치 플러시 간 작업 출력 보존
|
||||
- 항상 finally 블록에서 `task.output_pydantic` 복원
|
||||
- `convert_to_model`에서 `BaseModel` 입력 처리
|
||||
|
||||
### 문서화
|
||||
- v1.14.5에 대한 변경 로그 및 버전 업데이트
|
||||
- OSS 업그레이드 및 Crew-투-흐름 마이그레이션 가이드 추가
|
||||
- 개발 도구를 위한 추가 환경 변수 문서화
|
||||
- `TavilyGetResearch`에 대한 문서 추가
|
||||
|
||||
### 리팩토링
|
||||
- CLI를 독립형 `crewai-cli` 패키지로 추출
|
||||
|
||||
## 기여자
|
||||
|
||||
@NIK-TIGER-BILL, @akaKuruma, @cgoeppinger, @github-actions[bot], @greysonlalonde, @heitorado, @irfaan101, @iris-clawd, @lorenzejay, @manisrinivasan2k1, @minasami-pr, @mislavivanda, @theCyberTech, @theishangoswami, @wishhyt
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 5월 18일">
|
||||
## v1.14.5a7
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a7)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 문서
|
||||
- v1.14.5a6의 변경 로그 및 버전 업데이트
|
||||
|
||||
### 주요 변경 사항
|
||||
- function_calling_llm 필드 사용 중단
|
||||
|
||||
## 기여자
|
||||
|
||||
@greysonlalonde, @heitorado
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 5월 15일">
|
||||
## v1.14.5a6
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ LLM 연결과 기본 설정을 구성했다면 이제 Crew Studio 사용을 시
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="질문에 답하기">
|
||||
crew assistant가 요구 사항을 구체화할 수 있도록 하는 추가 질문에 답변하세요.
|
||||
</Step>
|
||||
@@ -158,10 +159,12 @@ LLM 연결과 기본 설정을 구성했다면 이제 Crew Studio 사용을 시
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="승인 또는 수정">
|
||||
계획을 승인하거나 필요하다면 변경을 요청하세요.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="다운로드 또는 배포">
|
||||
사용자화를 위해 코드를 다운로드하거나 플랫폼에 직접 배포하세요.
|
||||
</Step>
|
||||
|
||||
@@ -797,6 +797,7 @@ LLM 선택을 최적화하고자 하는 팀을 위해 **CrewAI AMP 플랫폼**
|
||||
여러 차원에서 우수한 성능을 제공하며 실제 환경에서 광범위하게 검증된 **GPT-4.1**, **Claude 3.7 Sonnet**, **Gemini 2.0 Flash**와 같은 잘 알려진 모델부터 시작하십시오.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="특화된 요구 사항 식별">
|
||||
crew에 코드 작성, reasoning, 속도 등 특정 요구가 있는지 확인하고, 이러한
|
||||
요구에 부합하는 **Claude 4 Sonnet**(개발용) 또는 **o3**(복잡한 분석용)과 같은
|
||||
@@ -804,6 +805,7 @@ LLM 선택을 최적화하고자 하는 팀을 위해 **CrewAI AMP 플랫폼**
|
||||
더불어 **Groq**와 같은 빠른 추론 제공자를 고려할 수 있습니다.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="다중 모델 전략 구현">
|
||||
각 에이전트의 역할에 따라 다양한 모델을 사용하세요. 관리자와 복잡한 작업에는
|
||||
고성능 모델을, 일상적 운영에는 효율적인 모델을 적용합니다.
|
||||
|
||||
@@ -4,64 +4,6 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="19 mai 2026">
|
||||
## v1.14.5
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Recursos
|
||||
- Deprecar `CrewAgentExecutor`, definir agentes Crew como `AgentExecutor`
|
||||
- Melhorar ferramentas do sandbox Daytona
|
||||
- Adicionar parâmetro de início `restore_from_state_id`
|
||||
- Adicionar destaques ao `ExaSearchTool`, renomeando de `EXASearchTool`
|
||||
|
||||
### Correções de Bugs
|
||||
- Corrigir vazamento de memória em `git.py` usando `cached_property`
|
||||
- Exibir chamadas de ferramentas transmitidas quando `available_functions` está ausente
|
||||
- Garantir eventos de carregamento de `skills` para rastros
|
||||
- Corrigir caminho do endpoint de status de `/{kickoff_id}/status` para `/status/{kickoff_id}`
|
||||
- Restaurar bloco de código ausente no guia de primeiro fluxo em pt-BR
|
||||
- Impedir que `result_as_answer` retorne mensagens de bloqueio de hook ou de erro como resposta final
|
||||
- Preservar saídas de tarefas durante o descarregamento assíncrono em lote
|
||||
- Sempre restaurar `task.output_pydantic` no bloco finally
|
||||
- Lidar com entrada de `BaseModel` em `convert_to_model`
|
||||
|
||||
### Documentação
|
||||
- Atualizar changelog e versão para v1.14.5
|
||||
- Adicionar guia de migração de atualização OSS & crew-to-flow
|
||||
- Documentar variáveis de ambiente adicionais para devtools
|
||||
- Adicionar documentação para `TavilyGetResearch`
|
||||
|
||||
### Refatoração
|
||||
- Extrair CLI para o pacote autônomo `crewai-cli`
|
||||
|
||||
## Contribuidores
|
||||
|
||||
@NIK-TIGER-BILL, @akaKuruma, @cgoeppinger, @github-actions[bot], @greysonlalonde, @heitorado, @irfaan101, @iris-clawd, @lorenzejay, @manisrinivasan2k1, @minasami-pr, @mislavivanda, @theCyberTech, @theishangoswami, @wishhyt
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="18 mai 2026">
|
||||
## v1.14.5a7
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a7)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Documentação
|
||||
- Atualizar changelog e versão para v1.14.5a6
|
||||
|
||||
### Mudanças Quebradoras
|
||||
- Depreciar o campo function_calling_llm
|
||||
|
||||
## Contributors
|
||||
|
||||
@greysonlalonde, @heitorado
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="15 mai 2026">
|
||||
## v1.14.5a6
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ Veja um fluxo de trabalho típico para criação de um crew com o Crew Studio:
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Responder Perguntas">
|
||||
Responda às perguntas de esclarecimento do Crew Assistant para refinar seus
|
||||
requisitos.
|
||||
@@ -160,10 +161,12 @@ Veja um fluxo de trabalho típico para criação de um crew com o Crew Studio:
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Aprovar ou Modificar">
|
||||
Aprove o plano ou solicite alterações, se necessário.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Baixar ou Fazer Deploy">
|
||||
Baixe o código para personalização ou faça o deploy diretamente na plataforma.
|
||||
</Step>
|
||||
|
||||
@@ -797,6 +797,7 @@ As tabelas abaixo mostram uma amostra dos modelos de maior destaque em cada cate
|
||||
Inicie com opções consagradas como **GPT-4.1**, **Claude 3.7 Sonnet** ou **Gemini 2.0 Flash**, que oferecem bom desempenho e ampla validação.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Identifique Demandas Especializadas">
|
||||
Descubra se sua crew possui requisitos específicos (código, raciocínio,
|
||||
velocidade) que justifiquem modelos como **Claude 4 Sonnet** para
|
||||
@@ -804,6 +805,7 @@ As tabelas abaixo mostram uma amostra dos modelos de maior destaque em cada cate
|
||||
velocidade, considere Groq aliado à seleção do modelo.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Implemente Estratégia Multi-Modelo">
|
||||
Use modelos diferentes para agentes distintos conforme o papel. Modelos de
|
||||
alta capacidade para managers e tarefas complexas, eficientes para rotinas.
|
||||
|
||||
@@ -8,7 +8,7 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.5",
|
||||
"crewai-core==1.14.5a6",
|
||||
"click~=8.1.7",
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"pydantic-settings~=2.10.1",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.5"
|
||||
__version__ = "1.14.5a6"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from crewai_core.plus_api import CreateCrewPayload
|
||||
from rich.console import Console
|
||||
|
||||
from crewai_cli import git
|
||||
@@ -162,7 +161,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
self,
|
||||
env_vars: dict[str, str],
|
||||
remote_repo_url: str,
|
||||
) -> CreateCrewPayload:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create the payload for crew creation.
|
||||
|
||||
@@ -173,8 +172,6 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
Returns:
|
||||
Dict[str, Any]: The payload for crew creation.
|
||||
"""
|
||||
if not self.project_name:
|
||||
raise ValueError("project_name is required to create a deployment payload")
|
||||
return {
|
||||
"deploy": {
|
||||
"name": self.project_name,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from functools import cached_property
|
||||
from functools import lru_cache
|
||||
import subprocess
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ class Repository:
|
||||
if not self.is_git_installed():
|
||||
raise ValueError("Git is not installed or not found in your PATH.")
|
||||
|
||||
if not self.is_git_repo:
|
||||
if not self.is_git_repo():
|
||||
raise ValueError(f"{self.path} is not a Git repository.")
|
||||
|
||||
self.fetch()
|
||||
@@ -40,9 +40,13 @@ class Repository:
|
||||
encoding="utf-8",
|
||||
).strip()
|
||||
|
||||
@cached_property # noqa: B019
|
||||
@lru_cache(maxsize=None) # noqa: B019
|
||||
def is_git_repo(self) -> bool:
|
||||
"""Check if the current directory is a git repository."""
|
||||
"""Check if the current directory is a git repository.
|
||||
|
||||
Notes:
|
||||
- TODO: This method is cached to avoid redundant checks, but using lru_cache on methods can lead to memory leaks
|
||||
"""
|
||||
try:
|
||||
subprocess.check_output(
|
||||
["git", "rev-parse", "--is-inside-work-tree"], # noqa: S607
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.5"
|
||||
__version__ = "1.14.5a6"
|
||||
|
||||
@@ -3,161 +3,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Final, Literal, TypedDict, cast
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
from crewai_core.constants import DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
from crewai_core.settings import Settings
|
||||
from crewai_core.version import get_crewai_version
|
||||
|
||||
|
||||
HttpMethod = Literal["GET", "POST", "PATCH", "DELETE"]
|
||||
|
||||
|
||||
class AvailableExport(TypedDict):
|
||||
name: str
|
||||
|
||||
|
||||
class EnvVarEntry(TypedDict):
|
||||
name: str
|
||||
description: str
|
||||
required: bool
|
||||
default: str | None
|
||||
|
||||
|
||||
class ToolMetadata(TypedDict):
|
||||
name: str
|
||||
module: str
|
||||
humanized_name: str
|
||||
description: str
|
||||
run_params_schema: dict[str, Any]
|
||||
init_params_schema: dict[str, Any]
|
||||
env_vars: list[EnvVarEntry]
|
||||
|
||||
|
||||
class ToolsMetadataPayload(TypedDict):
|
||||
package: str
|
||||
tools: list[ToolMetadata] | None
|
||||
|
||||
|
||||
class PublishToolPayload(TypedDict):
|
||||
handle: str
|
||||
public: bool
|
||||
version: str
|
||||
file: str
|
||||
description: str | None
|
||||
available_exports: list[AvailableExport] | None
|
||||
tools_metadata: ToolsMetadataPayload | None
|
||||
|
||||
|
||||
class CrewDeploymentSpec(TypedDict):
|
||||
name: str
|
||||
repo_clone_url: str
|
||||
env: dict[str, str]
|
||||
|
||||
|
||||
class CreateCrewPayload(TypedDict):
|
||||
deploy: CrewDeploymentSpec
|
||||
|
||||
|
||||
class _WithUserIdentifier(TypedDict):
|
||||
user_identifier: NotRequired[str]
|
||||
|
||||
|
||||
class LoginPayload(_WithUserIdentifier):
|
||||
pass
|
||||
|
||||
|
||||
class TraceExecutionContext(TypedDict):
|
||||
crew_fingerprint: str | None
|
||||
crew_name: str | None
|
||||
flow_name: str | None
|
||||
crewai_version: str
|
||||
privacy_level: str
|
||||
|
||||
|
||||
class TraceExecutionMetadata(TypedDict):
|
||||
expected_duration_estimate: int
|
||||
agent_count: int
|
||||
task_count: int
|
||||
flow_method_count: int
|
||||
execution_started_at: str
|
||||
|
||||
|
||||
class TraceBatchInitPayload(_WithUserIdentifier):
|
||||
trace_id: str
|
||||
execution_type: str
|
||||
execution_context: TraceExecutionContext
|
||||
execution_metadata: TraceExecutionMetadata
|
||||
ephemeral_trace_id: NotRequired[str]
|
||||
|
||||
|
||||
class TraceBatchMetadata(TypedDict):
|
||||
events_count: int
|
||||
batch_sequence: int
|
||||
is_final_batch: bool
|
||||
|
||||
|
||||
class TraceEventsPayload(TypedDict):
|
||||
events: list[dict[str, Any]]
|
||||
batch_metadata: TraceBatchMetadata
|
||||
|
||||
|
||||
class TraceFinalizePayload(TypedDict):
|
||||
status: Literal["completed"]
|
||||
duration_ms: float | None
|
||||
final_event_count: int
|
||||
|
||||
|
||||
class TraceFailedPayload(TypedDict):
|
||||
status: Literal["failed"]
|
||||
failure_reason: str
|
||||
|
||||
|
||||
Headers = TypedDict(
|
||||
"Headers",
|
||||
{
|
||||
"Content-Type": str,
|
||||
"User-Agent": str,
|
||||
"X-Crewai-Version": str,
|
||||
"Authorization": NotRequired[str],
|
||||
"X-Crewai-Organization-Id": NotRequired[str],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class RequestKwargs(TypedDict):
|
||||
headers: dict[str, str]
|
||||
json: NotRequired[Any]
|
||||
params: NotRequired[dict[str, str]]
|
||||
timeout: NotRequired[float]
|
||||
|
||||
|
||||
class PlusAPI:
|
||||
"""Client for working with the CrewAI+ API."""
|
||||
|
||||
TOOLS_RESOURCE: Final = "/crewai_plus/api/v1/tools"
|
||||
ORGANIZATIONS_RESOURCE: Final = "/crewai_plus/api/v1/me/organizations"
|
||||
CREWS_RESOURCE: Final = "/crewai_plus/api/v1/crews"
|
||||
AGENTS_RESOURCE: Final = "/crewai_plus/api/v1/agents"
|
||||
TRACING_RESOURCE: Final = "/crewai_plus/api/v1/tracing"
|
||||
EPHEMERAL_TRACING_RESOURCE: Final = "/crewai_plus/api/v1/tracing/ephemeral"
|
||||
INTEGRATIONS_RESOURCE: Final = "/crewai_plus/api/v1/integrations"
|
||||
TOOLS_RESOURCE = "/crewai_plus/api/v1/tools"
|
||||
ORGANIZATIONS_RESOURCE = "/crewai_plus/api/v1/me/organizations"
|
||||
CREWS_RESOURCE = "/crewai_plus/api/v1/crews"
|
||||
AGENTS_RESOURCE = "/crewai_plus/api/v1/agents"
|
||||
TRACING_RESOURCE = "/crewai_plus/api/v1/tracing"
|
||||
EPHEMERAL_TRACING_RESOURCE = "/crewai_plus/api/v1/tracing/ephemeral"
|
||||
INTEGRATIONS_RESOURCE = "/crewai_plus/api/v1/integrations"
|
||||
|
||||
def __init__(self, api_key: str | None = None) -> None:
|
||||
version = get_crewai_version()
|
||||
self.api_key = api_key
|
||||
self.headers: Headers = {
|
||||
self.headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"CrewAI-CLI/{version}",
|
||||
"X-Crewai-Version": version,
|
||||
"User-Agent": f"CrewAI-CLI/{get_crewai_version()}",
|
||||
"X-Crewai-Version": get_crewai_version(),
|
||||
}
|
||||
if api_key:
|
||||
self.headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
settings = Settings()
|
||||
if settings.org_uuid:
|
||||
self.headers["X-Crewai-Organization-Id"] = settings.org_uuid
|
||||
@@ -169,30 +44,17 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
method: HttpMethod,
|
||||
endpoint: str,
|
||||
*,
|
||||
json: Any = None,
|
||||
params: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
verify: bool = True,
|
||||
self, method: str, endpoint: str, **kwargs: Any
|
||||
) -> httpx.Response:
|
||||
url = urljoin(self.base_url, endpoint)
|
||||
request_kwargs: RequestKwargs = {"headers": cast(dict[str, str], self.headers)}
|
||||
if json is not None:
|
||||
request_kwargs["json"] = json
|
||||
if params is not None:
|
||||
request_kwargs["params"] = params
|
||||
if timeout is not None:
|
||||
request_kwargs["timeout"] = timeout
|
||||
verify = kwargs.pop("verify", True)
|
||||
with httpx.Client(trust_env=False, verify=verify) as client:
|
||||
return client.request(method, url, **request_kwargs)
|
||||
return client.request(method, url, headers=self.headers, **kwargs)
|
||||
|
||||
def login_to_tool_repository(
|
||||
self, user_identifier: str | None = None
|
||||
) -> httpx.Response:
|
||||
payload: LoginPayload = {}
|
||||
payload = {}
|
||||
if user_identifier:
|
||||
payload["user_identifier"] = user_identifier
|
||||
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
|
||||
@@ -203,7 +65,7 @@ class PlusAPI:
|
||||
async def get_agent(self, handle: str) -> httpx.Response:
|
||||
url = urljoin(self.base_url, f"{self.AGENTS_RESOURCE}/{handle}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
return await client.get(url, headers=cast(dict[str, str], self.headers))
|
||||
return await client.get(url, headers=self.headers)
|
||||
|
||||
def publish_tool(
|
||||
self,
|
||||
@@ -212,10 +74,10 @@ class PlusAPI:
|
||||
version: str,
|
||||
description: str | None,
|
||||
encoded_file: str,
|
||||
available_exports: list[AvailableExport] | None = None,
|
||||
tools_metadata: list[ToolMetadata] | None = None,
|
||||
available_exports: list[dict[str, Any]] | None = None,
|
||||
tools_metadata: list[dict[str, Any]] | None = None,
|
||||
) -> httpx.Response:
|
||||
params: PublishToolPayload = {
|
||||
params = {
|
||||
"handle": handle,
|
||||
"public": is_public,
|
||||
"version": version,
|
||||
@@ -267,13 +129,13 @@ class PlusAPI:
|
||||
def list_crews(self) -> httpx.Response:
|
||||
return self._make_request("GET", self.CREWS_RESOURCE)
|
||||
|
||||
def create_crew(self, payload: CreateCrewPayload) -> httpx.Response:
|
||||
def create_crew(self, payload: dict[str, Any]) -> httpx.Response:
|
||||
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
|
||||
|
||||
def get_organizations(self) -> httpx.Response:
|
||||
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)
|
||||
|
||||
def initialize_trace_batch(self, payload: TraceBatchInitPayload) -> httpx.Response:
|
||||
def initialize_trace_batch(self, payload: dict[str, Any]) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"POST",
|
||||
f"{self.TRACING_RESOURCE}/batches",
|
||||
@@ -282,7 +144,7 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def initialize_ephemeral_trace_batch(
|
||||
self, payload: TraceBatchInitPayload
|
||||
self, payload: dict[str, Any]
|
||||
) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"POST",
|
||||
@@ -291,7 +153,7 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def send_trace_events(
|
||||
self, trace_batch_id: str, payload: TraceEventsPayload
|
||||
self, trace_batch_id: str, payload: dict[str, Any]
|
||||
) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"POST",
|
||||
@@ -301,7 +163,7 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def send_ephemeral_trace_events(
|
||||
self, trace_batch_id: str, payload: TraceEventsPayload
|
||||
self, trace_batch_id: str, payload: dict[str, Any]
|
||||
) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"POST",
|
||||
@@ -311,7 +173,7 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def finalize_trace_batch(
|
||||
self, trace_batch_id: str, payload: TraceFinalizePayload
|
||||
self, trace_batch_id: str, payload: dict[str, Any]
|
||||
) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"PATCH",
|
||||
@@ -321,7 +183,7 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def finalize_ephemeral_trace_batch(
|
||||
self, trace_batch_id: str, payload: TraceFinalizePayload
|
||||
self, trace_batch_id: str, payload: dict[str, Any]
|
||||
) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"PATCH",
|
||||
@@ -333,28 +195,20 @@ class PlusAPI:
|
||||
def mark_trace_batch_as_failed(
|
||||
self, trace_batch_id: str, error_message: str
|
||||
) -> httpx.Response:
|
||||
payload: TraceFailedPayload = {
|
||||
"status": "failed",
|
||||
"failure_reason": error_message,
|
||||
}
|
||||
return self._make_request(
|
||||
"PATCH",
|
||||
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}",
|
||||
json=payload,
|
||||
json={"status": "failed", "failure_reason": error_message},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
def mark_ephemeral_trace_batch_as_failed(
|
||||
self, trace_batch_id: str, error_message: str
|
||||
) -> httpx.Response:
|
||||
payload: TraceFailedPayload = {
|
||||
"status": "failed",
|
||||
"failure_reason": error_message,
|
||||
}
|
||||
return self._make_request(
|
||||
"PATCH",
|
||||
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}",
|
||||
json=payload,
|
||||
json={"status": "failed", "failure_reason": error_message},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.14.5"
|
||||
__version__ = "1.14.5a6"
|
||||
|
||||
110
lib/crewai-files/src/crewai_files/core/security.py
Normal file
110
lib/crewai-files/src/crewai_files/core/security.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""URL validation utilities for crewai-files.
|
||||
|
||||
Provides SSRF protection by resolving DNS and checking that target IPs
|
||||
are not private or reserved before allowing HTTP requests.
|
||||
|
||||
Set CREWAI_FILES_ALLOW_UNSAFE_URLS=true to bypass validation (not
|
||||
recommended for production).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UNSAFE_URLS_ENV = "CREWAI_FILES_ALLOW_UNSAFE_URLS"
|
||||
|
||||
_BLOCKED_IPV4_NETWORKS = [
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"), # Link-local / cloud metadata
|
||||
ipaddress.ip_network("0.0.0.0/32"),
|
||||
]
|
||||
|
||||
_BLOCKED_IPV6_NETWORKS = [
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("::/128"),
|
||||
ipaddress.ip_network("fc00::/7"), # Unique local addresses
|
||||
ipaddress.ip_network("fe80::/10"), # Link-local IPv6
|
||||
]
|
||||
|
||||
|
||||
def _is_escape_hatch_enabled() -> bool:
|
||||
"""Check if the unsafe URLs escape hatch is enabled."""
|
||||
return os.environ.get(_UNSAFE_URLS_ENV, "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def _is_private_or_reserved(ip_str: str) -> bool:
|
||||
"""Check if an IP address is private, reserved, or otherwise unsafe."""
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip_str)
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
|
||||
addr = addr.ipv4_mapped
|
||||
networks = (
|
||||
_BLOCKED_IPV4_NETWORKS
|
||||
if isinstance(addr, ipaddress.IPv4Address)
|
||||
else _BLOCKED_IPV6_NETWORKS
|
||||
)
|
||||
return any(addr in network for network in networks)
|
||||
except ValueError:
|
||||
return True # If we can't parse, block it
|
||||
|
||||
|
||||
def validate_url(url: str) -> str:
|
||||
"""Validate that a URL is safe to fetch (SSRF protection).
|
||||
|
||||
Blocks ``file://`` scheme entirely. For ``http``/``https``, resolves
|
||||
DNS and checks that the target IP is not private or reserved.
|
||||
|
||||
Args:
|
||||
url: The URL to validate.
|
||||
|
||||
Returns:
|
||||
The validated URL string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the URL uses a blocked scheme or resolves to a
|
||||
private/reserved IP address.
|
||||
"""
|
||||
if _is_escape_hatch_enabled():
|
||||
logger.warning(
|
||||
"%s is enabled - skipping URL validation for: %s",
|
||||
_UNSAFE_URLS_ENV,
|
||||
url,
|
||||
)
|
||||
return url
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"Invalid URL scheme: {url}. Only http and https are allowed.")
|
||||
|
||||
if not parsed.hostname:
|
||||
raise ValueError(f"URL has no hostname: '{url}'")
|
||||
|
||||
try:
|
||||
addrinfos = socket.getaddrinfo(
|
||||
parsed.hostname,
|
||||
parsed.port or (443 if parsed.scheme == "https" else 80),
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
raise ValueError(f"Could not resolve hostname: '{parsed.hostname}'") from exc
|
||||
|
||||
for _family, _, _, _, sockaddr in addrinfos:
|
||||
ip_str = str(sockaddr[0])
|
||||
if _is_private_or_reserved(ip_str):
|
||||
raise ValueError(
|
||||
f"URL '{url}' resolves to private/reserved IP {ip_str}. "
|
||||
f"Access to internal networks is not allowed. "
|
||||
f"Set {_UNSAFE_URLS_ENV}=true to bypass."
|
||||
)
|
||||
|
||||
return url
|
||||
@@ -449,9 +449,10 @@ class FileUrl(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_url(self) -> FileUrl:
|
||||
"""Validate URL format."""
|
||||
if not self.url.startswith(("http://", "https://")):
|
||||
raise ValueError(f"Invalid URL scheme: {self.url}")
|
||||
"""Validate URL format and ensure it does not target internal networks."""
|
||||
from crewai_files.core.security import validate_url
|
||||
|
||||
validate_url(self.url)
|
||||
return self
|
||||
|
||||
@property
|
||||
@@ -475,7 +476,7 @@ class FileUrl(BaseModel):
|
||||
if self._content is None:
|
||||
import httpx
|
||||
|
||||
response = httpx.get(self.url, follow_redirects=True)
|
||||
response = httpx.get(self.url, follow_redirects=False)
|
||||
response.raise_for_status()
|
||||
self._content = response.content
|
||||
if "content-type" in response.headers:
|
||||
@@ -488,7 +489,7 @@ class FileUrl(BaseModel):
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(self.url, follow_redirects=True)
|
||||
response = await client.get(self.url, follow_redirects=False)
|
||||
response.raise_for_status()
|
||||
self._content = response.content
|
||||
if "content-type" in response.headers:
|
||||
|
||||
@@ -95,7 +95,7 @@ class TestFileUrl:
|
||||
content = url.read()
|
||||
|
||||
mock_get.assert_called_once_with(
|
||||
"https://example.com/image.png", follow_redirects=True
|
||||
"https://example.com/image.png", follow_redirects=False
|
||||
)
|
||||
assert content == b"fake image content"
|
||||
|
||||
|
||||
233
lib/crewai-files/tests/test_ssrf_protection.py
Normal file
233
lib/crewai-files/tests/test_ssrf_protection.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Tests for SSRF protection in FileUrl and the security module."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from crewai_files import FileUrl
|
||||
from crewai_files.core.security import (
|
||||
_is_private_or_reserved,
|
||||
validate_url,
|
||||
)
|
||||
import pytest
|
||||
|
||||
|
||||
class TestIsPrivateOrReserved:
|
||||
"""Tests for _is_private_or_reserved helper."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ip",
|
||||
[
|
||||
"127.0.0.1",
|
||||
"127.0.0.2",
|
||||
"10.0.0.1",
|
||||
"10.255.255.255",
|
||||
"172.16.0.1",
|
||||
"172.31.255.255",
|
||||
"192.168.0.1",
|
||||
"192.168.1.100",
|
||||
"169.254.169.254", # AWS metadata
|
||||
"0.0.0.0", # noqa: S104
|
||||
],
|
||||
)
|
||||
def test_private_ipv4_addresses_blocked(self, ip):
|
||||
assert _is_private_or_reserved(ip) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ip",
|
||||
[
|
||||
"::1",
|
||||
"::",
|
||||
"fc00::1",
|
||||
"fd00::1",
|
||||
"fe80::1",
|
||||
],
|
||||
)
|
||||
def test_private_ipv6_addresses_blocked(self, ip):
|
||||
assert _is_private_or_reserved(ip) is True
|
||||
|
||||
def test_ipv4_mapped_ipv6_loopback_blocked(self):
|
||||
assert _is_private_or_reserved("::ffff:127.0.0.1") is True
|
||||
|
||||
def test_ipv4_mapped_ipv6_metadata_blocked(self):
|
||||
assert _is_private_or_reserved("::ffff:169.254.169.254") is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ip",
|
||||
[
|
||||
"8.8.8.8",
|
||||
"1.1.1.1",
|
||||
"93.184.216.34", # example.com
|
||||
"2606:2800:220:1:248:1893:25c8:1946",
|
||||
],
|
||||
)
|
||||
def test_public_addresses_allowed(self, ip):
|
||||
assert _is_private_or_reserved(ip) is False
|
||||
|
||||
def test_unparseable_ip_blocked(self):
|
||||
assert _is_private_or_reserved("not-an-ip") is True
|
||||
|
||||
|
||||
class TestValidateUrl:
|
||||
"""Tests for validate_url function."""
|
||||
|
||||
def test_blocks_file_scheme(self):
|
||||
with pytest.raises(ValueError, match="Invalid URL scheme"):
|
||||
validate_url("file:///etc/passwd")
|
||||
|
||||
def test_blocks_ftp_scheme(self):
|
||||
with pytest.raises(ValueError, match="Invalid URL scheme"):
|
||||
validate_url("ftp://example.com/file")
|
||||
|
||||
def test_blocks_data_scheme(self):
|
||||
with pytest.raises(ValueError, match="Invalid URL scheme"):
|
||||
validate_url("data:text/plain;base64,SGVsbG8=")
|
||||
|
||||
def test_blocks_no_hostname(self):
|
||||
with pytest.raises(ValueError, match="URL has no hostname"):
|
||||
validate_url("http://")
|
||||
|
||||
def test_blocks_localhost(self):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
validate_url("http://localhost/secret")
|
||||
|
||||
def test_blocks_127_0_0_1(self):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
validate_url("http://127.0.0.1/secret")
|
||||
|
||||
def test_blocks_metadata_endpoint(self):
|
||||
"""Block AWS/GCP/Azure cloud metadata endpoint."""
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
validate_url("http://169.254.169.254/latest/meta-data/")
|
||||
|
||||
def test_blocks_private_10_network(self):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
validate_url("http://10.0.0.1/internal")
|
||||
|
||||
def test_blocks_private_172_network(self):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
validate_url("http://172.16.0.1/internal")
|
||||
|
||||
def test_blocks_private_192_168_network(self):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
validate_url("http://192.168.1.1/internal")
|
||||
|
||||
def test_blocks_0_0_0_0(self):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
validate_url("http://0.0.0.0/")
|
||||
|
||||
def test_allows_public_https_url(self):
|
||||
result = validate_url("https://example.com/image.png")
|
||||
assert result == "https://example.com/image.png"
|
||||
|
||||
def test_allows_public_http_url(self):
|
||||
result = validate_url("http://example.com/file.pdf")
|
||||
assert result == "http://example.com/file.pdf"
|
||||
|
||||
def test_blocks_unresolvable_hostname(self):
|
||||
with pytest.raises(ValueError, match="Could not resolve hostname"):
|
||||
validate_url("https://this-domain-definitely-does-not-exist-12345.invalid/")
|
||||
|
||||
def test_blocks_dns_resolving_to_private_ip(self):
|
||||
"""Simulate a hostname that resolves to a private IP (DNS rebinding)."""
|
||||
fake_addrinfo = [(2, 1, 6, "", ("10.0.0.1", 443))]
|
||||
with patch("socket.getaddrinfo", return_value=fake_addrinfo):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
validate_url("https://evil.example.com/steal")
|
||||
|
||||
def test_escape_hatch_allows_private_urls(self):
|
||||
"""CREWAI_FILES_ALLOW_UNSAFE_URLS=true bypasses validation."""
|
||||
with patch.dict("os.environ", {"CREWAI_FILES_ALLOW_UNSAFE_URLS": "true"}):
|
||||
result = validate_url("http://127.0.0.1/secret")
|
||||
assert result == "http://127.0.0.1/secret"
|
||||
|
||||
|
||||
class TestFileUrlSSRF:
|
||||
"""Tests for SSRF protection integrated into FileUrl."""
|
||||
|
||||
def test_rejects_localhost_url(self):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
FileUrl(url="http://localhost/secret")
|
||||
|
||||
def test_rejects_127_0_0_1(self):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
FileUrl(url="http://127.0.0.1:8080/admin")
|
||||
|
||||
def test_rejects_metadata_endpoint(self):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
FileUrl(url="http://169.254.169.254/latest/meta-data/")
|
||||
|
||||
def test_rejects_private_network_10(self):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
FileUrl(url="http://10.0.0.1/internal-service")
|
||||
|
||||
def test_rejects_private_network_172(self):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
FileUrl(url="http://172.16.5.10/api")
|
||||
|
||||
def test_rejects_private_network_192_168(self):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
FileUrl(url="http://192.168.1.1/router")
|
||||
|
||||
def test_rejects_file_scheme(self):
|
||||
with pytest.raises(ValueError, match="Invalid URL scheme"):
|
||||
FileUrl(url="file:///etc/passwd")
|
||||
|
||||
def test_rejects_ftp_scheme(self):
|
||||
with pytest.raises(ValueError, match="Invalid URL scheme"):
|
||||
FileUrl(url="ftp://example.com/file")
|
||||
|
||||
def test_accepts_public_https_url(self):
|
||||
url = FileUrl(url="https://example.com/image.png")
|
||||
assert url.url == "https://example.com/image.png"
|
||||
|
||||
def test_accepts_public_http_url(self):
|
||||
url = FileUrl(url="http://example.com/file.pdf")
|
||||
assert url.url == "http://example.com/file.pdf"
|
||||
|
||||
def test_rejects_dns_rebinding_to_private_ip(self):
|
||||
"""A hostname that resolves to an internal IP should be blocked."""
|
||||
fake_addrinfo = [(2, 1, 6, "", ("169.254.169.254", 80))]
|
||||
with patch("socket.getaddrinfo", return_value=fake_addrinfo):
|
||||
with pytest.raises(ValueError, match="private/reserved IP"):
|
||||
FileUrl(url="http://metadata.evil.com/steal-creds")
|
||||
|
||||
def test_escape_hatch_bypasses_ssrf_check(self):
|
||||
with patch.dict("os.environ", {"CREWAI_FILES_ALLOW_UNSAFE_URLS": "true"}):
|
||||
url = FileUrl(url="http://127.0.0.1/local")
|
||||
assert url.url == "http://127.0.0.1/local"
|
||||
|
||||
def test_read_does_not_follow_redirects(self):
|
||||
"""Verify read() uses follow_redirects=False to prevent redirect-based SSRF."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
url = FileUrl(url="https://example.com/image.png")
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = b"data"
|
||||
mock_response.headers = {}
|
||||
|
||||
with patch("httpx.get", return_value=mock_response) as mock_get:
|
||||
url.read()
|
||||
mock_get.assert_called_once_with(
|
||||
"https://example.com/image.png", follow_redirects=False
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aread_does_not_follow_redirects(self):
|
||||
"""Verify aread() uses follow_redirects=False."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
url = FileUrl(url="https://example.com/image.png")
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = b"data"
|
||||
mock_response.headers = {}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
await url.aread()
|
||||
mock_client.get.assert_called_once_with(
|
||||
"https://example.com/image.png", follow_redirects=False
|
||||
)
|
||||
@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"pytube~=15.0.0",
|
||||
"requests>=2.33.0,<3",
|
||||
"crewai==1.14.5",
|
||||
"crewai==1.14.5a6",
|
||||
"tiktoken>=0.8.0,<0.13",
|
||||
"beautifulsoup4~=4.13.4",
|
||||
"python-docx~=1.2.0",
|
||||
|
||||
@@ -330,4 +330,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.14.5"
|
||||
__version__ = "1.14.5a6"
|
||||
|
||||
@@ -8,8 +8,8 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.5",
|
||||
"crewai-cli==1.14.5",
|
||||
"crewai-core==1.14.5a6",
|
||||
"crewai-cli==1.14.5a6",
|
||||
# Core Dependencies
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"openai>=2.30.0,<3",
|
||||
@@ -54,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
|
||||
|
||||
[project.optional-dependencies]
|
||||
tools = [
|
||||
"crewai-tools==1.14.5",
|
||||
"crewai-tools==1.14.5a6",
|
||||
]
|
||||
embeddings = [
|
||||
"tiktoken>=0.8.0,<0.13"
|
||||
@@ -105,7 +105,7 @@ a2a = [
|
||||
"aiocache[redis,memcached]~=0.12.3",
|
||||
]
|
||||
file-processing = [
|
||||
"crewai-files",
|
||||
"crewai-files==1.14.5a6",
|
||||
]
|
||||
qdrant-edge = [
|
||||
"qdrant-edge-py>=0.6.0",
|
||||
|
||||
@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
|
||||
|
||||
_suppress_pydantic_deprecation_warnings()
|
||||
|
||||
__version__ = "1.14.5"
|
||||
__version__ = "1.14.5a6"
|
||||
|
||||
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"Memory": ("crewai.memory.unified_memory", "Memory"),
|
||||
|
||||
@@ -220,11 +220,7 @@ class Agent(BaseAgent):
|
||||
str | BaseLLM | None,
|
||||
BeforeValidator(_validate_llm_ref),
|
||||
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
|
||||
] = Field(
|
||||
description="Language model that will run the agent.",
|
||||
default=None,
|
||||
deprecated="function_calling_llm is deprecated and will be removed in a future release.",
|
||||
)
|
||||
] = Field(description="Language model that will run the agent.", default=None)
|
||||
system_template: str | None = Field(
|
||||
default=None, description="System format for the agent."
|
||||
)
|
||||
|
||||
@@ -51,10 +51,7 @@ class LangGraphAgentAdapter(BaseAgentAdapter):
|
||||
_graph: Any = PrivateAttr(default=None)
|
||||
_memory: Any = PrivateAttr(default=None)
|
||||
_max_iterations: int = PrivateAttr(default=10)
|
||||
function_calling_llm: Any = Field(
|
||||
default=None,
|
||||
deprecated="function_calling_llm is deprecated and will be removed in a future release.",
|
||||
)
|
||||
function_calling_llm: Any = Field(default=None)
|
||||
step_callback: SerializableCallable | None = Field(default=None)
|
||||
|
||||
model: str = Field(default="gpt-4o")
|
||||
|
||||
@@ -60,10 +60,7 @@ class OpenAIAgentAdapter(BaseAgentAdapter):
|
||||
_openai_agent: OpenAIAgentProtocol = PrivateAttr()
|
||||
_logger: Logger = PrivateAttr(default_factory=Logger)
|
||||
_active_thread: str | None = PrivateAttr(default=None)
|
||||
function_calling_llm: Any = Field(
|
||||
default=None,
|
||||
deprecated="function_calling_llm is deprecated and will be removed in a future release.",
|
||||
)
|
||||
function_calling_llm: Any = Field(default=None)
|
||||
step_callback: Any = Field(default=None)
|
||||
_tool_adapter: OpenAIAgentToolAdapter = PrivateAttr()
|
||||
_converter_adapter: OpenAIConverterAdapter = PrivateAttr()
|
||||
|
||||
@@ -251,11 +251,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
str | LLM | None,
|
||||
BeforeValidator(_validate_llm_ref),
|
||||
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
|
||||
] = Field(
|
||||
description="Language model that will run the agent.",
|
||||
default=None,
|
||||
deprecated="function_calling_llm is deprecated and will be removed in a future release.",
|
||||
)
|
||||
] = Field(description="Language model that will run the agent.", default=None)
|
||||
config: Json[dict[str, Any]] | dict[str, Any] | None = Field(default=None)
|
||||
id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True)
|
||||
share_crew: bool | None = Field(default=False)
|
||||
|
||||
@@ -6,14 +6,6 @@ import time
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from crewai_core.plus_api import (
|
||||
TraceBatchInitPayload,
|
||||
TraceBatchMetadata,
|
||||
TraceEventsPayload,
|
||||
TraceExecutionContext,
|
||||
TraceExecutionMetadata,
|
||||
TraceFinalizePayload,
|
||||
)
|
||||
from crewai_core.settings import Settings
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -131,27 +123,25 @@ class TraceBatchManager:
|
||||
return None
|
||||
|
||||
try:
|
||||
execution_context: TraceExecutionContext = {
|
||||
"crew_fingerprint": execution_metadata.get("crew_fingerprint"),
|
||||
"crew_name": execution_metadata.get("crew_name", None),
|
||||
"flow_name": execution_metadata.get("flow_name", None),
|
||||
"crewai_version": self.current_batch.version,
|
||||
"privacy_level": user_context.get("privacy_level", "standard"),
|
||||
}
|
||||
execution_metadata_payload: TraceExecutionMetadata = {
|
||||
"expected_duration_estimate": execution_metadata.get(
|
||||
"expected_duration_estimate", 300
|
||||
),
|
||||
"agent_count": execution_metadata.get("agent_count", 0),
|
||||
"task_count": execution_metadata.get("task_count", 0),
|
||||
"flow_method_count": execution_metadata.get("flow_method_count", 0),
|
||||
"execution_started_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
payload: TraceBatchInitPayload = {
|
||||
payload = {
|
||||
"trace_id": self.current_batch.batch_id,
|
||||
"execution_type": execution_metadata.get("execution_type", "crew"),
|
||||
"execution_context": execution_context,
|
||||
"execution_metadata": execution_metadata_payload,
|
||||
"execution_context": {
|
||||
"crew_fingerprint": execution_metadata.get("crew_fingerprint"),
|
||||
"crew_name": execution_metadata.get("crew_name", None),
|
||||
"flow_name": execution_metadata.get("flow_name", None),
|
||||
"crewai_version": self.current_batch.version,
|
||||
"privacy_level": user_context.get("privacy_level", "standard"),
|
||||
},
|
||||
"execution_metadata": {
|
||||
"expected_duration_estimate": execution_metadata.get(
|
||||
"expected_duration_estimate", 300
|
||||
),
|
||||
"agent_count": execution_metadata.get("agent_count", 0),
|
||||
"task_count": execution_metadata.get("task_count", 0),
|
||||
"flow_method_count": execution_metadata.get("flow_method_count", 0),
|
||||
"execution_started_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
}
|
||||
if use_ephemeral:
|
||||
payload["ephemeral_trace_id"] = self.current_batch.batch_id
|
||||
@@ -274,14 +264,13 @@ class TraceBatchManager:
|
||||
if not self.plus_api or not self.trace_batch_id or not self.event_buffer:
|
||||
return 500
|
||||
try:
|
||||
batch_metadata: TraceBatchMetadata = {
|
||||
"events_count": len(self.event_buffer),
|
||||
"batch_sequence": 1,
|
||||
"is_final_batch": False,
|
||||
}
|
||||
payload: TraceEventsPayload = {
|
||||
payload = {
|
||||
"events": [event.to_dict() for event in self.event_buffer],
|
||||
"batch_metadata": batch_metadata,
|
||||
"batch_metadata": {
|
||||
"events_count": len(self.event_buffer),
|
||||
"batch_sequence": 1,
|
||||
"is_final_batch": False,
|
||||
},
|
||||
}
|
||||
|
||||
response = (
|
||||
@@ -375,7 +364,7 @@ class TraceBatchManager:
|
||||
return
|
||||
|
||||
try:
|
||||
payload: TraceFinalizePayload = {
|
||||
payload = {
|
||||
"status": "completed",
|
||||
"duration_ms": self.calculate_duration("execution"),
|
||||
"final_event_count": events_count,
|
||||
|
||||
@@ -2633,7 +2633,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
the event_id of the MethodExecutionFinishedEvent, or None if events
|
||||
are suppressed.
|
||||
"""
|
||||
logger.info("Executing flow method: %s", method_name)
|
||||
try:
|
||||
dumped_params = {f"_{i}": arg for i, arg in enumerate(args)} | (
|
||||
kwargs or {}
|
||||
|
||||
@@ -13,7 +13,6 @@ import sys
|
||||
import types
|
||||
from typing import Any, cast, get_type_hints
|
||||
|
||||
from crewai_core.plus_api import AvailableExport, EnvVarEntry, ToolMetadata
|
||||
from crewai_core.project import (
|
||||
get_project_description as get_project_description,
|
||||
get_project_name as get_project_name,
|
||||
@@ -280,7 +279,7 @@ def is_valid_tool(obj: Any) -> bool:
|
||||
return isinstance(obj, Tool)
|
||||
|
||||
|
||||
def extract_available_exports(dir_path: str = "src") -> list[AvailableExport]:
|
||||
def extract_available_exports(dir_path: str = "src") -> list[dict[str, Any]]:
|
||||
"""Extract available tool classes from the project's __init__.py files.
|
||||
|
||||
Only includes classes that inherit from BaseTool or functions decorated with @tool.
|
||||
@@ -339,7 +338,7 @@ def _load_module_from_file(
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
|
||||
def _load_tools_from_init(init_file: Path) -> list[AvailableExport]:
|
||||
def _load_tools_from_init(init_file: Path) -> list[dict[str, Any]]:
|
||||
"""Load and validate tools from a given __init__.py file."""
|
||||
try:
|
||||
with _load_module_from_file(init_file) as module:
|
||||
@@ -393,7 +392,7 @@ def _print_no_tools_warning() -> None:
|
||||
)
|
||||
|
||||
|
||||
def extract_tools_metadata(dir_path: str = "src") -> list[ToolMetadata]:
|
||||
def extract_tools_metadata(dir_path: str = "src") -> list[dict[str, Any]]:
|
||||
"""
|
||||
Extract rich metadata from tool classes in the project.
|
||||
|
||||
@@ -405,7 +404,7 @@ def extract_tools_metadata(dir_path: str = "src") -> list[ToolMetadata]:
|
||||
- init_params_schema: JSON Schema for __init__ params (filtered)
|
||||
- env_vars: List of environment variable dicts
|
||||
"""
|
||||
tools_metadata: list[ToolMetadata] = []
|
||||
tools_metadata: list[dict[str, Any]] = []
|
||||
|
||||
for init_file in Path(dir_path).glob("**/__init__.py"):
|
||||
tools = _extract_tool_metadata_from_init(init_file)
|
||||
@@ -414,7 +413,7 @@ def extract_tools_metadata(dir_path: str = "src") -> list[ToolMetadata]:
|
||||
return tools_metadata
|
||||
|
||||
|
||||
def _extract_tool_metadata_from_init(init_file: Path) -> list[ToolMetadata]:
|
||||
def _extract_tool_metadata_from_init(init_file: Path) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Load module from init file and extract metadata from valid tool classes.
|
||||
"""
|
||||
@@ -429,7 +428,7 @@ def _extract_tool_metadata_from_init(init_file: Path) -> list[ToolMetadata]:
|
||||
if not exported_names:
|
||||
return []
|
||||
|
||||
tools_metadata: list[ToolMetadata] = []
|
||||
tools_metadata = []
|
||||
for name in exported_names:
|
||||
obj = getattr(module, name, None)
|
||||
if obj is None or not (
|
||||
@@ -447,7 +446,7 @@ def _extract_tool_metadata_from_init(init_file: Path) -> list[ToolMetadata]:
|
||||
return []
|
||||
|
||||
|
||||
def _extract_single_tool_metadata(tool_class: type) -> ToolMetadata | None:
|
||||
def _extract_single_tool_metadata(tool_class: type) -> dict[str, Any] | None:
|
||||
"""
|
||||
Extract metadata from a single tool class.
|
||||
"""
|
||||
@@ -471,17 +470,19 @@ def _extract_single_tool_metadata(tool_class: type) -> ToolMetadata | None:
|
||||
except (TypeError, ValueError):
|
||||
module = tool_class.__module__
|
||||
|
||||
return ToolMetadata(
|
||||
name=tool_class.__name__,
|
||||
module=module,
|
||||
humanized_name=str(
|
||||
_extract_field_default(fields.get("name"), fallback=tool_class.__name__)
|
||||
return {
|
||||
"name": tool_class.__name__,
|
||||
"module": module,
|
||||
"humanized_name": _extract_field_default(
|
||||
fields.get("name"), fallback=tool_class.__name__
|
||||
),
|
||||
description=str(_extract_field_default(fields.get("description"))).strip(),
|
||||
run_params_schema=_extract_run_params_schema(fields.get("args_schema")),
|
||||
init_params_schema=_extract_init_params_schema(tool_class),
|
||||
env_vars=_extract_env_vars(fields.get("env_vars")),
|
||||
)
|
||||
"description": str(
|
||||
_extract_field_default(fields.get("description"))
|
||||
).strip(),
|
||||
"run_params_schema": _extract_run_params_schema(fields.get("args_schema")),
|
||||
"init_params_schema": _extract_init_params_schema(tool_class),
|
||||
"env_vars": _extract_env_vars(fields.get("env_vars")),
|
||||
}
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
@@ -596,7 +597,7 @@ def _extract_init_params_schema(tool_class: type) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_env_vars(env_vars_field: dict[str, Any] | None) -> list[EnvVarEntry]:
|
||||
def _extract_env_vars(env_vars_field: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Extract environment variable definitions from env_vars field.
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.14.5"
|
||||
__version__ = "1.14.5a6"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from collections.abc import Mapping
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -356,19 +355,8 @@ def update_pyproject_dependencies(
|
||||
|
||||
workspace_packages = _DEFAULT_WORKSPACE_PACKAGES + (extra_packages or [])
|
||||
|
||||
current_extra: str | None = None
|
||||
extra_header = re.compile(r"^\s*([A-Za-z0-9_-]+)\s*=\s*\[")
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
match = extra_header.match(line)
|
||||
if match:
|
||||
current_extra = match.group(1)
|
||||
elif line.strip().startswith("]"):
|
||||
current_extra = None
|
||||
|
||||
for pkg in workspace_packages:
|
||||
if pkg == "crewai-files" and current_extra == "file-processing":
|
||||
continue
|
||||
if f"{pkg}==" in line:
|
||||
stripped = line.lstrip()
|
||||
indent = line[: len(line) - len(stripped)]
|
||||
@@ -744,23 +732,18 @@ def _is_prerelease(version: str) -> bool:
|
||||
return any(indicator in v for indicator in _PRERELEASE_INDICATORS)
|
||||
|
||||
|
||||
def get_commits_from_last_tag(
|
||||
tag_name: str, version: str, cwd: Path | None = None
|
||||
) -> tuple[str, str]:
|
||||
def get_commits_from_last_tag(tag_name: str, version: str) -> tuple[str, str]:
|
||||
"""Get commits from the last tag, excluding current version.
|
||||
|
||||
Args:
|
||||
tag_name: Current tag name (e.g., "v1.0.0").
|
||||
version: Current version (e.g., "1.0.0").
|
||||
cwd: Directory to run git commands in (defaults to current).
|
||||
|
||||
Returns:
|
||||
Tuple of (commit_range, commits) where commits is newline-separated.
|
||||
"""
|
||||
try:
|
||||
all_tags = run_command(
|
||||
["git", "tag", "--sort=-version:refname"], cwd=cwd
|
||||
).split("\n")
|
||||
all_tags = run_command(["git", "tag", "--sort=-version:refname"]).split("\n")
|
||||
prev_tags = [t for t in all_tags if t and t != tag_name and t != f"v{version}"]
|
||||
|
||||
if not _is_prerelease(version):
|
||||
@@ -769,30 +752,22 @@ def get_commits_from_last_tag(
|
||||
if prev_tags:
|
||||
last_tag = prev_tags[0]
|
||||
commit_range = f"{last_tag}..HEAD"
|
||||
commits = run_command(
|
||||
["git", "log", commit_range, "--pretty=format:%s"], cwd=cwd
|
||||
)
|
||||
commits = run_command(["git", "log", commit_range, "--pretty=format:%s"])
|
||||
else:
|
||||
commit_range = "HEAD"
|
||||
commits = run_command(["git", "log", "--pretty=format:%s"], cwd=cwd)
|
||||
commits = run_command(["git", "log", "--pretty=format:%s"])
|
||||
except subprocess.CalledProcessError:
|
||||
commit_range = "HEAD"
|
||||
commits = run_command(["git", "log", "--pretty=format:%s"], cwd=cwd)
|
||||
commits = run_command(["git", "log", "--pretty=format:%s"])
|
||||
|
||||
return commit_range, commits
|
||||
|
||||
|
||||
def get_github_contributors(
|
||||
commit_range: str,
|
||||
repo: str = "crewAIInc/crewAI",
|
||||
cwd: Path | None = None,
|
||||
) -> list[str]:
|
||||
def get_github_contributors(commit_range: str) -> list[str]:
|
||||
"""Get GitHub usernames from commit range using GitHub API.
|
||||
|
||||
Args:
|
||||
commit_range: Git commit range (e.g., "abc123..HEAD").
|
||||
repo: GitHub repo in ``owner/name`` form to resolve commits against.
|
||||
cwd: Directory to run git commands in (defaults to current).
|
||||
|
||||
Returns:
|
||||
List of GitHub usernames sorted alphabetically.
|
||||
@@ -804,10 +779,10 @@ def get_github_contributors(
|
||||
gh_token = None
|
||||
|
||||
g = Github(login_or_token=gh_token) if gh_token else Github()
|
||||
github_repo = g.get_repo(repo)
|
||||
github_repo = g.get_repo("crewAIInc/crewAI")
|
||||
|
||||
commit_shas = run_command(
|
||||
["git", "log", commit_range, "--pretty=format:%H"], cwd=cwd
|
||||
["git", "log", commit_range, "--pretty=format:%H"]
|
||||
).split("\n")
|
||||
|
||||
contributors = set()
|
||||
@@ -947,26 +922,9 @@ def _generate_release_notes(
|
||||
version: str,
|
||||
tag_name: str,
|
||||
no_edit: bool,
|
||||
cwd: Path | None = None,
|
||||
gh_repo: str = "crewAIInc/crewAI",
|
||||
openai_client: OpenAI | None = None,
|
||||
bump_already_done: bool = True,
|
||||
) -> tuple[str, OpenAI, bool]:
|
||||
"""Generate, display, and optionally edit release notes.
|
||||
|
||||
Args:
|
||||
version: Version being released.
|
||||
tag_name: Tag name for the release.
|
||||
no_edit: Skip the interactive edit prompt.
|
||||
cwd: Directory to run git commands in (defaults to current).
|
||||
gh_repo: GitHub repo (``owner/name``) for resolving contributors.
|
||||
openai_client: Reuse an existing OpenAI client if provided.
|
||||
bump_already_done: True when the ``feat: bump versions to <version>``
|
||||
commit for the current release is already in history (the real
|
||||
release path). False in previews where no bump exists yet — the
|
||||
most recent bump commit is the *previous* version and must be
|
||||
used as the range start.
|
||||
|
||||
Returns:
|
||||
Tuple of (release_notes, openai_client, is_prerelease).
|
||||
"""
|
||||
@@ -981,8 +939,7 @@ def _generate_release_notes(
|
||||
"log",
|
||||
"--grep=^feat: bump versions to",
|
||||
"--format=%H %s",
|
||||
],
|
||||
cwd=cwd,
|
||||
]
|
||||
)
|
||||
bump_entries = [
|
||||
line for line in prev_bump_output.strip().split("\n") if line.strip()
|
||||
@@ -990,8 +947,7 @@ def _generate_release_notes(
|
||||
|
||||
is_stable = not _is_prerelease(version)
|
||||
prev_commit = None
|
||||
scan_entries = bump_entries[1:] if bump_already_done else bump_entries
|
||||
for entry in scan_entries:
|
||||
for entry in bump_entries[1:]:
|
||||
bump_ver = entry.split("feat: bump versions to", 1)[-1].strip()
|
||||
if is_stable and _is_prerelease(bump_ver):
|
||||
continue
|
||||
@@ -1001,7 +957,7 @@ def _generate_release_notes(
|
||||
if prev_commit:
|
||||
commit_range = f"{prev_commit}..HEAD"
|
||||
commits = run_command(
|
||||
["git", "log", commit_range, "--pretty=format:%s"], cwd=cwd
|
||||
["git", "log", commit_range, "--pretty=format:%s"]
|
||||
)
|
||||
|
||||
commit_lines = [
|
||||
@@ -1011,21 +967,14 @@ def _generate_release_notes(
|
||||
]
|
||||
commits = "\n".join(commit_lines)
|
||||
else:
|
||||
commit_range, commits = get_commits_from_last_tag(
|
||||
tag_name, version, cwd=cwd
|
||||
)
|
||||
commit_range, commits = get_commits_from_last_tag(tag_name, version)
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
commit_range, commits = get_commits_from_last_tag(
|
||||
tag_name, version, cwd=cwd
|
||||
)
|
||||
commit_range, commits = get_commits_from_last_tag(tag_name, version)
|
||||
|
||||
github_contributors = get_github_contributors(
|
||||
commit_range, repo=gh_repo, cwd=cwd
|
||||
)
|
||||
github_contributors = get_github_contributors(commit_range)
|
||||
|
||||
if openai_client is None:
|
||||
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||
|
||||
if commits.strip():
|
||||
contributors_section = ""
|
||||
@@ -1583,13 +1532,7 @@ def _wait_for_pr_merged(branch: str, cwd: Path) -> None:
|
||||
time.sleep(_PR_MERGE_POLL_INTERVAL)
|
||||
|
||||
|
||||
def _release_enterprise(
|
||||
version: str,
|
||||
is_prerelease: bool,
|
||||
dry_run: bool,
|
||||
no_edit: bool = False,
|
||||
openai_client: OpenAI | None = None,
|
||||
) -> None:
|
||||
def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> None:
|
||||
"""Clone the enterprise repo, bump versions, and create a release PR.
|
||||
|
||||
Expects ENTERPRISE_REPO, ENTERPRISE_VERSION_DIRS, and
|
||||
@@ -1599,8 +1542,6 @@ def _release_enterprise(
|
||||
version: New version string.
|
||||
is_prerelease: Whether this is a pre-release version.
|
||||
dry_run: Show what would be done without making changes.
|
||||
no_edit: Skip the interactive release-notes edit prompt.
|
||||
openai_client: Reuse OpenAI client from earlier phases if available.
|
||||
"""
|
||||
if (
|
||||
not _ENTERPRISE_REPO
|
||||
@@ -1618,6 +1559,7 @@ def _release_enterprise(
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
console.print(f"[dim][DRY RUN][/dim] Would clone {enterprise_repo}")
|
||||
for d in _ENTERPRISE_VERSION_DIRS:
|
||||
console.print(f"[dim][DRY RUN][/dim] Would update versions in {d}")
|
||||
console.print(
|
||||
@@ -1628,26 +1570,6 @@ def _release_enterprise(
|
||||
"[dim][DRY RUN][/dim] Would create bump PR, wait for merge, "
|
||||
"then tag and release"
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
repo_dir = Path(tmp) / enterprise_repo.split("/")[-1]
|
||||
console.print(f"\nCloning {enterprise_repo} (read-only preview)...")
|
||||
run_command(["gh", "repo", "clone", enterprise_repo, str(repo_dir)])
|
||||
console.print(f"[green]✓[/green] Cloned {enterprise_repo}")
|
||||
|
||||
_generate_release_notes(
|
||||
version,
|
||||
version,
|
||||
no_edit,
|
||||
cwd=repo_dir,
|
||||
gh_repo=enterprise_repo,
|
||||
openai_client=openai_client,
|
||||
bump_already_done=False,
|
||||
)
|
||||
console.print(
|
||||
"[dim][DRY RUN][/dim] Would tag and create GitHub release "
|
||||
"with the notes above"
|
||||
)
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -1760,18 +1682,8 @@ def _release_enterprise(
|
||||
run_command(["git", "pull"], cwd=repo_dir)
|
||||
|
||||
tag_name = version
|
||||
|
||||
release_notes, _, _ = _generate_release_notes(
|
||||
version,
|
||||
tag_name,
|
||||
no_edit,
|
||||
cwd=repo_dir,
|
||||
gh_repo=enterprise_repo,
|
||||
openai_client=openai_client,
|
||||
)
|
||||
|
||||
run_command(
|
||||
["git", "tag", "-a", tag_name, "-m", release_notes],
|
||||
["git", "tag", "-a", tag_name, "-m", f"Release {version}"],
|
||||
cwd=repo_dir,
|
||||
)
|
||||
run_command(["git", "push", "origin", tag_name], cwd=repo_dir)
|
||||
@@ -1787,7 +1699,7 @@ def _release_enterprise(
|
||||
"--title",
|
||||
tag_name,
|
||||
"--notes",
|
||||
release_notes,
|
||||
f"Release {version}",
|
||||
]
|
||||
if is_prerelease:
|
||||
gh_cmd.append("--prerelease")
|
||||
@@ -2086,7 +1998,7 @@ def tag(dry_run: bool, no_edit: bool) -> None:
|
||||
console.print("[green]✓[/green] main branch up to date")
|
||||
|
||||
release_notes, openai_client, is_prerelease = _generate_release_notes(
|
||||
version, tag_name, no_edit, bump_already_done=True
|
||||
version, tag_name, no_edit
|
||||
)
|
||||
|
||||
docs_branch = _update_docs_and_create_pr(
|
||||
@@ -2197,7 +2109,7 @@ def release(
|
||||
|
||||
if skip_to_enterprise:
|
||||
try:
|
||||
_release_enterprise(version, is_prerelease, dry_run, no_edit=no_edit)
|
||||
_release_enterprise(version, is_prerelease, dry_run)
|
||||
except BaseException as e:
|
||||
_print_release_error(e)
|
||||
_resume_hint(
|
||||
@@ -2293,7 +2205,7 @@ def release(
|
||||
console.print("[green]✓[/green] main branch up to date")
|
||||
|
||||
release_notes, openai_client, is_prerelease = _generate_release_notes(
|
||||
version, tag_name, no_edit, bump_already_done=not dry_run
|
||||
version, tag_name, no_edit
|
||||
)
|
||||
|
||||
docs_branch = _update_docs_and_create_pr(
|
||||
@@ -2347,13 +2259,7 @@ def release(
|
||||
|
||||
if not skip_enterprise:
|
||||
try:
|
||||
_release_enterprise(
|
||||
version,
|
||||
is_prerelease,
|
||||
dry_run,
|
||||
no_edit=no_edit,
|
||||
openai_client=openai_client,
|
||||
)
|
||||
_release_enterprise(version, is_prerelease, dry_run)
|
||||
except BaseException as e:
|
||||
_print_release_error(e)
|
||||
_resume_hint(
|
||||
|
||||
@@ -282,25 +282,6 @@ class TestUpdatePyprojectDependencies:
|
||||
assert '"crewai-files==2.0.0"' in result
|
||||
assert '"requests>=2.0"' in result
|
||||
|
||||
def test_skips_crewai_files_in_file_processing_extra(self, tmp_path: Path) -> None:
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
dedent("""\
|
||||
[project.optional-dependencies]
|
||||
file-processing = [
|
||||
"crewai-files==1.0.0",
|
||||
]
|
||||
other = [
|
||||
"crewai-files==1.0.0",
|
||||
]
|
||||
""")
|
||||
)
|
||||
|
||||
update_pyproject_dependencies(pyproject, "2.0.0")
|
||||
result = pyproject.read_text()
|
||||
assert '"crewai-files==1.0.0"' in result
|
||||
assert '"crewai-files==2.0.0"' in result
|
||||
|
||||
def test_leaves_bare_crewai_pin_alone(self, tmp_path: Path) -> None:
|
||||
"""`crewai==` must not collide with `crewai-core==` etc."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
|
||||
8
uv.lock
generated
8
uv.lock
generated
@@ -13,7 +13,7 @@ resolution-markers = [
|
||||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-05-16T15:32:24.373474Z"
|
||||
exclude-newer = "2026-05-12T13:27:48.906744Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
@@ -3268,11 +3268,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user