@team-agent/installer 0.5.119 → 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.
Files changed (73) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +46 -1
  4. package/README.zh.md +46 -1
  5. package/crates/team-agent/src/cli/adapters.rs +11 -0
  6. package/crates/team-agent/src/cli/arguments.rs +401 -0
  7. package/crates/team-agent/src/cli/emit.rs +241 -142
  8. package/crates/team-agent/src/cli/intent/runtime.rs +1175 -0
  9. package/crates/team-agent/src/cli/intent/runtime_tests.rs +698 -0
  10. package/crates/team-agent/src/cli/intent/tests.rs +654 -0
  11. package/crates/team-agent/src/cli/intent.rs +642 -0
  12. package/crates/team-agent/src/cli/mod.rs +53 -6
  13. package/crates/team-agent/src/cli/named_address.rs +10 -1
  14. package/crates/team-agent/src/cli/spec.rs +22 -10
  15. package/crates/team-agent/src/cli/status_port/brief.rs +117 -16
  16. package/crates/team-agent/src/cli/tests/base.rs +6 -1
  17. package/crates/team-agent/src/cli/tests/parser_safety.rs +383 -0
  18. package/crates/team-agent/src/cli/tests/run_delegation.rs +63 -19
  19. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +56 -0
  20. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +3 -2
  21. package/crates/team-agent/src/cli/tests/verb_profile.rs +3 -1
  22. package/crates/team-agent/src/contract_runtime/backend.rs +1 -1
  23. package/crates/team-agent/src/db/message_store.rs +65 -0
  24. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +8 -0
  25. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +111 -4
  26. package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +244 -82
  27. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +166 -77
  28. package/crates/team-agent/src/lifecycle/launch.rs +29 -0
  29. package/crates/team-agent/src/lifecycle/restart/common.rs +7 -1
  30. package/crates/team-agent/src/lifecycle/restart.rs +17 -0
  31. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1 -3
  32. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +5 -15
  33. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -3
  34. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +2 -3
  35. package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +10 -2
  36. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +8 -1
  37. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +3 -2
  38. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +6 -9
  39. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +7 -5
  40. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +64 -19
  41. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +5 -2
  42. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +34 -10
  43. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +2 -4
  44. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +3 -4
  45. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +5 -4
  46. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +2 -4
  47. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +78 -36
  48. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +8 -4
  49. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +6 -12
  50. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +4 -0
  51. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1 -3
  52. package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +7 -0
  53. package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +47 -4
  54. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -3
  55. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -1
  56. package/crates/team-agent/src/lifecycle/types.rs +10 -0
  57. package/crates/team-agent/src/mcp_server/mod.rs +2 -0
  58. package/crates/team-agent/src/mcp_server/readiness.rs +432 -0
  59. package/crates/team-agent/src/mcp_server/readiness_tests.rs +242 -0
  60. package/crates/team-agent/src/mcp_server/wire.rs +96 -11
  61. package/crates/team-agent/src/messaging/delivery.rs +62 -0
  62. package/crates/team-agent/src/messaging/tests/dup_inject.rs +41 -1
  63. package/crates/team-agent/src/provider/adapter.rs +7 -0
  64. package/crates/team-agent/src/tmux_backend/tests.rs +37 -0
  65. package/crates/team-agent/src/tmux_backend.rs +8 -1
  66. package/package.json +4 -4
  67. package/skills/team-agent/SKILL.md +71 -54
  68. package/skills/team-agent/command-coverage.json +283 -13
  69. package/skills/team-agent/docs/reference/team-agent-operator.md +220 -0
  70. package/skills/team-agent/references/bug-as-artifact-flow.md +1 -1
  71. package/skills/team-agent/references/recovery-runbook.md +61 -212
  72. package/skills/team-agent/references/team-agent-operator.md +181 -48
  73. package/skills/team-agent/references/team-in-team.md +47 -80
