@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
@@ -0,0 +1,432 @@
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 Some(config) = agent.get("mcp_config").and_then(Value::as_str) else {
277
+ return Ok(None);
278
+ };
279
+ let provider = agent
280
+ .get("provider")
281
+ .and_then(Value::as_str)
282
+ .and_then(crate::provider::wire::parse_provider)
283
+ .ok_or("mcp_handshake_provider_missing")?;
284
+ if !crate::provider::get_adapter(provider).requires_worker_mcp_handshake() {
285
+ return Ok(None);
286
+ }
287
+ let owner = owner
288
+ .filter(|s| !s.is_empty())
289
+ .ok_or("mcp_handshake_scope_missing")?;
290
+ let workspace = workspace
291
+ .canonicalize()
292
+ .map_err(|_| "mcp_handshake_workspace")?;
293
+ let dir = directory(&workspace, false)?;
294
+ let receipt: Handshake = serde_json::from_value(read_json(
295
+ &dir.join(format!("{}.json", key(owner, agent_id))),
296
+ true,
297
+ )?)
298
+ .map_err(|_| "mcp_handshake_invalid")?;
299
+ if receipt.schema != 1
300
+ || receipt.workspace != workspace
301
+ || receipt.owner != owner
302
+ || receipt.agent != agent_id
303
+ {
304
+ return Err("mcp_handshake_scope_mismatch");
305
+ }
306
+ let spawned_at = agent
307
+ .get("spawned_at")
308
+ .and_then(Value::as_str)
309
+ .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
310
+ .ok_or("mcp_handshake_spawn_missing")?;
311
+ let started_at =
312
+ DateTime::parse_from_rfc3339(&receipt.started_at).map_err(|_| "mcp_handshake_invalid")?;
313
+ let flushed_at =
314
+ DateTime::parse_from_rfc3339(&receipt.flushed_at).map_err(|_| "mcp_handshake_invalid")?;
315
+ if started_at < spawned_at || flushed_at < started_at {
316
+ return Err("mcp_handshake_stale_spawn");
317
+ }
318
+ let expected_config = workspace
319
+ .join(".team/runtime/mcp")
320
+ .join(format!("{agent_id}.json"));
321
+ if Path::new(config)
322
+ .canonicalize()
323
+ .map_err(|_| "mcp_handshake_config_missing")?
324
+ != expected_config
325
+ {
326
+ return Err("mcp_handshake_config_scope");
327
+ }
328
+ private_metadata(&workspace.join(".team/runtime/mcp"), true)?;
329
+ let config = read_json(Path::new(config), false)?;
330
+ let server_config = config
331
+ .get("team_orchestrator")
332
+ .or_else(|| {
333
+ config
334
+ .get("mcpServers")
335
+ .and_then(|servers| servers.get("team_orchestrator"))
336
+ })
337
+ .ok_or("mcp_handshake_config_missing")?;
338
+ let expected = server_config
339
+ .get("command")
340
+ .and_then(Value::as_str)
341
+ .ok_or("mcp_handshake_config_missing")?;
342
+ if !Path::new(expected).is_absolute()
343
+ || Path::new(expected)
344
+ .canonicalize()
345
+ .map_err(|_| "mcp_handshake_image")?
346
+ != receipt.server.identity.executable
347
+ {
348
+ return Err("mcp_handshake_image");
349
+ }
350
+ let env = server_config
351
+ .get("env")
352
+ .ok_or("mcp_handshake_config_missing")?;
353
+ if env.get("TEAM_AGENT_ID").and_then(Value::as_str) != Some(agent_id)
354
+ || env.get("TEAM_AGENT_OWNER_TEAM_ID").and_then(Value::as_str) != Some(owner)
355
+ || env
356
+ .get("TEAM_AGENT_WORKSPACE")
357
+ .and_then(Value::as_str)
358
+ .and_then(|s| Path::new(s).canonicalize().ok())
359
+ .as_ref()
360
+ != Some(&workspace)
361
+ {
362
+ return Err("mcp_handshake_config_scope");
363
+ }
364
+ let carrier = agent
365
+ .get("pane_pid")
366
+ .and_then(Value::as_u64)
367
+ .and_then(|pid| u32::try_from(pid).ok())
368
+ .ok_or("mcp_handshake_carrier_missing")?;
369
+ let pane_id = agent
370
+ .get("pane_id")
371
+ .and_then(Value::as_str)
372
+ .filter(|id| !id.is_empty())
373
+ .ok_or("mcp_handshake_carrier_missing")?;
374
+ let carrier_birth =
375
+ process_birth_identity(carrier).map_err(|_| "mcp_handshake_carrier_missing")?;
376
+ if sample_process(&receipt.server) != ProcessState::Alive {
377
+ return Err("mcp_handshake_server_not_live");
378
+ }
379
+ let mut parent = receipt.server.parent;
380
+ let mut matched = false;
381
+ for ancestor in receipt.ancestors.iter().take(MAX_NATIVE_PARENT_HOPS) {
382
+ if ancestor.pid != parent
383
+ || process_birth_identity(parent).ok().as_deref() != Some(ancestor.birth.as_str())
384
+ {
385
+ return Err("mcp_handshake_ancestry");
386
+ }
387
+ if parent == carrier {
388
+ matched = true;
389
+ break;
390
+ }
391
+ parent = crate::platform::argv::parent_pid(parent).unwrap_or(0);
392
+ }
393
+ if !matched || sample_process(&receipt.server) != ProcessState::Alive {
394
+ return Err("mcp_handshake_ancestry");
395
+ }
396
+ Ok(Some(Admission {
397
+ server: receipt.server,
398
+ carrier,
399
+ carrier_birth,
400
+ pane_id: pane_id.to_string(),
401
+ }))
402
+ }
403
+
404
+ /// Recheck against the actual resolved target immediately before the input path.
405
+ pub(crate) fn revalidate(admission: &Admission, target: &Target, panes: &[PaneInfo]) -> Result<()> {
406
+ let Target::Pane(id) = target else {
407
+ return Err("mcp_handshake_carrier_missing");
408
+ };
409
+ let pane = panes
410
+ .iter()
411
+ .find(|pane| &pane.pane_id == id)
412
+ .ok_or("mcp_handshake_carrier_missing")?;
413
+ if id.as_str() != admission.pane_id
414
+ || pane.pane_pid != Some(admission.carrier)
415
+ || process_birth_identity(admission.carrier).ok().as_deref()
416
+ != Some(admission.carrier_birth.as_str())
417
+ || sample_process(&admission.server) != ProcessState::Alive
418
+ {
419
+ return Err("mcp_handshake_carrier_mismatch");
420
+ }
421
+ Ok(())
422
+ }
423
+
424
+ // Test construction stays in the MCP process-evidence owner, not messaging.
425
+ #[cfg(test)]
426
+ pub(crate) fn test_carrier_pid() -> Option<u32> {
427
+ crate::platform::process::current_parent_pid()
428
+ }
429
+
430
+ #[cfg(test)]
431
+ #[path = "readiness_tests.rs"]
432
+ mod tests;
@@ -0,0 +1,242 @@
1
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2
+
3
+ use super::*;
4
+ use crate::model::enums::Provider;
5
+ use crate::transport::{PaneId, SessionName};
6
+ use serde_json::json;
7
+ use std::collections::BTreeMap;
8
+ use std::os::unix::fs::PermissionsExt;
9
+
10
+ struct Case {
11
+ ws: PathBuf,
12
+ state: Value,
13
+ target: Target,
14
+ panes: Vec<PaneInfo>,
15
+ started: DateTime<Utc>,
16
+ }
17
+
18
+ impl Case {
19
+ fn new() -> Self {
20
+ let ws = std::env::temp_dir().join(format!(
21
+ "ta-mcp-handshake-{}-{}",
22
+ std::process::id(),
23
+ crate::provider::adapter::next_session_token()
24
+ ));
25
+ std::fs::create_dir(&ws).unwrap();
26
+ let ws = ws.canonicalize().unwrap();
27
+ let config = ws.join(".team/runtime/mcp/worker.json");
28
+ std::fs::create_dir_all(config.parent().unwrap()).unwrap();
29
+ let executable = std::env::current_exe().unwrap().canonicalize().unwrap();
30
+ std::fs::write(&config, serde_json::to_vec(&json!({
31
+ "mcpServers": {"team_orchestrator": {
32
+ "command": executable,
33
+ "env": {"TEAM_AGENT_WORKSPACE": ws, "TEAM_AGENT_ID": "worker", "TEAM_AGENT_OWNER_TEAM_ID": "owner"}
34
+ }}
35
+ })).unwrap()).unwrap();
36
+ // Existing managed configurations are 0644, unlike private receipts.
37
+ std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o644)).unwrap();
38
+ let carrier = crate::platform::process::current_parent_pid().unwrap();
39
+ assert!(carrier > 1);
40
+ let started = Utc::now();
41
+ let state = json!({"agents": {"worker": {
42
+ "provider": "cursor_agent", "mcp_config": config,
43
+ "spawned_at": (started - chrono::Duration::seconds(10)).to_rfc3339(),
44
+ "pane_id": "%1", "pane_pid": carrier
45
+ }}});
46
+ let panes = vec![PaneInfo {
47
+ pane_id: PaneId::new("%1"),
48
+ session: SessionName::new("owned"),
49
+ window_index: None,
50
+ window_name: None,
51
+ pane_index: None,
52
+ tty: None,
53
+ current_command: None,
54
+ current_path: None,
55
+ active: true,
56
+ pane_pid: Some(carrier),
57
+ leader_env: BTreeMap::new(),
58
+ }];
59
+ Self {
60
+ ws,
61
+ state,
62
+ target: Target::Pane(PaneId::new("%1")),
63
+ panes,
64
+ started,
65
+ }
66
+ }
67
+
68
+ fn path(&self) -> PathBuf {
69
+ self.ws
70
+ .join(".team/runtime/mcp-handshakes")
71
+ .join(format!("{}.json", key("owner", "worker")))
72
+ }
73
+
74
+ fn publish(&self) {
75
+ let outcome = publish(&self.ws, "owner", "worker", self.started);
76
+ if outcome.is_err() {
77
+ for event in crate::event_log::EventLog::new(&self.ws).tail(0).unwrap() {
78
+ if event["event"] == "mcp.handshake_evidence_failed" {
79
+ eprintln!("CONTROLLED_MCP_HANDSHAKE_FAILURE {event}");
80
+ }
81
+ }
82
+ }
83
+ outcome.unwrap();
84
+ }
85
+
86
+ fn admission(&self) -> Result<Option<Admission>> {
87
+ preflight(&self.ws, Some("owner"), "worker", &self.state)
88
+ }
89
+ }
90
+
91
+ impl Drop for Case {
92
+ fn drop(&mut self) {
93
+ let _ = std::fs::remove_dir_all(&self.ws);
94
+ }
95
+ }
96
+
97
+ #[test]
98
+ fn missing_handshake_defers_then_a_live_scoped_receipt_admits() {
99
+ let case = Case::new();
100
+ assert!(case.admission().is_err());
101
+ case.publish();
102
+ let admission = case
103
+ .admission()
104
+ .unwrap()
105
+ .expect("eager client must require a receipt");
106
+ revalidate(&admission, &case.target, &case.panes).unwrap();
107
+ }
108
+
109
+ #[test]
110
+ fn receipt_cannot_cross_workspace_owner_or_seat() {
111
+ let case = Case::new();
112
+ case.publish();
113
+ assert!(preflight(&case.ws, None, "worker", &case.state).is_err());
114
+ assert!(preflight(&case.ws, Some("other"), "worker", &case.state).is_err());
115
+ let original = std::fs::read(case.path()).unwrap();
116
+ for field in ["workspace", "owner", "agent"] {
117
+ let mut value: Value = serde_json::from_slice(&original).unwrap();
118
+ value[field] = json!("foreign");
119
+ std::fs::write(case.path(), serde_json::to_vec(&value).unwrap()).unwrap();
120
+ assert!(case.admission().is_err(), "foreign {field} cannot admit");
121
+ }
122
+ }
123
+
124
+ #[test]
125
+ fn malformed_stale_birth_ancestry_and_image_receipts_refuse() {
126
+ let case = Case::new();
127
+ case.publish();
128
+ let original: Value = serde_json::from_slice(&std::fs::read(case.path()).unwrap()).unwrap();
129
+ let mut changed = Vec::new();
130
+ let mut v = original.clone();
131
+ v["schema"] = json!(2);
132
+ changed.push(v);
133
+ let mut v = original.clone();
134
+ v["server"]["identity"]["birth_identity"] = json!("wrong-birth");
135
+ changed.push(v);
136
+ let mut v = original.clone();
137
+ v["server"]["identity"]["executable"] = json!("/not-authorized");
138
+ changed.push(v);
139
+ let mut v = original.clone();
140
+ v["server"]["image"]["inode"] = json!(0);
141
+ changed.push(v);
142
+ let mut v = original.clone();
143
+ v["ancestors"][0]["birth"] = json!("reused-parent");
144
+ changed.push(v);
145
+ let mut v = original.clone();
146
+ v["server"]["parent"] = json!(1);
147
+ changed.push(v);
148
+ let mut v = original.clone();
149
+ v["started_at"] = json!("2000-01-01T00:00:00Z");
150
+ changed.push(v);
151
+ for value in changed {
152
+ std::fs::write(case.path(), serde_json::to_vec(&value).unwrap()).unwrap();
153
+ assert!(case.admission().is_err());
154
+ }
155
+ std::fs::write(case.path(), b"{incomplete").unwrap();
156
+ assert!(case.admission().is_err());
157
+ }
158
+
159
+ #[test]
160
+ fn changed_current_target_and_new_spawn_do_not_reuse_a_handshake() {
161
+ let mut case = Case::new();
162
+ case.publish();
163
+ let admission = case.admission().unwrap().unwrap();
164
+ let mut other = case.panes.clone();
165
+ other[0].pane_pid = Some(std::process::id());
166
+ assert!(revalidate(&admission, &case.target, &other).is_err());
167
+ assert!(revalidate(&admission, &Target::Pane(PaneId::new("%2")), &case.panes).is_err());
168
+ case.state["agents"]["worker"]["spawned_at"] =
169
+ json!((Utc::now() + chrono::Duration::seconds(1)).to_rfc3339());
170
+ assert!(case.admission().is_err());
171
+ }
172
+
173
+ #[test]
174
+ fn older_helper_cannot_overwrite_a_newer_handshake() {
175
+ let case = Case::new();
176
+ case.publish();
177
+ let original = std::fs::read(case.path()).unwrap();
178
+ publish(
179
+ &case.ws,
180
+ "owner",
181
+ "worker",
182
+ case.started - chrono::Duration::seconds(1),
183
+ )
184
+ .unwrap();
185
+ assert_eq!(std::fs::read(case.path()).unwrap(), original);
186
+ }
187
+
188
+ #[test]
189
+ fn symlink_and_writable_evidence_are_not_adopted_or_overwritten() {
190
+ let case = Case::new();
191
+ case.publish();
192
+ std::fs::set_permissions(case.path(), std::fs::Permissions::from_mode(0o666)).unwrap();
193
+ assert!(case.admission().is_err());
194
+ std::fs::remove_file(case.path()).unwrap();
195
+ let outside = case.ws.join("outside");
196
+ std::fs::write(&outside, b"untouched").unwrap();
197
+ std::os::unix::fs::symlink(&outside, case.path()).unwrap();
198
+ assert!(case.admission().is_err());
199
+ assert!(publish(&case.ws, "owner", "worker", case.started).is_err());
200
+ assert_eq!(std::fs::read(outside).unwrap(), b"untouched");
201
+ assert!(std::fs::symlink_metadata(case.path())
202
+ .unwrap()
203
+ .file_type()
204
+ .is_symlink());
205
+ }
206
+
207
+ #[test]
208
+ fn config_scope_and_authorized_executable_cannot_be_substituted() {
209
+ let case = Case::new();
210
+ case.publish();
211
+ let path = case.ws.join(".team/runtime/mcp/worker.json");
212
+ let original: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
213
+ for field in [
214
+ "TEAM_AGENT_ID",
215
+ "TEAM_AGENT_OWNER_TEAM_ID",
216
+ "TEAM_AGENT_WORKSPACE",
217
+ ] {
218
+ let mut value = original.clone();
219
+ value["mcpServers"]["team_orchestrator"]["env"][field] = json!("foreign");
220
+ std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
221
+ assert!(case.admission().is_err());
222
+ }
223
+ let mut value = original;
224
+ value["mcpServers"]["team_orchestrator"]["command"] = json!("/bin/sh");
225
+ std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
226
+ assert!(case.admission().is_err());
227
+ }
228
+
229
+ #[test]
230
+ fn fake_and_pi_keep_their_existing_non_eager_or_runtime_registry_admission() {
231
+ assert!(!crate::provider::get_adapter(Provider::Fake).requires_worker_mcp_handshake());
232
+ assert!(!crate::provider::get_adapter(Provider::Pi).requires_worker_mcp_handshake());
233
+ assert!(crate::provider::get_adapter(Provider::CursorAgent).requires_worker_mcp_handshake());
234
+ let case = Case::new();
235
+ for provider in ["fake", "pi"] {
236
+ let mut state = case.state.clone();
237
+ state["agents"]["worker"]["provider"] = json!(provider);
238
+ assert!(preflight(&case.ws, Some("owner"), "worker", &state)
239
+ .unwrap()
240
+ .is_none());
241
+ }
242
+ }