codeframe-ai 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
codeframe/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeFRAME: Fully Remote Autonomous Multiagent Environment for Coding
|
|
3
|
+
|
|
4
|
+
An autonomous AI development system where multiple specialized agents
|
|
5
|
+
collaborate to build software projects from requirements to deployment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
__author__ = "Frank Bria"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__", "__author__"]
|
codeframe/__main__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Entry point for `python -m codeframe` invocation.
|
|
2
|
+
|
|
3
|
+
This module enables running the CLI via:
|
|
4
|
+
python -m codeframe [command] [options]
|
|
5
|
+
|
|
6
|
+
The help text will correctly show 'codeframe' as the command name.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
"""Run the CLI with proper program name."""
|
|
12
|
+
from codeframe.cli.app import main as app_main
|
|
13
|
+
|
|
14
|
+
# The telemetry-aware wrapper invokes the app with prog_name="codeframe",
|
|
15
|
+
# so the usage line shows the right command name here too.
|
|
16
|
+
app_main()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
main()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""E2B cloud execution adapter package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name: str):
|
|
7
|
+
if name == "E2BAgentAdapter":
|
|
8
|
+
from codeframe.adapters.e2b.adapter import E2BAgentAdapter
|
|
9
|
+
return E2BAgentAdapter
|
|
10
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = ["E2BAgentAdapter"]
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""E2B cloud execution adapter.
|
|
2
|
+
|
|
3
|
+
Runs CodeFrame's ReAct agent loop inside an E2B Linux sandbox, providing
|
|
4
|
+
fully isolated execution without touching the local filesystem.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable
|
|
14
|
+
|
|
15
|
+
from codeframe.adapters.e2b.credential_scanner import scan_path
|
|
16
|
+
from codeframe.core.adapters.agent_adapter import (
|
|
17
|
+
AgentEvent,
|
|
18
|
+
AgentResult,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# E2B pricing: ~$0.002 per sandbox-minute (estimate, adjust as needed)
|
|
24
|
+
_COST_PER_MINUTE = 0.002
|
|
25
|
+
|
|
26
|
+
# Hard cap on sandbox lifetime
|
|
27
|
+
_MAX_TIMEOUT_MINUTES = 60
|
|
28
|
+
_MIN_TIMEOUT_MINUTES = 1
|
|
29
|
+
|
|
30
|
+
# Remote workspace path inside the sandbox
|
|
31
|
+
_SANDBOX_WORKSPACE = "/workspace"
|
|
32
|
+
|
|
33
|
+
# Codeframe install command (uses the published package)
|
|
34
|
+
_INSTALL_CMD = "pip install codeframe --quiet"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class E2BAgentAdapter:
|
|
38
|
+
"""Runs a CodeFrame task inside an E2B Linux sandbox.
|
|
39
|
+
|
|
40
|
+
Lifecycle:
|
|
41
|
+
1. Credential-scan the local workspace — abort if secrets detected.
|
|
42
|
+
2. Create E2B sandbox with configured timeout.
|
|
43
|
+
3. Upload clean workspace files.
|
|
44
|
+
4. Initialize git inside sandbox (needed for diff-based change detection).
|
|
45
|
+
5. Install codeframe inside sandbox.
|
|
46
|
+
6. Run the agent via ``cf work start`` CLI.
|
|
47
|
+
7. Download changed files (via ``git diff``) to local workspace.
|
|
48
|
+
8. Return AgentResult with cloud metadata.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
name = "cloud"
|
|
52
|
+
|
|
53
|
+
def __init__(self, timeout_minutes: int = 30) -> None:
|
|
54
|
+
self._timeout_minutes = max(
|
|
55
|
+
_MIN_TIMEOUT_MINUTES,
|
|
56
|
+
min(timeout_minutes, _MAX_TIMEOUT_MINUTES),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def requirements(cls) -> dict[str, str]:
|
|
61
|
+
"""Return required environment variables."""
|
|
62
|
+
return {"E2B_API_KEY": "E2B API key for cloud sandbox execution"}
|
|
63
|
+
|
|
64
|
+
def run(
|
|
65
|
+
self,
|
|
66
|
+
task_id: str,
|
|
67
|
+
prompt: str,
|
|
68
|
+
workspace_path: Path,
|
|
69
|
+
on_event: Callable[[AgentEvent], None] | None = None,
|
|
70
|
+
) -> AgentResult:
|
|
71
|
+
"""Execute a task inside an E2B sandbox.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
task_id: CodeFrame task identifier.
|
|
75
|
+
prompt: Rich context prompt (written to sandbox as a file).
|
|
76
|
+
workspace_path: Local workspace root to upload.
|
|
77
|
+
on_event: Optional progress callback.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
AgentResult with status, modified_files, and cloud_metadata.
|
|
81
|
+
"""
|
|
82
|
+
start_time = time.monotonic()
|
|
83
|
+
|
|
84
|
+
def _emit(event_type: str, message: str, data: dict | None = None) -> None:
|
|
85
|
+
if on_event is not None:
|
|
86
|
+
on_event(AgentEvent(type=event_type, message=message, data=data or {}))
|
|
87
|
+
logger.info("[E2B] %s: %s", event_type, message)
|
|
88
|
+
|
|
89
|
+
# Step 1: Credential scan
|
|
90
|
+
_emit("progress", "Scanning workspace for credentials before upload...")
|
|
91
|
+
scan_result = scan_path(workspace_path)
|
|
92
|
+
|
|
93
|
+
if not scan_result.is_clean:
|
|
94
|
+
blocked = ", ".join(scan_result.blocked_files[:5])
|
|
95
|
+
error_msg = (
|
|
96
|
+
f"Credential scan failed: {len(scan_result.blocked_files)} "
|
|
97
|
+
f"sensitive file(s) detected and blocked from upload. "
|
|
98
|
+
f"Files: {blocked}"
|
|
99
|
+
)
|
|
100
|
+
_emit("error", error_msg)
|
|
101
|
+
elapsed = (time.monotonic() - start_time) / 60
|
|
102
|
+
return AgentResult(
|
|
103
|
+
status="failed",
|
|
104
|
+
error=error_msg,
|
|
105
|
+
cloud_metadata={
|
|
106
|
+
"sandbox_minutes": elapsed,
|
|
107
|
+
"cost_usd_estimate": 0.0,
|
|
108
|
+
"files_uploaded": 0,
|
|
109
|
+
"files_downloaded": 0,
|
|
110
|
+
"credential_scan_blocked": len(scan_result.blocked_files),
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Step 2: Create sandbox
|
|
115
|
+
try:
|
|
116
|
+
from e2b import Sandbox
|
|
117
|
+
except ImportError:
|
|
118
|
+
return AgentResult(
|
|
119
|
+
status="failed",
|
|
120
|
+
error=(
|
|
121
|
+
"The 'e2b' package is required for --engine cloud. "
|
|
122
|
+
"Install it with: pip install 'codeframe[cloud]'"
|
|
123
|
+
),
|
|
124
|
+
cloud_metadata={
|
|
125
|
+
"sandbox_minutes": 0.0,
|
|
126
|
+
"cost_usd_estimate": 0.0,
|
|
127
|
+
"files_uploaded": 0,
|
|
128
|
+
"files_downloaded": 0,
|
|
129
|
+
"credential_scan_blocked": 0,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
api_key = os.environ.get("E2B_API_KEY")
|
|
134
|
+
timeout_seconds = self._timeout_minutes * 60
|
|
135
|
+
|
|
136
|
+
_emit("progress", f"Creating E2B sandbox (timeout={self._timeout_minutes}min)...")
|
|
137
|
+
try:
|
|
138
|
+
sbx = Sandbox.create(
|
|
139
|
+
timeout=timeout_seconds,
|
|
140
|
+
api_key=api_key,
|
|
141
|
+
)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
elapsed = (time.monotonic() - start_time) / 60
|
|
144
|
+
return AgentResult(
|
|
145
|
+
status="failed",
|
|
146
|
+
error=f"Failed to create E2B sandbox: {exc}",
|
|
147
|
+
cloud_metadata={
|
|
148
|
+
"sandbox_minutes": elapsed,
|
|
149
|
+
"cost_usd_estimate": round(elapsed * _COST_PER_MINUTE, 6),
|
|
150
|
+
"files_uploaded": 0,
|
|
151
|
+
"files_downloaded": 0,
|
|
152
|
+
"credential_scan_blocked": 0,
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
_emit("progress", f"Sandbox created: {sbx.sandbox_id}")
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
# Step 3: Upload workspace files
|
|
160
|
+
files_uploaded = self._upload_workspace(sbx, workspace_path, _emit)
|
|
161
|
+
_emit("progress", f"Uploaded {files_uploaded} files to sandbox")
|
|
162
|
+
|
|
163
|
+
# Step 4: Initialize git baseline (for diff detection)
|
|
164
|
+
sbx.commands.run(
|
|
165
|
+
f"cd {_SANDBOX_WORKSPACE} && git init -q && git add -A && "
|
|
166
|
+
f"git -c user.email=agent@e2b.local -c user.name=agent commit -q -m init",
|
|
167
|
+
timeout=30,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Step 5: Install codeframe
|
|
171
|
+
_emit("progress", "Installing codeframe in sandbox...")
|
|
172
|
+
install_result = sbx.commands.run(
|
|
173
|
+
f"cd {_SANDBOX_WORKSPACE} && {_INSTALL_CMD}",
|
|
174
|
+
timeout=300,
|
|
175
|
+
)
|
|
176
|
+
if install_result.exit_code != 0:
|
|
177
|
+
logger.warning("pip install warnings: %s", install_result.stderr[:500])
|
|
178
|
+
|
|
179
|
+
# Step 6: Run agent
|
|
180
|
+
# Pass secrets via the SDK's envs dict — never interpolate into shell strings
|
|
181
|
+
_emit("progress", f"Starting agent for task {task_id}...")
|
|
182
|
+
agent_envs: dict[str, str] = {}
|
|
183
|
+
anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
|
184
|
+
if anthropic_key:
|
|
185
|
+
agent_envs["ANTHROPIC_API_KEY"] = anthropic_key
|
|
186
|
+
|
|
187
|
+
agent_cmd = f"cd {_SANDBOX_WORKSPACE} && cf work start {task_id} --execute"
|
|
188
|
+
|
|
189
|
+
output_lines: list[str] = []
|
|
190
|
+
|
|
191
|
+
def _on_stdout(line: str) -> None:
|
|
192
|
+
output_lines.append(line)
|
|
193
|
+
_emit("output", line, {"stream": "stdout"})
|
|
194
|
+
|
|
195
|
+
def _on_stderr(line: str) -> None:
|
|
196
|
+
output_lines.append(line)
|
|
197
|
+
_emit("output", line, {"stream": "stderr"})
|
|
198
|
+
|
|
199
|
+
agent_result = sbx.commands.run(
|
|
200
|
+
agent_cmd,
|
|
201
|
+
envs=agent_envs,
|
|
202
|
+
timeout=timeout_seconds,
|
|
203
|
+
on_stdout=_on_stdout,
|
|
204
|
+
on_stderr=_on_stderr,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
output_text = "\n".join(output_lines)
|
|
208
|
+
agent_succeeded = agent_result.exit_code == 0
|
|
209
|
+
|
|
210
|
+
# Step 7: Download changed files
|
|
211
|
+
files_downloaded = 0
|
|
212
|
+
modified_files: list[str] = []
|
|
213
|
+
|
|
214
|
+
if agent_succeeded:
|
|
215
|
+
_emit("progress", "Downloading changed files from sandbox...")
|
|
216
|
+
modified_files, files_downloaded = self._download_changed_files(
|
|
217
|
+
sbx, workspace_path, _emit
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
elapsed = (time.monotonic() - start_time) / 60
|
|
221
|
+
cloud_meta = {
|
|
222
|
+
"sandbox_minutes": round(elapsed, 3),
|
|
223
|
+
"cost_usd_estimate": round(elapsed * _COST_PER_MINUTE, 6),
|
|
224
|
+
"files_uploaded": files_uploaded,
|
|
225
|
+
"files_downloaded": files_downloaded,
|
|
226
|
+
"credential_scan_blocked": 0,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if agent_succeeded:
|
|
230
|
+
_emit("progress", "Execution complete")
|
|
231
|
+
return AgentResult(
|
|
232
|
+
status="completed",
|
|
233
|
+
output=output_text,
|
|
234
|
+
modified_files=modified_files,
|
|
235
|
+
cloud_metadata=cloud_meta,
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
error = agent_result.stderr or output_text or "Agent exited with non-zero status"
|
|
239
|
+
_emit("error", f"Agent failed: {error[:200]}")
|
|
240
|
+
return AgentResult(
|
|
241
|
+
status="failed",
|
|
242
|
+
output=output_text,
|
|
243
|
+
error=error[:500],
|
|
244
|
+
cloud_metadata=cloud_meta,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
finally:
|
|
248
|
+
try:
|
|
249
|
+
sbx.kill()
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
def _upload_workspace(
|
|
254
|
+
self,
|
|
255
|
+
sbx: object,
|
|
256
|
+
workspace_path: Path,
|
|
257
|
+
emit: Callable[[str, str, dict | None], None],
|
|
258
|
+
) -> int:
|
|
259
|
+
"""Upload workspace files to sandbox, returning the count uploaded."""
|
|
260
|
+
_EXCLUDED = frozenset({
|
|
261
|
+
"__pycache__", ".git", ".mypy_cache", ".pytest_cache",
|
|
262
|
+
".ruff_cache", "node_modules", ".venv", "venv",
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
uploaded = 0
|
|
266
|
+
for path in sorted(workspace_path.rglob("*")):
|
|
267
|
+
if any(part in _EXCLUDED for part in path.parts):
|
|
268
|
+
continue
|
|
269
|
+
if not path.is_file():
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
rel = path.relative_to(workspace_path)
|
|
273
|
+
remote_path = f"{_SANDBOX_WORKSPACE}/{rel}"
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
content = path.read_bytes()
|
|
277
|
+
sbx.files.write(remote_path, content)
|
|
278
|
+
uploaded += 1
|
|
279
|
+
except Exception as exc:
|
|
280
|
+
logger.warning("Failed to upload %s: %s", rel, exc)
|
|
281
|
+
|
|
282
|
+
return uploaded
|
|
283
|
+
|
|
284
|
+
def _download_changed_files(
|
|
285
|
+
self,
|
|
286
|
+
sbx: object,
|
|
287
|
+
workspace_path: Path,
|
|
288
|
+
emit: Callable[[str, str, dict | None], None],
|
|
289
|
+
) -> tuple[list[str], int]:
|
|
290
|
+
"""Download files changed or created by the agent.
|
|
291
|
+
|
|
292
|
+
Uses ``git status --porcelain`` to capture both modified tracked files
|
|
293
|
+
and newly created untracked files (git diff only sees tracked changes).
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Tuple of (list of relative file paths, count downloaded).
|
|
297
|
+
"""
|
|
298
|
+
status_result = sbx.commands.run(
|
|
299
|
+
f"cd {_SANDBOX_WORKSPACE} && git status --porcelain",
|
|
300
|
+
timeout=30,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if status_result.exit_code != 0 or not status_result.stdout.strip():
|
|
304
|
+
return [], 0
|
|
305
|
+
|
|
306
|
+
changed: list[str] = []
|
|
307
|
+
for line in status_result.stdout.splitlines():
|
|
308
|
+
line = line.strip()
|
|
309
|
+
if not line:
|
|
310
|
+
continue
|
|
311
|
+
# porcelain format: XY filename (or "XY old -> new" for renames)
|
|
312
|
+
parts = line.split(None, 1)
|
|
313
|
+
if len(parts) < 2:
|
|
314
|
+
continue
|
|
315
|
+
xy, filepath = parts
|
|
316
|
+
# Handle renames: "R old -> new" — take the new name after " -> "
|
|
317
|
+
if " -> " in filepath:
|
|
318
|
+
filepath = filepath.split(" -> ", 1)[1]
|
|
319
|
+
changed.append(filepath.strip())
|
|
320
|
+
|
|
321
|
+
downloaded = 0
|
|
322
|
+
modified_files: list[str] = []
|
|
323
|
+
|
|
324
|
+
for rel_path in changed:
|
|
325
|
+
remote = f"{_SANDBOX_WORKSPACE}/{rel_path}"
|
|
326
|
+
local = workspace_path / rel_path
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
content = sbx.files.read(remote)
|
|
330
|
+
local.parent.mkdir(parents=True, exist_ok=True)
|
|
331
|
+
if isinstance(content, str):
|
|
332
|
+
local.write_text(content, encoding="utf-8")
|
|
333
|
+
else:
|
|
334
|
+
local.write_bytes(bytes(content))
|
|
335
|
+
modified_files.append(rel_path)
|
|
336
|
+
downloaded += 1
|
|
337
|
+
logger.debug("Downloaded: %s", rel_path)
|
|
338
|
+
except Exception as exc:
|
|
339
|
+
logger.warning("Failed to download %s: %s", rel_path, exc)
|
|
340
|
+
|
|
341
|
+
emit("progress", f"Downloaded {downloaded} changed file(s)")
|
|
342
|
+
return modified_files, downloaded
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Cloud run metadata persistence for E2B budget tracking.
|
|
2
|
+
|
|
3
|
+
Records sandbox execution metrics (minutes, cost, file counts) in the
|
|
4
|
+
cloud_run_metadata SQLite table and provides lookup for the work show command.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def record_cloud_run(
|
|
15
|
+
workspace: Any,
|
|
16
|
+
run_id: str,
|
|
17
|
+
sandbox_minutes: float,
|
|
18
|
+
cost_usd: float,
|
|
19
|
+
files_uploaded: int,
|
|
20
|
+
files_downloaded: int,
|
|
21
|
+
scan_blocked: int,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Insert a cloud run record into cloud_run_metadata.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
workspace: Workspace instance with db_path attribute.
|
|
27
|
+
run_id: CodeFrame run identifier.
|
|
28
|
+
sandbox_minutes: Wall-clock minutes the sandbox was alive.
|
|
29
|
+
cost_usd: Estimated cost in USD.
|
|
30
|
+
files_uploaded: Number of files uploaded to the sandbox.
|
|
31
|
+
files_downloaded: Number of changed files downloaded from sandbox.
|
|
32
|
+
scan_blocked: Number of files blocked by credential scanner.
|
|
33
|
+
"""
|
|
34
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
35
|
+
conn = sqlite3.connect(workspace.db_path)
|
|
36
|
+
try:
|
|
37
|
+
conn.execute(
|
|
38
|
+
"""
|
|
39
|
+
INSERT OR REPLACE INTO cloud_run_metadata
|
|
40
|
+
(run_id, sandbox_minutes, cost_usd_estimate,
|
|
41
|
+
files_uploaded, files_downloaded,
|
|
42
|
+
credential_scan_blocked, created_at)
|
|
43
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
44
|
+
""",
|
|
45
|
+
(run_id, sandbox_minutes, cost_usd,
|
|
46
|
+
files_uploaded, files_downloaded, scan_blocked, created_at),
|
|
47
|
+
)
|
|
48
|
+
conn.commit()
|
|
49
|
+
finally:
|
|
50
|
+
conn.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_cloud_run(workspace: Any, run_id: str) -> dict | None:
|
|
54
|
+
"""Retrieve a cloud run record by run_id.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
workspace: Workspace instance with db_path attribute.
|
|
58
|
+
run_id: CodeFrame run identifier.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Dict with cloud run fields, or None if not found.
|
|
62
|
+
"""
|
|
63
|
+
conn = sqlite3.connect(workspace.db_path)
|
|
64
|
+
conn.row_factory = sqlite3.Row
|
|
65
|
+
try:
|
|
66
|
+
row = conn.execute(
|
|
67
|
+
"SELECT * FROM cloud_run_metadata WHERE run_id = ?", (run_id,)
|
|
68
|
+
).fetchone()
|
|
69
|
+
return dict(row) if row else None
|
|
70
|
+
finally:
|
|
71
|
+
conn.close()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Credential scanner for workspace upload safety.
|
|
2
|
+
|
|
3
|
+
Scans a directory tree for sensitive files and secret patterns before
|
|
4
|
+
uploading to an E2B sandbox, preventing accidental credential leakage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Directories that are always excluded from scanning and upload counts
|
|
17
|
+
_EXCLUDED_DIRS = frozenset({
|
|
18
|
+
"__pycache__",
|
|
19
|
+
".git",
|
|
20
|
+
".mypy_cache",
|
|
21
|
+
".pytest_cache",
|
|
22
|
+
".ruff_cache",
|
|
23
|
+
"node_modules",
|
|
24
|
+
".venv",
|
|
25
|
+
"venv",
|
|
26
|
+
".tox",
|
|
27
|
+
"dist",
|
|
28
|
+
"build",
|
|
29
|
+
".eggs",
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
# High-risk filename/extension patterns (case-insensitive glob-style matching)
|
|
33
|
+
_BLOCKED_FILENAME_PATTERNS = (
|
|
34
|
+
re.compile(r"^\.env$"),
|
|
35
|
+
re.compile(r"^\.env\."),
|
|
36
|
+
re.compile(r"\.pem$"),
|
|
37
|
+
re.compile(r"\.key$"),
|
|
38
|
+
re.compile(r"^id_rsa$"),
|
|
39
|
+
re.compile(r"^id_dsa$"),
|
|
40
|
+
re.compile(r"^id_ecdsa$"),
|
|
41
|
+
re.compile(r"^id_ed25519$"),
|
|
42
|
+
re.compile(r"^credentials$"),
|
|
43
|
+
re.compile(r"^secrets\..+"),
|
|
44
|
+
re.compile(r"\.pfx$"),
|
|
45
|
+
re.compile(r"\.p12$"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Content patterns that indicate embedded secrets (applied to text files)
|
|
49
|
+
_SECRET_CONTENT_PATTERNS = (
|
|
50
|
+
re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key
|
|
51
|
+
re.compile(r"sk-[a-zA-Z0-9]{48}"), # OpenAI API key
|
|
52
|
+
re.compile(r"ghp_[a-zA-Z0-9]{36}"), # GitHub PAT
|
|
53
|
+
re.compile(r"ghs_[a-zA-Z0-9]{36}"), # GitHub app token
|
|
54
|
+
re.compile(r"(?i)(api_key|secret|password)\s*=\s*['\"][^'\"]{8,}['\"]"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Max bytes to sample for content scanning (avoid reading huge binaries)
|
|
58
|
+
_MAX_CONTENT_SAMPLE = 8192
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ScanResult:
|
|
63
|
+
"""Result of a credential scan."""
|
|
64
|
+
|
|
65
|
+
blocked_files: list[str] = field(default_factory=list)
|
|
66
|
+
scanned_count: int = 0
|
|
67
|
+
is_clean: bool = True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def scan_path(root: Path) -> ScanResult:
|
|
71
|
+
"""Scan *root* for credentials before uploading to a sandbox.
|
|
72
|
+
|
|
73
|
+
Walks the directory tree, checks filenames against the blocklist, and
|
|
74
|
+
samples text file content for known secret patterns.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
root: Root directory to scan.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
ScanResult with blocked file list, scanned count, and is_clean flag.
|
|
81
|
+
"""
|
|
82
|
+
result = ScanResult()
|
|
83
|
+
|
|
84
|
+
for path in sorted(root.rglob("*")):
|
|
85
|
+
# Skip excluded directories
|
|
86
|
+
if any(part in _EXCLUDED_DIRS for part in path.parts):
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if not path.is_file():
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
filename = path.name
|
|
93
|
+
rel = str(path.relative_to(root))
|
|
94
|
+
|
|
95
|
+
# Check filename against blocklist
|
|
96
|
+
if _is_blocked_filename(filename):
|
|
97
|
+
result.blocked_files.append(rel)
|
|
98
|
+
result.is_clean = False
|
|
99
|
+
logger.info("Blocked (filename): %s", rel)
|
|
100
|
+
result.scanned_count += 1
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Check content for secret patterns (text files only)
|
|
104
|
+
if _contains_secret(path):
|
|
105
|
+
result.blocked_files.append(rel)
|
|
106
|
+
result.is_clean = False
|
|
107
|
+
logger.info("Blocked (content pattern): %s", rel)
|
|
108
|
+
|
|
109
|
+
result.scanned_count += 1
|
|
110
|
+
logger.debug("Scanned: %s", rel)
|
|
111
|
+
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_blocked_filename(filename: str) -> bool:
|
|
116
|
+
"""Return True if *filename* matches any high-risk pattern."""
|
|
117
|
+
for pattern in _BLOCKED_FILENAME_PATTERNS:
|
|
118
|
+
if pattern.search(filename):
|
|
119
|
+
return True
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _contains_secret(path: Path) -> bool:
|
|
124
|
+
"""Return True if the file content matches any known secret pattern."""
|
|
125
|
+
try:
|
|
126
|
+
content = path.read_bytes()[:_MAX_CONTENT_SAMPLE]
|
|
127
|
+
text = content.decode("utf-8", errors="replace")
|
|
128
|
+
except OSError:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
for pattern in _SECRET_CONTENT_PATTERNS:
|
|
132
|
+
if pattern.search(text):
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""LLM adapter package.
|
|
2
|
+
|
|
3
|
+
Provides a unified interface for LLM providers with task-based model selection.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from codeframe.adapters.llm import AnthropicProvider, get_provider
|
|
7
|
+
|
|
8
|
+
# Get configured provider
|
|
9
|
+
provider = get_provider()
|
|
10
|
+
|
|
11
|
+
# Or create directly
|
|
12
|
+
provider = AnthropicProvider()
|
|
13
|
+
|
|
14
|
+
# Make a completion
|
|
15
|
+
response = provider.complete(
|
|
16
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
17
|
+
purpose="planning", # Selects appropriate model
|
|
18
|
+
)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
from codeframe.adapters.llm.base import (
|
|
24
|
+
LLMProvider,
|
|
25
|
+
LLMResponse,
|
|
26
|
+
Message,
|
|
27
|
+
ModelSelector,
|
|
28
|
+
Purpose,
|
|
29
|
+
StreamChunk,
|
|
30
|
+
Tool,
|
|
31
|
+
ToolCall,
|
|
32
|
+
ToolResult,
|
|
33
|
+
)
|
|
34
|
+
from codeframe.adapters.llm.anthropic import AnthropicProvider
|
|
35
|
+
from codeframe.adapters.llm.mock import MockProvider
|
|
36
|
+
from codeframe.adapters.llm.openai import OpenAIProvider
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"LLMProvider",
|
|
40
|
+
"LLMResponse",
|
|
41
|
+
"Message",
|
|
42
|
+
"ModelSelector",
|
|
43
|
+
"Purpose",
|
|
44
|
+
"StreamChunk",
|
|
45
|
+
"Tool",
|
|
46
|
+
"ToolCall",
|
|
47
|
+
"ToolResult",
|
|
48
|
+
"AnthropicProvider",
|
|
49
|
+
"MockProvider",
|
|
50
|
+
"OpenAIProvider",
|
|
51
|
+
"get_provider",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
_OPENAI_COMPATIBLE = {"openai", "ollama", "vllm", "compatible"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_provider(provider_type: str = "anthropic", **kwargs) -> LLMProvider:
|
|
58
|
+
"""Get a configured LLM provider.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
provider_type: Provider type ("anthropic", "openai", "ollama",
|
|
62
|
+
"vllm", "compatible", or "mock"). OpenAI-compatible types are
|
|
63
|
+
all routed to OpenAIProvider.
|
|
64
|
+
**kwargs: Optional overrides passed to the provider constructor.
|
|
65
|
+
Supported keys: api_key, model, base_url.
|
|
66
|
+
For local providers (ollama, vllm, compatible) that don't
|
|
67
|
+
require authentication, api_key defaults to "not-required"
|
|
68
|
+
if OPENAI_API_KEY is not set.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Configured LLMProvider instance
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If provider type is unknown
|
|
75
|
+
"""
|
|
76
|
+
if provider_type in _OPENAI_COMPATIBLE:
|
|
77
|
+
api_key = kwargs.get("api_key") or os.environ.get("OPENAI_API_KEY")
|
|
78
|
+
if not api_key and provider_type != "openai":
|
|
79
|
+
# Local providers (ollama, vllm, compatible) don't need real auth;
|
|
80
|
+
# the openai SDK still requires a non-empty api_key value.
|
|
81
|
+
api_key = "not-required"
|
|
82
|
+
return OpenAIProvider(
|
|
83
|
+
api_key=api_key,
|
|
84
|
+
model=kwargs.get("model", os.environ.get("CODEFRAME_LLM_MODEL", "gpt-4o")),
|
|
85
|
+
base_url=kwargs.get("base_url", os.environ.get("OPENAI_BASE_URL")),
|
|
86
|
+
)
|
|
87
|
+
elif provider_type == "anthropic":
|
|
88
|
+
return AnthropicProvider()
|
|
89
|
+
elif provider_type == "mock":
|
|
90
|
+
return MockProvider()
|
|
91
|
+
else:
|
|
92
|
+
raise ValueError(f"Unknown provider type: {provider_type}")
|