@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +33 -11
- package/crates/team-agent/src/cli/mod.rs +6 -0
- package/crates/team-agent/src/cli/status_port.rs +72 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +14 -22
- package/crates/team-agent/src/event_log.rs +3 -3
- package/crates/team-agent/src/lib.rs +1 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +3 -13
- package/crates/team-agent/src/lifecycle/restart/agent.rs +103 -27
- package/crates/team-agent/src/lifecycle/restart/common.rs +136 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +168 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -7
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +3 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +10 -0
- package/crates/team-agent/src/mcp_server/wire.rs +3 -1
- package/crates/team-agent/src/provider/session/capture.rs +113 -22
- package/crates/team-agent/src/provider/session_scan/codex.rs +51 -4
- package/crates/team-agent/src/redaction.rs +185 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +116 -10
- package/crates/team-agent/src/tmux_backend.rs +6 -16
- package/crates/team-agent/src/transport/tests/wire.rs +6 -0
- package/crates/team-agent/src/transport.rs +8 -2
- package/package.json +4 -4
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
}
|