fix(web): démarre le serveur MCP custom CLI via idea-serve au lieu de node — #224
This commit is contained in:
@ -1,17 +1,34 @@
|
||||
---
|
||||
issueRef: "#224"
|
||||
version: 5
|
||||
version: 14
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1786981905837
|
||||
updatedAt: 1787222847970
|
||||
---
|
||||
2026-08-17 Main
|
||||
- Demande: permettre le passage en custom CLI sur la version web telephone avec une UI correctement adaptee aux telephones.
|
||||
- UX: la bascule doit vivre dans la session agent plein ecran mobile, pas dans la liste Travail. Controle segmente `TUI native` / `CLI custom`, affichage exclusif d'un mode a la fois, composer fixe en bas, adaptation tactile/mobile, confirmations en bottom sheet.
|
||||
- Architect: aucun nouveau port backend; reutiliser AgentGateway et CustomAgentChatView. Introduire un host mobile de session agent, partager la logique d'eligibilite custom CLI avec le desktop, et ajouter un `draftScope` explicite pour la persistance mobile sans `layoutId`.
|
||||
- DevFrontend: implementation annoncee terminee sur `feature/224-mobile-web-custom-cli`.
|
||||
- QA: verdict ROUGE. Repro reelle sur fallback mobile quand un profil perd le support custom CLI apres le mount.
|
||||
- Commande QA en echec: `cd /home/anthony/Documents/Projects/IdeA/frontend && npm test -- --run src/features/web/WebAgentCell.test.tsx`.
|
||||
- Symptom QA: test `falls back to native TUI when the mobile agent loses custom CLI support` KO, `terminal-view` introuvable, l'UI reste sur `custom-agent-chat-view` avec le bouton `CLI custom` toujours actif.
|
||||
- Diagnostic QA: `WebAgentCell.tsx` ne re-resout agent/profil qu'au mount; le fallback depend d'un `resolvedProfile` perime.
|
||||
- Signal secondaire QA: warning cleanup au detach depuis `CustomAgentChatView.tsx:924` avec `Cannot read properties of undefined (reading 'has')`.
|
||||
- Prochaine etape: correctif DevFrontend puis nouveau passage QA.
|
||||
- DevFrontend: implementation livree puis correctif applique apres retour QA rouge sur le fallback quand le support custom disparait apres mount.
|
||||
- QA finale: VERTE le 2026-08-17.
|
||||
- Commandes QA vertes: `cd /home/anthony/Documents/Projects/IdeA/frontend && npm test -- --run src/features/web/WebAgentCell.test.tsx` (8 passed), `npm test -- --run src/features/layout/LayoutGrid.chat.test.tsx` (14 passed), `npm run typecheck` (OK), `npm run build:web` (OK).
|
||||
- Git: commit feature `c547b71`, merge local `911dcf8` dans `develop`, puis bookkeeping `b25c17c`. Aucun push.
|
||||
- Statut: ticket cloture apres merge local dans `develop` avec QA verte.
|
||||
|
||||
2026-08-20 Main
|
||||
- Regression utilisateur: sur la version web telephone, le lancement de la custom CLI echoue maintenant avec un symptome runtime indiquant que `node` est introuvable.
|
||||
- Decision de pilotage: reouverture du ticket #224 car le symptome touche le meme parcours produit `version web telephone -> custom CLI`.
|
||||
- Ordre impose avant tout fix: QA doit d'abord ajouter des tests de non-regression sur le bon demarrage des serveurs MCP dans ce parcours web/mobile, vu l'historique de regressions repetes dans cette zone.
|
||||
- Regle anti-boucle pour la reprise: ne pas partir directement sur un correctif applicatif tant que QA n'a pas produit un signal rouge/vert concret sur ce scenario.
|
||||
- Retour QA: non-regression ajoutee dans `frontend/src/features/web/WebAgentCell.test.tsx` pour figer la couche existante la plus pertinente du parcours mobile web -> CLI custom.
|
||||
- Bornes couvrees par QA: reprise visible d'une session custom mobile avec replay d'activite MCP au re-attach; echec de lancement mobile quand le runtime remonte un `spawn node ENOENT`, avec erreur visible cote UI.
|
||||
- Commande QA executee: `cd /home/anthony/Documents/Projects/IdeA/frontend && npm test -- --run src/features/web/WebAgentCell.test.tsx`.
|
||||
- Resultat QA: VERT, `Test Files 1 passed`, `Tests 10 passed`.
|
||||
- Gap residuel explicitement note par QA: cette suite ne prouve pas encore le demarrage reel de `node` ni le boot effectif des serveurs MCP dans le runtime backend/web reel; elle fige le contrat frontend/integration autour du symptome.
|
||||
- Retour UX: en cas d'echec de boot runtime/MCP, garder la vue `CLI custom` mais afficher une alerte inline bloquante avec message utilisateur clair, CTA `Reessayer` et `Revenir a TUI native`; le detail technique brut `spawn node ENOENT` ne doit pas etre le message principal final.
|
||||
- Retour Architect: cause la plus probable cote backend/runtime sur le profil custom CLI Node-backed ou la resolution de commande dans le chemin web; points de verite a traiter d'abord dans `crates/web-server/src/lib.rs`, `StructuredSessionFactory`, et plus generalement dans le routage structured pour ne jamais degrader en PTY.
|
||||
- Decision d'implementation: DevBackend d'abord; frontend ne revient que si le backend expose une erreur typee ou si l'etat visible doit etre nettoye apres le fix runtime.
|
||||
- Decision Git: branche de travail locale `fix/224-web-custom-cli-node-runtime` creee depuis `develop@b25c17c`; commit scope puis merge local `--no-ff` vers `develop` une fois QA verte; aucun push.
|
||||
- Retour DevBackend: fix runtime implemente sans commit. `crates/application/src/agent/lifecycle.rs` fait maintenant reutiliser `mcp_server_wiring(...)` par `opencode_config_json` pour ecrire une commande MCP OpenCode basee sur le binaire IdeA (`idea-serve ... mcp-server`) quand un `McpRuntime` web est injecte, au lieu de dependre d'un `node` externe.
|
||||
- Preuve de test ajoutee par DevBackend: integration dans `crates/application/tests/agent_lifecycle.rs` garantissant qu'un lancement custom chat OpenCode avec runtime reste structured, n'ouvre aucun PTY, ecrit `opencode.json`, transmet `OPENCODE_CONFIG`, et que `mcp.idea.command[0] != "node"`.
|
||||
- Tests verts annonces par DevBackend: `cargo fmt`; `cargo test -p application opencode_config_json_with_runtime_uses_idea_exe_not_node -- --nocapture`; `cargo test -p application --test agent_lifecycle launch_opencode_chat_with_runtime_uses_idea_bridge_without_pty_or_node -- --nocapture`; `cargo test -p backend mcp_bridge -- --nocapture`; `cargo test -p web-server web_root_candidates_include_appimage_resource_dir -- --nocapture`; `cargo test -p web-server authorized_open_project_arms_orchestrator_watch -- --nocapture`; `npm test -- WebAgentCell.test.tsx`.
|
||||
- Note de reprise: un filtre large `cargo test -p application opencode -- --nocapture` remonte un echec existant non cible `p8c_opencode_same_pair_and_provider_are_isolated_by_project_root` (`left: 0 right: 2`), mais les non-regressions cibles du fix sont vertes separement.
|
||||
@ -2,16 +2,20 @@
|
||||
id: "2be065b5-1859-4698-8c9e-7f1fc2c1f7a3"
|
||||
number: 224
|
||||
title: "Version web téléphone: permettre le passage en custom CLI avec UI mobile adaptée"
|
||||
status: "inProgress"
|
||||
status: "qa"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: []
|
||||
agentRefs: [{"agentId":"fe887179-933f-47d4-960f-c3b06827f86c","role":"assigned"},{"agentId":"ab328d90-c307-4771-a3b6-6c56089c8506","role":"assigned"}]
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1786980793023
|
||||
updatedAt: 1786981905837
|
||||
version: 5
|
||||
updatedAt: 1787222847970
|
||||
version: 14
|
||||
---
|
||||
Ajouter sur la version web téléphone d'IdeA la possibilité de passer en custom CLI, avec une interface et un parcours adaptés aux écrans téléphone.
|
||||
Regression du parcours version web téléphone -> custom CLI. Le jeudi 20 août 2026, l'utilisateur signale qu'en lançant la custom CLI depuis la version web téléphone, le runtime indique que `node` est introuvable. Avant tout correctif de production, la priorité est d'ajouter des tests de non-régression prouvant le bon démarrage des serveurs MCP dans ce parcours web/mobile, car cette zone a déjà régressé plusieurs fois.
|
||||
|
||||
Ordre de traitement imposé:
|
||||
1. QA ajoute/étend la couverture de tests pour le démarrage des serveurs MCP via la version web téléphone et établit un signal rouge/vert fiable.
|
||||
2. Ensuite seulement, l'implémentation du correctif sera cadrée et déléguée au(x) dev(s) concerné(s).
|
||||
@ -3441,23 +3441,9 @@ fn opencode_config_json(
|
||||
}),
|
||||
);
|
||||
|
||||
let (command, args) = match runtime {
|
||||
Some(rt) => (
|
||||
rt.exe.clone(),
|
||||
vec![
|
||||
"mcp-server".to_owned(),
|
||||
"--endpoint".to_owned(),
|
||||
rt.endpoint.clone(),
|
||||
"--project".to_owned(),
|
||||
rt.project_id.clone(),
|
||||
"--requester".to_owned(),
|
||||
rt.requester.clone(),
|
||||
],
|
||||
),
|
||||
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
|
||||
};
|
||||
let command_array = std::iter::once(command)
|
||||
.chain(args)
|
||||
let wiring = mcp_server_wiring(domain::profile::McpTransport::Stdio, runtime);
|
||||
let command_array = std::iter::once(wiring.command)
|
||||
.chain(wiring.args)
|
||||
.map(serde_json::Value::String)
|
||||
.collect::<Vec<_>>();
|
||||
root.insert(
|
||||
@ -5334,6 +5320,37 @@ command = "idea-mcp"
|
||||
assert!(body.get("permission").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_config_json_with_runtime_uses_idea_exe_not_node() {
|
||||
let config =
|
||||
domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None)
|
||||
.unwrap();
|
||||
let runtime = McpRuntime {
|
||||
exe: "/opt/idea/idea-serve".to_owned(),
|
||||
endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
|
||||
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||
requester: "11112222-3333-4444-5555-666677778888".to_owned(),
|
||||
};
|
||||
|
||||
let body = opencode_config_json(&config, "/project", Some(&runtime), None);
|
||||
let command = body["mcp"]["idea"]["command"]
|
||||
.as_array()
|
||||
.expect("OpenCode local command is an argv array")
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(command[0], "/opt/idea/idea-serve");
|
||||
assert_ne!(command[0], "node");
|
||||
assert_eq!(command[1], "mcp-server");
|
||||
assert!(command.contains(&"--endpoint"));
|
||||
assert!(command.contains(&"/run/user/1000/idea-mcp/proj.sock"));
|
||||
assert!(command.contains(&"--project"));
|
||||
assert!(command.contains(&"0123456789abcdef0123456789abcdef"));
|
||||
assert!(command.contains(&"--requester"));
|
||||
assert!(command.contains(&"11112222-3333-4444-5555-666677778888"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_config_json_projects_allow_and_deny() {
|
||||
let config =
|
||||
|
||||
@ -2594,6 +2594,80 @@ async fn launch_opencode_writes_isolated_config_and_env() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_opencode_chat_with_runtime_uses_idea_bridge_without_pty_or_node() {
|
||||
let profile = opencode_profile(pid(9));
|
||||
let (launch, agent, fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let launch = launch
|
||||
.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
|
||||
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
|
||||
let runtime = application::McpRuntime {
|
||||
exe: "/opt/idea/idea-serve".to_owned(),
|
||||
endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
|
||||
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||
requester: agent.id.to_string(),
|
||||
};
|
||||
let mut input = launch_input_with_runtime(agent.id, runtime);
|
||||
input.require_structured = true;
|
||||
input.node_id = Some(nid(224));
|
||||
|
||||
let out = launch.execute(input).await.unwrap();
|
||||
|
||||
assert_eq!(factory.starts(), vec![pid(9)]);
|
||||
assert!(
|
||||
pty.spawns().is_empty(),
|
||||
"web custom chat must stay structured"
|
||||
);
|
||||
assert!(
|
||||
out.structured.is_some(),
|
||||
"backend returns a chat descriptor"
|
||||
);
|
||||
assert_eq!(
|
||||
structured.node_for_agent_in_project(project().id, &agent.id),
|
||||
Some(nid(224))
|
||||
);
|
||||
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
let opencode = writes_ending_with(&fs, "/opencode.json");
|
||||
assert_eq!(opencode.len(), 1, "one OpenCode config is written");
|
||||
let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap();
|
||||
let command = body["mcp"]["idea"]["command"]
|
||||
.as_array()
|
||||
.expect("OpenCode local command is an argv array")
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(command[0], "/opt/idea/idea-serve");
|
||||
assert_ne!(command[0], "node");
|
||||
assert_eq!(command[1], "mcp-server");
|
||||
assert!(command.contains(&"--endpoint"));
|
||||
assert!(command.contains(&"/run/user/1000/idea-mcp/proj.sock"));
|
||||
assert!(command.contains(&"--project"));
|
||||
assert!(command.contains(&"0123456789abcdef0123456789abcdef"));
|
||||
assert!(command.contains(&"--requester"));
|
||||
let requester = agent.id.to_string();
|
||||
assert!(command.contains(&requester.as_str()));
|
||||
|
||||
let env: std::collections::HashMap<_, _> = factory
|
||||
.envs()
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("structured start receives env")
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
env.get("OPENCODE_CONFIG").map(String::as_str),
|
||||
Some(format!("{run_dir}/opencode.json").as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_flag_appends_flag_and_path_to_args() {
|
||||
// Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed
|
||||
|
||||
@ -113,6 +113,7 @@ use infrastructure::{
|
||||
|
||||
pub mod dto;
|
||||
pub mod events;
|
||||
pub mod mcp_bridge;
|
||||
pub mod mcp_endpoint;
|
||||
pub mod openai_tools;
|
||||
pub mod stream;
|
||||
|
||||
260
crates/backend/src/mcp_bridge.rs
Normal file
260
crates/backend/src/mcp_bridge.rs
Normal file
@ -0,0 +1,260 @@
|
||||
//! The `idea mcp-server` bridge shared by desktop and standalone web binaries.
|
||||
//!
|
||||
//! An MCP CLI spawns `<exe> mcp-server --endpoint <…> --project <…> --requester <…>`.
|
||||
//! This process stays headless and relays JSON-RPC lines between stdio and the
|
||||
//! per-project local loopback endpoint where the real MCP server listens.
|
||||
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
|
||||
use interprocess::local_socket::traits::tokio::Stream as _;
|
||||
use interprocess::local_socket::{GenericFilePath, ToFsName as _};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const DRAIN_GRACE: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Parsed `mcp-server` invocation arguments.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BridgeArgs {
|
||||
/// The loopback address to connect to.
|
||||
pub endpoint: String,
|
||||
/// The project id forwarded in the handshake.
|
||||
pub project: String,
|
||||
/// The requester id forwarded in the handshake.
|
||||
pub requester: String,
|
||||
}
|
||||
|
||||
impl BridgeArgs {
|
||||
/// Parses `mcp-server`'s own arguments.
|
||||
pub fn parse<I, S>(args: I) -> Result<Self, String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
let mut endpoint: Option<String> = None;
|
||||
let mut project = String::new();
|
||||
let mut requester = String::new();
|
||||
|
||||
let mut it = args.into_iter().map(Into::into);
|
||||
while let Some(flag) = it.next() {
|
||||
match flag.as_str() {
|
||||
"--endpoint" => {
|
||||
endpoint = Some(
|
||||
it.next()
|
||||
.ok_or_else(|| "--endpoint requires a value".to_string())?,
|
||||
);
|
||||
}
|
||||
"--project" => {
|
||||
project = it
|
||||
.next()
|
||||
.ok_or_else(|| "--project requires a value".to_string())?;
|
||||
}
|
||||
"--requester" => {
|
||||
requester = it
|
||||
.next()
|
||||
.ok_or_else(|| "--requester requires a value".to_string())?;
|
||||
}
|
||||
other => return Err(format!("unknown argument: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
let endpoint = endpoint.ok_or_else(|| "--endpoint is required".to_string())?;
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
project,
|
||||
requester,
|
||||
})
|
||||
}
|
||||
|
||||
fn handshake_line(&self) -> Vec<u8> {
|
||||
let line = serde_json::json!({
|
||||
"project": self.project,
|
||||
"requester": self.requester,
|
||||
})
|
||||
.to_string();
|
||||
let mut bytes = line.into_bytes();
|
||||
bytes.push(b'\n');
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous entry point for the headless stdio bridge.
|
||||
#[must_use]
|
||||
pub fn run_mcp_bridge<I, S>(args: I) -> ExitCode
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
let args = match BridgeArgs::parse(args) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("idea mcp-server: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("idea mcp-server: failed to start runtime: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
runtime.block_on(async move {
|
||||
match bridge_over_loopback(&args).await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("idea mcp-server: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn bridge_over_loopback(args: &BridgeArgs) -> Result<(), String> {
|
||||
let conn = connect_loopback(&args.endpoint).await?;
|
||||
let (lp_read, lp_write) = tokio::io::split(conn);
|
||||
relay(
|
||||
args,
|
||||
tokio::io::stdin(),
|
||||
tokio::io::stdout(),
|
||||
lp_read,
|
||||
lp_write,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn connect_loopback(endpoint: &str) -> Result<LocalSocketStream, String> {
|
||||
let name = endpoint
|
||||
.to_fs_name::<GenericFilePath>()
|
||||
.map_err(|e| format!("invalid endpoint {endpoint:?}: {e}"))?;
|
||||
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, LocalSocketStream::connect(name)).await {
|
||||
Ok(Ok(stream)) => Ok(stream),
|
||||
Ok(Err(e)) => Err(format!("cannot connect to endpoint {endpoint:?}: {e}")),
|
||||
Err(_) => Err(format!(
|
||||
"timed out connecting to endpoint {endpoint:?} after {}s",
|
||||
CONNECT_TIMEOUT.as_secs()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn relay<CIn, COut, LIn, LOut>(
|
||||
args: &BridgeArgs,
|
||||
cli_in: CIn,
|
||||
cli_out: COut,
|
||||
lp_read: LIn,
|
||||
mut lp_write: LOut,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
CIn: AsyncRead + Unpin,
|
||||
COut: AsyncWrite + Unpin,
|
||||
LIn: AsyncRead + Unpin,
|
||||
LOut: AsyncWrite + Unpin,
|
||||
{
|
||||
lp_write
|
||||
.write_all(&args.handshake_line())
|
||||
.await
|
||||
.map_err(|e| format!("handshake write failed: {e}"))?;
|
||||
lp_write
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("handshake flush failed: {e}"))?;
|
||||
|
||||
let cli_to_lp = pump_lines(BufReader::new(cli_in), lp_write, "stdin", "loopback");
|
||||
let lp_to_cli = pump_lines(BufReader::new(lp_read), cli_out, "loopback", "stdout");
|
||||
tokio::pin!(cli_to_lp);
|
||||
tokio::pin!(lp_to_cli);
|
||||
|
||||
tokio::select! {
|
||||
r = &mut cli_to_lp => {
|
||||
let mut lp_write = r?;
|
||||
let _ = lp_write.shutdown().await;
|
||||
let _ = tokio::time::timeout(DRAIN_GRACE, &mut lp_to_cli).await;
|
||||
Ok(())
|
||||
}
|
||||
r = &mut lp_to_cli => r.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn pump_lines<R, W>(
|
||||
mut reader: BufReader<R>,
|
||||
mut writer: W,
|
||||
src: &str,
|
||||
dst: &str,
|
||||
) -> Result<W, String>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| format!("{src} read failed: {e}"))?;
|
||||
if n == 0 {
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("{dst} flush failed: {e}"))?;
|
||||
return Ok(writer);
|
||||
}
|
||||
writer
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("{dst} write failed: {e}"))?;
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("{dst} flush failed: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_all_three_flags() {
|
||||
let args = BridgeArgs::parse([
|
||||
"--endpoint",
|
||||
"/tmp/x.sock",
|
||||
"--project",
|
||||
"p1",
|
||||
"--requester",
|
||||
"agent-7",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(args.endpoint, "/tmp/x.sock");
|
||||
assert_eq!(args.project, "p1");
|
||||
assert_eq!(args.requester, "agent-7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_line_carries_identity_as_json() {
|
||||
let args = BridgeArgs {
|
||||
endpoint: "/tmp/x.sock".into(),
|
||||
project: "proj".into(),
|
||||
requester: "rq".into(),
|
||||
};
|
||||
let line = args.handshake_line();
|
||||
assert_eq!(*line.last().unwrap(), b'\n');
|
||||
let value: serde_json::Value = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
|
||||
assert_eq!(value["project"], "proj");
|
||||
assert_eq!(value["requester"], "rq");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_endpoint_returns_failure() {
|
||||
assert_eq!(run_mcp_bridge(["--project", "p"]), ExitCode::FAILURE);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,12 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) {
|
||||
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
return backend::mcp_bridge::run_mcp_bridge(rest);
|
||||
}
|
||||
web_server::run_from_args(std::env::args().skip(1).collect())
|
||||
}
|
||||
|
||||
@ -53,10 +53,11 @@ use application::{
|
||||
PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
|
||||
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
|
||||
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
||||
RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput,
|
||||
ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput,
|
||||
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
|
||||
StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
|
||||
ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput,
|
||||
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
|
||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||
RotateConversationLogInput, SetActiveLayoutInput, StopLiveAgentInput,
|
||||
SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
|
||||
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContextInput,
|
||||
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
|
||||
@ -506,33 +507,55 @@ fn config_with_effective_listen(mut config: ServerConfig, local_addr: SocketAddr
|
||||
}
|
||||
|
||||
fn resolve_web_root(explicit: Option<PathBuf>) -> Result<PathBuf, String> {
|
||||
if let Some(path) = explicit {
|
||||
return validate_web_root(path, "--web-root");
|
||||
}
|
||||
if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) {
|
||||
return validate_web_root(path, "IDEA_WEB_ROOT");
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
if let Ok(exe) = env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(dir.join("web"));
|
||||
candidates.push(dir.join("frontend").join("dist"));
|
||||
}
|
||||
}
|
||||
if let Ok(cwd) = env::current_dir() {
|
||||
candidates.push(cwd.join("frontend").join("dist"));
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
for (candidate, source) in web_root_candidates(explicit) {
|
||||
if candidate.join("index.html").is_file() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
if matches!(source, "--web-root" | "IDEA_WEB_ROOT") {
|
||||
return validate_web_root(candidate, source);
|
||||
}
|
||||
}
|
||||
|
||||
Err("web assets not found: build frontend/dist or pass --web-root PATH".to_owned())
|
||||
}
|
||||
|
||||
fn web_root_candidates(explicit: Option<PathBuf>) -> Vec<(PathBuf, &'static str)> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path) = explicit {
|
||||
candidates.push((path, "--web-root"));
|
||||
}
|
||||
if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) {
|
||||
candidates.push((path, "IDEA_WEB_ROOT"));
|
||||
}
|
||||
if let Some(appdir) = env::var_os("APPDIR").map(PathBuf::from) {
|
||||
candidates.extend(
|
||||
web_root_candidates_from_appdir(&appdir)
|
||||
.into_iter()
|
||||
.map(|candidate| (candidate, "APPDIR")),
|
||||
);
|
||||
}
|
||||
if let Ok(exe) = env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push((dir.join("web"), "current_exe"));
|
||||
candidates.push((dir.join("frontend").join("dist"), "current_exe"));
|
||||
if let Some(prefix) = dir.parent() {
|
||||
candidates.push((prefix.join("lib").join("IdeA").join("web"), "current_exe"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(cwd) = env::current_dir() {
|
||||
candidates.push((cwd.join("frontend").join("dist"), "cwd"));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn web_root_candidates_from_appdir(appdir: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
appdir.join("usr").join("lib").join("IdeA").join("web"),
|
||||
appdir.join("web"),
|
||||
]
|
||||
}
|
||||
|
||||
fn validate_web_root(path: PathBuf, source: &str) -> Result<PathBuf, String> {
|
||||
if path.join("index.html").is_file() {
|
||||
Ok(path)
|
||||
@ -3099,8 +3122,32 @@ async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result<Value,
|
||||
code: "INVALID".to_owned(),
|
||||
message: "open_project requires args.projectId".to_owned(),
|
||||
})?;
|
||||
let project = resolve_project_readonly(project_id, state).await?;
|
||||
serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error)
|
||||
let project = open_project_for_web(project_id, state).await?;
|
||||
serde_json::to_value(project).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn open_project_for_web(
|
||||
project_id: &str,
|
||||
state: &BackendCore,
|
||||
) -> Result<ProjectDto, ErrorDto> {
|
||||
let id = parse_project_id(project_id)?;
|
||||
let output = state
|
||||
.open_project
|
||||
.execute(OpenProjectInput { project_id: id })
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
let _ = state
|
||||
.reconcile_layouts
|
||||
.execute(ReconcileLayoutsInput { project_id: id })
|
||||
.await;
|
||||
let _ = state
|
||||
.reconcile_live_state
|
||||
.execute(ReconcileLiveStateInput { project_id: id })
|
||||
.await;
|
||||
let _ = state.reconcile_background_tasks.execute(id).await;
|
||||
state.ensure_orchestrator_watch(&output.project);
|
||||
state.reconcile_claude_run_dirs(&output.project).await;
|
||||
Ok(ProjectDto::from(output))
|
||||
}
|
||||
|
||||
async fn invoke_load_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
@ -8359,6 +8406,15 @@ mod tests {
|
||||
assert!(validate_web_root(create_web_root(), "--web-root").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_root_candidates_include_appimage_resource_dir() {
|
||||
let appdir = PathBuf::from("/tmp/.mount_IdeA_test");
|
||||
let candidates = web_root_candidates_from_appdir(&appdir);
|
||||
|
||||
assert!(candidates.contains(&appdir.join("usr").join("lib").join("IdeA").join("web")));
|
||||
assert!(candidates.contains(&appdir.join("web")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_allowed_origin_is_refused() {
|
||||
let state = state();
|
||||
@ -8472,6 +8528,49 @@ mod tests {
|
||||
assert!(body["root"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorized_open_project_arms_orchestrator_watch() {
|
||||
let config = test_config();
|
||||
let core = Arc::new(BackendCore::build(config.app_data_dir.clone()));
|
||||
let project_id = create_project_on_core(&core, "MCP Armed Open").await;
|
||||
let state = Arc::new(ServerState::with_core(config, core));
|
||||
state.set_pairing_code_for_test("PAIR1234".to_owned());
|
||||
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||
|
||||
assert!(
|
||||
state.app.open_project_ids().is_empty(),
|
||||
"precondition: the project must not already be armed before HTTP open_project"
|
||||
);
|
||||
|
||||
let response = request(
|
||||
Arc::clone(&state),
|
||||
Method::POST,
|
||||
"/api/invoke",
|
||||
json!({ "command": "open_project", "args": { "projectId": project_id } }),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await;
|
||||
let (status, body, _) = response_json(response).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["id"], project_id);
|
||||
let expected = parse_project_id(&project_id)
|
||||
.expect("test project id parses")
|
||||
.as_uuid()
|
||||
.simple()
|
||||
.to_string();
|
||||
let opened = state
|
||||
.app
|
||||
.open_project_ids()
|
||||
.into_iter()
|
||||
.map(|id| id.as_uuid().simple().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
opened.iter().any(|id| id == &expected),
|
||||
"expected open_project HTTP flow to arm the orchestrator watch"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorized_layout_invokes_match_tauri_contracts() {
|
||||
let state = state();
|
||||
|
||||
@ -120,6 +120,51 @@ describe("WebAgentCell", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replays startup MCP activity when the mobile custom CLI launches", async () => {
|
||||
const { agent, profile, agentId } = await seededAgentGateway(structuredProfile);
|
||||
const launchChatSpy = vi.spyOn(agent, "launchAgentChat");
|
||||
const startupScrollback = [
|
||||
{
|
||||
kind: "progress" as const,
|
||||
progress: {
|
||||
source: "ideaLocal" as const,
|
||||
kind: "mcp" as const,
|
||||
stage: "started" as const,
|
||||
label: "idea_context_read",
|
||||
toolName: "idea_context_read",
|
||||
text: "Bootstrap MCP tools",
|
||||
},
|
||||
},
|
||||
{ kind: "final" as const, content: "Custom CLI ready" },
|
||||
];
|
||||
const reattachChatSpy = vi
|
||||
.spyOn(agent, "reattachAgentChat")
|
||||
.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: startupScrollback,
|
||||
}));
|
||||
|
||||
renderCell(agent, profile, agentId, { fullHeight: true });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
|
||||
|
||||
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(reattachChatSpy).toHaveBeenCalledTimes(1));
|
||||
expect(launchChatSpy.mock.calls[0][2]).toMatchObject({
|
||||
cwd: "/srv/demo",
|
||||
nodeId: `web-mobile-${agentId}`,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "TUI native" }));
|
||||
await waitFor(() => expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull());
|
||||
fireEvent.click(screen.getByRole("button", { name: "CLI custom" }));
|
||||
|
||||
await waitFor(() => expect(reattachChatSpy).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByText("MCP")).toBeTruthy();
|
||||
expect(screen.getByText("idea_context_read")).toBeTruthy();
|
||||
expect(screen.getByText("Bootstrap MCP tools")).toBeTruthy();
|
||||
expect(screen.getByText("Custom CLI ready")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reattaches the mobile custom CLI session when returning to custom mode", async () => {
|
||||
const { agent, profile, agentId } = await seededAgentGateway(structuredProfile);
|
||||
const launchChatSpy = vi.spyOn(agent, "launchAgentChat");
|
||||
@ -138,6 +183,26 @@ describe("WebAgentCell", () => {
|
||||
expect(launchChatSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("surfaces a mobile custom CLI launch failure when the runtime cannot find node", async () => {
|
||||
const { agent, profile, agentId } = await seededAgentGateway(structuredProfile);
|
||||
const launchChatSpy = vi.spyOn(agent, "launchAgentChat").mockRejectedValueOnce({
|
||||
code: "PROCESS",
|
||||
message: "spawn node ENOENT: node is not installed on this runtime",
|
||||
});
|
||||
const reattachChatSpy = vi.spyOn(agent, "reattachAgentChat");
|
||||
|
||||
renderCell(agent, profile, agentId, { fullHeight: true });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
|
||||
|
||||
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
|
||||
expect(launchChatSpy.mock.calls[0][2]).toMatchObject({
|
||||
cwd: "/srv/demo",
|
||||
nodeId: `web-mobile-${agentId}`,
|
||||
});
|
||||
expect(reattachChatSpy).not.toHaveBeenCalled();
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("spawn node ENOENT");
|
||||
});
|
||||
|
||||
it("falls back to native TUI when the mobile agent loses custom CLI support", async () => {
|
||||
const { agent, profile, agentId } = await seededAgentGateway(structuredProfile);
|
||||
const created = (await agent.listAgents(PROJECT_ID)).find((candidate) => candidate.id === agentId);
|
||||
|
||||
Reference in New Issue
Block a user