@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.
Files changed (29) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/intent/runtime.rs +46 -10
  4. package/crates/team-agent/src/cli/intent/runtime_tests.rs +114 -0
  5. package/crates/team-agent/src/cli/mod.rs +4 -1
  6. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +56 -0
  7. package/crates/team-agent/src/coordinator/tests/health_sync.rs +27 -8
  8. package/crates/team-agent/src/db/message_store.rs +65 -0
  9. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +8 -0
  10. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +111 -4
  11. package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +244 -82
  12. package/crates/team-agent/src/lifecycle/launch.rs +1 -0
  13. package/crates/team-agent/src/lifecycle/restart/agent.rs +3 -0
  14. package/crates/team-agent/src/lifecycle/restart/common.rs +10 -1
  15. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -0
  16. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +16 -3
  17. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +60 -17
  18. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +162 -8
  19. package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +55 -6
  20. package/crates/team-agent/src/mcp_server/mod.rs +2 -0
  21. package/crates/team-agent/src/mcp_server/readiness.rs +495 -0
  22. package/crates/team-agent/src/mcp_server/readiness_tests.rs +272 -0
  23. package/crates/team-agent/src/mcp_server/wire.rs +96 -11
  24. package/crates/team-agent/src/messaging/delivery.rs +62 -0
  25. package/crates/team-agent/src/messaging/delivery_consumption_tests.rs +12 -1
  26. package/crates/team-agent/src/messaging/tests/dup_inject.rs +89 -5
  27. package/crates/team-agent/src/messaging/tests/spine.rs +45 -14
  28. package/crates/team-agent/src/provider/adapter.rs +7 -0
  29. package/package.json +4 -4
@@ -1,120 +1,282 @@
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
- //! ---
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
- /// purpose: 默认开启 per-seat provider-config 工程根;显式 0/false/off 才关
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
- let trimmed = value.trim();
31
- !matches!(
32
- trimmed,
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
- /// purpose: 算出该席位 cursor --workspace 工程根,不建盘
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
- Ok(workspace
46
- .join(".team")
47
- .join("runtime")
48
- .join("provider-config")
49
- .join(agent_id)
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
- .join(".cursor")
59
- .join("mcp.json"))
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
- 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)
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 == '.' || c == '_' || c == '-')
267
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
94
268
  {
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
- )));
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").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
- })
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: spawn_cwd.to_path_buf(),
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
- None,
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::new()),
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(Vec::new())
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.lines().any(|line| line.starts_with(&format!("{key}="))),
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!(argv_workspace, physical_workspace_path(&project).to_string_lossy());
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!(argv_workspace, physical_workspace_path(&ws).to_string_lossy());
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": "/bin/team-agent-test",
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(&ws, "seat-iso", &sample_mcp_config("seat-iso", &ws.to_string_lossy()))
462
- .expect("prepare isolated cursor")
463
- .expect("isolation defaults on");
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.ends_with("provider-config/seat-iso/cursor"),
466
- "per-seat project must be materialized: {}",
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!(workspace_flag, physical_workspace_path(&project).to_string_lossy());
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,