@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.
- 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/coordinator/tests/health_sync.rs +27 -8
- 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/agent.rs +3 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +10 -1
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -0
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +16 -3
- 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 +162 -8
- package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +55 -6
- package/crates/team-agent/src/mcp_server/mod.rs +2 -0
- package/crates/team-agent/src/mcp_server/readiness.rs +495 -0
- package/crates/team-agent/src/mcp_server/readiness_tests.rs +272 -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/delivery_consumption_tests.rs +12 -1
- package/crates/team-agent/src/messaging/tests/dup_inject.rs +89 -5
- package/crates/team-agent/src/messaging/tests/spine.rs +45 -14
- package/crates/team-agent/src/provider/adapter.rs +7 -0
- 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;
|