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
@@ -0,0 +1,562 @@
1
+ """V2 Settings router — agent settings + API key management + notifications.
2
+
3
+ Routes:
4
+ GET /api/v2/settings - Load agent settings (defaults if missing)
5
+ PUT /api/v2/settings - Save agent settings (merge into config)
6
+ GET /api/v2/settings/keys - List API key status for all providers
7
+ PUT /api/v2/settings/keys/{p} - Store an API key for provider p
8
+ DELETE /api/v2/settings/keys/{p} - Delete an API key for provider p
9
+ POST /api/v2/settings/verify-key - Live-verify a key against its provider
10
+ GET /api/v2/settings/notifications - Load outbound webhook config
11
+ PUT /api/v2/settings/notifications - Save outbound webhook config
12
+ POST /api/v2/settings/notifications/test - Fire a test payload and return HTTP status
13
+
14
+ Key management is machine-wide (CredentialManager / keyring) and does not
15
+ require a workspace. Env vars take precedence at read time. Notifications
16
+ config is per-workspace and persisted under .codeframe/notifications_config.json.
17
+ """
18
+
19
+ import logging
20
+
21
+ from typing import Optional, cast
22
+
23
+ from anthropic import Anthropic as _AnthropicClient
24
+ from anthropic import AuthenticationError as _AnthropicAuthError
25
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
26
+ from fastapi.concurrency import run_in_threadpool
27
+ from openai import AuthenticationError as _OpenAIAuthError
28
+ from openai import OpenAI as _OpenAIClient
29
+ from pydantic import BaseModel, Field
30
+
31
+ from codeframe.core.config import (
32
+ AgentBudgetConfig,
33
+ EnvironmentConfig,
34
+ load_environment_config,
35
+ save_environment_config,
36
+ )
37
+ from codeframe.core.credentials import (
38
+ CredentialManager,
39
+ CredentialProvider,
40
+ CredentialSource,
41
+ validate_credential_format,
42
+ )
43
+ from codeframe.core.notifications_config import (
44
+ load_notifications_config,
45
+ save_notifications_config,
46
+ )
47
+ from codeframe.core.workspace import Workspace
48
+ from codeframe.notifications.webhook import (
49
+ WebhookNotificationService,
50
+ format_test_payload,
51
+ )
52
+ from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard
53
+ from codeframe.ui.dependencies import get_v2_workspace
54
+ from codeframe.ui.models import (
55
+ AGENT_TYPES,
56
+ KEY_PROVIDERS,
57
+ AgentSettingsResponse,
58
+ AgentTypeModelConfig,
59
+ KeyProvider,
60
+ KeyStatusResponse,
61
+ StoreKeyRequest,
62
+ UpdateAgentSettingsRequest,
63
+ VerifyKeyRequest,
64
+ VerifyKeyResponse,
65
+ )
66
+ from codeframe.ui.response_models import ErrorCodes, api_error
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+ router = APIRouter(prefix="/api/v2/settings", tags=["settings"])
71
+
72
+
73
+ def get_credential_manager() -> CredentialManager:
74
+ """Dependency: machine-wide CredentialManager.
75
+
76
+ Overridden in tests to point at an isolated temp directory.
77
+ """
78
+ return CredentialManager()
79
+
80
+
81
+ def _config_to_response(config: EnvironmentConfig) -> AgentSettingsResponse:
82
+ """Map an EnvironmentConfig to the flat AgentSettings response shape."""
83
+ saved_models = config.agent_type_models or {}
84
+ agent_models = [
85
+ AgentTypeModelConfig(
86
+ agent_type=agent_type,
87
+ default_model=saved_models.get(agent_type, ""),
88
+ )
89
+ for agent_type in AGENT_TYPES
90
+ ]
91
+ # Guard against legacy YAML where agent_budget may have been removed/nulled.
92
+ budget = config.agent_budget or AgentBudgetConfig()
93
+ return AgentSettingsResponse(
94
+ agent_models=agent_models,
95
+ max_turns=budget.max_iterations,
96
+ max_cost_usd=config.max_cost_usd,
97
+ )
98
+
99
+
100
+ @router.get("", response_model=AgentSettingsResponse)
101
+ @rate_limit_standard()
102
+ async def get_settings(
103
+ request: Request,
104
+ workspace: Workspace = Depends(get_v2_workspace),
105
+ ) -> AgentSettingsResponse:
106
+ """Load agent settings for the workspace.
107
+
108
+ Returns defaults if no .codeframe/config.yaml exists.
109
+ """
110
+ try:
111
+ config = load_environment_config(workspace.repo_path) or EnvironmentConfig()
112
+ return _config_to_response(config)
113
+ except Exception as e:
114
+ logger.error(f"Failed to load settings: {e}", exc_info=True)
115
+ raise HTTPException(
116
+ status_code=500,
117
+ detail=api_error(
118
+ "Failed to load settings", ErrorCodes.EXECUTION_FAILED, str(e)
119
+ ),
120
+ )
121
+
122
+
123
+ @router.put("", response_model=AgentSettingsResponse)
124
+ @rate_limit_standard()
125
+ async def update_settings(
126
+ request: Request,
127
+ body: UpdateAgentSettingsRequest,
128
+ workspace: Workspace = Depends(get_v2_workspace),
129
+ ) -> AgentSettingsResponse:
130
+ """Save agent settings.
131
+
132
+ Merges into existing EnvironmentConfig so unrelated fields
133
+ (package_manager, test_framework, etc.) are preserved.
134
+ """
135
+ try:
136
+ config = load_environment_config(workspace.repo_path) or EnvironmentConfig()
137
+ if config.agent_budget is None:
138
+ config.agent_budget = AgentBudgetConfig()
139
+
140
+ config.agent_budget.max_iterations = body.max_turns
141
+ config.max_cost_usd = body.max_cost_usd
142
+ # Skip empty model strings — they're equivalent to "key not present"
143
+ # in _config_to_response, so persisting them just adds yaml noise.
144
+ # AgentType Literal in the model already rejects unknown agent_type values.
145
+ config.agent_type_models = {
146
+ entry.agent_type: entry.default_model
147
+ for entry in body.agent_models
148
+ if entry.default_model
149
+ }
150
+
151
+ save_environment_config(workspace.repo_path, config)
152
+ return _config_to_response(config)
153
+ except Exception as e:
154
+ logger.error(f"Failed to save settings: {e}", exc_info=True)
155
+ raise HTTPException(
156
+ status_code=500,
157
+ detail=api_error(
158
+ "Failed to save settings", ErrorCodes.EXECUTION_FAILED, str(e)
159
+ ),
160
+ )
161
+
162
+
163
+ # ============================================================================
164
+ # API Key Management (issue #555)
165
+ #
166
+ # These endpoints are machine-wide: they do not require a workspace. Keys are
167
+ # stored via CredentialManager (platform keyring or encrypted file fallback).
168
+ # Plaintext key values are NEVER returned in any response.
169
+ # ============================================================================
170
+
171
+ # Map the public provider name to the internal CredentialProvider enum.
172
+ _PROVIDER_MAP: dict[str, CredentialProvider] = {
173
+ "LLM_ANTHROPIC": CredentialProvider.LLM_ANTHROPIC,
174
+ "LLM_OPENAI": CredentialProvider.LLM_OPENAI,
175
+ "GIT_GITHUB": CredentialProvider.GIT_GITHUB,
176
+ }
177
+
178
+
179
+ def _resolve_provider(provider: str) -> CredentialProvider:
180
+ """Resolve a provider name from path/body, raising 400 for unknown values."""
181
+ cp = _PROVIDER_MAP.get(provider)
182
+ if cp is None:
183
+ raise HTTPException(
184
+ status_code=400,
185
+ detail=api_error(
186
+ f"Unknown provider: {provider}. Allowed: {', '.join(KEY_PROVIDERS)}",
187
+ ErrorCodes.VALIDATION_ERROR,
188
+ ),
189
+ )
190
+ return cp
191
+
192
+
193
+ def _last_four(value: str | None) -> str | None:
194
+ """Return the last 4 chars of a key, or None if no value.
195
+
196
+ For values shorter than 4 chars (which format validation should
197
+ already reject), returns the full value.
198
+ """
199
+ if not value:
200
+ return None
201
+ return value[-4:] if len(value) >= 4 else value
202
+
203
+
204
+ def _build_status(
205
+ provider_key: KeyProvider,
206
+ manager: CredentialManager,
207
+ ) -> KeyStatusResponse:
208
+ cp = _PROVIDER_MAP[provider_key]
209
+ source = manager.get_credential_source(cp)
210
+ if source == CredentialSource.NOT_FOUND:
211
+ return KeyStatusResponse(
212
+ provider=provider_key, stored=False, source="none", last_four=None
213
+ )
214
+
215
+ value = manager.get_credential(cp)
216
+ source_str = "environment" if source == CredentialSource.ENVIRONMENT else "stored"
217
+ return KeyStatusResponse(
218
+ provider=provider_key,
219
+ stored=value is not None,
220
+ source=source_str,
221
+ last_four=_last_four(value),
222
+ )
223
+
224
+
225
+ @router.get("/keys", response_model=list[KeyStatusResponse])
226
+ @rate_limit_standard()
227
+ async def list_key_status(
228
+ request: Request,
229
+ manager: CredentialManager = Depends(get_credential_manager),
230
+ ) -> list[KeyStatusResponse]:
231
+ """Return status of each known API key without exposing plaintext."""
232
+ return [_build_status(p, manager) for p in KEY_PROVIDERS]
233
+
234
+
235
+ @router.put("/keys/{provider}", response_model=KeyStatusResponse)
236
+ @rate_limit_standard()
237
+ async def store_key(
238
+ provider: str,
239
+ body: StoreKeyRequest,
240
+ request: Request,
241
+ manager: CredentialManager = Depends(get_credential_manager),
242
+ ) -> KeyStatusResponse:
243
+ """Store an API key for the given provider after validating its format."""
244
+ cp = _resolve_provider(provider)
245
+ if not validate_credential_format(cp, body.value):
246
+ raise HTTPException(
247
+ status_code=400,
248
+ detail=api_error(
249
+ f"Invalid {provider} key format",
250
+ ErrorCodes.VALIDATION_ERROR,
251
+ ),
252
+ )
253
+ try:
254
+ manager.set_credential(cp, body.value)
255
+ except Exception as e:
256
+ logger.error(f"Failed to store credential: {e}", exc_info=True)
257
+ raise HTTPException(
258
+ status_code=500,
259
+ detail=api_error(
260
+ "Failed to store credential", ErrorCodes.EXECUTION_FAILED, str(e)
261
+ ),
262
+ )
263
+ # _resolve_provider validated `provider` against KEY_PROVIDERS, so the
264
+ # cast is safe — clearer than a type: ignore.
265
+ return _build_status(cast(KeyProvider, provider), manager)
266
+
267
+
268
+ @router.delete("/keys/{provider}", status_code=204)
269
+ @rate_limit_standard()
270
+ async def delete_key(
271
+ provider: str,
272
+ request: Request,
273
+ manager: CredentialManager = Depends(get_credential_manager),
274
+ ) -> Response:
275
+ """Delete a stored credential. Idempotent — non-existent keys are a no-op."""
276
+ cp = _resolve_provider(provider)
277
+ try:
278
+ manager.delete_credential(cp)
279
+ except Exception as e:
280
+ # Underlying store can raise on a hard keyring error; treat absence as success.
281
+ msg = str(e).lower()
282
+ if "no such" in msg or "not found" in msg:
283
+ return Response(status_code=204)
284
+ logger.error(f"Failed to delete credential: {e}", exc_info=True)
285
+ raise HTTPException(
286
+ status_code=500,
287
+ detail=api_error(
288
+ "Failed to delete credential", ErrorCodes.EXECUTION_FAILED, str(e)
289
+ ),
290
+ )
291
+ return Response(status_code=204)
292
+
293
+
294
+ # ----------------------------------------------------------------------------
295
+ # Live verification helpers
296
+ #
297
+ # These are module-level so tests can monkeypatch them without touching the
298
+ # real network. The Anthropic / OpenAI clients are sync, so we wrap their
299
+ # calls in run_in_threadpool to avoid blocking the event loop.
300
+ # ----------------------------------------------------------------------------
301
+
302
+
303
+ async def _check_github_token(token: str) -> tuple[bool, str]:
304
+ """Validate a GitHub token via GET https://api.github.com/user.
305
+
306
+ Exception messages are NOT echoed back to the client because aiohttp
307
+ errors can include the request URL or headers (which carry the token).
308
+ The detailed error is logged server-side instead.
309
+ """
310
+ import aiohttp
311
+
312
+ headers = {
313
+ "Authorization": f"token {token}",
314
+ "Accept": "application/vnd.github+json",
315
+ "User-Agent": "codeframe-key-verify",
316
+ }
317
+ try:
318
+ timeout = aiohttp.ClientTimeout(total=10)
319
+ async with aiohttp.ClientSession(timeout=timeout) as session:
320
+ async with session.get(
321
+ "https://api.github.com/user", headers=headers
322
+ ) as resp:
323
+ if resp.status == 200:
324
+ data = await resp.json()
325
+ login = data.get("login", "")
326
+ return True, f"Authenticated as {login}" if login else "Valid token"
327
+ if resp.status == 401:
328
+ return False, "401 Unauthorized: invalid GitHub token"
329
+ return False, f"GitHub API returned status {resp.status}"
330
+ except Exception as e:
331
+ logger.warning(f"GitHub token verification raised: {e}", exc_info=True)
332
+ return False, "GitHub verification failed: network or server error"
333
+
334
+
335
+ def _verify_anthropic_sync(key: str) -> tuple[bool, str]:
336
+ """Verify an Anthropic key by issuing a minimal messages.create() call.
337
+
338
+ Uses messages.create rather than models.list because messages is the
339
+ stable, always-present API surface across all supported SDK versions
340
+ (>=0.18). max_tokens=1 keeps the cost trivial. Non-auth exceptions
341
+ are logged but not echoed to the client to avoid leaking provider
342
+ internals.
343
+ """
344
+ try:
345
+ client = _AnthropicClient(api_key=key)
346
+ client.messages.create(
347
+ model="claude-haiku-4-5-20251001",
348
+ max_tokens=1,
349
+ messages=[{"role": "user", "content": "ping"}],
350
+ )
351
+ return True, "Anthropic key accepted"
352
+ except _AnthropicAuthError:
353
+ return False, "Anthropic key rejected: authentication failed"
354
+ except Exception as e:
355
+ logger.warning(f"Anthropic verification raised: {e}", exc_info=True)
356
+ return False, "Anthropic verification failed: network or server error"
357
+
358
+
359
+ def _verify_openai_sync(key: str) -> tuple[bool, str]:
360
+ """Verify an OpenAI key via models.list (small, cheap, auth-required)."""
361
+ try:
362
+ client = _OpenAIClient(api_key=key)
363
+ client.models.list()
364
+ return True, "OpenAI key accepted"
365
+ except _OpenAIAuthError:
366
+ return False, "OpenAI key rejected: authentication failed"
367
+ except Exception as e:
368
+ logger.warning(f"OpenAI verification raised: {e}", exc_info=True)
369
+ return False, "OpenAI verification failed: network or server error"
370
+
371
+
372
+ @router.post("/verify-key", response_model=VerifyKeyResponse)
373
+ @rate_limit_ai()
374
+ async def verify_key(
375
+ body: VerifyKeyRequest,
376
+ request: Request,
377
+ manager: CredentialManager = Depends(get_credential_manager),
378
+ ) -> VerifyKeyResponse:
379
+ """Live-verify a key against its provider.
380
+
381
+ If body.value is None, the stored or env-var key is used. Failed
382
+ verifications return 200 with valid=false; only unexpected errors
383
+ (programmer bugs) raise 5xx.
384
+ """
385
+ cp = _resolve_provider(body.provider)
386
+ key = body.value if body.value else manager.get_credential(cp)
387
+ if not key:
388
+ return VerifyKeyResponse(
389
+ provider=body.provider, valid=False, message="No key provided or stored"
390
+ )
391
+
392
+ if cp == CredentialProvider.LLM_ANTHROPIC:
393
+ valid, message = await run_in_threadpool(_verify_anthropic_sync, key)
394
+ elif cp == CredentialProvider.LLM_OPENAI:
395
+ valid, message = await run_in_threadpool(_verify_openai_sync, key)
396
+ elif cp == CredentialProvider.GIT_GITHUB:
397
+ valid, message = await _check_github_token(key)
398
+ else: # pragma: no cover -- guarded by _resolve_provider
399
+ valid, message = False, "Verification not supported for this provider"
400
+
401
+ return VerifyKeyResponse(provider=body.provider, valid=valid, message=message)
402
+
403
+
404
+ # ============================================================================
405
+ # Outbound webhook notifications (issue #560)
406
+ #
407
+ # Per-workspace: stored under .codeframe/notifications_config.json. The URL is
408
+ # kept as plaintext for v1 — the workspace DB is already a local file, and the
409
+ # legacy BLOCKER_WEBHOOK_URL env var was plaintext too. Encryption-at-rest is
410
+ # tracked as future work.
411
+ # ============================================================================
412
+
413
+
414
+ class NotificationSettingsResponse(BaseModel):
415
+ webhook_url: Optional[str] = None
416
+ webhook_enabled: bool = False
417
+
418
+
419
+ class UpdateNotificationSettingsRequest(BaseModel):
420
+ webhook_url: Optional[str] = Field(
421
+ default=None,
422
+ description="Outbound webhook URL. Empty/None clears the value.",
423
+ )
424
+ webhook_enabled: bool = False
425
+
426
+
427
+ class TestWebhookResponse(BaseModel):
428
+ ok: bool
429
+ status_code: Optional[int] = None
430
+ error: Optional[str] = None
431
+
432
+
433
+ @router.get("/notifications", response_model=NotificationSettingsResponse)
434
+ @rate_limit_standard()
435
+ async def get_notification_settings(
436
+ request: Request,
437
+ workspace: Workspace = Depends(get_v2_workspace),
438
+ ) -> NotificationSettingsResponse:
439
+ """Load outbound webhook config for this workspace."""
440
+ cfg = load_notifications_config(workspace)
441
+ return NotificationSettingsResponse(
442
+ webhook_url=cfg["webhook_url"],
443
+ webhook_enabled=cfg["webhook_enabled"],
444
+ )
445
+
446
+
447
+ _ALLOWED_WEBHOOK_SCHEMES = frozenset({"http", "https"})
448
+
449
+
450
+ def _validate_webhook_url(url: Optional[str]) -> Optional[str]:
451
+ """Normalize and validate a user-supplied webhook URL.
452
+
453
+ Returns the trimmed URL on success, or ``None`` if empty. Raises
454
+ ``HTTPException(400)`` for non-``http(s)`` schemes — without this guard,
455
+ a user could enter ``file:///...`` or ``ftp://...`` and aiohttp would
456
+ happily attempt the request (SSRF on local resources).
457
+ """
458
+ from urllib.parse import urlparse
459
+
460
+ trimmed = (url or "").strip()
461
+ if not trimmed:
462
+ return None
463
+ parsed = urlparse(trimmed)
464
+ if parsed.scheme.lower() not in _ALLOWED_WEBHOOK_SCHEMES:
465
+ raise HTTPException(
466
+ status_code=400,
467
+ detail=api_error(
468
+ f"Webhook URL must use http or https, got: {parsed.scheme!r}",
469
+ ErrorCodes.VALIDATION_ERROR,
470
+ ),
471
+ )
472
+ if not parsed.netloc:
473
+ raise HTTPException(
474
+ status_code=400,
475
+ detail=api_error(
476
+ "Webhook URL must include a host",
477
+ ErrorCodes.VALIDATION_ERROR,
478
+ ),
479
+ )
480
+ return trimmed
481
+
482
+
483
+ @router.put("/notifications", response_model=NotificationSettingsResponse)
484
+ @rate_limit_standard()
485
+ async def update_notification_settings(
486
+ request: Request,
487
+ body: UpdateNotificationSettingsRequest,
488
+ workspace: Workspace = Depends(get_v2_workspace),
489
+ ) -> NotificationSettingsResponse:
490
+ """Save outbound webhook config for this workspace.
491
+
492
+ An empty / whitespace-only URL is normalized to ``None``. The enabled
493
+ flag is preserved as-is so the user can toggle delivery without losing
494
+ the saved URL. The URL is validated for ``http(s)`` scheme to avoid
495
+ ``file://`` / ``ftp://`` SSRF on local resources.
496
+ """
497
+ url = _validate_webhook_url(body.webhook_url)
498
+ try:
499
+ save_notifications_config(
500
+ workspace,
501
+ {"webhook_url": url, "webhook_enabled": body.webhook_enabled},
502
+ )
503
+ except OSError as e:
504
+ logger.error("Failed to save notifications config: %s", e, exc_info=True)
505
+ raise HTTPException(
506
+ status_code=500,
507
+ detail=api_error(
508
+ "Failed to save notifications config",
509
+ ErrorCodes.EXECUTION_FAILED,
510
+ str(e),
511
+ ),
512
+ )
513
+ return NotificationSettingsResponse(
514
+ webhook_url=url, webhook_enabled=body.webhook_enabled
515
+ )
516
+
517
+
518
+ @router.post("/notifications/test", response_model=TestWebhookResponse)
519
+ @rate_limit_standard()
520
+ async def test_notification_webhook(
521
+ request: Request,
522
+ workspace: Workspace = Depends(get_v2_workspace),
523
+ ) -> TestWebhookResponse:
524
+ """Fire a test payload against the configured webhook URL.
525
+
526
+ Returns the HTTP status code on success, or an error message on failure.
527
+ Returns 400 if no URL is configured or if the stored URL fails the same
528
+ safety checks the PUT endpoint enforces — the [Test] button should be
529
+ disabled in that case, but we guard server-side too.
530
+ """
531
+ try:
532
+ cfg = load_notifications_config(workspace)
533
+ except Exception as e:
534
+ logger.error("Failed to load notifications config: %s", e, exc_info=True)
535
+ raise HTTPException(
536
+ status_code=500,
537
+ detail=api_error(
538
+ "Failed to load notifications config",
539
+ ErrorCodes.EXECUTION_FAILED,
540
+ str(e),
541
+ ),
542
+ )
543
+
544
+ url = (cfg["webhook_url"] or "").strip()
545
+ if not url:
546
+ raise HTTPException(
547
+ status_code=400,
548
+ detail=api_error(
549
+ "No webhook URL configured", ErrorCodes.VALIDATION_ERROR
550
+ ),
551
+ )
552
+ # Re-validate at send time — defence-in-depth for hand-edited config files.
553
+ # We discard the return value: ``_validate_webhook_url`` raises
554
+ # ``HTTPException(400)`` on a bad scheme/host, which is the side-effect
555
+ # we want here. The trimmed URL it returns is already in ``url``.
556
+ _validate_webhook_url(url)
557
+
558
+ svc = WebhookNotificationService(webhook_url=url, timeout=5)
559
+ result = await svc.send_event(format_test_payload())
560
+ return TestWebhookResponse(
561
+ ok=result.ok, status_code=result.status_code, error=result.error
562
+ )