1use std::io::Error;
2use std::net::Ipv4Addr;
3use std::sync::Arc;
4
5use http::HeaderValue;
6use http::header::CACHE_CONTROL;
7use http::header::CONTENT_TYPE;
8use serde_json::Value;
9use serde_json::json;
10use tokio::io::AsyncReadExt;
11use tokio::io::AsyncWriteExt;
12use tokio::net::TcpListener;
13use tokio::net::TcpStream;
14use tokio::task::JoinSet;
15use tokio_util::sync::CancellationToken;
16use topcoat::Result;
17use topcoat::context::Cx;
18use topcoat::context::app_context;
19use topcoat::router::Body;
20use topcoat::router::IntoResponse;
21use topcoat::router::Response;
22use topcoat::router::Router;
23use topcoat::router::RouterBuilderDiscoverExt;
24use topcoat::router::headers;
25use topcoat::router::not_found;
26use topcoat::router::page;
27use topcoat::router::path_param;
28use topcoat::router::route;
29use topcoat::router::to_bytes;
30use topcoat::router::uri;
31
32use crate::WebOptions;
33use crate::bridge::AppServerBridge;
34use crate::components::DocumentData;
35use crate::components::document;
36use crate::components::thread_title;
37use crate::components::transcript_fragment;
38use crate::network::authority;
39use crate::network::bind_listeners;
40use crate::server_support::canonicalize_cwd;
41use crate::server_support::chronological_turns;
42use crate::server_support::generate_secret;
43use crate::server_support::mcp_callback_url;
44use crate::server_support::query_value;
45use crate::server_support::thread_with_initial_turns;
46use crate::stream;
47
48const MAX_RPC_BODY_BYTES: usize = 64 * 1024 * 1024;
49const APP_JS: &str = include_str!("../assets/app.js");
50const APP_CSS: &str = include_str!("../assets/app.css");
51
52struct WebState {
53 secret: String,
54 instance_id: String,
55 cwd: String,
56 bridge: Arc<AppServerBridge>,
57 events: Arc<stream::LiveEventHub>,
58 shutdown: CancellationToken,
59 mcp_callback_port: Option<u16>,
60}
61
62#[topcoat::router::path_param]
63struct Secret(str);
64
65#[topcoat::router::path_param]
66struct ThreadId(str);
67
68#[topcoat::router::path_param]
69struct CallbackId(str);
70
71#[topcoat::router::path_param]
72struct SessionId(str);
73
74pub async fn run(options: WebOptions) -> std::io::Result<()> {
75 let cwd = canonicalize_cwd(options.cwd)?;
76 let secret = generate_secret();
77 let (listeners, _port) = bind_listeners(options.port).await?;
78 let addresses = listeners
79 .iter()
80 .filter_map(|listener| listener.local_addr().ok())
81 .collect::<Vec<_>>();
82 let mut config_overrides = options.config_overrides;
83 let mcp_callback_port = if let Some(callback_url) = mcp_callback_url(&addresses, &secret) {
84 let callback_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
85 let callback_port = callback_listener.local_addr()?.port();
86 drop(callback_listener);
87 config_overrides.push(format!("mcp_oauth_callback_port={callback_port}"));
88 config_overrides.push(format!("mcp_oauth_callback_url={callback_url:?}"));
89 Some(callback_port)
90 } else {
91 None
92 };
93 let bridge = AppServerBridge::start(
94 &options.codex_executable,
95 &cwd,
96 &config_overrides,
97 options.strict_config,
98 )
99 .await?;
100 let shutdown_token = CancellationToken::new();
101 let event_hub = stream::LiveEventHub::start(Arc::clone(&bridge)).await;
102 let state = Arc::new(WebState {
103 secret: secret.clone(),
104 instance_id: generate_secret()[..16].to_string(),
105 cwd: cwd.to_string_lossy().into_owned(),
106 bridge: Arc::clone(&bridge),
107 events: event_hub,
108 shutdown: shutdown_token.clone(),
109 mcp_callback_port,
110 });
111 let router = Router::builder().discover().app_context(state).build();
112 let service = topcoat::router::RouterService::new(router);
113 let mut servers = JoinSet::new();
114 for listener in listeners {
115 let service = service.clone();
116 let shutdown_token = shutdown_token.clone();
117 servers.spawn(async move {
118 topcoat::serve_until(listener, service, async move {
119 shutdown_token.cancelled().await;
120 })
121 .await
122 });
123 }
124
125 let urls = addresses
126 .iter()
127 .map(|address| format!("http://{}/s/{secret}", authority(*address)))
128 .collect::<Vec<_>>();
129 println!("\nCodex Web\n");
130 for url in &urls {
131 println!(" {url}");
132 }
133 println!("\nAnyone with a link can control this Codex process.\n");
134
135 if options.open_browser
136 && let Some(url) = urls.iter().find(|url| url.starts_with("http://127.0.0.1:"))
137 && let Err(error) = webbrowser::open(url)
138 {
139 tracing::warn!(%error, "failed to open Codex Web in a browser");
140 }
141
142 tokio::select! {
143 signal = tokio::signal::ctrl_c() => signal?,
144 result = servers.join_next() => {
145 match result {
146 Some(Ok(Ok(()))) | None => {}
147 Some(Ok(Err(error))) => return Err(error),
148 Some(Err(error)) => return Err(Error::other(error)),
149 }
150 }
151 }
152 shutdown_token.cancel();
153 servers.abort_all();
157 while let Some(result) = servers.join_next().await {
158 match result {
159 Ok(Ok(())) => {}
160 Ok(Err(error)) => tracing::warn!(%error, "Codex Web listener stopped"),
161 Err(error) if error.is_cancelled() => {}
162 Err(error) => tracing::warn!(%error, "Codex Web listener task failed"),
163 }
164 }
165 bridge.shutdown().await;
166 Ok(())
167}
168
169#[page("/s/{secret}")]
170async fn home(cx: &Cx) -> Result {
171 let state = authorized_state(cx)?;
172 render_page(cx, state, None).await
173}
174
175#[page("/s/{secret}/thread/{thread_id}")]
176async fn thread_page(cx: &Cx) -> Result {
177 let state = authorized_state(cx)?;
178 render_page(cx, state, Some(path_param::<ThreadId>(cx))).await
179}
180
181#[route(GET "/s/{secret}/assets/app.js")]
182async fn app_js(cx: &Cx) -> Result<Response> {
183 authorized_state(cx)?;
184 static_asset(cx, "text/javascript; charset=utf-8", APP_JS)
185}
186
187#[route(GET "/s/{secret}/assets/app.css")]
188async fn app_css(cx: &Cx) -> Result<Response> {
189 authorized_state(cx)?;
190 static_asset(cx, "text/css; charset=utf-8", APP_CSS)
191}
192
193#[route(GET "/s/{secret}/events")]
194async fn event_stream(cx: &Cx) -> Result<Response> {
195 let state = authorized_state(cx)?;
196 let last_event_id = headers(cx)
197 .get("last-event-id")
198 .and_then(|value| value.to_str().ok())
199 .and_then(|value| value.parse().ok())
200 .or_else(|| {
201 query_value(uri(cx).query().unwrap_or_default(), "after")
202 .and_then(|value| value.parse().ok())
203 });
204 Ok(stream::response(Arc::clone(&state.events), last_event_id).await)
205}
206
207#[route(GET "/s/{secret}/oauth/callback/{callback_id}")]
208async fn mcp_oauth_callback(cx: &Cx) -> Result<Response> {
209 let state = authorized_state(cx)?;
210 let callback_port = state
211 .mcp_callback_port
212 .ok_or_else(|| Error::other("remote MCP OAuth callback is not configured"))?;
213 let callback_id = path_param::<CallbackId>(cx);
214 let query = uri(cx).query().unwrap_or_default();
215 let callback_path = format!("/s/{}/oauth/callback/{callback_id}?{query}", state.secret);
216 let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, callback_port))
217 .await
218 .map_err(Error::other)?;
219 stream
220 .write_all(
221 format!(
222 "GET {callback_path} HTTP/1.1\r\nHost: 127.0.0.1:{callback_port}\r\nConnection: close\r\n\r\n"
223 )
224 .as_bytes(),
225 )
226 .await
227 .map_err(Error::other)?;
228 let mut encoded = Vec::new();
229 stream
230 .take(64 * 1024)
231 .read_to_end(&mut encoded)
232 .await
233 .map_err(Error::other)?;
234 let forwarded = String::from_utf8_lossy(&encoded);
235 let body = forwarded
236 .split_once("\r\n\r\n")
237 .map(|(_, body)| body)
238 .unwrap_or("MCP OAuth callback returned an invalid response")
239 .to_string();
240 let mut response = body.into_response(cx)?;
241 response.headers_mut().insert(
242 CONTENT_TYPE,
243 HeaderValue::from_static("text/html; charset=utf-8"),
244 );
245 response
246 .headers_mut()
247 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
248 Ok(response)
249}
250
251#[route(GET "/s/{secret}/api/session/{session_id}")]
252async fn session_fragment(cx: &Cx) -> Result<Response> {
253 let state = authorized_state(cx)?;
254 let session_id = path_param::<SessionId>(cx);
255 let base_seq = state.events.watermark();
256 let active_request = async {
257 if session_id == "new" {
258 None
259 } else {
260 let paginated = state
261 .bridge
262 .request(
263 "thread/resume",
264 json!({
265 "threadId": session_id,
266 "excludeTurns": true,
267 "initialTurnsPage": {
268 "limit": 30,
269 "sortDirection": "desc",
270 "itemsView": "full"
271 }
272 }),
273 )
274 .await;
275 match paginated {
276 Ok(response) => Some(response),
277 Err(_) => state
278 .bridge
279 .request("thread/resume", json!({ "threadId": session_id }))
280 .await
281 .ok(),
282 }
283 }
284 };
285 let (active, approvals) =
286 tokio::join!(active_request, state.bridge.outstanding_server_requests(),);
287 let active_thread = active.as_ref().and_then(|response| response.get("thread"));
288 let hydrated_thread = active.as_ref().and_then(thread_with_initial_turns);
289 let display_thread = hydrated_thread.as_ref().or(active_thread);
290 let fragment = transcript_fragment(cx, display_thread, &approvals).await?;
291 let active_turn_id = display_thread
292 .and_then(|active_thread| active_thread.get("turns"))
293 .and_then(Value::as_array)
294 .and_then(|turns| {
295 turns
296 .iter()
297 .rev()
298 .find(|turn| turn.get("status").and_then(Value::as_str) == Some("inProgress"))
299 })
300 .and_then(|turn| turn.get("id"))
301 .and_then(Value::as_str)
302 .unwrap_or_default();
303 let payload = json!({
304 "html": fragment.render(cx),
305 "baseSeq": base_seq,
306 "instanceId": state.instance_id,
307 "nextCursor": active.as_ref().and_then(|response| response.pointer("/initialTurnsPage/nextCursor")),
308 "threadId": display_thread
309 .and_then(|active_thread| active_thread.get("id"))
310 .and_then(Value::as_str)
311 .unwrap_or_default(),
312 "activeTurnId": active_turn_id,
313 "title": display_thread.map(thread_title).unwrap_or("New task"),
314 "cwd": display_thread
315 .and_then(|active_thread| active_thread.get("cwd"))
316 .and_then(Value::as_str)
317 .unwrap_or_default(),
318 "model": active
319 .as_ref()
320 .and_then(|response| response.get("model"))
321 .and_then(Value::as_str),
322 "reasoningEffort": active
323 .as_ref()
324 .and_then(|response| response.get("reasoningEffort"))
325 .and_then(Value::as_str),
326 "permissionMode": permission_mode(active.as_ref()),
327 });
328 let mut response = serde_json::to_vec(&payload)?.into_response(cx)?;
329 response
330 .headers_mut()
331 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
332 response
333 .headers_mut()
334 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
335 Ok(response)
336}
337
338#[route(GET "/s/{secret}/api/session/{session_id}/turns")]
339async fn earlier_turns(cx: &Cx) -> Result<Response> {
340 let state = authorized_state(cx)?;
341 let session_id = path_param::<SessionId>(cx);
342 let cursor = query_value(uri(cx).query().unwrap_or_default(), "cursor");
343 let page = state
344 .bridge
345 .request(
346 "thread/turns/list",
347 json!({
348 "threadId": session_id,
349 "cursor": cursor,
350 "limit": 30,
351 "sortDirection": "desc",
352 "itemsView": "full"
353 }),
354 )
355 .await
356 .map_err(Error::other)?;
357 let thread_value = json!({ "turns": chronological_turns(page.get("data")) });
358 let fragment = transcript_fragment(cx, Some(&thread_value), &[]).await?;
359 json_response(
360 cx,
361 json!({
362 "html": fragment.render(cx),
363 "nextCursor": page.get("nextCursor").cloned().unwrap_or(Value::Null)
364 }),
365 )
366}
367
368#[route(POST "/s/{secret}/api/shutdown")]
369async fn shutdown_process(cx: &Cx) -> Result<Response> {
370 let state = authorized_state(cx)?;
371 state.shutdown.cancel();
372 json_response(cx, json!({ "stopping": true }))
373}
374
375#[route(POST "/s/{secret}/api/rpc")]
376async fn rpc(cx: &Cx, body: Body) -> Result<Response> {
377 let state = authorized_state(cx)?;
378 let bytes = to_bytes(body, MAX_RPC_BODY_BYTES)
379 .await
380 .map_err(Error::other)?;
381 let message: Value = serde_json::from_slice(&bytes)?;
382 let envelope = if let Some(method) = message.get("method").and_then(Value::as_str) {
383 state
384 .bridge
385 .request_envelope(
386 method,
387 message.get("params").cloned().unwrap_or_else(|| json!({})),
388 )
389 .await
390 .map_err(Error::other)?
391 } else {
392 state.bridge.respond(message).await.map_err(Error::other)?;
393 json!({ "result": {} })
394 };
395 let mut response = serde_json::to_vec(&envelope)?.into_response(cx)?;
396 response
397 .headers_mut()
398 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
399 response
400 .headers_mut()
401 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
402 Ok(response)
403}
404
405async fn render_page(cx: &Cx, state: &WebState, thread_id: Option<&str>) -> Result {
406 let base_seq = state.events.watermark();
407 let threads_request = state.bridge.request(
408 "thread/list",
409 json!({ "limit": 100, "sortKey": "recency_at", "sortDirection": "desc" }),
410 );
411 let models_request = state.bridge.request("model/list", json!({ "limit": 100 }));
412 let collaboration_modes_request = state.bridge.request("collaborationMode/list", json!({}));
413 let mcp_servers_request = state.bridge.request(
414 "mcpServerStatus/list",
415 json!({ "limit": 100, "detail": "toolsAndAuthOnly" }),
416 );
417 let active_request = async {
418 match thread_id {
419 Some(thread_id) => state
420 .bridge
421 .request(
422 "thread/resume",
423 json!({
424 "threadId": thread_id,
425 "excludeTurns": true,
426 "initialTurnsPage": {
427 "limit": 30,
428 "sortDirection": "desc",
429 "itemsView": "full"
430 }
431 }),
432 )
433 .await
434 .ok(),
435 None => None,
436 }
437 };
438 let approvals_request = state.bridge.outstanding_server_requests();
439 let (threads, models, collaboration_modes, mcp_servers, active, approvals) = tokio::join!(
440 threads_request,
441 models_request,
442 collaboration_modes_request,
443 mcp_servers_request,
444 active_request,
445 approvals_request,
446 );
447 let threads = threads.unwrap_or_else(|_| json!({ "data": [] }));
448 let models = models.unwrap_or_else(|_| json!({ "data": [] }));
449 let collaboration_modes = collaboration_modes.unwrap_or_else(|_| json!({ "data": [] }));
450 let mcp_servers = mcp_servers.unwrap_or_else(|_| json!({ "data": [] }));
451 let hydrated_thread = active.as_ref().and_then(thread_with_initial_turns);
452 let active_thread = hydrated_thread
453 .as_ref()
454 .or_else(|| active.as_ref().and_then(|response| response.get("thread")));
455 document(
456 cx,
457 DocumentData {
458 secret: &state.secret,
459 instance_id: &state.instance_id,
460 base_seq,
461 threads: threads
462 .get("data")
463 .and_then(Value::as_array)
464 .map(Vec::as_slice)
465 .unwrap_or_default(),
466 active_thread,
467 active_model: active
468 .as_ref()
469 .and_then(|response| response.get("model"))
470 .and_then(Value::as_str)
471 .unwrap_or_default(),
472 active_effort: active
473 .as_ref()
474 .and_then(|response| response.get("reasoningEffort"))
475 .and_then(Value::as_str)
476 .unwrap_or_default(),
477 active_permission_mode: permission_mode(active.as_ref()),
478 models: models
479 .get("data")
480 .and_then(Value::as_array)
481 .map(Vec::as_slice)
482 .unwrap_or_default(),
483 collaboration_modes: collaboration_modes
484 .get("data")
485 .and_then(Value::as_array)
486 .map(Vec::as_slice)
487 .unwrap_or_default(),
488 approvals: &approvals,
489 workspace_cwd: &state.cwd,
490 mcp_servers: mcp_servers
491 .get("data")
492 .and_then(Value::as_array)
493 .map(Vec::as_slice)
494 .unwrap_or_default(),
495 initial_next_cursor: active
496 .as_ref()
497 .and_then(|response| response.pointer("/initialTurnsPage/nextCursor"))
498 .and_then(Value::as_str),
499 },
500 )
501 .await
502}
503
504fn permission_mode(response: Option<&Value>) -> &'static str {
505 let profile_id = response
506 .and_then(|response| response.pointer("/activePermissionProfile/id"))
507 .and_then(Value::as_str);
508 match profile_id {
509 Some(":danger-full-access") => "full-access",
510 Some(":read-only") => "read-only",
511 Some(":workspace") => "workspace",
512 _ => {
513 let approval = response
514 .and_then(|response| response.get("approvalPolicy"))
515 .and_then(Value::as_str);
516 let sandbox = response
517 .and_then(|response| response.pointer("/sandbox/type"))
518 .and_then(Value::as_str);
519 match (approval, sandbox) {
520 (Some("never"), Some("danger-full-access")) => "full-access",
521 (_, Some("read-only")) => "read-only",
522 _ => "workspace",
523 }
524 }
525 }
526}
527
528fn authorized_state(cx: &Cx) -> Result<&WebState> {
529 let state: &Arc<WebState> = app_context(cx);
530 if path_param::<Secret>(cx) != state.secret {
531 return Err(not_found().into());
532 }
533 Ok(state)
534}
535
536fn static_asset(cx: &Cx, content_type: &'static str, body: &'static str) -> Result<Response> {
537 let mut response = body.into_response(cx)?;
538 response
539 .headers_mut()
540 .insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
541 response
542 .headers_mut()
543 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
544 Ok(response)
545}
546
547fn json_response(cx: &Cx, payload: Value) -> Result<Response> {
548 let mut response = serde_json::to_vec(&payload)?.into_response(cx)?;
549 response
550 .headers_mut()
551 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
552 response
553 .headers_mut()
554 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
555 Ok(response)
556}
557
558#[cfg(test)]
559#[path = "server_tests.rs"]
560mod tests;