@team-agent/installer 0.5.68 → 0.5.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/diagnose.rs +74 -0
- package/crates/team-agent/src/cli/emit.rs +5 -4
- package/crates/team-agent/src/cli/leader.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +1 -0
- package/crates/team-agent/src/compiler.rs +54 -7
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +1 -0
- package/crates/team-agent/src/layout/worker_window_helpers.rs +1 -0
- package/crates/team-agent/src/leader/start.rs +53 -12
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +67 -15
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +18 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +260 -44
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +120 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +5 -1
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +10 -59
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +838 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +9 -3
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +60 -28
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch.rs +10 -2
- package/crates/team-agent/src/lifecycle/profile_launch.rs +3 -2
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +90 -160
- package/crates/team-agent/src/lifecycle/restart/common.rs +136 -28
- package/crates/team-agent/src/lifecycle/restart/remove.rs +37 -1
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +24 -3
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +195 -18
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +30 -12
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_g0.stdout.txt +222 -0
- package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +5 -2
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +35 -48
- package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +97 -0
- package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +145 -0
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +243 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +856 -0
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +387 -0
- package/crates/team-agent/src/lifecycle/tests/pi_lifecycle_doctor_red.rs +98 -0
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +216 -0
- package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests.rs +7 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +61 -4
- package/crates/team-agent/src/messaging/delivery.rs +36 -12
- package/crates/team-agent/src/model/enums.rs +3 -1
- package/crates/team-agent/src/model/permissions.rs +1 -1
- package/crates/team-agent/src/provider/adapter.rs +29 -3
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +5 -2
- package/crates/team-agent/src/provider/adapters/mod.rs +1 -0
- package/crates/team-agent/src/provider/adapters/pi.rs +146 -0
- package/crates/team-agent/src/provider/bypass_flags.rs +1 -0
- package/crates/team-agent/src/provider/classify.rs +2 -2
- package/crates/team-agent/src/provider/faults.rs +1 -1
- package/crates/team-agent/src/provider/session/context_fork.rs +2 -1
- package/crates/team-agent/src/provider/session_scan/common.rs +1 -0
- package/crates/team-agent/src/provider/session_scan/pi.rs +172 -0
- package/crates/team-agent/src/provider/session_scan.rs +4 -0
- package/crates/team-agent/src/provider/tests/adapter.rs +80 -0
- package/crates/team-agent/src/provider/wire.rs +7 -1
- package/crates/team-agent/src/tmux_backend.rs +52 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +9 -5
|
@@ -3,19 +3,21 @@
|
|
|
3
3
|
//! contract:
|
|
4
4
|
//! provides:
|
|
5
5
|
//! - name: apply_cursor_mcp_overlay
|
|
6
|
-
//! what:
|
|
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:
|
|
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:
|
|
16
|
+
//! what: 隔离不可用时拒绝第二席;隔离可用时放行
|
|
15
17
|
//! boundary:
|
|
16
|
-
//! -
|
|
18
|
+
//! - 隔离开启时同 workspace 允许多 CursorAgent;隔离关闭/失败仍 fail-closed
|
|
17
19
|
//! - 不把身份从 json env 删掉(cursor 不继承父进程 TEAM_AGENT_*)
|
|
18
|
-
//! -
|
|
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
|
|
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:
|
|
194
|
+
/// returns: 隔离关闭且已有其它 CursorAgent 则 RequirementUnmet
|
|
70
195
|
/// contract:
|
|
71
196
|
/// provides:
|
|
72
197
|
/// - name: refuse_second_cursor_occupant
|
|
73
|
-
/// what: mcp.json last-writer
|
|
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: 写
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
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:
|
|
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(
|
|
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 =
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
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
|
-
///
|
|
387
|
-
/// returns:
|
|
388
|
-
/// errors: HOME
|
|
385
|
+
/// _cwd: 席位的工作目录;保留参数以维持现有调用契约
|
|
386
|
+
/// returns: 登录态存在时返回空值
|
|
387
|
+
/// errors: HOME 未设、登录态文件缺失或为空时返回 RequirementUnmet
|
|
389
388
|
/// ---
|
|
390
|
-
///
|
|
391
|
-
///
|
|
392
|
-
///
|
|
393
|
-
|
|
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
|
|
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:
|