@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.
Files changed (83) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +161 -29
  4. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  5. package/crates/team-agent/src/cli/emit.rs +24 -8
  6. package/crates/team-agent/src/cli/mod.rs +543 -98
  7. package/crates/team-agent/src/cli/models.rs +362 -32
  8. package/crates/team-agent/src/cli/named_address.rs +19 -5
  9. package/crates/team-agent/src/cli/profile.rs +35 -4
  10. package/crates/team-agent/src/cli/spec.rs +7 -2
  11. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  12. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  13. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  14. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  15. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  16. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  17. package/crates/team-agent/src/cli/types.rs +1 -0
  18. package/crates/team-agent/src/leader/lease.rs +492 -33
  19. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  20. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  21. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  22. package/crates/team-agent/src/leader/start.rs +151 -14
  23. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  24. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  25. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  26. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  27. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  28. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +65 -18
  29. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  30. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  31. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  32. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  33. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  34. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  35. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  36. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  37. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  38. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
  39. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  40. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  41. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  42. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  43. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  44. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
  45. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  46. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  47. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  48. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  49. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  50. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  51. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  52. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  53. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  54. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  55. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  56. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
  57. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  58. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  59. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  60. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
  61. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  62. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  63. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  64. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  65. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  66. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  67. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  68. package/crates/team-agent/src/messaging/delivery.rs +67 -8
  69. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  70. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  71. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  72. package/crates/team-agent/src/state/persist.rs +97 -1
  73. package/crates/team-agent/src/state/repository.rs +29 -0
  74. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
  75. package/crates/team-agent/src/tmux_backend/tests.rs +121 -4
  76. package/crates/team-agent/src/tmux_backend.rs +208 -118
  77. package/crates/team-agent/src/transport.rs +72 -0
  78. package/crates/team-agent/src/transport_factory.rs +53 -0
  79. package/package.json +4 -5
  80. package/skills/team-agent/SKILL.md +45 -17
  81. package/skills/team-agent/command-coverage.json +4 -29
  82. package/npm/check-skill-links.mjs +0 -88
  83. 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(workspace, &state, &target.info, provider);
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
- let backend = tmux_backend_for_endpoint(&endpoint);
348
- let resolved_endpoint = backend.tmux_endpoint();
349
- targets.extend(
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
- let backend = tmux_backend_for_endpoint(&endpoint);
362
- let resolved_endpoint = backend.tmux_endpoint();
363
- targets.extend(
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
- crate::transport_factory::tmux_workspace_transport(workspace)
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: None,
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(LeaderClaimTarget {
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
- claim_target_from_pane_info(target)
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
- // Fake provider: skip session-uuid check since attribution was
1845
- // bypassed. Nothing else to validate.
1846
- return Ok(());
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() || !target.active {
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
  }