@team-agent/installer 0.5.68 → 0.5.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/diagnose.rs +74 -0
  4. package/crates/team-agent/src/cli/emit.rs +5 -4
  5. package/crates/team-agent/src/cli/leader.rs +1 -0
  6. package/crates/team-agent/src/cli/spec.rs +1 -0
  7. package/crates/team-agent/src/compiler.rs +54 -7
  8. package/crates/team-agent/src/coordinator/runtime_detectors.rs +1 -0
  9. package/crates/team-agent/src/layout/worker_window_helpers.rs +1 -0
  10. package/crates/team-agent/src/leader/start.rs +53 -12
  11. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +67 -15
  12. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +18 -0
  13. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +1 -0
  14. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +260 -44
  15. package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +120 -0
  16. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +5 -1
  17. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +10 -59
  18. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +838 -0
  19. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +9 -3
  20. package/crates/team-agent/src/lifecycle/launch/spawn.rs +60 -28
  21. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +1 -0
  22. package/crates/team-agent/src/lifecycle/launch.rs +10 -2
  23. package/crates/team-agent/src/lifecycle/profile_launch.rs +3 -2
  24. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -0
  25. package/crates/team-agent/src/lifecycle/restart/agent.rs +90 -160
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +136 -28
  27. package/crates/team-agent/src/lifecycle/restart/remove.rs +37 -1
  28. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +24 -3
  29. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +195 -18
  30. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +30 -12
  31. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_g0.stdout.txt +222 -0
  32. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +5 -2
  33. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +35 -48
  34. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  35. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +97 -0
  36. package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +145 -0
  37. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +243 -0
  38. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +856 -0
  39. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +387 -0
  40. package/crates/team-agent/src/lifecycle/tests/pi_lifecycle_doctor_red.rs +98 -0
  41. package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +216 -0
  42. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +1 -0
  43. package/crates/team-agent/src/lifecycle/tests.rs +7 -0
  44. package/crates/team-agent/src/lifecycle/worker_command_context.rs +61 -4
  45. package/crates/team-agent/src/messaging/delivery.rs +36 -12
  46. package/crates/team-agent/src/model/enums.rs +3 -1
  47. package/crates/team-agent/src/model/permissions.rs +1 -1
  48. package/crates/team-agent/src/provider/adapter.rs +29 -3
  49. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +5 -2
  50. package/crates/team-agent/src/provider/adapters/mod.rs +1 -0
  51. package/crates/team-agent/src/provider/adapters/pi.rs +146 -0
  52. package/crates/team-agent/src/provider/bypass_flags.rs +1 -0
  53. package/crates/team-agent/src/provider/classify.rs +2 -2
  54. package/crates/team-agent/src/provider/faults.rs +1 -1
  55. package/crates/team-agent/src/provider/session/context_fork.rs +2 -1
  56. package/crates/team-agent/src/provider/session_scan/common.rs +1 -0
  57. package/crates/team-agent/src/provider/session_scan/pi.rs +172 -0
  58. package/crates/team-agent/src/provider/session_scan.rs +4 -0
  59. package/crates/team-agent/src/provider/tests/adapter.rs +80 -0
  60. package/crates/team-agent/src/provider/wire.rs +7 -1
  61. package/crates/team-agent/src/tmux_backend.rs +52 -0
  62. package/package.json +4 -4
  63. package/skills/team-agent/SKILL.md +9 -5
@@ -37,6 +37,7 @@
37
37
  //! maturity: wired
38
38
  //! ---
39
39
  use super::*;
40
+ use crate::transport::Transport;
40
41
 
