@team-agent/installer 0.5.83 → 0.5.85

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.
@@ -993,6 +993,145 @@ fn copilot_mcp_config_uses_copilot_field_name() {
993
993
  );
994
994
  }
995
995
 
996
+ fn assert_rules_t02_final_mcp(argv: &[String], ws: &Path, agent_id: &str, team: &str) {
997
+ let argument = flag_value(argv, "--additional-mcp-config").expect("final MCP flag");
998
+ let path = argument
999
+ .strip_prefix('@')
1000
+ .expect("final plan references a file");
1001
+ let config: serde_json::Value =
1002
+ serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1003
+ let server = &config["mcpServers"]["team_orchestrator"];
1004
+ assert_eq!(server["transport"], "stdio", "{config}");
1005
+ assert!(server.get("type").is_none(), "{config}");
1006
+ assert!(Path::new(server["command"].as_str().unwrap()).is_absolute());
1007
+ assert_eq!(
1008
+ server["args"],
1009
+ serde_json::json!(["mcp-server", "--workspace", ws])
1010
+ );
1011
+ assert_eq!(
1012
+ server["env"]["TEAM_AGENT_WORKSPACE"],
1013
+ ws.to_string_lossy().as_ref()
1014
+ );
1015
+ assert_eq!(server["env"]["TEAM_AGENT_ID"], agent_id);
1016
+ assert_eq!(server["env"]["TEAM_AGENT_OWNER_TEAM_ID"], team);
1017
+ eprintln!("T02 final MCP: agent={agent_id} team={team} argv={argv:?} config={config}");
1018
+ }
1019
+
1020
+ #[test]
1021
+ #[serial(env)]
1022
+ fn rules_t02_final_mcp_across_fresh_start_restart_and_add() {
1023
+ let hermetic = enter_hermetic("rules-t02-final-mcp");
1024
+ let _guard = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
1025
+ let ws = hermetic.workspace("mcp");
1026
+ let team_dir = write_copilot_team(&ws, "cp-resume", &["mcp_team"], false);
1027
+ seed_healthy_coordinator(&ws);
1028
+ let health = team_agent::coordinator::coordinator_health(
1029
+ &team_agent::coordinator::WorkspacePath::new(ws.clone()),
1030
+ );
1031
+ assert!(
1032
+ health.ok,
1033
+ "seeded coordinator must prevent daemon spawn: {health:?}"
1034
+ );
1035
+ let fresh = RecordingTransport::new();
1036
+ quick_start_with_transport_in_workspace(&ws, &team_dir, None, true, Some("cpresume"), &fresh)
1037
+ .expect("fresh entry");
1038
+ let argv = fresh.single_spawn().argv;
1039
+ assert_rules_t02_final_mcp(&argv, &ws, "worker_a", "cpresume");
1040
+ assert!(flag_value(&argv, "--session-id").is_some());
1041
+ assert!(flag_value(&argv, "--resume").is_none());
1042
+
1043
+ seed_running_resumable(&ws, "worker_a");
1044
+ seed_copilot_session_store(hermetic.home(), "99999999-aaaa-4bbb-8ccc-dddddddddddd");
1045
+ for single in [true, false] {
1046
+ // A previous file is an output, never the authority for the next spawn.
1047
+ std::fs::write(ws.join(".team/runtime/mcp/worker_a.json"),
1048
+ r#"{"mcpServers":{"team_orchestrator":{"type":"stdio","env":{"TEAM_AGENT_ID":"foreign"}}}}"#,
1049
+ ).unwrap();
1050
+ let transport = RecordingTransport::new();
1051
+ if single {
1052
+ team_agent::lifecycle::start_agent_with_transport(
1053
+ &ws,
1054
+ &team_agent::model::ids::AgentId::new("worker_a"),
1055
+ true,
1056
+ false,
1057
+ false,
1058
+ Some("cpresume"),
1059
+ &transport,
1060
+ )
1061
+ .expect("single start entry");
1062
+ } else {
1063
+ team_agent::lifecycle::restart_with_transport(&ws, true, Some("cpresume"), &transport)
1064
+ .expect("team restart entry");
1065
+ }
1066
+ let argv = transport.single_spawn().argv;
1067
+ assert_rules_t02_final_mcp(&argv, &ws, "worker_a", "cpresume");
1068
+ assert_eq!(
1069
+ flag_value(&argv, "--resume").as_deref(),
1070
+ Some("99999999-aaaa-4bbb-8ccc-dddddddddddd")
1071
+ );
1072
+ assert!(flag_value(&argv, "--session-id").is_none());
1073
+ }
1074
+
1075
+ let role_file = ws.join("worker_b.md");
1076
+ let role = std::fs::read_to_string(team_dir.join("agents/worker_a.md")).unwrap();
1077
+ std::fs::write(&role_file, role.replace("worker_a", "worker_b")).unwrap();
1078
+ let added = RecordingTransport::with_session_present();
1079
+ team_agent::lifecycle::add_agent_with_transport(
1080
+ &team_dir,
1081
+ &team_agent::model::ids::AgentId::new("worker_b"),
1082
+ &role_file,
1083
+ false,
1084
+ Some("cpresume"),
1085
+ &added,
1086
+ )
1087
+ .expect("add entry");
1088
+ assert_rules_t02_final_mcp(&added.single_spawn().argv, &ws, "worker_b", "cpresume");
1089
+ }
1090
+
1091
+ #[test]
1092
+ fn rules_t02_inline_server_map_preserves_unknown_and_non_object_values() {
1093
+ use team_agent::provider::adapters::copilot::copilot_translate_mcp_config;
1094
+ let raw = serde_json::json!({
1095
+ "stdio": {"type": "stdio", "command": "synthetic", "unknown": {"type": "nested"}},
1096
+ "http": {"type": "http", "url": "https://example.invalid"},
1097
+ "opaque": 7,
1098
+ });
1099
+ let translated = copilot_translate_mcp_config(&raw);
1100
+ assert_eq!(
1101
+ translated,
1102
+ serde_json::json!({
1103
+ "stdio": {"transport": "stdio", "command": "synthetic", "unknown": {"type": "nested"}},
1104
+ "http": {"transport": "http", "url": "https://example.invalid"},
1105
+ "opaque": 7,
1106
+ })
1107
+ );
1108
+ assert!(translated.get("mcpServers").is_none());
1109
+ for raw in [serde_json::json!(null), serde_json::json!([1, "opaque"])] {
1110
+ assert_eq!(copilot_translate_mcp_config(&raw), raw);
1111
+ }
1112
+ }
1113
+
1114
+ #[test]
1115
+ #[serial(env)]
1116
+ fn rules_t02_non_copilot_writer_keeps_canonical_schema() {
1117
+ let hermetic = enter_hermetic("rules-t02-canonical");
1118
+ let ws = hermetic.workspace("canonical");
1119
+ let config = team_agent::provider::get_adapter(Provider::ClaudeCode)
1120
+ .mcp_config(team_agent::provider::AuthMode::Subscription)
1121
+ .unwrap();
1122
+ let path = team_agent::lifecycle::launch::write_worker_mcp_config_for_provider(
1123
+ &ws,
1124
+ "synthetic",
1125
+ &config,
1126
+ Some(Provider::ClaudeCode),
1127
+ )
1128
+ .unwrap();
1129
+ let written: serde_json::Value =
1130
+ serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1131
+ assert_eq!(written["mcpServers"], config.raw);
1132
+ assert_eq!(written["mcpServers"]["team_orchestrator"]["type"], "stdio");
1133
+ }
1134
+
996
1135
  /// C-7-3 / RC-24: a resume command line must not carry `--session-id` and `--resume`
