chore(wip): checkpoint P8/C avant chantier Codex inter-agents
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>
This commit is contained in:
@ -5,31 +5,29 @@
|
||||
//! `Arc<dyn Port>` into the use cases (ARCHITECTURE §1.1, §10). The use cases
|
||||
//! are then exposed through `tauri::State<AppState>` to the command handlers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab,
|
||||
CloseTerminal,
|
||||
ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory,
|
||||
CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout,
|
||||
DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines,
|
||||
DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory,
|
||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
||||
GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents, ListEmbedderProfiles,
|
||||
ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
|
||||
ListTemplates,
|
||||
LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
|
||||
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||
RenameLayout, ResizeTerminal,
|
||||
ResolveMemoryLinks,
|
||||
SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions,
|
||||
SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
|
||||
UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
CloseTerminal, ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate,
|
||||
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
|
||||
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||
FirstRunState, GetMemory, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog,
|
||||
GitStage, GitStatus, GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents,
|
||||
ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LoadLayout, McpRuntime,
|
||||
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext, RecallMemory,
|
||||
ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
||||
ResizeTerminal, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, SetActiveLayout,
|
||||
SnapshotRunningAgents, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
||||
TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, UpdateMemory,
|
||||
UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
|
||||
@ -37,19 +35,25 @@ use domain::ports::{
|
||||
MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore,
|
||||
TemplateStore,
|
||||
};
|
||||
use domain::{DomainEvent, EmbedderProfile, Project, ProjectId};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
};
|
||||
use domain::remote::RemoteKind;
|
||||
use domain::{AgentId, DomainEvent, EmbedderProfile, Project, ProjectId};
|
||||
use serde_json::{json, Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use infrastructure::{
|
||||
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
|
||||
EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsHandoffStore, FsMemoryStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
|
||||
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
|
||||
Git2Repository, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, SystemMillisClock,
|
||||
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory,
|
||||
SystemClock, TokioBroadcastEventBus,
|
||||
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
FsHandoffStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore,
|
||||
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock,
|
||||
SystemMillisClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||
VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
use crate::chat::ChatBridge;
|
||||
@ -646,14 +650,15 @@ impl AppState {
|
||||
// porte une conversation et qu'un handoff existe (`<root>/.ideai/conversations/`),
|
||||
// son résumé est réinjecté dans le convention file. Best-effort, additif :
|
||||
// un handoff absent/illisible ⇒ lancement normal sans section.
|
||||
.with_handoff_provider(Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>)
|
||||
.with_handoff_provider(
|
||||
Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>
|
||||
)
|
||||
// Resumable moteur par provider (lot P8b) : après un lancement structuré
|
||||
// exposant un id de session moteur, rangé sous la clé de paire dans
|
||||
// `<root>/.ideai/conversations/providers.json`. Best-effort, additif : une
|
||||
// écriture en échec / pas d'id moteur ⇒ lancement normal, aucune écriture.
|
||||
.with_provider_session_provider(
|
||||
Arc::new(AppProviderSessionProvider) as Arc<dyn application::ProviderSessionProvider>,
|
||||
),
|
||||
.with_provider_session_provider(Arc::new(AppProviderSessionProvider)
|
||||
as Arc<dyn application::ProviderSessionProvider>),
|
||||
);
|
||||
|
||||
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
|
||||
@ -821,8 +826,7 @@ impl AppState {
|
||||
// cible (écriture sérialisée, une seule voie d'entrée). Le mailbox concret est
|
||||
// partagé pour `resolve`/`resolve_ticket`/`cancel_head` côté orchestrateur.
|
||||
let inmemory_mailbox = Arc::new(InMemoryMailbox::new());
|
||||
let mailbox =
|
||||
Arc::clone(&inmemory_mailbox) as Arc<dyn domain::mailbox::AgentMailbox>;
|
||||
let mailbox = Arc::clone(&inmemory_mailbox) as Arc<dyn domain::mailbox::AgentMailbox>;
|
||||
let input_mediator = Arc::new(
|
||||
MediatedInbox::with_pty(
|
||||
Arc::clone(&inmemory_mailbox),
|
||||
@ -835,8 +839,8 @@ impl AppState {
|
||||
) as Arc<dyn domain::input::InputMediator>;
|
||||
// Registre des conversations par paire (cadrage C3) : un fil par paire, session
|
||||
// vivante keyée par conversation (lève l'ambiguïté session/agent).
|
||||
let conversation_registry =
|
||||
Arc::new(InMemoryConversationRegistry::new()) as Arc<dyn domain::conversation::ConversationRegistry>;
|
||||
let conversation_registry = Arc::new(InMemoryConversationRegistry::new())
|
||||
as Arc<dyn domain::conversation::ConversationRegistry>;
|
||||
let orchestrator_service = Arc::new(
|
||||
OrchestratorService::new(
|
||||
Arc::clone(&create_agent),
|
||||
@ -1056,6 +1060,16 @@ impl AppState {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Repairs the persisted Claude run-dir artefacts of `project` so a freshly
|
||||
/// launched AppImage does not inherit stale MCP/settings state from older runs.
|
||||
///
|
||||
/// Best-effort and local-only: remote SSH/WSL projects are skipped because this
|
||||
/// migration rewrites the local run-dir files the AppImage owns. Launch-time
|
||||
/// repair still remains available on the normal activation path.
|
||||
pub async fn reconcile_claude_run_dirs(&self, project: &Project) {
|
||||
self.migrate_claude_run_dirs(project).await;
|
||||
}
|
||||
|
||||
/// Stops and removes the orchestrator watcher for `project_id`, if any.
|
||||
/// Called from `close_project` so a closed project stops consuming requests.
|
||||
/// Symmetrically stops the project's MCP server (its twin) so both entry doors
|
||||
@ -1078,6 +1092,556 @@ impl AppState {
|
||||
handle.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort migration of persisted Claude run dirs at project-open time:
|
||||
/// repairs stale `.mcp.json` declarations and missing
|
||||
/// `enabledMcpjsonServers` entries in `.claude/settings.local.json`.
|
||||
pub async fn migrate_claude_run_dirs(&self, project: &Project) {
|
||||
if project.remote.kind() != RemoteKind::Local {
|
||||
return;
|
||||
}
|
||||
let agents = match self
|
||||
.list_agents
|
||||
.execute(ListAgentsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(output) => output.agents,
|
||||
Err(_) => return,
|
||||
};
|
||||
let profiles = match self.list_profiles.execute().await {
|
||||
Ok(output) => output.profiles,
|
||||
Err(_) => return,
|
||||
};
|
||||
let profile_by_id: HashMap<_, _> = profiles.into_iter().map(|p| (p.id, p)).collect();
|
||||
for agent in agents {
|
||||
let Some(profile) = profile_by_id.get(&agent.profile_id) else {
|
||||
continue;
|
||||
};
|
||||
if !is_claude_mcp_profile(profile) {
|
||||
continue;
|
||||
}
|
||||
let _ = migrate_claude_run_dir(project, &agent.id, profile).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn migrate_claude_run_dir(
|
||||
project: &Project,
|
||||
agent_id: &AgentId,
|
||||
profile: &AgentProfile,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let run_dir = project
|
||||
.root
|
||||
.as_str()
|
||||
.trim_end_matches(['/', '\\'])
|
||||
.to_owned()
|
||||
+ &format!("/.ideai/run/{agent_id}");
|
||||
let run_dir_path = Path::new(&run_dir);
|
||||
if tokio::fs::metadata(run_dir_path).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
migrate_claude_settings(run_dir_path, project.root.as_str()).await?;
|
||||
|
||||
let runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
|
||||
exe,
|
||||
endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
|
||||
.as_cli_arg()
|
||||
.to_owned(),
|
||||
project_id: project.id.as_uuid().simple().to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
});
|
||||
migrate_claude_mcp_config(run_dir_path, profile, runtime.as_ref()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate_claude_settings(run_dir: &Path, project_root: &str) -> Result<(), std::io::Error> {
|
||||
let claude_dir = run_dir.join(".claude");
|
||||
let settings_path = claude_dir.join("settings.local.json");
|
||||
let next = match tokio::fs::read_to_string(&settings_path).await {
|
||||
Ok(existing) => merge_claude_settings_json(&existing, project_root),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
Some(claude_settings_seed_value(project_root))
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let Some(next) = next else {
|
||||
return Ok(());
|
||||
};
|
||||
tokio::fs::create_dir_all(&claude_dir).await?;
|
||||
let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?;
|
||||
body.push('\n');
|
||||
write_atomically(&settings_path, &body)
|
||||
}
|
||||
|
||||
async fn migrate_claude_mcp_config(
|
||||
run_dir: &Path,
|
||||
profile: &AgentProfile,
|
||||
runtime: Option<&McpRuntime>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let Some(target) = claude_mcp_config_target(profile) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(runtime) = runtime else {
|
||||
return Ok(());
|
||||
};
|
||||
let mcp_path = run_dir.join(target);
|
||||
let desired_server = mcp_server_entry(profile, Some(runtime));
|
||||
|
||||
let next = match tokio::fs::read_to_string(&mcp_path).await {
|
||||
Ok(existing) => merge_mcp_json(&existing, desired_server),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
Some(wrap_idea_mcp_server(desired_server))
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let Some(next) = next else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(parent) = mcp_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?;
|
||||
body.push('\n');
|
||||
write_atomically(&mcp_path, &body)
|
||||
}
|
||||
|
||||
fn is_claude_mcp_profile(profile: &AgentProfile) -> bool {
|
||||
let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude)
|
||||
|| matches!(
|
||||
&profile.context_injection,
|
||||
ContextInjection::ConventionFile { target }
|
||||
if target
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(target)
|
||||
.eq_ignore_ascii_case("CLAUDE.md")
|
||||
);
|
||||
is_claude && claude_mcp_config_target(profile).is_some()
|
||||
}
|
||||
|
||||
fn claude_mcp_config_target(profile: &AgentProfile) -> Option<&str> {
|
||||
match profile.mcp.as_ref().map(|mcp| &mcp.config) {
|
||||
Some(McpConfigStrategy::ConfigFile { target }) => Some(target.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option<Value> {
|
||||
let mut doc = match serde_json::from_str::<Value>(existing) {
|
||||
Ok(Value::Object(map)) => Value::Object(map),
|
||||
Ok(_) | Err(_) => return Some(claude_settings_seed_value(project_root)),
|
||||
};
|
||||
let before = doc.clone();
|
||||
let root = doc.as_object_mut().expect("object preserved above");
|
||||
let permissions = root
|
||||
.entry("permissions".to_owned())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !permissions.is_object() {
|
||||
*permissions = Value::Object(Map::new());
|
||||
}
|
||||
let permissions = permissions
|
||||
.as_object_mut()
|
||||
.expect("permissions forced to object");
|
||||
permissions.insert(
|
||||
"defaultMode".to_owned(),
|
||||
Value::String("bypassPermissions".to_owned()),
|
||||
);
|
||||
permissions.insert(
|
||||
"additionalDirectories".to_owned(),
|
||||
Value::Array(merge_string_array(
|
||||
permissions.get("additionalDirectories"),
|
||||
[project_root],
|
||||
)),
|
||||
);
|
||||
permissions.insert(
|
||||
"allow".to_owned(),
|
||||
Value::Array(merge_string_array(
|
||||
permissions.get("allow"),
|
||||
["Read", "Edit", "Write", "Bash"],
|
||||
)),
|
||||
);
|
||||
permissions.insert(
|
||||
"deny".to_owned(),
|
||||
Value::Array(merge_string_array(
|
||||
permissions.get("deny"),
|
||||
[
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf ~/)",
|
||||
"Bash(rm -rf ~/*)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)",
|
||||
],
|
||||
)),
|
||||
);
|
||||
root.insert(
|
||||
"skipDangerousModePermissionPrompt".to_owned(),
|
||||
Value::Bool(true),
|
||||
);
|
||||
root.insert(
|
||||
"enabledMcpjsonServers".to_owned(),
|
||||
Value::Array(merge_string_array(
|
||||
root.get("enabledMcpjsonServers"),
|
||||
["idea"],
|
||||
)),
|
||||
);
|
||||
let sandbox = root
|
||||
.entry("sandbox".to_owned())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !sandbox.is_object() {
|
||||
*sandbox = Value::Object(Map::new());
|
||||
}
|
||||
let sandbox = sandbox.as_object_mut().expect("sandbox forced to object");
|
||||
sandbox.insert("enabled".to_owned(), Value::Bool(false));
|
||||
|
||||
if doc != before {
|
||||
Some(doc)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_mcp_json(existing: &str, desired_server: Value) -> Option<Value> {
|
||||
let mut doc = match serde_json::from_str::<Value>(existing) {
|
||||
Ok(Value::Object(map)) => map,
|
||||
Ok(_) | Err(_) => return Some(wrap_idea_mcp_server(desired_server)),
|
||||
};
|
||||
let mcp_servers = doc
|
||||
.entry("mcpServers".to_owned())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !mcp_servers.is_object() {
|
||||
*mcp_servers = Value::Object(Map::new());
|
||||
}
|
||||
let changed = mcp_servers.get("idea") != Some(&desired_server);
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
match mcp_servers {
|
||||
Value::Object(servers) => {
|
||||
servers.insert("idea".to_owned(), desired_server);
|
||||
Some(Value::Object(doc))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_idea_mcp_server(server: Value) -> Value {
|
||||
let mut servers = Map::new();
|
||||
servers.insert("idea".to_owned(), server);
|
||||
let mut root = Map::new();
|
||||
root.insert("mcpServers".to_owned(), Value::Object(servers));
|
||||
Value::Object(root)
|
||||
}
|
||||
|
||||
fn mcp_server_entry(profile: &AgentProfile, runtime: Option<&McpRuntime>) -> Value {
|
||||
let transport = match profile.mcp.as_ref().map(|mcp| mcp.transport) {
|
||||
Some(McpTransport::Socket) => "socket",
|
||||
Some(McpTransport::Stdio) | None => "stdio",
|
||||
};
|
||||
let (command, args) = match runtime {
|
||||
Some(rt) => (
|
||||
rt.exe.clone(),
|
||||
vec![
|
||||
Value::String("mcp-server".to_owned()),
|
||||
Value::String("--endpoint".to_owned()),
|
||||
Value::String(rt.endpoint.clone()),
|
||||
Value::String("--project".to_owned()),
|
||||
Value::String(rt.project_id.clone()),
|
||||
Value::String("--requester".to_owned()),
|
||||
Value::String(rt.requester.clone()),
|
||||
],
|
||||
),
|
||||
None => (
|
||||
"idea".to_owned(),
|
||||
vec![Value::String("mcp-server".to_owned())],
|
||||
),
|
||||
};
|
||||
let mut server = Map::new();
|
||||
server.insert("command".to_owned(), Value::String(command));
|
||||
server.insert("args".to_owned(), Value::Array(args));
|
||||
server.insert("transport".to_owned(), Value::String(transport.to_owned()));
|
||||
Value::Object(server)
|
||||
}
|
||||
|
||||
fn claude_settings_seed_value(project_root: &str) -> Value {
|
||||
json!({
|
||||
"permissions": {
|
||||
"defaultMode": "bypassPermissions",
|
||||
"additionalDirectories": [project_root],
|
||||
"allow": ["Read", "Edit", "Write", "Bash"],
|
||||
"deny": [
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf ~/)",
|
||||
"Bash(rm -rf ~/*)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)"
|
||||
]
|
||||
},
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"enabledMcpjsonServers": ["idea"],
|
||||
"sandbox": { "enabled": false }
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_string_array<'a>(
|
||||
existing: Option<&Value>,
|
||||
required: impl IntoIterator<Item = &'a str>,
|
||||
) -> Vec<Value> {
|
||||
let mut seen = HashSet::<String>::new();
|
||||
let mut out = Vec::new();
|
||||
|
||||
if let Some(existing) = existing.and_then(Value::as_array) {
|
||||
for item in existing.iter().filter_map(Value::as_str) {
|
||||
if seen.insert(item.to_owned()) {
|
||||
out.push(Value::String(item.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for item in required {
|
||||
if seen.insert(item.to_owned()) {
|
||||
out.push(Value::String(item.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn write_atomically(path: &Path, content: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.map(OsString::from)
|
||||
.unwrap_or_else(|| OsString::from("tmp"));
|
||||
let tmp_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4());
|
||||
let tmp_path = path.with_file_name(tmp_name);
|
||||
|
||||
std::fs::write(&tmp_path, content.as_bytes())?;
|
||||
#[cfg(windows)]
|
||||
if path.exists() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod run_dir_migration_tests {
|
||||
use super::{
|
||||
claude_settings_seed_value, is_claude_mcp_profile, mcp_server_entry,
|
||||
merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir,
|
||||
};
|
||||
use application::McpRuntimeProvider;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
StructuredAdapter,
|
||||
};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn claude_profile() -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(Uuid::from_u128(9)),
|
||||
"Claude Code",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
None,
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
||||
McpTransport::Stdio,
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime(agent_id: AgentId) -> application::McpRuntime {
|
||||
application::McpRuntime {
|
||||
exe: "/opt/IdeA.AppImage".to_owned(),
|
||||
endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
|
||||
project_id: ProjectId::from_uuid(Uuid::from_u128(1234))
|
||||
.as_uuid()
|
||||
.simple()
|
||||
.to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_claude_settings_adds_idea_and_preserves_existing_entries() {
|
||||
let merged = merge_claude_settings_json(
|
||||
r#"{
|
||||
"permissions": {
|
||||
"additionalDirectories": ["/tmp/custom"],
|
||||
"allow": ["Read"],
|
||||
"deny": ["Bash(custom)"]
|
||||
},
|
||||
"enabledMcpjsonServers": ["other"],
|
||||
"extra": true
|
||||
}"#,
|
||||
"/home/me/proj",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let parsed = merged;
|
||||
assert_eq!(parsed["extra"], json!(true));
|
||||
assert_eq!(
|
||||
parsed["permissions"]["defaultMode"],
|
||||
json!("bypassPermissions")
|
||||
);
|
||||
assert!(parsed["permissions"]["additionalDirectories"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("/tmp/custom")));
|
||||
assert!(parsed["permissions"]["additionalDirectories"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("/home/me/proj")));
|
||||
assert!(parsed["enabledMcpjsonServers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("other")));
|
||||
assert!(parsed["enabledMcpjsonServers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("idea")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_idea_mcp_json_rewrites_idea_and_preserves_other_servers() {
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(77));
|
||||
let desired = mcp_server_entry(&claude_profile(), Some(&runtime(agent_id)));
|
||||
let merged = merge_mcp_json(
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"idea": { "command": "idea", "args": ["mcp-server"], "transport": "stdio" },
|
||||
"other": { "command": "keep-me", "args": [] }
|
||||
}
|
||||
}"#,
|
||||
desired.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let parsed = merged;
|
||||
assert_eq!(parsed["mcpServers"]["other"]["command"], json!("keep-me"));
|
||||
assert_eq!(parsed["mcpServers"]["idea"], desired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_claude_run_dir_repairs_legacy_files_on_disk() {
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(88));
|
||||
let temp = std::env::temp_dir().join(format!("idea-run-migrate-{}", Uuid::new_v4()));
|
||||
let project_root = temp.join("project");
|
||||
let run_dir = project_root.join(".ideai/run").join(agent_id.to_string());
|
||||
std::fs::create_dir_all(run_dir.join(".claude")).unwrap();
|
||||
std::fs::write(
|
||||
run_dir.join(".claude/settings.local.json"),
|
||||
r#"{"permissions":{"additionalDirectories":["/tmp/old"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
run_dir.join(".mcp.json"),
|
||||
r#"{"mcpServers":{"idea":{"command":"idea","args":["mcp-server"],"transport":"stdio"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let project = domain::Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1234)),
|
||||
"demo",
|
||||
domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(),
|
||||
domain::remote::RemoteRef::local(),
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
let profile = claude_profile();
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
migrate_claude_run_dir(&project, &agent_id, &profile)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let settings: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(run_dir.join(".claude/settings.local.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(settings["enabledMcpjsonServers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("idea")));
|
||||
assert!(settings["permissions"]["additionalDirectories"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!(project.root.as_str())));
|
||||
|
||||
let mcp: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(run_dir.join(".mcp.json")).unwrap())
|
||||
.unwrap();
|
||||
let expected_runtime = crate::mcp_endpoint::AppMcpRuntimeProvider
|
||||
.runtime_for(&project, agent_id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
mcp["mcpServers"]["idea"],
|
||||
mcp_server_entry(&claude_profile(), Some(&expected_runtime))
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(temp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_profile_guard_matches_only_claude_mcp_profiles() {
|
||||
assert!(is_claude_mcp_profile(&claude_profile()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_settings_fall_back_to_seed() {
|
||||
let merged = merge_claude_settings_json("{not-json", "/home/me/proj").unwrap();
|
||||
assert_eq!(merged, claude_settings_seed_value("/home/me/proj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_typed_settings_are_repaired_without_panicking() {
|
||||
let merged = merge_claude_settings_json(
|
||||
r#"{
|
||||
"permissions": [],
|
||||
"sandbox": false,
|
||||
"enabledMcpjsonServers": "idea"
|
||||
}"#,
|
||||
"/home/me/proj",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
merged["permissions"]["defaultMode"],
|
||||
json!("bypassPermissions")
|
||||
);
|
||||
assert_eq!(merged["sandbox"]["enabled"], json!(false));
|
||||
assert_eq!(merged["enabledMcpjsonServers"], json!(["idea"]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds the project's loopback endpoint listener (M5a). Best-effort: returns the
|
||||
@ -1113,10 +1677,7 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option<LocalSocketListener> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = endpoint
|
||||
.as_cli_arg()
|
||||
.to_fs_name::<GenericFilePath>()
|
||||
.ok()?;
|
||||
let name = endpoint.as_cli_arg().to_fs_name::<GenericFilePath>().ok()?;
|
||||
ListenerOptions::new()
|
||||
.name(name)
|
||||
// Reclaim (unlink) on drop so a clean close leaves no socket behind.
|
||||
@ -1808,7 +2369,10 @@ mod mcp_serve_peer_tests {
|
||||
.write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client.write_all(tools_list_line(1).as_bytes()).await.unwrap();
|
||||
client
|
||||
.write_all(tools_list_line(1).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client.flush().await.unwrap();
|
||||
|
||||
let resp = tokio::time::timeout(TIMEOUT, read_one_response(&mut client))
|
||||
@ -1833,7 +2397,10 @@ mod mcp_serve_peer_tests {
|
||||
"idea_memory_read",
|
||||
"idea_memory_write",
|
||||
] {
|
||||
assert!(names.contains(&expected), "missing tool {expected}; got {names:?}");
|
||||
assert!(
|
||||
names.contains(&expected),
|
||||
"missing tool {expected}; got {names:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
@ -1888,7 +2455,11 @@ mod mcp_serve_peer_tests {
|
||||
.iter()
|
||||
.filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
|
||||
.collect();
|
||||
assert_eq!(processed.len(), 1, "exactly one processed event; got {events:?}");
|
||||
assert_eq!(
|
||||
processed.len(),
|
||||
1,
|
||||
"exactly one processed event; got {events:?}"
|
||||
);
|
||||
match processed[0] {
|
||||
DomainEvent::OrchestratorRequestProcessed {
|
||||
requester_id,
|
||||
@ -1976,7 +2547,11 @@ mod mcp_serve_peer_tests {
|
||||
let resp = read_one_response(&mut client)
|
||||
.await
|
||||
.expect("the glued tools/list must still be served");
|
||||
assert_eq!(resp["id"], json!(7), "the buffered request id must come through");
|
||||
assert_eq!(
|
||||
resp["id"],
|
||||
json!(7),
|
||||
"the buffered request id must come through"
|
||||
);
|
||||
assert!(
|
||||
resp["result"]["tools"].is_array(),
|
||||
"buffered request produced a real tools/list result; got {resp}"
|
||||
@ -2053,7 +2628,10 @@ mod mcp_serve_peer_tests {
|
||||
.write_all(handshake_line("", "agent-1").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client.write_all(tools_list_line(1).as_bytes()).await.unwrap();
|
||||
client
|
||||
.write_all(tools_list_line(1).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client.flush().await.unwrap();
|
||||
|
||||
let resp = read_one_response(&mut client)
|
||||
@ -2091,7 +2669,10 @@ mod mcp_serve_peer_tests {
|
||||
.write_all(handshake_line(&project_id_arg(&proj), "agent-b").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client_b.write_all(tools_list_line(2).as_bytes()).await.unwrap();
|
||||
client_b
|
||||
.write_all(tools_list_line(2).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client_b.flush().await.unwrap();
|
||||
|
||||
let resp = read_one_response(&mut client_b)
|
||||
@ -2304,7 +2885,7 @@ mod mcp_e2e_loopback_tests {
|
||||
use async_trait::async_trait;
|
||||
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
|
||||
use interprocess::local_socket::traits::tokio::Stream as _;
|
||||
use tokio::io::{AsyncWriteExt, BufReader, AsyncBufReadExt};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
@ -2683,8 +3264,8 @@ mod mcp_e2e_loopback_tests {
|
||||
Arc::new(SystemMillisClock),
|
||||
Arc::new(FakePty) as Arc<dyn PtyPort>,
|
||||
)) as Arc<dyn domain::input::InputMediator>;
|
||||
let conversations =
|
||||
Arc::new(InMemoryConversationRegistry::new()) as Arc<dyn domain::conversation::ConversationRegistry>;
|
||||
let conversations = Arc::new(InMemoryConversationRegistry::new())
|
||||
as Arc<dyn domain::conversation::ConversationRegistry>;
|
||||
let service = OrchestratorService::new(
|
||||
create,
|
||||
launch,
|
||||
@ -2743,12 +3324,8 @@ mod mcp_e2e_loopback_tests {
|
||||
if let Some(publish) = events {
|
||||
server = server.with_events(publish);
|
||||
}
|
||||
let handle = McpServerHandle::start(
|
||||
server,
|
||||
endpoint.clone(),
|
||||
listener,
|
||||
project_id_arg(project),
|
||||
);
|
||||
let handle =
|
||||
McpServerHandle::start(server, endpoint.clone(), listener, project_id_arg(project));
|
||||
(handle, endpoint)
|
||||
}
|
||||
|
||||
@ -2790,7 +3367,7 @@ mod mcp_e2e_loopback_tests {
|
||||
{
|
||||
let mut line = String::new();
|
||||
match tokio::time::timeout(TIMEOUT, reader.read_line(&mut line)).await {
|
||||
Ok(Ok(0)) => None, // EOF before a full line
|
||||
Ok(Ok(0)) => None, // EOF before a full line
|
||||
Ok(Ok(_)) => serde_json::from_str(line.trim_end()).ok(),
|
||||
Ok(Err(_)) => None,
|
||||
Err(_) => None, // GARDE-FOU: timed out waiting for a reply
|
||||
@ -2859,7 +3436,11 @@ mod mcp_e2e_loopback_tests {
|
||||
let agent_id = contexts.seed_agent("architect");
|
||||
let (service, sessions, mailbox) = build_service(contexts);
|
||||
// Target already live in the PTY registry, so the ask reuses its terminal.
|
||||
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
|
||||
seed_live_pty(
|
||||
&sessions,
|
||||
agent_id,
|
||||
SessionId::from_uuid(Uuid::from_u128(4242)),
|
||||
);
|
||||
let proj = project();
|
||||
let (handle, endpoint) = start_real_server(service, &proj, None);
|
||||
|
||||
@ -2913,7 +3494,11 @@ mod mcp_e2e_loopback_tests {
|
||||
let reply_resp = read_one_response(&mut reader_b)
|
||||
.await
|
||||
.expect("a reply ack line");
|
||||
assert_eq!(reply_resp["result"]["isError"], json!(false), "got {reply_resp}");
|
||||
assert_eq!(
|
||||
reply_resp["result"]["isError"],
|
||||
json!(false),
|
||||
"got {reply_resp}"
|
||||
);
|
||||
|
||||
// The asker now receives its inline result.
|
||||
let resp = read_one_response(&mut reader_a)
|
||||
@ -2958,9 +3543,7 @@ mod mcp_e2e_loopback_tests {
|
||||
.await
|
||||
.unwrap();
|
||||
write_half
|
||||
.write_all(
|
||||
tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes(),
|
||||
)
|
||||
.write_all(tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
write_half.flush().await.unwrap();
|
||||
@ -3031,20 +3614,23 @@ mod mcp_e2e_loopback_tests {
|
||||
.await
|
||||
.unwrap();
|
||||
// A malformed JSON-RPC line (not valid JSON) — must NOT crash the serve loop.
|
||||
write_half
|
||||
.write_all(b"{ this is not json\n")
|
||||
.await
|
||||
.unwrap();
|
||||
write_half.write_all(b"{ this is not json\n").await.unwrap();
|
||||
write_half.flush().await.unwrap();
|
||||
|
||||
let resp = read_one_response(&mut reader)
|
||||
.await
|
||||
.expect("a parse error still owes a response");
|
||||
let error = &resp["error"];
|
||||
assert!(!error.is_null(), "malformed input must yield a JSON-RPC error: {resp}");
|
||||
assert!(
|
||||
!error.is_null(),
|
||||
"malformed input must yield a JSON-RPC error: {resp}"
|
||||
);
|
||||
// JSON-RPC mandates a null id when the request could not be correlated.
|
||||
assert_eq!(resp["id"], Value::Null, "got {resp}");
|
||||
assert!(resp["result"].is_null(), "no result on parse error; got {resp}");
|
||||
assert!(
|
||||
resp["result"].is_null(),
|
||||
"no result on parse error; got {resp}"
|
||||
);
|
||||
|
||||
// The server survived: a subsequent valid call still answers.
|
||||
write_half
|
||||
@ -3200,7 +3786,9 @@ mod bind_endpoint_d1_tests {
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
let ep = mcp_endpoint(&ProjectId::from_uuid(Uuid::from_u128(0xD1_0001)));
|
||||
let path = ep.socket_path().expect("unix endpoint exposes a socket path");
|
||||
let path = ep
|
||||
.socket_path()
|
||||
.expect("unix endpoint exposes a socket path");
|
||||
|
||||
// Clean any leftover from a previous run of this very test.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
@ -3214,7 +3802,10 @@ mod bind_endpoint_d1_tests {
|
||||
let corpse = UnixListener::bind(&path).expect("lay corpse socket");
|
||||
drop(corpse);
|
||||
}
|
||||
assert!(path.exists(), "corpse socket inode remains (no live listener)");
|
||||
assert!(
|
||||
path.exists(),
|
||||
"corpse socket inode remains (no live listener)"
|
||||
);
|
||||
|
||||
// 2) Rebind over the corpse: must succeed (reclaim unlinks then binds), NOT
|
||||
// fail with EADDRINUSE.
|
||||
@ -3239,4 +3830,3 @@ mod bind_endpoint_d1_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user