@team-agent/installer 0.5.112 → 0.5.113

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 (44) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +100 -44
  4. package/crates/team-agent/src/cli/emit.rs +127 -98
  5. package/crates/team-agent/src/cli/leaders.rs +32 -32
  6. package/crates/team-agent/src/cli/mod.rs +6 -6
  7. package/crates/team-agent/src/cli/named_address.rs +17 -16
  8. package/crates/team-agent/src/cli/route.rs +4 -4
  9. package/crates/team-agent/src/cli/send/presentation.rs +19 -18
  10. package/crates/team-agent/src/cli/send/resolve.rs +11 -11
  11. package/crates/team-agent/src/cli/send.rs +4 -4
  12. package/crates/team-agent/src/cli/spec.rs +29 -29
  13. package/crates/team-agent/src/cli/status.rs +7 -7
  14. package/crates/team-agent/src/cli/tests/base.rs +6 -6
  15. package/crates/team-agent/src/cli/tests/divergence.rs +3 -2
  16. package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
  17. package/crates/team-agent/src/cli/tests/named_address.rs +3 -3
  18. package/crates/team-agent/src/cli/tests/status_send.rs +7 -7
  19. package/crates/team-agent/src/cli/triage.rs +53 -2
  20. package/crates/team-agent/src/cli/types.rs +8 -8
  21. package/crates/team-agent/src/compiler/tests.rs +12 -0
  22. package/crates/team-agent/src/compiler.rs +0 -9
  23. package/crates/team-agent/src/leader/start.rs +5 -0
  24. package/crates/team-agent/src/leader/tests/identity.rs +21 -0
  25. package/crates/team-agent/src/lifecycle/launch/identity.rs +172 -13
  26. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +325 -14
  27. package/crates/team-agent/src/lifecycle/launch.rs +2 -2
  28. package/crates/team-agent/src/lifecycle/profile_launch.rs +58 -46
  29. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +97 -52
  30. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  31. package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +3 -2
  32. package/crates/team-agent/src/provider/adapter.rs +8 -59
  33. package/crates/team-agent/src/provider/adapters/claude.rs +6 -14
  34. package/crates/team-agent/src/provider/adapters/codex.rs +8 -18
  35. package/crates/team-agent/src/provider/adapters/copilot.rs +5 -22
  36. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +8 -45
  37. package/crates/team-agent/src/provider/adapters/grok.rs +7 -31
  38. package/crates/team-agent/src/provider/adapters/pi.rs +7 -18
  39. package/crates/team-agent/src/provider/command_helpers.rs +45 -0
  40. package/crates/team-agent/src/provider/mod.rs +1 -0
  41. package/crates/team-agent/src/provider/model_catalog.rs +21 -28
  42. package/crates/team-agent/src/provider/tests/adapter.rs +70 -0
  43. package/crates/team-agent/src/provider/tests/command_helpers.rs +55 -0
  44. package/package.json +4 -4
@@ -397,6 +397,25 @@ fn same_workspace(left: &Path, right: &Path) -> bool {
397
397
  == std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
398
398
  }
399
399
 
400
+ fn quick_start_tmux_endpoints_match(bound: &str, endpoint: &str) -> bool {
401
+ if bound == endpoint {
402
+ return true;
403
+ }
404
+ // A workspace transport uses a short `-L` name, while the native launcher
405
+ // persists an absolute `-S` path. Compare full resolved paths, never basenames.
406
+ let socket_path = |value: &str| {
407
+ let path = if Path::new(value).is_absolute() {
408
+ Some(PathBuf::from(value))
409
+ } else {
410
+ crate::tmux_backend::socket_path_for_name(value)
411
+ };
412
+ path.map(|path| crate::state::owner_gate::realpath_like(&path))
413
+ };
414
+ socket_path(bound)
415
+ .zip(socket_path(endpoint))
416
+ .is_some_and(|(bound, endpoint)| bound == endpoint)
417
+ }
418
+
400
419
  fn persisted_binding_matches_verified_pane(
401
420
  state: &serde_json::Value,
402
421
  workspace: &Path,
@@ -455,7 +474,7 @@ fn persisted_binding_matches_verified_pane(
455
474
  && receiver
456
475
  .get("tmux_socket")
457
476
  .and_then(serde_json::Value::as_str)
458
- == Some(endpoint)
477
+ .is_some_and(|bound| quick_start_tmux_endpoints_match(bound, endpoint))
459
478
  && receiver
460
479
  .get("authorized_team_workspace")
461
480
  .and_then(serde_json::Value::as_str)
@@ -1286,6 +1305,7 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
1286
1305
  )));
1287
1306
  }
1288
1307
  let state_path = crate::state::persist::runtime_state_path(&workspace);
1308
+ let mut leader_bootstrap = None;
1289
1309
  if state_path.exists() {
1290
1310
  let state = crate::state::persist::load_runtime_state(&workspace)
1291
1311
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
@@ -1363,6 +1383,16 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
1363
1383
  agent_ids,
1364
1384
  });
