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