@team-agent/installer 0.5.45 → 0.5.47

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.
@@ -8,6 +8,7 @@ use crate::provider::{
8
8
  CaptureSessionContext, CapturedSession, CapturedSessionCandidate, Provider, ProviderAdapter,
9
9
  ProviderError, SessionId,
10
10
  };
11
+ use crate::state::identity_keys::SessionAttributionKey;
11
12
 
12
13
  pub const SESSION_CAPTURE_CONVERGENCE_DEADLINE_MS: u64 = 12_000;
13
14
  pub const SESSION_CAPTURE_CONVERGENCE_POLL_MS: u64 = 250;
@@ -638,6 +639,7 @@ pub fn incomplete_interacted_resumable_agent_ids(state: &Value) -> Vec<String> {
638
639
  struct PendingSessionCapture {
639
640
  agent_id: String,
640
641
  provider: Provider,
642
+ team_key: String,
641
643
  context: CaptureSessionContext,
642
644
  }
643
645
 
@@ -673,6 +675,13 @@ where
673
675
  Some(PendingSessionCapture {
674
676
  agent_id: agent_id.to_string(),
675
677
  provider,
678
+ team_key: agent
679
+ .get("owner_team_id")
680
+ .or_else(|| agent.get("team_key"))
681
+ .and_then(Value::as_str)
682
+ .filter(|s| !s.is_empty())
683
+ .unwrap_or("current")
684
+ .to_string(),
676
685
  context: CaptureSessionContext {
677
686
  agent_id: agent_id.to_string(),
678
687
  spawn_cwd: PathBuf::from(spawn_cwd),
@@ -883,14 +892,14 @@ fn allocate_session_candidates(
883
892
  .as_ref()
884
893
  .is_some_and(|sid| sid.as_str() == expected.as_str())
885
894
  })
886
- .filter(|candidate| !candidate_keys_collide(candidate, claimed))
895
+ .filter(|candidate| !candidate_keys_collide(item, candidate, claimed))
887
896
  .collect();
888
897
  // Uniqueness requirement: only assign when the expected id maps to
889
898
  // exactly one available candidate. Multiple matches or a colliding
890
899
  // single match leave the agent for the ambiguity path below.
891
900
  if exact_matches.len() == 1 {
892
901
  let candidate = exact_matches[0].clone();
893
- claimed.extend(captured_provider_session_keys(&candidate.captured));
902
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
894
903
  assignments.insert(item.agent_id.clone(), candidate);
895
904
  }
896
905
  }
@@ -903,8 +912,9 @@ fn allocate_session_candidates(
903
912
  candidates_by_agent.get(&item.agent_id),
904
913
  claimed,
905
914
  CandidateMatchKind::PositiveAgentId,
915
+ item,
906
916
  ) {
907
- claimed.extend(captured_provider_session_keys(&candidate.captured));
917
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
908
918
  assignments.insert(item.agent_id.clone(), candidate);
909
919
  }
910
920
  }
@@ -916,8 +926,9 @@ fn allocate_session_candidates(
916
926
  candidates_by_agent.get(&item.agent_id),
917
927
  claimed,
918
928
  CandidateMatchKind::PathAgentId,
929
+ item,
919
930
  ) {
920
- claimed.extend(captured_provider_session_keys(&candidate.captured));
931
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
921
932
  assignments.insert(item.agent_id.clone(), candidate);
922
933
  }
923
934
  }
