@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
@@ -3,21 +3,21 @@
3
3
  //! contract:
4
4
  //! provides:
5
5
  //! - name: apply_cursor_mcp_overlay
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
6
+ //! what: 写 <workspace>/.cursor/mcp.json,env 必须带 TEAM_AGENT_ID
9
7
  //! - name: cursor_mcp_enable_argv
10
8
  //! what: 组 `agent mcp enable team_orchestrator`(不认 --workspace)
11
9
  //! - name: enable_cursor_workspace_mcp
12
- //! what: 在工程根跑 enable;测试隔离下跳过以免写 ~/.cursor
10
+ //! what: 在工程根(可选)或物理工作目录跑 enable;测试隔离下跳过以免写 ~/.cursor
11
+ //! - name: apply_cursor_spawn_workspace_pointers
12
+ //! what: 隔离开时 --workspace 指 per-seat 工程根,--add-dir 指真 workspace
13
13
  //! - name: physical_workspace_path
14
14
  //! what: pwd -P 等价路径;mcp enable 按 getcwd 分片
15
15
  //! - name: refuse_second_cursor_occupant
16
- //! what: 隔离不可用时拒绝第二席;隔离可用时放行
16
+ //! what: 同一物理 workspace 第二 CursorAgent 拒绝(mcp.json last-writer)
17
17
  //! boundary:
18
- //! - 隔离开启时同 workspace 允许多 CursorAgent;隔离关闭/失败仍 fail-closed
18
+ //! - 同 workspace 第二 CursorAgent 拒绝;U-07 过前不装「已支持多席」
19
19
  //! - 不把身份从 json env 删掉(cursor 不继承父进程 TEAM_AGENT_*)
20
- //! - 不改 HOME,不写 ~/.cursor/mcp.json 全局文件
20
+ //! - 不写 ~/.cursor/mcp.json 全局文件
21
21
  //! - 不调 --approve-mcps(未验证能代替 enable)
22
22
  //! maturity: wired
23
23
  //! ---
@@ -26,8 +26,6 @@ use std::collections::BTreeMap;
26
26
  use std::path::{Path, PathBuf};
27
27
  use std::process::Command;
28
28
 
29
- use sha2::{Digest, Sha256};
30
-
31
29
  use crate::lifecycle::LifecycleError;
32
30
  use crate::model::yaml::Value as YamlValue;
33
31
  use crate::provider::wire::command_name;
@@ -46,123 +44,6 @@ const REQUIRED_IDENTITY_KEYS: &[&str] = &[
46
44
  "TEAM_AGENT_AUTH_MODE",
47
45
  ];
48
46
 
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
-
166
47
  /// ---
167
48
  /// purpose: 给出 workspace 的物理路径
168
49
  /// returns: 能 canonicalize 就用它,否则原样返回
@@ -186,18 +67,17 @@ fn agent_is_cursor(provider: Option<&str>) -> bool {
186
67
  }
187
68
 
188
69
  /// ---
189
- /// purpose: 同一物理 workspace 的第二 CursorAgent:隔离可用则放行,否则 fail-closed
70
+ /// purpose: 同一物理 workspace 拒绝第二 CursorAgent
190
71
  /// params:
191
72
  /// workspace: 物理工作目录
192
73
  /// incoming_id: 正要起的席位 id
193
74
  /// spec: 可选 yaml spec,用来在 state 尚未写入时看见第二席
194
- /// returns: 隔离关闭且已有其它 CursorAgent 则 RequirementUnmet
75
+ /// returns: 已有其它 CursorAgent 则 RequirementUnmet,文案含 TEAM_AGENT_ID 与 mcp.json
195
76
  /// contract:
196
77
  /// provides:
197
78
  /// - name: refuse_second_cursor_occupant
198
- /// what: 共用 mcp.json last-writer 闸;隔离落地后不再挡第二席
79
+ /// what: mcp.json last-writer 闸;U-07 过前不装多席
199
80
  /// boundary:
200
- /// - 隔离失败/关闭时不改文案语义(仍点名 mcp.json last-writer)
201
81
  /// - 不改 grok 独占实现
202
82
  /// ---
