diff --git a/codeframe/adapters/e2b/__init__.py b/codeframe/adapters/e2b/__init__.py new file mode 100644 index 00000000..454e972e --- /dev/null +++ b/codeframe/adapters/e2b/__init__.py @@ -0,0 +1,13 @@ +"""E2B cloud execution adapter package.""" + +from __future__ import annotations + + +def __getattr__(name: str): + if name == "E2BAgentAdapter": + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + return E2BAgentAdapter + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = ["E2BAgentAdapter"] diff --git a/codeframe/adapters/e2b/adapter.py b/codeframe/adapters/e2b/adapter.py new file mode 100644 index 00000000..2c55da5b --- /dev/null +++ b/codeframe/adapters/e2b/adapter.py @@ -0,0 +1,342 @@ +"""E2B cloud execution adapter. + +Runs CodeFrame's ReAct agent loop inside an E2B Linux sandbox, providing +fully isolated execution without touching the local filesystem. +""" + +from __future__ import annotations + +import logging +import os +import time +from pathlib import Path +from typing import Callable + +from codeframe.adapters.e2b.credential_scanner import scan_path +from codeframe.core.adapters.agent_adapter import ( + AgentEvent, + AgentResult, +) + +logger = logging.getLogger(__name__) + +# E2B pricing: ~$0.002 per sandbox-minute (estimate, adjust as needed) +_COST_PER_MINUTE = 0.002 + +# Hard cap on sandbox lifetime +_MAX_TIMEOUT_MINUTES = 60 +_MIN_TIMEOUT_MINUTES = 1 + +# Remote workspace path inside the sandbox +_SANDBOX_WORKSPACE = "/workspace" + +# Codeframe install command (uses the published package) +_INSTALL_CMD = "pip install codeframe --quiet" + + +class E2BAgentAdapter: + """Runs a CodeFrame task inside an E2B Linux sandbox. + + Lifecycle: + 1. Credential-scan the local workspace — abort if secrets detected. + 2. Create E2B sandbox with configured timeout. + 3. Upload clean workspace files. + 4. Initialize git inside sandbox (needed for diff-based change detection). + 5. Install codeframe inside sandbox. + 6. Run the agent via ``cf work start`` CLI. + 7. Download changed files (via ``git diff``) to local workspace. + 8. Return AgentResult with cloud metadata. + """ + + name = "cloud" + + def __init__(self, timeout_minutes: int = 30) -> None: + self._timeout_minutes = max( + _MIN_TIMEOUT_MINUTES, + min(timeout_minutes, _MAX_TIMEOUT_MINUTES), + ) + + @classmethod + def requirements(cls) -> dict[str, str]: + """Return required environment variables.""" + return {"E2B_API_KEY": "E2B API key for cloud sandbox execution"} + + def run( + self, + task_id: str, + prompt: str, + workspace_path: Path, + on_event: Callable[[AgentEvent], None] | None = None, + ) -> AgentResult: + """Execute a task inside an E2B sandbox. + + Args: + task_id: CodeFrame task identifier. + prompt: Rich context prompt (written to sandbox as a file). + workspace_path: Local workspace root to upload. + on_event: Optional progress callback. + + Returns: + AgentResult with status, modified_files, and cloud_metadata. + """ + start_time = time.monotonic() + + def _emit(event_type: str, message: str, data: dict | None = None) -> None: + if on_event is not None: + on_event(AgentEvent(type=event_type, message=message, data=data or {})) + logger.info("[E2B] %s: %s", event_type, message) + + # Step 1: Credential scan + _emit("progress", "Scanning workspace for credentials before upload...") + scan_result = scan_path(workspace_path) + + if not scan_result.is_clean: + blocked = ", ".join(scan_result.blocked_files[:5]) + error_msg = ( + f"Credential scan failed: {len(scan_result.blocked_files)} " + f"sensitive file(s) detected and blocked from upload. " + f"Files: {blocked}" + ) + _emit("error", error_msg) + elapsed = (time.monotonic() - start_time) / 60 + return AgentResult( + status="failed", + error=error_msg, + cloud_metadata={ + "sandbox_minutes": elapsed, + "cost_usd_estimate": 0.0, + "files_uploaded": 0, + "files_downloaded": 0, + "credential_scan_blocked": len(scan_result.blocked_files), + }, + ) + + # Step 2: Create sandbox + try: + from e2b import Sandbox + except ImportError: + return AgentResult( + status="failed", + error=( + "The 'e2b' package is required for --engine cloud. " + "Install it with: pip install 'codeframe[cloud]'" + ), + cloud_metadata={ + "sandbox_minutes": 0.0, + "cost_usd_estimate": 0.0, + "files_uploaded": 0, + "files_downloaded": 0, + "credential_scan_blocked": 0, + }, + ) + + api_key = os.environ.get("E2B_API_KEY") + timeout_seconds = self._timeout_minutes * 60 + + _emit("progress", f"Creating E2B sandbox (timeout={self._timeout_minutes}min)...") + try: + sbx = Sandbox.create( + timeout=timeout_seconds, + api_key=api_key, + ) + except Exception as exc: + elapsed = (time.monotonic() - start_time) / 60 + return AgentResult( + status="failed", + error=f"Failed to create E2B sandbox: {exc}", + cloud_metadata={ + "sandbox_minutes": elapsed, + "cost_usd_estimate": round(elapsed * _COST_PER_MINUTE, 6), + "files_uploaded": 0, + "files_downloaded": 0, + "credential_scan_blocked": 0, + }, + ) + + _emit("progress", f"Sandbox created: {sbx.sandbox_id}") + + try: + # Step 3: Upload workspace files + files_uploaded = self._upload_workspace(sbx, workspace_path, _emit) + _emit("progress", f"Uploaded {files_uploaded} files to sandbox") + + # Step 4: Initialize git baseline (for diff detection) + sbx.commands.run( + f"cd {_SANDBOX_WORKSPACE} && git init -q && git add -A && " + f"git -c user.email=agent@e2b.local -c user.name=agent commit -q -m init", + timeout=30, + ) + + # Step 5: Install codeframe + _emit("progress", "Installing codeframe in sandbox...") + install_result = sbx.commands.run( + f"cd {_SANDBOX_WORKSPACE} && {_INSTALL_CMD}", + timeout=300, + ) + if install_result.exit_code != 0: + logger.warning("pip install warnings: %s", install_result.stderr[:500]) + + # Step 6: Run agent + # Pass secrets via the SDK's envs dict — never interpolate into shell strings + _emit("progress", f"Starting agent for task {task_id}...") + agent_envs: dict[str, str] = {} + anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "") + if anthropic_key: + agent_envs["ANTHROPIC_API_KEY"] = anthropic_key + + agent_cmd = f"cd {_SANDBOX_WORKSPACE} && cf work start {task_id} --execute" + + output_lines: list[str] = [] + + def _on_stdout(line: str) -> None: + output_lines.append(line) + _emit("output", line, {"stream": "stdout"}) + + def _on_stderr(line: str) -> None: + output_lines.append(line) + _emit("output", line, {"stream": "stderr"}) + + agent_result = sbx.commands.run( + agent_cmd, + envs=agent_envs, + timeout=timeout_seconds, + on_stdout=_on_stdout, + on_stderr=_on_stderr, + ) + + output_text = "\n".join(output_lines) + agent_succeeded = agent_result.exit_code == 0 + + # Step 7: Download changed files + files_downloaded = 0 + modified_files: list[str] = [] + + if agent_succeeded: + _emit("progress", "Downloading changed files from sandbox...") + modified_files, files_downloaded = self._download_changed_files( + sbx, workspace_path, _emit + ) + + elapsed = (time.monotonic() - start_time) / 60 + cloud_meta = { + "sandbox_minutes": round(elapsed, 3), + "cost_usd_estimate": round(elapsed * _COST_PER_MINUTE, 6), + "files_uploaded": files_uploaded, + "files_downloaded": files_downloaded, + "credential_scan_blocked": 0, + } + + if agent_succeeded: + _emit("progress", "Execution complete") + return AgentResult( + status="completed", + output=output_text, + modified_files=modified_files, + cloud_metadata=cloud_meta, + ) + else: + error = agent_result.stderr or output_text or "Agent exited with non-zero status" + _emit("error", f"Agent failed: {error[:200]}") + return AgentResult( + status="failed", + output=output_text, + error=error[:500], + cloud_metadata=cloud_meta, + ) + + finally: + try: + sbx.kill() + except Exception: + pass + + def _upload_workspace( + self, + sbx: object, + workspace_path: Path, + emit: Callable[[str, str, dict | None], None], + ) -> int: + """Upload workspace files to sandbox, returning the count uploaded.""" + _EXCLUDED = frozenset({ + "__pycache__", ".git", ".mypy_cache", ".pytest_cache", + ".ruff_cache", "node_modules", ".venv", "venv", + }) + + uploaded = 0 + for path in sorted(workspace_path.rglob("*")): + if any(part in _EXCLUDED for part in path.parts): + continue + if not path.is_file(): + continue + + rel = path.relative_to(workspace_path) + remote_path = f"{_SANDBOX_WORKSPACE}/{rel}" + + try: + content = path.read_bytes() + sbx.files.write(remote_path, content) + uploaded += 1 + except Exception as exc: + logger.warning("Failed to upload %s: %s", rel, exc) + + return uploaded + + def _download_changed_files( + self, + sbx: object, + workspace_path: Path, + emit: Callable[[str, str, dict | None], None], + ) -> tuple[list[str], int]: + """Download files changed or created by the agent. + + Uses ``git status --porcelain`` to capture both modified tracked files + and newly created untracked files (git diff only sees tracked changes). + + Returns: + Tuple of (list of relative file paths, count downloaded). + """ + status_result = sbx.commands.run( + f"cd {_SANDBOX_WORKSPACE} && git status --porcelain", + timeout=30, + ) + + if status_result.exit_code != 0 or not status_result.stdout.strip(): + return [], 0 + + changed: list[str] = [] + for line in status_result.stdout.splitlines(): + line = line.strip() + if not line: + continue + # porcelain format: XY filename (or "XY old -> new" for renames) + parts = line.split(None, 1) + if len(parts) < 2: + continue + xy, filepath = parts + # Handle renames: "R old -> new" — take the new name after " -> " + if " -> " in filepath: + filepath = filepath.split(" -> ", 1)[1] + changed.append(filepath.strip()) + + downloaded = 0 + modified_files: list[str] = [] + + for rel_path in changed: + remote = f"{_SANDBOX_WORKSPACE}/{rel_path}" + local = workspace_path / rel_path + + try: + content = sbx.files.read(remote) + local.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, str): + local.write_text(content, encoding="utf-8") + else: + local.write_bytes(bytes(content)) + modified_files.append(rel_path) + downloaded += 1 + logger.debug("Downloaded: %s", rel_path) + except Exception as exc: + logger.warning("Failed to download %s: %s", rel_path, exc) + + emit("progress", f"Downloaded {downloaded} changed file(s)") + return modified_files, downloaded diff --git a/codeframe/adapters/e2b/budget.py b/codeframe/adapters/e2b/budget.py new file mode 100644 index 00000000..f3f04d62 --- /dev/null +++ b/codeframe/adapters/e2b/budget.py @@ -0,0 +1,71 @@ +"""Cloud run metadata persistence for E2B budget tracking. + +Records sandbox execution metrics (minutes, cost, file counts) in the +cloud_run_metadata SQLite table and provides lookup for the work show command. +""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timezone +from typing import Any + + +def record_cloud_run( + workspace: Any, + run_id: str, + sandbox_minutes: float, + cost_usd: float, + files_uploaded: int, + files_downloaded: int, + scan_blocked: int, +) -> None: + """Insert a cloud run record into cloud_run_metadata. + + Args: + workspace: Workspace instance with db_path attribute. + run_id: CodeFrame run identifier. + sandbox_minutes: Wall-clock minutes the sandbox was alive. + cost_usd: Estimated cost in USD. + files_uploaded: Number of files uploaded to the sandbox. + files_downloaded: Number of changed files downloaded from sandbox. + scan_blocked: Number of files blocked by credential scanner. + """ + created_at = datetime.now(timezone.utc).isoformat() + conn = sqlite3.connect(workspace.db_path) + try: + conn.execute( + """ + INSERT OR REPLACE INTO cloud_run_metadata + (run_id, sandbox_minutes, cost_usd_estimate, + files_uploaded, files_downloaded, + credential_scan_blocked, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (run_id, sandbox_minutes, cost_usd, + files_uploaded, files_downloaded, scan_blocked, created_at), + ) + conn.commit() + finally: + conn.close() + + +def get_cloud_run(workspace: Any, run_id: str) -> dict | None: + """Retrieve a cloud run record by run_id. + + Args: + workspace: Workspace instance with db_path attribute. + run_id: CodeFrame run identifier. + + Returns: + Dict with cloud run fields, or None if not found. + """ + conn = sqlite3.connect(workspace.db_path) + conn.row_factory = sqlite3.Row + try: + row = conn.execute( + "SELECT * FROM cloud_run_metadata WHERE run_id = ?", (run_id,) + ).fetchone() + return dict(row) if row else None + finally: + conn.close() diff --git a/codeframe/adapters/e2b/credential_scanner.py b/codeframe/adapters/e2b/credential_scanner.py new file mode 100644 index 00000000..088a5477 --- /dev/null +++ b/codeframe/adapters/e2b/credential_scanner.py @@ -0,0 +1,134 @@ +"""Credential scanner for workspace upload safety. + +Scans a directory tree for sensitive files and secret patterns before +uploading to an E2B sandbox, preventing accidental credential leakage. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Directories that are always excluded from scanning and upload counts +_EXCLUDED_DIRS = frozenset({ + "__pycache__", + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "node_modules", + ".venv", + "venv", + ".tox", + "dist", + "build", + ".eggs", +}) + +# High-risk filename/extension patterns (case-insensitive glob-style matching) +_BLOCKED_FILENAME_PATTERNS = ( + re.compile(r"^\.env$"), + re.compile(r"^\.env\."), + re.compile(r"\.pem$"), + re.compile(r"\.key$"), + re.compile(r"^id_rsa$"), + re.compile(r"^id_dsa$"), + re.compile(r"^id_ecdsa$"), + re.compile(r"^id_ed25519$"), + re.compile(r"^credentials$"), + re.compile(r"^secrets\..+"), + re.compile(r"\.pfx$"), + re.compile(r"\.p12$"), +) + +# Content patterns that indicate embedded secrets (applied to text files) +_SECRET_CONTENT_PATTERNS = ( + re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key + re.compile(r"sk-[a-zA-Z0-9]{48}"), # OpenAI API key + re.compile(r"ghp_[a-zA-Z0-9]{36}"), # GitHub PAT + re.compile(r"ghs_[a-zA-Z0-9]{36}"), # GitHub app token + re.compile(r"(?i)(api_key|secret|password)\s*=\s*['\"][^'\"]{8,}['\"]"), +) + +# Max bytes to sample for content scanning (avoid reading huge binaries) +_MAX_CONTENT_SAMPLE = 8192 + + +@dataclass +class ScanResult: + """Result of a credential scan.""" + + blocked_files: list[str] = field(default_factory=list) + scanned_count: int = 0 + is_clean: bool = True + + +def scan_path(root: Path) -> ScanResult: + """Scan *root* for credentials before uploading to a sandbox. + + Walks the directory tree, checks filenames against the blocklist, and + samples text file content for known secret patterns. + + Args: + root: Root directory to scan. + + Returns: + ScanResult with blocked file list, scanned count, and is_clean flag. + """ + result = ScanResult() + + for path in sorted(root.rglob("*")): + # Skip excluded directories + if any(part in _EXCLUDED_DIRS for part in path.parts): + continue + + if not path.is_file(): + continue + + filename = path.name + rel = str(path.relative_to(root)) + + # Check filename against blocklist + if _is_blocked_filename(filename): + result.blocked_files.append(rel) + result.is_clean = False + logger.info("Blocked (filename): %s", rel) + result.scanned_count += 1 + continue + + # Check content for secret patterns (text files only) + if _contains_secret(path): + result.blocked_files.append(rel) + result.is_clean = False + logger.info("Blocked (content pattern): %s", rel) + + result.scanned_count += 1 + logger.debug("Scanned: %s", rel) + + return result + + +def _is_blocked_filename(filename: str) -> bool: + """Return True if *filename* matches any high-risk pattern.""" + for pattern in _BLOCKED_FILENAME_PATTERNS: + if pattern.search(filename): + return True + return False + + +def _contains_secret(path: Path) -> bool: + """Return True if the file content matches any known secret pattern.""" + try: + content = path.read_bytes()[:_MAX_CONTENT_SAMPLE] + text = content.decode("utf-8", errors="replace") + except OSError: + return False + + for pattern in _SECRET_CONTENT_PATTERNS: + if pattern.search(text): + return True + return False diff --git a/codeframe/cli/app.py b/codeframe/cli/app.py index 02490f80..251e212e 100644 --- a/codeframe/cli/app.py +++ b/codeframe/cli/app.py @@ -2306,7 +2306,7 @@ def work_start( engine: Optional[str] = typer.Option( None, "--engine", - help="Agent engine: react (default), plan (legacy), claude-code, codex, opencode, kilocode, or built-in", + help="Agent engine: react (default), plan (legacy), claude-code, codex, opencode, kilocode, cloud, or built-in", ), stall_timeout: int = typer.Option( 300, @@ -2325,6 +2325,11 @@ def work_start( help="Task execution isolation: none (default), worktree, or cloud", click_type=click.Choice(["none", "worktree", "cloud"], case_sensitive=False), ), + cloud_timeout: int = typer.Option( + 30, + "--cloud-timeout", + help="Sandbox timeout in minutes for --engine cloud (1-60, default: 30)", + ), ) -> None: """Start working on a task. @@ -2338,6 +2343,7 @@ def work_start( codeframe work start abc123 --execute --dry-run codeframe work start abc123 --execute --verbose codeframe work start abc123 --execute --isolation worktree + codeframe work start abc123 --execute --engine cloud --cloud-timeout 45 """ from codeframe.core.workspace import get_workspace from codeframe.core import tasks as tasks_module, runtime @@ -2379,6 +2385,9 @@ def work_start( if engine == "codex": from codeframe.cli.validators import require_openai_api_key require_openai_api_key() + elif engine == "cloud": + from codeframe.cli.validators import require_e2b_api_key + require_e2b_api_key() elif not is_external_engine(engine): from codeframe.cli.validators import require_anthropic_api_key require_anthropic_api_key() @@ -2406,6 +2415,7 @@ def work_start( workspace, run, dry_run=dry_run, debug=debug, verbose=verbose, engine=engine, stall_timeout_s=stall_timeout, stall_action=stall_action, isolation=isolation, + cloud_timeout_minutes=cloud_timeout, ) if state.status == AgentStatus.COMPLETED: @@ -2626,6 +2636,80 @@ def work_status( raise typer.Exit(1) +@work_app.command("show") +def work_show( + task_id: str = typer.Argument(..., help="Task ID to show run details for"), + workspace_path: Optional[Path] = typer.Option( + None, + "--workspace", + "-w", + help="Workspace path (defaults to current directory)", + ), +) -> None: + """Show detailed run information for a task, including cloud execution cost. + + Example: + codeframe work show abc123 + codeframe work show abc123 --workspace /path/to/project + """ + from codeframe.core.workspace import get_workspace + from codeframe.core import runtime, engine_stats + from rich.table import Table + + path = workspace_path or Path.cwd() + + try: + workspace = get_workspace(path) + + run = runtime.get_latest_run(workspace, task_id) + if not run: + console.print(f"[dim]No run found for task {task_id}[/dim]") + raise typer.Exit(0) + + console.print(f"\n[bold]Run Details[/bold] — {task_id[:8]}") + console.print(f" Run ID: [dim]{run.id}[/dim]") + console.print(f" Status: {run.status.value}") + console.print(f" Started: {run.started_at.strftime('%Y-%m-%d %H:%M:%S') if run.started_at else '—'}") + if run.completed_at: + duration_s = (run.completed_at - run.started_at).total_seconds() + console.print(f" Duration: {duration_s:.1f}s") + + # Engine stats from run_engine_log + logs = engine_stats.get_run_log(workspace, limit=20) + run_log = next((entry for entry in logs if entry.get("run_id") == run.id), None) + if run_log: + console.print(f" Engine: {run_log.get('engine', '—')}") + tokens = run_log.get("tokens_used", 0) + if tokens: + console.print(f" Tokens: {tokens:,}") + + # Cloud execution cost (E2B) + engine_name = run_log.get("engine") if run_log else None + if engine_name == "cloud": + from codeframe.adapters.e2b.budget import get_cloud_run + + cloud = get_cloud_run(workspace, run.id) + if cloud: + console.print() + tbl = Table(title="Cloud Execution Cost", show_header=True) + tbl.add_column("Metric", style="cyan") + tbl.add_column("Value", style="white") + tbl.add_row("Sandbox minutes", f"{cloud['sandbox_minutes']:.2f}") + tbl.add_row("Estimated cost", f"${cloud['cost_usd_estimate']:.4f}") + tbl.add_row("Files uploaded", str(cloud["files_uploaded"])) + tbl.add_row("Files downloaded", str(cloud["files_downloaded"])) + if cloud["credential_scan_blocked"]: + tbl.add_row( + "[red]Scan blocked[/red]", + str(cloud["credential_scan_blocked"]), + ) + console.print(tbl) + + except FileNotFoundError: + console.print(f"[red]Error:[/red] No workspace found at {path}") + raise typer.Exit(1) + + @work_app.command("diagnose") def work_diagnose( task_id: str = typer.Argument(..., help="Task ID to diagnose (can be partial)"), @@ -3544,7 +3628,7 @@ def batch_run( engine: Optional[str] = typer.Option( None, "--engine", - help="Agent engine: react (default), plan (legacy), claude-code, codex, opencode, kilocode, or built-in", + help="Agent engine: react (default), plan (legacy), claude-code, codex, opencode, kilocode, cloud, or built-in", ), stall_timeout: int = typer.Option( 300, @@ -3563,6 +3647,11 @@ def batch_run( help="Task execution isolation: none (default), worktree, or cloud", click_type=click.Choice(["none", "worktree", "cloud"], case_sensitive=False), ), + cloud_timeout: int = typer.Option( + 30, + "--cloud-timeout", + help="Sandbox timeout in minutes for --engine cloud (1-60, default: 30)", + ), ) -> None: """Execute multiple tasks in batch. @@ -3656,6 +3745,9 @@ def batch_run( if engine == "codex": from codeframe.cli.validators import require_openai_api_key require_openai_api_key() + elif engine == "cloud": + from codeframe.cli.validators import require_e2b_api_key + require_e2b_api_key() elif not is_external_engine(engine): from codeframe.cli.validators import require_anthropic_api_key require_anthropic_api_key() @@ -3678,6 +3770,7 @@ def batch_run( stall_timeout_s=stall_timeout, stall_action=stall_action, isolation=isolation, + cloud_timeout_minutes=cloud_timeout, ) # Show summary diff --git a/codeframe/cli/validators.py b/codeframe/cli/validators.py index c19683de..880f5a18 100644 --- a/codeframe/cli/validators.py +++ b/codeframe/cli/validators.py @@ -83,3 +83,41 @@ def require_openai_api_key() -> str: "Set it in your environment or add it to a .env file." ) raise typer.Exit(1) + + +def require_e2b_api_key() -> str: + """Ensure E2B_API_KEY is available, loading from .env if needed. + + Checks os.environ first. If not found, attempts to load from .env files + (~/.env as base, then cwd/.env with override). If found after loading, + sets in os.environ so subprocesses inherit it. + + Returns: + The API key string. + + Raises: + typer.Exit: If the key cannot be found anywhere. + """ + key = os.getenv("E2B_API_KEY") + if key: + return key + + cwd_env = Path.cwd() / ".env" + home_env = Path.home() / ".env" + + if home_env.exists(): + load_dotenv(home_env) + if cwd_env.exists(): + load_dotenv(cwd_env, override=True) + + key = os.getenv("E2B_API_KEY") + if key: + os.environ["E2B_API_KEY"] = key + return key + + console.print( + "[red]Error:[/red] E2B_API_KEY is not set. " + "Set it in your environment or add it to a .env file. " + "Get your key at https://e2b.dev" + ) + raise typer.Exit(1) diff --git a/codeframe/core/adapters/agent_adapter.py b/codeframe/core/adapters/agent_adapter.py index e92dcbc4..fed8b407 100644 --- a/codeframe/core/adapters/agent_adapter.py +++ b/codeframe/core/adapters/agent_adapter.py @@ -67,6 +67,7 @@ class AgentResult: blocker_question: str | None = None token_usage: AdapterTokenUsage | None = None duration_ms: int = 0 + cloud_metadata: dict | None = None @dataclass diff --git a/codeframe/core/conductor.py b/codeframe/core/conductor.py index fe3d6719..2cd6e08e 100644 --- a/codeframe/core/conductor.py +++ b/codeframe/core/conductor.py @@ -556,6 +556,7 @@ def start_batch( concurrency_by_status: Optional[dict[str, int]] = None, isolate: bool = True, isolation: str = "none", + cloud_timeout_minutes: int = 30, ) -> BatchRun: """Start a batch execution of multiple tasks. @@ -2039,6 +2040,7 @@ def _execute_task_subprocess( stall_timeout_s: int = 300, stall_action: str = "blocker", worktree_path: Optional[Path] = None, + cloud_timeout_minutes: int = 30, ) -> str: """Execute a single task via subprocess. @@ -2048,9 +2050,10 @@ def _execute_task_subprocess( workspace: Target workspace task_id: Task to execute batch_id: Optional batch ID for process tracking (enables force stop) - engine: Agent engine to use ("plan" or "react") + engine: Agent engine to use ("plan", "react", "cloud", etc.) stall_timeout_s: Stall detection timeout in seconds (0 = disabled) stall_action: Recovery action on stall ("blocker", "retry", or "fail") + cloud_timeout_minutes: Sandbox timeout for cloud engine (1-60) Returns: RunStatus value string (COMPLETED, FAILED, BLOCKED) @@ -2063,6 +2066,8 @@ def _execute_task_subprocess( "--stall-timeout", str(stall_timeout_s), "--stall-action", stall_action, ] + if engine == "cloud": + cmd += ["--cloud-timeout", str(cloud_timeout_minutes)] process = None try: diff --git a/codeframe/core/engine_registry.py b/codeframe/core/engine_registry.py index b7cf52fd..6492c6c2 100644 --- a/codeframe/core/engine_registry.py +++ b/codeframe/core/engine_registry.py @@ -16,6 +16,7 @@ "codex", "opencode", "kilocode", + "cloud", "built-in", # Alias for "react" }) @@ -25,6 +26,7 @@ "codex", "opencode", "kilocode", + "cloud", }) # Builtin engines that need workspace + LLM provider @@ -102,6 +104,11 @@ def get_external_adapter(engine: str, **kwargs: Any) -> AgentAdapter: from codeframe.core.adapters.kilocode import KilocodeAdapter return KilocodeAdapter(**kwargs) + elif engine == "cloud": + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + + timeout_minutes = kwargs.get("timeout_minutes", 30) + return E2BAgentAdapter(timeout_minutes=timeout_minutes) else: raise ValueError( f"Unknown external engine '{engine}'. " @@ -210,6 +217,9 @@ def _get_adapter_class(engine: str) -> type | None: elif engine == "kilocode": from codeframe.core.adapters.kilocode import KilocodeAdapter return KilocodeAdapter + elif engine == "cloud": + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + return E2BAgentAdapter return None diff --git a/codeframe/core/runtime.py b/codeframe/core/runtime.py index 0250b238..e2a434c9 100644 --- a/codeframe/core/runtime.py +++ b/codeframe/core/runtime.py @@ -600,6 +600,7 @@ def execute_agent( stall_timeout_s: int = 300, stall_action: str = "blocker", isolation: str = "none", + cloud_timeout_minutes: int = 30, ) -> "AgentState": """Execute a task using the agent orchestrator. @@ -614,9 +615,10 @@ def execute_agent( verbose: If True, print detailed progress to stdout fix_coordinator: Optional coordinator for global fixes (for parallel execution) event_publisher: Optional EventPublisher for SSE streaming (real-time events) - engine: Agent engine to use ("react", "plan", "claude-code", "opencode", "built-in") + engine: Agent engine to use ("react", "plan", "claude-code", "opencode", "cloud", "built-in") stall_timeout_s: Seconds without tool activity before stall detection (0 = disabled) stall_action: Recovery action on stall ("blocker", "retry", or "fail") + cloud_timeout_minutes: Sandbox timeout for cloud engine (1-60 minutes, default: 30) Returns: Final AgentState after execution @@ -735,7 +737,10 @@ def on_adapter_event(event: AdapterEvent) -> None: packager = TaskContextPackager(workspace) packaged = packager.build(run.task_id) - adapter = get_external_adapter(engine) + adapter_kwargs = {} + if engine == "cloud": + adapter_kwargs["timeout_minutes"] = cloud_timeout_minutes + adapter = get_external_adapter(engine, **adapter_kwargs) wrapper = VerificationWrapper( adapter, workspace, max_correction_rounds=5, verbose=verbose, ) @@ -834,6 +839,23 @@ def on_adapter_event(event: AdapterEvent) -> None: except Exception: logger.warning("Engine stats recording failed", exc_info=True) + # Persist cloud execution metadata if the adapter returned it + if engine == "cloud" and hasattr(result, "cloud_metadata") and result.cloud_metadata: + try: + from codeframe.adapters.e2b.budget import record_cloud_run + meta = result.cloud_metadata + record_cloud_run( + workspace=workspace, + run_id=run.id, + sandbox_minutes=meta.get("sandbox_minutes", 0.0), + cost_usd=meta.get("cost_usd_estimate", 0.0), + files_uploaded=meta.get("files_uploaded", 0), + files_downloaded=meta.get("files_downloaded", 0), + scan_blocked=meta.get("credential_scan_blocked", 0), + ) + except Exception: + logger.warning("Cloud run metadata recording failed", exc_info=True) + # Execute after_task hooks (non-blocking, after state is persisted) if env_config and hook_ctx: after_hook = None diff --git a/codeframe/core/workspace.py b/codeframe/core/workspace.py index 012b9877..7319a278 100644 --- a/codeframe/core/workspace.py +++ b/codeframe/core/workspace.py @@ -346,6 +346,18 @@ def _init_database(db_path: Path) -> None: ) """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS cloud_run_metadata ( + run_id TEXT PRIMARY KEY, + sandbox_minutes REAL NOT NULL, + cost_usd_estimate REAL NOT NULL, + files_uploaded INTEGER NOT NULL, + files_downloaded INTEGER NOT NULL, + credential_scan_blocked INTEGER NOT NULL, + created_at TEXT NOT NULL + ) + """) + # Create indexes for common queries cursor.execute("CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)") @@ -666,6 +678,18 @@ def _ensure_schema_upgrades(db_path: Path) -> None: cursor.execute("CREATE INDEX IF NOT EXISTS idx_llm_interactions_step ON llm_interactions(step_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_file_operations_run ON file_operations(run_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_file_operations_step ON file_operations(step_id)") + # Add cloud_run_metadata table for E2B cloud execution tracking + cursor.execute(""" + CREATE TABLE IF NOT EXISTS cloud_run_metadata ( + run_id TEXT PRIMARY KEY, + sandbox_minutes REAL NOT NULL, + cost_usd_estimate REAL NOT NULL, + files_uploaded INTEGER NOT NULL, + files_downloaded INTEGER NOT NULL, + credential_scan_blocked INTEGER NOT NULL, + created_at TEXT NOT NULL + ) + """) conn.commit() conn.close() diff --git a/pyproject.toml b/pyproject.toml index 6053512f..f2788bfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,9 @@ dependencies = [ ] [project.optional-dependencies] +cloud = [ + "e2b>=2.0.0", +] dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.23.0", @@ -72,6 +75,7 @@ dev = [ "mypy>=1.8.0", "pre-commit>=3.5.0", "hypothesis>=6.0.0", + "e2b>=2.0.0", ] [project.scripts] diff --git a/tests/adapters/test_e2b_adapter.py b/tests/adapters/test_e2b_adapter.py new file mode 100644 index 00000000..350d0784 --- /dev/null +++ b/tests/adapters/test_e2b_adapter.py @@ -0,0 +1,569 @@ +"""Tests for E2B cloud execution adapter. + +Uses mocked e2b.Sandbox to avoid real sandbox creation. +All tests are marked v2 (headless, CLI-first). +""" + +from __future__ import annotations + +import os +import sqlite3 +from pathlib import Path +from unittest.mock import MagicMock, patch +import pytest + +pytestmark = pytest.mark.v2 + + +# --------------------------------------------------------------------------- +# credential_scanner tests +# --------------------------------------------------------------------------- + +class TestCredentialScanner: + """Tests for the credential scanning module.""" + + def test_clean_directory_passes(self, tmp_path): + from codeframe.adapters.e2b.credential_scanner import scan_path + + (tmp_path / "main.py").write_text("print('hello')") + (tmp_path / "README.md").write_text("# My project") + + result = scan_path(tmp_path) + + assert result.is_clean + assert result.blocked_files == [] + assert result.scanned_count >= 2 + + def test_dot_env_file_blocked(self, tmp_path): + from codeframe.adapters.e2b.credential_scanner import scan_path + + (tmp_path / ".env").write_text("SECRET=abc123") + + result = scan_path(tmp_path) + + assert not result.is_clean + assert any(".env" in f for f in result.blocked_files) + + def test_dot_env_variants_blocked(self, tmp_path): + from codeframe.adapters.e2b.credential_scanner import scan_path + + (tmp_path / ".env.local").write_text("DB_PASSWORD=secret") + (tmp_path / ".env.production").write_text("API_KEY=xyz") + + result = scan_path(tmp_path) + + assert not result.is_clean + assert len(result.blocked_files) == 2 + + def test_pem_key_file_blocked(self, tmp_path): + from codeframe.adapters.e2b.credential_scanner import scan_path + + (tmp_path / "server.pem").write_text("-----BEGIN CERTIFICATE-----") + (tmp_path / "id_rsa").write_text("-----BEGIN RSA PRIVATE KEY-----") + + result = scan_path(tmp_path) + + assert not result.is_clean + assert len(result.blocked_files) == 2 + + def test_aws_key_pattern_in_content_blocked(self, tmp_path): + from codeframe.adapters.e2b.credential_scanner import scan_path + + (tmp_path / "config.py").write_text( + "AWS_ACCESS_KEY = 'AKIAIOSFODNN7EXAMPLE'" + ) + + result = scan_path(tmp_path) + + assert not result.is_clean + + def test_openai_key_pattern_blocked(self, tmp_path): + from codeframe.adapters.e2b.credential_scanner import scan_path + + (tmp_path / "settings.py").write_text( + "API_KEY = 'sk-" + "a" * 48 + "'" + ) + + result = scan_path(tmp_path) + + assert not result.is_clean + + def test_github_pat_blocked(self, tmp_path): + from codeframe.adapters.e2b.credential_scanner import scan_path + + (tmp_path / "ci.py").write_text( + "token = 'ghp_" + "a" * 36 + "'" + ) + + result = scan_path(tmp_path) + + assert not result.is_clean + + def test_scan_result_has_scanned_count(self, tmp_path): + from codeframe.adapters.e2b.credential_scanner import scan_path + + for i in range(5): + (tmp_path / f"file_{i}.py").write_text(f"x = {i}") + + result = scan_path(tmp_path) + + assert result.scanned_count == 5 + assert result.is_clean + + def test_nested_directory_scanned(self, tmp_path): + from codeframe.adapters.e2b.credential_scanner import scan_path + + subdir = tmp_path / "src" + subdir.mkdir() + (subdir / ".env").write_text("SECRET=leaked") + + result = scan_path(tmp_path) + + assert not result.is_clean + + def test_pycache_and_git_excluded(self, tmp_path): + """__pycache__ and .git dirs are not counted as user files.""" + from codeframe.adapters.e2b.credential_scanner import scan_path + + pycache = tmp_path / "__pycache__" + pycache.mkdir() + (pycache / "module.cpython-312.pyc").write_bytes(b"\x00\x01\x02") + + git_dir = tmp_path / ".git" + git_dir.mkdir() + (git_dir / "config").write_text("[core]") + + (tmp_path / "main.py").write_text("print('ok')") + + result = scan_path(tmp_path) + + assert result.is_clean + assert result.scanned_count == 1 # only main.py + + +# --------------------------------------------------------------------------- +# budget tests +# --------------------------------------------------------------------------- + +class TestBudget: + """Tests for cloud run metadata persistence.""" + + def _make_workspace(self, tmp_path): + """Create a minimal workspace with cloud_run_metadata table.""" + db_path = tmp_path / "codeframe.db" + conn = sqlite3.connect(str(db_path)) + conn.execute(""" + CREATE TABLE cloud_run_metadata ( + run_id TEXT PRIMARY KEY, + sandbox_minutes REAL NOT NULL, + cost_usd_estimate REAL NOT NULL, + files_uploaded INTEGER NOT NULL, + files_downloaded INTEGER NOT NULL, + credential_scan_blocked INTEGER NOT NULL, + created_at TEXT NOT NULL + ) + """) + conn.commit() + conn.close() + + ws = MagicMock() + ws.db_path = str(db_path) + return ws + + def test_record_and_retrieve(self, tmp_path): + from codeframe.adapters.e2b.budget import record_cloud_run, get_cloud_run + + ws = self._make_workspace(tmp_path) + + record_cloud_run( + workspace=ws, + run_id="run-123", + sandbox_minutes=12.5, + cost_usd=0.025, + files_uploaded=42, + files_downloaded=3, + scan_blocked=0, + ) + + record = get_cloud_run(ws, "run-123") + + assert record is not None + assert record["run_id"] == "run-123" + assert record["sandbox_minutes"] == pytest.approx(12.5) + assert record["cost_usd_estimate"] == pytest.approx(0.025) + assert record["files_uploaded"] == 42 + assert record["files_downloaded"] == 3 + assert record["credential_scan_blocked"] == 0 + + def test_get_nonexistent_returns_none(self, tmp_path): + from codeframe.adapters.e2b.budget import get_cloud_run + + ws = self._make_workspace(tmp_path) + + assert get_cloud_run(ws, "does-not-exist") is None + + def test_record_with_scan_blocked(self, tmp_path): + from codeframe.adapters.e2b.budget import record_cloud_run, get_cloud_run + + ws = self._make_workspace(tmp_path) + + record_cloud_run( + workspace=ws, + run_id="run-456", + sandbox_minutes=0.0, + cost_usd=0.0, + files_uploaded=0, + files_downloaded=0, + scan_blocked=3, + ) + + record = get_cloud_run(ws, "run-456") + assert record["credential_scan_blocked"] == 3 + + +# --------------------------------------------------------------------------- +# E2BAgentAdapter tests +# --------------------------------------------------------------------------- + +def _make_mock_sandbox(exit_code: int = 0, stdout: str = "", stderr: str = ""): + """Build a mock e2b.Sandbox with sensible defaults.""" + sbx = MagicMock() + sbx.sandbox_id = "sandbox-abc" + + # commands.run returns CommandResult + cmd_result = MagicMock() + cmd_result.exit_code = exit_code + cmd_result.stdout = stdout + cmd_result.stderr = stderr + sbx.commands.run.return_value = cmd_result + + # files.write / files.read + sbx.files.write.return_value = MagicMock() + sbx.files.read.return_value = "updated file content" + + return sbx + + +class TestE2BAgentAdapter: + """Tests for E2BAgentAdapter.""" + + def _make_workspace(self, tmp_path: Path) -> Path: + """Create a minimal workspace directory with some source files.""" + (tmp_path / "main.py").write_text("def hello(): pass\n") + (tmp_path / "README.md").write_text("# Test project") + return tmp_path + + @patch("e2b.Sandbox.create") + def test_successful_execution_returns_completed(self, mock_create, tmp_path): + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + + sbx = _make_mock_sandbox(exit_code=0, stdout="Task complete") + # Adapter runs: git-combined (1 call), pip install, cf work start, git status + diff_result = MagicMock() + diff_result.exit_code = 0 + # git status --porcelain format: "XY filename" + diff_result.stdout = " M main.py\n" + sbx.commands.run.side_effect = [ + MagicMock(exit_code=0, stdout="", stderr=""), # git init+add+commit + MagicMock(exit_code=0, stdout="installed", stderr=""), # pip install + MagicMock(exit_code=0, stdout="Task complete", stderr=""), # cf work start + diff_result, # git diff + ] + mock_create.return_value = sbx + + ws_path = self._make_workspace(tmp_path) + adapter = E2BAgentAdapter(timeout_minutes=5) + + with patch.dict(os.environ, {"E2B_API_KEY": "test-key"}): + result = adapter.run( + task_id="task-1", + prompt="Implement hello function", + workspace_path=ws_path, + ) + + assert result.status == "completed" + assert "main.py" in result.modified_files + + @patch("e2b.Sandbox.create") + def test_credential_scan_failure_returns_failed(self, mock_create, tmp_path): + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + + ws_path = self._make_workspace(tmp_path) + (ws_path / ".env").write_text("SECRET=do-not-upload") + + adapter = E2BAgentAdapter(timeout_minutes=5) + + with patch.dict(os.environ, {"E2B_API_KEY": "test-key"}): + result = adapter.run( + task_id="task-1", + prompt="Do something", + workspace_path=ws_path, + ) + + assert result.status == "failed" + assert "credential" in result.error.lower() + mock_create.assert_not_called() + + @patch("e2b.Sandbox.create") + def test_agent_failure_returns_failed(self, mock_create, tmp_path): + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + + sbx = MagicMock() + sbx.sandbox_id = "sandbox-xyz" + sbx.commands.run.side_effect = [ + MagicMock(exit_code=0, stdout="", stderr=""), # git combined + MagicMock(exit_code=0, stdout="", stderr=""), # pip install + MagicMock(exit_code=1, stdout="", stderr="Error: task failed"), # cf work start + ] + mock_create.return_value = sbx + + ws_path = self._make_workspace(tmp_path) + adapter = E2BAgentAdapter(timeout_minutes=5) + + with patch.dict(os.environ, {"E2B_API_KEY": "test-key"}): + result = adapter.run( + task_id="task-1", + prompt="Do something", + workspace_path=ws_path, + ) + + assert result.status == "failed" + + @patch("e2b.Sandbox.create") + def test_on_event_callback_called(self, mock_create, tmp_path): + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + + sbx = _make_mock_sandbox(exit_code=0) + sbx.commands.run.side_effect = [ + MagicMock(exit_code=0, stdout="", stderr=""), # git combined + MagicMock(exit_code=0, stdout="installed", stderr=""), # pip install + MagicMock(exit_code=0, stdout="done", stderr=""), # cf work start + MagicMock(exit_code=0, stdout="", stderr=""), # git diff (no changes) + ] + mock_create.return_value = sbx + + ws_path = self._make_workspace(tmp_path) + events = [] + adapter = E2BAgentAdapter(timeout_minutes=5) + + with patch.dict(os.environ, {"E2B_API_KEY": "test-key"}): + adapter.run( + task_id="task-1", + prompt="Do something", + workspace_path=ws_path, + on_event=events.append, + ) + + event_types = [e.type for e in events] + assert "progress" in event_types + + @patch("e2b.Sandbox.create") + def test_new_files_downloaded_via_porcelain(self, mock_create, tmp_path): + """Untracked new files (porcelain '?? ...') are also downloaded.""" + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + + sbx = _make_mock_sandbox(exit_code=0) + sbx.files.read.return_value = "# new file content" + # porcelain: modified file + untracked new file + status_result = MagicMock() + status_result.exit_code = 0 + status_result.stdout = " M existing.py\n?? new_module.py\n" + sbx.commands.run.side_effect = [ + MagicMock(exit_code=0, stdout="", stderr=""), # git combined + MagicMock(exit_code=0, stdout="", stderr=""), # pip install + MagicMock(exit_code=0, stdout="done", stderr=""), # cf work start + status_result, # git status + ] + mock_create.return_value = sbx + + ws_path = self._make_workspace(tmp_path) + adapter = E2BAgentAdapter(timeout_minutes=5) + + with patch.dict(os.environ, {"E2B_API_KEY": "test-key"}): + result = adapter.run( + task_id="task-1", + prompt="Create new module", + workspace_path=ws_path, + ) + + assert result.status == "completed" + assert "existing.py" in result.modified_files + assert "new_module.py" in result.modified_files + + @patch("e2b.Sandbox.create") + def test_timeout_clamped_to_max_60(self, mock_create, tmp_path): + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + + adapter = E2BAgentAdapter(timeout_minutes=999) + assert adapter._timeout_minutes == 60 + + def test_name_is_cloud(self): + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + + adapter = E2BAgentAdapter() + assert adapter.name == "cloud" + + def test_requirements_returns_e2b_api_key(self): + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + + reqs = E2BAgentAdapter.requirements() + assert "E2B_API_KEY" in reqs + + @patch("e2b.Sandbox.create") + def test_cloud_metadata_populated_in_result(self, mock_create, tmp_path): + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + + sbx = _make_mock_sandbox(exit_code=0) + sbx.commands.run.side_effect = [ + MagicMock(exit_code=0, stdout="", stderr=""), # git combined + MagicMock(exit_code=0, stdout="", stderr=""), # pip install + MagicMock(exit_code=0, stdout="done", stderr=""), # cf work start + MagicMock(exit_code=0, stdout="", stderr=""), # git diff + ] + mock_create.return_value = sbx + + ws_path = self._make_workspace(tmp_path) + adapter = E2BAgentAdapter(timeout_minutes=5) + + with patch.dict(os.environ, {"E2B_API_KEY": "test-key"}): + result = adapter.run( + task_id="task-1", + prompt="Do something", + workspace_path=ws_path, + ) + + assert result.cloud_metadata is not None + assert "sandbox_minutes" in result.cloud_metadata + assert "cost_usd_estimate" in result.cloud_metadata + assert result.cloud_metadata["sandbox_minutes"] >= 0 + + +# --------------------------------------------------------------------------- +# Engine registry tests +# --------------------------------------------------------------------------- + +class TestEngineRegistry: + def test_cloud_in_valid_engines(self): + from codeframe.core.engine_registry import VALID_ENGINES + assert "cloud" in VALID_ENGINES + + def test_cloud_in_external_engines(self): + from codeframe.core.engine_registry import EXTERNAL_ENGINES + assert "cloud" in EXTERNAL_ENGINES + + def test_cloud_not_in_builtin_engines(self): + from codeframe.core.engine_registry import BUILTIN_ENGINES + assert "cloud" not in BUILTIN_ENGINES + + def test_get_external_adapter_cloud_returns_e2b_adapter(self): + from codeframe.core.engine_registry import get_external_adapter + from codeframe.adapters.e2b.adapter import E2BAgentAdapter + + adapter = get_external_adapter("cloud", timeout_minutes=10) + assert isinstance(adapter, E2BAgentAdapter) + assert adapter._timeout_minutes == 10 + + def test_resolve_cloud_engine(self): + from codeframe.core.engine_registry import resolve_engine + assert resolve_engine("cloud") == "cloud" + + def test_is_external_engine_cloud(self): + from codeframe.core.engine_registry import is_external_engine + assert is_external_engine("cloud") + + def test_check_requirements_cloud(self): + from codeframe.core.engine_registry import check_requirements + import os + + with patch.dict(os.environ, {}, clear=True): + result = check_requirements("cloud") + + assert "E2B_API_KEY" in result + assert result["E2B_API_KEY"] is False + + def test_check_requirements_cloud_with_key(self): + from codeframe.core.engine_registry import check_requirements + import os + + with patch.dict(os.environ, {"E2B_API_KEY": "test-key"}): + result = check_requirements("cloud") + + assert result["E2B_API_KEY"] is True + + +# --------------------------------------------------------------------------- +# AgentResult cloud_metadata field +# --------------------------------------------------------------------------- + +class TestAgentResultCloudMetadata: + def test_cloud_metadata_defaults_to_none(self): + from codeframe.core.adapters.agent_adapter import AgentResult + + result = AgentResult(status="completed") + assert result.cloud_metadata is None + + def test_cloud_metadata_can_be_set(self): + from codeframe.core.adapters.agent_adapter import AgentResult + + result = AgentResult( + status="completed", + cloud_metadata={"sandbox_minutes": 5.0, "cost_usd_estimate": 0.01}, + ) + assert result.cloud_metadata["sandbox_minutes"] == 5.0 + + +# --------------------------------------------------------------------------- +# Validators +# --------------------------------------------------------------------------- + +class TestValidators: + def test_require_e2b_api_key_raises_when_missing(self): + import typer + from codeframe.cli.validators import require_e2b_api_key + + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(typer.Exit): + require_e2b_api_key() + + def test_require_e2b_api_key_returns_key_when_present(self): + from codeframe.cli.validators import require_e2b_api_key + + with patch.dict(os.environ, {"E2B_API_KEY": "test-e2b-key"}): + key = require_e2b_api_key() + + assert key == "test-e2b-key" + + +# --------------------------------------------------------------------------- +# Workspace schema: cloud_run_metadata table migration +# --------------------------------------------------------------------------- + +class TestWorkspaceCloudSchema: + def test_cloud_run_metadata_table_created(self, tmp_path): + from codeframe.core.workspace import create_or_load_workspace as init_workspace + + ws = init_workspace(tmp_path) + conn = sqlite3.connect(ws.db_path) + tables = {row[0] for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()} + conn.close() + + assert "cloud_run_metadata" in tables + + def test_cloud_run_metadata_columns(self, tmp_path): + from codeframe.core.workspace import create_or_load_workspace as init_workspace + + ws = init_workspace(tmp_path) + conn = sqlite3.connect(ws.db_path) + columns = {row[1] for row in conn.execute( + "PRAGMA table_info(cloud_run_metadata)" + ).fetchall()} + conn.close() + + required = { + "run_id", "sandbox_minutes", "cost_usd_estimate", + "files_uploaded", "files_downloaded", + "credential_scan_blocked", "created_at", + } + assert required <= columns diff --git a/uv.lock b/uv.lock index af5bc482..b90fbbb3 100644 --- a/uv.lock +++ b/uv.lock @@ -359,6 +359,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" }, ] +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, +] + [[package]] name = "certifi" version = "2025.10.5" @@ -592,8 +601,12 @@ dependencies = [ ] [package.optional-dependencies] +cloud = [ + { name = "e2b" }, +] dev = [ { name = "black" }, + { name = "e2b" }, { name = "hypothesis" }, { name = "mypy" }, { name = "pre-commit" }, @@ -617,6 +630,8 @@ requires-dist = [ { name = "anthropic", specifier = ">=0.18.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=24.1.0" }, { name = "claude-agent-sdk", specifier = ">=0.1.10" }, + { name = "e2b", marker = "extra == 'cloud'", specifier = ">=2.0.0" }, + { name = "e2b", marker = "extra == 'dev'", specifier = ">=2.0.0" }, { name = "fastapi", specifier = ">=0.109.0" }, { name = "fastapi-users", extras = ["sqlalchemy"], specifier = ">=15.0.2" }, { name = "gitpython", specifier = ">=3.1.40" }, @@ -659,7 +674,7 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" }, { name = "websockets", specifier = ">=12.0" }, ] -provides-extras = ["dev"] +provides-extras = ["cloud", "dev"] [package.metadata.requires-dev] dev = [ @@ -869,6 +884,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -878,6 +902,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] +[[package]] +name = "e2b" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "dockerfile-parse" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/87/e9b3bd252a4fe2b3fd6967ff985c7a5a15a31b2d5b8c37e50afb18797b17/e2b-2.20.0.tar.gz", hash = "sha256:52b3a00ac7015bbdce84913b2a57664d2def33d5a4069e34fa2354de31759173", size = 156575, upload-time = "2026-04-02T19:20:32.375Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/ce/e402e2ecebe40ed9af20cddb862386f2ce20336e35c0dea257812129020e/e2b-2.20.0-py3-none-any.whl", hash = "sha256:66f6edcf6b742ca180f3aadcff7966fda86d68430fa6b2becdfa0fcc72224988", size = 296483, upload-time = "2026-04-02T19:20:30.573Z" }, +] + [[package]] name = "ecdsa" version = "0.19.1" @@ -1986,6 +2031,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + [[package]] name = "pwdlib" version = "0.3.0" @@ -2234,6 +2294,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b", size = 11428, upload-time = "2024-02-12T19:38:42.531Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -3310,6 +3382,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, ] +[[package]] +name = "wcmatch" +version = "10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, +] + [[package]] name = "websockets" version = "15.0.1"