1use std::collections::HashSet;
2use std::io::Error;
3use std::net::Ipv4Addr;
4use std::sync::Arc;
5use std::sync::RwLock;
6
7use http::HeaderValue;
8use http::header::CACHE_CONTROL;
9use http::header::CONTENT_SECURITY_POLICY;
10use http::header::CONTENT_TYPE;
11use http::header::COOKIE;
12use http::header::HOST;
13use http::header::ORIGIN;
14use http::header::SET_COOKIE;
15use serde::Deserialize;
16use serde_json::Value;
17use serde_json::json;
18use tokio::io::AsyncReadExt;
19use tokio::io::AsyncWriteExt;
20use tokio::net::TcpListener;
21use tokio::net::TcpStream;
22use tokio::task::JoinSet;
23use tokio_util::sync::CancellationToken;
24use topcoat::Result;
25use topcoat::context::Cx;
26use topcoat::context::app_context;
27use topcoat::router::Body;
28use topcoat::router::IntoResponse;
29use topcoat::router::Response;
30use topcoat::router::Router;
31use topcoat::router::RouterBuilderDiscoverExt;
32use topcoat::router::forbidden;
33use topcoat::router::headers;
34use topcoat::router::not_found;
35use topcoat::router::page;
36use topcoat::router::path_param;
37use topcoat::router::route;
38use topcoat::router::to_bytes;
39use topcoat::router::unauthorized;
40use topcoat::router::uri;
41
42use crate::WebOptions;
43use crate::auth::WebAuth;
44use crate::bridge::AppServerBridge;
45use crate::components::DashboardDocumentData;
46use crate::components::DocumentData;
47use crate::components::bootstrap_document;
48use crate::components::dashboard_document;
49use crate::components::document;
50use crate::components::thread_title;
51use crate::components::transcript_fragment;
52use crate::daemon_config::AccountProfile;
53use crate::daemon_config::DaemonConfig;
54use crate::dashboard;
55use crate::directory;
56use crate::directory::DirectoryListRequest;
57use crate::network::authority;
58use crate::network::bind_listeners;
59use crate::server_support::canonicalize_cwd;
60use crate::server_support::chronological_turns;
61use crate::server_support::generate_secret;
62use crate::server_support::mcp_callback_url;
63use crate::server_support::query_value;
64use crate::server_support::thread_with_initial_turns;
65use crate::ssh_tunnel::ManagedSshTunnel;
66use crate::stream;
67
68const MAX_RPC_BODY_BYTES: usize = 64 * 1024 * 1024;
69const MAX_AUTH_BODY_BYTES: usize = 4 * 1024;
70const APP_JS: &str = include_str!("../assets/app.js");
71const APP_CSS: &str = include_str!("../assets/app.css");
72const BOOTSTRAP_JS: &str = include_str!("../assets/bootstrap.js");
73const HOME_JS: &str = include_str!("../assets/home.js");
74const SETTINGS_JS: &str = include_str!("../assets/settings.js");
75
76struct WebState {
77 auth: WebAuth,
78 allowed_authorities: HashSet<String>,
79 instance_id: String,
80 cwd: String,
81 bridge: Arc<AppServerBridge>,
82 events: Arc<stream::LiveEventHub>,
83 shutdown: CancellationToken,
84 mcp_callback_port: Option<u16>,
85 account_profile: String,
86 account_profile_name: String,
87 daemon_config: RwLock<DaemonConfig>,
88 proxy_active: bool,
89}
90
91#[topcoat::router::path_param]
92struct ThreadId(str);
93
94#[topcoat::router::path_param]
95struct InstanceId(str);
96
97#[topcoat::router::path_param]
98struct CallbackId(str);
99
100#[topcoat::router::path_param]
101struct SessionId(str);
102
103pub async fn run(options: WebOptions) -> std::io::Result<()> {
104 let explicit_cwd = options.cwd.is_some();
105 let cwd = options
106 .cwd
107 .map(canonicalize_cwd)
108 .transpose()?
109 .unwrap_or(std::env::current_dir()?.canonicalize()?);
110 let auth = WebAuth::load(options.reset_token)?;
111 let daemon_config = DaemonConfig::load(options.daemon_config.as_deref())?;
112 let mut ssh_tunnel = match daemon_config.ssh_tunnel.as_ref() {
113 Some(config) => Some(ManagedSshTunnel::start(config).await?),
114 None => None,
115 };
116 let requested_port = if options.port == 0 {
117 daemon_config.port.unwrap_or(0)
118 } else {
119 options.port
120 };
121 let (listeners, _port) = bind_listeners(requested_port).await?;
122 let addresses = listeners
123 .iter()
124 .filter_map(|listener| listener.local_addr().ok())
125 .collect::<Vec<_>>();
126 let mut config_overrides = options.config_overrides;
127 let callback_url = daemon_config
128 .mcp_oauth_callback_url
129 .clone()
130 .or_else(|| mcp_callback_url(&addresses));
131 let mcp_callback_port = if let Some(callback_url) = callback_url {
132 let callback_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
133 let callback_port = callback_listener.local_addr()?.port();
134 drop(callback_listener);
135 config_overrides.push(format!("mcp_oauth_callback_port={callback_port}"));
136 config_overrides.push(format!("mcp_oauth_callback_url={callback_url:?}"));
137 Some(callback_port)
138 } else {
139 None
140 };
141 let (account_profile, bridge, proxy_active) = start_account_bridge(
142 &daemon_config.accounts,
143 &options.app_server_command,
144 &cwd,
145 ssh_tunnel.as_ref().map(ManagedSshTunnel::proxy_url),
146 &config_overrides,
147 options.strict_config,
148 )
149 .await?;
150 let shutdown_token = CancellationToken::new();
151 let event_hub = stream::LiveEventHub::start(Arc::clone(&bridge)).await;
152 let mut allowed_authorities = addresses
153 .iter()
154 .map(|address| authority(*address))
155 .collect::<HashSet<_>>();
156 if let Some(port) = addresses.first().map(std::net::SocketAddr::port) {
157 allowed_authorities.insert(format!("localhost:{port}"));
158 }
159 let state = Arc::new(WebState {
160 auth,
161 allowed_authorities,
162 instance_id: generate_secret()[..16].to_string(),
163 cwd: directory::path_string(&cwd)?,
164 bridge: Arc::clone(&bridge),
165 events: event_hub,
166 shutdown: shutdown_token.clone(),
167 mcp_callback_port,
168 account_profile: account_profile.label.clone(),
169 account_profile_name: account_profile.name.clone(),
170 daemon_config: RwLock::new(daemon_config),
171 proxy_active,
172 });
173 let router = Router::builder()
174 .discover()
175 .app_context(Arc::clone(&state))
176 .build();
177 let service = topcoat::router::RouterService::new(router);
178 let mut servers = JoinSet::new();
179 for listener in listeners {
180 let service = service.clone();
181 let shutdown_token = shutdown_token.clone();
182 servers.spawn(async move {
183 topcoat::serve_until(listener, service, async move {
184 shutdown_token.cancelled().await;
185 })
186 .await
187 });
188 }
189
190 let startup_path = if explicit_cwd {
191 let return_to = format!("/new?cwd={}", urlencoding::encode(&state.cwd));
192 format!("/?returnTo={}", urlencoding::encode(&return_to))
193 } else {
194 "/".to_string()
195 };
196 let urls = addresses
197 .iter()
198 .map(|address| {
199 format!(
200 "http://{}{}#bootstrap={}",
201 authority(*address),
202 startup_path,
203 state.auth.bootstrap_token()
204 )
205 })
206 .collect::<Vec<_>>();
207 println!("\nCodex Web\n");
208 for url in &urls {
209 println!(" {url}");
210 }
211 println!("\nThis private link grants the server user's Codex and filesystem access.\n");
212 println!("Account: {}", state.account_profile);
213 println!(
214 "OpenAI proxy: {}\n",
215 if state.proxy_active {
216 "enabled"
217 } else {
218 "disabled"
219 }
220 );
221
222 if options.open_browser
223 && let Some(url) = urls.iter().find(|url| url.starts_with("http://127.0.0.1:"))
224 && let Err(error) = webbrowser::open(url)
225 {
226 tracing::warn!(%error, "failed to open Codex Web in a browser");
227 }
228
229 tokio::select! {
230 signal = tokio::signal::ctrl_c() => signal?,
231 result = servers.join_next() => {
232 match result {
233 Some(Ok(Ok(()))) | None => {}
234 Some(Ok(Err(error))) => return Err(error),
235 Some(Err(error)) => return Err(Error::other(error)),
236 }
237 }
238 }
239 shutdown_token.cancel();
240 servers.abort_all();
244 while let Some(result) = servers.join_next().await {
245 match result {
246 Ok(Ok(())) => {}
247 Ok(Err(error)) => tracing::warn!(%error, "Codex Web listener stopped"),
248 Err(error) if error.is_cancelled() => {}
249 Err(error) => tracing::warn!(%error, "Codex Web listener task failed"),
250 }
251 }
252 bridge.shutdown().await;
253 if let Some(tunnel) = ssh_tunnel.as_mut() {
254 tunnel.shutdown().await;
255 }
256 Ok(())
257}
258
259async fn start_account_bridge(
260 accounts: &[AccountProfile],
261 command: &crate::AppServerCommand,
262 cwd: &std::path::Path,
263 ssh_proxy: Option<&str>,
264 config_overrides: &[String],
265 strict_config: bool,
266) -> std::io::Result<(AccountProfile, Arc<AppServerBridge>, bool)> {
267 let mut failures = Vec::new();
268 for profile in accounts {
269 std::fs::create_dir_all(&profile.codex_home)?;
270 let proxy = if profile.use_ssh_tunnel {
271 ssh_proxy
272 } else {
273 profile.proxy.as_deref()
274 };
275 let mut overrides = config_overrides.to_vec();
276 if proxy.is_some() {
277 overrides.push("features.respect_system_proxy=true".to_string());
278 }
279 let bridge = match AppServerBridge::start(
280 command,
281 cwd,
282 &profile.codex_home,
283 proxy,
284 &overrides,
285 strict_config,
286 )
287 .await
288 {
289 Ok(bridge) => bridge,
290 Err(error) => {
291 failures.push(format!("{}: {error}", profile.name));
292 continue;
293 }
294 };
295 let account = bridge
296 .request("account/read", json!({ "refreshToken": false }))
297 .await;
298 let available = account.as_ref().is_ok_and(|result| {
299 result
300 .get("account")
301 .is_some_and(|account| !account.is_null())
302 || result.get("requiresOpenaiAuth").and_then(Value::as_bool) == Some(false)
303 });
304 if available && !account_rate_limited(&bridge).await {
305 return Ok((profile.clone(), bridge, proxy.is_some()));
306 }
307 failures.push(format!(
308 "{}: {}",
309 profile.name,
310 if available {
311 "rate limit exhausted"
312 } else {
313 "not signed in"
314 }
315 ));
316 bridge.shutdown().await;
317 }
318
319 let profile = accounts
320 .first()
321 .ok_or_else(|| Error::new(std::io::ErrorKind::InvalidInput, "no accounts configured"))?;
322 let proxy = if profile.use_ssh_tunnel {
323 ssh_proxy
324 } else {
325 profile.proxy.as_deref()
326 };
327 let mut overrides = config_overrides.to_vec();
328 if proxy.is_some() {
329 overrides.push("features.respect_system_proxy=true".to_string());
330 }
331 tracing::warn!(attempts = %failures.join("; "), "all configured accounts need attention; using the first account for login");
332 let bridge = AppServerBridge::start(
333 command,
334 cwd,
335 &profile.codex_home,
336 proxy,
337 &overrides,
338 strict_config,
339 )
340 .await?;
341 Ok((profile.clone(), bridge, proxy.is_some()))
342}
343
344async fn account_rate_limited(bridge: &Arc<AppServerBridge>) -> bool {
345 let Ok(result) = bridge.request("account/rateLimits/read", json!({})).await else {
346 return false;
347 };
348 let snapshot = result.get("rateLimits").unwrap_or(&result);
349 snapshot
350 .get("rateLimitReachedType")
351 .is_some_and(|value| !value.is_null())
352 || snapshot.get("spendControlReached").and_then(Value::as_bool) == Some(true)
353 || ["primary", "secondary"].into_iter().any(|window| {
354 snapshot
355 .get(window)
356 .and_then(|window| window.get("usedPercent"))
357 .and_then(Value::as_i64)
358 .is_some_and(|used| used >= 100)
359 })
360}
361
362#[page("/")]
363async fn home(cx: &Cx) -> Result {
364 public_state(cx)?;
365 bootstrap_document(cx).await
366}
367
368#[page("/i/{instance_id}")]
369async fn dashboard_page(cx: &Cx) -> Result {
370 let state = authorized_state(cx)?;
371 let data = dashboard::load(&state.bridge).await;
372 let accounts = account_profiles(state)?;
373 let base_path = base_path(state);
374 dashboard_document(
375 cx,
376 DashboardDocumentData {
377 base_path: &base_path,
378 projects: &data.projects,
379 threads: &data.threads,
380 account: data.account.as_ref(),
381 truncated: data.truncated,
382 profile_label: &state.account_profile,
383 proxy_active: state.proxy_active,
384 mcp_servers: &data.mcp_servers,
385 accounts: &accounts,
386 active_account: &state.account_profile_name,
387 },
388 )
389 .await
390}
391
392#[page("/i/{instance_id}/new")]
393async fn new_thread_page(cx: &Cx) -> Result {
394 let state = authorized_state(cx)?;
395 let selected_cwd = query_value(uri(cx).query().unwrap_or_default(), "cwd")
396 .map(|encoded| urlencoding::decode(&encoded).map(std::borrow::Cow::into_owned))
397 .transpose()
398 .map_err(Error::other)?
399 .map(|path| directory::canonical_directory(&path))
400 .transpose()?
401 .map(|path| directory::path_string(&path))
402 .transpose()?;
403 render_page(cx, state, None, selected_cwd.as_deref()).await
404}
405
406#[page("/i/{instance_id}/thread/{thread_id}")]
407async fn thread_page(cx: &Cx) -> Result {
408 let state = authorized_state(cx)?;
409 render_page(cx, state, Some(path_param::<ThreadId>(cx)), None).await
410}
411
412#[route(GET "/assets/app.js")]
413async fn app_js(cx: &Cx) -> Result<Response> {
414 public_state(cx)?;
415 static_asset(cx, "text/javascript; charset=utf-8", APP_JS)
416}
417
418#[route(GET "/assets/app.css")]
419async fn app_css(cx: &Cx) -> Result<Response> {
420 public_state(cx)?;
421 static_asset(cx, "text/css; charset=utf-8", APP_CSS)
422}
423
424#[route(GET "/assets/bootstrap.js")]
425async fn bootstrap_js(cx: &Cx) -> Result<Response> {
426 public_state(cx)?;
427 static_asset(cx, "text/javascript; charset=utf-8", BOOTSTRAP_JS)
428}
429
430#[route(GET "/assets/home.js")]
431async fn home_js(cx: &Cx) -> Result<Response> {
432 public_state(cx)?;
433 static_asset(cx, "text/javascript; charset=utf-8", HOME_JS)
434}
435
436#[route(GET "/assets/settings.js")]
437async fn settings_js(cx: &Cx) -> Result<Response> {
438 public_state(cx)?;
439 static_asset(cx, "text/javascript; charset=utf-8", SETTINGS_JS)
440}
441
442#[route(POST "/auth/exchange")]
443async fn exchange_token(cx: &Cx, body: Body) -> Result<Response> {
444 let state = public_state(cx)?;
445 authorize_mutation(cx, state)?;
446 let bytes = to_bytes(body, MAX_AUTH_BODY_BYTES)
447 .await
448 .map_err(Error::other)?;
449 let payload: Value = serde_json::from_slice(&bytes)?;
450 let token = payload
451 .get("token")
452 .and_then(Value::as_str)
453 .ok_or_else(unauthorized)?;
454 if !state.auth.verify_bootstrap(token) {
455 return Err(unauthorized().into());
456 }
457 let base_path = base_path(state);
458 let mut response = json_response(
459 cx,
460 json!({
461 "authenticated": true,
462 "basePath": base_path,
463 }),
464 )?;
465 response.headers_mut().insert(
466 SET_COOKIE,
467 HeaderValue::from_str(&state.auth.session_cookie(&base_path)?).map_err(Error::other)?,
468 );
469 Ok(response)
470}
471
472#[route(GET "/i/{instance_id}/events")]
473async fn event_stream(cx: &Cx) -> Result<Response> {
474 let state = authorized_state(cx)?;
475 let last_event_id = headers(cx)
476 .get("last-event-id")
477 .and_then(|value| value.to_str().ok())
478 .and_then(|value| value.parse().ok())
479 .or_else(|| {
480 query_value(uri(cx).query().unwrap_or_default(), "after")
481 .and_then(|value| value.parse().ok())
482 });
483 Ok(stream::response(Arc::clone(&state.events), last_event_id).await)
484}
485
486#[route(GET "/oauth/callback/{callback_id}")]
487async fn mcp_oauth_callback(cx: &Cx) -> Result<Response> {
488 let state = public_state(cx)?;
489 let callback_port = state
490 .mcp_callback_port
491 .ok_or_else(|| Error::other("remote MCP OAuth callback is not configured"))?;
492 let callback_id = path_param::<CallbackId>(cx);
493 let query = uri(cx).query().unwrap_or_default();
494 if !valid_callback_id(callback_id) || query.bytes().any(|byte| byte.is_ascii_control()) {
495 return Err(not_found().into());
496 }
497 let callback_path = format!("/oauth/callback/{callback_id}?{query}");
498 let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, callback_port))
499 .await
500 .map_err(Error::other)?;
501 stream
502 .write_all(
503 format!(
504 "GET {callback_path} HTTP/1.1\r\nHost: 127.0.0.1:{callback_port}\r\nConnection: close\r\n\r\n"
505 )
506 .as_bytes(),
507 )
508 .await
509 .map_err(Error::other)?;
510 let mut encoded = Vec::new();
511 stream
512 .take(64 * 1024)
513 .read_to_end(&mut encoded)
514 .await
515 .map_err(Error::other)?;
516 let forwarded = String::from_utf8_lossy(&encoded);
517 let body = forwarded
518 .split_once("\r\n\r\n")
519 .map(|(_, body)| body)
520 .unwrap_or("MCP OAuth callback returned an invalid response")
521 .to_string();
522 let mut response = body.into_response(cx)?;
523 response.headers_mut().insert(
524 CONTENT_TYPE,
525 HeaderValue::from_static("text/plain; charset=utf-8"),
526 );
527 response
528 .headers_mut()
529 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
530 response.headers_mut().insert(
531 CONTENT_SECURITY_POLICY,
532 HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"),
533 );
534 Ok(response)
535}
536
537#[route(GET "/i/{instance_id}/api/session/{session_id}")]
538async fn session_fragment(cx: &Cx) -> Result<Response> {
539 let state = authorized_state(cx)?;
540 let session_id = path_param::<SessionId>(cx);
541 let base_seq = state.events.watermark();
542 let active_request = async {
543 if session_id == "new" {
544 None
545 } else {
546 let paginated = state
547 .bridge
548 .request(
549 "thread/resume",
550 json!({
551 "threadId": session_id,
552 "excludeTurns": true,
553 "initialTurnsPage": {
554 "limit": 30,
555 "sortDirection": "desc",
556 "itemsView": "full"
557 }
558 }),
559 )
560 .await;
561 match paginated {
562 Ok(response) => Some(response),
563 Err(_) => state
564 .bridge
565 .request("thread/resume", json!({ "threadId": session_id }))
566 .await
567 .ok(),
568 }
569 }
570 };
571 let (active, approvals) =
572 tokio::join!(active_request, state.bridge.outstanding_server_requests(),);
573 let active_thread = active.as_ref().and_then(|response| response.get("thread"));
574 let hydrated_thread = active.as_ref().and_then(thread_with_initial_turns);
575 let display_thread = hydrated_thread.as_ref().or(active_thread);
576 let fragment = transcript_fragment(cx, display_thread, &approvals).await?;
577 let active_turn_id = display_thread
578 .and_then(|active_thread| active_thread.get("turns"))
579 .and_then(Value::as_array)
580 .and_then(|turns| {
581 turns
582 .iter()
583 .rev()
584 .find(|turn| turn.get("status").and_then(Value::as_str) == Some("inProgress"))
585 })
586 .and_then(|turn| turn.get("id"))
587 .and_then(Value::as_str)
588 .unwrap_or_default();
589 let payload = json!({
590 "html": fragment.render(cx),
591 "baseSeq": base_seq,
592 "instanceId": state.instance_id,
593 "nextCursor": active.as_ref().and_then(|response| response.pointer("/initialTurnsPage/nextCursor")),
594 "threadId": display_thread
595 .and_then(|active_thread| active_thread.get("id"))
596 .and_then(Value::as_str)
597 .unwrap_or_default(),
598 "activeTurnId": active_turn_id,
599 "title": display_thread.map(thread_title).unwrap_or("New task"),
600 "cwd": display_thread
601 .and_then(|active_thread| active_thread.get("cwd"))
602 .and_then(Value::as_str)
603 .unwrap_or_default(),
604 "model": active
605 .as_ref()
606 .and_then(|response| response.get("model"))
607 .and_then(Value::as_str),
608 "reasoningEffort": active
609 .as_ref()
610 .and_then(|response| response.get("reasoningEffort"))
611 .and_then(Value::as_str),
612 "permissionMode": permission_mode(active.as_ref()),
613 });
614 let mut response = serde_json::to_vec(&payload)?.into_response(cx)?;
615 response
616 .headers_mut()
617 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
618 response
619 .headers_mut()
620 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
621 Ok(response)
622}
623
624#[route(GET "/i/{instance_id}/api/session/{session_id}/turns")]
625async fn earlier_turns(cx: &Cx) -> Result<Response> {
626 let state = authorized_state(cx)?;
627 let session_id = path_param::<SessionId>(cx);
628 let cursor = query_value(uri(cx).query().unwrap_or_default(), "cursor");
629 let page = state
630 .bridge
631 .request(
632 "thread/turns/list",
633 json!({
634 "threadId": session_id,
635 "cursor": cursor,
636 "limit": 30,
637 "sortDirection": "desc",
638 "itemsView": "full"
639 }),
640 )
641 .await
642 .map_err(Error::other)?;
643 let thread_value = json!({ "turns": chronological_turns(page.get("data")) });
644 let fragment = transcript_fragment(cx, Some(&thread_value), &[]).await?;
645 json_response(
646 cx,
647 json!({
648 "html": fragment.render(cx),
649 "nextCursor": page.get("nextCursor").cloned().unwrap_or(Value::Null)
650 }),
651 )
652}
653
654#[route(POST "/i/{instance_id}/api/shutdown")]
655async fn shutdown_process(cx: &Cx) -> Result<Response> {
656 let state = authorized_state(cx)?;
657 authorize_mutation(cx, state)?;
658 state.shutdown.cancel();
659 json_response(cx, json!({ "stopping": true }))
660}
661
662#[route(POST "/i/{instance_id}/api/rpc")]
663async fn rpc(cx: &Cx, body: Body) -> Result<Response> {
664 let state = authorized_state(cx)?;
665 authorize_mutation(cx, state)?;
666 let bytes = to_bytes(body, MAX_RPC_BODY_BYTES)
667 .await
668 .map_err(Error::other)?;
669 let mut message: Value = serde_json::from_slice(&bytes)?;
670 if message.get("method").and_then(Value::as_str) == Some("thread/start")
671 && let Some(cwd) = message
672 .pointer("/params/cwd")
673 .and_then(Value::as_str)
674 .map(ToOwned::to_owned)
675 {
676 let canonical = directory::canonical_directory(&cwd)?;
677 if let Some(params) = message.get_mut("params").and_then(Value::as_object_mut) {
678 params.insert(
679 "cwd".to_string(),
680 Value::String(directory::path_string(&canonical)?),
681 );
682 }
683 }
684 let envelope = if let Some(method) = message.get("method").and_then(Value::as_str) {
685 state
686 .bridge
687 .request_envelope(
688 method,
689 message.get("params").cloned().unwrap_or_else(|| json!({})),
690 )
691 .await
692 .map_err(Error::other)?
693 } else {
694 state.bridge.respond(message).await.map_err(Error::other)?;
695 json!({ "result": {} })
696 };
697 let mut response = serde_json::to_vec(&envelope)?.into_response(cx)?;
698 response
699 .headers_mut()
700 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
701 response
702 .headers_mut()
703 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
704 Ok(response)
705}
706
707#[derive(Deserialize)]
708#[serde(
709 tag = "action",
710 rename_all = "camelCase",
711 rename_all_fields = "camelCase"
712)]
713enum AccountProfilesRequest {
714 Add {
715 name: String,
716 label: String,
717 codex_home: std::path::PathBuf,
718 proxy: Option<String>,
719 use_ssh_tunnel: bool,
720 },
721 Remove {
722 name: String,
723 },
724 Move {
725 name: String,
726 direction: AccountMoveDirection,
727 },
728}
729
730#[derive(Deserialize)]
731#[serde(rename_all = "camelCase")]
732enum AccountMoveDirection {
733 Up,
734 Down,
735}
736
737#[route(POST "/i/{instance_id}/api/account-profiles")]
738async fn update_account_profiles(cx: &Cx, body: Body) -> Result<Response> {
739 let state = authorized_state(cx)?;
740 authorize_mutation(cx, state)?;
741 let bytes = to_bytes(body, MAX_AUTH_BODY_BYTES)
742 .await
743 .map_err(Error::other)?;
744 let request: AccountProfilesRequest = serde_json::from_slice(&bytes)?;
745 let mut config = state
746 .daemon_config
747 .write()
748 .map_err(|_| Error::other("daemon config lock is poisoned"))?;
749 let mut accounts = config.accounts.clone();
750 match request {
751 AccountProfilesRequest::Add {
752 name,
753 label,
754 codex_home,
755 proxy,
756 use_ssh_tunnel,
757 } => accounts.push(AccountProfile {
758 label: if label.trim().is_empty() {
759 name.clone()
760 } else {
761 label
762 },
763 name,
764 codex_home,
765 proxy: proxy.filter(|value| !value.trim().is_empty()),
766 use_ssh_tunnel,
767 }),
768 AccountProfilesRequest::Remove { name } => {
769 if config
770 .accounts
771 .iter()
772 .find(|account| account.name == name)
773 .is_some_and(|account| account.name == state.account_profile_name)
774 {
775 return json_response(
776 cx,
777 json!({ "error": "the active account cannot be removed until the daemon restarts" }),
778 );
779 }
780 accounts.retain(|account| account.name != name);
781 }
782 AccountProfilesRequest::Move { name, direction } => {
783 let Some(index) = accounts.iter().position(|account| account.name == name) else {
784 return json_response(cx, json!({ "error": "account profile not found" }));
785 };
786 let target = match direction {
787 AccountMoveDirection::Up => index.saturating_sub(1),
788 AccountMoveDirection::Down => (index + 1).min(accounts.len() - 1),
789 };
790 accounts.swap(index, target);
791 }
792 }
793 match config.save_accounts(accounts) {
794 Ok(()) => json_response(cx, json!({ "restartRequired": true })),
795 Err(error) => json_response(cx, json!({ "error": error.to_string() })),
796 }
797}
798
799#[route(POST "/i/{instance_id}/api/fs/list")]
800async fn list_directory(cx: &Cx, body: Body) -> Result<Response> {
801 let state = authorized_state(cx)?;
802 authorize_mutation(cx, state)?;
803 let bytes = to_bytes(body, MAX_AUTH_BODY_BYTES)
804 .await
805 .map_err(Error::other)?;
806 let request: DirectoryListRequest = serde_json::from_slice(&bytes)?;
807 match tokio::task::spawn_blocking(move || directory::list(request)).await {
808 Ok(Ok(listing)) => {
809 let payload = serde_json::to_value(listing)?;
810 json_response(cx, payload)
811 }
812 Ok(Err(error)) => json_response(cx, json!({ "error": error.to_string() })),
813 Err(error) => Err(Error::other(error).into()),
814 }
815}
816
817async fn render_page(
818 cx: &Cx,
819 state: &WebState,
820 thread_id: Option<&str>,
821 selected_cwd: Option<&str>,
822) -> Result {
823 let base_seq = state.events.watermark();
824 let threads_request = state.bridge.request(
825 "thread/list",
826 json!({ "limit": 100, "sortKey": "recency_at", "sortDirection": "desc" }),
827 );
828 let models_request = state.bridge.request("model/list", json!({ "limit": 100 }));
829 let collaboration_modes_request = state.bridge.request("collaborationMode/list", json!({}));
830 let mcp_servers_request = state.bridge.request(
831 "mcpServerStatus/list",
832 json!({ "limit": 100, "detail": "toolsAndAuthOnly" }),
833 );
834 let active_request = async {
835 match thread_id {
836 Some(thread_id) => state
837 .bridge
838 .request(
839 "thread/resume",
840 json!({
841 "threadId": thread_id,
842 "excludeTurns": true,
843 "initialTurnsPage": {
844 "limit": 30,
845 "sortDirection": "desc",
846 "itemsView": "full"
847 }
848 }),
849 )
850 .await
851 .ok(),
852 None => None,
853 }
854 };
855 let approvals_request = state.bridge.outstanding_server_requests();
856 let (threads, models, collaboration_modes, mcp_servers, active, approvals) = tokio::join!(
857 threads_request,
858 models_request,
859 collaboration_modes_request,
860 mcp_servers_request,
861 active_request,
862 approvals_request,
863 );
864 let threads = threads.unwrap_or_else(|_| json!({ "data": [] }));
865 let models = models.unwrap_or_else(|_| json!({ "data": [] }));
866 let collaboration_modes = collaboration_modes.unwrap_or_else(|_| json!({ "data": [] }));
867 let mcp_servers = mcp_servers.unwrap_or_else(|_| json!({ "data": [] }));
868 let hydrated_thread = active.as_ref().and_then(thread_with_initial_turns);
869 let active_thread = hydrated_thread
870 .as_ref()
871 .or_else(|| active.as_ref().and_then(|response| response.get("thread")));
872 let workspace_cwd = page_workspace_cwd(active_thread, selected_cwd, &state.cwd);
873 let base_path = base_path(state);
874 let accounts = account_profiles(state)?;
875 document(
876 cx,
877 DocumentData {
878 base_path: &base_path,
879 instance_id: &state.instance_id,
880 base_seq,
881 threads: threads
882 .get("data")
883 .and_then(Value::as_array)
884 .map(Vec::as_slice)
885 .unwrap_or_default(),
886 active_thread,
887 active_model: active
888 .as_ref()
889 .and_then(|response| response.get("model"))
890 .and_then(Value::as_str)
891 .unwrap_or_default(),
892 active_effort: active
893 .as_ref()
894 .and_then(|response| response.get("reasoningEffort"))
895 .and_then(Value::as_str)
896 .unwrap_or_default(),
897 active_permission_mode: permission_mode(active.as_ref()),
898 models: models
899 .get("data")
900 .and_then(Value::as_array)
901 .map(Vec::as_slice)
902 .unwrap_or_default(),
903 collaboration_modes: collaboration_modes
904 .get("data")
905 .and_then(Value::as_array)
906 .map(Vec::as_slice)
907 .unwrap_or_default(),
908 approvals: &approvals,
909 workspace_cwd,
910 mcp_servers: mcp_servers
911 .get("data")
912 .and_then(Value::as_array)
913 .map(Vec::as_slice)
914 .unwrap_or_default(),
915 accounts: &accounts,
916 active_account: &state.account_profile_name,
917 initial_next_cursor: active
918 .as_ref()
919 .and_then(|response| response.pointer("/initialTurnsPage/nextCursor"))
920 .and_then(Value::as_str),
921 },
922 )
923 .await
924}
925
926fn page_workspace_cwd<'a>(
927 active_thread: Option<&'a Value>,
928 selected_cwd: Option<&'a str>,
929 daemon_cwd: &'a str,
930) -> &'a str {
931 active_thread
932 .and_then(|thread| thread.get("cwd"))
933 .and_then(Value::as_str)
934 .or(selected_cwd)
935 .unwrap_or(daemon_cwd)
936}
937
938fn account_profiles(state: &WebState) -> Result<Vec<AccountProfile>> {
939 state
940 .daemon_config
941 .read()
942 .map(|config| config.accounts.clone())
943 .map_err(|_| Error::other("daemon config lock is poisoned").into())
944}
945
946fn permission_mode(response: Option<&Value>) -> &'static str {
947 let profile_id = response
948 .and_then(|response| response.pointer("/activePermissionProfile/id"))
949 .and_then(Value::as_str);
950 match profile_id {
951 Some(":danger-full-access") => "full-access",
952 Some(":read-only") => "read-only",
953 Some(":workspace") => "workspace",
954 _ => {
955 let approval = response
956 .and_then(|response| response.get("approvalPolicy"))
957 .and_then(Value::as_str);
958 let sandbox = response
959 .and_then(|response| response.pointer("/sandbox/type"))
960 .and_then(Value::as_str);
961 match (approval, sandbox) {
962 (Some("never"), Some("danger-full-access")) => "full-access",
963 (_, Some("read-only")) => "read-only",
964 _ => "workspace",
965 }
966 }
967 }
968}
969
970fn authorized_state(cx: &Cx) -> Result<&WebState> {
971 let state = instance_state(cx)?;
972 if !is_authorized(cx, state) {
973 return Err(not_found().into());
974 }
975 Ok(state)
976}
977
978fn instance_state(cx: &Cx) -> Result<&WebState> {
979 let state = public_state(cx)?;
980 if path_param::<InstanceId>(cx) != state.instance_id {
981 return Err(not_found().into());
982 }
983 Ok(state)
984}
985
986fn base_path(state: &WebState) -> String {
987 format!("/i/{}", state.instance_id)
988}
989
990fn valid_callback_id(callback_id: &str) -> bool {
991 callback_id.len() == 12
992 && callback_id
993 .bytes()
994 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
995}
996
997fn public_state(cx: &Cx) -> Result<&WebState> {
998 let state: &Arc<WebState> = app_context(cx);
999 let host = headers(cx)
1000 .get(HOST)
1001 .and_then(|value| value.to_str().ok())
1002 .ok_or_else(forbidden)?;
1003 if !state.allowed_authorities.contains(host) {
1004 return Err(forbidden().into());
1005 }
1006 Ok(state)
1007}
1008
1009fn is_authorized(cx: &Cx, state: &WebState) -> bool {
1010 state.auth.authorize_cookie_header(
1011 headers(cx)
1012 .get(COOKIE)
1013 .and_then(|value| value.to_str().ok()),
1014 )
1015}
1016
1017fn authorize_mutation(cx: &Cx, state: &WebState) -> Result<()> {
1018 let host = headers(cx)
1019 .get(HOST)
1020 .and_then(|value| value.to_str().ok())
1021 .ok_or_else(forbidden)?;
1022 let origin = headers(cx)
1023 .get(ORIGIN)
1024 .and_then(|value| value.to_str().ok())
1025 .ok_or_else(forbidden)?;
1026 if !state.allowed_authorities.contains(host) || origin != format!("http://{host}") {
1027 return Err(forbidden().into());
1028 }
1029 Ok(())
1030}
1031
1032fn static_asset(cx: &Cx, content_type: &'static str, body: &'static str) -> Result<Response> {
1033 let mut response = body.into_response(cx)?;
1034 response
1035 .headers_mut()
1036 .insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
1037 response
1038 .headers_mut()
1039 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1040 Ok(response)
1041}
1042
1043fn json_response(cx: &Cx, payload: Value) -> Result<Response> {
1044 let mut response = serde_json::to_vec(&payload)?.into_response(cx)?;
1045 response
1046 .headers_mut()
1047 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
1048 response
1049 .headers_mut()
1050 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1051 Ok(response)
1052}
1053
1054#[cfg(test)]
1055#[path = "server_tests.rs"]
1056mod tests;