@team-agent/installer 0.5.75 → 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 (98) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +9 -0
  4. package/README.zh.md +8 -0
  5. package/crates/team-agent/src/cli/adapters.rs +184 -125
  6. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  7. package/crates/team-agent/src/cli/emit.rs +59 -15
  8. package/crates/team-agent/src/cli/mod.rs +600 -134
  9. package/crates/team-agent/src/cli/models.rs +485 -44
  10. package/crates/team-agent/src/cli/named_address.rs +19 -5
  11. package/crates/team-agent/src/cli/profile.rs +35 -4
  12. package/crates/team-agent/src/cli/spec.rs +7 -2
  13. package/crates/team-agent/src/cli/status_port/brief.rs +1137 -0
  14. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  15. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  16. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  17. package/crates/team-agent/src/cli/status_port.rs +8 -0
  18. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  19. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  20. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  21. package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
  22. package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
  23. package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
  24. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  25. package/crates/team-agent/src/cli/types.rs +1 -0
  26. package/crates/team-agent/src/leader/lease.rs +538 -41
  27. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  28. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  29. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  30. package/crates/team-agent/src/leader/start.rs +151 -14
  31. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  32. package/crates/team-agent/src/leader/types.rs +4 -0
  33. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  34. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  35. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  36. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  37. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +138 -14
  38. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  39. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  40. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  41. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  42. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  43. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  44. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  45. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  46. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  47. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +1002 -65
  48. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  49. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  50. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
  51. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  52. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  53. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  54. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -3
  55. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  56. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  57. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  58. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  59. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  60. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  61. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  62. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  63. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  64. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  65. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  66. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +4 -3
  67. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  68. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  69. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  70. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +69 -28
  71. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  72. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  73. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  74. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
  75. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  76. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  77. package/crates/team-agent/src/lifecycle/types.rs +12 -0
  78. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  79. package/crates/team-agent/src/messaging/delivery.rs +67 -8
  80. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  81. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  82. package/crates/team-agent/src/messaging/watchers.rs +48 -4
  83. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  84. package/crates/team-agent/src/state/paths.rs +17 -2
  85. package/crates/team-agent/src/state/persist.rs +97 -1
  86. package/crates/team-agent/src/state/projection.rs +18 -2
  87. package/crates/team-agent/src/state/repository.rs +29 -0
  88. package/crates/team-agent/src/state/selector.rs +23 -3
  89. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +144 -0
  90. package/crates/team-agent/src/tmux_backend/tests.rs +121 -4
  91. package/crates/team-agent/src/tmux_backend.rs +208 -118
  92. package/crates/team-agent/src/transport.rs +72 -0
  93. package/crates/team-agent/src/transport_factory.rs +53 -0
  94. package/package.json +4 -5
  95. package/skills/team-agent/SKILL.md +45 -17
  96. package/skills/team-agent/command-coverage.json +4 -29
  97. package/npm/check-skill-links.mjs +0 -88
  98. package/skills/team-agent/references/team-agent-operator.md +0 -672
@@ -32,6 +32,11 @@ pub(crate) struct AppliedLeaderGrant {
32
32
  pub(crate) receiver: Value,
33
33
  }
34
34
 
35
+ #[derive(Debug, Default)]
36
+ struct AttachRecovery {
37
+ blocked_counts: crate::message_store::BlockedLeaderRequeueCounts,
38
+ }
39
+
35
40
  #[derive(Debug)]
36
41
  pub(crate) struct AttachLeaderError {
37
42
  source: LeaderError,
@@ -92,6 +97,23 @@ pub fn attach_leader(
92
97
  "tmux pane not found: {pane_id}"
93
98
  )));
