#![cfg(feature = "shells")]
use rmcp::ErrorData as McpError;
use rmcp::model::CallToolResult;
use super::ServerState;
use super::helpers::json_result;
use super::mode::{ShellMode, reject_unsupported};
use super::types_shells::{
ShellBroadcastParams, ShellBroadcastResponse, ShellCaptureParams, ShellCaptureResponse, ShellEnv, ShellKillParams,
ShellKillResponse, ShellListParams, ShellListResponse, ShellParams, ShellSendParams, ShellSendResponse,
ShellSessionView, ShellSpawnParams, ShellSpawnResponse,
};
use crate::shells::SessionId;
use crate::shells::session::ShellCommand;
fn mcp_internal(prefix: &str, err: impl std::fmt::Display) -> McpError {
McpError::internal_error(format!("{prefix}: {err}"), None)
}
fn parse_session_id(raw: &str) -> Result<SessionId, McpError> {
if raw.trim().is_empty() {
return Err(McpError::invalid_params("session_id must not be empty", None));
}
Ok(SessionId::new(raw))
}
fn unknown_session_error(raw: &str) -> McpError {
McpError::invalid_params(
format!("unknown session_id {raw:?}; it may have been killed or never existed"),
None,
)
}
async fn require_session(state: &ServerState, raw: &str) -> Result<(SessionId, rmux_sdk::SessionName), McpError> {
let id = parse_session_id(raw)?;
let resolved = state
.shared
.shell_runtime
.resolve(&id)
.await
.map_err(|e| mcp_internal("resolve session against the embedded shell daemon", e))?;
resolved
.map(|name| (id, name))
.ok_or_else(|| unknown_session_error(raw))
}
fn reject_foreign_fields(mode: ShellMode, present: &[(&str, bool)], allowed: &[&str]) -> Result<(), McpError> {
let foreign: Vec<(&str, bool)> = present
.iter()
.filter(|(field, _)| !allowed.contains(field))
.copied()
.collect();
reject_unsupported(ShellMode::DOMAIN, mode.as_str(), &foreign)
}
fn require_field<T>(mode: ShellMode, field: &str, value: Option<T>) -> Result<T, McpError> {
value
.ok_or_else(|| McpError::invalid_params(format!("`shell` mode=\"{}\" requires `{field}`", mode.as_str()), None))
}
fn allowed_fields(mode: ShellMode) -> &'static [&'static str] {
match mode {
ShellMode::Spawn => &["command", "cwd", "env", "title"],
ShellMode::Send => &["session_id", "text", "enter"],
ShellMode::Capture => &["session_id", "lines"],
ShellMode::Kill => &["session_id"],
ShellMode::List => &[],
ShellMode::Broadcast => &["session_ids", "text", "enter"],
}
}
pub(super) async fn run_shell(state: &ServerState, params: ShellParams) -> Result<CallToolResult, McpError> {
let ShellParams {
mode,
command,
cwd,
env,
title,
session_id,
session_ids,
text,
enter,
lines,
} = params;
let present = [
("command", command.is_some()),
("cwd", cwd.is_some()),
("env", env.is_some()),
("title", title.is_some()),
("session_id", session_id.is_some()),
("session_ids", session_ids.is_some()),
("text", text.is_some()),
("enter", enter.is_some()),
("lines", lines.is_some()),
];
reject_foreign_fields(mode, &present, allowed_fields(mode))?;
match mode {
ShellMode::Spawn => {
run_shell_spawn(
state,
ShellSpawnParams {
command: require_field(mode, "command", command)?,
cwd,
env,
title,
},
)
.await
}
ShellMode::Send => {
run_shell_send(
state,
ShellSendParams {
session_id: require_field(mode, "session_id", session_id)?,
text: require_field(mode, "text", text)?,
enter: enter.unwrap_or(true),
},
)
.await
}
ShellMode::Capture => {
run_shell_capture(
state,
ShellCaptureParams {
session_id: require_field(mode, "session_id", session_id)?,
lines,
},
)
.await
}
ShellMode::Kill => {
run_shell_kill(
state,
ShellKillParams {
session_id: require_field(mode, "session_id", session_id)?,
},
)
.await
}
ShellMode::List => run_shell_list(state, ShellListParams {}).await,
ShellMode::Broadcast => {
run_shell_broadcast(
state,
ShellBroadcastParams {
session_ids: require_field(mode, "session_ids", session_ids)?,
text: require_field(mode, "text", text)?,
enter: enter.unwrap_or(true),
},
)
.await
}
}
}
pub(super) async fn run_shell_spawn(state: &ServerState, params: ShellSpawnParams) -> Result<CallToolResult, McpError> {
if !state.shared.config.shells.enabled {
return Err(McpError::invalid_params(
"shells are disabled in config ([shells].enabled = false)",
None,
));
}
let cwd = resolve_shell_cwd(&state.shared.root, params.cwd).await?;
let session_id = state.shared.shell_runtime.mint_session_id();
#[cfg_attr(not(all(feature = "comms", any(unix, windows))), allow(unused_mut))]
let mut environment = build_environment(params.env.unwrap_or_default())?;
#[cfg(all(feature = "comms", any(unix, windows)))]
let (room_id, child_agent) = couple_session_room(state, session_id.as_str(), &mut environment).await?;
#[cfg(not(all(feature = "comms", any(unix, windows))))]
let (room_id, child_agent): (Option<String>, Option<String>) = (None, None);
let spawned = state
.shared
.shell_runtime
.spawn(
session_id.clone(),
ShellCommand::Shell(params.command),
Some(cwd),
environment,
state.shared.config.shells.default_cols,
state.shared.config.shells.default_rows,
)
.await;
let (session_id, name) = match spawned {
Ok(pair) => pair,
Err(error) => {
#[cfg(all(feature = "comms", any(unix, windows)))]
if let Some(room) = room_id.as_deref() {
rollback_session_room(state, room).await;
}
return Err(mcp_internal("spawn shell session", error));
}
};
let target = crate::shells::launcher::AttachTarget {
session_name: name.as_str().to_string(),
socket_path: state.shared.shell_runtime.socket_path().to_path_buf(),
cols: state.shared.config.shells.default_cols,
rows: state.shared.config.shells.default_rows,
exe: std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("basemind")),
};
let attach_command = target.attach_command();
let visual = state.shared.config.shells.visual;
if visual != crate::config::VisualMode::Headless {
let terminal = state.shared.config.shells.terminal;
if let Err(error) = crate::shells::launcher::present(visual, terminal, &target) {
return Err(presentation_error(error, &session_id, &attach_command));
}
}
let response = ShellSpawnResponse {
session_id: session_id.to_string(),
attach_command,
room_id,
child_agent,
};
json_result(&response)
}
async fn resolve_shell_cwd(root: &std::path::Path, cwd: Option<crate::path::RelPath>) -> Result<String, McpError> {
let canonical_root = tokio::fs::canonicalize(root)
.await
.map_err(|error| mcp_internal("resolve workspace root", error))?;
let candidate = match cwd {
Some(rel) => {
let raw = rel
.as_str()
.ok_or_else(|| McpError::invalid_params("cwd is not valid UTF-8", None))?;
let relative = std::path::Path::new(raw);
if relative.is_absolute() {
return Err(McpError::invalid_params("cwd must be repository-relative", None));
}
root.join(relative)
}
None => root.to_path_buf(),
};
let absolute = tokio::fs::canonicalize(candidate)
.await
.map_err(|error| McpError::invalid_params(format!("cwd cannot be resolved: {error}"), None))?;
let metadata = tokio::fs::metadata(&absolute)
.await
.map_err(|error| McpError::invalid_params(format!("cwd cannot be inspected: {error}"), None))?;
if !absolute.starts_with(&canonical_root) || !metadata.is_dir() {
return Err(McpError::invalid_params(
"cwd must resolve to a directory inside the repository root",
None,
));
}
absolute
.to_str()
.map(ToOwned::to_owned)
.ok_or_else(|| McpError::invalid_params("cwd is not valid UTF-8", None))
}
fn presentation_error(error: anyhow::Error, session_id: &SessionId, attach_command: &str) -> McpError {
mcp_internal(
"present shell session",
format!("{error}; session {session_id} is live — attach with: {attach_command}"),
)
}
const LOADER_VARS: [&str; 5] = [
"LD_PRELOAD",
"LD_AUDIT",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
];
fn build_environment(env: Vec<ShellEnv>) -> Result<Vec<String>, McpError> {
let mut out = Vec::with_capacity(env.len());
for kv in env {
if kv.key.is_empty() {
return Err(McpError::invalid_params("env key must not be empty", None));
}
if kv.key.contains(['=', '\0', '\n', '\r']) {
return Err(McpError::invalid_params(
format!(
"env key {:?} must not contain '=', NUL, newline, or carriage return",
kv.key
),
None,
));
}
if kv.value.contains(['\0', '\n', '\r']) {
return Err(McpError::invalid_params(
format!(
"env value for key {:?} must not contain NUL, newline, or carriage return",
kv.key
),
None,
));
}
if LOADER_VARS.contains(&kv.key.as_str()) {
tracing::warn!(
key = %kv.key,
"shell_spawn: caller supplied a loader-injection env var; passing it through"
);
}
out.push(format!("{}={}", kv.key, kv.value));
}
Ok(out)
}
#[cfg(all(feature = "comms", any(unix, windows)))]
async fn couple_session_room(
state: &ServerState,
session_id: &str,
environment: &mut Vec<String>,
) -> Result<(Option<String>, Option<String>), McpError> {
match try_couple_session_thread(state, session_id, environment).await {
Ok((thread_id, child_agent)) => Ok((Some(thread_id), Some(child_agent))),
Err(error) => {
tracing::warn!(
error = %error,
"shell_spawn: comms coupling unavailable; spawning the session headless"
);
Ok((None, None))
}
}
}
#[cfg(all(feature = "comms", any(unix, windows)))]
async fn try_couple_session_thread(
state: &ServerState,
session_id: &str,
environment: &mut Vec<String>,
) -> Result<(String, String), McpError> {
use super::helpers_comms::{comms_err, resolve_comms_client};
use crate::comms::ids::AgentId;
let parent = &state.agent_id;
let comms_session_id = session_id.to_string();
let child_candidate = format!("{parent}-{comms_session_id}");
let child_agent = match AgentId::parse(child_candidate.clone()) {
Ok(id) => id,
Err(error) => {
let fallback = format!("shell-{comms_session_id}");
let fallback_id = AgentId::parse(fallback.clone()).map_err(|fallback_err| {
comms_err(format!(
"derive child agent id: candidate {child_candidate:?} rejected ({error}) and \
fallback {fallback:?} also rejected ({fallback_err})"
))
})?;
tracing::warn!(
error = %error,
rejected_candidate_len = child_candidate.len(),
fallback = %fallback,
"shell_spawn: derived child agent id rejected by AgentId::parse; using fallback"
);
fallback_id
}
};
let subject = format!("shell session {comms_session_id} ({parent} -> {child_agent})");
let thread_id = {
let handle = resolve_comms_client(state, None).await?;
let mut client = handle.lock().await;
let thread = client
.start_thread(Some(subject), None, vec![child_agent.clone()])
.await
.map_err(comms_err)?;
thread.id.into_string()
};
const IDENTITY_KEYS: [&str; 3] = ["BASEMIND_AGENT_ID", "BASEMIND_PARENT_AGENT_ID", "BASEMIND_THREAD_ID"];
environment.retain(|entry| {
let key = entry.split('=').next().unwrap_or(entry);
!IDENTITY_KEYS.contains(&key)
});
environment.push(format!("BASEMIND_AGENT_ID={child_agent}"));
environment.push(format!("BASEMIND_PARENT_AGENT_ID={parent}"));
environment.push(format!("BASEMIND_THREAD_ID={thread_id}"));
Ok((thread_id, child_agent.into_string()))
}
#[cfg(all(feature = "comms", any(unix, windows)))]
async fn rollback_session_room(state: &ServerState, thread_id: &str) {
use super::helpers_comms::resolve_comms_client;
use crate::comms::ids::ThreadId;
let Ok(thread) = ThreadId::parse(thread_id.to_string()) else {
tracing::warn!(thread_id = %thread_id, "shell_spawn rollback: orphan thread id is unparsable");
return;
};
let archive = async {
let handle = resolve_comms_client(state, None).await?;
let mut client = handle.lock().await;
client
.archive_thread(thread)
.await
.map_err(super::helpers_comms::comms_err)
};
if let Err(error) = archive.await {
tracing::warn!(
error = %error,
thread_id = %thread_id,
"shell_spawn rollback: failed to archive orphaned coupling thread; it may leak"
);
}
}
pub(super) async fn run_shell_send(state: &ServerState, params: ShellSendParams) -> Result<CallToolResult, McpError> {
let (id, name) = require_session(state, ¶ms.session_id).await?;
let session = state
.shared
.shell_runtime
.rmux()
.await
.map_err(|e| mcp_internal("connect embedded shell daemon", e))?
.session(name)
.await
.map_err(|e| mcp_internal("open shell session", e))?;
crate::shells::session::send_text(&session, ¶ms.text, params.enter)
.await
.map_err(|e| mcp_internal("send to shell session", e))?;
json_result(&ShellSendResponse {
session_id: id.to_string(),
sent: true,
})
}
pub(super) async fn run_shell_capture(
state: &ServerState,
params: ShellCaptureParams,
) -> Result<CallToolResult, McpError> {
if params
.lines
.is_some_and(|line_count| line_count > crate::shells::session::MAX_CAPTURE_LINES)
{
return Err(McpError::invalid_params(
format!("lines must be at most {}", crate::shells::session::MAX_CAPTURE_LINES),
None,
));
}
let (_id, name) = require_session(state, ¶ms.session_id).await?;
let session = state
.shared
.shell_runtime
.rmux()
.await
.map_err(|e| mcp_internal("connect embedded shell daemon", e))?
.session(name)
.await
.map_err(|e| mcp_internal("open shell session", e))?;
let text = crate::shells::session::capture(&session, params.lines)
.await
.map_err(|e| mcp_internal("capture shell output", e))?;
json_result(&ShellCaptureResponse { text })
}
pub(super) async fn run_shell_kill(state: &ServerState, params: ShellKillParams) -> Result<CallToolResult, McpError> {
let (id, name) = require_session(state, ¶ms.session_id).await?;
let session = state
.shared
.shell_runtime
.rmux()
.await
.map_err(|e| mcp_internal("connect embedded shell daemon", e))?
.session(name)
.await
.map_err(|e| mcp_internal("open shell session", e))?;
let killed = crate::shells::session::kill_session(&session)
.await
.map_err(|e| mcp_internal("kill shell session", e))?;
json_result(&ShellKillResponse {
session_id: id.to_string(),
killed,
})
}
pub(super) async fn run_shell_broadcast(
state: &ServerState,
params: ShellBroadcastParams,
) -> Result<CallToolResult, McpError> {
if params.session_ids.is_empty() {
return Err(McpError::invalid_params("session_ids must not be empty", None));
}
let mut ids = Vec::with_capacity(params.session_ids.len());
for raw in ¶ms.session_ids {
ids.push(parse_session_id(raw)?);
}
let live = state
.shared
.shell_runtime
.list()
.await
.map_err(|e| mcp_internal("list shell sessions", e))?;
let live_ids: ahash::AHashSet<&str> = live.iter().map(|info| info.session_id.as_str()).collect();
if let Some(unknown) = ids.iter().find(|id| !live_ids.contains(id.as_str())) {
return Err(unknown_session_error(unknown.as_str()));
}
let delivered = state
.shared
.shell_runtime
.broadcast(&ids, ¶ms.text, params.enter)
.await
.map_err(|e| mcp_internal("broadcast to shell sessions", e))?;
json_result(&ShellBroadcastResponse { delivered })
}
pub(super) async fn run_shell_list(state: &ServerState, _params: ShellListParams) -> Result<CallToolResult, McpError> {
let runtime = state
.shared
.shell_runtime
.list()
.await
.map_err(|e| mcp_internal("list shell sessions", e))?;
let mut sessions: Vec<ShellSessionView> = runtime
.into_iter()
.map(|info| ShellSessionView {
session_id: info.session_id.to_string(),
name: info.name.as_str().to_string(),
alive: info.alive,
parent_agent: None,
child_agent: None,
room_id: None,
})
.collect();
sessions.sort_by(|a, b| a.session_id.cmp(&b.session_id));
json_result(&ShellListResponse { sessions })
}
#[cfg(test)]
mod cwd_tests {
use super::resolve_shell_cwd;
use crate::path::RelPath;
#[tokio::test]
async fn shell_cwd_rejects_absolute_path_outside_workspace() {
let root = tempfile::tempdir().expect("workspace tempdir");
let outside = tempfile::tempdir().expect("outside tempdir");
let result = resolve_shell_cwd(
root.path(),
Some(RelPath::from(outside.path().to_string_lossy().as_ref())),
)
.await;
assert!(result.is_err(), "absolute cwd outside the workspace must be rejected");
}
#[cfg(unix)]
#[tokio::test]
async fn shell_cwd_rejects_symlink_escape() {
let root = tempfile::tempdir().expect("workspace tempdir");
let outside = tempfile::tempdir().expect("outside tempdir");
std::os::unix::fs::symlink(outside.path(), root.path().join("outside-link")).expect("create escaping symlink");
let result = resolve_shell_cwd(root.path(), Some(RelPath::from("outside-link"))).await;
assert!(result.is_err(), "symlink cwd outside the workspace must be rejected");
}
}