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.
Files changed (40) hide show
  1. open_codex_ui-0.1.4.dist-info/METADATA +249 -0
  2. open_codex_ui-0.1.4.dist-info/RECORD +40 -0
  3. open_codex_ui-0.1.4.dist-info/WHEEL +5 -0
  4. open_codex_ui-0.1.4.dist-info/entry_points.txt +7 -0
  5. open_codex_ui-0.1.4.dist-info/top_level.txt +1 -0
  6. yier_web/__init__.py +6 -0
  7. yier_web/app.py +223 -0
  8. yier_web/auth.py +269 -0
  9. yier_web/cli.py +172 -0
  10. yier_web/codex/__init__.py +3 -0
  11. yier_web/codex/ipc_manager.py +1761 -0
  12. yier_web/codex/session_events.py +110 -0
  13. yier_web/codex/ws_commands.py +299 -0
  14. yier_web/config.py +651 -0
  15. yier_web/directory_picker.py +93 -0
  16. yier_web/event_stream.py +29 -0
  17. yier_web/frontend.py +204 -0
  18. yier_web/routes/__init__.py +17 -0
  19. yier_web/routes/codex.py +573 -0
  20. yier_web/routes/core.py +281 -0
  21. yier_web/schemas.py +534 -0
  22. yier_web/static/assets/CodexEmbedView-CN_-Mhe2.js +1 -0
  23. yier_web/static/assets/CodexView-wpI61iXa.js +606 -0
  24. yier_web/static/assets/LoginView-CELCom2O.js +101 -0
  25. yier_web/static/assets/api-CeihACIV.js +1099 -0
  26. yier_web/static/assets/index-BdFqJ-Kl.css +1 -0
  27. yier_web/static/assets/index-CjVNk6ja.js +183 -0
  28. yier_web/static/assets/index-mSBvq1p8.js +79 -0
  29. yier_web/static/assets/open-codex-ui-icon-DKJ1ZKj4.svg +99 -0
  30. yier_web/static/assets/primeicons-C6QP2o4f.woff2 +0 -0
  31. yier_web/static/assets/primeicons-DMOk5skT.eot +0 -0
  32. yier_web/static/assets/primeicons-Dr5RGzOO.svg +345 -0
  33. yier_web/static/assets/primeicons-MpK4pl85.ttf +0 -0
  34. yier_web/static/assets/primeicons-WjwUDZjB.woff +0 -0
  35. yier_web/static/assets/useCodexWorkspace-6QenDzvb.css +1 -0
  36. yier_web/static/assets/useCodexWorkspace-D1rCOO1x.js +1128 -0
  37. yier_web/static/brand/open-codex-ui-logo.svg +85 -0
  38. yier_web/static/favicon.ico +0 -0
  39. yier_web/static/favicon.svg +99 -0
  40. 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)
@@ -0,0 +1,3 @@
1
+ from yier_web.codex.ipc_manager import CodexIpcManager
2
+
3
+ __all__ = ["CodexIpcManager"]