1365
1385
  }
1386
+ let entry = state
1387
+ .get("teams")
1388
+ .and_then(serde_json::Value::as_object)
1389
+ .and_then(|teams| teams.get(&canonical_team_key))
1390
+ .unwrap_or(&state);
1391
+ if is_quick_start_leader_bootstrap(entry) {
1392
+ let mut bootstrap = entry.clone();
1393
+ bootstrap["team_key"] = serde_json::json!(canonical_team_key);
1394
+ leader_bootstrap = Some(bootstrap);
1395
+ }
1366
1396
  }
1367
1397
  // CR-040/042: repeated quick-start from one template with distinct --team-id/--name
1368
1398
  // must NOT collide on the template-derived tmux session. Override the compiled
@@ -1384,12 +1414,20 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
1384
1414
  attach_commands: Vec::new(),
1385
1415
  });
1386
1416
  }
1417
+ if let Some(bootstrap) = &leader_bootstrap {
1418
+ // Allow initialization, not takeover of the existing leader binding.
1419
+ ensure_owner_allowed_for_state(bootstrap, None)?;
1420
+ }
1387
1421
  // E5 spec 迁移:spec 写到 .team/runtime/<team_key>/(中间产物,绝不落用户目录 agents_dir)。
1388
1422
  // Bug2:原子写(tmp+rename),避免半截 spec。
1389
1423
  let spec_path = crate::model::paths::runtime_spec_path(&workspace, &state_team_key);
1390
1424
  write_spec_atomic(&spec_path, &spec)?;
1391
- let _store = crate::message_store::MessageStore::open(&workspace)
1392
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1425
+ let _store = crate::message_store::MessageStore::open(&workspace).map_err(|error| {
1426
+ LifecycleError::StatePersist(format!(
1427
+ "database initialization failed at {}: {error}; coordinator start was not attempted",
1428
+ workspace.join(".team/team.db").display()
1429
+ ))
1430
+ })?;
1393
1431
  let resolved_spec_path =
1394
1432
  std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
1395
1433
  let scoped_endpoint = transport.tmux_endpoint();
@@ -1401,6 +1439,9 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
1401
1439
  &state_team_key,
1402
1440
  scoped_endpoint.as_deref(),
1403
1441
  );
1442
+ if let Some(bootstrap) = &leader_bootstrap {
1443
+ preserve_quick_start_bootstrap_binding(&mut state, bootstrap, &state_team_key);
1444
+ }
1404
1445
  // Keep this attempt-local seed separate from persisted `claimed_via`;
1405
1446
  // historical quick-start rows are never sufficient cleanup authority.
1406
1447
  let seeded_owner = state
@@ -1475,15 +1516,15 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
1475
1516
  launch.session_capture_incomplete_agents =
1476
1517
  quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
1477
1518
  let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
1478
- let coordinator_started = crate::coordinator::start_coordinator(&coordinator_workspace)
1479
- .map(|report| report.ok)
1480
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1481
- quick_start_phase_timer.emit(&workspace, "launch.phase", "coordinator_start");
1482
- let coordinator_action = if coordinator_started {
1483
- "coordinator started"
1484
- } else {
1485
- "coordinator not started"
1486
- };
1519
+ let coordinator_report = crate::coordinator::start_coordinator(&coordinator_workspace)
1520
+ .map_err(|error| LifecycleError::StatePersist(format!(
1521
+ "coordinator start failed for {}: {error}; inspect {} before retrying",
1522
+ workspace.display(),
1523
+ crate::coordinator::coordinator_log_path(&coordinator_workspace).display()
1524
+ )))?;
1525
+ quick_start_phase_timer.emit(&workspace, "launch.phase", crate::lifecycle::restart::LifecyclePhase::CoordinatorStart);
1526
+ require_quick_start_coordinator_started(&coordinator_report, &workspace)?;
1527
+ let coordinator_action = "coordinator started";
1487
1528
  // BUG-7: build an honest readiness verdict from the post-spawn runtime state.
1488
1529
  // - If persist_spawn_agent_state (BUG-2 fix) marked any agent non-running, the
1489
1530
  // team is observably Degraded.
@@ -1491,7 +1532,7 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
1491
1532
  // loaded successfully (provider-side codex/claude schema rejections happen
1492
1533
  // asynchronously after spawn), so the verdict is PendingToolLoad — never
1493
1534
  // bare Ready.
1494
- quick_start_phase_timer.emit(&workspace, "launch.phase", "readiness_wait");
1535
+ quick_start_phase_timer.emit(&workspace, "launch.phase", crate::lifecycle::restart::LifecyclePhase::ReadinessWait);
1495
1536
  let worker_readiness = quick_start_worker_readiness(&workspace, &state_team_key);
