@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.
@@ -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
- if !obj.contains_key("agent_id") {
542
- let resolved = agent_id
543
- .map(ToString::to_string)
544
- .or_else(|| {
545
- self.agent_id
546
- .as_ref()
547
- .map(|env_agent| env_agent.as_str().to_string())
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("Optional reporting agent id override."),
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
- let _lock = RuntimeLock::acquire(workspace, "state-save-raw", 2.0)?;
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.82",
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.82",
24
- "@team-agent/cli-darwin-x64": "0.5.82",
25
- "@team-agent/cli-linux-x64": "0.5.82"
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",