@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +100 -44
- package/crates/team-agent/src/cli/emit.rs +127 -98
- package/crates/team-agent/src/cli/leaders.rs +32 -32
- package/crates/team-agent/src/cli/mod.rs +6 -6
- package/crates/team-agent/src/cli/named_address.rs +17 -16
- package/crates/team-agent/src/cli/route.rs +4 -4
- package/crates/team-agent/src/cli/send/presentation.rs +19 -18
- package/crates/team-agent/src/cli/send/resolve.rs +11 -11
- package/crates/team-agent/src/cli/send.rs +4 -4
- package/crates/team-agent/src/cli/spec.rs +29 -29
- package/crates/team-agent/src/cli/status.rs +7 -7
- package/crates/team-agent/src/cli/tests/base.rs +6 -6
- package/crates/team-agent/src/cli/tests/divergence.rs +3 -2
- package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
- package/crates/team-agent/src/cli/tests/named_address.rs +3 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +7 -7
- package/crates/team-agent/src/cli/triage.rs +53 -2
- package/crates/team-agent/src/cli/types.rs +8 -8
- package/crates/team-agent/src/compiler/tests.rs +12 -0
- package/crates/team-agent/src/compiler.rs +0 -9
- package/crates/team-agent/src/leader/start.rs +5 -0
- package/crates/team-agent/src/leader/tests/identity.rs +21 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +172 -13
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +325 -14
- package/crates/team-agent/src/lifecycle/launch.rs +2 -2
- package/crates/team-agent/src/lifecycle/profile_launch.rs +58 -46
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +97 -52
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +3 -2
- package/crates/team-agent/src/provider/adapter.rs +8 -59
- package/crates/team-agent/src/provider/adapters/claude.rs +6 -14
- package/crates/team-agent/src/provider/adapters/codex.rs +8 -18
- package/crates/team-agent/src/provider/adapters/copilot.rs +5 -22
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +8 -45
- package/crates/team-agent/src/provider/adapters/grok.rs +7 -31
- package/crates/team-agent/src/provider/adapters/pi.rs +7 -18
- package/crates/team-agent/src/provider/command_helpers.rs +45 -0
- package/crates/team-agent/src/provider/mod.rs +1 -0
- package/crates/team-agent/src/provider/model_catalog.rs +21 -28
- package/crates/team-agent/src/provider/tests/adapter.rs +70 -0
- package/crates/team-agent/src/provider/tests/command_helpers.rs +55 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
1479
|
-
.
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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 系清掉会让子进程并入父会话的那组标识
|