@team-agent/installer 0.5.77 → 0.5.80

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 (95) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +161 -29
  4. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  5. package/crates/team-agent/src/cli/emit.rs +24 -8
  6. package/crates/team-agent/src/cli/mod.rs +543 -98
  7. package/crates/team-agent/src/cli/models.rs +393 -32
  8. package/crates/team-agent/src/cli/named_address.rs +19 -5
  9. package/crates/team-agent/src/cli/profile.rs +35 -4
  10. package/crates/team-agent/src/cli/spec.rs +7 -2
  11. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  12. package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
  13. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  14. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  15. package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
  16. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  17. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  18. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  19. package/crates/team-agent/src/cli/types.rs +1 -0
  20. package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
  21. package/crates/team-agent/src/leader/lease.rs +492 -33
  22. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  23. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  24. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  25. package/crates/team-agent/src/leader/start.rs +197 -88
  26. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  27. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  28. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  29. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  30. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  31. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +86 -23
  32. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  33. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  34. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  35. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  36. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  37. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  38. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
  42. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  43. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  44. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  45. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  46. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  47. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
  48. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  49. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  50. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  51. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  52. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  53. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  54. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
  55. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  56. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  57. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  58. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  59. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  60. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
  61. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  62. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  63. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  64. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
  65. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
  66. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  67. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  68. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  69. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  70. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  71. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  72. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  73. package/crates/team-agent/src/messaging/delivery.rs +221 -80
  74. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  75. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
  76. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  77. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  78. package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
  79. package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
  80. package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
  81. package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
  82. package/crates/team-agent/src/state/mod.rs +1 -0
  83. package/crates/team-agent/src/state/persist.rs +102 -4
  84. package/crates/team-agent/src/state/projection.rs +7 -1
  85. package/crates/team-agent/src/state/repository.rs +29 -0
  86. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
  87. package/crates/team-agent/src/tmux_backend/tests.rs +149 -4
  88. package/crates/team-agent/src/tmux_backend.rs +239 -127
  89. package/crates/team-agent/src/transport.rs +78 -0
  90. package/crates/team-agent/src/transport_factory.rs +53 -0
  91. package/package.json +4 -5
  92. package/skills/team-agent/SKILL.md +45 -17
  93. package/skills/team-agent/command-coverage.json +4 -29
  94. package/npm/check-skill-links.mjs +0 -88
  95. package/skills/team-agent/references/team-agent-operator.md +0 -672
@@ -1,6 +1,7 @@
1
1
  //! purpose: cursor 席位必须把身份写进它实际会读的 mcp.json env
2
- //! contract: overlay 后 provider-config/<id>/cursor/.cursor/mcp.json 含 team_orchestrator.env.TEAM_AGENT_ID
3
- //! (不是改 HOME,也不是靠 pane env 继承)
2
+ //! contract: 隔离默认开时 overlay 写 per-seat 工程根 `.cursor/mcp.json`(`--workspace` /
3
+ //! enable cwd 同一根);关隔离时写 `<workspace>/.cursor/mcp.json`。不是
4
+ //! `.team/runtime/mcp/*.json`,也不是靠 pane env 继承。
4
5
  //! boundary: 只覆盖 cursor launch 产物;不改 claude/codex/copilot/grok 路径
5
6
  //!
6
7
  //! 生产侧判据,未经血统审计。
@@ -16,34 +17,22 @@ use std::path::{Path, PathBuf};
16
17
  use std::sync::atomic::{AtomicU64, Ordering};
17
18
 
18
19
  use serial_test::serial;
19
- use sha2::{Digest, Sha256};
20
20
  use team_agent::lifecycle::quick_start_with_transport_in_workspace;
21
21
  use team_agent::lifecycle::{
22
22
  apply_cursor_mcp_overlay, apply_cursor_spawn_workspace_pointers,
23
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
+ cursor_mcp_enable_argv, cursor_mcp_enable_working_dir, cursor_mcp_json_path,
25
+ cursor_mcp_project_dir, physical_workspace_path, prepare_cursor_seat_mcp, LifecycleError,
26
26
  };
27
27
  use team_agent::provider::McpConfig;
