@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/intent/runtime.rs +46 -10
- package/crates/team-agent/src/cli/intent/runtime_tests.rs +114 -0
- package/crates/team-agent/src/cli/mod.rs +4 -1
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +56 -0
- package/crates/team-agent/src/db/message_store.rs +65 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +8 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +111 -4
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +244 -82
- package/crates/team-agent/src/lifecycle/launch.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +7 -1
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +60 -17
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +30 -7
- package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +47 -4
- package/crates/team-agent/src/mcp_server/mod.rs +2 -0
- package/crates/team-agent/src/mcp_server/readiness.rs +432 -0
- package/crates/team-agent/src/mcp_server/readiness_tests.rs +242 -0
- package/crates/team-agent/src/mcp_server/wire.rs +96 -11
- package/crates/team-agent/src/messaging/delivery.rs +62 -0
- package/crates/team-agent/src/messaging/tests/dup_inject.rs +41 -1
- package/crates/team-agent/src/provider/adapter.rs +7 -0
- package/package.json +4 -4
|
@@ -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,
|
|
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
|
-
|
|
203
|
-
|
|
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>,
|
|
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
|
-
|
|
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
|
|
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
|
|
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)?),
|
|
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,
|
|
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),
|
|
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:
|
|
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.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
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",
|