@team-agent/installer 0.5.46 → 0.5.48

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
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.46"
578
+ version = "0.5.48"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "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.46"
12
+ version = "0.5.48"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -11,16 +11,22 @@ use std::io::Write as _;
11
11
  pub fn emit(output: &CmdOutput, as_json: bool) -> Option<String> {
12
12
  match output {
13
13
  CmdOutput::None => None,
14
- CmdOutput::Human(text) => Some(text.clone()),
15
- CmdOutput::Json(value) if as_json => serde_json::to_string_pretty(&sort_json(value)).ok(),
16
- CmdOutput::Json(Value::Object(obj)) => {
17
- let lines: Vec<String> = obj
18
- .iter()
19
- .map(|(key, value)| format!("{key}: {}", human_value(value)))
20
- .collect();
21
- Some(lines.join("\n"))
14
+ CmdOutput::Human(text) => Some(crate::redaction::redact_external_text(text)),
15
+ CmdOutput::Json(value) => {
16
+ let value = crate::redaction::redact_external_value(value);
17
+ if as_json {
18
+ return serde_json::to_string_pretty(&sort_json(&value)).ok();
19
+ }
20
+ if let Value::Object(obj) = value {
21
+ let lines: Vec<String> = obj
22
+ .iter()
23
+ .map(|(key, value)| format!("{key}: {}", human_value(value)))
24
+ .collect();
25
+ Some(lines.join("\n"))
26
+ } else {
27
+ Some(human_value(&value))
28
+ }
22
29
  }
23
- CmdOutput::Json(value) => Some(human_value(value)),
24
30
  }
25
31
  }
26
32
 
@@ -620,8 +626,24 @@ fn emit_cli_error(command: &str, args: &[String], cwd: &Path, error: &CliError)
620
626
  }
621
627
  let normalized = normalize_cli_error(error);
622
628
  let payload_error = normalized.as_ref().unwrap_or(error);
