Skip to main content

codex_app_server_client/rest/
routes.rs

1use std::{
2    collections::HashSet,
3    convert::Infallible,
4    pin::Pin,
5    sync::{Arc, Mutex as StdMutex},
6    task::{Context, Poll},
7};
8
9use axum::{
10    extract::{rejection::JsonRejection, DefaultBodyLimit, Path, Query, State},
11    http::StatusCode,
12    response::{
13        sse::{Event as SseEvent, KeepAlive, Sse},
14        IntoResponse, Json, Response,
15    },
16    routing::{delete, get, post},
17    Router,
18};
19use futures_core::Stream;
20use serde::Deserialize;
21use tokio::sync::{OwnedSemaphorePermit, Semaphore};
22
23use crate::Error;
24
25use super::{
26    backend::CodexRestBackend,
27    types::{
28        RestApprovalPolicy, RestBackend, RestCallBody, RestCallRequest, RestClientOptions,
29        RestError, RestErrorReplyRequest, RestErrorResponse, RestEventResponse, RestFuture,
30        RestHealthResponse, RestListSessionsResponse, RestRequestReplyResultRequest, RestResult,
31        RestRouterOptions, RestSessionCreateRequest, RestTextTurnRequest,
32    },
33};
34
35#[derive(Clone)]
36struct RestState {
37    backend: Arc<dyn RestBackend>,
38    options: RestRouterOptions,
39    one_shot_gate: Arc<Semaphore>,
40    active_polls: Arc<StdMutex<HashSet<String>>>,
41}
42
43struct ActivePollGuard {
44    active_polls: Arc<StdMutex<HashSet<String>>>,
45    session_id: String,
46}
47
48impl Drop for ActivePollGuard {
49    fn drop(&mut self) {
50        let mut active = self
51            .active_polls
52            .lock()
53            .unwrap_or_else(|poisoned| poisoned.into_inner());
54        active.remove(&self.session_id);
55    }
56}
57
58/// Builds a conservative REST router backed by real `codex app-server` processes.
59///
60/// The default router exposes only non-executing health and compatibility
61/// routes. Use [`text_turn_router`], [`trusted_bridge_router`], or
62/// [`router_with_options`] when the router is mounted behind a trusted authz
63/// boundary and should execute Codex work.
64pub fn router() -> Router {
65    router_with_options(RestRouterOptions::default())
66}
67
68/// Builds a router with the one-shot text-turn helper enabled.
69pub fn text_turn_router() -> Router {
70    router_with_options(RestRouterOptions::text_turn())
71}
72
73/// Builds a trusted full bridge router backed by real `codex app-server` processes.
74///
75/// Routes:
76/// - `GET /health`
77/// - `GET /v1/health`
78/// - `GET /v1/compatibility`
79/// - `POST /v1/text-turn`
80/// - `POST /v1/call/{method}`
81/// - `GET|POST /v1/sessions`
82/// - `DELETE /v1/sessions/{sessionId}`
83/// - `POST /v1/sessions/{sessionId}/call/{method}`
84/// - `GET /v1/sessions/{sessionId}/events`
85/// - `GET /v1/sessions/{sessionId}/events/stream` (Server-Sent Events
86///   counterpart to `.../events`: same payloads, one per `data:` frame,
87///   streamed instead of long-polled one at a time)
88/// - `POST /v1/sessions/{sessionId}/requests/{requestKey}/result`
89/// - `POST /v1/sessions/{sessionId}/requests/{requestKey}/error`
90pub fn trusted_bridge_router() -> Router {
91    router_with_options(RestRouterOptions::trusted_bridge())
92}
93
94/// Builds a REST router backed by real `codex app-server` processes and options.
95pub fn router_with_options(options: RestRouterOptions) -> Router {
96    router_with_backend_and_options(
97        CodexRestBackend::with_limits(options.limits.clone()),
98        options,
99    )
100}
101
102/// Builds a REST router with a caller-provided backend.
103pub fn router_with_backend<B>(backend: B) -> Router
104where
105    B: RestBackend,
106{
107    router_with_backend_and_options(backend, RestRouterOptions::default())
108}
109
110/// Builds a REST router with a caller-provided backend and options.
111pub fn router_with_backend_and_options<B>(backend: B, options: RestRouterOptions) -> Router
112where
113    B: RestBackend,
114{
115    router_with_backend_arc_and_options(Arc::new(backend), options)
116}
117
118/// Builds a REST router from a shared backend trait object.
119pub fn router_with_backend_arc(backend: Arc<dyn RestBackend>) -> Router {
120    router_with_backend_arc_and_options(backend, RestRouterOptions::default())
121}
122
123/// Builds a REST router from a shared backend trait object and options.
124pub fn router_with_backend_arc_and_options(
125    backend: Arc<dyn RestBackend>,
126    options: RestRouterOptions,
127) -> Router {
128    let state = RestState {
129        backend,
130        one_shot_gate: Arc::new(Semaphore::new(options.limits.max_one_shot_concurrency)),
131        active_polls: Arc::default(),
132        options: options.clone(),
133    };
134
135    let router = Router::new()
136        .route("/health", get(health))
137        .route("/v1/health", get(health))
138        .route("/v1/compatibility", get(compatibility));
139
140    let router = if options.enable_text_turn_route {
141        router.route("/v1/text-turn", post(text_turn))
142    } else {
143        router
144    };
145
146    let router = if options.enable_bridge_routes {
147        router
148            .route("/v1/call/{*method}", post(call_method))
149            .route("/v1/sessions", get(list_sessions).post(create_session))
150            .route("/v1/sessions/{session_id}", delete(delete_session))
151            .route(
152                "/v1/sessions/{session_id}/call/{*method}",
153                post(call_session_method),
154            )
155            .route("/v1/sessions/{session_id}/events", get(poll_event))
156            .route(
157                "/v1/sessions/{session_id}/events/stream",
158                get(poll_event_stream),
159            )
160            .route(
161                "/v1/sessions/{session_id}/requests/{request_key}/result",
162                post(reply_request_result),
163            )
164            .route(
165                "/v1/sessions/{session_id}/requests/{request_key}/error",
166                post(reply_request_error),
167            )
168    } else {
169        router
170    };
171
172    // Cap request bodies before any handler runs. axum applies a silent 2 MiB
173    // default otherwise; this makes the bound explicit, tunable
174    // (`RestLimits::max_request_body_bytes`), and consistent with every other
175    // documented limit. Applied to the whole router, but only the body-reading
176    // POST routes can actually trip it - the health/compat/event GETs have no
177    // body to measure.
178    router
179        .layer(DefaultBodyLimit::max(options.limits.max_request_body_bytes))
180        .with_state(state)
181}
182
183#[derive(Clone, Debug, Deserialize)]
184#[serde(rename_all = "camelCase")]
185struct EventQuery {
186    timeout_ms: Option<u64>,
187}
188
189async fn health() -> impl IntoResponse {
190    Json(RestHealthResponse {
191        status: "ok".to_owned(),
192    })
193}
194
195async fn compatibility(State(state): State<RestState>) -> impl IntoResponse {
196    match state.backend.compatibility_report().await {
197        Ok(response) => Json(response).into_response(),
198        Err(error) => rest_error(error),
199    }
200}
201
202async fn text_turn(
203    State(state): State<RestState>,
204    body: std::result::Result<Json<RestTextTurnRequest>, JsonRejection>,
205) -> Response {
206    let Json(request) = match body {
207        Ok(body) => body,
208        Err(error) => return invalid_json(error),
209    };
210    if request.prompt.trim().is_empty() {
211        return invalid_request("prompt must not be empty");
212    }
213    if let Err(error) = validate_text_turn_request(&state.options, &request) {
214        return rest_error(error);
215    }
216
217    let _permit = match acquire_one_shot_permit(&state) {
218        Ok(permit) => permit,
219        Err(error) => return rest_error(error),
220    };
221    match state.backend.run_text_turn(request).await {
222        Ok(response) => Json(response).into_response(),
223        Err(error) => rest_error(error),
224    }
225}
226
227async fn call_method(
228    State(state): State<RestState>,
229    Path(method): Path<String>,
230    body: std::result::Result<Json<RestCallBody>, JsonRejection>,
231) -> Response {
232    let method = match normalize_method(method) {
233        Some(method) => method,
234        None => return invalid_request("method path must not be empty"),
235    };
236    let Json(body) = match body {
237        Ok(body) => body,
238        Err(error) => return invalid_json(error),
239    };
240    if let Err(error) = validate_client_options(&state.options, body.client.as_ref()) {
241        return rest_error(error);
242    }
243    let _permit = match acquire_one_shot_permit(&state) {
244        Ok(permit) => permit,
245        Err(error) => return rest_error(error),
246    };
247    let request = RestCallRequest {
248        session_id: None,
249        method,
250        params: body.params,
251        client: body.client,
252    };
253    match state.backend.call_method(request).await {
254        Ok(response) => Json(response).into_response(),
255        Err(error) => rest_error(error),
256    }
257}
258
259async fn create_session(
260    State(state): State<RestState>,
261    body: std::result::Result<Json<RestSessionCreateRequest>, JsonRejection>,
262) -> Response {
263    let Json(request) = match body {
264        Ok(body) => body,
265        Err(error) => return invalid_json(error),
266    };
267    if let Err(error) = validate_client_options(&state.options, request.client.as_ref()) {
268        return rest_error(error);
269    }
270    match state.backend.list_sessions().await {
271        Ok(sessions) if sessions.len() >= state.options.limits.max_sessions => {
272            return rest_error(RestError::RateLimited(format!(
273                "maximum REST session count ({}) reached",
274                state.options.limits.max_sessions
275            )));
276        }
277        Ok(_) => {}
278        Err(error) => return rest_error(error),
279    }
280    match state.backend.create_session(request).await {
281        Ok(response) => Json(response).into_response(),
282        Err(error) => rest_error(error),
283    }
284}
285
286async fn list_sessions(State(state): State<RestState>) -> Response {
287    match state.backend.list_sessions().await {
288        Ok(sessions) => Json(RestListSessionsResponse { sessions }).into_response(),
289        Err(error) => rest_error(error),
290    }
291}
292
293async fn delete_session(
294    State(state): State<RestState>,
295    Path(session_id): Path<String>,
296) -> Response {
297    match state.backend.delete_session(session_id).await {
298        Ok(response) => Json(response).into_response(),
299        Err(error) => rest_error(error),
300    }
301}
302
303async fn call_session_method(
304    State(state): State<RestState>,
305    Path((session_id, method)): Path<(String, String)>,
306    body: std::result::Result<Json<RestCallBody>, JsonRejection>,
307) -> Response {
308    let method = match normalize_method(method) {
309        Some(method) => method,
310        None => return invalid_request("method path must not be empty"),
311    };
312    let Json(body) = match body {
313        Ok(body) => body,
314        Err(error) => return invalid_json(error),
315    };
316    if body.client.is_some() {
317        return rest_error(RestError::InvalidRequest(
318            "`client` options are only accepted when creating a session or making one-shot calls"
319                .to_owned(),
320        ));
321    }
322    let request = RestCallRequest {
323        session_id: Some(session_id),
324        method,
325        params: body.params,
326        client: body.client,
327    };
328    match state.backend.call_method(request).await {
329        Ok(response) => Json(response).into_response(),
330        Err(error) => rest_error(error),
331    }
332}
333
334async fn poll_event(
335    State(state): State<RestState>,
336    Path(session_id): Path<String>,
337    Query(query): Query<EventQuery>,
338) -> Response {
339    let _guard = match acquire_poll_guard(&state, &session_id) {
340        Ok(guard) => guard,
341        Err(error) => return rest_error(error),
342    };
343    let timeout_ms = Some(clamp_poll_timeout_ms(
344        &state.options,
345        query
346            .timeout_ms
347            .unwrap_or(state.options.limits.max_poll_timeout.as_millis() as u64),
348    ));
349    match state.backend.poll_event(session_id, timeout_ms).await {
350        Ok(response) => Json(response).into_response(),
351        Err(error) => rest_error(error),
352    }
353}
354
355/// Server-Sent Events counterpart to [`poll_event`].
356///
357/// Where `GET .../events` returns exactly one [`RestEventResponse`] per
358/// request and requires the caller to poll again, this repeatedly calls
359/// [`RestBackend::poll_event`] and streams every response - including
360/// [`RestEventResponse::Timeout`] - as its own `data:` frame, tagged with an
361/// `event:` field matching the JSON payload's own `event` discriminant
362/// (`notification`, `request`, `closed`, or `timeout`). Forwarding
363/// `Timeout` rather than swallowing it is the deliberate choice for "what
364/// happens on a poll timeout" here: it gives a browser `EventSource`
365/// listener an application-level heartbeat with the exact same shape it
366/// would see from one long-poll cycle, on top of (not instead of) the
367/// wire-level `KeepAlive` comments axum injects if the stream is ever
368/// `Pending` for longer than [`RestLimits::sse_keep_alive_interval`].
369///
370/// The stream ends after forwarding [`RestEventResponse::Closed`] or a
371/// backend error (surfaced as a terminal `event: error` frame carrying the
372/// same [`RestErrorResponse`] shape the non-streaming routes return, minus
373/// the HTTP status code, since `200 OK` is already committed by the time
374/// any frame can be written). It never ends on `Timeout` - that's the
375/// "long-poll but as a stream" contract this route exists to provide.
376///
377/// [`ActivePollGuard`] is held for the entire lifetime of the stream (moved
378/// into the returned [`EventPollStream`], not the handler's local scope),
379/// so a session can have at most one active consumer whether that's a
380/// long-poll or an SSE stream, never both at once. Dropping the response
381/// body - which happens when the client disconnects - drops the
382/// [`EventPollStream`] and, with it, the guard, exactly as if the
383/// long-poll caller had stopped polling.
384///
385/// [`RestLimits`]: super::types::RestLimits
386async fn poll_event_stream(
387    State(state): State<RestState>,
388    Path(session_id): Path<String>,
389    Query(query): Query<EventQuery>,
390) -> Response {
391    let guard = match acquire_poll_guard(&state, &session_id) {
392        Ok(guard) => guard,
393        Err(error) => return rest_error(error),
394    };
395    let timeout_ms = clamp_stream_poll_timeout_ms(
396        &state.options,
397        query
398            .timeout_ms
399            .unwrap_or(state.options.limits.max_poll_timeout.as_millis() as u64),
400    );
401    let stream = EventPollStream {
402        backend: state.backend.clone(),
403        session_id,
404        timeout_ms,
405        pending: None,
406        guard: Some(guard),
407        done: false,
408        synchronous_polls: 0,
409    };
410    Sse::new(stream)
411        .keep_alive(KeepAlive::new().interval(state.options.limits.sse_keep_alive_interval))
412        .into_response()
413}
414
415/// [`Stream`] backing [`poll_event_stream`]: repeatedly drives
416/// [`RestBackend::poll_event`], turning each resolved [`RestEventResponse`]
417/// (or terminal error) into one SSE frame, and holds the session's
418/// [`ActivePollGuard`] for as long as the stream itself is alive.
419///
420/// Manually implemented (rather than built from `futures_util::stream`
421/// combinators or an `async_stream::stream!` block) to avoid adding either
422/// dependency - see README.md on this crate's minimal-dependency-graph
423/// rule. All fields are `Unpin` (an `Arc`, a `String`, a `u64`, an
424/// `Option<ActivePollGuard>`, and an `Option<Pin<Box<dyn Future + Send>>>`
425/// are all `Unpin` regardless of what's inside the box), so `poll_next` can
426/// use a plain `&mut Self` via `Pin::get_mut` instead of `pin_project`.
427struct EventPollStream {
428    backend: Arc<dyn RestBackend>,
429    session_id: String,
430    timeout_ms: u64,
431    /// The in-flight `poll_event` call, if one has been started and hasn't
432    /// resolved yet. Replaced with a fresh call after every resolution
433    /// until the stream reaches a terminal state.
434    pending: Option<RestFuture<RestEventResponse>>,
435    /// Held until a terminal event (`Closed` or an error) is reached, or
436    /// the stream itself is dropped (client disconnect) - whichever comes
437    /// first. `take()`n explicitly on the terminal-event path so the guard
438    /// is released the moment the session becomes pollable again, rather
439    /// than waiting for hyper to finish tearing down the now-finished body.
440    guard: Option<ActivePollGuard>,
441    done: bool,
442    /// Consecutive `poll_next` calls that resolved a `poll_event` future
443    /// without the executor ever getting control back. Reset to 0 whenever
444    /// this stream parks (returns `Poll::Pending`). See
445    /// [`YIELD_AFTER_SYNCHRONOUS_POLLS`].
446    synchronous_polls: u32,
447}
448
449/// How many back-to-back synchronously-resolving `poll_event` calls this
450/// stream will service before forcing itself to yield to the executor.
451///
452/// [`RestBackend`] is a public trait that host applications implement, and a
453/// `poll_event` future is free to resolve on its first poll - a backend with
454/// an already-buffered event does exactly that, and it is the natural shape
455/// for one. When that happens this stream never returns `Poll::Pending`, so
456/// the task driving it never hands control back to the runtime: on a
457/// current-thread runtime (which `codex-app-server-rest` uses) one such
458/// stream starves every other task in the process, and there is no upper
459/// bound on how long it continues.
460///
461/// The default [`crate::rest::CodexRestBackend`] happens not to trigger the
462/// unbounded case, because its `poll_event` bottoms out in tokio's mpsc and
463/// timer primitives, which park at least once. That is incidental, not a
464/// contract this stream can rely on: a bursty session (events already queued)
465/// and any third-party backend both break the assumption. So the bound is
466/// enforced here, where the loop actually lives, rather than being left to
467/// the backend's good behavior.
468///
469/// The value only needs to be small enough to bound starvation and large
470/// enough not to add a park to every event on a busy-but-well-behaved
471/// session; 32 is comfortably both.
472const YIELD_AFTER_SYNCHRONOUS_POLLS: u32 = 32;
473
474impl Stream for EventPollStream {
475    type Item = Result<SseEvent, Infallible>;
476
477    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
478        let this = self.get_mut();
479        if this.done {
480            return Poll::Ready(None);
481        }
482        if this.synchronous_polls >= YIELD_AFTER_SYNCHRONOUS_POLLS {
483            // Hand control back to the executor, then ask to be polled again
484            // immediately. This is what `tokio::task::yield_now()` does; it is
485            // open-coded because `poll_next` is a manual `poll` fn and cannot
486            // `.await`.
487            this.synchronous_polls = 0;
488            cx.waker().wake_by_ref();
489            return Poll::Pending;
490        }
491        if this.pending.is_none() {
492            this.pending = Some(
493                this.backend
494                    .poll_event(this.session_id.clone(), Some(this.timeout_ms)),
495            );
496        }
497        let pending = this
498            .pending
499            .as_mut()
500            .expect("pending future was just populated above");
501        match pending.as_mut().poll(cx) {
502            Poll::Pending => {
503                // Parked on the backend: the executor has control back, so the
504                // starvation budget is spent and resets.
505                this.synchronous_polls = 0;
506                Poll::Pending
507            }
508            Poll::Ready(result) => {
509                this.pending = None;
510                this.synchronous_polls = this.synchronous_polls.saturating_add(1);
511                match result {
512                    Ok(response) => {
513                        if matches!(response, RestEventResponse::Closed) {
514                            this.done = true;
515                            this.guard = None;
516                        }
517                        Poll::Ready(Some(Ok(sse_event_from_response(&response))))
518                    }
519                    Err(error) => {
520                        this.done = true;
521                        this.guard = None;
522                        Poll::Ready(Some(Ok(sse_error_event(error))))
523                    }
524                }
525            }
526        }
527    }
528}
529
530/// Renders a [`RestEventResponse`] as the SSE frame [`poll_event_stream`]
531/// sends for it: `event:` set to the JSON payload's own `event`
532/// discriminant, `data:` set to that same JSON payload serialized exactly
533/// as the long-poll route would return it.
534fn sse_event_from_response(response: &RestEventResponse) -> SseEvent {
535    let event_name = match response {
536        RestEventResponse::Notification { .. } => "notification",
537        RestEventResponse::Request { .. } => "request",
538        RestEventResponse::Closed => "closed",
539        RestEventResponse::Timeout => "timeout",
540    };
541    let payload = serde_json::to_string(response)
542        .unwrap_or_else(|_| r#"{"event":"internal_error"}"#.to_owned());
543    SseEvent::default().event(event_name).data(payload)
544}
545
546/// Renders a terminal backend error as an `event: error` SSE frame. Reuses
547/// [`rest_error_response`] so the JSON body matches what the non-streaming
548/// routes would return in `Err`, minus the HTTP status code - the response
549/// has already committed to `200 OK` by the time any frame can be written.
550fn sse_error_event(error: RestError) -> SseEvent {
551    let (_status, body) = rest_error_response(error);
552    let payload =
553        serde_json::to_string(&body).unwrap_or_else(|_| r#"{"error":"internal"}"#.to_owned());
554    SseEvent::default().event("error").data(payload)
555}
556
557async fn reply_request_result(
558    State(state): State<RestState>,
559    Path((session_id, request_key)): Path<(String, String)>,
560    body: std::result::Result<Json<RestRequestReplyResultRequest>, JsonRejection>,
561) -> Response {
562    let Json(body) = match body {
563        Ok(body) => body,
564        Err(error) => return invalid_json(error),
565    };
566    match state
567        .backend
568        .reply_request_result(session_id, request_key, body)
569        .await
570    {
571        Ok(response) => Json(response).into_response(),
572        Err(error) => rest_error(error),
573    }
574}
575
576async fn reply_request_error(
577    State(state): State<RestState>,
578    Path((session_id, request_key)): Path<(String, String)>,
579    body: std::result::Result<Json<RestErrorReplyRequest>, JsonRejection>,
580) -> Response {
581    let Json(body) = match body {
582        Ok(body) => body,
583        Err(error) => return invalid_json(error),
584    };
585    match state
586        .backend
587        .reply_request_error(session_id, request_key, body)
588        .await
589    {
590        Ok(response) => Json(response).into_response(),
591        Err(error) => rest_error(error),
592    }
593}
594
595fn invalid_request(message: impl Into<String>) -> Response {
596    (
597        StatusCode::BAD_REQUEST,
598        Json(RestErrorResponse {
599            error: "invalid_request".to_owned(),
600            message: message.into(),
601            code: None,
602            data: None,
603        }),
604    )
605        .into_response()
606}
607
608fn invalid_json(error: JsonRejection) -> Response {
609    // A body that blew the `DefaultBodyLimit` (see
610    // `router_with_backend_arc_and_options`) surfaces here as a `JsonRejection`
611    // too, but it is a `413`, not a malformed-request `400` - reporting it as
612    // "invalid_json" would tell a caller their JSON was wrong when it was
613    // simply too big. The rejection knows its own status; trust it for the
614    // too-large case and keep the crate's `payload_too_large` error shape so it
615    // matches `RestError::PayloadTooLarge` responses from elsewhere.
616    if error.status() == StatusCode::PAYLOAD_TOO_LARGE {
617        return (
618            StatusCode::PAYLOAD_TOO_LARGE,
619            Json(RestErrorResponse {
620                error: "payload_too_large".to_owned(),
621                message: error.body_text(),
622                code: None,
623                data: None,
624            }),
625        )
626            .into_response();
627    }
628    (
629        StatusCode::BAD_REQUEST,
630        Json(RestErrorResponse {
631            error: "invalid_json".to_owned(),
632            message: error.body_text(),
633            code: None,
634            data: None,
635        }),
636    )
637        .into_response()
638}
639
640/// Maps a [`RestError`] to the `(status, body)` pair the non-streaming
641/// routes respond with. Split out from [`rest_error`] so
642/// [`sse_error_event`] can reuse the exact same body construction for its
643/// terminal SSE frame without duplicating every match arm - the SSE case
644/// just has nowhere to put the status code, since `200 OK` and the SSE
645/// content-type are already committed by the time a frame can be written.
646fn rest_error_response(error: RestError) -> (StatusCode, RestErrorResponse) {
647    /// The shape every variant below shares except the two `Client` ones:
648    /// a status, a stable machine-readable `error` kind, and the message the
649    /// variant already carries. Factored out so adding a variant is one line
650    /// and cannot accidentally disagree with its neighbours about the
651    /// `code`/`data` fields, which only the JSON-RPC passthrough populates.
652    fn simple(status: StatusCode, kind: &str, message: String) -> (StatusCode, RestErrorResponse) {
653        (
654            status,
655            RestErrorResponse {
656                error: kind.to_owned(),
657                message,
658                code: None,
659                data: None,
660            },
661        )
662    }
663
664    match error {
665        RestError::NotFound(message) => simple(StatusCode::NOT_FOUND, "not_found", message),
666        RestError::Gone(message) => simple(StatusCode::GONE, "gone", message),
667        RestError::Forbidden(message) => simple(StatusCode::FORBIDDEN, "forbidden", message),
668        RestError::InvalidRequest(message) => {
669            simple(StatusCode::BAD_REQUEST, "invalid_request", message)
670        }
671        RestError::RateLimited(message) => {
672            simple(StatusCode::TOO_MANY_REQUESTS, "rate_limited", message)
673        }
674        RestError::Conflict(message) => simple(StatusCode::CONFLICT, "conflict", message),
675        RestError::TimedOut(message) => simple(StatusCode::GATEWAY_TIMEOUT, "timeout", message),
676        RestError::PayloadTooLarge(message) => {
677            simple(StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large", message)
678        }
679        RestError::Internal(message) => {
680            simple(StatusCode::INTERNAL_SERVER_ERROR, "internal", message)
681        }
682        // The only variants that don't fit `simple`: a JSON-RPC error from the
683        // app-server is passed through with its own `code`/`data` intact.
684        RestError::Client(Error::Rpc {
685            code,
686            message,
687            data,
688        }) => (
689            StatusCode::BAD_GATEWAY,
690            RestErrorResponse {
691                error: "json_rpc_error".to_owned(),
692                message,
693                code: Some(code),
694                data,
695            },
696        ),
697        RestError::Client(error) => simple(
698            StatusCode::BAD_GATEWAY,
699            "codex_app_server_error",
700            error.to_string(),
701        ),
702    }
703}
704
705fn rest_error(error: RestError) -> Response {
706    let (status, body) = rest_error_response(error);
707    (status, Json(body)).into_response()
708}
709
710fn validate_text_turn_request(
711    options: &RestRouterOptions,
712    request: &RestTextTurnRequest,
713) -> RestResult<()> {
714    if !options.allow_unsafe_client_options
715        && matches!(request.approval_policy, Some(RestApprovalPolicy::AllowAll))
716    {
717        return Err(RestError::Forbidden(
718            "`approvalPolicy: allow_all` requires a trusted REST bridge".to_owned(),
719        ));
720    }
721    validate_client_options(options, request.client.as_ref())
722}
723
724fn validate_client_options(
725    options: &RestRouterOptions,
726    client: Option<&RestClientOptions>,
727) -> RestResult<()> {
728    if options.allow_unsafe_client_options {
729        return Ok(());
730    }
731    let Some(client) = client else {
732        return Ok(());
733    };
734    if client.command.is_some() || !client.extra_args.is_empty() || !client.config.is_empty() {
735        return Err(RestError::Forbidden(
736            "client command, extraArgs, and config overrides require a trusted REST bridge"
737                .to_owned(),
738        ));
739    }
740    Ok(())
741}
742
743fn acquire_one_shot_permit(state: &RestState) -> RestResult<OwnedSemaphorePermit> {
744    state
745        .one_shot_gate
746        .clone()
747        .try_acquire_owned()
748        .map_err(|_| {
749            RestError::RateLimited(format!(
750                "maximum one-shot REST call concurrency ({}) reached",
751                state.options.limits.max_one_shot_concurrency
752            ))
753        })
754}
755
756fn acquire_poll_guard(state: &RestState, session_id: &str) -> RestResult<ActivePollGuard> {
757    let mut active = state
758        .active_polls
759        .lock()
760        .unwrap_or_else(|poisoned| poisoned.into_inner());
761    if !active.insert(session_id.to_owned()) {
762        return Err(RestError::Conflict(format!(
763            "an event poll is already active for session `{session_id}`"
764        )));
765    }
766    Ok(ActivePollGuard {
767        active_polls: state.active_polls.clone(),
768        session_id: session_id.to_owned(),
769    })
770}
771
772fn clamp_poll_timeout_ms(options: &RestRouterOptions, timeout_ms: u64) -> u64 {
773    let max = options.limits.max_poll_timeout.as_millis() as u64;
774    timeout_ms.min(max)
775}
776
777/// Like [`clamp_poll_timeout_ms`], but also enforces
778/// [`RestLimits::min_stream_poll_timeout`] as a floor.
779///
780/// The long-poll route deliberately has no floor: there, `timeoutMs=0` means
781/// "tell me if an event is already waiting, otherwise return immediately",
782/// which is a legitimate non-blocking-poll idiom, and each repeat costs the
783/// caller a full HTTP round trip that paces it.
784///
785/// The streaming route has no such pacing - it is one request that loops
786/// server-side for as long as the client reads - and no equivalent use for a
787/// zero timeout, since a stream by definition wants to wait for the next
788/// event. Without a floor, `?timeoutMs=0` turns one request into an unbounded
789/// run of back-to-back `poll_event` calls, each costing a session-map lock, an
790/// idle-session scan, a heap-allocated future, and a serialized `timeout`
791/// frame - all to report that nothing happened.
792///
793/// This costs real events nothing: `poll_event` resolves as soon as an event
794/// arrives, so the timeout only bounds the *idle* wait. The floor therefore
795/// only limits how often an idle stream emits `timeout` frames.
796fn clamp_stream_poll_timeout_ms(options: &RestRouterOptions, timeout_ms: u64) -> u64 {
797    let min = options.limits.min_stream_poll_timeout.as_millis() as u64;
798    let max = options.limits.max_poll_timeout.as_millis() as u64;
799    // `min` wins a misconfigured `min > max`: the floor is a resource-abuse
800    // backstop, and clamping into an empty range has to fail toward "poll less
801    // often", never toward the unbounded spin the floor exists to prevent.
802    timeout_ms.min(max).max(min)
803}
804
805fn normalize_method(method: String) -> Option<String> {
806    let method = method.trim_matches('/').trim();
807    (!method.is_empty()).then(|| method.to_owned())
808}