@team-agent/installer 0.5.84 → 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 CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.84"
584
+ version = "0.5.85"
585
585
  dependencies = [
586
586
  "anyhow",
587
587
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.84"
12
+ version = "0.5.85"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -222,18 +222,10 @@ fn set_owner_only_permissions(path: &Path) -> Result<(), CliError> {
222
222
 
223
223
  fn read_profile_env(path: &Path) -> Result<Map<String, Value>, CliError> {
224
224
  let raw = std::fs::read_to_string(path)?;
225
- let mut values = Map::new();
226
- for line in raw.lines() {
227
- let trimmed = line.trim();
228
- if trimmed.is_empty() || trimmed.starts_with('#') {
229
- continue;
230
- }
231
- let Some((key, value)) = trimmed.split_once('=') else {
232
- continue;
233
- };
234
- values.insert(key.to_string(), Value::String(value.to_string()));
235
- }
236
- Ok(values)
225
+ Ok(crate::lifecycle::profile_launch::parse_env_text(&raw)
226
+ .into_iter()
227
+ .map(|(key, value)| (key, Value::String(value)))
228
+ .collect())
237
229
  }
238
230
 
239
231
  fn redacted_values(values: &Map<String, Value>) -> Value {
@@ -322,3 +322,69 @@ fn profile_show_routes_and_preserves_redacted_secret_contract() {
322
322
  );
323
323
  let _ = std::fs::remove_dir_all(&ws);
324
324
  }
325
+
326
+ #[test]
327
+ fn config_authority_t04_show_doctor_and_runtime_parse_the_same_profile_bytes() {
328
+ let ws = tmp_workspace();
329
+ let runtime_dir = ws.join(".team/runtime");
330
+ std::fs::remove_dir(&runtime_dir).unwrap();
331
+ assert!(
332
+ !runtime_dir.exists(),
333
+ "T04 fixture must start without a runtime directory"
334
+ );
335
+ let dir = profiles_dir(&ws);
336
+ std::fs::create_dir_all(&dir).unwrap();
337
+ let body = "# synthetic fixture\nexport AUTH_MODE = 'compatible_api'\nPROXY_MODE=inherit\nexport PROXY_MODE = \"direct\"\nMODEL = 'synthetic-model'\nBASE_URL = \"http://127.0.0.1:9/v1\"\nexport API_KEY = 'synthetic-secret-placeholder'\nPLAIN=plain\nBAD-KEY=ignored\n1BAD=ignored\nMISSING_EQUALS\n";
338
+ std::fs::write(dir.join("syntax.env"), body).unwrap();
339
+ let runtime = crate::lifecycle::profile_launch::load_profile(&ws, "syntax", None).unwrap();
340
+ assert!(
341
+ !runtime_dir.exists(),
342
+ "load_profile must not create the runtime directory"
343
+ );
344
+ for (key, value) in [
345
+ ("AUTH_MODE", "compatible_api"),
346
+ ("PROXY_MODE", "direct"),
347
+ ("MODEL", "synthetic-model"),
348
+ ("PLAIN", "plain"),
349
+ ] {
350
+ assert_eq!(runtime.values.get(key).map(String::as_str), Some(value));
351
+ }
352
+ assert!(!runtime.values.contains_key("BAD-KEY"));
353
+ assert!(!runtime.values.contains_key("1BAD"));
354
+ for command in ["show", "doctor"] {
355
+ let result = cmd_profile(&ProfileArgs {
356
+ command: command.into(),
357
+ name: "syntax".into(),
358
+ workspace: ws.clone(),
359
+ team: None,
360
+ auth_mode: None,
361
+ proxy_mode: None,
362
+ json: true,
363
+ })
364
+ .unwrap();
365
+ let CmdOutput::Json(output) = result.output else {
366
+ panic!("expected JSON")
367
+ };
368
+ assert!(
369
+ !runtime_dir.exists(),
370
+ "profile {command} must not create the runtime directory"
371
+ );
372
+ assert_eq!(output["auth_mode"], runtime.values["AUTH_MODE"]);
373
+ assert_eq!(output["proxy_mode"], runtime.values["PROXY_MODE"]);
374
+ assert_eq!(output["credential_present"], true);
375
+ assert_eq!(output["secret_values_printed"], false);
376
+ assert!(!output.to_string().contains("synthetic-secret-placeholder"));
377
+ assert!(!output.to_string().contains("BAD-KEY"));
378
+ if command == "show" {
379
+ assert_eq!(output["values"]["MODEL"]["value"], "synthetic-model");
380
+ assert_eq!(output["values"]["API_KEY"]["redacted"], true);
381
+ assert_eq!(output["missing_common"], serde_json::json!([]));
382
+ }
383
+ }
384
+ assert_eq!(
385
+ std::fs::read_to_string(dir.join("syntax.env")).unwrap(),
386
+ body
387
+ );
388
+ assert!(!dir.join("syntax.example.env").exists());
389
+ assert!(!ws.join(".team/runtime").exists());
390
+ }
@@ -135,7 +135,7 @@ pub(crate) fn write_worker_mcp_config_for_provider(
135
135
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
136
136
  }
137
137
  let raw = if matches!(provider, Some(Provider::Copilot)) {
138
- copilot_translate_mcp_servers(&config.raw)
138
+ crate::provider::adapters::copilot::copilot_translate_mcp_config(&config.raw)
139
139
  } else {
140
140
  config.raw.clone()
141
141
  };
@@ -610,36 +610,6 @@ fn upsert_toml_table_prefixes(existing: &str, tables: &[&str], stanza: &str) ->
610
610
  }
611
611
  }
