@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
@@ -3,19 +3,21 @@
3
3
  //! contract:
4
4
  //! provides:
5
5
  //! - name: apply_cursor_mcp_overlay
6
- //! what: 写 <workspace>/.cursor/mcp.json,env 必须带 TEAM_AGENT_ID
6
+ //! what: 默认写 provider-config/<id>/cursor/.cursor/mcp.json;隔离关闭时写 workspace 文件
7
+ //! - name: apply_cursor_spawn_workspace_pointers
8
+ //! what: 隔离开时 --workspace 指 per-seat 工程根,--add-dir 指真 workspace
7
9
  //! - name: cursor_mcp_enable_argv
8
10
  //! what: 组 `agent mcp enable team_orchestrator`(不认 --workspace)
9
11
  //! - name: enable_cursor_workspace_mcp
10
- //! what: 在物理工作目录跑 enable;测试隔离下跳过以免写 ~/.cursor
12
+ //! what: 在工程根跑 enable;测试隔离下跳过以免写 ~/.cursor
11
13
  //! - name: physical_workspace_path
12
14
  //! what: pwd -P 等价路径;mcp enable 按 getcwd 分片
13
15
  //! - name: refuse_second_cursor_occupant
14
- //! what: 同一物理 workspace 第二 CursorAgent 拒绝(mcp.json last-writer)
16
+ //! what: 隔离不可用时拒绝第二席;隔离可用时放行
15
17
  //! boundary:
16
- //! - 同 workspace 第二 CursorAgent 拒绝;U-07 过前不装「已支持多席」
18
+ //! - 隔离开启时同 workspace 允许多 CursorAgent;隔离关闭/失败仍 fail-closed
17
19
  //! - 不把身份从 json env 删掉(cursor 不继承父进程 TEAM_AGENT_*)
18
- //! - 不写 ~/.cursor/mcp.json 全局文件
20
+ //! - 不改 HOME,不写 ~/.cursor/mcp.json 全局文件
19
21
  //! - 不调 --approve-mcps(未验证能代替 enable)
20
22
  //! maturity: wired
21
23
  //! ---
@@ -24,11 +26,17 @@ use std::collections::BTreeMap;
24
26
  use std::path::{Path, PathBuf};
25
27
  use std::process::Command;
26
28
 
29
+ use sha2::{Digest, Sha256};
30
+
27
31
  use crate::lifecycle::LifecycleError;
28
32
  use crate::model::yaml::Value as YamlValue;
29
33
  use crate::provider::wire::command_name;
30
34
  use crate::provider::Provider;
31
35
 
36
+ use super::cursor_mcp_iso::{
37
+ cursor_mcp_isolation_enabled, cursor_mcp_project_dir, materialize_cursor_mcp_project,
38
+ };
39
+
32
40
  /// Keys that must appear in mcp.json env. Cursor strips parent env down to
33
41
  /// HOME/PATH/TERM/… — TEAM_AGENT_ID only survives if it is in this table.
