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/config.py ADDED
@@ -0,0 +1,651 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from yier_agents.src.config import YIERConfig
12
+
13
+ from yier_web.schemas import (
14
+ BackendHealth,
15
+ BackendOption,
16
+ CodexConfigPayload,
17
+ CodexProjectDefinition,
18
+ CodexProjectPayload,
19
+ CodexRemoteConnection,
20
+ CodexRemoteConnectionPayload,
21
+ ConfigResponse,
22
+ LLMConfigPayload,
23
+ MCPRuntimeEntry,
24
+ SaveAppSettingsRequest,
25
+ SaveLLMRequest,
26
+ CodexThreadProjectAssignment,
27
+ WebSettings,
28
+ )
29
+
30
+
31
+ RUNTIME_STATUSES = {
32
+ "connected",
33
+ "disabled",
34
+ "failed",
35
+ "needs_auth",
36
+ "needs_client_registration",
37
+ }
38
+
39
+ PROVIDER_BASE_URLS = {
40
+ "zai": "https://api.z.ai/api/paas/v4",
41
+ "zai-coding-plan": "https://api.z.ai/api/coding/paas/v4",
42
+ }
43
+
44
+
45
+ class MCPValidationError(ValueError):
46
+ """Raised when MCP configuration payloads are malformed."""
47
+
48
+
49
+ class AppConfigService:
50
+ def __init__(self, project_root: Path, home_dir: Path | None = None) -> None:
51
+ self.project_root = project_root.resolve()
52
+ self.home_dir = (home_dir or Path.home()).resolve()
53
+ self.yier_root = self.home_dir / ".yier"
54
+ self.web_root = self.yier_root / "web"
55
+ self.settings_path = self.web_root / "settings.json"
56
+ self.mcp_config_path = self.yier_root / ".yier.json"
57
+ self.ensure_storage()
58
+
59
+ def ensure_storage(self) -> None:
60
+ self.yier_root.mkdir(parents=True, exist_ok=True)
61
+ self.web_root.mkdir(parents=True, exist_ok=True)
62
+
63
+ def default_allowed_roots(self) -> list[str]:
64
+ defaults = [
65
+ self.project_root,
66
+ self.yier_root,
67
+ self.home_dir / "Desktop",
68
+ self.home_dir / "Documents",
69
+ self.home_dir / "Downloads",
70
+ ]
71
+ unique_roots: list[str] = []
72
+ seen: set[str] = set()
73
+ for root in defaults:
74
+ resolved = root.resolve()
75
+ serialized = str(resolved)
76
+ if serialized in seen:
77
+ continue
78
+ seen.add(serialized)
79
+ unique_roots.append(serialized)
80
+ return unique_roots
81
+
82
+ def load_web_settings(self) -> WebSettings:
83
+ if not self.settings_path.exists():
84
+ return self._finalize_web_settings(
85
+ WebSettings(allowed_roots=self.default_allowed_roots())
86
+ )
87
+
88
+ try:
89
+ payload = json.loads(self.settings_path.read_text(encoding="utf-8"))
90
+ settings = WebSettings.model_validate(payload)
91
+ except (json.JSONDecodeError, ValidationError):
92
+ return self._finalize_web_settings(
93
+ WebSettings(allowed_roots=self.default_allowed_roots())
94
+ )
95
+
96
+ return self._finalize_web_settings(settings)
97
+
98
+ def save_llm_settings(self, payload: SaveLLMRequest) -> WebSettings:
99
+ settings = self.load_web_settings()
100
+ settings.llm.provider = payload.provider
101
+ settings.llm.base_url = payload.base_url
102
+ settings.llm.model = payload.model
103
+ if payload.api_key is not None and payload.api_key != "":
104
+ settings.llm.api_key = payload.api_key
105
+
106
+ settings.allowed_roots = self._normalize_allowed_roots(settings.allowed_roots)
107
+ if not settings.allowed_roots:
108
+ settings.allowed_roots = self.default_allowed_roots()
109
+
110
+ self._write_json(self.settings_path, settings.model_dump())
111
+ return settings
112
+
113
+ def save_allowed_roots(self, allowed_roots: list[str]) -> WebSettings:
114
+ settings = self.load_web_settings()
115
+ normalized_roots = self._normalize_allowed_roots(allowed_roots)
116
+ settings.allowed_roots = normalized_roots or self.default_allowed_roots()
117
+ self._write_json(self.settings_path, settings.model_dump())
118
+ return settings
119
+
120
+ def save_app_settings(self, payload: SaveAppSettingsRequest) -> WebSettings:
121
+ settings = self.load_web_settings()
122
+ projects = settings.codex.projects
123
+ assignments = settings.codex.thread_project_assignments
124
+ projectless_thread_ids = settings.codex.projectless_thread_ids
125
+ settings.session_defaults = payload.session_defaults
126
+ settings.codex = payload.codex
127
+ settings.codex.projects = projects
128
+ settings.codex.thread_project_assignments = assignments
129
+ settings.codex.projectless_thread_ids = projectless_thread_ids
130
+ settings.allowed_roots = self._normalize_allowed_roots(settings.allowed_roots)
131
+ if not settings.allowed_roots:
132
+ settings.allowed_roots = self.default_allowed_roots()
133
+ settings = self._finalize_web_settings(settings)
134
+ self._write_json(self.settings_path, settings.model_dump())
135
+ return settings
136
+
137
+ def save_codex_remote_connection(
138
+ self,
139
+ payload: CodexRemoteConnectionPayload,
140
+ *,
141
+ connection_id: str | None = None,
142
+ ) -> CodexRemoteConnection:
143
+ settings = self.load_web_settings()
144
+ connection = self._remote_connection_from_payload(
145
+ payload,
146
+ connection_id=connection_id,
147
+ )
148
+ connections = [
149
+ existing
150
+ for existing in settings.codex.remote_connections
151
+ if existing.id != connection.id
152
+ ]
153
+ connections.append(connection)
154
+ settings.codex.remote_connections = self._normalize_remote_connections(
155
+ connections
156
+ )
157
+ connection = settings.codex.remote_connections[-1]
158
+ self._write_json(self.settings_path, settings.model_dump())
159
+ return connection
160
+
161
+ def save_codex_project(
162
+ self, payload: CodexProjectPayload
163
+ ) -> CodexProjectDefinition:
164
+ settings = self.load_web_settings()
165
+ host_id = payload.host_id or "local"
166
+ kind = payload.kind
167
+ if kind == "local":
168
+ host_id = "local"
169
+ project_path = self.resolve_project_path(payload.project_path)
170
+ else:
171
+ if not host_id.startswith("ssh:"):
172
+ raise ValueError("A remote project requires an SSH host.")
173
+ connection_id = host_id.removeprefix("ssh:")
174
+ if connection_id not in {
175
+ connection.id for connection in settings.codex.remote_connections
176
+ }:
177
+ raise ValueError("Remote connection not found.")
178
+ project_path = payload.project_path
179
+
180
+ for project in settings.codex.projects:
181
+ if project.host_id == host_id and project_path in project.root_paths:
182
+ raise ValueError("This project has already been added.")
183
+
184
+ now = time.time()
185
+ project = CodexProjectDefinition(
186
+ name=payload.name or self._project_name(project_path),
187
+ kind=kind,
188
+ host_id=host_id,
189
+ root_paths=[project_path],
190
+ created_at=now,
191
+ updated_at=now,
192
+ )
193
+ settings.codex.projects.append(project)
194
+ self._write_json(self.settings_path, settings.model_dump())
195
+ return project
196
+
197
+ def delete_codex_project(self, project_id: str) -> None:
198
+ settings = self.load_web_settings()
199
+ before = len(settings.codex.projects)
200
+ settings.codex.projects = [
201
+ project for project in settings.codex.projects if project.id != project_id
202
+ ]
203
+ if len(settings.codex.projects) == before:
204
+ raise ValueError("Project not found.")
205
+ settings.codex.thread_project_assignments = {
206
+ thread_id: assignment
207
+ for thread_id, assignment in settings.codex.thread_project_assignments.items()
208
+ if assignment.project_id != project_id
209
+ }
210
+ self._write_json(self.settings_path, settings.model_dump())
211
+
212
+ def assign_codex_thread_project(
213
+ self,
214
+ thread_id: str,
215
+ *,
216
+ host_id: str,
217
+ cwd: str,
218
+ ) -> None:
219
+ settings = self.load_web_settings()
220
+ project = self._matching_codex_project(settings.codex.projects, host_id, cwd)
221
+ projectless_thread_ids = set(settings.codex.projectless_thread_ids)
222
+ if project is None:
223
+ settings.codex.thread_project_assignments.pop(thread_id, None)
224
+ projectless_thread_ids.add(thread_id)
225
+ else:
226
+ settings.codex.thread_project_assignments[thread_id] = (
227
+ CodexThreadProjectAssignment(
228
+ project_id=project.id,
229
+ project_kind=project.kind,
230
+ host_id=host_id,
231
+ cwd=cwd,
232
+ )
233
+ )
234
+ projectless_thread_ids.discard(thread_id)
235
+ settings.codex.projectless_thread_ids = sorted(projectless_thread_ids)
236
+ self._write_json(self.settings_path, settings.model_dump())
237
+
238
+ def copy_codex_thread_assignment(
239
+ self, source_thread_id: str, thread_id: str
240
+ ) -> None:
241
+ settings = self.load_web_settings()
242
+ assignment = settings.codex.thread_project_assignments.get(source_thread_id)
243
+ projectless_thread_ids = set(settings.codex.projectless_thread_ids)
244
+ if assignment is not None:
245
+ settings.codex.thread_project_assignments[thread_id] = (
246
+ assignment.model_copy()
247
+ )
248
+ projectless_thread_ids.discard(thread_id)
249
+ elif source_thread_id in projectless_thread_ids:
250
+ projectless_thread_ids.add(thread_id)
251
+ else:
252
+ return
253
+ settings.codex.projectless_thread_ids = sorted(projectless_thread_ids)
254
+ self._write_json(self.settings_path, settings.model_dump())
255
+
256
+ def delete_codex_remote_connection(self, connection_id: str) -> None:
257
+ settings = self.load_web_settings()
258
+ settings.codex.remote_connections = [
259
+ connection
260
+ for connection in settings.codex.remote_connections
261
+ if connection.id != connection_id
262
+ ]
263
+ if settings.codex.active_remote_connection_id == connection_id:
264
+ settings.codex.active_remote_connection_id = ""
265
+ host_id = f"ssh:{connection_id}"
266
+ removed_project_ids = {
267
+ project.id
268
+ for project in settings.codex.projects
269
+ if project.host_id == host_id
270
+ }
271
+ settings.codex.projects = [
272
+ project for project in settings.codex.projects if project.host_id != host_id
273
+ ]
274
+ settings.codex.thread_project_assignments = {
275
+ thread_id: assignment
276
+ for thread_id, assignment in settings.codex.thread_project_assignments.items()
277
+ if assignment.project_id not in removed_project_ids
278
+ }
279
+ self._write_json(self.settings_path, settings.model_dump())
280
+
281
+ def set_active_codex_remote_connection(self, connection_id: str) -> WebSettings:
282
+ settings = self.load_web_settings()
283
+ if connection_id:
284
+ known_ids = {
285
+ connection.id for connection in settings.codex.remote_connections
286
+ }
287
+ if connection_id not in known_ids:
288
+ raise ValueError("Remote connection not found.")
289
+ settings.codex.active_remote_connection_id = connection_id
290
+ self._write_json(self.settings_path, settings.model_dump())
291
+ return settings
292
+
293
+ def load_mcp_root_config(self) -> dict[str, Any]:
294
+ return YIERConfig.load_config(self.yier_root)
295
+
296
+ def load_mcp_servers(self) -> dict[str, dict[str, Any]]:
297
+ raw = self.load_mcp_root_config().get("mcpServers", {})
298
+ if isinstance(raw, dict):
299
+ return {
300
+ str(key): value for key, value in raw.items() if isinstance(value, dict)
301
+ }
302
+ return {}
303
+
304
+ def save_mcp_servers(
305
+ self, mcp_servers: dict[str, dict[str, Any]]
306
+ ) -> dict[str, dict[str, Any]]:
307
+ normalized = self._normalize_mcp_servers(mcp_servers)
308
+ payload = self.load_mcp_root_config()
309
+ payload["mcpServers"] = normalized
310
+ self._write_json(self.mcp_config_path, payload)
311
+ return normalized
312
+
313
+ def settings_marker(self) -> tuple[bool, int, int]:
314
+ return self._path_marker(self.settings_path)
315
+
316
+ def mcp_marker(self) -> tuple[bool, int, int]:
317
+ return self._path_marker(self.mcp_config_path)
318
+
319
+ def build_public_config(
320
+ self,
321
+ mcp_runtime: dict[str, MCPRuntimeEntry],
322
+ ) -> ConfigResponse:
323
+ settings = self.load_web_settings()
324
+ return ConfigResponse(
325
+ llm=LLMConfigPayload(
326
+ provider=settings.llm.provider,
327
+ base_url=settings.llm.base_url,
328
+ model=settings.llm.model,
329
+ has_api_key=bool(settings.llm.api_key),
330
+ ),
331
+ backends=self.backend_options(),
332
+ session_defaults=settings.session_defaults,
333
+ codex=CodexConfigPayload(
334
+ launcher_command=settings.codex.launcher_command,
335
+ model=settings.codex.model,
336
+ sandbox=settings.codex.sandbox,
337
+ approval_policy=settings.codex.approval_policy,
338
+ approvals_reviewer=settings.codex.approvals_reviewer,
339
+ personality=settings.codex.personality,
340
+ reasoning_effort=settings.codex.reasoning_effort,
341
+ show_reasoning_cards=settings.codex.show_reasoning_cards,
342
+ service_tier=settings.codex.service_tier,
343
+ active_remote_connection_id=settings.codex.active_remote_connection_id,
344
+ remote_connections=settings.codex.remote_connections,
345
+ projects=settings.codex.projects,
346
+ ),
347
+ allowed_roots=settings.allowed_roots,
348
+ mcp_runtime=mcp_runtime,
349
+ )
350
+
351
+ def backend_options(self) -> list[BackendOption]:
352
+ return [
353
+ BackendOption(id="codex", label="Codex App Server"),
354
+ ]
355
+
356
+ def build_backend_health(self) -> dict[str, BackendHealth]:
357
+ return {
358
+ "codex": BackendHealth(
359
+ ready=True,
360
+ detail=None,
361
+ ),
362
+ }
363
+
364
+ def _normalize_mcp_servers(
365
+ self,
366
+ mcp_servers: dict[str, dict[str, Any]],
367
+ ) -> dict[str, dict[str, Any]]:
368
+ normalized: dict[str, dict[str, Any]] = {}
369
+ for name, server in mcp_servers.items():
370
+ normalized_name = str(name).strip()
371
+ if not normalized_name:
372
+ raise MCPValidationError("MCP server names cannot be empty.")
373
+ if not isinstance(server, dict):
374
+ raise MCPValidationError(
375
+ f"MCP server '{normalized_name}' must be an object."
376
+ )
377
+ normalized[normalized_name] = self._normalize_mcp_server(
378
+ normalized_name, server
379
+ )
380
+ return normalized
381
+
382
+ def _remote_connection_from_payload(
383
+ self,
384
+ payload: CodexRemoteConnectionPayload,
385
+ *,
386
+ connection_id: str | None,
387
+ ) -> CodexRemoteConnection:
388
+ if payload.ssh_alias:
389
+ return CodexRemoteConnection(
390
+ id=connection_id or "",
391
+ display_name=payload.display_name,
392
+ ssh_alias=payload.ssh_alias,
393
+ remote_path=payload.remote_path,
394
+ auto_connect=payload.auto_connect,
395
+ ).normalized()
396
+ if not payload.ssh_host:
397
+ raise ValueError("ssh_host is required for a direct SSH connection.")
398
+ return CodexRemoteConnection(
399
+ id=connection_id or "",
400
+ display_name=payload.display_name,
401
+ ssh_host=payload.ssh_host,
402
+ ssh_username=payload.ssh_username,
403
+ ssh_port=payload.ssh_port,
404
+ identity_file=payload.identity_file,
405
+ remote_path=payload.remote_path,
406
+ auto_connect=payload.auto_connect,
407
+ ).normalized()
408
+
409
+ def _normalize_remote_connections(
410
+ self,
411
+ connections: list[CodexRemoteConnection],
412
+ ) -> list[CodexRemoteConnection]:
413
+ normalized: list[CodexRemoteConnection] = []
414
+ seen: set[str] = set()
415
+ for connection in connections:
416
+ item = connection.normalized()
417
+ if not item.id:
418
+ item = item.model_copy(update={"id": self._remote_connection_id(item)})
419
+ if item.id in seen:
420
+ item = item.model_copy(update={"id": self._remote_connection_id(item)})
421
+ seen.add(item.id)
422
+ normalized.append(item)
423
+ return normalized
424
+
425
+ def _project_name(self, project_path: str) -> str:
426
+ normalized = project_path.rstrip("/\\")
427
+ return Path(normalized).name or project_path
428
+
429
+ def _matching_codex_project(
430
+ self,
431
+ projects: list[CodexProjectDefinition],
432
+ host_id: str,
433
+ cwd: str,
434
+ ) -> CodexProjectDefinition | None:
435
+ return next(
436
+ (
437
+ project
438
+ for project in projects
439
+ if project.host_id == host_id and cwd in project.root_paths
440
+ ),
441
+ None,
442
+ )
443
+
444
+ def _remote_connection_id(self, connection: CodexRemoteConnection) -> str:
445
+ source = connection.ssh_alias or connection.ssh_host or connection.display_name
446
+ return source.replace("@", "-").replace(".", "-").replace(":", "-") or "remote"
447
+
448
+ def _normalize_mcp_server(
449
+ self, name: str, server: dict[str, Any]
450
+ ) -> dict[str, Any]:
451
+ server_type = server.get("type")
452
+ if server_type not in {"stdio", "http", "sse"}:
453
+ raise MCPValidationError(f"MCP server '{name}' has an invalid type.")
454
+
455
+ normalized: dict[str, Any] = {"type": server_type}
456
+ enabled = server.get("enabled", True)
457
+ if not isinstance(enabled, bool):
458
+ raise MCPValidationError(
459
+ f"MCP server '{name}' has a non-boolean 'enabled' value."
460
+ )
461
+ normalized["enabled"] = enabled
462
+
463
+ status = server.get("status")
464
+ if status is not None:
465
+ if status not in RUNTIME_STATUSES:
466
+ raise MCPValidationError(
467
+ f"MCP server '{name}' has an invalid status value."
468
+ )
469
+ normalized["status"] = status
470
+
471
+ if server_type == "stdio":
472
+ command = server.get("command", "")
473
+ if not isinstance(command, str) or not command.strip():
474
+ raise MCPValidationError(
475
+ f"MCP server '{name}' must define a stdio command."
476
+ )
477
+ normalized["command"] = command.strip()
478
+ normalized["args"] = self._coerce_string_list(
479
+ server.get("args", []), name, "args"
480
+ )
481
+ env = server.get("env", {})
482
+ normalized["env"] = self._coerce_string_map(env, name, "env")
483
+ else:
484
+ url = server.get("url", "")
485
+ if not isinstance(url, str) or not url.strip():
486
+ raise MCPValidationError(f"MCP server '{name}' must define a URL.")
487
+ normalized["url"] = url.strip()
488
+ headers = server.get("headers", {})
489
+ normalized["headers"] = self._coerce_string_map(headers, name, "headers")
490
+
491
+ return normalized
492
+
493
+ def _coerce_string_list(
494
+ self, value: Any, server_name: str, field_name: str
495
+ ) -> list[str]:
496
+ if value in (None, ""):
497
+ return []
498
+ if not isinstance(value, list) or any(
499
+ not isinstance(item, str) for item in value
500
+ ):
501
+ raise MCPValidationError(
502
+ f"MCP server '{server_name}' field '{field_name}' must be a string array."
503
+ )
504
+ return value
505
+
506
+ def _coerce_string_map(
507
+ self, value: Any, server_name: str, field_name: str
508
+ ) -> dict[str, str]:
509
+ if value in (None, ""):
510
+ return {}
511
+ if not isinstance(value, dict):
512
+ raise MCPValidationError(
513
+ f"MCP server '{server_name}' field '{field_name}' must be a string object."
514
+ )
515
+ normalized: dict[str, str] = {}
516
+ for key, item in value.items():
517
+ if not isinstance(key, str) or not isinstance(item, str):
518
+ raise MCPValidationError(
519
+ f"MCP server '{server_name}' field '{field_name}' must contain only strings."
520
+ )
521
+ normalized[key] = item
522
+ return normalized
523
+
524
+ def _infer_provider_from_base_url(self, base_url: str) -> str:
525
+ normalized_base_url = base_url.strip().rstrip("/")
526
+ if not normalized_base_url:
527
+ return ""
528
+ for provider, provider_base_url in PROVIDER_BASE_URLS.items():
529
+ if normalized_base_url == provider_base_url.rstrip("/"):
530
+ return provider
531
+ return ""
532
+
533
+ def _normalize_allowed_roots(self, allowed_roots: list[str]) -> list[str]:
534
+ normalized_roots: list[str] = []
535
+ seen: set[str] = set()
536
+ for root in allowed_roots:
537
+ candidate = str(root).strip()
538
+ if not candidate:
539
+ continue
540
+ resolved = str(self._resolve_user_path(candidate))
541
+ if resolved in seen:
542
+ continue
543
+ seen.add(resolved)
544
+ normalized_roots.append(resolved)
545
+ return normalized_roots
546
+
547
+ def resolve_project_path(self, raw_path: str | None) -> str:
548
+ candidate = raw_path.strip() if isinstance(raw_path, str) else ""
549
+ resolved = (
550
+ self._resolve_user_path(candidate) if candidate else self.project_root
551
+ )
552
+ return str(resolved.resolve())
553
+
554
+ def _finalize_web_settings(self, settings: WebSettings) -> WebSettings:
555
+ settings.allowed_roots = self._normalize_allowed_roots(settings.allowed_roots)
556
+ if not settings.allowed_roots:
557
+ settings.allowed_roots = self.default_allowed_roots()
558
+ inferred_provider = self._infer_provider_from_base_url(settings.llm.base_url)
559
+ if not settings.llm.provider and inferred_provider:
560
+ settings.llm = settings.llm.model_copy(
561
+ update={"provider": inferred_provider}
562
+ )
563
+ settings.session_defaults.default_project_path = self.resolve_project_path(
564
+ settings.session_defaults.default_project_path
565
+ )
566
+ settings.session_defaults.channel_project_path = self.resolve_project_path(
567
+ settings.session_defaults.channel_project_path
568
+ )
569
+ if not settings.codex.launcher_command:
570
+ settings.codex.launcher_command = "codex app-server --listen stdio://"
571
+ settings.codex.remote_connections = self._normalize_remote_connections(
572
+ settings.codex.remote_connections
573
+ )
574
+ remote_ids = {connection.id for connection in settings.codex.remote_connections}
575
+ if settings.codex.active_remote_connection_id not in remote_ids:
576
+ settings.codex.active_remote_connection_id = ""
577
+ known_hosts = {
578
+ "local",
579
+ *(f"ssh:{connection_id}" for connection_id in remote_ids),
580
+ }
581
+ settings.codex.projects = [
582
+ project
583
+ for project in settings.codex.projects
584
+ if project.root_paths and project.host_id in known_hosts
585
+ ]
586
+ settings.codex.projectless_thread_ids = list(
587
+ dict.fromkeys(
588
+ [
589
+ *settings.codex.projectless_thread_ids,
590
+ *self._desktop_projectless_thread_ids(),
591
+ ]
592
+ )
593
+ )
594
+ project_ids = {project.id for project in settings.codex.projects}
595
+ settings.codex.thread_project_assignments = {
596
+ thread_id: assignment
597
+ for thread_id, assignment in settings.codex.thread_project_assignments.items()
598
+ if assignment.project_id in project_ids
599
+ }
600
+ return settings
601
+
602
+ def _desktop_projectless_thread_ids(self) -> list[str]:
603
+ state_path = self.home_dir / ".codex" / ".codex-global-state.json"
604
+ if not state_path.exists():
605
+ return []
606
+ try:
607
+ payload = json.loads(state_path.read_text(encoding="utf-8"))
608
+ except (OSError, json.JSONDecodeError):
609
+ return []
610
+ candidates = payload.get("projectless-thread-ids")
611
+ if not isinstance(candidates, list):
612
+ persisted_atoms = payload.get("electron-persisted-atom-state")
613
+ if isinstance(persisted_atoms, dict):
614
+ candidates = persisted_atoms.get("projectless-thread-ids")
615
+ if not isinstance(candidates, list):
616
+ return []
617
+ return list(
618
+ dict.fromkeys(
619
+ thread_id.strip()
620
+ for thread_id in candidates
621
+ if isinstance(thread_id, str) and thread_id.strip()
622
+ )
623
+ )
624
+
625
+ def _resolve_user_path(self, raw_path: str) -> Path:
626
+ if raw_path == "~":
627
+ return self.home_dir.resolve()
628
+ if raw_path.startswith("~/"):
629
+ return (self.home_dir / raw_path[2:]).resolve()
630
+ candidate = Path(raw_path).expanduser()
631
+ if candidate.is_absolute():
632
+ return candidate.resolve()
633
+ return (self.project_root / candidate).resolve()
634
+
635
+ def _write_json(self, path: Path, payload: dict[str, Any]) -> None:
636
+ path.parent.mkdir(parents=True, exist_ok=True)
637
+ path.write_text(
638
+ json.dumps(payload, ensure_ascii=False, indent=2),
639
+ encoding="utf-8",
640
+ )
641
+ try:
642
+ os.chmod(path, 0o600)
643
+ except PermissionError:
644
+ pass
645
+
646
+ def _path_marker(self, path: Path) -> tuple[bool, int, int]:
647
+ try:
648
+ stat = path.stat()
649
+ except FileNotFoundError:
650
+ return (False, 0, 0)
651
+ return (True, stat.st_mtime_ns, stat.st_size)