Skip to main content

codex_web/
server.rs

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    // Event streams are intentionally long-lived, so a graceful listener drain can wait forever
225    // for browser connections after Ctrl-C. Stop accepting work, then cancel those connection
226    // tasks so the CLI returns promptly.
227    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;