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.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. 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,5 @@
1
+ """CodeFRAME adapters package.
2
+
3
+ Adapters provide implementations for external services (LLM providers, etc.)
4
+ that can be swapped without changing core logic.
5
+ """
@@ -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}")