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