@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
|
@@ -720,8 +720,12 @@ fn contract_b_step2_retry_delivers_queued_message_after_startup_trust_is_handled
|
|
|
720
720
|
let ws = tmp_dir("b-step2-trust-retry");
|
|
721
721
|
let mcp_config = ws.join(".team").join("runtime").join("mcp").join("w1.json");
|
|
722
722
|
std::fs::create_dir_all(mcp_config.parent().unwrap()).unwrap();
|
|
723
|
-
|
|
724
|
-
|
|
723
|
+
// This worker genuinely depends on MCP. Bind the controlled receipt to the
|
|
724
|
+
// live test carrier rather than bypassing readiness with an empty config.
|
|
725
|
+
let owner = "subprep-trust-retry";
|
|
726
|
+
let mut state = json!({
|
|
727
|
+
"team_key": owner,
|
|
728
|
+
"active_team_key": owner,
|
|
725
729
|
"session_name": "team-ta-subroot-SUB-PROBE-2-send-turn-idle",
|
|
726
730
|
"team_dir": ws.join("teamdir").to_string_lossy().to_string(),
|
|
727
731
|
"agents": {
|
|
@@ -736,6 +740,21 @@ fn contract_b_step2_retry_delivers_queued_message_after_startup_trust_is_handled
|
|
|
736
740
|
}
|
|
737
741
|
}
|
|
738
742
|
});
|
|
743
|
+
#[cfg(unix)]
|
|
744
|
+
let carrier = {
|
|
745
|
+
let carrier = crate::mcp_server::readiness::test_carrier_pid().unwrap();
|
|
746
|
+
state["agents"]["w1"]["pane_pid"] = json!(carrier);
|
|
747
|
+
state["agents"]["w1"]["spawned_at"] =
|
|
748
|
+
json!((chrono::Utc::now() - chrono::Duration::seconds(10)).to_rfc3339());
|
|
749
|
+
carrier
|
|
750
|
+
};
|
|
751
|
+
state["teams"] = json!({owner: state.clone()});
|
|
752
|
+
std::fs::write(&mcp_config, serde_json::to_vec(&json!({
|
|
753
|
+
"mcpServers": {"team_orchestrator": {
|
|
754
|
+
"command": std::env::current_exe().unwrap().canonicalize().unwrap(),
|
|
755
|
+
"env": {"TEAM_AGENT_WORKSPACE": ws.to_path_buf(), "TEAM_AGENT_ID": "w1", "TEAM_AGENT_OWNER_TEAM_ID": owner}
|
|
756
|
+
}}
|
|
757
|
+
})).unwrap()).unwrap();
|
|
739
758
|
team_agent::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
740
759
|
|
|
741
760
|
let store = MessageStore::open(&ws).unwrap();
|
|
@@ -748,7 +767,7 @@ fn contract_b_step2_retry_delivers_queued_message_after_startup_trust_is_handled
|
|
|
748
767
|
"SUB step2 probe: reply with one sentence and call report_result exactly once.",
|
|
749
768
|
None,
|
|
750
769
|
true,
|
|
751
|
-
|
|
770
|
+
Some(owner),
|
|
752
771
|
)
|
|
753
772
|
.unwrap();
|
|
754
773
|
let transport = RecordingTransport::new(vec![
|
|
@@ -759,6 +778,10 @@ fn contract_b_step2_retry_delivers_queued_message_after_startup_trust_is_handled
|
|
|
759
778
|
])
|
|
760
779
|
.with_session_present(true)
|
|
761
780
|
.with_windows(vec![WindowName::new("w1")]);
|
|
781
|
+
#[cfg(unix)]
|
|
782
|
+
{
|
|
783
|
+
transport.state.lock().unwrap().carrier = Some(carrier);
|
|
784
|
+
}
|
|
762
785
|
let event_log = EventLog::new(&ws);
|
|
763
786
|
|
|
764
787
|
let deferred =
|
|
@@ -787,6 +810,13 @@ fn contract_b_step2_retry_delivers_queued_message_after_startup_trust_is_handled
|
|
|
787
810
|
"fixture sanity: delivery defer must be observable as delivery.deferred_startup_prompt"
|
|
788
811
|
);
|
|
789
812
|
|
|
813
|
+
// No receipt existed during the actionable trust prompt. Once startup
|
|
814
|
+
// control can progress, model the successful live helper handshake before
|
|
815
|
+
// the coordinator retries this SAME queued row.
|
|
816
|
+
#[cfg(unix)]
|
|
817
|
+
{
|
|
818
|
+
crate::mcp_server::readiness::publish(&ws, owner, "w1", chrono::Utc::now()).unwrap();
|
|
819
|
+
}
|
|
790
820
|
let coord = Coordinator::new(
|
|
791
821
|
WorkspacePath::new(ws.to_path_buf()),
|
|
792
822
|
Box::new(RealAdapterRegistry),
|
|
@@ -1045,6 +1075,7 @@ struct TransportState {
|
|
|
1045
1075
|
session_present: bool,
|
|
1046
1076
|
windows: Vec<WindowName>,
|
|
1047
1077
|
spawn_argv: Vec<Vec<String>>,
|
|
1078
|
+
carrier: Option<u32>,
|
|
1048
1079
|
}
|
|
1049
1080
|
|
|
1050
1081
|
#[derive(Debug, Clone)]
|
|
@@ -1189,7 +1220,19 @@ impl Transport for RecordingTransport {
|
|
|
1189
1220
|
}
|
|
1190
1221
|
|
|
1191
1222
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
1192
|
-
Ok(
|
|
1223
|
+
Ok(self.state.lock().unwrap().carrier.map(|carrier| PaneInfo {
|
|
1224
|
+
pane_id: PaneId::new("%7"),
|
|
1225
|
+
session: SessionName::new("team-ta-subroot-SUB-PROBE-2-send-turn-idle"),
|
|
1226
|
+
window_index: None,
|
|
1227
|
+
window_name: Some(WindowName::new("w1")),
|
|
1228
|
+
pane_index: None,
|
|
1229
|
+
tty: None,
|
|
1230
|
+
current_command: None,
|
|
1231
|
+
current_path: None,
|
|
1232
|
+
active: true,
|
|
1233
|
+
pane_pid: Some(carrier),
|
|
1234
|
+
leader_env: BTreeMap::new(),
|
|
1235
|
+
}).into_iter().collect())
|
|
1193
1236
|
}
|
|
1194
1237
|
|
|
1195
1238
|
fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
|
|
@@ -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;
|