1496
1537
  let attach_windows = load_runtime_state(&workspace)
1497
1538
  .ok()
@@ -1531,7 +1572,7 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
1531
1572
  ));
1532
1573
  }
1533
1574
  next_actions.extend(attach_commands.iter().cloned());
1534
- quick_start_phase_timer.emit(&workspace, "launch.phase", "completed");
1575
+ quick_start_phase_timer.emit(&workspace, "launch.phase", crate::lifecycle::restart::LifecyclePhase::Completed);
1535
1576
  Ok(QuickStartReport::Ready {
1536
1577
  session_name,
1537
1578
  launch: Box::new(launch),
@@ -1542,8 +1583,73 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
1542
1583
  })
1543
1584
  }
1544
1585
 
1586
+ fn preserve_quick_start_bootstrap_binding(
1587
+ state: &mut serde_json::Value,
1588
+ bootstrap: &serde_json::Value,
1589
+ team_key: &str,
1590
+ ) {
1591
+ let mut binding = crate::state::ownership::OwnershipWrite::new();
1592
+ if let Some(owner) = bootstrap.get("team_owner") {
1593
+ binding = binding.with_team_owner(owner.clone());
1594
+ }
1595
+ if let Some(receiver) = bootstrap.get("leader_receiver") {
1596
+ binding = binding.with_leader_receiver(receiver.clone());
1597
+ }
1598
+ if let Some(epoch) = bootstrap.get("owner_epoch").and_then(serde_json::Value::as_u64)
1599
+ .or_else(|| bootstrap.get("team_owner").and_then(|owner| owner.get("owner_epoch")).and_then(serde_json::Value::as_u64))
1600
+ {
1601
+ binding = binding.with_owner_epoch(epoch);
1602
+ }
1603
+ crate::state::ownership::write_owner(state, team_key, binding);
1604
+ // This is the new launch's transient view, not the persisted bootstrap.
1605
+ // The canonical writer intentionally leaves root fields alone; remove the
1606
+ // provisional epoch-1 seed so save_launched_team_state cannot merge it back
1607
+ // over the preserved canonical binding. Reuse its existing view promotion.
1608
+ if let Some(root) = state.as_object_mut() {
1609
+ for key in ["team_owner", "leader_receiver", "owner_epoch"] {
1610
+ root.remove(key);
1611
+ }
1612
+ }
1613
+ super::state_projection::promote_launched_binding_from_team_entry(state, team_key);
1614
+ for key in ["leader_client", "is_external_leader"] {
1615
+ if let Some(value) = bootstrap.get(key) {
1616
+ state[key] = value.clone();
1617
+ }
1618
+ }
1619
+ }
1620
+
1621
+ fn require_quick_start_coordinator_started(
1622
+ report: &crate::coordinator::StartReport,
1623
+ workspace: &Path,
1624
+ ) -> Result<(), LifecycleError> {
1625
+ if report.ok {
1626
+ return Ok(());
1627
+ }
1628
+ let details = serde_json::json!({
1629
+ "ok": report.ok,
1630
+ "status": report.status,
1631
+ "pid": report.pid,
1632
+ "previous_pid": report.previous_pid,
1633
+ "binary_path": report.binary_path,
1634
+ "binary_version": report.binary_version,
1635
+ "rotation_reason": report.rotation_reason,
1636
+ "binary_identity_relation": report.binary_identity_relation,
1637
+ "log": report.log,
1638
+ "schema_error": report.schema_error.as_ref().map(|error| format!("{error:?}")),
1639
+ "action": report.action,
1640
+ "workspace": workspace,
1641
+ "coordinator_log_path": crate::coordinator::coordinator_log_path(
1642
+ &crate::coordinator::WorkspacePath::new(workspace.to_path_buf())
1643
+ ),
1644
+ });
1645
+ Err(LifecycleError::StatePersist(format!(
1646
+ "coordinator start refused: {details}; workers may already be started; inspect the report before retrying quick-start"
1647
+ )))
1648
+ }
1649
+
1545
1650
  #[cfg(test)]
