@team-agent/installer 0.5.119 → 0.5.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +46 -1
- package/README.zh.md +46 -1
- package/crates/team-agent/src/cli/adapters.rs +11 -0
- package/crates/team-agent/src/cli/arguments.rs +401 -0
- package/crates/team-agent/src/cli/emit.rs +241 -142
- package/crates/team-agent/src/cli/intent/runtime.rs +1175 -0
- package/crates/team-agent/src/cli/intent/runtime_tests.rs +698 -0
- package/crates/team-agent/src/cli/intent/tests.rs +654 -0
- package/crates/team-agent/src/cli/intent.rs +642 -0
- package/crates/team-agent/src/cli/mod.rs +53 -6
- package/crates/team-agent/src/cli/named_address.rs +10 -1
- package/crates/team-agent/src/cli/spec.rs +22 -10
- package/crates/team-agent/src/cli/status_port/brief.rs +117 -16
- package/crates/team-agent/src/cli/tests/base.rs +6 -1
- package/crates/team-agent/src/cli/tests/parser_safety.rs +383 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +63 -19
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +56 -0
- package/crates/team-agent/src/cli/tests/verb_install_skill.rs +3 -2
- package/crates/team-agent/src/cli/tests/verb_profile.rs +3 -1
- package/crates/team-agent/src/contract_runtime/backend.rs +1 -1
- 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/quick_start.rs +166 -77
- package/crates/team-agent/src/lifecycle/launch.rs +29 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +7 -1
- package/crates/team-agent/src/lifecycle/restart.rs +17 -0
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1 -3
- package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +5 -15
- package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -3
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +2 -3
- package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +10 -2
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +8 -1
- package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +3 -2
- package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +6 -9
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +7 -5
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +64 -19
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +5 -2
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +34 -10
- package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +2 -4
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +3 -4
- package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +5 -4
- package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +2 -4
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +78 -36
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +8 -4
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +6 -12
- package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +4 -0
- package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1 -3
- package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +7 -0
- package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +47 -4
- package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -3
- package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -1
- package/crates/team-agent/src/lifecycle/types.rs +10 -0
- 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/crates/team-agent/src/tmux_backend/tests.rs +37 -0
- package/crates/team-agent/src/tmux_backend.rs +8 -1
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +71 -54
- package/skills/team-agent/command-coverage.json +283 -13
- package/skills/team-agent/docs/reference/team-agent-operator.md +220 -0
- package/skills/team-agent/references/bug-as-artifact-flow.md +1 -1
- package/skills/team-agent/references/recovery-runbook.md +61 -212
- package/skills/team-agent/references/team-agent-operator.md +181 -48
- package/skills/team-agent/references/team-in-team.md +47 -80
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
//! A flushed MCP handshake is admission to first business, not client consumption.
|
|
2
|
+
//! Receipts are scope- and live-process-bound; no pane input or tools/call probe.
|
|
3
|
+
|
|
4
|
+
use std::fs::{File, OpenOptions};
|
|
5
|
+
use std::io::{Read, Write};
|
|
6
|
+
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
|
|
7
|
+
use std::path::{Path, PathBuf};
|
|
8
|
+
use std::time::{Duration, Instant};
|
|
9
|
+
|
|
10
|
+
use chrono::{DateTime, Utc};
|
|
11
|
+
use serde::{Deserialize, Serialize};
|
|
12
|
+
use serde_json::Value;
|
|
13
|
+
use team_agent_contract::host::process::{
|
|
14
|
+
capture_process, fingerprint_file, process_birth_identity, sample_process, ProcessStamp,
|
|
15
|
+
ProcessState, MAX_NATIVE_PARENT_HOPS,
|
|
16
|
+
};
|
|
17
|
+
use team_agent_contract::host::{digest, digest_hex};
|
|
18
|
+
|
|
19
|
+
use crate::transport::{PaneInfo, Target};
|
|
20
|
+
|
|
21
|
+
type Result<T> = std::result::Result<T, &'static str>;
|
|
22
|
+
const MAX_RECEIPT_BYTES: u64 = 64 * 1024;
|
|
23
|
+
// A 73MB debug image exceeded 2s on the official host. This is a finite
|
|
24
|
+
// computation budget, never a sleep or a minimum startup delay.
|
|
25
|
+
const IMAGE_EVIDENCE_BUDGET: Duration = Duration::from_secs(10);
|
|
26
|
+
|
|
27
|
+
#[derive(Serialize, Deserialize)]
|
|
28
|
+
#[serde(deny_unknown_fields)]
|
|
29
|
+
struct Ancestor {
|
|
30
|
+
pid: u32,
|
|
31
|
+
birth: String,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#[derive(Serialize, Deserialize)]
|
|
35
|
+
#[serde(deny_unknown_fields)]
|
|
36
|
+
struct Handshake {
|
|
37
|
+
schema: u8,
|
|
38
|
+
workspace: PathBuf,
|
|
39
|
+
owner: String,
|
|
40
|
+
agent: String,
|
|
41
|
+
started_at: String,
|
|
42
|
+
flushed_at: String,
|
|
43
|
+
server: ProcessStamp,
|
|
44
|
+
ancestors: Vec<Ancestor>,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fn private_metadata(path: &Path, directory: bool) -> Result<std::fs::Metadata> {
|
|
48
|
+
let m = std::fs::symlink_metadata(path).map_err(|_| "mcp_handshake_missing")?;
|
|
49
|
+
// SAFETY: geteuid has no arguments, pointers, or side effects.
|
|
50
|
+
let uid = unsafe { libc::geteuid() };
|
|
51
|
+
if m.file_type().is_symlink()
|
|
52
|
+
|| m.uid() != uid
|
|
53
|
+
|| (directory && !m.is_dir())
|
|
54
|
+
|| (!directory && (!m.is_file() || m.mode() & 0o022 != 0))
|
|
55
|
+
{
|
|
56
|
+
return Err("mcp_handshake_ownership");
|
|
57
|
+
}
|
|
58
|
+
Ok(m)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fn directory(workspace: &Path, create: bool) -> Result<PathBuf> {
|
|
62
|
+
let mut path = workspace.to_path_buf();
|
|
63
|
+
for component in [".team", "runtime", "mcp-handshakes"] {
|
|
64
|
+
path.push(component);
|
|
65
|
+
if create && !path.exists() {
|
|
66
|
+
match std::fs::DirBuilder::new().mode(0o700).create(&path) {
|
|
67
|
+
Ok(()) => {}
|
|
68
|
+
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
|
69
|
+
Err(_) => return Err("mcp_handshake_directory"),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let m = private_metadata(&path, true)?;
|
|
73
|
+
if component == "mcp-handshakes" && m.mode() & 0o777 != 0o700 {
|
|
74
|
+
return Err("mcp_handshake_ownership");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
Ok(path)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn key(owner: &str, agent: &str) -> String {
|
|
81
|
+
digest_hex(digest(format!("{owner}\0{agent}").as_bytes()))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn read_json(path: &Path, require_private: bool) -> Result<Value> {
|
|
85
|
+
let before = private_metadata(path, false)?;
|
|
86
|
+
if (require_private && before.mode() & 0o777 != 0o600) || before.len() > MAX_RECEIPT_BYTES {
|
|
87
|
+
return Err("mcp_handshake_invalid");
|
|
88
|
+
}
|
|
89
|
+
let file = OpenOptions::new()
|
|
90
|
+
.read(true)
|
|
91
|
+
.custom_flags(libc::O_NOFOLLOW)
|
|
92
|
+
.open(path)
|
|
93
|
+
.map_err(|_| "mcp_handshake_missing")?;
|
|
94
|
+
let opened = file.metadata().map_err(|_| "mcp_handshake_invalid")?;
|
|
95
|
+
if (before.dev(), before.ino()) != (opened.dev(), opened.ino()) {
|
|
96
|
+
return Err("mcp_handshake_replaced");
|
|
97
|
+
}
|
|
98
|
+
let mut data = Vec::new();
|
|
99
|
+
file.take(MAX_RECEIPT_BYTES + 1)
|
|
100
|
+
.read_to_end(&mut data)
|
|
101
|
+
.map_err(|_| "mcp_handshake_invalid")?;
|
|
102
|
+
if data.len() as u64 > MAX_RECEIPT_BYTES {
|
|
103
|
+
return Err("mcp_handshake_invalid");
|
|
104
|
+
}
|
|
105
|
+
serde_json::from_slice(&data).map_err(|_| "mcp_handshake_invalid")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
struct ReceiptLock(File);
|
|
109
|
+
impl Drop for ReceiptLock {
|
|
110
|
+
fn drop(&mut self) {
|
|
111
|
+
let _ = crate::platform::file_lock::unlock(&self.0);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn lock(path: &Path) -> Result<ReceiptLock> {
|
|
116
|
+
let file = OpenOptions::new()
|
|
117
|
+
.create(true)
|
|
118
|
+
.truncate(false)
|
|
119
|
+
.read(true)
|
|
120
|
+
.write(true)
|
|
121
|
+
.mode(0o600)
|
|
122
|
+
.custom_flags(libc::O_NOFOLLOW)
|
|
123
|
+
.open(path)
|
|
124
|
+
.map_err(|_| "mcp_handshake_lock")?;
|
|
125
|
+
private_metadata(path, false)?;
|
|
126
|
+
let start = Instant::now();
|
|
127
|
+
loop {
|
|
128
|
+
match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
|
|
129
|
+
Ok(true) => return Ok(ReceiptLock(file)),
|
|
130
|
+
Ok(false) if start.elapsed() < Duration::from_millis(500) => {
|
|
131
|
+
std::thread::sleep(Duration::from_millis(10));
|
|
132
|
+
}
|
|
133
|
+
_ => return Err("mcp_handshake_lock"),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Only the actual stdio writer calls this after initialize and tools/list flush.
|
|
139
|
+
/// File publication failure stays unready; it never becomes an input bypass.
|
|
140
|
+
pub(crate) fn publish(
|
|
141
|
+
workspace: &Path,
|
|
142
|
+
owner: &str,
|
|
143
|
+
agent: &str,
|
|
144
|
+
started_at: DateTime<Utc>,
|
|
145
|
+
) -> Result<()> {
|
|
146
|
+
if owner.is_empty() || agent.is_empty() {
|
|
147
|
+
return Err("mcp_handshake_scope_missing");
|
|
148
|
+
}
|
|
149
|
+
let workspace = workspace
|
|
150
|
+
.canonicalize()
|
|
151
|
+
.map_err(|_| "mcp_handshake_workspace")?;
|
|
152
|
+
let executable = std::env::current_exe().map_err(|_| "mcp_handshake_image")?;
|
|
153
|
+
let image = fingerprint_file(&executable, 512 * 1024 * 1024, IMAGE_EVIDENCE_BUDGET).map_err(
|
|
154
|
+
|error| {
|
|
155
|
+
let _ = crate::event_log::EventLog::new(&workspace).write(
|
|
156
|
+
"mcp.handshake_evidence_failed",
|
|
157
|
+
serde_json::json!({
|
|
158
|
+
"stage": "image_fingerprint",
|
|
159
|
+
"operation": error.operation,
|
|
160
|
+
"kind": format!("{:?}", error.kind),
|
|
161
|
+
"image_bytes": std::fs::metadata(&executable).ok().map(|m| m.len()),
|
|
162
|
+
"budget_ms": IMAGE_EVIDENCE_BUDGET.as_millis(),
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
"mcp_handshake_image"
|
|
166
|
+
},
|
|
167
|
+
)?;
|
|
168
|
+
let server = capture_process(
|
|
169
|
+
std::process::id(),
|
|
170
|
+
&executable,
|
|
171
|
+
image,
|
|
172
|
+
IMAGE_EVIDENCE_BUDGET,
|
|
173
|
+
)
|
|
174
|
+
.map_err(|_| "mcp_handshake_process")?;
|
|
175
|
+
let mut ancestors = Vec::new();
|
|
176
|
+
let mut parent = server.parent;
|
|
177
|
+
for _ in 0..MAX_NATIVE_PARENT_HOPS {
|
|
178
|
+
if parent <= 1 {
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
let birth = process_birth_identity(parent).map_err(|_| "mcp_handshake_ancestry")?;
|
|
182
|
+
ancestors.push(Ancestor { pid: parent, birth });
|
|
183
|
+
parent = crate::platform::argv::parent_pid(parent).unwrap_or(0);
|
|
184
|
+
}
|
|
185
|
+
if ancestors.is_empty() || sample_process(&server) != ProcessState::Alive {
|
|
186
|
+
return Err("mcp_handshake_process");
|
|
187
|
+
}
|
|
188
|
+
let receipt = Handshake {
|
|
189
|
+
schema: 1,
|
|
190
|
+
workspace: workspace.clone(),
|
|
191
|
+
owner: owner.to_string(),
|
|
192
|
+
agent: agent.to_string(),
|
|
193
|
+
started_at: started_at.to_rfc3339(),
|
|
194
|
+
flushed_at: Utc::now().to_rfc3339(),
|
|
195
|
+
server,
|
|
196
|
+
ancestors,
|
|
197
|
+
};
|
|
198
|
+
let dir = directory(&workspace, true)?;
|
|
199
|
+
let before = private_metadata(&dir, true)?;
|
|
200
|
+
let name = key(owner, agent);
|
|
201
|
+
let path = dir.join(format!("{name}.json"));
|
|
202
|
+
let _lock = lock(&dir.join(format!("{name}.lock")))?;
|
|
203
|
+
match std::fs::symlink_metadata(&path) {
|
|
204
|
+
Ok(_) => {
|
|
205
|
+
let old: Handshake = serde_json::from_value(read_json(&path, true)?)
|
|
206
|
+
.map_err(|_| "mcp_handshake_invalid")?;
|
|
207
|
+
if old.workspace != workspace
|
|
208
|
+
|| old.owner != owner
|
|
209
|
+
|| old.agent != agent
|
|
210
|
+
|| old.schema != 1
|
|
211
|
+
{
|
|
212
|
+
return Err("mcp_handshake_scope_mismatch");
|
|
213
|
+
}
|
|
214
|
+
let old_started = DateTime::parse_from_rfc3339(&old.started_at)
|
|
215
|
+
.map_err(|_| "mcp_handshake_invalid")?;
|
|
216
|
+
if old_started > started_at {
|
|
217
|
+
return Ok(());
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
221
|
+
Err(_) => return Err("mcp_handshake_invalid"),
|
|
222
|
+
}
|
|
223
|
+
let temp = dir.join(format!(
|
|
224
|
+
"{name}.{}-{}.tmp",
|
|
225
|
+
std::process::id(),
|
|
226
|
+
crate::provider::adapter::next_session_token()
|
|
227
|
+
));
|
|
228
|
+
let result = (|| {
|
|
229
|
+
let mut file = OpenOptions::new()
|
|
230
|
+
.write(true)
|
|
231
|
+
.create_new(true)
|
|
232
|
+
.mode(0o600)
|
|
233
|
+
.custom_flags(libc::O_NOFOLLOW)
|
|
234
|
+
.open(&temp)
|
|
235
|
+
.map_err(|_| "mcp_handshake_write")?;
|
|
236
|
+
serde_json::to_writer(&mut file, &receipt).map_err(|_| "mcp_handshake_write")?;
|
|
237
|
+
file.write_all(b"\n").map_err(|_| "mcp_handshake_write")?;
|
|
238
|
+
file.sync_all().map_err(|_| "mcp_handshake_write")?;
|
|
239
|
+
let after = private_metadata(&dir, true)?;
|
|
240
|
+
if (before.dev(), before.ino()) != (after.dev(), after.ino()) {
|
|
241
|
+
return Err("mcp_handshake_replaced");
|
|
242
|
+
}
|
|
243
|
+
std::fs::rename(&temp, &path).map_err(|_| "mcp_handshake_write")?;
|
|
244
|
+
File::open(&dir)
|
|
245
|
+
.and_then(|file| file.sync_all())
|
|
246
|
+
.map_err(|_| "mcp_handshake_write")
|
|
247
|
+
})();
|
|
248
|
+
if result.is_err() {
|
|
249
|
+
let _ = std::fs::remove_file(&temp);
|
|
250
|
+
}
|
|
251
|
+
result
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#[derive(Debug)]
|
|
255
|
+
pub(crate) struct Admission {
|
|
256
|
+
server: ProcessStamp,
|
|
257
|
+
carrier: u32,
|
|
258
|
+
carrier_birth: String,
|
|
259
|
+
pane_id: String,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/// Observation-only preflight, before claiming a delivery attempt. Missing facts
|
|
263
|
+
/// perform no input; the delivery owner releases only its exact claim for retry.
|
|
264
|
+
pub(crate) fn preflight(
|
|
265
|
+
workspace: &Path,
|
|
266
|
+
owner: Option<&str>,
|
|
267
|
+
agent_id: &str,
|
|
268
|
+
state: &Value,
|
|
269
|
+
) -> Result<Option<Admission>> {
|
|
270
|
+
if agent_id == "leader" {
|
|
271
|
+
return Ok(None);
|
|
272
|
+
}
|
|
273
|
+
let Some(agent) = state.get("agents").and_then(|agents| agents.get(agent_id)) else {
|
|
274
|
+
return Ok(None);
|
|
275
|
+
};
|
|
276
|
+
let Some(config) = agent.get("mcp_config").and_then(Value::as_str) else {
|
|
277
|
+
return Ok(None);
|
|
278
|
+
};
|
|
279
|
+
let provider = agent
|
|
280
|
+
.get("provider")
|
|
281
|
+
.and_then(Value::as_str)
|
|
282
|
+
.and_then(crate::provider::wire::parse_provider)
|
|
283
|
+
.ok_or("mcp_handshake_provider_missing")?;
|
|
284
|
+
if !crate::provider::get_adapter(provider).requires_worker_mcp_handshake() {
|
|
285
|
+
return Ok(None);
|
|
286
|
+
}
|
|
287
|
+
let owner = owner
|
|
288
|
+
.filter(|s| !s.is_empty())
|
|
289
|
+
.ok_or("mcp_handshake_scope_missing")?;
|
|
290
|
+
let workspace = workspace
|
|
291
|
+
.canonicalize()
|
|
292
|
+
.map_err(|_| "mcp_handshake_workspace")?;
|
|
293
|
+
let dir = directory(&workspace, false)?;
|
|
294
|
+
let receipt: Handshake = serde_json::from_value(read_json(
|
|
295
|
+
&dir.join(format!("{}.json", key(owner, agent_id))),
|
|
296
|
+
true,
|
|
297
|
+
)?)
|
|
298
|
+
.map_err(|_| "mcp_handshake_invalid")?;
|
|
299
|
+
if receipt.schema != 1
|
|
300
|
+
|| receipt.workspace != workspace
|
|
301
|
+
|| receipt.owner != owner
|
|
302
|
+
|| receipt.agent != agent_id
|
|
303
|
+
{
|
|
304
|
+
return Err("mcp_handshake_scope_mismatch");
|
|
305
|
+
}
|
|
306
|
+
let spawned_at = agent
|
|
307
|
+
.get("spawned_at")
|
|
308
|
+
.and_then(Value::as_str)
|
|
309
|
+
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
|
310
|
+
.ok_or("mcp_handshake_spawn_missing")?;
|
|
311
|
+
let started_at =
|
|
312
|
+
DateTime::parse_from_rfc3339(&receipt.started_at).map_err(|_| "mcp_handshake_invalid")?;
|
|
313
|
+
let flushed_at =
|
|
314
|
+
DateTime::parse_from_rfc3339(&receipt.flushed_at).map_err(|_| "mcp_handshake_invalid")?;
|
|
315
|
+
if started_at < spawned_at || flushed_at < started_at {
|
|
316
|
+
return Err("mcp_handshake_stale_spawn");
|
|
317
|
+
}
|
|
318
|
+
let expected_config = workspace
|
|
319
|
+
.join(".team/runtime/mcp")
|
|
320
|
+
.join(format!("{agent_id}.json"));
|
|
321
|
+
if Path::new(config)
|
|
322
|
+
.canonicalize()
|
|
323
|
+
.map_err(|_| "mcp_handshake_config_missing")?
|
|
324
|
+
!= expected_config
|
|
325
|
+
{
|
|
326
|
+
return Err("mcp_handshake_config_scope");
|
|
327
|
+
}
|
|
328
|
+
private_metadata(&workspace.join(".team/runtime/mcp"), true)?;
|
|
329
|
+
let config = read_json(Path::new(config), false)?;
|
|
330
|
+
let server_config = config
|
|
331
|
+
.get("team_orchestrator")
|
|
332
|
+
.or_else(|| {
|
|
333
|
+
config
|
|
334
|
+
.get("mcpServers")
|
|
335
|
+
.and_then(|servers| servers.get("team_orchestrator"))
|
|
336
|
+
})
|
|
337
|
+
.ok_or("mcp_handshake_config_missing")?;
|
|
338
|
+
let expected = server_config
|
|
339
|
+
.get("command")
|
|
340
|
+
.and_then(Value::as_str)
|
|
341
|
+
.ok_or("mcp_handshake_config_missing")?;
|
|
342
|
+
if !Path::new(expected).is_absolute()
|
|
343
|
+
|| Path::new(expected)
|
|
344
|
+
.canonicalize()
|
|
345
|
+
.map_err(|_| "mcp_handshake_image")?
|
|
346
|
+
!= receipt.server.identity.executable
|
|
347
|
+
{
|
|
348
|
+
return Err("mcp_handshake_image");
|
|
349
|
+
}
|
|
350
|
+
let env = server_config
|
|
351
|
+
.get("env")
|
|
352
|
+
.ok_or("mcp_handshake_config_missing")?;
|
|
353
|
+
if env.get("TEAM_AGENT_ID").and_then(Value::as_str) != Some(agent_id)
|
|
354
|
+
|| env.get("TEAM_AGENT_OWNER_TEAM_ID").and_then(Value::as_str) != Some(owner)
|
|
355
|
+
|| env
|
|
356
|
+
.get("TEAM_AGENT_WORKSPACE")
|
|
357
|
+
.and_then(Value::as_str)
|
|
358
|
+
.and_then(|s| Path::new(s).canonicalize().ok())
|
|
359
|
+
.as_ref()
|
|
360
|
+
!= Some(&workspace)
|
|
361
|
+
{
|
|
362
|
+
return Err("mcp_handshake_config_scope");
|
|
363
|
+
}
|
|
364
|
+
let carrier = agent
|
|
365
|
+
.get("pane_pid")
|
|
366
|
+
.and_then(Value::as_u64)
|
|
367
|
+
.and_then(|pid| u32::try_from(pid).ok())
|
|
368
|
+
.ok_or("mcp_handshake_carrier_missing")?;
|
|
369
|
+
let pane_id = agent
|
|
370
|
+
.get("pane_id")
|
|
371
|
+
.and_then(Value::as_str)
|
|
372
|
+
.filter(|id| !id.is_empty())
|
|
373
|
+
.ok_or("mcp_handshake_carrier_missing")?;
|
|
374
|
+
let carrier_birth =
|
|
375
|
+
process_birth_identity(carrier).map_err(|_| "mcp_handshake_carrier_missing")?;
|
|
376
|
+
if sample_process(&receipt.server) != ProcessState::Alive {
|
|
377
|
+
return Err("mcp_handshake_server_not_live");
|
|
378
|
+
}
|
|
379
|
+
let mut parent = receipt.server.parent;
|
|
380
|
+
let mut matched = false;
|
|
381
|
+
for ancestor in receipt.ancestors.iter().take(MAX_NATIVE_PARENT_HOPS) {
|
|
382
|
+
if ancestor.pid != parent
|
|
383
|
+
|| process_birth_identity(parent).ok().as_deref() != Some(ancestor.birth.as_str())
|
|
384
|
+
{
|
|
385
|
+
return Err("mcp_handshake_ancestry");
|
|
386
|
+
}
|
|
387
|
+
if parent == carrier {
|
|
388
|
+
matched = true;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
parent = crate::platform::argv::parent_pid(parent).unwrap_or(0);
|
|
392
|
+
}
|
|
393
|
+
if !matched || sample_process(&receipt.server) != ProcessState::Alive {
|
|
394
|
+
return Err("mcp_handshake_ancestry");
|
|
395
|
+
}
|
|
396
|
+
Ok(Some(Admission {
|
|
397
|
+
server: receipt.server,
|
|
398
|
+
carrier,
|
|
399
|
+
carrier_birth,
|
|
400
|
+
pane_id: pane_id.to_string(),
|
|
401
|
+
}))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/// Recheck against the actual resolved target immediately before the input path.
|
|
405
|
+
pub(crate) fn revalidate(admission: &Admission, target: &Target, panes: &[PaneInfo]) -> Result<()> {
|
|
406
|
+
let Target::Pane(id) = target else {
|
|
407
|
+
return Err("mcp_handshake_carrier_missing");
|
|
408
|
+
};
|
|
409
|
+
let pane = panes
|
|
410
|
+
.iter()
|
|
411
|
+
.find(|pane| &pane.pane_id == id)
|
|
412
|
+
.ok_or("mcp_handshake_carrier_missing")?;
|
|
413
|
+
if id.as_str() != admission.pane_id
|
|
414
|
+
|| pane.pane_pid != Some(admission.carrier)
|
|
415
|
+
|| process_birth_identity(admission.carrier).ok().as_deref()
|
|
416
|
+
!= Some(admission.carrier_birth.as_str())
|
|
417
|
+
|| sample_process(&admission.server) != ProcessState::Alive
|
|
418
|
+
{
|
|
419
|
+
return Err("mcp_handshake_carrier_mismatch");
|
|
420
|
+
}
|
|
421
|
+
Ok(())
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Test construction stays in the MCP process-evidence owner, not messaging.
|
|
425
|
+
#[cfg(test)]
|
|
426
|
+
pub(crate) fn test_carrier_pid() -> Option<u32> {
|
|
427
|
+
crate::platform::process::current_parent_pid()
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#[cfg(test)]
|
|
431
|
+
#[path = "readiness_tests.rs"]
|
|
432
|
+
mod tests;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
2
|
+
|
|
3
|
+
use super::*;
|
|
4
|
+
use crate::model::enums::Provider;
|
|
5
|
+
use crate::transport::{PaneId, SessionName};
|
|
6
|
+
use serde_json::json;
|
|
7
|
+
use std::collections::BTreeMap;
|
|
8
|
+
use std::os::unix::fs::PermissionsExt;
|
|
9
|
+
|
|
10
|
+
struct Case {
|
|
11
|
+
ws: PathBuf,
|
|
12
|
+
state: Value,
|
|
13
|
+
target: Target,
|
|
14
|
+
panes: Vec<PaneInfo>,
|
|
15
|
+
started: DateTime<Utc>,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
impl Case {
|
|
19
|
+
fn new() -> Self {
|
|
20
|
+
let ws = std::env::temp_dir().join(format!(
|
|
21
|
+
"ta-mcp-handshake-{}-{}",
|
|
22
|
+
std::process::id(),
|
|
23
|
+
crate::provider::adapter::next_session_token()
|
|
24
|
+
));
|
|
25
|
+
std::fs::create_dir(&ws).unwrap();
|
|
26
|
+
let ws = ws.canonicalize().unwrap();
|
|
27
|
+
let config = ws.join(".team/runtime/mcp/worker.json");
|
|
28
|
+
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
|
|
29
|
+
let executable = std::env::current_exe().unwrap().canonicalize().unwrap();
|
|
30
|
+
std::fs::write(&config, serde_json::to_vec(&json!({
|
|
31
|
+
"mcpServers": {"team_orchestrator": {
|
|
32
|
+
"command": executable,
|
|
33
|
+
"env": {"TEAM_AGENT_WORKSPACE": ws, "TEAM_AGENT_ID": "worker", "TEAM_AGENT_OWNER_TEAM_ID": "owner"}
|
|
34
|
+
}}
|
|
35
|
+
})).unwrap()).unwrap();
|
|
36
|
+
// Existing managed configurations are 0644, unlike private receipts.
|
|
37
|
+
std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o644)).unwrap();
|
|
38
|
+
let carrier = crate::platform::process::current_parent_pid().unwrap();
|
|
39
|
+
assert!(carrier > 1);
|
|
40
|
+
let started = Utc::now();
|
|
41
|
+
let state = json!({"agents": {"worker": {
|
|
42
|
+
"provider": "cursor_agent", "mcp_config": config,
|
|
43
|
+
"spawned_at": (started - chrono::Duration::seconds(10)).to_rfc3339(),
|
|
44
|
+
"pane_id": "%1", "pane_pid": carrier
|
|
45
|
+
}}});
|
|
46
|
+
let panes = vec![PaneInfo {
|
|
47
|
+
pane_id: PaneId::new("%1"),
|
|
48
|
+
session: SessionName::new("owned"),
|
|
49
|
+
window_index: None,
|
|
50
|
+
window_name: None,
|
|
51
|
+
pane_index: None,
|
|
52
|
+
tty: None,
|
|
53
|
+
current_command: None,
|
|
54
|
+
current_path: None,
|
|
55
|
+
active: true,
|
|
56
|
+
pane_pid: Some(carrier),
|
|
57
|
+
leader_env: BTreeMap::new(),
|
|
58
|
+
}];
|
|
59
|
+
Self {
|
|
60
|
+
ws,
|
|
61
|
+
state,
|
|
62
|
+
target: Target::Pane(PaneId::new("%1")),
|
|
63
|
+
panes,
|
|
64
|
+
started,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fn path(&self) -> PathBuf {
|
|
69
|
+
self.ws
|
|
70
|
+
.join(".team/runtime/mcp-handshakes")
|
|
71
|
+
.join(format!("{}.json", key("owner", "worker")))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fn publish(&self) {
|
|
75
|
+
let outcome = publish(&self.ws, "owner", "worker", self.started);
|
|
76
|
+
if outcome.is_err() {
|
|
77
|
+
for event in crate::event_log::EventLog::new(&self.ws).tail(0).unwrap() {
|
|
78
|
+
if event["event"] == "mcp.handshake_evidence_failed" {
|
|
79
|
+
eprintln!("CONTROLLED_MCP_HANDSHAKE_FAILURE {event}");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
outcome.unwrap();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fn admission(&self) -> Result<Option<Admission>> {
|
|
87
|
+
preflight(&self.ws, Some("owner"), "worker", &self.state)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
impl Drop for Case {
|
|
92
|
+
fn drop(&mut self) {
|
|
93
|
+
let _ = std::fs::remove_dir_all(&self.ws);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[test]
|
|
98
|
+
fn missing_handshake_defers_then_a_live_scoped_receipt_admits() {
|
|
99
|
+
let case = Case::new();
|
|
100
|
+
assert!(case.admission().is_err());
|
|
101
|
+
case.publish();
|
|
102
|
+
let admission = case
|
|
103
|
+
.admission()
|
|
104
|
+
.unwrap()
|
|
105
|
+
.expect("eager client must require a receipt");
|
|
106
|
+
revalidate(&admission, &case.target, &case.panes).unwrap();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#[test]
|
|
110
|
+
fn receipt_cannot_cross_workspace_owner_or_seat() {
|
|
111
|
+
let case = Case::new();
|
|
112
|
+
case.publish();
|
|
113
|
+
assert!(preflight(&case.ws, None, "worker", &case.state).is_err());
|
|
114
|
+
assert!(preflight(&case.ws, Some("other"), "worker", &case.state).is_err());
|
|
115
|
+
let original = std::fs::read(case.path()).unwrap();
|
|
116
|
+
for field in ["workspace", "owner", "agent"] {
|
|
117
|
+
let mut value: Value = serde_json::from_slice(&original).unwrap();
|
|
118
|
+
value[field] = json!("foreign");
|
|
119
|
+
std::fs::write(case.path(), serde_json::to_vec(&value).unwrap()).unwrap();
|
|
120
|
+
assert!(case.admission().is_err(), "foreign {field} cannot admit");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#[test]
|
|
125
|
+
fn malformed_stale_birth_ancestry_and_image_receipts_refuse() {
|
|
126
|
+
let case = Case::new();
|
|
127
|
+
case.publish();
|
|
128
|
+
let original: Value = serde_json::from_slice(&std::fs::read(case.path()).unwrap()).unwrap();
|
|
129
|
+
let mut changed = Vec::new();
|
|
130
|
+
let mut v = original.clone();
|
|
131
|
+
v["schema"] = json!(2);
|
|
132
|
+
changed.push(v);
|
|
133
|
+
let mut v = original.clone();
|
|
134
|
+
v["server"]["identity"]["birth_identity"] = json!("wrong-birth");
|
|
135
|
+
changed.push(v);
|
|
136
|
+
let mut v = original.clone();
|
|
137
|
+
v["server"]["identity"]["executable"] = json!("/not-authorized");
|
|
138
|
+
changed.push(v);
|
|
139
|
+
let mut v = original.clone();
|
|
140
|
+
v["server"]["image"]["inode"] = json!(0);
|
|
141
|
+
changed.push(v);
|
|
142
|
+
let mut v = original.clone();
|
|
143
|
+
v["ancestors"][0]["birth"] = json!("reused-parent");
|
|
144
|
+
changed.push(v);
|
|
145
|
+
let mut v = original.clone();
|
|
146
|
+
v["server"]["parent"] = json!(1);
|
|
147
|
+
changed.push(v);
|
|
148
|
+
let mut v = original.clone();
|
|
149
|
+
v["started_at"] = json!("2000-01-01T00:00:00Z");
|
|
150
|
+
changed.push(v);
|
|
151
|
+
for value in changed {
|
|
152
|
+
std::fs::write(case.path(), serde_json::to_vec(&value).unwrap()).unwrap();
|
|
153
|
+
assert!(case.admission().is_err());
|
|
154
|
+
}
|
|
155
|
+
std::fs::write(case.path(), b"{incomplete").unwrap();
|
|
156
|
+
assert!(case.admission().is_err());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[test]
|
|
160
|
+
fn changed_current_target_and_new_spawn_do_not_reuse_a_handshake() {
|
|
161
|
+
let mut case = Case::new();
|
|
162
|
+
case.publish();
|
|
163
|
+
let admission = case.admission().unwrap().unwrap();
|
|
164
|
+
let mut other = case.panes.clone();
|
|
165
|
+
other[0].pane_pid = Some(std::process::id());
|
|
166
|
+
assert!(revalidate(&admission, &case.target, &other).is_err());
|
|
167
|
+
assert!(revalidate(&admission, &Target::Pane(PaneId::new("%2")), &case.panes).is_err());
|
|
168
|
+
case.state["agents"]["worker"]["spawned_at"] =
|
|
169
|
+
json!((Utc::now() + chrono::Duration::seconds(1)).to_rfc3339());
|
|
170
|
+
assert!(case.admission().is_err());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#[test]
|
|
174
|
+
fn older_helper_cannot_overwrite_a_newer_handshake() {
|
|
175
|
+
let case = Case::new();
|
|
176
|
+
case.publish();
|
|
177
|
+
let original = std::fs::read(case.path()).unwrap();
|
|
178
|
+
publish(
|
|
179
|
+
&case.ws,
|
|
180
|
+
"owner",
|
|
181
|
+
"worker",
|
|
182
|
+
case.started - chrono::Duration::seconds(1),
|
|
183
|
+
)
|
|
184
|
+
.unwrap();
|
|
185
|
+
assert_eq!(std::fs::read(case.path()).unwrap(), original);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#[test]
|
|
189
|
+
fn symlink_and_writable_evidence_are_not_adopted_or_overwritten() {
|
|
190
|
+
let case = Case::new();
|
|
191
|
+
case.publish();
|
|
192
|
+
std::fs::set_permissions(case.path(), std::fs::Permissions::from_mode(0o666)).unwrap();
|
|
193
|
+
assert!(case.admission().is_err());
|
|
194
|
+
std::fs::remove_file(case.path()).unwrap();
|
|
195
|
+
let outside = case.ws.join("outside");
|
|
196
|
+
std::fs::write(&outside, b"untouched").unwrap();
|
|
197
|
+
std::os::unix::fs::symlink(&outside, case.path()).unwrap();
|
|
198
|
+
assert!(case.admission().is_err());
|
|
199
|
+
assert!(publish(&case.ws, "owner", "worker", case.started).is_err());
|
|
200
|
+
assert_eq!(std::fs::read(outside).unwrap(), b"untouched");
|
|
201
|
+
assert!(std::fs::symlink_metadata(case.path())
|
|
202
|
+
.unwrap()
|
|
203
|
+
.file_type()
|
|
204
|
+
.is_symlink());
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#[test]
|
|
208
|
+
fn config_scope_and_authorized_executable_cannot_be_substituted() {
|
|
209
|
+
let case = Case::new();
|
|
210
|
+
case.publish();
|
|
211
|
+
let path = case.ws.join(".team/runtime/mcp/worker.json");
|
|
212
|
+
let original: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
|
213
|
+
for field in [
|
|
214
|
+
"TEAM_AGENT_ID",
|
|
215
|
+
"TEAM_AGENT_OWNER_TEAM_ID",
|
|
216
|
+
"TEAM_AGENT_WORKSPACE",
|
|
217
|
+
] {
|
|
218
|
+
let mut value = original.clone();
|
|
219
|
+
value["mcpServers"]["team_orchestrator"]["env"][field] = json!("foreign");
|
|
220
|
+
std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
|
|
221
|
+
assert!(case.admission().is_err());
|
|
222
|
+
}
|
|
223
|
+
let mut value = original;
|
|
224
|
+
value["mcpServers"]["team_orchestrator"]["command"] = json!("/bin/sh");
|
|
225
|
+
std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
|
|
226
|
+
assert!(case.admission().is_err());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#[test]
|
|
230
|
+
fn fake_and_pi_keep_their_existing_non_eager_or_runtime_registry_admission() {
|
|
231
|
+
assert!(!crate::provider::get_adapter(Provider::Fake).requires_worker_mcp_handshake());
|
|
232
|
+
assert!(!crate::provider::get_adapter(Provider::Pi).requires_worker_mcp_handshake());
|
|
233
|
+
assert!(crate::provider::get_adapter(Provider::CursorAgent).requires_worker_mcp_handshake());
|
|
234
|
+
let case = Case::new();
|
|
235
|
+
for provider in ["fake", "pi"] {
|
|
236
|
+
let mut state = case.state.clone();
|
|
237
|
+
state["agents"]["worker"]["provider"] = json!(provider);
|
|
238
|
+
assert!(preflight(&case.ws, Some("owner"), "worker", &state)
|
|
239
|
+
.unwrap()
|
|
240
|
+
.is_none());
|
|
241
|
+
}
|
|
242
|
+
}
|