Skip to main content

objectiveai_cli/
context.rs

1//! Process-wide state threaded into every bare-naked `command::*::execute`.
2//!
3//! `Context` is constructed once in `main.rs` (or by a programmatic
4//! embedder) — synchronously and infallibly, no IO — then borrowed
5//! into the command tree. The three service clients are LAZY: the
6//! first `api_client()` / `viewer_client()` / `db_client()` call
7//! resolves the server's address (on-disk config, else the matching
8//! spawn flow, which itself short-circuits to the lock-published URL
9//! when the server is already up), builds the client, and memoizes it
10//! in a `tokio::sync::OnceCell` shared across `Context` clones (Arc).
11//! Concurrent callers coalesce on one initialization; a failed init
12//! is not cached, so the next call retries. Commands that never touch
13//! a service never spawn or connect to it.
14//!
15//! Identity hazard: the memoized API `HttpClient` snapshots
16//! `config.agent_instance_hierarchy` + `config.mcp_session_id` into
17//! headers. A clone that mutates those fields MUST call
18//! [`Context::reset_api_client`] afterwards — see that method's doc.
19
20use std::sync::Arc;
21
22use objectiveai_sdk::HttpClient;
23use tokio::sync::OnceCell;
24
25use crate::db;
26use crate::filesystem;
27use crate::plugin_path::PluginPath;
28use crate::run::Config;
29use crate::viewer_client::ViewerClient;
30
31#[derive(Clone)]
32pub struct Context {
33    pub config: Config,
34    pub filesystem: filesystem::Client,
35    /// The plugin a command is running on behalf of, when it was
36    /// invoked through a plugin's nested-command protocol. Assembled
37    /// from the `OBJECTIVEAI_PLUGIN_*` env vars (via `Config`).
38    pub plugin: Option<PluginPath>,
39    /// Lazily-built API `HttpClient` — see [`Context::api_client`].
40    api: Arc<OnceCell<HttpClient>>,
41    /// Lazily-built viewer client — see [`Context::viewer_client`].
42    /// No per-request identity; always shared across clones.
43    viewer: Arc<OnceCell<ViewerClient>>,
44    /// Lazily-connected db handle (pool + admin coordinates) — see
45    /// [`Context::db_handle`]. No per-request identity; always
46    /// shared across clones.
47    db: Arc<OnceCell<db::DbHandle>>,
48    /// Lazily-initialized WASI python runtime — see
49    /// [`Context::python`]. No per-request identity; always shared
50    /// across clones.
51    python: Arc<OnceCell<crate::python::Python>>,
52    /// When true, the embedded python's `objectiveai.execute(...)` host
53    /// call raises instead of dispatching a CLI command. Read by
54    /// `python::add_objectiveai_host` via the run's `PyHostState.ctx`.
55    /// Set by the `python --no-objectiveai` flag and automatically for
56    /// the per-stream-item python output transform. Copy field, carried
57    /// per-clone — see [`Context::with_no_objectiveai`].
58    pub no_objectiveai: bool,
59}
60
61impl Context {
62    pub fn new(config: Config) -> Self {
63        let filesystem = filesystem::Client::new(
64            config.objectiveai_dir.clone(),
65            config.objectiveai_state.clone(),
66            config.commit_author_name.clone(),
67            config.commit_author_email.clone(),
68        );
69        let plugin = PluginPath::from_parts(
70            config.plugin_owner.clone(),
71            config.plugin_repository.clone(),
72            config.plugin_version.clone(),
73        );
74        Self {
75            config,
76            filesystem,
77            plugin,
78            api: Arc::new(OnceCell::new()),
79            viewer: Arc::new(OnceCell::new()),
80            db: Arc::new(OnceCell::new()),
81            python: Arc::new(OnceCell::new()),
82            no_objectiveai: false,
83        }
84    }
85
86    /// Derive a clone with `objectiveai.execute` inside the embedded
87    /// python gated to raise instead of dispatch. Used by the `python`
88    /// command's `--no-objectiveai` flag and automatically by the
89    /// per-stream-item python output transform. No `reset_api_client`
90    /// needed — this flag doesn't affect any client's identity headers.
91    pub fn with_no_objectiveai(&self, no_objectiveai: bool) -> Self {
92        let mut clone = self.clone();
93        clone.no_objectiveai = no_objectiveai;
94        clone
95    }
96
97    /// The WASI python runtime, initialized on first use and
98    /// memoized. First use machine-wide JIT-compiles the embedded
99    /// interpreter and publishes `<bin>/cache/rustpython-<hash>.cwasm`
100    /// under the bin lock; later uses deserialize that artifact in
101    /// milliseconds. Commands that never execute python never pay
102    /// either cost.
103    pub async fn python(&self) -> Result<&crate::python::Python, crate::error::Error> {
104        self.python
105            .get_or_try_init(|| crate::python::Python::initialize(self.filesystem.bin_dir()))
106            .await
107    }
108
109    /// The API `HttpClient`, built on first use and memoized.
110    ///
111    /// Address resolution: `api.address` from the merged (`--final`)
112    /// config view when set, else the `api spawn` flow — which
113    /// returns the lock-published URL of an already-running api
114    /// without spawning, or starts one and waits for its lock.
115    pub async fn api_client(&self) -> Result<&HttpClient, crate::error::Error> {
116        self.api
117            .get_or_try_init(|| async {
118                let mut config = self
119                    .filesystem
120                    .read_config_view(objectiveai_sdk::cli::command::GetScope::Final)
121                    .await?;
122                let address = match config.api().get_address() {
123                    Some(a) => ensure_scheme(a),
124                    None => crate::command::api::spawn::spawn(self).await?,
125                };
126                Ok(build_http_client(&self.config, &mut config, address))
127            })
128            .await
129    }
130
131    /// Builds an `HttpClient` for direct GitHub reads (agents / swarms /
132    /// functions / profiles) — **without** spawning or contacting the
133    /// ObjectiveAI API. The `github_*` methods talk to github.com directly
134    /// and never use the base `address`, so we hand it a deliberately
135    /// invalid one: any accidental API call through this client fails loudly
136    /// instead of silently hitting a real server. `x_github_authorization`
137    /// (from config) authenticates the GitHub requests. Built fresh per
138    /// call — these commands are short-lived and don't memoize a client.
139    pub async fn github_http_client(
140        &self,
141    ) -> Result<HttpClient, crate::error::Error> {
142        let mut config = self
143            .filesystem
144            .read_config_view(objectiveai_sdk::cli::command::GetScope::Final)
145            .await?;
146        // Tripwire address — this client must only be used for github_* calls.
147        let address = "https://api.invalid.objectiveai-github-client-only/".to_string();
148        Ok(build_http_client(&self.config, &mut config, address))
149    }
150
151    /// Effective MCP timeout (ms), used as BOTH the connect and per-call
152    /// timeout for every MCP client this CLI drives (its streaming
153    /// conduit). The merged (`--final`) `api.mcp_timeout_ms` config value,
154    /// or the canonical default (60000ms) when unset.
155    pub async fn resolve_mcp_timeout_ms(&self) -> Result<u64, crate::error::Error> {
156        Ok(self.resolve_mcp_timeout_ms_opt().await?.unwrap_or(60000))
157    }
158
159    /// Effective backoff max-elapsed-time (ms) — the retry budget for the
160    /// CLI's own MCP client. The merged `api.backoff_max_elapsed_time_ms`
161    /// config value, or the canonical default (60000ms) when unset. The
162    /// other exponential-backoff knobs keep their built-in defaults.
163    pub async fn resolve_backoff_max_elapsed_time_ms(&self) -> Result<u64, crate::error::Error> {
164        Ok(self.resolve_backoff_max_elapsed_time_ms_opt().await?.unwrap_or(60000))
165    }
166
167    /// The configured `api.backoff_max_elapsed_time_ms`, or `None` when
168    /// unset — the api spawn uses this to project the backoff env onto the
169    /// spawned server only when the user explicitly set it.
170    pub async fn resolve_backoff_max_elapsed_time_ms_opt(
171        &self,
172    ) -> Result<Option<u64>, crate::error::Error> {
173        let mut config = self
174            .filesystem
175            .read_config_view(objectiveai_sdk::cli::command::GetScope::Final)
176            .await?;
177        Ok(config.api().get_backoff_max_elapsed_time_ms())
178    }
179
180    /// The configured `api.mcp_timeout_ms`, or `None` when unset.
181    ///
182    /// The api spawn uses this to project the `MCP_*` timeout env onto
183    /// the spawned (machine-wide, shared) server ONLY when the user
184    /// explicitly set a timeout. Leaving it unset lets the api resolve it
185    /// itself — `<OBJECTIVEAI_DIR>/.env`, then its built-in default. That
186    /// fallback is behaviorally identical to passing the canonical
187    /// default in production, and it's what the test `.env` (which sets
188    /// `MCP_BACKOFF_*=0` to fast-fail real MCP errors instead of masking
189    /// them with retry storms) relies on: unconditionally setting the
190    /// default here would clobber that `0` on the shared api.
191    pub async fn resolve_mcp_timeout_ms_opt(
192        &self,
193    ) -> Result<Option<u64>, crate::error::Error> {
194        let mut config = self
195            .filesystem
196            .read_config_view(objectiveai_sdk::cli::command::GetScope::Final)
197            .await?;
198        Ok(config.api().get_mcp_timeout_ms())
199    }
200
201    /// The synchronous-response viewer client, built on first use and
202    /// memoized.
203    ///
204    /// Address resolution mirrors [`Self::api_client`]:
205    /// `viewer.address` from the merged config view when set, else
206    /// the `viewer spawn` flow. Signature: env `VIEWER_SIGNATURE`,
207    /// else `viewer.signature` from the same view.
208    pub async fn viewer_client(&self) -> Result<&ViewerClient, crate::error::Error> {
209        self.viewer
210            .get_or_try_init(|| async {
211                let mut config = self
212                    .filesystem
213                    .read_config_view(objectiveai_sdk::cli::command::GetScope::Final)
214                    .await?;
215                let address = match config.viewer().get_address() {
216                    Some(a) => ensure_scheme(a),
217                    None => crate::command::viewer::spawn::spawn(self).await?,
218                };
219                let signature = env("VIEWER_SIGNATURE")
220                    .or_else(|| config.viewer().get_signature().map(String::from));
221                Ok(ViewerClient::new(address, signature))
222            })
223            .await
224    }
225
226    /// The db pool, connected on first use and memoized — the
227    /// pool-only view of [`Self::db_handle`].
228    pub async fn db_client(&self) -> Result<&db::Pool, crate::error::Error> {
229        Ok(&self.db_handle().await?.pool)
230    }
231
232    /// The db handle — pool plus the admin coordinates it was built
233    /// from (address, admin user/password, database name), which
234    /// [`crate::db::compartment`] needs to mint derived per-plugin
235    /// connection strings. Connected on first use (ensuring the
236    /// application database + schema exist) and memoized. Must NOT
237    /// connect eagerly: commands like `db config ...` and `db spawn`
238    /// have to work before any database exists — they're how you
239    /// bring one up in the first place.
240    ///
241    /// URL resolution: when `db.address` is set in the merged config
242    /// view, a remote-postgres URL is composed from the `config db`
243    /// parts; otherwise the `db spawn` flow returns the local
244    /// cluster's lock-published `postgresql://` URL (starting the
245    /// objectiveai-db supervisor if needed), whose admin coordinates
246    /// are parsed back out of that URL (our own
247    /// `postgresql://postgres:{password}@{host}:{port}` shape).
248    pub async fn db_handle(&self) -> Result<&db::DbHandle, crate::error::Error> {
249        self.db
250            .get_or_try_init(|| async {
251                let mut config = self
252                    .filesystem
253                    .read_config_view(objectiveai_sdk::cli::command::GetScope::Final)
254                    .await?;
255                let address = config.db().get_address().map(String::from);
256                let (url, address, admin_user, admin_password) = match address {
257                    Some(address) => {
258                        let db = config.db();
259                        let user = db
260                            .get_user()
261                            .unwrap_or(crate::filesystem::config::DB_DEFAULT_USER)
262                            .to_string();
263                        let password = db
264                            .get_password()
265                            .unwrap_or(crate::filesystem::config::DB_DEFAULT_PASSWORD)
266                            .to_string();
267                        let url = db::config_url(&address, &user, &password);
268                        (url, address, user, password)
269                    }
270                    None => {
271                        let url = crate::command::db::spawn::spawn(self).await?;
272                        let (address, user, password) =
273                            parse_spawn_db_url(&url).ok_or_else(|| {
274                                crate::error::Error::Instance(format!(
275                                    "db spawn published an unparseable URL: {url}"
276                                ))
277                            })?;
278                        (url, address, user, password)
279                    }
280                };
281                let database = config
282                    .db()
283                    .get_database()
284                    .unwrap_or(crate::filesystem::config::DB_DEFAULT_DATABASE)
285                    .to_string();
286                let pool = db::init(&url, &database).await?;
287                Ok(db::DbHandle {
288                    pool,
289                    address,
290                    admin_user,
291                    admin_password,
292                    database,
293                })
294            })
295            .await
296    }
297
298    /// Detach this clone's API-client cell. MUST be called after
299    /// mutating `config.agent_instance_hierarchy` or
300    /// `config.mcp_session_id` on a cloned `Context`: the memoized
301    /// `HttpClient` snapshots those into headers, so a shared cell
302    /// would either serve the base identity to this clone or poison
303    /// the base context with this clone's identity. The viewer/db
304    /// cells stay shared — neither client carries identity.
305    pub fn reset_api_client(&mut self) {
306        self.api = Arc::new(OnceCell::new());
307    }
308}
309
310/// Build the SDK `HttpClient` for this cli process. `address` is the
311/// already-resolved API base URL; every other field's precedence is
312/// `env var → on-disk config (merged final view) → SDK default`. The
313/// SDK's `env` feature is still enabled in `Cargo.toml`, but every
314/// value we pass in is already resolved (Some/None) so the SDK never
315/// reaches its own env fallback — the precedence chain is ours.
316///
317/// The viewer-mirror headers (`x_viewer_signature`,
318/// `x_viewer_address`) are deliberately not set — viewer discovery is
319/// lock-based now and the API client no longer carries them.
320///
321/// Sourcing `agent_instance_hierarchy` and `mcp_session_id` from
322/// `cli_config` is deliberate: those are env-populated at startup by
323/// `EnvConfigBuilder` *and* mutated for per-request overrides at the
324/// MCP boundary (see the per-call `X-OBJECTIVEAI-AGENT-INSTANCE-
325/// HIERARCHY` header stamp in `objectiveai-mcp`). Re-reading the env
326/// here would silently drop those overrides.
327fn build_http_client(
328    cli_config: &Config,
329    config: &mut crate::filesystem::config::Config,
330    address: String,
331) -> HttpClient {
332    // Every header value comes from the FINAL merged config view — never
333    // re-read from process env here. The config layer is the single source
334    // of truth (env is already folded in at the appropriate precedence when
335    // the view is built), so reading env again would double-source values.
336    let authorization =
337        config.api().get_objectiveai_authorization().map(String::from);
338
339    let user_agent = config.api().get_user_agent().map(String::from);
340
341    let x_title = config.api().get_x_title().map(String::from);
342
343    let http_referer = config.api().get_http_referer().map(String::from);
344
345    let x_github_authorization =
346        config.api().get_github_authorization().map(String::from);
347
348    let x_openrouter_authorization =
349        config.api().get_openrouter_authorization().map(String::from);
350
351    let x_mcp_authorization: Option<std::collections::HashMap<String, String>> =
352        config
353            .api()
354            .get_mcp_authorization()
355            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
356
357    // From cli_config (env-populated at startup, mutable per request
358    // at the MCP boundary) — never re-read from env here.
359    let agent_instance_hierarchy = Some(cli_config.agent_instance_hierarchy.clone());
360    let mcp_session_id = cli_config.mcp_session_id.clone();
361
362    HttpClient::new(
363        reqwest::Client::new(),
364        Some(address),
365        authorization,
366        user_agent,
367        x_title,
368        http_referer,
369        x_github_authorization,
370        x_openrouter_authorization,
371        x_mcp_authorization,
372        None::<String>,
373        None::<String>,
374        agent_instance_hierarchy,
375        mcp_session_id,
376    )
377}
378
379/// Ensure an explicit scheme on a configured address: leave
380/// `http://`/`https://` untouched, otherwise prefix `http://` —
381/// regardless of whether the host is an IPv4, IPv6, or hostname.
382pub(crate) fn ensure_scheme(a: &str) -> String {
383    if a.starts_with("http://") || a.starts_with("https://") {
384        a.to_string()
385    } else {
386        format!("http://{a}")
387    }
388}
389
390/// Parse `(host:port, user, password)` out of the db spawn lock's
391/// published URL. The format is OUR OWN — `objectiveai-db` publishes
392/// exactly `postgresql://{user}:{percent-encoded password}@{host}:{port}`
393/// (see its `connection_string`) — so this is a structural split, not
394/// a general URL parser. `None` on any shape mismatch.
395fn parse_spawn_db_url(url: &str) -> Option<(String, String, String)> {
396    let rest = url
397        .strip_prefix("postgresql://")
398        .or_else(|| url.strip_prefix("postgres://"))?;
399    let (credentials, address) = rest.rsplit_once('@')?;
400    let (user, encoded_password) = credentials.split_once(':')?;
401    let password = percent_encoding::percent_decode_str(encoded_password)
402        .decode_utf8()
403        .ok()?
404        .into_owned();
405    Some((address.to_string(), user.to_string(), password))
406}
407
408fn env(name: &str) -> Option<String> {
409    std::env::var(name).ok()
410}
411
412#[cfg(test)]
413mod tests {
414    use super::ensure_scheme;
415
416    #[test]
417    fn ensure_scheme_cases() {
418        assert_eq!(ensure_scheme("127.0.0.1"), "http://127.0.0.1");
419        assert_eq!(ensure_scheme("127.0.0.1:8080"), "http://127.0.0.1:8080");
420        assert_eq!(ensure_scheme("::1"), "http://::1");
421        assert_eq!(ensure_scheme("api.x"), "http://api.x");
422        assert_eq!(ensure_scheme("http://api.x"), "http://api.x");
423        assert_eq!(ensure_scheme("https://api.x"), "https://api.x");
424    }
425}