34
42
  const REQUIRED_IDENTITY_KEYS: &[&str] = &[
@@ -38,6 +46,123 @@ const REQUIRED_IDENTITY_KEYS: &[&str] = &[
38
46
  "TEAM_AGENT_AUTH_MODE",
39
47
  ];
40
48
 
49
+ const CURSOR_ENABLE_STDERR_MAX_BYTES: usize = 512;
50
+ const CURSOR_ENABLE_SECRET_INDICATORS: &[&str] = &[
51
+ "authorization",
52
+ "bearer",
53
+ "api_key",
54
+ "cookie",
55
+ "password",
56
+ "token=",
57
+ "sk-",
58
+ ];
59
+
60
+ impl LifecycleError {
61
+ pub(crate) fn cursor_mcp_enable_failure(
62
+ argv: &[String],
63
+ physical_cwd: &Path,
64
+ exit_code: Option<i32>,
65
+ stdout: &[u8],
66
+ stderr: &[u8],
67
+ spawn_error: Option<&str>,
68
+ ) -> Self {
69
+ let config_path = physical_cwd.join(".cursor").join("mcp.json");
70
+ let metadata = std::fs::metadata(&config_path).ok();
71
+ let config_exists = metadata.is_some();
72
+ let config_size = metadata
73
+ .as_ref()
74
+ .map(|value| value.len().to_string())
75
+ .unwrap_or_else(|| "unavailable".to_string());
76
+ #[cfg(unix)]
77
+ let config_mode = {
78
+ use std::os::unix::fs::PermissionsExt;
79
+ metadata
80
+ .as_ref()
81
+ .map(|value| format!("0o{:o}", value.permissions().mode()))
82
+ .unwrap_or_else(|| "unavailable".to_string())
83
+ };
84
+ #[cfg(not(unix))]
85
+ let config_mode = "unavailable_non_unix".to_string();
86
+ let config_sha256 = std::fs::read(&config_path)
87
+ .ok()
88
+ .map(|body| format!("{:x}", Sha256::digest(body)))
89
+ .unwrap_or_else(|| "unavailable".to_string());
90
+ let stderr_fact = bounded_safe_cursor_stderr(stderr)
91
+ .map(|safe| format!("stderr_first_safe: {safe}"))
92
+ .unwrap_or_else(|| "stderr_redacted".to_string());
93
+ let (reason, action) = match spawn_error {
94
+ Some(error) => (
95
+ format!("cannot run cursor MCP enable: {error}"),
96
+ format!(
97
+ "install cursor-agent on PATH (same binary as `{}`) and retry",
98
+ argv[0]
99
+ ),
100
+ ),
101
+ None => (
102
+ "without enable, cursor keeps MCP as not loaded (needs approval)".to_string(),
103
+ format!(
104
+ "from that directory run `{} mcp enable team_orchestrator`",
105
+ argv[0]
106
+ ),
107
+ ),
108
+ };
109
+ LifecycleError::RequirementUnmet(format!(
110
+ "error: `{}` failed (exit {})\n\
111
+ reason: {reason}\n\
112
+ argv: {}\n\
113
+ cwd: {}\n\
114
+ config_path: {}\n\
115
+ config_exists: {config_exists}\n\
116
+ config_mode: {config_mode}\n\
117
+ config_size: {config_size}\n\
118
+ config_sha256: {config_sha256}\n\
119
+ stdout_len: {}\n\
120
+ stderr_len: {}\n\
121
+ {stderr_fact}\n\
122
+ action: {action}",
123
+ argv.join(" "),
124
+ exit_code
125
+ .map(|code| code.to_string())
126
+ .unwrap_or_else(|| "unavailable".to_string()),
127
+ argv.join(" "),
128
+ physical_cwd.display(),
129
+ config_path.display(),
130
+ stdout.len(),
131
+ stderr.len(),
132
+ ))
133
+ }
134
+ }
135
+
136
+ fn bounded_safe_cursor_stderr(stderr: &[u8]) -> Option<String> {
137
+ let stderr = String::from_utf8_lossy(stderr);
138
+ let mut safe = String::new();
139
+ 'lines: for line in stderr.lines() {
140
+ let lower = line.to_ascii_lowercase();
141
+ if CURSOR_ENABLE_SECRET_INDICATORS
142
+ .iter()
143
+ .any(|indicator| lower.contains(indicator))
144
+ {
145
+ continue;
146
+ }
147
+ if line.trim().is_empty() {
148
+ continue;
149
+ }
150
+ if !safe.is_empty() {
151
+ if safe.len() == CURSOR_ENABLE_STDERR_MAX_BYTES {
152
+ break;
153
+ }
154
+ safe.push('\n');
155
+ }
156
+ for character in line.chars() {
157
+ if safe.len() + character.len_utf8() > CURSOR_ENABLE_STDERR_MAX_BYTES {
158
+ break 'lines;
159
+ }
160
+ safe.push(character);
161
+ }
162
+ }
163
+ (!safe.is_empty()).then_some(safe)
164
+ }
165
+
41
166
  /// ---
42
167
  /// purpose: 给出 workspace 的物理路径
43
168
  /// returns: 能 canonicalize 就用它,否则原样返回
