@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +9 -0
- package/README.zh.md +8 -0
- package/crates/team-agent/src/cli/adapters.rs +184 -125
- package/crates/team-agent/src/cli/diagnose.rs +333 -68
- package/crates/team-agent/src/cli/emit.rs +59 -15
- package/crates/team-agent/src/cli/mod.rs +600 -134
- package/crates/team-agent/src/cli/models.rs +485 -44
- 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/brief.rs +1137 -0
- 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/status_port.rs +8 -0
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
- package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
- 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 +538 -41
- 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/leader/types.rs +4 -0
- 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 +138 -14
- 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 +1002 -65
- 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/b0_reader_hideone_audit_contract.rs +45 -14
- 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 -3
- 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 +4 -3
- 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 +69 -28
- 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/upgrade_compat_0211_red.rs +22 -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 +12 -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/messaging/watchers.rs +48 -4
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
- package/crates/team-agent/src/state/paths.rs +17 -2
- package/crates/team-agent/src/state/persist.rs +97 -1
- package/crates/team-agent/src/state/projection.rs +18 -2
- package/crates/team-agent/src/state/repository.rs +29 -0
- package/crates/team-agent/src/state/selector.rs +23 -3
- package/crates/team-agent/src/test_support/models_timeout_fixture.rs +144 -0
- 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
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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:
|
|
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<
|
|
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 =
|
|
448
|
-
|
|
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, ¬ices),
|
|
453
502
|
)?;
|
|
454
|
-
Ok(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
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()
|
|
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
|
}
|