@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
@@ -0,0 +1,495 @@
1
+ //! A flushed MCP handshake is admission to first business, not client consumption.
2
+ //! Receipts are scope- and live-process-bound; no pane input or tools/call probe.
3
+
4
+ use std::fs::{File, OpenOptions};
5
+ use std::io::{Read, Write};
6
+ use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
7
+ use std::path::{Path, PathBuf};
8
+ use std::time::{Duration, Instant};
9
+
10
+ use chrono::{DateTime, Utc};
11
+ use serde::{Deserialize, Serialize};
12
+ use serde_json::Value;
13
+ use team_agent_contract::host::process::{
14
+ capture_process, fingerprint_file, process_birth_identity, sample_process, ProcessStamp,
15
+ ProcessState, MAX_NATIVE_PARENT_HOPS,
16
+ };
17
+ use team_agent_contract::host::{digest, digest_hex};
18
+
19
+ use crate::transport::{PaneInfo, Target};
20
+
21
+ type Result<T> = std::result::Result<T, &'static str>;
22
+ const MAX_RECEIPT_BYTES: u64 = 64 * 1024;
23
+ // A 73MB debug image exceeded 2s on the official host. This is a finite
24
+ // computation budget, never a sleep or a minimum startup delay.
25
+ const IMAGE_EVIDENCE_BUDGET: Duration = Duration::from_secs(10);
26
+
27
+ #[derive(Serialize, Deserialize)]
28
+ #[serde(deny_unknown_fields)]
29
+ struct Ancestor {
30
+ pid: u32,
31
+ birth: String,
32
+ }
33
+
34
+ #[derive(Serialize, Deserialize)]
35
+ #[serde(deny_unknown_fields)]
36
+ struct Handshake {
37
+ schema: u8,
38
+ workspace: PathBuf,
39
+ owner: String,
40
+ agent: String,
41
+ started_at: String,
42
+ flushed_at: String,
43
+ server: ProcessStamp,
44
+ ancestors: Vec<Ancestor>,
45
+ }
46
+
47
+ fn private_metadata(path: &Path, directory: bool) -> Result<std::fs::Metadata> {
48
+ let m = std::fs::symlink_metadata(path).map_err(|_| "mcp_handshake_missing")?;
49
+ // SAFETY: geteuid has no arguments, pointers, or side effects.
50
+ let uid = unsafe { libc::geteuid() };
51
+ if m.file_type().is_symlink()
52
+ || m.uid() != uid
53
+ || (directory && !m.is_dir())
54
+ || (!directory && (!m.is_file() || m.mode() & 0o022 != 0))
55
+ {
56
+ return Err("mcp_handshake_ownership");
57
+ }
58
+ Ok(m)
59
+ }
60
+
61
+ fn directory(workspace: &Path, create: bool) -> Result<PathBuf> {
62
+ let mut path = workspace.to_path_buf();
63
+ for component in [".team", "runtime", "mcp-handshakes"] {
64
+ path.push(component);
65
+ if create && !path.exists() {
66
+ match std::fs::DirBuilder::new().mode(0o700).create(&path) {
67
+ Ok(()) => {}
68
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
69
+ Err(_) => return Err("mcp_handshake_directory"),
70
+ }
71
+ }
72
+ let m = private_metadata(&path, true)?;
73
+ if component == "mcp-handshakes" && m.mode() & 0o777 != 0o700 {
74
+ return Err("mcp_handshake_ownership");
75
+ }
76
+ }
77
+ Ok(path)
78
+ }
79
+
80
+ fn key(owner: &str, agent: &str) -> String {
81
+ digest_hex(digest(format!("{owner}\0{agent}").as_bytes()))
82
+ }
83
+
84
+ fn read_json(path: &Path, require_private: bool) -> Result<Value> {
85
+ let before = private_metadata(path, false)?;
86
+ if (require_private && before.mode() & 0o777 != 0o600) || before.len() > MAX_RECEIPT_BYTES {
87
+ return Err("mcp_handshake_invalid");
88
+ }
89
+ let file = OpenOptions::new()
90
+ .read(true)
91
+ .custom_flags(libc::O_NOFOLLOW)
92
+ .open(path)
93
+ .map_err(|_| "mcp_handshake_missing")?;
94
+ let opened = file.metadata().map_err(|_| "mcp_handshake_invalid")?;
95
+ if (before.dev(), before.ino()) != (opened.dev(), opened.ino()) {
96
+ return Err("mcp_handshake_replaced");
97
+ }
98
+ let mut data = Vec::new();
99
+ file.take(MAX_RECEIPT_BYTES + 1)
100
+ .read_to_end(&mut data)
101
+ .map_err(|_| "mcp_handshake_invalid")?;
102
+ if data.len() as u64 > MAX_RECEIPT_BYTES {
103
+ return Err("mcp_handshake_invalid");
104
+ }
105
+ serde_json::from_slice(&data).map_err(|_| "mcp_handshake_invalid")
106
+ }
107
+
108
+ struct ReceiptLock(File);
109
+ impl Drop for ReceiptLock {
110
+ fn drop(&mut self) {
111
+ let _ = crate::platform::file_lock::unlock(&self.0);
112
+ }
113
+ }
114
+
115
+ fn lock(path: &Path) -> Result<ReceiptLock> {
116
+ let file = OpenOptions::new()
117
+ .create(true)
118
+ .truncate(false)
119
+ .read(true)
120
+ .write(true)
121
+ .mode(0o600)
122
+ .custom_flags(libc::O_NOFOLLOW)
123
+ .open(path)
124
+ .map_err(|_| "mcp_handshake_lock")?;
125
+ private_metadata(path, false)?;
126
+ let start = Instant::now();
127
+ loop {
128
+ match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
129
+ Ok(true) => return Ok(ReceiptLock(file)),
130
+ Ok(false) if start.elapsed() < Duration::from_millis(500) => {
131
+ std::thread::sleep(Duration::from_millis(10));
132
+ }
133
+ _ => return Err("mcp_handshake_lock"),
134
+ }
135
+ }
136
+ }
137
+
138
+ /// Only the actual stdio writer calls this after initialize and tools/list flush.
139
+ /// File publication failure stays unready; it never becomes an input bypass.
140
+ pub(crate) fn publish(
141
+ workspace: &Path,
142
+ owner: &str,
143
+ agent: &str,
144
+ started_at: DateTime<Utc>,
145
+ ) -> Result<()> {
146
+ if owner.is_empty() || agent.is_empty() {
147
+ return Err("mcp_handshake_scope_missing");
148
+ }
149
+ let workspace = workspace
150
+ .canonicalize()
151
+ .map_err(|_| "mcp_handshake_workspace")?;
152
+ let executable = std::env::current_exe().map_err(|_| "mcp_handshake_image")?;
153
+ let image = fingerprint_file(&executable, 512 * 1024 * 1024, IMAGE_EVIDENCE_BUDGET).map_err(
154
+ |error| {
155
+ let _ = crate::event_log::EventLog::new(&workspace).write(
156
+ "mcp.handshake_evidence_failed",
157
+ serde_json::json!({
158
+ "stage": "image_fingerprint",
159
+ "operation": error.operation,
160
+ "kind": format!("{:?}", error.kind),
161
+ "image_bytes": std::fs::metadata(&executable).ok().map(|m| m.len()),
162
+ "budget_ms": IMAGE_EVIDENCE_BUDGET.as_millis(),
163
+ }),
164
+ );
165
+ "mcp_handshake_image"
166
+ },
167
+ )?;
168
+ let server = capture_process(
169
+ std::process::id(),
170
+ &executable,
171
+ image,
172
+ IMAGE_EVIDENCE_BUDGET,
173
+ )
174
+ .map_err(|_| "mcp_handshake_process")?;
175
+ let mut ancestors = Vec::new();
176
+ let mut parent = server.parent;
177
+ for _ in 0..MAX_NATIVE_PARENT_HOPS {
178
+ if parent <= 1 {
179
+ break;
180
+ }
181
+ let birth = process_birth_identity(parent).map_err(|_| "mcp_handshake_ancestry")?;
182
+ ancestors.push(Ancestor { pid: parent, birth });
183
+ parent = crate::platform::argv::parent_pid(parent).unwrap_or(0);
184
+ }
185
+ if ancestors.is_empty() || sample_process(&server) != ProcessState::Alive {
186
+ return Err("mcp_handshake_process");
187
+ }
188
+ let receipt = Handshake {
189
+ schema: 1,
190
+ workspace: workspace.clone(),
191
+ owner: owner.to_string(),
192
+ agent: agent.to_string(),
193
+ started_at: started_at.to_rfc3339(),
194
+ flushed_at: Utc::now().to_rfc3339(),
195
+ server,
196
+ ancestors,
197
+ };
198
+ let dir = directory(&workspace, true)?;
199
+ let before = private_metadata(&dir, true)?;
200
+ let name = key(owner, agent);
201
+ let path = dir.join(format!("{name}.json"));
202
+ let _lock = lock(&dir.join(format!("{name}.lock")))?;
203
+ match std::fs::symlink_metadata(&path) {
204
+ Ok(_) => {
205
+ let old: Handshake = serde_json::from_value(read_json(&path, true)?)
206
+ .map_err(|_| "mcp_handshake_invalid")?;
207
+ if old.workspace != workspace
208
+ || old.owner != owner
209
+ || old.agent != agent
210
+ || old.schema != 1
211
+ {
212
+ return Err("mcp_handshake_scope_mismatch");
213
+ }
214
+ let old_started = DateTime::parse_from_rfc3339(&old.started_at)
215
+ .map_err(|_| "mcp_handshake_invalid")?;
216
+ if old_started > started_at {
217
+ return Ok(());
218
+ }
219
+ }
220
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
221
+ Err(_) => return Err("mcp_handshake_invalid"),
222
+ }
223
+ let temp = dir.join(format!(
224
+ "{name}.{}-{}.tmp",
225
+ std::process::id(),
226
+ crate::provider::adapter::next_session_token()
227
+ ));
228
+ let result = (|| {
229
+ let mut file = OpenOptions::new()
230
+ .write(true)
231
+ .create_new(true)
232
+ .mode(0o600)
233
+ .custom_flags(libc::O_NOFOLLOW)
234
+ .open(&temp)
235
+ .map_err(|_| "mcp_handshake_write")?;
236
+ serde_json::to_writer(&mut file, &receipt).map_err(|_| "mcp_handshake_write")?;
237
+ file.write_all(b"\n").map_err(|_| "mcp_handshake_write")?;
238
+ file.sync_all().map_err(|_| "mcp_handshake_write")?;
239
+ let after = private_metadata(&dir, true)?;
240
+ if (before.dev(), before.ino()) != (after.dev(), after.ino()) {
241
+ return Err("mcp_handshake_replaced");
242
+ }
243
+ std::fs::rename(&temp, &path).map_err(|_| "mcp_handshake_write")?;
244
+ File::open(&dir)
245
+ .and_then(|file| file.sync_all())
246
+ .map_err(|_| "mcp_handshake_write")
247
+ })();
248
+ if result.is_err() {
249
+ let _ = std::fs::remove_file(&temp);
250
+ }
251
+ result
252
+ }
253
+
254
+ #[derive(Debug)]
255
+ pub(crate) struct Admission {
256
+ server: ProcessStamp,
257
+ carrier: u32,
258
+ carrier_birth: String,
259
+ pane_id: String,
260
+ }
261
+
262
+ /// Observation-only preflight, before claiming a delivery attempt. Missing facts
263
+ /// perform no input; the delivery owner releases only its exact claim for retry.
264
+ pub(crate) fn preflight(
265
+ workspace: &Path,
266
+ owner: Option<&str>,
267
+ agent_id: &str,
268
+ state: &Value,
269
+ ) -> Result<Option<Admission>> {
270
+ if agent_id == "leader" {
271
+ return Ok(None);
272
+ }
273
+ let Some(agent) = state.get("agents").and_then(|agents| agents.get(agent_id)) else {
274
+ return Ok(None);
275
+ };
276
+ let provider = agent
277
+ .get("provider")
278
+ .and_then(Value::as_str)
279
+ .and_then(crate::provider::wire::parse_provider)
280
+ .ok_or("mcp_handshake_provider_missing")?;
281
+ if !crate::provider::get_adapter(provider).requires_worker_mcp_handshake() {
282
+ return Ok(None);
283
+ }
284
+ let config = agent
285
+ .get("mcp_config")
286
+ .and_then(Value::as_str)
287
+ .filter(|path| !path.trim().is_empty())
288
+ .ok_or("mcp_handshake_config_missing")?;
289
+ let owner = owner
290
+ .filter(|s| !s.is_empty())
291
+ .ok_or("mcp_handshake_scope_missing")?;
292
+ let workspace = workspace
293
+ .canonicalize()
294
+ .map_err(|_| "mcp_handshake_workspace")?;
295
+ let dir = directory(&workspace, false)?;
296
+ let receipt: Handshake = serde_json::from_value(read_json(
297
+ &dir.join(format!("{}.json", key(owner, agent_id))),
298
+ true,
299
+ )?)
300
+ .map_err(|_| "mcp_handshake_invalid")?;
301
+ if receipt.schema != 1
302
+ || receipt.workspace != workspace
303
+ || receipt.owner != owner
304
+ || receipt.agent != agent_id
305
+ {
306
+ return Err("mcp_handshake_scope_mismatch");
307
+ }
308
+ let spawned_at = agent
309
+ .get("spawned_at")
310
+ .and_then(Value::as_str)
311
+ .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
312
+ .ok_or("mcp_handshake_spawn_missing")?;
313
+ let started_at =
314
+ DateTime::parse_from_rfc3339(&receipt.started_at).map_err(|_| "mcp_handshake_invalid")?;
315
+ let flushed_at =
316
+ DateTime::parse_from_rfc3339(&receipt.flushed_at).map_err(|_| "mcp_handshake_invalid")?;
317
+ if started_at < spawned_at || flushed_at < started_at {
318
+ return Err("mcp_handshake_stale_spawn");
319
+ }
320
+ let expected_config = workspace
321
+ .join(".team/runtime/mcp")
322
+ .join(format!("{agent_id}.json"));
323
+ if Path::new(config)
324
+ .canonicalize()
325
+ .map_err(|_| "mcp_handshake_config_missing")?
326
+ != expected_config
327
+ {
328
+ return Err("mcp_handshake_config_scope");
329
+ }
330
+ private_metadata(&workspace.join(".team/runtime/mcp"), true)?;
331
+ let config = read_json(Path::new(config), false)?;
332
+ let server_config = config
333
+ .get("team_orchestrator")
334
+ .or_else(|| {
335
+ config
336
+ .get("mcpServers")
337
+ .and_then(|servers| servers.get("team_orchestrator"))
338
+ })
339
+ .ok_or("mcp_handshake_config_missing")?;
340
+ let expected = server_config
341
+ .get("command")
342
+ .and_then(Value::as_str)
343
+ .ok_or("mcp_handshake_config_missing")?;
344
+ if !Path::new(expected).is_absolute()
345
+ || Path::new(expected)
346
+ .canonicalize()
347
+ .map_err(|_| "mcp_handshake_image")?
348
+ != receipt.server.identity.executable
349
+ {
350
+ return Err("mcp_handshake_image");
351
+ }
352
+ let env = server_config
353
+ .get("env")
354
+ .ok_or("mcp_handshake_config_missing")?;
355
+ if env.get("TEAM_AGENT_ID").and_then(Value::as_str) != Some(agent_id)
356
+ || env.get("TEAM_AGENT_OWNER_TEAM_ID").and_then(Value::as_str) != Some(owner)
357
+ || env
358
+ .get("TEAM_AGENT_WORKSPACE")
359
+ .and_then(Value::as_str)
360
+ .and_then(|s| Path::new(s).canonicalize().ok())
361
+ .as_ref()
362
+ != Some(&workspace)
363
+ {
364
+ return Err("mcp_handshake_config_scope");
365
+ }
366
+ let carrier = agent
367
+ .get("pane_pid")
368
+ .and_then(Value::as_u64)
369
+ .and_then(|pid| u32::try_from(pid).ok())
370
+ .ok_or("mcp_handshake_carrier_missing")?;
371
+ let pane_id = agent
372
+ .get("pane_id")
373
+ .and_then(Value::as_str)
374
+ .filter(|id| !id.is_empty())
375
+ .ok_or("mcp_handshake_carrier_missing")?;
376
+ let carrier_birth =
377
+ process_birth_identity(carrier).map_err(|_| "mcp_handshake_carrier_missing")?;
378
+ if sample_process(&receipt.server) != ProcessState::Alive {
379
+ return Err("mcp_handshake_server_not_live");
380
+ }
381
+ let mut parent = receipt.server.parent;
382
+ let mut matched = false;
383
+ for ancestor in receipt.ancestors.iter().take(MAX_NATIVE_PARENT_HOPS) {
384
+ if ancestor.pid != parent
385
+ || process_birth_identity(parent).ok().as_deref() != Some(ancestor.birth.as_str())
386
+ {
387
+ return Err("mcp_handshake_ancestry");
388
+ }
389
+ if parent == carrier {
390
+ matched = true;
391
+ break;
392
+ }
393
+ parent = crate::platform::argv::parent_pid(parent).unwrap_or(0);
394
+ }
395
+ if !matched || sample_process(&receipt.server) != ProcessState::Alive {
396
+ return Err("mcp_handshake_ancestry");
397
+ }
398
+ Ok(Some(Admission {
399
+ server: receipt.server,
400
+ carrier,
401
+ carrier_birth,
402
+ pane_id: pane_id.to_string(),
403
+ }))
404
+ }
405
+
406
+ /// Recheck against the actual resolved target immediately before the input path.
407
+ pub(crate) fn revalidate(admission: &Admission, target: &Target, panes: &[PaneInfo]) -> Result<()> {
408
+ let Target::Pane(id) = target else {
409
+ return Err("mcp_handshake_carrier_missing");
410
+ };
411
+ let pane = panes
412
+ .iter()
413
+ .find(|pane| &pane.pane_id == id)
414
+ .ok_or("mcp_handshake_carrier_missing")?;
415
+ if id.as_str() != admission.pane_id
416
+ || pane.pane_pid != Some(admission.carrier)
417
+ || process_birth_identity(admission.carrier).ok().as_deref()
418
+ != Some(admission.carrier_birth.as_str())
419
+ || sample_process(&admission.server) != ProcessState::Alive
420
+ {
421
+ return Err("mcp_handshake_carrier_mismatch");
422
+ }
423
+ Ok(())
424
+ }
425
+
426
+ // Test construction stays in the MCP process-evidence owner, not messaging.
427
+ #[cfg(test)]
428
+ pub(crate) fn test_carrier_pid() -> Option<u32> {
429
+ crate::platform::process::current_parent_pid()
430
+ }
431
+
432
+ /// Bind a controlled live test helper through the real receipt publisher. This
433
+ /// does not bypass preflight or fabricate a process/image/ancestry receipt.
434
+ #[cfg(test)]
435
+ pub(crate) fn test_ready_worker(
436
+ workspace: &Path,
437
+ state: &mut Value,
438
+ owner: &str,
439
+ agent: &str,
440
+ pane: &str,
441
+ ) -> PaneInfo {
442
+ let workspace = workspace.canonicalize().unwrap();
443
+ let carrier = test_carrier_pid().unwrap();
444
+ let config = workspace
445
+ .join(".team/runtime/mcp")
446
+ .join(format!("{agent}.json"));
447
+ std::fs::create_dir_all(config.parent().unwrap()).unwrap();
448
+ std::fs::write(&config, serde_json::to_vec(&serde_json::json!({
449
+ "mcpServers": {"team_orchestrator": {
450
+ "command": std::env::current_exe().unwrap().canonicalize().unwrap(),
451
+ "env": {"TEAM_AGENT_WORKSPACE": workspace, "TEAM_AGENT_ID": agent, "TEAM_AGENT_OWNER_TEAM_ID": owner}
452
+ }}
453
+ })).unwrap()).unwrap();
454
+ state["team_key"] = serde_json::json!(owner);
455
+ state["active_team_key"] = serde_json::json!(owner);
456
+ let row = &mut state["agents"][agent];
457
+ row["mcp_config"] = serde_json::json!(config);
458
+ row["pane_id"] = serde_json::json!(pane);
459
+ row["pane_pid"] = serde_json::json!(carrier);
460
+ row["spawned_at"] =
461
+ serde_json::json!((Utc::now() - chrono::Duration::seconds(10)).to_rfc3339());
462
+ let window = row["window"].as_str().unwrap_or(agent).to_string();
463
+ let session = state["session_name"].as_str().unwrap().to_string();
464
+ if state["teams"][owner].is_object() {
465
+ state["teams"][owner]["agents"][agent] = state["agents"][agent].clone();
466
+ }
467
+ publish(&workspace, owner, agent, Utc::now()).unwrap();
468
+ let target = PaneInfo {
469
+ pane_id: crate::transport::PaneId::new(pane),
470
+ session: crate::transport::SessionName::new(session),
471
+ window_index: None,
472
+ window_name: Some(crate::transport::WindowName::new(window)),
473
+ pane_index: None,
474
+ tty: None,
475
+ current_command: None,
476
+ current_path: Some(workspace.clone()),
477
+ active: true,
478
+ pane_pid: Some(carrier),
479
+ leader_env: Default::default(),
480
+ };
481
+ let admission = preflight(&workspace, Some(owner), agent, state)
482
+ .unwrap()
483
+ .unwrap();
484
+ revalidate(
485
+ &admission,
486
+ &Target::Pane(target.pane_id.clone()),
487
+ std::slice::from_ref(&target),
488
+ )
489
+ .unwrap();
490
+ target
491
+ }
492
+
493
+ #[cfg(test)]
494
+ #[path = "readiness_tests.rs"]
495
+ mod tests;