diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs new file mode 100644 index 0000000..55ee35e --- /dev/null +++ b/src-tauri/src/config.rs @@ -0,0 +1,145 @@ +//! Persisted, **provider-tagged** connection target for the `oab-mcp` sidecar, +//! plus the **hermetic** child env used to spawn it. +//! +//! The desktop used to spawn the core with nothing but `OAB_CLUSTER`, so the +//! sidecar inherited whatever AWS credentials/region the host's *ambient* default +//! chain resolved — silently pointing Studio at the wrong account/region (the +//! `AccessDenied` drift). Two design choices close that: +//! +//! - **Provider-tagged.** The target is an enum (`Ecs` today; k8s etc. add a +//! variant). The spawn path is provider-agnostic — it just asks the target for +//! its env — so a new runtime is a new variant + its own `hermetic_env` arm, not +//! a rewrite. +//! - **Hermetic env.** The child env is built **from empty**: only a small +//! base allow-list of system vars is carried over (if present), then the target +//! injects exactly its own vars. No ambient credential/region can leak in, and +//! one provider's vars can never bleed into another provider's sidecar (a plain +//! "strip `AWS_*`" blacklist would still leak a stale `KUBECONFIG`). + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use tauri::Manager; + +/// Provider-tagged connection target. One variant today (ECS); adding k8s is a new +/// variant + its own `hermetic_env` arm — nothing else on the spawn path changes. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "provider", rename_all = "lowercase")] +pub enum McpTarget { + /// AWS ECS. `profile` / `region` are optional; left unset the sidecar gets + /// **no** AWS credentials in its (hermetic) env, which surfaces as an explicit + /// auth error rather than silently drifting onto an ambient identity — exactly + /// the drift this exists to close, so the UI nudges the user to set them. + Ecs { + cluster: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + region: Option, + }, +} + +/// System env keys every sidecar needs regardless of provider, carried over from +/// the parent **only if present**. Everything else is dropped — this is what makes +/// the env hermetic: no ambient `AWS_*` / `KUBECONFIG` / credential var leaks in. +const BASE_ENV_ALLOW: &[&str] = &[ + "HOME", // AWS SDK resolves ~/.aws/{config,credentials} via HOME + "PATH", // dylib / helper resolution + "TMPDIR", // temp files (macOS) + "TZ", // timestamps + "LANG", // locale + "LC_ALL", // locale + "LC_CTYPE", // locale + "SSL_CERT_FILE", // TLS trust, if the host pins one + "SSL_CERT_DIR", // TLS trust, if the host pins one +]; + +impl McpTarget { + /// First-run default: seed from the process env so behaviour is unchanged + /// until the user saves an explicit target. + pub fn env_seeded_default() -> Self { + let non_empty = |s: String| Some(s).filter(|v| !v.is_empty()); + McpTarget::Ecs { + cluster: std::env::var("OAB_CLUSTER").unwrap_or_else(|_| "oab".to_string()), + profile: std::env::var("AWS_PROFILE").ok().and_then(non_empty), + region: std::env::var("AWS_REGION") + .ok() + .or_else(|| std::env::var("AWS_DEFAULT_REGION").ok()) + .and_then(non_empty), + } + } + + /// The cluster the roster / tool calls target. + pub fn cluster(&self) -> &str { + match self { + McpTarget::Ecs { cluster, .. } => cluster, + } + } + + /// Build the sidecar's child env **from empty** (hermetic): carry only the base + /// allow-list that exists in the parent, then inject exactly this provider's + /// target vars. No ambient credential/region leaks; no cross-provider bleed. + /// + /// NOTE (desktop-only): the base list is a *desktop* app's needs. If this ever + /// runs in a container, a k8s/EKS variant must also allow the container-cred + /// vars its auth needs (e.g. `AWS_CONTAINER_CREDENTIALS_*` for EKS exec auth) — + /// declared explicitly in that variant's arm, never inherited by accident. + pub fn hermetic_env(&self) -> HashMap { + let mut env: HashMap = BASE_ENV_ALLOW + .iter() + .filter_map(|k| std::env::var(k).ok().map(|v| ((*k).to_string(), v))) + .collect(); + match self { + McpTarget::Ecs { + cluster, + profile, + region, + } => { + env.insert("OAB_CLUSTER".into(), cluster.clone()); + if let Some(p) = profile.as_deref().filter(|s| !s.is_empty()) { + env.insert("AWS_PROFILE".into(), p.to_string()); + } + if let Some(r) = region.as_deref().filter(|s| !s.is_empty()) { + env.insert("AWS_REGION".into(), r.to_string()); + env.insert("AWS_DEFAULT_REGION".into(), r.to_string()); + } + } + } + env + } +} + +fn config_path(app: &tauri::AppHandle) -> Result { + let dir = app + .path() + .app_config_dir() + .map_err(|e| format!("resolve app config dir: {e}"))?; + Ok(dir.join("mcp-target.json")) +} + +/// Load the persisted target, or the env-seeded default if absent / unreadable. +/// Never fails: a corrupt file falls back to default rather than blocking boot. +pub fn load(app: &tauri::AppHandle) -> McpTarget { + let Ok(path) = config_path(app) else { + return McpTarget::env_seeded_default(); + }; + match std::fs::read_to_string(&path) { + Ok(s) => serde_json::from_str(&s).unwrap_or_else(|_| McpTarget::env_seeded_default()), + Err(_) => McpTarget::env_seeded_default(), + } +} + +/// Persist the target (creating the config dir if needed), validating it +/// round-trips through JSON first. +pub fn save( + app: &tauri::AppHandle, + target: &McpTarget, +) -> Result<(), String> { + let path = config_path(app)?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("create config dir: {e}"))?; + } + let json = serde_json::to_string_pretty(target).map_err(|e| e.to_string())?; + std::fs::write(&path, json).map_err(|e| format!("write {}: {e}", path.display())) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 77e1bc3..ba32a0a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod config; mod mcp; mod remote; @@ -27,7 +28,7 @@ async fn start_core(app: tauri::AppHandle, core: tauri::State<'_, Core>) -> Resu return Ok(()); } // (the `core: spawning…` line from McpClient::spawn covers the "starting" beat) - match McpClient::spawn(&app, &default_cluster()).await { + match McpClient::spawn(&app, &config::load(&app)).await { Ok(client) => { *guard = Some(client); Ok(()) @@ -42,6 +43,45 @@ async fn start_core(app: tauri::AppHandle, core: tauri::State<'_, Core>) -> Resu } } +/// The persisted (or env-seeded default) sidecar connection target, for the +/// Config tab to render/edit. +#[tauri::command] +async fn mcp_target_get(app: tauri::AppHandle) -> Result { + Ok(config::load(&app)) +} + +/// Persist a new sidecar target and **reload the core onto it** without an app +/// restart: kill the running sidecar, spawn a fresh one with the new hermetic env. +#[tauri::command] +async fn mcp_target_set( + app: tauri::AppHandle, + core: tauri::State<'_, Core>, + target: config::McpTarget, +) -> Result<(), String> { + config::save(&app, &target)?; + let mut guard = core.0.lock().await; + if let Some(old) = guard.take() { + old.shutdown().await; + let _ = app.emit( + "app-log", + json!({ "level": "info", "msg": "core: reloading onto new target…" }), + ); + } + match McpClient::spawn(&app, &target).await { + Ok(client) => { + *guard = Some(client); + Ok(()) + } + Err(e) => { + let _ = app.emit( + "app-log", + json!({ "level": "error", "msg": format!("core: reload failed — {e}") }), + ); + Err(e) + } + } +} + /// List services (`deploy_list`) then fetch each one's per-instance 6-state /// (`deploy_get`), all over MCP — the two-step the in-process bridge used, /// now over the wire. Console view-model shape is unchanged. @@ -356,6 +396,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ start_core, + mcp_target_get, + mcp_target_set, deploy_list, runtime_context, fleet_config, diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index ecb8698..b4fc390 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -37,7 +37,9 @@ pub struct McpClient { } struct Inner { - child: Mutex, + /// `Option` so `shutdown` can take the child out and `kill()` it (which + /// consumes the handle) for a reload; `None` once the core is down. + child: Mutex>, pending: Mutex>>, next_id: AtomicU64, emit: EmitFn, @@ -72,11 +74,13 @@ impl Inner { impl McpClient { /// Spawn the sidecar, wire up the stdout reader, and complete the MCP - /// handshake. `cluster` is passed through as `OAB_CLUSTER` so the core - /// defaults match the desktop's. + /// handshake. The core's target is pinned from `target`: its cluster becomes + /// `OAB_CLUSTER`, and the child is spawned with a **hermetic** env (built from + /// empty — see [`crate::config::McpTarget::hermetic_env`]) so no ambient + /// credential/region can drag the sidecar onto the wrong identity. pub async fn spawn( app: &tauri::AppHandle, - cluster: &str, + target: &crate::config::McpTarget, ) -> Result { let app_emit = app.clone(); let emit: EmitFn = Arc::new(move |event: &str, payload: Value| { @@ -85,18 +89,20 @@ impl McpClient { emit( "app-log", - json!({ "level": "info", "msg": format!("core: spawning (cluster {cluster})…") }), + json!({ "level": "info", "msg": format!("core: spawning (cluster {})…", target.cluster()) }), ); + // Hermetic child env: cleared, then exactly the allow-list + target vars. let (mut rx, child) = app .shell() .sidecar("oab-mcp") .map_err(|e| format!("locate oab-mcp sidecar: {e}"))? - .env("OAB_CLUSTER", cluster) + .env_clear() + .envs(target.hermetic_env()) .spawn() .map_err(|e| format!("spawn oab-mcp: {e}"))?; let inner = Arc::new(Inner { - child: Mutex::new(child), + child: Mutex::new(Some(child)), pending: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), emit: emit.clone(), @@ -172,10 +178,22 @@ impl McpClient { .child .lock() .await + .as_mut() + .ok_or_else(|| "core is shut down".to_string())? .write(&line) .map_err(|e| format!("write to oab-mcp: {e}")) } + /// Kill the sidecar child (best-effort) so a reload can spawn a fresh one on a + /// new target. Idempotent — a second call is a no-op once the child is gone. + pub async fn shutdown(&self) { + if let Some(child) = self.inner.child.lock().await.take() { + let _ = child.kill(); + } + // Unblock any in-flight waiters rather than hanging them. + self.inner.pending.lock().await.clear(); + } + /// Send a request and await the correlated `result` (or a formatted error). /// Public so the reverse-MCP tunnel can relay an inner `tools/list` / /// `tools/call` to the sidecar and return its **raw** MCP result verbatim