@@ -61,17 +186,18 @@ fn agent_is_cursor(provider: Option<&str>) -> bool {
61
186
  }
62
187
 
63
188
  /// ---
64
- /// purpose: 同一物理 workspace 拒绝第二 CursorAgent
189
+ /// purpose: 同一物理 workspace 的第二 CursorAgent:隔离可用则放行,否则 fail-closed
65
190
  /// params:
66
191
  /// workspace: 物理工作目录
67
192
  /// incoming_id: 正要起的席位 id
68
193
  /// spec: 可选 yaml spec,用来在 state 尚未写入时看见第二席
69
- /// returns: 已有其它 CursorAgent 则 RequirementUnmet,文案含 TEAM_AGENT_ID 与 mcp.json
194
+ /// returns: 隔离关闭且已有其它 CursorAgent 则 RequirementUnmet
70
195
  /// contract:
71
196
  /// provides:
72
197
  /// - name: refuse_second_cursor_occupant
73
- /// what: mcp.json last-writer 闸;U-07 过前不装多席
198
+ /// what: 共用 mcp.json last-writer 闸;隔离落地后不再挡第二席
74
199
  /// boundary:
200
+ /// - 隔离失败/关闭时不改文案语义(仍点名 mcp.json last-writer)
75
201
  /// - 不改 grok 独占实现
76
202
  /// ---
