codex-autorunner 0.1.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 (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,552 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import logging
5
+ import time
6
+ import uuid
7
+ from urllib.parse import parse_qs, urlparse
8
+
9
+ from fastapi.responses import RedirectResponse, Response
10
+
11
+ from ..core.config import _normalize_base_path
12
+ from ..core.logging_utils import log_event
13
+ from ..core.request_context import reset_request_id, set_request_id
14
+ from .static_assets import security_headers
15
+
16
+
17
+ class BasePathRouterMiddleware:
18
+ """
19
+ Middleware that keeps the app mounted at / while enforcing a canonical base path.
20
+ - Requests that already include the base path are routed via root_path so routing stays rooted at /.
21
+ - Requests missing the base path but pointing at known CAR prefixes are redirected to the
22
+ canonical location (HTTP 308). WebSocket handshakes get the same redirect response.
23
+ """
24
+
25
+ def __init__(self, app, base_path: str, known_prefixes=None):
26
+ self.app = app
27
+ self.base_path = _normalize_base_path(base_path)
28
+ self.base_path_bytes = self.base_path.encode("utf-8")
29
+ self.known_prefixes = tuple(
30
+ known_prefixes
31
+ or (
32
+ "/",
33
+ "/api",
34
+ "/hub",
35
+ "/repos",
36
+ "/static",
37
+ "/health",
38
+ "/cat",
39
+ )
40
+ )
41
+
42
+ def __getattr__(self, name):
43
+ return getattr(self.app, name)
44
+
45
+ def _has_base(self, path: str, root_path: str) -> bool:
46
+ if not self.base_path:
47
+ return True
48
+ full_path = f"{root_path}{path}" if root_path else path
49
+ if full_path == self.base_path or full_path.startswith(f"{self.base_path}/"):
50
+ return True
51
+ return path == self.base_path or path.startswith(f"{self.base_path}/")
52
+
53
+ def _should_redirect(self, path: str, root_path: str) -> bool:
54
+ if not self.base_path:
55
+ return False
56
+ if self._has_base(path, root_path):
57
+ return False
58
+ return any(
59
+ path == prefix
60
+ or path.startswith(f"{prefix}/")
61
+ or (root_path and root_path.startswith(prefix))
62
+ for prefix in self.known_prefixes
63
+ )
64
+
65
+ async def _redirect(self, scope, receive, send, target: str):
66
+ if scope["type"] == "websocket":
67
+ headers = [(b"location", target.encode("utf-8"))]
68
+ await send(
69
+ {
70
+ "type": "websocket.http.response.start",
71
+ "status": 308,
72
+ "headers": headers,
73
+ }
74
+ )
75
+ await send(
76
+ {
77
+ "type": "websocket.http.response.body",
78
+ "body": b"",
79
+ "more_body": False,
80
+ }
81
+ )
82
+ return
83
+ response = RedirectResponse(target, status_code=308)
84
+ await response(scope, receive, send)
85
+
86
+ async def __call__(self, scope, receive, send):
87
+ scope_type = scope.get("type")
88
+ if scope_type not in ("http", "websocket"):
89
+ return await self.app(scope, receive, send)
90
+
91
+ path = scope.get("path") or "/"
92
+ root_path = scope.get("root_path") or ""
93
+
94
+ if not self.base_path:
95
+ return await self.app(scope, receive, send)
96
+
97
+ if self._has_base(path, root_path):
98
+ scope = dict(scope)
99
+ # Preserve the base path for downstream routing + URL generation.
100
+ if not root_path:
101
+ scope["root_path"] = self.base_path
102
+ root_path = self.base_path
103
+
104
+ # Starlette expects scope["path"] to include scope["root_path"] for
105
+ # mounted sub-apps (including /repos/* and /static/*). If we detect
106
+ # an already-stripped path (e.g., behind a proxy), re-prefix it.
107
+ if root_path and not path.startswith(root_path):
108
+ if path == "/":
109
+ scope["path"] = root_path
110
+ else:
111
+ scope["path"] = f"{root_path}{path}"
112
+ raw_path = scope.get("raw_path")
113
+ if raw_path and not raw_path.startswith(self.base_path_bytes):
114
+ if raw_path == b"/":
115
+ scope["raw_path"] = self.base_path_bytes
116
+ else:
117
+ scope["raw_path"] = self.base_path_bytes + raw_path
118
+ return await self.app(scope, receive, send)
119
+
120
+ if self._should_redirect(path, root_path):
121
+ target_path = f"{self.base_path}{path}"
122
+ query_string = scope.get("query_string") or b""
123
+ if query_string:
124
+ target_path = f"{target_path}?{query_string.decode('latin-1')}"
125
+ if not target_path:
126
+ target_path = "/"
127
+ return await self._redirect(scope, receive, send, target_path)
128
+
129
+ return await self.app(scope, receive, send)
130
+
131
+
132
+ class AuthTokenMiddleware:
133
+ """Middleware that enforces an auth token on all non-public endpoints."""
134
+
135
+ def __init__(self, app, token: str, base_path: str = ""):
136
+ self.app = app
137
+ self.token = token
138
+ self.base_path = _normalize_base_path(base_path)
139
+ self.public_prefixes = ("/static", "/health", "/cat")
140
+
141
+ def __getattr__(self, name):
142
+ return getattr(self.app, name)
143
+
144
+ def _full_path(self, scope) -> str:
145
+ path = scope.get("path") or "/"
146
+ root_path = scope.get("root_path") or ""
147
+ if root_path and path.startswith(root_path):
148
+ return path
149
+ if root_path:
150
+ return f"{root_path}{path}"
151
+ return path
152
+
153
+ def _strip_base_path(self, path: str) -> str:
154
+ if self.base_path and path.startswith(self.base_path):
155
+ stripped = path[len(self.base_path) :]
156
+ return stripped or "/"
157
+ return path
158
+
159
+ def _strip_repo_mount(self, path: str) -> str:
160
+ if not path.startswith("/repos/"):
161
+ return path
162
+ parts = path.split("/", 3)
163
+ if len(parts) < 4:
164
+ return path
165
+ if not parts[3]:
166
+ return path
167
+ remainder = f"/{parts[3]}"
168
+ return remainder or "/"
169
+
170
+ def _is_public_path(self, path: str) -> bool:
171
+ if path == "/":
172
+ return True
173
+ for prefix in self.public_prefixes:
174
+ if path == prefix or path.startswith(f"{prefix}/"):
175
+ return True
176
+ return False
177
+
178
+ def _requires_auth(self, scope) -> bool:
179
+ scope_type = scope.get("type")
180
+ if scope_type not in ("http", "websocket"):
181
+ return False
182
+ full_path = self._strip_base_path(self._full_path(scope))
183
+ repo_path = self._strip_repo_mount(full_path)
184
+ return not self._is_public_path(repo_path)
185
+
186
+ def _extract_header_token(self, scope) -> str | None:
187
+ headers = {k.lower(): v for k, v in (scope.get("headers") or [])}
188
+ raw = headers.get(b"authorization")
189
+ if not raw:
190
+ return None
191
+ try:
192
+ value = raw.decode("utf-8")
193
+ except UnicodeDecodeError:
194
+ return None
195
+ if not value.lower().startswith("bearer "):
196
+ return None
197
+ return value.split(" ", 1)[1].strip() or None
198
+
199
+ def _extract_query_token(self, scope) -> str | None:
200
+ query_string = scope.get("query_string") or b""
201
+ if not query_string:
202
+ return None
203
+ parsed = parse_qs(query_string.decode("latin-1"))
204
+ token_values = parsed.get("token") or []
205
+ return token_values[0] if token_values else None
206
+
207
+ def _extract_ws_protocol_token(self, scope) -> str | None:
208
+ if scope.get("type") != "websocket":
209
+ return None
210
+ headers = {k.lower(): v for k, v in (scope.get("headers") or [])}
211
+ raw = headers.get(b"sec-websocket-protocol")
212
+ if not raw:
213
+ return None
214
+ try:
215
+ value = raw.decode("latin-1")
216
+ except UnicodeDecodeError:
217
+ return None
218
+ for entry in value.split(","):
219
+ candidate = entry.strip()
220
+ if candidate.startswith("car-token-b64."):
221
+ token = candidate[len("car-token-b64.") :].strip()
222
+ if not token:
223
+ continue
224
+ padding = "=" * (-len(token) % 4)
225
+ try:
226
+ decoded = base64.urlsafe_b64decode(f"{token}{padding}")
227
+ except Exception:
228
+ continue
229
+ try:
230
+ return decoded.decode("utf-8").strip() or None
231
+ except UnicodeDecodeError:
232
+ continue
233
+ if candidate.startswith("car-token."):
234
+ token = candidate[len("car-token.") :].strip()
235
+ if token:
236
+ return token
237
+ return None
238
+
239
+ async def _reject_http(self, scope, receive, send) -> None:
240
+ response = Response(
241
+ content="Unauthorized",
242
+ status_code=401,
243
+ headers={"WWW-Authenticate": "Bearer"},
244
+ )
245
+ await response(scope, receive, send)
246
+
247
+ async def _reject_ws(self, scope, receive, send) -> None:
248
+ await send({"type": "websocket.close", "code": 1008})
249
+
250
+ async def __call__(self, scope, receive, send):
251
+ if not self._requires_auth(scope):
252
+ return await self.app(scope, receive, send)
253
+
254
+ token = self._extract_header_token(scope)
255
+ if scope.get("type") == "websocket" and token is None:
256
+ token = self._extract_ws_protocol_token(scope) or self._extract_query_token(
257
+ scope
258
+ )
259
+
260
+ if token != self.token:
261
+ if scope.get("type") == "websocket":
262
+ return await self._reject_ws(scope, receive, send)
263
+ return await self._reject_http(scope, receive, send)
264
+
265
+ return await self.app(scope, receive, send)
266
+
267
+
268
+ class HostOriginMiddleware:
269
+ """Validate Host and Origin headers for localhost hardening."""
270
+
271
+ def __init__(self, app, allowed_hosts, allowed_origins):
272
+ self.app = app
273
+ self.allowed_hosts = [
274
+ entry.strip().lower()
275
+ for entry in (allowed_hosts or [])
276
+ if isinstance(entry, str) and entry.strip()
277
+ ]
278
+ self.allowed_origins = {
279
+ entry
280
+ for entry in (
281
+ self._normalize_origin(raw)
282
+ for raw in (allowed_origins or [])
283
+ if isinstance(raw, str) and raw.strip()
284
+ )
285
+ if entry is not None
286
+ }
287
+
288
+ def __getattr__(self, name):
289
+ return getattr(self.app, name)
290
+
291
+ def _header(self, scope, key: bytes) -> str | None:
292
+ headers = {k.lower(): v for k, v in (scope.get("headers") or [])}
293
+ raw = headers.get(key)
294
+ if not raw:
295
+ return None
296
+ try:
297
+ return raw.decode("latin-1")
298
+ except Exception:
299
+ return None
300
+
301
+ def _split_host_port(self, value: str) -> tuple[str, str | None]:
302
+ value = value.strip().lower()
303
+ if not value:
304
+ return "", None
305
+ if value.startswith("["):
306
+ end = value.find("]")
307
+ if end != -1:
308
+ host = value[1:end]
309
+ rest = value[end + 1 :]
310
+ if rest.startswith(":") and len(rest) > 1:
311
+ return host, rest[1:]
312
+ return host, None
313
+ if value.count(":") == 1:
314
+ host, port = value.rsplit(":", 1)
315
+ if host and port:
316
+ return host, port
317
+ return value, None
318
+
319
+ def _host_allowed(self, host_header: str | None) -> bool:
320
+ if not self.allowed_hosts:
321
+ return True
322
+ if not host_header:
323
+ return False
324
+ header_host, header_port = self._split_host_port(host_header)
325
+ for allowed in self.allowed_hosts:
326
+ if allowed == "*":
327
+ return True
328
+ allowed_host, allowed_port = self._split_host_port(allowed)
329
+ if allowed_host != header_host:
330
+ continue
331
+ if allowed_port is None or allowed_port == header_port:
332
+ return True
333
+ return False
334
+
335
+ def _normalize_origin(self, origin: str) -> str | None:
336
+ value = origin.strip().lower()
337
+ if not value:
338
+ return None
339
+ if value == "null":
340
+ return value
341
+ parsed = urlparse(value)
342
+ if parsed.scheme and parsed.netloc:
343
+ return f"{parsed.scheme}://{parsed.netloc}"
344
+ return value
345
+
346
+ def _origin_scheme(self, scheme: str) -> str:
347
+ if scheme == "ws":
348
+ return "http"
349
+ if scheme == "wss":
350
+ return "https"
351
+ return scheme
352
+
353
+ def _request_origin(self, scheme: str, host_header: str | None) -> str | None:
354
+ if not host_header:
355
+ return None
356
+ normalized_scheme = self._origin_scheme(scheme).lower()
357
+ return f"{normalized_scheme}://{host_header.strip().lower()}"
358
+
359
+ def _origin_allowed(
360
+ self, origin: str | None, scheme: str, host: str | None
361
+ ) -> bool:
362
+ if not origin:
363
+ return True
364
+ normalized = self._normalize_origin(origin)
365
+ if not normalized:
366
+ return False
367
+ if normalized in self.allowed_origins:
368
+ return True
369
+ request_origin = self._request_origin(scheme, host)
370
+ return request_origin == normalized
371
+
372
+ async def _reject_http(self, scope, receive, send, status: int, body: str) -> None:
373
+ response = Response(content=body, status_code=status)
374
+ await response(scope, receive, send)
375
+
376
+ async def _reject_ws(self, send, status: int, body: str) -> None:
377
+ await send(
378
+ {
379
+ "type": "websocket.http.response.start",
380
+ "status": status,
381
+ "headers": [(b"content-type", b"text/plain; charset=utf-8")],
382
+ }
383
+ )
384
+ await send(
385
+ {
386
+ "type": "websocket.http.response.body",
387
+ "body": body.encode("utf-8"),
388
+ "more_body": False,
389
+ }
390
+ )
391
+
392
+ async def __call__(self, scope, receive, send):
393
+ scope_type = scope.get("type")
394
+ if scope_type not in ("http", "websocket"):
395
+ return await self.app(scope, receive, send)
396
+
397
+ host = self._header(scope, b"host")
398
+ if not self._host_allowed(host):
399
+ if scope_type == "websocket":
400
+ return await self._reject_ws(send, 400, "Invalid host")
401
+ return await self._reject_http(scope, receive, send, 400, "Invalid host")
402
+
403
+ origin = self._header(scope, b"origin")
404
+ scheme = scope.get("scheme") or "http"
405
+ if scope_type == "websocket":
406
+ if origin and not self._origin_allowed(origin, scheme, host):
407
+ return await self._reject_ws(send, 403, "Forbidden")
408
+ return await self.app(scope, receive, send)
409
+
410
+ method = (scope.get("method") or "GET").upper()
411
+ if method in {"POST", "PUT", "PATCH", "DELETE"} and origin:
412
+ if not self._origin_allowed(origin, scheme, host):
413
+ return await self._reject_http(scope, receive, send, 403, "Forbidden")
414
+
415
+ return await self.app(scope, receive, send)
416
+
417
+
418
+ class SecurityHeadersMiddleware:
419
+ """Attach security headers to HTML responses."""
420
+
421
+ def __init__(self, app):
422
+ self.app = app
423
+ self.headers = security_headers()
424
+
425
+ def __getattr__(self, name):
426
+ return getattr(self.app, name)
427
+
428
+ async def __call__(self, scope, receive, send):
429
+ if scope.get("type") != "http":
430
+ return await self.app(scope, receive, send)
431
+
432
+ async def send_wrapper(message):
433
+ if message.get("type") == "http.response.start":
434
+ headers = list(message.get("headers") or [])
435
+ existing = {name.lower() for name, _ in headers}
436
+ content_type = None
437
+ for name, value in headers:
438
+ if name.lower() == b"content-type":
439
+ try:
440
+ content_type = value.decode("latin-1").lower()
441
+ except Exception:
442
+ content_type = None
443
+ break
444
+ if content_type and content_type.startswith("text/html"):
445
+ for name, value in self.headers.items():
446
+ key = name.lower().encode("latin-1")
447
+ if key in existing:
448
+ continue
449
+ headers.append(
450
+ (name.encode("latin-1"), value.encode("latin-1"))
451
+ )
452
+ message["headers"] = headers
453
+ await send(message)
454
+
455
+ return await self.app(scope, receive, send_wrapper)
456
+
457
+
458
+ class RequestIdMiddleware:
459
+ """Attach request ids and emit structured request logs."""
460
+
461
+ def __init__(self, app, header_name: str = "x-request-id"):
462
+ self.app = app
463
+ self.header_name = header_name.lower()
464
+ self.header_bytes = self.header_name.encode("latin-1")
465
+
466
+ def __getattr__(self, name):
467
+ return getattr(self.app, name)
468
+
469
+ def _extract_request_id(self, scope) -> str:
470
+ for name, value in scope.get("headers") or []:
471
+ if name.lower() == self.header_bytes:
472
+ try:
473
+ candidate = value.decode("utf-8").strip()
474
+ except Exception:
475
+ candidate = ""
476
+ if candidate:
477
+ return candidate
478
+ return uuid.uuid4().hex
479
+
480
+ def _get_logger(self, scope) -> logging.Logger:
481
+ app = scope.get("app")
482
+ state = getattr(app, "state", None) if app else None
483
+ logger = getattr(state, "logger", None)
484
+ if isinstance(logger, logging.Logger):
485
+ return logger
486
+ return logging.getLogger("codex_autorunner.web")
487
+
488
+ async def __call__(self, scope, receive, send):
489
+ scope_type = scope.get("type")
490
+ if scope_type != "http":
491
+ return await self.app(scope, receive, send)
492
+
493
+ request_id = self._extract_request_id(scope)
494
+ token = set_request_id(request_id)
495
+ logger = self._get_logger(scope)
496
+ method = scope.get("method") or "GET"
497
+ path = scope.get("path") or "/"
498
+ client = scope.get("client")
499
+ client_addr = None
500
+ if client and len(client) >= 2:
501
+ client_addr = f"{client[0]}:{client[1]}"
502
+ start = time.monotonic()
503
+ status_code = None
504
+
505
+ log_event(
506
+ logger,
507
+ logging.INFO,
508
+ "http.request",
509
+ method=method,
510
+ path=path,
511
+ client=client_addr,
512
+ )
513
+
514
+ async def send_wrapper(message):
515
+ nonlocal status_code
516
+ if message.get("type") == "http.response.start":
517
+ status_code = message.get("status")
518
+ headers = list(message.get("headers") or [])
519
+ existing = {name.lower() for name, _ in headers}
520
+ if self.header_bytes not in existing:
521
+ headers.append((self.header_bytes, request_id.encode("latin-1")))
522
+ message["headers"] = headers
523
+ await send(message)
524
+
525
+ try:
526
+ await self.app(scope, receive, send_wrapper)
527
+ except Exception as exc:
528
+ duration_ms = (time.monotonic() - start) * 1000
529
+ log_event(
530
+ logger,
531
+ logging.ERROR,
532
+ "http.response",
533
+ method=method,
534
+ path=path,
535
+ status=status_code or 500,
536
+ duration_ms=round(duration_ms, 2),
537
+ exc=exc,
538
+ )
539
+ raise
540
+ else:
541
+ duration_ms = (time.monotonic() - start) * 1000
542
+ log_event(
543
+ logger,
544
+ logging.INFO,
545
+ "http.response",
546
+ method=method,
547
+ path=path,
548
+ status=status_code or 200,
549
+ duration_ms=round(duration_ms, 2),
550
+ )
551
+ finally:
552
+ reset_request_id(token)