@team-agent/installer 0.5.120 → 0.5.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -190,17 +190,20 @@ fn run_stdio_loop_inner<R: BufRead, W: Write>(
190
190
  report: &mut ServerRunReport,
191
191
  marker: &McpServerLifecycleMarker,
192
192
  ) -> Result<ServerRunReport, McpError> {
193
+ let mut initialized = false;
193
194
  for line in reader.lines() {
194
195
  let line = line?;
195
196
  report.requests_read = report.requests_read.saturating_add(1);
196
- let (frame, tools_list_success) = handle_stdin_line(tools, &line, report)?;
197
+ let (frame, response_kind) = handle_stdin_line(tools, &line, report)?;
197
198
  if let Some(value) = frame {
198
199
  let value = crate::redaction::redact_external_value(&value);
199
200
  serde_json::to_writer(&mut *writer, &value)?;
200
201
  writer.write_all(b"\n")?;
201
202
  writer.flush()?;
202
- if tools_list_success {
203
- marker.write_tools_list_response_written();
203
+ match response_kind {
204
+ ResponseKind::Initialize => initialized = true,
205
+ ResponseKind::ToolsList => marker.write_tools_list_response_written(initialized),
206
+ ResponseKind::Other => {}
204
207
  }
205
208
  report.responses_written = report.responses_written.saturating_add(1);
206
209
  }
@@ -209,11 +212,17 @@ fn run_stdio_loop_inner<R: BufRead, W: Write>(
209
212
  Ok(report.clone())
210
213
  }
211
214
 
215
+ enum ResponseKind {
216
+ Initialize,
217
+ ToolsList,
218
+ Other,
219
+ }
220
+
212
221
  fn handle_stdin_line(
213
222
  tools: &TeamOrchestratorTools,
214
223
  line: &str,
215
224
  report: &mut ServerRunReport,
216
- ) -> Result<(Option<Value>, bool), McpError> {
225
+ ) -> Result<(Option<Value>, ResponseKind), McpError> {
217
226
  let request: Value = match serde_json::from_str(line) {
218
227
  Ok(value) => value,
219
228
  Err(err) => {
@@ -224,23 +233,28 @@ fn handle_stdin_line(
224
233
  -32700,
225
234
  format!("parse error: {err}"),
226
235
  )),
227
- false,
236
+ ResponseKind::Other,
228
237
  ));
229
238
  }
230
239
  };
231
240
  if request.get("jsonrpc").and_then(Value::as_str) == Some("2.0") {
232
- let is_tools_list = request.get("method").and_then(Value::as_str) == Some("tools/list");
241
+ let method = request.get("method").and_then(Value::as_str);
242
+ let valid_id = request.get("id").is_some_and(|id| id.is_string() || id.is_number());
233
243
  match handle_mcp(tools, &request)? {
234
244
  Some(response) => {
235
- let tools_list_success = is_tools_list && response.error.is_none();
245
+ let kind = match (method, valid_id && response.error.is_none()) {
246
+ (Some("initialize"), true) => ResponseKind::Initialize,
247
+ (Some("tools/list"), true) => ResponseKind::ToolsList,
248
+ _ => ResponseKind::Other,
249
+ };
236
250
  if response.error.is_some() {
237
251
  report.error_frames = report.error_frames.saturating_add(1);
238
252
  }
239
- Ok((Some(serde_json::to_value(response)?), tools_list_success))
253
+ Ok((Some(serde_json::to_value(response)?), kind))
240
254
  }
241
255
  None => {
242
256
  report.notifications_skipped = report.notifications_skipped.saturating_add(1);
243
- Ok((None, false))
257
+ Ok((None, ResponseKind::Other))
244
258
  }
245
259
  }
246
260
  } else {
@@ -251,7 +265,7 @@ fn handle_stdin_line(
251
265
  err.to_envelope()
252
266
  }
253
267
  };
254
- Ok((Some(value), false))
268
+ Ok((Some(value), ResponseKind::Other))
255
269
  }
256
270
  }
257
271
 
@@ -281,6 +295,7 @@ struct McpServerLifecycleMarker {
281
295
  workspace: std::path::PathBuf,
282
296
  agent_id: Option<String>,
283
297
  owner_team_id: Option<String>,
298
+ started_at: chrono::DateTime<chrono::Utc>,
284
299
  pid: u32,
285
300
  ppid: u32,
286
301
  }
@@ -291,6 +306,7 @@ impl McpServerLifecycleMarker {
291
306
  workspace: workspace.to_path_buf(),
292
307
  agent_id: non_empty_env("TEAM_AGENT_ID"),
293
308
  owner_team_id: non_empty_env("TEAM_AGENT_OWNER_TEAM_ID"),
309
+ started_at: chrono::Utc::now(),
294
310
  pid: std::process::id(),
295
311
  ppid: parent_pid(),
296
312
  }