@@ -129,6 +129,14 @@ pub(super) fn running_agent_state(
129
129
  if let Some(started_agent) = started_agent {
130
130
  persist_started_agent_plan_state(&mut state, started_agent);
131
131
  }
132
+ // Cursor chdirs to its native --workspace primary. Session archives are
133
+ // keyed by this directory, not the real project supplied via --add-dir.
134
+ let cursor_cwd = if provider == Provider::CursorAgent && cursor_mcp_isolation_enabled() {
135
+ Some(cursor_mcp_project_dir(workspace, id)?)
136
+ } else {
137
+ None
138
+ };
139
+ let spawn_cwd = cursor_cwd.as_deref().unwrap_or(spawn_cwd);
132
140
  state.insert(
133
141
  "spawn_cwd".to_string(),
134
142
  serde_json::json!(spawn_cwd.to_string_lossy().to_string()),
@@ -33,6 +33,7 @@ use crate::provider::Provider;
33
33
 
34
34
  use super::cursor_mcp_iso::{
35
35
  cursor_mcp_isolation_enabled, cursor_mcp_project_dir, materialize_cursor_mcp_project,
36
+ validate_cursor_mcp_project_root,
36
37
  };
37
38
 
38
39
  /// Keys that must appear in mcp.json env. Cursor strips parent env down to
@@ -280,8 +281,31 @@ pub fn prepare_cursor_seat_mcp(
280
281
  mcp_config: &crate::provider::McpConfig,
281
282
  ) -> Result<Option<PathBuf>, LifecycleError> {
282
283
  if cursor_mcp_isolation_enabled() {
283
- let project = materialize_cursor_mcp_project(workspace, agent_id)?;
284
+ let server = mcp_config
285
+ .raw
286
+ .get("team_orchestrator")
287
+ .or_else(|| mcp_config.raw.get("team-agent"));
288
+ let env = server.and_then(|s| s.get("env"));
289
+ let field = |key| {
290
+ env.and_then(|v| v.get(key))
291
+ .and_then(serde_json::Value::as_str)
292
+ };
293
+ let physical = physical_workspace_path(workspace);
294
+ if field("TEAM_AGENT_ID") != Some(agent_id)
295
+ || field("TEAM_AGENT_WORKSPACE") != physical.to_str()
296
+ {
297
+ return Err(LifecycleError::RequirementUnmet(
298
+ "cursor MCP resolved identity does not match the selected workspace/seat".into(),
299
+ ));
300
+ }
301
+ let owner = field("TEAM_AGENT_OWNER_TEAM_ID")
302
+ .filter(|s| !s.trim().is_empty())
303
+ .ok_or_else(|| {
304
+ LifecycleError::RequirementUnmet("cursor MCP missing owner team".into())
305
+ })?;
306
+ let project = materialize_cursor_mcp_project(&physical, agent_id, owner)?;
284
307
  apply_cursor_mcp_overlay(&project, mcp_config)?;
308
+ validate_cursor_project_mcp(&physical, &project)?;
285
309
  Ok(Some(project))
286
310
  } else {
287
311
  apply_cursor_mcp_overlay(workspace, mcp_config)?;
@@ -305,14 +329,15 @@ pub fn enable_cursor_workspace_mcp_with_profile(
305
329
  profile_launch: Option<&crate::provider::ProviderProfileLaunch>,
306
330
  ) -> Result<(), LifecycleError> {
307
331
  let physical = cursor_mcp_enable_working_dir(workspace, project_root);
332
+ if project_root.is_some() {
333
+ validate_cursor_project_mcp(&physical_workspace_path(workspace), &physical)?;
334
+ }
308
335
  if skip_cursor_mcp_enable() {
309
336
  return Ok(());
310
337
  }
311
338
  let argv = cursor_mcp_enable_argv();
312
339
  let mut command = Command::new(&argv[0]);
313
- command
314
- .args(&argv[1..])
315
- .current_dir(&physical);
340
+ command.args(&argv[1..]).current_dir(&physical);
316
341
  apply_cursor_mcp_enable_profile_env(&mut command, profile_launch);
317
342
  let output = command.output().map_err(|e| {
318
343
  LifecycleError::RequirementUnmet(format!(
@@ -323,6 +348,11 @@ pub fn enable_cursor_workspace_mcp_with_profile(
323
348
  ))
324
349
  })?;
325
350
  if output.status.success() {
351
+ // Approval is not native tool readiness. Recheck our root/config after
352
+ // the external command; native loading/calls still need their own proof.
353
+ if project_root.is_some() {
354
+ validate_cursor_project_mcp(&physical_workspace_path(workspace), &physical)?;
355
+ }
326
356
  return Ok(());
327
357
  }
328
358
  let stderr = String::from_utf8_lossy(&output.stderr);
@@ -343,6 +373,83 @@ pub fn enable_cursor_workspace_mcp_with_profile(
343
373
  )))
344
374
  }
345
375
 
376
+ fn validate_cursor_project_mcp(workspace: &Path, project: &Path) -> Result<(), LifecycleError> {
377
+ use std::io::Read;
378
+ use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
379
+ validate_cursor_mcp_project_root(project)?;
380
+ let refuse = || {
381
+ LifecycleError::RequirementUnmet(
382
+ "cursor MCP private config is missing, mismatched, or its server executable is unavailable; native tools are not ready".into(),
383
+ )
384
+ };
385
+ let file = std::fs::OpenOptions::new()
386
+ .read(true)
387
+ .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
388
+ .open(project.join(".cursor/mcp.json"))
389
+ .map_err(|_| refuse())?;
390
+ let metadata = file.metadata().map_err(|_| refuse())?;
391
+ if !metadata.is_file() || metadata.nlink() != 1 || metadata.uid() != unsafe { libc::geteuid() }
392
+ {
393
+ return Err(refuse());
394
+ }
395
+ let mut bytes = Vec::new();
396
+ file.take(65537)
397
+ .read_to_end(&mut bytes)
398
+ .map_err(|_| refuse())?;
399
+ if bytes.len() > 65536 {
400
+ return Err(refuse());
401
+ }
402
+ let config: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| refuse())?;
403
+ let server = config
404
+ .get("mcpServers")
405
+ .and_then(|v| v.get("team_orchestrator"))
406
+ .ok_or_else(refuse)?;
407
+ let env = server.get("env").ok_or_else(refuse)?;
408
+ for key in REQUIRED_IDENTITY_KEYS {
409
+ if env
410
+ .get(*key)
411
+ .and_then(serde_json::Value::as_str)
412
+ .is_none_or(|s| s.trim().is_empty() || s.contains('{'))
413
+ {
414
+ return Err(refuse());
415
+ }
416
+ }
417
+ let seat = env
418
+ .get("TEAM_AGENT_ID")
419
+ .and_then(serde_json::Value::as_str)
420
+ .ok_or_else(refuse)?;
421
+ let owner = env
422
+ .get("TEAM_AGENT_OWNER_TEAM_ID")
423
+ .and_then(serde_json::Value::as_str)
424
+ .ok_or_else(refuse)?;
425
+ if env
426
+ .get("TEAM_AGENT_WORKSPACE")
427
+ .and_then(serde_json::Value::as_str)
428
+ != workspace.to_str()
429
+ || cursor_mcp_project_dir(workspace, seat)? != project
430
+ || materialize_cursor_mcp_project(workspace, seat, owner)? != project
431
+ {
432
+ return Err(refuse());
433
+ }
434
+ let command = server
435
+ .get("command")
436
+ .and_then(serde_json::Value::as_str)
437
+ .ok_or_else(refuse)?;
438
+ let metadata = std::fs::metadata(command).map_err(|_| refuse())?;
439
+ let current = std::env::current_exe()
440
+ .and_then(std::fs::canonicalize)
441
+ .map_err(|_| refuse())?;
442
+ if !Path::new(command).is_absolute()
443
+ || !metadata.is_file()
444
+ || metadata.mode() & 0o111 == 0
445
+ || std::fs::canonicalize(command).map_err(|_| refuse())? != current
446
+ || server.get("args") != Some(&serde_json::json!(["mcp-server", "--workspace", workspace]))
447
+ {
448
+ return Err(refuse());
449
+ }
450
+ Ok(())
451
+ }
452
+
346
453
  pub(crate) fn apply_cursor_mcp_enable_profile_env(
347
454
  command: &mut Command,
348
455
  profile_launch: Option<&crate::provider::ProviderProfileLaunch>,
@@ -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
  }