codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -1,587 +1,3 @@
1
- from __future__ import annotations
1
+ """Backward-compatible middleware exports."""
2
2
 
3
- import base64
4
- import binascii
5
- import hmac
6
- import logging
7
- import time
8
- import uuid
9
- from urllib.parse import parse_qs, urlparse
10
-
11
- from fastapi.responses import RedirectResponse, Response
12
-
13
- from ..core.config import _normalize_base_path
14
- from ..core.logging_utils import log_event
15
- from ..core.request_context import reset_request_id, set_request_id
16
- from .static_assets import security_headers
17
-
18
- logger = logging.getLogger("codex_autorunner.web.middleware")
19
-
20
-
21
- class BasePathRouterMiddleware:
22
- """
23
- Middleware that keeps the app mounted at / while enforcing a canonical base path.
24
- - Requests that already include the base path are routed via root_path so routing stays rooted at /.
25
- - Requests missing the base path but pointing at known CAR prefixes are redirected to the
26
- canonical location (HTTP 308). WebSocket handshakes get the same redirect response.
27
- """
28
-
29
- def __init__(self, app, base_path: str, known_prefixes=None):
30
- self.app = app
31
- self.base_path = _normalize_base_path(base_path)
32
- self.base_path_bytes = self.base_path.encode("utf-8")
33
- self.known_prefixes = tuple(
34
- known_prefixes
35
- or (
36
- "/",
37
- "/api",
38
- "/hub",
39
- "/repos",
40
- "/static",
41
- "/health",
42
- "/cat",
43
- )
44
- )
45
-
46
- def __getattr__(self, name):
47
- return getattr(self.app, name)
48
-
49
- def _has_base(self, path: str, root_path: str) -> bool:
50
- if not self.base_path:
51
- return True
52
- full_path = f"{root_path}{path}" if root_path else path
53
- if full_path == self.base_path or full_path.startswith(f"{self.base_path}/"):
54
- return True
55
- return path == self.base_path or path.startswith(f"{self.base_path}/")
56
-
57
- def _should_redirect(self, path: str, root_path: str) -> bool:
58
- if not self.base_path:
59
- return False
60
- if self._has_base(path, root_path):
61
- return False
62
- return any(
63
- path == prefix
64
- or path.startswith(f"{prefix}/")
65
- or (root_path and root_path.startswith(prefix))
66
- for prefix in self.known_prefixes
67
- )
68
-
69
- async def _redirect(self, scope, receive, send, target: str):
70
- if scope["type"] == "websocket":
71
- headers = [(b"location", target.encode("utf-8"))]
72
- await send(
73
- {
74
- "type": "websocket.http.response.start",
75
- "status": 308,
76
- "headers": headers,
77
- }
78
- )
79
- await send(
80
- {
81
- "type": "websocket.http.response.body",
82
- "body": b"",
83
- "more_body": False,
84
- }
85
- )
86
- return
87
- response = RedirectResponse(target, status_code=308)
88
- await response(scope, receive, send)
89
-
90
- async def __call__(self, scope, receive, send):
91
- scope_type = scope.get("type")
92
- if scope_type not in ("http", "websocket"):
93
- return await self.app(scope, receive, send)
94
-
95
- path = scope.get("path") or "/"
96
- root_path = scope.get("root_path") or ""
97
-
98
- if not self.base_path:
99
- return await self.app(scope, receive, send)
100
-
101
- if self._has_base(path, root_path):
102
- scope = dict(scope)
103
- # Preserve the base path for downstream routing + URL generation.
104
- if not root_path:
105
- scope["root_path"] = self.base_path
106
- root_path = self.base_path
107
-
108
- # Starlette expects scope["path"] to include scope["root_path"] for
109
- # mounted sub-apps (including /repos/* and /static/*). If we detect
110
- # an already-stripped path (e.g., behind a proxy), re-prefix it.
111
- if root_path and not path.startswith(root_path):
112
- if path == "/":
113
- scope["path"] = root_path
114
- else:
115
- scope["path"] = f"{root_path}{path}"
116
- raw_path = scope.get("raw_path")
117
- if raw_path and not raw_path.startswith(self.base_path_bytes):
118
- if raw_path == b"/":
119
- scope["raw_path"] = self.base_path_bytes
120
- else:
121
- scope["raw_path"] = self.base_path_bytes + raw_path
122
- return await self.app(scope, receive, send)
123
-
124
- if self._should_redirect(path, root_path):
125
- target_path = f"{self.base_path}{path}"
126
- query_string = scope.get("query_string") or b""
127
- if query_string:
128
- target_path = f"{target_path}?{query_string.decode('latin-1')}"
129
- if not target_path:
130
- target_path = "/"
131
- return await self._redirect(scope, receive, send, target_path)
132
-
133
- return await self.app(scope, receive, send)
134
-
135
-
136
- class AuthTokenMiddleware:
137
- """Middleware that enforces an auth token on all non-public endpoints."""
138
-
139
- def __init__(self, app, token: str, base_path: str = ""):
140
- self.app = app
141
- self.token = token
142
- self.base_path = _normalize_base_path(base_path)
143
- self.public_prefixes = ("/static", "/health", "/cat")
144
-
145
- def __getattr__(self, name):
146
- return getattr(self.app, name)
147
-
148
- def _full_path(self, scope) -> str:
149
- path = scope.get("path") or "/"
150
- root_path = scope.get("root_path") or ""
151
- if root_path and path.startswith(root_path):
152
- return path
153
- if root_path:
154
- return f"{root_path}{path}"
155
- return path
156
-
157
- def _strip_base_path(self, path: str) -> str:
158
- if self.base_path and path.startswith(self.base_path):
159
- stripped = path[len(self.base_path) :]
160
- return stripped or "/"
161
- return path
162
-
163
- def _strip_repo_mount(self, path: str) -> str:
164
- if not path.startswith("/repos/"):
165
- return path
166
- parts = path.split("/", 3)
167
- if len(parts) < 4:
168
- return path
169
- if not parts[3]:
170
- return path
171
- remainder = f"/{parts[3]}"
172
- return remainder or "/"
173
-
174
- def _is_public_path(self, path: str) -> bool:
175
- if path == "/":
176
- return True
177
- for prefix in self.public_prefixes:
178
- if path == prefix or path.startswith(f"{prefix}/"):
179
- return True
180
- return False
181
-
182
- def _requires_auth(self, scope) -> bool:
183
- scope_type = scope.get("type")
184
- if scope_type not in ("http", "websocket"):
185
- return False
186
- full_path = self._strip_base_path(self._full_path(scope))
187
- repo_path = self._strip_repo_mount(full_path)
188
- return not self._is_public_path(repo_path)
189
-
190
- def _extract_header_token(self, scope) -> str | None:
191
- headers = {k.lower(): v for k, v in (scope.get("headers") or [])}
192
- raw = headers.get(b"authorization")
193
- if not raw:
194
- return None
195
- try:
196
- value = raw.decode("utf-8")
197
- except UnicodeDecodeError:
198
- return None
199
- if not value.lower().startswith("bearer "):
200
- return None
201
- return value.split(" ", 1)[1].strip() or None
202
-
203
- def _extract_query_token(self, scope) -> str | None:
204
- query_string = scope.get("query_string") or b""
205
- if not query_string:
206
- return None
207
- parsed = parse_qs(query_string.decode("latin-1"))
208
- token_values = parsed.get("token") or []
209
- return token_values[0] if token_values else None
210
-
211
- def _extract_ws_protocol_token(self, scope) -> str | None:
212
- if scope.get("type") != "websocket":
213
- return None
214
- headers = {k.lower(): v for k, v in (scope.get("headers") or [])}
215
- raw = headers.get(b"sec-websocket-protocol")
216
- if not raw:
217
- return None
218
- try:
219
- value = raw.decode("latin-1")
220
- except UnicodeDecodeError:
221
- return None
222
- for entry in value.split(","):
223
- candidate = entry.strip()
224
- if candidate.startswith("car-token-b64."):
225
- token = candidate[len("car-token-b64.") :].strip()
226
- if not token:
227
- continue
228
- padding = "=" * (-len(token) % 4)
229
- try:
230
- decoded = base64.urlsafe_b64decode(f"{token}{padding}")
231
- except (binascii.Error, ValueError):
232
- logger.debug("Failed to decode base64 token")
233
- continue
234
- try:
235
- return decoded.decode("utf-8").strip() or None
236
- except UnicodeDecodeError:
237
- continue
238
- if candidate.startswith("car-token."):
239
- token = candidate[len("car-token.") :].strip()
240
- if token:
241
- return token
242
- return None
243
-
244
- async def _reject_http(self, scope, receive, send) -> None:
245
- response = Response(
246
- content="Unauthorized",
247
- status_code=401,
248
- headers={"WWW-Authenticate": "Bearer"},
249
- )
250
- await response(scope, receive, send)
251
-
252
- async def _reject_ws(self, scope, receive, send) -> None:
253
- await send({"type": "websocket.close", "code": 1008})
254
-
255
- async def __call__(self, scope, receive, send):
256
- if not self._requires_auth(scope):
257
- return await self.app(scope, receive, send)
258
-
259
- token = self._extract_header_token(scope)
260
- if token is None:
261
- if scope.get("type") == "websocket":
262
- token = self._extract_ws_protocol_token(scope)
263
- token = token or self._extract_query_token(scope)
264
-
265
- if not token or not hmac.compare_digest(token, self.token):
266
- if scope.get("type") == "websocket":
267
- return await self._reject_ws(scope, receive, send)
268
- return await self._reject_http(scope, receive, send)
269
-
270
- return await self.app(scope, receive, send)
271
-
272
-
273
- class HostOriginMiddleware:
274
- """Validate Host and Origin headers for localhost hardening."""
275
-
276
- def __init__(self, app, allowed_hosts, allowed_origins):
277
- self.app = app
278
- self.allowed_hosts = [
279
- entry.strip().lower()
280
- for entry in (allowed_hosts or [])
281
- if isinstance(entry, str) and entry.strip()
282
- ]
283
- self.allowed_origins = {
284
- entry
285
- for entry in (
286
- self._normalize_origin(raw)
287
- for raw in (allowed_origins or [])
288
- if isinstance(raw, str) and raw.strip()
289
- )
290
- if entry is not None
291
- }
292
-
293
- def __getattr__(self, name):
294
- return getattr(self.app, name)
295
-
296
- def _header(self, scope, key: bytes) -> str | None:
297
- headers = {k.lower(): v for k, v in (scope.get("headers") or [])}
298
- raw = headers.get(key)
299
- if not raw:
300
- return None
301
- try:
302
- return raw.decode("latin-1")
303
- except Exception:
304
- return None
305
-
306
- def _split_host_port(self, value: str) -> tuple[str, str | None]:
307
- value = value.strip().lower()
308
- if not value:
309
- return "", None
310
- if value.startswith("["):
311
- end = value.find("]")
312
- if end != -1:
313
- host = value[1:end]
314
- rest = value[end + 1 :]
315
- if rest.startswith(":") and len(rest) > 1:
316
- return host, rest[1:]
317
- return host, None
318
- if value.count(":") == 1:
319
- host, port = value.rsplit(":", 1)
320
- if host and port:
321
- return host, port
322
- return value, None
323
-
324
- def _host_allowed(self, host_header: str | None) -> bool:
325
- if not self.allowed_hosts:
326
- return True
327
- if not host_header:
328
- return False
329
- header_host, header_port = self._split_host_port(host_header)
330
- for allowed in self.allowed_hosts:
331
- if allowed == "*":
332
- return True
333
- allowed_host, allowed_port = self._split_host_port(allowed)
334
- if allowed_host != header_host:
335
- continue
336
- if allowed_port is None or allowed_port == header_port:
337
- return True
338
- return False
339
-
340
- def _normalize_origin(self, origin: str) -> str | None:
341
- value = origin.strip().lower()
342
- if not value:
343
- return None
344
- if value == "null":
345
- return value
346
- parsed = urlparse(value)
347
- if parsed.scheme and parsed.netloc:
348
- return f"{parsed.scheme}://{parsed.netloc}"
349
- return value
350
-
351
- def _origin_scheme(self, scheme: str) -> str:
352
- if scheme == "ws":
353
- return "http"
354
- if scheme == "wss":
355
- return "https"
356
- return scheme
357
-
358
- def _request_origin(self, scheme: str, host_header: str | None) -> str | None:
359
- if not host_header:
360
- return None
361
- normalized_scheme = self._origin_scheme(scheme).lower()
362
- return f"{normalized_scheme}://{host_header.strip().lower()}"
363
-
364
- def _origin_allowed(
365
- self, origin: str | None, scheme: str, host: str | None
366
- ) -> bool:
367
- if not origin:
368
- return True
369
- normalized = self._normalize_origin(origin)
370
- if not normalized:
371
- return False
372
- if normalized in self.allowed_origins:
373
- return True
374
- request_origin = self._request_origin(scheme, host)
375
- return request_origin == normalized
376
-
377
- async def _reject_http(self, scope, receive, send, status: int, body: str) -> None:
378
- response = Response(content=body, status_code=status)
379
- await response(scope, receive, send)
380
-
381
- async def _reject_ws(self, send, status: int, body: str) -> None:
382
- await send(
383
- {
384
- "type": "websocket.http.response.start",
385
- "status": status,
386
- "headers": [(b"content-type", b"text/plain; charset=utf-8")],
387
- }
388
- )
389
- await send(
390
- {
391
- "type": "websocket.http.response.body",
392
- "body": body.encode("utf-8"),
393
- "more_body": False,
394
- }
395
- )
396
-
397
- async def __call__(self, scope, receive, send):
398
- scope_type = scope.get("type")
399
- if scope_type not in ("http", "websocket"):
400
- return await self.app(scope, receive, send)
401
-
402
- host = self._header(scope, b"host")
403
- if not self._host_allowed(host):
404
- if scope_type == "websocket":
405
- return await self._reject_ws(send, 400, "Invalid host")
406
- return await self._reject_http(scope, receive, send, 400, "Invalid host")
407
-
408
- origin = self._header(scope, b"origin")
409
- scheme = scope.get("scheme") or "http"
410
- if scope_type == "websocket":
411
- if origin and not self._origin_allowed(origin, scheme, host):
412
- return await self._reject_ws(send, 403, "Forbidden")
413
- return await self.app(scope, receive, send)
414
-
415
- method = (scope.get("method") or "GET").upper()
416
- if method in {"POST", "PUT", "PATCH", "DELETE"} and origin:
417
- if not self._origin_allowed(origin, scheme, host):
418
- return await self._reject_http(scope, receive, send, 403, "Forbidden")
419
-
420
- return await self.app(scope, receive, send)
421
-
422
-
423
- class SecurityHeadersMiddleware:
424
- """Attach security headers to HTML responses."""
425
-
426
- def __init__(self, app):
427
- self.app = app
428
- self.headers = security_headers()
429
-
430
- def __getattr__(self, name):
431
- return getattr(self.app, name)
432
-
433
- async def __call__(self, scope, receive, send):
434
- if scope.get("type") != "http":
435
- return await self.app(scope, receive, send)
436
-
437
- async def send_wrapper(message):
438
- if message.get("type") == "http.response.start":
439
- headers = list(message.get("headers") or [])
440
- existing = {name.lower() for name, _ in headers}
441
- content_type = None
442
- for name, value in headers:
443
- if name.lower() == b"content-type":
444
- try:
445
- content_type = value.decode("latin-1").lower()
446
- except UnicodeDecodeError:
447
- logger.debug("Failed to decode content-type header")
448
- content_type = None
449
- break
450
- if content_type and content_type.startswith("text/html"):
451
- for name, value in self.headers.items():
452
- key = name.lower().encode("latin-1")
453
- if key in existing:
454
- continue
455
- headers.append(
456
- (name.encode("latin-1"), value.encode("latin-1"))
457
- )
458
- message["headers"] = headers
459
- await send(message)
460
-
461
- return await self.app(scope, receive, send_wrapper)
462
-
463
-
464
- class RequestIdMiddleware:
465
- """Attach request ids and emit structured request logs with latency and response size tracking."""
466
-
467
- def __init__(self, app, header_name: str = "x-request-id"):
468
- self.app = app
469
- self.header_name = header_name.lower()
470
- self.header_bytes = self.header_name.encode("latin-1")
471
-
472
- def __getattr__(self, name):
473
- return getattr(self.app, name)
474
-
475
- def _extract_request_id(self, scope) -> str:
476
- for name, value in scope.get("headers") or []:
477
- if name.lower() == self.header_bytes:
478
- try:
479
- candidate = value.decode("utf-8").strip()
480
- except UnicodeDecodeError:
481
- candidate = ""
482
- if candidate:
483
- return candidate
484
- return uuid.uuid4().hex
485
-
486
- def _get_logger(self, scope) -> logging.Logger:
487
- app = scope.get("app")
488
- state = getattr(app, "state", None) if app else None
489
- logger = getattr(state, "logger", None)
490
- if isinstance(logger, logging.Logger):
491
- return logger
492
- return logging.getLogger("codex_autorunner.web")
493
-
494
- def _is_heavy_endpoint(self, path: str) -> bool:
495
- """Check if endpoint should log response size (docs, runs, hub repos)."""
496
- path_lower = path.lower()
497
- heavy_prefixes = (
498
- "/api/workspace",
499
- "/api/workspace/spec/ingest",
500
- "/api/file-chat",
501
- "/api/usage",
502
- "/hub/usage",
503
- "/hub/repos",
504
- )
505
- return any(path_lower.startswith(prefix) for prefix in heavy_prefixes)
506
-
507
- async def __call__(self, scope, receive, send):
508
- scope_type = scope.get("type")
509
- if scope_type != "http":
510
- return await self.app(scope, receive, send)
511
-
512
- request_id = self._extract_request_id(scope)
513
- token = set_request_id(request_id)
514
- logger = self._get_logger(scope)
515
- method = scope.get("method") or "GET"
516
- path = scope.get("path") or "/"
517
- client = scope.get("client")
518
- client_addr = None
519
- if client and len(client) >= 2:
520
- client_addr = f"{client[0]}:{client[1]}"
521
- start = time.monotonic()
522
- status_code = None
523
- response_size = 0
524
- should_log_size = self._is_heavy_endpoint(path)
525
-
526
- log_event(
527
- logger,
528
- logging.INFO,
529
- "http.request",
530
- method=method,
531
- path=path,
532
- client=client_addr,
533
- )
534
-
535
- async def send_wrapper(message):
536
- nonlocal status_code, response_size
537
- if message.get("type") == "http.response.start":
538
- status_code = message.get("status")
539
- headers = list(message.get("headers") or [])
540
- existing = {name.lower() for name, _ in headers}
541
- if self.header_bytes not in existing:
542
- headers.append((self.header_bytes, request_id.encode("latin-1")))
543
- message["headers"] = headers
544
- elif message.get("type") == "http.response.body" and should_log_size:
545
- body = message.get("body") or b""
546
- if isinstance(body, (bytes, bytearray)):
547
- response_size += len(body)
548
- await send(message)
549
-
550
- try:
551
- await self.app(scope, receive, send_wrapper)
552
- except Exception as exc:
553
- duration_ms = (time.monotonic() - start) * 1000
554
- fields = {
555
- "method": method,
556
- "path": path,
557
- "status": status_code or 500,
558
- "duration_ms": round(duration_ms, 2),
559
- }
560
- if should_log_size:
561
- fields["response_size"] = response_size
562
- log_event(
563
- logger,
564
- logging.ERROR,
565
- "http.response",
566
- exc=exc,
567
- **fields,
568
- )
569
- raise
570
- else:
571
- duration_ms = (time.monotonic() - start) * 1000
572
- fields = {
573
- "method": method,
574
- "path": path,
575
- "status": status_code or 200,
576
- "duration_ms": round(duration_ms, 2),
577
- }
578
- if should_log_size:
579
- fields["response_size"] = response_size
580
- log_event(
581
- logger,
582
- logging.INFO,
583
- "http.response",
584
- **fields,
585
- )
586
- finally:
587
- reset_request_id(token)
3
+ from ..surfaces.web.middleware import * # noqa: F401,F403