203
83
  pub fn refuse_second_cursor_occupant(
@@ -249,9 +129,6 @@ pub fn refuse_second_cursor_occupant(
249
129
  if others.is_empty() {
250
130
  return Ok(());
251
131
  }
252
- if cursor_mcp_isolation_enabled() {
253
- return Ok(());
254
- }
255
132
  Err(LifecycleError::RequirementUnmet(format!(
256
133
  "error: cursor_agent seat already occupies this workspace\n\
257
134
  incoming: {incoming_id}\n\
@@ -269,41 +146,16 @@ fn agent_is_paused_yaml(agent: &YamlValue) -> bool {
269
146
  }
270
147
 
271
148
  /// ---
272
- /// purpose: 写 cursor 会读的 mcp.json;默认进 provider-config 工程根,关闭隔离时才写 workspace 文件
149
+ /// purpose: 写 workspace 下 cursor 会读的 mcp.json,env 里必须带席位身份键
273
150
  /// params:
274
151
  /// mcp_config: 已解析的 MCP 配置,须含 team_orchestrator 与 command
275
152
  /// returns: 写出的文件路径
276
- /// errors: 缺条目或缺 command 时返回 StatePersist;隔离建盘失败返回 RequirementUnmet
153
+ /// errors: 缺条目或缺 command 时返回 StatePersist,写盘失败也返回 StatePersist
277
154
  /// ---
278
155
  pub fn apply_cursor_mcp_overlay(
279
156
  workspace: &Path,
280
157
  mcp_config: &crate::provider::McpConfig,
281
158
  ) -> 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> {
307
159
  let server = mcp_config
308
160
  .raw
309
161
  .get("team_orchestrator")
@@ -319,8 +171,7 @@ fn parse_orchestrator_env(
319
171
  .filter(|value| !value.is_empty())
320
172
  .ok_or_else(|| {
321
173
  LifecycleError::StatePersist("cursor MCP overlay missing command".to_string())
322
- })?
323
- .to_string();
174
+ })?;
324
175
  let args = server
325
176
  .get("args")
326
177
  .and_then(serde_json::Value::as_array)
@@ -354,16 +205,9 @@ fn parse_orchestrator_env(
354
205
  }
355
206
  }
356
207
  }
357
- Ok((command, args, env))
358
- }
359
208
 
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)
209
+ let dir = workspace.join(".cursor");
210
+ std::fs::create_dir_all(&dir)
367
211
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", dir.display())))?;
368
212
  let path = dir.join("mcp.json");
369
213
  let mut root = read_existing_mcp_json(&path);
@@ -405,28 +249,6 @@ fn write_cursor_mcp_json_at(
405
249
  Ok(path)
406
250
  }
407
251
 
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
-
430
252
  /// ---
431
253
  /// purpose: 组出启用 team_orchestrator 的 cursor 命令行
432
254
  /// returns: 不带 workspace 参数的 argv,该命令按进程工作目录分片
@@ -441,49 +263,99 @@ pub fn cursor_mcp_enable_argv() -> Vec<String> {
441
263
  }
442
264
 
443
265
  /// ---
444
- /// purpose: 在工程根下执行 cursor 的 MCP 启用命令(getcwd 分片,不改 HOME)
266
+ /// purpose: 在物理工作目录下执行 cursor 的 MCP 启用命令
445
267
  /// returns: 测试隔离环境或显式跳过标志下直接成功,避免写用户全局配置
446
- /// errors: 命令跑不起来或退出码非零时返回 RequirementUnmet,附带有界脱敏诊断但不读出 json 内容
268
+ /// errors: 命令跑不起来或退出码非零时返回 RequirementUnmet,错误里只记输出长度不记内容
447
269
  /// ---
270
+ pub fn cursor_mcp_enable_working_dir(workspace: &Path, project_root: Option<&Path>) -> PathBuf {
271
+ match project_root {
272
+ Some(root) => physical_workspace_path(root),
273
+ None => physical_workspace_path(workspace),
274
+ }
275
+ }
276
+
277
+ pub fn prepare_cursor_seat_mcp(
278
+ workspace: &Path,
279
+ agent_id: &str,
280
+ mcp_config: &crate::provider::McpConfig,
281
+ ) -> Result<Option<PathBuf>, LifecycleError> {
282
+ if cursor_mcp_isolation_enabled() {
283
+ let project = materialize_cursor_mcp_project(workspace, agent_id)?;
284
+ apply_cursor_mcp_overlay(&project, mcp_config)?;
285
+ Ok(Some(project))
286
+ } else {
287
+ apply_cursor_mcp_overlay(workspace, mcp_config)?;
288
+ Ok(None)
289
+ }
290
+ }
291
+
448
292
  pub fn enable_cursor_workspace_mcp(
449
293
  workspace: &Path,
450
294
  project_root: Option<&Path>,
451
295
  ) -> Result<(), LifecycleError> {
296
+ enable_cursor_workspace_mcp_with_profile(workspace, project_root, None)
297
+ }
298
+
299
+ /// Run Cursor's MCP enable command with the same profile-local environment
300
+ /// policy as the worker spawn. In particular, subscription direct mode must
301
+ /// not reintroduce proxy URL keys during this preparatory command.
302
+ pub fn enable_cursor_workspace_mcp_with_profile(
303
+ workspace: &Path,
304
+ project_root: Option<&Path>,
305
+ profile_launch: Option<&crate::provider::ProviderProfileLaunch>,
306
+ ) -> Result<(), LifecycleError> {
307
+ let physical = cursor_mcp_enable_working_dir(workspace, project_root);
452
308
  if skip_cursor_mcp_enable() {
453
309
  return Ok(());
454
310
  }
455
- let physical = match project_root {
456
- Some(root) => physical_workspace_path(root),
457
- None => physical_workspace_path(workspace),
458
- };
459
311
  let argv = cursor_mcp_enable_argv();
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
- };
312
+ let mut command = Command::new(&argv[0]);
313
+ command
314
+ .args(&argv[1..])
315
+ .current_dir(&physical);
316
+ apply_cursor_mcp_enable_profile_env(&mut command, profile_launch);
317
+ let output = command.output().map_err(|e| {
318
+ LifecycleError::RequirementUnmet(format!(
319
+ "error: cannot run `{} mcp enable team_orchestrator`\n\
320
+ reason: {e}\n\
321
+ action: install cursor-agent on PATH (same binary as `agent`) and retry",
322
+ argv[0]
323
+ ))
324
+ })?;
476
325
  if output.status.success() {
477
326
  return Ok(());
478
327
  }
479
- Err(LifecycleError::cursor_mcp_enable_failure(
480
- &argv,
481
- &physical,
328
+ let stderr = String::from_utf8_lossy(&output.stderr);
329
+ let stdout = String::from_utf8_lossy(&output.stdout);
330
+ Err(LifecycleError::RequirementUnmet(format!(
331
+ "error: `{} mcp enable team_orchestrator` failed (exit {:?})\n\
332
+ reason: without enable, cursor keeps MCP as not loaded (needs approval)\n\
333
+ cwd: {}\n\
334
+ stdout_len: {}\n\
335
+ stderr_len: {}\n\
336
+ action: from that directory run `{} mcp enable team_orchestrator`",
337
+ argv[0],
482
338
  output.status.code(),
483
- &output.stdout,
484
- &output.stderr,
485
- None,
486
- ))
339
+ physical.display(),
340
+ stdout.len(),
341
+ stderr.len(),
342
+ argv[0]
343
+ )))
344
+ }
345
+
346
+ pub(crate) fn apply_cursor_mcp_enable_profile_env(
347
+ command: &mut Command,
348
+ profile_launch: Option<&crate::provider::ProviderProfileLaunch>,
349
+ ) {
350
+ let Some(profile_launch) = profile_launch else {
351
+ return;
352
+ };
353
+ for key in &profile_launch.env_unset {
354
+ command.env_remove(key);
355
+ }
356
+ for (key, value) in &profile_launch.env_overlay {
357
+ command.env(key, value);
358
+ }
487
359
  }
