@team-agent/installer 0.5.120 → 0.5.121
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/intent/runtime.rs +46 -10
- package/crates/team-agent/src/cli/intent/runtime_tests.rs +114 -0
- package/crates/team-agent/src/cli/mod.rs +4 -1
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +56 -0
- package/crates/team-agent/src/db/message_store.rs +65 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +8 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +111 -4
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +244 -82
- package/crates/team-agent/src/lifecycle/launch.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +7 -1
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +60 -17
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +30 -7
- package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +47 -4
- package/crates/team-agent/src/mcp_server/mod.rs +2 -0
- package/crates/team-agent/src/mcp_server/readiness.rs +432 -0
- package/crates/team-agent/src/mcp_server/readiness_tests.rs +242 -0
- package/crates/team-agent/src/mcp_server/wire.rs +96 -11
- package/crates/team-agent/src/messaging/delivery.rs +62 -0
- package/crates/team-agent/src/messaging/tests/dup_inject.rs +41 -1
- package/crates/team-agent/src/provider/adapter.rs +7 -0
- package/package.json +4 -4
|
@@ -1,120 +1,282 @@
|
|
|
1
|
-
//!
|
|
2
|
-
//!
|
|
3
|
-
//!
|
|
4
|
-
//!
|
|
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
|
-
//! ---
|
|
1
|
+
//! Cursor's native MCP project root must be an owned primary outside Git ancestry.
|
|
2
|
+
//! Keep the real project as --add-dir; never overwrite its shared MCP config or
|
|
3
|
+
//! silently relocate an existing native session. The persisted directory receipt
|
|
4
|
+
//! is transport ownership, not a canonical Provider/native-session identity.
|
|
17
5
|
|
|
6
|
+
use std::io::{Read, Write};
|
|
7
|
+
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
|
|
18
8
|
use std::path::{Path, PathBuf};
|
|
19
9
|
|
|
10
|
+
use serde::{Deserialize, Serialize};
|
|
11
|
+
use team_agent_contract::contract::types::{
|
|
12
|
+
Generation, InstanceId, InstanceIdentity, ScopeId, SeatId,
|
|
13
|
+
};
|
|
14
|
+
use team_agent_contract::host::files::{DirectoryReceipt, ScopedDirectory};
|
|
15
|
+
use team_agent_contract::host::{digest, digest_hex};
|
|
16
|
+
|
|
20
17
|
use crate::lifecycle::LifecycleError;
|
|
21
18
|
|
|
22
19
|
const ISOLATION_ENV: &str = "TEAM_AGENT_CURSOR_MCP_ISOLATION";
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
#[derive(Serialize, Deserialize)]
|
|
22
|
+
#[serde(deny_unknown_fields)]
|
|
23
|
+
struct ProjectRecord {
|
|
24
|
+
workspace: PathBuf,
|
|
25
|
+
agent_id: String,
|
|
26
|
+
owner_team_id: String,
|
|
27
|
+
directory: DirectoryReceipt,
|
|
28
|
+
}
|
|
29
|
+
|
|
27
30
|
pub fn cursor_mcp_isolation_enabled() -> bool {
|
|
28
31
|
match std::env::var(ISOLATION_ENV) {
|
|
29
|
-
Ok(value) =>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"0" | "false" | "FALSE" | "off" | "OFF" | "no" | "NO"
|
|
34
|
-
)
|
|
35
|
-
}
|
|
32
|
+
Ok(value) => !matches!(
|
|
33
|
+
value.trim(),
|
|
34
|
+
"0" | "false" | "FALSE" | "off" | "OFF" | "no" | "NO"
|
|
35
|
+
),
|
|
36
36
|
Err(_) => true,
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
fn failure(reason: impl std::fmt::Display) -> LifecycleError {
|
|
41
|
+
LifecycleError::RequirementUnmet(format!(
|
|
42
|
+
"cursor MCP private project refused: {reason}; do not overwrite shared .cursor/mcp.json or move an existing session"
|
|
43
|
+
))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn record_path(workspace: &Path, agent_id: &str) -> PathBuf {
|
|
47
|
+
workspace
|
|
48
|
+
.join(".team/runtime/provider-config")
|
|
49
|
+
.join(agent_id)
|
|
50
|
+
.join("cursor-project.json")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fn project_key(workspace: &Path, agent_id: &str) -> String {
|
|
54
|
+
digest_hex(digest(
|
|
55
|
+
format!("{}\0{agent_id}", workspace.display()).as_bytes(),
|
|
56
|
+
))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fn planned_project(workspace: &Path, agent_id: &str) -> Result<PathBuf, LifecycleError> {
|
|
60
|
+
// Do not assume TMPDIR is outside Git, or use an unchecked symlink alias.
|
|
61
|
+
let parent = std::env::temp_dir().canonicalize().map_err(failure)?;
|
|
62
|
+
validate_cursor_mcp_project_root(&parent)?;
|
|
63
|
+
Ok(parent.join(format!(
|
|
64
|
+
"ta-cursor-{}",
|
|
65
|
+
&project_key(workspace, agent_id)[..32]
|
|
66
|
+
)))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Computes a new path without creating it; existing allocations must reopen
|
|
70
|
+
/// their captured receipt, never adopt a replacement directory or a new TMPDIR.
|
|
43
71
|
pub fn cursor_mcp_project_dir(workspace: &Path, agent_id: &str) -> Result<PathBuf, LifecycleError> {
|
|
44
72
|
validate_agent_id(agent_id)?;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.join("cursor"))
|
|
73
|
+
let workspace = workspace.canonicalize().map_err(failure)?;
|
|
74
|
+
match read_record(&workspace, agent_id)? {
|
|
75
|
+
Some(record) => Ok(reopen(&record)?.path().to_path_buf()),
|
|
76
|
+
None => planned_project(&workspace, agent_id),
|
|
77
|
+
}
|
|
51
78
|
}
|
|
52
79
|
|
|
53
|
-
/// ---
|
|
54
|
-
/// purpose: 该席位 cursor 实际会读的项目 mcp.json
|
|
55
|
-
/// ---
|
|
56
80
|
pub fn cursor_mcp_json_path(workspace: &Path, agent_id: &str) -> Result<PathBuf, LifecycleError> {
|
|
57
|
-
Ok(cursor_mcp_project_dir(workspace, agent_id)
|
|
58
|
-
|
|
59
|
-
|
|
81
|
+
Ok(cursor_mcp_project_dir(workspace, agent_id)?.join(".cursor/mcp.json"))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Read-only scope proof for CLI planning. An external pathname or a matching
|
|
85
|
+
/// directory name is not ownership: the existing selected-team record must pin
|
|
86
|
+
/// this exact physical cwd. Never materialize or repair during preflight.
|
|
87
|
+
pub(crate) fn cursor_mcp_primary_matches_scope(
|
|
88
|
+
workspace: &Path,
|
|
89
|
+
owner_team_id: &str,
|
|
90
|
+
agent_id: &str,
|
|
91
|
+
declared_cwd: &Path,
|
|
92
|
+
) -> Result<bool, LifecycleError> {
|
|
93
|
+
validate_agent_id(agent_id)?;
|
|
94
|
+
let workspace = workspace.canonicalize().map_err(failure)?;
|
|
95
|
+
let Some(record) = read_record(&workspace, agent_id)? else {
|
|
96
|
+
return Ok(false);
|
|
97
|
+
};
|
|
98
|
+
if record.owner_team_id != owner_team_id || record.directory.path != declared_cwd {
|
|
99
|
+
return Ok(false);
|
|
100
|
+
}
|
|
101
|
+
Ok(reopen(&record)?.path() == declared_cwd)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Metadata-only mirror of the measured native project-root boundary. A .git
|
|
105
|
+
/// directory OR worktree file on any ancestor would redirect native MCP loading.
|
|
106
|
+
pub(super) fn validate_cursor_mcp_project_root(project: &Path) -> Result<(), LifecycleError> {
|
|
107
|
+
if project.canonicalize().map_err(failure)? != project {
|
|
108
|
+
return Err(failure("project path is not physical"));
|
|
109
|
+
}
|
|
110
|
+
for ancestor in project.ancestors() {
|
|
111
|
+
match std::fs::symlink_metadata(ancestor.join(".git")) {
|
|
112
|
+
Ok(_) => {
|
|
113
|
+
return Err(failure(
|
|
114
|
+
"Git ancestor would redirect native MCP projectRoot",
|
|
115
|
+
))
|
|
116
|
+
}
|
|
117
|
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
118
|
+
Err(e) => return Err(failure(e)),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
Ok(())
|
|
60
122
|
}
|
|
61
123
|
|
|
62
|
-
/// ---
|
|
63
|
-
/// purpose: 建 per-seat 工程根,并把团队 rules 链进该根(不链 mcp.json)
|
|
64
|
-
/// returns: 工程根
|
|
65
|
-
/// errors: agent_id 非法或建盘失败 → RequirementUnmet(不写共用 mcp.json)
|
|
66
|
-
/// ---
|
|
67
124
|
pub fn materialize_cursor_mcp_project(
|
|
68
125
|
workspace: &Path,
|
|
69
126
|
agent_id: &str,
|
|
127
|
+
owner_team_id: &str,
|
|
70
128
|
) -> Result<PathBuf, LifecycleError> {
|
|
71
|
-
|
|
72
|
-
let
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
))
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
129
|
+
validate_agent_id(agent_id)?;
|
|
130
|
+
let workspace = workspace.canonicalize().map_err(failure)?;
|
|
131
|
+
if owner_team_id.trim().is_empty() {
|
|
132
|
+
return Err(failure("missing owner team"));
|
|
133
|
+
}
|
|
134
|
+
if let Some(record) = read_record(&workspace, agent_id)? {
|
|
135
|
+
if record.owner_team_id != owner_team_id {
|
|
136
|
+
return Err(failure("record belongs to another owner team"));
|
|
137
|
+
}
|
|
138
|
+
return Ok(reopen(&record)?.path().to_path_buf());
|
|
139
|
+
}
|
|
140
|
+
let legacy = workspace
|
|
141
|
+
.join(".team/runtime/provider-config")
|
|
142
|
+
.join(agent_id)
|
|
143
|
+
.join("cursor");
|
|
144
|
+
match std::fs::symlink_metadata(&legacy) {
|
|
145
|
+
Ok(_) => return Err(failure("unrecorded legacy primary exists; preserve its cwd/SID and explicitly select a fresh seat")),
|
|
146
|
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
147
|
+
Err(e) => return Err(failure(e)),
|
|
148
|
+
}
|
|
149
|
+
let path = planned_project(&workspace, agent_id)?;
|
|
150
|
+
let key = project_key(&workspace, agent_id);
|
|
151
|
+
let mut nonce = [0u8; 32];
|
|
152
|
+
std::fs::File::open("/dev/urandom")
|
|
153
|
+
.and_then(|mut f| f.read_exact(&mut nonce))
|
|
154
|
+
.map_err(failure)?;
|
|
155
|
+
let owner = InstanceIdentity {
|
|
156
|
+
scope: ScopeId::new(format!("legacy-cursor-{}", &key[..32])).map_err(failure)?,
|
|
157
|
+
seat: SeatId::new(format!("seat-{}", &key[32..])).map_err(failure)?,
|
|
158
|
+
instance: InstanceId::new(format!("cwd-{}", digest_hex(digest(&nonce))))
|
|
159
|
+
.map_err(failure)?,
|
|
160
|
+
generation: Generation(1),
|
|
161
|
+
};
|
|
162
|
+
let directory = ScopedDirectory::create(&path, owner).map_err(failure)?;
|
|
163
|
+
std::fs::DirBuilder::new()
|
|
164
|
+
.mode(0o700)
|
|
165
|
+
.create(path.join(".cursor"))
|
|
166
|
+
.map_err(failure)?;
|
|
167
|
+
link_workspace_rules(&workspace, &path.join(".cursor"))?;
|
|
168
|
+
let record = ProjectRecord {
|
|
169
|
+
workspace: workspace.clone(),
|
|
170
|
+
agent_id: agent_id.into(),
|
|
171
|
+
owner_team_id: owner_team_id.into(),
|
|
172
|
+
directory: directory.receipt().clone(),
|
|
173
|
+
};
|
|
174
|
+
let record_path = record_path(&workspace, agent_id);
|
|
175
|
+
let parent = record_path
|
|
176
|
+
.parent()
|
|
177
|
+
.ok_or_else(|| failure("missing record parent"))?;
|
|
178
|
+
std::fs::create_dir_all(parent).map_err(failure)?;
|
|
179
|
+
if parent.canonicalize().map_err(failure)? != parent {
|
|
180
|
+
return Err(failure("record parent is a symlink"));
|
|
181
|
+
}
|
|
182
|
+
let bytes = serde_json::to_vec(&record).map_err(failure)?;
|
|
183
|
+
let mut file = std::fs::OpenOptions::new()
|
|
184
|
+
.write(true)
|
|
185
|
+
.create_new(true)
|
|
186
|
+
.mode(0o600)
|
|
187
|
+
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
|
188
|
+
.open(record_path)
|
|
189
|
+
.map_err(failure)?;
|
|
190
|
+
file.write_all(&bytes)
|
|
191
|
+
.and_then(|()| file.sync_all())
|
|
192
|
+
.map_err(failure)?;
|
|
193
|
+
directory.check_live().map_err(failure)?;
|
|
194
|
+
validate_cursor_mcp_project_root(&path)?;
|
|
195
|
+
Ok(path)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fn read_record(workspace: &Path, agent_id: &str) -> Result<Option<ProjectRecord>, LifecycleError> {
|
|
199
|
+
let path = record_path(workspace, agent_id);
|
|
200
|
+
let metadata = match std::fs::symlink_metadata(&path) {
|
|
201
|
+
Ok(metadata) => metadata,
|
|
202
|
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
203
|
+
Err(e) => return Err(failure(e)),
|
|
204
|
+
};
|
|
205
|
+
if !metadata.is_file()
|
|
206
|
+
|| metadata.file_type().is_symlink()
|
|
207
|
+
|| metadata.nlink() != 1
|
|
208
|
+
|| metadata.uid() != unsafe { libc::geteuid() }
|
|
209
|
+
|| metadata.mode() & 0o077 != 0
|
|
210
|
+
|| path.parent().and_then(|p| p.canonicalize().ok()).as_deref() != path.parent()
|
|
211
|
+
{
|
|
212
|
+
return Err(failure("unowned or aliased project record"));
|
|
213
|
+
}
|
|
214
|
+
let file = std::fs::OpenOptions::new()
|
|
215
|
+
.read(true)
|
|
216
|
+
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
|
217
|
+
.open(&path)
|
|
218
|
+
.map_err(failure)?;
|
|
219
|
+
let opened = file.metadata().map_err(failure)?;
|
|
220
|
+
if opened.dev() != metadata.dev() || opened.ino() != metadata.ino() {
|
|
221
|
+
return Err(failure("project record replaced"));
|
|
222
|
+
}
|
|
223
|
+
let mut bytes = Vec::new();
|
|
224
|
+
file.take(65537).read_to_end(&mut bytes).map_err(failure)?;
|
|
225
|
+
if bytes.len() > 65536 {
|
|
226
|
+
return Err(failure("oversized project record"));
|
|
227
|
+
}
|
|
228
|
+
let record: ProjectRecord = serde_json::from_slice(&bytes).map_err(failure)?;
|
|
229
|
+
if record.workspace != workspace
|
|
230
|
+
|| record.agent_id != agent_id
|
|
231
|
+
|| record.owner_team_id.trim().is_empty()
|
|
232
|
+
{
|
|
233
|
+
return Err(failure("project record identity mismatch"));
|
|
234
|
+
}
|
|
235
|
+
Ok(Some(record))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fn reopen(record: &ProjectRecord) -> Result<ScopedDirectory, LifecycleError> {
|
|
239
|
+
let key = project_key(&record.workspace, &record.agent_id);
|
|
240
|
+
if record.directory.path.file_name().and_then(|n| n.to_str())
|
|
241
|
+
!= Some(format!("ta-cursor-{}", &key[..32]).as_str())
|
|
242
|
+
|| record.directory.owner.scope.as_str() != format!("legacy-cursor-{}", &key[..32])
|
|
243
|
+
|| record.directory.owner.seat.as_str() != format!("seat-{}", &key[32..])
|
|
244
|
+
|| record.directory.owner.generation != Generation(1)
|
|
245
|
+
{
|
|
246
|
+
return Err(failure("directory receipt identity mismatch"));
|
|
247
|
+
}
|
|
248
|
+
let directory = ScopedDirectory::reopen(record.directory.clone()).map_err(failure)?;
|
|
249
|
+
validate_cursor_mcp_project_root(directory.path())?;
|
|
250
|
+
let cursor = directory.path().join(".cursor");
|
|
251
|
+
let metadata = std::fs::symlink_metadata(&cursor).map_err(failure)?;
|
|
252
|
+
if !metadata.is_dir()
|
|
253
|
+
|| metadata.file_type().is_symlink()
|
|
254
|
+
|| metadata.uid() != unsafe { libc::geteuid() }
|
|
255
|
+
|| metadata.mode() & 0o077 != 0
|
|
256
|
+
{
|
|
257
|
+
return Err(failure("unowned .cursor directory"));
|
|
258
|
+
}
|
|
259
|
+
Ok(directory)
|
|
84
260
|
}
|
|
85
261
|
|
|
86
262
|
fn validate_agent_id(agent_id: &str) -> Result<(), LifecycleError> {
|
|
87
263
|
if agent_id.is_empty()
|
|
88
|
-
|| agent_id.contains('/')
|
|
89
|
-
|| agent_id.contains('\\')
|
|
90
264
|
|| agent_id.contains("..")
|
|
91
265
|
|| !agent_id
|
|
92
266
|
.chars()
|
|
93
|
-
.all(|c| c.is_ascii_alphanumeric() || c
|
|
267
|
+
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
|
94
268
|
{
|
|
95
|
-
return Err(
|
|
96
|
-
"
|
|
97
|
-
|
|
98
|
-
action: rename the seat; do not share <workspace>/.cursor/mcp.json"
|
|
99
|
-
)));
|
|
269
|
+
return Err(failure(
|
|
270
|
+
"agent id must be a single path segment [A-Za-z0-9._-]+",
|
|
271
|
+
));
|
|
100
272
|
}
|
|
101
273
|
Ok(())
|
|
102
274
|
}
|
|
103
275
|
|
|
104
276
|
fn link_workspace_rules(workspace: &Path, dest_cursor: &Path) -> Result<(), LifecycleError> {
|
|
105
|
-
let source = workspace.join(".cursor
|
|
106
|
-
if
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
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
|
-
})
|
|
277
|
+
let source = workspace.join(".cursor/rules");
|
|
278
|
+
if source.exists() {
|
|
279
|
+
std::os::unix::fs::symlink(source, dest_cursor.join("rules")).map_err(failure)?;
|
|
280
|
+
}
|
|
281
|
+
Ok(())
|
|
120
282
|
}
|
|
@@ -375,6 +375,7 @@ pub(crate) fn run_pi_catalog_preflight(
|
|
|
375
375
|
}
|
|
376
376
|
|
|
377
377
|
mod cursor_mcp_iso;
|
|
378
|
+
pub(crate) use cursor_mcp_iso::cursor_mcp_primary_matches_scope;
|
|
378
379
|
pub use cursor_mcp_iso::{
|
|
379
380
|
cursor_mcp_isolation_enabled, cursor_mcp_json_path, cursor_mcp_project_dir,
|
|
380
381
|
materialize_cursor_mcp_project,
|
|
@@ -462,6 +462,9 @@ pub(super) fn spawn_agent_window(
|
|
|
462
462
|
&mut env,
|
|
463
463
|
)?;
|
|
464
464
|
}
|
|
465
|
+
// Keep provider/native cwd distinct from the real Team workspace. Cursor
|
|
466
|
+
// changes cwd to --workspace before writing its cwd-keyed session archive.
|
|
467
|
+
let mut native_spawn_cwd = spawn_cwd.to_path_buf();
|
|
465
468
|
// 0.5.67 Cursor 方案 1 变体: role 经 workspace rules 文件注入 (不 argv)。
|
|
466
469
|
if provider == crate::provider::Provider::CursorAgent {
|
|
467
470
|
crate::lifecycle::launch::refuse_second_cursor_occupant(
|
|
@@ -479,6 +482,9 @@ pub(super) fn spawn_agent_window(
|
|
|
479
482
|
agent_id.as_str(),
|
|
480
483
|
&mcp_config,
|
|
481
484
|
)?;
|
|
485
|
+
if let Some(primary) = project.as_ref() {
|
|
486
|
+
native_spawn_cwd = primary.clone();
|
|
487
|
+
}
|
|
482
488
|
crate::lifecycle::launch::enable_cursor_workspace_mcp_with_profile(
|
|
483
489
|
workspace,
|
|
484
490
|
project.as_deref(),
|
|
@@ -694,7 +700,7 @@ pub(super) fn spawn_agent_window(
|
|
|
694
700
|
plan,
|
|
695
701
|
profile_launch,
|
|
696
702
|
layout_placement: layout_placement.cloned(),
|
|
697
|
-
spawn_cwd:
|
|
703
|
+
spawn_cwd: native_spawn_cwd,
|
|
698
704
|
owner_team_id: team_id,
|
|
699
705
|
})
|
|
700
706
|
}
|
|
@@ -262,10 +262,7 @@ fn cursor_mcp_enable_child_env_applies_direct_profile_unsets() {
|
|
|
262
262
|
}
|
|
263
263
|
command.env("NO_PROXY", "fixture");
|
|
264
264
|
command.env("no_proxy", "fixture");
|
|
265
|
-
crate::lifecycle::launch::apply_cursor_mcp_enable_profile_env(
|
|
266
|
-
&mut command,
|
|
267
|
-
Some(&launch),
|
|
268
|
-
);
|
|
265
|
+
crate::lifecycle::launch::apply_cursor_mcp_enable_profile_env(&mut command, Some(&launch));
|
|
269
266
|
let output = command.output().expect("env child should run");
|
|
270
267
|
assert!(output.status.success());
|
|
271
268
|
let text = String::from_utf8(output.stdout).unwrap();
|
|
@@ -280,7 +277,9 @@ fn cursor_mcp_enable_child_env_applies_direct_profile_unsets() {
|
|
|
280
277
|
"GLOBAL_AGENT_HTTP_PROXY",
|
|
281
278
|
] {
|
|
282
279
|
assert!(
|
|
283
|
-
!text
|
|
280
|
+
!text
|
|
281
|
+
.lines()
|
|
282
|
+
.any(|line| line.starts_with(&format!("{key}="))),
|
|
284
283
|
"direct MCP child must omit {key}"
|
|
285
284
|
);
|
|
286
285
|
}
|
|
@@ -326,7 +325,38 @@ fn cursor_spawn_writes_identity_into_project_mcp_json() {
|
|
|
326
325
|
let enable_cwd = cursor_mcp_enable_working_dir(&ws, Some(&project));
|
|
327
326
|
assert_eq!(enable_cwd, physical_workspace_path(&project));
|
|
328
327
|
let argv_workspace = spawn_argv_workspace_flag(&ws).expect("spawn argv --workspace");
|
|
329
|
-
assert_eq!(
|
|
328
|
+
assert_eq!(
|
|
329
|
+
argv_workspace,
|
|
330
|
+
physical_workspace_path(&project).to_string_lossy()
|
|
331
|
+
);
|
|
332
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
333
|
+
let row = state.pointer("/agents/cursor_writer").unwrap();
|
|
334
|
+
assert_eq!(row["spawn_cwd"], project.to_string_lossy().as_ref());
|
|
335
|
+
assert_eq!(row["session_id"], serde_json::Value::Null);
|
|
336
|
+
|
|
337
|
+
// Measured native layout: the store bucket uses --workspace primary, not
|
|
338
|
+
// --add-dir. A decoy in the real project's bucket must never be selected.
|
|
339
|
+
let sid = "d298b261-23bb-42bb-bbeb-b2ec08d81ca3";
|
|
340
|
+
for (cwd, id) in [(&project, sid), (&ws, "foreign-project-session")] {
|
|
341
|
+
let bucket = format!("{:x}", md5::compute(cwd.to_string_lossy().as_bytes()));
|
|
342
|
+
let archive = _hermetic.home().join(".cursor/chats").join(bucket).join(id);
|
|
343
|
+
std::fs::create_dir_all(&archive).unwrap();
|
|
344
|
+
std::fs::write(archive.join("meta.json"), b"{}").unwrap();
|
|
345
|
+
}
|
|
346
|
+
let mut capture = serde_json::json!({"agents": {"cursor_writer": row.clone()}});
|
|
347
|
+
let report = crate::provider::session::capture::capture_missing_provider_sessions_once(
|
|
348
|
+
&mut capture,
|
|
349
|
+
&mut crate::provider::get_adapter,
|
|
350
|
+
false,
|
|
351
|
+
0,
|
|
352
|
+
)
|
|
353
|
+
.unwrap();
|
|
354
|
+
assert_eq!(report.assigned, vec!["cursor_writer"]);
|
|
355
|
+
assert_eq!(capture["agents"]["cursor_writer"]["session_id"], sid);
|
|
356
|
+
assert_eq!(
|
|
357
|
+
capture["agents"]["cursor_writer"]["spawn_cwd"],
|
|
358
|
+
project.to_string_lossy().as_ref()
|
|
359
|
+
);
|
|
330
360
|
}
|
|
331
361
|
|
|
332
362
|
#[test]
|
|
@@ -361,13 +391,14 @@ fn cursor_spawn_isolation_off_writes_workspace_mcp_json() {
|
|
|
361
391
|
});
|
|
362
392
|
assert_cursor_identity_overlay(&text, "cursor_writer", &ws);
|
|
363
393
|
assert!(
|
|
364
|
-
!cursor_mcp_json_path(&ws, "cursor_writer")
|
|
365
|
-
.unwrap()
|
|
366
|
-
.exists(),
|
|
394
|
+
!cursor_mcp_json_path(&ws, "cursor_writer").unwrap().exists(),
|
|
367
395
|
"isolation-off must not materialize the per-seat project overlay"
|
|
368
396
|
);
|
|
369
397
|
let argv_workspace = spawn_argv_workspace_flag(&ws).expect("spawn argv --workspace");
|
|
370
|
-
assert_eq!(
|
|
398
|
+
assert_eq!(
|
|
399
|
+
argv_workspace,
|
|
400
|
+
physical_workspace_path(&ws).to_string_lossy()
|
|
401
|
+
);
|
|
371
402
|
}
|
|
372
403
|
|
|
373
404
|
fn assert_cursor_identity_overlay(text: &str, agent_id: &str, workspace: &Path) {
|
|
@@ -441,7 +472,7 @@ fn sample_mcp_config(agent_id: &str, workspace: &str) -> McpConfig {
|
|
|
441
472
|
McpConfig {
|
|
442
473
|
raw: serde_json::json!({
|
|
443
474
|
"team_orchestrator": {
|
|
444
|
-
"command":
|
|
475
|
+
"command": std::env::current_exe().unwrap(),
|
|
445
476
|
"args": ["mcp-server", "--workspace", workspace],
|
|
446
477
|
"env": {
|
|
447
478
|
"TEAM_AGENT_ID": agent_id,
|
|
@@ -458,12 +489,21 @@ fn sample_mcp_config(agent_id: &str, workspace: &str) -> McpConfig {
|
|
|
458
489
|
#[serial(env)]
|
|
459
490
|
fn isolated_cursor_prepare_materializes_per_seat_project_not_workspace_overlay() {
|
|
460
491
|
let ws = tmp_dir("cursor-iso-clean");
|
|
461
|
-
let project = prepare_cursor_seat_mcp(
|
|
462
|
-
|
|
463
|
-
|
|
492
|
+
let project = prepare_cursor_seat_mcp(
|
|
493
|
+
&ws,
|
|
494
|
+
"seat-iso",
|
|
495
|
+
&sample_mcp_config("seat-iso", &ws.to_string_lossy()),
|
|
496
|
+
)
|
|
497
|
+
.expect("prepare isolated cursor")
|
|
498
|
+
.expect("isolation defaults on");
|
|
464
499
|
assert!(
|
|
465
|
-
project.
|
|
466
|
-
|
|
500
|
+
!project.starts_with(&ws)
|
|
501
|
+
&& project
|
|
502
|
+
.file_name()
|
|
503
|
+
.unwrap()
|
|
504
|
+
.to_string_lossy()
|
|
505
|
+
.starts_with("ta-cursor-"),
|
|
506
|
+
"per-seat native project must be outside workspace Git ancestry: {}",
|
|
467
507
|
project.display()
|
|
468
508
|
);
|
|
469
509
|
assert!(project.join(".cursor").is_dir());
|
|
@@ -489,7 +529,10 @@ fn isolated_cursor_prepare_materializes_per_seat_project_not_workspace_overlay()
|
|
|
489
529
|
.find(|pair| pair[0] == "--workspace")
|
|
490
530
|
.map(|pair| pair[1].as_str())
|
|
491
531
|
.unwrap();
|
|
492
|
-
assert_eq!(
|
|
532
|
+
assert_eq!(
|
|
533
|
+
workspace_flag,
|
|
534
|
+
physical_workspace_path(&project).to_string_lossy()
|
|
535
|
+
);
|
|
493
536
|
assert_eq!(
|
|
494
537
|
include_str!("../launch/spawn.rs").contains("prepare_cursor_seat_mcp("),
|
|
495
538
|
true,
|
|
@@ -26,14 +26,13 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
26
26
|
|
|
27
27
|
use crate::lifecycle::restart::classify_restart_plan_with_resume_validation;
|
|
28
28
|
use crate::lifecycle::types::ResumeDecision;
|
|
29
|
-
use crate::
|
|
30
|
-
|
|
31
|
-
};
|
|
29
|
+
use crate::model::ids::AgentId;
|
|
30
|
+
use crate::provider::session_scan::cursor::{cursor_session_archive_present, cursor_session_dir};
|
|
32
31
|
use crate::state::persist::{load_runtime_state, save_runtime_state};
|
|
33
32
|
use serial_test::serial;
|
|
34
|
-
use crate::model::ids::AgentId;
|
|
35
33
|
use team_agent::lifecycle::{
|
|
36
|
-
fork_agent_with_transport, quick_start_with_transport_in_workspace,
|
|
34
|
+
cursor_mcp_project_dir, fork_agent_with_transport, quick_start_with_transport_in_workspace,
|
|
35
|
+
restart_with_transport,
|
|
37
36
|
};
|
|
38
37
|
use team_agent::transport::test_support::OfflineTransport;
|
|
39
38
|
|
|
@@ -86,11 +85,16 @@ fn cursor_restart_uses_resume_when_session_captured_and_archive_present() {
|
|
|
86
85
|
quick_start_with_transport_in_workspace(&ws, &team, None, true, Some("cursortm"), &first)
|
|
87
86
|
.expect("initial cursor start");
|
|
88
87
|
let sid = "502896a1-72ba-4c53-9a86-b2da28780806";
|
|
88
|
+
let primary = cursor_mcp_project_dir(&ws, "cursor_writer").unwrap();
|
|
89
|
+
let primary_before = std::fs::canonicalize(&primary).unwrap();
|
|
89
90
|
let dir = {
|
|
90
91
|
let path = home
|
|
91
92
|
.join(".cursor")
|
|
92
93
|
.join("chats")
|
|
93
|
-
.join(
|
|
94
|
+
.join(format!(
|
|
95
|
+
"{:x}",
|
|
96
|
+
md5::compute(primary.to_string_lossy().as_bytes())
|
|
97
|
+
))
|
|
94
98
|
.join(sid);
|
|
95
99
|
std::fs::create_dir_all(&path).unwrap();
|
|
96
100
|
std::fs::write(path.join("store.db"), b"").unwrap();
|
|
@@ -100,7 +104,11 @@ fn cursor_restart_uses_resume_when_session_captured_and_archive_present() {
|
|
|
100
104
|
assert_eq!(cursor_session_dir(sid).as_deref(), Some(dir.as_path()));
|
|
101
105
|
|
|
102
106
|
let mut state = load_runtime_state(&ws).expect("load");
|
|
103
|
-
|
|
107
|
+
assert_eq!(
|
|
108
|
+
state["agents"]["cursor_writer"]["spawn_cwd"],
|
|
109
|
+
primary.to_string_lossy().as_ref()
|
|
110
|
+
);
|
|
111
|
+
patch_cursor_writer(&mut state, sid, &dir, &primary);
|
|
104
112
|
save_runtime_state(&ws, &state).expect("save captured");
|
|
105
113
|
|
|
106
114
|
seed_healthy_coordinator(&ws);
|
|
@@ -121,6 +129,21 @@ fn cursor_restart_uses_resume_when_session_captured_and_archive_present() {
|
|
|
121
129
|
!argv.iter().any(|a| a == "--continue"),
|
|
122
130
|
"resume argv must not use --continue; argv={argv:?}"
|
|
123
131
|
);
|
|
132
|
+
let after = load_runtime_state(&ws).unwrap();
|
|
133
|
+
assert_eq!(
|
|
134
|
+
after["agents"]["cursor_writer"]["spawn_cwd"],
|
|
135
|
+
primary.to_string_lossy().as_ref()
|
|
136
|
+
);
|
|
137
|
+
assert_eq!(after["agents"]["cursor_writer"]["session_id"], sid);
|
|
138
|
+
assert_eq!(
|
|
139
|
+
cursor_mcp_project_dir(&ws, "cursor_writer").unwrap(),
|
|
140
|
+
primary_before
|
|
141
|
+
);
|
|
142
|
+
assert_eq!(argv_flag(&argv, "--workspace").as_deref(), primary.to_str());
|
|
143
|
+
assert!(argv_contains_adjacent(
|
|
144
|
+
&argv,
|
|
145
|
+
&["--add-dir", ws.to_str().unwrap()]
|
|
146
|
+
));
|
|
124
147
|
}
|
|
125
148
|
|
|
126
149
|
#[test]
|