Skip to main content

codex_app_server_client/rest/
types.rs

1use std::{collections::HashMap, env, future::Future, pin::Pin, time::Duration};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::{
7    CompatibilityReport, Error, SessionOptions, TextTurnResult, DEFAULT_EVENTS_CHANNEL_CAPACITY,
8};
9
10pub type RestResult<T> = std::result::Result<T, RestError>;
11pub type RestFuture<T> = Pin<Box<dyn Future<Output = RestResult<T>> + Send + 'static>>;
12
13/// REST router behavior knobs.
14///
15/// [`Default`] is intentionally non-executing: only health and compatibility are
16/// mounted. Enable the text-turn helper or raw bridge routes explicitly when the
17/// caller mounts the router behind its own authz boundary.
18#[derive(Clone, Debug, Default)]
19pub struct RestRouterOptions {
20    pub enable_text_turn_route: bool,
21    pub enable_bridge_routes: bool,
22    pub allow_unsafe_client_options: bool,
23    pub limits: RestLimits,
24}
25
26impl RestRouterOptions {
27    /// Enables the one-shot text-turn helper without raw callable/session routes.
28    pub fn text_turn() -> Self {
29        Self {
30            enable_text_turn_route: true,
31            enable_bridge_routes: false,
32            allow_unsafe_client_options: false,
33            limits: RestLimits::default(),
34        }
35    }
36
37    /// Enables the full raw callable bridge for trusted deployments.
38    pub fn trusted_bridge() -> Self {
39        Self {
40            enable_text_turn_route: true,
41            enable_bridge_routes: true,
42            allow_unsafe_client_options: false,
43            limits: RestLimits::default(),
44        }
45    }
46
47    /// Allows request bodies to override the Codex command, extra arguments, and
48    /// app-server config. This is intentionally separate from
49    /// [`Self::trusted_bridge`]: admitting a caller to the bridge is not the same
50    /// as allowing that caller to choose host executables or weaken sandboxing.
51    pub fn with_unsafe_client_options(mut self, allow: bool) -> Self {
52        self.allow_unsafe_client_options = allow;
53        self
54    }
55
56    pub fn with_max_sessions(mut self, max_sessions: usize) -> Self {
57        self.limits.max_sessions = max_sessions;
58        self
59    }
60
61    pub fn with_max_poll_timeout_ms(mut self, timeout_ms: u64) -> Self {
62        self.limits.max_poll_timeout = Duration::from_millis(timeout_ms);
63        self
64    }
65
66    pub fn with_max_text_turn_duration_ms(mut self, timeout_ms: u64) -> Self {
67        self.limits.max_text_turn_duration = Duration::from_millis(timeout_ms);
68        self
69    }
70
71    pub fn with_max_text_turn_output_bytes(mut self, max_bytes: usize) -> Self {
72        self.limits.max_text_turn_output_bytes = max_bytes;
73        self
74    }
75
76    /// Sets the interval between SSE keep-alive frames on
77    /// `GET /v1/sessions/{sessionId}/events/stream`. See
78    /// [`RestLimits::sse_keep_alive_interval`].
79    pub fn with_sse_keep_alive_interval_ms(mut self, interval_ms: u64) -> Self {
80        self.limits.sse_keep_alive_interval = Duration::from_millis(interval_ms);
81        self
82    }
83
84    /// Replaces the whole [`RestLimits`] set in one call, e.g. with
85    /// [`RestLimits::from_env`] or [`RestLimits::try_from_env`]:
86    ///
87    /// ```rust,no_run
88    /// # use codex_app_server_client::rest::{RestLimits, RestRouterOptions};
89    /// let options = RestRouterOptions::trusted_bridge().with_limits(RestLimits::from_env());
90    /// # let _ = options;
91    /// ```
92    pub fn with_limits(mut self, limits: RestLimits) -> Self {
93        self.limits = limits;
94        self
95    }
96}
97
98/// Resource limits used by the default REST backend and route layer.
99///
100/// Every field has a hardcoded default (below) and can be overridden
101/// independently via a `CODEX_APP_SERVER_REST_*` environment variable
102/// through [`RestLimits::from_env`] / [`RestLimits::try_from_env`]. A
103/// variable that is absent falls back to the field's default; a variable
104/// that is *present but fails to parse* is a hard error
105/// ([`RestLimitsEnvError`]), never a silent fallback - a malformed override
106/// that quietly reverts to the default is exactly how an operator ships a
107/// 10x-wrong limit and never notices.
108///
109/// | Field | Env var | Default |
110/// |---|---|---|
111/// | [`max_sessions`](Self::max_sessions) | `CODEX_APP_SERVER_REST_MAX_SESSIONS` | `16` |
112/// | [`max_one_shot_concurrency`](Self::max_one_shot_concurrency) | `CODEX_APP_SERVER_REST_MAX_ONE_SHOT_CONCURRENCY` | `4` |
113/// | [`max_session_call_concurrency`](Self::max_session_call_concurrency) | `CODEX_APP_SERVER_REST_MAX_SESSION_CALL_CONCURRENCY` | `64` |
114/// | [`max_session_call_concurrency_per_session`](Self::max_session_call_concurrency_per_session) | `CODEX_APP_SERVER_REST_MAX_SESSION_CALL_CONCURRENCY_PER_SESSION` | `8` |
115/// | [`max_poll_timeout`](Self::max_poll_timeout) | `CODEX_APP_SERVER_REST_MAX_POLL_TIMEOUT_MS` | `30000` (30s) |
116/// | [`min_stream_poll_timeout`](Self::min_stream_poll_timeout) | `CODEX_APP_SERVER_REST_MIN_STREAM_POLL_TIMEOUT_MS` | `250` |
117/// | [`max_text_turn_duration`](Self::max_text_turn_duration) | `CODEX_APP_SERVER_REST_MAX_TEXT_TURN_DURATION_MS` | `600000` (10m) |
118/// | [`max_text_turn_output_bytes`](Self::max_text_turn_output_bytes) | `CODEX_APP_SERVER_REST_MAX_TEXT_TURN_OUTPUT_BYTES` | `1048576` (1 MiB) |
119/// | [`max_request_body_bytes`](Self::max_request_body_bytes) | `CODEX_APP_SERVER_REST_MAX_REQUEST_BODY_BYTES` | `2097152` (2 MiB) |
120/// | [`pending_request_ttl`](Self::pending_request_ttl) | `CODEX_APP_SERVER_REST_PENDING_REQUEST_TTL_MS` | `600000` (10m) |
121/// | [`max_pending_requests_per_session`](Self::max_pending_requests_per_session) | `CODEX_APP_SERVER_REST_MAX_PENDING_REQUESTS_PER_SESSION` | `64` |
122/// | [`events_channel_capacity`](Self::events_channel_capacity) | `CODEX_APP_SERVER_REST_EVENTS_CHANNEL_CAPACITY` | `1024` |
123/// | [`idle_session_ttl`](Self::idle_session_ttl) | `CODEX_APP_SERVER_REST_IDLE_SESSION_TTL_MS` | `1800000` (30m) |
124/// | [`compatibility_ttl`](Self::compatibility_ttl) | `CODEX_APP_SERVER_REST_COMPATIBILITY_TTL_MS` | `30000` (30s) |
125/// | [`sse_keep_alive_interval`](Self::sse_keep_alive_interval) | `CODEX_APP_SERVER_REST_SSE_KEEP_ALIVE_MS` | `15000` (15s) |
126#[derive(Clone, Debug)]
127pub struct RestLimits {
128    /// Maximum number of concurrently open stateful bridge sessions
129    /// (`POST /v1/sessions`). Enforced both by a semaphore in
130    /// [`crate::rest::CodexRestBackend`] and by an explicit pre-check in the
131    /// route handler (so a full backend rejects before spawning a process).
132    /// Env: `CODEX_APP_SERVER_REST_MAX_SESSIONS`. Default: `16`.
133    pub max_sessions: usize,
134    /// Maximum number of one-shot requests (`POST /v1/text-turn`,
135    /// `POST /v1/call/{method}`) running at once; each spawns its own
136    /// short-lived Codex process. Env:
137    /// `CODEX_APP_SERVER_REST_MAX_ONE_SHOT_CONCURRENCY`. Default: `4`.
138    pub max_one_shot_concurrency: usize,
139    /// Maximum number of in-flight `POST /v1/sessions/{sessionId}/call/*`
140    /// calls across *all* sessions combined. Env:
141    /// `CODEX_APP_SERVER_REST_MAX_SESSION_CALL_CONCURRENCY`. Default: `64`.
142    pub max_session_call_concurrency: usize,
143    /// Maximum number of in-flight `POST /v1/sessions/{sessionId}/call/*`
144    /// calls for a *single* session. Env:
145    /// `CODEX_APP_SERVER_REST_MAX_SESSION_CALL_CONCURRENCY_PER_SESSION`.
146    /// Default: `8`.
147    pub max_session_call_concurrency_per_session: usize,
148    /// Upper bound on `?timeoutMs=` for both
149    /// `GET /v1/sessions/{sessionId}/events` and
150    /// `GET /v1/sessions/{sessionId}/events/stream`; a larger requested
151    /// value is clamped down to this. Env:
152    /// `CODEX_APP_SERVER_REST_MAX_POLL_TIMEOUT_MS`. Default: `30000` (30s).
153    pub max_poll_timeout: Duration,
154    /// Lower bound on `?timeoutMs=` for `GET
155    /// /v1/sessions/{sessionId}/events/stream` only; a smaller requested value
156    /// is clamped up to this.
157    ///
158    /// The long-poll route has no floor on purpose (`timeoutMs=0` there is a
159    /// legitimate "is anything waiting right now?" non-blocking poll, paced by
160    /// one HTTP round trip per call). A stream has no such pacing and no use
161    /// for a zero timeout, so without a floor one request can drive an
162    /// unbounded run of back-to-back backend polls. Costs real events nothing:
163    /// the timeout only bounds the *idle* wait, so this just caps how often an
164    /// idle stream emits `timeout` frames. Env:
165    /// `CODEX_APP_SERVER_REST_MIN_STREAM_POLL_TIMEOUT_MS`. Default: `250`.
166    pub min_stream_poll_timeout: Duration,
167    /// Wall-clock budget for `POST /v1/text-turn` to reach a terminal turn
168    /// state; the turn is interrupted and the request fails with
169    /// [`RestError::TimedOut`] past this point. Env:
170    /// `CODEX_APP_SERVER_REST_MAX_TEXT_TURN_DURATION_MS`. Default:
171    /// `600000` (10m).
172    pub max_text_turn_duration: Duration,
173    /// Byte cap on accumulated turn output for `POST /v1/text-turn`; the
174    /// turn is interrupted and the request fails with
175    /// [`RestError::PayloadTooLarge`] past this point. This is the
176    /// response-byte-cap knob for the REST layer - the crate has no other
177    /// hardcoded response size limit to promote (see the `rest`
178    /// implementation notes for what was audited). Env:
179    /// `CODEX_APP_SERVER_REST_MAX_TEXT_TURN_OUTPUT_BYTES`. Default:
180    /// `1048576` (1 MiB).
181    pub max_text_turn_output_bytes: usize,
182    /// Cap on the size of a request *body*, applied to every route via axum's
183    /// `DefaultBodyLimit`. A request whose body exceeds this is rejected with
184    /// `413 Payload Too Large` before any handler runs.
185    ///
186    /// This is the input-side counterpart to
187    /// [`Self::max_text_turn_output_bytes`]: without it, the only bound on an
188    /// incoming prompt or raw-call params object is axum's own silent 2 MiB
189    /// default, which is neither documented nor tunable. Making it an explicit
190    /// `RestLimits` field keeps the "every limit has a default and an env
191    /// override" contract true on the request side too. Env:
192    /// `CODEX_APP_SERVER_REST_MAX_REQUEST_BODY_BYTES`. Default: `2097152`
193    /// (2 MiB, matching axum's historical default).
194    pub max_request_body_bytes: usize,
195    /// How long a server-originated request surfaced by
196    /// `GET /v1/sessions/{sessionId}/events(/stream)` stays answerable via
197    /// `POST .../requests/{requestKey}/result` or `.../error` before it
198    /// expires with [`RestError::Gone`] (also capped by the app-server's
199    /// own reply deadline for that request, whichever is sooner). Env:
200    /// `CODEX_APP_SERVER_REST_PENDING_REQUEST_TTL_MS`. Default: `600000`
201    /// (10m).
202    pub pending_request_ttl: Duration,
203    /// Maximum number of not-yet-replied-to server requests a single
204    /// session will hold at once; beyond this, new ones are rejected
205    /// (with a JSON-RPC error sent back to the app-server on the caller's
206    /// behalf) rather than buffered without bound. Env:
207    /// `CODEX_APP_SERVER_REST_MAX_PENDING_REQUESTS_PER_SESSION`. Default:
208    /// `64`.
209    pub max_pending_requests_per_session: usize,
210    /// Capacity of each REST-spawned session's internal event channel -
211    /// exactly the channel documented on [`crate::EventStream`], applied via
212    /// [`crate::SessionOptions::with_events_capacity`] to every session this
213    /// backend spawns (`POST /v1/text-turn`, `POST /v1/sessions`, and
214    /// session-less `POST /v1/call/{method}`). REST/SSE consumers reading
215    /// events over a network are exactly the "slow or stalled consumer" case
216    /// that channel's bound and drop policy exist for: notifications are
217    /// dropped once it's full, but server-originated requests always get a
218    /// fallback error reply first rather than being lost - see
219    /// [`crate::EventStream`]'s doc comment for the exact per-variant policy.
220    /// A `0` value is rejected when the session is actually spawned (see
221    /// [`crate::CodexAppServerClient::spawn_with_events_capacity`]), not
222    /// silently accepted here. Env:
223    /// `CODEX_APP_SERVER_REST_EVENTS_CHANNEL_CAPACITY`. Default: `1024`
224    /// (matching [`DEFAULT_EVENTS_CHANNEL_CAPACITY`]).
225    pub events_channel_capacity: usize,
226    /// How long a stateful bridge session may sit with no in-flight
227    /// operation before it is pruned (and its `codex app-server` process
228    /// torn down) on the next backend access. Env:
229    /// `CODEX_APP_SERVER_REST_IDLE_SESSION_TTL_MS`. Default: `1800000`
230    /// (30m).
231    pub idle_session_ttl: Duration,
232    /// How long a `GET /v1/compatibility` result is cached before the next
233    /// call re-runs the (blocking, `codex --version`-invoking) check. Env:
234    /// `CODEX_APP_SERVER_REST_COMPATIBILITY_TTL_MS`. Default: `30000`
235    /// (30s).
236    pub compatibility_ttl: Duration,
237    /// Interval between SSE keep-alive frames sent by
238    /// `GET /v1/sessions/{sessionId}/events/stream` while no real event is
239    /// ready. Passed straight through to axum's
240    /// [`KeepAlive::interval`](axum::response::sse::KeepAlive::interval).
241    /// Env: `CODEX_APP_SERVER_REST_SSE_KEEP_ALIVE_MS`. Default: `15000`
242    /// (15s, matching axum's own `KeepAlive` default - set explicitly here
243    /// rather than relied upon, so this crate's behavior doesn't silently
244    /// change if axum's default ever does).
245    pub sse_keep_alive_interval: Duration,
246}
247
248impl Default for RestLimits {
249    fn default() -> Self {
250        Self {
251            max_sessions: 16,
252            max_one_shot_concurrency: 4,
253            max_session_call_concurrency: 64,
254            max_session_call_concurrency_per_session: 8,
255            max_poll_timeout: Duration::from_secs(30),
256            min_stream_poll_timeout: Duration::from_millis(250),
257            max_text_turn_duration: Duration::from_secs(10 * 60),
258            max_text_turn_output_bytes: 1024 * 1024,
259            max_request_body_bytes: 2 * 1024 * 1024,
260            pending_request_ttl: Duration::from_secs(600),
261            max_pending_requests_per_session: 64,
262            events_channel_capacity: DEFAULT_EVENTS_CHANNEL_CAPACITY,
263            idle_session_ttl: Duration::from_secs(30 * 60),
264            compatibility_ttl: Duration::from_secs(30),
265            sse_keep_alive_interval: Duration::from_secs(15),
266        }
267    }
268}
269
270/// Error returned by [`RestLimits::try_from_env`] when a
271/// `CODEX_APP_SERVER_REST_*` environment variable is set but cannot be
272/// parsed as its expected type.
273///
274/// Deliberately distinct from [`RestError`]: this happens at process
275/// startup, before any router or backend exists, so it can't be reported
276/// through an HTTP response.
277#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
278#[error("environment variable `{var}` has an invalid value `{value}`: expected {expected}")]
279pub struct RestLimitsEnvError {
280    pub var: &'static str,
281    pub value: String,
282    pub expected: &'static str,
283}
284
285impl RestLimits {
286    /// Builds [`RestLimits`] from `CODEX_APP_SERVER_REST_*` environment
287    /// variables, using [`RestLimits::default`] for any variable that is
288    /// absent.
289    ///
290    /// # Panics
291    ///
292    /// Panics (via [`RestLimitsEnvError`]'s `Display`) if any
293    /// `CODEX_APP_SERVER_REST_*` variable is set but fails to parse. See
294    /// [`RestLimits::try_from_env`] to handle that case without a panic -
295    /// this constructor exists for the common case of one-shot process
296    /// startup, where a malformed limit should abort startup loudly rather
297    /// than be silently downgraded to the default or handled by caller
298    /// code that has to remember to check.
299    pub fn from_env() -> Self {
300        match Self::try_from_env() {
301            Ok(limits) => limits,
302            Err(error) => panic!("{error}"),
303        }
304    }
305
306    /// Builds [`RestLimits`] from `CODEX_APP_SERVER_REST_*` environment
307    /// variables, using [`RestLimits::default`] for any variable that is
308    /// absent, and returning [`RestLimitsEnvError`] for the first variable
309    /// that is present but fails to parse.
310    pub fn try_from_env() -> Result<Self, RestLimitsEnvError> {
311        let default = Self::default();
312        Ok(Self {
313            max_sessions: env_usize("CODEX_APP_SERVER_REST_MAX_SESSIONS", default.max_sessions)?,
314            max_one_shot_concurrency: env_usize(
315                "CODEX_APP_SERVER_REST_MAX_ONE_SHOT_CONCURRENCY",
316                default.max_one_shot_concurrency,
317            )?,
318            max_session_call_concurrency: env_usize(
319                "CODEX_APP_SERVER_REST_MAX_SESSION_CALL_CONCURRENCY",
320                default.max_session_call_concurrency,
321            )?,
322            max_session_call_concurrency_per_session: env_usize(
323                "CODEX_APP_SERVER_REST_MAX_SESSION_CALL_CONCURRENCY_PER_SESSION",
324                default.max_session_call_concurrency_per_session,
325            )?,
326            max_poll_timeout: env_duration_ms(
327                "CODEX_APP_SERVER_REST_MAX_POLL_TIMEOUT_MS",
328                default.max_poll_timeout,
329            )?,
330            min_stream_poll_timeout: env_duration_ms(
331                "CODEX_APP_SERVER_REST_MIN_STREAM_POLL_TIMEOUT_MS",
332                default.min_stream_poll_timeout,
333            )?,
334            max_text_turn_duration: env_duration_ms(
335                "CODEX_APP_SERVER_REST_MAX_TEXT_TURN_DURATION_MS",
336                default.max_text_turn_duration,
337            )?,
338            max_text_turn_output_bytes: env_usize(
339                "CODEX_APP_SERVER_REST_MAX_TEXT_TURN_OUTPUT_BYTES",
340                default.max_text_turn_output_bytes,
341            )?,
342            max_request_body_bytes: env_nonzero_usize(
343                "CODEX_APP_SERVER_REST_MAX_REQUEST_BODY_BYTES",
344                default.max_request_body_bytes,
345            )?,
346            pending_request_ttl: env_duration_ms(
347                "CODEX_APP_SERVER_REST_PENDING_REQUEST_TTL_MS",
348                default.pending_request_ttl,
349            )?,
350            max_pending_requests_per_session: env_usize(
351                "CODEX_APP_SERVER_REST_MAX_PENDING_REQUESTS_PER_SESSION",
352                default.max_pending_requests_per_session,
353            )?,
354            events_channel_capacity: env_nonzero_usize(
355                "CODEX_APP_SERVER_REST_EVENTS_CHANNEL_CAPACITY",
356                default.events_channel_capacity,
357            )?,
358            idle_session_ttl: env_duration_ms(
359                "CODEX_APP_SERVER_REST_IDLE_SESSION_TTL_MS",
360                default.idle_session_ttl,
361            )?,
362            compatibility_ttl: env_duration_ms(
363                "CODEX_APP_SERVER_REST_COMPATIBILITY_TTL_MS",
364                default.compatibility_ttl,
365            )?,
366            sse_keep_alive_interval: env_duration_ms(
367                "CODEX_APP_SERVER_REST_SSE_KEEP_ALIVE_MS",
368                default.sse_keep_alive_interval,
369            )?,
370        })
371    }
372}
373
374/// Reads `var` as a `usize`, falling back to `default` only when the
375/// variable is entirely absent. A variable that is set to anything that
376/// doesn't parse as a `usize` (empty, negative, non-numeric, non-UTF-8) is
377/// reported via `Err`, never silently mapped to `default`.
378/// Like [`env_usize`], but rejects `0`.
379///
380/// For most limits `0` is merely a useless setting (a semaphore nobody can
381/// acquire), which is the operator's business. For a channel capacity it is an
382/// *invalid* one: `tokio::sync::mpsc::channel(0)` panics. Catching it here
383/// means a bad value fails at `RestLimits::from_env()` - at startup, naming
384/// the variable - instead of surviving the config load and blowing up on the
385/// first session spawn, which is exactly the "present but unusable" case the
386/// hard-error rule on [`RestLimits`] exists to prevent.
387fn env_nonzero_usize(var: &'static str, default: usize) -> Result<usize, RestLimitsEnvError> {
388    let value = env_usize(var, default)?;
389    if value == 0 {
390        return Err(RestLimitsEnvError {
391            var,
392            value: "0".to_owned(),
393            expected: "a positive integer (a zero-capacity channel is not constructible)",
394        });
395    }
396    Ok(value)
397}
398
399fn env_usize(var: &'static str, default: usize) -> Result<usize, RestLimitsEnvError> {
400    match env::var(var) {
401        Ok(value) => value
402            .trim()
403            .parse::<usize>()
404            .map_err(|_| RestLimitsEnvError {
405                var,
406                value,
407                expected: "a non-negative integer",
408            }),
409        Err(env::VarError::NotPresent) => Ok(default),
410        Err(env::VarError::NotUnicode(raw)) => Err(RestLimitsEnvError {
411            var,
412            value: raw.to_string_lossy().into_owned(),
413            expected: "valid UTF-8 text",
414        }),
415    }
416}
417
418/// Reads `var` as a millisecond count and converts it to a [`Duration`],
419/// falling back to `default` only when the variable is entirely absent. See
420/// [`env_usize`] for the malformed-value policy (identical here).
421fn env_duration_ms(var: &'static str, default: Duration) -> Result<Duration, RestLimitsEnvError> {
422    match env::var(var) {
423        Ok(value) => value
424            .trim()
425            .parse::<u64>()
426            .map(Duration::from_millis)
427            .map_err(|_| RestLimitsEnvError {
428                var,
429                value,
430                expected: "a non-negative integer count of milliseconds",
431            }),
432        Err(env::VarError::NotPresent) => Ok(default),
433        Err(env::VarError::NotUnicode(raw)) => Err(RestLimitsEnvError {
434            var,
435            value: raw.to_string_lossy().into_owned(),
436            expected: "valid UTF-8 text",
437        }),
438    }
439}
440
441/// Errors surfaced by the optional REST adapter.
442#[derive(Debug, thiserror::Error)]
443pub enum RestError {
444    #[error("{0}")]
445    NotFound(String),
446
447    #[error("{0}")]
448    Gone(String),
449
450    #[error("{0}")]
451    Forbidden(String),
452
453    #[error("{0}")]
454    InvalidRequest(String),
455
456    #[error("{0}")]
457    RateLimited(String),
458
459    #[error("{0}")]
460    Conflict(String),
461
462    #[error("{0}")]
463    TimedOut(String),
464
465    #[error("{0}")]
466    PayloadTooLarge(String),
467
468    #[error("{0}")]
469    Internal(String),
470
471    #[error(transparent)]
472    Client(#[from] Error),
473}
474
475impl From<serde_json::Error> for RestError {
476    fn from(error: serde_json::Error) -> Self {
477        Self::Client(error.into())
478    }
479}
480
481/// Backend used by the REST router.
482///
483/// The default [`crate::rest::CodexRestBackend`] talks to real `codex app-server`
484/// processes. Tests and host applications can inject their own backend with
485/// [`crate::rest::router_with_backend`] to control process lifecycle, pooling, or policy.
486pub trait RestBackend: Send + Sync + 'static {
487    fn compatibility_report(&self) -> RestFuture<CompatibilityReport>;
488    fn run_text_turn(&self, request: RestTextTurnRequest) -> RestFuture<RestTextTurnResponse>;
489    fn create_session(
490        &self,
491        _request: RestSessionCreateRequest,
492    ) -> RestFuture<RestSessionCreateResponse> {
493        Box::pin(async move {
494            Err(RestError::NotFound(
495                "REST session bridge is not implemented by this backend".to_owned(),
496            ))
497        })
498    }
499    fn list_sessions(&self) -> RestFuture<Vec<RestSessionSummary>> {
500        Box::pin(async move { Ok(Vec::new()) })
501    }
502    fn delete_session(&self, session_id: String) -> RestFuture<RestStatusResponse> {
503        Box::pin(async move {
504            Err(RestError::NotFound(format!(
505                "session `{session_id}` was not found"
506            )))
507        })
508    }
509    fn call_method(&self, request: RestCallRequest) -> RestFuture<RestCallResponse> {
510        Box::pin(async move {
511            Err(RestError::NotFound(format!(
512                "REST raw call `{}` is not implemented by this backend",
513                request.method
514            )))
515        })
516    }
517    fn poll_event(
518        &self,
519        session_id: String,
520        timeout_ms: Option<u64>,
521    ) -> RestFuture<RestEventResponse> {
522        let _ = timeout_ms;
523        Box::pin(async move {
524            Err(RestError::NotFound(format!(
525                "session `{session_id}` was not found"
526            )))
527        })
528    }
529    fn reply_request_result(
530        &self,
531        session_id: String,
532        request_key: String,
533        body: RestRequestReplyResultRequest,
534    ) -> RestFuture<RestRequestReplyResponse> {
535        let _ = (request_key, body);
536        Box::pin(async move {
537            Err(RestError::NotFound(format!(
538                "session `{session_id}` was not found"
539            )))
540        })
541    }
542    fn reply_request_error(
543        &self,
544        session_id: String,
545        request_key: String,
546        body: RestErrorReplyRequest,
547    ) -> RestFuture<RestRequestReplyResponse> {
548        let _ = (request_key, body);
549        Box::pin(async move {
550            Err(RestError::NotFound(format!(
551                "session `{session_id}` was not found"
552            )))
553        })
554    }
555}
556
557/// Health response returned by `GET /health` and `GET /v1/health`.
558#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
559pub struct RestHealthResponse {
560    pub status: String,
561}
562
563/// REST approval policy preset used while collecting turn events.
564#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
565#[serde(rename_all = "snake_case")]
566pub enum RestApprovalPolicy {
567    #[default]
568    DenyAll,
569    ReadOnly,
570    AllowAll,
571}
572
573/// Optional client/session overrides for REST requests that spawn Codex.
574#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
575#[serde(rename_all = "camelCase", deny_unknown_fields)]
576pub struct RestClientOptions {
577    pub name: Option<String>,
578    pub version: Option<String>,
579    pub command: Option<String>,
580    #[serde(default, skip_serializing_if = "Vec::is_empty")]
581    pub extra_args: Vec<String>,
582    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
583    pub config: HashMap<String, String>,
584    pub call_timeout_ms: Option<u64>,
585}
586
587impl RestClientOptions {
588    pub(super) fn into_session_options(self, default_name: &str) -> SessionOptions {
589        let mut options = SessionOptions::new(
590            self.name.unwrap_or_else(|| default_name.to_owned()),
591            self.version
592                .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_owned()),
593        );
594        if let Some(command) = self.command {
595            options = options.with_command(command);
596        }
597        for arg in self.extra_args {
598            options = options.with_extra_arg(arg);
599        }
600        for (key, value) in self.config {
601            options = options.with_config(key, value);
602        }
603        if let Some(timeout_ms) = self.call_timeout_ms {
604            options = options.with_call_timeout(Duration::from_millis(timeout_ms));
605        }
606        options
607    }
608}
609
610/// Request body for `POST /v1/text-turn`.
611#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
612#[serde(rename_all = "camelCase", deny_unknown_fields)]
613pub struct RestTextTurnRequest {
614    pub prompt: String,
615    pub model: Option<String>,
616    pub approval_policy: Option<RestApprovalPolicy>,
617    pub client: Option<RestClientOptions>,
618}
619
620impl RestTextTurnRequest {
621    pub fn session_options(&self) -> SessionOptions {
622        session_options_from(self.client.clone(), "codex_app_server_rest")
623    }
624}
625
626/// Response body for `POST /v1/text-turn`.
627#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
628#[serde(rename_all = "camelCase")]
629pub struct RestTextTurnResponse {
630    pub thread_id: String,
631    pub turn_id: String,
632    pub turn_status: Option<String>,
633    pub agent_message: String,
634    pub latest_diff: Option<String>,
635    pub errors: Vec<Value>,
636}
637
638impl From<TextTurnResult> for RestTextTurnResponse {
639    fn from(result: TextTurnResult) -> Self {
640        let agent_message = result.agent_message().to_owned();
641        let latest_diff = result.latest_diff().map(str::to_owned);
642        let turn_status = result.events.terminal_status().and_then(|status| {
643            serde_json::to_value(status)
644                .ok()?
645                .as_str()
646                .map(str::to_owned)
647        });
648        let errors = result
649            .errors()
650            .iter()
651            .map(|error| {
652                serde_json::to_value(error).unwrap_or_else(|_| Value::String(error.message.clone()))
653            })
654            .collect();
655        Self {
656            thread_id: result.thread.thread.id,
657            turn_id: result.turn.turn.id,
658            turn_status,
659            agent_message,
660            latest_diff,
661            errors,
662        }
663    }
664}
665
666/// Request body for raw method bridge calls.
667#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
668#[serde(rename_all = "camelCase", deny_unknown_fields)]
669pub struct RestCallBody {
670    #[serde(default)]
671    pub params: Value,
672    pub client: Option<RestClientOptions>,
673}
674
675/// Backend-facing raw method call request.
676#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
677#[serde(rename_all = "camelCase")]
678pub struct RestCallRequest {
679    pub session_id: Option<String>,
680    pub method: String,
681    pub params: Value,
682    pub client: Option<RestClientOptions>,
683}
684
685/// Response body for raw method bridge calls.
686#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
687#[serde(rename_all = "camelCase")]
688pub struct RestCallResponse {
689    pub method: String,
690    pub result: Value,
691}
692
693/// Request body for `POST /v1/sessions`.
694#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
695#[serde(rename_all = "camelCase", deny_unknown_fields)]
696pub struct RestSessionCreateRequest {
697    pub client: Option<RestClientOptions>,
698}
699
700/// Response body for `POST /v1/sessions`.
701#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
702#[serde(rename_all = "camelCase")]
703pub struct RestSessionCreateResponse {
704    pub session_id: String,
705    pub initialize_response: Value,
706}
707
708/// One session entry in `GET /v1/sessions`.
709#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
710#[serde(rename_all = "camelCase")]
711pub struct RestSessionSummary {
712    pub session_id: String,
713}
714
715/// Response body for `GET /v1/sessions`.
716#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
717pub struct RestListSessionsResponse {
718    pub sessions: Vec<RestSessionSummary>,
719}
720
721/// Simple status response used by mutating bridge endpoints.
722#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
723pub struct RestStatusResponse {
724    pub status: String,
725}
726
727/// Event returned by `GET /v1/sessions/{sessionId}/events`.
728#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
729#[serde(
730    tag = "event",
731    rename_all = "snake_case",
732    rename_all_fields = "camelCase"
733)]
734pub enum RestEventResponse {
735    Notification {
736        notification: Value,
737    },
738    Request {
739        request_key: String,
740        request_id: Value,
741        method: String,
742        request: Value,
743    },
744    Closed,
745    Timeout,
746}
747
748impl RestEventResponse {
749    pub fn notification(notification: Value) -> Self {
750        Self::Notification { notification }
751    }
752
753    pub fn request(
754        request_key: impl Into<String>,
755        request_id: Value,
756        method: impl Into<String>,
757        request: Value,
758    ) -> Self {
759        Self::Request {
760            request_key: request_key.into(),
761            request_id,
762            method: method.into(),
763            request,
764        }
765    }
766
767    pub fn closed() -> Self {
768        Self::Closed
769    }
770
771    pub fn timeout() -> Self {
772        Self::Timeout
773    }
774}
775
776/// Request body for replying successfully to a server-originated request.
777#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
778#[serde(rename_all = "camelCase", deny_unknown_fields)]
779pub struct RestRequestReplyResultRequest {
780    pub result: Value,
781}
782
783/// Request body for replying with an error to a server-originated request.
784#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
785#[serde(rename_all = "camelCase", deny_unknown_fields)]
786pub struct RestErrorReplyRequest {
787    pub code: i64,
788    pub message: String,
789    pub data: Option<Value>,
790}
791
792/// Response body for request-reply endpoints.
793#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
794pub struct RestRequestReplyResponse {
795    pub status: String,
796}
797
798/// Structured REST error payload.
799#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
800pub struct RestErrorResponse {
801    pub error: String,
802    pub message: String,
803    #[serde(skip_serializing_if = "Option::is_none")]
804    pub code: Option<i64>,
805    #[serde(skip_serializing_if = "Option::is_none")]
806    pub data: Option<Value>,
807}
808
809pub(super) fn session_options_from(
810    client: Option<RestClientOptions>,
811    default_name: &str,
812) -> SessionOptions {
813    client
814        .unwrap_or_default()
815        .into_session_options(default_name)
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821    use std::{
822        ffi::OsStr,
823        sync::{Mutex, OnceLock},
824    };
825
826    /// `std::env` is process-global, so tests that mutate
827    /// `CODEX_APP_SERVER_REST_*` variables must not run concurrently with
828    /// each other (`cargo test` runs unit tests on multiple threads by
829    /// default). This serializes just the tests in this module - it does
830    /// not need to coordinate with anything outside this crate, since these
831    /// variable names are only ever touched here and in the `rest`
832    /// integration tests, which run in a separate test binary/process.
833    fn env_lock() -> &'static Mutex<()> {
834        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
835        LOCK.get_or_init(|| Mutex::new(()))
836    }
837
838    struct EnvVarGuard {
839        key: &'static str,
840        previous: Option<std::ffi::OsString>,
841    }
842
843    impl EnvVarGuard {
844        fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
845            let previous = env::var_os(key);
846            env::set_var(key, value);
847            Self { key, previous }
848        }
849
850        fn unset(key: &'static str) -> Self {
851            let previous = env::var_os(key);
852            env::remove_var(key);
853            Self { key, previous }
854        }
855    }
856
857    impl Drop for EnvVarGuard {
858        fn drop(&mut self) {
859            match &self.previous {
860                Some(previous) => env::set_var(self.key, previous),
861                None => env::remove_var(self.key),
862            }
863        }
864    }
865
866    const ALL_REST_LIMIT_VARS: &[&str] = &[
867        "CODEX_APP_SERVER_REST_MAX_SESSIONS",
868        "CODEX_APP_SERVER_REST_MAX_ONE_SHOT_CONCURRENCY",
869        "CODEX_APP_SERVER_REST_MAX_SESSION_CALL_CONCURRENCY",
870        "CODEX_APP_SERVER_REST_MAX_SESSION_CALL_CONCURRENCY_PER_SESSION",
871        "CODEX_APP_SERVER_REST_MAX_POLL_TIMEOUT_MS",
872        "CODEX_APP_SERVER_REST_MAX_TEXT_TURN_DURATION_MS",
873        "CODEX_APP_SERVER_REST_MAX_TEXT_TURN_OUTPUT_BYTES",
874        "CODEX_APP_SERVER_REST_PENDING_REQUEST_TTL_MS",
875        "CODEX_APP_SERVER_REST_MAX_PENDING_REQUESTS_PER_SESSION",
876        "CODEX_APP_SERVER_REST_EVENTS_CHANNEL_CAPACITY",
877        "CODEX_APP_SERVER_REST_IDLE_SESSION_TTL_MS",
878        "CODEX_APP_SERVER_REST_COMPATIBILITY_TTL_MS",
879        "CODEX_APP_SERVER_REST_SSE_KEEP_ALIVE_MS",
880    ];
881
882    /// Ensures every `CODEX_APP_SERVER_REST_*` variable starts (and ends)
883    /// unset for a test, regardless of what the ambient environment
884    /// happened to have, by unsetting (and restoring on drop) all of them.
885    fn clear_all_rest_limit_vars() -> Vec<EnvVarGuard> {
886        ALL_REST_LIMIT_VARS
887            .iter()
888            .map(|var| EnvVarGuard::unset(var))
889            .collect()
890    }
891
892    #[test]
893    fn try_from_env_uses_defaults_when_every_variable_is_absent() {
894        let _lock = env_lock()
895            .lock()
896            .unwrap_or_else(|poisoned| poisoned.into_inner());
897        let _cleared = clear_all_rest_limit_vars();
898
899        let limits = RestLimits::try_from_env().expect("all-absent env should use defaults");
900        let default = RestLimits::default();
901        assert_eq!(limits.max_sessions, default.max_sessions);
902        assert_eq!(
903            limits.max_session_call_concurrency_per_session,
904            default.max_session_call_concurrency_per_session
905        );
906        assert_eq!(limits.max_poll_timeout, default.max_poll_timeout);
907        assert_eq!(
908            limits.events_channel_capacity,
909            default.events_channel_capacity
910        );
911        assert_eq!(
912            limits.sse_keep_alive_interval,
913            default.sse_keep_alive_interval
914        );
915    }
916
917    #[test]
918    fn try_from_env_parses_valid_overrides() {
919        let _lock = env_lock()
920            .lock()
921            .unwrap_or_else(|poisoned| poisoned.into_inner());
922        let _cleared = clear_all_rest_limit_vars();
923        let _max_sessions = EnvVarGuard::set("CODEX_APP_SERVER_REST_MAX_SESSIONS", "42");
924        let _poll_timeout = EnvVarGuard::set("CODEX_APP_SERVER_REST_MAX_POLL_TIMEOUT_MS", "5000");
925        let _keep_alive = EnvVarGuard::set("CODEX_APP_SERVER_REST_SSE_KEEP_ALIVE_MS", "2500");
926
927        let limits = RestLimits::try_from_env().expect("well-formed overrides should parse");
928
929        assert_eq!(limits.max_sessions, 42);
930        assert_eq!(limits.max_poll_timeout, Duration::from_millis(5000));
931        assert_eq!(limits.sse_keep_alive_interval, Duration::from_millis(2500));
932        // Every other field falls back to its default when unset.
933        assert_eq!(
934            limits.max_one_shot_concurrency,
935            RestLimits::default().max_one_shot_concurrency
936        );
937    }
938
939    #[test]
940    fn try_from_env_reports_malformed_values_instead_of_defaulting() {
941        let _lock = env_lock()
942            .lock()
943            .unwrap_or_else(|poisoned| poisoned.into_inner());
944        let _cleared = clear_all_rest_limit_vars();
945        let _bad = EnvVarGuard::set("CODEX_APP_SERVER_REST_MAX_SESSIONS", "not-a-number");
946
947        let error = RestLimits::try_from_env()
948            .expect_err("a malformed override must not silently fall back to the default");
949
950        assert_eq!(error.var, "CODEX_APP_SERVER_REST_MAX_SESSIONS");
951        assert_eq!(error.value, "not-a-number");
952    }
953
954    #[test]
955    fn try_from_env_reports_empty_string_as_malformed() {
956        let _lock = env_lock()
957            .lock()
958            .unwrap_or_else(|poisoned| poisoned.into_inner());
959        let _cleared = clear_all_rest_limit_vars();
960        let _bad = EnvVarGuard::set("CODEX_APP_SERVER_REST_MAX_POLL_TIMEOUT_MS", "");
961
962        let error = RestLimits::try_from_env()
963            .expect_err("an empty override must not silently fall back to the default");
964
965        assert_eq!(error.var, "CODEX_APP_SERVER_REST_MAX_POLL_TIMEOUT_MS");
966    }
967
968    #[test]
969    fn try_from_env_reports_negative_values_as_malformed() {
970        let _lock = env_lock()
971            .lock()
972            .unwrap_or_else(|poisoned| poisoned.into_inner());
973        let _cleared = clear_all_rest_limit_vars();
974        let _bad = EnvVarGuard::set("CODEX_APP_SERVER_REST_MAX_SESSIONS", "-1");
975
976        let error = RestLimits::try_from_env()
977            .expect_err("a negative override must not silently fall back to the default");
978
979        assert_eq!(error.var, "CODEX_APP_SERVER_REST_MAX_SESSIONS");
980    }
981
982    /// A zero events capacity has to fail here, at config load, rather than
983    /// parsing cleanly and panicking inside `tokio::sync::mpsc::channel` the
984    /// first time a session is spawned - by which point the process has
985    /// already printed a startup banner claiming the configuration is good.
986    #[test]
987    fn try_from_env_rejects_a_zero_events_channel_capacity_at_load_time() {
988        let _lock = env_lock()
989            .lock()
990            .unwrap_or_else(|poisoned| poisoned.into_inner());
991        let _cleared = clear_all_rest_limit_vars();
992        let _bad = EnvVarGuard::set("CODEX_APP_SERVER_REST_EVENTS_CHANNEL_CAPACITY", "0");
993
994        let error = RestLimits::try_from_env()
995            .expect_err("a zero events channel capacity is not constructible and must be rejected");
996
997        assert_eq!(error.var, "CODEX_APP_SERVER_REST_EVENTS_CHANNEL_CAPACITY");
998        assert!(
999            error.to_string().contains("positive"),
1000            "the error should say what was expected, got: {error}"
1001        );
1002    }
1003
1004    #[test]
1005    fn try_from_env_parses_a_valid_events_channel_capacity_override() {
1006        let _lock = env_lock()
1007            .lock()
1008            .unwrap_or_else(|poisoned| poisoned.into_inner());
1009        let _cleared = clear_all_rest_limit_vars();
1010        let _capacity = EnvVarGuard::set("CODEX_APP_SERVER_REST_EVENTS_CHANNEL_CAPACITY", "7");
1011
1012        let limits = RestLimits::try_from_env().expect("a well-formed override should parse");
1013
1014        assert_eq!(limits.events_channel_capacity, 7);
1015    }
1016
1017    #[test]
1018    fn try_from_env_reports_a_malformed_events_channel_capacity_instead_of_defaulting() {
1019        let _lock = env_lock()
1020            .lock()
1021            .unwrap_or_else(|poisoned| poisoned.into_inner());
1022        let _cleared = clear_all_rest_limit_vars();
1023        let _bad = EnvVarGuard::set(
1024            "CODEX_APP_SERVER_REST_EVENTS_CHANNEL_CAPACITY",
1025            "not-a-number",
1026        );
1027
1028        let error = RestLimits::try_from_env()
1029            .expect_err("a malformed override must not silently fall back to the default");
1030
1031        assert_eq!(error.var, "CODEX_APP_SERVER_REST_EVENTS_CHANNEL_CAPACITY");
1032        assert_eq!(error.value, "not-a-number");
1033    }
1034
1035    #[test]
1036    #[should_panic(expected = "CODEX_APP_SERVER_REST_MAX_SESSIONS")]
1037    fn from_env_panics_on_malformed_override() {
1038        let _lock = env_lock()
1039            .lock()
1040            .unwrap_or_else(|poisoned| poisoned.into_inner());
1041        let _cleared = clear_all_rest_limit_vars();
1042        let _bad = EnvVarGuard::set("CODEX_APP_SERVER_REST_MAX_SESSIONS", "nope");
1043
1044        let _ = RestLimits::from_env();
1045    }
1046}