@team-agent/installer 0.5.82 → 0.5.84
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/emit.rs +193 -14
- package/crates/team-agent/src/cli/leaders.rs +304 -167
- package/crates/team-agent/src/cli/models.rs +231 -24
- package/crates/team-agent/src/cli/spec.rs +2 -2
- package/crates/team-agent/src/cli/types.rs +12 -1
- package/crates/team-agent/src/leader/registry.rs +295 -64
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +16 -18
- package/crates/team-agent/src/mcp_server/tests/tools.rs +31 -20
- package/crates/team-agent/src/mcp_server/tests/wire.rs +30 -0
- package/crates/team-agent/src/mcp_server/tools.rs +113 -11
- package/crates/team-agent/src/mcp_server/wire.rs +20 -1
- package/crates/team-agent/src/state/persist.rs +50 -1
- package/package.json +4 -4
|
@@ -82,6 +82,107 @@ impl TeamOrchestratorTools {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/// Validate an identity supplied by an MCP request against the identity captured
|
|
86
|
+
/// when this worker server started. Missing request fields are intentionally valid;
|
|
87
|
+
/// the captured identity is the only fallback. Present values must be non-empty and
|
|
88
|
+
/// equal to that capture, otherwise reject before routing or persistence.
|
|
89
|
+
pub(crate) fn validate_identity_argument(
|
|
90
|
+
&self,
|
|
91
|
+
tool: &str,
|
|
92
|
+
value: Option<&Value>,
|
|
93
|
+
) -> Result<(), ToolError> {
|
|
94
|
+
let expected = self.require_captured_identity(tool)?;
|
|
95
|
+
let Some(value) = value else {
|
|
96
|
+
return Ok(());
|
|
97
|
+
};
|
|
98
|
+
let Some(provided) = value.as_str().and_then(non_empty_string) else {
|
|
99
|
+
return Err(self.identity_mismatch(tool, expected.as_str(), None));
|
|
100
|
+
};
|
|
101
|
+
if provided != expected.as_str() {
|
|
102
|
+
return Err(self.identity_mismatch(tool, expected.as_str(), Some(provided)));
|
|
103
|
+
}
|
|
104
|
+
Ok(())
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// Report requests can carry identity in both the top-level argument and the
|
|
108
|
+
/// envelope. Validate both independently so an envelope cannot overwrite the
|
|
109
|
+
/// framework-bound sender after the top-level check.
|
|
110
|
+
pub(crate) fn validate_report_identity_request(
|
|
111
|
+
&self,
|
|
112
|
+
envelope: Option<&Value>,
|
|
113
|
+
explicit_agent_id: Option<&Value>,
|
|
114
|
+
) -> Result<(), ToolError> {
|
|
115
|
+
self.validate_identity_argument("report_result", explicit_agent_id)?;
|
|
116
|
+
if let Some(value) = envelope
|
|
117
|
+
.and_then(Value::as_object)
|
|
118
|
+
.and_then(|obj| obj.get("agent_id"))
|
|
119
|
+
{
|
|
120
|
+
self.validate_identity_argument("report_result", Some(value))?;
|
|
121
|
+
}
|
|
122
|
+
Ok(())
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fn require_captured_identity(&self, tool: &str) -> Result<&AgentId, ToolError> {
|
|
126
|
+
self.agent_id
|
|
127
|
+
.as_ref()
|
|
128
|
+
.filter(|agent| non_empty_string(agent.as_str()).is_some())
|
|
129
|
+
.ok_or_else(|| self.identity_mismatch(tool, "<missing>", None))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fn validate_report_identity_text(
|
|
133
|
+
&self,
|
|
134
|
+
envelope: Option<&Value>,
|
|
135
|
+
explicit_agent_id: Option<&str>,
|
|
136
|
+
) -> Result<(), ToolError> {
|
|
137
|
+
let expected = self.require_captured_identity("report_result")?;
|
|
138
|
+
if let Some(provided) = explicit_agent_id.and_then(non_empty_string) {
|
|
139
|
+
if provided != expected.as_str() {
|
|
140
|
+
return Err(self.identity_mismatch(
|
|
141
|
+
"report_result",
|
|
142
|
+
expected.as_str(),
|
|
143
|
+
Some(provided),
|
|
144
|
+
));
|
|
145
|
+
}
|
|
146
|
+
} else if explicit_agent_id.is_some() {
|
|
147
|
+
return Err(self.identity_mismatch("report_result", expected.as_str(), None));
|
|
148
|
+
}
|
|
149
|
+
if let Some(value) = envelope
|
|
150
|
+
.and_then(Value::as_object)
|
|
151
|
+
.and_then(|obj| obj.get("agent_id"))
|
|
152
|
+
{
|
|
153
|
+
self.validate_identity_argument("report_result", Some(value))?;
|
|
154
|
+
}
|
|
155
|
+
Ok(())
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fn identity_mismatch(&self, tool: &str, expected: &str, provided: Option<&str>) -> ToolError {
|
|
159
|
+
let mut error = ToolError::new(
|
|
160
|
+
ToolErrorReason::McpScopeRefused,
|
|
161
|
+
format!(
|
|
162
|
+
"identity_mismatch: {tool} agent_id must match framework-injected TEAM_AGENT_ID"
|
|
163
|
+
),
|
|
164
|
+
"IdentityError",
|
|
165
|
+
);
|
|
166
|
+
error
|
|
167
|
+
.extra
|
|
168
|
+
.insert("status".to_string(), Value::String("refused".to_string()));
|
|
169
|
+
error
|
|
170
|
+
.extra
|
|
171
|
+
.insert("identity_mismatch".to_string(), Value::Bool(true));
|
|
172
|
+
error
|
|
173
|
+
.extra
|
|
174
|
+
.insert("tool".to_string(), Value::String(tool.to_string()));
|
|
175
|
+
error.extra.insert(
|
|
176
|
+
"expected_agent_id".to_string(),
|
|
177
|
+
Value::String(expected.to_string()),
|
|
178
|
+
);
|
|
179
|
+
error.extra.insert(
|
|
180
|
+
"provided_agent_id".to_string(),
|
|
181
|
+
provided.map_or(Value::Null, |value| Value::String(value.to_string())),
|
|
182
|
+
);
|
|
183
|
+
error
|
|
184
|
+
}
|
|
185
|
+
|
|
85
186
|
/// `assign_task` (`tools.py:84-133`): C8 Family-B task-view reconcile then deliver.
|
|
86
187
|
/// Resolves team key from owner-team env (or `active_team_key`), appends or
|
|
87
188
|
/// field-updates the task in state, then delegates delivery to
|
|
@@ -214,6 +315,8 @@ impl TeamOrchestratorTools {
|
|
|
214
315
|
mailbox: Option<&Value>,
|
|
215
316
|
presentation: Option<&Value>,
|
|
216
317
|
) -> Result<SendOutcome, ToolError> {
|
|
318
|
+
// A worker without a spawn-time identity cannot safely emit a message.
|
|
319
|
+
self.require_captured_identity("send_message")?;
|
|
217
320
|
let normalized =
|
|
218
321
|
crate::messaging::presentation::normalize_send_presentation(mailbox, presentation)
|
|
219
322
|
.map_err(|error| {
|
|
@@ -430,6 +533,8 @@ impl TeamOrchestratorTools {
|
|
|
430
533
|
agent_id: Option<&str>,
|
|
431
534
|
presentation: Option<&Value>,
|
|
432
535
|
) -> ToolResult {
|
|
536
|
+
// Validate before scope reads, envelope normalization, or result persistence.
|
|
537
|
+
self.validate_report_identity_text(envelope, agent_id)?;
|
|
433
538
|
if let Some(envelope) = envelope {
|
|
434
539
|
self.validate_rpc_scope_args("report_result", envelope)?;
|
|
435
540
|
}
|
|
@@ -538,17 +643,14 @@ impl TeamOrchestratorTools {
|
|
|
538
643
|
.or_insert(Value::String(source.to_string()));
|
|
539
644
|
}
|
|
540
645
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
.unwrap_or_else(|| "unknown".to_string());
|
|
550
|
-
obj.insert("agent_id".to_string(), Value::String(resolved));
|
|
551
|
-
}
|
|
646
|
+
// The framework capture is authoritative even when a matching request
|
|
647
|
+
// value was supplied; this also canonicalizes surrounding whitespace.
|
|
648
|
+
let resolved = self
|
|
649
|
+
.agent_id
|
|
650
|
+
.as_ref()
|
|
651
|
+
.map(|env_agent| env_agent.as_str().to_string())
|
|
652
|
+
.unwrap_or_else(|| "unknown".to_string());
|
|
653
|
+
obj.insert("agent_id".to_string(), Value::String(resolved));
|
|
552
654
|
if !obj.contains_key("changes") {
|
|
553
655
|
obj.insert(
|
|
554
656
|
"changes".to_string(),
|
|
@@ -496,7 +496,9 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
|
|
|
496
496
|
insert_property(
|
|
497
497
|
&mut properties,
|
|
498
498
|
"agent_id",
|
|
499
|
-
string_property(
|
|
499
|
+
string_property(
|
|
500
|
+
"Optional reporting agent id; must match framework-injected TEAM_AGENT_ID.",
|
|
501
|
+
),
|
|
500
502
|
);
|
|
501
503
|
insert_property(
|
|
502
504
|
&mut properties,
|
|
@@ -671,6 +673,23 @@ pub(crate) fn dispatch_tool(
|
|
|
671
673
|
tool: McpTool,
|
|
672
674
|
args: &Value,
|
|
673
675
|
) -> ToolResult {
|
|
676
|
+
// Check framework-bound identity before scope resolution or any tool side effect.
|
|
677
|
+
match tool {
|
|
678
|
+
McpTool::SendMessage => {
|
|
679
|
+
tools.validate_identity_argument("send_message", args.get("agent_id"))?;
|
|
680
|
+
if let Some(value) = args
|
|
681
|
+
.get("envelope")
|
|
682
|
+
.and_then(Value::as_object)
|
|
683
|
+
.and_then(|object| object.get("agent_id"))
|
|
684
|
+
{
|
|
685
|
+
tools.validate_identity_argument("send_message", Some(value))?;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
McpTool::ReportResult => {
|
|
689
|
+
tools.validate_report_identity_request(args.get("envelope"), args.get("agent_id"))?
|
|
690
|
+
}
|
|
691
|
+
_ => {}
|
|
692
|
+
}
|
|
674
693
|
if scope_ceiling_tool(tool) {
|
|
675
694
|
tools.validate_rpc_scope_args(tool.wire_name(), args)?;
|
|
676
695
|
}
|
|
@@ -1706,12 +1706,35 @@ pub(crate) fn load_runtime_state_without_migrations(workspace: &Path) -> Result<
|
|
|
1706
1706
|
Ok(state)
|
|
1707
1707
|
}
|
|
1708
1708
|
|
|
1709
|
+
/// Run a read-only callback while holding the canonical `state-save` lock.
|
|
1710
|
+
/// Unlike `load_runtime_state`, this helper performs no migration, cache
|
|
1711
|
+
/// update, or write. A missing state file is represented as `None` so callers
|
|
1712
|
+
/// can conservatively retain derived indexes during workspace unavailability.
|
|
1713
|
+
pub(crate) fn with_runtime_state_lock_without_migrations<T>(
|
|
1714
|
+
workspace: &Path,
|
|
1715
|
+
callback: impl FnOnce(Option<Value>) -> T,
|
|
1716
|
+
) -> Result<T, StateError> {
|
|
1717
|
+
let path = runtime_state_path(workspace);
|
|
1718
|
+
if !path.exists() {
|
|
1719
|
+
return Ok(callback(None));
|
|
1720
|
+
}
|
|
1721
|
+
let _lock = RuntimeLock::acquire(workspace, "state-save", 2.0)?;
|
|
1722
|
+
let state = match std::fs::read_to_string(&path) {
|
|
1723
|
+
Ok(text) => Some(serde_json::from_str::<Value>(&text)?),
|
|
1724
|
+
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
|
|
1725
|
+
Err(error) => return Err(error.into()),
|
|
1726
|
+
};
|
|
1727
|
+
Ok(callback(state))
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1709
1730
|
pub(crate) fn save_runtime_state_without_migrations(
|
|
1710
1731
|
workspace: &Path,
|
|
1711
1732
|
state: &Value,
|
|
1712
1733
|
) -> Result<(), StateError> {
|
|
1713
1734
|
let path = runtime_state_path(workspace);
|
|
1714
|
-
|
|
1735
|
+
// Raw writers share the canonical lock so readers/pruners cannot race
|
|
1736
|
+
// a restore write through a parallel lock namespace.
|
|
1737
|
+
let _lock = RuntimeLock::acquire(workspace, "state-save", 2.0)?;
|
|
1715
1738
|
if let Some(parent) = path.parent() {
|
|
1716
1739
|
std::fs::create_dir_all(parent)?;
|
|
1717
1740
|
}
|
|
@@ -3546,4 +3569,30 @@ mod tests {
|
|
|
3546
3569
|
2,
|
|
3547
3570
|
);
|
|
3548
3571
|
}
|
|
3572
|
+
|
|
3573
|
+
#[test]
|
|
3574
|
+
fn state_save_lock_serializes_raw_writer_and_prune_reader() {
|
|
3575
|
+
let ws = temp_ws();
|
|
3576
|
+
save_runtime_state(&ws, &json!({"teams": {"fresh": {}}})).unwrap();
|
|
3577
|
+
let lock = RuntimeLock::acquire(&ws, "state-save", 2.0).unwrap();
|
|
3578
|
+
|
|
3579
|
+
let writer_ws = ws.clone();
|
|
3580
|
+
let writer = std::thread::spawn(move || {
|
|
3581
|
+
save_runtime_state_without_migrations(&writer_ws, &json!({"teams": {"fresh": {}}}))
|
|
3582
|
+
});
|
|
3583
|
+
std::thread::sleep(Duration::from_millis(100));
|
|
3584
|
+
assert!(!writer.is_finished(), "raw writer must share state-save lock");
|
|
3585
|
+
drop(lock);
|
|
3586
|
+
writer.join().unwrap().unwrap();
|
|
3587
|
+
|
|
3588
|
+
let lock = RuntimeLock::acquire(&ws, "state-save", 2.0).unwrap();
|
|
3589
|
+
let reader_ws = ws.clone();
|
|
3590
|
+
let reader = std::thread::spawn(move || {
|
|
3591
|
+
with_runtime_state_lock_without_migrations(&reader_ws, |state| state.is_some())
|
|
3592
|
+
});
|
|
3593
|
+
std::thread::sleep(Duration::from_millis(100));
|
|
3594
|
+
assert!(!reader.is_finished(), "prune reader must use state-save lock");
|
|
3595
|
+
drop(lock);
|
|
3596
|
+
assert!(reader.join().unwrap().unwrap());
|
|
3597
|
+
}
|
|
3549
3598
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.84",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.84",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.84",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.84"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|