488
360
 
489
361
  /// ---
@@ -503,11 +375,6 @@ pub fn apply_cursor_workspace_physical_path(argv: &mut [String], workspace: &Pat
503
375
 
504
376
  /// ---
505
377
  /// purpose: 隔离开时把 cursor --workspace 指到 per-seat 工程根,并用 --add-dir 挂上真 workspace
506
- /// params:
507
- /// argv: 就地改写(可能追加 --add-dir)
508
- /// workspace: 团队工作目录
509
- /// agent_id: 席位 id
510
- /// errors: 工程根路径非法时返回 RequirementUnmet
511
378
  /// ---
512
379
  pub fn apply_cursor_spawn_workspace_pointers(
513
380
  argv: &mut Vec<String>,
@@ -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 未登录时拒绝起席;目录信任与用户同意由可见 Grok TUI 负责
14
+ //! what: grok 未登录或目录未信任时拒绝起席
15
15
  //! - name: point_native_mcp_config_at_file
16
16
  //! what: 把 argv 里的 MCP 配置参数改指到已落盘的文件
17
17
  //! depends:
@@ -29,6 +29,7 @@
29
29
  //! ---
30
30
  use std::collections::{BTreeMap, BTreeSet};
31
31
  use std::path::{Path, PathBuf};
32
+ use std::process::Command;
32
33
 
33
34
  use crate::lifecycle::*;
34
35
  use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider, ProviderEffort};
@@ -380,19 +381,20 @@ fn grok_occupied_cwd_error(
380
381
  }
381
382
 
382
383
  /// ---
383
- /// purpose: 起 grok 席前确认已登录;目录信任与用户同意交给可见 Grok TUI
384
+ /// purpose: 起 grok 席前确认已登录且该目录已被信任
384
385
  /// params:
385
- /// _cwd: 席位的工作目录;保留参数以维持现有调用契约
386
- /// returns: 登录态存在时返回空值
387
- /// errors: HOME 未设、登录态文件缺失或为空时返回 RequirementUnmet
386
+ /// cwd: 席位的工作目录,用于判断目录信任
387
+ /// returns: 通过返回空值;关闭 folder-trust 的环境变量下跳过信任检查
388
+ /// errors: HOME 未设、登录态文件缺失或为空、目录未被信任时返回 RequirementUnmet
388
389
  /// ---
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> {
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> {
393
395
  let home = std::env::var_os("HOME").map(PathBuf::from).ok_or_else(|| {
394
396
  LifecycleError::RequirementUnmet(
395
- "error: HOME is unset; cannot verify grok login\n\
397
+ "error: HOME is unset; cannot verify grok login or folder trust\n\
396
398
  action: export HOME and retry"
397
399
  .to_string(),
398
400
  )
@@ -405,6 +407,15 @@ pub(crate) fn ensure_grok_login_and_folder_trust(_cwd: &Path) -> Result<(), Life
405
407
  grok_home.join("auth.json").display()
406
408
  )));
407
409
  }
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
+ }
408
419
  Ok(())
