@team-agent/installer 0.5.77 → 0.5.78
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/adapters.rs +161 -29
- package/crates/team-agent/src/cli/diagnose.rs +333 -68
- package/crates/team-agent/src/cli/emit.rs +24 -8
- package/crates/team-agent/src/cli/mod.rs +543 -98
- package/crates/team-agent/src/cli/models.rs +362 -32
- package/crates/team-agent/src/cli/named_address.rs +19 -5
- package/crates/team-agent/src/cli/profile.rs +35 -4
- package/crates/team-agent/src/cli/spec.rs +7 -2
- package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
- package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
- package/crates/team-agent/src/cli/status_port/store.rs +1 -1
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
- package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
- package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/leader/lease.rs +492 -33
- package/crates/team-agent/src/leader/owner_bind.rs +40 -12
- package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
- package/crates/team-agent/src/leader/rediscover.rs +36 -6
- package/crates/team-agent/src/leader/start.rs +151 -14
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +65 -18
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
- package/crates/team-agent/src/lifecycle/launch.rs +30 -7
- package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
- package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
- package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
- package/crates/team-agent/src/lifecycle/restart.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
- package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
- package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
- package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
- package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
- package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
- package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
- package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
- package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
- package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/wire.rs +140 -11
- package/crates/team-agent/src/messaging/delivery.rs +67 -8
- package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
- package/crates/team-agent/src/state/persist.rs +97 -1
- package/crates/team-agent/src/state/repository.rs +29 -0
- package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
- package/crates/team-agent/src/tmux_backend/tests.rs +121 -4
- package/crates/team-agent/src/tmux_backend.rs +208 -118
- package/crates/team-agent/src/transport.rs +72 -0
- package/crates/team-agent/src/transport_factory.rs +53 -0
- package/package.json +4 -5
- package/skills/team-agent/SKILL.md +45 -17
- package/skills/team-agent/command-coverage.json +4 -29
- package/npm/check-skill-links.mjs +0 -88
- package/skills/team-agent/references/team-agent-operator.md +0 -672
|
@@ -97,6 +97,23 @@ pub fn attach_leader(
|
|
|
97
97
|
"tmux pane not found: {pane_id}"
|
|
98
98
|
)));
|
|
99
99
|
};
|
|
100
|
+
if let Some(bound) = live_bound_pane_blocking_other_attach(&state, &pane_id, &targets) {
|
|
101
|
+
let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
|
|
102
|
+
emit_lease_refusal(
|
|
103
|
+
&event_log,
|
|
104
|
+
LeaseReason::PreviousOwnerAliveRefused,
|
|
105
|
+
&state,
|
|
106
|
+
Some(bound.as_str()),
|
|
107
|
+
Some(pane_id.as_str()),
|
|
108
|
+
&team_id,
|
|
109
|
+
)?;
|
|
110
|
+
return Ok(refused(
|
|
111
|
+
LeaseReason::PreviousOwnerAliveRefused,
|
|
112
|
+
"team already has a live owner pane; do not attach-leader from another pane",
|
|
113
|
+
Some(current_owner_epoch(&state)),
|
|
114
|
+
Some(bound),
|
|
115
|
+
));
|
|
116
|
+
}
|
|
100
117
|
let mut receiver = receiver_for_attach_target(
|
|
101
118
|
workspace,
|
|
102
119
|
&state,
|
|
@@ -107,7 +124,23 @@ pub fn attach_leader(
|
|
|
107
124
|
if let Some(endpoint) = target.endpoint.as_ref() {
|
|
108
125
|
receiver.tmux_socket = Some(endpoint.clone());
|
|
109
126
|
}
|
|
110
|
-
let validation = validate_attach_target(
|
|
127
|
+
let validation = validate_attach_target(
|
|
128
|
+
workspace,
|
|
129
|
+
&state,
|
|
130
|
+
&target.info,
|
|
131
|
+
provider,
|
|
132
|
+
target.endpoint.as_deref(),
|
|
133
|
+
);
|
|
134
|
+
if validation.is_ok() {
|
|
135
|
+
copy_verified_grant_from_state(
|
|
136
|
+
workspace,
|
|
137
|
+
&state,
|
|
138
|
+
&target.info,
|
|
139
|
+
provider,
|
|
140
|
+
target.endpoint.as_deref(),
|
|
141
|
+
&mut receiver,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
111
144
|
if validation.is_err() {
|
|
112
145
|
let pane_info = pane_info_value(&target.info);
|
|
113
146
|
let targets_value = Value::Array(
|
|
@@ -344,47 +377,45 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
|
|
|
344
377
|
// leader pane. Keep state-recorded worker endpoints as additional targets,
|
|
345
378
|
// but do not let a cross-socket pane-id collision hide the caller pane.
|
|
346
379
|
if let Some(endpoint) = crate::tmux_backend::socket_name_from_tmux_env() {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
backend
|
|
351
|
-
.list_targets()
|
|
352
|
-
.unwrap_or_default()
|
|
353
|
-
.into_iter()
|
|
354
|
-
.map(|info| AttachLeaderTarget {
|
|
355
|
-
info,
|
|
356
|
-
endpoint: resolved_endpoint.clone(),
|
|
357
|
-
}),
|
|
380
|
+
extend_attach_leader_targets(
|
|
381
|
+
&mut targets,
|
|
382
|
+
crate::transport_factory::leader_endpoint_transport(&endpoint).as_ref(),
|
|
358
383
|
);
|
|
359
384
|
}
|
|
360
385
|
for endpoint in state_recorded_tmux_endpoints(state) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
backend
|
|
365
|
-
.list_targets()
|
|
366
|
-
.unwrap_or_default()
|
|
367
|
-
.into_iter()
|
|
368
|
-
.map(|info| AttachLeaderTarget {
|
|
369
|
-
info,
|
|
370
|
-
endpoint: resolved_endpoint.clone(),
|
|
371
|
-
}),
|
|
386
|
+
extend_attach_leader_targets(
|
|
387
|
+
&mut targets,
|
|
388
|
+
crate::transport_factory::leader_endpoint_transport(&endpoint).as_ref(),
|
|
372
389
|
);
|
|
373
390
|
}
|
|
374
391
|
// Phase 1d Batch 6: factory tmux workspace helper for
|
|
375
392
|
// grep-visibility. Semantics unchanged; leader lease discovery is
|
|
376
393
|
// tmux-only (caller pane = tmux pane, MUST-12 anchor).
|
|
394
|
+
let workspace_backend = crate::transport_factory::tmux_workspace_transport(workspace);
|
|
395
|
+
let workspace_endpoint = workspace_backend.tmux_endpoint();
|
|
396
|
+
let workspace_transport: Box<dyn Transport> = match workspace_endpoint
|
|
397
|
+
.as_deref()
|
|
398
|
+
.filter(|endpoint| !endpoint.is_empty() && *endpoint != "default")
|
|
399
|
+
{
|
|
400
|
+
Some(endpoint) => crate::transport_factory::leader_endpoint_transport(endpoint),
|
|
401
|
+
None => Box::new(workspace_backend),
|
|
402
|
+
};
|
|
403
|
+
extend_attach_leader_targets(&mut targets, workspace_transport.as_ref());
|
|
404
|
+
targets
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
fn extend_attach_leader_targets(targets: &mut Vec<AttachLeaderTarget>, transport: &dyn Transport) {
|
|
408
|
+
let resolved_endpoint = transport.tmux_endpoint();
|
|
377
409
|
targets.extend(
|
|
378
|
-
|
|
410
|
+
transport
|
|
379
411
|
.list_targets()
|
|
380
412
|
.unwrap_or_default()
|
|
381
413
|
.into_iter()
|
|
382
414
|
.map(|info| AttachLeaderTarget {
|
|
383
415
|
info,
|
|
384
|
-
endpoint:
|
|
416
|
+
endpoint: resolved_endpoint.clone(),
|
|
385
417
|
}),
|
|
386
418
|
);
|
|
387
|
-
targets
|
|
388
419
|
}
|
|
389
420
|
|
|
390
421
|
fn tmux_backend_for_endpoint(endpoint: &str) -> crate::tmux_backend::TmuxBackend {
|
|
@@ -1529,6 +1560,22 @@ fn bound_pane(state: &Value) -> Option<String> {
|
|
|
1529
1560
|
.or_else(|| get_path_str(state, &["team_owner", "pane_id"]).filter(|v| !v.is_empty()))
|
|
1530
1561
|
}
|
|
1531
1562
|
|
|
1563
|
+
/// Live owner on this socket, different caller pane → ownership conflict,
|
|
1564
|
+
/// not "caller pane failed validation". Presence in list-panes is enough;
|
|
1565
|
+
/// do not require the owner pane to be the session's active pane.
|
|
1566
|
+
fn live_bound_pane_blocking_other_attach(
|
|
1567
|
+
state: &Value,
|
|
1568
|
+
caller: &PaneId,
|
|
1569
|
+
targets: &[AttachLeaderTarget],
|
|
1570
|
+
) -> Option<PaneId> {
|
|
1571
|
+
let bound = bound_pane(state)?;
|
|
1572
|
+
if bound == caller.as_str() {
|
|
1573
|
+
return None;
|
|
1574
|
+
}
|
|
1575
|
+
let live = targets.iter().any(|target| target.info.pane_id.as_str() == bound);
|
|
1576
|
+
live.then(|| PaneId::new(bound))
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1532
1579
|
fn bound_endpoint_matches_current_process(state: &Value) -> bool {
|
|
1533
1580
|
let Some(bound) =
|
|
1534
1581
|
get_path_str(state, &["leader_receiver", "tmux_socket"]).filter(|v| !v.is_empty())
|
|
@@ -1704,8 +1751,16 @@ fn claim_target_from_pane_info(target: &PaneInfo) -> Option<LeaderClaimTarget> {
|
|
|
1704
1751
|
if !target.active {
|
|
1705
1752
|
return None;
|
|
1706
1753
|
}
|
|
1754
|
+
claim_target_from_observed_pane(target)
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
fn claim_target_from_observed_pane(target: &PaneInfo) -> Option<LeaderClaimTarget> {
|
|
1707
1758
|
let provider = super::attribute_pane_provider(target)?;
|
|
1708
|
-
Some(
|
|
1759
|
+
Some(claim_target_with_provider(target, provider))
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
fn claim_target_with_provider(target: &PaneInfo, provider: Provider) -> LeaderClaimTarget {
|
|
1763
|
+
LeaderClaimTarget {
|
|
1709
1764
|
provider,
|
|
1710
1765
|
leader_session_uuid: target_leader_session_uuid(target),
|
|
1711
1766
|
team_id: target
|
|
@@ -1718,7 +1773,7 @@ fn claim_target_from_pane_info(target: &PaneInfo) -> Option<LeaderClaimTarget> {
|
|
|
1718
1773
|
scope_authority: None,
|
|
1719
1774
|
authorized_team_workspace: None,
|
|
1720
1775
|
binding_nonce: None,
|
|
1721
|
-
}
|
|
1776
|
+
}
|
|
1722
1777
|
}
|
|
1723
1778
|
|
|
1724
1779
|
fn workspace_claim_target_from_pane_info(
|
|
@@ -1729,7 +1784,7 @@ fn workspace_claim_target_from_pane_info(
|
|
|
1729
1784
|
if !crate::state::owner_gate::workspace_paths_match(current_path, workspace) {
|
|
1730
1785
|
return None;
|
|
1731
1786
|
}
|
|
1732
|
-
|
|
1787
|
+
claim_target_from_observed_pane(target)
|
|
1733
1788
|
}
|
|
1734
1789
|
|
|
1735
1790
|
fn write_claim_target_pane_nonce(target: &mut LeaderClaimTarget) -> Result<(), LeaderError> {
|
|
@@ -1825,6 +1880,7 @@ fn validate_attach_target(
|
|
|
1825
1880
|
state: &Value,
|
|
1826
1881
|
target: &PaneInfo,
|
|
1827
1882
|
requested_provider: Provider,
|
|
1883
|
+
endpoint: Option<&str>,
|
|
1828
1884
|
) -> Result<(), &'static str> {
|
|
1829
1885
|
// 0.5.9 (E6 real-machine e2e wiring): an explicit `--provider fake`
|
|
1830
1886
|
// is the operator's declaration that this pane is a fake-provider
|
|
@@ -1837,13 +1893,28 @@ fn validate_attach_target(
|
|
|
1837
1893
|
// provider list, only wired in test/fixture flows.
|
|
1838
1894
|
let claim_target = match workspace_claim_target_from_pane_info(workspace, target) {
|
|
1839
1895
|
Some(target) => Some(target),
|
|
1896
|
+
None if grant_allows_attach_target(workspace, state, target, requested_provider, endpoint)
|
|
1897
|
+
|| recorded_owner_live_facts_match(
|
|
1898
|
+
workspace,
|
|
1899
|
+
state,
|
|
1900
|
+
target,
|
|
1901
|
+
requested_provider,
|
|
1902
|
+
endpoint,
|
|
1903
|
+
) =>
|
|
1904
|
+
{
|
|
1905
|
+
Some(
|
|
1906
|
+
claim_target_from_observed_pane(target)
|
|
1907
|
+
.unwrap_or_else(|| claim_target_with_provider(target, requested_provider)),
|
|
1908
|
+
)
|
|
1909
|
+
}
|
|
1840
1910
|
None if matches!(requested_provider, Provider::Fake) => None,
|
|
1841
1911
|
None => return Err("leader_pane_validation_failed"),
|
|
1842
1912
|
};
|
|
1843
1913
|
let Some(claim_target) = claim_target else {
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1914
|
+
if matches!(requested_provider, Provider::Fake) {
|
|
1915
|
+
return Ok(());
|
|
1916
|
+
}
|
|
1917
|
+
return Err("leader_pane_validation_failed");
|
|
1847
1918
|
};
|
|
1848
1919
|
let recorded_uuid = get_path_str(state, &["team_owner", "leader_session_uuid"])
|
|
1849
1920
|
.or_else(|| get_path_str(state, &["leader_receiver", "leader_session_uuid"]));
|
|
@@ -1881,6 +1952,161 @@ fn receiver_for_attach_target(
|
|
|
1881
1952
|
))
|
|
1882
1953
|
}
|
|
1883
1954
|
|
|
1955
|
+
fn selected_grant_receiver<'a>(state: &'a Value) -> Option<&'a Value> {
|
|
1956
|
+
let key = crate::state::projection::team_state_key(state);
|
|
1957
|
+
crate::lifecycle::launch::selected_team_leader_receiver(state, &key)
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
pub(crate) fn grant_allows_attach_target(
|
|
1961
|
+
workspace: &Path,
|
|
1962
|
+
state: &Value,
|
|
1963
|
+
target: &PaneInfo,
|
|
1964
|
+
requested_provider: Provider,
|
|
1965
|
+
endpoint: Option<&str>,
|
|
1966
|
+
) -> bool {
|
|
1967
|
+
let Some(receiver) = selected_grant_receiver(state) else {
|
|
1968
|
+
return false;
|
|
1969
|
+
};
|
|
1970
|
+
let authority = receiver
|
|
1971
|
+
.get("scope_authority")
|
|
1972
|
+
.and_then(Value::as_str)
|
|
1973
|
+
.unwrap_or("");
|
|
1974
|
+
if authority != "explicit_claim" && authority != "fresh_caller" {
|
|
1975
|
+
return false;
|
|
1976
|
+
}
|
|
1977
|
+
let Some(authorized) = receiver
|
|
1978
|
+
.get("authorized_team_workspace")
|
|
1979
|
+
.and_then(Value::as_str)
|
|
1980
|
+
.filter(|raw| !raw.is_empty())
|
|
1981
|
+
else {
|
|
1982
|
+
return false;
|
|
1983
|
+
};
|
|
1984
|
+
if !crate::state::owner_gate::workspace_paths_match(Path::new(authorized), workspace) {
|
|
1985
|
+
return false;
|
|
1986
|
+
}
|
|
1987
|
+
let Some(recorded_nonce) = receiver
|
|
1988
|
+
.get("binding_nonce")
|
|
1989
|
+
.and_then(Value::as_str)
|
|
1990
|
+
.filter(|raw| !raw.is_empty())
|
|
1991
|
+
else {
|
|
1992
|
+
return false;
|
|
1993
|
+
};
|
|
1994
|
+
let live_nonce = target
|
|
1995
|
+
.leader_env
|
|
1996
|
+
.get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
|
|
1997
|
+
.map(String::as_str);
|
|
1998
|
+
let pane_ok = receiver.get("pane_id").and_then(Value::as_str) == Some(target.pane_id.as_str());
|
|
1999
|
+
let socket_ok = match (receiver.get("tmux_socket").and_then(Value::as_str), endpoint) {
|
|
2000
|
+
(Some(recorded), Some(actual)) => recorded == actual,
|
|
2001
|
+
(None, _) | (_, None) => false,
|
|
2002
|
+
};
|
|
2003
|
+
let provider_ok = receiver
|
|
2004
|
+
.get("provider")
|
|
2005
|
+
.and_then(Value::as_str)
|
|
2006
|
+
.and_then(crate::provider::wire::parse_provider)
|
|
2007
|
+
== Some(requested_provider);
|
|
2008
|
+
let owner_epoch = get_path_u64(state, &["team_owner", "owner_epoch"])
|
|
2009
|
+
.or_else(|| get_path_u64(state, &["leader_receiver", "owner_epoch"]));
|
|
2010
|
+
let receiver_epoch = receiver.get("owner_epoch").and_then(Value::as_u64);
|
|
2011
|
+
let epoch_ok = match (owner_epoch, receiver_epoch) {
|
|
2012
|
+
(Some(owner), Some(recv)) => owner == recv && owner > 0,
|
|
2013
|
+
_ => false,
|
|
2014
|
+
};
|
|
2015
|
+
pane_ok && socket_ok && live_nonce == Some(recorded_nonce) && provider_ok && epoch_ok
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
fn recorded_owner_live_facts_match(
|
|
2019
|
+
workspace: &Path,
|
|
2020
|
+
state: &Value,
|
|
2021
|
+
target: &PaneInfo,
|
|
2022
|
+
requested_provider: Provider,
|
|
2023
|
+
endpoint: Option<&str>,
|
|
2024
|
+
) -> bool {
|
|
2025
|
+
let current_path = target.current_path.as_deref();
|
|
2026
|
+
let Some(current_path) = current_path else {
|
|
2027
|
+
return false;
|
|
2028
|
+
};
|
|
2029
|
+
if !crate::state::owner_gate::workspace_paths_match(current_path, workspace) {
|
|
2030
|
+
return false;
|
|
2031
|
+
}
|
|
2032
|
+
let receiver = selected_grant_receiver(state);
|
|
2033
|
+
let recorded_pane = get_path_str(state, &["team_owner", "pane_id"])
|
|
2034
|
+
.or_else(|| get_path_str(state, &["leader_receiver", "pane_id"]))
|
|
2035
|
+
.or_else(|| {
|
|
2036
|
+
receiver
|
|
2037
|
+
.and_then(|value| value.get("pane_id"))
|
|
2038
|
+
.and_then(Value::as_str)
|
|
2039
|
+
.map(str::to_string)
|
|
2040
|
+
});
|
|
2041
|
+
if recorded_pane.as_deref() != Some(target.pane_id.as_str()) {
|
|
2042
|
+
return false;
|
|
2043
|
+
}
|
|
2044
|
+
let recorded_socket = get_path_str(state, &["leader_receiver", "tmux_socket"]).or_else(|| {
|
|
2045
|
+
receiver
|
|
2046
|
+
.and_then(|value| value.get("tmux_socket"))
|
|
2047
|
+
.and_then(Value::as_str)
|
|
2048
|
+
.map(str::to_string)
|
|
2049
|
+
});
|
|
2050
|
+
match (recorded_socket.as_deref(), endpoint) {
|
|
2051
|
+
(Some(recorded), Some(actual)) if recorded == actual => {}
|
|
2052
|
+
_ => return false,
|
|
2053
|
+
}
|
|
2054
|
+
let recorded_provider = get_path_str(state, &["team_owner", "provider"])
|
|
2055
|
+
.or_else(|| get_path_str(state, &["leader_receiver", "provider"]))
|
|
2056
|
+
.or_else(|| {
|
|
2057
|
+
receiver
|
|
2058
|
+
.and_then(|value| value.get("provider"))
|
|
2059
|
+
.and_then(Value::as_str)
|
|
2060
|
+
.map(str::to_string)
|
|
2061
|
+
})
|
|
2062
|
+
.and_then(|raw| parse_provider(&raw));
|
|
2063
|
+
if recorded_provider != Some(requested_provider) {
|
|
2064
|
+
return false;
|
|
2065
|
+
}
|
|
2066
|
+
let recorded_uuid = get_path_str(state, &["team_owner", "leader_session_uuid"])
|
|
2067
|
+
.or_else(|| get_path_str(state, &["leader_receiver", "leader_session_uuid"]));
|
|
2068
|
+
let Some(recorded_uuid) = recorded_uuid.filter(|raw| !raw.is_empty()) else {
|
|
2069
|
+
return false;
|
|
2070
|
+
};
|
|
2071
|
+
match target_leader_session_uuid(target) {
|
|
2072
|
+
Some(live_uuid) => live_uuid.as_str() == recorded_uuid,
|
|
2073
|
+
None => true,
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
fn copy_verified_grant_from_state(
|
|
2078
|
+
workspace: &Path,
|
|
2079
|
+
state: &Value,
|
|
2080
|
+
target: &PaneInfo,
|
|
2081
|
+
provider: Provider,
|
|
2082
|
+
endpoint: Option<&str>,
|
|
2083
|
+
receiver: &mut LeaderReceiver,
|
|
2084
|
+
) {
|
|
2085
|
+
if !grant_allows_attach_target(workspace, state, target, provider, endpoint) {
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
let Some(existing) = selected_grant_receiver(state) else {
|
|
2089
|
+
return;
|
|
2090
|
+
};
|
|
2091
|
+
let authority = existing
|
|
2092
|
+
.get("scope_authority")
|
|
2093
|
+
.and_then(Value::as_str)
|
|
2094
|
+
.filter(|raw| *raw == "explicit_claim" || *raw == "fresh_caller");
|
|
2095
|
+
let nonce = existing
|
|
2096
|
+
.get("binding_nonce")
|
|
2097
|
+
.and_then(Value::as_str)
|
|
2098
|
+
.filter(|raw| !raw.is_empty());
|
|
2099
|
+
let authorized = existing
|
|
2100
|
+
.get("authorized_team_workspace")
|
|
2101
|
+
.and_then(Value::as_str)
|
|
2102
|
+
.filter(|raw| !raw.is_empty());
|
|
2103
|
+
if let (Some(authority), Some(nonce), Some(authorized)) = (authority, nonce, authorized) {
|
|
2104
|
+
receiver.scope_authority = Some(authority.to_string());
|
|
2105
|
+
receiver.binding_nonce = Some(nonce.to_string());
|
|
2106
|
+
receiver.authorized_team_workspace = Some(PathBuf::from(authorized));
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
|
|
1884
2110
|
fn pane_info_value(target: &PaneInfo) -> Value {
|
|
1885
2111
|
let leader_env = target
|
|
1886
2112
|
.leader_env
|
|
@@ -2035,7 +2261,7 @@ fn authorize_fresh_caller_receiver(
|
|
|
2035
2261
|
let Some(target) = caller_target else {
|
|
2036
2262
|
return Ok(());
|
|
2037
2263
|
};
|
|
2038
|
-
if target.pane_id.as_str() != pane.as_pane_id().as_str()
|
|
2264
|
+
if target.pane_id.as_str() != pane.as_pane_id().as_str() {
|
|
2039
2265
|
return Err(LeaderError::Validation(
|
|
2040
2266
|
"fresh caller pane observation does not match the bound pane".to_string(),
|
|
2041
2267
|
));
|
|
@@ -2911,4 +3137,237 @@ mod tests {
|
|
|
2911
3137
|
Some(absolute)
|
|
2912
3138
|
);
|
|
2913
3139
|
}
|
|
3140
|
+
|
|
3141
|
+
fn grant_state(endpoint: &str, nonce: &str, epoch: u64, provider: &str) -> Value {
|
|
3142
|
+
json!({
|
|
3143
|
+
"team_owner": {
|
|
3144
|
+
"pane_id": "%1",
|
|
3145
|
+
"provider": provider,
|
|
3146
|
+
"owner_epoch": epoch
|
|
3147
|
+
},
|
|
3148
|
+
"leader_receiver": {
|
|
3149
|
+
"status": "attached",
|
|
3150
|
+
"pane_id": "%1",
|
|
3151
|
+
"provider": provider,
|
|
3152
|
+
"owner_epoch": epoch,
|
|
3153
|
+
"tmux_socket": endpoint,
|
|
3154
|
+
"scope_authority": "fresh_caller",
|
|
3155
|
+
"authorized_team_workspace": "/tmp/grant-ws",
|
|
3156
|
+
"binding_nonce": nonce
|
|
3157
|
+
}
|
|
3158
|
+
})
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
fn grant_pane(nonce: &str) -> PaneInfo {
|
|
3162
|
+
let mut leader_env = std::collections::BTreeMap::new();
|
|
3163
|
+
leader_env.insert(
|
|
3164
|
+
crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY.to_string(),
|
|
3165
|
+
nonce.to_string(),
|
|
3166
|
+
);
|
|
3167
|
+
PaneInfo {
|
|
3168
|
+
pane_id: PaneId::new("%1"),
|
|
3169
|
+
session: crate::transport::SessionName::new("s"),
|
|
3170
|
+
window_index: None,
|
|
3171
|
+
window_name: None,
|
|
3172
|
+
pane_index: None,
|
|
3173
|
+
tty: None,
|
|
3174
|
+
current_command: Some("codex".to_string()),
|
|
3175
|
+
current_path: Some(PathBuf::from("/tmp/grant-ws")),
|
|
3176
|
+
active: true,
|
|
3177
|
+
pane_pid: None,
|
|
3178
|
+
leader_env,
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
#[test]
|
|
3183
|
+
#[serial_test::serial(env)]
|
|
3184
|
+
fn grant_reuses_verified_endpoint() {
|
|
3185
|
+
let workspace = PathBuf::from("/tmp/grant-ws");
|
|
3186
|
+
let state = grant_state("/tmp/leader.sock", "n1", 2, "codex");
|
|
3187
|
+
let pane = grant_pane("n1");
|
|
3188
|
+
let transport = crate::transport::test_support::OfflineTransport::default()
|
|
3189
|
+
.with_tmux_endpoint("/tmp/leader.sock")
|
|
3190
|
+
.with_targets(vec![pane.clone()]);
|
|
3191
|
+
crate::transport_factory::with_leader_endpoint_transport(
|
|
3192
|
+
"/tmp/leader.sock",
|
|
3193
|
+
transport,
|
|
3194
|
+
|| {
|
|
3195
|
+
let targets = attach_leader_targets(&workspace, &state);
|
|
3196
|
+
let target = targets
|
|
3197
|
+
.iter()
|
|
3198
|
+
.find(|target| {
|
|
3199
|
+
target.info.pane_id.as_str() == "%1"
|
|
3200
|
+
&& target.endpoint.as_deref() == Some("/tmp/leader.sock")
|
|
3201
|
+
})
|
|
3202
|
+
.expect("attach_leader_targets must discover the recorded endpoint");
|
|
3203
|
+
assert!(grant_allows_attach_target(
|
|
3204
|
+
&workspace,
|
|
3205
|
+
&state,
|
|
3206
|
+
&target.info,
|
|
3207
|
+
crate::provider::Provider::Codex,
|
|
3208
|
+
target.endpoint.as_deref(),
|
|
3209
|
+
));
|
|
3210
|
+
let mut receiver = crate::leader::LeaderReceiver {
|
|
3211
|
+
mode: crate::leader::ReceiverMode::DirectTmux,
|
|
3212
|
+
status: crate::leader::ReceiverStatus::Attached,
|
|
3213
|
+
provider: crate::provider::Provider::Codex,
|
|
3214
|
+
pane_id: PaneId::new("%1"),
|
|
3215
|
+
session_name: None,
|
|
3216
|
+
window_index: None,
|
|
3217
|
+
window_name: None,
|
|
3218
|
+
pane_index: None,
|
|
3219
|
+
pane_tty: None,
|
|
3220
|
+
pane_current_command: None,
|
|
3221
|
+
tmux_socket: None,
|
|
3222
|
+
scope_authority: None,
|
|
3223
|
+
authorized_team_workspace: None,
|
|
3224
|
+
binding_nonce: None,
|
|
3225
|
+
fingerprint: None,
|
|
3226
|
+
leader_session_uuid: None,
|
|
3227
|
+
owner_epoch: None,
|
|
3228
|
+
attached_at: None,
|
|
3229
|
+
discovery: None,
|
|
3230
|
+
requested_provider: None,
|
|
3231
|
+
warning: None,
|
|
3232
|
+
};
|
|
3233
|
+
copy_verified_grant_from_state(
|
|
3234
|
+
&workspace,
|
|
3235
|
+
&state,
|
|
3236
|
+
&target.info,
|
|
3237
|
+
crate::provider::Provider::Codex,
|
|
3238
|
+
target.endpoint.as_deref(),
|
|
3239
|
+
&mut receiver,
|
|
3240
|
+
);
|
|
3241
|
+
assert_eq!(receiver.binding_nonce.as_deref(), Some("n1"));
|
|
3242
|
+
assert_eq!(receiver.scope_authority.as_deref(), Some("fresh_caller"));
|
|
3243
|
+
},
|
|
3244
|
+
);
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
#[test]
|
|
3248
|
+
fn claim_leader_discovery_still_requires_focus() {
|
|
3249
|
+
let mut pane = grant_pane("n1");
|
|
3250
|
+
pane.active = false;
|
|
3251
|
+
pane.current_command = Some("codex".to_string());
|
|
3252
|
+
assert!(
|
|
3253
|
+
claim_target_from_pane_info(&pane).is_none(),
|
|
3254
|
+
"claim-leader discovery must keep pane_active; attach validation uses observed pane"
|
|
3255
|
+
);
|
|
3256
|
+
assert!(claim_target_from_observed_pane(&pane).is_some());
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
#[test]
|
|
3260
|
+
fn grant_rejects_wrong_socket_same_pane() {
|
|
3261
|
+
let workspace = PathBuf::from("/tmp/grant-ws");
|
|
3262
|
+
let state = grant_state("/tmp/leader.sock", "n1", 2, "codex");
|
|
3263
|
+
let pane = grant_pane("n1");
|
|
3264
|
+
assert!(!grant_allows_attach_target(
|
|
3265
|
+
&workspace,
|
|
3266
|
+
&state,
|
|
3267
|
+
&pane,
|
|
3268
|
+
crate::provider::Provider::Codex,
|
|
3269
|
+
Some("/tmp/other.sock"),
|
|
3270
|
+
));
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
#[test]
|
|
3274
|
+
fn grant_rejects_missing_epoch() {
|
|
3275
|
+
let workspace = PathBuf::from("/tmp/grant-ws");
|
|
3276
|
+
let mut state = grant_state("/tmp/leader.sock", "n1", 2, "codex");
|
|
3277
|
+
state["leader_receiver"]
|
|
3278
|
+
.as_object_mut()
|
|
3279
|
+
.unwrap()
|
|
3280
|
+
.remove("owner_epoch");
|
|
3281
|
+
let pane = grant_pane("n1");
|
|
3282
|
+
assert!(!grant_allows_attach_target(
|
|
3283
|
+
&workspace,
|
|
3284
|
+
&state,
|
|
3285
|
+
&pane,
|
|
3286
|
+
crate::provider::Provider::Codex,
|
|
3287
|
+
Some("/tmp/leader.sock"),
|
|
3288
|
+
));
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
#[test]
|
|
3292
|
+
fn grant_rejects_wrong_nonce_and_provider() {
|
|
3293
|
+
let workspace = PathBuf::from("/tmp/grant-ws");
|
|
3294
|
+
let state = grant_state("/tmp/leader.sock", "n1", 2, "codex");
|
|
3295
|
+
let pane = grant_pane("other-nonce");
|
|
3296
|
+
assert!(!grant_allows_attach_target(
|
|
3297
|
+
&workspace,
|
|
3298
|
+
&state,
|
|
3299
|
+
&pane,
|
|
3300
|
+
crate::provider::Provider::Codex,
|
|
3301
|
+
Some("/tmp/leader.sock"),
|
|
3302
|
+
));
|
|
3303
|
+
let pane = grant_pane("n1");
|
|
3304
|
+
assert!(!grant_allows_attach_target(
|
|
3305
|
+
&workspace,
|
|
3306
|
+
&state,
|
|
3307
|
+
&pane,
|
|
3308
|
+
crate::provider::Provider::Copilot,
|
|
3309
|
+
Some("/tmp/leader.sock"),
|
|
3310
|
+
));
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
fn listed(pane: &str) -> AttachLeaderTarget {
|
|
3314
|
+
AttachLeaderTarget {
|
|
3315
|
+
info: PaneInfo {
|
|
3316
|
+
pane_id: PaneId::new(pane),
|
|
3317
|
+
session: crate::transport::SessionName::new("leader"),
|
|
3318
|
+
window_index: None,
|
|
3319
|
+
window_name: None,
|
|
3320
|
+
pane_index: None,
|
|
3321
|
+
tty: None,
|
|
3322
|
+
current_command: None,
|
|
3323
|
+
current_path: None,
|
|
3324
|
+
active: pane == "%6",
|
|
3325
|
+
pane_pid: None,
|
|
3326
|
+
leader_env: std::collections::BTreeMap::new(),
|
|
3327
|
+
},
|
|
3328
|
+
endpoint: Some("/tmp/ta.sock".to_string()),
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
#[test]
|
|
3333
|
+
fn live_owner_blocks_other_pane_even_if_owner_is_not_active() {
|
|
3334
|
+
// R3 same-socket: owner %0 exists (pane_dead=0) but is not the
|
|
3335
|
+
// session's active pane; caller %6 must not skip to pane validation.
|
|
3336
|
+
let state = serde_json::json!({
|
|
3337
|
+
"leader_receiver": {"pane_id": "%0", "owner_epoch": 1},
|
|
3338
|
+
"team_owner": {"pane_id": "%0", "owner_epoch": 1},
|
|
3339
|
+
});
|
|
3340
|
+
let targets = vec![listed("%0"), listed("%6")];
|
|
3341
|
+
let blocked =
|
|
3342
|
+
live_bound_pane_blocking_other_attach(&state, &PaneId::new("%6"), &targets);
|
|
3343
|
+
assert_eq!(blocked, Some(PaneId::new("%0")));
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
#[test]
|
|
3347
|
+
fn live_owner_does_not_block_attach_from_bound_pane() {
|
|
3348
|
+
let state = serde_json::json!({
|
|
3349
|
+
"leader_receiver": {"pane_id": "%0"},
|
|
3350
|
+
});
|
|
3351
|
+
let targets = vec![listed("%0"), listed("%6")];
|
|
3352
|
+
assert!(live_bound_pane_blocking_other_attach(
|
|
3353
|
+
&state,
|
|
3354
|
+
&PaneId::new("%0"),
|
|
3355
|
+
&targets,
|
|
3356
|
+
)
|
|
3357
|
+
.is_none());
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
#[test]
|
|
3361
|
+
fn missing_owner_pane_is_not_treated_as_live_conflict() {
|
|
3362
|
+
let state = serde_json::json!({
|
|
3363
|
+
"leader_receiver": {"pane_id": "%0"},
|
|
3364
|
+
});
|
|
3365
|
+
let targets = vec![listed("%6")];
|
|
3366
|
+
assert!(live_bound_pane_blocking_other_attach(
|
|
3367
|
+
&state,
|
|
3368
|
+
&PaneId::new("%6"),
|
|
3369
|
+
&targets,
|
|
3370
|
+
)
|
|
3371
|
+
.is_none());
|
|
3372
|
+
}
|
|
2914
3373
|
}
|