@team-agent/installer 0.5.68 → 0.5.69

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 +62 -5
  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 +830 -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 +70 -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 +146 -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 +128 -0
  37. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +207 -0
  38. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +838 -0
  39. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +320 -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 +152 -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 +2 -4
@@ -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,75 @@ 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 model = command_model.ok_or_else(|| {
396
+ LifecycleError::RequirementUnmet(
397
+ "Pi restart requires an explicit qualified model".to_string(),
398
+ )
399
+ })?;
400
+ let effort = restart_effort.ok_or_else(|| {
401
+ LifecycleError::RequirementUnmet(
402
+ "Pi restart requires an explicit thinking effort".to_string(),
403
+ )
404
+ })?;
405
+ let request = crate::lifecycle::launch::pi_mcp::PiMaterializeRequest {
406
+ workspace,
407
+ team_id: team_id.as_deref().unwrap_or(""),
408
+ agent_id: agent_id.as_str(),
409
+ model,
410
+ effort,
411
+ system_prompt: &system_prompt,
412
+ tool_categories: &resolved_tool_refs,
413
+ team_mcp_tools: &["send_message", "report_result"],
414
+ mcp_config: &mcp_config,
415
+ };
416
+ match resume_session_id {
417
+ Some(session_id) => {
418
+ let rollout_path = agent_rollout_path(agent).ok_or_else(|| {
419
+ LifecycleError::RequirementUnmet(
420
+ "Pi resume requires the persisted exact session path".to_string(),
421
+ )
422
+ })?;
423
+ crate::lifecycle::launch::pi_mcp::materialize_pi_resume_plan(
424
+ request,
425
+ session_id,
426
+ rollout_path.as_path(),
427
+ spawn_cwd,
428
+ )
429
+ }
430
+ None => crate::lifecycle::launch::pi_mcp::materialize_pi_plan(request),
431
+ }
432
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?
433
+ } else {
434
+ match resume_session_id {
435
+ Some(session_id) => adapter
436
+ .build_resume_command_plan(Some(session_id), context)
437
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?,
438
+ None => adapter
439
+ .build_command_plan(context)
440
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?,
441
+ }
384
442
  };
385
443
  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
- );
444
+ if let Some(mcp_config_path) = mcp_config_path.as_ref() {
445
+ crate::lifecycle::launch::point_native_mcp_config_at_file(
446
+ &mut plan.argv,
447
+ provider,
448
+ mcp_config_path,
449
+ );
450
+ }
391
451
  }
392
452
  crate::lifecycle::launch::fill_spawn_placeholders_full(
393
453
  &mut plan.argv,
@@ -427,8 +487,20 @@ pub(super) fn spawn_agent_window(
427
487
  &system_prompt,
428
488
  )?;
429
489
  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);
490
+ let project = if crate::lifecycle::launch::cursor_mcp_isolation_enabled() {
491
+ Some(crate::lifecycle::launch::cursor_mcp_project_dir(
492
+ workspace,
493
+ agent_id.as_str(),
494
+ )?)
495
+ } else {
496
+ None
497
+ };
498
+ crate::lifecycle::launch::enable_cursor_workspace_mcp(workspace, project.as_deref())?;
499
+ crate::lifecycle::launch::apply_cursor_spawn_workspace_pointers(
500
+ &mut plan.argv,
501
+ workspace,
502
+ agent_id.as_str(),
503
+ )?;
432
504
  crate::lifecycle::launch::apply_cursor_subscription_proxy_env(&mut env);
433
505
  }
434
506
  if provider == crate::provider::Provider::Grok {
@@ -446,8 +518,9 @@ pub(super) fn spawn_agent_window(
446
518
  //
447
519
  // NOTE: Step 4 will thread the YAML spec down to here so we can honour
448
520
  // 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);
521
+ // > workspace; state-based override is silently dropped for existing
522
+ // providers. Pi is the narrow exception because exact header/path resume
523
+ // must preserve the captured cwd or refuse.
451
524
  // 0.4.x provider effort MVP step 9: scrub CLAUDE_EFFORT for Claude
452
525
  // worker spawn so a parent shell env cannot silently override the
453
526
  // framework's effort decision.
@@ -912,6 +985,14 @@ pub(super) fn lifecycle_worker_tmux_backend_selection_for_state(
912
985
  )));