94
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
+ }
95
117
  let mut receiver = receiver_for_attach_target(
96
118
  workspace,
97
119
  &state,
@@ -102,7 +124,23 @@ pub fn attach_leader(
102
124
  if let Some(endpoint) = target.endpoint.as_ref() {
103
125
  receiver.tmux_socket = Some(endpoint.clone());
104
126
  }
105
- 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
+ }
106
144
  if validation.is_err() {
107
145
  let pane_info = pane_info_value(&target.info);
108
146
  let targets_value = Value::Array(
@@ -126,8 +164,11 @@ pub fn attach_leader(
126
164
  if let Some(endpoint) = target.endpoint.as_deref() {
127
165
  quiet_fake_leader_pane_echo(provider, &target.info, endpoint);
128
166
  }
129
- let _ =
167
+ let recovery =
130
168
  requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
169
+ let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
170
+ let attach_window_failures =
171
+ attach_window_failure_value(recovery.blocked_counts, &team_id, &pane_id);
131
172
  return Ok(LeaseResult {
132
173
  ok: true,
133
174
  status: LeaseStatus::Claimed,
@@ -141,6 +182,7 @@ pub fn attach_leader(
141
182
  .map(str::to_string),
142
183
  bound_pane_id: Some(pane_id),
143
184
  topology_convergence: None,
185
+ attach_window_failures,
144
186
  });
145
187
  }
146
188
  event_log.write(
@@ -165,7 +207,10 @@ pub fn attach_leader(
165
207
  if let Some(endpoint) = target.endpoint.as_deref() {
166
208
  quiet_fake_leader_pane_echo(provider, &target.info, endpoint);
167
209
  }
168
- let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
210
+ let recovery = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
211
+ let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
212
+ let attach_window_failures =
213
+ attach_window_failure_value(recovery.blocked_counts, &team_id, &pane_id);
169
214
  return Ok(LeaseResult {
170
215
  ok: true,
171
216
  status: LeaseStatus::AlreadyBound,
@@ -176,6 +221,7 @@ pub fn attach_leader(
176
221
  action: None,
177
222
  bound_pane_id: Some(pane_id),
178
223
  topology_convergence: None,
224
+ attach_window_failures,
179
225
  });
180
226
  }
181
227
  let identity = leader_identity_context(workspace, None, Some(&state))?;
@@ -192,7 +238,10 @@ pub fn attach_leader(
192
238
  if let Some(endpoint) = target.endpoint.as_deref() {
193
239
  quiet_fake_leader_pane_echo(provider, &target.info, endpoint);
194
240
  }
195
- let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
241
+ let recovery = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
242
+ let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
243
+ let attach_window_failures =
244
+ attach_window_failure_value(recovery.blocked_counts, &team_id, &pane_id);
196
245
  Ok(LeaseResult {
197
246
  ok: true,
198
247
  status: LeaseStatus::Claimed,
@@ -203,6 +252,7 @@ pub fn attach_leader(
203
252
  action: None,
204
253
  bound_pane_id: Some(pane_id),
205
254
  topology_convergence: None,
255
+ attach_window_failures,
206
256
  })
207
257
  }
208
258
 
@@ -327,47 +377,45 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
327
377
  // leader pane. Keep state-recorded worker endpoints as additional targets,
328
378
  // but do not let a cross-socket pane-id collision hide the caller pane.
329
379
  if let Some(endpoint) = crate::tmux_backend::socket_name_from_tmux_env() {
330
- let backend = tmux_backend_for_endpoint(&endpoint);
331
- let resolved_endpoint = backend.tmux_endpoint();
332
- targets.extend(
333
- backend
334
- .list_targets()
335
- .unwrap_or_default()
336
- .into_iter()
337
- .map(|info| AttachLeaderTarget {
338
- info,
339
- endpoint: resolved_endpoint.clone(),
340
- }),
380
+ extend_attach_leader_targets(
381
+ &mut targets,
382
+ crate::transport_factory::leader_endpoint_transport(&endpoint).as_ref(),
341
383
  );
342
384
  }
343
385
  for endpoint in state_recorded_tmux_endpoints(state) {
344
- let backend = tmux_backend_for_endpoint(&endpoint);
345
- let resolved_endpoint = backend.tmux_endpoint();
346
- targets.extend(
347
- backend
348
- .list_targets()
349
- .unwrap_or_default()
350
- .into_iter()
351
- .map(|info| AttachLeaderTarget {
352
- info,
353
- endpoint: resolved_endpoint.clone(),
354
- }),
386
+ extend_attach_leader_targets(
387
+ &mut targets,
388
+ crate::transport_factory::leader_endpoint_transport(&endpoint).as_ref(),
355
389
  );
356
390
  }
357
391
  // Phase 1d Batch 6: factory tmux workspace helper for
358
392
  // grep-visibility. Semantics unchanged; leader lease discovery is
359
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();
360
409
  targets.extend(
361
- crate::transport_factory::tmux_workspace_transport(workspace)
410
+ transport
362
411
  .list_targets()
363
412
  .unwrap_or_default()
364
413
  .into_iter()
365
414
  .map(|info| AttachLeaderTarget {
366
415
  info,
367
- endpoint: None,
416
+ endpoint: resolved_endpoint.clone(),
368
417
  }),
369
418
  );
370
- targets
371
419
  }
372
420
 
373
421
  fn tmux_backend_for_endpoint(endpoint: &str) -> crate::tmux_backend::TmuxBackend {
@@ -441,17 +489,34 @@ fn requeue_exhausted_watchers_after_attach(
441
489
  state: &Value,
442
490
  event_log: &crate::event_log::EventLog,
443
491
  pane_id: &PaneId,
444
- ) -> Result<Vec<crate::messaging::WatcherNotice>, LeaderError> {
492
+ ) -> Result<AttachRecovery, LeaderError> {
445
493
  let store = MessageStore::open(workspace)?;
446
494
  let team_id = TeamKey::new(crate::state::projection::team_state_key(state));
447
- let notices = crate::messaging::requeue_delivery_exhausted_watchers(
448
- workspace, &store, event_log, &team_id, pane_id,
449
- )?;
495
+ let (notices, blocked_counts) =
496
+ crate::messaging::watchers::requeue_delivery_exhausted_watchers_with_counts(
497
+ workspace, &store, event_log, &team_id, pane_id,
498
+ )?;
450
499
  event_log.write(
451
500
  super::LeaderEvent::ReceiverRequeuedExhaustedWatchers.name(),
452
501
  requeued_exhausted_watchers_event_payload(pane_id, &team_id, &notices),
453
502
  )?;
454
- Ok(notices)
503
+ Ok(AttachRecovery { blocked_counts })
504
+ }
505
+
506
+ fn attach_window_failure_value(
507
+ counts: crate::message_store::BlockedLeaderRequeueCounts,
508
+ team_id: &TeamKey,
509
+ pane_id: &PaneId,
510
+ ) -> Option<Value> {
511
+ (counts.blocked_leader_unbound > 0).then(|| {
512
+ json!({
513
+ "team_id": team_id.as_str(),
514
+ "pane_id": pane_id.as_str(),
515
+ "reason": "leader_not_attached",
516
+ "count": counts.blocked_leader_unbound,
517
+ "status": "requeued_pending_physical_retry",
518
+ })
519
+ })
455
520
  }
456
521
 
457
522
  /// R8 D4 (decoupled for offline byte-lock — c-lite): build the `leader_receiver.requeued_exhausted_watchers`
@@ -949,6 +1014,7 @@ fn claim_lease_no_incident_with_target(
949
1014
  action: None,
950
1015
  bound_pane_id: Some(caller_pane.clone()),
951
1016
  topology_convergence,
1017
+ attach_window_failures: None,
952
1018
  });
953
1019
  }
954
1020
  let owner_live = bound_pane_id.as_deref().is_some_and(|pane| {
@@ -1194,6 +1260,7 @@ fn claim_lease_no_incident_with_target(
1194
1260
  action: None,
1195
1261
  bound_pane_id: Some(caller_pane.clone()),
1196
1262
  topology_convergence,
1263
+ attach_window_failures: None,
1197
1264
  })
1198
1265
  }
1199
1266
 
@@ -1452,6 +1519,7 @@ fn convergence_persistence_refusal(
1452
1519
  ),
1453
1520
  bound_pane_id,
1454
1521
  topology_convergence,
1522
+ attach_window_failures: None,
1455
1523
  }
1456
1524
  }
1457
1525
 
@@ -1475,6 +1543,7 @@ fn refused(
1475
1543
  },
1476
1544
  bound_pane_id,
1477
1545
  topology_convergence: None,
1546
+ attach_window_failures: None,
1478
1547
  }
1479
1548
  }
1480
1549
 
@@ -1491,6 +1560,22 @@ fn bound_pane(state: &Value) -> Option<String> {
1491
1560
  .or_else(|| get_path_str(state, &["team_owner", "pane_id"]).filter(|v| !v.is_empty()))
1492
1561
  }
1493
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
+
1494
1579
  fn bound_endpoint_matches_current_process(state: &Value) -> bool {
1495
1580
  let Some(bound) =
1496
1581
  get_path_str(state, &["leader_receiver", "tmux_socket"]).filter(|v| !v.is_empty())
@@ -1666,8 +1751,16 @@ fn claim_target_from_pane_info(target: &PaneInfo) -> Option<LeaderClaimTarget> {
1666
1751
  if !target.active {
1667
1752
  return None;
1668
1753
  }
1754
+ claim_target_from_observed_pane(target)
1755
+ }
1756
+
1757
+ fn claim_target_from_observed_pane(target: &PaneInfo) -> Option<LeaderClaimTarget> {
1669
1758
  let provider = super::attribute_pane_provider(target)?;
1670
- 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 {
1671
1764
  provider,
1672
1765
  leader_session_uuid: target_leader_session_uuid(target),
1673
1766
  team_id: target
@@ -1680,7 +1773,7 @@ fn claim_target_from_pane_info(target: &PaneInfo) -> Option<LeaderClaimTarget> {
1680
1773
  scope_authority: None,
1681
1774
  authorized_team_workspace: None,
1682
1775
  binding_nonce: None,
1683
- })
1776
+ }
1684
1777
  }
1685
1778
 
1686
1779
  fn workspace_claim_target_from_pane_info(
@@ -1691,7 +1784,7 @@ fn workspace_claim_target_from_pane_info(
1691
1784
  if !crate::state::owner_gate::workspace_paths_match(current_path, workspace) {
1692
1785
  return None;
1693
1786
  }
1694
- claim_target_from_pane_info(target)
1787
+ claim_target_from_observed_pane(target)
1695
1788
  }
1696
1789
 
1697
1790
  fn write_claim_target_pane_nonce(target: &mut LeaderClaimTarget) -> Result<(), LeaderError> {
@@ -1787,6 +1880,7 @@ fn validate_attach_target(
1787
1880
  state: &Value,
1788
1881
  target: &PaneInfo,
1789
1882
  requested_provider: Provider,
1883
+ endpoint: Option<&str>,
1790
1884
  ) -> Result<(), &'static str> {
1791
1885
  // 0.5.9 (E6 real-machine e2e wiring): an explicit `--provider fake`
1792
1886
  // is the operator's declaration that this pane is a fake-provider
@@ -1799,13 +1893,28 @@ fn validate_attach_target(
1799
1893
  // provider list, only wired in test/fixture flows.
1800
1894
  let claim_target = match workspace_claim_target_from_pane_info(workspace, target) {
1801
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
+ }
1802
1910
  None if matches!(requested_provider, Provider::Fake) => None,
1803
1911
  None => return Err("leader_pane_validation_failed"),
1804
1912
  };
1805
1913
  let Some(claim_target) = claim_target else {
1806
- // Fake provider: skip session-uuid check since attribution was
1807
- // bypassed. Nothing else to validate.
1808
- return Ok(());
1914
+ if matches!(requested_provider, Provider::Fake) {
1915
+ return Ok(());
1916
+ }
1917
+ return Err("leader_pane_validation_failed");
1809
1918
  };
1810
1919
  let recorded_uuid = get_path_str(state, &["team_owner", "leader_session_uuid"])
1811
1920
  .or_else(|| get_path_str(state, &["leader_receiver", "leader_session_uuid"]));
@@ -1843,6 +1952,161 @@ fn receiver_for_attach_target(
1843
1952
  ))
1844
1953
  }
1845
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
+
1846
2110
  fn pane_info_value(target: &PaneInfo) -> Value {
1847
2111
  let leader_env = target
1848
2112
  .leader_env
@@ -1997,7 +2261,7 @@ fn authorize_fresh_caller_receiver(
1997
2261
  let Some(target) = caller_target else {
1998
2262
  return Ok(());
1999
2263
  };
2000
- 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() {
2001
2265
  return Err(LeaderError::Validation(
2002
2266
  "fresh caller pane observation does not match the bound pane".to_string(),
2003
2267
  ));
@@ -2873,4 +3137,237 @@ mod tests {
2873
3137
  Some(absolute)
2874
3138
  );
2875
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
+ }
2876
3373
  }