@team-agent/installer 0.5.74 → 0.5.75

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.
@@ -277,6 +277,10 @@ pub(super) fn spawn_agents(
277
277
  provider,
278
278
  ),
279
279
  );
280
+ // The provider is the final typed value after profile overlays. Keep it
281
+ // separate from inherited LEADER_* identity and let the tmux invocation
282
+ // boundary add the actual pane/socket only for the provider process.
283
+ crate::layout::worker_env::inject_current_caller_provider(&mut env, provider);
280
284
  // BUG / C-1-2 / C-6-1 cr verdict — Copilot system_prompt 走 spawn env overlay +
281
285
  // per-worker AGENTS.md(B2 灵魂件降级):写
282
286
  // <workspace>/.team/runtime/copilot-instructions/<agent_id>/AGENTS.md
@@ -51,6 +51,10 @@
51
51
  //! sites can reference the phases by name. The phase fns themselves
52
52
  //! remain in launch.rs until the next batch of relocations.
53
53
 
54
+ #[cfg(test)]
55
+ #[path = "../../../tests/support/hermetic.rs"]
56
+ mod hermetic;
57
+
54
58
  use std::collections::{BTreeMap, BTreeSet};
55
59
  use std::path::{Path, PathBuf};
56
60
  use std::process::Command;
@@ -66,16 +70,14 @@ use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
66
70
  use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
67
71
 
68
72
  use super::identity::spec_display_backend;
69
- use super::leader_context::{
70
- attributed_provider_for_pane_across_tmux_sockets, caller_provider_for_seed_with_lookup,
71
- seed_unbound_launched_owner,
72
- };
73
+ use super::leader_context::{caller_provider_for_seed_with_lookup, seed_unbound_launched_owner};
73
74
  use super::worker_env::spawn_timestamp;
74
75
 
75
76
  /// ---
76
77
  /// purpose: 由 spec 生成首份 runtime state 树
77
78
  /// params:
78
79
  /// team_key: 本团队的 runtime 键
80
+ /// scoped_endpoint: 本次起队已选中的 transport tmux endpoint,与 fresh bind 同源
79
81
  /// returns: 含 spec_path、workspace、team_dir、session_name、leader、agents、tasks 与 display_backend 的 state;随后按环境种入 launched owner,种不到则种一份 unbound owner
80
82
  /// ---
