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