41
42
  pub(super) struct SpawnedAgentWindow {
42
43
  pub spawn: crate::transport::SpawnResult,
@@ -340,11 +341,15 @@ pub(super) fn spawn_agent_window(
340
341
  agent_id.as_str(),
341
342
  team_id.as_deref().unwrap_or(""),
342
343
  );
343
- let mcp_config_path = crate::lifecycle::launch::write_worker_mcp_config(
344
- workspace,
345
- agent_id.as_str(),
346
- &mcp_config,
347
- )?;
344
+ let mcp_config_path = if provider == Provider::Pi {
345
+ None
346
+ } else {
347
+ Some(crate::lifecycle::launch::write_worker_mcp_config(
348
+ workspace,
349
+ agent_id.as_str(),
350
+ &mcp_config,
351
+ )?)
352
+ };
348
353
  let profile_launch =
349
354
  crate::lifecycle::profile_launch::prepare_provider_profile_launch_from_json(
350
355
  workspace,
@@ -374,20 +379,65 @@ pub(super) fn spawn_agent_window(
374
379
  agent_id_hint: Some(agent_id.as_str()),
375
380
  effort: restart_effort,
376
381
  };
377
- let mut plan = match resume_session_id {
378
- Some(session_id) => adapter
379
- .build_resume_command_plan(Some(session_id), context)
380
- .map_err(|e| LifecycleError::Provider(e.to_string()))?,
381
- None => adapter
382
- .build_command_plan(context)
383
- .map_err(|e| LifecycleError::Provider(e.to_string()))?,
382
+ let pi_spawn_cwd = (provider == Provider::Pi)
383
+ .then(|| {
384
+ agent
385
+ .get("spawn_cwd")
386
+ .and_then(serde_json::Value::as_str)
387
+ .filter(|value| !value.is_empty())
388
+ .map(PathBuf::from)
389
+ })
390
+ .flatten();
391
+ let spawn_cwd = spawn_cwd_override
392
+ .or(pi_spawn_cwd.as_deref())
393
+ .unwrap_or(workspace);
394
+ let mut plan = if provider == Provider::Pi {
395
+ let request = crate::lifecycle::launch::pi_mcp::PiMaterializeRequest {
396
+ workspace,
397
+ team_id: team_id.as_deref().unwrap_or(""),
398
+ agent_id: agent_id.as_str(),
399
+ model: command_model,
400
+ effort: restart_effort,
401
+ system_prompt: &system_prompt,
402
+ tool_categories: &resolved_tool_refs,
403
+ team_mcp_tools: &["send_message", "report_result"],
404
+ mcp_config: &mcp_config,
405
+ };
406
+ match resume_session_id {
407
+ Some(session_id) => {
408
+ let rollout_path = agent_rollout_path(agent).ok_or_else(|| {
409
+ LifecycleError::RequirementUnmet(
410
+ "Pi resume requires the persisted exact session path".to_string(),
411
+ )
412
+ })?;
413
+ crate::lifecycle::launch::pi_mcp::materialize_pi_resume_plan(
414
+ request,
415
+ session_id,
416
+ rollout_path.as_path(),
417
+ spawn_cwd,
418
+ )
419
+ }
420
+ None => crate::lifecycle::launch::pi_mcp::materialize_pi_plan(request),
421
+ }
422
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?
423
+ } else {
424
+ match resume_session_id {
425
+ Some(session_id) => adapter
426
+ .build_resume_command_plan(Some(session_id), context)
427
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?,
428
+ None => adapter
429
+ .build_command_plan(context)
430
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?,
431
+ }
384
432
  };
385
433
  if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
386
- crate::lifecycle::launch::point_native_mcp_config_at_file(
387
- &mut plan.argv,
388
- provider,
389
- &mcp_config_path,
390
- );
434
+ if let Some(mcp_config_path) = mcp_config_path.as_ref() {
435
+ crate::lifecycle::launch::point_native_mcp_config_at_file(
436
+ &mut plan.argv,
437
+ provider,
438
+ mcp_config_path,
439
+ );
440
+ }
391
441
  }
392
442
  crate::lifecycle::launch::fill_spawn_placeholders_full(
393
443
  &mut plan.argv,
@@ -427,8 +477,20 @@ pub(super) fn spawn_agent_window(
427
477
  &system_prompt,
428
478
  )?;
429
479
  crate::lifecycle::launch::apply_cursor_mcp_overlay(workspace, &mcp_config)?;
430
- crate::lifecycle::launch::enable_cursor_workspace_mcp(workspace)?;
431
- crate::lifecycle::launch::apply_cursor_workspace_physical_path(&mut plan.argv, workspace);
480
+ let project = if crate::lifecycle::launch::cursor_mcp_isolation_enabled() {
481
+ Some(crate::lifecycle::launch::cursor_mcp_project_dir(
482
+ workspace,
483
+ agent_id.as_str(),
484
+ )?)
485
+ } else {
486
+ None
487
+ };
488
+ crate::lifecycle::launch::enable_cursor_workspace_mcp(workspace, project.as_deref())?;
489
+ crate::lifecycle::launch::apply_cursor_spawn_workspace_pointers(
490
+ &mut plan.argv,
491
+ workspace,
492
+ agent_id.as_str(),
493
+ )?;
432
494
  crate::lifecycle::launch::apply_cursor_subscription_proxy_env(&mut env);
433
495
  }
434
496
  if provider == crate::provider::Provider::Grok {
@@ -446,8 +508,9 @@ pub(super) fn spawn_agent_window(
446
508
  //
447
509
  // NOTE: Step 4 will thread the YAML spec down to here so we can honour
448
510
  // a per-agent YAML `spawn_cwd` field if one is set. Until then, override
449
- // > workspace; state-based override is silently dropped.
450
- let spawn_cwd = spawn_cwd_override.unwrap_or(workspace);
511
+ // > workspace; state-based override is silently dropped for existing
512
+ // providers. Pi is the narrow exception because exact header/path resume
513
+ // must preserve the captured cwd or refuse.
451
514
  // 0.4.x provider effort MVP step 9: scrub CLAUDE_EFFORT for Claude
452
515
  // worker spawn so a parent shell env cannot silently override the
453
516
  // framework's effort decision.
@@ -912,6 +975,14 @@ pub(super) fn lifecycle_worker_tmux_backend_selection_for_state(
912
975
  )));
913
976
  }
914
977
  }
