@team-agent/installer 0.5.66 → 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.
- package/Cargo.lock +8 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +1 -0
- package/crates/team-agent/src/cli/adapters.rs +5 -0
- package/crates/team-agent/src/cli/diagnose.rs +156 -0
- package/crates/team-agent/src/cli/emit.rs +6 -5
- package/crates/team-agent/src/cli/grok_slot.rs +299 -0
- package/crates/team-agent/src/cli/leader.rs +22 -7
- package/crates/team-agent/src/cli/leaders.rs +125 -1
- package/crates/team-agent/src/cli/mod.rs +20 -0
- package/crates/team-agent/src/cli/send/presentation.rs +7 -1
- package/crates/team-agent/src/cli/spec.rs +3 -0
- package/crates/team-agent/src/cli/status_port/compact.rs +28 -0
- package/crates/team-agent/src/cli/status_port/snapshot.rs +25 -1
- package/crates/team-agent/src/cli/tests/base.rs +26 -3
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +129 -19
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +387 -6
- package/crates/team-agent/src/cli/tests/status_send.rs +79 -10
- package/crates/team-agent/src/communication_mode/mod.rs +6 -4
- package/crates/team-agent/src/compiler.rs +121 -5
- package/crates/team-agent/src/coordinator/backoff.rs +55 -0
- package/crates/team-agent/src/coordinator/conpty_shim.rs +82 -0
- package/crates/team-agent/src/coordinator/health.rs +173 -0
- package/crates/team-agent/src/coordinator/mod.rs +31 -0
- package/crates/team-agent/src/coordinator/orphan.rs +31 -0
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +31 -0
- package/crates/team-agent/src/coordinator/runtime_observation.rs +25 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +59 -0
- package/crates/team-agent/src/coordinator/steps/delivery.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/health_sync.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/mod.rs +22 -0
- package/crates/team-agent/src/coordinator/steps/persist.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/session_gate.rs +10 -0
- package/crates/team-agent/src/coordinator/tick.rs +133 -22
- package/crates/team-agent/src/coordinator/types.rs +49 -0
- package/crates/team-agent/src/db/message_store.rs +39 -5
- package/crates/team-agent/src/layout/worker_env.rs +20 -3
- package/crates/team-agent/src/layout/worker_window_helpers.rs +3 -0
- package/crates/team-agent/src/leader/provider_attribution.rs +53 -0
- package/crates/team-agent/src/leader/registry.rs +65 -0
- package/crates/team-agent/src/leader/start.rs +973 -75
- package/crates/team-agent/src/lifecycle/display.rs +56 -0
- package/crates/team-agent/src/lifecycle/helpers.rs +57 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +398 -26
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +80 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +60 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +101 -11
- package/crates/team-agent/src/lifecycle/launch/cursor_create_chat.rs +229 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +548 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +120 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +208 -456
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
- package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
- package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
- package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +482 -1
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +830 -0
- package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +87 -3
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +127 -25
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +140 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
- package/crates/team-agent/src/lifecycle/launch.rs +107 -29
- package/crates/team-agent/src/lifecycle/lock.rs +42 -1
- package/crates/team-agent/src/lifecycle/mod.rs +11 -0
- package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
- package/crates/team-agent/src/lifecycle/profile_launch.rs +99 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +52 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +214 -160
- package/crates/team-agent/src/lifecycle/restart/common.rs +513 -37
- package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +106 -0
- package/crates/team-agent/src/lifecycle/restart/remove.rs +233 -6
- package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
- package/crates/team-agent/src/lifecycle/restart.rs +29 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -3
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +24 -3
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
- package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +150 -157
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/core.rs +4 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +464 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +247 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
- 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 +787 -0
- package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
- package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +485 -0
- package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
- package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
- package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +63 -38
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +16 -8
- package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +20 -19
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +97 -61
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +97 -0
- package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +128 -0
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +207 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +838 -0
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +320 -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 +3 -0
- package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +40 -7
- package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +59 -1
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
- package/crates/team-agent/src/lifecycle/tests.rs +30 -13
- package/crates/team-agent/src/lifecycle/types.rs +61 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +143 -12
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
- package/crates/team-agent/src/mcp_server/tests.rs +196 -2
- package/crates/team-agent/src/messaging/delivery.rs +430 -41
- package/crates/team-agent/src/messaging/helpers.rs +2 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
- package/crates/team-agent/src/messaging/results.rs +4 -0
- package/crates/team-agent/src/messaging/send.rs +34 -0
- package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
- package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/types.rs +4 -1
- package/crates/team-agent/src/model/enums.rs +17 -6
- package/crates/team-agent/src/model/permissions.rs +3 -0
- package/crates/team-agent/src/os_probe.rs +73 -2
- package/crates/team-agent/src/provider/adapter.rs +259 -6
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +89 -0
- package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +3 -0
- package/crates/team-agent/src/provider/adapters/pi.rs +152 -0
- package/crates/team-agent/src/provider/bypass_flags.rs +47 -2
- package/crates/team-agent/src/provider/classify.rs +4 -2
- package/crates/team-agent/src/provider/faults.rs +2 -1
- package/crates/team-agent/src/provider/mod.rs +11 -0
- package/crates/team-agent/src/provider/session/capture.rs +158 -20
- package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
- package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
- package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
- package/crates/team-agent/src/provider/session/context_fork.rs +78 -3
- package/crates/team-agent/src/provider/session/mod.rs +18 -0
- package/crates/team-agent/src/provider/session/resume.rs +57 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
- package/crates/team-agent/src/provider/session_scan/common.rs +205 -2
- package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
- package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
- package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
- package/crates/team-agent/src/provider/session_scan/pi.rs +172 -0
- package/crates/team-agent/src/provider/session_scan.rs +12 -0
- package/crates/team-agent/src/provider/submit_now.rs +94 -0
- package/crates/team-agent/src/provider/tests/adapter.rs +384 -0
- package/crates/team-agent/src/provider/types.rs +4 -2
- package/crates/team-agent/src/provider/wire.rs +29 -1
- package/crates/team-agent/src/state/persist.rs +301 -8
- package/crates/team-agent/src/state/repository.rs +5 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
- package/crates/team-agent/src/tmux_backend.rs +1116 -75
- package/crates/team-agent/src/transport/tests/wire.rs +28 -2
- package/crates/team-agent/src/transport.rs +248 -1
- package/npm/install.mjs +129 -73
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +32 -239
- package/skills/team-agent/command-coverage.json +31 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -488
- package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +0 -447
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
//! ---
|
|
2
|
+
//! purpose: 把 team_orchestrator 身份写进 cursor 实际会读的 mcp.json
|
|
3
|
+
//! contract:
|
|
4
|
+
//! provides:
|
|
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
|
|
9
|
+
//! - name: cursor_mcp_enable_argv
|
|
10
|
+
//! what: 组 `agent mcp enable team_orchestrator`(不认 --workspace)
|
|
11
|
+
//! - name: enable_cursor_workspace_mcp
|
|
12
|
+
//! what: 在工程根跑 enable;测试隔离下跳过以免写 ~/.cursor
|
|
13
|
+
//! - name: physical_workspace_path
|
|
14
|
+
//! what: pwd -P 等价路径;mcp enable 按 getcwd 分片
|
|
15
|
+
//! - name: refuse_second_cursor_occupant
|
|
16
|
+
//! what: 隔离不可用时拒绝第二席;隔离可用时放行
|
|
17
|
+
//! boundary:
|
|
18
|
+
//! - 隔离开启时同 workspace 允许多 CursorAgent;隔离关闭/失败仍 fail-closed
|
|
19
|
+
//! - 不把身份从 json env 删掉(cursor 不继承父进程 TEAM_AGENT_*)
|
|
20
|
+
//! - 不改 HOME,不写 ~/.cursor/mcp.json 全局文件
|
|
21
|
+
//! - 不调 --approve-mcps(未验证能代替 enable)
|
|
22
|
+
//! maturity: wired
|
|
23
|
+
//! ---
|
|
24
|
+
|
|
25
|
+
use std::collections::BTreeMap;
|
|
26
|
+
use std::path::{Path, PathBuf};
|
|
27
|
+
use std::process::Command;
|
|
28
|
+
|
|
29
|
+
use sha2::{Digest, Sha256};
|
|
30
|
+
|
|
31
|
+
use crate::lifecycle::LifecycleError;
|
|
32
|
+
use crate::model::yaml::Value as YamlValue;
|
|
33
|
+
use crate::provider::wire::command_name;
|
|
34
|
+
use crate::provider::Provider;
|
|
35
|
+
|
|
36
|
+
use super::cursor_mcp_iso::{
|
|
37
|
+
cursor_mcp_isolation_enabled, cursor_mcp_project_dir, materialize_cursor_mcp_project,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/// Keys that must appear in mcp.json env. Cursor strips parent env down to
|
|
41
|
+
/// HOME/PATH/TERM/… — TEAM_AGENT_ID only survives if it is in this table.
|
|
42
|
+
const REQUIRED_IDENTITY_KEYS: &[&str] = &[
|
|
43
|
+
"TEAM_AGENT_WORKSPACE",
|
|
44
|
+
"TEAM_AGENT_ID",
|
|
45
|
+
"TEAM_AGENT_OWNER_TEAM_ID",
|
|
46
|
+
"TEAM_AGENT_AUTH_MODE",
|
|
47
|
+
];
|
|
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
|
+
|
|
166
|
+
/// ---
|
|
167
|
+
/// purpose: 给出 workspace 的物理路径
|
|
168
|
+
/// returns: 能 canonicalize 就用它,否则原样返回
|
|
169
|
+
/// ---
|
|
170
|
+
pub fn physical_workspace_path(workspace: &Path) -> PathBuf {
|
|
171
|
+
std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf())
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fn status_is_live(status: &str) -> bool {
|
|
175
|
+
!matches!(
|
|
176
|
+
status,
|
|
177
|
+
"stopped" | "stopping" | "removed" | "spawn_failed" | "failed"
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
fn agent_is_cursor(provider: Option<&str>) -> bool {
|
|
182
|
+
matches!(
|
|
183
|
+
provider.and_then(crate::lifecycle::profile_launch::parse_provider),
|
|
184
|
+
Some(Provider::CursorAgent)
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// ---
|
|
189
|
+
/// purpose: 同一物理 workspace 的第二 CursorAgent:隔离可用则放行,否则 fail-closed
|
|
190
|
+
/// params:
|
|
191
|
+
/// workspace: 物理工作目录
|
|
192
|
+
/// incoming_id: 正要起的席位 id
|
|
193
|
+
/// spec: 可选 yaml spec,用来在 state 尚未写入时看见第二席
|
|
194
|
+
/// returns: 隔离关闭且已有其它 CursorAgent 则 RequirementUnmet
|
|
195
|
+
/// contract:
|
|
196
|
+
/// provides:
|
|
197
|
+
/// - name: refuse_second_cursor_occupant
|
|
198
|
+
/// what: 共用 mcp.json last-writer 闸;隔离落地后不再挡第二席
|
|
199
|
+
/// boundary:
|
|
200
|
+
/// - 隔离失败/关闭时不改文案语义(仍点名 mcp.json last-writer)
|
|
201
|
+
/// - 不改 grok 独占实现
|
|
202
|
+
/// ---
|
|
203
|
+
pub fn refuse_second_cursor_occupant(
|
|
204
|
+
workspace: &Path,
|
|
205
|
+
incoming_id: &str,
|
|
206
|
+
spec: Option<&YamlValue>,
|
|
207
|
+
) -> Result<(), LifecycleError> {
|
|
208
|
+
let mut others = Vec::new();
|
|
209
|
+
if let Some(spec) = spec {
|
|
210
|
+
if let Some(agents) = spec.get("agents").and_then(YamlValue::as_list) {
|
|
211
|
+
for agent in agents {
|
|
212
|
+
let Some(id) = agent.get("id").and_then(YamlValue::as_str) else {
|
|
213
|
+
continue;
|
|
214
|
+
};
|
|
215
|
+
if id == incoming_id {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if agent_is_paused_yaml(agent) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if agent_is_cursor(agent.get("provider").and_then(YamlValue::as_str)) {
|
|
222
|
+
others.push(id.to_string());
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if let Ok(state) = crate::state::persist::load_runtime_state(workspace) {
|
|
228
|
+
if let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) {
|
|
229
|
+
for (id, agent) in agents {
|
|
230
|
+
if id == incoming_id {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
let provider = agent.get("provider").and_then(serde_json::Value::as_str);
|
|
234
|
+
if !agent_is_cursor(provider) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
let status = agent
|
|
238
|
+
.get("status")
|
|
239
|
+
.and_then(serde_json::Value::as_str)
|
|
240
|
+
.unwrap_or("running");
|
|
241
|
+
if status_is_live(status) {
|
|
242
|
+
others.push(id.clone());
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
others.sort();
|
|
248
|
+
others.dedup();
|
|
249
|
+
if others.is_empty() {
|
|
250
|
+
return Ok(());
|
|
251
|
+
}
|
|
252
|
+
if cursor_mcp_isolation_enabled() {
|
|
253
|
+
return Ok(());
|
|
254
|
+
}
|
|
255
|
+
Err(LifecycleError::RequirementUnmet(format!(
|
|
256
|
+
"error: cursor_agent seat already occupies this workspace\n\
|
|
257
|
+
incoming: {incoming_id}\n\
|
|
258
|
+
occupants: {}\n\
|
|
259
|
+
reason: <workspace>/.cursor/mcp.json is directory-scoped; a second seat overwrites TEAM_AGENT_ID (last-writer)\n\
|
|
260
|
+
workspace: {}\n\
|
|
261
|
+
action: do not add another CursorAgent in this workspace until per-seat MCP identity is isolated",
|
|
262
|
+
others.join(", "),
|
|
263
|
+
workspace.display(),
|
|
264
|
+
)))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
fn agent_is_paused_yaml(agent: &YamlValue) -> bool {
|
|
268
|
+
matches!(agent.get("paused"), Some(YamlValue::Bool(true)))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/// ---
|
|
272
|
+
/// purpose: 写 cursor 会读的 mcp.json;默认进 provider-config 工程根,关闭隔离时才写 workspace 文件
|
|
273
|
+
/// params:
|
|
274
|
+
/// mcp_config: 已解析的 MCP 配置,须含 team_orchestrator 与 command
|
|
275
|
+
/// returns: 写出的文件路径
|
|
276
|
+
/// errors: 缺条目或缺 command 时返回 StatePersist;隔离建盘失败返回 RequirementUnmet
|
|
277
|
+
/// ---
|
|
278
|
+
pub fn apply_cursor_mcp_overlay(
|
|
279
|
+
workspace: &Path,
|
|
280
|
+
mcp_config: &crate::provider::McpConfig,
|
|
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> {
|
|
307
|
+
let server = mcp_config
|
|
308
|
+
.raw
|
|
309
|
+
.get("team_orchestrator")
|
|
310
|
+
.or_else(|| mcp_config.raw.get("team-agent"))
|
|
311
|
+
.ok_or_else(|| {
|
|
312
|
+
LifecycleError::StatePersist(
|
|
313
|
+
"cursor MCP overlay requires team_orchestrator in resolved mcp config".to_string(),
|
|
314
|
+
)
|
|
315
|
+
})?;
|
|
316
|
+
let command = server
|
|
317
|
+
.get("command")
|
|
318
|
+
.and_then(serde_json::Value::as_str)
|
|
319
|
+
.filter(|value| !value.is_empty())
|
|
320
|
+
.ok_or_else(|| {
|
|
321
|
+
LifecycleError::StatePersist("cursor MCP overlay missing command".to_string())
|
|
322
|
+
})?
|
|
323
|
+
.to_string();
|
|
324
|
+
let args = server
|
|
325
|
+
.get("args")
|
|
326
|
+
.and_then(serde_json::Value::as_array)
|
|
327
|
+
.map(|items| {
|
|
328
|
+
items
|
|
329
|
+
.iter()
|
|
330
|
+
.filter_map(serde_json::Value::as_str)
|
|
331
|
+
.map(str::to_string)
|
|
332
|
+
.collect::<Vec<_>>()
|
|
333
|
+
})
|
|
334
|
+
.unwrap_or_default();
|
|
335
|
+
let env = server
|
|
336
|
+
.get("env")
|
|
337
|
+
.and_then(serde_json::Value::as_object)
|
|
338
|
+
.map(|map| {
|
|
339
|
+
map.iter()
|
|
340
|
+
.filter_map(|(key, value)| {
|
|
341
|
+
value.as_str().map(|text| (key.clone(), text.to_string()))
|
|
342
|
+
})
|
|
343
|
+
.collect::<BTreeMap<_, _>>()
|
|
344
|
+
})
|
|
345
|
+
.unwrap_or_default();
|
|
346
|
+
for key in REQUIRED_IDENTITY_KEYS {
|
|
347
|
+
match env.get(*key).map(String::as_str) {
|
|
348
|
+
Some(value) if !value.trim().is_empty() && !value.contains('{') => {}
|
|
349
|
+
_ => {
|
|
350
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
351
|
+
"cursor MCP overlay missing resolved {key} in json env \
|
|
352
|
+
(cursor does not inherit parent TEAM_AGENT_* into the MCP child)"
|
|
353
|
+
)));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
Ok((command, args, env))
|
|
358
|
+
}
|
|
359
|
+
|
|
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)
|
|
367
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", dir.display())))?;
|
|
368
|
+
let path = dir.join("mcp.json");
|
|
369
|
+
let mut root = read_existing_mcp_json(&path);
|
|
370
|
+
let servers = root
|
|
371
|
+
.as_object_mut()
|
|
372
|
+
.ok_or_else(|| {
|
|
373
|
+
LifecycleError::StatePersist(format!(
|
|
374
|
+
"{}: cursor mcp.json root must be an object",
|
|
375
|
+
path.display()
|
|
376
|
+
))
|
|
377
|
+
})?
|
|
378
|
+
.entry("mcpServers")
|
|
379
|
+
.or_insert_with(|| serde_json::json!({}));
|
|
380
|
+
let servers = servers.as_object_mut().ok_or_else(|| {
|
|
381
|
+
LifecycleError::StatePersist(format!("{}: mcpServers must be an object", path.display()))
|
|
382
|
+
})?;
|
|
383
|
+
// 0.5.67 overlay 曾误用 team-agent 作 inbound key。cursor 按 server 名
|
|
384
|
+
// 给工具加命名空间,旧名会变成另一套工具。写新表时摘掉旧名。
|
|
385
|
+
servers.remove("team-agent");
|
|
386
|
+
let mut env_json = serde_json::Map::new();
|
|
387
|
+
for (key, value) in &env {
|
|
388
|
+
env_json.insert(key.clone(), serde_json::Value::String(value.clone()));
|
|
389
|
+
}
|
|
390
|
+
servers.insert(
|
|
391
|
+
"team_orchestrator".to_string(),
|
|
392
|
+
serde_json::json!({
|
|
393
|
+
"command": command,
|
|
394
|
+
"args": args,
|
|
395
|
+
"env": env_json,
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
let body = serde_json::to_string_pretty(&root)
|
|
399
|
+
.map_err(|e| LifecycleError::StatePersist(format!("serialize cursor mcp.json: {e}")))?;
|
|
400
|
+
let tmp = dir.join("mcp.json.tmp");
|
|
401
|
+
std::fs::write(&tmp, body.as_bytes())
|
|
402
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", tmp.display())))?;
|
|
403
|
+
std::fs::rename(&tmp, &path)
|
|
404
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
|
|
405
|
+
Ok(path)
|
|
406
|
+
}
|
|
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
|
+
|
|
430
|
+
/// ---
|
|
431
|
+
/// purpose: 组出启用 team_orchestrator 的 cursor 命令行
|
|
432
|
+
/// returns: 不带 workspace 参数的 argv,该命令按进程工作目录分片
|
|
433
|
+
/// ---
|
|
434
|
+
pub fn cursor_mcp_enable_argv() -> Vec<String> {
|
|
435
|
+
vec![
|
|
436
|
+
command_name(Provider::CursorAgent).to_string(),
|
|
437
|
+
"mcp".to_string(),
|
|
438
|
+
"enable".to_string(),
|
|
439
|
+
"team_orchestrator".to_string(),
|
|
440
|
+
]
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/// ---
|
|
444
|
+
/// purpose: 在工程根下执行 cursor 的 MCP 启用命令(getcwd 分片,不改 HOME)
|
|
445
|
+
/// returns: 测试隔离环境或显式跳过标志下直接成功,避免写用户全局配置
|
|
446
|
+
/// errors: 命令跑不起来或退出码非零时返回 RequirementUnmet,附带有界脱敏诊断但不读出 json 内容
|
|
447
|
+
/// ---
|
|
448
|
+
pub fn enable_cursor_workspace_mcp(
|
|
449
|
+
workspace: &Path,
|
|
450
|
+
project_root: Option<&Path>,
|
|
451
|
+
) -> Result<(), LifecycleError> {
|
|
452
|
+
if skip_cursor_mcp_enable() {
|
|
453
|
+
return Ok(());
|
|
454
|
+
}
|
|
455
|
+
let physical = match project_root {
|
|
456
|
+
Some(root) => physical_workspace_path(root),
|
|
457
|
+
None => physical_workspace_path(workspace),
|
|
458
|
+
};
|
|
459
|
+
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
|
+
};
|
|
476
|
+
if output.status.success() {
|
|
477
|
+
return Ok(());
|
|
478
|
+
}
|
|
479
|
+
Err(LifecycleError::cursor_mcp_enable_failure(
|
|
480
|
+
&argv,
|
|
481
|
+
&physical,
|
|
482
|
+
output.status.code(),
|
|
483
|
+
&output.stdout,
|
|
484
|
+
&output.stderr,
|
|
485
|
+
None,
|
|
486
|
+
))
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/// ---
|
|
490
|
+
/// purpose: 把 argv 里 workspace 参数的值换成物理路径
|
|
491
|
+
/// params:
|
|
492
|
+
/// argv: 就地改写;没有该参数时什么都不做
|
|
493
|
+
/// ---
|
|
494
|
+
pub fn apply_cursor_workspace_physical_path(argv: &mut [String], workspace: &Path) {
|
|
495
|
+
let physical = physical_workspace_path(workspace);
|
|
496
|
+
let Some(index) = argv.iter().position(|arg| arg == "--workspace") else {
|
|
497
|
+
return;
|
|
498
|
+
};
|
|
499
|
+
if let Some(value) = argv.get_mut(index.saturating_add(1)) {
|
|
500
|
+
*value = physical.to_string_lossy().into_owned();
|
|
501
|
+
}
|
|
502
|
+
}
|
|
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
|
+
|
|
534
|
+
fn skip_cursor_mcp_enable() -> bool {
|
|
535
|
+
cfg!(test)
|
|
536
|
+
|| std::env::var_os("TEAM_AGENT_TEST_TMP").is_some()
|
|
537
|
+
|| matches!(
|
|
538
|
+
std::env::var("TEAM_AGENT_SKIP_CURSOR_MCP_ENABLE").as_deref(),
|
|
539
|
+
Ok("1") | Ok("true") | Ok("yes")
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
fn read_existing_mcp_json(path: &Path) -> serde_json::Value {
|
|
544
|
+
let Ok(text) = std::fs::read_to_string(path) else {
|
|
545
|
+
return serde_json::json!({"mcpServers": {}});
|
|
546
|
+
};
|
|
547
|
+
serde_json::from_str(&text).unwrap_or_else(|_| serde_json::json!({"mcpServers": {}}))
|
|
548
|
+
}
|
|
@@ -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
|
+
}
|