28
28
  use team_agent::transport::test_support::OfflineTransport;
29
29
 
30
30
  #[test]
31
- #[serial(cursor_mcp_isolation)]
32
31
  fn cursor_overlay_writes_identity_into_mcp_json_env() {
33
32
  let ws = tmp_dir("cursor-mcp-id");
34
33
  apply_cursor_mcp_overlay(&ws, &sample_mcp_config("seat-a", &ws.to_string_lossy()))
35
34
  .expect("overlay write");
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();
35
+ let text = std::fs::read_to_string(ws.join(".cursor/mcp.json")).unwrap();
47
36
  assert!(
48
37
  text.contains("\"team_orchestrator\""),
49
38
  "cursor overlay must use the canonical server name"
@@ -71,7 +60,6 @@ fn cursor_overlay_writes_identity_into_mcp_json_env() {
71
60
  }
72
61
 
73
62
  #[test]
74
- #[serial(cursor_mcp_isolation)]
75
63
  fn cursor_overlay_refuses_missing_identity() {
76
64
  let ws = tmp_dir("cursor-mcp-missing-id");
77
65
  let cfg = McpConfig {
@@ -96,7 +84,6 @@ fn cursor_overlay_refuses_missing_identity() {
96
84
  }
97
85
 
98
86
  #[test]
99
- #[serial(cursor_mcp_isolation)]
100
87
  fn cursor_overlay_keeps_unrelated_servers_and_replaces_orchestrator() {
101
88
  let ws = tmp_dir("cursor-mcp-merge");
102
89
  let dir = ws.join(".cursor");
@@ -114,63 +101,38 @@ fn cursor_overlay_keeps_unrelated_servers_and_replaces_orchestrator() {
114
101
  .unwrap();
115
102
 
116
103
  apply_cursor_mcp_overlay(&ws, &sample_mcp_config("seat-b", "/ws-b")).expect("overlay merge");
117
- let project = std::fs::read_to_string(dir.join("mcp.json")).unwrap();
104
+ let text = std::fs::read_to_string(dir.join("mcp.json")).unwrap();
118
105
  assert!(
119
- project.contains("keep-me"),
120
- "unrelated project MCP servers must survive in workspace mcp.json"
106
+ text.contains("keep-me"),
107
+ "unrelated project MCP servers must survive"
121
108
  );
122
109
  assert!(
123
- !project.contains("/old/team-agent") && !project.contains("/stale"),
124
- "stale orchestrator/legacy names must be scrubbed from the shared project file"
110
+ !text.contains("/old/team-agent") && !text.contains("/stale"),
111
+ "stale orchestrator/legacy names must be replaced"
125
112
  );
126
- let isolated = std::fs::read_to_string(cursor_mcp_json_path(&ws, "seat-b").unwrap()).unwrap();
127
113
  assert!(
128
- isolated.contains("seat-b") && isolated.contains("/ws-b"),
129
- "new identity must land in the per-seat mcp.json"
114
+ text.contains("seat-b") && text.contains("/ws-b"),
115
+ "new identity must land"
130
116
  );
131
117
  }
132
118
 
133
119
  #[test]
134
- #[serial(cursor_mcp_isolation)]
135
- fn cursor_second_seat_keeps_both_identities_when_isolated() {
120
+ fn cursor_second_seat_overwrites_json_identity_without_exclusive_gate() {
136
121
  let ws = tmp_dir("cursor-mcp-two");
137
122
  apply_cursor_mcp_overlay(&ws, &sample_mcp_config("first", "/ws")).expect("first");
138
123
  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");
160
124
  let text = std::fs::read_to_string(ws.join(".cursor/mcp.json")).unwrap();
161
- restore_isolation_env(previous);
162
125
  assert!(
163
126
  text.contains("second"),
164
- "destruction: shared mcp.json last writer is second"
127
+ "last writer wins on a shared --workspace; no grok-style exclusive gate"
165
128
  );
166
129
  assert!(
167
130
  !text.contains("\"first\""),
168
- "destruction: criterion 2 goes red — first identity is gone"
131
+ "previous seat identity must not remain in the single json file"
169
132
  );
170
133
  }
171
134
 
172
135
  #[test]
173
- #[serial(cursor_mcp_isolation)]
174
136
  fn cursor_enable_argv_is_documented_subcommand_without_workspace_flag() {
175
137
  let argv = cursor_mcp_enable_argv();
176
138
  assert_eq!(
@@ -185,72 +147,6 @@ fn cursor_enable_argv_is_documented_subcommand_without_workspace_flag() {
185
147
  }
186
148
 
187
149
  #[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)]
254
150
  fn cursor_workspace_flag_is_rewritten_to_physical_path() {
255
151
  let ws = tmp_dir("cursor-phys");
256
152
  let mut argv = vec![
@@ -264,29 +160,6 @@ fn cursor_workspace_flag_is_rewritten_to_physical_path() {
264
160
  }
265
161
 
266
162
  #[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)]
290
163
  #[serial(env)]
291
164
  fn cursor_subscription_proxy_copies_keys_without_requiring_profile() {
292
165
  let prev = std::env::var("HTTPS_PROXY").ok();
@@ -312,14 +185,119 @@ fn cursor_subscription_proxy_copies_keys_without_requiring_profile() {
312
185
  }
313
186
 
314
187
  #[test]
315
- #[serial(cursor_mcp_isolation)]
188
+ fn cursor_subscription_direct_profile_unsets_only_proxy_url_keys() {
189
+ let ws = tmp_dir("cursor-profile-direct");
190
+ let profiles = ws.join(".team/current/profiles");
191
+ std::fs::create_dir_all(&profiles).unwrap();
192
+ std::fs::write(
193
+ profiles.join("cursor.env"),
194
+ "AUTH_MODE=subscription\nPROFILE_NAME=cursor\nPROXY_MODE=direct\n",
195
+ )
196
+ .unwrap();
197
+
198
+ let launch = crate::lifecycle::profile_launch::prepare_provider_profile_launch_from_json(
199
+ &ws,
200
+ "cursor",
201
+ &serde_json::json!({
202
+ "id": "cursor",
203
+ "provider": "cursor_agent",
204
+ "auth_mode": "subscription",
205
+ "profile": "cursor"
206
+ }),
207
+ None,
208
+ )
209
+ .expect("direct Cursor profile should prepare");
210
+ for key in [
211
+ "HTTPS_PROXY",
212
+ "HTTP_PROXY",
213
+ "ALL_PROXY",
214
+ "https_proxy",
215
+ "http_proxy",
216
+ "all_proxy",
217
+ "GLOBAL_AGENT_HTTPS_PROXY",
218
+ "GLOBAL_AGENT_HTTP_PROXY",
219
+ ] {
220
+ assert!(launch.env_unset.contains(key), "direct must unset {key}");
221
+ }
222
+ assert!(!launch.env_unset.contains("NO_PROXY"));
223
+ assert!(!launch.env_unset.contains("no_proxy"));
224
+ }
225
+
226
+ #[test]
227
+ fn cursor_mcp_enable_child_env_applies_direct_profile_unsets() {
228
+ let ws = tmp_dir("cursor-profile-enable-env");
229
+ let profiles = ws.join(".team/current/profiles");
230
+ std::fs::create_dir_all(&profiles).unwrap();
231
+ std::fs::write(
232
+ profiles.join("cursor.env"),
233
+ "AUTH_MODE=subscription\nPROFILE_NAME=cursor\nPROXY_MODE=direct\n",
234
+ )
235
+ .unwrap();
236
+ let launch = crate::lifecycle::profile_launch::prepare_provider_profile_launch_from_json(
237
+ &ws,
238
+ "cursor",
239
+ &serde_json::json!({
240
+ "id": "cursor",
241
+ "provider": "cursor_agent",
242
+ "auth_mode": "subscription",
243
+ "profile": "cursor"
244
+ }),
245
+ None,
246
+ )
247
+ .expect("direct Cursor profile should prepare");
248
+
249
+ let mut command = std::process::Command::new("/usr/bin/env");
250
+ command.env_clear();
251
+ for key in [
252
+ "HTTPS_PROXY",
253
+ "HTTP_PROXY",
254
+ "ALL_PROXY",
255
+ "https_proxy",
256
+ "http_proxy",
257
+ "all_proxy",
258
+ "GLOBAL_AGENT_HTTPS_PROXY",
259
+ "GLOBAL_AGENT_HTTP_PROXY",
260
+ ] {
261
+ command.env(key, "fixture");
262
+ }
263
+ command.env("NO_PROXY", "fixture");
264
+ command.env("no_proxy", "fixture");
265
+ crate::lifecycle::launch::apply_cursor_mcp_enable_profile_env(
266
+ &mut command,
267
+ Some(&launch),
268
+ );
269
+ let output = command.output().expect("env child should run");
270
+ assert!(output.status.success());
271
+ let text = String::from_utf8(output.stdout).unwrap();
272
+ for key in [
273
+ "HTTPS_PROXY",
274
+ "HTTP_PROXY",
275
+ "ALL_PROXY",
276
+ "https_proxy",
277
+ "http_proxy",
278
+ "all_proxy",
279
+ "GLOBAL_AGENT_HTTPS_PROXY",
280
+ "GLOBAL_AGENT_HTTP_PROXY",
281
+ ] {
282
+ assert!(
283
+ !text.lines().any(|line| line.starts_with(&format!("{key}="))),
284
+ "direct MCP child must omit {key}"
285
+ );
286
+ }
287
+ assert!(text.lines().any(|line| line == "NO_PROXY=fixture"));
288
+ assert!(text.lines().any(|line| line == "no_proxy=fixture"));
289
+ }
290
+
291
+ #[test]
292
+ #[serial(env)]
316
293
  fn cursor_spawn_writes_identity_into_project_mcp_json() {
294
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter("cursor-mcp-spawn");
317
295
  let ws = tmp_dir("cursor-mcp-spawn");
318
296
  let team = write_cursor_team(&ws, "cursortm", "cursor_writer");
319
- let config_path = cursor_mcp_json_path(&ws, "cursor_writer").expect("iso path");
297
+ let selected = cursor_mcp_json_path(&ws, "cursor_writer").unwrap();
320
298
  assert!(
321
- !config_path.exists(),
322
- "precondition: project cursor mcp.json must be absent before spawn"
299
+ !selected.exists() && !ws.join(".cursor/mcp.json").exists(),
300
+ "precondition: selected project and workspace overlay must be absent before spawn"
323
301
  );
324
302
 
325
303
  quick_start_with_transport_in_workspace(
@@ -332,13 +310,66 @@ fn cursor_spawn_writes_identity_into_project_mcp_json() {
332
310
  )
333
311
  .expect("cursor quick-start through offline transport should spawn");
334
312
 
313
+ let text = std::fs::read_to_string(&selected).unwrap_or_else(|err| {
314
+ panic!(
315
+ "cursor spawn must materialize {}; isolation default reads this per-seat file: {err}",
316
+ selected.display()
317
+ )
318
+ });
319
+ assert_cursor_identity_overlay(&text, "cursor_writer", &ws);
320
+ assert!(
321
+ !ws.join(".cursor/mcp.json").exists(),
322
+ "default isolation must not dual-write the team workspace overlay"
323
+ );
324
+ let project = cursor_mcp_project_dir(&ws, "cursor_writer").unwrap();
325
+ let enable_cwd = cursor_mcp_enable_working_dir(&ws, Some(&project));
326
+ assert_eq!(enable_cwd, physical_workspace_path(&project));
327
+ let argv_workspace = spawn_argv_workspace_flag(&ws).expect("spawn argv --workspace");
328
+ assert_eq!(argv_workspace, physical_workspace_path(&project).to_string_lossy());
329
+ }
330
+
331
+ #[test]
332
+ #[serial(env)]
333
+ fn cursor_spawn_isolation_off_writes_workspace_mcp_json() {
334
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter("cursor-mcp-spawn-legacy");
335
+ let key = "TEAM_AGENT_CURSOR_MCP_ISOLATION";
336
+ let prev = std::env::var(key).ok();
337
+ std::env::set_var(key, "0");
338
+ let ws = tmp_dir("cursor-mcp-spawn-legacy");
339
+ let team = write_cursor_team(&ws, "cursortm", "cursor_writer");
340
+ let result = quick_start_with_transport_in_workspace(
341
+ &ws,
342
+ &team,
343
+ None,
344
+ true,
345
+ Some("cursortm"),
346
+ &OfflineTransport::new(),
347
+ );
348
+ match prev {
349
+ Some(value) => std::env::set_var(key, value),
350
+ None => std::env::remove_var(key),
351
+ }
352
+ result.expect("legacy isolation-off cursor spawn");
353
+ let config_path = ws.join(".cursor").join("mcp.json");
335
354
  let text = std::fs::read_to_string(&config_path).unwrap_or_else(|err| {
336
355
  panic!(
337
- "cursor spawn must materialize per-seat {}; cursor reads --workspace/.cursor/mcp.json: {err}",
356
+ "isolation-off spawn must write {}; cursor --workspace is the team root: {err}",
338
357
  config_path.display()
339
358
  )
340
359
  });
341
- let workspace = ws.to_string_lossy();
360
+ assert_cursor_identity_overlay(&text, "cursor_writer", &ws);
361
+ assert!(
362
+ !cursor_mcp_json_path(&ws, "cursor_writer")
363
+ .unwrap()
364
+ .exists(),
365
+ "isolation-off must not materialize the per-seat project overlay"
366
+ );
367
+ let argv_workspace = spawn_argv_workspace_flag(&ws).expect("spawn argv --workspace");
368
+ assert_eq!(argv_workspace, physical_workspace_path(&ws).to_string_lossy());
369
+ }
370
+
371
+ fn assert_cursor_identity_overlay(text: &str, agent_id: &str, workspace: &Path) {
372
+ let workspace = workspace.to_string_lossy();
342
373
  assert!(
343
374
  text.contains("\"team_orchestrator\""),
344
375
  "spawned overlay must declare team_orchestrator"
@@ -349,10 +380,10 @@ fn cursor_spawn_writes_identity_into_project_mcp_json() {
349
380
  );
350
381
  assert!(
351
382
  text.contains(workspace.as_ref()),
352
- "workspace must be the real path, not a placeholder"
383
+ "TEAM_AGENT_WORKSPACE / mcp args must be the real team path, not a placeholder"
353
384
  );
354
385
  assert!(
355
- text.contains("\"TEAM_AGENT_ID\"") && text.contains("cursor_writer"),
386
+ text.contains("\"TEAM_AGENT_ID\"") && text.contains(agent_id),
356
387
  "identity must be in json env"
357
388
  );
358
389
  assert!(
@@ -363,6 +394,27 @@ fn cursor_spawn_writes_identity_into_project_mcp_json() {
363
394
  );
364
395
  }
365
396
 
397
+ fn spawn_argv_workspace_flag(workspace: &Path) -> Option<String> {
398
+ let log = workspace.join(".team").join("logs").join("events.jsonl");
399
+ let text = std::fs::read_to_string(log).ok()?;
400
+ for line in text.lines().rev() {
401
+ let value: serde_json::Value = serde_json::from_str(line).ok()?;
402
+ if value.get("event").and_then(serde_json::Value::as_str)
403
+ != Some("provider.worker.spawn_argv")
404
+ {
405
+ continue;
406
+ }
407
+ let argv = value.get("argv")?.as_array()?;
408
+ let mut items = argv.iter().filter_map(serde_json::Value::as_str);
409
+ while let Some(item) = items.next() {
410
+ if item == "--workspace" {
411
+ return items.next().map(str::to_string);
412
+ }
413
+ }
414
+ }
415
+ None
416
+ }
417
+
366
418
  fn write_cursor_team(ws: &Path, team_key: &str, agent_id: &str) -> PathBuf {
367
419
  let team = ws.join(team_key);
368
420
  std::fs::create_dir_all(team.join("agents")).unwrap();
@@ -400,6 +452,68 @@ fn sample_mcp_config(agent_id: &str, workspace: &str) -> McpConfig {
400
452
  }
401
453
  }
402
454
 
455
+ #[test]
456
+ #[serial(env)]
457
+ fn isolated_cursor_prepare_materializes_per_seat_project_not_workspace_overlay() {
458
+ let ws = tmp_dir("cursor-iso-clean");
459
+ let project = prepare_cursor_seat_mcp(&ws, "seat-iso", &sample_mcp_config("seat-iso", &ws.to_string_lossy()))
460
+ .expect("prepare isolated cursor")
461
+ .expect("isolation defaults on");
462
+ assert!(
463
+ project.ends_with("provider-config/seat-iso/cursor"),
464
+ "per-seat project must be materialized: {}",
465
+ project.display()
466
+ );
467
+ assert!(project.join(".cursor").is_dir());
468
+ let overlay = std::fs::read_to_string(cursor_mcp_json_path(&ws, "seat-iso").unwrap()).unwrap();
469
+ assert!(
470
+ overlay.contains("seat-iso") && overlay.contains("team_orchestrator"),
471
+ "overlay must land in the per-seat mcp.json"
472
+ );
473
+ assert!(
474
+ !ws.join(".cursor/mcp.json").exists(),
475
+ "clean workspace overlay must not be written when isolation is on"
476
+ );
477
+ let enable_cwd = cursor_mcp_enable_working_dir(&ws, Some(&project));
478
+ assert_eq!(enable_cwd, physical_workspace_path(&project));
479
+ let mut argv = vec![
480
+ "agent".to_string(),
481
+ "--workspace".to_string(),
482
+ ws.to_string_lossy().into_owned(),
483
+ ];
484
+ apply_cursor_spawn_workspace_pointers(&mut argv, &ws, "seat-iso").unwrap();
485
+ let workspace_flag = argv
486
+ .windows(2)
487
+ .find(|pair| pair[0] == "--workspace")
488
+ .map(|pair| pair[1].as_str())
489
+ .unwrap();
490
+ assert_eq!(workspace_flag, physical_workspace_path(&project).to_string_lossy());
491
+ assert_eq!(
492
+ include_str!("../launch/spawn.rs").contains("prepare_cursor_seat_mcp("),
493
+ true,
494
+ "fresh spawn must call the shared materializer, not only compute the path"
495
+ );
496
+ assert!(
497
+ include_str!("../restart/common.rs").contains("prepare_cursor_seat_mcp("),
498
+ "restart spawn_agent_window must use the same materializer"
499
+ );
500
+ }
501
+
502
+ #[test]
503
+ fn cursor_enable_working_dir_does_not_depend_on_cfg_test_skip() {
504
+ let ws = tmp_dir("cursor-enable-cwd");
505
+ let project = ws.join(".team/runtime/provider-config/seat/cursor");
506
+ std::fs::create_dir_all(&project).unwrap();
507
+ assert_eq!(
508
+ cursor_mcp_enable_working_dir(&ws, Some(&project)),
509
+ physical_workspace_path(&project)
510
+ );
511
+ assert_eq!(
512
+ cursor_mcp_enable_working_dir(&ws, None),
513
+ physical_workspace_path(&ws)
514
+ );
515
+ }
516
+
403
517
  fn tmp_dir(tag: &str) -> PathBuf {
404
518
  static N: AtomicU64 = AtomicU64::new(0);
405
519
  // 见 gate_fixtures::scratch_dir:⛔ 不硬编码绝对路径,默认标准临时目录。
@@ -415,50 +529,3 @@ fn tmp_dir(tag: &str) -> PathBuf {
415
529
  std::fs::create_dir_all(&dir).unwrap();
416
530
  std::fs::canonicalize(dir).unwrap()
417
531
  }
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
- }
@@ -8,8 +8,8 @@
8
8
  //! what: 写了 model 则该值进入 `--model <值>`
9
9
  //! - name: cursor-effort-refuses-compile
10
10
  //! what: 角色写 effort 则失败,不得丢掉后仍起席
11
- //! - name: cursor-second-seat-isolated-identity
12
- //! what: 默认 per-seat MCP 隔离时同 workspace 两个 CursorAgent 都启动且身份各自保留
11
+ //! - name: cursor-second-seat-refuses
12
+ //! what: 同 workspace 第二 CursorAgent 拒绝,文案含 TEAM_AGENT_ID/mcp.json
13
13
  //! boundary:
14
14
  //! - 只钉 Provider::CursorAgent;不改 grok/claude 路径
15
15
  //! maturity: wired
@@ -26,7 +26,6 @@ use std::path::{Path, PathBuf};
26
26
  use std::sync::atomic::{AtomicU64, Ordering};
27
27
 
28
28
  use serial_test::serial;
29
- use team_agent::lifecycle::cursor_mcp_json_path;
30
29
  use team_agent::lifecycle::quick_start_with_transport_in_workspace;
31
30
  use team_agent::transport::test_support::OfflineTransport;
32
31
 
@@ -67,6 +66,7 @@ fn cursor_role_missing_model_refuses_to_start() {
67
66
  #[test]
68
67
  #[serial(env)]
69
68
  fn cursor_role_explicit_model_reaches_argv() {
69
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter("cursor-with-model");
70
70
  let ws = tmp_dir("cursor-with-model");
71
71
  let team = write_role_team(
72
72
  &ws,
@@ -127,9 +127,8 @@ fn cursor_role_with_effort_refuses_to_start() {
127
127
 
128
128
  #[test]
129
129
  #[serial(env)]
130
- fn cursor_second_seat_in_same_workspace_uses_isolated_identity() {
131
- let previous_isolation = std::env::var("TEAM_AGENT_CURSOR_MCP_ISOLATION").ok();
132
- std::env::remove_var("TEAM_AGENT_CURSOR_MCP_ISOLATION");
130
+ fn cursor_second_seat_in_same_workspace_is_refused() {
131
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter("cursor-two");
133
132
  let ws = tmp_dir("cursor-two");
134
133
  let team = ws.join("cursortm");
135
134
  std::fs::create_dir_all(team.join("agents")).unwrap();
@@ -156,36 +155,21 @@ fn cursor_second_seat_in_same_workspace_uses_isolated_identity() {
156
155
  Some("cursortm"),
157
156
  &transport,
158
157
  );
159
- restore_isolation_env(previous_isolation);
160
- result.expect("default per-seat MCP isolation must allow both Cursor seats");
161
- let records = transport.spawn_records();
162
- assert_eq!(
163
- records.len(),
164
- 2,
165
- "both Cursor seats must spawn; records={records:?}"
166
- );
167
-
168
- let first = std::fs::read_to_string(cursor_mcp_json_path(&ws, "cursor_a").unwrap())
169
- .expect("first seat MCP config");
170
- let second = std::fs::read_to_string(cursor_mcp_json_path(&ws, "cursor_b").unwrap())
171
- .expect("second seat MCP config");
158
+ let err = match result {
159
+ Ok(report) => panic!("second cursor seat must refuse; report={report:?}"),
160
+ Err(error) => error.to_string(),
161
+ };
172
162
  assert!(
173
- first.contains("\"cursor_a\"") && !first.contains("\"cursor_b\""),
174
- "first seat must retain its own TEAM_AGENT_ID"
163
+ err.contains("TEAM_AGENT_ID") && err.contains("mcp.json"),
164
+ "error must name the last-writer identity file; err={err}"
175
165
  );
176
166
  assert!(
177
- second.contains("\"cursor_b\"") && !second.contains("\"cursor_a\""),
178
- "second seat must retain its own TEAM_AGENT_ID"
167
+ transport.spawn_records().is_empty(),
168
+ "must refuse before any cursor spawn; records={:?}",
169
+ transport.spawn_records()
179
170
  );
180
171
  }
181
172
 
182
- fn restore_isolation_env(previous: Option<String>) {
183
- match previous {
184
- Some(value) => std::env::set_var("TEAM_AGENT_CURSOR_MCP_ISOLATION", value),
185
- None => std::env::remove_var("TEAM_AGENT_CURSOR_MCP_ISOLATION"),
186
- }
187
- }
188
-
189
173
  fn first_spawn_argv(transport: &OfflineTransport) -> Vec<String> {
190
174
  let records = transport.spawn_records();
191
175
  assert!(