978
+ if let Some((endpoint, source)) = crate::tmux_backend::owning_tmux_endpoint_from_state(state) {
979
+ let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
980
+ return Ok(crate::tmux_backend::RuntimeTmuxBackendSelection {
981
+ tmux_endpoint_used: backend.tmux_endpoint(),
982
+ backend,
983
+ tmux_endpoint_source: source,
984
+ });
985
+ }
915
986
  Ok(
916
987
  crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
917
988
  run_workspace,
@@ -928,6 +999,9 @@ pub(super) fn lifecycle_worker_tmux_backend_for_state(
928
999
  run_workspace: &Path,
929
1000
  state: &serde_json::Value,
930
1001
  ) -> crate::tmux_backend::TmuxBackend {
1002
+ if let Some((endpoint, _source)) = crate::tmux_backend::owning_tmux_endpoint_from_state(state) {
1003
+ return crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
1004
+ }
931
1005
  crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(run_workspace, Some(state))
932
1006
  .backend
933
1007
  }
@@ -1091,12 +1165,34 @@ fn json_team_identity_matches(existing: &serde_json::Value, compact: &serde_json
1091
1165
  /// returns: 非空的 session_name,缺失时退到默认名
1092
1166
  /// ---
1093
1167
  pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
1168
+ worker_session_name_from_state(state)
1169
+ .map(SessionName::new)
1170
+ .unwrap_or_else(|| SessionName::new("team-agent"))
1171
+ }
1172
+
1173
+ fn worker_session_name_from_state(state: &serde_json::Value) -> Option<&str> {
1094
1174
  state
1095
1175
  .get("session_name")
1096
1176
  .and_then(|v| v.as_str())
1097
1177
  .filter(|s| !s.is_empty())
1098
- .map(SessionName::new)
1099
- .unwrap_or_else(|| SessionName::new("team-agent"))
1178
+ .or_else(|| {
1179
+ let team_key = state
1180
+ .get("active_team_key")
1181
+ .and_then(serde_json::Value::as_str)
1182
+ .filter(|team_key| !team_key.is_empty())?;
1183
+ state
1184
+ .get("teams")?
1185
+ .get(team_key)?
1186
+ .pointer("/leader_receiver/session_name")?
1187
+ .as_str()
1188
+ .filter(|session| !session.is_empty())
1189
+ })
1190
+ .or_else(|| {
1191
+ state
1192
+ .pointer("/leader_receiver/session_name")
1193
+ .and_then(serde_json::Value::as_str)
1194
+ .filter(|session| !session.is_empty())
1195
+ })
1100
1196
  }
1101
1197
 
1102
1198
  /// ---
@@ -1104,11 +1200,7 @@ pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
1104
1200
  /// returns: 记了则 true
1105
1201
  /// ---
1106
1202
  pub(super) fn session_name_present(state: &serde_json::Value) -> bool {
1107
- state
1108
- .get("session_name")
1109
- .and_then(|v| v.as_str())
1110
- .map(|s| !s.is_empty())
1111
- .unwrap_or(false)
1203
+ worker_session_name_from_state(state).is_some()
1112
1204
  }
1113
1205
 
1114
1206
  /// ---
@@ -1349,6 +1441,22 @@ pub(super) fn resume_backing_probe_for_agent(
1349
1441
  crate::provider::session_scan::cursor::cursor_session_archive_present(dir)
1350
1442
  })
1351
1443
  }
