@team-agent/installer 0.5.113 → 0.5.115
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 +252 -84
- package/Cargo.toml +3 -1
- package/crates/team-agent/Cargo.toml +3 -0
- package/crates/team-agent/src/cli/adapters.rs +28 -15
- package/crates/team-agent/src/cli/diagnose.rs +36 -13
- package/crates/team-agent/src/cli/emit.rs +57 -22
- package/crates/team-agent/src/cli/leader.rs +16 -0
- package/crates/team-agent/src/cli/leader_prompt.rs +261 -0
- package/crates/team-agent/src/cli/mod.rs +157 -8
- package/crates/team-agent/src/cli/models.rs +119 -18
- package/crates/team-agent/src/cli/spec.rs +7 -1
- package/crates/team-agent/src/cli/status_port/brief.rs +268 -101
- package/crates/team-agent/src/cli/tests/base.rs +88 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +2 -0
- package/crates/team-agent/src/compiler.rs +66 -18
- package/crates/team-agent/src/contract_runtime/backend.rs +959 -0
- package/crates/team-agent/src/contract_runtime/commands.rs +245 -0
- package/crates/team-agent/src/contract_runtime/config.rs +340 -0
- package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
- package/crates/team-agent/src/contract_runtime/discovery.rs +383 -0
- package/crates/team-agent/src/contract_runtime/forward.rs +229 -0
- package/crates/team-agent/src/contract_runtime/framework.rs +294 -0
- package/crates/team-agent/src/contract_runtime/mcp.rs +396 -0
- package/crates/team-agent/src/contract_runtime/mod.rs +16 -0
- package/crates/team-agent/src/contract_runtime/prompt.rs +216 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +269 -0
- package/crates/team-agent/src/contract_runtime/registry.rs +197 -0
- package/crates/team-agent/src/contract_runtime/workspace.rs +160 -0
- package/crates/team-agent/src/coordinator/tick.rs +26 -8
- package/crates/team-agent/src/leader/mod.rs +1 -0
- package/crates/team-agent/src/leader/prompt.rs +906 -0
- package/crates/team-agent/src/leader/start.rs +353 -21
- package/crates/team-agent/src/leader/tests/mod.rs +2 -0
- package/crates/team-agent/src/leader/tests/prompt_contract.rs +268 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +571 -389
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +38 -9
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +15 -0
- package/crates/team-agent/src/lifecycle/launch.rs +45 -28
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +23 -1
- package/crates/team-agent/src/lifecycle/restart/selection.rs +34 -0
- package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
- package/crates/team-agent/src/lifecycle/types.rs +4 -1
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +37 -27
- package/crates/team-agent/src/main.rs +6 -0
- package/crates/team-agent/src/messaging/delivery.rs +50 -1
- package/crates/team-agent/src/messaging/send.rs +10 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +131 -0
- package/crates/team-agent/src/model/spec.rs +24 -12
- package/crates/team-agent/src/state/persist.rs +73 -0
- package/package.json +4 -4
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
//! Shared-framework seams. A contract seat is never projected as a legacy pane:
|
|
2
|
+
//! its private socket's `%0` must not be interpreted on the workspace tmux server.
|
|
3
|
+
use super::{
|
|
4
|
+
backend::{self, Backend, BackendError},
|
|
5
|
+
config,
|
|
6
|
+
};
|
|
7
|
+
use serde_json::{json, Value};
|
|
8
|
+
use std::path::Path;
|
|
9
|
+
use team_agent_contract::contract::types::SeatId;
|
|
10
|
+
use team_agent_contract::host::process::{sample_process, ProcessState};
|
|
11
|
+
use team_agent_contract::orchestration::{
|
|
12
|
+
operator::OperatorSend,
|
|
13
|
+
store::{Outcome, SeatStatus},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
pub fn is_contract(agent: &Value) -> bool {
|
|
17
|
+
agent
|
|
18
|
+
.get("provider")
|
|
19
|
+
.and_then(Value::as_str)
|
|
20
|
+
.is_some_and(|provider| super::registry::descriptor(provider).is_some())
|
|
21
|
+
}
|
|
22
|
+
/// Published paused roles without an instance never crossed native startup.
|
|
23
|
+
/// A bound instance is never skipped, even if a stale row says paused.
|
|
24
|
+
pub(crate) fn is_paused_unstarted(agent: &Value) -> bool {
|
|
25
|
+
is_contract(agent)
|
|
26
|
+
&& agent.get("status").and_then(Value::as_str) == Some("paused")
|
|
27
|
+
&& agent.get("contract_instance").is_none_or(Value::is_null)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
pub fn has_contract(state: &Value) -> bool {
|
|
31
|
+
state
|
|
32
|
+
.get("agents")
|
|
33
|
+
.and_then(Value::as_object)
|
|
34
|
+
.is_some_and(|agents| agents.values().any(is_contract))
|
|
35
|
+
}
|
|
36
|
+
pub fn only_contract(state: &Value) -> bool {
|
|
37
|
+
state
|
|
38
|
+
.get("agents")
|
|
39
|
+
.and_then(Value::as_object)
|
|
40
|
+
.is_some_and(|agents| !agents.is_empty() && agents.values().all(is_contract))
|
|
41
|
+
}
|
|
42
|
+
/// Unsupported lifecycle operations must stop before the legacy provider enum
|
|
43
|
+
/// can turn an unknown native seat into its historical Codex fallback.
|
|
44
|
+
pub fn require_legacy_lifecycle(
|
|
45
|
+
workspace: &Path,
|
|
46
|
+
team: Option<&str>,
|
|
47
|
+
agent: Option<&str>,
|
|
48
|
+
operation: &str,
|
|
49
|
+
) -> Result<(), crate::cli::CliError> {
|
|
50
|
+
let Ok(selected) = crate::state::selector::resolve_active_team_readonly(
|
|
51
|
+
workspace,
|
|
52
|
+
team,
|
|
53
|
+
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
54
|
+
) else {
|
|
55
|
+
return Ok(());
|
|
56
|
+
};
|
|
57
|
+
let selected_native = match agent {
|
|
58
|
+
Some(id) => selected
|
|
59
|
+
.state
|
|
60
|
+
.get("agents")
|
|
61
|
+
.and_then(|agents| agents.get(id))
|
|
62
|
+
.is_some_and(is_contract),
|
|
63
|
+
None => has_contract(&selected.state),
|
|
64
|
+
};
|
|
65
|
+
let configured_native = selected
|
|
66
|
+
.state
|
|
67
|
+
.get("team_dir")
|
|
68
|
+
.and_then(Value::as_str)
|
|
69
|
+
.and_then(|directory| {
|
|
70
|
+
team_config(
|
|
71
|
+
&selected.run_workspace,
|
|
72
|
+
Path::new(directory),
|
|
73
|
+
&selected.team_key,
|
|
74
|
+
)
|
|
75
|
+
.ok()
|
|
76
|
+
.flatten()
|
|
77
|
+
})
|
|
78
|
+
.is_some_and(|config| match agent {
|
|
79
|
+
Some(id) => config.roles.iter().any(|role| role.id.as_str() == id),
|
|
80
|
+
None => !config.roles.is_empty(),
|
|
81
|
+
});
|
|
82
|
+
if selected_native || configured_native {
|
|
83
|
+
return Err(crate::cli::CliError::Runtime(format!("contract_capability_unverified: {operation} is not admitted for contract-native seats; no legacy fallback or fresh replacement was performed")));
|
|
84
|
+
}
|
|
85
|
+
Ok(())
|
|
86
|
+
}
|
|
87
|
+
pub fn require_legacy_role(
|
|
88
|
+
path: Option<&Path>,
|
|
89
|
+
provider: Option<&str>,
|
|
90
|
+
) -> Result<(), crate::cli::CliError> {
|
|
91
|
+
let declared = path
|
|
92
|
+
.and_then(|path| std::fs::read_to_string(path).ok())
|
|
93
|
+
.and_then(|raw| {
|
|
94
|
+
crate::compiler::split_front_matter(&raw)
|
|
95
|
+
.0
|
|
96
|
+
.get("provider")
|
|
97
|
+
.and_then(crate::model::yaml::Value::as_str)
|
|
98
|
+
.map(str::to_owned)
|
|
99
|
+
});
|
|
100
|
+
if provider
|
|
101
|
+
.or(declared.as_deref())
|
|
102
|
+
.is_some_and(super::registry::recognizes)
|
|
103
|
+
{
|
|
104
|
+
return Err(crate::cli::CliError::Runtime("contract_capability_unverified: mutable contract-native role lifecycle is not admitted; use initial quick-start; no role file was changed".into()));
|
|
105
|
+
}
|
|
106
|
+
Ok(())
|
|
107
|
+
}
|
|
108
|
+
pub fn team_config(
|
|
109
|
+
workspace: &Path,
|
|
110
|
+
directory: &Path,
|
|
111
|
+
team: &str,
|
|
112
|
+
) -> Result<Option<config::TeamConfig>, config::ConfigError> {
|
|
113
|
+
config::read_team(&crate::cli::QuickStartArgs {
|
|
114
|
+
workspace: workspace.into(),
|
|
115
|
+
agents_dir: directory.into(),
|
|
116
|
+
name: None,
|
|
117
|
+
team_id: Some(team.into()),
|
|
118
|
+
yes: false,
|
|
119
|
+
json: false,
|
|
120
|
+
detail: false,
|
|
121
|
+
backend: Some("tmux".into()),
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
pub fn start_role(
|
|
125
|
+
workspace: &Path,
|
|
126
|
+
directory: &Path,
|
|
127
|
+
team: &str,
|
|
128
|
+
id: &str,
|
|
129
|
+
) -> Result<(), crate::lifecycle::LifecycleError> {
|
|
130
|
+
let error = |error: String| crate::lifecycle::LifecycleError::Provider(error);
|
|
131
|
+
let config = team_config(workspace, directory, team)
|
|
132
|
+
.map_err(|e| error(e.to_string()))?
|
|
133
|
+
.ok_or_else(|| error("contract role no longer configured".into()))?;
|
|
134
|
+
let role = config
|
|
135
|
+
.roles
|
|
136
|
+
.iter()
|
|
137
|
+
.find(|role| role.id.as_str() == id)
|
|
138
|
+
.ok_or_else(|| error("contract role not found".into()))?;
|
|
139
|
+
let (_, record) =
|
|
140
|
+
backend::start(workspace, team, role, &config.members).map_err(|e| error(e.to_string()))?;
|
|
141
|
+
if record.outcome != Outcome::Committed {
|
|
142
|
+
return Err(error(format!(
|
|
143
|
+
"native startup {:?}: {}",
|
|
144
|
+
record.outcome,
|
|
145
|
+
record.failure.unwrap_or_default()
|
|
146
|
+
)));
|
|
147
|
+
}
|
|
148
|
+
Ok(())
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
pub fn project_seat(workspace: &Path, team: &str, id: &str) -> Result<Value, BackendError> {
|
|
152
|
+
let mut backend = Backend::open_readonly(workspace, team)?;
|
|
153
|
+
let seat = backend
|
|
154
|
+
.store
|
|
155
|
+
.seat(&SeatId::new(id)?)?
|
|
156
|
+
.ok_or(BackendError::Metadata)?;
|
|
157
|
+
let role = backend::read_role(&backend.binding, &seat.identity)?;
|
|
158
|
+
if role.provider != seat.provider {
|
|
159
|
+
return Err(BackendError::Metadata);
|
|
160
|
+
}
|
|
161
|
+
let alive = seat
|
|
162
|
+
.physical
|
|
163
|
+
.as_ref()
|
|
164
|
+
.is_some_and(|target| sample_process(&target.process) == ProcessState::Alive);
|
|
165
|
+
// Present only individually proven tools. A successful report_result
|
|
166
|
+
// never turns send_message/get_team_status into invented discoveries.
|
|
167
|
+
let mut observed = Vec::new();
|
|
168
|
+
let mut binding_source = None;
|
|
169
|
+
if super::registry::provider(seat.provider.as_str())?.uses_tool_roundtrips() {
|
|
170
|
+
let protocol = super::pump::protocol(&mut backend, &seat, &team_agent_contract::host::clock::RealClock::new())?;
|
|
171
|
+
if let team_agent_contract::contract::probe::ProbeOutcome::Observed(binding) = protocol.binding.outcome {
|
|
172
|
+
binding_source = Some(format!("{:?}", binding.source));
|
|
173
|
+
observed = binding.tools;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
let tools: serde_json::Map<String, Value> = [
|
|
177
|
+
(team_agent_contract::contract::types::LogicalTool::SendMessage, "send_message"),
|
|
178
|
+
(team_agent_contract::contract::types::LogicalTool::ReportResult, "report_result"),
|
|
179
|
+
(team_agent_contract::contract::types::LogicalTool::GetTeamStatus, "get_team_status"),
|
|
180
|
+
].into_iter().map(|(tool, name)| (name.into(), json!(if observed.contains(&tool) { "observed" } else { "unknown" }))).collect();
|
|
181
|
+
let binding_status = if observed.len() == 3 { "observed" } else if observed.is_empty() { "unverified" } else { "partial" };
|
|
182
|
+
let status = if seat.status == SeatStatus::Stopped {
|
|
183
|
+
"stopped"
|
|
184
|
+
} else if seat.status != SeatStatus::Unknown && alive {
|
|
185
|
+
"running"
|
|
186
|
+
} else {
|
|
187
|
+
"unknown"
|
|
188
|
+
};
|
|
189
|
+
Ok(
|
|
190
|
+
json!({"agent_id":id,"provider":seat.provider,"status":status,"model":role.model,"effort":role.effort,
|
|
191
|
+
"contract_prompt_sha256":team_agent_contract::host::digest_hex(role.prompt.sha256),
|
|
192
|
+
"runtime_family":"contract","contract_scope":seat.identity.scope,"contract_instance":seat.identity.instance,
|
|
193
|
+
"generation":seat.identity.generation,"owner_team_id":team,"team_key":team,
|
|
194
|
+
"session_id":seat.session.map(|session| session.native_session),
|
|
195
|
+
"native_endpoint":seat.endpoint,"native_pane":seat.pane,
|
|
196
|
+
"pane_id":null,"window":null,"session_name":null,
|
|
197
|
+
"mcp_binding":binding_status,"mcp_binding_source":binding_source,"mcp_tools":tools,
|
|
198
|
+
"bootstrap_used":seat.bootstrap_used}),
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
pub fn enqueue(
|
|
203
|
+
workspace: &Path,
|
|
204
|
+
state: &Value,
|
|
205
|
+
recipient: &str,
|
|
206
|
+
content: &str,
|
|
207
|
+
options: &crate::messaging::SendOptions,
|
|
208
|
+
) -> Result<crate::messaging::DeliveryOutcome, crate::messaging::MessagingError> {
|
|
209
|
+
use crate::messaging::{DeliveryOutcome, DeliveryStatus, MessagingError};
|
|
210
|
+
let error = |error: String| MessagingError::Validation(error);
|
|
211
|
+
let team = crate::state::projection::team_state_key(state);
|
|
212
|
+
let mut backend = Backend::open(workspace, &team).map_err(|e| error(e.to_string()))?;
|
|
213
|
+
let request = OperatorSend {
|
|
214
|
+
recipient: SeatId::new(recipient).map_err(|e| error(e.to_string()))?,
|
|
215
|
+
content: content.into(),
|
|
216
|
+
task: options.task_id.as_ref().map(|task| task.as_str().into()),
|
|
217
|
+
message: options
|
|
218
|
+
.message_id
|
|
219
|
+
.clone()
|
|
220
|
+
.map(team_agent_contract::contract::types::MessageId::new)
|
|
221
|
+
.transpose()
|
|
222
|
+
.map_err(|e| error(e.to_string()))?,
|
|
223
|
+
mailbox: options.presentation.sink
|
|
224
|
+
== crate::messaging::presentation::PresentationSink::Casefile,
|
|
225
|
+
};
|
|
226
|
+
let sender = SeatId::new(options.sender.as_str()).map_err(|e| error(e.to_string()))?;
|
|
227
|
+
let receipt = backend
|
|
228
|
+
.store
|
|
229
|
+
.send_from_framework(&request, &sender)
|
|
230
|
+
.map_err(|e| error(e.to_string()))?;
|
|
231
|
+
// Queued is durable admission only. The coordinator consumes this outbox;
|
|
232
|
+
// CLI send never performs its own paste or repeats an uncertain attempt.
|
|
233
|
+
Ok(DeliveryOutcome {
|
|
234
|
+
ok: true,
|
|
235
|
+
status: if receipt.mailbox {
|
|
236
|
+
DeliveryStatus::StoredOnly
|
|
237
|
+
} else {
|
|
238
|
+
DeliveryStatus::Queued
|
|
239
|
+
},
|
|
240
|
+
message_status: crate::messaging::helpers::MessageStatusShadow(
|
|
241
|
+
if receipt.mailbox {
|
|
242
|
+
"stored_only"
|
|
243
|
+
} else {
|
|
244
|
+
"accepted"
|
|
245
|
+
}
|
|
246
|
+
.into(),
|
|
247
|
+
),
|
|
248
|
+
message_id: Some(receipt.message.as_str().into()),
|
|
249
|
+
verification: Some(
|
|
250
|
+
json!({"runtime":"contract","task_id":receipt.task,
|
|
251
|
+
"target":receipt.target,"native_submission":"not_observed"})
|
|
252
|
+
.to_string(),
|
|
253
|
+
),
|
|
254
|
+
stage: None,
|
|
255
|
+
reason: None,
|
|
256
|
+
channel: None,
|
|
257
|
+
ack_forced_off: false,
|
|
258
|
+
turn_verification: None,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
pub fn stop_selected(workspace: &Path, state: &Value) -> Result<Vec<Value>, BackendError> {
|
|
263
|
+
let team = crate::state::projection::team_state_key(state);
|
|
264
|
+
let mut receipts = Vec::new();
|
|
265
|
+
if let Some(agents) = state.get("agents").and_then(Value::as_object) {
|
|
266
|
+
for (id, agent) in agents.iter().filter(|(_, agent)| is_contract(agent)) {
|
|
267
|
+
if is_paused_unstarted(agent) {
|
|
268
|
+
receipts.push(json!({"agent_id":id,"status":"paused","stopped":false}));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
let backend = Backend::open(workspace, &team)?;
|
|
272
|
+
let seat = backend
|
|
273
|
+
.store
|
|
274
|
+
.seat(&SeatId::new(id)?)?
|
|
275
|
+
.ok_or(BackendError::Metadata)?;
|
|
276
|
+
if seat.status == SeatStatus::Stopped {
|
|
277
|
+
receipts.push(json!({"agent_id":id,"status":"already_stopped"}));
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
let _ = agent;
|
|
281
|
+
let receipt = backend::stop(workspace, &team, &SeatId::new(id)?)?;
|
|
282
|
+
if receipt.outcome != Outcome::Committed {
|
|
283
|
+
return Err(BackendError::Shutdown {
|
|
284
|
+
operation: receipt.id,
|
|
285
|
+
phase: receipt.phase,
|
|
286
|
+
outcome: receipt.outcome,
|
|
287
|
+
failure: receipt.failure,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
receipts.push(json!({"agent_id":id,"status":"stopped","operation":receipt.id}));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
Ok(receipts)
|
|
294
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
//! Owned native stdio entry. Invocation arguments identify an expected record;
|
|
2
|
+
//! only actual executable/birth/parent and current instance checks authorize it.
|
|
3
|
+
use std::fmt::Display;
|
|
4
|
+
use std::io::Write;
|
|
5
|
+
use std::path::Path;
|
|
6
|
+
use std::time::{Duration, Instant};
|
|
7
|
+
|
|
8
|
+
use serde_json::{json, Value};
|
|
9
|
+
use team_agent_contract::contract::types::*;
|
|
10
|
+
use team_agent_contract::host::process::{
|
|
11
|
+
capture_process, verify_native_ancestry, MAX_NATIVE_PARENT_HOPS,
|
|
12
|
+
};
|
|
13
|
+
use team_agent_contract::orchestration::{
|
|
14
|
+
mcp::*,
|
|
15
|
+
protocol::ConnectionRecord,
|
|
16
|
+
store::{ContractStore, SeatRecord, SeatStatus},
|
|
17
|
+
Error,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
use super::backend::{Backend, BackendError};
|
|
21
|
+
|
|
22
|
+
// Diagnostics are not protocol facts or admission. Never write them to stdout,
|
|
23
|
+
// include request/argv/env bytes, or let a failed log write change the result.
|
|
24
|
+
struct Diagnostics {
|
|
25
|
+
log: Option<crate::event_log::EventLog>,
|
|
26
|
+
fields: Value,
|
|
27
|
+
}
|
|
28
|
+
impl Diagnostics {
|
|
29
|
+
fn new() -> Self {
|
|
30
|
+
Self {
|
|
31
|
+
log: None,
|
|
32
|
+
fields: json!({
|
|
33
|
+
"pid": std::process::id(),
|
|
34
|
+
"entry_ppid": crate::platform::process::current_parent_pid(),
|
|
35
|
+
}),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fn bind_log(&mut self, workspace: &Path) {
|
|
40
|
+
let directory = crate::model::paths::logs_dir(workspace);
|
|
41
|
+
let path = directory.join("events.jsonl");
|
|
42
|
+
// Entry diagnostics must not create an unverified workspace, follow a
|
|
43
|
+
// redirected log path, or depend on successfully opening contract.db.
|
|
44
|
+
let regular_or_absent = std::fs::symlink_metadata(&path).map_or_else(
|
|
45
|
+
|error| error.kind() == std::io::ErrorKind::NotFound,
|
|
46
|
+
|metadata| metadata.is_file() && !metadata.file_type().is_symlink(),
|
|
47
|
+
);
|
|
48
|
+
if workspace.is_absolute()
|
|
49
|
+
&& workspace.canonicalize().is_ok_and(|path| path == workspace)
|
|
50
|
+
&& directory.canonicalize().is_ok_and(|path| path == directory)
|
|
51
|
+
&& regular_or_absent
|
|
52
|
+
{
|
|
53
|
+
self.log = Some(crate::event_log::EventLog::new(workspace));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fn record(&self, stage: &str, outcome: &str, error: Option<&dyn Display>) -> Value {
|
|
58
|
+
let mut value = self.fields.clone();
|
|
59
|
+
value["event"] = json!("contract.mcp");
|
|
60
|
+
value["ts"] = json!(chrono::Utc::now().to_rfc3339());
|
|
61
|
+
value["stage"] = json!(stage);
|
|
62
|
+
value["outcome"] = json!(outcome);
|
|
63
|
+
if let Some(error) = error {
|
|
64
|
+
let text = crate::redaction::redact_external_text(&error.to_string());
|
|
65
|
+
value["error"] = json!(text.chars().take(256).collect::<String>());
|
|
66
|
+
value["error_truncated"] = json!(text.chars().count() > 256);
|
|
67
|
+
}
|
|
68
|
+
crate::redaction::redact_external_value(&value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fn emit(&self, stage: &str, outcome: &str, error: Option<&dyn Display>) {
|
|
72
|
+
let value = self.record(stage, outcome, error);
|
|
73
|
+
write_diagnostic(self.log.as_ref(), &value, &mut std::io::stderr().lock());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fn step<T, E: Display>(
|
|
77
|
+
&self,
|
|
78
|
+
stage: &str,
|
|
79
|
+
action: impl FnOnce() -> Result<T, E>,
|
|
80
|
+
) -> Result<T, E> {
|
|
81
|
+
self.emit(stage, "started", None);
|
|
82
|
+
let result = action();
|
|
83
|
+
self.emit(
|
|
84
|
+
stage,
|
|
85
|
+
if result.is_ok() { "ok" } else { "error" },
|
|
86
|
+
result.as_ref().err().map(|error| error as &dyn Display),
|
|
87
|
+
);
|
|
88
|
+
result
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fn write_diagnostic(
|
|
93
|
+
log: Option<&crate::event_log::EventLog>,
|
|
94
|
+
value: &Value,
|
|
95
|
+
stderr: &mut impl Write,
|
|
96
|
+
) {
|
|
97
|
+
if log.is_some_and(|log| log.write("contract.mcp", value.clone()).is_ok()) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if let Ok(mut bytes) = serde_json::to_vec(value) {
|
|
101
|
+
// Valid bounded JSON even if future diagnostic fields grow unexpectedly.
|
|
102
|
+
if bytes.len() > 8191 {
|
|
103
|
+
bytes = br#"{"event":"contract.mcp","stage":"diagnostic_size_limit"}"#.to_vec();
|
|
104
|
+
}
|
|
105
|
+
bytes.push(b'\n');
|
|
106
|
+
let _ = stderr.write_all(&bytes);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fn arguments(
|
|
111
|
+
args: &[String],
|
|
112
|
+
) -> Result<(std::path::PathBuf, String, SeatId, InstanceId, Generation), BackendError> {
|
|
113
|
+
let mut values = std::collections::BTreeMap::new();
|
|
114
|
+
if !args.len().is_multiple_of(2) {
|
|
115
|
+
return Err(BackendError::Metadata);
|
|
116
|
+
}
|
|
117
|
+
for pair in args.chunks_exact(2) {
|
|
118
|
+
if !matches!(
|
|
119
|
+
pair[0].as_str(),
|
|
120
|
+
"--workspace" | "--team" | "--seat" | "--instance" | "--generation"
|
|
121
|
+
) || values.insert(pair[0].as_str(), pair[1].as_str()).is_some()
|
|
122
|
+
{
|
|
123
|
+
return Err(BackendError::Metadata);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if values.len() != 5 {
|
|
127
|
+
return Err(BackendError::Metadata);
|
|
128
|
+
}
|
|
129
|
+
let get = |key| values.get(key).copied().ok_or(BackendError::Metadata);
|
|
130
|
+
Ok((
|
|
131
|
+
get("--workspace")?.into(),
|
|
132
|
+
get("--team")?.into(),
|
|
133
|
+
SeatId::new(get("--seat")?)?,
|
|
134
|
+
InstanceId::new(get("--instance")?)?,
|
|
135
|
+
Generation(
|
|
136
|
+
get("--generation")?
|
|
137
|
+
.parse()
|
|
138
|
+
.map_err(|_| BackendError::Metadata)?,
|
|
139
|
+
),
|
|
140
|
+
))
|
|
141
|
+
}
|
|
142
|
+
fn wait_target(store: &ContractStore, identity: &InstanceIdentity) -> Result<SeatRecord, Error> {
|
|
143
|
+
let deadline = Instant::now() + Duration::from_secs(30);
|
|
144
|
+
loop {
|
|
145
|
+
let seat = store.assert_current(identity)?;
|
|
146
|
+
if matches!(seat.status, SeatStatus::Stopped | SeatStatus::Unknown) {
|
|
147
|
+
return Err(Error::Fence);
|
|
148
|
+
}
|
|
149
|
+
if seat.physical.is_some() {
|
|
150
|
+
return Ok(seat);
|
|
151
|
+
}
|
|
152
|
+
if Instant::now() >= deadline {
|
|
153
|
+
return Err(Error::Host("native target publication timed out"));
|
|
154
|
+
}
|
|
155
|
+
std::thread::sleep(Duration::from_millis(25));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
pub fn run(args: &[String]) -> Result<(), BackendError> {
|
|
160
|
+
let mut diagnostics = Diagnostics::new();
|
|
161
|
+
// This is the first action in the main.rs contract-mcp dispatch target.
|
|
162
|
+
// Arguments are not trusted as a log destination yet: stderr only.
|
|
163
|
+
diagnostics.emit("entry", "entered", None);
|
|
164
|
+
let result = run_observed(args, &mut diagnostics);
|
|
165
|
+
diagnostics.emit(
|
|
166
|
+
"exit",
|
|
167
|
+
if result.is_ok() { "ok" } else { "error" },
|
|
168
|
+
result.as_ref().err().map(|error| error as &dyn Display),
|
|
169
|
+
);
|
|
170
|
+
result
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fn run_observed(args: &[String], diagnostics: &mut Diagnostics) -> Result<(), BackendError> {
|
|
174
|
+
let (workspace, team, seat, instance, generation) =
|
|
175
|
+
diagnostics.step("arguments", || arguments(args))?;
|
|
176
|
+
diagnostics.bind_log(&workspace);
|
|
177
|
+
diagnostics.fields["seat"] = json!(seat.as_str().chars().take(96).collect::<String>());
|
|
178
|
+
diagnostics.fields["instance"] = json!(instance.as_str().chars().take(96).collect::<String>());
|
|
179
|
+
diagnostics.fields["generation"] = json!(generation.0);
|
|
180
|
+
diagnostics.emit("entry", "arguments_validated", None);
|
|
181
|
+
let mut backend = diagnostics.step("backend_open", || Backend::open(&workspace, &team))?;
|
|
182
|
+
let identity = InstanceIdentity {
|
|
183
|
+
scope: backend.binding.scope.clone(),
|
|
184
|
+
seat,
|
|
185
|
+
instance,
|
|
186
|
+
generation,
|
|
187
|
+
};
|
|
188
|
+
diagnostics.fields["scope"] = json!(identity.scope.as_str());
|
|
189
|
+
let seat = diagnostics.step("target_wait", || wait_target(&backend.store, &identity))?;
|
|
190
|
+
let target = seat
|
|
191
|
+
.physical
|
|
192
|
+
.as_ref()
|
|
193
|
+
.ok_or(Error::Invalid("native target missing"))?;
|
|
194
|
+
diagnostics.fields["expected_native_pid"] = json!(target.process.identity.pid);
|
|
195
|
+
diagnostics.emit("target_published", "observed", None);
|
|
196
|
+
let process = diagnostics.step("process_capture", || {
|
|
197
|
+
capture_process(
|
|
198
|
+
std::process::id(),
|
|
199
|
+
&backend.binding.candidate,
|
|
200
|
+
backend.binding.candidate_sha256,
|
|
201
|
+
Duration::from_secs(10),
|
|
202
|
+
)
|
|
203
|
+
})?;
|
|
204
|
+
diagnostics.fields["captured_ppid"] = json!(process.parent);
|
|
205
|
+
diagnostics.fields["direct_parent_matches"] =
|
|
206
|
+
json!(process.parent == target.process.identity.pid);
|
|
207
|
+
diagnostics.fields["max_parent_hops"] = json!(MAX_NATIVE_PARENT_HOPS);
|
|
208
|
+
diagnostics.emit("parent_fence", "started", None);
|
|
209
|
+
let ancestry = verify_native_ancestry(&process, &target.process);
|
|
210
|
+
diagnostics.fields["parent_matches"] = json!(ancestry.is_ok());
|
|
211
|
+
if let Ok(chain) = &ancestry {
|
|
212
|
+
diagnostics.fields["ancestor_chain"] = json!(chain);
|
|
213
|
+
}
|
|
214
|
+
diagnostics.emit(
|
|
215
|
+
"parent_fence",
|
|
216
|
+
if ancestry.is_ok() { "ok" } else { "error" },
|
|
217
|
+
ancestry.as_ref().err().map(|error| error as &dyn Display),
|
|
218
|
+
);
|
|
219
|
+
ancestry?;
|
|
220
|
+
let connection = diagnostics
|
|
221
|
+
.step("connection_identity", || {
|
|
222
|
+
backend.store.propose_identity(&identity.seat)
|
|
223
|
+
})?
|
|
224
|
+
.instance;
|
|
225
|
+
let record = ConnectionRecord {
|
|
226
|
+
identity: identity.clone(),
|
|
227
|
+
connection: connection.clone(),
|
|
228
|
+
server_key: seat.server_key.clone(),
|
|
229
|
+
binding_key: seat.binding_key.clone(),
|
|
230
|
+
process,
|
|
231
|
+
native_process: target.process.clone(),
|
|
232
|
+
closed: false,
|
|
233
|
+
};
|
|
234
|
+
diagnostics.step("register_connection", || {
|
|
235
|
+
backend.store.register_connection(&record)
|
|
236
|
+
})?;
|
|
237
|
+
// Registration is not an initialize/tools-list/client-consumption fact.
|
|
238
|
+
// Those continue to be recorded only at the existing protocol boundaries.
|
|
239
|
+
let result = diagnostics.step("serve", || {
|
|
240
|
+
serve_with_diagnostics(
|
|
241
|
+
&mut backend.store,
|
|
242
|
+
|store, request| {
|
|
243
|
+
if verify_native_ancestry(&record.process, &record.native_process).is_err() {
|
|
244
|
+
return Err(Error::Fence);
|
|
245
|
+
}
|
|
246
|
+
let current = store.assert_current(&identity)?;
|
|
247
|
+
if current.binding_key != record.binding_key
|
|
248
|
+
|| matches!(current.status, SeatStatus::Stopped | SeatStatus::Unknown)
|
|
249
|
+
|| current
|
|
250
|
+
.physical
|
|
251
|
+
.as_ref()
|
|
252
|
+
.is_none_or(|target| target.process != record.native_process)
|
|
253
|
+
{
|
|
254
|
+
return Err(Error::Fence);
|
|
255
|
+
}
|
|
256
|
+
let business =
|
|
257
|
+
request.get("method").and_then(serde_json::Value::as_str) == Some("tools/call");
|
|
258
|
+
let mut task = String::new();
|
|
259
|
+
if business {
|
|
260
|
+
// A native callback can race the physical receipt's DB commit. Wait
|
|
261
|
+
// for that exact generation's submitted turn, never a queued row.
|
|
262
|
+
let deadline = Instant::now() + Duration::from_secs(5);
|
|
263
|
+
loop {
|
|
264
|
+
match store.submitted_task(&identity) {
|
|
265
|
+
Ok(Some(bound)) => {
|
|
266
|
+
task = bound;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
Ok(None) | Err(Error::Conflict) if Instant::now() < deadline => {
|
|
270
|
+
std::thread::sleep(Duration::from_millis(20))
|
|
271
|
+
}
|
|
272
|
+
Ok(None) => return Err(Error::Invalid("no submitted native turn")),
|
|
273
|
+
Err(error) => return Err(error),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if verify_native_ancestry(&record.process, &record.native_process).is_err() {
|
|
278
|
+
return Err(Error::Fence);
|
|
279
|
+
}
|
|
280
|
+
Ok(CallContext {
|
|
281
|
+
identity: identity.clone(),
|
|
282
|
+
binding_key: record.binding_key.clone(),
|
|
283
|
+
connection_id: connection.clone(),
|
|
284
|
+
task_id: task,
|
|
285
|
+
})
|
|
286
|
+
},
|
|
287
|
+
std::io::stdin().lock(),
|
|
288
|
+
std::io::stdout().lock(),
|
|
289
|
+
|stage, error| {
|
|
290
|
+
diagnostics.emit(
|
|
291
|
+
match stage {
|
|
292
|
+
RequestRejectionStage::Context => "request_context",
|
|
293
|
+
RequestRejectionStage::Handler => "request_handler",
|
|
294
|
+
},
|
|
295
|
+
"rejected",
|
|
296
|
+
Some(error),
|
|
297
|
+
);
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
});
|
|
301
|
+
let closed = diagnostics.step("close_connection", || {
|
|
302
|
+
backend.store.close_connection(&identity, &connection)
|
|
303
|
+
});
|
|
304
|
+
result?;
|
|
305
|
+
closed?;
|
|
306
|
+
Ok(())
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#[cfg(test)]
|
|
310
|
+
mod tests {
|
|
311
|
+
use super::*;
|
|
312
|
+
|
|
313
|
+
#[test]
|
|
314
|
+
fn diagnostics_preserve_errors_and_distinguish_captured_and_expected_parent() {
|
|
315
|
+
let mut diagnostics = Diagnostics::new();
|
|
316
|
+
diagnostics.fields["captured_ppid"] = json!(202);
|
|
317
|
+
diagnostics.fields["expected_native_pid"] = json!(303);
|
|
318
|
+
diagnostics.fields["parent_matches"] = json!(false);
|
|
319
|
+
let result = diagnostics.step("parent_fence", || Err::<(), _>(Error::Fence));
|
|
320
|
+
assert_eq!(result, Err(Error::Fence));
|
|
321
|
+
let event = diagnostics.record("parent_fence", "error", Some(&Error::Fence));
|
|
322
|
+
assert_eq!(event["captured_ppid"], 202);
|
|
323
|
+
assert_eq!(event["expected_native_pid"], 303);
|
|
324
|
+
assert_eq!(event["parent_matches"], false);
|
|
325
|
+
assert_eq!(event["error"], Error::Fence.to_string());
|
|
326
|
+
assert!(event["entry_ppid"].is_number());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#[test]
|
|
330
|
+
fn rejected_requests_keep_the_actual_error_category_and_stage() {
|
|
331
|
+
let diagnostics = Diagnostics::new();
|
|
332
|
+
for (stage, error) in [
|
|
333
|
+
("request_context", Error::Conflict),
|
|
334
|
+
("request_context", Error::Fence),
|
|
335
|
+
("request_context", Error::Database),
|
|
336
|
+
("request_handler", Error::Invalid("argument type")),
|
|
337
|
+
] {
|
|
338
|
+
let event = diagnostics.record(stage, "rejected", Some(&error));
|
|
339
|
+
assert_eq!(event["stage"], stage);
|
|
340
|
+
assert_eq!(event["outcome"], "rejected");
|
|
341
|
+
assert_eq!(event["error"], error.to_string());
|
|
342
|
+
assert!(event.get("request").is_none());
|
|
343
|
+
assert!(event.get("arguments").is_none());
|
|
344
|
+
let mut stderr = Vec::new();
|
|
345
|
+
write_diagnostic(None, &event, &mut stderr);
|
|
346
|
+
assert!(stderr.len() <= 8192);
|
|
347
|
+
assert_eq!(serde_json::from_slice::<Value>(&stderr).unwrap(), event);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#[test]
|
|
352
|
+
fn diagnostics_redact_and_bound_error_without_argument_or_environment_fields() {
|
|
353
|
+
let diagnostics = Diagnostics::new();
|
|
354
|
+
let error = format!("PASSWORD=do-not-log {}", "x".repeat(20_000));
|
|
355
|
+
let event = diagnostics.record("arguments", "error", Some(&error));
|
|
356
|
+
assert_eq!(event["error_truncated"], true);
|
|
357
|
+
assert!(!event.to_string().contains("do-not-log"));
|
|
358
|
+
for field in ["argv", "args", "env", "prompt", "request", "content"] {
|
|
359
|
+
assert!(event.get(field).is_none());
|
|
360
|
+
}
|
|
361
|
+
let mut stderr = Vec::new();
|
|
362
|
+
write_diagnostic(None, &event, &mut stderr);
|
|
363
|
+
assert!(stderr.len() <= 8192);
|
|
364
|
+
assert_eq!(serde_json::from_slice::<Value>(&stderr).unwrap(), event);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#[test]
|
|
368
|
+
fn diagnostic_sink_failures_do_not_change_execution_results() {
|
|
369
|
+
let log = crate::event_log::EventLog::at("/dev/null/contract-mcp-events".into());
|
|
370
|
+
let diagnostics = Diagnostics::new();
|
|
371
|
+
let event = diagnostics.record("backend_open", "error", Some(&Error::Fence));
|
|
372
|
+
let mut stderr = Vec::new();
|
|
373
|
+
write_diagnostic(Some(&log), &event, &mut stderr);
|
|
374
|
+
assert_eq!(serde_json::from_slice::<Value>(&stderr).unwrap(), event);
|
|
375
|
+
struct Broken;
|
|
376
|
+
impl Write for Broken {
|
|
377
|
+
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
|
378
|
+
Err(std::io::ErrorKind::BrokenPipe.into())
|
|
379
|
+
}
|
|
380
|
+
fn flush(&mut self) -> std::io::Result<()> {
|
|
381
|
+
Err(std::io::ErrorKind::BrokenPipe.into())
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
write_diagnostic(Some(&log), &event, &mut Broken);
|
|
385
|
+
assert_eq!(diagnostics.step("fixture", || Ok::<_, Error>(17)), Ok(17));
|
|
386
|
+
stderr.clear();
|
|
387
|
+
write_diagnostic(
|
|
388
|
+
None,
|
|
389
|
+
&json!({"unexpected": "x".repeat(20_000)}),
|
|
390
|
+
&mut stderr,
|
|
391
|
+
);
|
|
392
|
+
let bounded: Value = serde_json::from_slice(&stderr).unwrap();
|
|
393
|
+
assert_eq!(bounded["stage"], "diagnostic_size_limit");
|
|
394
|
+
assert!(stderr.len() <= 8192);
|
|
395
|
+
}
|
|
396
|
+
}
|