@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +38 -9
- package/crates/team-agent/src/cli/models.rs +231 -24
- package/crates/team-agent/src/cli/profile.rs +4 -12
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/tests/verb_profile.rs +66 -0
- package/crates/team-agent/src/cli/types.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +1 -31
- package/crates/team-agent/src/lifecycle/profile_launch.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +179 -8
- 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/messaging/results/collection_tests.rs +719 -0
- package/crates/team-agent/src/messaging/results.rs +104 -27
- package/crates/team-agent/src/provider/adapters/copilot.rs +1 -1
- package/crates/team-agent/src/state/persist.rs +15 -1
- package/package.json +4 -4
|
@@ -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
|
|
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(
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
assert_eq!(
|
|
189
|
-
|
|
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
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
assert_eq!(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"
|
|
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
|
-
|
|
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
|
}
|