@@ -953,9 +964,10 @@ fn allocate_session_candidates(
953
964
  candidates_by_agent.get(&item.agent_id),
954
965
  claimed,
955
966
  CandidateMatchKind::Any,
967
+ item,
956
968
  ) {
957
969
  Some(candidate) => {
958
- claimed.extend(captured_provider_session_keys(&candidate.captured));
970
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
959
971
  assignments.insert(item.agent_id.clone(), candidate);
960
972
  }
961
973
  None => {
@@ -995,11 +1007,14 @@ fn allocate_global_one_to_one(
995
1007
  let Some(agent_candidates) = candidates_by_agent.get(agent_id) else {
996
1008
  return;
997
1009
  };
1010
+ let Some(owner) = pending.iter().find(|item| item.agent_id == *agent_id) else {
1011
+ continue;
1012
+ };
998
1013
  for candidate in agent_candidates {
999
- if candidate_keys_collide(candidate, claimed) {
1014
+ if candidate_keys_collide(owner, candidate, claimed) {
1000
1015
  continue;
1001
1016
  }
1002
- let key = candidate_key(candidate);
1017
+ let key = candidate_key(owner, candidate);
1003
1018
  if key.is_empty() {
1004
1019
  continue;
1005
1020
  }
@@ -1010,7 +1025,9 @@ fn allocate_global_one_to_one(
1010
1025
  return;
1011
1026
  }
1012
1027
  for (agent_id, candidate) in remaining_agents.into_iter().zip(candidates.into_values()) {
1013
- claimed.extend(captured_provider_session_keys(&candidate.captured));
1028
+ if let Some(item) = pending.iter().find(|item| item.agent_id == agent_id) {
1029
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
1030
+ }
1014
1031
  assignments.insert(agent_id, candidate);
1015
1032
  }
1016
1033
  }
@@ -1035,6 +1052,7 @@ fn unique_available_candidate(
1035
1052
  candidates: Option<&Vec<CapturedSessionCandidate>>,
1036
1053
  claimed: &BTreeSet<String>,
1037
1054
  match_kind: CandidateMatchKind,
1055
+ owner: &PendingSessionCapture,
1038
1056
  ) -> Option<CapturedSessionCandidate> {
1039
1057
  let matches = candidates?
1040
1058
  .iter()
@@ -1043,7 +1061,7 @@ fn unique_available_candidate(
1043
1061
  CandidateMatchKind::PathAgentId => candidate.agent_path_match,
1044
1062
  CandidateMatchKind::Any => true,
1045
1063
  })
1046
- .filter(|candidate| !candidate_keys_collide(candidate, claimed))
1064
+ .filter(|candidate| !candidate_keys_collide(owner, candidate, claimed))
1047
1065
  .cloned()
1048
1066
  .collect::<Vec<_>>();
1049
1067
  if matches.len() == 1 {
@@ -1061,16 +1079,17 @@ enum CandidateMatchKind {
1061
1079
  }
1062
1080
 
1063
1081
  fn candidate_keys_collide(
1082
+ owner: &PendingSessionCapture,
1064
1083
  candidate: &CapturedSessionCandidate,
1065
1084
  claimed: &BTreeSet<String>,
1066
1085
  ) -> bool {
1067
- captured_provider_session_keys(&candidate.captured)
1086
+ captured_provider_session_keys(owner, &candidate.captured)
1068
1087
  .iter()
1069
1088
  .any(|key| claimed.contains(key))
1070
1089
  }
1071
1090
 
1072
- fn candidate_key(candidate: &CapturedSessionCandidate) -> String {
1073
- captured_provider_session_keys(&candidate.captured)
1091
+ fn candidate_key(owner: &PendingSessionCapture, candidate: &CapturedSessionCandidate) -> String {
1092
+ captured_provider_session_keys(owner, &candidate.captured)
1074
1093
  .into_iter()
1075
1094
  .collect::<Vec<_>>()
1076
1095
  .join("|")
@@ -1130,12 +1149,13 @@ fn claimed_provider_session_keys(
1130
1149
  pending_ids: &BTreeSet<String>,
1131
1150
  ) -> BTreeSet<String> {
1132
1151
  let mut keys = BTreeSet::new();
1152
+ let team_key = crate::state::projection::team_state_key(state);
1133
1153
  // 1. Non-pending worker sessions (existing behaviour).
1134
1154
  for (agent_id, agent) in agents {
1135
1155
  if pending_ids.contains(agent_id) {
1136
1156
  continue;
1137
1157
  }
1138
- push_provider_session_keys(&mut keys, agent);
1158
+ push_provider_session_keys(&mut keys, &team_key, agent_id, agent);
1139
1159
  }
1140
1160
  // 2. P0 (lane-046-capture-gap): leader anchor sessions. The leader's
1141
1161
  // own provider transcript must never be attributed to a worker. Scan
@@ -1152,14 +1172,39 @@ fn claimed_provider_session_keys(
1152
1172
  keys
1153
1173
  }
1154
1174
 
1155
- fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
1175
+ fn push_provider_session_keys(
1176
+ keys: &mut BTreeSet<String>,
1177
+ fallback_team_key: &str,
1178
+ fallback_agent_id: &str,
1179
+ value: &Value,
1180
+ ) {
1181
+ let provider = value
1182
+ .get("provider")
1183
+ .and_then(Value::as_str)
1184
+ .and_then(parse_provider)
1185
+ .unwrap_or(Provider::Fake);
1186
+ let team_key = value
1187
+ .get("owner_team_id")
1188
+ .or_else(|| value.get("team_key"))
1189
+ .and_then(Value::as_str)
1190
+ .filter(|s| !s.is_empty())
1191
+ .unwrap_or(fallback_team_key);
1192
+ let agent_id = value
1193
+ .get("agent_id")
1194
+ .and_then(Value::as_str)
1195
+ .filter(|s| !s.is_empty())
1196
+ .unwrap_or(fallback_agent_id);
1156
1197
  for field in ["session_id", "provider_session_id"] {
1157
1198
  if let Some(session_id) = value
1158
1199
  .get(field)
1159
1200
  .and_then(Value::as_str)
1160
1201
  .filter(|s| !s.is_empty())
1161
1202
  {
1162
- keys.insert(format!("session:{session_id}"));
1203
+ if let Some(key) = SessionAttributionKey::new(provider, team_key, agent_id, session_id)
1204
+ {
1205
+ keys.insert(session_attribution_key_string(&key));
1206
+ }
1207
+ keys.insert(global_session_attribution_key_string(session_id));
1163
1208
  }
1164
1209
  }
1165
1210
  for field in ["rollout_path", "transcript_path"] {
@@ -1168,7 +1213,10 @@ fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
1168
1213
  .and_then(Value::as_str)
1169
1214
  .filter(|s| !s.is_empty())
1170
1215
  {
1171
- keys.insert(format!("rollout:{path}"));
1216
+ keys.insert(transcript_attribution_key_string(
1217
+ provider, team_key, agent_id, path,
1218
+ ));
1219
+ keys.insert(global_transcript_attribution_key_string(path));
1172
1220
  }
1173
1221
  }
1174
1222
  }
@@ -1176,25 +1224,68 @@ fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
1176
1224
  fn push_leader_provider_session_keys(keys: &mut BTreeSet<String>, scope: &Value) {
1177
1225
  for anchor in ["leader_receiver", "team_owner"] {
1178
1226
  if let Some(node) = scope.get(anchor) {
1179
- push_provider_session_keys(keys, node);
1227
+ push_provider_session_keys(keys, "leader", "leader", node);
1180
1228
  }
1181
1229
  }
1182
1230
  }
1183
1231
 
1184
- fn captured_provider_session_keys(captured: &CapturedSession) -> BTreeSet<String> {
1232
+ fn captured_provider_session_keys(
1233
+ owner: &PendingSessionCapture,
1234
+ captured: &CapturedSession,
1235
+ ) -> BTreeSet<String> {
1185
1236
  let mut keys = BTreeSet::new();
1237
+ let team_key = owner.team_key.as_str();
1186
1238
  if let Some(session_id) = &captured.session_id {
1187
- keys.insert(format!("session:{}", session_id.as_str()));
1239
+ if let Some(key) = SessionAttributionKey::new(
1240
+ owner.provider,
1241
+ team_key,
1242
+ owner.agent_id.as_str(),
1243
+ session_id.as_str(),
1244
+ ) {
1245
+ keys.insert(session_attribution_key_string(&key));
1246
+ }
1247
+ keys.insert(global_session_attribution_key_string(session_id.as_str()));
1188
1248
  }
1189
1249
  if let Some(rollout_path) = &captured.rollout_path {
1190
- keys.insert(format!(
1191
- "rollout:{}",
1192
- rollout_path.as_path().to_string_lossy()
1250
+ let path = rollout_path.as_path().to_string_lossy();
1251
+ keys.insert(transcript_attribution_key_string(
1252
+ owner.provider,
1253
+ team_key,
1254
+ owner.agent_id.as_str(),
1255
+ &path,
1193
1256
  ));
1257
+ keys.insert(global_transcript_attribution_key_string(&path));
1194
1258
  }
1195
1259
  keys
1196
1260
  }
1197
1261
 
1262
+ fn session_attribution_key_string(key: &SessionAttributionKey) -> String {
1263
+ format!(
1264
+ "SessionAttributionKey(provider={:?},team={},agent={},session={})",
1265
+ key.provider(),
1266
+ key.team_key(),
1267
+ key.agent_id(),
1268
+ key.session_id()
1269
+ )
1270
+ }
1271
+
1272
+ fn transcript_attribution_key_string(
1273
+ provider: Provider,
1274
+ team_key: &str,
1275
+ agent_id: &str,
1276
+ path: &str,
1277
+ ) -> String {
1278
+ format!("TranscriptAttributionKey(provider={provider:?},team={team_key},agent={agent_id},path={path})")
1279
+ }
1280
+
1281
+ fn global_session_attribution_key_string(session_id: &str) -> String {
1282
+ format!("GlobalSessionAttributionKey(session={session_id})")
1283
+ }
1284
+
1285
+ fn global_transcript_attribution_key_string(path: &str) -> String {
1286
+ format!("GlobalTranscriptAttributionKey(path={path})")
1287
+ }
1288
+
1198
1289
  #[cfg(test)]
1199
1290
  pub(crate) mod test_support {
1200
1291
  use super::*;
@@ -14,10 +14,13 @@ pub(super) fn apply_spawned_at_filter(
14
14
  Some(p) => p.as_path(),
15
15
  None => return false,
16
16
  };
17
- std::fs::metadata(path)
18
- .and_then(|meta| meta.modified())
19
- .map(|mtime| mtime >= cutoff)
20
- .unwrap_or(false)
17
+ match codex_rollout_created_at(path) {
18
+ Some(created_at) => created_at >= cutoff,
19
+ None => std::fs::metadata(path)
20
+ .and_then(|meta| meta.modified())
21
+ .map(|mtime| mtime >= cutoff)
22
+ .unwrap_or(false),
23
+ }
21
24
  });
22
25
  }
23
26
 
@@ -27,6 +30,50 @@ pub(super) fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
27
30
  .map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
28
31
  }
29
32
 
33
+ fn codex_rollout_created_at(path: &std::path::Path) -> Option<std::time::SystemTime> {
34
+ created_at_from_rollout_head(path).or_else(|| created_at_from_rollout_filename(path))
35
+ }
36
+
37
+ fn created_at_from_rollout_head(path: &std::path::Path) -> Option<std::time::SystemTime> {
38
+ let text = super::common::read_head_text(path, super::common::CAPTURE_HEAD_BYTES).ok()?;
39
+ super::common::parse_session_records(&text)
40
+ .iter()
41
+ .find_map(record_created_at)
42
+ .and_then(|raw| parse_spawned_at(&raw))
43
+ }
44
+
45
+ fn record_created_at(record: &serde_json::Value) -> Option<String> {
46
+ record
47
+ .get("created_at")
48
+ .and_then(serde_json::Value::as_str)
49
+ .or_else(|| {
50
+ record
51
+ .get("session_meta")
52
+ .and_then(|v| v.get("payload"))
53
+ .or_else(|| record.get("payload"))
54
+ .and_then(|v| v.get("created_at"))
55
+ .and_then(serde_json::Value::as_str)
56
+ })
57
+ .map(ToString::to_string)
58
+ }
59
+
60
+ fn created_at_from_rollout_filename(path: &std::path::Path) -> Option<std::time::SystemTime> {
61
+ let name = path.file_name()?.to_str()?;
62
+ let start = name.find("rollout-")? + "rollout-".len();
63
+ let stamp = name.get(start..start + 19)?;
64
+ if stamp.as_bytes().get(10).copied() != Some(b'T') {
65
+ return None;
66
+ }
67
+ let raw = format!(
68
+ "{}T{}:{}:{}+00:00",
69
+ &stamp[0..10],
70
+ &stamp[11..13],
71
+ &stamp[14..16],
72
+ &stamp[17..19]
73
+ );
74
+ parse_spawned_at(&raw)
75
+ }
76
+
30
77
  #[cfg(test)]
31
78
  mod tests {
32
79
  use super::*;
@@ -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
+ }