@@ -300,7 +316,7 @@ impl McpServerLifecycleMarker {
300
316
  self.write("mcp.server_started", None, None);
301
317
  }
302
318
 
303
- fn write_tools_list_response_written(&self) {
319
+ fn write_tools_list_response_written(&self, initialized: bool) {
304
320
  let _ = EventLog::new(&self.workspace).write(
305
321
  "mcp.tools_list_response_written",
306
322
  serde_json::json!({
@@ -313,8 +329,17 @@ impl McpServerLifecycleMarker {
313
329
  "ppid": self.ppid,
314
330
  "spawn_epoch": null,
315
331
  "spawn_epoch_source": "unavailable",
332
+ "initialize_response_flushed": initialized,
316
333
  }),
317
334
  );
335
+ #[cfg(unix)]
336
+ if initialized {
337
+ if let (Some(owner), Some(agent)) = (&self.owner_team_id, &self.agent_id) {
338
+ if let Err(reason) = super::readiness::publish(&self.workspace, owner, agent, self.started_at) {
339
+ self.write("mcp.handshake_receipt_failed", Some(reason), None);
340
+ }
341
+ }
342
+ }
318
343
  }
319
344
 
320
345
  fn write_exit(&self, reason: &str, error: Option<&str>) {
@@ -725,6 +750,66 @@ mod e23_lifecycle_marker_tests {
725
750
  let _ = std::fs::remove_dir_all(&ws);
726
751
  }
727
752
 
753
+ #[cfg(unix)]
754
+ fn run_bound_handshake<W: Write>(ws: &Path, input: &str, writer: &mut W) -> Result<ServerRunReport, McpError> {
755
+ let marker = McpServerLifecycleMarker {
756
+ workspace: ws.to_path_buf(), agent_id: Some("worker".into()),
757
+ owner_team_id: Some("owner".into()), started_at: chrono::Utc::now(),
758
+ pid: std::process::id(), ppid: parent_pid(),
759
+ };
760
+ run_stdio_loop_inner(&TeamOrchestratorTools::new(ws), std::io::Cursor::new(input.as_bytes()),
761
+ writer, &mut ServerRunReport::default(), &marker)
762
+ }
763
+
764
+ #[cfg(unix)]
765
+ fn handshake_receipts(ws: &Path) -> usize {
766
+ std::fs::read_dir(ws.join(".team/runtime/mcp-handshakes"))
767
+ .map(|files| files.filter_map(Result::ok)
768
+ .filter(|file| file.path().extension().is_some_and(|ext| ext == "json")).count())
769
+ .unwrap_or(0)
770
+ }
771
+
772
+ #[test]
773
+ #[cfg(unix)]
774
+ fn stdio_handshake_receipt_requires_initialize_and_tools_list_flush() {
775
+ let initialize = r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#;
776
+ let tools = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#;
777
+ let invalid_initialize = r#"{"jsonrpc":"2.0","method":"initialize"}"#;
778
+ let invalid_tools = r#"{"jsonrpc":"2.0","method":"tools/list"}"#;
779
+ for (tag, requests, expected) in [
780
+ ("init-only", vec![initialize], 0),
781
+ ("list-only", vec![tools], 0),
782
+ ("invalid-init", vec![invalid_initialize, tools], 0),
783
+ ("invalid-list", vec![initialize, invalid_tools], 0),
784
+ ("ready", vec![initialize, tools], 1),
785
+ ] {
786
+ let ws = marker_ws(tag);
787
+ let mut output = Vec::new();
788
+ run_bound_handshake(&ws, &(requests.join("\n") + "\n"), &mut output).unwrap();
789
+ assert_eq!(handshake_receipts(&ws), expected, "{tag}");
790
+ let _ = std::fs::remove_dir_all(ws);
791
+ }
792
+ }
793
+
794
+ #[test]
795
+ #[cfg(unix)]
796
+ fn stdio_failed_tools_list_flush_cannot_publish_a_handshake() {
797
+ struct FailSecondFlush(usize);
798
+ impl Write for FailSecondFlush {
799
+ fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { Ok(buf.len()) }
800
+ fn flush(&mut self) -> std::io::Result<()> {
801
+ self.0 += 1;
802
+ if self.0 == 2 { Err(std::io::Error::other("controlled tools/list flush failure")) }
803
+ else { Ok(()) }
804
+ }
805
+ }
806
+ let ws = marker_ws("handshake-flush-failure");
807
+ let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}\n";
808
+ assert!(run_bound_handshake(&ws, input, &mut FailSecondFlush(0)).is_err());
809
+ assert_eq!(handshake_receipts(&ws), 0);
810
+ let _ = std::fs::remove_dir_all(ws);
811
+ }
812
+
728
813
  #[test]
729
814
  fn stdio_loop_writes_started_and_stdin_eof_exit_markers() {
730
815
  let ws = marker_ws("eof");
@@ -302,6 +302,49 @@ fn prepare_message_state(
302
302
  Ok(Ok(prepared))
303
303
  }
304
304
 
305
+ #[cfg(unix)]
306
+ fn mcp_handshake_pending_outcome(
307
+ store: &MessageStore,
308
+ event_log: &EventLog,
309
+ message_id: &str,
310
+ attempt: u32,
311
+ reason: &str,
312
+ ) -> Result<DeliveryOutcome, MessagingError> {
313
+ // No paste/key has occurred. Only this exact claim may be released; a
314
+ // concurrent cancellation or newer delivery must never be overwritten.
315
+ if !store.release_delivery_before_input(message_id, attempt, reason)? {
316
+ return Ok(DeliveryOutcome {
317
+ ok: false,
318
+ status: DeliveryStatus::Refused,
319
+ message_status: MessageStatusShadow("target_resolved".to_string()),
320
+ message_id: Some(message_id.to_string()),
321
+ verification: None,
322
+ stage: None,
323
+ reason: Some(DeliveryRefusal::MessageAlreadyClaimed),
324
+ channel: None,
325
+ ack_forced_off: false,
326
+ turn_verification: None,
327
+ });
328
+ }
329
+ event_log.write("delivery.deferred_mcp_handshake", serde_json::json!({
330
+ "message_id": message_id,
331
+ "reason": reason,
332
+ "effect": "none",
333
+ }))?;
334
+ Ok(DeliveryOutcome {
335
+ ok: false,
336
+ status: DeliveryStatus::Degraded,
337
+ message_status: MessageStatusShadow("target_resolved".to_string()),
338
+ message_id: Some(message_id.to_string()),
339
+ verification: Some(reason.to_string()),
340
+ stage: None,
341
+ reason: None,
342
+ channel: None,
343
+ ack_forced_off: false,
344
+ turn_verification: None,
345
+ })
346
+ }
347
+
305
348
  fn deliver_prepared_message(
306
349
  workspace: &Path,
307
350
  store: &MessageStore,
@@ -642,6 +685,25 @@ fn deliver_prepared_message(
642
685
  turn_verification: None,
643
686
  });
644
687
  }
688
+ // Startup control must remain reachable before protocol admission: some
689
+ // clients cannot initialize MCP until their trust prompt is answered.
690
+ // No business input is allowed before the current tools/list flush. A
691
+ // readiness-only refusal releases this exact claim without spending an
692
+ // input attempt; it never holds a pane-input lock or sleeps for startup.
693
+ #[cfg(unix)]
694
+ {
695
+ let admission = crate::mcp_server::readiness::preflight(
696
+ workspace, canonical_owner_team_id.as_deref(), &message.recipient, state,
697
+ ).and_then(|admission| {
698
+ if let Some(admission) = admission.as_ref() {
699
+ crate::mcp_server::readiness::revalidate(admission, &target, &live_targets)?;
700
+ }
701
+ Ok(())
702
+ });
703
+ if let Err(reason) = admission {
704
+ return mcp_handshake_pending_outcome(store, event_log, message_id, attempt, reason);
705
+ }
706
+ }
645
707
  let rendered = render_message(
646
708
  &message.sender,
647
709
  message.task_id.as_deref(),
@@ -28,6 +28,44 @@ use crate::transport::{
28
28
  TurnVerification, WindowName,
29
29
  };
30
30
 
31
+ #[test]
32
+ #[cfg(unix)]
33
+ fn eager_mcp_waits_without_spending_input_attempts_then_delivers_the_same_message() {
34
+ let mut case = WorkerCase::new("mcp-handshake", "cursor_agent");
35
+ let carrier = crate::mcp_server::readiness::test_carrier_pid().unwrap();
36
+ let config = case.workspace.join(".team/runtime/mcp/w1.json");
37
+ std::fs::create_dir_all(config.parent().unwrap()).unwrap();
38
+ std::fs::write(&config, serde_json::to_vec(&serde_json::json!({
39
+ "mcpServers": {"team_orchestrator": {
40
+ "command": std::env::current_exe().unwrap().canonicalize().unwrap(),
41
+ "env": {"TEAM_AGENT_WORKSPACE": case.workspace, "TEAM_AGENT_ID": "w1", "TEAM_AGENT_OWNER_TEAM_ID": "dup-team"}
42
+ }}
43
+ })).unwrap()).unwrap();
44
+ case.state["team_key"] = serde_json::json!("dup-team");
45
+ case.state["agents"]["w1"]["mcp_config"] = serde_json::json!(config);
46
+ case.state["agents"]["w1"]["pane_pid"] = serde_json::json!(carrier);
47
+ case.state["agents"]["w1"]["spawned_at"] = serde_json::json!((chrono::Utc::now() - chrono::Duration::seconds(10)).to_rfc3339());
48
+ case.state["teams"] = serde_json::json!({"dup-team": case.state.clone()});
49
+ crate::state::persist::save_runtime_state(&case.workspace, &case.state).unwrap();
50
+ let transport = RecordingTransport { carrier: Some(carrier), ..RecordingTransport::new("") };
51
+ let message_id = case.store.create_message(None, "leader", "w1", "handshake canary", None, false, Some("dup-team")).unwrap();
52
+ for _ in 0..3 {
53
+ let outcome = deliver_pending_message(&case.workspace, &case.store, &transport,
54
+ &message_id, &case.event_log, &case.state).unwrap();
55
+ assert!(!outcome.ok);
56
+ assert!(outcome.verification.as_deref().unwrap().starts_with("mcp_handshake_"));
57
+ assert_eq!(transport.inject_count(), 0);
58
+ }
59
+ let connection = rusqlite::Connection::open(case.store.db_path()).unwrap();
60
+ let attempts: i64 = connection.query_row("select delivery_attempts from messages where message_id=?1", [&message_id], |row| row.get(0)).unwrap();
61
+ assert_eq!(attempts, 0, "waiting is not a physical delivery attempt");
62
+ crate::mcp_server::readiness::publish(&case.workspace, "dup-team", "w1", chrono::Utc::now()).unwrap();
63
+ let outcome = deliver_pending_message(&case.workspace, &case.store, &transport,
64
+ &message_id, &case.event_log, &case.state).unwrap();
65
+ assert_eq!(outcome.message_id.as_deref(), Some(message_id.as_str()));
66
+ assert_eq!(transport.inject_count(), 1, "same queued row proceeds after the real receipt");
67
+ }
68
+
31
69
  #[test]
32
70
  fn error_retry_claim_is_single_winner_across_two_delivers() {
33
71
  let case = WorkerCase::new("claim-gate", "fake");
@@ -273,6 +311,7 @@ fn poison_state_file(workspace: &Path) {
273
311
  struct RecordingTransport {
274
312
  injects: Arc<AtomicUsize>,
275
313
  capture: Arc<Mutex<String>>,
314
+ carrier: Option<u32>,
276
315
  }
277
316
 
278
317
  impl RecordingTransport {
@@ -280,6 +319,7 @@ impl RecordingTransport {
280
319
  Self {
281
320
  injects: Arc::new(AtomicUsize::new(0)),
282
321
  capture: Arc::new(Mutex::new(capture.to_string())),
322
+ carrier: None,
283
323
  }
284
324
  }
285
325
 
@@ -370,7 +410,7 @@ impl Transport for RecordingTransport {
370
410
  current_command: Some("sleep".to_string()),
371
411
  current_path: None,
372
412
  active: true,
373
- pane_pid: None,
413
+ pane_pid: self.carrier,
374
414
  leader_env: BTreeMap::new(),
375
415
  }])
376
416
  }
@@ -214,6 +214,13 @@ pub trait ProviderAdapter {
214
214
  /// compatible_api 走 `ensure_compatible_claude_mcp_config`)。
215
215
  fn mcp_config(&self, auth_mode: AuthMode) -> Result<McpConfig, ProviderError>;
216
216
 
217
+ /// Eager stdio clients must publish their initial MCP handshake before input.
218
+ /// Pi owns admission in its runtime registry hooks; Fake has no MCP client.
219
+ /// The process-stamp receipt port is currently POSIX-only, not a Windows claim.
220
+ fn requires_worker_mcp_handshake(&self) -> bool {
221
+ cfg!(unix) && !matches!(self.provider(), Provider::Fake | Provider::Pi)
222
+ }
223
+
217
224
  /// 安装 MCP server。gemini 写全局 `~/.gemini/settings.json` 并备份/还原
218
225
  /// (`gemini.py:40-78`)——有副作用 I/O,失败可还原 → Result。
219
226
  fn install_mcp(&self, config: &McpConfig) -> Result<(), ProviderError>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.120",
3
+ "version": "0.5.121",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.120",
24
- "@team-agent/cli-darwin-x64": "0.5.120",
25
- "@team-agent/cli-linux-x64": "0.5.120"
23
+ "@team-agent/cli-darwin-arm64": "0.5.121",
24
+ "@team-agent/cli-darwin-x64": "0.5.121",
25
+ "@team-agent/cli-linux-x64": "0.5.121"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",