1444
+ Provider::Pi => {
1445
+ let spawn_cwd = agent
1446
+ .get("spawn_cwd")
1447
+ .and_then(serde_json::Value::as_str)
1448
+ .filter(|value| !value.is_empty())
1449
+ .map(PathBuf::from)
1450
+ .unwrap_or_else(|| workspace.to_path_buf());
1451
+ rollout_path.is_some_and(|path| {
1452
+ crate::provider::session_scan::pi::validate_exact_backing(
1453
+ path.as_path(),
1454
+ session_id,
1455
+ &spawn_cwd,
1456
+ )
1457
+ .is_ok()
1458
+ })
1459
+ }
1352
1460
  Provider::GeminiCli | Provider::Fake => false,
1353
1461
  };
1354
1462
 
@@ -539,6 +539,22 @@ fn remove_agent_at_paths(
539
539
  }
540
540
  });
541
541
  }
542
+ let pi_cleanup_paths = preflight
543
+ .seat
544
+ .state
545
+ .get("agents")
546
+ .and_then(|agents| agents.get(agent_id.as_str()))
547
+ .and_then(|agent| agent.get("provider"))
548
+ .and_then(serde_json::Value::as_str)
549
+ .and_then(crate::provider::wire::parse_provider)
550
+ .filter(|provider| *provider == Provider::Pi)
551
+ .map(|_| {
552
+ crate::lifecycle::launch::pi_mcp::pi_seat_paths(
553
+ workspace,
554
+ &preflight.seat.team_key,
555
+ agent_id.as_str(),
556
+ )
557
+ });
542
558
  let paths = LifecyclePathRefs {
543
559
  run_workspace: workspace,
544
560
  spec_workspace,
@@ -574,7 +590,27 @@ fn remove_agent_at_paths(
574
590
  Ok(success)
575
591
  });
