1use super::*;
2
3static AGENT_OPEN_LOCKS: std::sync::LazyLock<
23 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
24> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
25
26fn agent_open_lock(agent_id: &str) -> Arc<tokio::sync::Mutex<()>> {
27 let mut locks = AGENT_OPEN_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
28 locks
29 .entry(agent_id.to_string())
30 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
31 .clone()
32}
33
34const AGENT_OPEN_INSTALL_WAIT_SECS: u64 = 150;
37
38fn resolve_cli_args(provider: &providers::ProviderConfig, controller_type: &str) -> Vec<String> {
61 let args: &[&str] = match controller_type {
62 "persistent" => provider.persistent_launch_args.unwrap_or(provider.launch_args),
63 "app-server" => provider
64 .app_server
65 .as_ref()
66 .map(|app_server| app_server.launch_args)
67 .unwrap_or(provider.launch_args),
68 _ => provider.launch_args,
69 };
70 args.iter().map(|s| s.to_string()).collect()
71}
72
73fn resolve_vendor_env_override(
74 provider: &providers::ProviderConfig,
75 agent: &AgentDefinition,
76) -> Option<(&'static str, String)> {
77 if agent.model_vendor_base_url.is_empty() {
78 return None;
79 }
80 provider
81 .base_url_env_var
82 .map(|var| (var, agent.model_vendor_base_url.clone()))
83}
84
85#[cfg(test)]
97mod resolve_vendor_env_override_tests {
98 use super::*;
99
100 fn base_agent(model_vendor_base_url: &str) -> AgentDefinition {
101 AgentDefinition {
102 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
103 id: "a1".to_string(),
104 slug: "a1".to_string(),
105 name: "T".to_string(),
106 icon: String::new(),
107 provider: "claude".to_string(),
108 description: String::new(),
109 working_directory: String::new(),
110 shell: String::new(),
111 provider_flags: String::new(),
112 auto_start: 0,
113 restart_on_crash: 0,
114 idle_timeout_minutes: 0,
115 created_at: 0,
116 agent_type: "host".to_string(),
117 environment: String::new(),
118 agent_bus_id: String::new(),
119 is_seeded: 0,
120 accounts: String::new(),
121 parent_id: String::new(),
122 branch_label: String::new(),
123 updated_at: 0,
124 user_hidden: 0,
125 container_image: String::new(),
126 container_volumes: "[]".to_string(),
127 container_name: String::new(),
128 use_ambient_login: 0,
129 auto_continue_enabled: 0,
130 model_vendor_base_url: model_vendor_base_url.to_string(),
131 memory_id: String::new(),
132 }
133 }
134
135 #[test]
136 fn returns_none_when_override_is_unset() {
137 let provider = providers::get_provider("claude").unwrap();
138 let agent = base_agent("");
139 assert!(resolve_vendor_env_override(provider, &agent).is_none());
140 }
141
142 #[test]
143 fn returns_the_env_var_and_value_for_a_supporting_provider() {
144 let provider = providers::get_provider("claude").unwrap();
145 let agent = base_agent("https://my-proxy.example.com");
146 assert_eq!(
147 resolve_vendor_env_override(provider, &agent),
148 Some(("ANTHROPIC_BASE_URL", "https://my-proxy.example.com".to_string()))
149 );
150 }
151
152 #[test]
153 fn returns_none_for_a_non_supporting_provider_even_if_the_field_is_set() {
154 let provider = providers::get_provider("codex").unwrap();
158 let agent = base_agent("https://my-proxy.example.com");
159 assert!(resolve_vendor_env_override(provider, &agent).is_none());
160 }
161}
162
163#[cfg(test)]
164mod resolve_cli_args_tests {
165 use super::*;
166
167 #[test]
168 fn persistent_controller_uses_persistent_launch_args() {
169 let provider = providers::get_provider("claude").unwrap();
170 assert_eq!(
171 resolve_cli_args(provider, "persistent"),
172 provider.persistent_launch_args.unwrap().to_vec()
173 );
174 }
175
176 #[test]
177 fn subprocess_controller_uses_the_default_launch_args() {
178 let provider = providers::get_provider("codex").unwrap();
179 assert_eq!(resolve_cli_args(provider, "subprocess"), provider.launch_args.to_vec());
180 }
181
182 #[test]
189 fn app_server_controller_uses_the_app_server_launch_args_not_the_one_shot_ones() {
190 let provider = providers::get_provider("codex").unwrap();
191 let app_server_args = provider.app_server.as_ref().unwrap().launch_args.to_vec();
192
193 let resolved = resolve_cli_args(provider, "app-server");
194
195 assert_eq!(resolved, app_server_args);
196 assert_ne!(
197 resolved,
198 provider.launch_args.to_vec(),
199 "must not silently fall back to the one-shot exec args"
200 );
201 }
202
203 #[test]
207 fn app_server_controller_type_falls_back_gracefully_for_a_provider_without_one() {
208 let provider = providers::get_provider("claude").unwrap();
209 assert!(provider.app_server.is_none());
210 assert_eq!(resolve_cli_args(provider, "app-server"), provider.launch_args.to_vec());
211 }
212}
213
214fn select_openable_agent<'a>(
223 agents: &'a [AgentDefinition],
224 wanted: &str,
225) -> Result<&'a AgentDefinition, String> {
226 let names = |a: &&AgentDefinition| a.id == wanted || a.name.eq_ignore_ascii_case(wanted);
227 let users = || agents.iter().filter(|a| a.is_seeded == 0);
228 let found = users()
229 .find(|a| a.id == wanted)
230 .or_else(|| users().find(|a| a.name.eq_ignore_ascii_case(wanted)))
231 .or_else(|| agents.iter().find(names))
232 .ok_or_else(|| format!("AGENT_NOT_FOUND: no agent definition with id '{wanted}'"))?;
233 if found.is_seeded != 0 {
234 return Err(format!(
235 "TEMPLATE_NOT_OPENABLE: '{}' is a template; agent.open opens an agent from My Agents — create an agent from this template in the agent picker first",
236 found.name
237 ));
238 }
239 Ok(found)
240}
241
242fn require_local_agent_row(mstore: &Store, agent_id: &str) -> Result<(), String> {
249 match mstore.agent_row_ensure_local(agent_id) {
250 Ok(true) => Ok(()),
251 Ok(false) => Err(format!(
252 "AGENT_ROW_UNAVAILABLE: could not create a local row for agent '{agent_id}' — not started"
253 )),
254 Err(e) => Err(format!(
255 "AGENT_ROW_UNAVAILABLE: local row for agent '{agent_id}' could not be read or created ({e}) — not started"
256 )),
257 }
258}
259
260fn record_agent_open_launch(mstore: &Store, agent_id: &str, block_id: &str) -> Result<(), String> {
270 let now = agentmux_common::time::now_ms();
271 match mstore.instance_record_launch(agent_id, block_id, now) {
272 Ok(true) => {
273 let mut stamp = obj::MetaMapType::new();
274 stamp.insert("agentInstanceId".to_string(), json!(agent_id));
275 if let Err(e) = crate::server::service::update_object_meta(
276 mstore,
277 &format!("block:{block_id}"),
278 &stamp,
279 ) {
280 tracing::warn!(agent_id, block_id, error = %e, "agent.open: stamping the block failed");
281 }
282 }
283 Ok(false) => {
284 return Err(format!(
285 "AGENT_ROW_UNAVAILABLE: agent '{agent_id}' has no local row to record the launch on (deleted?) — not started"
286 ));
287 }
288 Err(e) => {
289 return Err(format!(
290 "AGENT_ROW_UNAVAILABLE: recording the launch of agent '{agent_id}' failed ({e}) — not started"
291 ));
292 }
293 }
294 Ok(())
295}
296
297pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
298 register_agent_open(engine, state);
299}
300
301fn register_agent_open(engine: &Arc<WshRpcEngine>, state: &AppState) {
302 let app_state = state.clone();
303
304 engine.register_typed(COMMAND_AGENT_OPEN, move |cmd: CommandAgentOpenData, _ctx| {
305 let app_state = app_state.clone();
306 async move { open_agent_impl(&app_state, cmd).await }
307 });
308}
309
310pub(crate) async fn open_agent_impl(
320 state: &AppState,
321 cmd: CommandAgentOpenData,
322) -> Result<AgentOpenResult, String> {
323 open_agent_inner(state, cmd, None).await
324}
325
326pub(crate) async fn open_agent_into_block(
335 state: &AppState,
336 agent_id: &str,
337 tab_id: &str,
338 block_id: &str,
339) -> Result<AgentOpenResult, String> {
340 open_agent_inner(
341 state,
342 CommandAgentOpenData {
343 agent_id: agent_id.to_string(),
344 tab_id: Some(tab_id.to_string()),
345 split_direction: None,
346 split_reference_block_id: None,
347 focus: Some(false),
348 },
349 Some(block_id),
350 )
351 .await
352}
353
354async fn open_agent_inner(
355 state: &AppState,
356 cmd: CommandAgentOpenData,
357 into_block: Option<&str>,
358) -> Result<AgentOpenResult, String> {
359 let mstore = state.mstore.clone();
360 let broker = state.broker.clone();
361 let event_bus = state.event_bus.clone();
362 let filestore = state.filestore.clone();
363 let app_state = state.clone();
366
367 tracing::info!(agent_id = %cmd.agent_id, "agent.open");
368
369 let agents = mstore.agent_def_list()
371 .map_err(|e| format!("agent.open: {e}"))?;
372 let mut agent = select_openable_agent(&agents, &cmd.agent_id)?.clone();
373
374 agent.provider = app_state.id_store.resolve_effective_provider_id(&agent);
380
381 let open_lock = agent_open_lock(&agent.id);
385 let _open_guard = open_lock.lock().await;
386
387 crate::backend::agent_admission::check_before_spawn(
393 crate::backend::agent_admission::lease_store_for(mstore.shared_agent_registry()),
394 &agent.id,
395 &agent.name,
396 &app_state.boot_id,
397 )
398 .await
399 .map_err(|refusal| format!("AGENT_LIVE_ELSEWHERE: {refusal}"))?;
400
401 require_local_agent_row(&mstore, &agent.id)?;
406
407 let provider = providers::get_provider(&agent.provider)
409 .ok_or_else(|| format!("INVALID_PROVIDER: unknown provider '{}'", agent.provider))?;
410
411 let tab_id = resolve_tab_id(&mstore, cmd.tab_id.as_deref())?;
413
414 if let Some(existing) = find_agent_block(&mstore, &tab_id, &agent.id)? {
417 if blockcontroller::get_controller(&existing.oid).is_none() {
420 let controller_type = if agent.agent_type == "container" {
421 "subprocess"
422 } else {
423 provider.controller_type_str()
424 };
425 let mut meta_update = obj::MetaMapType::new();
427 meta_update.insert("controller".to_string(), json!(controller_type));
428 meta_update.insert("agentProvider".to_string(), json!(&agent.provider));
429 let _ = crate::server::service::update_object_meta(
430 &mstore, &format!("block:{}", existing.oid), &meta_update,
431 );
432 record_agent_open_launch(&mstore, &agent.id, &existing.oid)?;
438 let block_for_resync = mstore.must_get::<Block>(&existing.oid)
440 .map_err(|e| format!("agent.open: reload block: {e}"))?;
441 let _ = blockcontroller::resync_controller(
442 &block_for_resync, &tab_id, None, true, true,
443 Some(broker.clone()), Some(event_bus.clone()), Some(mstore.clone()),
444 Some(filestore.clone()),
445 Some(app_state.id_store.clone()), Some(app_state.identity_store.clone()),
446 mstore.shared_agent_registry(),
447 app_state.boot_id.clone(), &app_state.auth_key,
448 Some(app_state.config_watcher.clone()),
449 );
450 }
451 let status = blockcontroller::get_block_controller_status(&existing.oid)
452 .map(|s| s.shellprocstatus)
453 .unwrap_or_else(|| "init".to_string());
454 return Ok(AgentOpenResult {
455 block_id: existing.oid,
456 tab_id,
457 agent_id: cmd.agent_id,
458 provider: agent.provider,
459 controller_type: provider.controller_type_str().to_string(),
460 status,
461 created: false,
462 });
463 }
464
465 let paths = agentmux_common::DataPaths::from_env()
478 .ok_or_else(|| "DataPaths::from_env() failed".to_string())?;
479 let npm_bin = crate::backend::cli_install::npm_bin(
480 &crate::backend::cli_install::install_dir(&paths, &provider.id, provider.pinned_version),
481 provider.cli_command,
482 )
483 .to_string_lossy()
484 .to_string();
485 let mut resolved_cli_path = crate::backend::cli_install::find_installed(
486 &paths,
487 &provider.id,
488 provider.pinned_version,
489 provider.cli_command,
490 )
491 .map(|p| p.to_string_lossy().to_string())
492 .unwrap_or_else(|| npm_bin.clone());
493 if !std::path::Path::new(&resolved_cli_path).exists() {
494 if provider.npm_package.is_empty() {
498 if let Some(path) = resolve_provider_cli_on_path(provider.id, provider.cli_command).await {
499 resolved_cli_path = path;
500 }
501 }
502 if !std::path::Path::new(&resolved_cli_path).exists() {
503 if provider.npm_package.is_empty() || provider.pinned_version.is_empty() {
508 return Err(format!(
509 "CLI_NOT_AVAILABLE: {} not found at {}. Install it, then try again.",
510 provider.cli_command, npm_bin
511 ));
512 }
513 let req = crate::backend::cli_install::NpmInstallRequest {
514 provider_id: provider.id.to_string(),
515 npm_package: provider.npm_package.to_string(),
516 pinned_version: provider.pinned_version.to_string(),
517 cli_command: provider.cli_command.to_string(),
518 background: false,
519 };
520 match crate::backend::cli_install::ensure_installed(
521 paths.clone(),
522 req,
523 std::time::Duration::from_secs(AGENT_OPEN_INSTALL_WAIT_SECS),
524 )
525 .await
526 {
527 Ok(bin) => resolved_cli_path = bin.to_string_lossy().to_string(),
528 Err(crate::backend::cli_install::EnsureError::StillInstalling) => {
529 return Err(format!(
530 "CLI_INSTALLING: {} {} is still being installed. Try again in a minute; \
531 the install carries on in the background.",
532 provider.cli_command, provider.pinned_version
533 ));
534 }
535 Err(crate::backend::cli_install::EnsureError::Failed(why)) => {
536 return Err(format!(
537 "CLI_NOT_AVAILABLE: {} {} could not be installed: {why}",
538 provider.cli_command, provider.pinned_version
539 ));
540 }
541 }
542 }
543 }
544
545 let controller_type = provider.controller_type_str();
547 let controller_type = if agent.agent_type == "container" {
551 "subprocess"
552 } else {
553 controller_type
554 };
555 let remembered = crate::backend::agent_runtime::Remembered::parse(
565 provider.id,
566 &mstore.agent_last_runtime_get(&agent.id).unwrap_or_default(),
567 );
568 let (base_args, pane_flags, remembered_mode) = crate::backend::agent_runtime::apply_remembered(
569 provider.id,
570 resolve_cli_args(&provider, controller_type),
571 &agent.provider_flags,
572 &remembered,
573 );
574 let mut seeded =
575 crate::backend::agent_runtime::seed_launch(provider.id, base_args, &pane_flags);
576 if let (Some(mode), Some(rt)) = (remembered_mode, seeded.runtime.as_mut()) {
577 rt["permissionMode"] = json!(mode);
578 }
579
580 let agent_slug = agentmux_common::slug::path_slug(&agent.name);
581 let work_dir = if agent.working_directory.is_empty() {
587 crate::backend::storage::agents::default_agent_working_dir(&agent.name)
588 } else {
589 agent.working_directory.clone()
590 };
591
592 let mut env_vars = serde_json::Map::new();
594 for key in provider.unset_env {
595 env_vars.insert(key.to_string(), json!(""));
596 }
597 let auth_dir = providers::default_auth_dir(provider)?;
603 providers::prepare_provider_auth_dir(provider, &auth_dir).map_err(|e| {
618 format!(
619 "failed to isolate this agent's Claude Code config ({auth_dir}): {e}. \
620 Refusing to launch with an unprotected config dir."
621 )
622 })?;
623 env_vars.insert(provider.auth_config_dir_env_var.to_string(), json!(auth_dir));
624 for (k, v) in provider.auth_extra_env {
625 env_vars.insert(k.to_string(), json!(v));
626 }
627 if let Some((var, value)) = resolve_vendor_env_override(provider, &agent) {
635 env_vars.insert(var.to_string(), json!(value));
636 }
637 if let Ok(Some(env_blob)) = mstore.agent_content_get(&agent.id, "env") {
641 for line in env_blob.content.lines() {
642 if let Some((k, v)) = line.split_once('=') {
643 let k = k.trim();
644 if !k.is_empty() && !env_vars.contains_key(k) {
645 env_vars.insert(k.to_string(), json!(v));
646 }
647 }
648 }
649 }
650 let routing_id = if !agent.slug.is_empty() { &agent.slug } else { &agent_slug };
656 env_vars.insert("AGENTMUX_AGENT_ID".to_string(), json!(routing_id));
657 env_vars.insert("MUXBUS_AGENT_ID".to_string(), json!(routing_id));
665 if controller_type != "persistent" {
667 env_vars.insert("CLAUDE_CODE_EXIT_AFTER_STOP_DELAY".to_string(), json!("30000"));
668 }
669
670 let closing_blocks: Vec<String> = mstore
704 .get_all::<Block>()
705 .map(|blocks| {
706 blocks
707 .into_iter()
708 .filter(|b| {
709 obj::meta_get_string(&b.meta, "agentId", "") == agent.id
710 && blockcontroller::is_closing(&b.oid)
711 })
712 .map(|b| b.oid)
713 .collect()
714 })
715 .unwrap_or_default();
716 for closing in &closing_blocks {
717 blockcontroller::wait_closing_stopped(
718 closing,
719 blockcontroller::SHUTDOWN_GRACE + std::time::Duration::from_secs(2),
720 )
721 .await;
722 }
723 let agent_live_elsewhere = mstore.get_all::<Block>()
724 .map(|blocks| {
725 blocks.iter().any(|b| {
726 obj::meta_get_string(&b.meta, "agentId", "") == agent.id
727 && blockcontroller::get_controller(&b.oid).is_some()
728 })
729 })
730 .unwrap_or(false);
731 let resume_session_id: Option<String> = if agent_live_elsewhere {
732 None
733 } else {
734 mstore.shared_agent_registry()
735 .and_then(|reg| reg.list_active().ok())
736 .and_then(|records| {
737 records.into_iter()
738 .filter(|r| r.data.definition_id == agent.id)
739 .filter(|r| r.data.session_id.as_deref().map_or(false, |s| !s.is_empty()))
740 .max_by_key(|r| r.data.last_launched_at_ms)
741 })
742 .and_then(|r| r.data.session_id)
743 };
744
745 let mut meta = MetaMapType::new();
746 meta.insert("view".to_string(), json!("agent"));
747 meta.insert("agentId".to_string(), json!(&agent.id));
748 if let Ok(Some(c)) = mstore.agent_content_get(&agent.id, "ui:zoom") {
755 if let Some(z) = parse_seed_zoom(&c.content) {
756 meta.insert("term:zoom".to_string(), json!(z));
757 }
758 }
759 {
772 use crate::backend::agent_color::{dim_agent_color, is_valid_agent_color, pick_agent_color};
773 let stored = mstore
774 .agent_content_get(&agent.id, "ui:color")
775 .ok()
776 .flatten()
777 .map(|c| c.content.trim().to_string())
778 .filter(|c| is_valid_agent_color(c));
779 let color = match stored {
780 Some(c) => c,
781 None => {
782 let picked = pick_agent_color(&agent.id).to_string();
783 let now_ms = agentmux_common::time::now_ms();
784 let _ = mstore.agent_content_set(&crate::backend::storage::store::AgentContent {
788 agent_id: agent.id.clone(),
789 content_type: "ui:color".to_string(),
790 content: picked.clone(),
791 updated_at: now_ms,
792 });
793 picked
794 }
795 };
796 meta.insert("frame:activebordercolor".to_string(), json!(&color));
797 meta.insert("frame:bordercolor".to_string(), json!(dim_agent_color(&color)));
798 }
799 meta.insert("agentProvider".to_string(), json!(&agent.provider));
800 meta.insert("agentName".to_string(), json!(&agent.name));
801 meta.insert("agentIcon".to_string(), json!(if agent.icon.is_empty() { "wand-magic-sparkles" } else { &agent.icon }));
802 meta.insert("agentMode".to_string(), json!(if agent.agent_type.is_empty() { "host" } else { &agent.agent_type }));
803 if !agent.container_image.is_empty() {
804 meta.insert("agent:container_image".to_string(), json!(&agent.container_image));
805 }
806 if agent.container_volumes != "[]" && !agent.container_volumes.is_empty() {
807 meta.insert("agent:container_volumes".to_string(), json!(&agent.container_volumes));
808 }
809 meta.insert("agent:container_command".to_string(), json!(provider.cli_command));
816 meta.insert("agentOutputFormat".to_string(), json!(provider.styled_output_format));
820 meta.insert("controller".to_string(), json!(controller_type));
821 meta.insert("cmd".to_string(), json!(&resolved_cli_path));
822 crate::backend::agent_runtime::apply_to_meta(&mut meta, seeded, &pane_flags);
824 meta.insert("cmd:cwd".to_string(), json!(&work_dir));
825 meta.insert("cmd:env".to_string(), serde_json::Value::Object(env_vars));
826 meta.insert("agent:resume_flag".to_string(), json!(provider.resume_flag.unwrap_or("")));
827 meta.insert("agent:resume_strategy".to_string(), json!(provider.resume_strategy_str()));
828 meta.insert("agent:session_id_field".to_string(), json!(provider.session_id_field));
829 meta.insert("agent:prompt_arg_flag".to_string(), json!(provider.prompt_arg_flag().unwrap_or("")));
830 if let Some(sid) = &resume_session_id {
831 meta.insert("agent:sessionid".to_string(), json!(sid));
832 }
833
834 let meta_val = serde_json::to_value(&meta)
842 .map_err(|e| format!("agent.open: meta serialize: {e}"))?;
843 let block_id = if let Some(target) = into_block {
844 let target_block = mstore
849 .get::<Block>(target)
850 .map_err(|e| format!("agent.open: read target pane: {e}"))?
851 .ok_or_else(|| format!("agent.open: no such pane: {target}"))?;
852 if obj::meta_get_string(&target_block.meta, "view", "") != "agent"
853 || !obj::meta_get_string(&target_block.meta, "agentId", "").is_empty()
854 {
855 return Err("agent.open: the target pane is not an empty agent pane".to_string());
856 }
857 let mut patch = meta_val.clone();
858 if let Some(p) = patch.as_object_mut() {
859 p.insert(crate::backend::layout_file::META_LAYOUT_AGENT.to_string(), serde_json::Value::Null);
861 }
862 let meta_events = crate::server::service::dispatch_to_reducer(
863 &app_state,
864 agentmux_common::ipc::Command::UpdateBlockMeta { block_id: target.to_string(), meta_patch: patch },
865 )
866 .await;
867 if let Some(msg) = meta_events.iter().find_map(|e| match e {
868 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
869 _ => None,
870 }) {
871 return Err(format!("agent.open: UpdateBlockMeta: {msg}"));
872 }
873 for ev in &meta_events {
874 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &mstore) {
875 tracing::warn!("agent.open: UpdateBlockMeta mstore apply failed: {e}");
876 }
877 }
878 crate::server::service::publish_events(&app_state, &meta_events);
879 target.to_string()
880 } else {
881 let create_events = crate::server::service::dispatch_to_reducer(
882 &app_state,
883 agentmux_common::ipc::Command::CreateBlock {
884 tab_id: tab_id.clone(),
885 meta: meta_val,
886 },
887 )
888 .await;
889 if let Some(msg) = create_events.iter().find_map(|e| match e {
890 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
891 _ => None,
892 }) {
893 return Err(format!("agent.open: CreateBlock: {msg}"));
894 }
895 let block_id = create_events
896 .iter()
897 .find_map(|e| match e {
898 agentmux_common::ipc::Event::BlockCreated { block_id, .. } => {
899 Some(block_id.clone())
900 }
901 _ => None,
902 })
903 .ok_or_else(|| "agent.open: CreateBlock emitted no BlockCreated".to_string())?;
904 for ev in &create_events {
905 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &mstore) {
906 tracing::warn!("agent.open: CreateBlock mstore apply failed: {e}");
907 }
908 }
909 crate::server::service::publish_events(&app_state, &create_events);
910
911 {
919 let action = obj::LayoutActionData {
920 actiontype: "insert".to_string(),
921 actionid: uuid::Uuid::new_v4().to_string(),
922 blockid: block_id.clone(),
923 nodesize: None,
924 nodesizefraction: None,
925 indexarr: None,
926 focused: cmd.focus.unwrap_or(true),
933 magnified: false,
934 ephemeral: false,
935 targetblockid: String::new(),
936 position: String::new(),
937 };
938 if let Err(e) = crate::server::service::queue_layout_actions_via_reducer(
939 &app_state,
940 &tab_id,
941 vec![action],
942 )
943 .await
944 {
945 tracing::warn!("agent.open: layout action enqueue failed: {e}");
946 }
947 }
948 block_id
949 };
950
951 tracing::info!(
952 block_id = %block_id,
953 agent_id = %cmd.agent_id,
954 provider = %agent.provider,
955 controller_type = %controller_type,
956 "agent.open: block created + layout updated"
957 );
958
959 write_agent_config_files(&mstore, &app_state.id_store, &app_state.identity_store, &agent, routing_id, &work_dir)?;
965
966 record_agent_open_launch(&mstore, &agent.id, &block_id)?;
971
972 let block_for_resync = mstore.must_get::<Block>(&block_id)
974 .map_err(|e| format!("agent.open: reload block: {e}"))?;
975 blockcontroller::resync_controller(
976 &block_for_resync,
977 &tab_id,
978 None,
979 true,
980 true,
981 Some(broker.clone()),
982 Some(event_bus.clone()),
983 Some(mstore.clone()),
984 Some(filestore.clone()),
985 Some(app_state.id_store.clone()),
986 Some(app_state.identity_store.clone()),
987 mstore.shared_agent_registry(),
988 app_state.boot_id.clone(),
989 &app_state.auth_key,
990 Some(app_state.config_watcher.clone()),
991 )?;
992
993 {
995 let mut updates = Vec::new();
996 if let Ok(updated_block) = mstore.must_get::<Block>(&block_id) {
997 updates.push(obj::MuxObjUpdate {
998 updatetype: "update".into(),
999 otype: "block".into(),
1000 oid: block_id.clone(),
1001 obj: Some(obj::mux_obj_to_value(&updated_block)),
1002 });
1003 }
1004 if let Ok(updated_tab) = mstore.must_get::<Tab>(&tab_id) {
1005 updates.push(obj::MuxObjUpdate {
1006 updatetype: "update".into(),
1007 otype: "tab".into(),
1008 oid: tab_id.clone(),
1009 obj: Some(obj::mux_obj_to_value(&updated_tab)),
1010 });
1011 if let Ok(updated_layout) = mstore.must_get::<obj::LayoutState>(&updated_tab.layoutstate) {
1012 updates.push(obj::MuxObjUpdate {
1013 updatetype: "update".into(),
1014 otype: "layout".into(),
1015 oid: updated_tab.layoutstate.clone(),
1016 obj: Some(obj::mux_obj_to_value(&updated_layout)),
1017 });
1018 }
1019 }
1020 event_bus.broadcast_mux_obj_updates(&updates);
1024 }
1025
1026 let status = blockcontroller::get_block_controller_status(&block_id)
1037 .map(|s| s.shellprocstatus)
1038 .unwrap_or_else(|| "init".to_string());
1039 Ok(AgentOpenResult {
1040 block_id,
1041 tab_id,
1042 agent_id: cmd.agent_id,
1043 provider: agent.provider,
1044 controller_type: controller_type.to_string(),
1045 status,
1046 created: true,
1047 })
1048}
1049
1050pub(super) fn write_agent_config_files(
1052 mstore: &Store,
1053 id_store: &Store,
1054 identity_store: &Store,
1055 agent: &crate::backend::storage::AgentDefinition,
1056 agent_slug: &str,
1057 work_dir: &str,
1058) -> Result<(), String> {
1059 let contents = mstore.agent_content_get_all(&agent.id)
1061 .unwrap_or_default();
1062
1063 let mut content_map = std::collections::HashMap::new();
1064 for fc in &contents {
1065 content_map.insert(fc.content_type.clone(), fc.content.clone());
1066 }
1067
1068 let workdir_is_shared = agent.working_directory.is_empty();
1074 if workdir_is_shared {
1075 let settings_str = content_map
1076 .entry("settings".to_string())
1077 .or_insert_with(|| "{}".to_string());
1078 match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(settings_str) {
1079 Ok(mut obj) => {
1080 obj.entry("autoMemoryEnabled".to_string())
1081 .or_insert(json!(false));
1082 *settings_str =
1083 serde_json::to_string(&obj).unwrap_or_else(|_| "{}".to_string());
1084 }
1085 Err(e) => {
1086 tracing::warn!(
1087 work_dir = %work_dir,
1088 error = %e,
1089 "write_agent_config_files: settings JSON unparseable; \
1090 autoMemoryEnabled guard skipped — memory writes may be active on shared workdir"
1091 );
1092 }
1093 }
1094 }
1095
1096 let global_bundles = crate::backend::operator_config_seed::global_bundles_for_agent(
1100 id_store.bundle_list_global().unwrap_or_default(),
1101 &agent.agent_type,
1102 );
1103 let startup_block = crate::backend::storage::join_startup_blocks(
1104 &crate::backend::storage::format_global_bundle_block(&global_bundles),
1105 &crate::backend::storage::format_agent_bundle_block(&crate::backend::storage::agent_picked_bundles(
1106 mstore, id_store, &agent.id,
1107 )),
1108 );
1109 if !startup_block.is_empty() {
1110 content_map
1111 .entry("memory".to_string())
1112 .and_modify(|existing| {
1113 *existing = format!("{startup_block}\n\n---\n\n{existing}");
1114 })
1115 .or_insert(startup_block);
1116 }
1117
1118 let effective_skills = mstore.effective_skills(identity_store, &agent.id);
1124
1125 let mut config_files = crate::backend::agent_config::build_config_files(
1126 &content_map,
1127 &effective_skills,
1128 &agent.name,
1129 &agent.id,
1130 agent_slug,
1131 work_dir,
1132 &agent.provider,
1133 );
1134
1135 let visible_mcp: Vec<crate::backend::storage::McpServer> = mstore.effective_mcp_servers(identity_store, &agent.id, agent.agent_type == "container");
1143 let has_own_mcp_refs = visible_mcp.iter().any(|server| !server.is_global);
1144 if !visible_mcp.is_empty() {
1145 let blob_for_merge = if has_own_mcp_refs {
1146 None
1147 } else {
1148 content_map.get("mcp").map(|s| s.as_str())
1149 };
1150 if let Some(mcp_json) = crate::backend::agent_config::build_mcp_config_from_refs(
1151 &visible_mcp,
1152 blob_for_merge,
1153 agent_slug,
1154 &agent.agent_bus_id,
1155 ) {
1156 if let Some(pos) = config_files.iter().position(|f| f.filename == ".mcp.json") {
1157 config_files[pos].content = mcp_json;
1158 } else {
1159 config_files.push(crate::backend::agent_config::AgentConfigFile {
1160 filename: ".mcp.json".to_string(),
1161 content: mcp_json,
1162 });
1163 }
1164 }
1165 }
1166
1167 if let Some(pos) = config_files.iter().position(|f| f.filename == ".mcp.json") {
1182 crate::server::agent_self_keys::record_config_id_drift("m4d.config_id_drift.agent_open", agent_slug, Some(&agent.slug));
1183 match crate::backend::agent_config::inject_jekt_signing_keys_into_mcp_json(
1184 &config_files[pos].content,
1185 &mstore,
1186 agent_slug,
1187 ) {
1188 Some(rewritten) => config_files[pos].content = rewritten,
1189 None => {
1190 tracing::warn!(
1191 agent_id = %agent.id,
1192 "agent_open: jekt/LAN signing keys not injected into .mcp.json — \
1193 this agent's jekts will render TRUST=self-declared / TRUST=network-claimed \
1194 instead of verified"
1195 );
1196 }
1197 }
1198 }
1199
1200 let expanded_dir = if work_dir.starts_with("~/") || work_dir == "~" {
1202 if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
1203 format!("{}/{}", home, work_dir.trim_start_matches("~/"))
1204 } else {
1205 work_dir.to_string()
1206 }
1207 } else {
1208 work_dir.to_string()
1209 };
1210
1211 let base_path = std::path::Path::new(&expanded_dir);
1212 if !base_path.exists() {
1213 std::fs::create_dir_all(base_path) .map_err(|e| format!("failed to create working dir: {e}"))?;
1215 }
1216 let wd = crate::backend::workdir_fs::Workdir::open(base_path)
1217 .map_err(|e| format!("failed to open working dir {}: {e}", base_path.display()))?;
1218
1219 let new_managed_skill_paths = crate::backend::agent_config::managed_skill_file_paths(
1226 config_files.iter().map(|f| f.filename.as_str()),
1227 );
1228 crate::backend::agent_config::cleanup_stale_managed_skill_files(base_path, &new_managed_skill_paths);
1229
1230 for file in &config_files {
1231 if file.filename == "CLAUDE.md" {
1236 if let Err(e) =
1237 crate::backend::agent_config::write_claude_md_respecting_ownership(base_path, &file.content)
1238 {
1239 return Err(format!("failed to write CLAUDE.md: {e}"));
1240 }
1241 continue;
1242 }
1243 if crate::backend::providers::is_known_startup_instructions_filename(&file.filename) {
1250 crate::backend::agent_config::write_startup_instructions_respecting_existing(
1251 base_path,
1252 &file.filename,
1253 &file.content,
1254 )
1255 .map_err(|e| format!("failed to write {}: {e}", file.filename))?;
1256 continue;
1257 }
1258 if file.filename == ".mcp.json" {
1261 crate::backend::agent_config::write_mcp_json_respecting_user_servers(base_path, &file.content)
1262 .map_err(|e| format!("failed to write .mcp.json: {e}"))?;
1263 continue;
1264 }
1265 match wd.write(&file.filename, file.content.as_bytes(), false) {
1269 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
1270 tracing::warn!(
1271 work_dir = %expanded_dir,
1272 path = %file.filename,
1273 error = %e,
1274 "write_agent_config_files: refusing to write a config path outside the working directory"
1275 );
1276 }
1277 other => other.map_err(|e| format!("failed to write {}: {e}", file.filename))?,
1278 }
1279 }
1280
1281 crate::backend::agent_config::write_managed_skill_file_manifest(base_path, &new_managed_skill_paths);
1282
1283 observe_project_instructions(mstore, agent, &expanded_dir);
1296
1297 tracing::info!(
1298 agent_id = %agent.id,
1299 work_dir = %expanded_dir,
1300 file_count = config_files.len(),
1301 "agent.open: wrote config files"
1302 );
1303
1304 Ok(())
1305}
1306
1307pub(super) fn observe_project_instructions(
1312 mstore: &crate::backend::storage::store::Store,
1313 agent: &crate::backend::storage::AgentDefinition,
1314 working_dir: &str,
1315) {
1316 use crate::backend::storage::project_instructions::ProjectInstructionObservation;
1317
1318 let files = crate::backend::project_instructions::resolve_project_instructions(
1319 &agent.provider,
1320 working_dir,
1321 );
1322 if files.is_empty() {
1323 return;
1324 }
1325 let now = agentmux_common::time::now_ms();
1326 let observations: Vec<ProjectInstructionObservation> = files
1327 .iter()
1328 .map(|f| ProjectInstructionObservation {
1329 path: f.path.clone(),
1330 content_hash: f.content_hash.clone(),
1331 size_bytes: f.size_bytes as i64,
1332 owner: match f.owner {
1333 crate::backend::project_instructions::InstructionOwner::Agentmux => "agentmux",
1334 crate::backend::project_instructions::InstructionOwner::Foreign => "foreign",
1335 }
1336 .to_string(),
1337 existed: f.exists,
1338 observed_at: now,
1339 })
1340 .collect();
1341
1342 if let Err(e) = mstore.project_instructions_record(&agent.id, &observations) {
1343 tracing::warn!(
1344 agent_id = %agent.id, error = %e,
1345 "agent.open: could not record project-instruction observations"
1346 );
1347 }
1348}
1349
1350#[cfg(test)]
1351mod write_agent_config_files_tests {
1352 use super::*;
1353 use crate::backend::storage::Skill;
1354
1355 fn make_store() -> Store {
1356 Store::open_in_memory().unwrap()
1357 }
1358
1359 fn make_agent(id: &str, working_directory: &str) -> AgentDefinition {
1360 AgentDefinition {
1361 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1362 id: id.to_string(),
1363 slug: String::new(),
1364 name: "Test Agent".to_string(),
1365 icon: String::new(),
1366 provider: "claude".to_string(),
1367 description: String::new(),
1368 working_directory: working_directory.to_string(),
1369 shell: String::new(),
1370 provider_flags: String::new(),
1371 auto_start: 0,
1372 restart_on_crash: 0,
1373 idle_timeout_minutes: 0,
1374 created_at: 1_700_000_000_000,
1375 agent_type: "host".to_string(),
1376 environment: String::new(),
1377 agent_bus_id: String::new(),
1378 is_seeded: 0,
1379 accounts: String::new(),
1380 parent_id: String::new(),
1381 branch_label: String::new(),
1382 updated_at: 1_700_000_000_000,
1383 user_hidden: 0,
1384 container_image: String::new(),
1385 container_volumes: "[]".to_string(),
1386 container_name: String::new(),
1387 use_ambient_login: 0,
1388 auto_continue_enabled: 0,
1389 model_vendor_base_url: String::new(),
1390 memory_id: String::new(),
1391 }
1392 }
1393
1394 fn make_skill(skill_type: &str) -> Skill {
1395 Skill {
1396 id: "skill-1".to_string(),
1397 name: "Deploy".to_string(),
1398 trigger: "deploy".to_string(),
1399 skill_type: skill_type.to_string(),
1400 description: "Deploy the app".to_string(),
1401 content: "1. Test\n2. Deploy".to_string(),
1402 is_global: false,
1403 created_at: 1_700_000_000_000,
1404 updated_at: 1_700_000_000_000,
1405 }
1406 }
1407
1408 #[test]
1413 fn switching_skill_format_removes_the_stale_artifact() {
1414 let mstore = make_store();
1415 let id_store = make_store();
1416 let work_dir = tempfile::tempdir().unwrap();
1417 let work_dir_str = work_dir.path().to_str().unwrap();
1418
1419 let mut agent = make_agent("agent-1", work_dir_str);
1420 mstore.agent_def_insert(&mut agent).unwrap();
1421 mstore.skill_upsert_unique(&mstore, "agent-1", &make_skill("agent-skill"), true).unwrap();
1422
1423 write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1424 let skill_md = work_dir.path().join(".claude/skills/deploy/SKILL.md");
1425 assert!(skill_md.exists(), "expected .claude/skills/deploy/SKILL.md to be written");
1426
1427 let mut prompt_skill = make_skill("prompt");
1429 prompt_skill.updated_at = 1_700_000_000_001;
1430 mstore.skill_upsert_unique(&mstore, "agent-1", &prompt_skill, true).unwrap();
1431 write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1432
1433 let command_md = work_dir.path().join(".claude/commands/deploy.md");
1434 assert!(command_md.exists(), "expected .claude/commands/deploy.md to be written");
1435 assert!(
1436 !skill_md.exists(),
1437 "stale .claude/skills/deploy/SKILL.md must be removed when the skill switches to prompt format"
1438 );
1439 assert!(
1440 !skill_md.parent().unwrap().exists(),
1441 "the now-empty .claude/skills/deploy/ directory should be cleaned up too"
1442 );
1443 }
1444
1445 #[test]
1449 fn user_authored_files_outside_the_manifest_are_never_touched() {
1450 let mstore = make_store();
1451 let id_store = make_store();
1452 let work_dir = tempfile::tempdir().unwrap();
1453 let work_dir_str = work_dir.path().to_str().unwrap();
1454
1455 let mut agent = make_agent("agent-1", work_dir_str);
1456 mstore.agent_def_insert(&mut agent).unwrap();
1457
1458 let user_file = work_dir.path().join(".claude/commands/my-own-command.md");
1459 std::fs::create_dir_all(user_file.parent().unwrap()).unwrap();
1460 std::fs::write(&user_file, "# hand-authored, not from AgentMux").unwrap();
1461
1462 mstore.skill_upsert_unique(&mstore, "agent-1", &make_skill("agent-skill"), true).unwrap();
1463 write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1464 write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1465
1466 assert!(user_file.exists(), "hand-authored file outside AgentMux's manifest must survive");
1467 }
1468
1469 #[test]
1475 fn cleanup_refuses_to_delete_a_manifest_path_that_escapes_the_working_dir() {
1476 let mstore = make_store();
1477 let id_store = make_store();
1478 let work_dir = tempfile::tempdir().unwrap();
1479 let work_dir_str = work_dir.path().to_str().unwrap();
1480
1481 let mut agent = make_agent("agent-1", work_dir_str);
1482 mstore.agent_def_insert(&mut agent).unwrap();
1483
1484 let sentinel_dir = tempfile::tempdir().unwrap();
1487 let sentinel = sentinel_dir.path().join("outside-file.txt");
1488 std::fs::write(&sentinel, "must survive").unwrap();
1489
1490 std::fs::create_dir_all(work_dir.path().join(".claude")).unwrap();
1492 let traversal_rel = format!(
1493 "../{}/outside-file.txt",
1494 sentinel_dir.path().file_name().unwrap().to_str().unwrap()
1495 );
1496 let malicious_manifest = serde_json::to_string(&vec![traversal_rel]).unwrap();
1497 std::fs::write(
1498 work_dir.path().join(crate::backend::agent_config::MANAGED_SKILL_FILES_MANIFEST),
1499 malicious_manifest,
1500 )
1501 .unwrap();
1502
1503 write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1506
1507 assert!(sentinel.exists(), "file outside the working directory must never be deleted");
1508 }
1509
1510 #[test]
1515 fn config_files_are_never_written_through_a_claude_folder_linking_outside() {
1516 let mstore = make_store();
1517 let id_store = make_store();
1518 let work_dir = tempfile::tempdir().unwrap();
1519 let work_dir_str = work_dir.path().to_str().unwrap();
1520 let outside = tempfile::tempdir().unwrap();
1521 #[cfg(unix)]
1522 let linked = std::os::unix::fs::symlink(outside.path(), work_dir.path().join(".claude")).is_ok();
1523 #[cfg(windows)]
1524 let linked = std::os::windows::fs::symlink_dir(outside.path(), work_dir.path().join(".claude")).is_ok();
1525 if !linked {
1526 return; }
1528
1529 let mut agent = make_agent("agent-1", work_dir_str);
1530 mstore.agent_def_insert(&mut agent).unwrap();
1531 mstore.skill_upsert_unique(&mstore, "agent-1", &make_skill("agent-skill"), true).unwrap();
1532 write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1533
1534 let leaked: Vec<_> = walkdir_files(outside.path());
1535 assert!(leaked.is_empty(), "nothing may be written outside the workdir, found {leaked:?}");
1536 }
1537
1538 fn walkdir_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
1539 let mut out = Vec::new();
1540 for entry in std::fs::read_dir(dir).unwrap().flatten() {
1541 let p = entry.path();
1542 if p.is_dir() {
1543 out.extend(walkdir_files(&p));
1544 } else {
1545 out.push(p);
1546 }
1547 }
1548 out
1549 }
1550
1551 #[test]
1556 fn a_leftover_bundle_mcp_ref_neither_launches_nor_drops_the_agents_legacy_mcp_blob() {
1557 let mstore = make_store();
1558 let id_store = make_store();
1559 let work_dir = tempfile::tempdir().unwrap();
1560 let work_dir_str = work_dir.path().to_str().unwrap();
1561
1562 let mut agent = make_agent("agent-1", work_dir_str);
1563 agent.memory_id = "bundle-1".to_string();
1564 mstore.agent_def_insert(&mut agent).unwrap();
1565
1566 mstore
1567 .bundle_upsert(&crate::backend::storage::bundles::Bundle {
1568 id: "bundle-1".to_string(),
1569 name: "Bundle 1".to_string(),
1570 description: String::new(),
1571 is_blank: false,
1572 is_global: false,
1573 provider: "claude".to_string(),
1574 model: "anthropic".to_string(),
1575 instructions: String::new(),
1576 instructions_by_provider: "{}".to_string(),
1577 context_files: "[]".to_string(),
1578 mcp_servers: "[]".to_string(),
1579 skills: "[]".to_string(),
1580 sort_order: 0,
1581 created_at: 1_700_000_000_000,
1582 updated_at: 1_700_000_000_000,
1583 is_system: false,
1584 })
1585 .unwrap();
1586 leftover_bundle_mcp_ref(&mstore, "bundle-1", "bundle-private-server", "bundle-server", "bundle-tool");
1587 mstore
1590 .agent_content_set(&crate::backend::storage::content::AgentContent {
1591 agent_id: "agent-1".to_string(),
1592 content_type: "mcp".to_string(),
1593 content: r#"{"mcpServers":{"legacy-server":{"command":"legacy-tool"}}}"#.to_string(),
1594 updated_at: 1_700_000_000_000,
1595 })
1596 .unwrap();
1597
1598 write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1599
1600 let mcp_json_path = work_dir.path().join(".mcp.json");
1601 let mcp_json = std::fs::read_to_string(&mcp_json_path).unwrap();
1602 let parsed: serde_json::Value = serde_json::from_str(&mcp_json).unwrap();
1603 assert!(
1604 parsed["mcpServers"].get("legacy-server").is_some(),
1605 "a leftover bundle ref must not drop the agent's legacy .mcp.json blob: {mcp_json}"
1606 );
1607 assert!(
1608 parsed["mcpServers"].get("bundle-server").is_none(),
1609 "a bundle's server must not launch: {mcp_json}"
1610 );
1611 }
1612
1613 fn leftover_bundle_mcp_ref(store: &Store, bundle_id: &str, id: &str, name: &str, command: &str) {
1615 store
1616 .mcp_server_insert_raw(&crate::backend::storage::McpServer {
1617 id: id.to_string(),
1618 name: name.to_string(),
1619 transport: "stdio".to_string(),
1620 config: format!(r#"{{"command":"{command}"}}"#),
1621 is_global: false,
1622 created_at: 1_700_000_000_000,
1623 updated_at: 1_700_000_000_000,
1624 })
1625 .unwrap();
1626 store
1627 .conn()
1628 .lock()
1629 .unwrap()
1630 .execute(
1631 "INSERT INTO db_bundle_mcp_ref (bundle_id, mcp_id) VALUES (?1, ?2)",
1632 rusqlite::params![bundle_id, id],
1633 )
1634 .unwrap();
1635 }
1636
1637 fn make_bundle(id: &str, name: &str, instructions: &str, is_global: bool) -> crate::backend::storage::bundles::Bundle {
1638 crate::backend::storage::bundles::Bundle {
1639 id: id.to_string(),
1640 name: name.to_string(),
1641 description: String::new(),
1642 is_blank: false,
1643 is_global,
1644 provider: String::new(),
1645 model: String::new(),
1646 instructions: instructions.to_string(),
1647 instructions_by_provider: "{}".to_string(),
1648 context_files: "[]".to_string(),
1649 mcp_servers: "[]".to_string(),
1650 skills: "[]".to_string(),
1651 sort_order: 0,
1652 created_at: 1_700_000_000_000,
1653 updated_at: 1_700_000_000_000,
1654 is_system: false,
1655 }
1656 }
1657
1658 #[test]
1664 fn picked_bundles_reach_the_startup_file_but_not_the_mcp_config() {
1665 let mstore = make_store();
1666 let id_store = make_store();
1667 let work_dir = tempfile::tempdir().unwrap();
1668 let work_dir_str = work_dir.path().to_str().unwrap();
1669
1670 let mut agent = make_agent("agent-1", work_dir_str);
1671 mstore.agent_def_insert(&mut agent).unwrap();
1672 id_store.bundle_upsert(&make_bundle("global-1", "Rules", "global rule", true)).unwrap();
1673 id_store.bundle_upsert(&make_bundle("b1", "Reviewer", "review carefully", false)).unwrap();
1674 id_store.bundle_upsert(&make_bundle("b2", "Writer", "write plainly", false)).unwrap();
1675 leftover_bundle_mcp_ref(&mstore, "b2", "b2-server", "writer-tools", "writer-tool");
1676 mstore
1677 .agent_bundles_set("agent-1", &["b2".to_string(), "gone".to_string(), "b1".to_string()])
1678 .unwrap();
1679
1680 write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1681
1682 let claude_md = std::fs::read_to_string(work_dir.path().join("CLAUDE.md")).unwrap();
1683 let global = claude_md.find("# [Workspace] Rules").expect("Global Memory section");
1684 let writer = claude_md.find("# [Bundle] Writer\n\nwrite plainly").expect("first pick's section");
1685 let reviewer = claude_md.find("# [Bundle] Reviewer\n\nreview carefully").expect("second pick's section");
1686 assert!(global < writer && writer < reviewer, "Global Memory, then the picks in order: {claude_md}");
1687
1688 let mcp_json = std::fs::read_to_string(work_dir.path().join(".mcp.json")).unwrap_or_default();
1689 assert!(!mcp_json.contains("writer-tools"), "a picked bundle's server must not launch: {mcp_json}");
1690 }
1691}
1692
1693#[cfg(test)]
1694mod record_agent_open_launch_tests {
1695 use super::*;
1696 use crate::backend::storage::agents::test_agent_def;
1697
1698 #[test]
1705 fn an_open_without_a_confirmed_local_row_is_refused() {
1706 let store = Store::open_in_memory().unwrap();
1707 let err = require_local_agent_row(&store, "agent-nowhere").unwrap_err();
1708 assert!(err.starts_with("AGENT_ROW_UNAVAILABLE"), "{err}");
1709 let mut def = test_agent_def("agent-here", "Here", "claude", "agent", 1, "");
1710 store.agent_def_insert(&mut def).unwrap();
1711 assert!(require_local_agent_row(&store, "agent-here").is_ok());
1712 }
1713
1714 #[test]
1718 fn an_open_whose_agent_was_deleted_mid_open_is_refused() {
1719 let store = Store::open_in_memory().unwrap();
1720 let mut def = test_agent_def("agent-gone-mid", "GoneMid", "claude", "agent", 1, "");
1721 store.agent_def_insert(&mut def).unwrap();
1722 assert!(require_local_agent_row(&store, "agent-gone-mid").is_ok());
1723 assert!(store.agent_def_delete("agent-gone-mid").unwrap());
1724 let err =
1725 record_agent_open_launch(&store, "agent-gone-mid", &uuid::Uuid::new_v4().to_string())
1726 .unwrap_err();
1727 assert!(err.starts_with("AGENT_ROW_UNAVAILABLE"), "{err}");
1728 }
1729
1730 #[test]
1731 fn an_agent_open_launch_is_recorded_stamped_and_resolves() {
1732 let store = Store::open_in_memory().unwrap();
1733 let mut def = test_agent_def("agent-open-x", "OpenX", "claude", "agent", 1, "");
1734 store.agent_def_insert(&mut def).unwrap();
1735 let block_id = uuid::Uuid::new_v4().to_string();
1737 let mut block = Block {
1738 oid: block_id.clone(),
1739 parentoref: String::new(),
1740 version: 0,
1741 runtimeopts: None,
1742 stickers: None,
1743 meta: {
1744 let mut m = obj::MetaMapType::new();
1745 m.insert("agentId".to_string(), json!("agent-open-x"));
1746 m
1747 },
1748 subblockids: None,
1749 };
1750 store.insert(&mut block).unwrap();
1751
1752 record_agent_open_launch(&store, "agent-open-x", &block_id).unwrap();
1753
1754 let stamped: Block = store.must_get(&block_id).unwrap();
1755 assert_eq!(
1756 stamped.meta.get("agentInstanceId"),
1757 Some(&json!("agent-open-x"))
1758 );
1759 let row = store.instance_get("agent-open-x").unwrap().unwrap();
1760 assert_eq!(row.block_id, block_id);
1761 assert_eq!(row.status, "running");
1762 let resolved = store
1763 .instance_get_active_for_block(&block_id)
1764 .unwrap()
1765 .map(|r| r.id);
1766 assert_eq!(resolved.as_deref(), Some("agent-open-x"));
1767 }
1768
1769 fn def(id: &str, name: &str, is_seeded: i64) -> AgentDefinition {
1770 let mut d = test_agent_def(id, name, "claude", "agent", 1, "");
1771 d.is_seeded = is_seeded;
1772 d
1773 }
1774
1775 #[test]
1780 fn agent_open_selects_my_agents_and_refuses_templates() {
1781 let agents = vec![
1782 def("claude", "Claude", 1),
1783 def("agent-mine", "Claude", 0),
1784 def("codex", "Codex", 1),
1785 def("agent-other", "agent-mine", 0),
1786 ];
1787 let pick = |wanted| select_openable_agent(&agents, wanted).map(|a| a.id.as_str());
1788
1789 assert_eq!(pick("agent-mine"), Ok("agent-mine"), "an id wins over a name");
1790 assert_eq!(pick("claude"), Ok("agent-mine"), "not the template's id");
1791 assert_eq!(pick("CLAUDE"), Ok("agent-mine"));
1792 for wanted in ["codex", "Codex"] {
1793 let err = pick(wanted).unwrap_err();
1794 assert!(err.starts_with("TEMPLATE_NOT_OPENABLE"), "{wanted}: {err}");
1795 }
1796 let err = pick("nobody").unwrap_err();
1797 assert!(err.starts_with("AGENT_NOT_FOUND"), "{err}");
1798 }
1799}
1800
1801#[cfg(test)]
1802mod single_live_instance_tests {
1803 use super::*;
1804 use crate::backend::storage::agents::test_agent_def;
1805
1806 fn snapshot(root: &std::path::Path) -> Vec<(std::path::PathBuf, Vec<u8>)> {
1809 let mut out = Vec::new();
1810 let mut stack = vec![root.to_path_buf()];
1811 while let Some(dir) = stack.pop() {
1812 for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
1813 let path = entry.path();
1814 if path.file_name().is_some_and(|n| n == "leases") {
1815 continue;
1816 }
1817 if path.is_dir() {
1818 stack.push(path);
1819 } else {
1820 out.push((path.clone(), std::fs::read(&path).unwrap_or_default()));
1821 }
1822 }
1823 }
1824 out.sort();
1825 out
1826 }
1827
1828 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1832 async fn opening_an_agent_live_in_another_instance_writes_nothing() {
1833 let state = crate::server::tests::test_state();
1834 let tmp = tempfile::tempdir().unwrap();
1835 let registry = Arc::new(crate::registry::Registry::open(tmp.path().join("registry")).unwrap());
1836 state.mstore.set_registry(Arc::clone(®istry));
1837 let uid = uuid::Uuid::new_v4().to_string();
1838 let mut def = test_agent_def(&uid, "Agent3", "claude", "host", 1, "");
1839 state.mstore.agent_def_insert(&mut def).unwrap();
1840
1841 let lease_store =
1842 crate::backend::agent_admission::lease_store_for(Some(Arc::clone(®istry))).unwrap();
1843 let _other = crate::backend::agent_admission::acquire(
1844 &lease_store,
1845 &uid,
1846 "Agent3",
1847 &Arc::from("another-srv-boot"),
1848 "other-block",
1849 None,
1850 |_| {},
1851 )
1852 .unwrap();
1853
1854 let blocks_before = state.mstore.get_all::<Block>().unwrap().len();
1855 let registry_before = snapshot(registry.root());
1856
1857 let err = open_agent_impl(
1858 &state,
1859 CommandAgentOpenData {
1860 agent_id: uid.clone(),
1861 tab_id: None,
1862 split_direction: None,
1863 split_reference_block_id: None,
1864 focus: None,
1865 },
1866 )
1867 .await
1868 .err()
1869 .expect("the open must be refused");
1870
1871 assert!(err.starts_with("AGENT_LIVE_ELSEWHERE: Agent3 is already running"), "{err}");
1872 assert_eq!(state.mstore.get_all::<Block>().unwrap().len(), blocks_before, "no block created");
1873 assert_eq!(snapshot(registry.root()), registry_before, "shared registry untouched");
1874 }
1875}