623
- let _ = std::fs::write(&log_path, format!("{payload_error}\n"));
624
- let payload = payload_error.to_payload(&log_path, command);
629
+ let safe_error = crate::redaction::redact_external_text(&payload_error.to_string());
630
+ let _ = std::fs::write(&log_path, format!("{safe_error}\n"));
631
+ let mut payload = payload_error.to_payload(&log_path, command);
632
+ payload.error = safe_error;
633
+ payload.action = crate::redaction::redact_external_text(&payload.action);
634
+ payload.log = crate::redaction::redact_external_text(&payload.log);
635
+ payload.reason = payload
636
+ .reason
637
+ .map(|value| crate::redaction::redact_external_text(&value));
638
+ payload.session_name = payload
639
+ .session_name
640
+ .map(|value| crate::redaction::redact_external_text(&value));
641
+ payload.next_actions = payload.next_actions.map(|values| {
642
+ values
643
+ .into_iter()
644
+ .map(|value| crate::redaction::redact_external_text(&value))
645
+ .collect()
646
+ });
625
647
  if has_arg(args, "--json") {
626
648
  if let Ok(value) = serde_json::to_value(payload) {
627
649
  println!("{}", python_compact_json(&value));
@@ -89,7 +89,7 @@ pub fn status_scoped(
89
89
  );
90
90
  }
91
91
  let readiness = crate::cli::diagnose::wait_readiness(&readiness_state);
92
- let full = json!({
92
+ let full = crate::redaction::redact_external_value(&json!({
93
93
  "ok": true,
94
94
  "team": state.pointer("/leader/id").cloned().unwrap_or_else(|| json!("leader")),
95
95
  "session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
@@ -124,7 +124,7 @@ pub fn status_scoped(
124
124
  .tail(10)
125
125
  .map_err(|e| CliError::Runtime(e.to_string()))?,
126
126
  ),
127
- });
127
+ }));
128
128
  if compact {
129
129
  Ok(compact_status(full))
130
130
  } else {
@@ -147,7 +147,7 @@ impl EventLog {
147
147
  obj.insert(k, v);
148
148
  }
149
149
  }
150
- let event = Value::Object(obj);
150
+ let event = crate::redaction::redact_external_value(&Value::Object(obj));
151
151
  self.maybe_rotate()?;
152
152
  // 单次 write_all(line+"\n"):POSIX O_APPEND 对 <PIPE_BUF 写原子,避免并发写者交错(对抗 P1)。
153
153
  let mut bytes = to_python_json(&sort_value(&event)).into_bytes();
@@ -176,11 +176,11 @@ impl EventLog {
176
176
  let mut out = Vec::new();
177
177
  for line in &lines[start..] {
178
178
  match serde_json::from_str::<Value>(line) {
179
- Ok(v) => out.push(v),
179
+ Ok(v) => out.push(crate::redaction::redact_external_value(&v)),
180
180
  Err(_) => {
181
181
  let mut m = serde_json::Map::new();
182
182
  m.insert("raw".to_string(), Value::String((*line).to_string()));
183
- out.push(Value::Object(m));
183
+ out.push(crate::redaction::redact_external_value(&Value::Object(m)));
184
184
  }
185
185
  }
186
186
  }
@@ -56,6 +56,7 @@ pub use crate::db::message_store;
56
56
  // step 8 (provider) — ProviderAdapter trait + typed provider/turn-state/liveness 等(ROUND-0 骨架;
57
57
  // fn body unimplemented!(),P2 porter 落实现)。MUST-NOT-13:provider 调用全走 trait。
58
58
  pub mod provider;
59
+ mod redaction;
59
60
  /// unit-6 (Stage 2) compat shim. Physical home is now
60
61
  /// `crate::provider::session::capture`; this re-export keeps every
61
62
  /// `crate::session_capture::*` caller working without modification.
@@ -4366,6 +4366,7 @@ pub fn fork_agent_with_transport(
4366
4366
  // E5 §3:team_dir(角色定义+profiles)恒用户目录。spec 读用 selector 解析的 spec_path
4367
4367
  // (读序 B:runtime 优先、legacy 回落),写恒走 runtime_spec_path(canonical 落点)。
4368
4368
  let fork_team_dir = selected.team_dir.clone();
4369
+ let fork_team = selected.team_key.clone();
4369
4370
  let read_spec_path = selected
4370
4371
  .spec_path
4371
4372
  .clone()
@@ -4373,7 +4374,7 @@ pub fn fork_agent_with_transport(
4373
4374
  let workspace = selected.run_workspace;
4374
4375
  let state = selected.state;
4375
4376
  ensure_owner_allowed_for_state(&state, Some(source_agent_id))?;
4376
- let spec_path = crate::model::paths::runtime_spec_path(&workspace, &selected.team_key);
4377
+ let spec_path = crate::model::paths::runtime_spec_path(&workspace, &fork_team);
4377
4378
  let text = std::fs::read_to_string(&read_spec_path)
4378
4379
  .map_err(|e| LifecycleError::Compile(format!("{}: {e}", read_spec_path.display())))?;
4379
4380
  let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
@@ -4487,7 +4488,6 @@ pub fn fork_agent_with_transport(
4487
4488
  &safety,
4488
4489
  )?;
4489
4490
  let resolved_tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
4490
- let fork_team = crate::messaging::leader_receiver::active_team_key(&workspace, &state);
4491
4491
  let mcp_config = adapter.mcp_config(auth_mode).map_err(|e| {
4492
4492
  let _ = std::fs::write(&spec_path, text.as_bytes());
4493
4493
  LifecycleError::Provider(e.to_string())
@@ -4625,13 +4625,16 @@ pub fn fork_agent_with_transport(
4625
4625
  &mcp_config_path,
4626
4626
  as_agent_id,
4627
4627
  &profile_launch,
4628
+ &fork_team,
4628
4629
  );
4629
4630
  return Err(e);
4630
4631
  }
4631
- if let Err(e) = crate::state::persist::save_runtime_state_with_lifecycle_topology_authority(
4632
- &workspace,
4632
+ if let Err(e) = crate::state::repository::StateRepository::new(&workspace).save(
4633
+ crate::state::repository::StateWriteIntent::ForkAgent {
4634
+ team_key: &fork_team,
4635
+ agent_id: as_agent_id.as_str(),
4636
+ },
4633
4637
  &next_state,
4634
- &[as_agent_id.as_str()],
4635
4638
  ) {
4636
4639
  rollback_fork_after_spawn(
4637
4640
  &workspace,
@@ -4644,9 +4647,56 @@ pub fn fork_agent_with_transport(
4644
4647
  &mcp_config_path,
4645
4648
  as_agent_id,
4646
4649
  &profile_launch,
4650
+ &fork_team,
4647
4651
  );
4648
4652
  return Err(LifecycleError::StatePersist(e.to_string()));
4649
4653
  }
4654
+ let registration =
4655
+ crate::state::projection::select_runtime_state(&workspace, Some(fork_team.as_str()))
4656
+ .map_err(|e| e.to_string())
4657
+ .and_then(|saved| {
4658
+ let agent = saved
4659
+ .get("agents")
4660
+ .and_then(|agents| agents.get(as_agent_id.as_str()))
4661
+ .ok_or_else(|| "canonical team row is missing".to_string())?;
4662
+ if agent.get("pane_id").and_then(serde_json::Value::as_str)
4663
+ != Some(spawn.pane_id.as_str())
4664
+ {
4665
+ return Err("canonical team pane_id does not match spawned pane".to_string());
4666
+ }
4667
+ if agent.get("window").and_then(serde_json::Value::as_str) != Some(window.as_str())
4668
+ {
4669
+ return Err("canonical team window does not match spawned window".to_string());
4670
+ }
4671
+ if let Some(pid) = spawn.child_pid {
4672
+ if agent.get("pane_pid").and_then(serde_json::Value::as_u64)
4673
+ != Some(u64::from(pid))
4674
+ {
4675
+ return Err(
4676
+ "canonical team pane_pid does not match spawned process".to_string()
4677
+ );
4678
+ }
4679
+ }
4680
+ Ok(())
4681
+ });
4682
+ if let Err(reason) = registration {
4683
+ rollback_fork_after_spawn(
4684
+ &workspace,
4685
+ &spec_path,
4686
+ &text,
4687
+ &old_state,
4688
+ transport,
4689
+ &session_name,
4690
+ &window,
4691
+ &mcp_config_path,
4692
+ as_agent_id,
4693
+ &profile_launch,
4694
+ &fork_team,
4695
+ );
4696
+ return Err(LifecycleError::StatePersist(format!(
4697
+ "fork spawned but team registration readback failed: {reason}"
4698
+ )));
4699
+ }
4650
4700
  if let Err(e) = maybe_fail_fork_after_spawn("start_coordinator") {
4651
4701
  rollback_fork_after_spawn(
4652
4702
  &workspace,
@@ -4659,6 +4709,7 @@ pub fn fork_agent_with_transport(
4659
4709
  &mcp_config_path,
4660
4710
  as_agent_id,
4661
4711
  &profile_launch,
4712
+ &fork_team,
4662
4713
  );
4663
4714
  return Err(e);
4664
4715
  }
@@ -4678,6 +4729,7 @@ pub fn fork_agent_with_transport(
4678
4729
  &mcp_config_path,
4679
4730
  as_agent_id,
4680
4731
  &profile_launch,
4732
+ &fork_team,
4681
4733
  );
4682
4734
  LifecycleError::StatePersist(e.to_string())
4683
4735
  })?;
@@ -4704,16 +4756,19 @@ fn rollback_fork_after_spawn(
4704
4756
  mcp_config_path: &Path,
4705
4757
  agent_id: &AgentId,
4706
4758
  profile_launch: &crate::provider::ProviderProfileLaunch,
4759
+ team_key: &str,
4707
4760
  ) {
4708
4761
  let _ = transport.kill_window(&Target::SessionWindow {
4709
4762
  session: session_name.clone(),
4710
4763
  window: window.clone(),
4711
4764
  });
4712
4765
  let _ = std::fs::write(spec_path, spec_text.as_bytes());
4713
- let _ = crate::state::persist::save_runtime_state_with_deleted_agents(
4714
- workspace,
4766
+ let _ = crate::state::repository::StateRepository::new(workspace).save(
4767
+ crate::state::repository::StateWriteIntent::AgentRollback {
4768
+ team_key: Some(team_key),
4769
+ agent_id: agent_id.as_str(),
4770
+ },
4715
4771
  old_state,
4716
- &[agent_id.as_str()],
4717
4772
  );
4718
4773
  cleanup_fork_mcp_artifacts(workspace, agent_id, mcp_config_path, profile_launch);
4719
4774
  }
@@ -485,27 +485,17 @@ fn proxy_scheme(url: &str) -> Option<String> {
485
485
 
486
486
  fn redact_endpoint(raw: &str) -> String {
487
487
  let no_query = raw.split_once('?').map(|(head, _)| head).unwrap_or(raw);
488
- let Some((scheme, rest)) = no_query.split_once("://") else {
489
- return no_query.to_string();
490
- };
491
- let slash = rest.find('/').unwrap_or(rest.len());
492
- let authority = &rest[..slash];
493
- let path = &rest[slash..];
494
- if let Some((_, host)) = authority.rsplit_once('@') {
495
- format!("{scheme}://[redacted]@{host}{path}")
496
- } else {
497
- no_query.to_string()
498
- }
488
+ crate::redaction::redact_external_text(no_query)
499
489
  }
500
490
 
501
491
  fn redact_text(raw: &str, secrets: &[&str]) -> String {
502
492
  let mut out = raw.chars().take(512).collect::<String>();
503
493
  for secret in secrets {
504
494
  if !secret.is_empty() {
505
- out = out.replace(secret, "[redacted]");
495
+ out = out.replace(secret, "[REDACTED]");
506
496
  }
507
497
  }
508
- out
498
+ crate::redaction::redact_external_text(&out)
509
499
  }
510
500
 
511
501
  fn auth_mode_wire(auth_mode: AuthMode) -> &'static str {
@@ -113,11 +113,7 @@ pub(crate) fn start_agent_at_paths(
113
113
  let window = agent_window(&agent, agent_id);
114
114
  let adaptive_layout =
115
115
  open_display && crate::lifecycle::launch::state_uses_adaptive_layout(&state);
116
- let fake_provider = raw_agent
117
- .get("provider")
118
- .and_then(serde_json::Value::as_str)
119
- .is_some_and(|provider| provider.eq_ignore_ascii_case("fake"));
120
- if force && !fake_provider && is_per_agent_window(&window, agent_id) {
116
+ if force && is_per_agent_window(&window, agent_id) {
121
117
  let expected_pane_id = raw_agent
122
118
  .get("pane_id")
123
119
  .and_then(serde_json::Value::as_str)
@@ -276,12 +272,20 @@ pub(crate) fn start_agent_at_paths(
276
272
  None,
277
273
  Some(resolved_team_key.as_str()),
278
274
  )?;
279
- verify_spawned_pane_matches_target(
275
+ if let Err(error) = verify_spawned_pane_matches_target(
280
276
  transport,
281
277
  &spawn.spawn.pane_id,
282
278
  &session_name,
283
279
  &spawn.spawn.window,
284
- )?;
280
+ ) {
281
+ if let Err(rollback_error) = transport.kill_pane(&spawn.spawn.pane_id) {
282
+ return Err(LifecycleError::RequirementUnmet(format!(
283
+ "{error}; failed to roll back spawned pane {}: {rollback_error}",
284
+ spawn.spawn.pane_id.as_str()
285
+ )));
286
+ }
287
+ return Err(error);
288
+ }
285
289
  let actual_spawn_window = spawn.spawn.window.as_str().to_string();
286
290
  mark_agent_started(
287
291
  &mut state,
@@ -89,11 +89,19 @@ fn same_role_cohort_error(
89
89
  expected_live: usize,
90
90
  expected_old_only: bool,
91
91
  ) -> Option<String> {
92
- let panes = transport.list_targets().ok()?;
92
+ let panes = match transport.list_targets() {
93
+ Ok(panes) => panes,
94
+ Err(error) => {
95
+ return Some(format!(
96
+ "{operation} refused: same-role cohort observation failed: {error}"
97
+ ));
98
+ }
99
+ };
93
100
  let mut cardinality_proofs = Vec::new();
94
101
  let mut binding_proofs = Vec::new();
102
+ let mut observation_proofs = Vec::new();
95
103
  for target in targets {
96
- let live_panes = panes
104
+ let candidate_panes = panes
97
105
  .iter()
98
106
  .filter(|pane| pane.session.as_str() == session_name.as_str())
99
107
  .filter(|pane| {
@@ -101,20 +109,30 @@ fn same_role_cohort_error(
101
109
  .as_ref()
102
110
  .is_some_and(|window| window.as_str() == target.window)
103
111
  })
104
- .filter(|pane| {
105
- !expected_old_only
106
- || target
107
- .expected_pane_id
108
- .as_deref()
109
- .is_some_and(|expected| pane.pane_id.as_str() == expected)
110
- })
111
- .filter(|pane| {
112
- transport
113
- .liveness(&pane.pane_id)
114
- .is_ok_and(|live| live == crate::transport::PaneLiveness::Live)
115
- })
116
- .map(|pane| pane.pane_id.as_str().to_string())
117
112
  .collect::<Vec<_>>();
113
+ let mut live_panes = Vec::new();
114
+ for pane in candidate_panes {
115
+ match transport.liveness(&pane.pane_id) {
116
+ Ok(crate::transport::PaneLiveness::Live) => {
117
+ live_panes.push(pane.pane_id.as_str().to_string());
118
+ }
119
+ Ok(crate::transport::PaneLiveness::Dead) => {}
120
+ Ok(crate::transport::PaneLiveness::Unknown) => {
121
+ observation_proofs.push(format!(
122
+ "{}:window={}:pane={}:liveness=unknown",
123
+ target.agent_id,
124
+ target.window,
125
+ pane.pane_id.as_str()
126
+ ));
127
+ }
128
+ Err(error) => observation_proofs.push(format!(
129
+ "{}:window={}:pane={}:liveness_error={error}",
130
+ target.agent_id,
131
+ target.window,
132
+ pane.pane_id.as_str()
133
+ )),
134
+ }
135
+ }
118
136
  if live_panes.len() != expected_live {
119
137
  cardinality_proofs.push(format!(
120
138
  "{}:window={}:live_panes=[{}]",
@@ -135,6 +153,12 @@ fn same_role_cohort_error(
135
153
  }
136
154
  }
137
155
  }
156
+ if !observation_proofs.is_empty() {
157
+ return Some(format!(
158
+ "{operation} refused: same-role cohort observation failed; {}",
159
+ observation_proofs.join("; ")
160
+ ));
161
+ }
138
162
  if !binding_proofs.is_empty() {
139
163
  return Some(format!(
140
164
  "{operation} refused: spawn identity/binding mismatch; {}",
@@ -476,6 +500,16 @@ pub(super) fn spawn_agent_window(
476
500
  );
477
501
  if let Some(error) = startup_prompt.capture_error.as_deref() {
478
502
  if is_structural_startup_prompt_error(error) {
503
+ if let Err(rollback_error) = transport.kill_pane(&spawn.pane_id) {
504
+ return Err(LifecycleError::Transport(format!(
505
+ "startup prompt structural failure for {}:{} pane {}: {}; failed to roll back spawned pane: {}",
506
+ session_name.as_str(),
507
+ window.as_str(),
508
+ spawn.pane_id.as_str(),
509
+ error,
510
+ rollback_error
511
+ )));
512
+ }
479
513
  return Err(LifecycleError::Transport(format!(
480
514
  "startup prompt structural failure for {}:{} pane {}: {}",
481
515
  session_name.as_str(),
@@ -14,7 +14,7 @@ pub(super) type LaneSpawns = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<St
14
14
  /// end-to-end in-process.
15
15
  pub(super) struct LaneTransport {
16
16
  session: String,
17
- windows: Vec<String>,
17
+ windows: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
18
18
  killed: LaneKills,
19
19
  spawns: LaneSpawns,
20
20
  }
@@ -22,7 +22,9 @@ impl LaneTransport {
22
22
  pub(super) fn new(session: &str, windows: &[&str]) -> Self {
23
23
  Self {
24
24
  session: session.to_string(),
25
- windows: windows.iter().map(|w| (*w).to_string()).collect(),
25
+ windows: std::sync::Arc::new(std::sync::Mutex::new(
26
+ windows.iter().map(|w| (*w).to_string()).collect(),
27
+ )),
26
28
  killed: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
27
29
  spawns: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
28
30
  }
@@ -120,6 +122,8 @@ impl crate::transport::Transport for LaneTransport {
120
122
  ) -> Result<Vec<crate::transport::PaneInfo>, crate::transport::TransportError> {
121
123
  Ok(self
122
124
  .windows
125
+ .lock()
126
+ .unwrap()
123
127
  .iter()
124
128
  .map(|w| crate::transport::PaneInfo {
125
129
  pane_id: crate::transport::PaneId::new(format!("%{w}")),
@@ -149,6 +153,8 @@ impl crate::transport::Transport for LaneTransport {
149
153
  if s.as_str() == self.session {
150
154
  Ok(self
151
155
  .windows
156
+ .lock()
157
+ .unwrap()
152
158
  .iter()
153
159
  .map(|w| crate::transport::WindowName::new(w.as_str()))
154
160
  .collect())
@@ -174,13 +180,22 @@ impl crate::transport::Transport for LaneTransport {
174
180
  &self,
175
181
  t: &crate::transport::Target,
176
182
  ) -> Result<(), crate::transport::TransportError> {
177
- let name = match t {
178
- crate::transport::Target::Pane(p) => p.as_str().to_string(),
183
+ let (name, window) = match t {
184
+ crate::transport::Target::Pane(p) => (p.as_str().to_string(), None),
179
185
  crate::transport::Target::SessionWindow { session, window } => {
180
- format!("{}:{}", session.as_str(), window.as_str())
186
+ (
187
+ format!("{}:{}", session.as_str(), window.as_str()),
188
+ Some(window.as_str()),
189
+ )
181
190
  }
182
191
  };
183
192
  self.killed.lock().unwrap().push(name);
193
+ if let Some(window) = window {
194
+ self.windows
195
+ .lock()
196
+ .unwrap()
197
+ .retain(|candidate| candidate != window);
198
+ }
184
199
  Ok(())
185
200
  }
186
201
  fn attach_session(
@@ -204,6 +204,7 @@ fn run_stdio_loop_inner<R: BufRead, W: Write>(
204
204
  report.requests_read = report.requests_read.saturating_add(1);
205
205
  let frame = handle_stdin_line(tools, &line, report)?;
206
206
  if let Some(value) = frame {
207
+ let value = crate::redaction::redact_external_value(&value);
207
208
  serde_json::to_writer(&mut *writer, &value)?;
208
209
  writer.write_all(b"\n")?;
209
210
  writer.flush()?;
@@ -266,7 +267,8 @@ fn rpc_id_from_request(request: &Value) -> RpcId {
266
267
  }
267
268
 
268
269
  fn tool_call_result_value(is_error: bool, body: &Value) -> Value {
269
- let text = json_dumps_default(body);
270
+ let body = crate::redaction::redact_external_value(body);
271
+ let text = json_dumps_default(&body);
270
272
  let mut content = serde_json::Map::new();
271
273
  content.insert("type".to_string(), Value::String("text".to_string()));
272
274
  content.insert("text".to_string(), Value::String(text));
@@ -0,0 +1,185 @@
1
+ use std::sync::LazyLock;
2
+
3
+ use regex::{Captures, Regex};
4
+ use serde_json::Value;
5
+
6
+ const REDACTED: &str = "[REDACTED]";
7
+ const SENSITIVE_FAMILIES: [&str; 7] = [
8
+ "PROXY",
9
+ "TOKEN",
10
+ "KEY",
11
+ "PASSWORD",
12
+ "AUTH",
13
+ "SECRET",
14
+ "CREDENTIAL",
15
+ ];
16
+ const STRUCTURAL_KEYS: [&str; 13] = [
17
+ "active_team_key",
18
+ "cohort_key",
19
+ "dedupe_key",
20
+ "error_key",
21
+ "error_observation_key",
22
+ "last_check_key",
23
+ "last_error_observation_key",
24
+ "last_notified_key",
25
+ "last_suppressed_key",
26
+ "parent_team_key",
27
+ "runtime_team_key",
28
+ "team_key",
29
+ "team_state_key",
30
+ ];
31
+
32
+ static SHELL_ASSIGNMENT: LazyLock<Regex> = LazyLock::new(|| {
33
+ Regex::new(
34
+ r#"(?i)(?P<prefix>^|[\s\[,;('"])(?P<key>[a-z_][a-z0-9_]*)=(?P<value>'[^']*'|"[^"]*"|[^\s,\]\[};)]+)"#,
35
+ )
36
+ .expect("shell assignment redaction regex")
37
+ });
38
+
39
+ static URL_USERINFO: LazyLock<Regex> = LazyLock::new(|| {
40
+ Regex::new(r"(?P<scheme>[A-Za-z][A-Za-z0-9+.-]*://)[^\s/?#]+@")
41
+ .expect("URL userinfo redaction regex")
42
+ });
43
+
44
+ pub(crate) fn redact_external_value(value: &serde_json::Value) -> serde_json::Value {
45
+ match value {
46
+ Value::Object(object) => Value::Object(
47
+ object
48
+ .iter()
49
+ .map(|(key, value)| {
50
+ let value = if is_sensitive_env_key(key) {
51
+ Value::String(REDACTED.to_string())
52
+ } else {
53
+ redact_external_value(value)
54
+ };
55
+ (key.clone(), value)
56
+ })
57
+ .collect(),
58
+ ),
59
+ Value::Array(values) => Value::Array(values.iter().map(redact_external_value).collect()),
60
+ Value::String(text) => Value::String(redact_external_text(text)),
61
+ other => other.clone(),
62
+ }
63
+ }
64
+
65
+ pub(crate) fn redact_external_text(text: &str) -> String {
66
+ let assignments = SHELL_ASSIGNMENT.replace_all(text, |captures: &Captures<'_>| {
67
+ let key = captures.name("key").map_or("", |value| value.as_str());
68
+ if !is_sensitive_env_key(key) {
69
+ return captures[0].to_string();
70
+ }
71
+ let prefix = captures.name("prefix").map_or("", |value| value.as_str());
72
+ let value = captures.name("value").map_or("", |value| value.as_str());
73
+ let quote = match (value.as_bytes().first(), value.as_bytes().last()) {
74
+ (Some(b'\''), Some(b'\'')) => "'",
75
+ (Some(b'"'), Some(b'"')) => "\"",
76
+ _ => "",
77
+ };
78
+ let unquoted = value
79
+ .strip_prefix(quote)
80
+ .and_then(|value| value.strip_suffix(quote))
81
+ .unwrap_or(value);
82
+ let has_url_userinfo = URL_USERINFO.is_match(unquoted);
83
+ let redacted_url = URL_USERINFO
84
+ .replace_all(unquoted, "${scheme}[REDACTED]@")
85
+ .into_owned();
86
+ let safe_value = if has_url_userinfo {
87
+ &redacted_url
88
+ } else {
89
+ REDACTED
90
+ };
91
+ format!("{prefix}{key}={quote}{safe_value}{quote}")
92
+ });
93
+ URL_USERINFO
94
+ .replace_all(&assignments, "${scheme}[REDACTED]@")
95
+ .into_owned()
96
+ }
97
+
98
+ fn is_sensitive_env_key(key: &str) -> bool {
99
+ if STRUCTURAL_KEYS
100
+ .iter()
101
+ .any(|structural| key.eq_ignore_ascii_case(structural))
102
+ {
103
+ return false;
104
+ }
105
+ let key = key.to_ascii_uppercase();
106
+ SENSITIVE_FAMILIES
107
+ .iter()
108
+ .any(|family| key == *family || key.ends_with(&format!("_{family}")))
109
+ }
110
+
111
+ #[cfg(test)]
112
+ mod tests {
113
+ use super::*;
114
+ use serde_json::json;
115
+
116
+ #[test]
117
+ fn recursive_values_mask_env_families_without_erasing_wire_truth() {
118
+ let input = json!({
119
+ "HTTPS_PROXY": "proxy-secret",
120
+ "Copilot_Github_Token": "token-secret",
121
+ "nested": [{"db_password": "password-secret"}],
122
+ "team_key": "current",
123
+ "active_team_key": "current",
124
+ "dedupe_key": "restart:worker",
125
+ "auth_mode": "subscription",
126
+ "model_source": "role",
127
+ "model_stale": true,
128
+ });
129
+
130
+ let redacted = redact_external_value(&input);
131
+
132
+ assert_eq!(redacted["HTTPS_PROXY"], REDACTED);
133
+ assert_eq!(redacted["Copilot_Github_Token"], REDACTED);
134
+ assert_eq!(redacted["nested"][0]["db_password"], REDACTED);
135
+ assert_eq!(redacted["team_key"], "current");
136
+ assert_eq!(redacted["active_team_key"], "current");
137
+ assert_eq!(redacted["dedupe_key"], "restart:worker");
138
+ assert_eq!(redacted["auth_mode"], "subscription");
139
+ assert_eq!(redacted["model_source"], "role");
140
+ assert_eq!(redacted["model_stale"], true);
141
+ assert_eq!(redact_external_value(&redacted), redacted);
142
+ }
143
+
144
+ #[test]
145
+ fn text_masks_quoted_assignments_and_url_userinfo_idempotently() {
146
+ let input = "HTTPS_PROXY='https://user:pass@proxy.invalid:8443/path' http_proxy=other endpoint=https://user:pass@proxy.invalid:8443/path";
147
+ let expected = "HTTPS_PROXY='https://[REDACTED]@proxy.invalid:8443/path' http_proxy=[REDACTED] endpoint=https://[REDACTED]@proxy.invalid:8443/path";
148
+
149
+ let redacted = redact_external_text(input);
150
+
151
+ assert_eq!(redacted, expected);
152
+ assert_eq!(redact_external_text(&redacted), redacted);
153
+ }
154
+
155
+ #[test]
156
+ fn text_leaves_non_env_assignments_and_plain_urls_unchanged() {
157
+ let input = "team_key=current author=operator endpoint=https://proxy.invalid/health";
158
+ assert_eq!(redact_external_text(input), input);
159
+ }
160
+
161
+ #[test]
162
+ fn mixed_external_value_is_idempotent() {
163
+ let marker = "synthetic-redaction-unit-marker";
164
+ let credential_url = format!("https://demo-user:{marker}@proxy.invalid:8443/path");
165
+ let diagnostic = format!(
166
+ "subprocess exited 37: argv=[tmux, HTTPS_PROXY='{credential_url}']; endpoint={credential_url}"
167
+ );
168
+ let input = json!({
169
+ "HTTPS_PROXY": marker,
170
+ "ordinary_diagnostic": format!(
171
+ "HTTPS_PROXY='{credential_url}' http_proxy=\"{credential_url}\" OPENAI_API_KEY={marker} endpoint={credential_url}"
172
+ ),
173
+ "nested": [[diagnostic, credential_url]],
174
+ "team_key": "current",
175
+ });
176
+
177
+ let once = redact_external_value(&input);
178
+ let twice = redact_external_value(&once);
179
+ let text = once.to_string();
180
+
181
+ assert!(!text.contains(marker));
182
+ assert!(text.contains("https://[REDACTED]@proxy.invalid:8443/path"));
183
+ assert_eq!(twice, once);
184
+ }
185
+ }
@@ -315,16 +315,22 @@ fn route_direct(
315
315
  StateWriteIntent::RemoveAgent { agent_id, .. } => {
316
316
  helper_write_team_scoped_with_deleted_agents(workspace, state, &[agent_id])
317
317
  }
318
- // ForkAgent -> the launch fork writer uses the root helper at
319
- // lifecycle/launch.rs:4542.
320
- StateWriteIntent::ForkAgent { .. } => helper_write_root(workspace, state),
321
- // AgentRollback -> pre-state rollback uses either the root helper or
322
- // the deleted-agents variant depending on the caller; S1a keeps the
323
- // root form as the shared entry, and rollback specializations retain
324
- // their allowlisted callsites in launch.rs / restart/remove.rs.
325
- StateWriteIntent::AgentRollback { agent_id, .. } => {
326
- helper_write_root_with_deleted_agents(workspace, state, &[agent_id])
318
+ // ForkAgent mutates a selected team projection, so persist it back to
319
+ // that projection with the forked row as lifecycle topology authority.
320
+ StateWriteIntent::ForkAgent { agent_id, .. } => {
321
+ helper_write_team_scoped_with_lifecycle_topology_authority(
322
+ workspace,
323
+ state,
324
+ &[agent_id],
325
+ )
327
326
  }
327
+ // Team-scoped rollback must restore only the selected projection and
328
+ // preserve sibling teams. Legacy callers without a team keep the root
329
+ // deleted-agents writer.
330
+ StateWriteIntent::AgentRollback { team_key, agent_id } => match team_key {
331
+ Some(_) => helper_write_team_scoped_with_deleted_agents(workspace, state, &[agent_id]),
332
+ None => helper_write_root_with_deleted_agents(workspace, state, &[agent_id]),
333
+ },
328
334
  // ClaimLeader -> leader/lease.rs:1625 uses the root helper, and the
329
335
  // scoped preserve-claim-fields variant at :1702 uses the team-tombstoned
330
336
  // agents helper.
@@ -514,9 +514,9 @@ fn spawn_into_same_named_replacement_returns_identity_created_by_spawn_command()
514
514
  }
515
515
 
516
516
  #[test]
517
- fn spawn_with_command_refuses_spawn_pane_owned_by_other_window() {
517
+ fn spawn_with_command_refuses_and_rolls_back_spawn_pane_owned_by_other_window() {
518
518
  let pane_inventory = "%5\tteamsess\t1\tw2\t0\t/dev/ttys005\tnode\t1\t/work/dir\t1\t0\t125\n";
519
- let (be, _rec) = backend_with(
519
+ let (be, rec) = backend_with(
520
520
  MockResp::Out(ok("")),
521
521
  vec![
522
522
  MockResp::Out(ok("%5\n")),
@@ -539,6 +539,48 @@ fn spawn_with_command_refuses_spawn_pane_owned_by_other_window() {
539
539
  && msg.contains("observed=teamsess:w2"),
540
540
  "error must include requested/observed ownership evidence, got {msg}"
541
541
  );
542
+ let calls = rec.lock().unwrap().clone();
543
+ assert!(
544
+ calls
545
+ .iter()
546
+ .any(|call| call == &svec(&["tmux", "kill-pane", "-t", "%5"])),
547
+ "identity mismatch must roll back the exact pane created by this spawn; calls={calls:?}"
548
+ );
549
+ }
550
+
551
+ #[test]
552
+ fn spawn_with_command_retries_until_spawn_pane_is_visible() {
553
+ let pane_inventory = "%6\tteamsess\t1\tw1\t0\t/dev/ttys006\tnode\t1\t/work/dir\t1\t0\t126\n";
554
+ let (be, rec) = backend_with(
555
+ MockResp::Out(ok(pane_inventory)),
556
+ vec![MockResp::Out(ok("%6\n")), MockResp::Out(ok(""))],
557
+ );
558
+ let result = be
559
+ .spawn_into(
560
+ &SessionName::new("teamsess"),
561
+ &WindowName::new("w1"),
562
+ &svec(&["provider-bin"]),
563
+ Path::new("/work/dir"),
564
+ &BTreeMap::new(),
565
+ )
566
+ .expect("spawn pane must tolerate bounded tmux inventory lag");
567
+ let calls = rec.lock().unwrap().clone();
568
+
569
+ assert_eq!(result.pane_id.as_str(), "%6");
570
+ assert_eq!(
571
+ calls
572
+ .iter()
573
+ .filter(|call| call.get(1).is_some_and(|arg| arg == "list-panes"))
574
+ .count(),
575
+ 2,
576
+ "spawn identity must retry the same pane id until it becomes visible; calls={calls:?}"
577
+ );
578
+ assert!(
579
+ calls
580
+ .iter()
581
+ .all(|call| call.get(1).is_none_or(|arg| arg != "kill-pane")),
582
+ "a delayed but valid identity must not be rolled back; calls={calls:?}"
583
+ );
542
584
  }
543
585
 
544
586
  #[test]
@@ -1742,16 +1784,16 @@ fn query_single_field_argv_and_nonzero_maps_to_none() {
1742
1784
  }
1743
1785
 
1744
1786
  // ── 11. list_targets (TRANSPORT TRIO) — `list-panes -a -F TMUX_PANE_FORMAT` + per-line parse ────
1745
- // Golden _legacy_pane_discovery.py:29-33 _tmux_list_panes: `tmux list-panes -a -F <TMUX_PANE_FORMAT>`
1746
- // (returncode != 0 -> []), parse each tab line via _parse_tmux_pane_info. TMUX_PANE_FORMAT
1747
- // (runtime.py:456-460) is the byte-exact tab string locked below; P5 (C-P5-3) appends
1787
+ // Golden _legacy_pane_discovery.py:29-33 _tmux_list_panes: `tmux list-panes -a -F <TMUX_PANE_FORMAT>`.
1788
+ // The Rust backend uses the printable separator locked below so the 12-field frame stays explicit
1789
+ // in argv/log evidence, while retaining the golden nonzero -> empty inventory behavior. P5 (C-P5-3) appends
1748
1790
  // `#{pane_pid}` as field 12 so pane pids ride the single list-panes call (the per-pane
1749
1791
  // display-message N+1 fallback is gone). leader_env stays the reverse-env real-machine bit.
1750
1792
  #[test]
1751
1793
  fn list_targets_argv_and_parses_tmux_pane_format() {
1752
- const FMT: &str = "#{pane_id}\t#{session_name}\t#{window_index}\t#{window_name}\t#{pane_index}\t#{pane_tty}\t#{pane_current_command}\t#{pane_active}\t#{pane_current_path}\t#{session_attached}\t#{pane_in_mode}\t#{pane_pid}";
1753
- let stdout = "%7\tteam-x\t0\twin0\t0\t/dev/ttys003\tcodex\t1\t/Users/me/work\t1\t0\t41001\n\
1754
- %8\tteam-x\t1\twin1\t0\t/dev/ttys004\tnode\t0\t/Users/me/other\t0\t0\t41002\n";
1794
+ const FMT: &str = "#{pane_id}__TA_FIELD__#{session_name}__TA_FIELD__#{window_index}__TA_FIELD__#{window_name}__TA_FIELD__#{pane_index}__TA_FIELD__#{pane_tty}__TA_FIELD__#{pane_current_command}__TA_FIELD__#{pane_active}__TA_FIELD__#{pane_current_path}__TA_FIELD__#{session_attached}__TA_FIELD__#{pane_in_mode}__TA_FIELD__#{pane_pid}";
1795
+ let stdout = "%7__TA_FIELD__team-x__TA_FIELD__0__TA_FIELD__win0__TA_FIELD__0__TA_FIELD__/dev/ttys003__TA_FIELD__codex__TA_FIELD__1__TA_FIELD__/Users/me/work__TA_FIELD__1__TA_FIELD__0__TA_FIELD__41001\n\
1796
+ %8__TA_FIELD__team-x__TA_FIELD__1__TA_FIELD__win1__TA_FIELD__0__TA_FIELD__/dev/ttys004__TA_FIELD__node__TA_FIELD__0__TA_FIELD__/Users/me/other__TA_FIELD__0__TA_FIELD__0__TA_FIELD__41002\n";
1755
1797
  let (be, rec) = backend_with(MockResp::Out(ok(stdout)), vec![]);
1756
1798
  let panes = be.list_targets().expect("list_targets ok");
1757
1799
  assert_eq!(
@@ -71,6 +71,8 @@ pub trait CommandRunner: Send + Sync {
71
71
  pub struct RealCommandRunner;
72
72
 
73
73
  const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
74
+ const SPAWN_IDENTITY_TIMEOUT: Duration = Duration::from_millis(500);
75
+ const SPAWN_IDENTITY_POLL_INTERVAL: Duration = Duration::from_millis(25);
74
76
 
75
77
  impl CommandRunner for RealCommandRunner {
76
78
  fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
@@ -779,46 +781,68 @@ impl TmuxBackend {
779
781
  });
780
782
  }
781
783
  let pane_id = PaneId::new(pane);
782
- let targets = self.list_targets()?;
783
- if let Some(target) = targets.iter().find(|target| {
784
- target.pane_id == pane_id
785
- && target.session.as_str() == session.as_str()
786
- && target
787
- .window_name
788
- .as_ref()
789
- .is_some_and(|name| name.as_str() == window.as_str())
790
- }) {
791
- return Ok(SpawnResult {
792
- pane_id,
793
- session: session.clone(),
794
- window: window.clone(),
795
- child_pid: target.pane_pid,
796
- });
797
- }
798
- let observed = targets
799
- .iter()
800
- .find(|target| target.pane_id == pane_id)
801
- .map(|target| {
802
- format!(
803
- "{}:{}",
804
- target.session.as_str(),
805
- target
806
- .window_name
807
- .as_ref()
808
- .map(WindowName::as_str)
809
- .unwrap_or("<unknown>")
810
- )
811
- })
812
- .unwrap_or_else(|| "<missing-from-list-targets>".to_string());
784
+ let deadline = Instant::now() + SPAWN_IDENTITY_TIMEOUT;
785
+ let observed = loop {
786
+ match self.list_targets() {
787
+ Ok(targets) => {
788
+ if let Some(target) = targets.iter().find(|target| {
789
+ target.pane_id == pane_id
790
+ && target.session.as_str() == session.as_str()
791
+ && target
792
+ .window_name
793
+ .as_ref()
794
+ .is_some_and(|name| name.as_str() == window.as_str())
795
+ }) {
796
+ return Ok(SpawnResult {
797
+ pane_id,
798
+ session: session.clone(),
799
+ window: window.clone(),
800
+ child_pid: target.pane_pid,
801
+ });
802
+ }
803
+ if let Some(target) = targets.iter().find(|target| target.pane_id == pane_id) {
804
+ break format!(
805
+ "{}:{}",
806
+ target.session.as_str(),
807
+ target
808
+ .window_name
809
+ .as_ref()
810
+ .map(WindowName::as_str)
811
+ .unwrap_or("<unknown>")
812
+ );
813
+ }
814
+ if Instant::now() >= deadline {
815
+ break "<missing-from-list-targets>".to_string();
816
+ }
817
+ }
818
+ Err(error) => {
819
+ if Instant::now() >= deadline {
820
+ break format!("<list-targets-error:{error}>");
821
+ }
822
+ }
823
+ }
824
+ std::thread::sleep(SPAWN_IDENTITY_POLL_INTERVAL);
825
+ };
826
+ let rollback_argv = vec![
827
+ "tmux".to_string(),
828
+ "kill-pane".to_string(),
829
+ "-t".to_string(),
830
+ pane_id.as_str().to_string(),
831
+ ];
832
+ let rollback_error = self.run_ok(&rollback_argv).err();
833
+ let rollback_suffix = rollback_error
834
+ .map(|error| format!("; failed to roll back spawned pane: {error}"))
835
+ .unwrap_or_default();
813
836
  Err(TransportError::Subprocess {
814
837
  argv: spawn_argv,
815
838
  code: output.code,
816
839
  stderr: format!(
817
- "tmux spawn pane identity mismatch: requested={}:{} observed_pane={} observed={}",
840
+ "tmux spawn pane identity mismatch: requested={}:{} observed_pane={} observed={}{}",
818
841
  session.as_str(),
819
842
  window.as_str(),
820
843
  pane_id.as_str(),
821
- observed
844
+ observed,
845
+ rollback_suffix
822
846
  ),
823
847
  })
824
848
  }
@@ -1338,6 +1362,7 @@ fn strip_ansi_escapes_inplace(input: &str) -> String {
1338
1362
  }
1339
1363
 
1340
1364
  fn scrub_secrets(line: &str) -> String {
1365
+ let line = crate::redaction::redact_external_text(line);
1341
1366
  // Five shapes: sk-XXXX, ghp_XXXX, AKIAXXXX (16-char uppercase id), Bearer XXXX,
1342
1367
  // 32+ hex (token).
1343
1368
  let mut out = String::with_capacity(line.len());
@@ -2313,7 +2338,9 @@ impl Transport for TmuxBackend {
2313
2338
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
2314
2339
  // P5 (C-P5-3): `#{pane_pid}` rides the single list-panes call (field index 11),
2315
2340
  // killing the per-pane display-message N+1 fallback.
2316
- const TMUX_PANE_FORMAT: &str = "#{pane_id}\t#{session_name}\t#{window_index}\t#{window_name}\t#{pane_index}\t#{pane_tty}\t#{pane_current_command}\t#{pane_active}\t#{pane_current_path}\t#{session_attached}\t#{pane_in_mode}\t#{pane_pid}";
2341
+ // Use a printable sentinel so the 12-field frame stays explicit in argv/log evidence;
2342
+ // `parse_pane_info_line` retains compatibility with legacy tab-delimited output.
2343
+ const TMUX_PANE_FORMAT: &str = "#{pane_id}__TA_FIELD__#{session_name}__TA_FIELD__#{window_index}__TA_FIELD__#{window_name}__TA_FIELD__#{pane_index}__TA_FIELD__#{pane_tty}__TA_FIELD__#{pane_current_command}__TA_FIELD__#{pane_active}__TA_FIELD__#{pane_current_path}__TA_FIELD__#{session_attached}__TA_FIELD__#{pane_in_mode}__TA_FIELD__#{pane_pid}";
2317
2344
  let argv = self.tmux_argv(&[
2318
2345
  "tmux".to_string(),
2319
2346
  "list-panes".to_string(),
@@ -2509,7 +2536,11 @@ fn query_pane_pid(backend: &TmuxBackend, pane: &PaneId) -> Result<Option<u32>, T
2509
2536
  }
2510
2537
 
2511
2538
  fn parse_pane_info_line(line: &str) -> Option<PaneInfo> {
2512
- let fields = line.split('\t').collect::<Vec<_>>();
2539
+ let fields = if line.contains("__TA_FIELD__") {
2540
+ line.split("__TA_FIELD__").collect::<Vec<_>>()
2541
+ } else {
2542
+ line.split('\t').collect::<Vec<_>>()
2543
+ };
2513
2544
  if fields.len() < 11 {
2514
2545
  return None;
2515
2546
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.46",
3
+ "version": "0.5.48",
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.46",
24
- "@team-agent/cli-darwin-x64": "0.5.46",
25
- "@team-agent/cli-linux-x64": "0.5.46"
23
+ "@team-agent/cli-darwin-arm64": "0.5.48",
24
+ "@team-agent/cli-darwin-x64": "0.5.48",
25
+ "@team-agent/cli-linux-x64": "0.5.48"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",