@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
package/Cargo.lock CHANGED
@@ -727,7 +727,7 @@ dependencies = [
727
727
 
728
728
  [[package]]
729
729
  name = "team-agent"
730
- version = "0.5.120"
730
+ version = "0.5.122"
731
731
  dependencies = [
732
732
  "anyhow",
733
733
  "chrono",
package/Cargo.toml CHANGED
@@ -11,7 +11,7 @@ exclude = ["crates/team-agent-contract"]
11
11
 
12
12
  [workspace.package]
13
13
  edition = "2021"
14
- version = "0.5.120"
14
+ version = "0.5.122"
15
15
  license = "AGPL-3.0"
16
16
  rust-version = "1.95"
17
17
 
@@ -442,6 +442,37 @@ fn stop_args(plan: &Plan, id: &str) -> StopAgentArgs {
442
442
  json: true,
443
443
  }
444
444
  }
445
+ fn check_incarnation_cwd(
446
+ selected: &SelectedTeam,
447
+ id: &str,
448
+ row: &Value,
449
+ provider: crate::provider::Provider,
450
+ ) -> Result<(), CliError> {
451
+ if let Some(cwd) = row
452
+ .get("spawn_cwd")
453
+ .and_then(Value::as_str)
454
+ .filter(|cwd| !cwd.is_empty())
455
+ {
456
+ let declared_cwd = Path::new(cwd);
457
+ let cwd = checked(declared_cwd.canonicalize())?;
458
+ let inside = cwd.starts_with(&selected.run_workspace);
459
+ #[cfg(unix)]
460
+ let owned_cursor_primary = !inside
461
+ && provider == crate::provider::Provider::CursorAgent
462
+ && checked(crate::lifecycle::launch::cursor_mcp_primary_matches_scope(
463
+ &selected.run_workspace,
464
+ &selected.team_key,
465
+ id,
466
+ declared_cwd,
467
+ ))?;
468
+ #[cfg(not(unix))]
469
+ let owned_cursor_primary = false;
470
+ if !cwd.is_dir() || !(inside || owned_cursor_primary) {
471
+ return Err(refusal("legacy incarnation cwd is outside the selected workspace; no context or scope fallback"));
472
+ }
473
+ }
474
+ Ok(())
475
+ }
445
476
  fn preflight_start(
446
477
  selected: &SelectedTeam,
447
478
  id: &str,
@@ -515,16 +546,7 @@ fn preflight_start(
515
546
  {
516
547
  return Err(refusal("resume metadata lacks the scoped spawn cwd"));
517
548
  }
518
- if let Some(cwd) = row
519
- .get("spawn_cwd")
520
- .and_then(Value::as_str)
521
- .filter(|cwd| !cwd.is_empty())
522
- {
523
- let cwd = checked(Path::new(cwd).canonicalize())?;
524
- if !cwd.is_dir() || !cwd.starts_with(&selected.run_workspace) {
525
- return Err(refusal("legacy incarnation cwd is outside the selected workspace; no context or scope fallback"));
526
- }
527
- }
549
+ check_incarnation_cwd(selected, id, row, provider)?;
528
550
  if row
529
551
  .get("task_prompt_delivered")
530
552
  .is_some_and(|value| !value.is_null() && !value.is_boolean())
@@ -1134,6 +1156,20 @@ pub(super) fn recheck(plan: &Plan, step: &Step, after_stop: bool) -> Result<(),
1134
1156
  }
1135
1157
  }
1136
1158
  }
1159
+ #[cfg(unix)]
1160
+ if !crate::contract_runtime::framework::is_contract(row)
1161
+ && crate::provider::wire::parse_provider(provider(row))
1162
+ == Some(crate::provider::Provider::CursorAgent)
1163
+ && (matches!(step, Step::StartAgent(_))
1164
+ || (plan.intent.verb == Verb::Restart && matches!(step, Step::StopAgent(_)))
1165
+ || (matches!(step, Step::RestartTeam(_))
1166
+ && row.get("status").and_then(Value::as_str) == Some("running")
1167
+ && !crate::state::projection::agent_explicit_stop(row)))
1168
+ {
1169
+ // Reopen the same ownership proof at the existing execution recheck,
1170
+ // before effects; a planning-time receipt is not a filesystem lease.
1171
+ check_incarnation_cwd(&selected, id, row, crate::provider::Provider::CursorAgent)?;
1172
+ }
1137
1173
  }
