Sauvegarde de l'arbre de travail en cours (persistance P8, conversations C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le support de la délégation inter-agents pour les profils Codex. Le round-trip inter-agent question/réponse est couvert sans tokens par les tests loopback existants (state::mcp_e2e_loopback_tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
294 lines
13 KiB
Rust
294 lines
13 KiB
Rust
//! Per-project loopback **endpoint** for the IdeA MCP transport (M5a).
|
|
//!
|
|
//! ## Where this sits in the bind (cadrage v5 §1, §2)
|
|
//!
|
|
//! The S-MCP transport is **stdio-spawn**: an MCP CLI (Claude Code, Codex)
|
|
//! reads a `{command,args}` declaration and *spawns* a thin `idea mcp-server`
|
|
//! bridge. That bridge talks JSON-RPC on its stdin/stdout to the CLI and relays
|
|
//! every line, over a **local loopback**, to the IdeA (Tauri) process where the
|
|
//! real [`OrchestratorService`](application::OrchestratorService) lives. The
|
|
//! loopback is a **Unix domain socket** (Linux/macOS) or a **Windows named
|
|
//! pipe** — never a network port, so it stays AppImage- and SSH-remote-safe and
|
|
//! needs no firewall/permission.
|
|
//!
|
|
//! This module owns the **single source of truth** for that loopback address:
|
|
//! [`mcp_endpoint`]. It is deterministic per [`ProjectId`] and is called by
|
|
//! **both** sides of the contract (cadrage §2):
|
|
//! - the side that *listens* — [`ensure_mcp_server`](crate::state::AppState::ensure_mcp_server),
|
|
//! which binds the listener at project-open and tears it down at close (M5a);
|
|
//! - the side that *writes the CLI declaration* — `apply_mcp_config` (M5d),
|
|
//! which must point the spawned bridge at the **exact** same address.
|
|
//!
|
|
//! Keeping the derivation here (not duplicated on each side) is what makes the
|
|
//! M1↔M3 coherence test possible.
|
|
//!
|
|
//! ## Transport crate choice — `interprocess`
|
|
//!
|
|
//! We use the [`interprocess`] crate's *local socket* abstraction rather than
|
|
//! `tokio::net::{UnixListener, windows::named_pipe}` directly, because:
|
|
//! - **One cross-OS API** for UDS (Unix) and named pipes (Windows): a single
|
|
//! `accept` loop in M5c, no divergent `cfg` branches with two transport types.
|
|
//! - The workspace `tokio` is built **without** the `net` feature; pulling
|
|
//! `interprocess` (with its `tokio` feature) into *only* this crate avoids
|
|
//! widening tokio's surface workspace-wide.
|
|
//! - On Unix its listener carries a *reclaim guard* that **unlinks the socket
|
|
//! file on drop** — so closing a project (dropping the handle) cleans the
|
|
//! filesystem with no leak, satisfying M5a's teardown requirement for free.
|
|
//!
|
|
//! We deliberately bind a **filesystem-path** socket on Unix (under a per-user
|
|
//! runtime dir) rather than an abstract/namespaced one, so that the existence of
|
|
//! the endpoint is observable as a real path (testable) and cleaned up on close.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use application::{McpRuntime, McpRuntimeProvider};
|
|
use domain::{AgentId, Project, ProjectId};
|
|
|
|
/// The loopback address of a project's MCP endpoint — the value [`mcp_endpoint`]
|
|
/// returns. Deterministic per [`ProjectId`]; the single source of truth shared by
|
|
/// the listener (M5a/M3) and the CLI-declaration writer (M5d).
|
|
///
|
|
/// On Unix it is a **filesystem path** to a Unix-domain socket (the file is what
|
|
/// gets bound and, on close, unlinked). On Windows it is a **named pipe** path of
|
|
/// the form `\\.\pipe\idea-mcp-<id>`. The [`as_cli_arg`](Self::as_cli_arg) form is
|
|
/// the exact string handed to the spawned bridge via `--endpoint`.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpEndpoint {
|
|
/// The platform-native address string (UDS path / named-pipe path).
|
|
addr: String,
|
|
}
|
|
|
|
impl McpEndpoint {
|
|
/// The address as the bridge expects it on the command line (`--endpoint`).
|
|
/// Identical string on both sides of the contract (cadrage §2).
|
|
#[must_use]
|
|
pub fn as_cli_arg(&self) -> &str {
|
|
&self.addr
|
|
}
|
|
|
|
/// The Unix socket **file path**, when this endpoint is a filesystem-path UDS.
|
|
/// Used by the listener to ensure the parent runtime dir exists and by tests to
|
|
/// assert creation/cleanup. `None` on platforms whose address is not a path
|
|
/// (Windows named pipes have no filesystem entry to manage).
|
|
#[must_use]
|
|
pub fn socket_path(&self) -> Option<PathBuf> {
|
|
#[cfg(unix)]
|
|
{
|
|
Some(PathBuf::from(&self.addr))
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The directory under which per-project Unix sockets live, derived from the
|
|
/// per-user runtime dir (`$XDG_RUNTIME_DIR`, falling back to `$TMPDIR`, then
|
|
/// `/tmp`). Kept short so the full socket path stays well under the ~108-byte
|
|
/// `sockaddr_un` limit (`/run/user/<uid>/idea-mcp/<32 hex>.sock` ≈ 50 bytes).
|
|
#[cfg(unix)]
|
|
fn unix_runtime_dir() -> PathBuf {
|
|
let base = std::env::var_os("XDG_RUNTIME_DIR")
|
|
.map(PathBuf::from)
|
|
.or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from))
|
|
.unwrap_or_else(|| PathBuf::from("/tmp"));
|
|
base.join("idea-mcp")
|
|
}
|
|
|
|
/// **Single source of truth.** Computes the deterministic loopback endpoint of a
|
|
/// project's MCP transport from its [`ProjectId`].
|
|
///
|
|
/// Same input ⇒ same output (stable across calls and processes); distinct projects
|
|
/// ⇒ distinct endpoints (the id's 32-hex *simple* form is unique and collision-free
|
|
/// by construction). No network port is ever used.
|
|
///
|
|
/// - **Unix**: `<runtime-dir>/idea-mcp/<id>.sock` — a UDS file path.
|
|
/// - **Windows**: `\\.\pipe\idea-mcp-<id>` — a named pipe.
|
|
///
|
|
/// where `<id>` is the project UUID in hyphen-free hex (`simple`) form.
|
|
#[must_use]
|
|
pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
|
|
// Hyphen-free, lowercase, fixed-width (32 chars): safe in both a filename and
|
|
// a `\\.\pipe\` name, and unique per project.
|
|
let id = project_id.as_uuid().simple().to_string();
|
|
|
|
#[cfg(unix)]
|
|
let addr = unix_runtime_dir()
|
|
.join(format!("{id}.sock"))
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
|
|
#[cfg(windows)]
|
|
let addr = format!(r"\\.\pipe\idea-mcp-{id}");
|
|
|
|
#[cfg(not(any(unix, windows)))]
|
|
let addr = format!("idea-mcp-{id}");
|
|
|
|
McpEndpoint { addr }
|
|
}
|
|
|
|
/// Chemin du binaire IdeA à inscrire comme `command` dans la déclaration MCP.
|
|
///
|
|
/// Privilégie `$APPIMAGE` (chemin **stable** du `.AppImage`, qui survit aux
|
|
/// redémarrages) car sous AppImage `current_exe()` pointe sur un montage éphémère
|
|
/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP.
|
|
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
|
|
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
|
|
pub(crate) fn idea_exe_path() -> Option<String> {
|
|
std::env::var("APPIMAGE").ok().or_else(|| {
|
|
std::env::current_exe()
|
|
.ok()
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
})
|
|
}
|
|
|
|
/// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à
|
|
/// l'orchestrateur (couche `application`) les **faits OS/runtime** nécessaires pour
|
|
/// écrire la déclaration MCP réelle quand il (re)lance une cible sur le chemin
|
|
/// `ask` (`ensure_live_pty`).
|
|
///
|
|
/// Sans état : tout est dérivé du `Project` et de l'`AgentId` reçus + de
|
|
/// l'environnement (`$APPIMAGE`/`current_exe`). C'est le **seul** détenteur légitime
|
|
/// de ces faits (la couche `application` ne doit pas dépendre de `current_exe` /
|
|
/// `$APPIMAGE` / `mcp_endpoint`, cadrage v5 §0.3 / §7) ; seules des **chaînes**
|
|
/// traversent la frontière via [`McpRuntime`].
|
|
pub struct AppMcpRuntimeProvider;
|
|
|
|
impl McpRuntimeProvider for AppMcpRuntimeProvider {
|
|
/// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera
|
|
/// `idea_reply`). Réutilise la **même** logique que le chemin GUI
|
|
/// (`commands.rs::launch_agent`) : même endpoint (source de vérité unique),
|
|
/// même forme `simple` 32-hex du `project_id`. `None` (exe introuvable) ⇒
|
|
/// l'orchestrateur dégrade vers la déclaration minimale, jamais d'échec de
|
|
/// lancement.
|
|
fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime> {
|
|
Some(McpRuntime {
|
|
exe: idea_exe_path()?,
|
|
endpoint: mcp_endpoint(&project.id).as_cli_arg().to_owned(),
|
|
project_id: project.id.as_uuid().simple().to_string(),
|
|
requester: agent_id.to_string(),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use uuid::Uuid;
|
|
|
|
fn pid(s: &str) -> ProjectId {
|
|
ProjectId::from_uuid(Uuid::parse_str(s).unwrap())
|
|
}
|
|
|
|
#[test]
|
|
fn endpoint_is_deterministic_for_the_same_project() {
|
|
let p = pid("11111111-1111-1111-1111-111111111111");
|
|
assert_eq!(mcp_endpoint(&p), mcp_endpoint(&p));
|
|
assert_eq!(mcp_endpoint(&p).as_cli_arg(), mcp_endpoint(&p).as_cli_arg());
|
|
}
|
|
|
|
#[test]
|
|
fn distinct_projects_get_distinct_endpoints() {
|
|
let a = pid("11111111-1111-1111-1111-111111111111");
|
|
let b = pid("22222222-2222-2222-2222-222222222222");
|
|
assert_ne!(mcp_endpoint(&a), mcp_endpoint(&b));
|
|
assert_ne!(mcp_endpoint(&a).as_cli_arg(), mcp_endpoint(&b).as_cli_arg());
|
|
}
|
|
|
|
#[test]
|
|
fn address_encodes_the_project_id_without_hyphens() {
|
|
let p = pid("abcdef01-2345-6789-abcd-ef0123456789");
|
|
let arg = mcp_endpoint(&p).as_cli_arg().to_owned();
|
|
assert!(
|
|
arg.contains("abcdef0123456789abcdef0123456789"),
|
|
"endpoint must embed the hyphen-free id, got {arg}"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn unix_endpoint_is_a_sock_path_under_the_runtime_dir() {
|
|
let p = pid("11111111-1111-1111-1111-111111111111");
|
|
let ep = mcp_endpoint(&p);
|
|
let path = ep
|
|
.socket_path()
|
|
.expect("unix endpoint exposes a socket path");
|
|
assert!(path.extension().is_some_and(|e| e == "sock"));
|
|
assert_eq!(path.parent().unwrap(), unix_runtime_dir());
|
|
}
|
|
|
|
/// `idea_exe_path()` : `$APPIMAGE` posé ⇒ on renvoie sa valeur (chemin stable du
|
|
/// `.AppImage`, qui survit aux redémarrages) ; absent ⇒ on retombe sur
|
|
/// `current_exe()`. Les deux assertions sont regroupées dans **un seul** test pour
|
|
/// éviter une course inter-tests sur la variable d'env globale du process ; la var
|
|
/// est sauvegardée/restaurée pour ne pas polluer les autres tests.
|
|
#[test]
|
|
fn idea_exe_path_prefers_appimage_then_falls_back_to_current_exe() {
|
|
let saved = std::env::var_os("APPIMAGE");
|
|
|
|
// APPIMAGE posé ⇒ valeur exacte renvoyée.
|
|
std::env::set_var("APPIMAGE", "/opt/idea/IdeA.AppImage");
|
|
assert_eq!(
|
|
idea_exe_path().as_deref(),
|
|
Some("/opt/idea/IdeA.AppImage"),
|
|
"$APPIMAGE doit primer"
|
|
);
|
|
|
|
// APPIMAGE absent ⇒ repli sur current_exe() (présent dans un binaire de test).
|
|
std::env::remove_var("APPIMAGE");
|
|
let fallback = idea_exe_path();
|
|
let expected = std::env::current_exe()
|
|
.ok()
|
|
.map(|p| p.to_string_lossy().into_owned());
|
|
assert_eq!(fallback, expected, "repli attendu sur current_exe()");
|
|
assert!(fallback.is_some(), "current_exe() résolvable dans le test");
|
|
|
|
// Restauration de l'état initial de la variable.
|
|
match saved {
|
|
Some(v) => std::env::set_var("APPIMAGE", v),
|
|
None => std::env::remove_var("APPIMAGE"),
|
|
}
|
|
}
|
|
|
|
/// `AppMcpRuntimeProvider::runtime_for` : cohérence des champs avec les sources de
|
|
/// vérité — endpoint identique à `mcp_endpoint(project.id).as_cli_arg()`,
|
|
/// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`.
|
|
#[test]
|
|
fn app_provider_runtime_for_matches_sources_of_truth() {
|
|
use domain::project::ProjectPath;
|
|
use domain::remote::RemoteRef;
|
|
use domain::{AgentId, Project, ProjectId};
|
|
|
|
let project = Project::new(
|
|
ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()),
|
|
"demo",
|
|
ProjectPath::new("/home/me/proj").unwrap(),
|
|
RemoteRef::local(),
|
|
1_700_000_000_000,
|
|
)
|
|
.unwrap();
|
|
let agent_id = AgentId::from_uuid(Uuid::from_u128(42));
|
|
|
|
// On s'assure que current_exe/$APPIMAGE est résolvable (sinon runtime_for None).
|
|
let rt = AppMcpRuntimeProvider
|
|
.runtime_for(&project, agent_id)
|
|
.expect("runtime_for doit produire un McpRuntime (exe résolvable)");
|
|
|
|
// Endpoint = même source de vérité que le listener.
|
|
assert_eq!(
|
|
rt.endpoint,
|
|
mcp_endpoint(&project.id).as_cli_arg(),
|
|
"endpoint cohérent avec mcp_endpoint()"
|
|
);
|
|
// project_id en forme simple 32-hex (hyphen-free), consommée par la garde M5c.
|
|
assert_eq!(rt.project_id, project.id.as_uuid().simple().to_string());
|
|
assert_eq!(rt.project_id.len(), 32, "forme simple 32-hex");
|
|
assert!(!rt.project_id.contains('-'), "pas de tirets");
|
|
// requester = l'id de la cible relancée.
|
|
assert_eq!(rt.requester, agent_id.to_string());
|
|
// exe non vide.
|
|
assert!(!rt.exe.is_empty(), "exe renseigné");
|
|
}
|
|
}
|