Skip to main content

codex_web/
server.rs

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