1138
1174
  Ok(())
1139
1175
  }
@@ -2,6 +2,10 @@
2
2
  use super::super::tests::Sandbox;
3
3
  use super::*;
4
4
 
5
+ #[cfg(unix)]
6
+ #[path = "../../../tests/support/hermetic.rs"]
7
+ mod hermetic_guard;
8
+
5
9
  fn fixture(ids: &[&str]) -> (Sandbox, Intent) {
6
10
  let root = Sandbox::new();
7
11
  std::fs::create_dir(root.path().join("agents")).unwrap();
@@ -311,6 +315,116 @@ fn preserve_rejects_known_missing_backing_before_stop_auto_uses_existing_decisio
311
315
  }
312
316
  }
313
317
 
318
+ #[cfg(unix)]
319
+ fn cursor_scope_fixture() -> (
320
+ hermetic_guard::HermeticTestEnv,
321
+ Sandbox,
322
+ SelectedTeam,
323
+ PathBuf,
324
+ Value,
325
+ ) {
326
+ let env = hermetic_guard::HermeticTestEnv::enter("cursor-intent-scope");
327
+ let (root, _) = fixture(&["one"]);
328
+ install(root.path(), &[("one", "stopped", true)]);
329
+ let selected =
330
+ resolve_active_team_readonly(root.path(), Some("fixture"), SelectorMode::RuntimeOnly)
331
+ .unwrap();
332
+ let config = crate::provider::McpConfig {
333
+ raw: json!({"team_orchestrator": {
334
+ "command": std::env::current_exe().unwrap(),
335
+ "args": ["mcp-server", "--workspace", root.path()],
336
+ "env": {"TEAM_AGENT_WORKSPACE": root.path(), "TEAM_AGENT_ID": "one", "TEAM_AGENT_OWNER_TEAM_ID": "fixture", "TEAM_AGENT_AUTH_MODE": "subscription"}
337
+ }}),
338
+ };
339
+ let primary = crate::lifecycle::launch::prepare_cursor_seat_mcp(root.path(), "one", &config)
340
+ .unwrap()
341
+ .unwrap();
342
+ let sid = "af3ce55e-2ee9-4bc5-8dae-b628e7447286";
343
+ let archive = env
344
+ .home()
345
+ .join(".cursor/chats")
346
+ .join(format!(
347
+ "{:x}",
348
+ md5::compute(primary.to_string_lossy().as_bytes())
349
+ ))
350
+ .join(sid);
351
+ std::fs::create_dir_all(&archive).unwrap();
352
+ std::fs::write(archive.join("meta.json"), b"{}").unwrap();
353
+ let row = json!({"provider":"cursor_agent","status":"stopped","explicit_stop":true,"session_id":sid,"rollout_path":archive,"spawn_cwd":primary});
354
+ (env, root, selected, primary, row)
355
+ }
356
+
357
+ #[test]
358
+ #[cfg(unix)]
359
+ #[serial_test::serial(env)]
360
+ fn issue342_cursor_owned_primary_scope_is_readonly_and_exact() {
361
+ let (_env, root, selected, primary, row) = cursor_scope_fixture();
362
+ let state = crate::state::persist::runtime_state_path(root.path());
363
+ let record = root
364
+ .path()
365
+ .join(".team/runtime/provider-config/one/cursor-project.json");
366
+ let paths = [state, record, primary.join(".cursor/mcp.json")];
367
+ let before: Vec<_> = paths.iter().map(|p| std::fs::read(p).unwrap()).collect();
368
+ assert!(!primary.starts_with(root.path()));
369
+ for policy in [SessionPolicy::Preserve, SessionPolicy::Auto] {
370
+ assert!(preflight_start(&selected, "one", &row, policy).is_ok());
371
+ }
372
+ let after: Vec<_> = paths.iter().map(|p| std::fs::read(p).unwrap()).collect();
373
+ assert_eq!(
374
+ before, after,
375
+ "scope preflight must not write state, record or MCP config"
376
+ );
377
+
378
+ let mut foreign_provider = row.clone();
379
+ foreign_provider["provider"] = json!("pi");
380
+ assert!(preflight_start(&selected, "one", &foreign_provider, SessionPolicy::Auto).is_err());
381
+ let mut other_team = selected.clone();
382
+ other_team.team_key = "foreign-team".into();
383
+ assert!(preflight_start(&other_team, "one", &row, SessionPolicy::Preserve).is_err());
384
+ assert!(preflight_start(&selected, "two", &row, SessionPolicy::Preserve).is_err());
385
+ let outside = Sandbox::new();
386
+ let mut other_workspace = selected.clone();
387
+ other_workspace.run_workspace = outside.path().to_owned();
388
+ assert!(preflight_start(&other_workspace, "one", &row, SessionPolicy::Preserve).is_err());
389
+ assert!(!outside.path().join(".team").exists());
390
+ }
391
+
392
+ #[test]
393
+ #[cfg(unix)]
394
+ #[serial_test::serial(env)]
395
+ fn issue342_cursor_scope_refuses_unrecorded_alias_and_replaced_incarnation() {
396
+ use std::os::unix::fs::{symlink, DirBuilderExt};
397
+ let (_env, root, selected, primary, row) = cursor_scope_fixture();
398
+ let record = root
399
+ .path()
400
+ .join(".team/runtime/provider-config/one/cursor-project.json");
401
+ let bytes = std::fs::read(&record).unwrap();
402
+ let retained = record.with_extension("retained");
403
+ std::fs::rename(&record, &retained).unwrap();
404
+ assert!(preflight_start(&selected, "one", &row, SessionPolicy::Preserve).is_err());
405
+ assert!(
406
+ !record.exists(),
407
+ "a same-named external directory is not authority to create a record"
408
+ );
409
+ std::fs::rename(retained, &record).unwrap();
410
+ let mut forged = row.clone();
411
+ let arbitrary = Sandbox::new();
412
+ forged["spawn_cwd"] = json!(arbitrary.path());
413
+ assert!(preflight_start(&selected, "one", &forged, SessionPolicy::Auto).is_err());
414
+ let alias = root.path().join("primary-alias");
415
+ symlink(&primary, &alias).unwrap();
416
+ forged["spawn_cwd"] = json!(alias);
417
+ assert!(preflight_start(&selected, "one", &forged, SessionPolicy::Preserve).is_err());
418
+ let old = primary.with_extension("retained");
419
+ std::fs::rename(&primary, old).unwrap();
420
+ std::fs::DirBuilder::new()
421
+ .mode(0o700)
422
+ .create(&primary)
423
+ .unwrap();
424
+ assert!(preflight_start(&selected, "one", &row, SessionPolicy::Auto).is_err());
425
+ assert_eq!(std::fs::read(record).unwrap(), bytes);
426
+ }
427
+
314
428
  #[test]
