@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +33 -11
- package/crates/team-agent/src/cli/status_port.rs +2 -2
- 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/launch.rs +63 -8
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +3 -13
- package/crates/team-agent/src/lifecycle/restart/agent.rs +11 -7
- package/crates/team-agent/src/lifecycle/restart/common.rs +49 -15
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +20 -5
- package/crates/team-agent/src/mcp_server/wire.rs +3 -1
- package/crates/team-agent/src/redaction.rs +185 -0
- package/crates/team-agent/src/state/repository.rs +15 -9
- package/crates/team-agent/src/tmux_backend/tests.rs +50 -8
- package/crates/team-agent/src/tmux_backend.rs +66 -35
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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
|
|
15
|
-
CmdOutput::Json(value)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
624
|
-
let
|
|
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, &
|
|
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::
|
|
4632
|
-
|
|
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::
|
|
4714
|
-
|
|
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
|
-
|
|
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, "[
|
|
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
|
-
|
|
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()
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
319
|
-
// lifecycle
|
|
320
|
-
StateWriteIntent::ForkAgent { .. } =>
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
1747
|
-
//
|
|
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}
|
|
1753
|
-
let stdout = "%
|
|
1754
|
-
%
|
|
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
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
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",
|