81
83
  pub(super) fn initial_runtime_state(
@@ -84,6 +86,7 @@ pub(super) fn initial_runtime_state(
84
86
  workspace: &Path,
85
87
  team_dir: &Path,
86
88
  team_key: &str,
89
+ scoped_endpoint: Option<&str>,
87
90
  ) -> serde_json::Value {
88
91
  let mut agents = serde_json::Map::new();
89
92
  for agent in spec_agent_values(spec) {
@@ -144,7 +147,7 @@ pub(super) fn initial_runtime_state(
144
147
  );
145
148
  state.insert("is_external_leader".to_string(), serde_json::json!(false));
146
149
  let mut state = serde_json::Value::Object(state);
147
- if !seed_launched_owner_from_env(&mut state) {
150
+ if !seed_launched_owner_from_env(&mut state, scoped_endpoint) {
148
151
  let team_id = crate::state::projection::team_state_key(&state);
149
152
  seed_unbound_launched_owner(&mut state, &team_id);
150
153
  }
@@ -157,9 +160,12 @@ pub(super) fn initial_runtime_state(
157
160
  /// state: 待改写的 state,成功时写入 owner 与 leader receiver
158
161
  /// returns: true 表示已种入,取不到 caller 身份或无 pane 时为 false
159
162
  /// ---
160
- pub(super) fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
163
+ pub(super) fn seed_launched_owner_from_env(
164
+ state: &mut serde_json::Value,
165
+ scoped_endpoint: Option<&str>,
166
+ ) -> bool {
161
167
  let team_id = crate::state::projection::team_state_key(state);
162
- let Ok(caller) = crate::state::identity::caller_identity_from_env(
168
+ let Ok(mut caller) = crate::state::identity::caller_identity_from_env(
163
169
  Some(state),
164
170
  &crate::state::identity::SystemEnv,
165
171
  Some(&team_id),
@@ -167,11 +173,27 @@ pub(super) fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> boo
167
173
  ) else {
168
174
  return false;
169
175
  };
170
- seed_launched_owner_from_caller_with_provider_lookup(
171
- state,
172
- caller,
173
- attributed_provider_for_pane_across_tmux_sockets,
174
- )
176
+ let current_pane = std::env::var("TMUX_PANE")
177
+ .ok()
178
+ .filter(|pane| !pane.is_empty());
179
+ let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
180
+ match crate::layout::worker_env::caller_provider_resolution(
181
+ current_pane.as_deref(),
182
+ current_endpoint.as_deref(),
183
+ scoped_endpoint,
184
+ ) {
185
+ crate::layout::worker_env::CallerProviderResolution::Invalid => return false,
186
+ crate::layout::worker_env::CallerProviderResolution::Valid(provider) => {
187
+ caller.provider = crate::provider::wire::provider_wire(provider).to_string();
188
+ }
189
+ crate::layout::worker_env::CallerProviderResolution::Absent => {}
190
+ }
191
+ // Absent keeps the existing explicit/direct pane path. Do not unbind a
192
+ // caller TMUX_PANE just because launcher context and LEADER_PROVIDER are
193
+ // both missing; that is the external-leader same-id-across-sockets case.
194
+ // Invalid already returned. Do not replace a missing provider with broad
195
+ // pane/process attribution.
196
+ seed_launched_owner_from_caller_with_provider_lookup(state, caller, |_| None)
175
197
  }
176
198
 
177
199
  /// ---
@@ -619,9 +641,13 @@ pub(super) fn team_workspace(team_dir: &Path) -> PathBuf {
619
641
 
620
642
  #[cfg(test)]
621
643
  mod tests {
622
- #![allow(clippy::unwrap_used)]
644
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
623
645
  use super::*;
646
+ use crate::layout::worker_env::{CALLER_ENDPOINT_ENV, CALLER_PANE_ENV, CALLER_PROVIDER_ENV};
624
647
  use crate::model::enums::Provider;
648
+ use serde_json::json;
649
+
650
+ use super::hermetic::HermeticTestEnv;
625
651
 
626
652
  fn yaml_agent(bypass: bool) -> Value {
627
653
  crate::model::yaml::loads(&format!(
@@ -651,4 +677,103 @@ mod tests {
651
677
  assert!(!safety.enabled);
652
678
  assert_eq!(safety.source, DangerousApprovalSource::Disabled);
653
679
  }
680
+
681
+ fn seed_state() -> serde_json::Value {
682
+ json!({
683
+ "team_key": "verify031",
684
+ "workspace": "/tmp/ws",
685
+ "session_name": "team-verify031",
686
+ "agents": {}
687
+ })
688
+ }
689
+
690
+ fn owner_pane(state: &serde_json::Value) -> Option<&str> {
691
+ state
692
+ .pointer("/teams/verify031/team_owner/pane_id")
693
+ .and_then(serde_json::Value::as_str)
694
+ }
695
+
696
+ fn owner_provider(state: &serde_json::Value) -> Option<&str> {
697
+ state
698
+ .pointer("/teams/verify031/team_owner/provider")
699
+ .and_then(serde_json::Value::as_str)
700
+ }
701
+
702
+ fn receiver_pane(state: &serde_json::Value) -> Option<&str> {
703
+ state
704
+ .pointer("/teams/verify031/leader_receiver/pane_id")
705
+ .and_then(serde_json::Value::as_str)
706
+ }
707
+
708
+ #[test]
709
+ #[serial_test::serial(env)]
710
+ fn seed_absent_caller_keeps_direct_tmux_pane_without_unbinding() {
711
+ let hermetic = HermeticTestEnv::enter("seed-absent-direct-pane");
712
+ let _tmux = hermetic.with_env("TMUX", "/tmp/default-tmux-socket,123,0");
713
+ let _pane = hermetic.with_env("TMUX_PANE", "%0");
714
+ let mut state = seed_state();
715
+ assert!(
716
+ seed_launched_owner_from_env(&mut state, Some("/tmp/product-team.sock")),
717
+ "Absent caller context must still seed the caller's TMUX_PANE; state={state}"
718
+ );
719
+ assert_eq!(owner_pane(&state), Some("%0"));
720
+ assert_eq!(receiver_pane(&state), Some("%0"));
721
+ assert!(
722
+ owner_provider(&state).is_none(),
723
+ "direct pane seed must not invent a provider; state={state}"
724
+ );
725
+ }
726
+
727
+ #[test]
728
+ #[serial_test::serial(env)]
729
+ fn seed_valid_caller_uses_typed_provider_and_same_scoped_endpoint() {
730
+ let hermetic = HermeticTestEnv::enter("seed-valid-scoped");
731
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
732
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
733
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
734
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
735
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
736
+ let mut state = seed_state();
737
+ assert!(seed_launched_owner_from_env(
738
+ &mut state,
739
+ Some("/tmp/tmux.sock")
740
+ ));
741
+ assert_eq!(owner_pane(&state), Some("%1"));
742
+ assert_eq!(owner_provider(&state), Some("pi"));
743
+ assert_eq!(receiver_pane(&state), Some("%1"));
744
+ }
745
+
746
+ #[test]
747
+ #[serial_test::serial(env)]
748
+ fn seed_invalid_caller_does_not_fall_back_to_direct_pane() {
749
+ let hermetic = HermeticTestEnv::enter("seed-invalid-scoped");
750
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
751
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
752
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
753
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
754
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
755
+ let mut state = seed_state();
756
+ assert!(
757
+ !seed_launched_owner_from_env(&mut state, Some("/tmp/product.sock")),
758
+ "scoped endpoint mismatch must fail closed; state={state}"
759
+ );
760
+ assert!(owner_pane(&state).is_none(), "state={state}");
761
+ assert!(receiver_pane(&state).is_none(), "state={state}");
762
+ }
763
+
764
+ #[test]
765
+ #[serial_test::serial(env)]
766
+ fn seed_absent_keeps_explicit_leader_provider() {
767
+ let hermetic = HermeticTestEnv::enter("seed-absent-explicit");
768
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
769
+ let _pane = hermetic.with_env("TMUX_PANE", "%3");
770
+ let _leader = hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", "pi");
771
+ let mut state = seed_state();
772
+ assert!(seed_launched_owner_from_env(
773
+ &mut state,
774
+ Some("/tmp/tmux.sock")
775
+ ));
776
+ assert_eq!(owner_pane(&state), Some("%3"));
777
+ assert_eq!(owner_provider(&state), Some("pi"));
778
+ }
654
779
  }
@@ -523,6 +523,9 @@ pub(super) fn spawn_agent_window(
523
523
  provider,
524
524
  ),
525
525
  );
526
+ // Keep the launcher-known typed provider independent from inherited
527
+ // LEADER_* identity. The tmux wrapper binds pane/socket at invocation time.
528
+ crate::layout::worker_env::inject_current_caller_provider(&mut env, provider);
526
529
 
527
530
  // 0.4.6 Stage 2: write actual spawn plan event BEFORE invoking the
528
531
  // transport spawn. Mirrors `launch.rs:359-380` (the reference impl)
@@ -54,14 +54,14 @@ fn worker_spawn_inherits_parent_process_env_for_proxy_and_ca() {
54
54
  &launch_transport,
55
55
  )
56
56
  .expect("launch fixture should spawn worker");
57
- let launch_command_line = launch_transport.single_spawn_command_line();
57
+ let launch_spawn = launch_transport.single_spawn();
58
58
 
59
59
  let restart_team = compiled_team_dir("restart", &[("worker_a", "Restart Worker")]);
60
60
  seed_restart_state(&restart_team, "worker_a");
61
61
  let restart_transport = RecordingTransport::new().with_session_present(false);
62
62
  restart_with_transport(&restart_team, true, None, &restart_transport)
63
63
  .expect("restart fixture should spawn worker");
64
- let restart_command_line = restart_transport.single_spawn_command_line();
64
+ let restart_spawn = restart_transport.single_spawn();
65
65
 
66
66
  let add_team = compiled_team_dir("add-agent", &[("worker_a", "Existing Worker")]);
67
67
  seed_running_add_agent_state(&add_team, "worker_a");
@@ -77,17 +77,15 @@ fn worker_spawn_inherits_parent_process_env_for_proxy_and_ca() {
77
77
  &add_transport,
78
78
  )
79
79
  .expect("add-agent fixture should spawn worker");
80
- let add_command_line = add_transport.single_spawn_command_line();
80
+ let add_spawn = add_transport.single_spawn();
81
81
 
82
82
  let failures = [
83
- ("quick-start launch", launch_command_line, "worker_a"),
84
- ("restart", restart_command_line, "worker_a"),
85
- ("add-agent", add_command_line, "worker_b"),
83
+ ("quick-start launch", launch_spawn, "worker_a"),
84
+ ("restart", restart_spawn, "worker_a"),
85
+ ("add-agent", add_spawn, "worker_b"),
86
86
  ]
87
87
  .into_iter()
88
- .flat_map(|(surface, command_line, agent_id)| {
89
- spawn_env_contract_failures(surface, &command_line, agent_id)
90
- })
88
+ .flat_map(|(surface, spawn, agent_id)| spawn_env_contract_failures(surface, &spawn, agent_id))
91
89
  .collect::<Vec<_>>();
92
90
 
93
91
  assert!(
@@ -97,6 +95,61 @@ fn worker_spawn_inherits_parent_process_env_for_proxy_and_ca() {
97
95
  );
98
96
  }
99
97
 
98
+ #[test]
99
+ fn tmux_control_filter_uses_env_keys_not_substring_in_inherited_values() {
100
+ let mut env = BTreeMap::from([
101
+ ("TA_SPAWN_ENV_CANARY".to_string(), "FOO".to_string()),
102
+ (
103
+ "HTTP_PROXY".to_string(),
104
+ "http://canary-proxy:1".to_string(),
105
+ ),
106
+ (
107
+ "NODE_EXTRA_CA_CERTS".to_string(),
108
+ "/canary/ca.pem".to_string(),
109
+ ),
110
+ ("TEAM_AGENT_WORKSPACE".to_string(), "/ws".to_string()),
111
+ (
112
+ "TEAM_AGENT_OWNER_TEAM_ID".to_string(),
113
+ "teamdir".to_string(),
114
+ ),
115
+ ("TEAM_AGENT_AGENT_ID".to_string(), "worker_a".to_string()),
116
+ (
117
+ "HARNESS_PROBE".to_string(),
118
+ "printf 'LS_USR_BIN_TMUX=%s\\n'\n".to_string(),
119
+ ),
120
+ ]);
121
+ let spawn = RecordedSpawn {
122
+ argv: vec!["codex".to_string()],
123
+ env: env.clone(),
124
+ env_unset: Vec::new(),
125
+ session: SessionName::new("s"),
126
+ window: WindowName::new("w"),
127
+ pane_id: PaneId::new("%1"),
128
+ };
129
+ assert!(
130
+ spawn_env_contract_failures("harness-value", &spawn, "worker_a").is_empty(),
131
+ "inherited values may contain the letters TMUX= without leaking TMUX/TMUX_PANE keys"
132
+ );
133
+
134
+ env.insert(
135
+ "TMUX".to_string(),
136
+ "/tmp/tmux-canary/default,1".to_string(),
137
+ );
138
+ let leaked = RecordedSpawn {
139
+ env,
140
+ ..spawn
141
+ };
142
+ let failures = spawn_env_contract_failures("key-leak", &leaked, "worker_a");
143
+ assert!(
144
+ failures.iter().any(|row| row.contains("present_keys=") && row.contains("TMUX")),
145
+ "actual TMUX key must still fail closed; failures={failures:?}"
146
+ );
147
+ assert!(
148
+ failures.iter().all(|row| !row.contains("command_line=")),
149
+ "failures must not dump command_line; failures={failures:?}"
150
+ );
151
+ }
152
+
100
153
  #[test]
101
154
  #[serial(env)]
102
155
  fn worker_spawn_scrubs_cross_provider_process_identity_but_keeps_config_env() {
@@ -234,42 +287,67 @@ fn worker_spawn_stays_out_of_coordinator_tick_and_daemon_preserves_parent_env()
234
287
  );
235
288
  }
236
289
 
237
- fn spawn_env_contract_failures(surface: &str, command_line: &str, agent_id: &str) -> Vec<String> {
290
+ fn spawn_env_has_tmux_control_assignment(spawn: &RecordedSpawn) -> bool {
291
+ spawn.env.contains_key("TMUX")
292
+ || spawn.env.contains_key("TMUX_PANE")
293
+ || spawn.argv.iter().any(|part| {
294
+ part.split_whitespace().any(|tok| {
295
+ tok == "TMUX" || tok.starts_with("TMUX=") || tok.starts_with("TMUX_PANE=")
296
+ })
297
+ })
298
+ }
299
+
300
+ fn spawn_env_contract_failures(
301
+ surface: &str,
302
+ spawn: &RecordedSpawn,
303
+ agent_id: &str,
304
+ ) -> Vec<String> {
238
305
  let mut failures = Vec::new();
239
- for expected in [
240
- "TA_SPAWN_ENV_CANARY=FOO",
241
- "HTTP_PROXY=http://canary-proxy:1",
242
- "NODE_EXTRA_CA_CERTS=/canary/ca.pem",
243
- "TEAM_AGENT_WORKSPACE=",
244
- "TEAM_AGENT_OWNER_TEAM_ID=teamdir",
306
+ for (key, expected) in [
307
+ ("TA_SPAWN_ENV_CANARY", Some("FOO")),
308
+ ("HTTP_PROXY", Some("http://canary-proxy:1")),
309
+ ("NODE_EXTRA_CA_CERTS", Some("/canary/ca.pem")),
310
+ ("TEAM_AGENT_OWNER_TEAM_ID", Some("teamdir")),
245
311
  ] {
246
- if !command_line.contains(expected) {
247
- failures.push(format!(
248
- "{surface}: missing `{expected}`; worker spawn must inherit parent env and overlay Team Agent identity; command_line={command_line:?}"
249
- ));
312
+ match (spawn.env.get(key).map(String::as_str), expected) {
313
+ (Some(actual), Some(wanted)) if actual == wanted => {}
314
+ (Some(_), Some(wanted)) => failures.push(format!(
315
+ "{surface}: `{key}` present but not `{wanted}`"
316
+ )),
317
+ _ => failures.push(format!(
318
+ "{surface}: missing `{key}` overlay/inherit"
319
+ )),
250
320
  }
251
321
  }
252
- if !command_line.contains(&format!("TEAM_AGENT_AGENT_ID={agent_id}")) {
322
+ if spawn
323
+ .env
324
+ .get("TEAM_AGENT_WORKSPACE")
325
+ .is_none_or(|value| value.is_empty())
326
+ {
327
+ failures.push(format!("{surface}: missing `TEAM_AGENT_WORKSPACE` overlay"));
328
+ }
329
+ if spawn.env.get("TEAM_AGENT_AGENT_ID").map(String::as_str) != Some(agent_id) {
253
330
  failures.push(format!(
254
- "{surface}: missing `TEAM_AGENT_AGENT_ID={agent_id}` overlay; command_line={command_line:?}"
331
+ "{surface}: missing `TEAM_AGENT_AGENT_ID={agent_id}` overlay"
255
332
  ));
256
333
  }
257
- if command_line.contains("TMUX=") || command_line.contains("TMUX_PANE=") {
334
+ if spawn_env_has_tmux_control_assignment(spawn) {
335
+ let present: Vec<&str> = ["TMUX", "TMUX_PANE"]
336
+ .into_iter()
337
+ .filter(|key| spawn.env.contains_key(*key))
338
+ .collect();
258
339
  failures.push(format!(
259
- "{surface}: worker spawn must filter tmux control env from inherited parent env; command_line={command_line:?}"
340
+ "{surface}: worker spawn must filter tmux control env from inherited parent env; present_keys={present:?}"
260
341
  ));
261
342
  }
262
- if !command_line.split_whitespace().any(|part| part == "codex") {
343
+ if !spawn.argv.iter().any(|part| part == "codex") {
263
344
  failures.push(format!(
264
- "{surface}: provider executable must remain the command name `codex`; command_line={command_line:?}"
345
+ "{surface}: provider executable must remain the command name `codex`"
265
346
  ));
266
347
  }
267
- if command_line
268
- .split_whitespace()
269
- .any(|part| part.ends_with("/codex"))
270
- {
348
+ if spawn.argv.iter().any(|part| part.ends_with("/codex")) {
271
349
  failures.push(format!(
272
- "{surface}: provider executable must not be hard-coded as an absolute codex path; command_line={command_line:?}"
350
+ "{surface}: provider executable must not be hard-coded as an absolute codex path"
273
351
  ));
274
352
  }
275
353
  failures
@@ -580,6 +658,7 @@ impl RecordingTransport {
580
658
  self
581
659
  }
582
660
 
661
+ #[allow(dead_code)]
583
662
  fn single_spawn_command_line(&self) -> String {
584
663
  self.single_spawn().command_line()
585
664
  }