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,232 @@
|
|
|
1
|
+
"""V2 Templates router - delegates to core modules.
|
|
2
|
+
|
|
3
|
+
This module provides v2-style API endpoints for template management that
|
|
4
|
+
delegate to core/templates.py. It uses the v2 Workspace model.
|
|
5
|
+
|
|
6
|
+
The v1 router (templates.py) remains for backwards compatibility.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Literal, Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from codeframe.core.workspace import Workspace
|
|
16
|
+
from codeframe.lib.rate_limiter import rate_limit_standard
|
|
17
|
+
from codeframe.core import templates
|
|
18
|
+
from codeframe.ui.dependencies import get_v2_workspace
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
router = APIRouter(prefix="/api/v2/templates", tags=["templates-v2"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ============================================================================
|
|
26
|
+
# Request/Response Models
|
|
27
|
+
# ============================================================================
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TemplateTaskResponse(BaseModel):
|
|
31
|
+
"""Response model for a template task."""
|
|
32
|
+
|
|
33
|
+
title: str
|
|
34
|
+
description: str
|
|
35
|
+
estimated_hours: float = Field(..., gt=0, description="Estimated hours (must be positive)")
|
|
36
|
+
complexity_score: int = Field(..., ge=1, le=5, description="Complexity rating (1-5)")
|
|
37
|
+
uncertainty_level: Literal["low", "medium", "high"] = Field(
|
|
38
|
+
..., description="Uncertainty level"
|
|
39
|
+
)
|
|
40
|
+
depends_on_indices: list[int]
|
|
41
|
+
tags: list[str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TemplateResponse(BaseModel):
|
|
45
|
+
"""Response model for a task template."""
|
|
46
|
+
|
|
47
|
+
id: str
|
|
48
|
+
name: str
|
|
49
|
+
description: str
|
|
50
|
+
category: str
|
|
51
|
+
tags: list[str]
|
|
52
|
+
total_estimated_hours: float
|
|
53
|
+
tasks: list[TemplateTaskResponse]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TemplateListResponse(BaseModel):
|
|
57
|
+
"""Response model for template list."""
|
|
58
|
+
|
|
59
|
+
id: str
|
|
60
|
+
name: str
|
|
61
|
+
description: str
|
|
62
|
+
category: str
|
|
63
|
+
total_estimated_hours: float
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ApplyTemplateRequest(BaseModel):
|
|
67
|
+
"""Request model for applying a template."""
|
|
68
|
+
|
|
69
|
+
template_id: str
|
|
70
|
+
context: dict[str, Any] = Field(default_factory=dict)
|
|
71
|
+
issue_number: str = "1"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ApplyTemplateResponse(BaseModel):
|
|
75
|
+
"""Response model for applied template."""
|
|
76
|
+
|
|
77
|
+
template_id: str
|
|
78
|
+
tasks_created: int
|
|
79
|
+
task_ids: list[str] # v2 uses string UUIDs
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CategoryListResponse(BaseModel):
|
|
83
|
+
"""Response model for category list."""
|
|
84
|
+
|
|
85
|
+
categories: list[str]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ============================================================================
|
|
89
|
+
# Template Endpoints
|
|
90
|
+
# ============================================================================
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.get("", response_model=list[TemplateListResponse])
|
|
94
|
+
@rate_limit_standard()
|
|
95
|
+
async def list_templates(
|
|
96
|
+
request: Request,
|
|
97
|
+
category: Optional[str] = Query(None, description="Optional category filter"),
|
|
98
|
+
) -> list[TemplateListResponse]:
|
|
99
|
+
"""List all available task templates.
|
|
100
|
+
|
|
101
|
+
This is the v2 equivalent of `cf templates list`.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
category: Optional category filter
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
List of templates with basic info
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
template_list = templates.list_templates(category=category)
|
|
111
|
+
|
|
112
|
+
return [
|
|
113
|
+
TemplateListResponse(
|
|
114
|
+
id=t.id,
|
|
115
|
+
name=t.name,
|
|
116
|
+
description=t.description,
|
|
117
|
+
category=t.category,
|
|
118
|
+
total_estimated_hours=t.total_estimated_hours,
|
|
119
|
+
)
|
|
120
|
+
for t in template_list
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.error(f"Failed to list templates: {e}", exc_info=True)
|
|
125
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@router.get("/categories", response_model=CategoryListResponse)
|
|
129
|
+
@rate_limit_standard()
|
|
130
|
+
async def list_categories(request: Request) -> CategoryListResponse:
|
|
131
|
+
"""List all template categories.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of category names
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
categories = templates.get_categories()
|
|
138
|
+
return CategoryListResponse(categories=categories)
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(f"Failed to list categories: {e}", exc_info=True)
|
|
142
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.get("/{template_id}", response_model=TemplateResponse)
|
|
146
|
+
@rate_limit_standard()
|
|
147
|
+
async def get_template(
|
|
148
|
+
request: Request,
|
|
149
|
+
template_id: str,
|
|
150
|
+
) -> TemplateResponse:
|
|
151
|
+
"""Get details for a specific template.
|
|
152
|
+
|
|
153
|
+
This is the v2 equivalent of `cf templates show`.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
template_id: Template ID
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Template with full details including tasks
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
template = templates.get_template(template_id)
|
|
163
|
+
|
|
164
|
+
if not template:
|
|
165
|
+
raise HTTPException(status_code=404, detail=f"Template not found: {template_id}")
|
|
166
|
+
|
|
167
|
+
return TemplateResponse(
|
|
168
|
+
id=template.id,
|
|
169
|
+
name=template.name,
|
|
170
|
+
description=template.description,
|
|
171
|
+
category=template.category,
|
|
172
|
+
tags=template.tags,
|
|
173
|
+
total_estimated_hours=template.total_estimated_hours,
|
|
174
|
+
tasks=[
|
|
175
|
+
TemplateTaskResponse(
|
|
176
|
+
title=task.title,
|
|
177
|
+
description=task.description,
|
|
178
|
+
estimated_hours=task.estimated_hours,
|
|
179
|
+
complexity_score=task.complexity_score,
|
|
180
|
+
uncertainty_level=task.uncertainty_level,
|
|
181
|
+
depends_on_indices=task.depends_on_indices,
|
|
182
|
+
tags=task.tags,
|
|
183
|
+
)
|
|
184
|
+
for task in template.tasks
|
|
185
|
+
],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
except HTTPException:
|
|
189
|
+
raise
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.error(f"Failed to get template {template_id}: {e}", exc_info=True)
|
|
192
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@router.post("/apply", response_model=ApplyTemplateResponse)
|
|
196
|
+
@rate_limit_standard()
|
|
197
|
+
async def apply_template(
|
|
198
|
+
request: Request,
|
|
199
|
+
body: ApplyTemplateRequest,
|
|
200
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
201
|
+
) -> ApplyTemplateResponse:
|
|
202
|
+
"""Apply a template to create tasks for a workspace.
|
|
203
|
+
|
|
204
|
+
This is the v2 equivalent of `cf templates apply`.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
request: HTTP request for rate limiting
|
|
208
|
+
body: Template application request
|
|
209
|
+
workspace: v2 Workspace
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Result with created task IDs
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
result = templates.apply_template(
|
|
216
|
+
workspace=workspace,
|
|
217
|
+
template_id=body.template_id,
|
|
218
|
+
issue_number=body.issue_number,
|
|
219
|
+
context=body.context,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return ApplyTemplateResponse(
|
|
223
|
+
template_id=result.template_id,
|
|
224
|
+
tasks_created=result.tasks_created,
|
|
225
|
+
task_ids=result.task_ids,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
except ValueError as e:
|
|
229
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.error(f"Failed to apply template: {e}", exc_info=True)
|
|
232
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""WebSocket router for interactive terminal in a session workspace.
|
|
2
|
+
|
|
3
|
+
Endpoint:
|
|
4
|
+
WS /ws/sessions/{session_id}/terminal?token=<JWT>
|
|
5
|
+
|
|
6
|
+
Client → Server message types:
|
|
7
|
+
Raw bytes / text: forwarded verbatim to subprocess stdin.
|
|
8
|
+
{"type": "resize", "cols": 120, "rows": 40}: resize the terminal window.
|
|
9
|
+
|
|
10
|
+
Server → Client:
|
|
11
|
+
Raw bytes from subprocess stdout/stderr.
|
|
12
|
+
|
|
13
|
+
Note: Uses asyncio pipes (not PTY) for simplicity. Arrow keys, colour output,
|
|
14
|
+
and interactive programs like vim require a PTY — that is a known limitation of
|
|
15
|
+
this initial implementation.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
|
|
24
|
+
import jwt as pyjwt
|
|
25
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
26
|
+
from sqlalchemy import select
|
|
27
|
+
|
|
28
|
+
from codeframe.auth.manager import SECRET, JWT_ALGORITHM, JWT_AUDIENCE, get_async_session_maker
|
|
29
|
+
from codeframe.auth.models import User
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
router = APIRouter(tags=["websocket"])
|
|
34
|
+
|
|
35
|
+
# Per-user concurrent terminal connection counter (in-process; resets on restart)
|
|
36
|
+
_MAX_TERMINALS_PER_USER = 3
|
|
37
|
+
_user_terminal_counts: dict[int, int] = {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Helpers
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _authenticate_websocket(websocket: WebSocket) -> int | None:
|
|
46
|
+
"""Validate JWT from query param. Returns user_id or closes the socket."""
|
|
47
|
+
token = websocket.query_params.get("token")
|
|
48
|
+
if not token:
|
|
49
|
+
await websocket.close(code=4001, reason="Authentication required: missing token")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
payload = pyjwt.decode(token, SECRET, algorithms=[JWT_ALGORITHM], audience=JWT_AUDIENCE)
|
|
54
|
+
user_id_str = payload.get("sub")
|
|
55
|
+
if not user_id_str:
|
|
56
|
+
await websocket.close(code=4001, reason="Invalid token: missing subject")
|
|
57
|
+
return None
|
|
58
|
+
user_id = int(user_id_str)
|
|
59
|
+
except pyjwt.ExpiredSignatureError:
|
|
60
|
+
await websocket.close(code=4001, reason="Token expired")
|
|
61
|
+
return None
|
|
62
|
+
except (pyjwt.InvalidTokenError, ValueError) as exc:
|
|
63
|
+
logger.debug("Terminal WS JWT decode error: %s", exc)
|
|
64
|
+
await websocket.close(code=4001, reason="Invalid authentication token")
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
async_session_maker = get_async_session_maker()
|
|
69
|
+
async with async_session_maker() as session:
|
|
70
|
+
result = await session.execute(select(User).where(User.id == user_id))
|
|
71
|
+
user = result.scalar_one_or_none()
|
|
72
|
+
if user is None:
|
|
73
|
+
await websocket.close(code=4001, reason="User not found")
|
|
74
|
+
return None
|
|
75
|
+
if not user.is_active:
|
|
76
|
+
await websocket.close(code=4001, reason="User is inactive")
|
|
77
|
+
return None
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
logger.error("Terminal WS user lookup error: %s", exc)
|
|
80
|
+
await websocket.close(code=4001, reason="Authentication failed")
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
return user_id
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Endpoint
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.websocket("/ws/sessions/{session_id}/terminal")
|
|
92
|
+
async def session_terminal_ws(session_id: str, websocket: WebSocket) -> None:
|
|
93
|
+
"""Bidirectional WebSocket that shells bash in the session's workspace."""
|
|
94
|
+
# --- Auth ---
|
|
95
|
+
user_id = await _authenticate_websocket(websocket)
|
|
96
|
+
if user_id is None:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# --- Session lookup ---
|
|
100
|
+
db = getattr(websocket.app.state, "db", None)
|
|
101
|
+
if db is None:
|
|
102
|
+
await websocket.close(code=1011, reason="Database unavailable")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
session = await asyncio.to_thread(db.interactive_sessions.get, session_id)
|
|
106
|
+
if session is None or session.get("state") == "ended":
|
|
107
|
+
await websocket.close(code=4004, reason="Session not found or ended")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# --- Ownership check ---
|
|
111
|
+
session_user_id = session.get("user_id")
|
|
112
|
+
if session_user_id is not None and int(session_user_id) != user_id:
|
|
113
|
+
await websocket.close(code=4003, reason="Forbidden: session belongs to another user")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
workspace_path = session.get("workspace_path")
|
|
117
|
+
if not workspace_path:
|
|
118
|
+
logger.error("session_id=%s has no workspace_path; refusing terminal spawn", session_id)
|
|
119
|
+
await websocket.close(code=4008, reason="Session has no workspace configured")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# --- Per-user connection cap ---
|
|
123
|
+
current = _user_terminal_counts.get(user_id, 0)
|
|
124
|
+
if current >= _MAX_TERMINALS_PER_USER:
|
|
125
|
+
await websocket.close(code=4029, reason="Too many open terminals; close an existing session first")
|
|
126
|
+
return
|
|
127
|
+
_user_terminal_counts[user_id] = current + 1
|
|
128
|
+
|
|
129
|
+
await websocket.accept()
|
|
130
|
+
|
|
131
|
+
# --- Spawn bash with a minimal, explicit environment ---
|
|
132
|
+
# Do NOT use os.environ.copy() — it would expose server secrets (API keys, DB creds)
|
|
133
|
+
# to the subprocess. Only pass variables required for a functional terminal.
|
|
134
|
+
env = {
|
|
135
|
+
"TERM": "xterm-256color",
|
|
136
|
+
"HOME": os.environ.get("HOME", "/tmp"),
|
|
137
|
+
"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
|
|
138
|
+
"SHELL": "/bin/bash",
|
|
139
|
+
"LANG": os.environ.get("LANG", "en_US.UTF-8"),
|
|
140
|
+
"USER": os.environ.get("USER", ""),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
shell_exe = shutil.which("bash") or shutil.which("sh") or "sh"
|
|
144
|
+
|
|
145
|
+
process: asyncio.subprocess.Process | None = None
|
|
146
|
+
ws_to_stdin_task: asyncio.Task | None = None
|
|
147
|
+
stdout_to_ws_task: asyncio.Task | None = None
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
process = await asyncio.create_subprocess_exec(
|
|
151
|
+
shell_exe,
|
|
152
|
+
stdin=asyncio.subprocess.PIPE,
|
|
153
|
+
stdout=asyncio.subprocess.PIPE,
|
|
154
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
155
|
+
cwd=workspace_path,
|
|
156
|
+
env=env,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# --- Relay: stdout → WebSocket ---
|
|
160
|
+
async def _stdout_relay() -> None:
|
|
161
|
+
assert process is not None
|
|
162
|
+
assert process.stdout is not None
|
|
163
|
+
try:
|
|
164
|
+
while True:
|
|
165
|
+
chunk = await process.stdout.read(4096)
|
|
166
|
+
if not chunk:
|
|
167
|
+
break
|
|
168
|
+
try:
|
|
169
|
+
await websocket.send_bytes(chunk)
|
|
170
|
+
except Exception:
|
|
171
|
+
break
|
|
172
|
+
except asyncio.CancelledError:
|
|
173
|
+
pass
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
logger.debug("Terminal stdout relay error: %s", exc)
|
|
176
|
+
|
|
177
|
+
# --- Relay: WebSocket → stdin (handles both text and binary frames) ---
|
|
178
|
+
async def _stdin_relay() -> None:
|
|
179
|
+
assert process is not None
|
|
180
|
+
assert process.stdin is not None
|
|
181
|
+
try:
|
|
182
|
+
while True:
|
|
183
|
+
try:
|
|
184
|
+
msg = await websocket.receive()
|
|
185
|
+
except WebSocketDisconnect:
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
if "text" in msg:
|
|
189
|
+
raw_text: str = msg["text"]
|
|
190
|
+
if len(raw_text) > 65536:
|
|
191
|
+
logger.warning("session_id=%s: dropping oversized text frame (%d bytes)", session_id, len(raw_text))
|
|
192
|
+
continue
|
|
193
|
+
try:
|
|
194
|
+
parsed = json.loads(raw_text)
|
|
195
|
+
if isinstance(parsed, dict) and parsed.get("type") == "resize":
|
|
196
|
+
# Resize: nothing to do without a PTY
|
|
197
|
+
continue
|
|
198
|
+
except json.JSONDecodeError:
|
|
199
|
+
pass
|
|
200
|
+
process.stdin.write(raw_text.encode())
|
|
201
|
+
await process.stdin.drain()
|
|
202
|
+
elif "bytes" in msg:
|
|
203
|
+
raw_bytes: bytes = msg["bytes"]
|
|
204
|
+
if len(raw_bytes) > 65536:
|
|
205
|
+
logger.warning("session_id=%s: dropping oversized binary frame (%d bytes)", session_id, len(raw_bytes))
|
|
206
|
+
continue
|
|
207
|
+
try:
|
|
208
|
+
parsed = json.loads(raw_bytes)
|
|
209
|
+
if isinstance(parsed, dict) and parsed.get("type") == "resize":
|
|
210
|
+
continue
|
|
211
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
212
|
+
pass
|
|
213
|
+
process.stdin.write(raw_bytes)
|
|
214
|
+
await process.stdin.drain()
|
|
215
|
+
|
|
216
|
+
except WebSocketDisconnect:
|
|
217
|
+
raise
|
|
218
|
+
except asyncio.CancelledError:
|
|
219
|
+
pass
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
logger.debug("Terminal stdin relay error: %s", exc)
|
|
222
|
+
|
|
223
|
+
stdout_to_ws_task = asyncio.create_task(_stdout_relay())
|
|
224
|
+
ws_to_stdin_task = asyncio.create_task(_stdin_relay())
|
|
225
|
+
|
|
226
|
+
# Wait for either task to finish (disconnect or process exit)
|
|
227
|
+
await asyncio.wait(
|
|
228
|
+
[stdout_to_ws_task, ws_to_stdin_task],
|
|
229
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
except WebSocketDisconnect:
|
|
233
|
+
logger.debug("Terminal WebSocket disconnected: session_id=%s", session_id)
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
logger.error("Terminal WebSocket error: %s", exc, exc_info=True)
|
|
236
|
+
finally:
|
|
237
|
+
# Cancel relay tasks
|
|
238
|
+
for task in [ws_to_stdin_task, stdout_to_ws_task]:
|
|
239
|
+
if task and not task.done():
|
|
240
|
+
task.cancel()
|
|
241
|
+
try:
|
|
242
|
+
await task
|
|
243
|
+
except (asyncio.CancelledError, Exception):
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
# Terminate subprocess
|
|
247
|
+
if process is not None:
|
|
248
|
+
try:
|
|
249
|
+
process.terminate()
|
|
250
|
+
await asyncio.wait_for(process.wait(), timeout=3.0)
|
|
251
|
+
except (ProcessLookupError, asyncio.TimeoutError):
|
|
252
|
+
try:
|
|
253
|
+
process.kill()
|
|
254
|
+
except ProcessLookupError:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
# Release the per-user connection slot
|
|
258
|
+
count = _user_terminal_counts.get(user_id, 0)
|
|
259
|
+
if count > 1:
|
|
260
|
+
_user_terminal_counts[user_id] = count - 1
|
|
261
|
+
else:
|
|
262
|
+
_user_terminal_counts.pop(user_id, None)
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
await websocket.close()
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|