@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.
@@ -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,
@@ -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: spawn_cwd.to_path_buf(),
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.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,
@@ -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::provider::session_scan::cursor::{
30
- cursor_session_archive_present, cursor_session_dir,
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, restart_with_transport,
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("00a437742b92089861da7821a62f232a")
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
- patch_cursor_writer(&mut state, sid, &dir, &ws);
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]