mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-06-24 17:48:11 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d0e3a841b | ||
|
|
12a5e91efb | ||
|
|
fac3e3579b | ||
|
|
a046e6a50b | ||
|
|
1862ff8f6c |
@@ -4,6 +4,29 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="24 يونيو 2026">
|
||||
## v1.14.8a4
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### الميزات
|
||||
- دعم تدفقات المحادثة في واجهة سطر الأوامر TUI.
|
||||
|
||||
### إصلاحات الأخطاء
|
||||
- إصلاح مسار التوجيه الرمزي في استخراج أرشيف المهارات.
|
||||
- التحقق من صحة مسارات تعريف التدفق الإعلاني.
|
||||
|
||||
### الوثائق
|
||||
- تحديث اللقطة وسجل التغييرات للإصدار v1.14.8a3.
|
||||
|
||||
## المساهمون
|
||||
|
||||
@lorenzejay, @theCyberTech, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="23 يونيو 2026">
|
||||
## v1.14.8a3
|
||||
|
||||
|
||||
@@ -4,6 +4,29 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="Jun 24, 2026">
|
||||
## v1.14.8a4
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
- Support conversational flows in the CLI TUI.
|
||||
|
||||
### Bug Fixes
|
||||
- Fix symlink path traversal in skill archive extraction.
|
||||
- Validate declarative flow definition paths.
|
||||
|
||||
### Documentation
|
||||
- Update snapshot and changelog for v1.14.8a3.
|
||||
|
||||
## Contributors
|
||||
|
||||
@lorenzejay, @theCyberTech, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="Jun 23, 2026">
|
||||
## v1.14.8a3
|
||||
|
||||
|
||||
@@ -4,6 +4,29 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="2026년 6월 24일">
|
||||
## v1.14.8a4
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 기능
|
||||
- CLI TUI에서 대화형 흐름 지원.
|
||||
|
||||
### 버그 수정
|
||||
- 기술 아카이브 추출 시 심볼릭 링크 경로 탐색 문제 수정.
|
||||
- 선언적 흐름 정의 경로 검증.
|
||||
|
||||
### 문서
|
||||
- v1.14.8a3에 대한 스냅샷 및 변경 로그 업데이트.
|
||||
|
||||
## 기여자
|
||||
|
||||
@lorenzejay, @theCyberTech, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 6월 23일">
|
||||
## v1.14.8a3
|
||||
|
||||
|
||||
@@ -4,6 +4,29 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="24 jun 2026">
|
||||
## v1.14.8a4
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Recursos
|
||||
- Suporte a fluxos de conversa na TUI do CLI.
|
||||
|
||||
### Correções de Bugs
|
||||
- Corrigir a travessia de caminho de symlink na extração de arquivo de habilidade.
|
||||
- Validar os caminhos de definição de fluxo declarativo.
|
||||
|
||||
### Documentação
|
||||
- Atualizar snapshot e changelog para v1.14.8a3.
|
||||
|
||||
## Contribuidores
|
||||
|
||||
@lorenzejay, @theCyberTech, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="23 jun 2026">
|
||||
## v1.14.8a3
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.8a3",
|
||||
"crewai-core==1.14.8a4",
|
||||
"click>=8.1.7,<9",
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"pydantic-settings~=2.10.1",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.8a3"
|
||||
__version__ = "1.14.8a4"
|
||||
|
||||
@@ -17,7 +17,7 @@ from textual.binding import Binding, BindingType
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.css.query import NoMatches
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Footer, Header, Static
|
||||
from textual.widgets import Button, Footer, Header, Input, Static
|
||||
|
||||
|
||||
_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||||
@@ -382,6 +382,18 @@ Screen {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#conversation-input {
|
||||
display: none;
|
||||
height: 3;
|
||||
border-top: hkey #333333;
|
||||
background: #1c1c1c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
#conversation-input:focus {
|
||||
border-top: hkey #1F7982;
|
||||
}
|
||||
|
||||
Header {
|
||||
background: #1c1c1c;
|
||||
color: #FF5A50;
|
||||
@@ -483,6 +495,7 @@ FooterKey .footer-key--key {
|
||||
total_tasks: int = 0,
|
||||
agent_names: list[str] | None = None,
|
||||
task_names: list[str] | None = None,
|
||||
conversational: bool = False,
|
||||
):
|
||||
super().__init__()
|
||||
self.title = f"CrewAI — {crew_name}"
|
||||
@@ -544,6 +557,13 @@ FooterKey .footer-key--key {
|
||||
self._event_handlers: list[tuple[type, Any]] = []
|
||||
|
||||
self._crew: Any = None
|
||||
self._flow: Any = None
|
||||
self._is_conversational = conversational
|
||||
self._conversation_messages: list[tuple[str, str]] = []
|
||||
self._conversation_turns = 0
|
||||
self._conversation_turn_in_progress = False
|
||||
self._conversation_previous_defer_trace_finalization: bool | None = None
|
||||
self._conversation_exit_commands = {"exit", "quit"}
|
||||
self._default_inputs: dict[str, Any] | None = None
|
||||
self._crew_result: Any = None
|
||||
self._crew_json_path: Any = None
|
||||
@@ -566,6 +586,10 @@ FooterKey .footer-key--key {
|
||||
yield Static(id="task-header")
|
||||
with VerticalScroll(id="scroll-area"):
|
||||
yield Static(id="main-content")
|
||||
yield Input(
|
||||
placeholder="Message the flow...",
|
||||
id="conversation-input",
|
||||
)
|
||||
with VerticalScroll(id="log-panel"):
|
||||
yield Static(id="log-content")
|
||||
yield Footer()
|
||||
@@ -574,7 +598,9 @@ FooterKey .footer-key--key {
|
||||
self._start_time = time.time()
|
||||
self._subscribe()
|
||||
self._tick_timer = self.set_interval(1 / 8, self._tick)
|
||||
if self._crew:
|
||||
if self._is_conversational and self._flow:
|
||||
self._start_conversational_session()
|
||||
elif self._crew:
|
||||
self._run_crew_worker()
|
||||
elif self._crew_json_path:
|
||||
self._load_and_run_worker()
|
||||
@@ -725,6 +751,140 @@ FooterKey .footer-key--key {
|
||||
self._tick_timer = self.set_interval(1 / 2, self._tick)
|
||||
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
|
||||
|
||||
# ── Conversational flow execution ───────────────────────
|
||||
|
||||
def _start_conversational_session(self) -> None:
|
||||
from crewai.events.listeners.tracing.utils import (
|
||||
set_suppress_tracing_messages,
|
||||
set_tui_mode,
|
||||
)
|
||||
|
||||
set_tui_mode(True)
|
||||
set_suppress_tracing_messages(True)
|
||||
with self._lock:
|
||||
self._status = "chatting"
|
||||
self._current_step = None
|
||||
self._elapsed_frozen = None
|
||||
self._conversation_previous_defer_trace_finalization = getattr(
|
||||
self._flow, "defer_trace_finalization", False
|
||||
)
|
||||
self._flow.defer_trace_finalization = True
|
||||
|
||||
try:
|
||||
input_widget = self.query_one("#conversation-input", Input)
|
||||
input_widget.display = True
|
||||
input_widget.focus()
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
def _finalize_conversational_session(self) -> None:
|
||||
if not (self._is_conversational and self._flow):
|
||||
return
|
||||
try:
|
||||
self._flow.finalize_session_traces()
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
previous = self._conversation_previous_defer_trace_finalization
|
||||
if previous is not None:
|
||||
try:
|
||||
self._flow.defer_trace_finalization = previous
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id != "conversation-input":
|
||||
return
|
||||
if not self._is_conversational:
|
||||
return
|
||||
|
||||
message = event.value.strip()
|
||||
event.input.value = ""
|
||||
if not message:
|
||||
return
|
||||
if message.lower() in self._conversation_exit_commands:
|
||||
self._finalize_conversational_session()
|
||||
self._unsubscribe()
|
||||
self.exit(self._crew_result)
|
||||
return
|
||||
if self._conversation_turn_in_progress:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._conversation_messages.append(("user", message))
|
||||
self._conversation_turn_in_progress = True
|
||||
self._conversation_turns += 1
|
||||
self._status = "working"
|
||||
self._current_step = ("yellow", "Thinking…", "")
|
||||
self._is_streaming = False
|
||||
self._streaming_text = ""
|
||||
self._task_full_output = ""
|
||||
self._current_llm_text = ""
|
||||
|
||||
event.input.disabled = True
|
||||
self._run_conversation_turn_worker(message)
|
||||
|
||||
@work(thread=True, exclusive=True, group="conversation")
|
||||
def _run_conversation_turn_worker(self, message: str) -> None:
|
||||
from crewai.events.listeners.tracing.utils import (
|
||||
set_suppress_tracing_messages,
|
||||
set_tui_mode,
|
||||
)
|
||||
|
||||
set_tui_mode(True)
|
||||
set_suppress_tracing_messages(True)
|
||||
try:
|
||||
result = self._flow.handle_turn(message)
|
||||
if hasattr(result, "get_full_text") and hasattr(result, "result"):
|
||||
for _chunk in result:
|
||||
pass
|
||||
result = result.result
|
||||
self.call_from_thread(self._on_conversation_turn_done, result)
|
||||
except Exception as e:
|
||||
self.call_from_thread(self._on_conversation_turn_failed, str(e))
|
||||
|
||||
def _on_conversation_turn_done(self, result: Any) -> None:
|
||||
with self._lock:
|
||||
output = self._stringify_output(result)
|
||||
self._conversation_messages.append(("assistant", output))
|
||||
self._crew_result = result
|
||||
self._conversation_turn_in_progress = False
|
||||
self._status = "chatting"
|
||||
self._is_streaming = False
|
||||
self._streaming_text = ""
|
||||
self._current_step = None
|
||||
self._enable_conversation_input()
|
||||
self._tick()
|
||||
self._scroll_to_result()
|
||||
|
||||
def _on_conversation_turn_failed(self, error: str) -> None:
|
||||
with self._lock:
|
||||
self._status = "failed"
|
||||
self._error = error
|
||||
self._conversation_turn_in_progress = False
|
||||
self._is_streaming = False
|
||||
self._current_step = None
|
||||
self._enable_conversation_input()
|
||||
self._tick()
|
||||
|
||||
def _enable_conversation_input(self) -> None:
|
||||
try:
|
||||
input_widget = self.query_one("#conversation-input", Input)
|
||||
input_widget.disabled = False
|
||||
input_widget.focus()
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
def _stringify_output(self, result: Any) -> str:
|
||||
raw_result = getattr(result, "raw", result)
|
||||
if raw_result is None:
|
||||
return ""
|
||||
if isinstance(raw_result, str):
|
||||
return raw_result
|
||||
try:
|
||||
return _json.dumps(raw_result, default=str, ensure_ascii=False)
|
||||
except TypeError:
|
||||
return str(raw_result)
|
||||
|
||||
# ── Actions ─────────────────────────────────────────────
|
||||
|
||||
def action_toggle_sidebar(self) -> None:
|
||||
@@ -783,6 +943,7 @@ FooterKey .footer-key--key {
|
||||
self._refresh_log_panel()
|
||||
|
||||
async def action_quit(self) -> None:
|
||||
self._finalize_conversational_session()
|
||||
self._unsubscribe()
|
||||
self.exit(self._crew_result)
|
||||
|
||||
@@ -958,6 +1119,30 @@ FooterKey .footer-key--key {
|
||||
t = Text()
|
||||
sidebar_width = 30
|
||||
|
||||
if self._is_conversational:
|
||||
t.append(" CONVERSATION\n", style=f"bold {_C_PRIMARY}")
|
||||
t.append("\n")
|
||||
if self._conversation_turn_in_progress:
|
||||
t.append(f" {self._spinner()} ", style=_C_PRIMARY)
|
||||
t.append("Working\n", style=f"bold {_C_TEXT}")
|
||||
elif self._status == "failed":
|
||||
t.append(" ✘ Failed\n", style=_C_RED)
|
||||
else:
|
||||
t.append(" ● Ready\n", style=_C_GREEN)
|
||||
t.append(f" Turns {self._conversation_turns}\n", style=_C_DIM)
|
||||
t.append("\n")
|
||||
t.append(" TOKENS\n", style=f"bold {_C_PRIMARY}")
|
||||
t.append("\n")
|
||||
out = self._output_tokens + self._live_out_tokens
|
||||
t.append(f" ↑ {self._input_tokens:,}\n", style=_C_DIM)
|
||||
t.append(f" ↓ {out:,}\n", style=_C_DIM)
|
||||
t.append("\n")
|
||||
t.append(" COMMANDS\n", style=f"bold {_C_PRIMARY}")
|
||||
t.append("\n")
|
||||
t.append(" quit / exit\n", style=_C_DIM)
|
||||
widget.update(t)
|
||||
return
|
||||
|
||||
t.append(" TASKS\n", style=f"bold {_C_PRIMARY}")
|
||||
t.append("\n")
|
||||
|
||||
@@ -1011,6 +1196,22 @@ FooterKey .footer-key--key {
|
||||
widget = self.query_one("#task-header", Static)
|
||||
t = Text()
|
||||
|
||||
if self._is_conversational:
|
||||
if self._status == "failed":
|
||||
t.append("✘ ", style=f"bold {_C_RED}")
|
||||
t.append("Failed", style=f"bold {_C_RED}")
|
||||
if self._error:
|
||||
t.append(f"\n{self._error[:120]}", style=_C_RED)
|
||||
elif self._conversation_turn_in_progress:
|
||||
t.append(f"{self._spinner()} ", style=_C_PRIMARY)
|
||||
t.append("Flow is responding", style=f"bold {_C_PRIMARY}")
|
||||
else:
|
||||
t.append("● ", style=f"bold {_C_GREEN}")
|
||||
t.append("Conversational flow ready", style=f"bold {_C_GREEN}")
|
||||
t.append(" Type a message below", style=_C_DIM)
|
||||
widget.update(t)
|
||||
return
|
||||
|
||||
if self._status == "completed":
|
||||
elapsed = self._elapsed_frozen or (time.time() - self._start_time)
|
||||
t.append("✔ ", style=f"bold {_C_GREEN}")
|
||||
@@ -1062,6 +1263,41 @@ FooterKey .footer-key--key {
|
||||
t = Text()
|
||||
should_scroll = False
|
||||
|
||||
if self._is_conversational:
|
||||
if not self._conversation_messages and not self._is_streaming:
|
||||
t.append(" Start the conversation below.\n", style=_C_MUTED)
|
||||
for role, content in self._conversation_messages:
|
||||
if role == "user":
|
||||
t.append("\n You\n", style=f"bold {_C_TEAL}")
|
||||
else:
|
||||
t.append("\n Assistant\n", style=f"bold {_C_PRIMARY}")
|
||||
rendered = _format_json_in_text(_unescape_text(content))
|
||||
for line in rendered.split("\n"):
|
||||
style = _C_TEXT if role == "assistant" else _C_DIM
|
||||
t.append(f" {line}\n", style=style)
|
||||
|
||||
if self._is_streaming and self._streaming_text:
|
||||
text = _unescape_text(self._filtered_streaming_text())
|
||||
if text.strip():
|
||||
t.append("\n Assistant\n", style=f"bold {_C_PRIMARY}")
|
||||
for line in text.rstrip().split("\n")[-40:]:
|
||||
t.append(f" {line}\n", style=_C_TEXT)
|
||||
should_scroll = True
|
||||
|
||||
if self._status == "failed" and self._error:
|
||||
t.append("\n Error\n", style=f"bold {_C_RED}")
|
||||
t.append(f" {self._error}\n", style=_C_RED)
|
||||
|
||||
widget.update(t)
|
||||
if should_scroll:
|
||||
try:
|
||||
self.query_one("#scroll-area", VerticalScroll).scroll_end(
|
||||
animate=False
|
||||
)
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
return
|
||||
|
||||
# Plan section
|
||||
if self._plan and self._plan.get("steps"):
|
||||
plan_title = self._plan.get("plan", "Plan")
|
||||
|
||||
@@ -378,12 +378,40 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
|
||||
|
||||
def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None:
|
||||
"""Path-traversal-safe extraction for Python < 3.12."""
|
||||
"""Path-traversal-safe extraction for Python versions without tar filters.
|
||||
|
||||
Validates both the member's own path and, for symlink/hardlink members,
|
||||
the link target. Without the link-target check a malicious archive can
|
||||
plant a symlink that escapes ``dest`` (e.g. ``link -> /home/user/.ssh``)
|
||||
followed by a regular member written *through* that link
|
||||
(``link/authorized_keys``), escaping ``dest`` even though every member
|
||||
name resolves inside it. This mirrors the protection that
|
||||
``tarfile.extractall(..., filter="data")`` provides when available.
|
||||
"""
|
||||
dest_resolved = dest.resolve()
|
||||
for member in tf.getmembers():
|
||||
member_path = (dest / member.name).resolve()
|
||||
if not member_path.is_relative_to(dest_resolved):
|
||||
raise ValueError(f"Blocked path traversal attempt: {member.name!r}")
|
||||
if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
|
||||
raise ValueError(f"Blocked unsupported tar member: {member.name!r}")
|
||||
if member.issym() or member.islnk():
|
||||
link_target = member.linkname
|
||||
# Absolute link targets always escape the destination.
|
||||
if os.path.isabs(link_target):
|
||||
raise ValueError(
|
||||
f"Blocked link target escaping destination: "
|
||||
f"{member.name!r} -> {link_target!r}"
|
||||
)
|
||||
# Hardlink names are relative to the archive root; symlink
|
||||
# targets are relative to the member's own directory.
|
||||
anchor = dest if member.islnk() else (dest / member.name).parent
|
||||
resolved_target = (anchor / link_target).resolve()
|
||||
if not resolved_target.is_relative_to(dest_resolved):
|
||||
raise ValueError(
|
||||
f"Blocked link target escaping destination: "
|
||||
f"{member.name!r} -> {link_target!r}"
|
||||
)
|
||||
tf.extractall(dest) # noqa: S202
|
||||
|
||||
|
||||
|
||||
105
lib/cli/src/crewai_cli/kickoff_flow.py
Normal file
105
lib/cli/src/crewai_cli/kickoff_flow.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def _project_script_target(script_name: str) -> str | None:
|
||||
try:
|
||||
from crewai_cli.utils import read_toml
|
||||
|
||||
pyproject = read_toml()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
target = pyproject.get("project", {}).get("scripts", {}).get(script_name)
|
||||
return target if isinstance(target, str) else None
|
||||
|
||||
|
||||
def _prepare_project_import_path() -> None:
|
||||
cwd = Path.cwd()
|
||||
for path in (cwd / "src", cwd):
|
||||
path_str = str(path)
|
||||
if path.exists() and path_str not in sys.path:
|
||||
sys.path.insert(0, path_str)
|
||||
|
||||
|
||||
def _load_conversational_flow_from_kickoff_script() -> Any | None:
|
||||
target = _project_script_target("kickoff")
|
||||
if not target or ":" not in target:
|
||||
return None
|
||||
|
||||
module_name, _callable_name = target.split(":", 1)
|
||||
_prepare_project_import_path()
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
from crewai.flow.flow import Flow
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for value in vars(module).values():
|
||||
if (
|
||||
inspect.isclass(value)
|
||||
and value is not Flow
|
||||
and issubclass(value, Flow)
|
||||
and getattr(value, "conversational", False)
|
||||
):
|
||||
return value()
|
||||
|
||||
for value in vars(module).values():
|
||||
if (
|
||||
isinstance(value, Flow)
|
||||
and getattr(value, "conversational", False)
|
||||
and callable(getattr(value, "handle_turn", None))
|
||||
):
|
||||
return value
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _run_conversational_flow_tui(flow: Any) -> Any:
|
||||
from crewai_cli.crew_run_tui import CrewRunApp
|
||||
|
||||
app = CrewRunApp(
|
||||
crew_name=getattr(flow, "name", None) or type(flow).__name__,
|
||||
conversational=True,
|
||||
)
|
||||
app._flow = flow
|
||||
app.run()
|
||||
|
||||
if app._status == "failed":
|
||||
raise SystemExit(1)
|
||||
|
||||
return app._crew_result
|
||||
|
||||
|
||||
def kickoff_flow() -> None:
|
||||
"""
|
||||
Kickoff the flow by running a command in the UV environment.
|
||||
"""
|
||||
flow = _load_conversational_flow_from_kickoff_script()
|
||||
if flow is not None:
|
||||
_run_conversational_flow_tui(flow)
|
||||
return
|
||||
|
||||
command = ["uv", "run", "kickoff"]
|
||||
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603
|
||||
|
||||
if result.stderr:
|
||||
click.echo(result.stderr, err=True)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(f"An error occurred while running the flow: {e}", err=True)
|
||||
click.echo(e.output, err=True)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"An unexpected error occurred: {e}", err=True)
|
||||
@@ -604,6 +604,16 @@ def _run_flow_project(
|
||||
run_declarative_flow_in_project_env(definition=definition)
|
||||
return
|
||||
|
||||
from crewai_cli.kickoff_flow import (
|
||||
_load_conversational_flow_from_kickoff_script,
|
||||
_run_conversational_flow_tui,
|
||||
)
|
||||
|
||||
flow = _load_conversational_flow_from_kickoff_script()
|
||||
if flow is not None:
|
||||
_run_conversational_flow_tui(flow)
|
||||
return
|
||||
|
||||
_execute_uv_script("kickoff", entity_type="flow")
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PureWindowsPath
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
@@ -12,7 +12,7 @@ from crewai_cli.utils import build_env_with_all_tool_credentials
|
||||
|
||||
|
||||
def run_declarative_flow_in_project_env(
|
||||
definition: str, inputs: str | None = None
|
||||
definition: str | Path, inputs: str | None = None
|
||||
) -> None:
|
||||
"""Run a declarative flow inside the project's Python environment."""
|
||||
if is_declarative_flow_project_env() or not _has_project_file():
|
||||
@@ -25,7 +25,7 @@ def run_declarative_flow_in_project_env(
|
||||
_execute_declarative_flow_command(["uv", "run", "crewai", "run"])
|
||||
|
||||
|
||||
def plot_declarative_flow_in_project_env(definition: str) -> None:
|
||||
def plot_declarative_flow_in_project_env(definition: str | Path) -> None:
|
||||
"""Plot a declarative flow inside the project's Python environment."""
|
||||
if is_declarative_flow_project_env() or not _has_project_file():
|
||||
plot_declarative_flow(definition=definition)
|
||||
@@ -34,7 +34,7 @@ def plot_declarative_flow_in_project_env(definition: str) -> None:
|
||||
_execute_declarative_flow_command(["uv", "run", "crewai", "flow", "plot"])
|
||||
|
||||
|
||||
def run_declarative_flow(definition: str, inputs: str | None = None) -> None:
|
||||
def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> None:
|
||||
"""Run a declarative flow from a definition path."""
|
||||
parsed_inputs = _parse_inputs(inputs)
|
||||
|
||||
@@ -50,7 +50,7 @@ def run_declarative_flow(definition: str, inputs: str | None = None) -> None:
|
||||
click.echo(_format_result(result))
|
||||
|
||||
|
||||
def plot_declarative_flow(definition: str) -> None:
|
||||
def plot_declarative_flow(definition: str | Path) -> None:
|
||||
"""Plot a declarative flow from a definition path."""
|
||||
try:
|
||||
flow = load_declarative_flow(definition)
|
||||
@@ -62,7 +62,7 @@ def plot_declarative_flow(definition: str) -> None:
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
|
||||
def load_declarative_flow(definition: str) -> Any:
|
||||
def load_declarative_flow(definition: str | Path) -> Any:
|
||||
"""Load a declarative Flow instance from a definition path."""
|
||||
try:
|
||||
from crewai.flow.flow import Flow
|
||||
@@ -102,7 +102,8 @@ def load_declarative_flow(definition: str) -> Any:
|
||||
|
||||
def configured_project_declarative_flow(
|
||||
pyproject_data: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
project_root: Path | None = None,
|
||||
) -> Path | None:
|
||||
"""Return the configured declarative flow source for flow projects."""
|
||||
if pyproject_data is None:
|
||||
try:
|
||||
@@ -118,7 +119,66 @@ def configured_project_declarative_flow(
|
||||
definition = crewai_config.get("definition")
|
||||
if not isinstance(definition, str):
|
||||
return None
|
||||
return definition.strip() or None
|
||||
definition = definition.strip()
|
||||
if not definition:
|
||||
return None
|
||||
|
||||
return _resolve_project_definition_path(
|
||||
definition=definition,
|
||||
project_root=project_root or Path.cwd(),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_project_definition_path(definition: str, project_root: Path) -> Path:
|
||||
definition_path = Path(definition)
|
||||
windows_definition_path = PureWindowsPath(definition)
|
||||
|
||||
if definition.startswith("~"):
|
||||
raise click.UsageError(
|
||||
"[tool.crewai] definition must be a project-local path; "
|
||||
f"got {definition!r}."
|
||||
)
|
||||
|
||||
if definition_path.is_absolute() or windows_definition_path.is_absolute():
|
||||
raise click.UsageError(
|
||||
"[tool.crewai] definition must be relative to the project root; "
|
||||
f"got {definition!r}."
|
||||
)
|
||||
|
||||
try:
|
||||
root = project_root.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise click.UsageError(
|
||||
f"Invalid project root for [tool.crewai] definition: {exc}"
|
||||
) from exc
|
||||
|
||||
candidate = root / definition_path
|
||||
try:
|
||||
resolved_candidate = candidate.resolve(strict=False)
|
||||
except OSError as exc:
|
||||
raise click.UsageError(
|
||||
f"Invalid [tool.crewai] definition path {definition!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
if not resolved_candidate.is_relative_to(root):
|
||||
raise click.UsageError(
|
||||
"[tool.crewai] definition must resolve inside the project root; "
|
||||
f"got {definition!r}."
|
||||
)
|
||||
|
||||
if not resolved_candidate.exists():
|
||||
raise click.UsageError(
|
||||
"[tool.crewai] definition must point to an existing file; "
|
||||
f"got {definition!r}."
|
||||
)
|
||||
|
||||
if not resolved_candidate.is_file():
|
||||
raise click.UsageError(
|
||||
"[tool.crewai] definition must point to a regular file; "
|
||||
f"got {definition!r}."
|
||||
)
|
||||
|
||||
return resolved_candidate
|
||||
|
||||
|
||||
def _execute_declarative_flow_command(command: list[str]) -> None:
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
|
||||
authors = [{ name = "Your Name", email = "you@example.com" }]
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.14.8a3"
|
||||
"crewai[tools]==1.14.8a4"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
|
||||
authors = [{ name = "Your Name", email = "you@example.com" }]
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.14.8a3"
|
||||
"crewai[tools]==1.14.8a4"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
|
||||
authors = [{ name = "Your Name", email = "you@example.com" }]
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.14.8a3"
|
||||
"crewai[tools]==1.14.8a4"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.14.8a3"
|
||||
"crewai[tools]==1.14.8a4"
|
||||
]
|
||||
|
||||
[tool.crewai]
|
||||
|
||||
140
lib/cli/tests/skills/test_safe_extract.py
Normal file
140
lib/cli/tests/skills/test_safe_extract.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Regression tests for path-traversal-safe archive extraction.
|
||||
|
||||
Guards against symlink/hardlink-based path traversal in the fallback used on
|
||||
Python versions without tarfile extraction filters. The filtered path relies on
|
||||
`tarfile.extractall(..., filter="data")`; the fallback must provide the same
|
||||
protection by validating link targets, not just member names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai_cli.experimental.skills.main import _safe_extractall
|
||||
|
||||
|
||||
def _tar_from_members(build) -> tarfile.TarFile:
|
||||
"""Build an in-memory tar archive via `build(tf)` and return it for reading."""
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tf:
|
||||
build(tf)
|
||||
buf.seek(0)
|
||||
return tarfile.open(fileobj=buf, mode="r")
|
||||
|
||||
|
||||
def test_blocks_symlink_escaping_destination(tmp_path: Path) -> None:
|
||||
"""A symlink whose target escapes dest, plus a file written through it,
|
||||
must be rejected before anything is extracted."""
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
dest = tmp_path / "dest"
|
||||
dest.mkdir()
|
||||
|
||||
def build(tf: tarfile.TarFile) -> None:
|
||||
link = tarfile.TarInfo("link")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = str(outside) # absolute path outside dest
|
||||
tf.addfile(link)
|
||||
payload = b"pwned"
|
||||
info = tarfile.TarInfo("link/evil.txt")
|
||||
info.size = len(payload)
|
||||
tf.addfile(info, io.BytesIO(payload))
|
||||
|
||||
with _tar_from_members(build) as tf:
|
||||
with pytest.raises(ValueError, match="escaping destination"):
|
||||
_safe_extractall(tf, dest)
|
||||
|
||||
assert not (outside / "evil.txt").exists()
|
||||
|
||||
|
||||
def test_blocks_relative_symlink_escaping_destination(tmp_path: Path) -> None:
|
||||
"""A relative symlink (../..) that escapes dest is also rejected."""
|
||||
dest = tmp_path / "dest"
|
||||
dest.mkdir()
|
||||
|
||||
def build(tf: tarfile.TarFile) -> None:
|
||||
link = tarfile.TarInfo("sub/link")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = "../../outside" # escapes dest from sub/
|
||||
tf.addfile(link)
|
||||
|
||||
with _tar_from_members(build) as tf:
|
||||
with pytest.raises(ValueError, match="escaping destination"):
|
||||
_safe_extractall(tf, dest)
|
||||
|
||||
|
||||
def test_blocks_hardlink_escaping_destination(tmp_path: Path) -> None:
|
||||
"""A hardlink whose target escapes dest is rejected."""
|
||||
dest = tmp_path / "dest"
|
||||
dest.mkdir()
|
||||
|
||||
def build(tf: tarfile.TarFile) -> None:
|
||||
link = tarfile.TarInfo("escape")
|
||||
link.type = tarfile.LNKTYPE
|
||||
link.linkname = "../outside.txt" # escapes archive root
|
||||
tf.addfile(link)
|
||||
|
||||
with _tar_from_members(build) as tf:
|
||||
with pytest.raises(ValueError, match="escaping destination"):
|
||||
_safe_extractall(tf, dest)
|
||||
|
||||
|
||||
def test_blocks_special_tar_member(tmp_path: Path) -> None:
|
||||
"""Special tar members such as FIFOs are rejected."""
|
||||
dest = tmp_path / "dest"
|
||||
dest.mkdir()
|
||||
|
||||
def build(tf: tarfile.TarFile) -> None:
|
||||
fifo = tarfile.TarInfo("pipe")
|
||||
fifo.type = tarfile.FIFOTYPE
|
||||
tf.addfile(fifo)
|
||||
|
||||
with _tar_from_members(build) as tf:
|
||||
with pytest.raises(ValueError, match="unsupported tar member"):
|
||||
_safe_extractall(tf, dest)
|
||||
|
||||
|
||||
def test_allows_benign_relative_symlink(tmp_path: Path) -> None:
|
||||
"""A symlink that stays within dest is permitted."""
|
||||
dest = tmp_path / "dest"
|
||||
dest.mkdir()
|
||||
|
||||
def build(tf: tarfile.TarFile) -> None:
|
||||
payload = b"hi"
|
||||
info = tarfile.TarInfo("real.txt")
|
||||
info.size = len(payload)
|
||||
tf.addfile(info, io.BytesIO(payload))
|
||||
link = tarfile.TarInfo("alias.txt")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = "real.txt" # stays inside dest
|
||||
tf.addfile(link)
|
||||
|
||||
with _tar_from_members(build) as tf:
|
||||
_safe_extractall(tf, dest)
|
||||
|
||||
assert (dest / "real.txt").read_bytes() == b"hi"
|
||||
assert (dest / "alias.txt").is_symlink()
|
||||
assert (dest / "alias.txt").readlink() == Path("real.txt")
|
||||
|
||||
|
||||
def test_allows_benign_archive(tmp_path: Path) -> None:
|
||||
"""An ordinary archive of regular files extracts correctly."""
|
||||
dest = tmp_path / "dest"
|
||||
dest.mkdir()
|
||||
|
||||
def build(tf: tarfile.TarFile) -> None:
|
||||
for name, body in (("SKILL.md", b"# skill"), ("scripts/run.py", b"print(1)")):
|
||||
payload = body
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(payload)
|
||||
tf.addfile(info, io.BytesIO(payload))
|
||||
|
||||
with _tar_from_members(build) as tf:
|
||||
_safe_extractall(tf, dest)
|
||||
|
||||
assert (dest / "SKILL.md").read_bytes() == b"# skill"
|
||||
assert (dest / "scripts" / "run.py").read_bytes() == b"print(1)"
|
||||
@@ -126,6 +126,52 @@ def test_chain_deploy_does_not_login_for_deploy_exit(monkeypatch, capsys) -> Non
|
||||
assert "Deploy failed with exit code 42" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_conversation_turn_done_records_assistant_message() -> None:
|
||||
class RawResult:
|
||||
raw = "hello from the flow"
|
||||
|
||||
app = CrewRunApp(conversational=True)
|
||||
app._conversation_turn_in_progress = True
|
||||
app._enable_conversation_input = lambda: None # type: ignore[method-assign]
|
||||
app._tick = lambda: None # type: ignore[method-assign]
|
||||
app._scroll_to_result = lambda: None # type: ignore[method-assign]
|
||||
|
||||
app._on_conversation_turn_done(RawResult())
|
||||
|
||||
assert app._conversation_messages == [("assistant", "hello from the flow")]
|
||||
assert app._conversation_turn_in_progress is False
|
||||
assert app._status == "chatting"
|
||||
assert isinstance(app._crew_result, RawResult)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_input_submits_turn() -> None:
|
||||
class FakeFlow:
|
||||
defer_trace_finalization = False
|
||||
|
||||
def handle_turn(self, message: str) -> str:
|
||||
return f"reply: {message}"
|
||||
|
||||
def finalize_session_traces(self) -> None:
|
||||
pass
|
||||
|
||||
app = CrewRunApp(crew_name="Demo", conversational=True)
|
||||
app._flow = FakeFlow()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.click("#conversation-input")
|
||||
await pilot.press("h", "i", "enter")
|
||||
for _ in range(50):
|
||||
await pilot.pause(0.05)
|
||||
if app._conversation_messages[-1:] == [("assistant", "reply: hi")]:
|
||||
break
|
||||
|
||||
assert app._conversation_messages == [
|
||||
("user", "hi"),
|
||||
("assistant", "reply: hi"),
|
||||
]
|
||||
|
||||
|
||||
def test_plan_step_status_updates_only_the_explicit_step() -> None:
|
||||
app = _app_with_plan()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
@@ -107,6 +108,8 @@ def test_configured_project_declarative_flow(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
definition_path = tmp_path / "flow.yaml"
|
||||
definition_path.write_text(FLOW_YAML, encoding="utf-8")
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[tool.crewai]\ntype = "flow"\ndefinition = " flow.yaml "\n',
|
||||
encoding="utf-8",
|
||||
@@ -114,4 +117,132 @@ def test_configured_project_declarative_flow(
|
||||
|
||||
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
|
||||
|
||||
assert configured_project_declarative_flow() == "flow.yaml"
|
||||
assert configured_project_declarative_flow() == definition_path.resolve()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("definition", "expected_error"),
|
||||
[
|
||||
("C:/tmp/flow.yaml", "must be relative to the project root"),
|
||||
("~/flow.yaml", "must be a project-local path"),
|
||||
("../flow.yaml", "must resolve inside the project root"),
|
||||
],
|
||||
)
|
||||
def test_configured_project_declarative_flow_rejects_unsafe_paths(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
definition: str,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
f'[tool.crewai]\ntype = "flow"\ndefinition = "{definition}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
configured_project_declarative_flow()
|
||||
|
||||
assert expected_error in exc_info.value.message
|
||||
|
||||
|
||||
def test_configured_project_declarative_flow_allows_normalized_project_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
definition_path = tmp_path / "flow.yaml"
|
||||
definition_path.write_text(FLOW_YAML, encoding="utf-8")
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[tool.crewai]\ntype = "flow"\ndefinition = "src/../flow.yaml"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
|
||||
|
||||
assert configured_project_declarative_flow() == definition_path.resolve()
|
||||
|
||||
|
||||
def test_configured_project_declarative_flow_rejects_absolute_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
definition = tmp_path / "flow.yaml"
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
f'[tool.crewai]\ntype = "flow"\ndefinition = "{definition.as_posix()}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
configured_project_declarative_flow()
|
||||
|
||||
assert "must be relative to the project root" in exc_info.value.message
|
||||
|
||||
|
||||
def test_configured_project_declarative_flow_rejects_symlink_escape(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
outside_definition = tmp_path.parent / "outside-flow.yaml"
|
||||
outside_definition.write_text(FLOW_YAML, encoding="utf-8")
|
||||
link = tmp_path / "flow.yaml"
|
||||
try:
|
||||
link.symlink_to(outside_definition)
|
||||
except (NotImplementedError, OSError) as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
configured_project_declarative_flow()
|
||||
|
||||
assert "must resolve inside the project root" in exc_info.value.message
|
||||
|
||||
|
||||
def test_configured_project_declarative_flow_rejects_missing_file(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[tool.crewai]\ntype = "flow"\ndefinition = "missing-flow.yaml"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
configured_project_declarative_flow()
|
||||
|
||||
assert "must point to an existing file" in exc_info.value.message
|
||||
|
||||
|
||||
def test_configured_project_declarative_flow_rejects_directory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "flow.yaml").mkdir()
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
configured_project_declarative_flow()
|
||||
|
||||
assert "must point to a regular file" in exc_info.value.message
|
||||
|
||||
63
lib/cli/tests/test_kickoff_flow.py
Normal file
63
lib/cli/tests/test_kickoff_flow.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from crewai_cli import kickoff_flow
|
||||
|
||||
|
||||
def test_loads_conversational_flow_from_kickoff_script(tmp_path, monkeypatch) -> None:
|
||||
package_dir = tmp_path / "src" / "demo_chat"
|
||||
package_dir.mkdir(parents=True)
|
||||
(package_dir / "__init__.py").write_text("")
|
||||
(package_dir / "main.py").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"from crewai.flow import Flow",
|
||||
"",
|
||||
"class DemoChatFlow(Flow):",
|
||||
" conversational = True",
|
||||
]
|
||||
)
|
||||
)
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"[project]",
|
||||
'name = "demo-chat"',
|
||||
"[project.scripts]",
|
||||
'kickoff = "demo_chat.main:kickoff"',
|
||||
]
|
||||
)
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
sys.modules.pop("demo_chat.main", None)
|
||||
sys.modules.pop("demo_chat", None)
|
||||
|
||||
flow = kickoff_flow._load_conversational_flow_from_kickoff_script()
|
||||
|
||||
assert flow is not None
|
||||
assert type(flow).__name__ == "DemoChatFlow"
|
||||
assert flow.conversational is True
|
||||
|
||||
|
||||
def test_kickoff_flow_falls_back_to_uv_when_no_conversational_flow(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_run(command, capture_output, text, check):
|
||||
calls.append(command)
|
||||
|
||||
class Result:
|
||||
stderr = ""
|
||||
|
||||
return Result()
|
||||
|
||||
monkeypatch.setattr(
|
||||
kickoff_flow, "_load_conversational_flow_from_kickoff_script", lambda: None
|
||||
)
|
||||
monkeypatch.setattr(kickoff_flow.subprocess, "run", fake_run)
|
||||
|
||||
kickoff_flow.kickoff_flow()
|
||||
|
||||
assert calls == [["uv", "run", "kickoff"]]
|
||||
@@ -645,6 +645,10 @@ def test_run_crew_runs_python_flow_project(monkeypatch, capsys):
|
||||
"_execute_uv_script",
|
||||
lambda script_name, **kwargs: calls.append((script_name, kwargs)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.kickoff_flow._load_conversational_flow_from_kickoff_script",
|
||||
lambda: None,
|
||||
)
|
||||
|
||||
run_crew_module.run_crew()
|
||||
|
||||
@@ -652,6 +656,41 @@ def test_run_crew_runs_python_flow_project(monkeypatch, capsys):
|
||||
assert calls == [("kickoff", {"entity_type": "flow"})]
|
||||
|
||||
|
||||
def test_run_crew_runs_conversational_flow_tui(monkeypatch, capsys):
|
||||
class Flow:
|
||||
pass
|
||||
|
||||
flow = Flow()
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"read_toml",
|
||||
lambda: {"tool": {"crewai": {"type": "flow"}}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.kickoff_flow._load_conversational_flow_from_kickoff_script",
|
||||
lambda: flow,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.kickoff_flow._run_conversational_flow_tui",
|
||||
lambda loaded_flow: calls.append(loaded_flow),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"_execute_uv_script",
|
||||
lambda *_args, **_kwargs: pytest.fail(
|
||||
"conversational flows must use the TUI"
|
||||
),
|
||||
)
|
||||
|
||||
run_crew_module.run_crew()
|
||||
|
||||
assert capsys.readouterr().out == ""
|
||||
assert calls == [flow]
|
||||
|
||||
|
||||
def test_run_crew_rejects_filename_for_flow_project(monkeypatch):
|
||||
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
@@ -666,9 +705,14 @@ def test_run_crew_rejects_filename_for_flow_project(monkeypatch):
|
||||
assert "--filename can only be used when running crews" in exc_info.value.message
|
||||
|
||||
|
||||
def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys):
|
||||
def test_run_crew_runs_configured_declarative_flow_project(
|
||||
monkeypatch, tmp_path: Path, capsys
|
||||
):
|
||||
calls = []
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
definition_path = tmp_path / "flow.yaml"
|
||||
definition_path.write_text("schema: crewai.flow/v1\n", encoding="utf-8")
|
||||
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
@@ -695,4 +739,4 @@ def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys):
|
||||
run_crew_module.run_crew()
|
||||
|
||||
assert capsys.readouterr().out == ""
|
||||
assert calls == [("flow.yaml", None)]
|
||||
assert calls == [(definition_path.resolve(), None)]
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.8a3"
|
||||
__version__ = "1.14.8a4"
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.14.8a3"
|
||||
__version__ = "1.14.8a4"
|
||||
|
||||
@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"pytube~=15.0.0",
|
||||
"requests>=2.33.0,<3",
|
||||
"crewai==1.14.8a3",
|
||||
"crewai==1.14.8a4",
|
||||
"tiktoken>=0.8.0,<0.13",
|
||||
"beautifulsoup4~=4.13.4",
|
||||
"python-docx~=1.2.0",
|
||||
|
||||
@@ -330,4 +330,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.14.8a3"
|
||||
__version__ = "1.14.8a4"
|
||||
|
||||
@@ -8,8 +8,8 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.8a3",
|
||||
"crewai-cli==1.14.8a3",
|
||||
"crewai-core==1.14.8a4",
|
||||
"crewai-cli==1.14.8a4",
|
||||
# Core Dependencies
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"openai>=2.30.0,<3",
|
||||
@@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
|
||||
|
||||
[project.optional-dependencies]
|
||||
tools = [
|
||||
"crewai-tools==1.14.8a3",
|
||||
"crewai-tools==1.14.8a4",
|
||||
]
|
||||
embeddings = [
|
||||
"tiktoken>=0.8.0,<0.13"
|
||||
|
||||
@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
|
||||
|
||||
_suppress_pydantic_deprecation_warnings()
|
||||
|
||||
__version__ = "1.14.8a3"
|
||||
__version__ = "1.14.8a4"
|
||||
|
||||
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"Memory": ("crewai.memory.unified_memory", "Memory"),
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
from typing import TypedDict
|
||||
@@ -127,12 +128,36 @@ class SkillCacheManager:
|
||||
|
||||
|
||||
def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None:
|
||||
"""Path-traversal-safe extraction for Python < 3.12."""
|
||||
"""Path-traversal-safe extraction for Python versions without tar filters.
|
||||
|
||||
Validates both the member's own path and, for symlink/hardlink members,
|
||||
the link target. Without the link-target check a malicious archive can
|
||||
plant a symlink that escapes ``dest`` followed by a regular member written
|
||||
through that link, escaping ``dest`` even though every member name resolves
|
||||
inside it. This mirrors the protection that
|
||||
``tarfile.extractall(..., filter="data")`` provides when available.
|
||||
"""
|
||||
dest_resolved = dest.resolve()
|
||||
for member in tf.getmembers():
|
||||
member_path = (dest / member.name).resolve()
|
||||
if not member_path.is_relative_to(dest_resolved):
|
||||
raise ValueError(f"Blocked path traversal attempt: {member.name!r}")
|
||||
if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
|
||||
raise ValueError(f"Blocked unsupported tar member: {member.name!r}")
|
||||
if member.issym() or member.islnk():
|
||||
link_target = member.linkname
|
||||
if os.path.isabs(link_target):
|
||||
raise ValueError(
|
||||
f"Blocked link target escaping destination: "
|
||||
f"{member.name!r} -> {link_target!r}"
|
||||
)
|
||||
anchor = dest if member.islnk() else (dest / member.name).parent
|
||||
resolved_target = (anchor / link_target).resolve()
|
||||
if not resolved_target.is_relative_to(dest_resolved):
|
||||
raise ValueError(
|
||||
f"Blocked link target escaping destination: "
|
||||
f"{member.name!r} -> {link_target!r}"
|
||||
)
|
||||
tf.extractall(dest) # noqa: S202
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import json
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
from crewai.experimental.skills.cache import SkillCacheManager
|
||||
import pytest
|
||||
|
||||
from crewai.experimental.skills.cache import SkillCacheManager, _safe_extractall
|
||||
|
||||
|
||||
def _make_tar_gz(files: dict[str, str]) -> bytes:
|
||||
@@ -35,6 +37,15 @@ def _make_tar_gz(files: dict[str, str]) -> bytes:
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _tar_from_members(build) -> tarfile.TarFile:
|
||||
"""Build an in-memory tar archive via `build(tf)` and return it for reading."""
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tf:
|
||||
build(tf)
|
||||
buf.seek(0)
|
||||
return tarfile.open(fileobj=buf, mode="r")
|
||||
|
||||
|
||||
class TestSkillCacheManager:
|
||||
def test_get_cached_path_missing(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
@@ -113,3 +124,85 @@ class TestSkillCacheManager:
|
||||
dest = cache.store("acme", "my-skill", None, archive)
|
||||
meta = json.loads((dest / ".crewai_meta.json").read_text())
|
||||
assert meta["version"] is None
|
||||
|
||||
|
||||
def test_safe_extractall_blocks_symlink_escaping_cache_destination(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A symlink whose target escapes dest is rejected before extraction."""
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
dest = tmp_path / "dest"
|
||||
dest.mkdir()
|
||||
|
||||
def build(tf: tarfile.TarFile) -> None:
|
||||
link = tarfile.TarInfo("link")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = str(outside)
|
||||
tf.addfile(link)
|
||||
payload = b"pwned"
|
||||
info = tarfile.TarInfo("link/evil.txt")
|
||||
info.size = len(payload)
|
||||
tf.addfile(info, io.BytesIO(payload))
|
||||
|
||||
with _tar_from_members(build) as tf:
|
||||
with pytest.raises(ValueError, match="escaping destination"):
|
||||
_safe_extractall(tf, dest)
|
||||
|
||||
assert not (outside / "evil.txt").exists()
|
||||
|
||||
|
||||
def test_safe_extractall_blocks_hardlink_escaping_cache_destination(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A hardlink whose target escapes dest is rejected."""
|
||||
dest = tmp_path / "dest"
|
||||
dest.mkdir()
|
||||
|
||||
def build(tf: tarfile.TarFile) -> None:
|
||||
link = tarfile.TarInfo("escape")
|
||||
link.type = tarfile.LNKTYPE
|
||||
link.linkname = "../outside.txt"
|
||||
tf.addfile(link)
|
||||
|
||||
with _tar_from_members(build) as tf:
|
||||
with pytest.raises(ValueError, match="escaping destination"):
|
||||
_safe_extractall(tf, dest)
|
||||
|
||||
|
||||
def test_safe_extractall_blocks_special_cache_tar_member(tmp_path: Path) -> None:
|
||||
"""Special tar members such as FIFOs are rejected."""
|
||||
dest = tmp_path / "dest"
|
||||
dest.mkdir()
|
||||
|
||||
def build(tf: tarfile.TarFile) -> None:
|
||||
fifo = tarfile.TarInfo("pipe")
|
||||
fifo.type = tarfile.FIFOTYPE
|
||||
tf.addfile(fifo)
|
||||
|
||||
with _tar_from_members(build) as tf:
|
||||
with pytest.raises(ValueError, match="unsupported tar member"):
|
||||
_safe_extractall(tf, dest)
|
||||
|
||||
|
||||
def test_safe_extractall_allows_benign_cache_symlink(tmp_path: Path) -> None:
|
||||
"""A symlink that stays within dest is permitted."""
|
||||
dest = tmp_path / "dest"
|
||||
dest.mkdir()
|
||||
|
||||
def build(tf: tarfile.TarFile) -> None:
|
||||
payload = b"hi"
|
||||
info = tarfile.TarInfo("real.txt")
|
||||
info.size = len(payload)
|
||||
tf.addfile(info, io.BytesIO(payload))
|
||||
link = tarfile.TarInfo("alias.txt")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = "real.txt"
|
||||
tf.addfile(link)
|
||||
|
||||
with _tar_from_members(build) as tf:
|
||||
_safe_extractall(tf, dest)
|
||||
|
||||
assert (dest / "real.txt").read_bytes() == b"hi"
|
||||
assert (dest / "alias.txt").is_symlink()
|
||||
assert (dest / "alias.txt").readlink() == Path("real.txt")
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.14.8a3"
|
||||
__version__ = "1.14.8a4"
|
||||
|
||||
Reference in New Issue
Block a user