codex-autorunner 0.1.2__py3-none-any.whl → 1.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.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +68 -35
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/logging.py +21 -5
- codex_autorunner/agents/opencode/run_prompt.py +1 -0
- codex_autorunner/agents/opencode/runtime.py +176 -47
- codex_autorunner/agents/opencode/supervisor.py +36 -48
- codex_autorunner/agents/registry.py +155 -8
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +22 -37
- codex_autorunner/cli.py +5 -1156
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +4 -0
- codex_autorunner/core/about_car.py +49 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_logging.py +7 -3
- codex_autorunner/core/app_server_prompts.py +27 -260
- codex_autorunner/core/app_server_threads.py +26 -28
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +12 -2
- codex_autorunner/core/config.py +587 -103
- codex_autorunner/core/docs.py +10 -2
- codex_autorunner/core/drafts.py +136 -0
- codex_autorunner/core/engine.py +1531 -866
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +202 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +88 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +131 -0
- codex_autorunner/core/flows/runtime.py +382 -0
- codex_autorunner/core/flows/store.py +568 -0
- codex_autorunner/core/flows/transition.py +138 -0
- codex_autorunner/core/flows/ux_helpers.py +257 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +136 -16
- codex_autorunner/core/locks.py +4 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/core/ports/agent_backend.py +150 -0
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/core/ports/run_event.py +91 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/review_context.py +5 -8
- codex_autorunner/core/run_index.py +6 -0
- codex_autorunner/core/runner_process.py +5 -2
- codex_autorunner/core/state.py +0 -88
- codex_autorunner/core/state_roots.py +57 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +201 -0
- codex_autorunner/core/ticket_manager_cli.py +432 -0
- codex_autorunner/core/update.py +24 -16
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +120 -11
- codex_autorunner/discovery.py +2 -4
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +98 -0
- codex_autorunner/integrations/agents/__init__.py +17 -0
- codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +448 -0
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +598 -0
- codex_autorunner/integrations/agents/runner.py +91 -0
- codex_autorunner/integrations/agents/wiring.py +271 -0
- codex_autorunner/integrations/app_server/client.py +583 -152
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +204 -165
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +221 -0
- codex_autorunner/integrations/telegram/constants.py +17 -2
- codex_autorunner/integrations/telegram/dispatch.py +17 -0
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
- codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
- codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +111 -16
- codex_autorunner/integrations/telegram/outbox.py +208 -37
- codex_autorunner/integrations/telegram/progress_stream.py +3 -10
- codex_autorunner/integrations/telegram/service.py +221 -42
- codex_autorunner/integrations/telegram/state.py +100 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
- codex_autorunner/integrations/telegram/transport.py +39 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +37 -67
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +3 -0
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -624
- codex_autorunner/routes/file_chat.py +7 -0
- codex_autorunner/routes/flows.py +7 -0
- codex_autorunner/routes/messages.py +7 -0
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -188
- codex_autorunner/routes/usage.py +3 -0
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +3 -0
- codex_autorunner/server.py +3 -2
- codex_autorunner/static/agentControls.js +41 -11
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +35 -24
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +36 -8
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +344 -325
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +126 -185
- codex_autorunner/static/index.html +839 -863
- codex_autorunner/static/liveUpdates.js +1 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +873 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +149 -217
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/styles.css +8850 -3876
- codex_autorunner/static/tabs.js +175 -11
- codex_autorunner/static/terminal.js +32 -0
- codex_autorunner/static/terminalManager.js +34 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +844 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1988 -0
- codex_autorunner/static/utils.js +43 -3
- codex_autorunner/static/voice.js +1 -0
- codex_autorunner/static/workspace.js +765 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +1224 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2019 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +78 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +277 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
- codex_autorunner/surfaces/web/routes/flows.py +1164 -0
- codex_autorunner/surfaces/web/routes/messages.py +459 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +280 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +417 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +27 -0
- codex_autorunner/tickets/agent_pool.py +399 -0
- codex_autorunner/tickets/files.py +89 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +97 -0
- codex_autorunner/tickets/outbox.py +244 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +881 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1771
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -587
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -396
- codex_autorunner/web/static_assets.py +4 -484
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +335 -0
- codex_autorunner-1.1.0.dist-info/METADATA +154 -0
- codex_autorunner-1.1.0.dist-info/RECORD +308 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
- codex_autorunner/agents/execution/policy.py +0 -292
- codex_autorunner/agents/factory.py +0 -52
- codex_autorunner/agents/orchestrator.py +0 -358
- codex_autorunner/core/doc_chat.py +0 -1446
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -250
- codex_autorunner/spec_ingest.py +0 -812
- codex_autorunner/static/docChatActions.js +0 -287
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -285
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -504
- codex_autorunner/static/logs.js +0 -678
- codex_autorunner/static/review.js +0 -157
- codex_autorunner/static/runs.js +0 -418
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -94
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.2.dist-info/METADATA +0 -249
- codex_autorunner-0.1.2.dist-info/RECORD +0 -222
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
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)
|