913
986
  }
914
987
  }
988
+ if let Some((endpoint, source)) = crate::tmux_backend::owning_tmux_endpoint_from_state(state) {
989
+ let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
990
+ return Ok(crate::tmux_backend::RuntimeTmuxBackendSelection {
991
+ tmux_endpoint_used: backend.tmux_endpoint(),
992
+ backend,
993
+ tmux_endpoint_source: source,
994
+ });
995
+ }
915
996
  Ok(
916
997
  crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
917
998
  run_workspace,
@@ -928,6 +1009,9 @@ pub(super) fn lifecycle_worker_tmux_backend_for_state(
928
1009
  run_workspace: &Path,
929
1010
  state: &serde_json::Value,
930
1011
  ) -> crate::tmux_backend::TmuxBackend {
1012
+ if let Some((endpoint, _source)) = crate::tmux_backend::owning_tmux_endpoint_from_state(state) {
1013
+ return crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
1014
+ }
931
1015
  crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(run_workspace, Some(state))
932
1016
  .backend
933
1017
  }
@@ -1091,12 +1175,34 @@ fn json_team_identity_matches(existing: &serde_json::Value, compact: &serde_json
1091
1175
  /// returns: 非空的 session_name,缺失时退到默认名
1092
1176
  /// ---
1093
1177
  pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
1178
+ worker_session_name_from_state(state)
1179
+ .map(SessionName::new)
1180
+ .unwrap_or_else(|| SessionName::new("team-agent"))
1181
+ }
1182
+
1183
+ fn worker_session_name_from_state(state: &serde_json::Value) -> Option<&str> {
1094
1184
  state
1095
1185
  .get("session_name")
1096
1186
  .and_then(|v| v.as_str())
1097
1187
  .filter(|s| !s.is_empty())
1098
- .map(SessionName::new)
1099
- .unwrap_or_else(|| SessionName::new("team-agent"))
1188
+ .or_else(|| {
1189
+ let team_key = state
1190
+ .get("active_team_key")
1191
+ .and_then(serde_json::Value::as_str)
1192
+ .filter(|team_key| !team_key.is_empty())?;
1193
+ state
1194
+ .get("teams")?
1195
+ .get(team_key)?
1196
+ .pointer("/leader_receiver/session_name")?
1197
+ .as_str()
1198
+ .filter(|session| !session.is_empty())
1199
+ })
1200
+ .or_else(|| {
1201
+ state
1202
+ .pointer("/leader_receiver/session_name")
1203
+ .and_then(serde_json::Value::as_str)
1204
+ .filter(|session| !session.is_empty())
1205
+ })
1100
1206
  }
1101
1207
 
1102
1208
  /// ---
@@ -1104,11 +1210,7 @@ pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
1104
1210
  /// returns: 记了则 true
1105
1211
  /// ---
1106
1212
  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)
1213
+ worker_session_name_from_state(state).is_some()
1112
1214
  }
1113
1215
 
1114
1216
  /// ---
@@ -1349,6 +1451,22 @@ pub(super) fn resume_backing_probe_for_agent(
1349
1451
  crate::provider::session_scan::cursor::cursor_session_archive_present(dir)
1350
1452
  })
1351
1453
  }
1454
+ Provider::Pi => {
1455
+ let spawn_cwd = agent
1456
+ .get("spawn_cwd")
1457
+ .and_then(serde_json::Value::as_str)
1458
+ .filter(|value| !value.is_empty())
1459
+ .map(PathBuf::from)
1460
+ .unwrap_or_else(|| workspace.to_path_buf());
1461
+ rollout_path.is_some_and(|path| {
1462
+ crate::provider::session_scan::pi::validate_exact_backing(
1463
+ path.as_path(),
1464
+ session_id,
1465
+ &spawn_cwd,
1466
+ )
1467
+ .is_ok()
1468
+ })
1469
+ }
1352
1470
  Provider::GeminiCli | Provider::Fake => false,
1353
1471
  };
1354
1472
 
@@ -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
+ }