@team-agent/installer 0.5.120 → 0.5.122
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/coordinator/tests/health_sync.rs +27 -8
- 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/agent.rs +3 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +10 -1
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -0
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +16 -3
- 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 +162 -8
- package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +55 -6
- package/crates/team-agent/src/mcp_server/mod.rs +2 -0
- package/crates/team-agent/src/mcp_server/readiness.rs +495 -0
- package/crates/team-agent/src/mcp_server/readiness_tests.rs +272 -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/delivery_consumption_tests.rs +12 -1
- package/crates/team-agent/src/messaging/tests/dup_inject.rs +89 -5
- package/crates/team-agent/src/messaging/tests/spine.rs +45 -14
- 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,
|
|
@@ -1410,6 +1410,9 @@ pub(super) fn mark_agent_started(
|
|
|
1410
1410
|
"spawn_cwd".to_string(),
|
|
1411
1411
|
serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
|
|
1412
1412
|
);
|
|
1413
|
+
if let Some(path) = &spawn.mcp_config_path {
|
|
1414
|
+
agent.insert("mcp_config".to_string(), serde_json::json!(path));
|
|
1415
|
+
}
|
|
1413
1416
|
// 0.4.6 Stage 1+2: ensure spawn_epoch is present after every start.
|
|
1414
1417
|
// reset_agent_at_paths bumps it before start; non-reset starts
|
|
1415
1418
|
// initialise to 1 (or preserve existing) so capture/event paths can
|
|
@@ -45,6 +45,8 @@ pub(super) struct SpawnedAgentWindow {
|
|
|
45
45
|
pub profile_launch: crate::provider::ProviderProfileLaunch,
|
|
46
46
|
pub layout_placement: Option<crate::lifecycle::launch::LayoutPlacement>,
|
|
47
47
|
pub spawn_cwd: std::path::PathBuf,
|
|
48
|
+
/// Actual configuration generated for this spawn, not inferred from old state.
|
|
49
|
+
pub mcp_config_path: Option<std::path::PathBuf>,
|
|
48
50
|
/// Issue 2 (Round 3b gate review §6): the resolved `owner_team_id` used
|
|
49
51
|
/// for this spawn's MCP env / command. Callers (`mark_agent_respawned`,
|
|
50
52
|
/// `mark_agent_started`) must persist this back into the agent row so
|
|
@@ -462,6 +464,9 @@ pub(super) fn spawn_agent_window(
|
|
|
462
464
|
&mut env,
|
|
463
465
|
)?;
|
|
464
466
|
}
|
|
467
|
+
// Keep provider/native cwd distinct from the real Team workspace. Cursor
|
|
468
|
+
// changes cwd to --workspace before writing its cwd-keyed session archive.
|
|
469
|
+
let mut native_spawn_cwd = spawn_cwd.to_path_buf();
|
|
465
470
|
// 0.5.67 Cursor 方案 1 变体: role 经 workspace rules 文件注入 (不 argv)。
|
|
466
471
|
if provider == crate::provider::Provider::CursorAgent {
|
|
467
472
|
crate::lifecycle::launch::refuse_second_cursor_occupant(
|
|
@@ -479,6 +484,9 @@ pub(super) fn spawn_agent_window(
|
|
|
479
484
|
agent_id.as_str(),
|
|
480
485
|
&mcp_config,
|
|
481
486
|
)?;
|
|
487
|
+
if let Some(primary) = project.as_ref() {
|
|
488
|
+
native_spawn_cwd = primary.clone();
|
|
489
|
+
}
|
|
482
490
|
crate::lifecycle::launch::enable_cursor_workspace_mcp_with_profile(
|
|
483
491
|
workspace,
|
|
484
492
|
project.as_deref(),
|
|
@@ -694,7 +702,8 @@ pub(super) fn spawn_agent_window(
|
|
|
694
702
|
plan,
|
|
695
703
|
profile_launch,
|
|
696
704
|
layout_placement: layout_placement.cloned(),
|
|
697
|
-
spawn_cwd:
|
|
705
|
+
spawn_cwd: native_spawn_cwd,
|
|
706
|
+
mcp_config_path,
|
|
698
707
|
owner_team_id: team_id,
|
|
699
708
|
})
|
|
700
709
|
}
|
|
@@ -2480,6 +2480,9 @@ fn mark_agent_respawned(
|
|
|
2480
2480
|
"spawn_cwd".to_string(),
|
|
2481
2481
|
serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
|
|
2482
2482
|
);
|
|
2483
|
+
if let Some(path) = &spawn.mcp_config_path {
|
|
2484
|
+
agent.insert("mcp_config".to_string(), serde_json::json!(path));
|
|
2485
|
+
}
|
|
2483
2486
|
// 0.5.38 (`.team/artifacts/startup-latency-locate.md` §7): every
|
|
2484
2487
|
// multi-worker restart respawn is a fresh process cohort. The
|
|
2485
2488
|
// pre-0.5.38 code only wrote `spawned_at` here and left `spawn_epoch`
|
|
@@ -2968,6 +2971,7 @@ impl Clone for ParallelSpawnResult {
|
|
|
2968
2971
|
profile_launch: self.spawn.profile_launch.clone(),
|
|
2969
2972
|
layout_placement: self.spawn.layout_placement.clone(),
|
|
2970
2973
|
spawn_cwd: self.spawn.spawn_cwd.clone(),
|
|
2974
|
+
mcp_config_path: self.spawn.mcp_config_path.clone(),
|
|
2971
2975
|
owner_team_id: self.spawn.owner_team_id.clone(),
|
|
2972
2976
|
},
|
|
2973
2977
|
spawn_start: self.spawn_start,
|
|
@@ -4232,6 +4236,7 @@ tasks:
|
|
|
4232
4236
|
profile_launch: crate::provider::ProviderProfileLaunch::default(),
|
|
4233
4237
|
layout_placement: None,
|
|
4234
4238
|
spawn_cwd: std::path::PathBuf::from("/tmp/team-epoch"),
|
|
4239
|
+
mcp_config_path: None,
|
|
4235
4240
|
owner_team_id: None,
|
|
4236
4241
|
};
|
|
4237
4242
|
mark_agent_respawned(
|
|
@@ -59,6 +59,14 @@ fn b4_monitor_step_failure_does_not_block_deliver_pending() {
|
|
|
59
59
|
let _g = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
|
|
60
60
|
let ws = tmp_ws("b4-monitor");
|
|
61
61
|
seed_copilot_team_state(&ws, "cp1", true);
|
|
62
|
+
let mut state = team_agent::state::persist::load_runtime_state(&ws).unwrap();
|
|
63
|
+
#[cfg(unix)]
|
|
64
|
+
let targets = vec![crate::mcp_server::readiness::test_ready_worker(
|
|
65
|
+
&ws, &mut state, "team-b4", "cp1", "%41",
|
|
66
|
+
)];
|
|
67
|
+
#[cfg(not(unix))]
|
|
68
|
+
let targets = Vec::new();
|
|
69
|
+
save_runtime_state(&ws, &state).unwrap();
|
|
62
70
|
let store = MessageStore::open(&ws).unwrap();
|
|
63
71
|
store
|
|
64
72
|
.create_message(
|
|
@@ -68,14 +76,17 @@ fn b4_monitor_step_failure_does_not_block_deliver_pending() {
|
|
|
68
76
|
"do the thing",
|
|
69
77
|
None,
|
|
70
78
|
false,
|
|
71
|
-
|
|
79
|
+
Some("team-b4"),
|
|
72
80
|
)
|
|
73
81
|
.unwrap();
|
|
74
82
|
|
|
75
83
|
let coord = Coordinator::new(
|
|
76
84
|
WorkspacePath::new(ws.clone()),
|
|
77
85
|
Box::new(RealRegistry),
|
|
78
|
-
Box::new(MonitorFailTransport
|
|
86
|
+
Box::new(MonitorFailTransport {
|
|
87
|
+
targets,
|
|
88
|
+
..MonitorFailTransport::new()
|
|
89
|
+
}),
|
|
79
90
|
);
|
|
80
91
|
let report = coord.tick();
|
|
81
92
|
|
|
@@ -603,11 +614,13 @@ fn attach_socket_path(command: &str) -> Option<PathBuf> {
|
|
|
603
614
|
/// (B-4). Sessions/windows present so the tick proceeds to the monitor steps.
|
|
604
615
|
struct MonitorFailTransport {
|
|
605
616
|
session: Mutex<bool>,
|
|
617
|
+
targets: Vec<PaneInfo>,
|
|
606
618
|
}
|
|
607
619
|
impl MonitorFailTransport {
|
|
608
620
|
fn new() -> Self {
|
|
609
621
|
Self {
|
|
610
622
|
session: Mutex::new(true),
|
|
623
|
+
targets: Vec::new(),
|
|
611
624
|
}
|
|
612
625
|
}
|
|
613
626
|
}
|
|
@@ -688,7 +701,7 @@ impl Transport for MonitorFailTransport {
|
|
|
688
701
|
}
|
|
689
702
|
}
|
|
690
703
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
691
|
-
Ok(
|
|
704
|
+
Ok(self.targets.clone())
|
|
692
705
|
}
|
|
693
706
|
fn list_windows(&self, _s: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
694
707
|
Ok(vec![WindowName::new("cp1")])
|
|
@@ -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,
|