612
612
 
613
- /// ---
614
- /// purpose: 把 MCP 配置里每个 server 的 type 字段名换成 transport
615
- /// returns: 同结构的新值,其余字段全部保留;非对象原样返回
616
- /// ---
617
- /// C-3-4 cr verdict v2 — McpConfig.raw 是 `{name: {type, command, args, env}}` 形;
618
- /// copilot mcp add schema 取 `transport` 替 `type`(stdio|http|sse 同值)。仅
619
- /// 字段名变换,其余字段全保留。
620
- pub(super) fn copilot_translate_mcp_servers(raw: &serde_json::Value) -> serde_json::Value {
621
- let Some(servers) = raw.as_object() else {
622
- return raw.clone();
623
- };
624
- let mut translated = serde_json::Map::new();
625
- for (name, server) in servers {
626
- let Some(obj) = server.as_object() else {
627
- translated.insert(name.clone(), server.clone());
628
- continue;
629
- };
630
- let mut out = serde_json::Map::new();
631
- for (key, value) in obj {
632
- if key == "type" {
633
- out.insert("transport".to_string(), value.clone());
634
- } else {
635
- out.insert(key.clone(), value.clone());
636
- }
637
- }
638
- translated.insert(name.clone(), serde_json::Value::Object(out));
639
- }
640
- serde_json::Value::Object(translated)
641
- }
642
-
643
613
  /// ---
644
614
  /// purpose: 把 argv 里 provider 原生的 MCP 配置参数改指到已落盘的文件
645
615
  /// params:
@@ -333,7 +333,7 @@ fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
333
333
  }
334
334
  }
335
335
 
336
- fn parse_env_text(text: &str) -> BTreeMap<String, String> {
336
+ pub(crate) fn parse_env_text(text: &str) -> BTreeMap<String, String> {
337
337
  let mut values = BTreeMap::new();
338
338
  for raw in text.lines() {
339
339
  let mut line = raw.trim();
@@ -343,10 +343,11 @@ pub(super) fn spawn_agent_window(
343
343
  let mcp_config_path = if provider == crate::provider::Provider::Pi {
344
344
  None
345
345
  } else {
346
- Some(crate::lifecycle::launch::write_worker_mcp_config(
346
+ Some(crate::lifecycle::launch::write_worker_mcp_config_for_provider(
347
347
  workspace,
348
348
  agent_id.as_str(),
349
349
  &mcp_config,
350
+ Some(provider),
350
351
  )?)
351
352
  };
352
353
  let profile_launch =
@@ -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
  }