997
1136
  /// together (conflicting session semantics). Driven through the real restart path so
998
1137
  /// the public resume builder is exercised end to end.
@@ -1436,11 +1575,7 @@ fn write_copilot_team(ws: &Path, name: &str, tools: &[&str], dangerous: bool) ->
1436
1575
  let team = ws.join(name);
1437
1576
  std::fs::create_dir_all(team.join("agents")).unwrap();
1438
1577
  // 0.5.66 bypass 单源:bypass 由角色字段驱动,不再是 TEAM.md dangerous_auto_approve。
1439
- let role_bypass = if dangerous {
1440
- "true"
1441
- } else {
1442
- "false"
1443
- };
1578
+ let role_bypass = if dangerous { "true" } else { "false" };
1444
1579
  std::fs::write(
1445
1580
  team.join("TEAM.md"),
1446
1581
  format!(
@@ -1637,6 +1772,7 @@ struct RecordedSpawn {
1637
1772
  struct RecordingTransport {
1638
1773
  spawns: Mutex<Vec<RecordedSpawn>>,
1639
1774
  session_present: Mutex<bool>,
1775
+ panes: Mutex<Vec<PaneInfo>>,
1640
1776
  }
1641
1777
 
1642
1778
  impl RecordingTransport {
@@ -1681,8 +1817,22 @@ impl Transport for RecordingTransport {
1681
1817
  window: window.as_str().to_string(),
1682
1818
  });
1683
1819
  *self.session_present.lock().unwrap() = true;
1820
+ let pane_id = PaneId::new(format!("%{}", spawns.len()));
1821
+ self.panes.lock().unwrap().push(PaneInfo {
1822
+ pane_id: pane_id.clone(),
1823
+ session: session.clone(),
1824
+ window_index: None,
1825
+ window_name: Some(window.clone()),
1826
+ pane_index: None,
1827
+ tty: None,
1828
+ current_command: None,
1829
+ current_path: None,
1830
+ active: false,
1831
+ pane_pid: Some(30_000 + spawns.len() as u32),
1832
+ leader_env: BTreeMap::new(),
1833
+ });
1684
1834
  Ok(SpawnResult {
1685
- pane_id: PaneId::new(format!("%{}", spawns.len())),
1835
+ pane_id,
1686
1836
  session: session.clone(),
1687
1837
  window: window.clone(),
1688
1838
  child_pid: Some(30_000 + spawns.len() as u32),
@@ -1747,7 +1897,7 @@ impl Transport for RecordingTransport {
1747
1897
  }
1748
1898
 
1749
1899
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
1750
- Ok(Vec::new())
1900
+ Ok(self.panes.lock().unwrap().clone())
1751
1901
  }
1752
1902
 
1753
1903
  fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
@@ -1769,10 +1919,20 @@ impl Transport for RecordingTransport {
1769
1919
 
1770
1920
  fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
1771
1921
  *self.session_present.lock().unwrap() = false;
1922
+ self.panes
1923
+ .lock()
1924
+ .unwrap()
1925
+ .retain(|pane| &pane.session != _session);
1772
1926
  Ok(())
1773
1927
  }
1774
1928
 
1775
- fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
1929
+ fn kill_window(&self, target: &Target) -> Result<(), TransportError> {
1930
+ self.panes.lock().unwrap().retain(|pane| match target {
1931
+ Target::Pane(id) => &pane.pane_id != id,
1932
+ Target::SessionWindow { session, window } => {
1933
+ &pane.session != session || pane.window_name.as_ref() != Some(window)
1934
+ }
1935
+ });
1776
1936
  Ok(())
1777
1937
  }
1778
1938
 
@@ -1796,6 +1956,17 @@ fn copilot_empty_mcp_list_shim(tag: &str) -> TempWorkspace {
1796
1956
  #[cfg(unix)]
1797
1957
  {
1798
1958
  use std::os::unix::fs::PermissionsExt;
1959
+ // coordinator health needs the seeded test PID's real non-zombie status.
1960
+ // Keep PATH isolated and refuse any broader process inspection.
1961
+ std::fs::write(
1962
+ bin.join("ps"),
1963
+ format!(
1964
+ "#!/bin/sh\nif [ \"$#\" = 4 ] && [ \"$1\" = -p ] && [ \"$2\" = {} ] && [ \"$3\" = -o ] && [ \"$4\" = stat= ]; then\n exec /bin/ps \"$@\"\nfi\nexit 1\n",
1965
+ std::process::id(),
1966
+ ),
1967
+ )
1968
+ .unwrap();
1969
+ std::fs::set_permissions(bin.join("ps"), std::fs::Permissions::from_mode(0o755)).unwrap();
1799
1970
  std::fs::set_permissions(bin.join("copilot"), std::fs::Permissions::from_mode(0o755))
1800
1971
  .unwrap();
1801
1972
  }
@@ -163,30 +163,28 @@ fn verify_cleanup_surviving_receipt(
163
163
  assert!(!receipt.exists(), "cleanup must remove only the exact receipt path");
164
164
  }
165
165
 
166
- // ── #36 report_result setdefault: populated envelope keys WIN over args ─────
167
- // GOLDEN (probe_setdefault.py): envelope {agent_id:env-agent, task_id:env-task,...}
168
- // + explicit args agent_id=ARG-agent, task_id=ARG-task → returned dict keeps
169
- // env-agent / env-task (setdefault). Rust unconditionally insert-overrides.
166
+ // ── #36 report_result identity: neither envelope nor args can override capture ─
170
167
  #[test]
171
- fn report_result_setdefault_envelope_wins_over_args() {
168
+ fn report_result_rejects_envelope_and_argument_identity_mismatch() {
172
169
  let tools = TeamOrchestratorTools::with_identity(
173
170
  &unique_ws("report-setdefault"),
174
171
  Some(AgentId::new("env-id")),
175
172
  None,
176
173
  );
177
- let ok = tools.report_result(
178
- Some(&json!({
179
- "agent_id": "env-agent", "task_id": "env-task",
180
- "status": "blocked", "summary": "env summary"
181
- })),
182
- Some("ARG summary"), ResultStatus::Success,
183
- None, None, None, None, None,
184
- Some("ARG-task"), Some("ARG-agent"),
185
- ).expect("report ok");
186
- let v = serde_json::to_value(&ok).unwrap();
187
- // setdefault: the pre-populated envelope values win.
188
- assert_eq!(v.get("agent_id"), Some(&json!("env-agent")), "envelope agent_id wins (setdefault)");
189
- assert_eq!(v.get("task_id"), Some(&json!("env-task")), "envelope task_id wins (setdefault)");
174
+ let error = tools
175
+ .report_result(
176
+ Some(&json!({
177
+ "agent_id": "env-agent", "task_id": "env-task",
178
+ "status": "blocked", "summary": "env summary"
179
+ })),
180
+ Some("ARG summary"), ResultStatus::Success,
181
+ None, None, None, None, None,
182
+ Some("ARG-task"), Some("ARG-agent"),
183
+ )
184
+ .expect_err("envelope and argument identities must be bound to the worker capture");
185
+ assert_eq!(error.reason, ToolErrorReason::McpScopeRefused);
186
+ assert!(error.message.contains("identity_mismatch"));
187
+ assert_eq!(error.extra.get("expected_agent_id"), Some(&json!("env-id")));
190
188
  }
191
189
 
192
190
  // ── #44 report_result task_id inference from state (_latest_task_for_assignee)
@@ -25,31 +25,42 @@
25
25
  }
26
26
 
27
27
  #[test]
28
- fn report_result_explicit_agent_overrides_env() {
28
+ fn report_result_rejects_explicit_agent_mismatch() {
29
29
  let tools = TeamOrchestratorTools::with_identity(
30
- &unique_ws("report-explicit"),
30
+ &unique_ws("report-explicit-mismatch"),
31
31
  Some(AgentId::new("worker-7")),
32
32
  Some(TeamKey::new("teamA")),
33
33
  );
34
- let ok = tools.report_result(
35
- None, Some("done"), ResultStatus::Success,
36
- None, None, None, None, None,
37
- Some("task-9"), Some("explicit-agent"),
38
- ).expect("report ok");
39
- let v = serde_json::to_value(&ok).unwrap();
40
- // explicit > env: both task_id and agent_id are ok-whitelist keys
41
- // (normalize.py:45-46) echoed by runtime.report_result, so present on success.
42
- // UNCONDITIONAL asserts — the override must be proven, not silently skipped.
43
- assert_eq!(
44
- v.get("agent_id"),
45
- Some(&json!("explicit-agent")),
46
- "explicit agent_id overrides env"
47
- );
48
- assert_eq!(
49
- v.get("task_id"),
50
- Some(&json!("task-9")),
51
- "explicit task_id flows through"
34
+ let error = tools
35
+ .report_result(
36
+ None, Some("done"), ResultStatus::Success,
37
+ None, None, None, None, None,
38
+ Some("task-9"), Some("explicit-agent"),
39
+ )
40
+ .expect_err("a request agent_id cannot override the captured identity");
41
+ assert_eq!(error.reason, ToolErrorReason::McpScopeRefused);
42
+ assert!(error.message.contains("identity_mismatch"));
43
+ assert_eq!(error.extra.get("expected_agent_id"), Some(&json!("worker-7")));
44
+ assert_eq!(error.extra.get("provided_agent_id"), Some(&json!("explicit-agent")));
45
+ }
46
+
47
+ #[test]
48
+ fn report_result_accepts_explicit_captured_agent() {
49
+ let tools = TeamOrchestratorTools::with_identity(
50
+ &unique_ws("report-explicit-match"),
51
+ Some(AgentId::new("worker-7")),
52
+ Some(TeamKey::new("teamA")),
52
53
  );
54
+ let ok = tools
55
+ .report_result(
56
+ None, Some("done"), ResultStatus::Success,
57
+ None, None, None, None, None,
58
+ Some("task-9"), Some("worker-7"),
59
+ )
60
+ .expect("matching explicit agent_id remains compatible");
61
+ let v = serde_json::to_value(&ok).unwrap();
62
+ assert_eq!(v.get("agent_id"), Some(&json!("worker-7")));
63
+ assert_eq!(v.get("task_id"), Some(&json!("task-9")));
53
64
  }
54
65
 
55
66
  #[test]
@@ -264,6 +264,36 @@ fn dispatch_unknown_tool_returns_unknown_tool_error() {
264
264
  assert_eq!(err.message, "unknown tool 'nope'");
265
265
  }
266
266
 
267
+ #[test]
268
+ fn worker_identity_is_bound_before_send_or_report_dispatch() {
269
+ let tools = TeamOrchestratorTools::with_identity(
270
+ Path::new("/tmp/ws"),
271
+ Some(AgentId::new("worker-7")),
272
+ None,
273
+ );
274
+ let send = dispatch(
275
+ &tools,
276
+ &json!({
277
+ "tool": "send_message",
278
+ "arguments": {"to": "leader", "content": "spoof", "agent_id": "in-dev2"}
279
+ }),
280
+ )
281
+ .expect_err("send_message must reject a spoofed request identity");
282
+ assert!(send.message.contains("identity_mismatch"));
283
+ assert_eq!(send.extra.get("expected_agent_id"), Some(&json!("worker-7")));
284
+
285
+ let report = dispatch(
286
+ &tools,
287
+ &json!({
288
+ "tool": "report_result",
289
+ "arguments": {"envelope": {"agent_id": "in-dev2", "summary": "spoof"}}
290
+ }),
291
+ )
292
+ .expect_err("report_result envelope must not override captured identity");
293
+ assert!(report.message.contains("identity_mismatch"));
294
+ assert_eq!(report.extra.get("provided_agent_id"), Some(&json!("in-dev2")));
295
+ }
296
+
267
297
  // ════════════════════════════════════════════════════════════════════════
268
298
  // requires_ack_for_target — leader-only → false (tools.py:16)
269
299
  // ════════════════════════════════════════════════════════════════════════
@@ -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
  }