1546
1651
  mod fresh_quick_start_leader_binding_tests {
1652
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1547
1653
  use super::*;
1548
1654
  use crate::layout::worker_env::{CALLER_ENDPOINT_ENV, CALLER_PANE_ENV, CALLER_PROVIDER_ENV};
1549
1655
  use crate::transport::test_support::OfflineTransport;
@@ -1556,6 +1662,103 @@ mod fresh_quick_start_leader_binding_tests {
1556
1662
 
1557
1663
  static SEQUENCE: AtomicU64 = AtomicU64::new(0);
1558
1664
 
1665
+ #[test]
1666
+ fn bootstrap_binding_is_preserved_without_epoch_reset_or_takeover() {
1667
+ let bootstrap = json!({
1668
+ "team_owner": {"pane_id": "%0", "provider": "pi", "owner_epoch": 7, "claimed_via": "claim-leader"},
1669
+ "leader_receiver": {"pane_id": "%0", "status": "attached", "owner_epoch": 7},
1670
+ "owner_epoch": 7,
1671
+ "leader_client": {"diagnostic_only": true},
1672
+ "is_external_leader": true
1673
+ });
1674
+ let mut launched = json!({
1675
+ "team_key": "current", "agents": {"worker": {}},
1676
+ "team_owner": {"owner_epoch": 1, "claimed_via": "quick-start"},
1677
+ "leader_receiver": {"status": "pending"}, "owner_epoch": 1,
1678
+ "is_external_leader": false
1679
+ });
1680
+ preserve_quick_start_bootstrap_binding(&mut launched, &bootstrap, "current");
1681
+ for key in ["team_owner", "leader_receiver", "owner_epoch"] {
1682
+ assert_eq!(launched.get(key), bootstrap.get(key));
1683
+ assert_eq!(launched["teams"]["current"].get(key), bootstrap.get(key));
1684
+ }
1685
+ assert_eq!(launched.get("leader_client"), bootstrap.get("leader_client"));
1686
+ assert_eq!(launched.get("is_external_leader"), Some(&json!(true)));
1687
+ assert_eq!(launched["agents"], json!({"worker": {}}));
1688
+ let merged = super::super::state_projection::merge_workspace_team_state_with_key(
1689
+ &bootstrap, &launched, "current",
1690
+ );
1691
+ assert_eq!(merged["teams"]["current"]["team_owner"], bootstrap["team_owner"]);
1692
+ assert_eq!(merged["teams"]["current"]["owner_epoch"], json!(7));
1693
+ }
1694
+
1695
+ #[test]
1696
+ fn coordinator_negative_report_retains_actual_details_and_does_not_become_ready() {
1697
+ let mut report = crate::coordinator::StartReport {
1698
+ ok: false,
1699
+ pid: None,
1700
+ status: crate::coordinator::StartOutcome::SchemaIncompatible,
1701
+ previous_pid: Some(crate::coordinator::Pid(17)),
1702
+ binary_path: Some("/tmp/candidate/team-agent".to_string()),
1703
+ binary_version: Some("0.5.112".to_string()),
1704
+ rotation_reason: Some("original rotation reason".to_string()),
1705
+ binary_identity_relation: crate::coordinator::CoordinatorBinaryIdentityRelation::Unknown,
1706
+ log: Some(PathBuf::from("/tmp/current/.team/runtime/coordinator.log")),
1707
+ schema_error: Some(crate::coordinator::SchemaError::InitFailed { message: "original database permission denied".to_string() }),
1708
+ action: Some("repair the exact database permissions".to_string()),
1709
+ };
1710
+ let error = require_quick_start_coordinator_started(&report, Path::new("/tmp/current"))
1711
+ .unwrap_err().to_string();
1712
+ for detail in ["schema_incompatible", "original database permission denied", "repair the exact database permissions", "/tmp/candidate/team-agent", "/tmp/current/.team/runtime/coordinator.log", "workers may already be started"] {
1713
+ assert!(error.contains(detail), "lost {detail}: {error}");
1714
+ }
1715
+ report.ok = true;
1716
+ report.status = crate::coordinator::StartOutcome::AlreadyRunning;
1717
+ report.binary_identity_relation = crate::coordinator::CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller;
1718
+ assert!(require_quick_start_coordinator_started(&report, Path::new("/tmp/current")).is_ok());
1719
+ }
1720
+
1721
+ #[test]
1722
+ #[serial_test::serial(env)]
1723
+ fn same_current_leader_bootstrap_reaches_fresh_preflight_without_mutating_seed() {
1724
+ let env = HermeticTestEnv::enter("current-bootstrap-preflight");
1725
+ env.scrub_tmux();
1726
+ env.assert_no_real_tmux();
1727
+ let workspace = std::env::temp_dir().join(format!(
1728
+ "ta-current-bootstrap-{}-{}", std::process::id(), SEQUENCE.fetch_add(1, Ordering::Relaxed)
1729
+ ));
1730
+ let team = workspace.join(".team/current");
1731
+ std::fs::create_dir_all(team.join("agents")).unwrap();
1732
+ std::fs::write(team.join("TEAM.md"), "---\nname: current\nobjective: Bootstrap fixture.\n---\n").unwrap();
1733
+ std::fs::write(team.join("agents/worker.md"), "---\nname: worker\nrole: Worker\nprovider: codex\nauth_mode: subscription\ndangerously_skip_permissions: false\n---\nBounded fixture.\n").unwrap();
1734
+ let state_path = crate::state::persist::runtime_state_path(&workspace);
1735
+ std::fs::create_dir_all(state_path.parent().unwrap()).unwrap();
1736
+ let state = json!({
1737
+ "active_team_key": "current", "team_key": "current", "tasks": [],
1738
+ "teams": {"current": {
1739
+ "agents": {}, "tasks": [], "session_name": null,
1740
+ "team_owner": {"pane_id": "%0", "provider": "pi", "owner_epoch": 1},
1741
+ "leader_receiver": {"pane_id": "%0", "status": "attached"}
1742
+ }}
1743
+ });
1744
+ std::fs::write(&state_path, serde_json::to_vec(&state).unwrap()).unwrap();
1745
+ let before = std::fs::read(&state_path).unwrap();
1746
+ // The real entrypoint must reach its existing strict identity preflight,
1747
+ // not return ExistingRuntime merely because the launcher registered current.
1748
+ std::env::set_var("TMUX", "/private/tmp/bootstrap-test/default,1,0");
1749
+ std::env::set_var("TMUX_PANE", "%0");
1750
+ std::env::set_var(CALLER_PANE_ENV, "%0");
1751
+ std::env::set_var(CALLER_PROVIDER_ENV, "pi");
1752
+ std::env::set_var(CALLER_ENDPOINT_ENV, "/private/tmp/different-bootstrap/default");
1753
+ let transport = OfflineTransport::new().with_tmux_endpoint("/private/tmp/bootstrap-test/default");
1754
+ let result = quick_start_with_transport_in_workspace(&workspace, &team, None, true, None, &transport).unwrap();
1755
+ assert!(matches!(result, QuickStartReport::PreflightBlocked { .. }), "unexpected outcome: {result:?}");
1756
+ assert_eq!(std::fs::read(&state_path).unwrap(), before);
1757
+ assert!(!workspace.join(".team/team.db").exists());
1758
+ assert!(!crate::model::paths::runtime_spec_path(&workspace, "current").exists());
1759
+ std::fs::remove_dir_all(&workspace).unwrap();
1760
+ }
1761
+
1559
1762
  fn workspace_with_provider(tag: &str, provider: &str) -> PathBuf {
1560
1763
  let path = std::env::temp_dir().join(format!(
1561
1764
  "ta-quick-start-bind-{tag}-{}-{}",
@@ -2863,6 +3066,114 @@ mod fresh_quick_start_leader_binding_tests {
2863
3066
  })
2864
3067
  }
2865
3068
 
3069
+ fn native_persisted_binding(workspace: &Path, socket: &str) -> serde_json::Value {
3070
+ let uuid = "9e1ceba443966bc206a776a7c621e808";
3071
+ json!({
3072
+ "workspace": workspace,
3073
+ "agents": {}, "tasks": [], "session_name": null,
3074
+ "team_owner": complete_owner("%0", "pi", uuid, 1),
3075
+ "leader_receiver": complete_receiver("%0", "pi", uuid, 1, socket)
3076
+ })
3077
+ }
3078
+
3079
+ #[cfg(unix)]
3080
+ #[test]
3081
+ #[serial_test::serial(env)]
3082
+ fn native_persisted_binding_matches_workspace_short_socket_without_weakening_identity() {
3083
+ let hermetic = HermeticTestEnv::enter("native-persisted-socket");
3084
+ let workspace = hermetic.root();
3085
+ let transport = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
3086
+ let endpoint = transport.tmux_endpoint().unwrap();
3087
+ let socket = crate::tmux_backend::socket_path_for_workspace(workspace).unwrap();
3088
+ let state = native_persisted_binding(workspace, socket.to_str().unwrap());
3089
+ let before = state.clone();
3090
+ let pane = PaneId::new("%0");
3091
+ assert_ne!(socket.to_str(), Some(endpoint.as_str()));
3092
+ assert!(persisted_binding_matches_verified_pane(
3093
+ &state, workspace, &pane, Provider::Pi, Some(&endpoint),
3094
+ ));
3095
+ assert_eq!(state, before);
3096
+ assert!(!persisted_binding_matches_verified_pane(
3097
+ &state, workspace, &PaneId::new("%1"), Provider::Pi, Some(&endpoint),
3098
+ ));
3099
+ assert!(!persisted_binding_matches_verified_pane(
3100
+ &state, workspace, &pane, Provider::Codex, Some(&endpoint),
3101
+ ));
3102
+ for endpoint in [None, Some("")] {
3103
+ assert!(!persisted_binding_matches_verified_pane(
3104
+ &state, workspace, &pane, Provider::Pi, endpoint,
3105
+ ));
3106
+ }
3107
+ for (field, value) in [
3108
+ ("/team_owner/pane_id", json!("%1")),
3109
+ ("/leader_receiver/pane_id", json!("%1")),
3110
+ ("/team_owner/provider", json!("codex")),
3111
+ ("/leader_receiver/provider", json!("codex")),
3112
+ ("/team_owner/owner_epoch", json!(0)),
3113
+ ("/leader_receiver/owner_epoch", json!(2)),
3114
+ ("/team_owner/leader_session_uuid", json!("")),
3115
+ ("/leader_receiver/leader_session_uuid", json!("foreign")),
3116
+ ("/leader_receiver/status", json!("pending")),
3117
+ ("/leader_receiver/mode", json!("external")),
3118
+ ("/leader_receiver/tmux_socket", json!(null)),
3119
+ ("/leader_receiver/tmux_socket", json!("")),
3120
+ ("/workspace", json!(workspace.join("foreign"))),
3121
+ ] {
3122
+ let mut conflict = state.clone();
3123
+ *conflict.pointer_mut(field).unwrap() = value;
3124
+ assert!(!persisted_binding_matches_verified_pane(
3125
+ &conflict, workspace, &pane, Provider::Pi, Some(&endpoint),
3126
+ ), "conflicting {field} must refuse");
3127
+ }
3128
+ let mut unauthorized = state.clone();
3129
+ unauthorized["leader_receiver"]["authorized_team_workspace"] =
3130
+ json!(workspace.join("foreign"));
3131
+ assert!(!persisted_binding_matches_verified_pane(
3132
+ &unauthorized, workspace, &pane, Provider::Pi, Some(&endpoint),
3133
+ ));
3134
+ }
3135
+
3136
+ #[cfg(unix)]
3137
+ #[test]
3138
+ #[serial_test::serial(env)]
3139
+ fn native_persisted_binding_rejects_same_socket_basename_in_foreign_root() {
3140
+ let hermetic = HermeticTestEnv::enter("native-persisted-foreign-root");
3141
+ let workspace = hermetic.root();
3142
+ let transport = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
3143
+ let endpoint = transport.tmux_endpoint().unwrap();
3144
+ let foreign_socket = workspace.join("foreign-socket-root").join(&endpoint);
3145
+ let state = native_persisted_binding(workspace, foreign_socket.to_str().unwrap());
3146
+ assert_eq!(foreign_socket.file_name().unwrap().to_str(), Some(endpoint.as_str()));
3147
+ assert!(!persisted_binding_matches_verified_pane(
3148
+ &state, workspace, &PaneId::new("%0"), Provider::Pi, Some(&endpoint),
3149
+ ));
3150
+ assert!(!quick_start_tmux_endpoints_match(
3151
+ foreign_socket.to_str().unwrap(), "default",
3152
+ ));
3153
+ assert!(!quick_start_tmux_endpoints_match(
3154
+ foreign_socket.to_str().unwrap(), "",
3155
+ ));
3156
+ }
3157
+
3158
+ #[cfg(unix)]
3159
+ #[test]
3160
+ #[serial_test::serial(env)]
3161
+ fn native_persisted_binding_matches_full_socket_path_symlink_alias() {
3162
+ let hermetic = HermeticTestEnv::enter("native-persisted-path-alias");
3163
+ let workspace = hermetic.root();
3164
+ let root = workspace.join("socket-root");
3165
+ std::fs::create_dir_all(&root).unwrap();
3166
+ let alias = workspace.join("socket-alias");
3167
+ std::os::unix::fs::symlink(&root, &alias).unwrap();
3168
+ let socket = root.join("ta-native.sock");
3169
+ std::fs::write(&socket, "fixture endpoint; no tmux server").unwrap();
3170
+ let state = native_persisted_binding(workspace, socket.to_str().unwrap());
3171
+ assert!(persisted_binding_matches_verified_pane(
3172
+ &state, workspace, &PaneId::new("%0"), Provider::Pi,
3173
+ alias.join("ta-native.sock").to_str(),
3174
+ ));
3175
+ }
3176
+
2866
3177
  fn commit_fresh_binding(
2867
3178
  workspace: &Path,
2868
3179
  owner: &serde_json::Value,
@@ -133,7 +133,7 @@ pub fn launch_with_transport_in_workspace(
133
133
  // 所有角色都未声明 dangerously_skip_permissions=true → 写 warning 事件不阻塞。
134
134
  // TODO(0.6.0): remove(0.6.0 彻底删源头 A 后此警示无意义)。
135
135
  emit_dangerous_flag_no_role_declared_warning(workspace, &spec);
136
- phase_timer.emit(workspace, "launch.phase", "compile_spec");
136
+ phase_timer.emit(workspace, "launch.phase", super::restart::LifecyclePhase::CompileSpec);
137
137
  let session_name = spec_session_name(&spec);
138
138
  if !dry_run && transport_has_session(transport, &session_name) {
139
139
  return Err(LifecycleError::SessionConflict(format!(
@@ -145,7 +145,7 @@ pub fn launch_with_transport_in_workspace(
145
145
  let started = if dry_run {
146
146
  Vec::new()
147
147
  } else {
148
- phase_timer.emit(workspace, "launch.phase", "spawn_all");
148
+ phase_timer.emit(workspace, "launch.phase", super::restart::LifecyclePhase::SpawnAll);
149
149
  let started = spawn_agents(workspace, spec_path, &spec, &session_name, transport)?;
150
150
  persist_spawn_agent_state(
151
151
  workspace,
@@ -31,6 +31,7 @@ use crate::lifecycle::LifecycleError;
31
31
  use crate::model::enums::{AuthMode, Provider};
32
32
  use crate::model::yaml::Value as YamlValue;
33
33
  use crate::provider::{McpConfig, ProviderCommandOverrides, ProviderProfileLaunch};
34
+ use crate::provider::command_helpers::append_env_pair;
34
35
 
35
36
  const COMPATIBLE_NETWORK_ENV_KEYS: &[&str] = &[
36
37
  "HTTPS_PROXY",
@@ -463,40 +464,26 @@ fn provider_env_exports(
463
464
  }
464
465
  match provider {
465
466
  Provider::Claude | Provider::ClaudeCode => {
466
- if let Some(value) = value_or_alternate(values, "ANTHROPIC_BASE_URL", "BASE_URL") {
467
- exports.insert("ANTHROPIC_BASE_URL".to_string(), value.to_string());
468
- }
467
+ append_env_pair(&mut exports, "ANTHROPIC_BASE_URL", value_or_alternate(values, "ANTHROPIC_BASE_URL", "BASE_URL"));
469
468
  if auth_mode == AuthMode::OfficialApi {
470
- if let Some(value) = value_or_alternate(values, "ANTHROPIC_API_KEY", "API_KEY") {
471
- exports.insert("ANTHROPIC_API_KEY".to_string(), value.to_string());
472
- }
469
+ append_env_pair(&mut exports, "ANTHROPIC_API_KEY", value_or_alternate(values, "ANTHROPIC_API_KEY", "API_KEY"));
473
470
  }
474
- if let Some(value) = value_or_alternate(values, "ANTHROPIC_AUTH_TOKEN", "AUTH_TOKEN")
471
+ append_env_pair(&mut exports, "ANTHROPIC_AUTH_TOKEN", value_or_alternate(values, "ANTHROPIC_AUTH_TOKEN", "AUTH_TOKEN")
475
472
  .or_else(|| {
476
473
  (auth_mode == AuthMode::CompatibleApi)
477
474
  .then(|| value_or_alternate(values, "ANTHROPIC_API_KEY", "API_KEY"))
478
475
  .flatten()
479
- })
480
- {
481
- exports.insert("ANTHROPIC_AUTH_TOKEN".to_string(), value.to_string());
482
- }
483
- if let Some(value) = profile_model(values) {
484
- exports.insert("ANTHROPIC_MODEL".to_string(), value.to_string());
485
- }
476
+ }));
477
+ append_env_pair(&mut exports, "ANTHROPIC_MODEL", profile_model(values));
486
478
  }
487
479
  Provider::Codex => {
488
- if let Some(value) = value_or_alternate(values, "OPENAI_API_KEY", "API_KEY") {
489
- exports.insert("TEAM_AGENT_PROVIDER_API_KEY".to_string(), value.to_string());
490
- exports.insert("OPENAI_API_KEY".to_string(), value.to_string());
491
- }
492
- if let Some(value) = values.get("BASE_URL").filter(|value| !value.is_empty()) {
493
- exports.insert("OPENAI_BASE_URL".to_string(), value.to_string());
494
- }
480
+ let key = value_or_alternate(values, "OPENAI_API_KEY", "API_KEY");
481
+ append_env_pair(&mut exports, "TEAM_AGENT_PROVIDER_API_KEY", key);
482
+ append_env_pair(&mut exports, "OPENAI_API_KEY", key);
483
+ append_env_pair(&mut exports, "OPENAI_BASE_URL", values.get("BASE_URL").filter(|value| !value.is_empty()).map(String::as_str));
495
484
  }
496
485
  Provider::GeminiCli => {
497
- if let Some(value) = value_or_alternate(values, "GEMINI_API_KEY", "API_KEY") {
498
- exports.insert("GEMINI_API_KEY".to_string(), value.to_string());
499
- }
486
+ append_env_pair(&mut exports, "GEMINI_API_KEY", value_or_alternate(values, "GEMINI_API_KEY", "API_KEY"));
500
487
  }
501
488
  // C-A-4 cr verdict v2 — copilot BYOK(== compatible_api 档,help-providers 原文
502
489
  // "GitHub authentication is not required" when COPILOT_PROVIDER_BASE_URL set):
@@ -506,28 +493,14 @@ fn provider_env_exports(
506
493
  // Subscription/OfficialApi 不导出 COPILOT_PROVIDER_*(避免误改 auth 通道)。
507
494
  Provider::Copilot => {
508
495
  if auth_mode == AuthMode::CompatibleApi {
509
- if let Some(value) =
510
- value_or_alternate(values, "COPILOT_PROVIDER_BASE_URL", "BASE_URL")
511
- {
512
- exports.insert("COPILOT_PROVIDER_BASE_URL".to_string(), value.to_string());
513
- }
514
- if let Some(value) =
515
- value_or_alternate(values, "COPILOT_PROVIDER_TYPE", "PROVIDER_TYPE")
516
- {
517
- exports.insert("COPILOT_PROVIDER_TYPE".to_string(), value.to_string());
518
- }
519
- if let Some(value) =
520
- value_or_alternate(values, "COPILOT_PROVIDER_API_KEY", "API_KEY")
521
- {
522
- exports.insert("COPILOT_PROVIDER_API_KEY".to_string(), value.to_string());
523
- }
524
- if let Some(value) =
525
- value_or_alternate(values, "COPILOT_PROVIDER_WIRE_API", "WIRE_API")
526
- {
527
- exports.insert("COPILOT_PROVIDER_WIRE_API".to_string(), value.to_string());
528
- }
529
- if let Some(value) = value_or_alternate(values, "COPILOT_MODEL", "MODEL") {
530
- exports.insert("COPILOT_MODEL".to_string(), value.to_string());
496
+ for (key, alternate) in [
497
+ ("COPILOT_PROVIDER_BASE_URL", "BASE_URL"),
498
+ ("COPILOT_PROVIDER_TYPE", "PROVIDER_TYPE"),
499
+ ("COPILOT_PROVIDER_API_KEY", "API_KEY"),
500
+ ("COPILOT_PROVIDER_WIRE_API", "WIRE_API"),
501
+ ("COPILOT_MODEL", "MODEL"),
502
+ ] {
503
+ append_env_pair(&mut exports, key, value_or_alternate(values, key, alternate));
531
504
  }
532
505
  }
533
506
  }
@@ -539,6 +512,45 @@ fn provider_env_exports(
539
512
  exports
540
513
  }
541
514
 
515
+ #[cfg(test)]
516
+ mod append_env_contract {
517
+ use super::*;
518
+
519
+ #[test]
520
+ fn profile_append_keeps_native_keys_alternates_and_auth_gates() {
521
+ let values: BTreeMap<String, String> = [
522
+ ("API_KEY", "dummy"), ("BASE_URL", " raw\nbase "),
523
+ ("AUTH_TOKEN", "dummy-token"), ("MODEL", " raw model "),
524
+ ("PROVIDER_TYPE", "custom"), ("WIRE_API", "responses"),
525
+ ("OPENAI_API_KEY", "native-dummy"),
526
+ ].into_iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
527
+ let map = |pairs: &[(&str, &str)]| -> BTreeMap<String, String> {
528
+ pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
529
+ };
530
+ for provider in [Provider::Claude, Provider::ClaudeCode] {
531
+ assert_eq!(provider_env_exports(provider, AuthMode::OfficialApi, &values), map(&[
532
+ ("ANTHROPIC_BASE_URL", " raw\nbase "), ("ANTHROPIC_API_KEY", "dummy"),
533
+ ("ANTHROPIC_AUTH_TOKEN", "dummy-token"), ("ANTHROPIC_MODEL", " raw model "),
534
+ ]));
535
+ }
536
+ assert_eq!(provider_env_exports(Provider::Codex, AuthMode::CompatibleApi, &values), map(&[
537
+ ("TEAM_AGENT_PROVIDER_API_KEY", "native-dummy"), ("OPENAI_API_KEY", "native-dummy"),
538
+ ("OPENAI_BASE_URL", " raw\nbase "),
539
+ ]));
540
+ assert_eq!(provider_env_exports(Provider::GeminiCli, AuthMode::OfficialApi, &values), map(&[("GEMINI_API_KEY", "dummy")]));
541
+ assert_eq!(provider_env_exports(Provider::Copilot, AuthMode::CompatibleApi, &values), map(&[
542
+ ("COPILOT_PROVIDER_BASE_URL", " raw\nbase "), ("COPILOT_PROVIDER_TYPE", "custom"),
543
+ ("COPILOT_PROVIDER_API_KEY", "dummy"), ("COPILOT_PROVIDER_WIRE_API", "responses"),
544
+ ("COPILOT_MODEL", " raw model "),
545
+ ]));
546
+ assert!(provider_env_exports(Provider::Copilot, AuthMode::OfficialApi, &values).is_empty());
547
+ for provider in crate::provider::wire::all_providers() {
548
+ assert!(provider_env_exports(*provider, AuthMode::Subscription, &values).is_empty());
549
+ assert!(provider_env_exports(*provider, AuthMode::CompatibleApi, &BTreeMap::new()).is_empty());
550
+ }
551
+ }
552
+ }
553
+
542
554
  /// ---
543
555
  /// purpose: 给出该 provider 与 auth_mode 下必须从子进程环境里清掉的变量名集
544
556
  /// returns: 变量名集合;claude 系清掉会让子进程并入父会话的那组标识