Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src-tauri/src/config.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
region: Option<String>,
},
}

/// 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<String, String> {
let mut env: HashMap<String, String> = 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<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Result<PathBuf, String> {
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<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> 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<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
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()))
}
44 changes: 43 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod config;
mod mcp;
mod remote;

Expand Down Expand Up @@ -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(())
Expand All @@ -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<config::McpTarget, String> {
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.
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 25 additions & 7 deletions src-tauri/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ pub struct McpClient {
}

struct Inner {
child: Mutex<CommandChild>,
/// `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<Option<CommandChild>>,
pending: Mutex<HashMap<u64, oneshot::Sender<Value>>>,
next_id: AtomicU64,
emit: EmitFn,
Expand Down Expand Up @@ -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<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
cluster: &str,
target: &crate::config::McpTarget,
) -> Result<Self, String> {
let app_emit = app.clone();
let emit: EmitFn = Arc::new(move |event: &str, payload: Value| {
Expand All @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
Loading