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