315
429
  #[serial_test::serial(env)]
316
430
  fn changed_role_and_socket_refuse_in_the_real_readonly_recheck() {
@@ -1724,7 +1724,10 @@ pub mod lifecycle_port {
1724
1724
  let wp = crate::coordinator::WorkspacePath::new(run_workspace.clone());
1725
1725
  let coordinator_pid_before_stop = crate::coordinator::coordinator_health(&wp).pid;
1726
1726
  coordinator_pid_for_report = coordinator_pid_before_stop.map(|pid| pid.get());
1727
- match stop_coordinator_bounded(wp, std::time::Duration::from_millis(900)) {
1727
+ // Owned termination can use 5s graceful + 5s escalation waits.
1728
+ // Await its result within the existing shutdown deadline, not an
1729
+ // unrelated 900ms cap that abandons a still-finishing stop worker.
1730
+ match stop_coordinator_bounded(wp, deadline.remaining()) {
1728
1731
  Some(Ok(report)) => Some(report),
1729
1732
  Some(Err(error)) => {
1730
1733
  kill_error.get_or_insert(error);
@@ -1254,6 +1254,62 @@ fn bounded_coordinator_stop_returns_grace_window_late_success() {
1254
1254
  assert_eq!(report.status, crate::coordinator::StopOutcome::Stopped);
1255
1255
  }
1256
1256
 
1257
+ #[test]
1258
+ fn bounded_coordinator_stop_retains_completion_beyond_old_1150ms_cap() {
1259
+ let ws = tmp_shutdown_workspace("coordinator-stop-after-old-cap");
1260
+ let cleanup = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1261
+ let worker_cleanup = cleanup.clone();
1262
+ let report = crate::cli::lifecycle_port::stop_coordinator_bounded_with(
1263
+ crate::coordinator::WorkspacePath::new(ws),
1264
+ std::time::Duration::from_secs(20),
1265
+ move |_workspace| {
1266
+ std::thread::sleep(std::time::Duration::from_millis(1300));
1267
+ worker_cleanup.store(true, std::sync::atomic::Ordering::SeqCst);
1268
+ Ok(crate::coordinator::StopReport {
1269
+ ok: true,
1270
+ status: crate::coordinator::StopOutcome::Stopped,
1271
+ pid: Some(crate::coordinator::Pid::new(12345)),
1272
+ })
1273
+ },
1274
+ )
1275
+ .expect("completion inside the shutdown budget must not be abandoned")
1276
+ .expect("owned stop completed successfully");
1277
+
1278
+ assert!(cleanup.load(std::sync::atomic::Ordering::SeqCst));
1279
+ assert!(report.ok);
1280
+ assert_eq!(report.status, crate::coordinator::StopOutcome::Stopped);
1281
+ }
1282
+
1283
+ #[test]
1284
+ fn bounded_coordinator_stop_does_not_claim_completion_after_budget_exhaustion() {
1285
+ let ws = tmp_shutdown_workspace("coordinator-stop-real-timeout");
1286
+ let (release_tx, release_rx) = std::sync::mpsc::channel();
1287
+ let (completed_tx, completed_rx) = std::sync::mpsc::channel();
1288
+ let report = crate::cli::lifecycle_port::stop_coordinator_bounded_with(
1289
+ crate::coordinator::WorkspacePath::new(ws),
1290
+ std::time::Duration::from_millis(1),
1291
+ move |_workspace| {
1292
+ release_rx.recv().unwrap();
1293
+ completed_tx.send(()).unwrap();
1294
+ Ok(crate::coordinator::StopReport {
1295
+ ok: true,
1296
+ status: crate::coordinator::StopOutcome::Stopped,
1297
+ pid: Some(crate::coordinator::Pid::new(12345)),
1298
+ })
1299
+ },
1300
+ );
1301
+
1302
+ assert!(report.is_none(), "an unfinished stop must remain unknown");
1303
+ assert!(matches!(
1304
+ completed_rx.try_recv(),
1305
+ Err(std::sync::mpsc::TryRecvError::Empty)
1306
+ ));
1307
+ release_tx.send(()).unwrap();
1308
+ completed_rx
1309
+ .recv_timeout(std::time::Duration::from_secs(5))
1310
+ .expect("release the controlled worker without leaving a blocked thread");
1311
+ }
1312
+
1257
1313
  #[test]
1258
1314
  fn shutdown_outcome_late_or_postcheck_gone_is_ok_with_lsof_diagnostic() {
1259
1315
  let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
@@ -15,6 +15,7 @@ use super::*;
15
15
  /// agent. `has_session`→true (gate passes); `inject`→Ok (delivery may run); the rest are Ok defaults.
16
16
  struct CapturingTransport {
17
17
  scrollback: String,
18
+ targets: Vec<PaneInfo>,
18
19
  }
19
20
  impl Transport for CapturingTransport {
20
21
  fn kind(&self) -> BackendKind {
@@ -73,7 +74,7 @@ impl Transport for CapturingTransport {
73
74
  Ok(PaneLiveness::Live)
74
75
  }
75
76
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
76
- Ok(Vec::new())
77
+ Ok(self.targets.clone())
77
78
  }
78
79
  fn has_session(&self, _s: &SessionName) -> Result<bool, TransportError> {
79
80
  Ok(true)
@@ -106,6 +107,14 @@ impl Transport for CapturingTransport {
106
107
  fn seeded_health_coord(
107
108
  agents: serde_json::Value,
108
109
  scrollback: &str,
110
+ ) -> (Coordinator, std::path::PathBuf) {
111
+ seeded_health_coord_with_readiness(agents, scrollback, false)
112
+ }
113
+
114
+ fn seeded_health_coord_with_readiness(
115
+ agents: serde_json::Value,
116
+ scrollback: &str,
117
+ ready: bool,
109
118
  ) -> (Coordinator, std::path::PathBuf) {
110
119
  use std::sync::atomic::{AtomicU64, Ordering};
111
120
  static N: AtomicU64 = AtomicU64::new(0);
@@ -115,11 +124,18 @@ fn seeded_health_coord(
115
124
  N.fetch_add(1, Ordering::Relaxed)
116
125
  ));
117
126
  std::fs::create_dir_all(&dir).unwrap();
118
- crate::state::persist::save_runtime_state(
119
- &dir,
120
- &serde_json::json!({ "session_name": "team-health", "agents": agents }),
121
- )
122
- .unwrap();
127
+ let mut state = serde_json::json!({ "session_name": "team-health", "agents": agents });
128
+ #[cfg(unix)]
129
+ let targets = if ready {
130
+ vec![crate::mcp_server::readiness::test_ready_worker(
131
+ &dir, &mut state, "current", "w1", "%1",
132
+ )]
133
+ } else {
134
+ Vec::new()
135
+ };
136
+ #[cfg(not(unix))]
137
+ let targets = { let _ = ready; Vec::new() };
138
+ crate::state::persist::save_runtime_state(&dir, &state).unwrap();
123
139
  let ws = WorkspacePath::new(dir.clone());
124
140
  let reg: Box<dyn ProviderRegistry> = Box::new(MockRegistry::new(&[], &[]));
125
141
  let coord = Coordinator::for_test(
@@ -127,6 +143,7 @@ fn seeded_health_coord(
127
143
  reg,
128
144
  Box::new(CapturingTransport {
129
145
  scrollback: scrollback.to_string(),
146
+ targets,
130
147
  }),
131
148
  None,
132
149
  None,
@@ -260,10 +277,12 @@ fn spine2_capture_missing_captures_session_id_for_missing_agent() {
260
277
  // activity/agent_health WORKING remains diagnostic turn state.
261
278
  #[test]
262
279
  fn spine2_sync_health_working_status_delivers_same_tick() {
263
- let (coord, dir) = seeded_health_coord(one_agent("codex"), "Working (5s · esc to interrupt)");
280
+ let (coord, dir) = seeded_health_coord_with_readiness(
281
+ one_agent("codex"), "Working (5s · esc to interrupt)", true,
282
+ );
264
283
  let store = MessageStore::open(&dir).unwrap();
265
284
  let mid = store
266
- .create_message(Some("t"), "leader", "w1", "hi", None, true, None)
285
+ .create_message(Some("t"), "leader", "w1", "hi", None, true, Some("current"))
267
286
  .unwrap();
268
287
  let conn = crate::db::schema::open_db(store.db_path()).unwrap();
269
288
  conn.execute(
@@ -585,6 +585,30 @@ impl MessageStore {
585
585
  Ok(rows == 1)
586
586
  }
587
587
 
588
+ /// Release only an owned claim that has performed no input. Readiness
589
+ /// waiting must remain retryable without consuming the input-attempt budget.
590
+ /// A changed attempt, status, or error means this caller no longer owns it.
591
+ pub(crate) fn release_delivery_before_input(
592
+ &self,
593
+ message_id: &str,
594
+ attempt: u32,
595
+ reason: &str,
596
+ ) -> Result<bool, MessageStoreError> {
597
+ if attempt == 0 || reason.is_empty() {
598
+ return Ok(false);
599
+ }
600
+ let conn = crate::db::schema::open_db(&self.path)?;
601
+ let rows = conn.execute(
602
+ "update messages
603
+ set delivery_attempts = delivery_attempts - 1,
604
+ error = ?3, updated_at = ?4
605
+ where message_id = ?1 and status = 'target_resolved'
606
+ and error is null and delivery_attempts = ?2",
607
+ params![message_id, attempt, reason, now_ts()],
608
+ )?;
609
+ Ok(rows == 1)
610
+ }
611
+
588
612
  /// Read inbox rows through [`Self::inbox`]; recovery transitions below are
589
613
  /// separate repository owners and are not claim eligibility.
590
614
  pub fn requeue_blocked_leader_messages(
@@ -1168,6 +1192,47 @@ mod tests {
1168
1192
  assert_eq!(col_i64(&read(&s), &mid, "delivery_attempts"), 1);
1169
1193
  }
1170
1194
 
1195
+ #[test]
1196
+ fn release_delivery_before_input_preserves_budget_and_single_winner() {
1197
+ let s = store();
1198
+ let mid = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
1199
+ assert!(s.claim_for_delivery(&mid).unwrap());
1200
+ assert!(!s.claim_for_delivery(&mid).unwrap());
1201
+ assert!(s.release_delivery_before_input(&mid, 1, "mcp_handshake_missing").unwrap());
1202
+ assert_eq!(col_i64(&read(&s), &mid, "delivery_attempts"), 0);
1203
+ assert_eq!(col_str(&read(&s), &mid, "error").as_deref(), Some("mcp_handshake_missing"));
1204
+ assert!(!s.release_delivery_before_input(&mid, 1, "late").unwrap());
1205
+ assert!(s.claim_for_delivery(&mid).unwrap());
1206
+ assert!(!s.claim_for_delivery(&mid).unwrap());
1207
+ assert_eq!(col_i64(&read(&s), &mid, "delivery_attempts"), 1);
1208
+ }
1209
+
1210
+ #[test]
1211
+ fn release_delivery_before_input_cannot_overwrite_changed_claims() {
1212
+ let s = store();
1213
+ let mid = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
1214
+ assert!(s.claim_for_delivery(&mid).unwrap());
1215
+ assert!(!s.release_delivery_before_input(&mid, 0, "missing").unwrap());
1216
+ assert!(!s.release_delivery_before_input(&mid, 2, "stale").unwrap());
1217
+ s.mark(&mid, "target_resolved", Some("other_owner")).unwrap();
1218
+ assert!(!s.release_delivery_before_input(&mid, 1, "stale").unwrap());
1219
+ assert_eq!(col_str(&read(&s), &mid, "error").as_deref(), Some("other_owner"));
1220
+ assert!(s.claim_for_delivery(&mid).unwrap());
1221
+ assert!(!s.release_delivery_before_input(&mid, 1, "old_claim").unwrap());
1222
+ assert_eq!(col_i64(&read(&s), &mid, "delivery_attempts"), 2);
1223
+ assert_eq!(col_str(&read(&s), &mid, "error"), None);
1224
+ for status in ["cancelled", "delivered", "submitted_unverified"] {
1225
+ // Each terminal/effect state needs its own row: mark correctly
1226
+ // refuses to downgrade a previously delivered row.
1227
+ let changed = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
1228
+ assert!(s.claim_for_delivery(&changed).unwrap());
1229
+ s.mark(&changed, status, None).unwrap();
1230
+ assert!(!s.release_delivery_before_input(&changed, 1, "stale").unwrap());
1231
+ assert_eq!(status_of(&read(&s), &changed), status);
1232
+ assert_eq!(col_i64(&read(&s), &changed, "delivery_attempts"), 1);
1233
+ }
1234
+ }
1235
+
1171
1236
  #[test]
1172
1237
  fn claim_for_delivery_never_claims_stored_only_presentation() {
1173
1238
  let s = store();
@@ -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>,