77
203
  pub fn refuse_second_cursor_occupant(
@@ -123,6 +249,9 @@ pub fn refuse_second_cursor_occupant(
123
249
  if others.is_empty() {
124
250
  return Ok(());
125
251
  }
252
+ if cursor_mcp_isolation_enabled() {
253
+ return Ok(());
254
+ }
126
255
  Err(LifecycleError::RequirementUnmet(format!(
127
256
  "error: cursor_agent seat already occupies this workspace\n\
128
257
  incoming: {incoming_id}\n\
@@ -140,16 +269,41 @@ fn agent_is_paused_yaml(agent: &YamlValue) -> bool {
140
269
  }
141
270
 
142
271
  /// ---
143
- /// purpose: 写 workspace 下 cursor 会读的 mcp.json,env 里必须带席位身份键
272
+ /// purpose: 写 cursor 会读的 mcp.json;默认进 provider-config 工程根,关闭隔离时才写 workspace 文件
144
273
  /// params:
145
274
  /// mcp_config: 已解析的 MCP 配置,须含 team_orchestrator 与 command
146
275
  /// returns: 写出的文件路径
147
- /// errors: 缺条目或缺 command 时返回 StatePersist,写盘失败也返回 StatePersist
276
+ /// errors: 缺条目或缺 command 时返回 StatePersist;隔离建盘失败返回 RequirementUnmet
148
277
  /// ---
149
278
  pub fn apply_cursor_mcp_overlay(
150
279
  workspace: &Path,
151
280
  mcp_config: &crate::provider::McpConfig,
152
281
  ) -> Result<PathBuf, LifecycleError> {
282
+ let (command, args, env) = parse_orchestrator_env(mcp_config)?;
283
+ let agent_id = env
284
+ .get("TEAM_AGENT_ID")
285
+ .map(String::as_str)
286
+ .filter(|value| !value.trim().is_empty())
287
+ .ok_or_else(|| {
288
+ LifecycleError::StatePersist(
289
+ "cursor MCP overlay missing resolved TEAM_AGENT_ID in json env \
290
+ (cursor does not inherit parent TEAM_AGENT_* into the MCP child)"
291
+ .to_string(),
292
+ )
293
+ })?;
294
+ let cursor_dir = if cursor_mcp_isolation_enabled() {
295
+ let project = materialize_cursor_mcp_project(workspace, agent_id)?;
296
+ scrub_workspace_orchestrator_mcp(workspace)?;
297
+ project.join(".cursor")
298
+ } else {
299
+ workspace.join(".cursor")
300
+ };
301
+ write_cursor_mcp_json_at(&cursor_dir, command, args, env)
302
+ }
303
+
304
+ fn parse_orchestrator_env(
305
+ mcp_config: &crate::provider::McpConfig,
306
+ ) -> Result<(String, Vec<String>, BTreeMap<String, String>), LifecycleError> {
153
307
  let server = mcp_config
154
308
  .raw
155
309
  .get("team_orchestrator")
@@ -165,7 +319,8 @@ pub fn apply_cursor_mcp_overlay(
165
319
  .filter(|value| !value.is_empty())
166
320
  .ok_or_else(|| {
167
321
  LifecycleError::StatePersist("cursor MCP overlay missing command".to_string())
168
- })?;
322
+ })?
323
+ .to_string();
169
324
  let args = server
170
325
  .get("args")
171
326
  .and_then(serde_json::Value::as_array)
@@ -199,9 +354,16 @@ pub fn apply_cursor_mcp_overlay(
199
354
  }
200
355
  }
201
356
  }
357
+ Ok((command, args, env))
358
+ }
202
359
 
203
- let dir = workspace.join(".cursor");
204
- std::fs::create_dir_all(&dir)
360
+ fn write_cursor_mcp_json_at(
361
+ dir: &Path,
362
+ command: String,
363
+ args: Vec<String>,
364
+ env: BTreeMap<String, String>,
365
+ ) -> Result<PathBuf, LifecycleError> {
366
+ std::fs::create_dir_all(dir)
205
367
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", dir.display())))?;
206
368
  let path = dir.join("mcp.json");
207
369
  let mut root = read_existing_mcp_json(&path);
@@ -243,6 +405,28 @@ pub fn apply_cursor_mcp_overlay(
243
405
  Ok(path)
244
406
  }
245
407
 
408
+ fn scrub_workspace_orchestrator_mcp(workspace: &Path) -> Result<(), LifecycleError> {
409
+ let path = workspace.join(".cursor").join("mcp.json");
410
+ if !path.is_file() {
411
+ return Ok(());
412
+ }
413
+ let mut root = read_existing_mcp_json(&path);
414
+ let Some(servers) = root
415
+ .as_object_mut()
416
+ .and_then(|obj| obj.get_mut("mcpServers"))
417
+ .and_then(serde_json::Value::as_object_mut)
418
+ else {
419
+ return Ok(());
420
+ };
421
+ servers.remove("team_orchestrator");
422
+ servers.remove("team-agent");
423
+ let body = serde_json::to_string_pretty(&root)
424
+ .map_err(|e| LifecycleError::StatePersist(format!("serialize cursor mcp.json: {e}")))?;
425
+ std::fs::write(&path, body.as_bytes())
426
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
427
+ Ok(())
428
+ }
429
+
246
430
  /// ---
247
431
  /// purpose: 组出启用 team_orchestrator 的 cursor 命令行
248
432
  /// returns: 不带 workspace 参数的 argv,该命令按进程工作目录分片
@@ -257,47 +441,49 @@ pub fn cursor_mcp_enable_argv() -> Vec<String> {
257
441
  }
258
442
 
259
443
  /// ---
260
- /// purpose: 在物理工作目录下执行 cursor 的 MCP 启用命令
444
+ /// purpose: 在工程根下执行 cursor 的 MCP 启用命令(getcwd 分片,不改 HOME)
261
445
  /// returns: 测试隔离环境或显式跳过标志下直接成功,避免写用户全局配置
262
- /// errors: 命令跑不起来或退出码非零时返回 RequirementUnmet,错误里只记输出长度不记内容
446
+ /// errors: 命令跑不起来或退出码非零时返回 RequirementUnmet,附带有界脱敏诊断但不读出 json 内容
263
447
  /// ---
264
- pub fn enable_cursor_workspace_mcp(workspace: &Path) -> Result<(), LifecycleError> {
448
+ pub fn enable_cursor_workspace_mcp(
449
+ workspace: &Path,
450
+ project_root: Option<&Path>,
451
+ ) -> Result<(), LifecycleError> {
265
452
  if skip_cursor_mcp_enable() {
266
453
  return Ok(());
267
454
  }
268
- let physical = physical_workspace_path(workspace);
455
+ let physical = match project_root {
456
+ Some(root) => physical_workspace_path(root),
457
+ None => physical_workspace_path(workspace),
458
+ };
269
459
  let argv = cursor_mcp_enable_argv();
270
- let output = Command::new(&argv[0])
271
- .args(&argv[1..])
272
- .current_dir(&physical)
273
- .output()
274
- .map_err(|e| {
275
- LifecycleError::RequirementUnmet(format!(
276
- "error: cannot run `{} mcp enable team_orchestrator`\n\
277
- reason: {e}\n\
278
- action: install cursor-agent on PATH (same binary as `agent`) and retry",
279
- argv[0]
280
- ))
281
- })?;
460
+ let mut cmd = Command::new(&argv[0]);
461
+ cmd.args(&argv[1..]).current_dir(&physical);
462
+ let output = match cmd.output() {
463
+ Ok(output) => output,
464
+ Err(error) => {
465
+ let error = error.to_string();
466
+ return Err(LifecycleError::cursor_mcp_enable_failure(
467
+ &argv,
468
+ &physical,
469
+ None,
470
+ &[],
471
+ &[],
472
+ Some(&error),
473
+ ));
474
+ }
475
+ };
282
476
  if output.status.success() {
283
477
  return Ok(());
284
478
  }
285
- let stderr = String::from_utf8_lossy(&output.stderr);
286
- let stdout = String::from_utf8_lossy(&output.stdout);
287
- Err(LifecycleError::RequirementUnmet(format!(
288
- "error: `{} mcp enable team_orchestrator` failed (exit {:?})\n\
289
- reason: without enable, cursor keeps MCP as not loaded (needs approval)\n\
290
- cwd: {}\n\
291
- stdout_len: {}\n\
292
- stderr_len: {}\n\
293
- action: from that directory run `{} mcp enable team_orchestrator`",
294
- argv[0],
479
+ Err(LifecycleError::cursor_mcp_enable_failure(
480
+ &argv,
481
+ &physical,
295
482
  output.status.code(),
296
- physical.display(),
297
- stdout.len(),
298
- stderr.len(),
299
- argv[0]
300
- )))
483
+ &output.stdout,
484
+ &output.stderr,
485
+ None,
486
+ ))
301
487
  }
302
488
 
303
489
  /// ---
@@ -315,6 +501,36 @@ pub fn apply_cursor_workspace_physical_path(argv: &mut [String], workspace: &Pat
315
501
  }
316
502
  }
317
503
 
504
+ /// ---
505
+ /// purpose: 隔离开时把 cursor --workspace 指到 per-seat 工程根,并用 --add-dir 挂上真 workspace
506
+ /// params:
507
+ /// argv: 就地改写(可能追加 --add-dir)
508
+ /// workspace: 团队工作目录
509
+ /// agent_id: 席位 id
510
+ /// errors: 工程根路径非法时返回 RequirementUnmet
511
+ /// ---
512
+ pub fn apply_cursor_spawn_workspace_pointers(
513
+ argv: &mut Vec<String>,
514
+ workspace: &Path,
515
+ agent_id: &str,
516
+ ) -> Result<(), LifecycleError> {
517
+ if cursor_mcp_isolation_enabled() {
518
+ let project = physical_workspace_path(&cursor_mcp_project_dir(workspace, agent_id)?);
519
+ apply_cursor_workspace_physical_path(argv, &project);
520
+ let team = physical_workspace_path(workspace);
521
+ let already = argv
522
+ .windows(2)
523
+ .any(|pair| pair[0] == "--add-dir" && Path::new(&pair[1]) == team.as_path());
524
+ if !already {
525
+ argv.push("--add-dir".to_string());
526
+ argv.push(team.to_string_lossy().into_owned());
527
+ }
528
+ } else {
529
+ apply_cursor_workspace_physical_path(argv, workspace);
530
+ }
531
+ Ok(())
532
+ }
533
+
318
534
  fn skip_cursor_mcp_enable() -> bool {
319
535
  cfg!(test)
320
536
  || std::env::var_os("TEAM_AGENT_TEST_TMP").is_some()
@@ -0,0 +1,120 @@
1
+ //! ---
2
+ //! purpose: cursor 的目录作用域 mcp.json 改成 per-seat provider-config 工程根(对齐 grok overlay / claude CLAUDE_CONFIG_DIR)
3
+ //! contract:
4
+ //! provides:
5
+ //! - name: materialize_cursor_mcp_project
6
+ //! what: 在 provider-config/<id>/cursor 建 .cursor/,失败不写共用 mcp.json
7
+ //! - name: cursor_mcp_isolation_enabled
8
+ //! what: 默认开;TEAM_AGENT_CURSOR_MCP_ISOLATION=0 时关(只给破坏齿/回归旧闸)
9
+ //! - name: cursor_mcp_project_dir
10
+ //! what: 该席位作为 cursor --workspace 的工程根(未建盘)
11
+ //! boundary:
12
+ //! - 隔离失败 = RequirementUnmet,⛔ 不降级写 <workspace>/.cursor/mcp.json
13
+ //! - 不改 HOME(不是 copilot COPILOT_HOME / 会话库分叉)
14
+ //! - 不削弱 refuse_second:隔离关闭时仍拒第二席
15
+ //! maturity: wired
16
+ //! ---
17
+
18
+ use std::path::{Path, PathBuf};
19
+
20
+ use crate::lifecycle::LifecycleError;
21
+
22
+ const ISOLATION_ENV: &str = "TEAM_AGENT_CURSOR_MCP_ISOLATION";
23
+
24
+ /// ---
25
+ /// purpose: 默认开启 per-seat provider-config 工程根;显式 0/false/off 才关
26
+ /// ---
27
+ pub fn cursor_mcp_isolation_enabled() -> bool {
28
+ match std::env::var(ISOLATION_ENV) {
29
+ Ok(value) => {
30
+ let trimmed = value.trim();
31
+ !matches!(
32
+ trimmed,
33
+ "0" | "false" | "FALSE" | "off" | "OFF" | "no" | "NO"
34
+ )
35
+ }
36
+ Err(_) => true,
37
+ }
38
+ }
39
+
40
+ /// ---
41
+ /// purpose: 算出该席位 cursor --workspace 工程根,不建盘
42
+ /// ---
43
+ pub fn cursor_mcp_project_dir(workspace: &Path, agent_id: &str) -> Result<PathBuf, LifecycleError> {
44
+ validate_agent_id(agent_id)?;
45
+ Ok(workspace
46
+ .join(".team")
47
+ .join("runtime")
48
+ .join("provider-config")
49
+ .join(agent_id)
50
+ .join("cursor"))
51
+ }
52
+
53
+ /// ---
54
+ /// purpose: 该席位 cursor 实际会读的项目 mcp.json
55
+ /// ---
56
+ pub fn cursor_mcp_json_path(workspace: &Path, agent_id: &str) -> Result<PathBuf, LifecycleError> {
57
+ Ok(cursor_mcp_project_dir(workspace, agent_id)?
58
+ .join(".cursor")
59
+ .join("mcp.json"))
60
+ }
61
+
62
+ /// ---
63
+ /// purpose: 建 per-seat 工程根,并把团队 rules 链进该根(不链 mcp.json)
64
+ /// returns: 工程根
65
+ /// errors: agent_id 非法或建盘失败 → RequirementUnmet(不写共用 mcp.json)
66
+ /// ---
67
+ pub fn materialize_cursor_mcp_project(
68
+ workspace: &Path,
69
+ agent_id: &str,
70
+ ) -> Result<PathBuf, LifecycleError> {
71
+ let project = cursor_mcp_project_dir(workspace, agent_id)?;
72
+ let cursor_dir = project.join(".cursor");
73
+ std::fs::create_dir_all(&cursor_dir).map_err(|e| {
74
+ LifecycleError::RequirementUnmet(format!(
75
+ "error: cannot materialize cursor per-seat MCP project\n\
76
+ reason: {e}\n\
77
+ path: {}\n\
78
+ action: fix workspace permissions; do not fall back to <workspace>/.cursor/mcp.json",
79
+ cursor_dir.display()
80
+ ))
81
+ })?;
82
+ link_workspace_rules(workspace, &cursor_dir)?;
83
+ Ok(project)
84
+ }
85
+
86
+ fn validate_agent_id(agent_id: &str) -> Result<(), LifecycleError> {
87
+ if agent_id.is_empty()
88
+ || agent_id.contains('/')
89
+ || agent_id.contains('\\')
90
+ || agent_id.contains("..")
91
+ || !agent_id
92
+ .chars()
93
+ .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
94
+ {
95
+ return Err(LifecycleError::RequirementUnmet(format!(
96
+ "error: cursor per-seat MCP project cannot use agent id {agent_id:?}\n\
97
+ reason: id must be a single path segment [A-Za-z0-9._-]+\n\
98
+ action: rename the seat; do not share <workspace>/.cursor/mcp.json"
99
+ )));
100
+ }
101
+ Ok(())
102
+ }
103
+
104
+ fn link_workspace_rules(workspace: &Path, dest_cursor: &Path) -> Result<(), LifecycleError> {
105
+ let source = workspace.join(".cursor").join("rules");
106
+ if !source.exists() {
107
+ return Ok(());
108
+ }
109
+ let dest = dest_cursor.join("rules");
110
+ if dest.exists() {
111
+ return Ok(());
112
+ }
113
+ std::os::unix::fs::symlink(&source, &dest).map_err(|e| {
114
+ LifecycleError::RequirementUnmet(format!(
115
+ "error: cannot link workspace cursor rules into per-seat project\n\
116
+ reason: {e}\n\
117
+ action: do not fall back to <workspace>/.cursor/mcp.json"
118
+ ))
119
+ })
120
+ }
@@ -70,7 +70,11 @@ pub fn in_window_fork_command(provider: Provider, auth: AuthMode) -> Option<&'st
70
70
  fn refuse_missing_in_window_fork(provider: Provider, provider_raw: &str) -> LifecycleError {
71
71
  if matches!(
72
72
  provider,
73
- Provider::Grok | Provider::Claude | Provider::ClaudeCode | Provider::CursorAgent
73
+ Provider::Grok
74
+ | Provider::Claude
75
+ | Provider::ClaudeCode
76
+ | Provider::CursorAgent
77
+ | Provider::Pi
74
78
  ) {
75
79
  LifecycleError::Provider(format!(
76
80
  "{provider_raw} does not support native session fork"
@@ -11,7 +11,7 @@
11
11
  //! - name: reconcile_grok_toml_per_seat_keys
12
12
  //! what: 起 grok 席前清掉共享 toml 里遗留的 per-seat 键并留审计事件
13
13
  //! - name: ensure_grok_login_and_folder_trust
14
- //! what: grok 未登录或目录未信任时拒绝起席
14
+ //! what: grok 未登录时拒绝起席;目录信任与用户同意由可见 Grok TUI 负责
15
15
  //! - name: point_native_mcp_config_at_file
16
16
  //! what: 把 argv 里的 MCP 配置参数改指到已落盘的文件
17
17
  //! depends:
@@ -29,7 +29,6 @@
29
29
  //! ---
30
30
  use std::collections::{BTreeMap, BTreeSet};
31
31
  use std::path::{Path, PathBuf};
32
- use std::process::Command;
33
32
 
34
33
  use crate::lifecycle::*;
35
34
  use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider, ProviderEffort};
@@ -381,20 +380,19 @@ fn grok_occupied_cwd_error(
381
380
  }
382
381
 
383
382
  /// ---
384
- /// purpose: 起 grok 席前确认已登录且该目录已被信任
383
+ /// purpose: 起 grok 席前确认已登录;目录信任与用户同意交给可见 Grok TUI
385
384
  /// params:
386
- /// cwd: 席位的工作目录,用于判断目录信任
387
- /// returns: 通过返回空值;关闭 folder-trust 的环境变量下跳过信任检查
388
- /// errors: HOME 未设、登录态文件缺失或为空、目录未被信任时返回 RequirementUnmet
385
+ /// _cwd: 席位的工作目录;保留参数以维持现有调用契约
386
+ /// returns: 登录态存在时返回空值
387
+ /// errors: HOME 未设、登录态文件缺失或为空时返回 RequirementUnmet
389
388
  /// ---
390
- /// 未登录 / 目录未信任时不许起出「能收信、没有手」的 grok 席。
391
- /// 登录态看 `$HOME/.grok/auth.json`;目录信任看 `$HOME/.grok/trusted_folders.toml`
392
- /// (与 grok `--trust` / `/hooks-trust` 同一份;未信任则项目作用域 MCP 不生效)。
393
- /// `GROK_FOLDER_TRUST=0` 时 grok 自己关掉 folder-trust,本检查跟着放行。
394
- pub(crate) fn ensure_grok_login_and_folder_trust(cwd: &Path) -> Result<(), LifecycleError> {
389
+ /// 未登录时不许起出「能收信、没有手」的 grok 席。登录态只看
390
+ /// `$HOME/.grok/auth.json`;Team Agent 不判断或改变目录信任,Grok TUI
391
+ /// 如需用户同意会在可见 pane 内自行询问。
392
+ pub(crate) fn ensure_grok_login_and_folder_trust(_cwd: &Path) -> Result<(), LifecycleError> {
395
393
  let home = std::env::var_os("HOME").map(PathBuf::from).ok_or_else(|| {
396
394
  LifecycleError::RequirementUnmet(
397
- "error: HOME is unset; cannot verify grok login or folder trust\n\
395
+ "error: HOME is unset; cannot verify grok login\n\
398
396
  action: export HOME and retry"
399
397
  .to_string(),
400
398
  )
@@ -407,15 +405,6 @@ pub(crate) fn ensure_grok_login_and_folder_trust(cwd: &Path) -> Result<(), Lifec
407
405
  grok_home.join("auth.json").display()
408
406
  )));
409
407
  }
410
- if folder_trust_required() && !grok_folder_is_trusted(&grok_home, cwd) {
411
- return Err(LifecycleError::RequirementUnmet(format!(
412
- "error: grok folder is not trusted; project-scope MCP at {}/.grok/config.toml will not load\n\
413
- cwd: {}\n\
414
- action: from that directory run `grok --trust` (or `/hooks-trust` in a grok session), then retry",
415
- cwd.display(),
416
- cwd.display()
417
- )));
418
- }
419
408
  Ok(())
420
409
  }
421
410
 
@@ -430,44 +419,6 @@ fn grok_auth_present(grok_home: &Path) -> bool {
430
419
  value.as_object().is_some_and(|map| !map.is_empty())
431
420
  }
432
421
 
433
- fn folder_trust_required() -> bool {
434
- !matches!(
435
- std::env::var("GROK_FOLDER_TRUST").ok().as_deref(),
436
- Some("0") | Some("false") | Some("off")
437
- )
438
- }
439
-
440
- fn grok_folder_is_trusted(grok_home: &Path, cwd: &Path) -> bool {
441
- let text = std::fs::read_to_string(grok_home.join("trusted_folders.toml")).unwrap_or_default();
442
- let cwd = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
443
- grok_trusted_folders(&text).into_iter().any(|trusted| {
444
- let trusted = std::fs::canonicalize(&trusted).unwrap_or(trusted);
445
- cwd == trusted || cwd.starts_with(&trusted)
446
- })
447
- }
448
-
449
- fn grok_trusted_folders(text: &str) -> Vec<PathBuf> {
450
- let mut current = None;
451
- let mut out = Vec::new();
452
- for line in text.lines() {
453
- let trimmed = line.trim();
454
- if let Some(rest) = trimmed.strip_prefix("[folders.\"") {
455
- current = rest.strip_suffix("\"]").map(str::to_string);
456
- continue;
457
- }
458
- if trimmed.starts_with('[') {
459
- current = None;
460
- continue;
461
- }
462
- if trimmed == "trusted = true" {
463
- if let Some(path) = current.take() {
464
- out.push(PathBuf::from(path));
465
- }
466
- }
467
- }
468
- out
469
- }
470
-
471
422
  /// ---
472
423
  /// purpose: 写 workspace 下 grok 的项目作用域 MCP 配置,服务名固定为 team_orchestrator
473
424
  /// params: