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}
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    /// The synchronous-response viewer client, built on first use and
113    /// memoized.
114    ///
115    /// Address resolution mirrors [`Self::api_client`]:
116    /// `viewer.address` from the merged config view when set, else
117    /// the `viewer spawn` flow. Signature: env `VIEWER_SIGNATURE`,
118    /// else `viewer.signature` from the same view.
119    pub async fn viewer_client(&self) -> Result<&ViewerClient, crate::error::Error> {
120        self.viewer
121            .get_or_try_init(|| async {
122                let mut config = self
123                    .filesystem
124                    .read_config_view(objectiveai_sdk::cli::command::GetScope::Final)
125                    .await?;
126                let address = match config.viewer().get_address() {
127                    Some(a) => ensure_scheme(a),
128                    None => crate::command::viewer::spawn::spawn(self).await?,
129                };
130                let signature = env("VIEWER_SIGNATURE")
131                    .or_else(|| config.viewer().get_signature().map(String::from));
132                Ok(ViewerClient::new(address, signature))
133            })
134            .await
135    }
136
137    /// The db pool, connected on first use and memoized — the
138    /// pool-only view of [`Self::db_handle`].
139    pub async fn db_client(&self) -> Result<&db::Pool, crate::error::Error> {
140        Ok(&self.db_handle().await?.pool)
141    }
142
143    /// The db handle — pool plus the admin coordinates it was built
144    /// from (address, admin user/password, database name), which
145    /// [`crate::db::compartment`] needs to mint derived per-plugin
146    /// connection strings. Connected on first use (ensuring the
147    /// application database + schema exist) and memoized. Must NOT
148    /// connect eagerly: commands like `db config ...` and `db spawn`
149    /// have to work before any database exists — they're how you
150    /// bring one up in the first place.
151    ///
152    /// URL resolution: when `db.address` is set in the merged config
153    /// view, a remote-postgres URL is composed from the `config db`
154    /// parts; otherwise the `db spawn` flow returns the local
155    /// cluster's lock-published `postgresql://` URL (starting the
156    /// objectiveai-db supervisor if needed), whose admin coordinates
157    /// are parsed back out of that URL (our own
158    /// `postgresql://postgres:{password}@{host}:{port}` shape).
159    pub async fn db_handle(&self) -> Result<&db::DbHandle, crate::error::Error> {
160        self.db
161            .get_or_try_init(|| async {
162                let mut config = self
163                    .filesystem
164                    .read_config_view(objectiveai_sdk::cli::command::GetScope::Final)
165                    .await?;
166                let address = config.db().get_address().map(String::from);
167                let (url, address, admin_user, admin_password) = match address {
168                    Some(address) => {
169                        let db = config.db();
170                        let user = db
171                            .get_user()
172                            .unwrap_or(crate::filesystem::config::DB_DEFAULT_USER)
173                            .to_string();
174                        let password = db
175                            .get_password()
176                            .unwrap_or(crate::filesystem::config::DB_DEFAULT_PASSWORD)
177                            .to_string();
178                        let url = db::config_url(&address, &user, &password);
179                        (url, address, user, password)
180                    }
181                    None => {
182                        let url = crate::command::db::spawn::spawn(self).await?;
183                        let (address, user, password) =
184                            parse_spawn_db_url(&url).ok_or_else(|| {
185                                crate::error::Error::Instance(format!(
186                                    "db spawn published an unparseable URL: {url}"
187                                ))
188                            })?;
189                        (url, address, user, password)
190                    }
191                };
192                let database = config
193                    .db()
194                    .get_database()
195                    .unwrap_or(crate::filesystem::config::DB_DEFAULT_DATABASE)
196                    .to_string();
197                let pool = db::init(&url, &database).await?;
198                Ok(db::DbHandle {
199                    pool,
200                    address,
201                    admin_user,
202                    admin_password,
203                    database,
204                })
205            })
206            .await
207    }
208
209    /// Detach this clone's API-client cell. MUST be called after
210    /// mutating `config.agent_instance_hierarchy` or
211    /// `config.mcp_session_id` on a cloned `Context`: the memoized
212    /// `HttpClient` snapshots those into headers, so a shared cell
213    /// would either serve the base identity to this clone or poison
214    /// the base context with this clone's identity. The viewer/db
215    /// cells stay shared — neither client carries identity.
216    pub fn reset_api_client(&mut self) {
217        self.api = Arc::new(OnceCell::new());
218    }
219}
220
221/// Build the SDK `HttpClient` for this cli process. `address` is the
222/// already-resolved API base URL; every other field's precedence is
223/// `env var → on-disk config (merged final view) → SDK default`. The
224/// SDK's `env` feature is still enabled in `Cargo.toml`, but every
225/// value we pass in is already resolved (Some/None) so the SDK never
226/// reaches its own env fallback — the precedence chain is ours.
227///
228/// The viewer-mirror headers (`x_viewer_signature`,
229/// `x_viewer_address`) are deliberately not set — viewer discovery is
230/// lock-based now and the API client no longer carries them.
231///
232/// Sourcing `agent_instance_hierarchy` and `mcp_session_id` from
233/// `cli_config` is deliberate: those are env-populated at startup by
234/// `EnvConfigBuilder` *and* mutated for per-request overrides at the
235/// MCP boundary (see the per-call `X-OBJECTIVEAI-AGENT-INSTANCE-
236/// HIERARCHY` header stamp in `objectiveai-mcp`). Re-reading the env
237/// here would silently drop those overrides.
238fn build_http_client(
239    cli_config: &Config,
240    config: &mut crate::filesystem::config::Config,
241    address: String,
242) -> HttpClient {
243    let authorization = env("OBJECTIVEAI_AUTHORIZATION").or_else(|| {
244        config
245            .api()
246            .get_objectiveai_authorization()
247            .map(String::from)
248    });
249
250    let user_agent =
251        env("USER_AGENT").or_else(|| config.api().get_user_agent().map(String::from));
252
253    let x_title =
254        env("X_TITLE").or_else(|| config.api().get_x_title().map(String::from));
255
256    let http_referer = env("HTTP_REFERER")
257        .or_else(|| config.api().get_http_referer().map(String::from));
258
259    // Deliberately NOT env-sourced: the GitHub credential lives in the
260    // on-disk json config only (`api config github-authorization set`).
261    let x_github_authorization =
262        config.api().get_github_authorization().map(String::from);
263
264    let x_openrouter_authorization = env("OPENROUTER_AUTHORIZATION").or_else(|| {
265        config
266            .api()
267            .get_openrouter_authorization()
268            .map(String::from)
269    });
270
271    let x_mcp_authorization: Option<std::collections::HashMap<String, String>> =
272        env("MCP_AUTHORIZATION")
273            .and_then(|v| serde_json::from_str(&v).ok())
274            .or_else(|| {
275                config
276                    .api()
277                    .get_mcp_authorization()
278                    .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
279            });
280
281    let x_commit_author_name = env("COMMIT_AUTHOR_NAME")
282        .or_else(|| config.api().get_commit_author_name().map(String::from));
283
284    let x_commit_author_email = env("COMMIT_AUTHOR_EMAIL")
285        .or_else(|| config.api().get_commit_author_email().map(String::from));
286
287    // From cli_config (env-populated at startup, mutable per request
288    // at the MCP boundary) — never re-read from env here.
289    let agent_instance_hierarchy = Some(cli_config.agent_instance_hierarchy.clone());
290    let mcp_session_id = cli_config.mcp_session_id.clone();
291
292    HttpClient::new(
293        reqwest::Client::new(),
294        Some(address),
295        authorization,
296        user_agent,
297        x_title,
298        http_referer,
299        x_github_authorization,
300        x_openrouter_authorization,
301        x_mcp_authorization,
302        None::<String>,
303        None::<String>,
304        x_commit_author_name,
305        x_commit_author_email,
306        agent_instance_hierarchy,
307        mcp_session_id,
308    )
309}
310
311/// Ensure an explicit scheme on a configured address: leave
312/// `http://`/`https://` untouched, otherwise prefix `http://` —
313/// regardless of whether the host is an IPv4, IPv6, or hostname.
314pub(crate) fn ensure_scheme(a: &str) -> String {
315    if a.starts_with("http://") || a.starts_with("https://") {
316        a.to_string()
317    } else {
318        format!("http://{a}")
319    }
320}
321
322/// Parse `(host:port, user, password)` out of the db spawn lock's
323/// published URL. The format is OUR OWN — `objectiveai-db` publishes
324/// exactly `postgresql://{user}:{percent-encoded password}@{host}:{port}`
325/// (see its `connection_string`) — so this is a structural split, not
326/// a general URL parser. `None` on any shape mismatch.
327fn parse_spawn_db_url(url: &str) -> Option<(String, String, String)> {
328    let rest = url
329        .strip_prefix("postgresql://")
330        .or_else(|| url.strip_prefix("postgres://"))?;
331    let (credentials, address) = rest.rsplit_once('@')?;
332    let (user, encoded_password) = credentials.split_once(':')?;
333    let password = percent_encoding::percent_decode_str(encoded_password)
334        .decode_utf8()
335        .ok()?
336        .into_owned();
337    Some((address.to_string(), user.to_string(), password))
338}
339
340fn env(name: &str) -> Option<String> {
341    std::env::var(name).ok()
342}
343
344#[cfg(test)]
345mod tests {
346    use super::ensure_scheme;
347
348    #[test]
349    fn ensure_scheme_cases() {
350        assert_eq!(ensure_scheme("127.0.0.1"), "http://127.0.0.1");
351        assert_eq!(ensure_scheme("127.0.0.1:8080"), "http://127.0.0.1:8080");
352        assert_eq!(ensure_scheme("::1"), "http://::1");
353        assert_eq!(ensure_scheme("api.x"), "http://api.x");
354        assert_eq!(ensure_scheme("http://api.x"), "http://api.x");
355        assert_eq!(ensure_scheme("https://api.x"), "https://api.x");
356    }
357}