409
420
  }
410
421
 
@@ -419,6 +430,44 @@ fn grok_auth_present(grok_home: &Path) -> bool {
419
430
  value.as_object().is_some_and(|map| !map.is_empty())
420
431
  }
421
432
 
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
+
422
471
  /// ---
423
472
  /// purpose: 写 workspace 下 grok 的项目作用域 MCP 配置,服务名固定为 team_orchestrator
424
473
  /// params:
@@ -151,14 +151,30 @@ pub(crate) fn parse_pi_list_models_table(bytes: &[u8]) -> Result<Vec<String>, Pr
151
151
  if line.trim().is_empty() {
152
152
  continue;
153
153
  }
154
- let columns = line.split_whitespace().collect::<Vec<_>>();
155
- if columns.len() < 2 || columns[0].is_empty() || columns[1].is_empty() {
156
- return Err(ProviderError::Command(format!(
154
+ let malformed = || {
155
+ ProviderError::Command(format!(
157
156
  "Pi model catalog row {} is malformed",
158
157
  index + 2
159
- )));
158
+ ))
159
+ };
160
+ let (provider, model) = line
161
+ .trim()
162
+ .split_once(char::is_whitespace)
163
+ .ok_or_else(malformed)?;
164
+ let mut model = model.trim();
165
+ // Pi prints the raw model id, which may contain spaces. Only the
166
+ // capability columns following it are single whitespace-free values.
167
+ for _ in 2..header_columns.len() {
168
+ model = model
169
+ .rsplit_once(char::is_whitespace)
170
+ .ok_or_else(malformed)?
171
+ .0
172
+ .trim_end();
173
+ }
174
+ if model.is_empty() {
175
+ return Err(malformed());
160
176
  }
161
- let exact = format!("{}/{}", columns[0], columns[1]);
177
+ let exact = format!("{provider}/{model}");
162
178
  if !seen.insert(exact.clone()) {
163
179
  return Err(ProviderError::Command(format!(
164
180
  "Pi model catalog contains duplicate exact id {exact:?}"
@@ -642,6 +658,64 @@ fn observe_pi_catalog_test(f: impl FnOnce(&mut PiCatalogTestContext)) {
642
658
  });
643
659
  }
644
660
 
661
+ fn spawn_catalog_reader(
662
+ stdout: std::process::ChildStdout,
663
+ max_bytes: u64,
664
+ ) -> mpsc::Receiver<(std::io::Result<usize>, Vec<u8>)> {
665
+ let (sender, receiver) = mpsc::channel();
666
+ std::thread::spawn(move || {
667
+ let mut bytes = Vec::new();
668
+ let result = stdout.take(max_bytes + 1).read_to_end(&mut bytes);
669
+ let _ = sender.send((result, bytes));
670
+ });
671
+ receiver
672
+ }
673
+
674
+ fn finish_catalog_reader(
675
+ receiver: mpsc::Receiver<(std::io::Result<usize>, Vec<u8>)>,
676
+ status: std::process::ExitStatus,
677
+ timeout: Duration,
678
+ max_bytes: u64,
679
+ ) -> Result<Vec<u8>, String> {
680
+ let (result, bytes) = match receiver.recv_timeout(timeout) {
681
+ Ok(value) => value,
682
+ Err(_) => {
683
+ #[cfg(test)]
684
+ observe_pi_catalog_test(|context| context.observation.reader_timeout = true);
685
+ return Err("Pi model catalog command timed out".to_string());
686
+ }
687
+ };
688
+ if !status.success() {
689
+ return Err("Pi model catalog command failed".into());
690
+ }
691
+ result.map_err(|_| "Pi model catalog could not be read".to_string())?;
692
+ if bytes.len() as u64 > max_bytes {
693
+ return Err("Pi model catalog exceeds the bounded output limit".into());
694
+ }
695
+ Ok(bytes)
696
+ }
697
+
698
+ #[cfg(test)]
699
+ pub(crate) fn run_pi_catalog_after_parent_exit_for_test(
700
+ stdout: std::process::ChildStdout,
701
+ parent_pid: u32,
702
+ status: std::process::ExitStatus,
703
+ timeout: Duration,
704
+ max_bytes: u64,
705
+ ) -> Result<Vec<u8>, String> {
706
+ observe_pi_catalog_test(|context| {
707
+ context.observation.parent_pid = Some(parent_pid);
708
+ context.observation.parent_exit_success = Some(status.success());
709
+ context.observation.parent_exit_code = status.code();
710
+ });
711
+ finish_catalog_reader(
712
+ spawn_catalog_reader(stdout, max_bytes),
713
+ status,
714
+ timeout,
715
+ max_bytes,
716
+ )
717
+ }
718
+
645
719
  /// Discover candidates with one PATH-first catalog observation. This deliberately
646
720
  /// does not resolve adapters or scan wrapper chains; callers use it only for
647
721
  /// rejecting an unqualified role model before lifecycle mutation.
@@ -685,12 +759,7 @@ pub(crate) fn run_pi_catalog(
685
759
  let _ = child.wait();
686
760
  "Pi model catalog output unavailable".to_string()
687
761
  })?;
688
- let (sender, receiver) = mpsc::channel();
689
- std::thread::spawn(move || {
690
- let mut bytes = Vec::new();
691
- let result = stdout.take(max_bytes + 1).read_to_end(&mut bytes);
692
- let _ = sender.send((result, bytes));
693
- });
762
+ let receiver = spawn_catalog_reader(stdout, max_bytes);
694
763
  let start = Instant::now();
695
764
  loop {
696
765
  match child.try_wait() {
@@ -700,18 +769,12 @@ pub(crate) fn run_pi_catalog(
700
769
  context.observation.parent_exit_success = Some(status.success());
701
770
  context.observation.parent_exit_code = status.code();
702
771
  });
703
- let (result, bytes) = match receiver.recv_timeout(timeout.saturating_sub(start.elapsed())) {
704
- Ok(value) => value,
705
- Err(_) => {
706
- #[cfg(test)]
707
- observe_pi_catalog_test(|context| context.observation.reader_timeout = true);
708
- return Err("Pi model catalog command timed out".to_string());
709
- }
710
- };
711
- if !status.success() { return Err("Pi model catalog command failed".into()); }
712
- result.map_err(|_| "Pi model catalog could not be read".to_string())?;
713
- if bytes.len() as u64 > max_bytes { return Err("Pi model catalog exceeds the bounded output limit".into()); }
714
- return Ok(bytes);
772
+ return finish_catalog_reader(
773
+ receiver,
774
+ status,
775
+ timeout.saturating_sub(start.elapsed()),
776
+ max_bytes,
777
+ );
715
778
  }
716
779
  Ok(None) if start.elapsed() < timeout => std::thread::sleep(Duration::from_millis(20)),
717
780
  Ok(None) => {