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
|
@@ -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
|
+
)
|