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/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)
|