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