open-codex-ui 0.1.4__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.
- open_codex_ui-0.1.4.dist-info/METADATA +249 -0
- open_codex_ui-0.1.4.dist-info/RECORD +40 -0
- open_codex_ui-0.1.4.dist-info/WHEEL +5 -0
- open_codex_ui-0.1.4.dist-info/entry_points.txt +7 -0
- open_codex_ui-0.1.4.dist-info/top_level.txt +1 -0
- yier_web/__init__.py +6 -0
- yier_web/app.py +223 -0
- yier_web/auth.py +269 -0
- yier_web/cli.py +172 -0
- yier_web/codex/__init__.py +3 -0
- yier_web/codex/ipc_manager.py +1761 -0
- yier_web/codex/session_events.py +110 -0
- yier_web/codex/ws_commands.py +299 -0
- yier_web/config.py +651 -0
- yier_web/directory_picker.py +93 -0
- yier_web/event_stream.py +29 -0
- yier_web/frontend.py +204 -0
- yier_web/routes/__init__.py +17 -0
- yier_web/routes/codex.py +573 -0
- yier_web/routes/core.py +281 -0
- yier_web/schemas.py +534 -0
- yier_web/static/assets/CodexEmbedView-CN_-Mhe2.js +1 -0
- yier_web/static/assets/CodexView-wpI61iXa.js +606 -0
- yier_web/static/assets/LoginView-CELCom2O.js +101 -0
- yier_web/static/assets/api-CeihACIV.js +1099 -0
- yier_web/static/assets/index-BdFqJ-Kl.css +1 -0
- yier_web/static/assets/index-CjVNk6ja.js +183 -0
- yier_web/static/assets/index-mSBvq1p8.js +79 -0
- yier_web/static/assets/open-codex-ui-icon-DKJ1ZKj4.svg +99 -0
- yier_web/static/assets/primeicons-C6QP2o4f.woff2 +0 -0
- yier_web/static/assets/primeicons-DMOk5skT.eot +0 -0
- yier_web/static/assets/primeicons-Dr5RGzOO.svg +345 -0
- yier_web/static/assets/primeicons-MpK4pl85.ttf +0 -0
- yier_web/static/assets/primeicons-WjwUDZjB.woff +0 -0
- yier_web/static/assets/useCodexWorkspace-6QenDzvb.css +1 -0
- yier_web/static/assets/useCodexWorkspace-D1rCOO1x.js +1128 -0
- yier_web/static/brand/open-codex-ui-logo.svg +85 -0
- yier_web/static/favicon.ico +0 -0
- yier_web/static/favicon.svg +99 -0
- yier_web/static/index.html +24 -0
yier_web/auth.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import PurePosixPath
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from litestar import Request
|
|
14
|
+
from litestar.response import Redirect, Response
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
AUTH_COOKIE_NAME = "yier_auth_session"
|
|
18
|
+
CODEX_EMBED_TOKEN_ENV = "YIER_CODEX_EMBED_TOKEN"
|
|
19
|
+
DEFAULT_SESSION_TTL_HOURS = 24 * 7
|
|
20
|
+
PBKDF2_HASH_PREFIX = "pbkdf2_sha256"
|
|
21
|
+
PBKDF2_DEFAULT_ITERATIONS = 600_000
|
|
22
|
+
PUBLIC_API_PATHS = {
|
|
23
|
+
"/api/auth/login",
|
|
24
|
+
"/api/auth/logout",
|
|
25
|
+
"/api/auth/session",
|
|
26
|
+
}
|
|
27
|
+
PUBLIC_FRONTEND_PATHS = {
|
|
28
|
+
"/login",
|
|
29
|
+
"/codex/embed",
|
|
30
|
+
"/__vite_ping",
|
|
31
|
+
"/vite.svg",
|
|
32
|
+
}
|
|
33
|
+
PUBLIC_FRONTEND_PREFIXES = (
|
|
34
|
+
"/assets/",
|
|
35
|
+
"/@id/",
|
|
36
|
+
"/@vite/",
|
|
37
|
+
"/src/",
|
|
38
|
+
"/node_modules/",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def hash_password(password: str, *, iterations: int = PBKDF2_DEFAULT_ITERATIONS) -> str:
|
|
43
|
+
normalized_password = password.strip()
|
|
44
|
+
if not normalized_password:
|
|
45
|
+
raise ValueError("Password cannot be empty.")
|
|
46
|
+
if iterations <= 0:
|
|
47
|
+
raise ValueError("Iterations must be positive.")
|
|
48
|
+
|
|
49
|
+
salt = secrets.token_hex(16)
|
|
50
|
+
digest = hashlib.pbkdf2_hmac(
|
|
51
|
+
"sha256",
|
|
52
|
+
normalized_password.encode("utf-8"),
|
|
53
|
+
salt.encode("utf-8"),
|
|
54
|
+
iterations,
|
|
55
|
+
)
|
|
56
|
+
return f"{PBKDF2_HASH_PREFIX}${iterations}${salt}${digest.hex()}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def verify_password(password: str, password_hash: str) -> bool:
|
|
60
|
+
parts = password_hash.split("$", 3)
|
|
61
|
+
if len(parts) != 4:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
algorithm, raw_iterations, salt, expected_digest = parts
|
|
65
|
+
if algorithm != PBKDF2_HASH_PREFIX:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
iterations = int(raw_iterations)
|
|
70
|
+
except ValueError:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
if iterations <= 0 or not salt or not expected_digest:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
actual_digest = hashlib.pbkdf2_hmac(
|
|
77
|
+
"sha256",
|
|
78
|
+
password.strip().encode("utf-8"),
|
|
79
|
+
salt.encode("utf-8"),
|
|
80
|
+
iterations,
|
|
81
|
+
).hex()
|
|
82
|
+
return hmac.compare_digest(actual_digest, expected_digest)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _env_positive_int(name: str, default: int) -> int:
|
|
86
|
+
raw_value = os.getenv(name, "").strip()
|
|
87
|
+
if not raw_value:
|
|
88
|
+
return default
|
|
89
|
+
try:
|
|
90
|
+
parsed = int(raw_value)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return default
|
|
93
|
+
return parsed if parsed > 0 else default
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _urlsafe_b64encode(value: bytes) -> str:
|
|
97
|
+
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _urlsafe_b64decode(value: str) -> bytes:
|
|
101
|
+
padding = "=" * (-len(value) % 4)
|
|
102
|
+
return base64.urlsafe_b64decode(f"{value}{padding}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AuthService:
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
self._password = os.getenv("YIER_AUTH_PASSWORD", "").strip()
|
|
108
|
+
self._password_hash = os.getenv("YIER_AUTH_PASSWORD_HASH", "").strip()
|
|
109
|
+
self._secret = os.getenv("YIER_AUTH_SECRET", "").strip()
|
|
110
|
+
self._codex_embed_token = os.getenv(CODEX_EMBED_TOKEN_ENV, "").strip()
|
|
111
|
+
self._session_ttl_seconds = (
|
|
112
|
+
_env_positive_int("YIER_AUTH_SESSION_TTL_HOURS", DEFAULT_SESSION_TTL_HOURS)
|
|
113
|
+
* 3600
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def enabled(self) -> bool:
|
|
118
|
+
return bool(self._password or self._password_hash)
|
|
119
|
+
|
|
120
|
+
def session_payload(self, request: Request) -> dict[str, bool]:
|
|
121
|
+
return {
|
|
122
|
+
"enabled": self.enabled,
|
|
123
|
+
"authenticated": self.is_authenticated(request),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def is_public_path(self, path: str) -> bool:
|
|
127
|
+
if path in PUBLIC_API_PATHS or path in PUBLIC_FRONTEND_PATHS:
|
|
128
|
+
return True
|
|
129
|
+
if any(path.startswith(prefix) for prefix in PUBLIC_FRONTEND_PREFIXES):
|
|
130
|
+
return True
|
|
131
|
+
if path.startswith("/api/"):
|
|
132
|
+
return False
|
|
133
|
+
return bool(PurePosixPath(path).suffix)
|
|
134
|
+
|
|
135
|
+
def is_authenticated(self, request: Request) -> bool:
|
|
136
|
+
if not self.enabled:
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
return self._is_authenticated_cookie(request.cookies)
|
|
140
|
+
|
|
141
|
+
def is_codex_embed_token_valid(self, token: str | None) -> bool:
|
|
142
|
+
if not self._codex_embed_token:
|
|
143
|
+
return False
|
|
144
|
+
return hmac.compare_digest((token or "").strip(), self._codex_embed_token)
|
|
145
|
+
|
|
146
|
+
def is_codex_websocket_authorized(self, connection: Any) -> bool:
|
|
147
|
+
if not self.enabled:
|
|
148
|
+
return True
|
|
149
|
+
if self._is_authenticated_cookie(getattr(connection, "cookies", {})):
|
|
150
|
+
return True
|
|
151
|
+
query_params = getattr(connection, "query_params", {})
|
|
152
|
+
return self.is_codex_embed_token_valid(query_params.get("embed_token"))
|
|
153
|
+
|
|
154
|
+
def _is_authenticated_cookie(self, cookies: Any) -> bool:
|
|
155
|
+
token = cookies.get(AUTH_COOKIE_NAME)
|
|
156
|
+
if not token:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
return self._verify_session_token(token)
|
|
160
|
+
|
|
161
|
+
def verify_login_password(self, password: str) -> bool:
|
|
162
|
+
if not self.enabled:
|
|
163
|
+
return True
|
|
164
|
+
if self._password_hash:
|
|
165
|
+
return verify_password(password, self._password_hash)
|
|
166
|
+
return hmac.compare_digest(password.strip(), self._password)
|
|
167
|
+
|
|
168
|
+
def build_login_response(self, request: Request) -> Response:
|
|
169
|
+
response = Response(content={"enabled": self.enabled, "authenticated": True})
|
|
170
|
+
if self.enabled:
|
|
171
|
+
response.set_cookie(
|
|
172
|
+
key=AUTH_COOKIE_NAME,
|
|
173
|
+
value=self._build_session_token(),
|
|
174
|
+
max_age=self._session_ttl_seconds,
|
|
175
|
+
path="/",
|
|
176
|
+
secure=self._request_is_secure(request),
|
|
177
|
+
httponly=True,
|
|
178
|
+
samesite="lax",
|
|
179
|
+
)
|
|
180
|
+
return response
|
|
181
|
+
|
|
182
|
+
def build_logout_response(self, request: Request) -> Response:
|
|
183
|
+
response = Response(
|
|
184
|
+
content={
|
|
185
|
+
"enabled": self.enabled,
|
|
186
|
+
"authenticated": not self.enabled,
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
response.delete_cookie(
|
|
190
|
+
AUTH_COOKIE_NAME,
|
|
191
|
+
path="/",
|
|
192
|
+
)
|
|
193
|
+
return response
|
|
194
|
+
|
|
195
|
+
def build_unauthorized_api_response(self) -> Response:
|
|
196
|
+
return Response(
|
|
197
|
+
content={"detail": "Authentication required."},
|
|
198
|
+
status_code=401,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def build_login_redirect(self, request: Request) -> Redirect:
|
|
202
|
+
return Redirect(
|
|
203
|
+
path="/login",
|
|
204
|
+
query_params={"next": self._request_target(request)},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _build_session_token(self) -> str:
|
|
208
|
+
now = int(time.time())
|
|
209
|
+
payload = {
|
|
210
|
+
"exp": now + self._session_ttl_seconds,
|
|
211
|
+
"iat": now,
|
|
212
|
+
"nonce": secrets.token_urlsafe(12),
|
|
213
|
+
}
|
|
214
|
+
payload_bytes = json.dumps(
|
|
215
|
+
payload,
|
|
216
|
+
ensure_ascii=True,
|
|
217
|
+
separators=(",", ":"),
|
|
218
|
+
sort_keys=True,
|
|
219
|
+
).encode("utf-8")
|
|
220
|
+
encoded_payload = _urlsafe_b64encode(payload_bytes)
|
|
221
|
+
signature = hmac.new(
|
|
222
|
+
self._signing_key(),
|
|
223
|
+
encoded_payload.encode("utf-8"),
|
|
224
|
+
hashlib.sha256,
|
|
225
|
+
).digest()
|
|
226
|
+
return f"{encoded_payload}.{_urlsafe_b64encode(signature)}"
|
|
227
|
+
|
|
228
|
+
def _verify_session_token(self, token: str) -> bool:
|
|
229
|
+
try:
|
|
230
|
+
encoded_payload, encoded_signature = token.split(".", 1)
|
|
231
|
+
except ValueError:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
expected_signature = hmac.new(
|
|
235
|
+
self._signing_key(),
|
|
236
|
+
encoded_payload.encode("utf-8"),
|
|
237
|
+
hashlib.sha256,
|
|
238
|
+
).digest()
|
|
239
|
+
try:
|
|
240
|
+
provided_signature = _urlsafe_b64decode(encoded_signature)
|
|
241
|
+
payload_bytes = _urlsafe_b64decode(encoded_payload)
|
|
242
|
+
payload = json.loads(payload_bytes.decode("utf-8"))
|
|
243
|
+
except (ValueError, json.JSONDecodeError):
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
if not hmac.compare_digest(provided_signature, expected_signature):
|
|
247
|
+
return False
|
|
248
|
+
if not isinstance(payload, dict):
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
expires_at = payload.get("exp")
|
|
252
|
+
if not isinstance(expires_at, int):
|
|
253
|
+
return False
|
|
254
|
+
return expires_at >= int(time.time())
|
|
255
|
+
|
|
256
|
+
def _signing_key(self) -> bytes:
|
|
257
|
+
seed = self._secret or self._password_hash or self._password
|
|
258
|
+
return hashlib.sha256(seed.encode("utf-8")).digest()
|
|
259
|
+
|
|
260
|
+
def _request_is_secure(self, request: Request) -> bool:
|
|
261
|
+
forwarded_proto = request.headers.get("x-forwarded-proto", "")
|
|
262
|
+
normalized_proto = forwarded_proto.split(",", 1)[0].strip().lower()
|
|
263
|
+
if normalized_proto:
|
|
264
|
+
return normalized_proto == "https"
|
|
265
|
+
return request.url.scheme == "https"
|
|
266
|
+
|
|
267
|
+
def _request_target(self, request: Request) -> str:
|
|
268
|
+
query = request.url.query
|
|
269
|
+
return f"{request.url.path}?{query}" if query else request.url.path
|
yier_web/cli.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from granian import Granian, loops
|
|
13
|
+
from granian.constants import Interfaces
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def project_root() -> Path:
|
|
17
|
+
return Path(__file__).resolve().parent.parent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def web_root() -> Path:
|
|
21
|
+
return project_root() / "web"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@loops.register("auto")
|
|
25
|
+
def build_loop():
|
|
26
|
+
asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
|
|
27
|
+
return asyncio.new_event_loop()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def dev() -> int:
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
description="Start frontend and backend in development mode."
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
35
|
+
parser.add_argument("--port", type=int, default=9999)
|
|
36
|
+
parser.add_argument("--no-reload", action="store_true")
|
|
37
|
+
args = parser.parse_args()
|
|
38
|
+
|
|
39
|
+
frontend_process = _spawn_process(["pnpm", "dev"], cwd=web_root())
|
|
40
|
+
backend_process = _spawn_process(
|
|
41
|
+
[
|
|
42
|
+
sys.executable,
|
|
43
|
+
str(project_root() / "main.py"),
|
|
44
|
+
"--debug",
|
|
45
|
+
"--host",
|
|
46
|
+
args.host,
|
|
47
|
+
"--port",
|
|
48
|
+
str(args.port),
|
|
49
|
+
*(["--reload"] if not args.no_reload else []),
|
|
50
|
+
],
|
|
51
|
+
cwd=project_root(),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
return _wait_for_processes(
|
|
56
|
+
[
|
|
57
|
+
("frontend", frontend_process),
|
|
58
|
+
("backend", backend_process),
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
finally:
|
|
62
|
+
_terminate_process(frontend_process)
|
|
63
|
+
_terminate_process(backend_process)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dev_backend() -> int:
|
|
67
|
+
parser = argparse.ArgumentParser(
|
|
68
|
+
description="Start the backend in development mode."
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
71
|
+
parser.add_argument("--port", type=int, default=9999)
|
|
72
|
+
parser.add_argument("--no-reload", action="store_true")
|
|
73
|
+
args = parser.parse_args()
|
|
74
|
+
|
|
75
|
+
server = build_server(
|
|
76
|
+
host=args.host,
|
|
77
|
+
port=args.port,
|
|
78
|
+
debug=True,
|
|
79
|
+
reload=not args.no_reload,
|
|
80
|
+
)
|
|
81
|
+
server.serve()
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def dev_web() -> int:
|
|
86
|
+
return _run_foreground_process(["pnpm", "dev"], cwd=web_root())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def prod() -> int:
|
|
90
|
+
parser = argparse.ArgumentParser(description="Start Open Codex UI in production mode.")
|
|
91
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
92
|
+
parser.add_argument("--port", type=int, default=9999)
|
|
93
|
+
args = parser.parse_args()
|
|
94
|
+
|
|
95
|
+
server = build_server(
|
|
96
|
+
host=args.host,
|
|
97
|
+
port=args.port,
|
|
98
|
+
debug=False,
|
|
99
|
+
reload=False,
|
|
100
|
+
)
|
|
101
|
+
server.serve()
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_web() -> int:
|
|
106
|
+
return _run_foreground_process(["pnpm", "build"], cwd=web_root())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_server(*, host: str, port: int, debug: bool, reload: bool) -> Granian:
|
|
110
|
+
os.environ["YIER_DEBUG"] = "1" if debug else "0"
|
|
111
|
+
return Granian(
|
|
112
|
+
"yier_web.app:create_app",
|
|
113
|
+
address=host,
|
|
114
|
+
port=port,
|
|
115
|
+
interface=Interfaces.ASGI,
|
|
116
|
+
factory=True,
|
|
117
|
+
reload=reload,
|
|
118
|
+
workers_kill_timeout=5,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run_foreground_process(command: list[str], cwd: Path) -> int:
|
|
123
|
+
completed = subprocess.run(command, cwd=cwd, check=False)
|
|
124
|
+
return completed.returncode
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _spawn_process(command: list[str], cwd: Path) -> subprocess.Popen[bytes]:
|
|
128
|
+
return subprocess.Popen(
|
|
129
|
+
command,
|
|
130
|
+
cwd=cwd,
|
|
131
|
+
start_new_session=True,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _wait_for_processes(processes: list[tuple[str, subprocess.Popen[bytes]]]) -> int:
|
|
136
|
+
try:
|
|
137
|
+
while True:
|
|
138
|
+
for name, process in processes:
|
|
139
|
+
return_code = process.poll()
|
|
140
|
+
if return_code is None:
|
|
141
|
+
continue
|
|
142
|
+
if return_code != 0:
|
|
143
|
+
print(f"{name} exited with code {return_code}.", file=sys.stderr)
|
|
144
|
+
return return_code
|
|
145
|
+
time.sleep(0.2)
|
|
146
|
+
except KeyboardInterrupt:
|
|
147
|
+
return 130
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _terminate_process(process: subprocess.Popen[bytes]) -> None:
|
|
151
|
+
if process.poll() is not None:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
if os.name == "posix":
|
|
155
|
+
try:
|
|
156
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
157
|
+
except ProcessLookupError:
|
|
158
|
+
return
|
|
159
|
+
else:
|
|
160
|
+
process.terminate()
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
process.wait(timeout=5)
|
|
164
|
+
except subprocess.TimeoutExpired:
|
|
165
|
+
if os.name == "posix":
|
|
166
|
+
try:
|
|
167
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
168
|
+
except ProcessLookupError:
|
|
169
|
+
return
|
|
170
|
+
else:
|
|
171
|
+
process.kill()
|
|
172
|
+
process.wait(timeout=5)
|