576
592
  match result {
577
- Ok(success) => {
593
+ Ok(mut success) => {
594
+ if let Some(pi_paths) = pi_cleanup_paths.as_ref() {
595
+ let cleanup = crate::lifecycle::launch::pi_mcp::pi_cleanup_plan(
596
+ crate::lifecycle::launch::pi_mcp::PiCleanupAction::Remove,
597
+ pi_paths,
598
+ );
599
+ for path in cleanup.delete_paths {
600
+ match std::fs::remove_file(&path) {
601
+ Ok(()) => success
602
+ .cleared_locations
603
+ .push(serde_json::json!(path.to_string_lossy().to_string())),
604
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
605
+ Err(error) => {
606
+ return Err(LifecycleError::StatePersist(format!(
607
+ "remove-agent completed logical removal for {agent_id}, but exact Pi wrapper cleanup failed at {}: {error}",
608
+ path.display()
609
+ )));
610
+ }
611
+ }
612
+ }
613
+ }
578
614
  // Foundation-0 F0-2: the historical dual-write to the legacy
579
615
  // per-session snapshot has been retired
580
616
  // (`.team/artifacts/foundation-0-slice-design.md` §§4-5).
@@ -133,19 +133,40 @@ fn launch_restart_start_add_and_fork_all_delegate_to_the_profile_launch_resolver
133
133
  ("launch quick-start", &launch, "spawn_agents"),
134
134
  ("add-agent", &launch, "add_agent_with_transport"),
135
135
  ("fork-agent", &launch, "fork_agent_with_transport"),
136
- ("restart", &restart_common, "restart_with_transport"),
137
- ("start-agent", &restart_agent, "start_agent_at_paths"),
136
+ (
137
+ "restart/start shared spawn",
138
+ &restart_common,
139
+ "pub(super) fn spawn_agent_window(",
140
+ ),
138
141
  ];
139
142
  for (surface, source, entry) in surfaces {
140
143
  let section = source
141
144
  .find(entry)
142
145
  .map(|idx| &source[idx..])
143
- .unwrap_or(source.as_str());
146
+ .unwrap_or_else(|| panic!("{surface} entry missing: {entry}"));
144
147
  assert!(
145
148
  section.contains("prepare_provider_profile_launch"),
146
149
  "{surface} must call the single profile resolver before building/spawning provider argv; entry={entry}"
147
150
  );
148
151
  }
152
+
153
+ let start_agent = restart_agent
154
+ .find("pub(crate) fn start_agent_at_paths(")
155
+ .map(|idx| &restart_agent[idx..])
156
+ .unwrap_or_else(|| panic!("start-agent entry missing: start_agent_at_paths"));
157
+ assert!(
158
+ start_agent.contains("spawn_agent_window("),
159
+ "start-agent must delegate provider argv construction to the shared restart/start spawn path"
160
+ );
161
+
162
+ let spawn_agent_window = restart_common
163
+ .find("pub(super) fn spawn_agent_window(")
164
+ .map(|idx| &restart_common[idx..])
165
+ .unwrap_or_else(|| panic!("restart/start shared spawn entry missing"));
166
+ assert!(
167
+ spawn_agent_window.contains("prepare_provider_profile_launch_from_json("),
168
+ "shared restart/start spawn must call the JSON profile launch resolver"
169
+ );
149
170
  }
150
171
 
151
172
  #[test]
@@ -1,6 +1,6 @@
1
1
  //! purpose: cursor 席位必须把身份写进它实际会读的 mcp.json env
2
- //! contract: overlay 后 `<workspace>/.cursor/mcp.json` 含 team_orchestrator.env.TEAM_AGENT_ID
3
- //! (不是 `.team/runtime/mcp/*.json`,也不是靠 pane env 继承)
2
+ //! contract: overlay 后 provider-config/<id>/cursor/.cursor/mcp.json 含 team_orchestrator.env.TEAM_AGENT_ID
3
+ //! (不是改 HOME,也不是靠 pane env 继承)
4
4
  //! boundary: 只覆盖 cursor launch 产物;不改 claude/codex/copilot/grok 路径
5
5
  //!
6
6
  //! 生产侧判据,未经血统审计。
@@ -16,21 +16,34 @@ use std::path::{Path, PathBuf};
16
16
  use std::sync::atomic::{AtomicU64, Ordering};
17
17
 
18
18
  use serial_test::serial;
19
+ use sha2::{Digest, Sha256};
19
20
  use team_agent::lifecycle::quick_start_with_transport_in_workspace;
20
21
  use team_agent::lifecycle::{
21
- apply_cursor_mcp_overlay, apply_cursor_subscription_proxy_env,
22
- apply_cursor_workspace_physical_path, cursor_mcp_enable_argv, physical_workspace_path,
23
- LifecycleError,
22
+ apply_cursor_mcp_overlay, apply_cursor_spawn_workspace_pointers,
23
+ apply_cursor_subscription_proxy_env, apply_cursor_workspace_physical_path,
24
+ cursor_mcp_enable_argv, cursor_mcp_json_path, cursor_mcp_project_dir, physical_workspace_path,
25
+ refuse_second_cursor_occupant, LifecycleError,
24
26
  };
25
27
  use team_agent::provider::McpConfig;
26
28
  use team_agent::transport::test_support::OfflineTransport;
27
29
 
28
30
  #[test]
31
+ #[serial(cursor_mcp_isolation)]
29
32
  fn cursor_overlay_writes_identity_into_mcp_json_env() {
30
33
  let ws = tmp_dir("cursor-mcp-id");
31
34
  apply_cursor_mcp_overlay(&ws, &sample_mcp_config("seat-a", &ws.to_string_lossy()))
32
35
  .expect("overlay write");
33
- let text = std::fs::read_to_string(ws.join(".cursor/mcp.json")).unwrap();
36
+ let path = cursor_mcp_json_path(&ws, "seat-a").unwrap();
37
+ let rendered = path.to_string_lossy();
38
+ assert!(
39
+ rendered.contains("provider-config") && rendered.contains("/cursor/"),
40
+ "identity must land under provider-config/<id>/cursor, not a HOME fork: {rendered}"
41
+ );
42
+ assert!(
43
+ !rendered.contains("/.team/runtime/cursor-mcp/"),
44
+ "must not use the rejected COPILOT_HOME-style cursor-mcp tree: {rendered}"
45
+ );
46
+ let text = std::fs::read_to_string(&path).unwrap();
34
47
  assert!(
35
48
  text.contains("\"team_orchestrator\""),
36
49
  "cursor overlay must use the canonical server name"
@@ -58,6 +71,7 @@ fn cursor_overlay_writes_identity_into_mcp_json_env() {
58
71
  }
59
72
 
60
73
  #[test]
74
+ #[serial(cursor_mcp_isolation)]
61
75
  fn cursor_overlay_refuses_missing_identity() {
62
76
  let ws = tmp_dir("cursor-mcp-missing-id");
63
77
  let cfg = McpConfig {
@@ -82,6 +96,7 @@ fn cursor_overlay_refuses_missing_identity() {
82
96
  }
83
97
 
84
98
  #[test]
99
+ #[serial(cursor_mcp_isolation)]
85
100
  fn cursor_overlay_keeps_unrelated_servers_and_replaces_orchestrator() {
86
101
  let ws = tmp_dir("cursor-mcp-merge");
87
102
  let dir = ws.join(".cursor");
@@ -99,38 +114,63 @@ fn cursor_overlay_keeps_unrelated_servers_and_replaces_orchestrator() {
99
114
  .unwrap();
100
115
 
101
116
  apply_cursor_mcp_overlay(&ws, &sample_mcp_config("seat-b", "/ws-b")).expect("overlay merge");
102
- let text = std::fs::read_to_string(dir.join("mcp.json")).unwrap();
117
+ let project = std::fs::read_to_string(dir.join("mcp.json")).unwrap();
103
118
  assert!(
104
- text.contains("keep-me"),
105
- "unrelated project MCP servers must survive"
119
+ project.contains("keep-me"),
120
+ "unrelated project MCP servers must survive in workspace mcp.json"
106
121
  );
107
122
  assert!(
108
- !text.contains("/old/team-agent") && !text.contains("/stale"),
109
- "stale orchestrator/legacy names must be replaced"
123
+ !project.contains("/old/team-agent") && !project.contains("/stale"),
124
+ "stale orchestrator/legacy names must be scrubbed from the shared project file"
110
125
  );
126
+ let isolated = std::fs::read_to_string(cursor_mcp_json_path(&ws, "seat-b").unwrap()).unwrap();
111
127
  assert!(
112
- text.contains("seat-b") && text.contains("/ws-b"),
113
- "new identity must land"
128
+ isolated.contains("seat-b") && isolated.contains("/ws-b"),
129
+ "new identity must land in the per-seat mcp.json"
114
130
  );
115
131
  }
116
132
 
117
133
  #[test]
118
- fn cursor_second_seat_overwrites_json_identity_without_exclusive_gate() {
134
+ #[serial(cursor_mcp_isolation)]
135
+ fn cursor_second_seat_keeps_both_identities_when_isolated() {
119
136
  let ws = tmp_dir("cursor-mcp-two");
120
137
  apply_cursor_mcp_overlay(&ws, &sample_mcp_config("first", "/ws")).expect("first");
121
138
  apply_cursor_mcp_overlay(&ws, &sample_mcp_config("second", "/ws")).expect("second");
139
+ let first = std::fs::read_to_string(cursor_mcp_json_path(&ws, "first").unwrap()).unwrap();
140
+ let second = std::fs::read_to_string(cursor_mcp_json_path(&ws, "second").unwrap()).unwrap();
141
+ assert!(
142
+ first.contains("\"first\"") && !first.contains("\"second\""),
143
+ "seat first must keep its TEAM_AGENT_ID"
144
+ );
145
+ assert!(
146
+ second.contains("\"second\"") && !second.contains("\"first\""),
147
+ "seat second must keep its TEAM_AGENT_ID"
148
+ );
149
+ }
150
+
151
+ #[test]
152
+ #[serial(cursor_mcp_isolation)]
153
+ #[serial(env)]
154
+ fn cursor_shared_overlay_last_writer_is_the_destruction_tooth() {
155
+ let previous = std::env::var("TEAM_AGENT_CURSOR_MCP_ISOLATION").ok();
156
+ std::env::set_var("TEAM_AGENT_CURSOR_MCP_ISOLATION", "0");
157
+ let ws = tmp_dir("cursor-mcp-shared-red");
158
+ apply_cursor_mcp_overlay(&ws, &sample_mcp_config("first", "/ws")).expect("first");
159
+ apply_cursor_mcp_overlay(&ws, &sample_mcp_config("second", "/ws")).expect("second");
122
160
  let text = std::fs::read_to_string(ws.join(".cursor/mcp.json")).unwrap();
161
+ restore_isolation_env(previous);
123
162
  assert!(
124
163
  text.contains("second"),
125
- "last writer wins on a shared --workspace; no grok-style exclusive gate"
164
+ "destruction: shared mcp.json last writer is second"
126
165
  );
127
166
  assert!(
128
167
  !text.contains("\"first\""),
129
- "previous seat identity must not remain in the single json file"
168
+ "destruction: criterion 2 goes red — first identity is gone"
130
169
  );
131
170
  }
132
171
 
133
172
  #[test]
173
+ #[serial(cursor_mcp_isolation)]
134
174
  fn cursor_enable_argv_is_documented_subcommand_without_workspace_flag() {
135
175
  let argv = cursor_mcp_enable_argv();
136
176
  assert_eq!(
@@ -145,6 +185,72 @@ fn cursor_enable_argv_is_documented_subcommand_without_workspace_flag() {
145
185
  }
146
186
 
147
187
  #[test]
188
+ #[serial(cursor_mcp_isolation)]
189
+ fn cursor_enable_failure_preserves_cwd_config_identity_and_redacted_stderr() {
190
+ let ws = tmp_dir("cursor-enable-diagnostic");
191
+ apply_cursor_mcp_overlay(
192
+ &ws,
193
+ &sample_mcp_config("diagnostic-seat", &ws.to_string_lossy()),
194
+ )
195
+ .expect("write fake cursor overlay");
196
+ let cwd = physical_workspace_path(&cursor_mcp_project_dir(&ws, "diagnostic-seat").unwrap());
197
+ let config_path = cwd.join(".cursor/mcp.json");
198
+ let config_body = std::fs::read(&config_path).expect("read fake overlay identity");
199
+ let config_metadata = std::fs::metadata(&config_path).expect("fake overlay metadata");
200
+ let expected_hash = format!("{:x}", Sha256::digest(&config_body));
201
+ let benign = format!("benign first-enable diagnostic {}", "x".repeat(700));
202
+ let secret_value = "must-not-leak-secret-value";
203
+ let stderr =
204
+ format!("Authorization: Bearer sk-{secret_value} token={secret_value}\n{benign}\n");
205
+ let argv = cursor_mcp_enable_argv();
206
+ let error = LifecycleError::cursor_mcp_enable_failure(
207
+ &argv,
208
+ &cwd,
209
+ Some(23),
210
+ b"out",
211
+ stderr.as_bytes(),
212
+ None,
213
+ );
214
+ let LifecycleError::RequirementUnmet(report) = error else {
215
+ panic!("expected RequirementUnmet diagnostic")
216
+ };
217
+
218
+ assert!(report.contains("argv: agent mcp enable team_orchestrator"));
219
+ assert!(report.contains(&format!("cwd: {}", cwd.display())));
220
+ assert!(report.contains(&format!("config_path: {}", config_path.display())));
221
+ assert!(report.contains("config_exists: true"));
222
+ assert!(report.contains(&format!("config_size: {}", config_body.len())));
223
+ assert!(report.contains(&format!("config_sha256: {expected_hash}")));
224
+ assert!(report.contains("stdout_len: 3"));
225
+ assert!(report.contains(&format!("stderr_len: {}", stderr.len())));
226
+ #[cfg(unix)]
227
+ {
228
+ use std::os::unix::fs::PermissionsExt;
229
+ assert!(report.contains(&format!(
230
+ "config_mode: 0o{:o}",
231
+ config_metadata.permissions().mode()
232
+ )));
233
+ }
234
+ #[cfg(not(unix))]
235
+ assert!(report.contains("config_mode: unavailable_non_unix"));
236
+
237
+ let safe = report
238
+ .lines()
239
+ .find_map(|line| line.strip_prefix("stderr_first_safe: "))
240
+ .expect("bounded benign stderr must be retained");
241
+ assert!(safe.starts_with("benign first-enable diagnostic"));
242
+ assert_eq!(safe.len(), 512, "safe stderr must be capped at 512 bytes");
243
+ assert!(!report.contains("Authorization"));
244
+ assert!(!report.contains("Bearer"));
245
+ assert!(!report.contains(secret_value));
246
+ assert!(
247
+ !report.contains("TEAM_AGENT_AUTH_MODE"),
248
+ "diagnostic must not include overlay JSON contents"
249
+ );
250
+ }
251
+
252
+ #[test]
253
+ #[serial(cursor_mcp_isolation)]
148
254
  fn cursor_workspace_flag_is_rewritten_to_physical_path() {
149
255
  let ws = tmp_dir("cursor-phys");
150
256
  let mut argv = vec![
@@ -158,6 +264,29 @@ fn cursor_workspace_flag_is_rewritten_to_physical_path() {
158
264
  }
159
265
 
160
266
  #[test]
267
+ #[serial(cursor_mcp_isolation)]
268
+ fn cursor_spawn_pointers_use_per_seat_workspace_and_add_dir() {
269
+ let ws = tmp_dir("cursor-pointers");
270
+ apply_cursor_mcp_overlay(&ws, &sample_mcp_config("seat-p", &ws.to_string_lossy()))
271
+ .expect("overlay");
272
+ let mut argv = vec![
273
+ "agent".to_string(),
274
+ "--workspace".to_string(),
275
+ ws.to_string_lossy().into_owned(),
276
+ ];
277
+ apply_cursor_spawn_workspace_pointers(&mut argv, &ws, "seat-p").expect("pointers");
278
+ let project = physical_workspace_path(&cursor_mcp_project_dir(&ws, "seat-p").unwrap());
279
+ let team = physical_workspace_path(&ws);
280
+ assert_eq!(argv[2], project.to_string_lossy().as_ref());
281
+ assert!(
282
+ argv.windows(2)
283
+ .any(|pair| pair[0] == "--add-dir" && pair[1] == team.to_string_lossy().as_ref()),
284
+ "true workspace must be added with documented --add-dir: {argv:?}"
285
+ );
286
+ }
287
+
288
+ #[test]
289
+ #[serial(cursor_mcp_isolation)]
161
290
  #[serial(env)]
162
291
  fn cursor_subscription_proxy_copies_keys_without_requiring_profile() {
163
292
  let prev = std::env::var("HTTPS_PROXY").ok();
@@ -183,10 +312,11 @@ fn cursor_subscription_proxy_copies_keys_without_requiring_profile() {
183
312
  }
184
313
 
185
314
  #[test]
315
+ #[serial(cursor_mcp_isolation)]
186
316
  fn cursor_spawn_writes_identity_into_project_mcp_json() {
187
317
  let ws = tmp_dir("cursor-mcp-spawn");
188
318
  let team = write_cursor_team(&ws, "cursortm", "cursor_writer");
189
- let config_path = ws.join(".cursor").join("mcp.json");
319
+ let config_path = cursor_mcp_json_path(&ws, "cursor_writer").expect("iso path");
190
320
  assert!(
191
321
  !config_path.exists(),
192
322
  "precondition: project cursor mcp.json must be absent before spawn"
@@ -204,7 +334,7 @@ fn cursor_spawn_writes_identity_into_project_mcp_json() {
204
334
 
205
335
  let text = std::fs::read_to_string(&config_path).unwrap_or_else(|err| {
206
336
  panic!(
207
- "cursor spawn must materialize {}; cursor only reads this workspace file: {err}",
337
+ "cursor spawn must materialize per-seat {}; cursor reads --workspace/.cursor/mcp.json: {err}",
208
338
  config_path.display()
209
339
  )
210
340
  });
@@ -285,3 +415,50 @@ fn tmp_dir(tag: &str) -> PathBuf {
285
415
  std::fs::create_dir_all(&dir).unwrap();
286
416
  std::fs::canonicalize(dir).unwrap()
287
417
  }
418
+
419
+ fn restore_isolation_env(previous: Option<String>) {
420
+ match previous {
421
+ Some(value) => std::env::set_var("TEAM_AGENT_CURSOR_MCP_ISOLATION", value),
422
+ None => std::env::remove_var("TEAM_AGENT_CURSOR_MCP_ISOLATION"),
423
+ }
424
+ }
425
+
426
+ #[test]
427
+ #[serial(cursor_mcp_isolation)]
428
+ fn refuse_second_allows_when_isolation_on() {
429
+ let ws = tmp_dir("cursor-refuse-iso-on");
430
+ let spec = team_agent::model::yaml::loads(
431
+ "agents:\n - id: a\n provider: cursor_agent\n - id: b\n provider: cursor_agent\n",
432
+ )
433
+ .expect("spec");
434
+ refuse_second_cursor_occupant(&ws, "b", Some(&spec)).expect("isolation on allows second");
435
+ }
436
+
437
+ #[test]
438
+ #[serial(cursor_mcp_isolation)]
439
+ #[serial(env)]
440
+ fn refuse_second_still_blocks_when_isolation_disabled() {
441
+ let previous = std::env::var("TEAM_AGENT_CURSOR_MCP_ISOLATION").ok();
442
+ std::env::set_var("TEAM_AGENT_CURSOR_MCP_ISOLATION", "0");
443
+ let ws = tmp_dir("cursor-refuse-iso-off");
444
+ let spec = team_agent::model::yaml::loads(
445
+ "agents:\n - id: a\n provider: cursor_agent\n - id: b\n provider: cursor_agent\n",
446
+ )
447
+ .expect("spec");
448
+ let err = refuse_second_cursor_occupant(&ws, "b", Some(&spec));
449
+ restore_isolation_env(previous);
450
+ let err = err.expect_err("isolation off must fail-closed");
451
+ match err {
452
+ LifecycleError::RequirementUnmet(text) => {
453
+ assert!(
454
+ text.contains("cursor_agent seat already occupies this workspace"),
455
+ "fail-closed text must stay: {text}"
456
+ );
457
+ assert!(
458
+ text.contains("last-writer"),
459
+ "reason must still name last-writer: {text}"
460
+ );
461
+ }
462
+ other => panic!("expected RequirementUnmet, got {other:?}"),
463
+ }
464
+ }