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