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,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