@team-agent/installer 0.5.52 → 0.5.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +18 -2
- package/crates/team-agent/src/cli/emit.rs +111 -3
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/send/persist.rs +1 -0
- package/crates/team-agent/src/cli/send/presentation.rs +1 -0
- package/crates/team-agent/src/cli/send.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +4 -1
- package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +12 -0
- package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
- package/crates/team-agent/src/db/message_store.rs +31 -2
- package/crates/team-agent/src/db/migration.rs +7 -6
- package/crates/team-agent/src/db/schema.rs +18 -5
- package/crates/team-agent/src/diagnose/orphans.rs +24 -3
- package/crates/team-agent/src/kill_audit.rs +67 -0
- package/crates/team-agent/src/leader/lease.rs +324 -57
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
- package/crates/team-agent/src/leader/rediscover.rs +6 -0
- package/crates/team-agent/src/leader/start.rs +144 -23
- package/crates/team-agent/src/leader/tests/idle.rs +3 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +14 -2
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +17 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tools.rs +89 -4
- package/crates/team-agent/src/mcp_server/types.rs +7 -0
- package/crates/team-agent/src/mcp_server/wire.rs +65 -4
- package/crates/team-agent/src/messaging/delivery.rs +2 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
- package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/persist.rs +68 -2
- package/crates/team-agent/src/messaging/presentation.rs +307 -0
- package/crates/team-agent/src/messaging/results.rs +130 -3
- package/crates/team-agent/src/messaging/selftest.rs +1 -0
- package/crates/team-agent/src/messaging/send.rs +54 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
- package/crates/team-agent/src/messaging/types.rs +2 -0
- package/crates/team-agent/src/messaging/watchers.rs +1 -0
- package/crates/team-agent/src/provider/adapter.rs +54 -13
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
- package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/session/capture.rs +395 -52
- package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
- package/crates/team-agent/src/provider/session/mod.rs +6 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
- package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
- package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
- package/crates/team-agent/src/provider/session_scan.rs +3 -3
- package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
- package/crates/team-agent/src/provider/tests.rs +1 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
- package/crates/team-agent/src/tmux_backend.rs +29 -2
- package/package.json +4 -4
- package/schemas/result-envelope.schema.json +10 -0
- package/skills/team-agent/SKILL.md +11 -3
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
use std::collections::BTreeSet;
|
|
5
5
|
use std::path::{Path, PathBuf};
|
|
6
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
7
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
6
8
|
|
|
7
9
|
use serde_json::{json, Value};
|
|
10
|
+
use sha2::{Digest, Sha256};
|
|
8
11
|
|
|
9
12
|
use crate::message_store::MessageStore;
|
|
10
13
|
use crate::model::enums::PaneLiveness;
|
|
@@ -357,6 +360,29 @@ fn push_state_tmux_endpoints(state: &Value, out: &mut BTreeSet<String>) {
|
|
|
357
360
|
}
|
|
358
361
|
}
|
|
359
362
|
|
|
363
|
+
fn state_records_pane_endpoint(state: &Value, pane_id: &str, endpoint: &str) -> bool {
|
|
364
|
+
let record_matches = |record: &Value| {
|
|
365
|
+
record.get("pane_id").and_then(Value::as_str) == Some(pane_id)
|
|
366
|
+
&& record
|
|
367
|
+
.get("tmux_socket")
|
|
368
|
+
.and_then(Value::as_str)
|
|
369
|
+
.is_some_and(|recorded| tmux_endpoints_match(recorded, endpoint))
|
|
370
|
+
};
|
|
371
|
+
["team_owner", "leader_receiver"]
|
|
372
|
+
.iter()
|
|
373
|
+
.any(|key| state.get(*key).is_some_and(&record_matches))
|
|
374
|
+
|| state
|
|
375
|
+
.get("teams")
|
|
376
|
+
.and_then(Value::as_object)
|
|
377
|
+
.is_some_and(|teams| {
|
|
378
|
+
teams.values().any(|team| {
|
|
379
|
+
["team_owner", "leader_receiver"]
|
|
380
|
+
.iter()
|
|
381
|
+
.any(|key| team.get(*key).is_some_and(&record_matches))
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
360
386
|
fn requeue_exhausted_watchers_after_attach(
|
|
361
387
|
workspace: &Path,
|
|
362
388
|
state: &Value,
|
|
@@ -489,41 +515,50 @@ pub fn claim_leader(
|
|
|
489
515
|
.unwrap_or_default();
|
|
490
516
|
let raw_state = crate::state::persist::load_runtime_state_without_migrations(workspace)?;
|
|
491
517
|
let event_log = crate::event_log::EventLog::new(workspace);
|
|
518
|
+
let active_team = crate::messaging::leader_receiver::active_team_key(workspace, &raw_state);
|
|
519
|
+
let requested_team = team
|
|
520
|
+
.filter(|team| !team.is_empty())
|
|
521
|
+
.or_else(|| {
|
|
522
|
+
raw_state
|
|
523
|
+
.get("active_team_key")
|
|
524
|
+
.and_then(Value::as_str)
|
|
525
|
+
.filter(|team| !team.is_empty())
|
|
526
|
+
})
|
|
527
|
+
.unwrap_or(active_team.as_str());
|
|
528
|
+
let team_id = TeamKey::new(requested_team.to_string());
|
|
492
529
|
let targets = claim_leader_targets(workspace, &raw_state);
|
|
493
|
-
let caller_candidate =
|
|
494
|
-
.
|
|
495
|
-
.filter(|target| target.info.pane_id.as_str() == caller)
|
|
496
|
-
.min_by_key(|target| target.source.priority());
|
|
530
|
+
let caller_candidate =
|
|
531
|
+
select_claim_authority(workspace, &raw_state, team_id.as_str(), &targets, &caller);
|
|
497
532
|
let caller_pane_info = caller_candidate.map(|target| &target.info);
|
|
498
|
-
let
|
|
499
|
-
|
|
500
|
-
|
|
533
|
+
let observed_endpoint = caller_candidate.and_then(|candidate| {
|
|
534
|
+
authoritative_observed_endpoint(workspace, &raw_state, &targets, candidate)
|
|
535
|
+
});
|
|
536
|
+
let mut caller_target = caller_candidate.and_then(|target| {
|
|
537
|
+
claim_target_from_pane_info(&target.info).map(|mut claim_target| {
|
|
538
|
+
claim_target.endpoint.clone_from(&observed_endpoint);
|
|
501
539
|
claim_target
|
|
502
540
|
})
|
|
503
541
|
});
|
|
504
|
-
let
|
|
505
|
-
.
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
.map(str::to_string)
|
|
525
|
-
.unwrap_or_else(|| active_team.clone()),
|
|
526
|
-
);
|
|
542
|
+
if let (Some(candidate), Some(target)) = (caller_candidate, caller_target.as_mut()) {
|
|
543
|
+
if candidate.info.current_path.as_deref().is_some_and(|path| {
|
|
544
|
+
!crate::messaging::leader_channel::path_is_in_workspace(path, workspace)
|
|
545
|
+
}) {
|
|
546
|
+
let endpoint = target.endpoint.as_deref().ok_or_else(|| {
|
|
547
|
+
LeaderError::Validation(
|
|
548
|
+
"explicit cross-workspace claim requires a canonical tmux endpoint".to_string(),
|
|
549
|
+
)
|
|
550
|
+
})?;
|
|
551
|
+
if !Path::new(endpoint).is_absolute() {
|
|
552
|
+
return Err(LeaderError::Validation(
|
|
553
|
+
"explicit cross-workspace claim requires an absolute tmux endpoint".to_string(),
|
|
554
|
+
));
|
|
555
|
+
}
|
|
556
|
+
let nonce = next_pane_binding_nonce(workspace, endpoint, &candidate.info.pane_id);
|
|
557
|
+
target.scope_authority = Some("explicit_claim".to_string());
|
|
558
|
+
target.authorized_team_workspace = Some(canonical_workspace(workspace));
|
|
559
|
+
target.binding_nonce = Some(nonce);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
527
562
|
let scoped_team = if team_id.as_str() == active_team
|
|
528
563
|
|| raw_state
|
|
529
564
|
.get("teams")
|
|
@@ -695,6 +730,7 @@ fn claim_lease_no_incident_with_target(
|
|
|
695
730
|
let non_empty_caller_pane = NonEmptyPaneId::try_from_pane(caller_pane)?;
|
|
696
731
|
let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
|
|
697
732
|
if bound_pane_id.as_deref() == Some(caller_pane.as_str()) && bound_endpoint_matches_caller {
|
|
733
|
+
write_claim_target_pane_nonce(caller_target)?;
|
|
698
734
|
let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
|
|
699
735
|
let observed_endpoint = caller_target.and_then(|target| target.endpoint.as_deref());
|
|
700
736
|
let convergence_candidate = observed_endpoint.or(current_endpoint.as_deref());
|
|
@@ -718,6 +754,7 @@ fn claim_lease_no_incident_with_target(
|
|
|
718
754
|
pane_info,
|
|
719
755
|
observed_endpoint.or(current_endpoint.as_deref()),
|
|
720
756
|
scoped_team.or(team),
|
|
757
|
+
caller_target,
|
|
721
758
|
)
|
|
722
759
|
});
|
|
723
760
|
if converged || observation_refreshed {
|
|
@@ -883,6 +920,7 @@ fn claim_lease_no_incident_with_target(
|
|
|
883
920
|
} else {
|
|
884
921
|
LeaseReason::VacantAcquired
|
|
885
922
|
};
|
|
923
|
+
write_claim_target_pane_nonce(caller_target)?;
|
|
886
924
|
let mut identity = leader_identity_context(workspace, Some(team_id.as_str()), Some(state))?;
|
|
887
925
|
if let Some(uuid) = caller_target.and_then(|target| target.leader_session_uuid.as_ref()) {
|
|
888
926
|
identity.leader_session_uuid = uuid.clone();
|
|
@@ -898,6 +936,13 @@ fn claim_lease_no_incident_with_target(
|
|
|
898
936
|
Discovery::ClaimLeader,
|
|
899
937
|
caller_target.and_then(|target| target.pane_info.clone()),
|
|
900
938
|
);
|
|
939
|
+
if let Some(target) = caller_target {
|
|
940
|
+
receiver.scope_authority.clone_from(&target.scope_authority);
|
|
941
|
+
receiver
|
|
942
|
+
.authorized_team_workspace
|
|
943
|
+
.clone_from(&target.authorized_team_workspace);
|
|
944
|
+
receiver.binding_nonce.clone_from(&target.binding_nonce);
|
|
945
|
+
}
|
|
901
946
|
if let Some(endpoint) = observed_endpoint.as_ref() {
|
|
902
947
|
receiver.tmux_socket = Some(endpoint.clone());
|
|
903
948
|
}
|
|
@@ -1023,6 +1068,8 @@ fn apply_endpoint_convergence(
|
|
|
1023
1068
|
"new_tmux_endpoint": candidate_endpoint,
|
|
1024
1069
|
"candidate_source": candidate_source,
|
|
1025
1070
|
"owner_epoch": owner_epoch.0,
|
|
1071
|
+
"persisted": false,
|
|
1072
|
+
"checked_paths": convergence_checked_paths(team_id.as_str()),
|
|
1026
1073
|
})),
|
|
1027
1074
|
false,
|
|
1028
1075
|
),
|
|
@@ -1038,6 +1085,8 @@ fn apply_endpoint_convergence(
|
|
|
1038
1085
|
"reason": reason,
|
|
1039
1086
|
"candidate_source": candidate_source,
|
|
1040
1087
|
"owner_epoch": owner_epoch.0,
|
|
1088
|
+
"persisted": false,
|
|
1089
|
+
"checked_paths": convergence_checked_paths(team_id.as_str()),
|
|
1041
1090
|
"action": format!(
|
|
1042
1091
|
"old tmux endpoint {old_endpoint} still has this team's session or pane tuple; run team-agent diagnose --json before retrying restart"
|
|
1043
1092
|
),
|
|
@@ -1313,32 +1362,89 @@ struct LeaderClaimTarget {
|
|
|
1313
1362
|
team_id: Option<String>,
|
|
1314
1363
|
pane_info: Option<PaneInfo>,
|
|
1315
1364
|
endpoint: Option<String>,
|
|
1365
|
+
scope_authority: Option<String>,
|
|
1366
|
+
authorized_team_workspace: Option<PathBuf>,
|
|
1367
|
+
binding_nonce: Option<String>,
|
|
1316
1368
|
}
|
|
1317
1369
|
|
|
1318
1370
|
#[derive(Clone)]
|
|
1319
1371
|
struct ClaimLeaderTargetCandidate {
|
|
1320
1372
|
info: PaneInfo,
|
|
1321
1373
|
endpoint: Option<String>,
|
|
1322
|
-
source: ClaimLeaderTargetSource,
|
|
1323
1374
|
}
|
|
1324
1375
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1376
|
+
fn select_claim_authority<'a>(
|
|
1377
|
+
workspace: &Path,
|
|
1378
|
+
state: &Value,
|
|
1379
|
+
team_id: &str,
|
|
1380
|
+
targets: &'a [ClaimLeaderTargetCandidate],
|
|
1381
|
+
caller: &str,
|
|
1382
|
+
) -> Option<&'a ClaimLeaderTargetCandidate> {
|
|
1383
|
+
targets
|
|
1384
|
+
.iter()
|
|
1385
|
+
.filter(|target| {
|
|
1386
|
+
target.info.pane_id.as_str() == caller
|
|
1387
|
+
&& target.info.active
|
|
1388
|
+
&& super::attribute_pane_provider(&target.info).is_some()
|
|
1389
|
+
})
|
|
1390
|
+
.min_by_key(|target| {
|
|
1391
|
+
let recorded = target
|
|
1392
|
+
.endpoint
|
|
1393
|
+
.as_deref()
|
|
1394
|
+
.is_some_and(|endpoint| state_records_pane_endpoint(state, caller, endpoint));
|
|
1395
|
+
let in_workspace = target.info.current_path.as_deref().is_some_and(|path| {
|
|
1396
|
+
crate::messaging::leader_channel::path_is_in_workspace(path, workspace)
|
|
1397
|
+
});
|
|
1398
|
+
let team_affinity = match target
|
|
1399
|
+
.info
|
|
1400
|
+
.leader_env
|
|
1401
|
+
.get("TEAM_AGENT_TEAM_ID")
|
|
1402
|
+
.filter(|value| !value.is_empty())
|
|
1403
|
+
{
|
|
1404
|
+
Some(observed) if observed == team_id => 0,
|
|
1405
|
+
None => 1,
|
|
1406
|
+
Some(_) => 2,
|
|
1407
|
+
};
|
|
1408
|
+
(
|
|
1409
|
+
!recorded,
|
|
1410
|
+
!in_workspace,
|
|
1411
|
+
team_affinity,
|
|
1412
|
+
target.info.pane_pid.unwrap_or(u32::MAX),
|
|
1413
|
+
target.info.session.as_str().to_string(),
|
|
1414
|
+
target.info.window_index.unwrap_or(u32::MAX),
|
|
1415
|
+
target.info.pane_index.unwrap_or(u32::MAX),
|
|
1416
|
+
target.info.tty.clone().unwrap_or_default(),
|
|
1417
|
+
)
|
|
1418
|
+
})
|
|
1331
1419
|
}
|
|
1332
1420
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1421
|
+
fn authoritative_observed_endpoint(
|
|
1422
|
+
workspace: &Path,
|
|
1423
|
+
state: &Value,
|
|
1424
|
+
targets: &[ClaimLeaderTargetCandidate],
|
|
1425
|
+
authority: &ClaimLeaderTargetCandidate,
|
|
1426
|
+
) -> Option<String> {
|
|
1427
|
+
let recorded = state_recorded_tmux_endpoints(state);
|
|
1428
|
+
let workspace_name = crate::tmux_backend::socket_name_for_workspace(workspace);
|
|
1429
|
+
let workspace_path = crate::tmux_backend::socket_path_for_workspace(workspace);
|
|
1430
|
+
targets
|
|
1431
|
+
.iter()
|
|
1432
|
+
.filter(|candidate| candidate.info == authority.info)
|
|
1433
|
+
.filter_map(|candidate| candidate.endpoint.as_ref())
|
|
1434
|
+
.min_by_key(|endpoint| {
|
|
1435
|
+
let canonical_workspace_endpoint = endpoint.as_str() == workspace_name
|
|
1436
|
+
|| workspace_path
|
|
1437
|
+
.as_ref()
|
|
1438
|
+
.is_some_and(|path| path.to_string_lossy().as_ref() == endpoint.as_str());
|
|
1439
|
+
(
|
|
1440
|
+
!state_records_pane_endpoint(state, authority.info.pane_id.as_str(), endpoint),
|
|
1441
|
+
!recorded.contains(endpoint.as_str()),
|
|
1442
|
+
!canonical_workspace_endpoint,
|
|
1443
|
+
!Path::new(endpoint.as_str()).is_absolute(),
|
|
1444
|
+
endpoint.as_str(),
|
|
1445
|
+
)
|
|
1446
|
+
})
|
|
1447
|
+
.cloned()
|
|
1342
1448
|
}
|
|
1343
1449
|
|
|
1344
1450
|
fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTargetCandidate> {
|
|
@@ -1354,7 +1460,6 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
|
|
|
1354
1460
|
.map(|info| ClaimLeaderTargetCandidate {
|
|
1355
1461
|
info,
|
|
1356
1462
|
endpoint: resolved_endpoint.clone(),
|
|
1357
|
-
source: ClaimLeaderTargetSource::CurrentTmux,
|
|
1358
1463
|
}),
|
|
1359
1464
|
);
|
|
1360
1465
|
}
|
|
@@ -1369,7 +1474,6 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
|
|
|
1369
1474
|
.map(|info| ClaimLeaderTargetCandidate {
|
|
1370
1475
|
info,
|
|
1371
1476
|
endpoint: resolved_endpoint.clone(),
|
|
1372
|
-
source: ClaimLeaderTargetSource::StateRecorded,
|
|
1373
1477
|
}),
|
|
1374
1478
|
);
|
|
1375
1479
|
}
|
|
@@ -1383,7 +1487,6 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
|
|
|
1383
1487
|
.map(|info| ClaimLeaderTargetCandidate {
|
|
1384
1488
|
info,
|
|
1385
1489
|
endpoint: workspace_endpoint.clone(),
|
|
1386
|
-
source: ClaimLeaderTargetSource::Workspace,
|
|
1387
1490
|
}),
|
|
1388
1491
|
);
|
|
1389
1492
|
let default_backend = crate::transport_factory::tmux_default_transport();
|
|
@@ -1396,21 +1499,16 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
|
|
|
1396
1499
|
.map(|info| ClaimLeaderTargetCandidate {
|
|
1397
1500
|
info,
|
|
1398
1501
|
endpoint: default_endpoint.clone(),
|
|
1399
|
-
source: ClaimLeaderTargetSource::Default,
|
|
1400
1502
|
}),
|
|
1401
1503
|
);
|
|
1402
1504
|
targets
|
|
1403
1505
|
}
|
|
1404
1506
|
|
|
1405
|
-
fn claim_target_from_pane_info(
|
|
1507
|
+
fn claim_target_from_pane_info(target: &PaneInfo) -> Option<LeaderClaimTarget> {
|
|
1406
1508
|
if !target.active {
|
|
1407
1509
|
return None;
|
|
1408
1510
|
}
|
|
1409
1511
|
let provider = super::attribute_pane_provider(target)?;
|
|
1410
|
-
let current_path = target.current_path.as_deref()?;
|
|
1411
|
-
if !crate::state::owner_gate::workspace_paths_match(current_path, workspace) {
|
|
1412
|
-
return None;
|
|
1413
|
-
}
|
|
1414
1512
|
Some(LeaderClaimTarget {
|
|
1415
1513
|
provider,
|
|
1416
1514
|
leader_session_uuid: target_leader_session_uuid(target),
|
|
@@ -1421,9 +1519,74 @@ fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<Le
|
|
|
1421
1519
|
.cloned(),
|
|
1422
1520
|
pane_info: Some(target.clone()),
|
|
1423
1521
|
endpoint: None,
|
|
1522
|
+
scope_authority: None,
|
|
1523
|
+
authorized_team_workspace: None,
|
|
1524
|
+
binding_nonce: None,
|
|
1424
1525
|
})
|
|
1425
1526
|
}
|
|
1426
1527
|
|
|
1528
|
+
fn workspace_claim_target_from_pane_info(
|
|
1529
|
+
workspace: &Path,
|
|
1530
|
+
target: &PaneInfo,
|
|
1531
|
+
) -> Option<LeaderClaimTarget> {
|
|
1532
|
+
let current_path = target.current_path.as_deref()?;
|
|
1533
|
+
if !crate::state::owner_gate::workspace_paths_match(current_path, workspace) {
|
|
1534
|
+
return None;
|
|
1535
|
+
}
|
|
1536
|
+
claim_target_from_pane_info(target)
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
fn write_claim_target_pane_nonce(target: Option<&LeaderClaimTarget>) -> Result<(), LeaderError> {
|
|
1540
|
+
let Some(target) = target.filter(|target| target.scope_authority.is_some()) else {
|
|
1541
|
+
return Ok(());
|
|
1542
|
+
};
|
|
1543
|
+
let endpoint = target.endpoint.as_deref().ok_or_else(|| {
|
|
1544
|
+
LeaderError::Validation("explicit claim is missing its tmux endpoint".to_string())
|
|
1545
|
+
})?;
|
|
1546
|
+
let pane = target
|
|
1547
|
+
.pane_info
|
|
1548
|
+
.as_ref()
|
|
1549
|
+
.map(|info| &info.pane_id)
|
|
1550
|
+
.ok_or_else(|| {
|
|
1551
|
+
LeaderError::Validation("explicit claim is missing its pane observation".to_string())
|
|
1552
|
+
})?;
|
|
1553
|
+
let nonce = target.binding_nonce.as_deref().ok_or_else(|| {
|
|
1554
|
+
LeaderError::Validation("explicit claim is missing its pane binding nonce".to_string())
|
|
1555
|
+
})?;
|
|
1556
|
+
tmux_backend_for_endpoint(endpoint)
|
|
1557
|
+
.set_pane_binding_nonce(pane, nonce)
|
|
1558
|
+
.map_err(|error| {
|
|
1559
|
+
LeaderError::Validation(format!(
|
|
1560
|
+
"failed to bind the explicit leader pane instance: {error}"
|
|
1561
|
+
))
|
|
1562
|
+
})
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
fn canonical_workspace(workspace: &Path) -> PathBuf {
|
|
1566
|
+
workspace
|
|
1567
|
+
.canonicalize()
|
|
1568
|
+
.unwrap_or_else(|_| workspace.to_path_buf())
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
fn next_pane_binding_nonce(workspace: &Path, endpoint: &str, pane: &PaneId) -> String {
|
|
1572
|
+
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
1573
|
+
let nanos = SystemTime::now()
|
|
1574
|
+
.duration_since(UNIX_EPOCH)
|
|
1575
|
+
.map_or(0, |duration| duration.as_nanos());
|
|
1576
|
+
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
1577
|
+
let material = format!(
|
|
1578
|
+
"{}\0{endpoint}\0{}\0{}\0{nanos}\0{counter}",
|
|
1579
|
+
canonical_workspace(workspace).display(),
|
|
1580
|
+
pane.as_str(),
|
|
1581
|
+
std::process::id(),
|
|
1582
|
+
);
|
|
1583
|
+
let digest = Sha256::digest(material.as_bytes());
|
|
1584
|
+
digest[..16]
|
|
1585
|
+
.iter()
|
|
1586
|
+
.map(|byte| format!("{byte:02x}"))
|
|
1587
|
+
.collect()
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1427
1590
|
fn target_leader_session_uuid(target: &PaneInfo) -> Option<crate::model::ids::LeaderSessionUuid> {
|
|
1428
1591
|
target
|
|
1429
1592
|
.leader_env
|
|
@@ -1447,7 +1610,7 @@ fn validate_attach_target(
|
|
|
1447
1610
|
// a real Codex/Claude/Copilot binary. This is a targeted escape
|
|
1448
1611
|
// hatch — `Provider::Fake` is not selectable from the user-facing
|
|
1449
1612
|
// provider list, only wired in test/fixture flows.
|
|
1450
|
-
let claim_target = match
|
|
1613
|
+
let claim_target = match workspace_claim_target_from_pane_info(workspace, target) {
|
|
1451
1614
|
Some(target) => Some(target),
|
|
1452
1615
|
None if matches!(requested_provider, Provider::Fake) => None,
|
|
1453
1616
|
None => return Err("leader_pane_validation_failed"),
|
|
@@ -1553,7 +1716,7 @@ impl TargetScanLiveness {
|
|
|
1553
1716
|
let live_panes = targets
|
|
1554
1717
|
.iter()
|
|
1555
1718
|
.filter_map(|target| {
|
|
1556
|
-
let claim_target =
|
|
1719
|
+
let claim_target = workspace_claim_target_from_pane_info(workspace, target)?;
|
|
1557
1720
|
if let Some(owner_uuid) = owner_uuid.as_deref() {
|
|
1558
1721
|
let target_uuid = claim_target.leader_session_uuid.as_ref()?.as_str();
|
|
1559
1722
|
if target_uuid != owner_uuid {
|
|
@@ -1656,6 +1819,9 @@ fn make_receiver(
|
|
|
1656
1819
|
pane_tty: target.as_ref().and_then(|t| t.tty.clone()),
|
|
1657
1820
|
pane_current_command: target.as_ref().and_then(|t| t.current_command.clone()),
|
|
1658
1821
|
tmux_socket: crate::tmux_backend::socket_name_from_tmux_env(),
|
|
1822
|
+
scope_authority: None,
|
|
1823
|
+
authorized_team_workspace: None,
|
|
1824
|
+
binding_nonce: None,
|
|
1659
1825
|
fingerprint: target.as_ref().map(receiver_fingerprint),
|
|
1660
1826
|
leader_session_uuid: Some(uuid.clone()),
|
|
1661
1827
|
owner_epoch: Some(epoch),
|
|
@@ -1685,6 +1851,7 @@ fn refresh_already_bound_receiver_observation(
|
|
|
1685
1851
|
observed: &PaneInfo,
|
|
1686
1852
|
observed_endpoint: Option<&str>,
|
|
1687
1853
|
team_key: Option<&str>,
|
|
1854
|
+
claim_target: Option<&LeaderClaimTarget>,
|
|
1688
1855
|
) -> bool {
|
|
1689
1856
|
let attached_at = now_ts();
|
|
1690
1857
|
let mut refreshed = state
|
|
@@ -1696,6 +1863,7 @@ fn refresh_already_bound_receiver_observation(
|
|
|
1696
1863
|
observed,
|
|
1697
1864
|
observed_endpoint,
|
|
1698
1865
|
attached_at.as_str(),
|
|
1866
|
+
claim_target,
|
|
1699
1867
|
)
|
|
1700
1868
|
});
|
|
1701
1869
|
if let Some(team_key) = team_key {
|
|
@@ -1710,6 +1878,7 @@ fn refresh_already_bound_receiver_observation(
|
|
|
1710
1878
|
observed,
|
|
1711
1879
|
observed_endpoint,
|
|
1712
1880
|
attached_at.as_str(),
|
|
1881
|
+
claim_target,
|
|
1713
1882
|
)
|
|
1714
1883
|
});
|
|
1715
1884
|
}
|
|
@@ -1721,6 +1890,7 @@ fn refresh_receiver_observation(
|
|
|
1721
1890
|
observed: &PaneInfo,
|
|
1722
1891
|
observed_endpoint: Option<&str>,
|
|
1723
1892
|
attached_at: &str,
|
|
1893
|
+
claim_target: Option<&LeaderClaimTarget>,
|
|
1724
1894
|
) -> bool {
|
|
1725
1895
|
let before = Value::Object(receiver.clone());
|
|
1726
1896
|
receiver.insert(
|
|
@@ -1775,6 +1945,26 @@ fn refresh_receiver_observation(
|
|
|
1775
1945
|
Value::String(endpoint.to_string()),
|
|
1776
1946
|
);
|
|
1777
1947
|
}
|
|
1948
|
+
if let Some(target) = claim_target.filter(|target| target.scope_authority.is_some()) {
|
|
1949
|
+
receiver.insert(
|
|
1950
|
+
"scope_authority".to_string(),
|
|
1951
|
+
Value::String(target.scope_authority.clone().unwrap_or_default()),
|
|
1952
|
+
);
|
|
1953
|
+
receiver.insert(
|
|
1954
|
+
"authorized_team_workspace".to_string(),
|
|
1955
|
+
Value::String(
|
|
1956
|
+
target
|
|
1957
|
+
.authorized_team_workspace
|
|
1958
|
+
.as_deref()
|
|
1959
|
+
.map(|path| path.to_string_lossy().into_owned())
|
|
1960
|
+
.unwrap_or_default(),
|
|
1961
|
+
),
|
|
1962
|
+
);
|
|
1963
|
+
receiver.insert(
|
|
1964
|
+
"binding_nonce".to_string(),
|
|
1965
|
+
Value::String(target.binding_nonce.clone().unwrap_or_default()),
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1778
1968
|
receiver.insert(
|
|
1779
1969
|
"attached_at".to_string(),
|
|
1780
1970
|
Value::String(attached_at.to_string()),
|
|
@@ -2303,6 +2493,7 @@ mod tests {
|
|
|
2303
2493
|
&observed_pane(),
|
|
2304
2494
|
Some("/tmp/team-agent-live.sock"),
|
|
2305
2495
|
Some("team-a"),
|
|
2496
|
+
None,
|
|
2306
2497
|
));
|
|
2307
2498
|
let receiver = &state["leader_receiver"];
|
|
2308
2499
|
assert_eq!(receiver["owner_epoch"], json!(10));
|
|
@@ -2321,4 +2512,80 @@ mod tests {
|
|
|
2321
2512
|
json!(10)
|
|
2322
2513
|
);
|
|
2323
2514
|
}
|
|
2515
|
+
|
|
2516
|
+
#[test]
|
|
2517
|
+
fn already_bound_explicit_claim_persists_cross_workspace_authority() {
|
|
2518
|
+
let mut state = json!({
|
|
2519
|
+
"leader_receiver": {"status": "attached", "pane_id": "%7"},
|
|
2520
|
+
"teams": {"team-a": {"leader_receiver": {
|
|
2521
|
+
"status": "attached", "pane_id": "%7"
|
|
2522
|
+
}}}
|
|
2523
|
+
});
|
|
2524
|
+
let target = LeaderClaimTarget {
|
|
2525
|
+
provider: Provider::Codex,
|
|
2526
|
+
leader_session_uuid: None,
|
|
2527
|
+
team_id: Some("team-a".to_string()),
|
|
2528
|
+
pane_info: Some(observed_pane()),
|
|
2529
|
+
endpoint: Some("/tmp/team-agent-live.sock".to_string()),
|
|
2530
|
+
scope_authority: Some("explicit_claim".to_string()),
|
|
2531
|
+
authorized_team_workspace: Some(PathBuf::from("/tmp/workspace-a")),
|
|
2532
|
+
binding_nonce: Some("nonce-7".to_string()),
|
|
2533
|
+
};
|
|
2534
|
+
|
|
2535
|
+
assert!(refresh_already_bound_receiver_observation(
|
|
2536
|
+
&mut state,
|
|
2537
|
+
&observed_pane(),
|
|
2538
|
+
Some("/tmp/team-agent-live.sock"),
|
|
2539
|
+
Some("team-a"),
|
|
2540
|
+
Some(&target),
|
|
2541
|
+
));
|
|
2542
|
+
for receiver in [
|
|
2543
|
+
&state["leader_receiver"],
|
|
2544
|
+
&state["teams"]["team-a"]["leader_receiver"],
|
|
2545
|
+
] {
|
|
2546
|
+
assert_eq!(receiver["scope_authority"], json!("explicit_claim"));
|
|
2547
|
+
assert_eq!(
|
|
2548
|
+
receiver["authorized_team_workspace"],
|
|
2549
|
+
json!("/tmp/workspace-a")
|
|
2550
|
+
);
|
|
2551
|
+
assert_eq!(receiver["binding_nonce"], json!("nonce-7"));
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
#[test]
|
|
2556
|
+
fn pane_binding_nonce_rotates_per_explicit_claim() {
|
|
2557
|
+
let workspace = Path::new("/tmp/workspace-a");
|
|
2558
|
+
let endpoint = "/tmp/team-agent-live.sock";
|
|
2559
|
+
let pane = PaneId::new("%7");
|
|
2560
|
+
let first = next_pane_binding_nonce(workspace, endpoint, &pane);
|
|
2561
|
+
let second = next_pane_binding_nonce(workspace, endpoint, &pane);
|
|
2562
|
+
assert_eq!(first.len(), 32);
|
|
2563
|
+
assert!(first.chars().all(|ch| ch.is_ascii_hexdigit()));
|
|
2564
|
+
assert_ne!(first, second);
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
#[test]
|
|
2568
|
+
fn claim_authority_preserves_recorded_absolute_observation() {
|
|
2569
|
+
let workspace = Path::new("/tmp/workspace-a");
|
|
2570
|
+
let absolute = "/tmp/tmux-501/ta-live";
|
|
2571
|
+
let state = json!({
|
|
2572
|
+
"leader_receiver": {"pane_id": "%9", "tmux_socket": absolute}
|
|
2573
|
+
});
|
|
2574
|
+
let pane = observed_pane();
|
|
2575
|
+
let targets = vec![
|
|
2576
|
+
ClaimLeaderTargetCandidate {
|
|
2577
|
+
info: pane.clone(),
|
|
2578
|
+
endpoint: Some(crate::tmux_backend::socket_name_for_workspace(workspace)),
|
|
2579
|
+
},
|
|
2580
|
+
ClaimLeaderTargetCandidate {
|
|
2581
|
+
info: pane,
|
|
2582
|
+
endpoint: Some(absolute.to_string()),
|
|
2583
|
+
},
|
|
2584
|
+
];
|
|
2585
|
+
|
|
2586
|
+
assert_eq!(
|
|
2587
|
+
authoritative_observed_endpoint(workspace, &state, &targets, &targets[0]).as_deref(),
|
|
2588
|
+
Some(absolute)
|
|
2589
|
+
);
|
|
2590
|
+
}
|
|
2324
2591
|
}
|
|
@@ -32,6 +32,9 @@ fn receiver(pane: &str, uuid: &str, epoch: u64) -> LeaderReceiver {
|
|
|
32
32
|
pane_tty: None,
|
|
33
33
|
pane_current_command: None,
|
|
34
34
|
tmux_socket: None,
|
|
35
|
+
scope_authority: None,
|
|
36
|
+
authorized_team_workspace: None,
|
|
37
|
+
binding_nonce: None,
|
|
35
38
|
fingerprint: None,
|
|
36
39
|
leader_session_uuid: serde_json::from_value(Value::String(uuid.to_string())).ok(),
|
|
37
40
|
owner_epoch: Some(OwnerEpoch(epoch)),
|
|
@@ -1059,6 +1059,9 @@ fn receiver_from_candidate(
|
|
|
1059
1059
|
pane_tty: target.tty.clone(),
|
|
1060
1060
|
pane_current_command: target.current_command.clone(),
|
|
1061
1061
|
tmux_socket: prior.tmux_socket.clone(),
|
|
1062
|
+
scope_authority: prior.scope_authority.clone(),
|
|
1063
|
+
authorized_team_workspace: prior.authorized_team_workspace.clone(),
|
|
1064
|
+
binding_nonce: prior.binding_nonce.clone(),
|
|
1062
1065
|
fingerprint: target.fingerprint.clone(),
|
|
1063
1066
|
leader_session_uuid: uuid,
|
|
1064
1067
|
owner_epoch: Some(epoch),
|
|
@@ -1082,6 +1085,9 @@ fn empty_prior(provider: Provider, epoch: OwnerEpoch) -> LeaderReceiver {
|
|
|
1082
1085
|
pane_tty: None,
|
|
1083
1086
|
pane_current_command: None,
|
|
1084
1087
|
tmux_socket: None,
|
|
1088
|
+
scope_authority: None,
|
|
1089
|
+
authorized_team_workspace: None,
|
|
1090
|
+
binding_nonce: None,
|
|
1085
1091
|
fingerprint: None,
|
|
1086
1092
|
leader_session_uuid: None,
|
|
1087
1093
|
owner_epoch: Some(epoch),
|