Skip to main content

codex_runtime/runtime/client/
mod.rs

1use thiserror::Error;
2
3use crate::runtime::api::{PromptRunError, PromptRunParams, PromptRunResult};
4use crate::runtime::core::{Runtime, RuntimeConfig};
5use crate::runtime::errors::RuntimeError;
6use crate::runtime::transport::StdioProcessSpec;
7
8mod compat_guard;
9mod config;
10mod profile;
11mod session;
12
13pub use compat_guard::{CompatibilityGuard, SemVerTriplet};
14pub use config::ClientConfig;
15pub use profile::{RunProfile, SessionConfig};
16pub use session::Session;
17
18use compat_guard::validate_runtime_compatibility;
19use profile::{prepared_prompt_run_from_profile, session_thread_start_params};
20
21#[derive(Clone)]
22pub struct Client {
23    runtime: Runtime,
24    config: ClientConfig,
25}
26
27impl Client {
28    /// Connect using default config (default CLI).
29    /// Side effects: spawns `<cli_bin> app-server`.
30    /// Allocation: runtime buffers + internal channels.
31    pub async fn connect_default() -> Result<Self, ClientError> {
32        Self::connect(ClientConfig::new()).await
33    }
34
35    /// Connect using explicit client config.
36    /// Side effects: spawns `<cli_bin> app-server` and validates initialize compatibility guard.
37    /// Allocation: runtime buffers + internal channels.
38    pub async fn connect(config: ClientConfig) -> Result<Self, ClientError> {
39        let mut process = StdioProcessSpec::new(config.cli_bin.clone());
40        process.args = vec!["app-server".to_owned()];
41        process.args.extend(config.app_server_args.iter().cloned());
42        process.env = config.process_env.clone();
43        process.cwd = config.process_cwd.clone();
44
45        let runtime = Runtime::spawn_local(
46            RuntimeConfig::new(process)
47                .with_hooks(config.hooks.clone())
48                .with_initialize_capabilities(config.initialize_capabilities),
49        )
50        .await?;
51        if let Err(compatibility) =
52            validate_runtime_compatibility(&runtime, &config.compatibility_guard)
53        {
54            if let Err(shutdown) = runtime.shutdown().await {
55                return Err(ClientError::CompatibilityValidationWithShutdown {
56                    compatibility: Box::new(compatibility),
57                    shutdown,
58                });
59            }
60            return Err(compatibility);
61        }
62
63        Ok(Self { runtime, config })
64    }
65
66    /// Run one prompt using default policies (approval=never, sandbox=read-only).
67    /// Side effects: sends thread/turn RPC calls to app-server.
68    pub async fn run(
69        &self,
70        cwd: impl Into<String>,
71        prompt: impl Into<String>,
72    ) -> Result<PromptRunResult, PromptRunError> {
73        self.runtime.run_prompt_simple(cwd, prompt).await
74    }
75
76    /// Run one prompt with explicit model/policy/attachment options.
77    /// Side effects: sends thread/turn RPC calls to app-server.
78    pub async fn run_with(
79        &self,
80        params: PromptRunParams,
81    ) -> Result<PromptRunResult, PromptRunError> {
82        self.runtime.run_prompt(params).await
83    }
84
85    /// Run one prompt with one reusable profile (model/effort/policy/attachments/timeout).
86    /// Side effects: sends thread/turn RPC calls to app-server.
87    /// Allocation: moves profile-owned Strings/vectors + one prompt String. Complexity: O(n), n = attachment count + field sizes.
88    pub async fn run_with_profile(
89        &self,
90        cwd: impl Into<String>,
91        prompt: impl Into<String>,
92        profile: RunProfile,
93    ) -> Result<PromptRunResult, PromptRunError> {
94        let prepared = prepared_prompt_run_from_profile(cwd.into(), prompt, profile);
95        self.runtime
96            .run_prompt_with_hooks(prepared.params, Some(prepared.hooks.as_ref()))
97            .await
98    }
99
100    /// Start a prepared session and return a reusable handle.
101    /// Side effects: sends thread/start RPC call to app-server.
102    /// Allocation: clones model/cwd/sandbox into thread-start payload. Complexity: O(n), n = total field sizes.
103    pub async fn start_session(&self, config: SessionConfig) -> Result<Session, PromptRunError> {
104        let thread = self
105            .runtime
106            .thread_start_with_hooks(session_thread_start_params(&config), Some(&config.hooks))
107            .await?;
108
109        Ok(Session::new(self.runtime.clone(), thread.thread_id, config))
110    }
111
112    /// Resume an existing session id with prepared defaults.
113    /// Side effects: sends thread/resume RPC call to app-server.
114    /// Allocation: clones model/cwd/sandbox into thread-resume payload. Complexity: O(n), n = total field sizes.
115    pub async fn resume_session(
116        &self,
117        thread_id: &str,
118        config: SessionConfig,
119    ) -> Result<Session, PromptRunError> {
120        let thread = self
121            .runtime
122            .thread_resume_with_hooks(
123                thread_id,
124                session_thread_start_params(&config),
125                Some(&config.hooks),
126            )
127            .await?;
128
129        Ok(Session::new(self.runtime.clone(), thread.thread_id, config))
130    }
131
132    /// Borrow underlying runtime for full low-level control.
133    /// Allocation: none. Complexity: O(1).
134    pub fn runtime(&self) -> &Runtime {
135        &self.runtime
136    }
137
138    /// Return connect-time client config snapshot.
139    /// Allocation: none. Complexity: O(1).
140    pub fn config(&self) -> &ClientConfig {
141        &self.config
142    }
143
144    /// Shutdown child process and background tasks.
145    /// Side effects: closes channels and terminates child process.
146    pub async fn shutdown(&self) -> Result<(), RuntimeError> {
147        self.runtime.shutdown().await
148    }
149}
150
151#[derive(Clone, Debug, Error, PartialEq, Eq)]
152pub enum ClientError {
153    #[error("failed to read current directory: {0}")]
154    CurrentDir(String),
155
156    #[error("initialize response missing userAgent")]
157    MissingInitializeUserAgent,
158
159    #[error("initialize response has unsupported userAgent format: {0}")]
160    InvalidInitializeUserAgent(String),
161
162    #[error("incompatible codex runtime version: detected={detected} required>={required} userAgent={user_agent}")]
163    IncompatibleCodexVersion {
164        detected: String,
165        required: String,
166        user_agent: String,
167    },
168
169    #[error(
170        "compatibility validation failed: {compatibility}; runtime shutdown failed: {shutdown}"
171    )]
172    CompatibilityValidationWithShutdown {
173        compatibility: Box<ClientError>,
174        shutdown: RuntimeError,
175    },
176
177    #[error("runtime error: {0}")]
178    Runtime(#[from] RuntimeError),
179}
180
181#[cfg(test)]
182fn parse_initialize_user_agent(value: &str) -> Option<(String, SemVerTriplet)> {
183    compat_guard::parse_initialize_user_agent(value)
184}
185
186#[cfg(test)]
187fn session_prompt_params(config: &SessionConfig, prompt: impl Into<String>) -> PromptRunParams {
188    profile::session_prompt_params(config, prompt)
189}
190
191#[cfg(test)]
192fn profile_to_prompt_params(
193    cwd: String,
194    prompt: impl Into<String>,
195    profile: RunProfile,
196) -> PromptRunParams {
197    profile::profile_to_prompt_params(cwd, prompt, profile)
198}
199
200#[cfg(test)]
201mod tests;